/**
* Responsive image helpers shared by the public Image components.
*
* These build a `srcset` for locally-stored / R2-stored media by delegating to
* Astro's configured image service (`astro:assets`). On Cloudflare that is the
* Images binding; on Node it is sharp; if neither is available it is a no-op
* passthrough. The calling `.astro` component passes Astro's `getImage` in so
* this module stays free of the `astro:assets` virtual import (which only
* resolves inside an Astro project, not in this precompiled package).
*/
/** Standard responsive breakpoints. Matches CDN-provider srcset generation. */
export const RESPONSIVE_BREAKPOINTS = [640, 750, 828, 960, 1080, 1280, 1600, 1920];
/** Matches absolute http(s) URLs — the only shape Astro's image services optimize. */
const ABSOLUTE_HTTP_URL = /^https?:\/\//i;
/**
* Pick the srcset widths to generate for an image rendered at `maxWidth`.
* Includes breakpoints up to 2x (retina) plus the rendered width itself, so the
* browser always has an exact-fit candidate.
*/
export function responsiveWidths(maxWidth: number): number[] {
const cap = maxWidth * 2;
const widths = new Set(RESPONSIVE_BREAKPOINTS.filter((w) => w <= cap));
widths.add(maxWidth);
return [...widths].toSorted((a, b) => a - b);
}
/** Build the `sizes` attribute for an image with a known display width. */
export function responsiveSizes(width: number | undefined): string {
return width ? `(min-width: ${width}px) ${width}px, 100vw` : "100vw";
}
/**
* Make a same-origin media URL absolute so Astro's image service can optimize it.
*
* Astro only optimizes absolute http(s) URLs; a same-origin proxy path like
* `/_emdash/api/media/file/x.jpg` is otherwise treated as an unoptimizable
* public asset. Resolving it against the site's public origin (and authorizing
* that origin via `image.remotePatterns`) lets the service transform it.
*
* Only **same-origin** root-relative paths are resolved. Protocol-relative
* URLs (`//evil.com/x`) and backslash tricks (`/\evil.com`) also start with `/`
* but resolve to a different origin -- a classic SSRF vector once a
* remotePattern authorizes the media path -- so anything that escapes the
* origin is returned unchanged (and then skipped by `buildResponsiveImage`,
* which only accepts absolute http(s) URLs). Already-absolute URLs (CDN/public
* bucket) and non-path values (`data:`, `blob:`) are returned unchanged too.
*/
export function toAbsoluteMediaUrl(src: string, origin: string | undefined): string {
if (!src || !origin || !src.startsWith("/")) return src;
try {
const resolved = new URL(src, origin);
if (resolved.origin !== new URL(origin).origin) return src;
return resolved.href;
} catch {
return src;
}
}
/**
* Minimal structural subset of Astro's `getImage`. Astro's `ImageTransform`
* carries a `[key: string]: any` index signature, so the real `getImage` is
* assignable to this narrower type.
*/
export type GetImage = (options: {
src: string;
width?: number;
height?: number;
widths?: number[];
sizes?: string;
}) => Promise<{ src: string; srcSet?: { attribute?: string } | undefined }>;
export interface ResponsiveImage {
src: string;
srcset?: string;
sizes?: string;
}
/**
* Generate a responsive `src`/`srcset`/`sizes` for a media URL via Astro's
* configured image service.
*
* Astro's image services (sharp, Cloudflare `/cdn-cgi/image`, and the default
* Cloudflare `cloudflare-binding` service) only optimize **absolute** URLs whose
* host is authorized via `image.domains` / `image.remotePatterns`. Anything else
* is passed through unchanged, which would yield a useless srcset (the same URL
* at every width descriptor). We therefore only attempt optimization for
* absolute http(s) URLs and verify the service actually rewrote the URL.
*
* Returns `null` so callers fall back to a plain `
` when:
* - dimensions are unknown (avoids an inferSize fetch on every render),
* - the URL is relative (a same-origin proxy/public asset Astro won't optimize),
* - the host isn't authorized (the service passed the URL through unchanged),
* - no image service is configured / `getImage` throws.
*/
export async function buildResponsiveImage(
getImage: GetImage,
opts: { src: string; width?: number; height?: number },
): Promise {
const { src, width, height } = opts;
if (!src || !width || !height) return null;
if (!ABSOLUTE_HTTP_URL.test(src)) return null;
try {
const sizes = responsiveSizes(width);
const result = await getImage({
src,
width,
height,
widths: responsiveWidths(width),
sizes,
});
// Passthrough: the service returned the source unchanged (unauthorized
// host or no optimization available). Don't emit a no-op srcset.
if (!result.src || result.src === src) return null;
return {
src: result.src,
srcset: result.srcSet?.attribute || undefined,
sizes,
};
} catch {
return null;
}
}