/* [RFC-0152] The Image Provider Port — the rendering/optimization analogue of the RFC-0141 content-source port. Defines "how an authored image becomes src + srcset" as a swappable provider so the optimization backend (Cloudflare runtime today, a CMS/DAM or build-time pipeline later) can change without touching any section or component. Ships the `cloudflare-runtime` provider (Cloudflare Image Resizing via /cdn-cgi/image) as the default. [RFC-0204] Adds the `build-portable` provider that emits srcset from pre-generated static width variants (no Cloudflare Image Transformations required). Do not run sharp or any build-time optimization here — providers emit URLs only. Do not resolve asset tokens — that is resolveImage / the content-source port. Do not implement the cms-native provider yet (later phase). RFC-0152: introduced the Image Provider Port and the cloudflare-runtime provider. RFC-0204: added ImageVariantManifest types and createBuildPortableProvider factory. warpgogol-m000179: byBasename becomes Record<string, string[]> — every same-basename origin is a candidate; ImageRequest.lang picks the /<lang>/ mirror origin for Astro-hashed descriptors instead of last-write-wins. */ /** A resolved image to render. Astro's ImageMetadata is structurally assignable. */ export interface ImageDescriptor { /** Origin path: root-relative for in-repo assets ("/_astro/..") or an absolute URL (CMS/DAM). */ src: string; /** Intrinsic width, when known — used to avoid upscaling the responsive ladder. */ width?: number; /** Intrinsic height, when known. */ height?: number; } /** Output format requested from the provider. */ export type ImageFormat = "webp" | "avif" | "auto"; /** Quality presets. NOTE: Cloudflare expects a numeric quality — never the literal "max". */ export const IMAGE_QUALITY_PRESETS = { low: 30, mid: 60, high: 80, max: 100 } as const; export type ImageQualityPreset = keyof typeof IMAGE_QUALITY_PRESETS; /** Default no-upscale responsive width ladder. */ export const DEFAULT_IMAGE_WIDTHS: readonly number[] = [320, 480, 640, 768, 1024, 1280, 1536, 1920]; export interface ImageRequest { /** Candidate widths; defaults to DEFAULT_IMAGE_WIDTHS. Filtered to never exceed the intrinsic width. */ widths?: readonly number[]; /** hint; defaults to "100vw". */ sizes?: string; /** Numeric quality OR a preset; resolved to a number for the provider. */ quality?: number | ImageQualityPreset; /** Output format; defaults to "webp". */ format?: ImageFormat; /** * Content language of the page being rendered — disambiguates manifest * `byBasename` candidates that share a bare basename across language mirrors * (e.g. `pages/de/assets/x.webp` vs `pages/uk/assets/x.webp` with different * localized bytes). The fallback prefers a candidate whose origin path * contains the `//` segment. */ lang?: string; } export interface ImageSources { /** Fallback src (largest candidate). */ src: string; /** Responsive srcset (`url 320w, url 640w, ...`); omitted when a single candidate. */ srcset?: string; /** . */ sizes?: string; /** * Width of the fallback `src` variant in CSS pixels. * Set by providers that select a specific variant (e.g. build-portable). * When present, uses this instead of the intrinsic ImageMetadata width. */ width?: number; /** * Height of the fallback `src` variant in CSS pixels. * Derived from the selected variant width + original aspect ratio. */ height?: number; } /** The Image Provider Port. Every optimization backend implements this single interface. */ export interface ImageProvider { readonly id: "cloudflare-runtime" | "cms-native" | "build-portable"; buildSources(descriptor: ImageDescriptor, request?: ImageRequest): ImageSources; } const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n)); /** Resolve a preset or numeric quality to a Cloudflare-valid integer (1..100). */ export function resolveImageQuality(quality?: number | ImageQualityPreset): number { if (typeof quality === "number") return clamp(Math.round(quality), 1, 100); if (quality && quality in IMAGE_QUALITY_PRESETS) return IMAGE_QUALITY_PRESETS[quality]; return IMAGE_QUALITY_PRESETS.high; } /** Build a sorted, de-duplicated, no-upscale candidate width list. */ export function candidateWidths( intrinsic?: number, widths: readonly number[] = DEFAULT_IMAGE_WIDTHS, ): number[] { const base = widths.filter((w) => !intrinsic || w < intrinsic); const set = new Set(base); if (intrinsic && intrinsic > 0) set.add(intrinsic); const sorted = [...set].sort((a, b) => a - b); return sorted.length > 0 ? sorted : intrinsic ? [intrinsic] : [...widths]; } function isAbsoluteUrl(src: string): boolean { return /^https?:\/\//i.test(src) || src.startsWith("data:"); } /** * cloudflare-runtime provider (RFC-0152 default). * When Cloudflare Image Transformations are enabled (PUBLIC_CF_IMAGE_TRANSFORM=on), * emits /cdn-cgi/image// URLs resolved at request time by Cloudflare — * a responsive srcset for in-repo assets and remote CMS URLs. `onerror=redirect` only * helps when the feature IS enabled but a specific transform errors; it does NOT save a * zone where the feature is OFF (those URLs 404). Hence the safe passthrough default. */ /** * Whether to emit /cdn-cgi/image transform URLs (baked at build time). * * IMPORTANT: /cdn-cgi/image only works when Cloudflare Image Transformations are * ENABLED on the zone. When the feature is OFF, those URLs return 404 with NO * `onerror` fallback — so emitting them unconditionally breaks every image on a * zone that has not enabled the feature. Therefore the safe default is OFF: serve * the raw origin asset (always 200, no resize, no srcset). Opt in per app by * setting `PUBLIC_CF_IMAGE_TRANSFORM=on` once Transformations are enabled on the * zone — then the provider emits the responsive /cdn-cgi/image srcset. */ function cloudflareTransformEnabled(): boolean { try { const env = (import.meta as ImportMeta & { env?: Record }).env; return env?.PUBLIC_CF_IMAGE_TRANSFORM === "on"; } catch { return false; } } export const cloudflareRuntimeProvider: ImageProvider = { id: "cloudflare-runtime", buildSources(descriptor, request = {}) { // data: URLs cannot be resized — pass straight through. if (descriptor.src.startsWith("data:")) { return { src: descriptor.src }; } // Safe default: until Cloudflare Image Transformations are enabled on the zone // (PUBLIC_CF_IMAGE_TRANSFORM=on), serve the raw origin asset — it always deploys // to dist/client/_astro and is served 200. No /cdn-cgi/image, no srcset, no resize. if (!cloudflareTransformEnabled()) { return { src: descriptor.src }; } const quality = resolveImageQuality(request.quality); const format: ImageFormat = request.format ?? "webp"; const origin = isAbsoluteUrl(descriptor.src) ? descriptor.src : descriptor.src.replace(/^\//, ""); const url = (w: number): string => { const opts = `onerror=redirect,width=${w},quality=${quality},format=${format}`; return `/cdn-cgi/image/${opts}/${origin}`; }; const widths = candidateWidths(descriptor.width, request.widths); const largest = widths[widths.length - 1] ?? descriptor.width ?? 1280; const srcset = widths.length > 1 ? widths.map((w) => `${url(w)} ${w}w`).join(", ") : undefined; return { src: url(largest), srcset, sizes: srcset ? (request.sizes ?? "100vw") : undefined, }; }, }; let activeProvider: ImageProvider = cloudflareRuntimeProvider; /** Return the active default image provider. */ export function getDefaultImageProvider(): ImageProvider { return activeProvider; } /** Override the active default image provider (app/deploy-target wiring, tests). */ export function setDefaultImageProvider(provider: ImageProvider): void { activeProvider = provider; } /** Convenience: build sources via the active default provider. */ export function buildImageSources( descriptor: ImageDescriptor, request?: ImageRequest, ): ImageSources { return activeProvider.buildSources(descriptor, request); } // --------------------------------------------------------------------------- // RFC-0204: build-portable provider — pre-generated responsive variants. // --------------------------------------------------------------------------- /** A single pre-generated width variant for one source image. */ export interface ImageVariant { /** CSS pixel width of this variant. */ width: number; /** Root-relative URL served from public/_img//.webp (always deployed). */ url: string; } /** Manifest entry for one source image origin. */ export interface ImageVariantEntry { /** * Origin src key — matches the exact `descriptor.src` value emitted by resolveImage * (e.g. "/_astro/katrin-hennings..webp"). Used as the lookup key. */ origin: string; /** Intrinsic width of the source, when known. */ intrinsicWidth?: number; /** Intrinsic height of the source, when known. */ intrinsicHeight?: number; /** Pre-generated variants sorted ascending by width, never exceeding intrinsicWidth. */ variants: ImageVariant[]; /** * SHA-256 hex digest of the source file bytes at generation time. * Used by `image.variants.generate` to detect source changes and invalidate * stale derived variants (same contract as video.variants.generate RFC-0210). */ sourceHash?: string; /** RFC-0928: WebP quality used by image.variants.generate (minimum 90). */ quality?: number; } /** Top-level manifest written by `image.variants.generate` and read by createBuildPortableProvider. */ export interface ImageVariantManifest { version: 1; /** Map from origin src → entry (stable content-path key, e.g. /src/content/business-profile/de/assets/portrait.webp). */ byOrigin: Record; /** * Secondary lookup: maps the bare image basename (no hash, no ext) to every * entry origin key sharing it. Allows the provider to resolve Astro-hashed * descriptor.src values like "/_astro/katrin-hennings.BIwCI837.webp" → entry * via "katrin-hennings". Multiple candidates occur for language-mirrored * assets — `ImageRequest.lang` selects the matching origin. */ byBasename: Record; } /** * Extract the bare basename (no hash suffix, no extension) from an Astro-hashed URL. * "/_astro/katrin-hennings.CrtmuSkJ.webp" → "katrin-hennings" * "/src/content/business-profile/de/assets/katrin-hennings.webp" → "katrin-hennings" * * Vite/Rollup content hashes use base64url characters (a-z A-Z 0-9 - _), not pure hex. * The hash is always exactly 8 characters before the final extension. */ function extractBasename(src: string): string { const filename = src.split("/").pop() ?? ""; // Strip extension const noExt = filename.replace(/\.[a-z0-9]+$/i, ""); // Strip Vite/Rollup hash suffix: ".<8-char alphanumeric-dash-underscore>" return noExt.replace(/\.[a-zA-Z0-9_-]{8}$/, ""); } /** * RFC-0204: build-portable provider factory. * * Reads a pre-generated `ImageVariantManifest` (produced by `image.variants.generate`) * and emits a responsive `srcset` from static width-variant URLs. Falls back to * `{ src: descriptor.src }` for images not in the manifest (SVG, data:, unmanaged). * * Lookup strategy: primary by exact `byOrigin` key (content-relative path); * fallback by `byBasename` to handle Astro-hashed `/_astro/..webp` URLs. * * IMPORTANT: synchronous — do NOT import Astro `astro:assets` / `` / `getImage`. */ export function createBuildPortableProvider(manifest: ImageVariantManifest): ImageProvider { return { id: "build-portable", buildSources(descriptor, request = {}) { if (!descriptor.src || descriptor.src.startsWith("data:")) { return { src: descriptor.src }; } // Primary: exact origin key match (e.g. /src/content/.../portrait.webp) let entry = manifest.byOrigin[descriptor.src]; // Secondary: match by stripped basename for Astro-hashed URLs (/_astro/..webp) if (!entry && manifest.byBasename) { const basename = extractBasename(descriptor.src); const listed = manifest.byBasename[basename]; // Tolerate manifests written by older generators (single string value). const candidates = Array.isArray(listed) ? listed : listed ? [listed] : []; const originKey = (request.lang ? candidates.find((k) => k.includes(`/${request.lang}/`)) : undefined) ?? candidates[0]; if (originKey) entry = manifest.byOrigin[originKey]; } if (!entry || entry.variants.length === 0) { return { src: descriptor.src }; } const widths = candidateWidths(entry.intrinsicWidth, request.widths); const available = entry.variants.filter((v) => widths.includes(v.width)); const sorted = available.length > 0 ? available : entry.variants; const largest = sorted[sorted.length - 1]; if (!largest) return { src: descriptor.src }; const srcset = sorted.length > 1 ? sorted.map((v) => `${v.url} ${v.width}w`).join(", ") : undefined; // Derive variant height from the original aspect ratio const aspectRatio = entry.intrinsicWidth && entry.intrinsicHeight ? entry.intrinsicHeight / entry.intrinsicWidth : undefined; const variantHeight = aspectRatio !== undefined ? Math.round(largest.width * aspectRatio) : undefined; return { src: largest.url, srcset, sizes: srcset ? (request.sizes ?? "100vw") : undefined, width: largest.width, height: variantHeight, }; }, }; }