cloudflare-next-intl 0.8.56 → 0.8.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -256,7 +256,7 @@ export default defineConfig({
256
256
  ```
257
257
 
258
258
  ##### What `cloudflareNextIntl()` Does
259
- 1. **Build-Time & Dev Image Optimizer (`imageOptimizer`)**: Automatically scans your image directories (`public/images`, `public/icons`), downscales oversized assets, produces modern `.avif` and `.webp` formats, generates 8px `.blur.webp` thumbnails with Next.js-matching SVG Gaussian blur placeholders, and provides transparent `<Image placeholder="blur" />` shimming via virtual modules.
259
+ 1. **Build-Time & Dev Image Optimizer (`imageOptimizer`)**: Automatically scans your image directories (`public/images`, `public/icons`), downscales oversized assets, produces sibling formats (`webp` by default; also supports `avif`, `png`, `jpeg`, `gif`, `tiff`, `heif`, `jp2`, `jxl`), generates 8px `.blur.webp` thumbnails with Next.js-matching SVG Gaussian blur placeholders, and provides transparent `<Image placeholder="blur" />` shimming via virtual modules. When more than one format is generated for an image, the shim renders a `<picture>` with one `<source>` per format — ordered exactly as configured — so the browser picks the best format it supports, with the original untouched file as an `onError` fallback if a generated asset fails to load. When the same image is used at different widths across the codebase, each size gets its own generated variant, and each `<Image>` usage automatically resolves to the closest matching size.
260
260
  2. **Locale File Bundling & Resolution (`localeFiles`)**: Resolves `@locale-file/*` to your `./messages` directory and transforms dynamic imports into `import.meta.glob('/messages/*.json', { eager: true })` for lightning-fast locale loading on Cloudflare Workers.
261
261
  3. **User-Agent Stub (`userAgentStub`)**: Prevents Next.js `user-agent` from importing `node:fs` during workerd runtime execution (which otherwise causes runtime 404 / 500 crashes in Workers proxy/middleware).
262
262
  4. **Cloudflare Workers Client Stub (`cfWorkersClientStub`)**: Stubs `cloudflare:workers` in client builds so shared modules can be referenced without client bundling errors.
@@ -274,10 +274,11 @@ export default defineConfig({
274
274
  cloudflareNextIntl({
275
275
  imageOptimizer: { // Image optimizer configuration (or `false` to disable)
276
276
  maxWidth: 1920, // Downscale max width limit (default: 1920, or `false`)
277
- formats: ["avif", "webp"], // Target sibling formats (default: ["avif", "webp"], or `false`)
277
+ formats: ["avif", "webp"], // Target sibling formats, in browser-preference order (default: ["webp"], or `false`).
278
+ // Also supports: "png", "jpeg", "gif", "tiff", "heif", "jp2", "jxl"
278
279
  quality: 80, // Compression quality (default: 80)
279
280
  blur: { quality: 70, stdDeviation: 20 }, // Next.js blur placeholder options (or `false`)
280
- overrides: { // Per-image overrides keyed by public src path
281
+ overrides: { // Per-image overrides keyed by public src path (wins over scanned <Image> props)
281
282
  "/images/hero.png": { maxWidth: false, formats: ["webp"], blur: { quality: 80 } },
282
283
  "/images/logo.png": { formats: false, blur: false },
283
284
  },
@@ -296,6 +297,53 @@ export default defineConfig({
296
297
  Individual standalone plugins are also exported if you only need a specific feature:
297
298
  `imageOptimizerPlugin` (or `imageOptimizer`), `buildIdAsset`, `localeFilePlugin`, `userAgentStubPlugin`, `cfWorkersClientStubPlugin`.
298
299
 
300
+ ##### Per-Image Optimizer Settings
301
+
302
+ Instead of (or in addition to) the centralized `overrides` config, settings can be set directly as props on individual `<Image>` usages. The build scans your JSX for these props and applies them per image — a matching `overrides` entry for the same `src` still wins if both are set:
303
+
304
+ ```tsx
305
+ import { Image } from "cloudflare-next-intl/image";
306
+
307
+ <Image src="/images/hero.png" formats={["avif", "webp"]} quality={95} blur={{ size: 16, quality: 90 }} />
308
+ <Image src="/images/icon.svg" formats={false} blur={false} maxWidth={false} />
309
+ ```
310
+
311
+ Supported props: `formats` (array or `false`), `maxWidth` (number or `false`), `quality` (number), `blur` (`true`, `false`, or `{ size, quality, stdDeviation }`).
312
+
313
+ ##### Multi-Size Variants (Responsive Images)
314
+
315
+ Every `<Image>` usage's own `width` prop is also scanned. If the same `src` is used at different widths across the codebase — a thumbnail and a hero, say — each distinct width gets its own generated variant instead of the sizes overwriting one another:
316
+
317
+ ```tsx
318
+ // components/Thumbnail.tsx
319
+ <Image src="/images/hero.png" width={200} height={150} alt="thumbnail" />
320
+
321
+ // components/Hero.tsx
322
+ <Image src="/images/hero.png" width={1200} height={900} alt="hero" />
323
+ ```
324
+
325
+ This produces two sets of generated files (`hero.webp` at the default/full-resolution size and `hero-200w.webp` for the thumbnail), and each `<Image>` call automatically resolves to the closest generated size that is at least as large as its own `width` prop — no manual `srcset` configuration needed. A width larger than any generated variant falls back to the largest one available (never upscales). This is additive to `maxWidth`/`overrides`: an explicit `maxWidth` (via prop or `overrides`) still controls the *default* variant's size, while `width` usages add extra sizes alongside it.
326
+
327
+ ##### Browser Format Negotiation
328
+
329
+ When an image's `formats` list has more than one entry, the generated `<source>` tags follow that exact order, so the browser always picks the first format in the list it can decode — no client-side JavaScript is involved:
330
+
331
+ ```tsx
332
+ <Image src="/images/hero.png" formats={["avif", "webp"]} />
333
+ ```
334
+
335
+ renders as:
336
+
337
+ ```html
338
+ <picture>
339
+ <source type="image/avif" srcset="/generated/images/hero.avif" />
340
+ <source type="image/webp" srcset="/generated/images/hero.webp" />
341
+ <img src="/generated/images/hero.avif" ... />
342
+ </picture>
343
+ ```
344
+
345
+ If the primary generated asset fails to load at runtime (e.g. a missing/corrupted build output), the `<img>` falls back to the original, unprocessed source file automatically via `onError`.
346
+
299
347
  ```tsx
300
348
  // Client Components ("use client")
301
349
  import { useLocale } from "cloudflare-next-intl/use";
@@ -4,9 +4,16 @@ import { run } from "../dist/src/image_optimizer/run.js";
4
4
  import { resolveOptions } from "../dist/src/image_optimizer/types.js";
5
5
 
6
6
  const root = process.cwd();
7
- const options = resolveOptions();
7
+ const args = process.argv.slice(2);
8
+ const onlyUsed = !args.includes("--all");
9
+ const options = resolveOptions({ onlyUsed });
8
10
  const cacheFile = path.resolve(root, options.cacheDir, "manifest.json");
9
11
 
10
- console.log("[cfni-image-optimizer] scanning images in", options.dirs.join(", "));
12
+ if (onlyUsed) {
13
+ console.log("[cfni-image-optimizer] scanning code for used <Image> references...");
14
+ } else {
15
+ console.log("[cfni-image-optimizer] scanning all images in", options.dirs.join(", "));
16
+ }
17
+
11
18
  const entries = await run(root, options, cacheFile);
12
19
  console.log(`[cfni-image-optimizer] processed ${entries.length} images into ${options.outDir}`);
@@ -0,0 +1 @@
1
+ export { default, default as Image, getImageProps, getImageBlurSvg, type ManifestEntry, } from "./image_optimizer/next_image_shim.js";
@@ -0,0 +1 @@
1
+ export { default, default as Image, getImageProps, getImageBlurSvg, } from "./image_optimizer/next_image_shim.js";
@@ -4,5 +4,6 @@ export { processImage, makeBlurDataURL, toGeneratedPath, toPublicSrc, } from "./
4
4
  export { renderManifest, writeManifest, } from "./manifest.js";
5
5
  export { isFresh, loadCache, saveCache, type CacheEntry, type CacheData, } from "./cache.js";
6
6
  export { collectImages, run, } from "./run.js";
7
+ export { collectUsedImages, findCodeFiles, extractImageReferences, CODE_EXTENSIONS, IGNORED_DIRS, } from "./scan_used.js";
7
8
  export { getImageBlurSvg, } from "./blur_svg.js";
8
9
  export { type ManifestEntry, } from "./next_image_shim.js";
@@ -4,4 +4,5 @@ export { processImage, makeBlurDataURL, toGeneratedPath, toPublicSrc, } from "./
4
4
  export { renderManifest, writeManifest, } from "./manifest.js";
5
5
  export { isFresh, loadCache, saveCache, } from "./cache.js";
6
6
  export { collectImages, run, } from "./run.js";
7
+ export { collectUsedImages, findCodeFiles, extractImageReferences, CODE_EXTENSIONS, IGNORED_DIRS, } from "./scan_used.js";
7
8
  export { getImageBlurSvg, } from "./blur_svg.js";
@@ -1,7 +1,24 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import NextImage, { getImageProps as nextGetImageProps } from "next/image";
3
3
  import manifest from "virtual:cloudflare-next-intl-images-manifest";
4
4
  import { getImageBlurSvg } from "./blur_svg.js";
5
+ /**
6
+ * When an image was generated at multiple widths (because it's used at
7
+ * different sizes across the codebase), picks the variant whose width is
8
+ * closest to what this particular <Image> usage requested — preferring the
9
+ * smallest variant that is still >= the requested width, to avoid upscaling
10
+ * visible blur, and falling back to the largest available otherwise.
11
+ */
12
+ function pickVariant(entry, requestedWidth) {
13
+ if (!requestedWidth || !entry.variants || entry.variants.length === 0) {
14
+ return entry;
15
+ }
16
+ const atLeast = entry.variants.filter((v) => v.width >= requestedWidth);
17
+ if (atLeast.length > 0) {
18
+ return atLeast.reduce((best, v) => (v.width < best.width ? v : best));
19
+ }
20
+ return entry.variants.reduce((best, v) => (v.width > best.width ? v : best));
21
+ }
5
22
  const manifestData = manifest;
6
23
  const images = (manifestData && typeof manifestData === "object" && manifestData.images)
7
24
  ? manifestData.images
@@ -33,6 +50,16 @@ function findEntry(srcVal) {
33
50
  }
34
51
  return undefined;
35
52
  }
53
+ /** Swap the format extension of every URL in a generated srcset. */
54
+ function retargetSrcSet(srcSet, fromSrc, toSrc) {
55
+ if (!srcSet)
56
+ return srcSet;
57
+ const fromEncoded = encodeURIComponent(fromSrc);
58
+ const toEncoded = encodeURIComponent(toSrc);
59
+ return srcSet
60
+ .split(fromEncoded).join(toEncoded)
61
+ .split(fromSrc).join(toSrc);
62
+ }
36
63
  function resolveProps(props) {
37
64
  let src = props.src;
38
65
  let blurDataURL = props.blurDataURL;
@@ -41,23 +68,24 @@ function resolveProps(props) {
41
68
  let style = props.style;
42
69
  const entry = findEntry(src);
43
70
  if (entry) {
44
- if (entry.src) {
71
+ const variant = pickVariant(entry, typeof props.width === "number" ? props.width : undefined);
72
+ if (variant.src) {
45
73
  if (typeof src === "string") {
46
- src = entry.src;
74
+ src = variant.src;
47
75
  }
48
76
  else if (typeof src === "object" && src !== null && "src" in src) {
49
- src = { ...src, src: entry.src };
77
+ src = { ...src, src: variant.src };
50
78
  }
51
79
  else {
52
- src = entry.src;
80
+ src = variant.src;
53
81
  }
54
82
  }
55
- if (!blurDataURL && props.placeholder === "blur" && entry.blurDataURL) {
56
- blurDataURL = getImageBlurSvg(entry.blurDataURL, entry.blurWidth, entry.blurHeight, props.style?.objectFit);
83
+ if (!blurDataURL && props.placeholder === "blur" && variant.blurDataURL) {
84
+ blurDataURL = getImageBlurSvg(variant.blurDataURL, variant.blurWidth, variant.blurHeight, props.style?.objectFit);
57
85
  }
58
- if (!width && !props.fill && entry.width) {
59
- width = entry.width;
60
- height = entry.height;
86
+ if (!width && !props.fill && variant.width) {
87
+ width = variant.width;
88
+ height = variant.height;
61
89
  }
62
90
  }
63
91
  if (props.placeholder === "blur" && blurDataURL) {
@@ -74,7 +102,27 @@ function resolveProps(props) {
74
102
  }
75
103
  export default function Image(props) {
76
104
  const resolved = resolveProps(props);
77
- return _jsx(NextImage, { ...resolved });
105
+ const entry = findEntry(props.src);
106
+ const variant = entry
107
+ ? pickVariant(entry, typeof props.width === "number" ? props.width : undefined)
108
+ : undefined;
109
+ const alternates = (variant?.sources ?? []).filter((source) => source.src !== variant?.src);
110
+ if (alternates.length === 0) {
111
+ return _jsx(NextImage, { ...resolved });
112
+ }
113
+ const { props: imgProps } = nextGetImageProps(resolved);
114
+ const primarySrc = variant?.src ?? String(imgProps.src);
115
+ const originalSrc = entry?.originalSrc;
116
+ const onError = (event) => {
117
+ const img = event.currentTarget;
118
+ if (originalSrc && img.src !== originalSrc && !img.src.endsWith(originalSrc)) {
119
+ img.srcset = "";
120
+ img.src = originalSrc;
121
+ }
122
+ };
123
+ return (_jsxs("picture", { children: [alternates.map((source) => (_jsx("source", { type: source.type, sizes: imgProps.sizes, srcSet: imgProps.srcSet
124
+ ? retargetSrcSet(imgProps.srcSet, primarySrc, source.src)
125
+ : source.src }, source.src))), _jsx("img", { ...imgProps, onError: onError })] }));
78
126
  }
79
127
  export function getImageProps(props) {
80
128
  const resolved = resolveProps(props);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ import { bench, describe } from "vitest";
2
+ import sharp from "sharp";
3
+ import { makeTempDir, writeFixtureJpg, writeFixturePng } from "../test_utils/image_optimizer_test_helpers.js";
4
+ import { processImage } from "./process_image.js";
5
+ import { resolveOptions } from "./types.js";
6
+ const root = await makeTempDir();
7
+ const smallPng = await writeFixturePng(root, "small.png", 200, 150);
8
+ const largePng = await writeFixturePng(root, "large.png", 2400, 1600);
9
+ const photoJpg = await writeFixtureJpg(root, "photo.jpg", 1600, 1200);
10
+ const largeBuffer = await sharp(largePng).toBuffer();
11
+ const photoBuffer = await sharp(photoJpg).toBuffer();
12
+ describe("avif encode: effort trade-off", () => {
13
+ bench("effort 0 (fastest, default quality)", async () => {
14
+ await sharp(photoBuffer).avif({ quality: 80, effort: 0 }).toBuffer();
15
+ });
16
+ bench("effort 4 (sharp default)", async () => {
17
+ await sharp(photoBuffer).avif({ quality: 80 }).toBuffer();
18
+ });
19
+ bench("effort 9 (max compression, slowest)", async () => {
20
+ await sharp(photoBuffer).avif({ quality: 80, effort: 9 }).toBuffer();
21
+ });
22
+ });
23
+ describe("webp encode: effort trade-off", () => {
24
+ bench("effort 0 (fastest)", async () => {
25
+ await sharp(photoBuffer).webp({ quality: 80, effort: 0 }).toBuffer();
26
+ });
27
+ bench("effort 4 (sharp default)", async () => {
28
+ await sharp(photoBuffer).webp({ quality: 80 }).toBuffer();
29
+ });
30
+ bench("effort 6 (max compression, slowest)", async () => {
31
+ await sharp(photoBuffer).webp({ quality: 80, effort: 6 }).toBuffer();
32
+ });
33
+ });
34
+ describe("resize kernel trade-off (downscale 2400x1600 -> 800)", () => {
35
+ bench("kernel: nearest (fastest, lowest quality)", async () => {
36
+ await sharp(largeBuffer).resize({ width: 800, kernel: "nearest" }).webp({ quality: 80 }).toBuffer();
37
+ });
38
+ bench("kernel: lanczos3 (sharp default)", async () => {
39
+ await sharp(largeBuffer).resize({ width: 800 }).webp({ quality: 80 }).toBuffer();
40
+ });
41
+ bench("kernel: mitchell (mid-quality)", async () => {
42
+ await sharp(largeBuffer).resize({ width: 800, kernel: "mitchell" }).webp({ quality: 80 }).toBuffer();
43
+ });
44
+ });
45
+ describe("mozjpeg vs baseline jpeg encode", () => {
46
+ bench("baseline jpeg", async () => {
47
+ await sharp(photoBuffer).jpeg({ quality: 80 }).toBuffer();
48
+ });
49
+ bench("mozjpeg (current production setting)", async () => {
50
+ await sharp(photoBuffer).jpeg({ quality: 80, mozjpeg: true }).toBuffer();
51
+ });
52
+ });
53
+ describe("processImage full pipeline (single format + blur)", () => {
54
+ bench("small image (200x150)", async () => {
55
+ await processImage(smallPng, root, resolveOptions({ formats: ["webp"] }), root);
56
+ });
57
+ bench("large image requiring downscale (2400x1600 -> 1920)", async () => {
58
+ await processImage(largePng, root, resolveOptions({ formats: ["webp"] }), root);
59
+ });
60
+ });
61
+ describe("processImage: blur enabled vs disabled overhead", () => {
62
+ bench("with blur placeholder", async () => {
63
+ await processImage(photoJpg, root, resolveOptions({ formats: ["webp"], blur: true }), root);
64
+ });
65
+ bench("without blur placeholder", async () => {
66
+ await processImage(photoJpg, root, resolveOptions({ formats: ["webp"], blur: false }), root);
67
+ });
68
+ });
@@ -1,9 +1,18 @@
1
- import type { OptimizedImage, ResolvedBlurOptions, ResolvedOptions } from "./types.js";
1
+ import type { ImageFormat, OptimizedImage, OptimizedImageSource, ResolvedBlurOptions, ResolvedOptions } from "./types.js";
2
+ export declare const EXTENSION_BY_FORMAT: Record<ImageFormat, string>;
3
+ export declare function mimeTypeFor(format: ImageFormat | "original", originalSrc: string): string;
4
+ /**
5
+ * <picture> tries <source> tags in document order, so sources must follow the
6
+ * user's own `formats` order (their priority) with "original" always last as fallback.
7
+ */
8
+ export declare function sortSources(sources: OptimizedImageSource[], formats: ImageFormat[]): OptimizedImageSource[];
2
9
  export declare function toPublicSrc(absolutePath: string, publicRoot: string): string;
3
10
  export declare function toGeneratedPath(absolutePath: string, publicRoot: string, outDir: string, root: string): {
4
11
  targetFile: string;
5
12
  targetSrc: string;
6
13
  };
14
+ /** Suffixes a generated file/src path with `-{width}w` so multiple widths of the same image don't collide, e.g. hero.webp -> hero-400w.webp. The default (first/primary) width keeps the unsuffixed name for backward compatibility. */
15
+ export declare function withWidthSuffix(pathStr: string, width: number, isDefault: boolean): string;
7
16
  export declare function makeBlurDataURL(targetFile: string, sourceWidth: number, sourceHeight: number, blurOptions: ResolvedBlurOptions): Promise<{
8
17
  blurDataURL: string;
9
18
  blurWidth: number;
@@ -2,6 +2,56 @@ import { mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import sharp from "sharp";
4
4
  import { resolveImageConfig } from "./types.js";
5
+ const MIME_BY_FORMAT = {
6
+ avif: "image/avif",
7
+ webp: "image/webp",
8
+ png: "image/png",
9
+ jpeg: "image/jpeg",
10
+ gif: "image/gif",
11
+ tiff: "image/tiff",
12
+ heif: "image/heif",
13
+ jp2: "image/jp2",
14
+ jxl: "image/jxl",
15
+ };
16
+ const MIME_BY_EXTENSION = {
17
+ ".avif": "image/avif",
18
+ ".webp": "image/webp",
19
+ ".png": "image/png",
20
+ ".jpg": "image/jpeg",
21
+ ".jpeg": "image/jpeg",
22
+ ".gif": "image/gif",
23
+ ".tif": "image/tiff",
24
+ ".tiff": "image/tiff",
25
+ ".heif": "image/heif",
26
+ ".heic": "image/heif",
27
+ ".jp2": "image/jp2",
28
+ ".jxl": "image/jxl",
29
+ };
30
+ export const EXTENSION_BY_FORMAT = {
31
+ avif: "avif",
32
+ webp: "webp",
33
+ png: "png",
34
+ jpeg: "jpg",
35
+ gif: "gif",
36
+ tiff: "tiff",
37
+ heif: "heif",
38
+ jp2: "jp2",
39
+ jxl: "jxl",
40
+ };
41
+ export function mimeTypeFor(format, originalSrc) {
42
+ if (format === "original") {
43
+ return MIME_BY_EXTENSION[path.extname(originalSrc).toLowerCase()] ?? "image/jpeg";
44
+ }
45
+ return MIME_BY_FORMAT[format];
46
+ }
47
+ /**
48
+ * <picture> tries <source> tags in document order, so sources must follow the
49
+ * user's own `formats` order (their priority) with "original" always last as fallback.
50
+ */
51
+ export function sortSources(sources, formats) {
52
+ const priority = [...formats, "original"];
53
+ return [...sources].sort((a, b) => priority.indexOf(a.format) - priority.indexOf(b.format));
54
+ }
5
55
  export function toPublicSrc(absolutePath, publicRoot) {
6
56
  const relative = path.relative(publicRoot, absolutePath);
7
57
  return `/${relative.split(path.sep).join("/")}`;
@@ -14,6 +64,12 @@ export function toGeneratedPath(absolutePath, publicRoot, outDir, root) {
14
64
  const targetSrc = `/${path.join(outDirRelativePublic, relative).split(path.sep).join("/")}`;
15
65
  return { targetFile, targetSrc };
16
66
  }
67
+ /** Suffixes a generated file/src path with `-{width}w` so multiple widths of the same image don't collide, e.g. hero.webp -> hero-400w.webp. The default (first/primary) width keeps the unsuffixed name for backward compatibility. */
68
+ export function withWidthSuffix(pathStr, width, isDefault) {
69
+ if (isDefault)
70
+ return pathStr;
71
+ return pathStr.replace(/(\.[^./]+)$/, `-${width}w$1`);
72
+ }
17
73
  export async function makeBlurDataURL(targetFile, sourceWidth, sourceHeight, blurOptions) {
18
74
  const blurFile = targetFile.replace(/\.[^.]+$/, ".blur.webp");
19
75
  let blurWidth;
@@ -37,16 +93,89 @@ export async function makeBlurDataURL(targetFile, sourceWidth, sourceHeight, blu
37
93
  blurHeight,
38
94
  };
39
95
  }
40
- async function encodeSibling(targetFile, sourcePath, format, quality, maxWidth, needsResize) {
41
- const target = targetFile.replace(/\.[^.]+$/, `.${format}`);
96
+ async function encodeFormat(targetFile, sourcePath, format, quality, targetWidth) {
42
97
  let pipeline = sharp(sourcePath);
43
- if (needsResize) {
44
- pipeline = pipeline.resize({ width: maxWidth });
98
+ if (targetWidth !== undefined) {
99
+ pipeline = pipeline.resize({ width: targetWidth });
100
+ }
101
+ let encoded;
102
+ if (format === "avif") {
103
+ encoded = pipeline.avif({ quality });
104
+ }
105
+ else if (format === "webp") {
106
+ encoded = pipeline.webp({ quality });
107
+ }
108
+ else if (format === "png") {
109
+ encoded = pipeline.png({ quality, compressionLevel: 9 });
110
+ }
111
+ else if (format === "jpeg") {
112
+ encoded = pipeline.jpeg({ quality, mozjpeg: true });
113
+ }
114
+ else if (format === "gif") {
115
+ encoded = pipeline.gif();
116
+ }
117
+ else if (format === "tiff") {
118
+ encoded = pipeline.tiff({ quality });
119
+ }
120
+ else if (format === "heif") {
121
+ encoded = pipeline.heif({ quality, compression: "hevc" });
122
+ }
123
+ else if (format === "jp2") {
124
+ encoded = pipeline.jp2({ quality });
125
+ }
126
+ else if (format === "jxl") {
127
+ encoded = pipeline.jxl({ quality });
128
+ }
129
+ else {
130
+ const ext = path.extname(sourcePath).toLowerCase();
131
+ encoded = ext === ".png"
132
+ ? pipeline.png({ quality, compressionLevel: 9 })
133
+ : pipeline.jpeg({ quality, mozjpeg: true });
134
+ }
135
+ await encoded.toFile(targetFile);
136
+ }
137
+ /** Resolves one requested width against the source dimensions: `undefined` means "use source size" (no resize), a number is clamped to not exceed the source width (never upscale). */
138
+ function resolveTargetWidth(requestedWidth, sourceWidth) {
139
+ if (requestedWidth === false)
140
+ return undefined;
141
+ return requestedWidth < sourceWidth ? requestedWidth : undefined;
142
+ }
143
+ async function processVariant(absolutePath, publicSrc, targetFile, targetSrc, targetWidth, sourceWidth, sourceHeight, config, isDefault) {
144
+ const width = targetWidth ?? sourceWidth;
145
+ const height = targetWidth ? Math.round((sourceHeight * targetWidth) / sourceWidth) : sourceHeight;
146
+ const primaryFormat = config.formats.length > 0
147
+ ? config.formats[0]
148
+ : "original";
149
+ const primaryFile = withWidthSuffix(primaryFormat === "original" ? targetFile : targetFile.replace(/\.[^.]+$/, `.${EXTENSION_BY_FORMAT[primaryFormat]}`), width, isDefault);
150
+ const primarySrc = withWidthSuffix(primaryFormat === "original" ? targetSrc : targetSrc.replace(/\.[^.]+$/, `.${EXTENSION_BY_FORMAT[primaryFormat]}`), width, isDefault);
151
+ await encodeFormat(primaryFile, absolutePath, primaryFormat, config.quality, targetWidth);
152
+ const sources = [
153
+ { format: primaryFormat, src: primarySrc, type: mimeTypeFor(primaryFormat, publicSrc) },
154
+ ];
155
+ for (let i = 1; i < config.formats.length; i++) {
156
+ const format = config.formats[i];
157
+ const ext = EXTENSION_BY_FORMAT[format];
158
+ const siblingFile = withWidthSuffix(targetFile.replace(/\.[^.]+$/, `.${ext}`), width, isDefault);
159
+ await encodeFormat(siblingFile, absolutePath, format, config.quality, targetWidth);
160
+ sources.push({
161
+ format,
162
+ src: withWidthSuffix(targetSrc.replace(/\.[^.]+$/, `.${ext}`), width, isDefault),
163
+ type: mimeTypeFor(format, publicSrc),
164
+ });
45
165
  }
46
- const encoded = format === "avif"
47
- ? pipeline.avif({ quality })
48
- : pipeline.webp({ quality });
49
- await encoded.toFile(target);
166
+ let blurResult;
167
+ if (config.blur.enabled) {
168
+ blurResult = await makeBlurDataURL(primaryFile, width, height, config.blur);
169
+ }
170
+ return {
171
+ width,
172
+ height,
173
+ src: primarySrc,
174
+ sources: sortSources(sources, config.formats),
175
+ blurDataURL: blurResult?.blurDataURL,
176
+ blurWidth: blurResult?.blurWidth,
177
+ blurHeight: blurResult?.blurHeight,
178
+ };
50
179
  }
51
180
  export async function processImage(absolutePath, publicRoot, options, root = path.dirname(publicRoot)) {
52
181
  const publicSrc = toPublicSrc(absolutePath, publicRoot);
@@ -54,36 +183,21 @@ export async function processImage(absolutePath, publicRoot, options, root = pat
54
183
  const metadata = await sharp(absolutePath).metadata();
55
184
  const sourceWidth = metadata.width;
56
185
  const sourceHeight = metadata.height;
57
- const needsResize = typeof config.maxWidth === "number" && sourceWidth > config.maxWidth;
58
- const width = needsResize ? config.maxWidth : sourceWidth;
59
- const height = needsResize
60
- ? Math.round((sourceHeight * config.maxWidth) / sourceWidth)
61
- : sourceHeight;
62
186
  const { targetFile, targetSrc } = toGeneratedPath(absolutePath, publicRoot, options.outDir, root);
63
187
  await mkdir(path.dirname(targetFile), { recursive: true });
64
- let pipeline = sharp(absolutePath);
65
- if (needsResize) {
66
- pipeline = pipeline.resize({ width: config.maxWidth });
67
- }
68
- const extension = path.extname(absolutePath).toLowerCase();
69
- const encoded = extension === ".png"
70
- ? pipeline.png({ quality: config.quality, compressionLevel: 9 })
71
- : pipeline.jpeg({ quality: config.quality, mozjpeg: true });
72
- await encoded.toFile(targetFile);
73
- for (const format of config.formats) {
74
- await encodeSibling(targetFile, absolutePath, format, config.quality, config.maxWidth, needsResize);
75
- }
76
- let blurResult;
77
- if (config.blur.enabled) {
78
- blurResult = await makeBlurDataURL(targetFile, width, height, config.blur);
188
+ const defaultTargetWidth = resolveTargetWidth(config.maxWidth, sourceWidth);
189
+ const requestedWidths = Array.from(new Set(config.extraWidths));
190
+ const extraTargetWidths = requestedWidths
191
+ .map((w) => resolveTargetWidth(w, sourceWidth) ?? sourceWidth)
192
+ .filter((w) => w !== (defaultTargetWidth ?? sourceWidth));
193
+ const defaultVariant = await processVariant(absolutePath, publicSrc, targetFile, targetSrc, defaultTargetWidth, sourceWidth, sourceHeight, config, true);
194
+ const variants = [defaultVariant];
195
+ for (const width of extraTargetWidths) {
196
+ variants.push(await processVariant(absolutePath, publicSrc, targetFile, targetSrc, width, sourceWidth, sourceHeight, config, false));
79
197
  }
80
198
  return {
81
199
  originalSrc: publicSrc,
82
- src: targetSrc,
83
- width,
84
- height,
85
- blurDataURL: blurResult?.blurDataURL,
86
- blurWidth: blurResult?.blurWidth,
87
- blurHeight: blurResult?.blurHeight,
200
+ ...defaultVariant,
201
+ variants: variants.length > 1 ? variants : undefined,
88
202
  };
89
203
  }
@@ -1,3 +1,10 @@
1
- import type { OptimizedImage, ResolvedOptions } from "./types.js";
1
+ import type { ImageOverrideOptions, OptimizedImage, ResolvedOptions } from "./types.js";
2
2
  export declare function collectImages(dirs: string[], root: string): Promise<string[]>;
3
+ export declare function targetAndSiblingPaths(absolutePath: string, publicRoot: string, options: ResolvedOptions, root: string): Promise<string[]>;
4
+ /**
5
+ * Merges optimizer overrides scanned from <Image> JSX props with the plugin's
6
+ * centralized `overrides` config, so settings can live at the usage site. An
7
+ * explicit config override for a given src still wins over a scanned one.
8
+ */
9
+ export declare function mergeOverrides(scanned: Record<string, ImageOverrideOptions>, configured: Record<string, ImageOverrideOptions>): Record<string, ImageOverrideOptions>;
3
10
  export declare function run(root: string, options: ResolvedOptions, cacheFile?: string): Promise<OptimizedImage[]>;
@@ -1,8 +1,10 @@
1
1
  import { readdir, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import sharp from "sharp";
3
4
  import { isFresh, loadCache, saveCache } from "./cache.js";
4
5
  import { writeManifest } from "./manifest.js";
5
- import { processImage, toGeneratedPath, toPublicSrc } from "./process_image.js";
6
+ import { EXTENSION_BY_FORMAT, processImage, toGeneratedPath, toPublicSrc, withWidthSuffix } from "./process_image.js";
7
+ import { collectUsedImageOverrides, collectUsedImages } from "./scan_used.js";
6
8
  import { resolveImageConfig, SUPPORTED_EXTENSIONS } from "./types.js";
7
9
  async function walk(directory, found) {
8
10
  let items;
@@ -30,35 +32,91 @@ export async function collectImages(dirs, root) {
30
32
  }
31
33
  return found.sort();
32
34
  }
33
- function targetAndSiblingPaths(absolutePath, publicRoot, options, root) {
35
+ function variantTargetPaths(targetFile, config, width, isDefault) {
36
+ const primaryFormat = config.formats.length > 0
37
+ ? config.formats[0]
38
+ : "original";
39
+ const primaryExt = primaryFormat === "original" ? undefined : EXTENSION_BY_FORMAT[primaryFormat];
40
+ const primaryFile = withWidthSuffix(primaryExt ? targetFile.replace(/\.[^.]+$/, `.${primaryExt}`) : targetFile, width, isDefault);
41
+ const result = [primaryFile];
42
+ for (let i = 1; i < config.formats.length; i++) {
43
+ const ext = EXTENSION_BY_FORMAT[config.formats[i]];
44
+ result.push(withWidthSuffix(targetFile.replace(/\.[^.]+$/, `.${ext}`), width, isDefault));
45
+ }
46
+ if (config.blur.enabled) {
47
+ result.push(primaryFile.replace(/\.[^.]+$/, ".blur.webp"));
48
+ }
49
+ return result;
50
+ }
51
+ export async function targetAndSiblingPaths(absolutePath, publicRoot, options, root) {
34
52
  const publicSrc = toPublicSrc(absolutePath, publicRoot);
35
53
  const config = resolveImageConfig(publicSrc, options);
36
54
  const { targetFile } = toGeneratedPath(absolutePath, publicRoot, options.outDir, root);
37
- const siblings = config.formats.map((format) => targetFile.replace(/\.[^.]+$/, `.${format}`));
38
- const result = [targetFile, ...siblings];
39
- if (config.blur.enabled) {
40
- result.push(targetFile.replace(/\.[^.]+$/, ".blur.webp"));
55
+ const metadata = await sharp(absolutePath).metadata();
56
+ const sourceWidth = metadata.width;
57
+ const defaultWidth = config.maxWidth !== false && config.maxWidth < sourceWidth
58
+ ? config.maxWidth
59
+ : sourceWidth;
60
+ const result = variantTargetPaths(targetFile, config, defaultWidth, true);
61
+ const extraWidths = Array.from(new Set(config.extraWidths))
62
+ .map((w) => (w < sourceWidth ? w : sourceWidth))
63
+ .filter((w) => w !== defaultWidth);
64
+ for (const width of extraWidths) {
65
+ result.push(...variantTargetPaths(targetFile, config, width, false));
41
66
  }
42
67
  return result;
43
68
  }
69
+ /**
70
+ * Merges optimizer overrides scanned from <Image> JSX props with the plugin's
71
+ * centralized `overrides` config, so settings can live at the usage site. An
72
+ * explicit config override for a given src still wins over a scanned one.
73
+ */
74
+ export function mergeOverrides(scanned, configured) {
75
+ const merged = { ...scanned };
76
+ for (const [src, override] of Object.entries(configured)) {
77
+ const existing = merged[src];
78
+ const mergedWidths = existing?.extraWidths || override.extraWidths
79
+ ? Array.from(new Set([...(existing?.extraWidths ?? []), ...(override.extraWidths ?? [])]))
80
+ : undefined;
81
+ merged[src] = { ...existing, ...override };
82
+ if (mergedWidths) {
83
+ merged[src].extraWidths = mergedWidths;
84
+ }
85
+ }
86
+ return merged;
87
+ }
44
88
  export async function run(root, options, cacheFile = path.resolve(root, options.cacheDir, "manifest.json")) {
45
89
  const publicRoot = path.resolve(root, "public");
46
90
  const manifestPath = path.resolve(root, options.manifest);
47
91
  const cache = await loadCache(cacheFile);
48
92
  const nextCache = {};
49
- const files = await collectImages(options.dirs, root);
93
+ let files = [];
94
+ if (options.onlyUsed) {
95
+ files = await collectUsedImages(root, "public");
96
+ if (files.length === 0) {
97
+ files = await collectImages(options.dirs, root);
98
+ }
99
+ }
100
+ else {
101
+ files = await collectImages(options.dirs, root);
102
+ }
103
+ const scannedOverrides = await collectUsedImageOverrides(root, "public");
104
+ const resolvedOptions = {
105
+ ...options,
106
+ overrides: mergeOverrides(scannedOverrides, options.overrides),
107
+ };
50
108
  const entries = [];
51
109
  for (const file of files) {
52
110
  const relativeKey = path.relative(root, file);
53
111
  const cached = cache[relativeKey];
54
- const targets = targetAndSiblingPaths(file, publicRoot, options, root);
112
+ const targets = await targetAndSiblingPaths(file, publicRoot, resolvedOptions, root);
55
113
  const fresh = await isFresh(file, cached, targets);
56
114
  if (fresh && cached) {
57
115
  entries.push(cached.result);
58
116
  nextCache[relativeKey] = cached;
59
117
  continue;
60
118
  }
61
- const result = await processImage(file, publicRoot, options, root);
119
+ const result = await processImage(file, publicRoot, resolvedOptions, root);
62
120
  const fileStat = await stat(file);
63
121
  entries.push(result);
64
122
  nextCache[relativeKey] = {
@@ -0,0 +1,23 @@
1
+ import type { ImageOverrideOptions } from "./types.js";
2
+ export declare const CODE_EXTENSIONS: readonly string[];
3
+ export declare const IGNORED_DIRS: ReadonlySet<string>;
4
+ export declare function findCodeFiles(dir: string, found?: string[]): Promise<string[]>;
5
+ export declare function extractImageReferences(code: string): string[];
6
+ /**
7
+ * Scans JSX/TSX source for <Image> tags carrying per-image optimizer props
8
+ * (formats / blur / quality / maxWidth) and turns them into override entries
9
+ * keyed by the tag's own src, so settings can live next to usage instead of
10
+ * only in the plugin's centralized `overrides` config. Every tag's own
11
+ * `width` prop is also collected into `extraWidths`, merged across all usages
12
+ * of the same src, so the same image used at different sizes (a thumbnail and
13
+ * a hero, say) gets a separate generated variant for each size instead of one
14
+ * usage's width silently overwriting another's.
15
+ */
16
+ export declare function extractImageOverrides(code: string): Record<string, ImageOverrideOptions>;
17
+ /**
18
+ * Scans project source for <Image> tags with per-image optimizer props
19
+ * (formats / blur / quality / maxWidth) and returns them keyed by public src,
20
+ * in the same shape as the plugin's `overrides` config option.
21
+ */
22
+ export declare function collectUsedImageOverrides(root: string, publicDir?: string): Promise<Record<string, ImageOverrideOptions>>;
23
+ export declare function collectUsedImages(root: string, publicDir?: string): Promise<string[]>;
@@ -0,0 +1,248 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ export const CODE_EXTENSIONS = [
4
+ ".tsx",
5
+ ".ts",
6
+ ".jsx",
7
+ ".js",
8
+ ".mjs",
9
+ ".cjs",
10
+ ".astro",
11
+ ".vue",
12
+ ".svelte",
13
+ ".mdx",
14
+ ".html",
15
+ ];
16
+ export const IGNORED_DIRS = new Set([
17
+ "node_modules",
18
+ ".git",
19
+ ".next",
20
+ ".vinext",
21
+ "dist",
22
+ "build",
23
+ "coverage",
24
+ ".cache",
25
+ ".skeleton-diff",
26
+ "generated",
27
+ ]);
28
+ export async function findCodeFiles(dir, found = []) {
29
+ let items;
30
+ try {
31
+ items = await readdir(dir, { withFileTypes: true });
32
+ }
33
+ catch {
34
+ return found;
35
+ }
36
+ for (const item of items) {
37
+ if (IGNORED_DIRS.has(item.name))
38
+ continue;
39
+ const full = path.join(dir, item.name);
40
+ if (item.isDirectory()) {
41
+ await findCodeFiles(full, found);
42
+ }
43
+ else if (CODE_EXTENSIONS.includes(path.extname(item.name).toLowerCase())) {
44
+ found.push(full);
45
+ }
46
+ }
47
+ return found;
48
+ }
49
+ export function extractImageReferences(code) {
50
+ const refs = new Set();
51
+ const pattern = /(?:["'`])([^"'`\s\n\r#?]+\.(?:png|jpg|jpeg|webp|avif))(?:\?[^"'`\s]*|#[^"'`\s]*)?(?:["'`])/gi;
52
+ let match;
53
+ while ((match = pattern.exec(code)) !== null) {
54
+ const raw = match[1];
55
+ if (raw)
56
+ refs.add(raw);
57
+ }
58
+ return Array.from(refs);
59
+ }
60
+ /** Merges one override into a map by src, unioning `extraWidths` instead of letting the later usage overwrite the earlier one. */
61
+ function mergeOverrideInto(map, src, override) {
62
+ const existing = map[src];
63
+ const mergedWidths = existing?.extraWidths || override.extraWidths
64
+ ? Array.from(new Set([...(existing?.extraWidths ?? []), ...(override.extraWidths ?? [])]))
65
+ : undefined;
66
+ map[src] = { ...existing, ...override };
67
+ if (mergedWidths) {
68
+ map[src].extraWidths = mergedWidths;
69
+ }
70
+ }
71
+ const IMAGE_FORMATS = [
72
+ "avif", "webp", "png", "jpeg", "gif", "tiff", "heif", "jp2", "jxl",
73
+ ];
74
+ function parseFormatsAttr(raw) {
75
+ const trimmed = raw.trim();
76
+ if (trimmed === "false")
77
+ return false;
78
+ const values = trimmed.match(/[a-z0-9]+/gi) ?? [];
79
+ const formats = values
80
+ .map((v) => v.toLowerCase())
81
+ .filter((v) => IMAGE_FORMATS.includes(v));
82
+ return formats.length > 0 ? formats : undefined;
83
+ }
84
+ function parseNumberAttr(raw) {
85
+ const value = Number(raw.trim().replace(/[{}]/g, ""));
86
+ return Number.isFinite(value) ? value : undefined;
87
+ }
88
+ function parseBlurAttr(raw) {
89
+ const trimmed = raw.trim();
90
+ if (trimmed === "false")
91
+ return false;
92
+ if (trimmed === "true")
93
+ return true;
94
+ const blur = {};
95
+ const size = trimmed.match(/size\s*:\s*(\d+)/);
96
+ const quality = trimmed.match(/quality\s*:\s*(\d+)/);
97
+ const stdDeviation = trimmed.match(/stdDeviation\s*:\s*(\d+)/);
98
+ if (size)
99
+ blur.size = Number(size[1]);
100
+ if (quality)
101
+ blur.quality = Number(quality[1]);
102
+ if (stdDeviation)
103
+ blur.stdDeviation = Number(stdDeviation[1]);
104
+ return Object.keys(blur).length > 0 ? blur : undefined;
105
+ }
106
+ /**
107
+ * Scans JSX/TSX source for <Image> tags carrying per-image optimizer props
108
+ * (formats / blur / quality / maxWidth) and turns them into override entries
109
+ * keyed by the tag's own src, so settings can live next to usage instead of
110
+ * only in the plugin's centralized `overrides` config. Every tag's own
111
+ * `width` prop is also collected into `extraWidths`, merged across all usages
112
+ * of the same src, so the same image used at different sizes (a thumbnail and
113
+ * a hero, say) gets a separate generated variant for each size instead of one
114
+ * usage's width silently overwriting another's.
115
+ */
116
+ export function extractImageOverrides(code) {
117
+ const overrides = {};
118
+ const tagPattern = /<Image\b([^>]*)\/?>/gs;
119
+ let tagMatch;
120
+ while ((tagMatch = tagPattern.exec(code)) !== null) {
121
+ const attrs = tagMatch[1];
122
+ const srcMatch = attrs.match(/\bsrc\s*=\s*(?:["'`]([^"'`]+)["'`]|\{["'`]([^"'`]+)["'`]\})/);
123
+ if (!srcMatch)
124
+ continue;
125
+ const src = (srcMatch[1] ?? srcMatch[2]).split("?")[0].split("#")[0];
126
+ const override = {};
127
+ const formatsMatch = attrs.match(/\bformats\s*=\s*\{([^}]*)\}/);
128
+ if (formatsMatch) {
129
+ const parsed = parseFormatsAttr(formatsMatch[1]);
130
+ if (parsed !== undefined)
131
+ override.formats = parsed;
132
+ }
133
+ const maxWidthMatch = attrs.match(/\bmaxWidth\s*=\s*\{([^}]*)\}/);
134
+ if (maxWidthMatch) {
135
+ const trimmed = maxWidthMatch[1].trim();
136
+ const parsed = trimmed === "false" ? false : parseNumberAttr(trimmed);
137
+ if (parsed !== undefined)
138
+ override.maxWidth = parsed;
139
+ }
140
+ const widthMatch = attrs.match(/\bwidth\s*=\s*\{?(\d+)\}?/);
141
+ if (widthMatch) {
142
+ override.extraWidths = [Number(widthMatch[1])];
143
+ }
144
+ const qualityMatch = attrs.match(/\bquality\s*=\s*\{?(\d+)\}?/);
145
+ if (qualityMatch) {
146
+ override.quality = Number(qualityMatch[1]);
147
+ }
148
+ const blurMatch = attrs.match(/\bblur\s*=\s*\{([^}]*)\}/);
149
+ if (blurMatch) {
150
+ const parsed = parseBlurAttr(blurMatch[1]);
151
+ if (parsed !== undefined)
152
+ override.blur = parsed;
153
+ }
154
+ if (Object.keys(override).length > 0) {
155
+ mergeOverrideInto(overrides, src, override);
156
+ }
157
+ }
158
+ return overrides;
159
+ }
160
+ async function collectCodeFiles(root, publicDir) {
161
+ let rootItems;
162
+ try {
163
+ rootItems = await readdir(root, { withFileTypes: true });
164
+ }
165
+ catch {
166
+ return [];
167
+ }
168
+ const codeFiles = [];
169
+ for (const item of rootItems) {
170
+ if (IGNORED_DIRS.has(item.name))
171
+ continue;
172
+ if (item.name === publicDir)
173
+ continue;
174
+ const full = path.join(root, item.name);
175
+ if (item.isDirectory()) {
176
+ await findCodeFiles(full, codeFiles);
177
+ }
178
+ else if (CODE_EXTENSIONS.includes(path.extname(item.name).toLowerCase())) {
179
+ codeFiles.push(full);
180
+ }
181
+ }
182
+ return codeFiles;
183
+ }
184
+ function resolvePublicSrc(ref) {
185
+ const cleanRef = ref.split("?")[0].split("#")[0];
186
+ let relative = cleanRef;
187
+ if (relative.startsWith("/"))
188
+ relative = relative.slice(1);
189
+ if (relative.startsWith("public/"))
190
+ relative = relative.slice("public/".length);
191
+ return `/${relative}`;
192
+ }
193
+ /**
194
+ * Scans project source for <Image> tags with per-image optimizer props
195
+ * (formats / blur / quality / maxWidth) and returns them keyed by public src,
196
+ * in the same shape as the plugin's `overrides` config option.
197
+ */
198
+ export async function collectUsedImageOverrides(root, publicDir = "public") {
199
+ const codeFiles = await collectCodeFiles(root, publicDir);
200
+ const overrides = {};
201
+ for (const file of codeFiles) {
202
+ const content = await readFile(file, "utf8").catch(() => "");
203
+ const fileOverrides = extractImageOverrides(content);
204
+ for (const [src, override] of Object.entries(fileOverrides)) {
205
+ const publicSrc = resolvePublicSrc(src);
206
+ mergeOverrideInto(overrides, publicSrc, override);
207
+ }
208
+ }
209
+ return overrides;
210
+ }
211
+ export async function collectUsedImages(root, publicDir = "public") {
212
+ const publicRoot = path.resolve(root, publicDir);
213
+ const codeFiles = await collectCodeFiles(root, publicDir);
214
+ const referenced = new Set();
215
+ for (const file of codeFiles) {
216
+ const content = await readFile(file, "utf8").catch(() => "");
217
+ const refs = extractImageReferences(content);
218
+ for (const ref of refs) {
219
+ referenced.add(ref);
220
+ }
221
+ }
222
+ const resolvedFiles = new Set();
223
+ for (const ref of referenced) {
224
+ const cleanRef = ref.split("?")[0].split("#")[0];
225
+ let relativeInPublic = cleanRef;
226
+ if (relativeInPublic.startsWith("/"))
227
+ relativeInPublic = relativeInPublic.slice(1);
228
+ if (relativeInPublic.startsWith("public/"))
229
+ relativeInPublic = relativeInPublic.slice("public/".length);
230
+ const candidate = path.resolve(publicRoot, relativeInPublic);
231
+ if (candidate.startsWith(publicRoot)) {
232
+ resolvedFiles.add(candidate);
233
+ }
234
+ }
235
+ const existing = [];
236
+ for (const file of resolvedFiles) {
237
+ try {
238
+ const fileStat = await stat(file);
239
+ if (fileStat.isFile()) {
240
+ existing.push(file);
241
+ }
242
+ }
243
+ catch {
244
+ // does not exist, ignore
245
+ }
246
+ }
247
+ return existing.sort();
248
+ }
@@ -1,4 +1,4 @@
1
- export type ImageFormat = "avif" | "webp";
1
+ export type ImageFormat = "avif" | "webp" | "png" | "jpeg" | "gif" | "tiff" | "heif" | "jp2" | "jxl";
2
2
  export interface ImageBlurOptions {
3
3
  /** Enable blur placeholder generation. Default: true */
4
4
  enabled?: boolean;
@@ -14,6 +14,13 @@ export interface ImageOverrideOptions {
14
14
  formats?: ImageFormat[] | false;
15
15
  /** Max width to downscale, or `false` to preserve original dimensions. Default: inherits global */
16
16
  maxWidth?: number | false;
17
+ /**
18
+ * Additional widths to also generate as separate variants, e.g. when the
19
+ * same src is used at different sizes across the codebase (a thumbnail and
20
+ * a hero). Populated automatically by scanning <Image width=...> usages;
21
+ * merges (never overwrites) across multiple usages of the same src.
22
+ */
23
+ extraWidths?: number[];
17
24
  /** Compression quality (1-100). Default: inherits global */
18
25
  quality?: number;
19
26
  /** Blur placeholder settings for this image, or `false` to disable. Default: inherits global */
@@ -22,7 +29,7 @@ export interface ImageOverrideOptions {
22
29
  export interface ImageOptimizerPluginOptions {
23
30
  /** Enable or disable image optimization. Default: true */
24
31
  enabled?: boolean;
25
- /** Directories scanned recursively relative to project root. Default: ["public/images", "public/icons"] */
32
+ /** Directories scanned recursively relative to project root when onlyUsed is false. Default: ["public/images", "public/icons"] */
26
33
  dirs?: string[];
27
34
  /** Target directory for optimized assets. Default: "public/generated" */
28
35
  outDir?: string;
@@ -30,7 +37,7 @@ export interface ImageOptimizerPluginOptions {
30
37
  maxWidth?: number | false;
31
38
  /** Compression quality for rasters. Default: 80 */
32
39
  quality?: number;
33
- /** Target sibling formats, or `false` to disable format conversions. Default: ["avif", "webp"] */
40
+ /** Target sibling formats, or `false` to disable format conversions. Default: ["webp"] */
34
41
  formats?: ImageFormat[] | false;
35
42
  /** Output path for generated JSON manifest. Default: "public/generated/images.json" */
36
43
  manifest?: string;
@@ -40,6 +47,8 @@ export interface ImageOptimizerPluginOptions {
40
47
  dev?: boolean;
41
48
  /** Cache directory. Default: "node_modules/.cache/cloudflare-next-intl/image-optimizer" */
42
49
  cacheDir?: string;
50
+ /** Scan code files and optimize ONLY images actually referenced in <Image>. Default: true */
51
+ onlyUsed?: boolean;
43
52
  /** Per-image overrides keyed by public src (e.g. `"/images/hero.png"`) */
44
53
  overrides?: Record<string, ImageOverrideOptions>;
45
54
  }
@@ -60,23 +69,40 @@ export interface ResolvedOptions {
60
69
  blur: ResolvedBlurOptions;
61
70
  dev: boolean;
62
71
  cacheDir: string;
72
+ onlyUsed: boolean;
63
73
  overrides: Record<string, ImageOverrideOptions>;
64
74
  }
65
75
  export interface ResolvedImageConfig {
66
76
  maxWidth: number | false;
77
+ extraWidths: number[];
67
78
  quality: number;
68
79
  formats: ImageFormat[];
69
80
  blur: ResolvedBlurOptions;
70
81
  }
71
- export interface OptimizedImage {
72
- originalSrc: string;
82
+ export interface OptimizedImageSource {
83
+ format: ImageFormat | "original";
73
84
  src: string;
85
+ type: string;
86
+ }
87
+ export interface OptimizedImageVariant {
74
88
  width: number;
75
89
  height: number;
90
+ src: string;
91
+ sources?: OptimizedImageSource[];
76
92
  blurDataURL?: string;
77
93
  blurWidth?: number;
78
94
  blurHeight?: number;
79
95
  }
96
+ /**
97
+ * `src`/`width`/`height`/`sources`/`blur*` mirror the default variant (the
98
+ * first one generated) so existing single-size lookups keep working; `variants`
99
+ * carries every width actually requested via <Image width=...> across the
100
+ * codebase, so the component can pick the closest match to what's rendered.
101
+ */
102
+ export interface OptimizedImage extends OptimizedImageVariant {
103
+ originalSrc: string;
104
+ variants?: OptimizedImageVariant[];
105
+ }
80
106
  export interface ManifestData {
81
107
  images: Record<string, OptimizedImage>;
82
108
  }
@@ -1,4 +1,4 @@
1
- export const SUPPORTED_EXTENSIONS = [".png", ".jpg", ".jpeg"];
1
+ export const SUPPORTED_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".avif"];
2
2
  export const DEFAULT_BLUR_OPTIONS = {
3
3
  enabled: true,
4
4
  size: 8,
@@ -11,11 +11,12 @@ export const DEFAULT_OPTIONS = {
11
11
  outDir: "public/generated",
12
12
  maxWidth: 1920,
13
13
  quality: 80,
14
- formats: ["avif", "webp"],
14
+ formats: ["webp"],
15
15
  manifest: "public/generated/images.json",
16
16
  blur: DEFAULT_BLUR_OPTIONS,
17
17
  dev: true,
18
18
  cacheDir: "node_modules/.cache/cloudflare-next-intl/image-optimizer",
19
+ onlyUsed: true,
19
20
  overrides: {},
20
21
  };
21
22
  export function resolveBlurOptions(blur, parentDefault = DEFAULT_BLUR_OPTIONS) {
@@ -51,6 +52,7 @@ export function resolveOptions(options) {
51
52
  blur,
52
53
  dev: raw.dev ?? DEFAULT_OPTIONS.dev,
53
54
  cacheDir: raw.cacheDir ?? DEFAULT_OPTIONS.cacheDir,
55
+ onlyUsed: raw.onlyUsed ?? DEFAULT_OPTIONS.onlyUsed,
54
56
  overrides: raw.overrides ? { ...raw.overrides } : {},
55
57
  };
56
58
  }
@@ -59,6 +61,7 @@ export function resolveImageConfig(publicSrc, options) {
59
61
  if (!override) {
60
62
  return {
61
63
  maxWidth: options.maxWidth,
64
+ extraWidths: [],
62
65
  quality: options.quality,
63
66
  formats: options.formats,
64
67
  blur: options.blur,
@@ -78,5 +81,6 @@ export function resolveImageConfig(publicSrc, options) {
78
81
  const blur = override.blur !== undefined
79
82
  ? resolveBlurOptions(override.blur, options.blur)
80
83
  : options.blur;
81
- return { maxWidth, quality, formats, blur };
84
+ const extraWidths = override.extraWidths ? [...override.extraWidths] : [];
85
+ return { maxWidth, extraWidths, quality, formats, blur };
82
86
  }
@@ -4,3 +4,4 @@ export * from './server';
4
4
  export * from './client';
5
5
  export * from './theme_switcher';
6
6
  export * from './types';
7
+ export { default as Image, getImageProps, getImageBlurSvg } from './image_optimizer/next_image_shim';
package/dist/src/index.js CHANGED
@@ -4,3 +4,4 @@ export * from './server';
4
4
  export * from './client';
5
5
  export * from './theme_switcher';
6
6
  export * from './types';
7
+ export { default as Image, getImageProps, getImageBlurSvg } from './image_optimizer/next_image_shim';
@@ -0,0 +1,4 @@
1
+ export declare function makeTempDir(): Promise<string>;
2
+ export declare function cleanup(dir: string): Promise<void>;
3
+ export declare function writeFixturePng(dir: string, filename: string, width: number, height: number): Promise<string>;
4
+ export declare function writeFixtureJpg(dir: string, filename: string, width: number, height: number): Promise<string>;
@@ -0,0 +1,40 @@
1
+ import { mkdtemp, mkdir, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import sharp from "sharp";
5
+ export async function makeTempDir() {
6
+ return mkdtemp(path.join(tmpdir(), "cfni-img-test-"));
7
+ }
8
+ export async function cleanup(dir) {
9
+ await rm(dir, { recursive: true, force: true });
10
+ }
11
+ export async function writeFixturePng(dir, filename, width, height) {
12
+ await mkdir(dir, { recursive: true });
13
+ const target = path.join(dir, filename);
14
+ await sharp({
15
+ create: {
16
+ width,
17
+ height,
18
+ channels: 4,
19
+ background: { r: 120, g: 180, b: 240, alpha: 1 },
20
+ },
21
+ })
22
+ .png()
23
+ .toFile(target);
24
+ return target;
25
+ }
26
+ export async function writeFixtureJpg(dir, filename, width, height) {
27
+ await mkdir(dir, { recursive: true });
28
+ const target = path.join(dir, filename);
29
+ await sharp({
30
+ create: {
31
+ width,
32
+ height,
33
+ channels: 3,
34
+ background: { r: 240, g: 120, b: 180 },
35
+ },
36
+ })
37
+ .jpeg()
38
+ .toFile(target);
39
+ return target;
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.8.56",
3
+ "version": "0.8.58",
4
4
  "description": "Optimized Next Intl Package Special for App Router and Cloudflare",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -25,6 +25,14 @@
25
25
  "types": "./dist/index.d.ts",
26
26
  "import": "./dist/index.js"
27
27
  },
28
+ "./image": {
29
+ "types": "./dist/src/image.d.ts",
30
+ "import": "./dist/src/image.js"
31
+ },
32
+ "./Image": {
33
+ "types": "./dist/src/image.d.ts",
34
+ "import": "./dist/src/image.js"
35
+ },
28
36
  "./client": {
29
37
  "types": "./dist/src/client/index.d.ts",
30
38
  "import": "./dist/src/client/index.js"