release: WRNexusJS 0.7.0
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# @wrnexus/image
|
||||
|
||||
Responsive image attribute generation with secure remote-host policies and audits for dimensions, LCP loading, source oversizing, transfer size, and modern formats.
|
||||
|
||||
```ts
|
||||
import { createResponsiveImage } from "@wrnexus/image";
|
||||
const attrs = createResponsiveImage({
|
||||
src: "/hero.jpg",
|
||||
alt: "Hero",
|
||||
width: 1600,
|
||||
height: 900,
|
||||
widths: [480, 960, 1600],
|
||||
sizes: "100vw",
|
||||
format: "avif",
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@wrnexus/image",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/security": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
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;
|
||||
|
||||
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 separator = src.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 `${src}${separator}${params.toString()}`;
|
||||
};
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { auditImage, createResponsiveImage } from "../src/index.ts";
|
||||
|
||||
describe("@wrnexus/image", () => {
|
||||
test("builds deterministic responsive image attributes", () => {
|
||||
const image = createResponsiveImage({
|
||||
src: "/hero.jpg",
|
||||
alt: "Hero",
|
||||
width: 1200,
|
||||
height: 600,
|
||||
widths: [400, 800, 1200, 800],
|
||||
sizes: "100vw",
|
||||
format: "webp",
|
||||
fetchPriority: "high",
|
||||
});
|
||||
expect(image.srcset).toContain("400w");
|
||||
expect(image.srcset).toContain("1200w");
|
||||
expect(image.loading).toBe("eager");
|
||||
expect(image.width).toBe("1200");
|
||||
});
|
||||
|
||||
test("reports layout shift and LCP mistakes", () => {
|
||||
const issues = auditImage({ src: "hero.jpg", isLcp: true, loading: "lazy" });
|
||||
expect(issues.map((issue) => issue.code)).toContain("WRN-IMAGE-DIMENSIONS");
|
||||
expect(issues.map((issue) => issue.code)).toContain("WRN-IMAGE-LCP-LAZY");
|
||||
});
|
||||
test("rejects insecure remote images unless explicitly allowed", () => {
|
||||
expect(() =>
|
||||
createResponsiveImage({
|
||||
src: "http://images.example.com/hero.jpg",
|
||||
alt: "Hero",
|
||||
width: 800,
|
||||
height: 400,
|
||||
remoteHosts: ["images.example.com"],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user