@lonik/oh-image 1.2.7 → 1.2.8

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.js CHANGED
@@ -1,11 +1,36 @@
1
1
  import { basename, dirname, extname, join, parse } from "node:path";
2
+ import queryString from "query-string";
2
3
  import { createHash } from "node:crypto";
3
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
- import queryString from "query-string";
5
- import sharp from "sharp";
6
5
  import pLimit from "p-limit";
6
+ import sharp from "sharp";
7
7
 
8
8
  //#region src/plugin/utils.ts
9
+ function queryToOptions(processKey, uri) {
10
+ const [path, query] = uri.split("?");
11
+ if (!query || !path) return {
12
+ shouldProcess: false,
13
+ path: ""
14
+ };
15
+ const parsed = queryString.parse(query, {
16
+ parseBooleans: true,
17
+ parseNumbers: true,
18
+ arrayFormat: "comma",
19
+ types: { bps: "number[]" }
20
+ });
21
+ if (processKey in parsed) return {
22
+ shouldProcess: true,
23
+ options: parsed,
24
+ path
25
+ };
26
+ else return {
27
+ shouldProcess: false,
28
+ path
29
+ };
30
+ }
31
+
32
+ //#endregion
33
+ //#region src/plugin/file-utils.ts
9
34
  async function getFileHash(filePath) {
10
35
  const content = await readFile(filePath);
11
36
  return createHash("sha256").update(content).digest("hex").slice(0, 16);
@@ -22,33 +47,84 @@ async function saveFileSafe(path, data) {
22
47
  try {
23
48
  await mkdir(dir, { recursive: true });
24
49
  await writeFile(path, data);
25
- console.log(`Successfully saved to ${path}`);
26
50
  } catch (err) {
27
51
  console.error("Failed to save file:", err);
28
52
  }
29
53
  }
30
- function queryToOptions(processKey, uri) {
31
- const [path, query] = uri.split("?");
32
- if (!query || !path) return {
33
- shouldProcess: false,
34
- path: ""
35
- };
36
- const parsed = queryString.parse(query, {
37
- parseBooleans: true,
38
- parseNumbers: true,
39
- arrayFormat: "comma",
40
- types: { bps: "number[]" }
41
- });
42
- if (processKey in parsed) return {
43
- shouldProcess: true,
44
- options: parsed,
45
- path
54
+
55
+ //#endregion
56
+ //#region src/plugin/image-identifier.ts
57
+ function createId(name, format, prefix, hash, dirs) {
58
+ const uniqueFileId = `${prefix}-${hash}-${basename(name)}.${format}`;
59
+ if (!dirs.isBuild) return join(dirs.devDir, uniqueFileId);
60
+ return join(dirs.assetsDir, dirs.distDir, uniqueFileId);
61
+ }
62
+ function createImageIdentifier(name, hash, dirs) {
63
+ return {
64
+ main(format) {
65
+ return createId(name, format, "main", hash, dirs);
66
+ },
67
+ placeholder(format) {
68
+ return createId(name, format, "placeholder", hash, dirs);
69
+ },
70
+ srcSet(format, breakpoint) {
71
+ return createId(name, format, `breakpoint-${breakpoint}`, hash, dirs);
72
+ }
46
73
  };
47
- else return {
48
- shouldProcess: false,
49
- path
74
+ }
75
+
76
+ //#endregion
77
+ //#region src/plugin/image-entries.ts
78
+ const PLACEHOLDER_IMG_SIZE = 8;
79
+ const PLACEHOLDER_BLUR_QUALITY = 70;
80
+ function createImageEntries() {
81
+ const map = /* @__PURE__ */ new Map();
82
+ return {
83
+ get(key) {
84
+ return map.get(key);
85
+ },
86
+ set(key, entry) {
87
+ map.set(key, entry);
88
+ },
89
+ entries() {
90
+ return map.entries();
91
+ },
92
+ createMainEntry(identifier, entry) {
93
+ const mainEntry = {
94
+ width: entry.width,
95
+ height: entry.height,
96
+ format: entry.format,
97
+ origin: entry.origin
98
+ };
99
+ this.set(identifier, mainEntry);
100
+ },
101
+ createPlaceholderEntry(identifier, main) {
102
+ let placeholderHeight = 0;
103
+ let placeholderWidth = 0;
104
+ if (main.width >= main.height) {
105
+ placeholderWidth = PLACEHOLDER_IMG_SIZE;
106
+ placeholderHeight = Math.max(Math.round(main.height / main.width * PLACEHOLDER_IMG_SIZE), 10);
107
+ } else {
108
+ placeholderWidth = Math.max(Math.round(main.width / main.height * PLACEHOLDER_IMG_SIZE), 10);
109
+ placeholderHeight = PLACEHOLDER_IMG_SIZE;
110
+ }
111
+ const placeholderEntry = {
112
+ width: placeholderWidth,
113
+ height: placeholderHeight,
114
+ format: main.format,
115
+ blur: PLACEHOLDER_BLUR_QUALITY,
116
+ origin: main.origin
117
+ };
118
+ this.set(identifier, placeholderEntry);
119
+ },
120
+ createSrcSetEntry(identifier, entry) {
121
+ this.set(identifier, entry);
122
+ }
50
123
  };
51
124
  }
125
+
126
+ //#endregion
127
+ //#region src/plugin/image-process.ts
52
128
  async function processImage(path, options) {
53
129
  let processed = sharp(path);
54
130
  if (options.width || options.height) processed = processed.resize({
@@ -63,8 +139,6 @@ async function processImage(path, options) {
63
139
  //#endregion
64
140
  //#region src/plugin/plugin.ts
65
141
  const DEFAULT_IMAGE_FORMAT = "webp";
66
- const PLACEHOLDER_IMG_SIZE = 8;
67
- const PLACEHOLDER_BLUR_QUALITY = 70;
68
142
  const DEFAULT_CONFIGS = {
69
143
  distDir: "oh-images",
70
144
  bps: [
@@ -91,24 +165,11 @@ function ohImage(options) {
91
165
  let assetsDir;
92
166
  let outDir;
93
167
  let cacheDir;
94
- const imageEntries = /* @__PURE__ */ new Map();
168
+ const imageEntries = createImageEntries();
95
169
  const config = {
96
170
  ...DEFAULT_CONFIGS,
97
171
  ...options
98
172
  };
99
- /**
100
- * used for dev server to match url to path
101
- * @param url
102
- */
103
- function urlToPath(url) {
104
- const fileId = basename(url);
105
- return join(cacheDir, fileId);
106
- }
107
- function genIdentifier(uri, format, prefix, hash) {
108
- const uniqueFileId = `${prefix}-${hash}-${basename(uri)}.${format}`;
109
- if (!isBuild) return join(DEV_DIR, uniqueFileId);
110
- return join(assetsDir, config.distDir, uniqueFileId);
111
- }
112
173
  return {
113
174
  name: "oh-image",
114
175
  configResolved(viteConfig) {
@@ -122,14 +183,14 @@ function ohImage(options) {
122
183
  server.middlewares.use(async (req, res, next) => {
123
184
  const url = req.url;
124
185
  if (!url?.includes(DEV_DIR) || !SUPPORTED_IMAGE_FORMATS.test(url)) return next();
125
- const path = urlToPath(url);
186
+ const fileId = basename(url);
187
+ const path = join(cacheDir, fileId);
126
188
  const ext = extname(url).slice(1);
127
189
  const image = await readFileSafe(path);
128
190
  const imageEntry = imageEntries.get(url);
129
191
  if (!imageEntry) {
130
192
  console.warn("Image entry not found with id: " + url);
131
- next();
132
- return;
193
+ return next();
133
194
  }
134
195
  if (image) {
135
196
  res.setHeader("Content-Type", `image/${ext}`);
@@ -156,14 +217,20 @@ function ohImage(options) {
156
217
  ...config,
157
218
  ...parsed.options
158
219
  };
159
- const mainIdentifier = genIdentifier(name, mergedOptions.format ?? ext.slice(1), "main", hash);
160
- const mainEntry = {
220
+ const format = mergedOptions.format ?? ext.slice(1);
221
+ const identifier = createImageIdentifier(name, hash, {
222
+ isBuild,
223
+ devDir: DEV_DIR,
224
+ assetsDir,
225
+ distDir: config.distDir
226
+ });
227
+ const mainIdentifier = identifier.main(format);
228
+ imageEntries.createMainEntry(mainIdentifier, {
161
229
  width: mergedOptions.width,
162
230
  height: mergedOptions.height,
163
231
  format: mergedOptions.format,
164
232
  origin
165
- };
166
- imageEntries.set(mainIdentifier, mainEntry);
233
+ });
167
234
  const src = {
168
235
  width: metadata.width,
169
236
  height: metadata.height,
@@ -171,36 +238,24 @@ function ohImage(options) {
171
238
  srcSets: ""
172
239
  };
173
240
  if (parsed.options?.placeholder) {
174
- let placeholderHeight = 0;
175
- let placeholderWidth = 0;
176
- if (metadata.width >= metadata.height) {
177
- placeholderWidth = PLACEHOLDER_IMG_SIZE;
178
- placeholderHeight = Math.max(Math.round(metadata.height / metadata.width * PLACEHOLDER_IMG_SIZE), 1);
179
- } else {
180
- placeholderWidth = Math.max(Math.round(metadata.width / metadata.height * PLACEHOLDER_IMG_SIZE), 1);
181
- placeholderHeight = PLACEHOLDER_IMG_SIZE;
182
- }
183
- const placeholderIdentifier = genIdentifier(name, DEFAULT_IMAGE_FORMAT, "placeholder", hash);
184
- const placeholderEntry = {
185
- width: placeholderWidth,
186
- height: placeholderHeight,
241
+ const placeholderIdentifier = identifier.placeholder(DEFAULT_IMAGE_FORMAT);
242
+ imageEntries.createPlaceholderEntry(placeholderIdentifier, {
243
+ width: metadata.width,
244
+ height: metadata.height,
187
245
  format: DEFAULT_IMAGE_FORMAT,
188
- blur: PLACEHOLDER_BLUR_QUALITY,
189
246
  origin
190
- };
191
- imageEntries.set(placeholderIdentifier, placeholderEntry);
247
+ });
192
248
  src.placeholderUrl = placeholderIdentifier;
193
249
  }
194
250
  if (mergedOptions.bps) {
195
251
  const srcSets = [];
196
252
  for (const breakpoint of mergedOptions.bps) {
197
- const srcSetIdentifier = genIdentifier(name, DEFAULT_IMAGE_FORMAT, `breakpoint-${breakpoint}`, hash);
198
- const srcSetEntry = {
253
+ const srcSetIdentifier = identifier.srcSet(DEFAULT_IMAGE_FORMAT, breakpoint);
254
+ imageEntries.createSrcSetEntry(srcSetIdentifier, {
199
255
  width: breakpoint,
200
256
  format: DEFAULT_IMAGE_FORMAT,
201
257
  origin
202
- };
203
- imageEntries.set(srcSetIdentifier, srcSetEntry);
258
+ });
204
259
  srcSets.push(`${srcSetIdentifier} ${breakpoint}w`);
205
260
  }
206
261
  src.srcSets = srcSets.join(", ");
@@ -214,7 +269,7 @@ function ohImage(options) {
214
269
  },
215
270
  async writeBundle() {
216
271
  const limit = pLimit(30);
217
- const tasks = Array.from(imageEntries, ([key, value]) => limit(async () => {
272
+ const tasks = Array.from(imageEntries.entries(), ([key, value]) => limit(async () => {
218
273
  const processed = await processImage(value.origin, value);
219
274
  await saveFileSafe(join(outDir, key), processed);
220
275
  }));
package/dist/react.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import * as react_jsx_runtime0 from "react/jsx-runtime";
2
1
  import { ImgHTMLAttributes } from "react";
2
+ import * as react_jsx_runtime0 from "react/jsx-runtime";
3
3
 
4
4
  //#region src/react/types.d.ts
5
5
  type ImageSrcType = string | ImageSrc;
@@ -22,4 +22,33 @@ interface ImageProps extends Partial<Pick<ImgHTMLAttributes<HTMLImageElement>, "
22
22
  //#region src/react/image.d.ts
23
23
  declare function Image(props: ImageProps): react_jsx_runtime0.JSX.Element;
24
24
  //#endregion
25
- export { Image, type ImageProps };
25
+ //#region src/react/use-img-loaded.d.ts
26
+ /**
27
+ * A hook that tracks whether an image element has finished loading.
28
+ *
29
+ * Handles all edge cases:
30
+ * - Image already cached/complete on mount
31
+ * - Normal load via event listener
32
+ * - Errors (resets to `false`)
33
+ * - `src` changes (resets to `false` until the new source loads)
34
+ * - Element unmount / ref set to `null`
35
+ *
36
+ * @returns `[ref, isLoaded]` — attach the ref callback to an `<img>` element.
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * function Avatar({ src }: { src: string }) {
41
+ * const [imgRef, isLoaded] = useImgLoaded(src);
42
+ * return (
43
+ * <img
44
+ * ref={imgRef}
45
+ * src={src}
46
+ * style={{ opacity: isLoaded ? 1 : 0 }}
47
+ * />
48
+ * );
49
+ * }
50
+ * ```
51
+ */
52
+ declare function useImgLoaded(src: string | undefined): [(img: HTMLImageElement | null) => void, boolean];
53
+ //#endregion
54
+ export { Image, type ImageProps, useImgLoaded };
package/dist/react.js CHANGED
@@ -1,6 +1,84 @@
1
1
  import * as ReactDOM from "react-dom";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
2
3
  import { jsx } from "react/jsx-runtime";
3
4
 
5
+ //#region src/react/use-img-loaded.ts
6
+ /**
7
+ * A hook that tracks whether an image element has finished loading.
8
+ *
9
+ * Handles all edge cases:
10
+ * - Image already cached/complete on mount
11
+ * - Normal load via event listener
12
+ * - Errors (resets to `false`)
13
+ * - `src` changes (resets to `false` until the new source loads)
14
+ * - Element unmount / ref set to `null`
15
+ *
16
+ * @returns `[ref, isLoaded]` — attach the ref callback to an `<img>` element.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * function Avatar({ src }: { src: string }) {
21
+ * const [imgRef, isLoaded] = useImgLoaded(src);
22
+ * return (
23
+ * <img
24
+ * ref={imgRef}
25
+ * src={src}
26
+ * style={{ opacity: isLoaded ? 1 : 0 }}
27
+ * />
28
+ * );
29
+ * }
30
+ * ```
31
+ */
32
+ function useImgLoaded(src) {
33
+ const [state, setState] = useState({
34
+ src,
35
+ loaded: false
36
+ });
37
+ let isLoaded = state.loaded;
38
+ if (state.src !== src) {
39
+ isLoaded = false;
40
+ setState({
41
+ src,
42
+ loaded: false
43
+ });
44
+ }
45
+ const imgRef = useRef(null);
46
+ const onLoad = useCallback(() => setState((prev) => ({
47
+ ...prev,
48
+ loaded: true
49
+ })), []);
50
+ const onError = useCallback(() => setState((prev) => ({
51
+ ...prev,
52
+ loaded: false
53
+ })), []);
54
+ const ref = useCallback((img) => {
55
+ const prev = imgRef.current;
56
+ if (prev) {
57
+ prev.removeEventListener("load", onLoad);
58
+ prev.removeEventListener("error", onError);
59
+ }
60
+ imgRef.current = img;
61
+ if (!img) return;
62
+ if (img.complete && img.naturalWidth > 0) setState((prev$1) => ({
63
+ ...prev$1,
64
+ loaded: true
65
+ }));
66
+ img.addEventListener("load", onLoad);
67
+ img.addEventListener("error", onError);
68
+ }, [onLoad, onError]);
69
+ useEffect(() => {
70
+ return () => {
71
+ const img = imgRef.current;
72
+ if (img) {
73
+ img.removeEventListener("load", onLoad);
74
+ img.removeEventListener("error", onError);
75
+ }
76
+ };
77
+ }, [onLoad, onError]);
78
+ return [ref, isLoaded];
79
+ }
80
+
81
+ //#endregion
4
82
  //#region src/react/image.tsx
5
83
  const preload = "preload" in ReactDOM && typeof ReactDOM.preload === "function" ? ReactDOM.preload : null;
6
84
  function resolveOptions(props) {
@@ -44,7 +122,8 @@ function getFillStyles(props) {
44
122
  }
45
123
  function Image(props) {
46
124
  const options = resolveOptions(props);
47
- const placeholderStyles = getPlaceholderStyles(options);
125
+ const [imgRef, isLoaded] = useImgLoaded(options.src);
126
+ const placeholderStyles = isLoaded ? {} : getPlaceholderStyles(options);
48
127
  const fillStyles = getFillStyles(options);
49
128
  const styles = {
50
129
  ...placeholderStyles,
@@ -52,6 +131,7 @@ function Image(props) {
52
131
  ...props.style
53
132
  };
54
133
  return /* @__PURE__ */ jsx("img", {
134
+ ref: imgRef,
55
135
  className: props.className,
56
136
  style: styles,
57
137
  src: options.src,
@@ -67,4 +147,4 @@ function Image(props) {
67
147
  }
68
148
 
69
149
  //#endregion
70
- export { Image };
150
+ export { Image, useImgLoaded };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lonik/oh-image",
3
3
  "type": "module",
4
- "version": "1.2.7",
4
+ "version": "1.2.8",
5
5
  "description": "A React component library for optimized image handling.",
6
6
  "author": "Luka Onikadze <lukonik@gmail.com>",
7
7
  "license": "MIT",
@@ -51,29 +51,33 @@
51
51
  "sharp": ">=0.34.5"
52
52
  },
53
53
  "devDependencies": {
54
+ "@commitlint/config-conventional": "^20.4.1",
54
55
  "@eslint/js": "^9.39.2",
56
+ "@testing-library/jest-dom": "^6.9.1",
57
+ "@testing-library/react": "^16.3.2",
55
58
  "@tsconfig/strictest": "^2.0.8",
56
59
  "@types/node": "^25.0.3",
57
60
  "@types/react": "^18",
58
61
  "@types/react-dom": "^18",
59
62
  "@vitejs/plugin-react": "^5.1.2",
60
- "@vitest/browser-playwright": "^4.0.16",
61
63
  "bumpp": "^10.3.2",
62
64
  "eslint": "^9.39.2",
63
65
  "eslint-config-prettier": "^10.1.8",
64
66
  "eslint-plugin-react": "^7.37.5",
65
67
  "globals": "^17.3.0",
68
+ "happy-dom": "^20.6.0",
66
69
  "tsdown": "^0.18.1",
67
70
  "typescript": "^5.9.3",
68
71
  "typescript-eslint": "^8.54.0",
69
72
  "vite": "^7.3.0",
70
- "vitest": "^4.0.16",
71
- "vitest-browser-react": "^2.0.2",
72
- "@commitlint/config-conventional": "^20.4.1"
73
+ "vitest": "^4.0.16"
73
74
  },
74
75
  "dependencies": {
76
+ "@types/supertest": "^6.0.3",
77
+ "memfs": "^4.56.10",
75
78
  "p-limit": "^7.3.0",
76
- "query-string": "^9.3.1"
79
+ "query-string": "^9.3.1",
80
+ "supertest": "^7.2.2"
77
81
  },
78
82
  "keywords": [
79
83
  "react",