cloudflare-next-intl 0.8.56 → 0.8.57

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.
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,39 @@ 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
+ ##### Browser Format Negotiation
314
+
315
+ 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:
316
+
317
+ ```tsx
318
+ <Image src="/images/hero.png" formats={["avif", "webp"]} />
319
+ ```
320
+
321
+ renders as:
322
+
323
+ ```html
324
+ <picture>
325
+ <source type="image/avif" srcset="/generated/images/hero.avif" />
326
+ <source type="image/webp" srcset="/generated/images/hero.webp" />
327
+ <img src="/generated/images/hero.avif" ... />
328
+ </picture>
329
+ ```
330
+
331
+ 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`.
332
+
299
333
  ```tsx
300
334
  // Client Components ("use client")
301
335
  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,4 +1,4 @@
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";
@@ -33,6 +33,16 @@ function findEntry(srcVal) {
33
33
  }
34
34
  return undefined;
35
35
  }
36
+ /** Swap the format extension of every URL in a generated srcset. */
37
+ function retargetSrcSet(srcSet, fromSrc, toSrc) {
38
+ if (!srcSet)
39
+ return srcSet;
40
+ const fromEncoded = encodeURIComponent(fromSrc);
41
+ const toEncoded = encodeURIComponent(toSrc);
42
+ return srcSet
43
+ .split(fromEncoded).join(toEncoded)
44
+ .split(fromSrc).join(toSrc);
45
+ }
36
46
  function resolveProps(props) {
37
47
  let src = props.src;
38
48
  let blurDataURL = props.blurDataURL;
@@ -74,7 +84,24 @@ function resolveProps(props) {
74
84
  }
75
85
  export default function Image(props) {
76
86
  const resolved = resolveProps(props);
77
- return _jsx(NextImage, { ...resolved });
87
+ const entry = findEntry(props.src);
88
+ const alternates = (entry?.sources ?? []).filter((source) => source.src !== entry?.src);
89
+ if (alternates.length === 0) {
90
+ return _jsx(NextImage, { ...resolved });
91
+ }
92
+ const { props: imgProps } = nextGetImageProps(resolved);
93
+ const primarySrc = entry?.src ?? String(imgProps.src);
94
+ const originalSrc = entry?.originalSrc;
95
+ const onError = (event) => {
96
+ const img = event.currentTarget;
97
+ if (originalSrc && img.src !== originalSrc && !img.src.endsWith(originalSrc)) {
98
+ img.srcset = "";
99
+ img.src = originalSrc;
100
+ }
101
+ };
102
+ return (_jsxs("picture", { children: [alternates.map((source) => (_jsx("source", { type: source.type, sizes: imgProps.sizes, srcSet: imgProps.srcSet
103
+ ? retargetSrcSet(imgProps.srcSet, primarySrc, source.src)
104
+ : source.src }, source.src))), _jsx("img", { ...imgProps, onError: onError })] }));
78
105
  }
79
106
  export function getImageProps(props) {
80
107
  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,4 +1,10 @@
1
- import type { OptimizedImage, ResolvedBlurOptions, ResolvedOptions } from "./types.js";
1
+ import type { ImageFormat, OptimizedImage, OptimizedImageSource, ResolvedBlurOptions, ResolvedOptions } from "./types.js";
2
+ export declare function mimeTypeFor(format: ImageFormat | "original", originalSrc: string): string;
3
+ /**
4
+ * <picture> tries <source> tags in document order, so sources must follow the
5
+ * user's own `formats` order (their priority) with "original" always last as fallback.
6
+ */
7
+ export declare function sortSources(sources: OptimizedImageSource[], formats: ImageFormat[]): OptimizedImageSource[];
2
8
  export declare function toPublicSrc(absolutePath: string, publicRoot: string): string;
3
9
  export declare function toGeneratedPath(absolutePath: string, publicRoot: string, outDir: string, root: string): {
4
10
  targetFile: string;
@@ -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
+ 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("/")}`;
@@ -37,16 +87,46 @@ export async function makeBlurDataURL(targetFile, sourceWidth, sourceHeight, blu
37
87
  blurHeight,
38
88
  };
39
89
  }
40
- async function encodeSibling(targetFile, sourcePath, format, quality, maxWidth, needsResize) {
41
- const target = targetFile.replace(/\.[^.]+$/, `.${format}`);
90
+ async function encodeFormat(targetFile, sourcePath, format, quality, maxWidth, needsResize) {
42
91
  let pipeline = sharp(sourcePath);
43
92
  if (needsResize) {
44
93
  pipeline = pipeline.resize({ width: maxWidth });
45
94
  }
46
- const encoded = format === "avif"
47
- ? pipeline.avif({ quality })
48
- : pipeline.webp({ quality });
49
- await encoded.toFile(target);
95
+ let encoded;
96
+ if (format === "avif") {
97
+ encoded = pipeline.avif({ quality });
98
+ }
99
+ else if (format === "webp") {
100
+ encoded = pipeline.webp({ quality });
101
+ }
102
+ else if (format === "png") {
103
+ encoded = pipeline.png({ quality, compressionLevel: 9 });
104
+ }
105
+ else if (format === "jpeg") {
106
+ encoded = pipeline.jpeg({ quality, mozjpeg: true });
107
+ }
108
+ else if (format === "gif") {
109
+ encoded = pipeline.gif();
110
+ }
111
+ else if (format === "tiff") {
112
+ encoded = pipeline.tiff({ quality });
113
+ }
114
+ else if (format === "heif") {
115
+ encoded = pipeline.heif({ quality, compression: "hevc" });
116
+ }
117
+ else if (format === "jp2") {
118
+ encoded = pipeline.jp2({ quality });
119
+ }
120
+ else if (format === "jxl") {
121
+ encoded = pipeline.jxl({ quality });
122
+ }
123
+ else {
124
+ const ext = path.extname(sourcePath).toLowerCase();
125
+ encoded = ext === ".png"
126
+ ? pipeline.png({ quality, compressionLevel: 9 })
127
+ : pipeline.jpeg({ quality, mozjpeg: true });
128
+ }
129
+ await encoded.toFile(targetFile);
50
130
  }
51
131
  export async function processImage(absolutePath, publicRoot, options, root = path.dirname(publicRoot)) {
52
132
  const publicSrc = toPublicSrc(absolutePath, publicRoot);
@@ -61,25 +141,38 @@ export async function processImage(absolutePath, publicRoot, options, root = pat
61
141
  : sourceHeight;
62
142
  const { targetFile, targetSrc } = toGeneratedPath(absolutePath, publicRoot, options.outDir, root);
63
143
  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);
144
+ const primaryFormat = config.formats.length > 0
145
+ ? config.formats[0]
146
+ : "original";
147
+ const primaryFile = primaryFormat === "original"
148
+ ? targetFile
149
+ : targetFile.replace(/\.[^.]+$/, `.${EXTENSION_BY_FORMAT[primaryFormat]}`);
150
+ const primarySrc = primaryFormat === "original"
151
+ ? targetSrc
152
+ : targetSrc.replace(/\.[^.]+$/, `.${EXTENSION_BY_FORMAT[primaryFormat]}`);
153
+ await encodeFormat(primaryFile, absolutePath, primaryFormat, config.quality, config.maxWidth, needsResize);
154
+ const sources = [
155
+ { format: primaryFormat, src: primarySrc, type: mimeTypeFor(primaryFormat, publicSrc) },
156
+ ];
157
+ for (let i = 1; i < config.formats.length; i++) {
158
+ const format = config.formats[i];
159
+ const ext = EXTENSION_BY_FORMAT[format];
160
+ const siblingFile = targetFile.replace(/\.[^.]+$/, `.${ext}`);
161
+ await encodeFormat(siblingFile, absolutePath, format, config.quality, config.maxWidth, needsResize);
162
+ sources.push({
163
+ format,
164
+ src: targetSrc.replace(/\.[^.]+$/, `.${ext}`),
165
+ type: mimeTypeFor(format, publicSrc),
166
+ });
75
167
  }
76
168
  let blurResult;
77
169
  if (config.blur.enabled) {
78
- blurResult = await makeBlurDataURL(targetFile, width, height, config.blur);
170
+ blurResult = await makeBlurDataURL(primaryFile, width, height, config.blur);
79
171
  }
80
172
  return {
81
173
  originalSrc: publicSrc,
82
- src: targetSrc,
174
+ src: primarySrc,
175
+ sources: sortSources(sources, config.formats),
83
176
  width,
84
177
  height,
85
178
  blurDataURL: blurResult?.blurDataURL,
@@ -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): 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[]>;
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { isFresh, loadCache, saveCache } from "./cache.js";
4
4
  import { writeManifest } from "./manifest.js";
5
5
  import { processImage, toGeneratedPath, toPublicSrc } from "./process_image.js";
6
+ import { collectUsedImageOverrides, collectUsedImages } from "./scan_used.js";
6
7
  import { resolveImageConfig, SUPPORTED_EXTENSIONS } from "./types.js";
7
8
  async function walk(directory, found) {
8
9
  let items;
@@ -30,35 +31,70 @@ export async function collectImages(dirs, root) {
30
31
  }
31
32
  return found.sort();
32
33
  }
33
- function targetAndSiblingPaths(absolutePath, publicRoot, options, root) {
34
+ export function targetAndSiblingPaths(absolutePath, publicRoot, options, root) {
34
35
  const publicSrc = toPublicSrc(absolutePath, publicRoot);
35
36
  const config = resolveImageConfig(publicSrc, options);
36
37
  const { targetFile } = toGeneratedPath(absolutePath, publicRoot, options.outDir, root);
37
- const siblings = config.formats.map((format) => targetFile.replace(/\.[^.]+$/, `.${format}`));
38
- const result = [targetFile, ...siblings];
38
+ const primaryFormat = config.formats.length > 0
39
+ ? config.formats[0]
40
+ : "original";
41
+ const primaryFile = primaryFormat === "original"
42
+ ? targetFile
43
+ : targetFile.replace(/\.[^.]+$/, `.${primaryFormat}`);
44
+ const result = [primaryFile];
45
+ for (let i = 1; i < config.formats.length; i++) {
46
+ const format = config.formats[i];
47
+ result.push(targetFile.replace(/\.[^.]+$/, `.${format}`));
48
+ }
39
49
  if (config.blur.enabled) {
40
- result.push(targetFile.replace(/\.[^.]+$/, ".blur.webp"));
50
+ result.push(primaryFile.replace(/\.[^.]+$/, ".blur.webp"));
41
51
  }
42
52
  return result;
43
53
  }
54
+ /**
55
+ * Merges optimizer overrides scanned from <Image> JSX props with the plugin's
56
+ * centralized `overrides` config, so settings can live at the usage site. An
57
+ * explicit config override for a given src still wins over a scanned one.
58
+ */
59
+ export function mergeOverrides(scanned, configured) {
60
+ const merged = { ...scanned };
61
+ for (const [src, override] of Object.entries(configured)) {
62
+ merged[src] = { ...merged[src], ...override };
63
+ }
64
+ return merged;
65
+ }
44
66
  export async function run(root, options, cacheFile = path.resolve(root, options.cacheDir, "manifest.json")) {
45
67
  const publicRoot = path.resolve(root, "public");
46
68
  const manifestPath = path.resolve(root, options.manifest);
47
69
  const cache = await loadCache(cacheFile);
48
70
  const nextCache = {};
49
- const files = await collectImages(options.dirs, root);
71
+ let files = [];
72
+ if (options.onlyUsed) {
73
+ files = await collectUsedImages(root, "public");
74
+ if (files.length === 0) {
75
+ files = await collectImages(options.dirs, root);
76
+ }
77
+ }
78
+ else {
79
+ files = await collectImages(options.dirs, root);
80
+ }
81
+ const scannedOverrides = await collectUsedImageOverrides(root, "public");
82
+ const resolvedOptions = {
83
+ ...options,
84
+ overrides: mergeOverrides(scannedOverrides, options.overrides),
85
+ };
50
86
  const entries = [];
51
87
  for (const file of files) {
52
88
  const relativeKey = path.relative(root, file);
53
89
  const cached = cache[relativeKey];
54
- const targets = targetAndSiblingPaths(file, publicRoot, options, root);
90
+ const targets = targetAndSiblingPaths(file, publicRoot, resolvedOptions, root);
55
91
  const fresh = await isFresh(file, cached, targets);
56
92
  if (fresh && cached) {
57
93
  entries.push(cached.result);
58
94
  nextCache[relativeKey] = cached;
59
95
  continue;
60
96
  }
61
- const result = await processImage(file, publicRoot, options, root);
97
+ const result = await processImage(file, publicRoot, resolvedOptions, root);
62
98
  const fileStat = await stat(file);
63
99
  entries.push(result);
64
100
  nextCache[relativeKey] = {
@@ -0,0 +1,19 @@
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.
11
+ */
12
+ export declare function extractImageOverrides(code: string): Record<string, ImageOverrideOptions>;
13
+ /**
14
+ * Scans project source for <Image> tags with per-image optimizer props
15
+ * (formats / blur / quality / maxWidth) and returns them keyed by public src,
16
+ * in the same shape as the plugin's `overrides` config option.
17
+ */
18
+ export declare function collectUsedImageOverrides(root: string, publicDir?: string): Promise<Record<string, ImageOverrideOptions>>;
19
+ export declare function collectUsedImages(root: string, publicDir?: string): Promise<string[]>;
@@ -0,0 +1,229 @@
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
+ const IMAGE_FORMATS = [
61
+ "avif", "webp", "png", "jpeg", "gif", "tiff", "heif", "jp2", "jxl",
62
+ ];
63
+ function parseFormatsAttr(raw) {
64
+ const trimmed = raw.trim();
65
+ if (trimmed === "false")
66
+ return false;
67
+ const values = trimmed.match(/[a-z0-9]+/gi) ?? [];
68
+ const formats = values
69
+ .map((v) => v.toLowerCase())
70
+ .filter((v) => IMAGE_FORMATS.includes(v));
71
+ return formats.length > 0 ? formats : undefined;
72
+ }
73
+ function parseNumberAttr(raw) {
74
+ const value = Number(raw.trim().replace(/[{}]/g, ""));
75
+ return Number.isFinite(value) ? value : undefined;
76
+ }
77
+ function parseBlurAttr(raw) {
78
+ const trimmed = raw.trim();
79
+ if (trimmed === "false")
80
+ return false;
81
+ if (trimmed === "true")
82
+ return true;
83
+ const blur = {};
84
+ const size = trimmed.match(/size\s*:\s*(\d+)/);
85
+ const quality = trimmed.match(/quality\s*:\s*(\d+)/);
86
+ const stdDeviation = trimmed.match(/stdDeviation\s*:\s*(\d+)/);
87
+ if (size)
88
+ blur.size = Number(size[1]);
89
+ if (quality)
90
+ blur.quality = Number(quality[1]);
91
+ if (stdDeviation)
92
+ blur.stdDeviation = Number(stdDeviation[1]);
93
+ return Object.keys(blur).length > 0 ? blur : undefined;
94
+ }
95
+ /**
96
+ * Scans JSX/TSX source for <Image> tags carrying per-image optimizer props
97
+ * (formats / blur / quality / maxWidth) and turns them into override entries
98
+ * keyed by the tag's own src, so settings can live next to usage instead of
99
+ * only in the plugin's centralized `overrides` config.
100
+ */
101
+ export function extractImageOverrides(code) {
102
+ const overrides = {};
103
+ const tagPattern = /<Image\b([^>]*)\/?>/gs;
104
+ let tagMatch;
105
+ while ((tagMatch = tagPattern.exec(code)) !== null) {
106
+ const attrs = tagMatch[1];
107
+ const srcMatch = attrs.match(/\bsrc\s*=\s*(?:["'`]([^"'`]+)["'`]|\{["'`]([^"'`]+)["'`]\})/);
108
+ if (!srcMatch)
109
+ continue;
110
+ const src = (srcMatch[1] ?? srcMatch[2]).split("?")[0].split("#")[0];
111
+ const override = {};
112
+ const formatsMatch = attrs.match(/\bformats\s*=\s*\{([^}]*)\}/);
113
+ if (formatsMatch) {
114
+ const parsed = parseFormatsAttr(formatsMatch[1]);
115
+ if (parsed !== undefined)
116
+ override.formats = parsed;
117
+ }
118
+ const maxWidthMatch = attrs.match(/\bmaxWidth\s*=\s*\{([^}]*)\}/);
119
+ if (maxWidthMatch) {
120
+ const trimmed = maxWidthMatch[1].trim();
121
+ const parsed = trimmed === "false" ? false : parseNumberAttr(trimmed);
122
+ if (parsed !== undefined)
123
+ override.maxWidth = parsed;
124
+ }
125
+ const qualityMatch = attrs.match(/\bquality\s*=\s*\{?(\d+)\}?/);
126
+ if (qualityMatch) {
127
+ override.quality = Number(qualityMatch[1]);
128
+ }
129
+ const blurMatch = attrs.match(/\bblur\s*=\s*\{([^}]*)\}/);
130
+ if (blurMatch) {
131
+ const parsed = parseBlurAttr(blurMatch[1]);
132
+ if (parsed !== undefined)
133
+ override.blur = parsed;
134
+ }
135
+ if (Object.keys(override).length > 0) {
136
+ overrides[src] = { ...overrides[src], ...override };
137
+ }
138
+ }
139
+ return overrides;
140
+ }
141
+ async function collectCodeFiles(root, publicDir) {
142
+ let rootItems;
143
+ try {
144
+ rootItems = await readdir(root, { withFileTypes: true });
145
+ }
146
+ catch {
147
+ return [];
148
+ }
149
+ const codeFiles = [];
150
+ for (const item of rootItems) {
151
+ if (IGNORED_DIRS.has(item.name))
152
+ continue;
153
+ if (item.name === publicDir)
154
+ continue;
155
+ const full = path.join(root, item.name);
156
+ if (item.isDirectory()) {
157
+ await findCodeFiles(full, codeFiles);
158
+ }
159
+ else if (CODE_EXTENSIONS.includes(path.extname(item.name).toLowerCase())) {
160
+ codeFiles.push(full);
161
+ }
162
+ }
163
+ return codeFiles;
164
+ }
165
+ function resolvePublicSrc(ref) {
166
+ const cleanRef = ref.split("?")[0].split("#")[0];
167
+ let relative = cleanRef;
168
+ if (relative.startsWith("/"))
169
+ relative = relative.slice(1);
170
+ if (relative.startsWith("public/"))
171
+ relative = relative.slice("public/".length);
172
+ return `/${relative}`;
173
+ }
174
+ /**
175
+ * Scans project source for <Image> tags with per-image optimizer props
176
+ * (formats / blur / quality / maxWidth) and returns them keyed by public src,
177
+ * in the same shape as the plugin's `overrides` config option.
178
+ */
179
+ export async function collectUsedImageOverrides(root, publicDir = "public") {
180
+ const codeFiles = await collectCodeFiles(root, publicDir);
181
+ const overrides = {};
182
+ for (const file of codeFiles) {
183
+ const content = await readFile(file, "utf8").catch(() => "");
184
+ const fileOverrides = extractImageOverrides(content);
185
+ for (const [src, override] of Object.entries(fileOverrides)) {
186
+ const publicSrc = resolvePublicSrc(src);
187
+ overrides[publicSrc] = { ...overrides[publicSrc], ...override };
188
+ }
189
+ }
190
+ return overrides;
191
+ }
192
+ export async function collectUsedImages(root, publicDir = "public") {
193
+ const publicRoot = path.resolve(root, publicDir);
194
+ const codeFiles = await collectCodeFiles(root, publicDir);
195
+ const referenced = new Set();
196
+ for (const file of codeFiles) {
197
+ const content = await readFile(file, "utf8").catch(() => "");
198
+ const refs = extractImageReferences(content);
199
+ for (const ref of refs) {
200
+ referenced.add(ref);
201
+ }
202
+ }
203
+ const resolvedFiles = new Set();
204
+ for (const ref of referenced) {
205
+ const cleanRef = ref.split("?")[0].split("#")[0];
206
+ let relativeInPublic = cleanRef;
207
+ if (relativeInPublic.startsWith("/"))
208
+ relativeInPublic = relativeInPublic.slice(1);
209
+ if (relativeInPublic.startsWith("public/"))
210
+ relativeInPublic = relativeInPublic.slice("public/".length);
211
+ const candidate = path.resolve(publicRoot, relativeInPublic);
212
+ if (candidate.startsWith(publicRoot)) {
213
+ resolvedFiles.add(candidate);
214
+ }
215
+ }
216
+ const existing = [];
217
+ for (const file of resolvedFiles) {
218
+ try {
219
+ const fileStat = await stat(file);
220
+ if (fileStat.isFile()) {
221
+ existing.push(file);
222
+ }
223
+ }
224
+ catch {
225
+ // does not exist, ignore
226
+ }
227
+ }
228
+ return existing.sort();
229
+ }
@@ -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;
@@ -22,7 +22,7 @@ export interface ImageOverrideOptions {
22
22
  export interface ImageOptimizerPluginOptions {
23
23
  /** Enable or disable image optimization. Default: true */
24
24
  enabled?: boolean;
25
- /** Directories scanned recursively relative to project root. Default: ["public/images", "public/icons"] */
25
+ /** Directories scanned recursively relative to project root when onlyUsed is false. Default: ["public/images", "public/icons"] */
26
26
  dirs?: string[];
27
27
  /** Target directory for optimized assets. Default: "public/generated" */
28
28
  outDir?: string;
@@ -30,7 +30,7 @@ export interface ImageOptimizerPluginOptions {
30
30
  maxWidth?: number | false;
31
31
  /** Compression quality for rasters. Default: 80 */
32
32
  quality?: number;
33
- /** Target sibling formats, or `false` to disable format conversions. Default: ["avif", "webp"] */
33
+ /** Target sibling formats, or `false` to disable format conversions. Default: ["webp"] */
34
34
  formats?: ImageFormat[] | false;
35
35
  /** Output path for generated JSON manifest. Default: "public/generated/images.json" */
36
36
  manifest?: string;
@@ -40,6 +40,8 @@ export interface ImageOptimizerPluginOptions {
40
40
  dev?: boolean;
41
41
  /** Cache directory. Default: "node_modules/.cache/cloudflare-next-intl/image-optimizer" */
42
42
  cacheDir?: string;
43
+ /** Scan code files and optimize ONLY images actually referenced in <Image>. Default: true */
44
+ onlyUsed?: boolean;
43
45
  /** Per-image overrides keyed by public src (e.g. `"/images/hero.png"`) */
44
46
  overrides?: Record<string, ImageOverrideOptions>;
45
47
  }
@@ -60,6 +62,7 @@ export interface ResolvedOptions {
60
62
  blur: ResolvedBlurOptions;
61
63
  dev: boolean;
62
64
  cacheDir: string;
65
+ onlyUsed: boolean;
63
66
  overrides: Record<string, ImageOverrideOptions>;
64
67
  }
65
68
  export interface ResolvedImageConfig {
@@ -68,9 +71,15 @@ export interface ResolvedImageConfig {
68
71
  formats: ImageFormat[];
69
72
  blur: ResolvedBlurOptions;
70
73
  }
74
+ export interface OptimizedImageSource {
75
+ format: ImageFormat | "original";
76
+ src: string;
77
+ type: string;
78
+ }
71
79
  export interface OptimizedImage {
72
80
  originalSrc: string;
73
81
  src: string;
82
+ sources?: OptimizedImageSource[];
74
83
  width: number;
75
84
  height: number;
76
85
  blurDataURL?: string;
@@ -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
  }
@@ -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.57",
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"