367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
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, """)
|
|
.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<ImageFormat>(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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><defs><linearGradient id="g"><stop stop-color="${color}"/><stop offset="1" stop-color="${accent}"/></linearGradient></defs><rect width="100%" height="100%" fill="url(#g)"/></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 `<link ${attrs.join(" ")} />`;
|
|
}
|
|
|
|
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";
|