@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 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({ … }), prachtImage()] (from "@pracht/image/vite")
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-CA97TjOR.mjs";
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-BavYkV_M.mjs";
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 effectiveSizes = sizes ?? (fill ? "100vw" : void 0);
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 largestSrc = candidates[candidates.length - 1];
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} ${plan.widths[index]}w` : `${url} ${index + 1}x`).join(", ") : void 0;
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 h("img", imgProps);
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-BavYkV_M.mjs";
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({ … }), prachtImage()] };
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-CA97TjOR.mjs";
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">): string;
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 { readFile, stat } from "node:fs/promises";
2
- import { isAbsolute, relative, resolve, sep } from "node:path";
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({ … }), prachtImage()] };
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
- async function transform(filePath, assetId) {
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
- return createImageModuleCode(assetId, analyzed);
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 moduleId = `${resolved.id}${resolved.id.includes("?") ? "&" : "?"}${PRACHT_IMAGE_QUERY}`;
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
- return transform(resolved.filePath, resolved.assetId);
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.replace(/\\/g, "/") !== filePath.replace(/\\/g, "/")) continue;
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pracht/image",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Responsive, CLS-safe <Image> component for Pracht with pluggable optimization loaders and a Node image endpoint.",
5
5
  "keywords": [
6
6
  "pracht",