cloudflare-next-intl 0.8.57 → 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 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.
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.
@@ -310,6 +310,20 @@ import { Image } from "cloudflare-next-intl/image";
310
310
 
311
311
  Supported props: `formats` (array or `false`), `maxWidth` (number or `false`), `quality` (number), `blur` (`true`, `false`, or `{ size, quality, stdDeviation }`).
312
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
+
313
327
  ##### Browser Format Negotiation
314
328
 
315
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:
@@ -2,6 +2,23 @@ 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
@@ -51,23 +68,24 @@ function resolveProps(props) {
51
68
  let style = props.style;
52
69
  const entry = findEntry(src);
53
70
  if (entry) {
54
- if (entry.src) {
71
+ const variant = pickVariant(entry, typeof props.width === "number" ? props.width : undefined);
72
+ if (variant.src) {
55
73
  if (typeof src === "string") {
56
- src = entry.src;
74
+ src = variant.src;
57
75
  }
58
76
  else if (typeof src === "object" && src !== null && "src" in src) {
59
- src = { ...src, src: entry.src };
77
+ src = { ...src, src: variant.src };
60
78
  }
61
79
  else {
62
- src = entry.src;
80
+ src = variant.src;
63
81
  }
64
82
  }
65
- if (!blurDataURL && props.placeholder === "blur" && entry.blurDataURL) {
66
- 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);
67
85
  }
68
- if (!width && !props.fill && entry.width) {
69
- width = entry.width;
70
- height = entry.height;
86
+ if (!width && !props.fill && variant.width) {
87
+ width = variant.width;
88
+ height = variant.height;
71
89
  }
72
90
  }
73
91
  if (props.placeholder === "blur" && blurDataURL) {
@@ -85,12 +103,15 @@ function resolveProps(props) {
85
103
  export default function Image(props) {
86
104
  const resolved = resolveProps(props);
87
105
  const entry = findEntry(props.src);
88
- const alternates = (entry?.sources ?? []).filter((source) => source.src !== entry?.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);
89
110
  if (alternates.length === 0) {
90
111
  return _jsx(NextImage, { ...resolved });
91
112
  }
92
113
  const { props: imgProps } = nextGetImageProps(resolved);
93
- const primarySrc = entry?.src ?? String(imgProps.src);
114
+ const primarySrc = variant?.src ?? String(imgProps.src);
94
115
  const originalSrc = entry?.originalSrc;
95
116
  const onError = (event) => {
96
117
  const img = event.currentTarget;
@@ -1,4 +1,5 @@
1
1
  import type { ImageFormat, OptimizedImage, OptimizedImageSource, ResolvedBlurOptions, ResolvedOptions } from "./types.js";
2
+ export declare const EXTENSION_BY_FORMAT: Record<ImageFormat, string>;
2
3
  export declare function mimeTypeFor(format: ImageFormat | "original", originalSrc: string): string;
3
4
  /**
4
5
  * <picture> tries <source> tags in document order, so sources must follow the
@@ -10,6 +11,8 @@ export declare function toGeneratedPath(absolutePath: string, publicRoot: string
10
11
  targetFile: string;
11
12
  targetSrc: string;
12
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;
13
16
  export declare function makeBlurDataURL(targetFile: string, sourceWidth: number, sourceHeight: number, blurOptions: ResolvedBlurOptions): Promise<{
14
17
  blurDataURL: string;
15
18
  blurWidth: number;
@@ -27,7 +27,7 @@ const MIME_BY_EXTENSION = {
27
27
  ".jp2": "image/jp2",
28
28
  ".jxl": "image/jxl",
29
29
  };
30
- const EXTENSION_BY_FORMAT = {
30
+ export const EXTENSION_BY_FORMAT = {
31
31
  avif: "avif",
32
32
  webp: "webp",
33
33
  png: "png",
@@ -64,6 +64,12 @@ export function toGeneratedPath(absolutePath, publicRoot, outDir, root) {
64
64
  const targetSrc = `/${path.join(outDirRelativePublic, relative).split(path.sep).join("/")}`;
65
65
  return { targetFile, targetSrc };
66
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
+ }
67
73
  export async function makeBlurDataURL(targetFile, sourceWidth, sourceHeight, blurOptions) {
68
74
  const blurFile = targetFile.replace(/\.[^.]+$/, ".blur.webp");
69
75
  let blurWidth;
@@ -87,10 +93,10 @@ export async function makeBlurDataURL(targetFile, sourceWidth, sourceHeight, blu
87
93
  blurHeight,
88
94
  };
89
95
  }
90
- async function encodeFormat(targetFile, sourcePath, format, quality, maxWidth, needsResize) {
96
+ async function encodeFormat(targetFile, sourcePath, format, quality, targetWidth) {
91
97
  let pipeline = sharp(sourcePath);
92
- if (needsResize) {
93
- pipeline = pipeline.resize({ width: maxWidth });
98
+ if (targetWidth !== undefined) {
99
+ pipeline = pipeline.resize({ width: targetWidth });
94
100
  }
95
101
  let encoded;
96
102
  if (format === "avif") {
@@ -128,40 +134,32 @@ async function encodeFormat(targetFile, sourcePath, format, quality, maxWidth, n
128
134
  }
129
135
  await encoded.toFile(targetFile);
130
136
  }
131
- export async function processImage(absolutePath, publicRoot, options, root = path.dirname(publicRoot)) {
132
- const publicSrc = toPublicSrc(absolutePath, publicRoot);
133
- const config = resolveImageConfig(publicSrc, options);
134
- const metadata = await sharp(absolutePath).metadata();
135
- const sourceWidth = metadata.width;
136
- const sourceHeight = metadata.height;
137
- const needsResize = typeof config.maxWidth === "number" && sourceWidth > config.maxWidth;
138
- const width = needsResize ? config.maxWidth : sourceWidth;
139
- const height = needsResize
140
- ? Math.round((sourceHeight * config.maxWidth) / sourceWidth)
141
- : sourceHeight;
142
- const { targetFile, targetSrc } = toGeneratedPath(absolutePath, publicRoot, options.outDir, root);
143
- await mkdir(path.dirname(targetFile), { recursive: true });
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;
144
146
  const primaryFormat = config.formats.length > 0
145
147
  ? config.formats[0]
146
148
  : "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);
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);
154
152
  const sources = [
155
153
  { format: primaryFormat, src: primarySrc, type: mimeTypeFor(primaryFormat, publicSrc) },
156
154
  ];
157
155
  for (let i = 1; i < config.formats.length; i++) {
158
156
  const format = config.formats[i];
159
157
  const ext = EXTENSION_BY_FORMAT[format];
160
- const siblingFile = targetFile.replace(/\.[^.]+$/, `.${ext}`);
161
- await encodeFormat(siblingFile, absolutePath, format, config.quality, config.maxWidth, needsResize);
158
+ const siblingFile = withWidthSuffix(targetFile.replace(/\.[^.]+$/, `.${ext}`), width, isDefault);
159
+ await encodeFormat(siblingFile, absolutePath, format, config.quality, targetWidth);
162
160
  sources.push({
163
161
  format,
164
- src: targetSrc.replace(/\.[^.]+$/, `.${ext}`),
162
+ src: withWidthSuffix(targetSrc.replace(/\.[^.]+$/, `.${ext}`), width, isDefault),
165
163
  type: mimeTypeFor(format, publicSrc),
166
164
  });
167
165
  }
@@ -170,13 +168,36 @@ export async function processImage(absolutePath, publicRoot, options, root = pat
170
168
  blurResult = await makeBlurDataURL(primaryFile, width, height, config.blur);
171
169
  }
172
170
  return {
173
- originalSrc: publicSrc,
174
- src: primarySrc,
175
- sources: sortSources(sources, config.formats),
176
171
  width,
177
172
  height,
173
+ src: primarySrc,
174
+ sources: sortSources(sources, config.formats),
178
175
  blurDataURL: blurResult?.blurDataURL,
179
176
  blurWidth: blurResult?.blurWidth,
180
177
  blurHeight: blurResult?.blurHeight,
181
178
  };
182
179
  }
180
+ export async function processImage(absolutePath, publicRoot, options, root = path.dirname(publicRoot)) {
181
+ const publicSrc = toPublicSrc(absolutePath, publicRoot);
182
+ const config = resolveImageConfig(publicSrc, options);
183
+ const metadata = await sharp(absolutePath).metadata();
184
+ const sourceWidth = metadata.width;
185
+ const sourceHeight = metadata.height;
186
+ const { targetFile, targetSrc } = toGeneratedPath(absolutePath, publicRoot, options.outDir, root);
187
+ await mkdir(path.dirname(targetFile), { recursive: true });
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));
197
+ }
198
+ return {
199
+ originalSrc: publicSrc,
200
+ ...defaultVariant,
201
+ variants: variants.length > 1 ? variants : undefined,
202
+ };
203
+ }
@@ -1,6 +1,6 @@
1
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[];
3
+ export declare function targetAndSiblingPaths(absolutePath: string, publicRoot: string, options: ResolvedOptions, root: string): Promise<string[]>;
4
4
  /**
5
5
  * Merges optimizer overrides scanned from <Image> JSX props with the plugin's
6
6
  * centralized `overrides` config, so settings can live at the usage site. An
@@ -1,8 +1,9 @@
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";
6
7
  import { collectUsedImageOverrides, collectUsedImages } from "./scan_used.js";
7
8
  import { resolveImageConfig, SUPPORTED_EXTENSIONS } from "./types.js";
8
9
  async function walk(directory, found) {
@@ -31,26 +32,40 @@ export async function collectImages(dirs, root) {
31
32
  }
32
33
  return found.sort();
33
34
  }
34
- export function targetAndSiblingPaths(absolutePath, publicRoot, options, root) {
35
- const publicSrc = toPublicSrc(absolutePath, publicRoot);
36
- const config = resolveImageConfig(publicSrc, options);
37
- const { targetFile } = toGeneratedPath(absolutePath, publicRoot, options.outDir, root);
35
+ function variantTargetPaths(targetFile, config, width, isDefault) {
38
36
  const primaryFormat = config.formats.length > 0
39
37
  ? config.formats[0]
40
38
  : "original";
41
- const primaryFile = primaryFormat === "original"
42
- ? targetFile
43
- : targetFile.replace(/\.[^.]+$/, `.${primaryFormat}`);
39
+ const primaryExt = primaryFormat === "original" ? undefined : EXTENSION_BY_FORMAT[primaryFormat];
40
+ const primaryFile = withWidthSuffix(primaryExt ? targetFile.replace(/\.[^.]+$/, `.${primaryExt}`) : targetFile, width, isDefault);
44
41
  const result = [primaryFile];
45
42
  for (let i = 1; i < config.formats.length; i++) {
46
- const format = config.formats[i];
47
- result.push(targetFile.replace(/\.[^.]+$/, `.${format}`));
43
+ const ext = EXTENSION_BY_FORMAT[config.formats[i]];
44
+ result.push(withWidthSuffix(targetFile.replace(/\.[^.]+$/, `.${ext}`), width, isDefault));
48
45
  }
49
46
  if (config.blur.enabled) {
50
47
  result.push(primaryFile.replace(/\.[^.]+$/, ".blur.webp"));
51
48
  }
52
49
  return result;
53
50
  }
51
+ export async function targetAndSiblingPaths(absolutePath, publicRoot, options, root) {
52
+ const publicSrc = toPublicSrc(absolutePath, publicRoot);
53
+ const config = resolveImageConfig(publicSrc, options);
54
+ const { targetFile } = toGeneratedPath(absolutePath, publicRoot, options.outDir, root);
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));
66
+ }
67
+ return result;
68
+ }
54
69
  /**
55
70
  * Merges optimizer overrides scanned from <Image> JSX props with the plugin's
56
71
  * centralized `overrides` config, so settings can live at the usage site. An
@@ -59,7 +74,14 @@ export function targetAndSiblingPaths(absolutePath, publicRoot, options, root) {
59
74
  export function mergeOverrides(scanned, configured) {
60
75
  const merged = { ...scanned };
61
76
  for (const [src, override] of Object.entries(configured)) {
62
- merged[src] = { ...merged[src], ...override };
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
+ }
63
85
  }
64
86
  return merged;
65
87
  }
@@ -87,7 +109,7 @@ export async function run(root, options, cacheFile = path.resolve(root, options.
87
109
  for (const file of files) {
88
110
  const relativeKey = path.relative(root, file);
89
111
  const cached = cache[relativeKey];
90
- const targets = targetAndSiblingPaths(file, publicRoot, resolvedOptions, root);
112
+ const targets = await targetAndSiblingPaths(file, publicRoot, resolvedOptions, root);
91
113
  const fresh = await isFresh(file, cached, targets);
92
114
  if (fresh && cached) {
93
115
  entries.push(cached.result);
@@ -7,7 +7,11 @@ export declare function extractImageReferences(code: string): string[];
7
7
  * Scans JSX/TSX source for <Image> tags carrying per-image optimizer props
8
8
  * (formats / blur / quality / maxWidth) and turns them into override entries
9
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.
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.
11
15
  */
12
16
  export declare function extractImageOverrides(code: string): Record<string, ImageOverrideOptions>;
13
17
  /**
@@ -57,6 +57,17 @@ export function extractImageReferences(code) {
57
57
  }
58
58
  return Array.from(refs);
59
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
+ }
60
71
  const IMAGE_FORMATS = [
61
72
  "avif", "webp", "png", "jpeg", "gif", "tiff", "heif", "jp2", "jxl",
62
73
  ];
@@ -96,7 +107,11 @@ function parseBlurAttr(raw) {
96
107
  * Scans JSX/TSX source for <Image> tags carrying per-image optimizer props
97
108
  * (formats / blur / quality / maxWidth) and turns them into override entries
98
109
  * 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.
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.
100
115
  */
101
116
  export function extractImageOverrides(code) {
102
117
  const overrides = {};
@@ -122,6 +137,10 @@ export function extractImageOverrides(code) {
122
137
  if (parsed !== undefined)
123
138
  override.maxWidth = parsed;
124
139
  }
140
+ const widthMatch = attrs.match(/\bwidth\s*=\s*\{?(\d+)\}?/);
141
+ if (widthMatch) {
142
+ override.extraWidths = [Number(widthMatch[1])];
143
+ }
125
144
  const qualityMatch = attrs.match(/\bquality\s*=\s*\{?(\d+)\}?/);
126
145
  if (qualityMatch) {
127
146
  override.quality = Number(qualityMatch[1]);
@@ -133,7 +152,7 @@ export function extractImageOverrides(code) {
133
152
  override.blur = parsed;
134
153
  }
135
154
  if (Object.keys(override).length > 0) {
136
- overrides[src] = { ...overrides[src], ...override };
155
+ mergeOverrideInto(overrides, src, override);
137
156
  }
138
157
  }
139
158
  return overrides;
@@ -184,7 +203,7 @@ export async function collectUsedImageOverrides(root, publicDir = "public") {
184
203
  const fileOverrides = extractImageOverrides(content);
185
204
  for (const [src, override] of Object.entries(fileOverrides)) {
186
205
  const publicSrc = resolvePublicSrc(src);
187
- overrides[publicSrc] = { ...overrides[publicSrc], ...override };
206
+ mergeOverrideInto(overrides, publicSrc, override);
188
207
  }
189
208
  }
190
209
  return overrides;
@@ -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 */
@@ -67,6 +74,7 @@ export interface ResolvedOptions {
67
74
  }
68
75
  export interface ResolvedImageConfig {
69
76
  maxWidth: number | false;
77
+ extraWidths: number[];
70
78
  quality: number;
71
79
  formats: ImageFormat[];
72
80
  blur: ResolvedBlurOptions;
@@ -76,16 +84,25 @@ export interface OptimizedImageSource {
76
84
  src: string;
77
85
  type: string;
78
86
  }
79
- export interface OptimizedImage {
80
- originalSrc: string;
81
- src: string;
82
- sources?: OptimizedImageSource[];
87
+ export interface OptimizedImageVariant {
83
88
  width: number;
84
89
  height: number;
90
+ src: string;
91
+ sources?: OptimizedImageSource[];
85
92
  blurDataURL?: string;
86
93
  blurWidth?: number;
87
94
  blurHeight?: number;
88
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
+ }
89
106
  export interface ManifestData {
90
107
  images: Record<string, OptimizedImage>;
91
108
  }
@@ -61,6 +61,7 @@ export function resolveImageConfig(publicSrc, options) {
61
61
  if (!override) {
62
62
  return {
63
63
  maxWidth: options.maxWidth,
64
+ extraWidths: [],
64
65
  quality: options.quality,
65
66
  formats: options.formats,
66
67
  blur: options.blur,
@@ -80,5 +81,6 @@ export function resolveImageConfig(publicSrc, options) {
80
81
  const blur = override.blur !== undefined
81
82
  ? resolveBlurOptions(override.blur, options.blur)
82
83
  : options.blur;
83
- return { maxWidth, quality, formats, blur };
84
+ const extraWidths = override.extraWidths ? [...override.extraWidths] : [];
85
+ return { maxWidth, extraWidths, quality, formats, blur };
84
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.8.57",
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",