release: WRNexusJS 0.8.0
This commit is contained in:
+55
-10
@@ -1,16 +1,61 @@
|
||||
# @wrnexus/image
|
||||
|
||||
Responsive image attribute generation with secure remote-host policies and audits for dimensions, LCP loading, source oversizing, transfer size, and modern formats.
|
||||
Secure responsive-image planning, loader adapters, picture sources, preload hints, placeholders, and performance auditing for WRNexusJS.
|
||||
|
||||
Build-time conversion is available through `optimizeImage`. It normalizes and
|
||||
bounds width/format variants, prevents variant explosions, writes deterministic
|
||||
filenames, and returns a manifest with dimensions and byte sizes:
|
||||
|
||||
```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",
|
||||
import { optimizeImage } from "@wrnexus/image";
|
||||
|
||||
const manifest = await optimizeImage("public/hero.jpg", {
|
||||
outputDir: "public/generated/images",
|
||||
widths: [480, 960, 1440],
|
||||
formats: ["avif", "webp"],
|
||||
quality: 80,
|
||||
});
|
||||
```
|
||||
|
||||
Install the optional `sharp` peer (`bun add sharp`) for the default AVIF/WebP
|
||||
processor. Build systems can instead supply an `ImageProcessor` adapter, which
|
||||
also makes transformation pipelines deterministic in tests.
|
||||
|
||||
## Helper API
|
||||
|
||||
```ts
|
||||
import {
|
||||
createResponsiveImage,
|
||||
createPicture,
|
||||
createCdnImageLoader,
|
||||
createPathImageLoader,
|
||||
createBlurPlaceholder,
|
||||
imagePreload,
|
||||
auditImage,
|
||||
} from "@wrnexus/image";
|
||||
|
||||
const loader = createCdnImageLoader("https://images.example.com/transform");
|
||||
const picture = createPicture({
|
||||
src: "/hero.jpg",
|
||||
alt: "Product dashboard",
|
||||
width: 1600,
|
||||
height: 900,
|
||||
widths: [480, 768, 1200, 1600],
|
||||
formats: ["avif", "webp"],
|
||||
sizes: "(max-width: 768px) 100vw, 1200px",
|
||||
fetchPriority: "high",
|
||||
loader,
|
||||
});
|
||||
```
|
||||
|
||||
Remote loaders require HTTPS. Source URLs are validated, dimensions and quality are bounded, placeholder colors are restricted to safe CSS colors, and preload attributes are escaped.
|
||||
|
||||
## Components
|
||||
|
||||
Enable `imagePlugin()` and use:
|
||||
|
||||
- `<OptimizedImage />`
|
||||
- `<Picture />`
|
||||
- `<ImageCard />`
|
||||
|
||||
The package-owned blocks compose `@wrnexus/ui` where a complete UI block is appropriate while keeping the low-level image element lightweight.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
component ImageCard {
|
||||
props {
|
||||
title: string = ""
|
||||
description: string = ""
|
||||
src: string = ""
|
||||
alt: string = ""
|
||||
width: string = ""
|
||||
height: string = ""
|
||||
href: string = ""
|
||||
actionLabel: string = ""
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<Card {...attrs} title='{title}' description='{description}' actionHref='{href}' actionLabel='{actionLabel}' color='{color}' size='{size}' class='{class}'>
|
||||
<OptimizedImage src='{src}' alt='{alt}' width='{width}' height='{height}' rounded="true" color='{color}' size='{size}' />
|
||||
<slot></slot>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
component OptimizedImage {
|
||||
outputs {
|
||||
load(payload: { sourceEvent: Event; src: string })
|
||||
error(payload: { sourceEvent: Event; src: string })
|
||||
}
|
||||
props {
|
||||
src: string = ""
|
||||
srcset: string = ""
|
||||
sizes: string = ""
|
||||
alt: string = ""
|
||||
width: string = ""
|
||||
height: string = ""
|
||||
loading: string = "lazy"
|
||||
decoding: string = "async"
|
||||
fetchpriority: string = "auto"
|
||||
placeholder: string = ""
|
||||
objectFit: string = "cover"
|
||||
rounded: boolean = false
|
||||
color: string = "primary"
|
||||
size: string = "md"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<img
|
||||
{...attrs}
|
||||
src='{src}'
|
||||
srcset='{srcset}'
|
||||
sizes='{sizes}'
|
||||
alt='{alt}'
|
||||
width='{width}'
|
||||
height='{height}'
|
||||
loading='{loading}'
|
||||
decoding='{decoding}'
|
||||
fetchpriority='{fetchpriority}'
|
||||
style='object-fit:{objectFit};background-image:{placeholder ? "url(" + placeholder + ")" : "none"};background-size:cover'
|
||||
class='wire-next wire-next--color-{color} wire-next--size-{size} max-w-full {rounded ? "rounded-[var(--wire-radius-sm)]" : ""} {class}'
|
||||
@load='output.load({ sourceEvent: event, src: src })'
|
||||
@error='output.error({ sourceEvent: event, src: src })'
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
component Picture {
|
||||
props {
|
||||
sources: unknown[] = []
|
||||
src: string = ""
|
||||
srcset: string = ""
|
||||
sizes: string = ""
|
||||
alt: string = ""
|
||||
width: string = ""
|
||||
height: string = ""
|
||||
loading: string = "lazy"
|
||||
decoding: string = "async"
|
||||
fetchpriority: string = "auto"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<picture {...attrs} class='{class}'>
|
||||
{#each sources as source}<source type='{source.type}' srcset='{source.srcset}' sizes='{source.sizes || sizes}' media='{source.media || ""}' />{/each}
|
||||
<img src='{src}' srcset='{srcset}' sizes='{sizes}' alt='{alt}' width='{width}' height='{height}' loading='{loading}' decoding='{decoding}' fetchpriority='{fetchpriority}' class="max-w-full" />
|
||||
</picture>
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,43 @@
|
||||
{
|
||||
"name": "@wrnexus/image",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.",
|
||||
"main": "src/index.ts",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./plugin": "./src/plugin.ts",
|
||||
"./components/*": "./components/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/security": "workspace:*"
|
||||
"@wrnexus/security": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*"
|
||||
},
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"components",
|
||||
"README.md"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"typescript": "^5.9.2",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"sharp": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"wrnexus": {
|
||||
"plugin": {
|
||||
"plugin": "./src/plugin.ts",
|
||||
"export": "default",
|
||||
"factory": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+180
-2
@@ -11,6 +11,29 @@ export interface ImageLoaderInput {
|
||||
|
||||
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[];
|
||||
@@ -65,11 +88,14 @@ export interface ImageAuditIssue {
|
||||
}
|
||||
|
||||
export const defaultImageLoader: ImageLoader = ({ src, width, quality, format }) => {
|
||||
const separator = src.includes("?") ? "&" : "?";
|
||||
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 `${src}${separator}${params.toString()}`;
|
||||
return `${source}${separator}${params.toString()}${hash}`;
|
||||
};
|
||||
|
||||
function validateSource(src: string, policy: ImagePolicy): void {
|
||||
@@ -100,6 +126,10 @@ export function createResponsiveImage(options: ResponsiveImageOptions): Responsi
|
||||
}
|
||||
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))
|
||||
@@ -186,3 +216,151 @@ export function auditImage(input: ImageAuditInput): ImageAuditIssue[] {
|
||||
}
|
||||
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";
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { mkdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { basename, extname, join, resolve } from "node:path";
|
||||
import { normalizeImageWidths, type ImageFormat } from "./index.ts";
|
||||
|
||||
export interface ImageProcessorResult {
|
||||
data: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ImageProcessor {
|
||||
transform(
|
||||
input: string,
|
||||
options: { width: number; format: Exclude<ImageFormat, "original">; quality: number },
|
||||
): Promise<ImageProcessorResult>;
|
||||
}
|
||||
|
||||
export interface OptimizeImageOptions {
|
||||
outputDir: string;
|
||||
widths: number[];
|
||||
formats?: Array<Exclude<ImageFormat, "original">>;
|
||||
quality?: number;
|
||||
maxVariants?: number;
|
||||
processor?: ImageProcessor;
|
||||
}
|
||||
|
||||
export interface OptimizedImageVariant {
|
||||
path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
format: Exclude<ImageFormat, "original">;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface OptimizedImageManifest {
|
||||
source: string;
|
||||
variants: OptimizedImageVariant[];
|
||||
}
|
||||
|
||||
async function sharpProcessor(): Promise<ImageProcessor> {
|
||||
const packageName = "sharp";
|
||||
let sharp: any;
|
||||
try {
|
||||
const module = (await import(packageName)) as { default?: any };
|
||||
sharp = module.default ?? module;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"WRN-IMAGE-SHARP-MISSING: install the optional `sharp` peer or provide an ImageProcessor",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return {
|
||||
async transform(input, options) {
|
||||
const pipeline = sharp(input)
|
||||
.rotate()
|
||||
.resize({ width: options.width, withoutEnlargement: true });
|
||||
const { data, info } = await pipeline
|
||||
.toFormat(options.format, { quality: options.quality })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
return { data: new Uint8Array(data), width: info.width, height: info.height };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function optimizeImage(
|
||||
input: string,
|
||||
options: OptimizeImageOptions,
|
||||
): Promise<OptimizedImageManifest> {
|
||||
const widths = normalizeImageWidths(options.widths);
|
||||
const formats = [...new Set(options.formats ?? ["avif", "webp"])] as Array<
|
||||
Exclude<ImageFormat, "original">
|
||||
>;
|
||||
if (!widths.length) throw new RangeError("Image optimization requires at least one valid width");
|
||||
if (
|
||||
!formats.length ||
|
||||
formats.some((format) => !["avif", "webp", "jpeg", "png"].includes(format))
|
||||
)
|
||||
throw new RangeError("Image optimization has an unsupported output format");
|
||||
const maxVariants = options.maxVariants ?? 32;
|
||||
if (!Number.isInteger(maxVariants) || maxVariants < 1)
|
||||
throw new RangeError("Image maxVariants must be positive");
|
||||
if (widths.length * formats.length > maxVariants)
|
||||
throw new RangeError(`Image optimization exceeds ${maxVariants} variants`);
|
||||
const quality = Math.max(1, Math.min(100, Math.round(options.quality ?? 80)));
|
||||
const processor = options.processor ?? (await sharpProcessor());
|
||||
const outputDir = resolve(options.outputDir);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const stem = basename(input, extname(input)).replace(/[^A-Za-z0-9._-]+/g, "-") || "image";
|
||||
const variants: OptimizedImageVariant[] = [];
|
||||
for (const format of formats) {
|
||||
for (const width of widths) {
|
||||
const result = await processor.transform(resolve(input), { width, format, quality });
|
||||
if (
|
||||
!Number.isInteger(result.width) ||
|
||||
result.width < 1 ||
|
||||
!Number.isInteger(result.height) ||
|
||||
result.height < 1
|
||||
)
|
||||
throw new Error("Image processor returned invalid dimensions");
|
||||
const path = join(outputDir, `${stem}-${result.width}.${format === "jpeg" ? "jpg" : format}`);
|
||||
writeFileSync(path, result.data);
|
||||
variants.push({
|
||||
path,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
format,
|
||||
bytes: statSync(path).size,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { source: resolve(input), variants };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { definePlugin } from "@wrnexus/plugin";
|
||||
export interface ImagePluginOptions {
|
||||
components?: boolean;
|
||||
componentDir?: string;
|
||||
}
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
export function imageComponentsDir(): string {
|
||||
return join(packageRoot, "components");
|
||||
}
|
||||
export function imagePlugin(options: ImagePluginOptions = {}) {
|
||||
return definePlugin({
|
||||
name: "@wrnexus/image",
|
||||
version: "0.8.0",
|
||||
componentDirs:
|
||||
options.components === false ? [] : [options.componentDir ?? imageComponentsDir()],
|
||||
});
|
||||
}
|
||||
export default imagePlugin;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { optimizeImage } from "../src/index.ts";
|
||||
|
||||
test("build optimizer emits deterministic bounded variants and a manifest", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-images-"));
|
||||
const input = join(root, "Hero image.png");
|
||||
writeFileSync(input, new Uint8Array([1, 2, 3]));
|
||||
const manifest = await optimizeImage(input, {
|
||||
outputDir: join(root, "output"),
|
||||
widths: [800, 400, 800],
|
||||
formats: ["webp", "avif"],
|
||||
processor: {
|
||||
async transform(_input, options) {
|
||||
return {
|
||||
data: new TextEncoder().encode(`${options.width}:${options.format}:${options.quality}`),
|
||||
width: options.width,
|
||||
height: options.width / 2,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(manifest.variants).toHaveLength(4);
|
||||
expect(manifest.variants.map(({ width, format }) => `${width}:${format}`)).toEqual([
|
||||
"400:webp",
|
||||
"800:webp",
|
||||
"400:avif",
|
||||
"800:avif",
|
||||
]);
|
||||
expect(readFileSync(manifest.variants[0]!.path, "utf8")).toBe("400:webp:80");
|
||||
});
|
||||
|
||||
test("build optimizer rejects variant explosions and unsupported formats", async () => {
|
||||
const processor = { transform: async () => ({ data: new Uint8Array(), width: 1, height: 1 }) };
|
||||
await expect(
|
||||
optimizeImage("input.png", {
|
||||
outputDir: ".",
|
||||
widths: [100, 200],
|
||||
formats: ["webp", "avif"],
|
||||
maxVariants: 3,
|
||||
processor,
|
||||
}),
|
||||
).rejects.toThrow("exceeds");
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createBlurPlaceholder,
|
||||
createCdnImageLoader,
|
||||
createPathImageLoader,
|
||||
createPicture,
|
||||
imagePreload,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("image package kit", () => {
|
||||
test("creates AVIF/WebP picture plans and preload hints", () => {
|
||||
const picture = createPicture({
|
||||
src: "/hero.jpg",
|
||||
alt: "Hero",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
widths: [480, 768, 1200],
|
||||
formats: ["avif", "webp"],
|
||||
});
|
||||
expect(picture.sources.map((source) => source.type)).toEqual(["image/avif", "image/webp"]);
|
||||
expect(imagePreload(picture.image)).toContain('rel="preload"');
|
||||
});
|
||||
|
||||
test("requires HTTPS CDN endpoints and safe placeholder colors", () => {
|
||||
expect(() => createCdnImageLoader("http://images.example.test")).toThrow();
|
||||
expect(() => createBlurPlaceholder({ color: 'url("javascript:alert(1)")' })).toThrow();
|
||||
expect(createBlurPlaceholder({ color: "#fff", accent: "var(--wire-color-surface)" })).toMatch(
|
||||
/^data:image\/svg\+xml/,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps query parameters before URL fragments and rejects invalid dimensions", () => {
|
||||
const picture = createPicture({
|
||||
src: "/hero.jpg#preview",
|
||||
alt: "Hero",
|
||||
width: 640,
|
||||
height: 360,
|
||||
});
|
||||
expect(picture.image.src).toContain("?w=640");
|
||||
expect(picture.image.src.endsWith("#preview")).toBe(true);
|
||||
expect(() => createBlurPlaceholder({ width: Number.NaN })).toThrow("finite");
|
||||
});
|
||||
|
||||
test("encodes source paths in the local loader", () => {
|
||||
const loader = createPathImageLoader();
|
||||
expect(loader({ src: "/images/hero one.jpg", width: 640 })).toContain(
|
||||
encodeURIComponent("/images/hero one.jpg"),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user