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 +15 -1
- package/dist/src/image_optimizer/next_image_shim.js +32 -11
- package/dist/src/image_optimizer/process_image.d.ts +3 -0
- package/dist/src/image_optimizer/process_image.js +51 -30
- package/dist/src/image_optimizer/run.d.ts +1 -1
- package/dist/src/image_optimizer/run.js +34 -12
- package/dist/src/image_optimizer/scan_used.d.ts +5 -1
- package/dist/src/image_optimizer/scan_used.js +22 -3
- package/dist/src/image_optimizer/types.d.ts +21 -4
- package/dist/src/image_optimizer/types.js +3 -1
- package/package.json +1 -1
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
|
-
|
|
71
|
+
const variant = pickVariant(entry, typeof props.width === "number" ? props.width : undefined);
|
|
72
|
+
if (variant.src) {
|
|
55
73
|
if (typeof src === "string") {
|
|
56
|
-
src =
|
|
74
|
+
src = variant.src;
|
|
57
75
|
}
|
|
58
76
|
else if (typeof src === "object" && src !== null && "src" in src) {
|
|
59
|
-
src = { ...src, src:
|
|
77
|
+
src = { ...src, src: variant.src };
|
|
60
78
|
}
|
|
61
79
|
else {
|
|
62
|
-
src =
|
|
80
|
+
src = variant.src;
|
|
63
81
|
}
|
|
64
82
|
}
|
|
65
|
-
if (!blurDataURL && props.placeholder === "blur" &&
|
|
66
|
-
blurDataURL = getImageBlurSvg(
|
|
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 &&
|
|
69
|
-
width =
|
|
70
|
-
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
|
|
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 =
|
|
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,
|
|
96
|
+
async function encodeFormat(targetFile, sourcePath, format, quality, targetWidth) {
|
|
91
97
|
let pipeline = sharp(sourcePath);
|
|
92
|
-
if (
|
|
93
|
-
pipeline = pipeline.resize({ width:
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const width =
|
|
139
|
-
const height =
|
|
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
|
-
|
|
149
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
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
|
|
47
|
-
result.push(targetFile.replace(/\.[^.]+$/, `.${
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
84
|
+
const extraWidths = override.extraWidths ? [...override.extraWidths] : [];
|
|
85
|
+
return { maxWidth, extraWidths, quality, formats, blur };
|
|
84
86
|
}
|