@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 +27 -4
- package/dist/plugin.js +221 -0
- package/dist/react.js +73 -0
- package/package.json +61 -24
package/README.md
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# react-components-starter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A starter for creating a React component library.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Development
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
"
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
"
|
|
35
|
-
"
|
|
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
|
+
}
|