@lonik/oh-image 0.0.1 → 1.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/README.md CHANGED
@@ -1,7 +1,30 @@
1
- # oh-image
1
+ # react-components-starter
2
2
 
3
- This library was generated with [Nx](https://nx.dev).
3
+ A starter for creating a React component library.
4
4
 
5
- ## Running unit tests
5
+ ## Development
6
6
 
7
- Run `nx test oh-image` to execute the unit tests via [Vitest](https://vitest.dev/).
7
+ - Install dependencies:
8
+
9
+ ```bash
10
+ npm install
11
+ ```
12
+
13
+ - Run the playground:
14
+
15
+ ```bash
16
+ npm run play
17
+ ```
18
+
19
+ - Run the unit tests:
20
+
21
+ ```bash
22
+ npm run test
23
+ ```
24
+
25
+ - Build the library:
26
+
27
+ ```bash
28
+ npm run build
29
+ ```
30
+ # tsdown-template
package/dist/plugin.js ADDED
@@ -0,0 +1,221 @@
1
+ import { basename, dirname, extname, join, parse } from "node:path";
2
+ import { randomBytes } from "node:crypto";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import queryString from "query-string";
5
+ import sharp from "sharp";
6
+ import pLimit from "p-limit";
7
+
8
+ //#region src/plugin/utils.ts
9
+ function getRandomString(length = 32) {
10
+ return randomBytes(Math.ceil(length * 3 / 4)).toString("base64").slice(0, length).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
11
+ }
12
+ async function readFileSafe(path) {
13
+ try {
14
+ return await readFile(path);
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+ async function saveFileSafe(path, data) {
20
+ const dir = dirname(path);
21
+ try {
22
+ await mkdir(dir, { recursive: true });
23
+ await writeFile(path, data);
24
+ console.log(`Successfully saved to ${path}`);
25
+ } catch (err) {
26
+ console.error("Failed to save file:", err);
27
+ }
28
+ }
29
+ function queryToOptions(processKey, uri) {
30
+ const [path, query] = uri.split("?");
31
+ if (!query || !path) return {
32
+ shouldProcess: false,
33
+ path: ""
34
+ };
35
+ const parsed = queryString.parse(query, {
36
+ parseBooleans: true,
37
+ parseNumbers: true,
38
+ arrayFormat: "comma"
39
+ });
40
+ if (processKey in parsed) return {
41
+ shouldProcess: true,
42
+ options: parsed,
43
+ path
44
+ };
45
+ else return {
46
+ shouldProcess: false,
47
+ path
48
+ };
49
+ }
50
+ async function processImage(path, options) {
51
+ let processed = sharp(path);
52
+ if (options.width || options.height) processed = processed.resize({
53
+ width: options.width ?? void 0,
54
+ height: options.height ?? void 0
55
+ });
56
+ if (options.format) processed = processed.toFormat(options.format);
57
+ if (options.blur) processed = processed.blur(options.blur);
58
+ return await processed.toBuffer();
59
+ }
60
+
61
+ //#endregion
62
+ //#region src/plugin/plugin.ts
63
+ const DEFAULT_CONFIGS = {
64
+ distDir: "oh-images",
65
+ bps: [
66
+ 16,
67
+ 48,
68
+ 96,
69
+ 128,
70
+ 384,
71
+ 640,
72
+ 750,
73
+ 828,
74
+ 1080,
75
+ 1200,
76
+ 1920
77
+ ],
78
+ format: "webp",
79
+ blur: false,
80
+ width: null,
81
+ height: null,
82
+ placeholder: false,
83
+ placeholderH: 100,
84
+ placeholderW: 100,
85
+ placeholderB: true,
86
+ placeholderF: "webp",
87
+ srcSetsF: "webp"
88
+ };
89
+ const PROCESS_KEY = "oh";
90
+ const SUPPORTED_IMAGE_FORMATS = /\.(jpe?g|png|webp|avif|gif|tiff?|svg)(\?.*)?$/i;
91
+ const DEV_DIR = "/@oh-images/";
92
+ function ohImage(options) {
93
+ let isBuild = false;
94
+ let assetsDir;
95
+ let outDir;
96
+ let cacheDir;
97
+ const imageEntries = /* @__PURE__ */ new Map();
98
+ const config = {
99
+ ...DEFAULT_CONFIGS,
100
+ ...options
101
+ };
102
+ /**
103
+ * used for dev server to match url to path
104
+ * @param url
105
+ */
106
+ function urlToPath(url) {
107
+ const fileId = basename(url);
108
+ return join(cacheDir, fileId);
109
+ }
110
+ function genIdentifier(uri, format, prefix) {
111
+ const fileId = basename(uri);
112
+ const uniqueFileId = `${prefix}-${getRandomString()}-${fileId}.${format}`;
113
+ if (!isBuild) return join(DEV_DIR, uniqueFileId);
114
+ return join(assetsDir, config.distDir, uniqueFileId);
115
+ }
116
+ return {
117
+ name: "oh-image",
118
+ configResolved(viteConfig) {
119
+ cacheDir = join(viteConfig.cacheDir, DEV_DIR);
120
+ isBuild = viteConfig.command === "build";
121
+ assetsDir = viteConfig.build.assetsDir;
122
+ outDir = join(viteConfig.root, viteConfig.build.outDir);
123
+ },
124
+ enforce: "pre",
125
+ configureServer(server) {
126
+ server.middlewares.use(async (req, res, next) => {
127
+ const url = req.url;
128
+ if (!url?.includes(DEV_DIR) || !SUPPORTED_IMAGE_FORMATS.test(url)) return next();
129
+ const path = urlToPath(url);
130
+ const ext = extname(url).slice(1);
131
+ const image = await readFileSafe(path);
132
+ const imageEntry = imageEntries.get(url);
133
+ if (!imageEntry) {
134
+ console.warn("Image entry not found with id: " + url);
135
+ next();
136
+ return;
137
+ }
138
+ if (image) {
139
+ res.setHeader("Content-Type", `image/${ext}`);
140
+ res.end(image);
141
+ return;
142
+ }
143
+ const processed = await processImage(imageEntry.origin, imageEntry);
144
+ await saveFileSafe(path, processed);
145
+ res.setHeader("Content-Type", `image/${ext}`);
146
+ res.end(processed);
147
+ });
148
+ },
149
+ load: {
150
+ filter: { id: SUPPORTED_IMAGE_FORMATS },
151
+ async handler(id) {
152
+ try {
153
+ const parsed = queryToOptions(PROCESS_KEY, id);
154
+ if (!parsed.shouldProcess) return null;
155
+ const origin = parsed.path;
156
+ const { name, ext } = parse(parsed.path);
157
+ const metadata = await sharp(parsed.path).metadata();
158
+ const mergedOptions = {
159
+ ...config,
160
+ ...parsed.options
161
+ };
162
+ const mainIdentifier = genIdentifier(name, mergedOptions.format ?? ext.slice(1), "main");
163
+ const mainEntry = {
164
+ width: mergedOptions.width,
165
+ height: mergedOptions.height,
166
+ blur: mergedOptions.blur,
167
+ format: mergedOptions.format,
168
+ origin
169
+ };
170
+ imageEntries.set(mainIdentifier, mainEntry);
171
+ const src = {
172
+ width: metadata.width,
173
+ height: metadata.height,
174
+ src: mainIdentifier,
175
+ srcSets: []
176
+ };
177
+ if (parsed.options?.placeholder) {
178
+ const placeholderIdentifier = genIdentifier(name, mergedOptions.placeholderF, "placeholder");
179
+ const placeholderEntry = {
180
+ width: mergedOptions.placeholderW,
181
+ height: mergedOptions.placeholderH,
182
+ format: mergedOptions.placeholderF,
183
+ blur: mergedOptions.placeholderB,
184
+ origin
185
+ };
186
+ imageEntries.set(placeholderIdentifier, placeholderEntry);
187
+ src.placeholderUrl = placeholderIdentifier;
188
+ }
189
+ if (mergedOptions.bps) for (const breakpoint of mergedOptions.bps) {
190
+ const srcSetIdentifier = genIdentifier(name, mergedOptions.srcSetsF, `breakpoint-${breakpoint}`);
191
+ const srcSetEntry = {
192
+ width: breakpoint,
193
+ format: mergedOptions.srcSetsF,
194
+ origin
195
+ };
196
+ imageEntries.set(srcSetIdentifier, srcSetEntry);
197
+ src.srcSets.push({
198
+ src: srcSetIdentifier,
199
+ width: `${breakpoint}w`
200
+ });
201
+ }
202
+ return `export default ${JSON.stringify(src)};`;
203
+ } catch (err) {
204
+ console.error(`Couldn't load image with id: ${id} error:${err}`);
205
+ return null;
206
+ }
207
+ }
208
+ },
209
+ async writeBundle() {
210
+ const limit = pLimit(30);
211
+ const tasks = Array.from(imageEntries, ([key, value]) => limit(async () => {
212
+ const processed = await processImage(value.origin, value);
213
+ await saveFileSafe(join(outDir, key), processed);
214
+ }));
215
+ await Promise.all(tasks);
216
+ }
217
+ };
218
+ }
219
+
220
+ //#endregion
221
+ export { ohImage };
package/dist/react.js ADDED
@@ -0,0 +1,73 @@
1
+ import { preload } from "react-dom";
2
+ import { jsx } from "react/jsx-runtime";
3
+
4
+ //#region src/react/image.tsx
5
+ function resolveOptions(props) {
6
+ const { src, ...rest } = props;
7
+ const resolved = { ...rest };
8
+ if (typeof src === "object") {
9
+ resolved.src = src.src;
10
+ resolved.width ??= src.width;
11
+ resolved.height ??= src.height;
12
+ resolved.srcset ??= src.srcSets.map((set) => `${set.src} ${set.width}`).join(", ");
13
+ resolved.placeholderUrl ??= src.placeholderUrl;
14
+ } else resolved.src = src;
15
+ if (props.asap) {
16
+ resolved.decoding = "async";
17
+ resolved.loading = "eager";
18
+ resolved.fetchPriority = "high";
19
+ preload(resolved.src, {
20
+ as: "image",
21
+ fetchPriority: "high"
22
+ });
23
+ }
24
+ if (props.fill) resolved.sizes ||= "100vw";
25
+ return resolved;
26
+ }
27
+ function getPlaceholderStyles(props) {
28
+ if (!props.placeholder) return {};
29
+ if (!props.placeholderUrl) {
30
+ console.warn("Blur URL is required for placeholder");
31
+ return {};
32
+ }
33
+ return {
34
+ backgroundPosition: "50% 50%",
35
+ backgroundRepeat: "no-repeat",
36
+ backgroundImage: `url(${props.placeholderUrl})`,
37
+ backgroundSize: "cover"
38
+ };
39
+ }
40
+ function getFillStyles(props) {
41
+ if (!props.fill) return {};
42
+ return {
43
+ width: "100%",
44
+ height: "100%",
45
+ inset: "0"
46
+ };
47
+ }
48
+ function Image(props) {
49
+ const options = resolveOptions(props);
50
+ const placeholderStyles = getPlaceholderStyles(options);
51
+ const fillStyles = getFillStyles(options);
52
+ const styles = {
53
+ ...placeholderStyles,
54
+ ...fillStyles,
55
+ ...props.style
56
+ };
57
+ return /* @__PURE__ */ jsx("img", {
58
+ className: props.className,
59
+ style: styles,
60
+ src: options.src,
61
+ width: options.width,
62
+ height: options.height,
63
+ srcSet: options.srcset,
64
+ alt: options.alt,
65
+ loading: options.loading,
66
+ decoding: options.decoding,
67
+ sizes: options.sizes,
68
+ fetchPriority: options.fetchPriority ?? "auto"
69
+ });
70
+ }
71
+
72
+ //#endregion
73
+ export { Image };
package/package.json CHANGED
@@ -1,37 +1,74 @@
1
1
  {
2
2
  "name": "@lonik/oh-image",
3
- "version": "0.0.1",
4
3
  "type": "module",
5
- "publishConfig": {
6
- "access": "public"
7
- },
4
+ "version": "1.0.1",
5
+ "description": "A React component library for optimized image handling.",
6
+ "author": "Luka Onikadze <lukonik@gmail.com>",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/lukonik/oh-image#readme",
8
9
  "repository": {
9
10
  "type": "git",
10
- "url": "https://github.com/lukonik/oh-image.git",
11
- "directory": "packages/oh-image"
11
+ "url": "git+https://github.com/lukonik/oh-image.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/lukonik/oh-image/issues"
12
15
  },
13
- "keywords": [
14
- "image",
15
- "optimization",
16
- "processing"
17
- ],
18
- "main": "./dist/index.js",
19
- "module": "./dist/index.js",
20
- "types": "./dist/index.d.ts",
21
16
  "exports": {
22
17
  "./package.json": "./package.json",
23
- ".": {
24
- "@oh-image/source": "./src/index.ts",
25
- "types": "./dist/index.d.ts",
26
- "import": "./dist/index.js",
27
- "default": "./dist/index.js"
18
+ "./plugin": {
19
+ "types": "./dist/plugin.d.ts",
20
+ "default": "./dist/plugin.js"
21
+ },
22
+ "./react": {
23
+ "types": "./dist/react.d.ts",
24
+ "default": "./dist/react.js"
28
25
  }
29
26
  },
30
27
  "files": [
31
- "dist",
32
- "!**/*.tsbuildinfo"
28
+ "dist"
33
29
  ],
34
- "nx": {
35
- "name": "oh-image"
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "tsdown",
35
+ "dev": "tsdown --watch",
36
+ "play": "vite",
37
+ "play:build": "vite build",
38
+ "play:preview": "vite preview",
39
+ "test": "vitest",
40
+ "typecheck": "tsc --noEmit",
41
+ "release": "bumpp && pnpm publish",
42
+ "prepublishOnly": "pnpm run build"
43
+ },
44
+ "peerDependencies": {
45
+ "react": "^19.2.0",
46
+ "react-dom": "^19.2.0",
47
+ "sharp": "^0.34.5"
48
+ },
49
+ "devDependencies": {
50
+ "@eslint/js": "^9.39.2",
51
+ "@tsconfig/strictest": "^2.0.8",
52
+ "@types/node": "^25.0.3",
53
+ "@types/react": "^19.2.7",
54
+ "@types/react-dom": "^19.2.3",
55
+ "@vitejs/plugin-react": "^5.1.2",
56
+ "@vitest/browser-playwright": "^4.0.16",
57
+ "bumpp": "^10.3.2",
58
+ "eslint": "^9.39.2",
59
+ "eslint-config-prettier": "^10.1.8",
60
+ "eslint-plugin-react": "^7.37.5",
61
+ "globals": "^17.3.0",
62
+ "tsdown": "^0.18.1",
63
+ "typescript": "^5.9.3",
64
+ "typescript-eslint": "^8.54.0",
65
+ "vite": "^7.3.0",
66
+ "vitest": "^4.0.16",
67
+ "vitest-browser-react": "^2.0.2",
68
+ "@commitlint/config-conventional": "^20.4.1"
69
+ },
70
+ "dependencies": {
71
+ "p-limit": "^7.3.0",
72
+ "query-string": "^9.3.1"
36
73
  }
37
- }
74
+ }