@lonik/oh-image 1.3.0 → 2.0.1
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/dist/plugin.d.ts +22 -2
- package/dist/plugin.js +92 -18
- package/dist/react.d.ts +67 -2
- package/dist/react.js +254 -26
- package/package.json +5 -1
package/dist/plugin.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { FormatEnum } from "sharp";
|
|
|
2
2
|
import { Plugin } from "vite";
|
|
3
3
|
|
|
4
4
|
//#region src/plugin/types.d.ts
|
|
5
|
-
interface PluginConfig extends Required<Pick<ImageOptions, "placeholder" | "
|
|
5
|
+
interface PluginConfig extends Required<Pick<ImageOptions, "placeholder" | "breakpoints" | "format">> {
|
|
6
6
|
/** Directory name where processed images will be output during build */
|
|
7
7
|
distDir: string;
|
|
8
8
|
}
|
|
@@ -16,7 +16,27 @@ interface ImageOptions {
|
|
|
16
16
|
/** Whether to generate a placeholder image for lazy loading */
|
|
17
17
|
placeholder?: boolean;
|
|
18
18
|
/** Breakpoints array - widths in pixels for responsive srcSet generation */
|
|
19
|
-
|
|
19
|
+
breakpoints?: number[];
|
|
20
|
+
/** Blur the image */
|
|
21
|
+
blur?: number | null;
|
|
22
|
+
/** Flip the image vertically */
|
|
23
|
+
flip?: boolean | null;
|
|
24
|
+
/** Flop the image horizontally */
|
|
25
|
+
flop?: boolean | null;
|
|
26
|
+
/** Rotate the image */
|
|
27
|
+
rotate?: number | null;
|
|
28
|
+
/** Sharpen the image */
|
|
29
|
+
sharpen?: number | null;
|
|
30
|
+
/** Apply median filter */
|
|
31
|
+
median?: number | null;
|
|
32
|
+
/** Apply gamma correction */
|
|
33
|
+
gamma?: number | null;
|
|
34
|
+
/** Negate the image */
|
|
35
|
+
negate?: boolean | null;
|
|
36
|
+
/** Normalize the image */
|
|
37
|
+
normalize?: boolean | null;
|
|
38
|
+
/** Apply threshold */
|
|
39
|
+
threshold?: number | null;
|
|
20
40
|
}
|
|
21
41
|
//#endregion
|
|
22
42
|
//#region src/plugin/plugin.d.ts
|
package/dist/plugin.js
CHANGED
|
@@ -10,30 +10,47 @@ function queryToOptions(processKey, uri) {
|
|
|
10
10
|
const [path, query] = uri.split("?");
|
|
11
11
|
if (!query || !path) return {
|
|
12
12
|
shouldProcess: false,
|
|
13
|
-
path: ""
|
|
13
|
+
path: "",
|
|
14
|
+
queryString: ""
|
|
14
15
|
};
|
|
15
16
|
const parsed = queryString.parse(query, {
|
|
16
17
|
parseBooleans: true,
|
|
17
18
|
parseNumbers: true,
|
|
18
19
|
arrayFormat: "comma",
|
|
19
|
-
types: {
|
|
20
|
+
types: {
|
|
21
|
+
breakpoints: "number[]",
|
|
22
|
+
blur: "number",
|
|
23
|
+
flip: "boolean",
|
|
24
|
+
flop: "boolean",
|
|
25
|
+
rotate: "number",
|
|
26
|
+
sharpen: "number",
|
|
27
|
+
median: "number",
|
|
28
|
+
gamma: "number",
|
|
29
|
+
negate: "boolean",
|
|
30
|
+
normalize: "boolean",
|
|
31
|
+
threshold: "number"
|
|
32
|
+
}
|
|
20
33
|
});
|
|
21
34
|
if (processKey in parsed) return {
|
|
22
35
|
shouldProcess: true,
|
|
23
36
|
options: parsed,
|
|
24
|
-
path
|
|
37
|
+
path,
|
|
38
|
+
queryString: query
|
|
25
39
|
};
|
|
26
40
|
else return {
|
|
27
41
|
shouldProcess: false,
|
|
28
|
-
path
|
|
42
|
+
path,
|
|
43
|
+
queryString: query
|
|
29
44
|
};
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
//#endregion
|
|
33
48
|
//#region src/plugin/file-utils.ts
|
|
34
|
-
async function
|
|
35
|
-
|
|
36
|
-
|
|
49
|
+
async function getHash(value) {
|
|
50
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
51
|
+
}
|
|
52
|
+
async function getFileHash(filePath, queryString$1) {
|
|
53
|
+
return `${await getHash(await readFile(filePath))}-${await getHash(queryString$1)}`;
|
|
37
54
|
}
|
|
38
55
|
async function readFileSafe(path) {
|
|
39
56
|
try {
|
|
@@ -94,7 +111,17 @@ function createImageEntries() {
|
|
|
94
111
|
width: entry.width,
|
|
95
112
|
height: entry.height,
|
|
96
113
|
format: entry.format,
|
|
97
|
-
origin: entry.origin
|
|
114
|
+
origin: entry.origin,
|
|
115
|
+
blur: entry.blur,
|
|
116
|
+
flip: entry.flip,
|
|
117
|
+
flop: entry.flop,
|
|
118
|
+
rotate: entry.rotate,
|
|
119
|
+
sharpen: entry.sharpen,
|
|
120
|
+
median: entry.median,
|
|
121
|
+
gamma: entry.gamma,
|
|
122
|
+
negate: entry.negate,
|
|
123
|
+
normalize: entry.normalize,
|
|
124
|
+
threshold: entry.threshold
|
|
98
125
|
};
|
|
99
126
|
this.set(identifier, mainEntry);
|
|
100
127
|
},
|
|
@@ -113,7 +140,16 @@ function createImageEntries() {
|
|
|
113
140
|
height: placeholderHeight,
|
|
114
141
|
format: main.format,
|
|
115
142
|
blur: PLACEHOLDER_BLUR_QUALITY,
|
|
116
|
-
origin: main.origin
|
|
143
|
+
origin: main.origin,
|
|
144
|
+
flip: main.flip,
|
|
145
|
+
flop: main.flop,
|
|
146
|
+
rotate: main.rotate,
|
|
147
|
+
sharpen: main.sharpen,
|
|
148
|
+
median: main.median,
|
|
149
|
+
gamma: main.gamma,
|
|
150
|
+
negate: main.negate,
|
|
151
|
+
normalize: main.normalize,
|
|
152
|
+
threshold: main.threshold
|
|
117
153
|
};
|
|
118
154
|
this.set(identifier, placeholderEntry);
|
|
119
155
|
},
|
|
@@ -133,6 +169,15 @@ async function processImage(path, options) {
|
|
|
133
169
|
});
|
|
134
170
|
if (options.format) processed = processed.toFormat(options.format);
|
|
135
171
|
if (options.blur) processed = processed.blur(options.blur);
|
|
172
|
+
if (options.flip) processed = processed.flip();
|
|
173
|
+
if (options.flop) processed = processed.flop();
|
|
174
|
+
if (options.rotate) processed = processed.rotate(options.rotate);
|
|
175
|
+
if (options.sharpen) processed = processed.sharpen(options.sharpen);
|
|
176
|
+
if (options.median) processed = processed.median(options.median);
|
|
177
|
+
if (options.gamma) processed = processed.gamma(options.gamma);
|
|
178
|
+
if (options.negate) processed = processed.negate();
|
|
179
|
+
if (options.normalize) processed = processed.normalize();
|
|
180
|
+
if (options.threshold) processed = processed.threshold(options.threshold);
|
|
136
181
|
return await processed.toBuffer();
|
|
137
182
|
}
|
|
138
183
|
|
|
@@ -141,7 +186,7 @@ async function processImage(path, options) {
|
|
|
141
186
|
const DEFAULT_IMAGE_FORMAT = "webp";
|
|
142
187
|
const DEFAULT_CONFIGS = {
|
|
143
188
|
distDir: "oh-images",
|
|
144
|
-
|
|
189
|
+
breakpoints: [
|
|
145
190
|
16,
|
|
146
191
|
48,
|
|
147
192
|
96,
|
|
@@ -186,12 +231,12 @@ function ohImage(options) {
|
|
|
186
231
|
const fileId = basename(url);
|
|
187
232
|
const path = join(cacheDir, fileId);
|
|
188
233
|
const ext = extname(url).slice(1);
|
|
189
|
-
const image = await readFileSafe(path);
|
|
190
234
|
const imageEntry = imageEntries.get(url);
|
|
191
235
|
if (!imageEntry) {
|
|
192
236
|
console.warn("Image entry not found with id: " + url);
|
|
193
237
|
return next();
|
|
194
238
|
}
|
|
239
|
+
const image = await readFileSafe(path);
|
|
195
240
|
if (image) {
|
|
196
241
|
res.setHeader("Content-Type", `image/${ext}`);
|
|
197
242
|
res.end(image);
|
|
@@ -212,7 +257,7 @@ function ohImage(options) {
|
|
|
212
257
|
const origin = parsed.path;
|
|
213
258
|
const { name, ext } = parse(parsed.path);
|
|
214
259
|
const metadata = await sharp(parsed.path).metadata();
|
|
215
|
-
const hash = await getFileHash(origin);
|
|
260
|
+
const hash = await getFileHash(origin, parsed.queryString);
|
|
216
261
|
const mergedOptions = {
|
|
217
262
|
...config,
|
|
218
263
|
...parsed.options
|
|
@@ -229,7 +274,17 @@ function ohImage(options) {
|
|
|
229
274
|
width: mergedOptions.width,
|
|
230
275
|
height: mergedOptions.height,
|
|
231
276
|
format: mergedOptions.format,
|
|
232
|
-
origin
|
|
277
|
+
origin,
|
|
278
|
+
blur: mergedOptions.blur,
|
|
279
|
+
flip: mergedOptions.flip,
|
|
280
|
+
flop: mergedOptions.flop,
|
|
281
|
+
rotate: mergedOptions.rotate,
|
|
282
|
+
sharpen: mergedOptions.sharpen,
|
|
283
|
+
median: mergedOptions.median,
|
|
284
|
+
gamma: mergedOptions.gamma,
|
|
285
|
+
negate: mergedOptions.negate,
|
|
286
|
+
normalize: mergedOptions.normalize,
|
|
287
|
+
threshold: mergedOptions.threshold
|
|
233
288
|
});
|
|
234
289
|
const src = {
|
|
235
290
|
width: metadata.width,
|
|
@@ -237,24 +292,43 @@ function ohImage(options) {
|
|
|
237
292
|
src: mainIdentifier,
|
|
238
293
|
srcSets: ""
|
|
239
294
|
};
|
|
240
|
-
if (
|
|
295
|
+
if (mergedOptions.placeholder) {
|
|
241
296
|
const placeholderIdentifier = identifier.placeholder(DEFAULT_IMAGE_FORMAT);
|
|
242
297
|
imageEntries.createPlaceholderEntry(placeholderIdentifier, {
|
|
243
298
|
width: metadata.width,
|
|
244
299
|
height: metadata.height,
|
|
245
300
|
format: DEFAULT_IMAGE_FORMAT,
|
|
246
|
-
origin
|
|
301
|
+
origin,
|
|
302
|
+
flip: mergedOptions.flip,
|
|
303
|
+
flop: mergedOptions.flop,
|
|
304
|
+
rotate: mergedOptions.rotate,
|
|
305
|
+
sharpen: mergedOptions.sharpen,
|
|
306
|
+
median: mergedOptions.median,
|
|
307
|
+
gamma: mergedOptions.gamma,
|
|
308
|
+
negate: mergedOptions.negate,
|
|
309
|
+
normalize: mergedOptions.normalize,
|
|
310
|
+
threshold: mergedOptions.threshold
|
|
247
311
|
});
|
|
248
312
|
src.placeholderUrl = placeholderIdentifier;
|
|
249
313
|
}
|
|
250
|
-
if (mergedOptions.
|
|
314
|
+
if (mergedOptions.breakpoints) {
|
|
251
315
|
const srcSets = [];
|
|
252
|
-
for (const breakpoint of mergedOptions.
|
|
316
|
+
for (const breakpoint of mergedOptions.breakpoints) {
|
|
253
317
|
const srcSetIdentifier = identifier.srcSet(DEFAULT_IMAGE_FORMAT, breakpoint);
|
|
254
318
|
imageEntries.createSrcSetEntry(srcSetIdentifier, {
|
|
255
319
|
width: breakpoint,
|
|
256
320
|
format: DEFAULT_IMAGE_FORMAT,
|
|
257
|
-
origin
|
|
321
|
+
origin,
|
|
322
|
+
blur: mergedOptions.blur,
|
|
323
|
+
flip: mergedOptions.flip,
|
|
324
|
+
flop: mergedOptions.flop,
|
|
325
|
+
rotate: mergedOptions.rotate,
|
|
326
|
+
sharpen: mergedOptions.sharpen,
|
|
327
|
+
median: mergedOptions.median,
|
|
328
|
+
gamma: mergedOptions.gamma,
|
|
329
|
+
negate: mergedOptions.negate,
|
|
330
|
+
normalize: mergedOptions.normalize,
|
|
331
|
+
threshold: mergedOptions.threshold
|
|
258
332
|
});
|
|
259
333
|
srcSets.push(`${srcSetIdentifier} ${breakpoint}w`);
|
|
260
334
|
}
|
package/dist/react.d.ts
CHANGED
|
@@ -2,8 +2,15 @@ import { ImgHTMLAttributes } from "react";
|
|
|
2
2
|
import * as react_jsx_runtime0 from "react/jsx-runtime";
|
|
3
3
|
|
|
4
4
|
//#region src/react/types.d.ts
|
|
5
|
+
interface ImageLoaderOptions {
|
|
6
|
+
src: string;
|
|
7
|
+
width?: number | null | undefined;
|
|
8
|
+
height?: number | null | undefined;
|
|
9
|
+
isPlaceholder?: boolean;
|
|
10
|
+
}
|
|
11
|
+
type ImageLoader = (options: ImageLoaderOptions) => string;
|
|
5
12
|
type ImageSrcType = string | ImageSrc;
|
|
6
|
-
interface ImageProps extends Partial<Pick<ImgHTMLAttributes<HTMLImageElement>, "fetchPriority" | "decoding" | "loading" | "
|
|
13
|
+
interface ImageProps extends Partial<Pick<ImgHTMLAttributes<HTMLImageElement>, "fetchPriority" | "decoding" | "loading" | "srcSet" | "className" | "sizes" | "style">> {
|
|
7
14
|
/** Alternative text for the image, required for accessibility. Use an empty string for decorative images. */
|
|
8
15
|
alt: string;
|
|
9
16
|
/** Configures the Image component to load the image immediately. */
|
|
@@ -17,6 +24,10 @@ interface ImageProps extends Partial<Pick<ImgHTMLAttributes<HTMLImageElement>, "
|
|
|
17
24
|
* styles such that the image fills its containing element.
|
|
18
25
|
*/
|
|
19
26
|
fill?: boolean;
|
|
27
|
+
loader?: ImageLoader | null;
|
|
28
|
+
width?: number | undefined;
|
|
29
|
+
height?: number | undefined;
|
|
30
|
+
breakpoints?: number[];
|
|
20
31
|
}
|
|
21
32
|
//#endregion
|
|
22
33
|
//#region src/react/image.d.ts
|
|
@@ -51,4 +62,58 @@ declare function Image(props: ImageProps): react_jsx_runtime0.JSX.Element;
|
|
|
51
62
|
*/
|
|
52
63
|
declare function useImgLoaded(src: string | undefined): [(img: HTMLImageElement | null) => void, boolean];
|
|
53
64
|
//#endregion
|
|
54
|
-
|
|
65
|
+
//#region src/react/loaders/cloudflare-context.d.ts
|
|
66
|
+
interface CloudflareLoaderOptions {
|
|
67
|
+
path: string;
|
|
68
|
+
placeholder: boolean;
|
|
69
|
+
format: string;
|
|
70
|
+
params?: Record<string, string>;
|
|
71
|
+
placeholderParams?: Record<string, string>;
|
|
72
|
+
breakpoints?: number[];
|
|
73
|
+
}
|
|
74
|
+
declare function useCloudflareContext(): CloudflareLoaderOptions;
|
|
75
|
+
declare function CloudflareLoaderProvider({
|
|
76
|
+
children,
|
|
77
|
+
...props
|
|
78
|
+
}: {
|
|
79
|
+
children: React.ReactNode;
|
|
80
|
+
} & Partial<CloudflareLoaderOptions>): react_jsx_runtime0.JSX.Element;
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/react/loaders/cloudflare-loader.d.ts
|
|
83
|
+
declare function useCloudflareLoader(options?: Partial<CloudflareLoaderOptions>): ImageLoader;
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/react/loaders/imgproxy-context.d.ts
|
|
86
|
+
interface ImgproxyLoaderOptions {
|
|
87
|
+
path: string;
|
|
88
|
+
placeholder: boolean;
|
|
89
|
+
format: string;
|
|
90
|
+
params?: Record<string, string>;
|
|
91
|
+
placeholderParams?: Record<string, string>;
|
|
92
|
+
breakpoints?: number[];
|
|
93
|
+
paramsSeparator?: string;
|
|
94
|
+
}
|
|
95
|
+
declare function useImgproxyContext(): ImgproxyLoaderOptions;
|
|
96
|
+
declare function ImgproxyLoaderProvider({
|
|
97
|
+
children,
|
|
98
|
+
...props
|
|
99
|
+
}: {
|
|
100
|
+
children: React.ReactNode;
|
|
101
|
+
} & Partial<ImgproxyLoaderOptions>): react_jsx_runtime0.JSX.Element;
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/react/loaders/imgproxy-loader.d.ts
|
|
104
|
+
declare function useImgproxyLoader(options?: Partial<ImgproxyLoaderOptions>): ImageLoader;
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/react/image-context.d.ts
|
|
107
|
+
interface ImageContextValue extends Pick<ImageProps, "loading"> {
|
|
108
|
+
breakpoints: number[];
|
|
109
|
+
loader: ImageLoader | null;
|
|
110
|
+
}
|
|
111
|
+
declare function useImageContext(): ImageContextValue;
|
|
112
|
+
declare function ImageProvider({
|
|
113
|
+
children,
|
|
114
|
+
...props
|
|
115
|
+
}: {
|
|
116
|
+
children: React.ReactNode;
|
|
117
|
+
} & Partial<ImageContextValue>): react_jsx_runtime0.JSX.Element;
|
|
118
|
+
//#endregion
|
|
119
|
+
export { type CloudflareLoaderOptions, CloudflareLoaderProvider, Image, type ImageLoader, type ImageLoaderOptions, type ImageProps, ImageProvider, type ImageSrcType, type ImgproxyLoaderOptions, ImgproxyLoaderProvider, useCloudflareContext, useCloudflareLoader, useImageContext, useImgLoaded, useImgproxyContext, useImgproxyLoader };
|
package/dist/react.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as ReactDOM from "react-dom";
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { jsx } from "react/jsx-runtime";
|
|
4
4
|
|
|
5
5
|
//#region src/react/use-img-loaded.ts
|
|
@@ -85,51 +85,157 @@ function useImgLoaded(src) {
|
|
|
85
85
|
* Should match something like: "100w, 200w".
|
|
86
86
|
*/
|
|
87
87
|
const VALID_WIDTH_DESCRIPTOR_SRCSET = /^((\s*\d+w\s*(,|$)){1,})$/;
|
|
88
|
-
function resolveOptions(prop) {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
resolved.sizes = resolveSizes(prop);
|
|
104
|
-
resolved.loading = resolveLoading(prop);
|
|
88
|
+
function resolveOptions(prop, defaultOptions) {
|
|
89
|
+
const resolved = {
|
|
90
|
+
...defaultOptions,
|
|
91
|
+
...prop
|
|
92
|
+
};
|
|
93
|
+
resolved.decoding = resolveDecoding(resolved);
|
|
94
|
+
resolved.fetchPriority = resolveFetchPriority(resolved);
|
|
95
|
+
resolved.loading = resolveLoading(resolved);
|
|
96
|
+
resolved.srcSet = resolveSrcSet(resolved);
|
|
97
|
+
resolved.sizes = resolveSizes(resolved, resolved.srcSet, resolved.loading);
|
|
98
|
+
resolved.placeholderUrl = resolvePlaceholderURL(resolved);
|
|
99
|
+
resolved.height = resolveHeight(resolved);
|
|
100
|
+
resolved.width = resolveWidth(resolved);
|
|
101
|
+
resolved.src = resolveSrc(resolved);
|
|
105
102
|
return resolved;
|
|
106
103
|
}
|
|
104
|
+
function resolveDecoding(prop) {
|
|
105
|
+
return prop.asap ? "async" : prop.decoding;
|
|
106
|
+
}
|
|
107
|
+
function resolveFetchPriority(prop) {
|
|
108
|
+
if (prop.asap) return "high";
|
|
109
|
+
return prop.fetchPriority ?? "auto";
|
|
110
|
+
}
|
|
111
|
+
function resolveSrcSet(prop) {
|
|
112
|
+
if (prop.srcSet) return prop.srcSet;
|
|
113
|
+
if (typeof prop.src === "object") return prop.src.srcSets;
|
|
114
|
+
if (!prop.breakpoints) return;
|
|
115
|
+
const baseSrc = prop.src;
|
|
116
|
+
const entries = [];
|
|
117
|
+
for (const breakpoint of prop.breakpoints) if (prop.loader) entries.push(`${prop.loader({
|
|
118
|
+
src: baseSrc,
|
|
119
|
+
width: breakpoint,
|
|
120
|
+
height: prop.height,
|
|
121
|
+
isPlaceholder: false
|
|
122
|
+
})} ${breakpoint}w`);
|
|
123
|
+
if (entries.length === 0) return;
|
|
124
|
+
return entries.join(", ");
|
|
125
|
+
}
|
|
107
126
|
function resolveLoading(prop) {
|
|
108
127
|
if (!prop.asap && prop.loading !== void 0) return prop.loading;
|
|
109
128
|
return prop.asap ? "eager" : "lazy";
|
|
110
129
|
}
|
|
111
|
-
function resolveSizes(prop) {
|
|
112
|
-
const loading = resolveLoading(prop);
|
|
130
|
+
function resolveSizes(prop, resolvedSrcSet, resolvedLoading) {
|
|
131
|
+
const loading = resolvedLoading ?? resolveLoading(prop);
|
|
132
|
+
const srcSet = resolvedSrcSet ?? prop.srcSet;
|
|
113
133
|
let sizes = prop.sizes;
|
|
114
134
|
if (prop.fill) sizes ||= "100vw";
|
|
115
135
|
if (sizes) {
|
|
116
136
|
if (loading === "lazy") sizes = "auto, " + sizes;
|
|
117
|
-
} else if (
|
|
137
|
+
} else if (srcSet && VALID_WIDTH_DESCRIPTOR_SRCSET.test(srcSet) && loading === "lazy") sizes = "auto, 100vw";
|
|
118
138
|
return sizes;
|
|
119
139
|
}
|
|
140
|
+
function resolveSrc(prop) {
|
|
141
|
+
if (typeof prop.src === "object") return prop.src.src;
|
|
142
|
+
if (prop.loader) return prop.loader({
|
|
143
|
+
src: prop.src,
|
|
144
|
+
width: prop.width,
|
|
145
|
+
height: prop.height
|
|
146
|
+
});
|
|
147
|
+
return prop.src;
|
|
148
|
+
}
|
|
149
|
+
function resolveWidth(prop) {
|
|
150
|
+
if (prop.width) return prop.width;
|
|
151
|
+
if (typeof prop.src === "object") return prop.src.width;
|
|
152
|
+
}
|
|
153
|
+
function resolveHeight(prop) {
|
|
154
|
+
if (prop.height) return prop.height;
|
|
155
|
+
if (typeof prop.src === "object") return prop.src.height;
|
|
156
|
+
}
|
|
157
|
+
function resolvePlaceholderURL(prop) {
|
|
158
|
+
if (prop.placeholderUrl) return prop.placeholderUrl;
|
|
159
|
+
if (typeof prop.src === "object") return prop.src.placeholderUrl;
|
|
160
|
+
if (prop.loader) return prop.loader({
|
|
161
|
+
isPlaceholder: true,
|
|
162
|
+
src: prop.src,
|
|
163
|
+
width: prop.width,
|
|
164
|
+
height: prop.height
|
|
165
|
+
});
|
|
166
|
+
}
|
|
120
167
|
|
|
121
168
|
//#endregion
|
|
122
169
|
//#region src/react/prop-asserts.ts
|
|
123
170
|
function assertProps(prop) {
|
|
124
|
-
|
|
171
|
+
try {
|
|
172
|
+
assertLoadingProp(prop);
|
|
173
|
+
assertDecodingProp(prop);
|
|
174
|
+
assertFetchPriorityProp(prop);
|
|
175
|
+
assertBreakpointsProp(prop);
|
|
176
|
+
assertFillProp(prop);
|
|
177
|
+
assertDimensionsProp(prop);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
const message = err instanceof Error ? err.message : err;
|
|
180
|
+
console.warn(message);
|
|
181
|
+
}
|
|
125
182
|
}
|
|
126
183
|
function assert(assertion, message) {
|
|
127
184
|
if (import.meta.env.DEV) {
|
|
128
|
-
if (assertion()) throw new Error(message);
|
|
185
|
+
if (assertion()) throw new Error(message || void 0);
|
|
129
186
|
}
|
|
130
187
|
}
|
|
131
188
|
function assertLoadingProp(prop) {
|
|
132
|
-
assert(() => prop.loading && prop.asap, `Do not use \`loading\` on a asap image — asap images are always eagerly loaded.`);
|
|
189
|
+
assert(() => prop.loading && prop.asap, import.meta.env.DEV && `Do not use \`loading\` on a asap image — asap images are always eagerly loaded.`);
|
|
190
|
+
}
|
|
191
|
+
function assertDecodingProp(prop) {
|
|
192
|
+
assert(() => prop.decoding && prop.asap, import.meta.env.DEV && `Do not use \`decoding\` on a asap image — asap images always use async decoding.`);
|
|
193
|
+
}
|
|
194
|
+
function assertFetchPriorityProp(prop) {
|
|
195
|
+
assert(() => prop.fetchPriority && prop.asap, import.meta.env.DEV && `Do not use \`fetchPriority\` on a asap image — asap images always use high fetch priority.`);
|
|
196
|
+
}
|
|
197
|
+
function assertBreakpointsProp(prop) {
|
|
198
|
+
assert(() => prop.breakpoints && typeof prop.src === "object", import.meta.env.DEV && `Do not use \`breakpoints\` when \`src\` is an imported image — the image's built-in srcSets are used instead.`);
|
|
199
|
+
assert(() => prop.breakpoints && typeof prop.src === "string" && !prop.loader, import.meta.env.DEV && `Do not use \`breakpoints\` without a \`loader\` — breakpoints require a loader to generate srcSet entries.`);
|
|
200
|
+
}
|
|
201
|
+
function assertFillProp(prop) {
|
|
202
|
+
assert(() => prop.fill && (prop.width !== void 0 || prop.height !== void 0), import.meta.env.DEV && `Do not use \`width\` or \`height\` with \`fill\` — fill mode makes the image fill its container.`);
|
|
203
|
+
}
|
|
204
|
+
function assertDimensionsProp(prop) {
|
|
205
|
+
assert(() => typeof prop.src === "string" && !prop.fill && prop.width === void 0 && prop.height === void 0, import.meta.env.DEV && `Image is missing \`width\` and \`height\` props. Either provide dimensions, use \`fill\`, or use an imported image source.`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/react/image-context.tsx
|
|
210
|
+
const ImageContext = createContext({
|
|
211
|
+
breakpoints: [
|
|
212
|
+
16,
|
|
213
|
+
48,
|
|
214
|
+
96,
|
|
215
|
+
128,
|
|
216
|
+
384,
|
|
217
|
+
640,
|
|
218
|
+
750,
|
|
219
|
+
828,
|
|
220
|
+
1080,
|
|
221
|
+
1200,
|
|
222
|
+
1920
|
|
223
|
+
],
|
|
224
|
+
loading: "lazy",
|
|
225
|
+
loader: null
|
|
226
|
+
});
|
|
227
|
+
function useImageContext() {
|
|
228
|
+
return useContext(ImageContext);
|
|
229
|
+
}
|
|
230
|
+
function ImageProvider({ children, ...props }) {
|
|
231
|
+
const ctx = useImageContext();
|
|
232
|
+
return /* @__PURE__ */ jsx(ImageContext.Provider, {
|
|
233
|
+
value: {
|
|
234
|
+
...ctx,
|
|
235
|
+
...props
|
|
236
|
+
},
|
|
237
|
+
children
|
|
238
|
+
});
|
|
133
239
|
}
|
|
134
240
|
|
|
135
241
|
//#endregion
|
|
@@ -154,7 +260,7 @@ function getFillStyles(props) {
|
|
|
154
260
|
}
|
|
155
261
|
function Image(props) {
|
|
156
262
|
assertProps(props);
|
|
157
|
-
const options = resolveOptions(props);
|
|
263
|
+
const options = resolveOptions(props, useImageContext());
|
|
158
264
|
const [imgRef, isLoaded] = useImgLoaded(options.src);
|
|
159
265
|
const placeholderStyles = isLoaded ? {} : getPlaceholderStyles(options);
|
|
160
266
|
const fillStyles = getFillStyles(options);
|
|
@@ -184,4 +290,126 @@ function Image(props) {
|
|
|
184
290
|
}
|
|
185
291
|
|
|
186
292
|
//#endregion
|
|
187
|
-
|
|
293
|
+
//#region src/react/loaders/image-loader-utils.ts
|
|
294
|
+
function normalizeLoaderParams(params, separator) {
|
|
295
|
+
return Object.entries(params).map(([key, value]) => `${key}${separator}${value}`);
|
|
296
|
+
}
|
|
297
|
+
function isAbsoluteUrl(src) {
|
|
298
|
+
return /^https?:\/\//.test(src);
|
|
299
|
+
}
|
|
300
|
+
function assertPath(path) {
|
|
301
|
+
assert(() => !path?.trim(), import.meta.env.DEV && `Path is required`);
|
|
302
|
+
assert(() => {
|
|
303
|
+
try {
|
|
304
|
+
new URL(path);
|
|
305
|
+
return !isAbsoluteUrl(path);
|
|
306
|
+
} catch {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
}, import.meta.env.DEV && `Path is invalid url: ${path}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
//#endregion
|
|
313
|
+
//#region src/react/loaders/cloudflare-context.tsx
|
|
314
|
+
const CloudflareContext = createContext({
|
|
315
|
+
path: "",
|
|
316
|
+
placeholder: true,
|
|
317
|
+
format: "auto",
|
|
318
|
+
placeholderParams: { quality: "low" }
|
|
319
|
+
});
|
|
320
|
+
function useCloudflareContext() {
|
|
321
|
+
return useContext(CloudflareContext);
|
|
322
|
+
}
|
|
323
|
+
function CloudflareLoaderProvider({ children, ...props }) {
|
|
324
|
+
const ctx = useCloudflareContext();
|
|
325
|
+
return /* @__PURE__ */ jsx(CloudflareContext.Provider, {
|
|
326
|
+
value: {
|
|
327
|
+
...ctx,
|
|
328
|
+
...props
|
|
329
|
+
},
|
|
330
|
+
children
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
//#endregion
|
|
335
|
+
//#region src/react/loaders/cloudflare-loader.ts
|
|
336
|
+
function useCloudflareLoader(options) {
|
|
337
|
+
const resolvedOptions = {
|
|
338
|
+
...useCloudflareContext(),
|
|
339
|
+
...options
|
|
340
|
+
};
|
|
341
|
+
assertPath(resolvedOptions.path);
|
|
342
|
+
return (imageOptions) => {
|
|
343
|
+
const parts = [];
|
|
344
|
+
const format = resolvedOptions.format;
|
|
345
|
+
if (format) parts.push(`format=${format}`);
|
|
346
|
+
if (imageOptions.width) parts.push(`width=${imageOptions.width}`);
|
|
347
|
+
if (imageOptions.height) parts.push(`height=${imageOptions.height}`);
|
|
348
|
+
if (resolvedOptions.params) parts.push(...normalizeLoaderParams(resolvedOptions.params, "="));
|
|
349
|
+
if (imageOptions.isPlaceholder) {
|
|
350
|
+
if (resolvedOptions.placeholderParams) {
|
|
351
|
+
const placeholderParams = normalizeLoaderParams(resolvedOptions.placeholderParams, "=");
|
|
352
|
+
parts.push(...placeholderParams);
|
|
353
|
+
}
|
|
354
|
+
} else if (resolvedOptions.params) {
|
|
355
|
+
const params = normalizeLoaderParams(resolvedOptions.params, "=");
|
|
356
|
+
parts.push(...params);
|
|
357
|
+
}
|
|
358
|
+
const processingOptions = parts.join(",");
|
|
359
|
+
return `${resolvedOptions.path}/cdn-cgi/image/${processingOptions}/${imageOptions.src}`;
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/react/loaders/imgproxy-context.tsx
|
|
365
|
+
const ImgproxyContext = createContext({
|
|
366
|
+
path: "",
|
|
367
|
+
placeholder: true,
|
|
368
|
+
format: "webp",
|
|
369
|
+
placeholderParams: { quality: "1" }
|
|
370
|
+
});
|
|
371
|
+
function useImgproxyContext() {
|
|
372
|
+
return useContext(ImgproxyContext);
|
|
373
|
+
}
|
|
374
|
+
function ImgproxyLoaderProvider({ children, ...props }) {
|
|
375
|
+
const ctx = useImgproxyContext();
|
|
376
|
+
return /* @__PURE__ */ jsx(ImgproxyContext.Provider, {
|
|
377
|
+
value: {
|
|
378
|
+
...ctx,
|
|
379
|
+
...props
|
|
380
|
+
},
|
|
381
|
+
children
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
//#endregion
|
|
386
|
+
//#region src/react/loaders/imgproxy-loader.ts
|
|
387
|
+
function useImgproxyLoader(options) {
|
|
388
|
+
const resolvedOptions = {
|
|
389
|
+
...useImgproxyContext(),
|
|
390
|
+
...options
|
|
391
|
+
};
|
|
392
|
+
assertPath(resolvedOptions.path);
|
|
393
|
+
return (imageOptions) => {
|
|
394
|
+
const parts = [];
|
|
395
|
+
const format = resolvedOptions.format;
|
|
396
|
+
const paramsSeparator = resolvedOptions.paramsSeparator ?? "/";
|
|
397
|
+
if (format) parts.push(`format:${format}`);
|
|
398
|
+
if (imageOptions.width) parts.push(`width:${imageOptions.width}`);
|
|
399
|
+
if (imageOptions.height) parts.push(`height:${imageOptions.height}`);
|
|
400
|
+
if (imageOptions.isPlaceholder) {
|
|
401
|
+
if (resolvedOptions.placeholderParams) {
|
|
402
|
+
const placeholderParams = normalizeLoaderParams(resolvedOptions.placeholderParams, ":");
|
|
403
|
+
parts.push(...placeholderParams);
|
|
404
|
+
}
|
|
405
|
+
} else if (resolvedOptions.params) {
|
|
406
|
+
const params = normalizeLoaderParams(resolvedOptions.params, ":");
|
|
407
|
+
parts.push(...params);
|
|
408
|
+
}
|
|
409
|
+
const processingOptions = parts.join(paramsSeparator);
|
|
410
|
+
return `${resolvedOptions.path}/${processingOptions}/plain/${imageOptions.src}`;
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
//#endregion
|
|
415
|
+
export { CloudflareLoaderProvider, Image, ImageProvider, ImgproxyLoaderProvider, useCloudflareContext, useCloudflareLoader, useImageContext, useImgLoaded, useImgproxyContext, useImgproxyLoader };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lonik/oh-image",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "2.0.1",
|
|
5
5
|
"description": "A React component library for optimized image handling.",
|
|
6
6
|
"author": "Luka Onikadze <lukonik@gmail.com>",
|
|
7
7
|
"license": "MIT",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"play:build": "vite build",
|
|
42
42
|
"play:preview": "vite preview",
|
|
43
43
|
"test": "vitest",
|
|
44
|
+
"coverage": "vitest --coverage",
|
|
44
45
|
"typecheck": "tsc --noEmit",
|
|
45
46
|
"release": "bumpp && pnpm publish",
|
|
46
47
|
"prepublishOnly": "pnpm run build"
|
|
@@ -60,10 +61,13 @@
|
|
|
60
61
|
"@types/react": "^18",
|
|
61
62
|
"@types/react-dom": "^18",
|
|
62
63
|
"@vitejs/plugin-react": "^5.1.2",
|
|
64
|
+
"@vitest/coverage-v8": "4.0.18",
|
|
63
65
|
"bumpp": "^10.3.2",
|
|
64
66
|
"eslint": "^9.39.2",
|
|
65
67
|
"eslint-config-prettier": "^10.1.8",
|
|
66
68
|
"eslint-plugin-react": "^7.37.5",
|
|
69
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
70
|
+
"eslint-plugin-react-refresh": "^0.5.0",
|
|
67
71
|
"globals": "^17.3.0",
|
|
68
72
|
"happy-dom": "^20.6.0",
|
|
69
73
|
"tsdown": "^0.18.1",
|