@pracht/image 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/client.d.ts +11 -0
- package/dist/{config-BavYkV_M.mjs → config-HOu7M27N.mjs} +12 -1
- package/dist/index.d.mts +9 -2
- package/dist/index.mjs +19 -7
- package/dist/{metadata-CA97TjOR.d.mts → metadata-Dap0USIM.d.mts} +15 -1
- package/dist/node.mjs +1 -1
- package/dist/vite.d.mts +22 -5
- package/dist/vite.mjs +250 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,10 @@ next/image's loader pattern.
|
|
|
20
20
|
URL (hashed source assets or stable `publicDir` URLs), intrinsic dimensions
|
|
21
21
|
(EXIF-orientation aware), and a tiny inline `blurDataURL` for CSS-only
|
|
22
22
|
`placeholder="blur"`.
|
|
23
|
+
- Cached `?pracht&pracht-static` imports that emit responsive, content-hashed
|
|
24
|
+
WebP variants for static hosts and zero-hydration pages.
|
|
25
|
+
- `getImageProps()` for HTML compilers that need exactly the same plain
|
|
26
|
+
`<img>` attributes as the Preact component.
|
|
23
27
|
|
|
24
28
|
```bash
|
|
25
29
|
pnpm add @pracht/image
|
|
@@ -33,12 +37,24 @@ import { Image } from "@pracht/image";
|
|
|
33
37
|
```
|
|
34
38
|
|
|
35
39
|
```tsx
|
|
36
|
-
// vite.config.ts: plugins: [pracht({ … })
|
|
40
|
+
// vite.config.ts: plugins: [prachtImage(), pracht({ … })]
|
|
37
41
|
import hero from "./hero.jpg?pracht"; // { src, width, height, blurDataURL }
|
|
38
42
|
|
|
39
43
|
<Image src={hero} alt="Hero" placeholder="blur" />;
|
|
40
44
|
```
|
|
41
45
|
|
|
46
|
+
For prebuilt responsive files, import
|
|
47
|
+
`./hero.jpg?pracht&pracht-static`. Configure the candidate widths and quality
|
|
48
|
+
with `prachtImage({ staticWidths, staticQuality })`. Static metadata bypasses
|
|
49
|
+
the global runtime loader unless the component supplies an explicit `loader`.
|
|
50
|
+
Server-only route variants are published to `dist/client` by default; use
|
|
51
|
+
`staticOutDir` for a different adapter-served directory. SVG and animated
|
|
52
|
+
sources keep their original encoded bytes but are still published when only a
|
|
53
|
+
server graph discovers them. Identical pass-through sources share one hashed
|
|
54
|
+
URL while retaining a live source if another copy changes. Root-relative
|
|
55
|
+
`publicDir` sources stay unprocessed at their stable public URLs and bypass the
|
|
56
|
+
global runtime loader for static imports.
|
|
57
|
+
|
|
42
58
|
```ts
|
|
43
59
|
// src/api/_pracht/image.ts — mounts the optimization endpoint
|
|
44
60
|
import { createImageHandler } from "@pracht/image/node";
|
package/client.d.ts
CHANGED
|
@@ -17,3 +17,14 @@ declare module "*?pracht" {
|
|
|
17
17
|
export const blurDataURL: string | undefined;
|
|
18
18
|
export default metadata;
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
declare module "*?pracht&pracht-static" {
|
|
22
|
+
const metadata: import("@pracht/image").PrachtImageMetadata;
|
|
23
|
+
export const src: string;
|
|
24
|
+
export const width: number;
|
|
25
|
+
export const height: number;
|
|
26
|
+
export const blurDataURL: string | undefined;
|
|
27
|
+
/** Undefined for unprocessed root-relative publicDir sources. */
|
|
28
|
+
export const variants: readonly import("@pracht/image").PrachtImageVariant[] | undefined;
|
|
29
|
+
export default metadata;
|
|
30
|
+
}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
//#region src/loaders.ts
|
|
2
2
|
const DEFAULT_QUALITY = 75;
|
|
3
|
+
function normalizeDeployBase(raw) {
|
|
4
|
+
if (typeof raw !== "string" || raw === "" || raw === "." || raw === "./") return "/";
|
|
5
|
+
if (raw.includes("://") || raw.startsWith("//")) return "/";
|
|
6
|
+
const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
7
|
+
return withLeadingSlash.endsWith("/") ? withLeadingSlash : `${withLeadingSlash}/`;
|
|
8
|
+
}
|
|
9
|
+
function withDeployBase(path) {
|
|
10
|
+
if (!path.startsWith("/") || path.startsWith("//")) return path;
|
|
11
|
+
const base = normalizeDeployBase(import.meta.env?.BASE_URL);
|
|
12
|
+
return base === "/" ? path : `${base}${path.slice(1)}`;
|
|
13
|
+
}
|
|
3
14
|
/**
|
|
4
15
|
* The default optimization endpoint. It maps onto an API route file at
|
|
5
16
|
* `src/api/_pracht/image.ts` that re-exports the handler from
|
|
@@ -11,7 +22,7 @@ const DEFAULT_IMAGE_ENDPOINT = "/api/_pracht/image";
|
|
|
11
22
|
* handler is mounted somewhere other than {@link DEFAULT_IMAGE_ENDPOINT}.
|
|
12
23
|
*/
|
|
13
24
|
function createDefaultLoader(endpoint = DEFAULT_IMAGE_ENDPOINT) {
|
|
14
|
-
return ({ src, width, quality }) => `${endpoint}?url=${encodeURIComponent(src)}&w=${width}&q=${quality ?? 75}`;
|
|
25
|
+
return ({ src, width, quality }) => `${withDeployBase(endpoint)}?url=${encodeURIComponent(src)}&w=${width}&q=${quality ?? 75}`;
|
|
15
26
|
}
|
|
16
27
|
/**
|
|
17
28
|
* Targets the pracht image endpoint served by `createImageHandler()` from
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as PrachtImageMetadata } from "./metadata-
|
|
1
|
+
import { n as PrachtImageVariant, t as PrachtImageMetadata } from "./metadata-Dap0USIM.mjs";
|
|
2
2
|
import { JSX, VNode } from "preact";
|
|
3
3
|
|
|
4
4
|
//#region src/loaders.d.ts
|
|
@@ -103,6 +103,13 @@ interface ImageProps extends Omit<JSX.HTMLAttributes<HTMLImageElement>, "src" |
|
|
|
103
103
|
* loader (see `configureImage()` and the `loader` prop).
|
|
104
104
|
*/
|
|
105
105
|
declare function Image(props: ImageProps): VNode;
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the plain `<img>` attributes used by {@link Image}. This is useful
|
|
108
|
+
* for HTML compilers such as `@pracht/markdown`, which need the exact same
|
|
109
|
+
* sizing, loader, placeholder, and priority semantics without mounting a
|
|
110
|
+
* second Preact renderer.
|
|
111
|
+
*/
|
|
112
|
+
declare function getImageProps(props: ImageProps): JSX.IntrinsicElements["img"];
|
|
106
113
|
//#endregion
|
|
107
114
|
//#region src/config.d.ts
|
|
108
115
|
/**
|
|
@@ -135,4 +142,4 @@ declare function getImageConfig(): ImageConfig;
|
|
|
135
142
|
/** Restore the default configuration. Primarily useful in tests. */
|
|
136
143
|
declare function resetImageConfig(): void;
|
|
137
144
|
//#endregion
|
|
138
|
-
export { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_ENDPOINT, DEFAULT_IMAGE_SIZES, DEFAULT_QUALITY, Image, type ImageConfig, type ImageLoader, type ImageLoaderArgs, type ImageProps, type PrachtImageMetadata, cloudflareLoader, configureImage, createDefaultLoader, defaultLoader, getImageConfig, passthroughLoader, resetImageConfig, vercelLoader };
|
|
145
|
+
export { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_ENDPOINT, DEFAULT_IMAGE_SIZES, DEFAULT_QUALITY, Image, type ImageConfig, type ImageLoader, type ImageLoaderArgs, type ImageProps, type PrachtImageMetadata, type PrachtImageVariant, cloudflareLoader, configureImage, createDefaultLoader, defaultLoader, getImageConfig, getImageProps, passthroughLoader, resetImageConfig, vercelLoader };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as resetImageConfig, c as cloudflareLoader, d as passthroughLoader, f as vercelLoader, i as getImageConfig, l as createDefaultLoader, n as DEFAULT_IMAGE_SIZES, o as DEFAULT_IMAGE_ENDPOINT, r as configureImage, s as DEFAULT_QUALITY, t as DEFAULT_DEVICE_SIZES, u as defaultLoader } from "./config-
|
|
1
|
+
import { a as resetImageConfig, c as cloudflareLoader, d as passthroughLoader, f as vercelLoader, i as getImageConfig, l as createDefaultLoader, n as DEFAULT_IMAGE_SIZES, o as DEFAULT_IMAGE_ENDPOINT, r as configureImage, s as DEFAULT_QUALITY, t as DEFAULT_DEVICE_SIZES, u as defaultLoader } from "./config-HOu7M27N.mjs";
|
|
2
2
|
import { h } from "preact";
|
|
3
3
|
//#region src/image.ts
|
|
4
4
|
const FILL_STYLE = {
|
|
@@ -97,6 +97,15 @@ function planSrcSet(deviceSizes, imageSizes, width, sizes) {
|
|
|
97
97
|
* loader (see `configureImage()` and the `loader` prop).
|
|
98
98
|
*/
|
|
99
99
|
function Image(props) {
|
|
100
|
+
return h("img", getImageProps(props));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the plain `<img>` attributes used by {@link Image}. This is useful
|
|
104
|
+
* for HTML compilers such as `@pracht/markdown`, which need the exact same
|
|
105
|
+
* sizing, loader, placeholder, and priority semantics without mounting a
|
|
106
|
+
* second Preact renderer.
|
|
107
|
+
*/
|
|
108
|
+
function getImageProps(props) {
|
|
100
109
|
const { src, alt, width, height, fill = false, sizes, quality, priority = false, loading, loader, placeholder = "empty", blurDataURL, style, ...rest } = props;
|
|
101
110
|
const metadata = typeof src === "string" ? void 0 : src;
|
|
102
111
|
const srcString = typeof src === "string" ? src : src.src;
|
|
@@ -113,16 +122,19 @@ function Image(props) {
|
|
|
113
122
|
if (placeholder === "blur" && resolvedBlurDataURL == null) warnOnce(`blur-missing:${srcString}`, `[pracht/image] <Image src="${srcString}"> uses placeholder="blur" without a blurDataURL. Import the image with the "?pracht" query (via prachtImage() from "@pracht/image/vite") or pass a blurDataURL prop. Rendering without a placeholder.`);
|
|
114
123
|
if (resolvedBlurDataURL != null && safeBlurDataURL == null) warnOnce(`blur-invalid:${srcString}`, `[pracht/image] <Image src="${srcString}"> received a blurDataURL that is not a well-formed "data:image/…" URI. It was ignored because interpolating arbitrary strings into the style attribute could inject CSS.`);
|
|
115
124
|
}
|
|
116
|
-
const
|
|
125
|
+
const staticMetadata = loader == null && metadata != null && Object.hasOwn(metadata, "variants");
|
|
126
|
+
const staticVariants = staticMetadata ? metadata.variants : void 0;
|
|
127
|
+
const effectiveSizes = sizes ?? (fill ? "100vw" : staticVariants?.length && numericWidth != null ? `(max-width: ${numericWidth}px) 100vw, ${numericWidth}px` : void 0);
|
|
117
128
|
const plan = planSrcSet(config.deviceSizes, config.imageSizes, numericWidth, effectiveSizes);
|
|
118
|
-
const candidates = plan.widths.map((candidateWidth) => resolvedLoader({
|
|
129
|
+
const candidates = staticVariants?.length ? staticVariants.map((variant) => variant.src) : staticMetadata ? [srcString] : plan.widths.map((candidateWidth) => resolvedLoader({
|
|
119
130
|
src: srcString,
|
|
120
131
|
width: candidateWidth,
|
|
121
132
|
quality: resolvedQuality
|
|
122
133
|
}));
|
|
123
|
-
const
|
|
134
|
+
const candidateWidths = staticVariants?.length ? staticVariants.map((variant) => variant.width) : plan.widths;
|
|
135
|
+
const largestSrc = candidates[candidates.length - 1] ?? srcString;
|
|
124
136
|
const optimized = new Set(candidates).size > 1;
|
|
125
|
-
const srcset = optimized ? candidates.map((url, index) => plan.descriptor === "w" ? `${url} ${
|
|
137
|
+
const srcset = optimized ? candidates.map((url, index) => staticVariants?.length || plan.descriptor === "w" ? `${url} ${candidateWidths[index]}w` : `${url} ${index + 1}x`).join(", ") : void 0;
|
|
126
138
|
const blur = safeBlurDataURL != null ? blurBackground(safeBlurDataURL) : void 0;
|
|
127
139
|
let mergedStyle = style;
|
|
128
140
|
if (blur || fill) {
|
|
@@ -148,7 +160,7 @@ function Image(props) {
|
|
|
148
160
|
}
|
|
149
161
|
if (priority) imgProps.fetchpriority = "high";
|
|
150
162
|
if (mergedStyle != null) imgProps.style = mergedStyle;
|
|
151
|
-
return
|
|
163
|
+
return imgProps;
|
|
152
164
|
}
|
|
153
165
|
//#endregion
|
|
154
|
-
export { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_ENDPOINT, DEFAULT_IMAGE_SIZES, DEFAULT_QUALITY, Image, cloudflareLoader, configureImage, createDefaultLoader, defaultLoader, getImageConfig, passthroughLoader, resetImageConfig, vercelLoader };
|
|
166
|
+
export { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_ENDPOINT, DEFAULT_IMAGE_SIZES, DEFAULT_QUALITY, Image, cloudflareLoader, configureImage, createDefaultLoader, defaultLoader, getImageConfig, getImageProps, passthroughLoader, resetImageConfig, vercelLoader };
|
|
@@ -24,6 +24,20 @@ interface PrachtImageMetadata {
|
|
|
24
24
|
* so a raster blur adds bytes without adding information.
|
|
25
25
|
*/
|
|
26
26
|
blurDataURL?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Build-generated responsive variants. Present for `?pracht&pracht-static`
|
|
29
|
+
* imports; ordinary `?pracht` imports continue to expose the hashed original
|
|
30
|
+
* and rely on the configured runtime/platform loader.
|
|
31
|
+
*/
|
|
32
|
+
variants?: readonly PrachtImageVariant[];
|
|
33
|
+
}
|
|
34
|
+
interface PrachtImageVariant {
|
|
35
|
+
/** Final, base-aware URL of the generated asset. */
|
|
36
|
+
src: string;
|
|
37
|
+
/** Raster width represented by this candidate. */
|
|
38
|
+
width: number;
|
|
39
|
+
/** MIME type of the generated asset. */
|
|
40
|
+
type: `image/${string}`;
|
|
27
41
|
}
|
|
28
42
|
//#endregion
|
|
29
|
-
export { PrachtImageMetadata as t };
|
|
43
|
+
export { PrachtImageVariant as n, PrachtImageMetadata as t };
|
package/dist/node.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as DEFAULT_IMAGE_SIZES, t as DEFAULT_DEVICE_SIZES } from "./config-
|
|
1
|
+
import { n as DEFAULT_IMAGE_SIZES, t as DEFAULT_DEVICE_SIZES } from "./config-HOu7M27N.mjs";
|
|
2
2
|
//#region src/node.ts
|
|
3
3
|
const SHARP_INSTALL_HINT = "Image optimization requires the optional \"sharp\" dependency. Install it in your app with \"pnpm add sharp\" (or npm install / yarn add) to enable the pracht image endpoint.";
|
|
4
4
|
const DEFAULT_CACHE_CONTROL = "public, max-age=14400, must-revalidate";
|
package/dist/vite.d.mts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* ```ts
|
|
5
5
|
* // vite.config.ts — add it next to pracht(); it is not included by default.
|
|
6
6
|
* import { prachtImage } from "@pracht/image/vite";
|
|
7
|
-
* export default { plugins: [pracht({ … })
|
|
7
|
+
* export default { plugins: [prachtImage(), pracht({ … })] };
|
|
8
8
|
* ```
|
|
9
9
|
*
|
|
10
10
|
* ```tsx
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* build time. Add
|
|
20
20
|
* `/// <reference types="@pracht/image/client" />` (or `"types":
|
|
21
21
|
* ["@pracht/image/client"]` in tsconfig) so TypeScript understands the query.
|
|
22
|
-
*/import { t as PrachtImageMetadata } from "./metadata-
|
|
22
|
+
*/import { t as PrachtImageMetadata } from "./metadata-Dap0USIM.mjs";
|
|
23
23
|
import { Plugin } from "vite";
|
|
24
24
|
|
|
25
25
|
//#region src/vite.d.ts
|
|
@@ -35,6 +35,16 @@ interface PrachtImageOptions {
|
|
|
35
35
|
blurWidth?: number;
|
|
36
36
|
/** WebP quality (1-100) of the blur placeholder. Defaults to 70. */
|
|
37
37
|
blurQuality?: number;
|
|
38
|
+
/** Widths generated for `?pracht&pracht-static` imports. */
|
|
39
|
+
staticWidths?: readonly number[];
|
|
40
|
+
/** WebP quality for generated static variants. Defaults to 75. */
|
|
41
|
+
staticQuality?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Client asset directory used when a static image is reachable only from
|
|
44
|
+
* the server graph. Relative paths resolve from Vite's root. Defaults to
|
|
45
|
+
* `dist/client`, matching `pracht build`.
|
|
46
|
+
*/
|
|
47
|
+
staticOutDir?: string;
|
|
38
48
|
/** Override how sharp is imported (useful for tests). */
|
|
39
49
|
loadSharp?: () => Promise<unknown>;
|
|
40
50
|
}
|
|
@@ -51,6 +61,8 @@ interface SharpPipeline {
|
|
|
51
61
|
rotate(): SharpPipeline;
|
|
52
62
|
resize(options: {
|
|
53
63
|
width: number;
|
|
64
|
+
height?: number;
|
|
65
|
+
fit?: "inside";
|
|
54
66
|
withoutEnlargement: boolean;
|
|
55
67
|
}): SharpPipeline;
|
|
56
68
|
webp(options: {
|
|
@@ -58,9 +70,14 @@ interface SharpPipeline {
|
|
|
58
70
|
}): SharpPipeline;
|
|
59
71
|
toBuffer(): Promise<Uint8Array>;
|
|
60
72
|
}
|
|
61
|
-
type SharpFactory = (input: Uint8Array) => SharpPipeline
|
|
73
|
+
type SharpFactory = ((input: Uint8Array) => SharpPipeline) & {
|
|
74
|
+
versions?: {
|
|
75
|
+
sharp?: string;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
62
78
|
/** `/path/to/hero.jpg?pracht` → true; `?pracht` may combine with other params. */
|
|
63
79
|
declare function isPrachtImageId(id: string): boolean;
|
|
80
|
+
declare function isStaticPrachtImageId(id: string): boolean;
|
|
64
81
|
/** Strip the entire query, leaving the file path. */
|
|
65
82
|
declare function stripImageQuery(id: string): string;
|
|
66
83
|
/**
|
|
@@ -82,7 +99,7 @@ declare function analyzeImage(sharp: SharpFactory, source: Uint8Array, options:
|
|
|
82
99
|
* `blurDataURL`. The metadata contract promises a real asset URL (hashed for
|
|
83
100
|
* source files, stable for publicDir files). Exported for tests.
|
|
84
101
|
*/
|
|
85
|
-
declare function createImageModuleCode(assetId: string, analyzed: Omit<PrachtImageMetadata, "src"
|
|
102
|
+
declare function createImageModuleCode(assetId: string, analyzed: Omit<PrachtImageMetadata, "src">, variants?: PrachtImageMetadata["variants"], staticQuery?: boolean): string;
|
|
86
103
|
declare function prachtImage(options?: PrachtImageOptions): Plugin;
|
|
87
104
|
//#endregion
|
|
88
|
-
export { PrachtImageOptions, analyzeImage, createImageModuleCode, isPrachtImageId, prachtImage, stripImageQuery };
|
|
105
|
+
export { PrachtImageOptions, analyzeImage, createImageModuleCode, isPrachtImageId, isStaticPrachtImageId, prachtImage, stripImageQuery };
|
package/dist/vite.mjs
CHANGED
|
@@ -1,9 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, stat, unlink, utimes, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
3
4
|
//#region src/vite.ts
|
|
4
5
|
const PRACHT_IMAGE_QUERY = "pracht";
|
|
5
6
|
const DEFAULT_BLUR_WIDTH = 8;
|
|
6
7
|
const DEFAULT_BLUR_QUALITY = 70;
|
|
8
|
+
const DEFAULT_STATIC_QUALITY = 75;
|
|
9
|
+
const DEFAULT_STATIC_WIDTHS = [
|
|
10
|
+
320,
|
|
11
|
+
640,
|
|
12
|
+
960,
|
|
13
|
+
1280,
|
|
14
|
+
1920
|
|
15
|
+
];
|
|
16
|
+
const STATIC_IMAGE_QUERY = "pracht-static";
|
|
17
|
+
const STATIC_CACHE_VERSION = "pracht-static-image-v2";
|
|
18
|
+
/**
|
|
19
|
+
* WebP stores each dimension in 14 bits, so no side of an encoded image may
|
|
20
|
+
* exceed 16383px. Both the configured widths and the intrinsic-width variant
|
|
21
|
+
* are capped to it: a panorama should ship one clamped variant, not fail the
|
|
22
|
+
* build with a raw encoder error.
|
|
23
|
+
*/
|
|
24
|
+
const WEBP_MAX_DIMENSION = 16383;
|
|
25
|
+
/**
|
|
26
|
+
* Generated variants that have not been used for this long are dropped from
|
|
27
|
+
* the disk cache. Entries are touched on every hit, so anything older belongs
|
|
28
|
+
* to an image that has since been edited, renamed, or deleted.
|
|
29
|
+
*/
|
|
30
|
+
const STATIC_CACHE_MAX_AGE_MS = 720 * 60 * 60 * 1e3;
|
|
7
31
|
const SHARP_INSTALL_HINT = "[pracht/image] \"?pracht\" image imports require the optional \"sharp\" dependency at build time. Install it in your app with \"pnpm add -D sharp\" (or npm install -D sharp / yarn add -D sharp).";
|
|
8
32
|
function createSharpImporter(load) {
|
|
9
33
|
let cached;
|
|
@@ -21,6 +45,11 @@ function isPrachtImageId(id) {
|
|
|
21
45
|
if (queryStart === -1) return false;
|
|
22
46
|
return id.slice(queryStart + 1).split("&").some((part) => part === PRACHT_IMAGE_QUERY);
|
|
23
47
|
}
|
|
48
|
+
function isStaticPrachtImageId(id) {
|
|
49
|
+
const queryStart = id.indexOf("?");
|
|
50
|
+
if (queryStart === -1) return false;
|
|
51
|
+
return id.slice(queryStart + 1).split("&").some((part) => part === STATIC_IMAGE_QUERY);
|
|
52
|
+
}
|
|
24
53
|
/** Strip the entire query, leaving the file path. */
|
|
25
54
|
function stripImageQuery(id) {
|
|
26
55
|
const queryStart = id.indexOf("?");
|
|
@@ -46,6 +75,8 @@ async function analyzeImage(sharp, source, options) {
|
|
|
46
75
|
};
|
|
47
76
|
const blur = await sharp(source).rotate().resize({
|
|
48
77
|
width: options.blurWidth,
|
|
78
|
+
height: WEBP_MAX_DIMENSION,
|
|
79
|
+
fit: "inside",
|
|
49
80
|
withoutEnlargement: true
|
|
50
81
|
}).webp({ quality: options.blurQuality }).toBuffer();
|
|
51
82
|
return {
|
|
@@ -65,17 +96,30 @@ async function analyzeImage(sharp, source, options) {
|
|
|
65
96
|
* `blurDataURL`. The metadata contract promises a real asset URL (hashed for
|
|
66
97
|
* source files, stable for publicDir files). Exported for tests.
|
|
67
98
|
*/
|
|
68
|
-
function createImageModuleCode(assetId, analyzed) {
|
|
99
|
+
function createImageModuleCode(assetId, analyzed, variants = void 0, staticQuery = false) {
|
|
100
|
+
if (variants?.length) return [
|
|
101
|
+
`export const variants = ${JSON.stringify(variants)};`,
|
|
102
|
+
"export const src = variants[variants.length - 1].src;",
|
|
103
|
+
`export const width = ${JSON.stringify(analyzed.width)};`,
|
|
104
|
+
`export const height = ${JSON.stringify(analyzed.height)};`,
|
|
105
|
+
`export const blurDataURL = ${JSON.stringify(analyzed.blurDataURL)};`,
|
|
106
|
+
"export default { src, width, height, blurDataURL, variants };"
|
|
107
|
+
].join("\n");
|
|
69
108
|
const assetImport = `${assetId.replace(/\\/g, "/")}?url&no-inline`;
|
|
70
109
|
return [
|
|
71
110
|
`import src from ${JSON.stringify(assetImport)};`,
|
|
72
111
|
`export const width = ${JSON.stringify(analyzed.width)};`,
|
|
73
112
|
`export const height = ${JSON.stringify(analyzed.height)};`,
|
|
74
113
|
`export const blurDataURL = ${JSON.stringify(analyzed.blurDataURL)};`,
|
|
114
|
+
...staticQuery ? ["export const variants = undefined;"] : [],
|
|
75
115
|
"export { src };",
|
|
76
|
-
"export default { src, width, height, blurDataURL };"
|
|
116
|
+
staticQuery ? "export default { src, width, height, blurDataURL, variants };" : "export default { src, width, height, blurDataURL };"
|
|
77
117
|
].join("\n");
|
|
78
118
|
}
|
|
119
|
+
/** Vite ids, watcher paths, and Windows drive paths must compare equal. */
|
|
120
|
+
function toPosixPath(value) {
|
|
121
|
+
return value.replace(/\\/g, "/");
|
|
122
|
+
}
|
|
79
123
|
async function resolvePublicFile(publicDir, source) {
|
|
80
124
|
if (!publicDir || !source.startsWith("/")) return void 0;
|
|
81
125
|
const candidate = resolve(publicDir, `.${source}`);
|
|
@@ -83,13 +127,18 @@ async function resolvePublicFile(publicDir, source) {
|
|
|
83
127
|
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) return;
|
|
84
128
|
return (await stat(candidate).catch(() => void 0))?.isFile() ? candidate : void 0;
|
|
85
129
|
}
|
|
130
|
+
function contentTypeForOriginal(format, extension) {
|
|
131
|
+
if (format === "svg" || extension === ".svg") return "image/svg+xml";
|
|
132
|
+
if (format) return `image/${format}`;
|
|
133
|
+
return `image/${extension.replace(/^\./, "") || "octet-stream"}`;
|
|
134
|
+
}
|
|
86
135
|
/**
|
|
87
136
|
* Vite plugin enabling build-time image imports:
|
|
88
137
|
*
|
|
89
138
|
* ```ts
|
|
90
139
|
* // vite.config.ts — add it next to pracht(); it is not included by default.
|
|
91
140
|
* import { prachtImage } from "@pracht/image/vite";
|
|
92
|
-
* export default { plugins: [pracht({ … })
|
|
141
|
+
* export default { plugins: [prachtImage(), pracht({ … })] };
|
|
93
142
|
* ```
|
|
94
143
|
*
|
|
95
144
|
* ```tsx
|
|
@@ -108,17 +157,139 @@ async function resolvePublicFile(publicDir, source) {
|
|
|
108
157
|
function prachtImage(options = {}) {
|
|
109
158
|
const blurWidth = options.blurWidth ?? DEFAULT_BLUR_WIDTH;
|
|
110
159
|
const blurQuality = options.blurQuality ?? DEFAULT_BLUR_QUALITY;
|
|
160
|
+
const staticQuality = options.staticQuality ?? DEFAULT_STATIC_QUALITY;
|
|
161
|
+
const staticWidths = [...new Set(options.staticWidths ?? DEFAULT_STATIC_WIDTHS)].sort((left, right) => left - right);
|
|
111
162
|
if (!Number.isInteger(blurWidth) || blurWidth < 1 || blurWidth > 64) throw new Error("prachtImage({ blurWidth }) expects an integer between 1 and 64.");
|
|
112
163
|
if (!Number.isInteger(blurQuality) || blurQuality < 1 || blurQuality > 100) throw new Error("prachtImage({ blurQuality }) expects an integer between 1 and 100.");
|
|
164
|
+
if (!Number.isInteger(staticQuality) || staticQuality < 1 || staticQuality > 100) throw new Error("prachtImage({ staticQuality }) expects an integer between 1 and 100.");
|
|
165
|
+
if (staticWidths.length === 0 || staticWidths.some((width) => !Number.isInteger(width) || width < 1 || width > WEBP_MAX_DIMENSION)) throw new Error(`prachtImage({ staticWidths }) expects one or more integer widths between 1 and ${WEBP_MAX_DIMENSION}; WebP cannot encode a dimension above that.`);
|
|
113
166
|
const importSharp = createSharpImporter(options.loadSharp ?? (() => import("sharp")));
|
|
114
167
|
const cache = /* @__PURE__ */ new Map();
|
|
115
168
|
const resolvedImages = /* @__PURE__ */ new Map();
|
|
169
|
+
const staticAssets = /* @__PURE__ */ new Map();
|
|
116
170
|
let publicDir = "";
|
|
117
|
-
|
|
171
|
+
let root = process.cwd();
|
|
172
|
+
let base = "/";
|
|
173
|
+
let assetsDir = "assets";
|
|
174
|
+
let cacheDir = "";
|
|
175
|
+
let staticOutDir = "";
|
|
176
|
+
let isSsrBuild = false;
|
|
177
|
+
function staticAssetUrl(fileName) {
|
|
178
|
+
if (base === "" || base === "./") return `${base}${fileName}`;
|
|
179
|
+
return `${base.endsWith("/") ? base : `${base}/`}${fileName}`;
|
|
180
|
+
}
|
|
181
|
+
function staticCacheDir() {
|
|
182
|
+
return join(cacheDir, "pracht-image");
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Record a generated (or pass-through) asset by path. The same content hash
|
|
186
|
+
* can legitimately be produced by two sources, so ownership retains each
|
|
187
|
+
* source's backing path: an entry only disappears once every source that
|
|
188
|
+
* claimed it is gone.
|
|
189
|
+
*/
|
|
190
|
+
function registerStaticAsset(fileName, contentType, path, filePath) {
|
|
191
|
+
const existing = staticAssets.get(fileName);
|
|
192
|
+
if (existing) {
|
|
193
|
+
existing.sources.set(toPosixPath(filePath), path);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
staticAssets.set(fileName, {
|
|
197
|
+
contentType,
|
|
198
|
+
path,
|
|
199
|
+
sources: new Map([[toPosixPath(filePath), path]])
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async function readStaticAsset(fileName, asset) {
|
|
203
|
+
try {
|
|
204
|
+
return await readFile(asset.path);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new Error(`[pracht/image] Could not read the bytes for static asset ${JSON.stringify(fileName)} from ${JSON.stringify(asset.path)} (generated from ${[...asset.sources.keys()].map((source) => JSON.stringify(source)).join(", ")}): ${error instanceof Error ? error.message : String(error)}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
let cachePrune;
|
|
210
|
+
/**
|
|
211
|
+
* Age-based prune of the generated-variant cache, run once per plugin
|
|
212
|
+
* instance. Without it, every image edit leaves its previous encodes behind
|
|
213
|
+
* forever. Best effort by design: an unreadable or unwritable cache
|
|
214
|
+
* directory must never fail a build.
|
|
215
|
+
*/
|
|
216
|
+
function pruneStaticCache() {
|
|
217
|
+
cachePrune ??= (async () => {
|
|
218
|
+
const directory = staticCacheDir();
|
|
219
|
+
const entries = await readdir(directory).catch(() => []);
|
|
220
|
+
const cutoff = Date.now() - STATIC_CACHE_MAX_AGE_MS;
|
|
221
|
+
await Promise.all(entries.map(async (entry) => {
|
|
222
|
+
const entryPath = join(directory, entry);
|
|
223
|
+
const stats = await stat(entryPath).catch(() => void 0);
|
|
224
|
+
if (!stats?.isFile() || stats.mtimeMs >= cutoff) return;
|
|
225
|
+
await unlink(entryPath).catch(() => void 0);
|
|
226
|
+
}));
|
|
227
|
+
})().catch(() => void 0);
|
|
228
|
+
return cachePrune;
|
|
229
|
+
}
|
|
230
|
+
async function createStaticVariants(sharp, source, filePath, intrinsicWidth, intrinsicHeight) {
|
|
231
|
+
const metadata = await sharp(source).metadata();
|
|
232
|
+
if (metadata.format === "svg" || (metadata.pages ?? 1) > 1) {
|
|
233
|
+
const extension = extname(filePath).toLowerCase();
|
|
234
|
+
const stem = basename(filePath, extname(filePath)).replace(/[^a-zA-Z0-9_-]+/g, "-") || "image";
|
|
235
|
+
const hash = createHash("sha256").update(STATIC_CACHE_VERSION).update("original").update(source).digest("hex").slice(0, 12);
|
|
236
|
+
const fileName = posix.join(assetsDir, `${stem}.${hash}${extension}`);
|
|
237
|
+
const contentType = contentTypeForOriginal(metadata.format, extension);
|
|
238
|
+
registerStaticAsset(fileName, contentType, filePath, filePath);
|
|
239
|
+
return [{
|
|
240
|
+
src: staticAssetUrl(fileName),
|
|
241
|
+
width: intrinsicWidth,
|
|
242
|
+
type: contentType
|
|
243
|
+
}];
|
|
244
|
+
}
|
|
245
|
+
const maxWidth = Math.max(1, Math.min(intrinsicWidth, WEBP_MAX_DIMENSION, Math.floor(WEBP_MAX_DIMENSION * intrinsicWidth / intrinsicHeight)));
|
|
246
|
+
const widths = [...new Set([...staticWidths.filter((width) => width < maxWidth), maxWidth])];
|
|
247
|
+
const stem = basename(filePath, extname(filePath)).replace(/[^a-zA-Z0-9_-]+/g, "-") || "image";
|
|
248
|
+
const variants = [];
|
|
249
|
+
for (const width of widths) {
|
|
250
|
+
const hash = createHash("sha256").update(STATIC_CACHE_VERSION).update(source).update(JSON.stringify({
|
|
251
|
+
width,
|
|
252
|
+
quality: staticQuality,
|
|
253
|
+
format: "webp",
|
|
254
|
+
encoder: sharp.versions?.sharp ?? "unknown"
|
|
255
|
+
})).digest("hex").slice(0, 12);
|
|
256
|
+
const fileName = posix.join(assetsDir, `${stem}.${width}.${hash}.webp`);
|
|
257
|
+
const cachedPath = join(staticCacheDir(), `${hash}.webp`);
|
|
258
|
+
if ((await stat(cachedPath).catch(() => void 0))?.isFile()) {
|
|
259
|
+
const now = /* @__PURE__ */ new Date();
|
|
260
|
+
await utimes(cachedPath, now, now).catch(() => void 0);
|
|
261
|
+
} else {
|
|
262
|
+
const output = Buffer.from(await sharp(source).rotate().resize({
|
|
263
|
+
width,
|
|
264
|
+
height: WEBP_MAX_DIMENSION,
|
|
265
|
+
fit: "inside",
|
|
266
|
+
withoutEnlargement: true
|
|
267
|
+
}).webp({ quality: staticQuality }).toBuffer());
|
|
268
|
+
await mkdir(staticCacheDir(), { recursive: true });
|
|
269
|
+
const temporaryPath = `${cachedPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
270
|
+
try {
|
|
271
|
+
await writeFile(temporaryPath, output);
|
|
272
|
+
await rename(temporaryPath, cachedPath);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (!(await stat(cachedPath).catch(() => void 0))?.isFile()) throw error;
|
|
275
|
+
} finally {
|
|
276
|
+
await unlink(temporaryPath).catch(() => void 0);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
registerStaticAsset(fileName, "image/webp", cachedPath, filePath);
|
|
280
|
+
variants.push({
|
|
281
|
+
src: staticAssetUrl(fileName),
|
|
282
|
+
width,
|
|
283
|
+
type: "image/webp"
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return variants;
|
|
287
|
+
}
|
|
288
|
+
async function transform(filePath, assetId, staticImport, staticQuery) {
|
|
118
289
|
const stats = await stat(filePath).catch(() => {
|
|
119
290
|
throw new Error(`[pracht/image] Could not read "${filePath}" for a "?pracht" import.`);
|
|
120
291
|
});
|
|
121
|
-
const cacheKey = `${filePath}\0${assetId}`;
|
|
292
|
+
const cacheKey = `${filePath}\0${assetId}\0${staticImport ? "static" : staticQuery ? "static-fallback" : "metadata"}`;
|
|
122
293
|
const cached = cache.get(cacheKey);
|
|
123
294
|
if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) return cached.code;
|
|
124
295
|
const code = (async () => {
|
|
@@ -132,7 +303,13 @@ function prachtImage(options = {}) {
|
|
|
132
303
|
} catch (error) {
|
|
133
304
|
throw new Error(`[pracht/image] Failed to process "?pracht" import of "${filePath}": ${error instanceof Error ? error.message : String(error)}`);
|
|
134
305
|
}
|
|
135
|
-
|
|
306
|
+
let variants;
|
|
307
|
+
if (staticImport) try {
|
|
308
|
+
variants = await createStaticVariants(sharp, source, filePath, analyzed.width, analyzed.height);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
throw new Error(`[pracht/image] Failed to generate static variants for "${filePath}": ${error instanceof Error ? error.message : String(error)}`);
|
|
311
|
+
}
|
|
312
|
+
return createImageModuleCode(assetId, analyzed, variants, staticQuery);
|
|
136
313
|
})();
|
|
137
314
|
code.catch(() => cache.delete(cacheKey));
|
|
138
315
|
cache.set(cacheKey, {
|
|
@@ -146,18 +323,30 @@ function prachtImage(options = {}) {
|
|
|
146
323
|
name: "pracht:image-imports",
|
|
147
324
|
enforce: "pre",
|
|
148
325
|
configResolved(config) {
|
|
326
|
+
root = config.root;
|
|
149
327
|
publicDir = config.publicDir;
|
|
328
|
+
base = config.base;
|
|
329
|
+
assetsDir = config.build.assetsDir;
|
|
330
|
+
cacheDir = config.cacheDir;
|
|
331
|
+
staticOutDir = resolve(root, options.staticOutDir ?? "dist/client");
|
|
332
|
+
isSsrBuild = Boolean(config.build.ssr);
|
|
333
|
+
},
|
|
334
|
+
buildStart() {
|
|
335
|
+
return pruneStaticCache();
|
|
150
336
|
},
|
|
151
337
|
async resolveId(source, importer) {
|
|
152
338
|
if (!isPrachtImageId(source)) return null;
|
|
153
339
|
const sourcePath = stripImageQuery(source);
|
|
340
|
+
const staticImport = isStaticPrachtImageId(source);
|
|
154
341
|
const resolved = await this.resolve(sourcePath, importer, { skipSelf: true });
|
|
155
342
|
if (!resolved) return null;
|
|
156
343
|
const publicFile = resolved.id === sourcePath ? await resolvePublicFile(publicDir, sourcePath) : void 0;
|
|
157
|
-
const
|
|
344
|
+
const query = staticImport ? `${PRACHT_IMAGE_QUERY}&${STATIC_IMAGE_QUERY}` : PRACHT_IMAGE_QUERY;
|
|
345
|
+
const moduleId = `${resolved.id}${resolved.id.includes("?") ? "&" : "?"}${query}`;
|
|
158
346
|
resolvedImages.set(moduleId, {
|
|
159
347
|
filePath: publicFile ?? resolved.id,
|
|
160
|
-
assetId: publicFile ? sourcePath : resolved.id
|
|
348
|
+
assetId: publicFile ? sourcePath : resolved.id,
|
|
349
|
+
publicAsset: publicFile !== void 0
|
|
161
350
|
});
|
|
162
351
|
return moduleId;
|
|
163
352
|
},
|
|
@@ -168,12 +357,60 @@ function prachtImage(options = {}) {
|
|
|
168
357
|
assetId: stripImageQuery(id)
|
|
169
358
|
};
|
|
170
359
|
this.addWatchFile(resolved.filePath);
|
|
171
|
-
|
|
360
|
+
const staticQuery = isStaticPrachtImageId(id);
|
|
361
|
+
const staticImport = staticQuery && !resolved.publicAsset;
|
|
362
|
+
if (staticImport && (base === "" || base === "./")) throw new Error("[pracht/image] \"?pracht&pracht-static\" imports require an absolute Vite base (for example \"/\" or \"/docs/\"); a relative base cannot produce route-safe image URLs.");
|
|
363
|
+
return transform(resolved.filePath, resolved.assetId, staticImport, staticQuery);
|
|
364
|
+
},
|
|
365
|
+
configureServer(server) {
|
|
366
|
+
server.middlewares.use((request, response, next) => {
|
|
367
|
+
const pathname = new URL(request.url ?? "/", "http://pracht.local").pathname;
|
|
368
|
+
const entry = [...staticAssets].find(([fileName]) => new URL(staticAssetUrl(fileName), "http://pracht.local").pathname === pathname);
|
|
369
|
+
if (!entry) return next();
|
|
370
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
371
|
+
if (method !== "GET" && method !== "HEAD") return next();
|
|
372
|
+
const respond = (source) => {
|
|
373
|
+
response.statusCode = 200;
|
|
374
|
+
response.setHeader("cache-control", "no-store");
|
|
375
|
+
response.setHeader("content-type", entry[1].contentType);
|
|
376
|
+
response.setHeader("x-content-type-options", "nosniff");
|
|
377
|
+
response.end(source);
|
|
378
|
+
};
|
|
379
|
+
if (method === "HEAD") return respond();
|
|
380
|
+
readStaticAsset(entry[0], entry[1]).then(respond, next);
|
|
381
|
+
});
|
|
382
|
+
},
|
|
383
|
+
async generateBundle() {
|
|
384
|
+
const consumer = this.environment?.config?.consumer;
|
|
385
|
+
if (consumer ? consumer === "server" : isSsrBuild) return;
|
|
386
|
+
for (const [fileName, asset] of staticAssets) this.emitFile({
|
|
387
|
+
type: "asset",
|
|
388
|
+
fileName,
|
|
389
|
+
source: await readStaticAsset(fileName, asset)
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
async writeBundle() {
|
|
393
|
+
const consumer = this.environment?.config?.consumer;
|
|
394
|
+
if (!(consumer ? consumer === "server" : isSsrBuild)) return;
|
|
395
|
+
for (const [fileName, asset] of staticAssets) {
|
|
396
|
+
const outputPath = resolve(staticOutDir, fileName);
|
|
397
|
+
const relativePath = relative(staticOutDir, outputPath);
|
|
398
|
+
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) throw new Error(`[pracht/image] Refusing to write static variant outside ${JSON.stringify(staticOutDir)}: ${JSON.stringify(fileName)}.`);
|
|
399
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
400
|
+
await writeFile(outputPath, await readStaticAsset(fileName, asset));
|
|
401
|
+
}
|
|
172
402
|
},
|
|
173
403
|
watchChange(filePath) {
|
|
404
|
+
const changed = toPosixPath(filePath);
|
|
405
|
+
for (const [fileName, asset] of staticAssets) {
|
|
406
|
+
if (!asset.sources.delete(changed)) continue;
|
|
407
|
+
const nextPath = asset.sources.values().next().value;
|
|
408
|
+
if (nextPath === void 0) staticAssets.delete(fileName);
|
|
409
|
+
else asset.path = nextPath;
|
|
410
|
+
}
|
|
174
411
|
if (this.environment.mode !== "dev") return;
|
|
175
412
|
for (const [moduleId, resolved] of resolvedImages) {
|
|
176
|
-
if (resolved.filePath
|
|
413
|
+
if (toPosixPath(resolved.filePath) !== changed) continue;
|
|
177
414
|
const module = this.environment.moduleGraph.getModuleById(moduleId);
|
|
178
415
|
if (module) this.environment.moduleGraph.invalidateModule(module);
|
|
179
416
|
}
|
|
@@ -181,4 +418,4 @@ function prachtImage(options = {}) {
|
|
|
181
418
|
};
|
|
182
419
|
}
|
|
183
420
|
//#endregion
|
|
184
|
-
export { analyzeImage, createImageModuleCode, isPrachtImageId, prachtImage, stripImageQuery };
|
|
421
|
+
export { analyzeImage, createImageModuleCode, isPrachtImageId, isStaticPrachtImageId, prachtImage, stripImageQuery };
|