@pracht/image 0.1.1 → 0.3.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 +12 -1
- package/client.d.ts +19 -0
- package/dist/{config-BavYkV_M.mjs → config-HOu7M27N.mjs} +12 -1
- package/dist/index.d.mts +25 -4
- package/dist/index.mjs +35 -11
- package/dist/metadata-CA97TjOR.d.mts +29 -0
- package/dist/node.mjs +1 -1
- package/dist/vite.d.mts +88 -0
- package/dist/vite.mjs +184 -0
- package/package.json +18 -5
package/README.md
CHANGED
|
@@ -16,10 +16,14 @@ next/image's loader pattern.
|
|
|
16
16
|
[sharp](https://sharp.pixelplumbing.com) (optional peer dependency) with a
|
|
17
17
|
trusted-local-origin/remote-allowlist security model and revalidated cache
|
|
18
18
|
headers.
|
|
19
|
+
- Build-time `?pracht` image imports (`@pracht/image/vite`): Vite-managed asset
|
|
20
|
+
URL (hashed source assets or stable `publicDir` URLs), intrinsic dimensions
|
|
21
|
+
(EXIF-orientation aware), and a tiny inline `blurDataURL` for CSS-only
|
|
22
|
+
`placeholder="blur"`.
|
|
19
23
|
|
|
20
24
|
```bash
|
|
21
25
|
pnpm add @pracht/image
|
|
22
|
-
pnpm add sharp # only needed for the built-in endpoint
|
|
26
|
+
pnpm add sharp # only needed for the built-in endpoint and ?pracht imports
|
|
23
27
|
```
|
|
24
28
|
|
|
25
29
|
```tsx
|
|
@@ -28,6 +32,13 @@ import { Image } from "@pracht/image";
|
|
|
28
32
|
<Image src="/banner.jpg" alt="Banner" width={1200} height={280} priority />;
|
|
29
33
|
```
|
|
30
34
|
|
|
35
|
+
```tsx
|
|
36
|
+
// vite.config.ts: plugins: [pracht({ … }), prachtImage()] (from "@pracht/image/vite")
|
|
37
|
+
import hero from "./hero.jpg?pracht"; // { src, width, height, blurDataURL }
|
|
38
|
+
|
|
39
|
+
<Image src={hero} alt="Hero" placeholder="blur" />;
|
|
40
|
+
```
|
|
41
|
+
|
|
31
42
|
```ts
|
|
32
43
|
// src/api/_pracht/image.ts — mounts the optimization endpoint
|
|
33
44
|
import { createImageHandler } from "@pracht/image/node";
|
package/client.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Type declarations for build-time `?pracht` image imports (enabled by the
|
|
2
|
+
// `prachtImage()` Vite plugin from "@pracht/image/vite").
|
|
3
|
+
//
|
|
4
|
+
// Reference this file once in your app, either with a triple-slash directive
|
|
5
|
+
// in any .d.ts file:
|
|
6
|
+
//
|
|
7
|
+
// /// <reference types="@pracht/image/client" />
|
|
8
|
+
//
|
|
9
|
+
// or via tsconfig: `"types": ["@pracht/image/client"]`.
|
|
10
|
+
|
|
11
|
+
declare module "*?pracht" {
|
|
12
|
+
const metadata: import("@pracht/image").PrachtImageMetadata;
|
|
13
|
+
export const src: string;
|
|
14
|
+
export const width: number;
|
|
15
|
+
export const height: number;
|
|
16
|
+
/** Undefined for SVG sources (vectors scale cleanly without a blur). */
|
|
17
|
+
export const blurDataURL: string | undefined;
|
|
18
|
+
export default metadata;
|
|
19
|
+
}
|
|
@@ -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,3 +1,4 @@
|
|
|
1
|
+
import { t as PrachtImageMetadata } from "./metadata-CA97TjOR.mjs";
|
|
1
2
|
import { JSX, VNode } from "preact";
|
|
2
3
|
|
|
3
4
|
//#region src/loaders.d.ts
|
|
@@ -48,9 +49,13 @@ declare const vercelLoader: ImageLoader;
|
|
|
48
49
|
declare const passthroughLoader: ImageLoader;
|
|
49
50
|
//#endregion
|
|
50
51
|
//#region src/image.d.ts
|
|
51
|
-
interface ImageProps extends Omit<JSX.HTMLAttributes<HTMLImageElement>, "src" | "srcset" | "srcSet" | "width" | "height" | "sizes" | "loading" | "alt" | "style"> {
|
|
52
|
-
/**
|
|
53
|
-
|
|
52
|
+
interface ImageProps extends Omit<JSX.HTMLAttributes<HTMLImageElement>, "src" | "srcset" | "srcSet" | "width" | "height" | "sizes" | "loading" | "alt" | "style" | "placeholder"> {
|
|
53
|
+
/**
|
|
54
|
+
* Source path (`/hero.jpg`), absolute URL, or the metadata object from a
|
|
55
|
+
* build-time `?pracht` import (which supplies `width`, `height`, and
|
|
56
|
+
* `blurDataURL` automatically).
|
|
57
|
+
*/
|
|
58
|
+
src: string | PrachtImageMetadata;
|
|
54
59
|
/** Required for accessibility. Use `alt=""` for decorative images. */
|
|
55
60
|
alt: string;
|
|
56
61
|
/** Intrinsic width in pixels. Required unless `fill` is set. */
|
|
@@ -74,6 +79,22 @@ interface ImageProps extends Omit<JSX.HTMLAttributes<HTMLImageElement>, "src" |
|
|
|
74
79
|
loading?: "lazy" | "eager";
|
|
75
80
|
/** Per-component loader override; falls back to the configured loader. */
|
|
76
81
|
loader?: ImageLoader;
|
|
82
|
+
/**
|
|
83
|
+
* `"blur"` paints a tiny inline preview behind the image while it loads.
|
|
84
|
+
* Requires a `blurDataURL` — supplied automatically when `src` is a
|
|
85
|
+
* `?pracht` import, or pass it by hand. The placeholder is pure CSS
|
|
86
|
+
* (a `background-image` on the `<img>` itself), so it needs no hydration
|
|
87
|
+
* and works with `hydration: "none"`; the real image simply covers it once
|
|
88
|
+
* it paints. Note: images with transparency show the placeholder through
|
|
89
|
+
* transparent regions — prefer `placeholder="empty"` for those.
|
|
90
|
+
*/
|
|
91
|
+
placeholder?: "blur" | "empty";
|
|
92
|
+
/**
|
|
93
|
+
* `data:image/…` URI painted behind the image when `placeholder="blur"`.
|
|
94
|
+
* Values that are not well-formed image data URIs are ignored (they could
|
|
95
|
+
* otherwise inject CSS via the style attribute).
|
|
96
|
+
*/
|
|
97
|
+
blurDataURL?: string;
|
|
77
98
|
style?: string | JSX.CSSProperties;
|
|
78
99
|
}
|
|
79
100
|
/**
|
|
@@ -114,4 +135,4 @@ declare function getImageConfig(): ImageConfig;
|
|
|
114
135
|
/** Restore the default configuration. Primarily useful in tests. */
|
|
115
136
|
declare function resetImageConfig(): void;
|
|
116
137
|
//#endregion
|
|
117
|
-
export { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_ENDPOINT, DEFAULT_IMAGE_SIZES, DEFAULT_QUALITY, Image, type ImageConfig, type ImageLoader, type ImageLoaderArgs, type ImageProps, cloudflareLoader, configureImage, createDefaultLoader, defaultLoader, getImageConfig, passthroughLoader, resetImageConfig, vercelLoader };
|
|
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 };
|
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 = {
|
|
@@ -11,6 +11,19 @@ const FILL_STYLE = {
|
|
|
11
11
|
bottom: 0
|
|
12
12
|
};
|
|
13
13
|
const FILL_STYLE_STRING = "position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;";
|
|
14
|
+
const BLUR_DATA_URL_PATTERN = /^data:image\/[a-z0-9.+-]+(?:;[a-z0-9=+-]+)*,[a-z0-9+/=._%-]*$/i;
|
|
15
|
+
function blurBackground(blurDataURL) {
|
|
16
|
+
const image = `url("${blurDataURL}")`;
|
|
17
|
+
return {
|
|
18
|
+
styleString: `background-image:${image};background-size:cover;background-position:50% 50%;background-repeat:no-repeat;`,
|
|
19
|
+
styleObject: {
|
|
20
|
+
backgroundImage: image,
|
|
21
|
+
backgroundSize: "cover",
|
|
22
|
+
backgroundPosition: "50% 50%",
|
|
23
|
+
backgroundRepeat: "no-repeat"
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
14
27
|
const warned = /* @__PURE__ */ new Set();
|
|
15
28
|
function getNodeEnv() {
|
|
16
29
|
return globalThis.process?.env?.NODE_ENV;
|
|
@@ -84,31 +97,42 @@ function planSrcSet(deviceSizes, imageSizes, width, sizes) {
|
|
|
84
97
|
* loader (see `configureImage()` and the `loader` prop).
|
|
85
98
|
*/
|
|
86
99
|
function Image(props) {
|
|
87
|
-
const { src, alt, width, height, fill = false, sizes, quality, priority = false, loading, loader, style, ...rest } = props;
|
|
100
|
+
const { src, alt, width, height, fill = false, sizes, quality, priority = false, loading, loader, placeholder = "empty", blurDataURL, style, ...rest } = props;
|
|
101
|
+
const metadata = typeof src === "string" ? void 0 : src;
|
|
102
|
+
const srcString = typeof src === "string" ? src : src.src;
|
|
88
103
|
const config = getImageConfig();
|
|
89
104
|
const resolvedLoader = loader ?? config.loader;
|
|
90
105
|
const resolvedQuality = quality ?? config.quality;
|
|
91
|
-
const numericWidth = toDimension(width);
|
|
92
|
-
const numericHeight = toDimension(height);
|
|
106
|
+
const numericWidth = toDimension(width) ?? (fill ? void 0 : metadata?.width);
|
|
107
|
+
const numericHeight = toDimension(height) ?? (fill ? void 0 : metadata?.height);
|
|
108
|
+
const resolvedBlurDataURL = placeholder === "blur" ? blurDataURL ?? metadata?.blurDataURL : void 0;
|
|
109
|
+
const safeBlurDataURL = resolvedBlurDataURL != null && BLUR_DATA_URL_PATTERN.test(resolvedBlurDataURL) ? resolvedBlurDataURL : void 0;
|
|
93
110
|
if (isDevWarningsEnabled()) {
|
|
94
|
-
if (!fill && (numericWidth == null || numericHeight == null)) warnOnce(`dimensions:${
|
|
95
|
-
if (fill && (width != null || height != null)) warnOnce(`fill-dimensions:${
|
|
111
|
+
if (!fill && (numericWidth == null || numericHeight == null)) warnOnce(`dimensions:${srcString}`, `[pracht/image] <Image src="${srcString}"> is missing required "width" and "height" props. Provide the intrinsic dimensions (or use the "fill" prop) so the browser can reserve space and avoid layout shift.`);
|
|
112
|
+
if (fill && (width != null || height != null)) warnOnce(`fill-dimensions:${srcString}`, `[pracht/image] <Image src="${srcString}"> uses "fill" together with "width"/"height". "fill" images size themselves to their positioned parent; remove the explicit dimensions.`);
|
|
113
|
+
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
|
+
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.`);
|
|
96
115
|
}
|
|
97
116
|
const effectiveSizes = sizes ?? (fill ? "100vw" : void 0);
|
|
98
117
|
const plan = planSrcSet(config.deviceSizes, config.imageSizes, numericWidth, effectiveSizes);
|
|
99
118
|
const candidates = plan.widths.map((candidateWidth) => resolvedLoader({
|
|
100
|
-
src,
|
|
119
|
+
src: srcString,
|
|
101
120
|
width: candidateWidth,
|
|
102
121
|
quality: resolvedQuality
|
|
103
122
|
}));
|
|
104
123
|
const largestSrc = candidates[candidates.length - 1];
|
|
105
124
|
const optimized = new Set(candidates).size > 1;
|
|
106
125
|
const srcset = optimized ? candidates.map((url, index) => plan.descriptor === "w" ? `${url} ${plan.widths[index]}w` : `${url} ${index + 1}x`).join(", ") : void 0;
|
|
126
|
+
const blur = safeBlurDataURL != null ? blurBackground(safeBlurDataURL) : void 0;
|
|
107
127
|
let mergedStyle = style;
|
|
108
|
-
if (fill)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
128
|
+
if (blur || fill) {
|
|
129
|
+
const baseString = `${blur?.styleString ?? ""}${fill ? FILL_STYLE_STRING : ""}`;
|
|
130
|
+
mergedStyle = typeof style === "string" ? `${baseString}${style}` : {
|
|
131
|
+
...blur?.styleObject,
|
|
132
|
+
...fill ? FILL_STYLE : void 0,
|
|
133
|
+
...style
|
|
134
|
+
};
|
|
135
|
+
}
|
|
112
136
|
const imgProps = {
|
|
113
137
|
...rest,
|
|
114
138
|
src: largestSrc,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//#region src/metadata.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Metadata produced by a build-time `?pracht` image import (see
|
|
4
|
+
* `prachtImage()` from `@pracht/image/vite`).
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import hero from "./hero.jpg?pracht";
|
|
8
|
+
* // hero: { src, width, height, blurDataURL }
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Pass the whole object to `<Image src={hero} …>` to get intrinsic sizing and
|
|
12
|
+
* `placeholder="blur"` support without repeating dimensions by hand.
|
|
13
|
+
*/
|
|
14
|
+
interface PrachtImageMetadata {
|
|
15
|
+
/** Final asset URL (hashed in production builds, dev-served in dev). */
|
|
16
|
+
src: string;
|
|
17
|
+
/** Intrinsic width in pixels, after applying EXIF orientation. */
|
|
18
|
+
width: number;
|
|
19
|
+
/** Intrinsic height in pixels, after applying EXIF orientation. */
|
|
20
|
+
height: number;
|
|
21
|
+
/**
|
|
22
|
+
* Tiny inline preview (`data:image/webp;base64,…`, ~8px wide) for
|
|
23
|
+
* `placeholder="blur"`. Undefined for SVG sources: vectors scale cleanly,
|
|
24
|
+
* so a raster blur adds bytes without adding information.
|
|
25
|
+
*/
|
|
26
|
+
blurDataURL?: string;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
export { 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
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite plugin enabling build-time image imports:
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* // vite.config.ts — add it next to pracht(); it is not included by default.
|
|
6
|
+
* import { prachtImage } from "@pracht/image/vite";
|
|
7
|
+
* export default { plugins: [pracht({ … }), prachtImage()] };
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* import hero from "./hero.jpg?pracht";
|
|
12
|
+
* <Image src={hero} alt="…" placeholder="blur" />;
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The import yields `{ src, width, height, blurDataURL }`: `src` goes through
|
|
16
|
+
* Vite's normal asset pipeline (hashed source assets, stable publicDir URLs,
|
|
17
|
+
* `base`, dev server), dimensions come from sharp metadata with EXIF
|
|
18
|
+
* orientation applied, and `blurDataURL` is a tiny inline WebP generated at
|
|
19
|
+
* build time. Add
|
|
20
|
+
* `/// <reference types="@pracht/image/client" />` (or `"types":
|
|
21
|
+
* ["@pracht/image/client"]` in tsconfig) so TypeScript understands the query.
|
|
22
|
+
*/import { t as PrachtImageMetadata } from "./metadata-CA97TjOR.mjs";
|
|
23
|
+
import { Plugin } from "vite";
|
|
24
|
+
|
|
25
|
+
//#region src/vite.d.ts
|
|
26
|
+
/**
|
|
27
|
+
* Options for the `?pracht` image import plugin.
|
|
28
|
+
*/
|
|
29
|
+
interface PrachtImageOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Width in pixels of the generated blur placeholder. Kept tiny on purpose:
|
|
32
|
+
* the placeholder is inlined as a base64 data URI in HTML and JS.
|
|
33
|
+
* Defaults to 8.
|
|
34
|
+
*/
|
|
35
|
+
blurWidth?: number;
|
|
36
|
+
/** WebP quality (1-100) of the blur placeholder. Defaults to 70. */
|
|
37
|
+
blurQuality?: number;
|
|
38
|
+
/** Override how sharp is imported (useful for tests). */
|
|
39
|
+
loadSharp?: () => Promise<unknown>;
|
|
40
|
+
}
|
|
41
|
+
/** Minimal structural typing for the parts of sharp used at build time. */
|
|
42
|
+
interface SharpMetadata {
|
|
43
|
+
format?: string;
|
|
44
|
+
width?: number;
|
|
45
|
+
height?: number;
|
|
46
|
+
orientation?: number;
|
|
47
|
+
pages?: number;
|
|
48
|
+
}
|
|
49
|
+
interface SharpPipeline {
|
|
50
|
+
metadata(): Promise<SharpMetadata>;
|
|
51
|
+
rotate(): SharpPipeline;
|
|
52
|
+
resize(options: {
|
|
53
|
+
width: number;
|
|
54
|
+
withoutEnlargement: boolean;
|
|
55
|
+
}): SharpPipeline;
|
|
56
|
+
webp(options: {
|
|
57
|
+
quality: number;
|
|
58
|
+
}): SharpPipeline;
|
|
59
|
+
toBuffer(): Promise<Uint8Array>;
|
|
60
|
+
}
|
|
61
|
+
type SharpFactory = (input: Uint8Array) => SharpPipeline;
|
|
62
|
+
/** `/path/to/hero.jpg?pracht` → true; `?pracht` may combine with other params. */
|
|
63
|
+
declare function isPrachtImageId(id: string): boolean;
|
|
64
|
+
/** Strip the entire query, leaving the file path. */
|
|
65
|
+
declare function stripImageQuery(id: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* Read intrinsic dimensions (respecting EXIF orientation) and generate the
|
|
68
|
+
* blur placeholder for one image buffer. Exported for tests.
|
|
69
|
+
*/
|
|
70
|
+
declare function analyzeImage(sharp: SharpFactory, source: Uint8Array, options: {
|
|
71
|
+
blurWidth: number;
|
|
72
|
+
blurQuality: number;
|
|
73
|
+
}): Promise<Omit<PrachtImageMetadata, "src">>;
|
|
74
|
+
/**
|
|
75
|
+
* Generate the virtual module for a `?pracht` import. The `?url` import
|
|
76
|
+
* delegates the actual file to Vite's asset pipeline, so hashing, `base`,
|
|
77
|
+
* and dev serving all behave exactly like a plain asset import. `no-inline`
|
|
78
|
+
* opts out of `assetsInlineLimit`: without it, images under the limit
|
|
79
|
+
* (default 4 KB) turn `src` into a `data:` URI, which breaks
|
|
80
|
+
* optimization-endpoint loaders (`/api/_pracht/image?url=data%3A…` is not a
|
|
81
|
+
* fetchable same-origin path) and double-ships the bytes next to
|
|
82
|
+
* `blurDataURL`. The metadata contract promises a real asset URL (hashed for
|
|
83
|
+
* source files, stable for publicDir files). Exported for tests.
|
|
84
|
+
*/
|
|
85
|
+
declare function createImageModuleCode(assetId: string, analyzed: Omit<PrachtImageMetadata, "src">): string;
|
|
86
|
+
declare function prachtImage(options?: PrachtImageOptions): Plugin;
|
|
87
|
+
//#endregion
|
|
88
|
+
export { PrachtImageOptions, analyzeImage, createImageModuleCode, isPrachtImageId, prachtImage, stripImageQuery };
|
package/dist/vite.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
//#region src/vite.ts
|
|
4
|
+
const PRACHT_IMAGE_QUERY = "pracht";
|
|
5
|
+
const DEFAULT_BLUR_WIDTH = 8;
|
|
6
|
+
const DEFAULT_BLUR_QUALITY = 70;
|
|
7
|
+
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
|
+
function createSharpImporter(load) {
|
|
9
|
+
let cached;
|
|
10
|
+
return () => {
|
|
11
|
+
cached ??= load().then((mod) => mod.default ?? mod, () => {
|
|
12
|
+
cached = void 0;
|
|
13
|
+
throw new Error(SHARP_INSTALL_HINT);
|
|
14
|
+
});
|
|
15
|
+
return cached;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** `/path/to/hero.jpg?pracht` → true; `?pracht` may combine with other params. */
|
|
19
|
+
function isPrachtImageId(id) {
|
|
20
|
+
const queryStart = id.indexOf("?");
|
|
21
|
+
if (queryStart === -1) return false;
|
|
22
|
+
return id.slice(queryStart + 1).split("&").some((part) => part === PRACHT_IMAGE_QUERY);
|
|
23
|
+
}
|
|
24
|
+
/** Strip the entire query, leaving the file path. */
|
|
25
|
+
function stripImageQuery(id) {
|
|
26
|
+
const queryStart = id.indexOf("?");
|
|
27
|
+
return queryStart === -1 ? id : id.slice(0, queryStart);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Read intrinsic dimensions (respecting EXIF orientation) and generate the
|
|
31
|
+
* blur placeholder for one image buffer. Exported for tests.
|
|
32
|
+
*/
|
|
33
|
+
async function analyzeImage(sharp, source, options) {
|
|
34
|
+
const metadata = await sharp(source).metadata();
|
|
35
|
+
let width = metadata.width;
|
|
36
|
+
let height = metadata.height;
|
|
37
|
+
const orientation = metadata.orientation ?? 1;
|
|
38
|
+
if (orientation >= 5 && orientation <= 8) {
|
|
39
|
+
width = metadata.height;
|
|
40
|
+
height = metadata.width;
|
|
41
|
+
}
|
|
42
|
+
if (width == null || height == null || width <= 0 || height <= 0) throw new Error(`could not determine intrinsic dimensions (format: ${metadata.format ?? "unknown"}). For SVG sources, add width/height or a viewBox attribute.`);
|
|
43
|
+
if (metadata.format === "svg") return {
|
|
44
|
+
width,
|
|
45
|
+
height
|
|
46
|
+
};
|
|
47
|
+
const blur = await sharp(source).rotate().resize({
|
|
48
|
+
width: options.blurWidth,
|
|
49
|
+
withoutEnlargement: true
|
|
50
|
+
}).webp({ quality: options.blurQuality }).toBuffer();
|
|
51
|
+
return {
|
|
52
|
+
width,
|
|
53
|
+
height,
|
|
54
|
+
blurDataURL: `data:image/webp;base64,${Buffer.from(blur).toString("base64")}`
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Generate the virtual module for a `?pracht` import. The `?url` import
|
|
59
|
+
* delegates the actual file to Vite's asset pipeline, so hashing, `base`,
|
|
60
|
+
* and dev serving all behave exactly like a plain asset import. `no-inline`
|
|
61
|
+
* opts out of `assetsInlineLimit`: without it, images under the limit
|
|
62
|
+
* (default 4 KB) turn `src` into a `data:` URI, which breaks
|
|
63
|
+
* optimization-endpoint loaders (`/api/_pracht/image?url=data%3A…` is not a
|
|
64
|
+
* fetchable same-origin path) and double-ships the bytes next to
|
|
65
|
+
* `blurDataURL`. The metadata contract promises a real asset URL (hashed for
|
|
66
|
+
* source files, stable for publicDir files). Exported for tests.
|
|
67
|
+
*/
|
|
68
|
+
function createImageModuleCode(assetId, analyzed) {
|
|
69
|
+
const assetImport = `${assetId.replace(/\\/g, "/")}?url&no-inline`;
|
|
70
|
+
return [
|
|
71
|
+
`import src from ${JSON.stringify(assetImport)};`,
|
|
72
|
+
`export const width = ${JSON.stringify(analyzed.width)};`,
|
|
73
|
+
`export const height = ${JSON.stringify(analyzed.height)};`,
|
|
74
|
+
`export const blurDataURL = ${JSON.stringify(analyzed.blurDataURL)};`,
|
|
75
|
+
"export { src };",
|
|
76
|
+
"export default { src, width, height, blurDataURL };"
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
async function resolvePublicFile(publicDir, source) {
|
|
80
|
+
if (!publicDir || !source.startsWith("/")) return void 0;
|
|
81
|
+
const candidate = resolve(publicDir, `.${source}`);
|
|
82
|
+
const relativePath = relative(publicDir, candidate);
|
|
83
|
+
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) return;
|
|
84
|
+
return (await stat(candidate).catch(() => void 0))?.isFile() ? candidate : void 0;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Vite plugin enabling build-time image imports:
|
|
88
|
+
*
|
|
89
|
+
* ```ts
|
|
90
|
+
* // vite.config.ts — add it next to pracht(); it is not included by default.
|
|
91
|
+
* import { prachtImage } from "@pracht/image/vite";
|
|
92
|
+
* export default { plugins: [pracht({ … }), prachtImage()] };
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* ```tsx
|
|
96
|
+
* import hero from "./hero.jpg?pracht";
|
|
97
|
+
* <Image src={hero} alt="…" placeholder="blur" />;
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* The import yields `{ src, width, height, blurDataURL }`: `src` goes through
|
|
101
|
+
* Vite's normal asset pipeline (hashed source assets, stable publicDir URLs,
|
|
102
|
+
* `base`, dev server), dimensions come from sharp metadata with EXIF
|
|
103
|
+
* orientation applied, and `blurDataURL` is a tiny inline WebP generated at
|
|
104
|
+
* build time. Add
|
|
105
|
+
* `/// <reference types="@pracht/image/client" />` (or `"types":
|
|
106
|
+
* ["@pracht/image/client"]` in tsconfig) so TypeScript understands the query.
|
|
107
|
+
*/
|
|
108
|
+
function prachtImage(options = {}) {
|
|
109
|
+
const blurWidth = options.blurWidth ?? DEFAULT_BLUR_WIDTH;
|
|
110
|
+
const blurQuality = options.blurQuality ?? DEFAULT_BLUR_QUALITY;
|
|
111
|
+
if (!Number.isInteger(blurWidth) || blurWidth < 1 || blurWidth > 64) throw new Error("prachtImage({ blurWidth }) expects an integer between 1 and 64.");
|
|
112
|
+
if (!Number.isInteger(blurQuality) || blurQuality < 1 || blurQuality > 100) throw new Error("prachtImage({ blurQuality }) expects an integer between 1 and 100.");
|
|
113
|
+
const importSharp = createSharpImporter(options.loadSharp ?? (() => import("sharp")));
|
|
114
|
+
const cache = /* @__PURE__ */ new Map();
|
|
115
|
+
const resolvedImages = /* @__PURE__ */ new Map();
|
|
116
|
+
let publicDir = "";
|
|
117
|
+
async function transform(filePath, assetId) {
|
|
118
|
+
const stats = await stat(filePath).catch(() => {
|
|
119
|
+
throw new Error(`[pracht/image] Could not read "${filePath}" for a "?pracht" import.`);
|
|
120
|
+
});
|
|
121
|
+
const cacheKey = `${filePath}\0${assetId}`;
|
|
122
|
+
const cached = cache.get(cacheKey);
|
|
123
|
+
if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) return cached.code;
|
|
124
|
+
const code = (async () => {
|
|
125
|
+
const [sharp, source] = await Promise.all([importSharp(), readFile(filePath)]);
|
|
126
|
+
let analyzed;
|
|
127
|
+
try {
|
|
128
|
+
analyzed = await analyzeImage(sharp, source, {
|
|
129
|
+
blurWidth,
|
|
130
|
+
blurQuality
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new Error(`[pracht/image] Failed to process "?pracht" import of "${filePath}": ${error instanceof Error ? error.message : String(error)}`);
|
|
134
|
+
}
|
|
135
|
+
return createImageModuleCode(assetId, analyzed);
|
|
136
|
+
})();
|
|
137
|
+
code.catch(() => cache.delete(cacheKey));
|
|
138
|
+
cache.set(cacheKey, {
|
|
139
|
+
mtimeMs: stats.mtimeMs,
|
|
140
|
+
size: stats.size,
|
|
141
|
+
code
|
|
142
|
+
});
|
|
143
|
+
return code;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
name: "pracht:image-imports",
|
|
147
|
+
enforce: "pre",
|
|
148
|
+
configResolved(config) {
|
|
149
|
+
publicDir = config.publicDir;
|
|
150
|
+
},
|
|
151
|
+
async resolveId(source, importer) {
|
|
152
|
+
if (!isPrachtImageId(source)) return null;
|
|
153
|
+
const sourcePath = stripImageQuery(source);
|
|
154
|
+
const resolved = await this.resolve(sourcePath, importer, { skipSelf: true });
|
|
155
|
+
if (!resolved) return null;
|
|
156
|
+
const publicFile = resolved.id === sourcePath ? await resolvePublicFile(publicDir, sourcePath) : void 0;
|
|
157
|
+
const moduleId = `${resolved.id}${resolved.id.includes("?") ? "&" : "?"}${PRACHT_IMAGE_QUERY}`;
|
|
158
|
+
resolvedImages.set(moduleId, {
|
|
159
|
+
filePath: publicFile ?? resolved.id,
|
|
160
|
+
assetId: publicFile ? sourcePath : resolved.id
|
|
161
|
+
});
|
|
162
|
+
return moduleId;
|
|
163
|
+
},
|
|
164
|
+
async load(id) {
|
|
165
|
+
if (!isPrachtImageId(id)) return null;
|
|
166
|
+
const resolved = resolvedImages.get(id) ?? {
|
|
167
|
+
filePath: stripImageQuery(id),
|
|
168
|
+
assetId: stripImageQuery(id)
|
|
169
|
+
};
|
|
170
|
+
this.addWatchFile(resolved.filePath);
|
|
171
|
+
return transform(resolved.filePath, resolved.assetId);
|
|
172
|
+
},
|
|
173
|
+
watchChange(filePath) {
|
|
174
|
+
if (this.environment.mode !== "dev") return;
|
|
175
|
+
for (const [moduleId, resolved] of resolvedImages) {
|
|
176
|
+
if (resolved.filePath.replace(/\\/g, "/") !== filePath.replace(/\\/g, "/")) continue;
|
|
177
|
+
const module = this.environment.moduleGraph.getModuleById(moduleId);
|
|
178
|
+
if (module) this.environment.moduleGraph.invalidateModule(module);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
184
|
+
export { analyzeImage, createImageModuleCode, isPrachtImageId, prachtImage, stripImageQuery };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pracht/image",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"directory": "packages/image"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
|
-
"dist"
|
|
26
|
+
"dist",
|
|
27
|
+
"client.d.ts"
|
|
27
28
|
],
|
|
28
29
|
"type": "module",
|
|
29
30
|
"exports": {
|
|
@@ -34,22 +35,34 @@
|
|
|
34
35
|
"./node": {
|
|
35
36
|
"types": "./dist/node.d.mts",
|
|
36
37
|
"default": "./dist/node.mjs"
|
|
38
|
+
},
|
|
39
|
+
"./vite": {
|
|
40
|
+
"types": "./dist/vite.d.mts",
|
|
41
|
+
"default": "./dist/vite.mjs"
|
|
42
|
+
},
|
|
43
|
+
"./client": {
|
|
44
|
+
"types": "./client.d.ts"
|
|
37
45
|
}
|
|
38
46
|
},
|
|
39
47
|
"publishConfig": {
|
|
40
48
|
"provenance": true
|
|
41
49
|
},
|
|
42
50
|
"peerDependencies": {
|
|
43
|
-
"preact": "^10.0.0",
|
|
44
|
-
"sharp": "^0.33.0 || ^0.34.0"
|
|
51
|
+
"preact": "^10.0.0 || ^11.0.0-0",
|
|
52
|
+
"sharp": "^0.33.0 || ^0.34.0",
|
|
53
|
+
"vite": "^8.0.0"
|
|
45
54
|
},
|
|
46
55
|
"peerDependenciesMeta": {
|
|
47
56
|
"sharp": {
|
|
48
57
|
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"vite": {
|
|
60
|
+
"optional": true
|
|
49
61
|
}
|
|
50
62
|
},
|
|
51
63
|
"devDependencies": {
|
|
52
|
-
"sharp": "^0.34.0"
|
|
64
|
+
"sharp": "^0.34.0",
|
|
65
|
+
"vite": "^8.0.0"
|
|
53
66
|
},
|
|
54
67
|
"scripts": {
|
|
55
68
|
"build": "tsdown"
|