cloudflare-next-intl 0.13.5 → 0.14.0

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
@@ -282,7 +282,7 @@ export default defineConfig({
282
282
 
283
283
  Being text-only and transitive, it can over-flag: any local file the page reaches — however many imports away — that merely *calls* a recognized signal counts, even along a branch that never runs in production (a `Config.isDev`-gated `fetch(..., { cache: "no-store" })`) or one that's optional/best-effort (a `try`/`catch`-wrapped `getAuthUser()` used only to tag a log line). It cannot see that a call is conditional or swallowed. For a page whose only reason for being flagged is that kind of optional read — attaching "whoever's signed in, if anyone" to an error report, analytics event, or log line — switch that read to `resolveOptionalAuthUser()` (`cloudflare-next-intl/resolveOptionalAuthUser`, see Firebase Auth below): it wraps `getAuthUser()` the same way but, being an npm-package import, is a boundary the scan doesn't open, so it contributes no signal. If that read lives in a shared `onError` sink used by many pages at once, `resolveErrorReportingUser(useAuthUser?)` (same subpath) is the more precise fix: it's off (`{ user: null }`, no `getAuthUser()` call) by default, and only a `reportError({ ..., useAuthUser: true })` call site that actually wants the user on that report opts in per-call — instead of every page reaching that sink getting flagged.
284
284
  2. **Auto Locale Params for Static Pages (`autoLocaleParams`)**: Automatically scans `page`/`layout`/`loading` files under a `[locale]`-scoped route during `configResolved` and inserts the `locale` param from `params` plus a `setLocale(locale)` call wherever `getTranslations()`/`useTranslations()` is used without one — the common cause of a page reading `NEXT_LOCALE` from cookies (and so implicitly becoming request-dependent) even though it never calls `cookies()` itself. Runs before `autoDynamicPages` so a page fixed this way no longer trips the cookie-derived-locale signal below. The scan (`checkLocaleParams`, also usable standalone from `cloudflare-next-intl/checkLocaleParams`, or via the `cfni-check-locale-params` CLI bin) is a text heuristic like `checkDynamicPages`; pass `mode: "report"` to only log without editing files, or `skip`/`overrides`/`localeParam` to fine-tune per-file.
285
- 4. **Build-Time 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. Remote, `data:` and `blob:` srcs (e.g. upload previews) are never matched against the manifest and pass through untouched. An auto-detected image loader is applied on the client, so `<Image>` stays safe in Server Components; a loader that throws falls back to the original `src`. Runs on production build only by default (`dev: false`) — downscaling/format conversion is a build concern, and re-scanning on every dev server start slows cold starts for no dev-time benefit; the shim degrades cleanly with no manifest (plain, unoptimized `next/image` rendering, no blur placeholder). Pass `dev: true` to also run it in dev and preview real optimized output/blur.
285
+ 4. **Build-Time 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. Automatically prunes unoptimized original images from final build output (`pruneOriginalsFromBuild: true` by default) when replaced by optimized assets, while preserving images used in unoptimized contexts (raw `<img>`, CSS, schema). Also rewrites Next.js metadata image URLs (`openGraph`, `twitter`, `icons`) to point to optimized assets (`optimizeMetadata: true` by default). 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. Remote, `data:` and `blob:` srcs (e.g. upload previews) are never matched against the manifest and pass through untouched. An auto-detected image loader is applied on the client, so `<Image>` stays safe in Server Components; a loader that throws falls back to the original `src`. Runs on production build only by default (`dev: false`) — downscaling/format conversion is a build concern, and re-scanning on every dev server start slows cold starts for no dev-time benefit; the shim degrades cleanly with no manifest (plain, unoptimized `next/image` rendering, no blur placeholder). Pass `dev: true` to also run it in dev and preview real optimized output/blur.
286
286
  5. **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.
287
287
  6. **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).
288
288
  7. **Cloudflare Workers Client Stub (`cfWorkersClientStub`)**: Stubs `cloudflare:workers` in client builds so shared modules can be referenced without client bundling errors.
@@ -314,6 +314,8 @@ export default defineConfig({
314
314
  overrides: { // Per-image overrides keyed by public src path (wins over scanned <Image> props)
315
315
  "/images/hero.png": { maxWidth: false, formats: ["webp"], blur: { quality: 80 } },
316
316
  },
317
+ optimizeMetadata: true, // Optimize images in Next.js metadata (OG, twitter, icons) & rewrite URLs (default: true)
318
+ pruneOriginalsFromBuild: true, // Prune unoptimized originals from build output when replaced (default: true)
317
319
  autoImageLoader: true, // Auto-detect custom image loader for standalone imageOptimizer usages (default: true, or false/options)
318
320
  },
319
321
  autoImageLoader: true, // Auto-detect custom image loader (from next.config.* loaderFile or image-loader.ts; default: true, or options object)
@@ -4,7 +4,9 @@ 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
+ export { collectUsedImages, findCodeFiles, extractImageReferences, extractImageOverrides, extractImageUsageDetails, classifyImageUsages, getPrunableOriginalPaths, CODE_EXTENSIONS, ASSET_EXTENSIONS, SCANNED_EXTENSIONS, IGNORED_DIRS, IGNORED_FILES, type ImageUsageDetails, type ImageUsageClassification, } from "./scan_used.js";
8
+ export { transformMetadataImageUrls, getMetadataRanges, findBalancedBraces, } from "./metadata_transform.js";
9
+ export { pruneBuildOutput, type PruneResult, } from "./prune_build.js";
8
10
  export { getImageBlurSvg, } from "./blur_svg.js";
9
11
  export { type ManifestEntry, } from "./next_image_shim.js";
10
12
  export { VIRTUAL_IMAGE_LOADER_ID, RESOLVED_IMAGE_LOADER_ID, type AutoImageLoaderOptions, type DetectedImageLoader, findLoaderFileFromNextConfig, findExportedLoaderName, detectImageLoader, generateLoaderVirtualModule, } from "./detect_loader.js";
@@ -4,6 +4,8 @@ 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
+ export { collectUsedImages, findCodeFiles, extractImageReferences, extractImageOverrides, extractImageUsageDetails, classifyImageUsages, getPrunableOriginalPaths, CODE_EXTENSIONS, ASSET_EXTENSIONS, SCANNED_EXTENSIONS, IGNORED_DIRS, IGNORED_FILES, } from "./scan_used.js";
8
+ export { transformMetadataImageUrls, getMetadataRanges, findBalancedBraces, } from "./metadata_transform.js";
9
+ export { pruneBuildOutput, } from "./prune_build.js";
8
10
  export { getImageBlurSvg, } from "./blur_svg.js";
9
11
  export { VIRTUAL_IMAGE_LOADER_ID, RESOLVED_IMAGE_LOADER_ID, findLoaderFileFromNextConfig, findExportedLoaderName, detectImageLoader, generateLoaderVirtualModule, } from "./detect_loader.js";
@@ -0,0 +1,8 @@
1
+ export declare function findBalancedBraces(code: string, openIndex: number): number;
2
+ export declare function getMetadataRanges(code: string): {
3
+ start: number;
4
+ end: number;
5
+ }[];
6
+ export declare function transformMetadataImageUrls(code: string, manifestImages: Record<string, {
7
+ src: string;
8
+ }>): string | undefined;
@@ -0,0 +1,123 @@
1
+ export function findBalancedBraces(code, openIndex) {
2
+ if (code[openIndex] !== "{")
3
+ return -1;
4
+ let depth = 0;
5
+ let inString = null;
6
+ let inComment = null;
7
+ for (let i = openIndex; i < code.length; i++) {
8
+ const char = code[i];
9
+ const next = code[i + 1];
10
+ if (inComment === "line") {
11
+ if (char === "\n")
12
+ inComment = null;
13
+ continue;
14
+ }
15
+ if (inComment === "block") {
16
+ if (char === "*" && next === "/") {
17
+ inComment = null;
18
+ i++;
19
+ }
20
+ continue;
21
+ }
22
+ if (inString) {
23
+ if (char === "\\") {
24
+ i++;
25
+ }
26
+ else if (char === inString) {
27
+ inString = null;
28
+ }
29
+ continue;
30
+ }
31
+ if (char === "/" && next === "/") {
32
+ inComment = "line";
33
+ i++;
34
+ continue;
35
+ }
36
+ if (char === "/" && next === "*") {
37
+ inComment = "block";
38
+ i++;
39
+ continue;
40
+ }
41
+ if (char === '"' || char === "'" || char === "`") {
42
+ inString = char;
43
+ continue;
44
+ }
45
+ if (char === "{") {
46
+ depth++;
47
+ }
48
+ else if (char === "}") {
49
+ depth--;
50
+ if (depth === 0) {
51
+ return i;
52
+ }
53
+ }
54
+ }
55
+ return -1;
56
+ }
57
+ export function getMetadataRanges(code) {
58
+ const ranges = [];
59
+ const metadataDeclPattern = /\bmetadata\s*(?::\s*[A-Za-z0-9_$.<>[\]\s]+)?\s*=\s*\{/g;
60
+ let match;
61
+ while ((match = metadataDeclPattern.exec(code)) !== null) {
62
+ const openIndex = match.index + match[0].length - 1;
63
+ const closeIndex = findBalancedBraces(code, openIndex);
64
+ if (closeIndex !== -1) {
65
+ ranges.push({ start: openIndex, end: closeIndex });
66
+ }
67
+ }
68
+ const genMetadataPattern = /(?:async\s+)?function\s+generateMetadata\b[^{]*\{/g;
69
+ while ((match = genMetadataPattern.exec(code)) !== null) {
70
+ const openIndex = match.index + match[0].length - 1;
71
+ const closeIndex = findBalancedBraces(code, openIndex);
72
+ if (closeIndex !== -1) {
73
+ ranges.push({ start: openIndex, end: closeIndex });
74
+ }
75
+ }
76
+ const propPattern = /\b(openGraph|twitter|icons)\s*:\s*\{/g;
77
+ while ((match = propPattern.exec(code)) !== null) {
78
+ const openIndex = match.index + match[0].length - 1;
79
+ const insideExisting = ranges.some((r) => openIndex >= r.start && openIndex <= r.end);
80
+ if (!insideExisting) {
81
+ const closeIndex = findBalancedBraces(code, openIndex);
82
+ if (closeIndex !== -1) {
83
+ ranges.push({ start: openIndex, end: closeIndex });
84
+ }
85
+ }
86
+ }
87
+ return ranges;
88
+ }
89
+ function normalizeSrcKey(src) {
90
+ const clean = src.split("?")[0].split("#")[0];
91
+ return clean.startsWith("/") ? clean : `/${clean}`;
92
+ }
93
+ export function transformMetadataImageUrls(code, manifestImages) {
94
+ if (Object.keys(manifestImages).length === 0)
95
+ return undefined;
96
+ if (!/\b(metadata|generateMetadata|openGraph|twitter|icons)\b/.test(code)) {
97
+ return undefined;
98
+ }
99
+ const ranges = getMetadataRanges(code);
100
+ if (ranges.length === 0)
101
+ return undefined;
102
+ let modified = false;
103
+ let result = "";
104
+ let lastIndex = 0;
105
+ ranges.sort((a, b) => a.start - b.start);
106
+ for (const range of ranges) {
107
+ result += code.slice(lastIndex, range.start);
108
+ const block = code.slice(range.start, range.end + 1);
109
+ const transformedBlock = block.replace(/(["'`])([^"'`\s\n\r#?]+\.(?:png|jpg|jpeg|webp|avif))(?:\?[^"'`\s]*)?(?:#[^"'`\s]*)?\1/gi, (fullMatch, quote, rawPath) => {
110
+ const key = normalizeSrcKey(rawPath);
111
+ const entry = manifestImages[key];
112
+ if (entry && entry.src) {
113
+ modified = true;
114
+ return `${quote}${entry.src}${quote}`;
115
+ }
116
+ return fullMatch;
117
+ });
118
+ result += transformedBlock;
119
+ lastIndex = range.end + 1;
120
+ }
121
+ result += code.slice(lastIndex);
122
+ return modified ? result : undefined;
123
+ }
@@ -5,6 +5,7 @@ type Loader = (props: {
5
5
  width: number;
6
6
  quality?: number;
7
7
  }) => string;
8
+ export declare function preloadLoader(): Promise<void>;
8
9
  export declare function safeLoader(loader: Loader): Loader;
9
10
  export declare function NextImageClient(props: ImageProps): React.JSX.Element;
10
11
  export {};
@@ -2,13 +2,22 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import NextImage from "next/image.js";
4
4
  let defaultCustomLoader;
5
- try {
6
- const loaderMod = (await import("virtual:cloudflare-next-intl-image-loader"));
7
- const candidate = loaderMod.defaultLoader ?? loaderMod.default;
8
- defaultCustomLoader = typeof candidate === "function" ? candidate : undefined;
5
+ let loaderInitPromise;
6
+ function ensureLoader() {
7
+ if (!loaderInitPromise) {
8
+ loaderInitPromise = import("virtual:cloudflare-next-intl-image-loader")
9
+ .then((mod) => {
10
+ const loaderMod = mod;
11
+ const candidate = loaderMod?.defaultLoader ?? loaderMod?.default;
12
+ defaultCustomLoader = typeof candidate === "function" ? candidate : undefined;
13
+ })
14
+ .catch(() => undefined);
15
+ }
16
+ return loaderInitPromise;
9
17
  }
10
- catch {
11
- defaultCustomLoader = undefined;
18
+ ensureLoader();
19
+ export async function preloadLoader() {
20
+ await ensureLoader();
12
21
  }
13
22
  export function safeLoader(loader) {
14
23
  return (params) => {
@@ -23,8 +32,8 @@ export function safeLoader(loader) {
23
32
  return params.src;
24
33
  };
25
34
  }
26
- const safeDefaultLoader = defaultCustomLoader ? safeLoader(defaultCustomLoader) : undefined;
27
35
  export function NextImageClient(props) {
28
- const loader = typeof props.loader === "function" ? safeLoader(props.loader) : safeDefaultLoader;
36
+ const loaderCandidate = typeof props.loader === "function" ? props.loader : defaultCustomLoader;
37
+ const loader = typeof loaderCandidate === "function" ? safeLoader(loaderCandidate) : undefined;
29
38
  return _jsx(NextImage, { ...props, loader: loader });
30
39
  }
@@ -4,9 +4,14 @@ import { fileURLToPath } from "node:url";
4
4
  import { run } from "./run.js";
5
5
  import { resolveOptions } from "./types.js";
6
6
  import { VIRTUAL_IMAGE_LOADER_ID, RESOLVED_IMAGE_LOADER_ID, detectImageLoader, generateLoaderVirtualModule, } from "./detect_loader.js";
7
+ import { transformMetadataImageUrls } from "./metadata_transform.js";
8
+ import { pruneBuildOutput } from "./prune_build.js";
9
+ import { classifyImageUsages, getPrunableOriginalPaths } from "./scan_used.js";
7
10
  export const VIRTUAL_IMAGE_SHIM_ID = "virtual:cloudflare-next-intl-image";
8
11
  export const VIRTUAL_MANIFEST_ID = "virtual:cloudflare-next-intl-images-manifest";
9
12
  const RESOLVED_MANIFEST_ID = "\0" + VIRTUAL_MANIFEST_ID;
13
+ const sharedPrunableByRoot = new Map();
14
+ const sharedManifestByRoot = new Map();
10
15
  export function getShimPath(dir = path.dirname(fileURLToPath(import.meta.url))) {
11
16
  const jsPath = path.join(dir, "next_image_shim.js");
12
17
  if (existsSync(jsPath))
@@ -16,10 +21,12 @@ export function getShimPath(dir = path.dirname(fileURLToPath(import.meta.url)))
16
21
  export function imageOptimizerPlugin(options) {
17
22
  const resolved = resolveOptions(options);
18
23
  let command = "build";
24
+ let viteConfig;
19
25
  return {
20
26
  name: "cloudflare-next-intl-image-optimizer",
21
27
  enforce: "pre",
22
28
  configResolved(config) {
29
+ viteConfig = config;
23
30
  command = config.command === "serve" ? "serve" : "build";
24
31
  },
25
32
  resolveId(id) {
@@ -60,20 +67,78 @@ export function imageOptimizerPlugin(options) {
60
67
  return undefined;
61
68
  if (id === getShimPath())
62
69
  return undefined;
63
- if (!/from\s*["']next\/image["']/.test(code))
64
- return undefined;
65
- const next = code.replace(/(import\s+(?!type\s)[^;]*?from\s*)(["'])next\/image\2/g, `$1$2${VIRTUAL_IMAGE_SHIM_ID}$2`);
66
- return next === code ? undefined : { code: next, map: null };
70
+ let transformedCode = code;
71
+ const root = process.cwd();
72
+ const activeManifest = sharedManifestByRoot.get(root);
73
+ if (command === "build" && resolved.optimizeMetadata && activeManifest && Object.keys(activeManifest).length > 0) {
74
+ const metaReplaced = transformMetadataImageUrls(transformedCode, activeManifest);
75
+ if (metaReplaced !== undefined) {
76
+ transformedCode = metaReplaced;
77
+ }
78
+ }
79
+ if (/from\s*["']next\/image["']/.test(transformedCode)) {
80
+ transformedCode = transformedCode.replace(/(import\s+(?!type\s)[^;]*?from\s*)(["'])next\/image\2/g, `$1$2${VIRTUAL_IMAGE_SHIM_ID}$2`);
81
+ }
82
+ return transformedCode === code ? undefined : { code: transformedCode, map: null };
67
83
  },
68
84
  async buildStart() {
69
85
  if (!resolved.enabled)
70
86
  return;
87
+ const root = process.cwd();
88
+ const manifestPath = path.resolve(root, resolved.manifest);
89
+ if (existsSync(manifestPath)) {
90
+ try {
91
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
92
+ if (parsed?.images && typeof parsed.images === "object") {
93
+ sharedManifestByRoot.set(root, parsed.images);
94
+ }
95
+ }
96
+ catch {
97
+ }
98
+ }
71
99
  if (command === "serve" && !resolved.dev)
72
100
  return;
73
- const root = process.cwd();
74
101
  const cacheFile = path.resolve(root, resolved.cacheDir, "manifest.json");
75
102
  const entries = await run(root, resolved, cacheFile);
103
+ const manifestImages = {};
104
+ for (const entry of entries) {
105
+ manifestImages[entry.originalSrc] = { src: entry.src };
106
+ }
107
+ sharedManifestByRoot.set(root, manifestImages);
76
108
  this.info?.(`[cloudflare-next-intl] image-optimizer: ${entries.length} images in ${resolved.manifest}`);
109
+ if (command === "build" && resolved.pruneOriginalsFromBuild) {
110
+ const classifications = await classifyImageUsages(root, "public");
111
+ const prunableOriginals = getPrunableOriginalPaths(classifications, manifestImages, resolved.optimizeMetadata);
112
+ sharedPrunableByRoot.set(root, prunableOriginals);
113
+ }
114
+ },
115
+ closeBundle() {
116
+ if (command !== "build" || !resolved.enabled || !resolved.pruneOriginalsFromBuild)
117
+ return;
118
+ const root = process.cwd();
119
+ const prunable = sharedPrunableByRoot.get(root);
120
+ if (!prunable || prunable.length === 0)
121
+ return;
122
+ const outDirs = new Set();
123
+ const clientDir = path.resolve(root, "dist/client");
124
+ if (existsSync(clientDir)) {
125
+ outDirs.add(clientDir);
126
+ }
127
+ if (viteConfig?.build?.outDir) {
128
+ const outDir = path.resolve(root, viteConfig.build.outDir);
129
+ if (!outDir.endsWith("server") && !outDir.endsWith("dist") && existsSync(outDir)) {
130
+ outDirs.add(outDir);
131
+ }
132
+ }
133
+ if (outDirs.size === 0)
134
+ return;
135
+ const { prunedFiles, bytesSaved } = pruneBuildOutput(Array.from(outDirs), prunable);
136
+ if (prunedFiles.length > 0) {
137
+ const mb = (bytesSaved / (1024 * 1024)).toFixed(2);
138
+ const kb = (bytesSaved / 1024).toFixed(1);
139
+ const sizeStr = bytesSaved >= 1024 * 1024 ? `${mb} MB` : `${kb} kB`;
140
+ this.info?.(`[cloudflare-next-intl] image-optimizer: pruned ${prunedFiles.length} unoptimized images from build output (${sizeStr} saved)`);
141
+ }
77
142
  },
78
143
  };
79
144
  }
@@ -0,0 +1,5 @@
1
+ export interface PruneResult {
2
+ prunedFiles: string[];
3
+ bytesSaved: number;
4
+ }
5
+ export declare function pruneBuildOutput(outDirs: string[], prunableRelativePaths: string[]): PruneResult;
@@ -0,0 +1,36 @@
1
+ import { existsSync, readdirSync, rmdirSync, statSync, unlinkSync } from "node:fs";
2
+ import path from "node:path";
3
+ export function pruneBuildOutput(outDirs, prunableRelativePaths) {
4
+ const prunedFiles = [];
5
+ let bytesSaved = 0;
6
+ for (const outDir of outDirs) {
7
+ if (!existsSync(outDir))
8
+ continue;
9
+ for (const relPath of prunableRelativePaths) {
10
+ const cleanRel = relPath.replace(/^\/+/, "");
11
+ const fullPath = path.resolve(outDir, cleanRel);
12
+ if (!fullPath.startsWith(outDir))
13
+ continue;
14
+ if (existsSync(fullPath)) {
15
+ const st = statSync(fullPath);
16
+ if (st.isFile()) {
17
+ bytesSaved += st.size;
18
+ unlinkSync(fullPath);
19
+ prunedFiles.push(fullPath);
20
+ let parent = path.dirname(fullPath);
21
+ while (parent !== outDir && parent.startsWith(outDir)) {
22
+ const remaining = readdirSync(parent);
23
+ if (remaining.length === 0) {
24
+ rmdirSync(parent);
25
+ parent = path.dirname(parent);
26
+ }
27
+ else {
28
+ break;
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
35
+ return { prunedFiles, bytesSaved };
36
+ }
@@ -1,8 +1,27 @@
1
1
  import type { ImageOverrideOptions } from "./types.js";
2
2
  export declare const CODE_EXTENSIONS: readonly string[];
3
+ export declare const ASSET_EXTENSIONS: readonly string[];
4
+ export declare const SCANNED_EXTENSIONS: readonly string[];
3
5
  export declare const IGNORED_DIRS: ReadonlySet<string>;
4
- export declare function findCodeFiles(dir: string, found?: string[]): Promise<string[]>;
6
+ export declare const IGNORED_FILES: ReadonlySet<string>;
7
+ export declare function findCodeFiles(dir: string, found?: string[], extensions?: readonly string[]): Promise<string[]>;
5
8
  export declare function extractImageReferences(code: string): string[];
6
9
  export declare function extractImageOverrides(code: string): Record<string, ImageOverrideOptions>;
7
10
  export declare function collectUsedImageOverrides(root: string, publicDir?: string): Promise<Record<string, ImageOverrideOptions>>;
8
11
  export declare function collectUsedImages(root: string, publicDir?: string): Promise<string[]>;
12
+ export interface ImageUsageDetails {
13
+ imageComponentRefs: Set<string>;
14
+ metadataRefs: Set<string>;
15
+ otherRefs: Set<string>;
16
+ }
17
+ export interface ImageUsageClassification {
18
+ canonicalSrc: string;
19
+ inImageComponent: boolean;
20
+ inMetadata: boolean;
21
+ inOtherPlace: boolean;
22
+ }
23
+ export declare function extractImageUsageDetails(code: string, filePath?: string): ImageUsageDetails;
24
+ export declare function classifyImageUsages(root: string, publicDir?: string): Promise<Record<string, ImageUsageClassification>>;
25
+ export declare function getPrunableOriginalPaths(classifications: Record<string, ImageUsageClassification>, manifestImages: Record<string, {
26
+ src: string;
27
+ }>, optimizeMetadata: boolean): string[];
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { getMetadataRanges } from "./metadata_transform.js";
3
4
  export const CODE_EXTENSIONS = [
4
5
  ".tsx",
5
6
  ".ts",
@@ -13,6 +14,18 @@ export const CODE_EXTENSIONS = [
13
14
  ".mdx",
14
15
  ".html",
15
16
  ];
17
+ export const ASSET_EXTENSIONS = [
18
+ ".css",
19
+ ".scss",
20
+ ".sass",
21
+ ".less",
22
+ ".json",
23
+ ".md",
24
+ ];
25
+ export const SCANNED_EXTENSIONS = [
26
+ ...CODE_EXTENSIONS,
27
+ ...ASSET_EXTENSIONS,
28
+ ];
16
29
  export const IGNORED_DIRS = new Set([
17
30
  "node_modules",
18
31
  ".git",
@@ -20,12 +33,38 @@ export const IGNORED_DIRS = new Set([
20
33
  ".vinext",
21
34
  "dist",
22
35
  "build",
36
+ "out",
37
+ "boundled",
38
+ "bundled",
39
+ ".wrangler",
40
+ ".open-next",
23
41
  "coverage",
24
42
  ".cache",
25
43
  ".skeleton-diff",
44
+ ".superpowers",
45
+ "scripts",
46
+ "tmp",
47
+ ".tmp_figma",
26
48
  "generated",
49
+ "docs",
50
+ ".agent",
51
+ ".claude",
52
+ "screens",
53
+ "e2e",
54
+ "test",
55
+ "tests",
56
+ ".husky",
57
+ ".github",
58
+ ]);
59
+ export const IGNORED_FILES = new Set([
60
+ "package-lock.json",
61
+ "yarn.lock",
62
+ "pnpm-lock.yaml",
63
+ "bun.lockb",
64
+ "shrinkwrap.yaml",
65
+ "images.json",
27
66
  ]);
28
- export async function findCodeFiles(dir, found = []) {
67
+ export async function findCodeFiles(dir, found = [], extensions = CODE_EXTENSIONS) {
29
68
  let items;
30
69
  try {
31
70
  items = await readdir(dir, { withFileTypes: true });
@@ -36,11 +75,13 @@ export async function findCodeFiles(dir, found = []) {
36
75
  for (const item of items) {
37
76
  if (IGNORED_DIRS.has(item.name))
38
77
  continue;
78
+ if (IGNORED_FILES.has(item.name))
79
+ continue;
39
80
  const full = path.join(dir, item.name);
40
81
  if (item.isDirectory()) {
41
- await findCodeFiles(full, found);
82
+ await findCodeFiles(full, found, extensions);
42
83
  }
43
- else if (CODE_EXTENSIONS.includes(path.extname(item.name).toLowerCase())) {
84
+ else if (extensions.includes(path.extname(item.name).toLowerCase())) {
44
85
  found.push(full);
45
86
  }
46
87
  }
@@ -146,7 +187,7 @@ export function extractImageOverrides(code) {
146
187
  }
147
188
  return overrides;
148
189
  }
149
- async function collectCodeFiles(root, publicDir) {
190
+ async function collectCodeFiles(root, publicDir, extensions = CODE_EXTENSIONS) {
150
191
  let rootItems;
151
192
  try {
152
193
  rootItems = await readdir(root, { withFileTypes: true });
@@ -158,13 +199,15 @@ async function collectCodeFiles(root, publicDir) {
158
199
  for (const item of rootItems) {
159
200
  if (IGNORED_DIRS.has(item.name))
160
201
  continue;
202
+ if (IGNORED_FILES.has(item.name))
203
+ continue;
161
204
  if (item.name === publicDir)
162
205
  continue;
163
206
  const full = path.join(root, item.name);
164
207
  if (item.isDirectory()) {
165
- await findCodeFiles(full, codeFiles);
208
+ await findCodeFiles(full, codeFiles, extensions);
166
209
  }
167
- else if (CODE_EXTENSIONS.includes(path.extname(item.name).toLowerCase())) {
210
+ else if (extensions.includes(path.extname(item.name).toLowerCase())) {
168
211
  codeFiles.push(full);
169
212
  }
170
213
  }
@@ -173,10 +216,13 @@ async function collectCodeFiles(root, publicDir) {
173
216
  function resolvePublicSrc(ref) {
174
217
  const cleanRef = ref.split("?")[0].split("#")[0];
175
218
  let relative = cleanRef;
176
- if (relative.startsWith("/"))
219
+ const publicIdx = relative.indexOf("public/");
220
+ if (publicIdx !== -1) {
221
+ relative = relative.slice(publicIdx + "public/".length);
222
+ }
223
+ else if (relative.startsWith("/")) {
177
224
  relative = relative.slice(1);
178
- if (relative.startsWith("public/"))
179
- relative = relative.slice("public/".length);
225
+ }
180
226
  return `/${relative}`;
181
227
  }
182
228
  export async function collectUsedImageOverrides(root, publicDir = "public") {
@@ -207,10 +253,13 @@ export async function collectUsedImages(root, publicDir = "public") {
207
253
  for (const ref of referenced) {
208
254
  const cleanRef = ref.split("?")[0].split("#")[0];
209
255
  let relativeInPublic = cleanRef;
210
- if (relativeInPublic.startsWith("/"))
256
+ const publicIdx = relativeInPublic.indexOf("public/");
257
+ if (publicIdx !== -1) {
258
+ relativeInPublic = relativeInPublic.slice(publicIdx + "public/".length);
259
+ }
260
+ else if (relativeInPublic.startsWith("/")) {
211
261
  relativeInPublic = relativeInPublic.slice(1);
212
- if (relativeInPublic.startsWith("public/"))
213
- relativeInPublic = relativeInPublic.slice("public/".length);
262
+ }
214
263
  const candidate = path.resolve(publicRoot, relativeInPublic);
215
264
  if (candidate.startsWith(publicRoot)) {
216
265
  resolvedFiles.add(candidate);
@@ -229,3 +278,135 @@ export async function collectUsedImages(root, publicDir = "public") {
229
278
  }
230
279
  return existing.sort();
231
280
  }
281
+ export function extractImageUsageDetails(code, filePath) {
282
+ const imageComponentRefs = new Set();
283
+ const metadataRefs = new Set();
284
+ const otherRefs = new Set();
285
+ const ext = path.extname(filePath || "").toLowerCase();
286
+ const isNonCodeAsset = ASSET_EXTENSIONS.includes(ext);
287
+ if (isNonCodeAsset) {
288
+ for (const ref of extractImageReferences(code)) {
289
+ otherRefs.add(ref);
290
+ }
291
+ return { imageComponentRefs, metadataRefs, otherRefs };
292
+ }
293
+ const importRanges = [];
294
+ const importRegex = /(?:import\s+(?:type\s+)?[\w\s{},*]+\s+from\s*|require\s*\(\s*)["'`]([^"'`\s\n\r#?]+\.(?:png|jpg|jpeg|webp|avif))["'`]\)?/g;
295
+ let impMatch;
296
+ while ((impMatch = importRegex.exec(code)) !== null) {
297
+ importRanges.push({ start: impMatch.index, end: impMatch.index + impMatch[0].length });
298
+ imageComponentRefs.add(impMatch[1]);
299
+ }
300
+ const imageRanges = [];
301
+ const tagPattern = /<Image\b([^>]*)\/?>/gs;
302
+ let tagMatch;
303
+ while ((tagMatch = tagPattern.exec(code)) !== null) {
304
+ imageRanges.push({ start: tagMatch.index, end: tagMatch.index + tagMatch[0].length });
305
+ const attrs = tagMatch[1];
306
+ const literalMatch = attrs.match(/\bsrc\s*=\s*(?:["'`]([^"'`]+)["'`]|\{\s*["'`]([^"'`]+)["'`]\s*\})/);
307
+ if (literalMatch) {
308
+ const raw = literalMatch[1] ?? literalMatch[2];
309
+ if (raw)
310
+ imageComponentRefs.add(raw);
311
+ }
312
+ else {
313
+ const identMatch = attrs.match(/\bsrc\s*=\s*\{\s*([a-zA-Z0-9_$]+)\s*\}/);
314
+ if (identMatch) {
315
+ const varName = identMatch[1];
316
+ const declRegex = new RegExp(`(?:const|let|var)\\s+${varName}\\s*=\\s*["'\`]([^"'\`]+)["'\`]`, "g");
317
+ const declMatch = declRegex.exec(code);
318
+ const importRegex = new RegExp(`import\\s+${varName}\\s+from\\s+["'\`]([^"'\`]+)["'\`]`, "g");
319
+ const importMatch = importRegex.exec(code);
320
+ const matchedSrc = declMatch?.[1] ?? importMatch?.[1];
321
+ const matchedRange = declMatch
322
+ ? { start: declMatch.index, end: declMatch.index + declMatch[0].length }
323
+ : importMatch
324
+ ? { start: importMatch.index, end: importMatch.index + importMatch[0].length }
325
+ : null;
326
+ if (matchedSrc && matchedRange) {
327
+ const identRegex = new RegExp(`\\b${varName}\\b`, "g");
328
+ const totalMatches = code.match(identRegex).length;
329
+ let inImageCount = 0;
330
+ const allTags = code.matchAll(/<Image\b([^>]*)\/?>/gs);
331
+ for (const m of allTags) {
332
+ if (m[1].includes(varName))
333
+ inImageCount++;
334
+ }
335
+ if (totalMatches <= inImageCount + 1) {
336
+ imageComponentRefs.add(matchedSrc);
337
+ imageRanges.push(matchedRange);
338
+ }
339
+ }
340
+ }
341
+ }
342
+ }
343
+ const metadataRanges = getMetadataRanges(code);
344
+ for (const range of metadataRanges) {
345
+ const block = code.slice(range.start, range.end + 1);
346
+ const refs = extractImageReferences(block);
347
+ for (const ref of refs) {
348
+ metadataRefs.add(ref);
349
+ }
350
+ }
351
+ const pattern = /(?:["'`])([^"'`\s\n\r#?]+\.(?:png|jpg|jpeg|webp|avif))(?:\?[^"'`\s]*|#[^"'`\s]*)?(?:["'`])/gi;
352
+ let match;
353
+ while ((match = pattern.exec(code)) !== null) {
354
+ const raw = match[1];
355
+ const offset = match.index;
356
+ const inImp = importRanges.some((r) => offset >= r.start && offset < r.end);
357
+ const inImage = inImp || imageRanges.some((r) => offset >= r.start && offset < r.end);
358
+ const inMeta = metadataRanges.some((r) => offset >= r.start && offset < r.end);
359
+ if (!inImage && !inMeta) {
360
+ otherRefs.add(raw);
361
+ }
362
+ }
363
+ return { imageComponentRefs, metadataRefs, otherRefs };
364
+ }
365
+ export async function classifyImageUsages(root, publicDir = "public") {
366
+ const codeFiles = await collectCodeFiles(root, publicDir, SCANNED_EXTENSIONS);
367
+ const classifications = {};
368
+ function getOrCreate(canonicalSrc) {
369
+ if (!classifications[canonicalSrc]) {
370
+ classifications[canonicalSrc] = {
371
+ canonicalSrc,
372
+ inImageComponent: false,
373
+ inMetadata: false,
374
+ inOtherPlace: false,
375
+ };
376
+ }
377
+ return classifications[canonicalSrc];
378
+ }
379
+ for (const file of codeFiles) {
380
+ const content = await readFile(file, "utf8").catch(() => "");
381
+ const details = extractImageUsageDetails(content, file);
382
+ for (const ref of details.imageComponentRefs) {
383
+ const canonical = resolvePublicSrc(ref);
384
+ getOrCreate(canonical).inImageComponent = true;
385
+ }
386
+ for (const ref of details.metadataRefs) {
387
+ const canonical = resolvePublicSrc(ref);
388
+ getOrCreate(canonical).inMetadata = true;
389
+ }
390
+ for (const ref of details.otherRefs) {
391
+ const canonical = resolvePublicSrc(ref);
392
+ getOrCreate(canonical).inOtherPlace = true;
393
+ }
394
+ }
395
+ return classifications;
396
+ }
397
+ export function getPrunableOriginalPaths(classifications, manifestImages, optimizeMetadata) {
398
+ const prunable = [];
399
+ for (const [canonicalSrc, usage] of Object.entries(classifications)) {
400
+ if (!manifestImages[canonicalSrc])
401
+ continue;
402
+ if (usage.inOtherPlace)
403
+ continue;
404
+ const canPrune = optimizeMetadata
405
+ ? (usage.inImageComponent || usage.inMetadata)
406
+ : (usage.inImageComponent && !usage.inMetadata);
407
+ if (canPrune) {
408
+ prunable.push(canonicalSrc.replace(/^\/+/, ""));
409
+ }
410
+ }
411
+ return prunable.sort();
412
+ }
@@ -30,6 +30,8 @@ export interface ImageOptimizerPluginOptions {
30
30
  concurrency?: number;
31
31
  effort?: number;
32
32
  autoImageLoader?: boolean | AutoImageLoaderOptions;
33
+ optimizeMetadata?: boolean;
34
+ pruneOriginalsFromBuild?: boolean;
33
35
  }
34
36
  export interface ResolvedBlurOptions {
35
37
  enabled: boolean;
@@ -53,6 +55,8 @@ export interface ResolvedOptions {
53
55
  concurrency: number;
54
56
  effort: number | undefined;
55
57
  autoImageLoader: boolean | AutoImageLoaderOptions;
58
+ optimizeMetadata: boolean;
59
+ pruneOriginalsFromBuild: boolean;
56
60
  }
57
61
  export interface ResolvedImageConfig {
58
62
  maxWidth: number | false;
@@ -22,6 +22,8 @@ export const DEFAULT_OPTIONS = {
22
22
  concurrency: Math.max(1, Math.min(cpus().length, 8)),
23
23
  effort: undefined,
24
24
  autoImageLoader: true,
25
+ optimizeMetadata: true,
26
+ pruneOriginalsFromBuild: true,
25
27
  };
26
28
  export function resolveBlurOptions(blur, parentDefault = DEFAULT_BLUR_OPTIONS) {
27
29
  if (blur === false) {
@@ -61,6 +63,8 @@ export function resolveOptions(options) {
61
63
  concurrency: Math.max(1, raw.concurrency ?? DEFAULT_OPTIONS.concurrency),
62
64
  effort: raw.effort,
63
65
  autoImageLoader: raw.autoImageLoader ?? DEFAULT_OPTIONS.autoImageLoader,
66
+ optimizeMetadata: raw.optimizeMetadata ?? DEFAULT_OPTIONS.optimizeMetadata,
67
+ pruneOriginalsFromBuild: raw.pruneOriginalsFromBuild ?? DEFAULT_OPTIONS.pruneOriginalsFromBuild,
64
68
  };
65
69
  }
66
70
  export function resolveImageConfig(publicSrc, options) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.13.5",
3
+ "version": "0.14.0",
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",
@@ -368,7 +368,7 @@
368
368
  "cloudflare-next-intl-db": "^0.3.0",
369
369
  "cloudflare-next-intl-db-codegen": "^0.1.4",
370
370
  "cloudflare-next-intl-errors": "^0.1.0",
371
- "cloudflare-next-intl-intl": "^0.2.0",
371
+ "cloudflare-next-intl-intl": "^0.2.1",
372
372
  "cloudflare-next-intl-theme": "^0.1.0",
373
373
  "sharp": "^0.34.5 || ^0.35.0"
374
374
  },