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