@pracht/image 0.1.0 → 0.2.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 +16 -3
- package/client.d.ts +19 -0
- package/dist/index.d.mts +25 -4
- package/dist/index.mjs +39 -14
- package/dist/metadata-CA97TjOR.d.mts +29 -0
- package/dist/node.d.mts +7 -6
- package/dist/node.mjs +9 -11
- 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";
|
|
@@ -40,8 +51,10 @@ export const GET = imageHandler;
|
|
|
40
51
|
export const HEAD = imageHandler;
|
|
41
52
|
```
|
|
42
53
|
|
|
43
|
-
Set `localOrigin` to the same trusted
|
|
44
|
-
`nodeAdapter({ canonicalOrigin })
|
|
54
|
+
Set `localOrigin` to the same trusted origin used by
|
|
55
|
+
`nodeAdapter({ canonicalOrigin })` in development and production. Relative
|
|
56
|
+
sources fail closed when it is omitted; request and Host-derived origins are
|
|
57
|
+
never trusted, even when they look like loopback addresses.
|
|
45
58
|
|
|
46
59
|
See [docs/IMAGES.md](https://github.com/JoviDeCroock/pracht/blob/main/docs/IMAGES.md)
|
|
47
60
|
for the full guide: loader configuration, endpoint security options, and
|
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
|
+
}
|
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
|
@@ -11,15 +11,29 @@ 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;
|
|
17
30
|
}
|
|
18
31
|
function getImportMetaDev() {
|
|
19
|
-
const
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
if (typeof
|
|
32
|
+
const mode = import.meta.env?.MODE;
|
|
33
|
+
if (mode === "production") return false;
|
|
34
|
+
const dev = import.meta.env?.DEV;
|
|
35
|
+
if (typeof dev === "boolean") return dev;
|
|
36
|
+
if (typeof mode === "string") return mode !== "production";
|
|
23
37
|
}
|
|
24
38
|
function isDevWarningsEnabled() {
|
|
25
39
|
const nodeEnv = getNodeEnv();
|
|
@@ -83,31 +97,42 @@ function planSrcSet(deviceSizes, imageSizes, width, sizes) {
|
|
|
83
97
|
* loader (see `configureImage()` and the `loader` prop).
|
|
84
98
|
*/
|
|
85
99
|
function Image(props) {
|
|
86
|
-
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;
|
|
87
103
|
const config = getImageConfig();
|
|
88
104
|
const resolvedLoader = loader ?? config.loader;
|
|
89
105
|
const resolvedQuality = quality ?? config.quality;
|
|
90
|
-
const numericWidth = toDimension(width);
|
|
91
|
-
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;
|
|
92
110
|
if (isDevWarningsEnabled()) {
|
|
93
|
-
if (!fill && (numericWidth == null || numericHeight == null)) warnOnce(`dimensions:${
|
|
94
|
-
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.`);
|
|
95
115
|
}
|
|
96
116
|
const effectiveSizes = sizes ?? (fill ? "100vw" : void 0);
|
|
97
117
|
const plan = planSrcSet(config.deviceSizes, config.imageSizes, numericWidth, effectiveSizes);
|
|
98
118
|
const candidates = plan.widths.map((candidateWidth) => resolvedLoader({
|
|
99
|
-
src,
|
|
119
|
+
src: srcString,
|
|
100
120
|
width: candidateWidth,
|
|
101
121
|
quality: resolvedQuality
|
|
102
122
|
}));
|
|
103
123
|
const largestSrc = candidates[candidates.length - 1];
|
|
104
124
|
const optimized = new Set(candidates).size > 1;
|
|
105
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;
|
|
106
127
|
let mergedStyle = style;
|
|
107
|
-
if (fill)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
}
|
|
111
136
|
const imgProps = {
|
|
112
137
|
...rest,
|
|
113
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.d.mts
CHANGED
|
@@ -18,10 +18,9 @@ interface CreateImageHandlerOptions {
|
|
|
18
18
|
/** Remote sources to allow. Defaults to none (same-origin only). */
|
|
19
19
|
remotePatterns?: RemotePattern[];
|
|
20
20
|
/**
|
|
21
|
-
* Trusted
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* available without configuration for local development.
|
|
21
|
+
* Trusted origin used to resolve relative image paths. Required whenever
|
|
22
|
+
* relative sources are served so an attacker-controlled Host header cannot
|
|
23
|
+
* turn the endpoint into an open proxy.
|
|
25
24
|
*/
|
|
26
25
|
localOrigin?: string;
|
|
27
26
|
/**
|
|
@@ -67,7 +66,9 @@ interface ImageHandlerArgs {
|
|
|
67
66
|
* ```ts
|
|
68
67
|
* // src/api/_pracht/image.ts
|
|
69
68
|
* import { createImageHandler } from "@pracht/image/node";
|
|
70
|
-
* const imageHandler = createImageHandler(
|
|
69
|
+
* const imageHandler = createImageHandler({
|
|
70
|
+
* localOrigin: process.env.PRACHT_ORIGIN,
|
|
71
|
+
* });
|
|
71
72
|
* export const GET = imageHandler;
|
|
72
73
|
* export const HEAD = imageHandler;
|
|
73
74
|
* ```
|
|
@@ -75,7 +76,7 @@ interface ImageHandlerArgs {
|
|
|
75
76
|
* The handler resizes and re-encodes images with sharp (an optional peer
|
|
76
77
|
* dependency — install it in your app), negotiates WebP/AVIF via the `Accept`
|
|
77
78
|
* header, and answers with cacheable responses keyed on the query string.
|
|
78
|
-
* Relative sources resolve against a trusted `localOrigin
|
|
79
|
+
* Relative sources resolve only against a configured, trusted `localOrigin`;
|
|
79
80
|
* `remotePatterns` opts specific remote hosts in.
|
|
80
81
|
*/
|
|
81
82
|
declare function createImageHandler(options?: CreateImageHandlerOptions): (args: ImageHandlerArgs) => Promise<Response>;
|
package/dist/node.mjs
CHANGED
|
@@ -55,10 +55,6 @@ function normalizeLocalOrigin(value) {
|
|
|
55
55
|
if (url.protocol !== "http:" && url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new Error("createImageHandler({ localOrigin }) expects an http(s) origin without a path.");
|
|
56
56
|
return url.origin;
|
|
57
57
|
}
|
|
58
|
-
function isLoopbackHostname(hostname) {
|
|
59
|
-
const host = hostname.toLowerCase();
|
|
60
|
-
return host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(host);
|
|
61
|
-
}
|
|
62
58
|
function isAllowedTarget(url, localOrigin, patterns) {
|
|
63
59
|
if (url.protocol !== "http:" && url.protocol !== "https:" || url.username || url.password) return false;
|
|
64
60
|
return localOrigin !== void 0 && url.origin === localOrigin || matchesRemotePatterns(url, patterns);
|
|
@@ -114,7 +110,9 @@ function imageResponseBody(request, bytes) {
|
|
|
114
110
|
* ```ts
|
|
115
111
|
* // src/api/_pracht/image.ts
|
|
116
112
|
* import { createImageHandler } from "@pracht/image/node";
|
|
117
|
-
* const imageHandler = createImageHandler(
|
|
113
|
+
* const imageHandler = createImageHandler({
|
|
114
|
+
* localOrigin: process.env.PRACHT_ORIGIN,
|
|
115
|
+
* });
|
|
118
116
|
* export const GET = imageHandler;
|
|
119
117
|
* export const HEAD = imageHandler;
|
|
120
118
|
* ```
|
|
@@ -122,7 +120,7 @@ function imageResponseBody(request, bytes) {
|
|
|
122
120
|
* The handler resizes and re-encodes images with sharp (an optional peer
|
|
123
121
|
* dependency — install it in your app), negotiates WebP/AVIF via the `Accept`
|
|
124
122
|
* header, and answers with cacheable responses keyed on the query string.
|
|
125
|
-
* Relative sources resolve against a trusted `localOrigin
|
|
123
|
+
* Relative sources resolve only against a configured, trusted `localOrigin`;
|
|
126
124
|
* `remotePatterns` opts specific remote hosts in.
|
|
127
125
|
*/
|
|
128
126
|
function createImageHandler(options = {}) {
|
|
@@ -147,7 +145,6 @@ function createImageHandler(options = {}) {
|
|
|
147
145
|
headers: { allow: "GET, HEAD" }
|
|
148
146
|
});
|
|
149
147
|
const requestUrl = new URL(request.url);
|
|
150
|
-
const localOrigin = configuredLocalOrigin ?? (isLoopbackHostname(requestUrl.hostname) ? requestUrl.origin : void 0);
|
|
151
148
|
const source = requestUrl.searchParams.get("url");
|
|
152
149
|
const widthParam = requestUrl.searchParams.get("w");
|
|
153
150
|
const qualityParam = requestUrl.searchParams.get("q");
|
|
@@ -163,8 +160,9 @@ function createImageHandler(options = {}) {
|
|
|
163
160
|
if (target.username || target.password) return errorResponse(400, "The \"url\" parameter may not contain credentials.");
|
|
164
161
|
if (!matchesRemotePatterns(target, remotePatterns)) return errorResponse(403, `Remote image "${source}" is not allowed. Add its host to the remotePatterns option of createImageHandler() to opt it in.`);
|
|
165
162
|
} else if (source.startsWith("/")) {
|
|
166
|
-
if (!
|
|
167
|
-
target = new URL(source,
|
|
163
|
+
if (!configuredLocalOrigin) return errorResponse(500, "Relative image sources require createImageHandler({ localOrigin }).");
|
|
164
|
+
target = new URL(source, configuredLocalOrigin);
|
|
165
|
+
if (target.origin !== configuredLocalOrigin) return errorResponse(400, "Relative \"url\" values must remain on the configured localOrigin.");
|
|
168
166
|
} else return errorResponse(400, "The \"url\" parameter must be a relative path (starting with \"/\") or an absolute http(s) URL.");
|
|
169
167
|
if (!widthParam) return errorResponse(400, "Missing required \"w\" query parameter.");
|
|
170
168
|
const width = Number(widthParam);
|
|
@@ -195,7 +193,7 @@ function createImageHandler(options = {}) {
|
|
|
195
193
|
} catch {
|
|
196
194
|
return errorResponse(502, `Source image "${source}" returned an invalid redirect.`);
|
|
197
195
|
}
|
|
198
|
-
if (!isAllowedTarget(nextTarget,
|
|
196
|
+
if (!isAllowedTarget(nextTarget, configuredLocalOrigin, remotePatterns)) return errorResponse(403, `Source image "${source}" redirected to a host that is not allowed.`);
|
|
199
197
|
try {
|
|
200
198
|
await upstream.body?.cancel();
|
|
201
199
|
} catch {}
|
|
@@ -209,7 +207,7 @@ function createImageHandler(options = {}) {
|
|
|
209
207
|
} catch {
|
|
210
208
|
finalUrl = void 0;
|
|
211
209
|
}
|
|
212
|
-
if (finalUrl && !isAllowedTarget(finalUrl,
|
|
210
|
+
if (finalUrl && !isAllowedTarget(finalUrl, configuredLocalOrigin, remotePatterns)) return errorResponse(403, `Source image "${source}" redirected to a host that is not allowed.`);
|
|
213
211
|
}
|
|
214
212
|
if (!upstream.ok) return errorResponse(502, `Source image "${source}" responded with ${upstream.status}.`);
|
|
215
213
|
const sourceType = (upstream.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
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.2.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"
|