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