/** * Responsive-media intent — Save-Data / DPR / Client Hints projection (#125). * * Pure resolution and srcset/image-set builders. Hosts inject capabilities from * `@czap/edge` `ClientHints` or `@czap/detect`; core stays dependency-free. * * @module */ import { ValidationError } from '@czap/error'; /** Capability slice required to resolve a responsive media intent. */ export interface ResponsiveMediaCapabilities { readonly devicePixelRatio: number; readonly saveData: boolean; } /** One candidate source in a responsive set. */ export interface ResponsiveMediaVariant { readonly src: string; /** Intrinsic width in CSS pixels — used for `Nw` descriptors. */ readonly width?: number; /** Explicit density descriptor (e.g. `2x`); overrides width-based DPR inference. */ readonly descriptor?: string; } /** Authoring input to {@link ResponsiveMedia.intent}. */ export interface ResponsiveMediaIntentInput { readonly id: string; readonly alt: string; readonly variants: readonly ResponsiveMediaVariant[]; /** Lighter asset used when Save-Data is on. */ readonly saveDataVariant?: ResponsiveMediaVariant; readonly sizes?: string; } /** Sealed responsive-media intent — data over graph, no behavior authority. */ export interface ResponsiveMediaIntent extends ResponsiveMediaIntentInput { readonly _tag: 'ResponsiveMediaIntent'; } /** Why a particular variant was chosen. */ export type ResponsiveMediaResolutionReason = 'save-data' | 'save-data-floor' | 'dpr-match' | 'dpr-floor' | 'fallback'; /** Resolved single source for SSR or runtime ``. */ export interface ResolvedResponsiveMedia { readonly src: string; readonly reason: ResponsiveMediaResolutionReason; } /** * The EFFECTIVE candidate set — the single law every responsive-media output derives * from ({@link selectCandidates}). Under `caps.saveData` the set is capped to the ONE * light/floor variant, so no artifact (`srcset`, ``, the preload `imagesrcset`, * CSS `image-set()`, the cache-key digest) can ever advertise a heavier candidate — the * browser cannot re-fetch what no output lists (F-RM-1a..e). */ export interface ResponsiveMediaCandidateSet { /** * The candidates safe to advertise under `caps`. Save-Data caps this to a single * light/floor variant; otherwise it is the full authored set. `srcset`, the general * ``, the preload `imagesrcset`, and CSS `image-set()` all enumerate THIS. */ readonly candidates: readonly ResponsiveMediaVariant[]; /** The single best variant for `` — the DPR pick WITHIN `candidates`. */ readonly resolved: ResponsiveMediaVariant; /** Why `resolved` was chosen and how `candidates` was capped. */ readonly reason: ResponsiveMediaResolutionReason; } /** Structured `` projection. */ export interface ResponsiveMediaPictureProjection { readonly picture: string; readonly img: string; readonly srcset: string; readonly sizes: string; readonly resolved: ResolvedResponsiveMedia; /** * Optional `` for the resolved (or full srcset) * asset — hosts put this in `` for LCP (#125). */ readonly preload: string; } function parseDescriptorDpr(descriptor: string): number | undefined { const match = /^([\d.]+)x$/i.exec(descriptor.trim()); if (!match) return undefined; const value = Number(match[1]); return Number.isFinite(value) && value > 0 ? value : undefined; } function variantDpr(variant: ResponsiveMediaVariant, fallbackWidth: number): number | undefined { if (variant.descriptor !== undefined) { const explicit = parseDescriptorDpr(variant.descriptor); if (explicit !== undefined) return explicit; const widthMatch = /^(\d+)w$/i.exec(variant.descriptor.trim()); if (widthMatch) { const w = Number(widthMatch[1]); return w > 0 && fallbackWidth > 0 ? w / fallbackWidth : undefined; } } if (variant.width !== undefined && variant.width > 0 && fallbackWidth > 0) { return variant.width / fallbackWidth; } return undefined; } function minPositiveWidth(variants: readonly ResponsiveMediaVariant[]): number { let min = Number.POSITIVE_INFINITY; for (const variant of variants) { if (variant.width !== undefined && variant.width > 0) { min = Math.min(min, variant.width); } } return Number.isFinite(min) ? min : 0; } function escapeAttr(value: string): string { return value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } /** * Build a `srcset` string from variants with `w` or `x` descriptors. * * Variants without enough metadata are skipped; result is empty when none qualify. */ export function buildResponsiveSrcset(variants: readonly ResponsiveMediaVariant[], baseWidth?: number): string { const parts: string[] = []; for (const variant of variants) { if (variant.descriptor !== undefined) { parts.push(`${variant.src} ${variant.descriptor}`); continue; } if (variant.width !== undefined && variant.width > 0) { parts.push(`${variant.src} ${variant.width}w`); continue; } const inferred = baseWidth !== undefined ? variantDpr(variant, baseWidth) : undefined; if (inferred !== undefined) { parts.push(`${variant.src} ${inferred}x`); } } return parts.join(', '); } /** * Build a CSS `image-set()` value from variants (native CSS first). * * Uses `type()` only when variants carry standard image extensions; unknown * types are omitted rather than guessed. */ export function buildResponsiveImageSet(variants: readonly ResponsiveMediaVariant[], baseWidth?: number): string { const parts: string[] = []; const inferredBase = baseWidth ?? minPositiveWidth(variants); for (const variant of variants) { let descriptor: string | undefined; if (variant.descriptor !== undefined) { const trimmed = variant.descriptor.trim(); if (/^[\d.]+x$/i.test(trimmed)) { descriptor = trimmed; } else { const explicit = parseDescriptorDpr(trimmed); if (explicit !== undefined) descriptor = `${explicit}x`; } // `Nw` width descriptors are invalid inside CSS image-set() — skip. } if (descriptor === undefined && variant.width !== undefined && inferredBase > 0) { const dpr = variant.width / inferredBase; if (Number.isFinite(dpr) && dpr > 0) { descriptor = `${dpr}x`; } } if (descriptor === undefined) { continue; } parts.push(`url("${variant.src}") ${descriptor}`); } if (parts.length === 0) { // A lone candidate with no derivable descriptor — a bare Save-Data light asset `{ src }` — // still belongs in image-set(): default it to `1x` so CSS consumers advertise the light URL // rather than `none`, matching the reduced-data path (Codex P2). Multiple // descriptor-less candidates have a genuinely ambiguous DPR, so those still yield `none`. if (variants.length === 1) return `image-set(url("${variants[0]!.src}") 1x)`; return 'none'; } return `image-set(${parts.join(', ')})`; } /** * THE one effective-candidate law — the single function every responsive-media * output consumes (#140). Returns the {@link ResponsiveMediaCandidateSet}: the * candidates safe to advertise under `caps`, the single best `src`, and the reason. * * Save-Data wins over DPR and caps ALL candidates to the floor: the authored * `saveDataVariant` when present (`save-data`), else the LIGHTEST available variant * (`save-data-floor`) — a Save-Data client must never be advertised a heavier * candidate through ANY artifact, even when the author skipped the explicit light * variant. Otherwise the full authored set is advertised and `resolved` is the DPR * pick: the variant whose DPR is closest without going under the device ratio * (`dpr-match`), else the largest available (`dpr-floor`), else the first (`fallback`). */ export function selectCandidates( intent: ResponsiveMediaIntent, caps: ResponsiveMediaCapabilities, ): ResponsiveMediaCandidateSet { if (intent.variants.length === 0) { throw ValidationError('selectCandidates', 'ResponsiveMediaIntent.variants must be non-empty'); } const dpr = Number.isFinite(caps.devicePixelRatio) && caps.devicePixelRatio > 0 ? caps.devicePixelRatio : 1; const baseWidth = minPositiveWidth(intent.variants); const scored = intent.variants .map((variant) => ({ variant, dpr: variantDpr(variant, baseWidth) ?? 1 })) .sort((a, b) => a.dpr - b.dpr); // Save-Data caps the WHOLE set to one light candidate — the browser cannot // re-fetch a heavy asset that no output advertises (F-RM-1a..e). if (caps.saveData) { if (intent.saveDataVariant !== undefined) { const only = intent.saveDataVariant; return Object.freeze({ candidates: Object.freeze([only]), resolved: only, reason: 'save-data' }); } // No authored light variant — honor Save-Data with the smallest candidate // rather than silently falling through to the heavy DPR match (F-RM-1c). const smallest = scored[0]!.variant; return Object.freeze({ candidates: Object.freeze([smallest]), resolved: smallest, reason: 'save-data-floor' }); } // Normal path: advertise the FULL authored set; `resolved` is the DPR pick. const candidates = intent.variants; const atOrAbove = scored.filter((entry) => entry.dpr >= dpr); if (atOrAbove.length > 0) { return Object.freeze({ candidates, resolved: atOrAbove[0]!.variant, reason: 'dpr-match' }); } const floor = scored[scored.length - 1]; if (floor !== undefined) { return Object.freeze({ candidates, resolved: floor.variant, reason: 'dpr-floor' }); } return Object.freeze({ candidates, resolved: intent.variants[0]!, reason: 'fallback' }); } /** * Resolve the single best `src` for SSR / fallback `` given capabilities. * * A thin projection of {@link selectCandidates}: takes its `resolved` variant and * `reason`. Kept as its own export for hosts that only need the one `src` — but it * derives from the SAME law as `srcset` / `` / preload / image-set, so a * Save-Data client is never SILENTLY served a light `src` while a heavy candidate * leaks through another artifact. */ export function resolveResponsiveMedia( intent: ResponsiveMediaIntent, caps: ResponsiveMediaCapabilities, ): ResolvedResponsiveMedia { const selection = selectCandidates(intent, caps); return Object.freeze({ src: selection.resolved.src, reason: selection.reason }); } /** * Project a responsive-media intent to a `` + fallback ``. * * Native markup first: `` per density band; runtime/SSR picks * `resolved.src` on the inner `` for hosts without picture support. */ export function projectResponsiveMediaPicture( intent: ResponsiveMediaIntent, caps: ResponsiveMediaCapabilities, ): ResponsiveMediaPictureProjection { // ONE source: every artifact below enumerates the SAME effective candidate set. const selection = selectCandidates(intent, caps); const resolved = Object.freeze({ src: selection.resolved.src, reason: selection.reason }); const sizes = intent.sizes ?? '100vw'; const srcset = buildResponsiveSrcset(selection.candidates); // The reduced-data advertises ONLY the authored light asset — a client // that reports `prefers-reduced-data` picks it even when the server never saw a // Save-Data header — so this branch NEVER lists a heavy candidate either. A // `saveDataVariant` may omit width/descriptor (a single bare light asset), in which // case `buildResponsiveSrcset` returns '' — falling back to a bare-URL srcset (valid, // defaults to `1x`) keeps the reduced-data present so those clients are never // dropped onto the heavy `srcset` (Codex P2). const saveDataSrcset = intent.saveDataVariant !== undefined ? buildResponsiveSrcset([intent.saveDataVariant]) || intent.saveDataVariant.src : ''; const sources: string[] = []; if (saveDataSrcset.length > 0) { sources.push( ``, ); } if (srcset.length > 0) { sources.push(``); } const img = `${escapeAttr(intent.alt)}`; const picture = sources.length > 0 ? `${sources.join('')}${img}` : img; // Preload the EFFECTIVE set only (F-RM-1d, the worst leak — it drove the LCP): // under Save-Data `srcset` is already the light set, so the LCP preload can // never pull the heavy DPR-matched asset. const preload = srcset.length > 0 ? `` : ``; return Object.freeze({ picture, img, srcset, sizes, resolved, preload, }); } /** Authoring sugar namespace — data over intent, no behavior authority. */ export const ResponsiveMedia = { /** Seal a responsive-media intent from authoring input. */ intent(input: ResponsiveMediaIntentInput): ResponsiveMediaIntent { return Object.freeze({ _tag: 'ResponsiveMediaIntent', ...input }); }, } as const;