import { validateUrl } from "@wrnexus/security"; export type ImageFormat = "avif" | "webp" | "jpeg" | "png" | "original"; export interface ImageLoaderInput { src: string; width: number; quality?: number; format?: ImageFormat; } export type ImageLoader = (input: ImageLoaderInput) => string; function safeSvgColor(value: string, name: string): string { const color = value.trim(); if (color.length > 128) throw new TypeError(`Unsafe ${name} image placeholder color.`); if ( /^#[0-9a-f]{3,8}$/i.test(color) || /^(?:rgb|hsl)a?\([0-9.,%\s/+-]+\)$/i.test(color) || /^var\(--[A-Za-z0-9_-]+\)$/.test(color) || /^[A-Za-z]+$/.test(color) ) { return color; } throw new TypeError(`Unsafe ${name} image placeholder color.`); } function escapeHtmlAttribute(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } export interface ImagePolicy { remoteHosts?: string[]; allowedProtocols?: string[]; maxWidth?: number; maxQuality?: number; } export interface ResponsiveImageOptions extends ImagePolicy { src: string; alt: string; width: number; height: number; widths?: number[]; sizes?: string; quality?: number; format?: ImageFormat; loading?: "eager" | "lazy"; fetchPriority?: "high" | "low" | "auto"; decoding?: "async" | "sync" | "auto"; loader?: ImageLoader; class?: string; } export interface ResponsiveImageAttributes { src: string; srcset?: string; sizes?: string; alt: string; width: string; height: string; loading: "eager" | "lazy"; decoding: "async" | "sync" | "auto"; fetchpriority?: "high" | "low" | "auto"; class?: string; } export interface ImageAuditInput { src: string; width?: number; height?: number; renderedWidth?: number; bytes?: number; loading?: string; fetchPriority?: string; isLcp?: boolean; } export interface ImageAuditIssue { code: string; severity: "error" | "warning" | "info"; message: string; } export const defaultImageLoader: ImageLoader = ({ src, width, quality, format }) => { const hashIndex = src.indexOf("#"); const source = hashIndex >= 0 ? src.slice(0, hashIndex) : src; const hash = hashIndex >= 0 ? src.slice(hashIndex) : ""; const separator = source.includes("?") ? "&" : "?"; const params = new URLSearchParams({ w: String(width) }); if (quality !== undefined) params.set("q", String(quality)); if (format && format !== "original") params.set("format", format); return `${source}${separator}${params.toString()}${hash}`; }; function validateSource(src: string, policy: ImagePolicy): void { if (/^(?:https?:)?\/\//i.test(src)) { validateUrl(src.startsWith("//") ? `https:${src}` : src, { allowRelative: false, allowedProtocols: policy.allowedProtocols ?? ["https:"], allowedHosts: policy.remoteHosts, allowCredentials: false, }); } else { validateUrl(src, { base: "https://wrnexus.invalid", allowRelative: true, allowedProtocols: ["https:"], allowCredentials: false, }); } } export function createResponsiveImage(options: ResponsiveImageOptions): ResponsiveImageAttributes { validateSource(options.src, options); if (!Number.isFinite(options.width) || options.width <= 0) { throw new RangeError("Image width must be a positive number."); } if (!Number.isFinite(options.height) || options.height <= 0) { throw new RangeError("Image height must be a positive number."); } const maxWidth = options.maxWidth ?? 8_192; const maxQuality = options.maxQuality ?? 100; if (!Number.isFinite(maxWidth) || maxWidth <= 0) throw new RangeError("Image maxWidth must be positive."); if (!Number.isFinite(maxQuality) || maxQuality <= 0) throw new RangeError("Image maxQuality must be positive."); const quality = Math.min(maxQuality, Math.max(1, Math.round(options.quality ?? 80))); const widths = [...new Set(options.widths ?? [options.width])] .map((width) => Math.round(width)) .filter((width) => width > 0 && width <= maxWidth) .sort((a, b) => a - b); if (!widths.includes(Math.round(options.width)) && options.width <= maxWidth) { widths.push(Math.round(options.width)); widths.sort((a, b) => a - b); } const loader = options.loader ?? defaultImageLoader; const src = loader({ src: options.src, width: Math.min(Math.round(options.width), maxWidth), quality, format: options.format, }); const srcset = widths.length > 1 ? widths .map( (width) => `${loader({ src: options.src, width, quality, format: options.format })} ${width}w`, ) .join(", ") : undefined; return { src, ...(srcset ? { srcset } : {}), ...(options.sizes ? { sizes: options.sizes } : {}), alt: options.alt, width: String(Math.round(options.width)), height: String(Math.round(options.height)), loading: options.loading ?? (options.fetchPriority === "high" ? "eager" : "lazy"), decoding: options.decoding ?? "async", ...(options.fetchPriority ? { fetchpriority: options.fetchPriority } : {}), ...(options.class ? { class: options.class } : {}), }; } export function auditImage(input: ImageAuditInput): ImageAuditIssue[] { const issues: ImageAuditIssue[] = []; if (!input.width || !input.height) { issues.push({ code: "WRN-IMAGE-DIMENSIONS", severity: "error", message: "Images must declare width and height to prevent layout shifts.", }); } if (input.isLcp && input.loading === "lazy") { issues.push({ code: "WRN-IMAGE-LCP-LAZY", severity: "error", message: "The LCP image must not be lazy-loaded.", }); } if (input.isLcp && input.fetchPriority !== "high") { issues.push({ code: "WRN-IMAGE-LCP-PRIORITY", severity: "warning", message: "Consider fetchpriority=high for the LCP image.", }); } if (input.width && input.renderedWidth && input.width > input.renderedWidth * 2.5) { issues.push({ code: "WRN-IMAGE-OVERSIZED", severity: "warning", message: "The source image is substantially wider than its rendered size.", }); } if ((input.bytes ?? 0) > 1_000_000) { issues.push({ code: "WRN-IMAGE-BYTES", severity: (input.bytes ?? 0) > 3_000_000 ? "error" : "warning", message: `Image transfer size is ${Math.round((input.bytes ?? 0) / 1024)} KiB.`, }); } if (/\.(?:png|jpe?g)(?:\?|$)/i.test(input.src)) { issues.push({ code: "WRN-IMAGE-MODERN-FORMAT", severity: "info", message: "Consider serving AVIF or WebP with a compatible fallback.", }); } return issues; } export interface PictureSource { type: string; srcset: string; sizes?: string; } export interface PicturePlan { image: ResponsiveImageAttributes; sources: PictureSource[]; } export function normalizeImageWidths( widths: readonly number[], options: { min?: number; max?: number } = {}, ): number[] { const requestedMin = options.min ?? 16; const requestedMax = options.max ?? 8_192; if (!Number.isFinite(requestedMin) || !Number.isFinite(requestedMax)) { throw new RangeError("Image width bounds must be finite."); } const min = Math.max(1, Math.round(requestedMin)); const max = Math.max(min, Math.round(requestedMax)); return [...new Set(widths.map((width) => Math.round(width)))] .filter((width) => Number.isFinite(width) && width >= min && width <= max) .sort((left, right) => left - right); } export function createCdnImageLoader( baseUrl: string, options: { sourceParam?: string; widthParam?: string; qualityParam?: string; formatParam?: string; } = {}, ): ImageLoader { const base = new URL(baseUrl); validateUrl(base, { allowRelative: false, allowedProtocols: ["https:"], allowCredentials: false, }); return ({ src, width, quality, format }) => { const url = new URL(base); url.searchParams.set(options.sourceParam ?? "src", src); url.searchParams.set(options.widthParam ?? "w", String(width)); if (quality !== undefined) url.searchParams.set(options.qualityParam ?? "q", String(quality)); if (format && format !== "original") url.searchParams.set(options.formatParam ?? "format", format); return url.toString(); }; } export function createPathImageLoader(prefix = "/__wrnexus/image"): ImageLoader { validateUrl(prefix, { base: "https://wrnexus.invalid", allowRelative: true, allowedProtocols: ["https:"], allowCredentials: false, }); return ({ src, width, quality, format }) => { const path = `${prefix.replace(/\/$/, "")}/${encodeURIComponent(src)}`; const query = new URLSearchParams({ w: String(width) }); if (quality !== undefined) query.set("q", String(quality)); if (format && format !== "original") query.set("format", format); return `${path}?${query}`; }; } export function createPicture( options: ResponsiveImageOptions & { formats?: ImageFormat[] }, ): PicturePlan { validateSource(options.src, options); const formats: ImageFormat[] = [...new Set(options.formats ?? ["avif", "webp"])]; const widths = normalizeImageWidths(options.widths ?? [options.width], { max: options.maxWidth }); const loader = options.loader ?? defaultImageLoader; const quality = Math.min( options.maxQuality ?? 100, Math.max(1, Math.round(options.quality ?? 80)), ); const sources = formats .filter((format) => format !== "original") .map((format) => ({ type: `image/${format === "jpeg" ? "jpeg" : format}`, srcset: widths .map((width) => `${loader({ src: options.src, width, quality, format })} ${width}w`) .join(", "), ...(options.sizes ? { sizes: options.sizes } : {}), })); return { image: createResponsiveImage({ ...options, widths, format: options.format ?? "original" }), sources, }; } export function createBlurPlaceholder( options: { width?: number; height?: number; color?: string; accent?: string } = {}, ): string { const requestedWidth = options.width ?? 16; const requestedHeight = options.height ?? 9; if (!Number.isFinite(requestedWidth) || !Number.isFinite(requestedHeight)) { throw new RangeError("Image placeholder dimensions must be finite."); } const width = Math.max(1, Math.min(512, Math.round(requestedWidth))); const height = Math.max(1, Math.min(512, Math.round(requestedHeight))); const color = safeSvgColor(options.color ?? "#e2e8f0", "primary"); const accent = safeSvgColor(options.accent ?? "#cbd5e1", "accent"); const svg = ``; return `data:image/svg+xml,${encodeURIComponent(svg)}`; } export function imagePreload( image: ResponsiveImageAttributes, options: { as?: string; type?: string; crossOrigin?: "anonymous" | "use-credentials" } = {}, ): string { const attrs = [ 'rel="preload"', `as="${options.as ?? "image"}"`, `href="${escapeHtmlAttribute(image.src)}"`, ]; if (image.srcset) attrs.push(`imagesrcset="${escapeHtmlAttribute(image.srcset)}"`); if (image.sizes) attrs.push(`imagesizes="${escapeHtmlAttribute(image.sizes)}"`); if (options.type) attrs.push(`type="${escapeHtmlAttribute(options.type)}"`); if (options.crossOrigin) attrs.push(`crossorigin="${options.crossOrigin}"`); return ``; } export function imageCacheKey(input: ImageLoaderInput): string { const source = `${input.src}|${Math.round(input.width)}|${input.quality ?? ""}|${input.format ?? "original"}`; let hash = 0x811c9dc5; for (let index = 0; index < source.length; index++) { hash ^= source.charCodeAt(index); hash = Math.imul(hash, 0x01000193); } return `img-${(hash >>> 0).toString(36)}`; } export { imagePlugin, imageComponentsDir } from "./plugin.ts"; export type { ImagePluginOptions } from "./plugin.ts"; export { optimizeImage } from "./optimize.ts"; export type { ImageProcessor, ImageProcessorResult, OptimizeImageOptions, OptimizedImageManifest, OptimizedImageVariant, } from "./optimize.ts";