@eclipsa/image 0.0.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.
@@ -0,0 +1,16 @@
1
+ $ vp pack && bun ../../scripts/release/write-dist-package-json.ts
2
+ ℹ entry: mod.ts, client.ts, vite.ts
3
+ ℹ tsconfig: ../../tsconfig.json
4
+ ℹ Build start
5
+ ℹ Cleaning 10 files
6
+ ℹ dist/vite.mjs 9.43 kB │ gzip: 3.07 kB
7
+ ℹ dist/mod.mjs 0.72 kB │ gzip: 0.40 kB
8
+ ℹ dist/client.mjs 0.01 kB │ gzip: 0.03 kB
9
+ ℹ dist/vite.mjs.map 18.38 kB │ gzip: 5.62 kB
10
+ ℹ dist/mod.mjs.map 2.24 kB │ gzip: 0.87 kB
11
+ ℹ dist/mod.d.mts 1.00 kB │ gzip: 0.43 kB
12
+ ℹ dist/vite.d.mts 0.90 kB │ gzip: 0.41 kB
13
+ ℹ dist/client.d.mts 0.01 kB │ gzip: 0.03 kB
14
+ ℹ 8 files, total: 32.68 kB
15
+ ✔ Build complete in 2818ms
16
+ dist/package.json
@@ -0,0 +1,16 @@
1
+ $ vp pack && bun ../../scripts/release/write-dist-package-json.ts
2
+ ℹ entry: mod.ts, client.ts, vite.ts
3
+ ℹ tsconfig: ../../tsconfig.json
4
+ ℹ Build start
5
+ ℹ Cleaning 10 files
6
+ ℹ dist/vite.mjs 9.43 kB │ gzip: 3.07 kB
7
+ ℹ dist/mod.mjs 0.74 kB │ gzip: 0.39 kB
8
+ ℹ dist/client.mjs 0.01 kB │ gzip: 0.03 kB
9
+ ℹ dist/vite.mjs.map 18.38 kB │ gzip: 5.62 kB
10
+ ℹ dist/mod.mjs.map 2.10 kB │ gzip: 0.86 kB
11
+ ℹ dist/mod.d.mts 1.00 kB │ gzip: 0.43 kB
12
+ ℹ dist/vite.d.mts 0.90 kB │ gzip: 0.41 kB
13
+ ℹ dist/client.d.mts 0.01 kB │ gzip: 0.03 kB
14
+ ℹ 8 files, total: 32.57 kB
15
+ ✔ Build complete in 2090ms
16
+ dist/package.json
@@ -0,0 +1,11 @@
1
+ $ vp test --run
2
+ RUN /home/nakasyou/eclipsa/packages/image
3
+
4
+ ✓ vite-config.test.ts (1 test) 6ms
5
+ ✓ mod.test.tsx (6 tests) 30ms
6
+
7
+ Test Files 2 passed (2)
8
+ Tests 7 passed (7)
9
+ Start at 17:36:38
10
+ Duration 555ms (transform 117ms, setup 0ms, import 609ms, tests 36ms, environment 0ms)
11
+
@@ -0,0 +1 @@
1
+ $ bun x tsc -p ../../tsconfig.json --noEmit
package/client.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ declare module '*.avif?eclipsa-image' {
2
+ import type { ImageSource } from '@eclipsa/image'
3
+
4
+ const image: ImageSource
5
+ export default image
6
+ }
7
+
8
+ declare module '*.jpeg?eclipsa-image' {
9
+ import type { ImageSource } from '@eclipsa/image'
10
+
11
+ const image: ImageSource
12
+ export default image
13
+ }
14
+
15
+ declare module '*.jpg?eclipsa-image' {
16
+ import type { ImageSource } from '@eclipsa/image'
17
+
18
+ const image: ImageSource
19
+ export default image
20
+ }
21
+
22
+ declare module '*.png?eclipsa-image' {
23
+ import type { ImageSource } from '@eclipsa/image'
24
+
25
+ const image: ImageSource
26
+ export default image
27
+ }
28
+
29
+ declare module '*.svg?eclipsa-image' {
30
+ import type { ImageSource } from '@eclipsa/image'
31
+
32
+ const image: ImageSource
33
+ export default image
34
+ }
35
+
36
+ declare module '*.webp?eclipsa-image' {
37
+ import type { ImageSource } from '@eclipsa/image'
38
+
39
+ const image: ImageSource
40
+ export default image
41
+ }
package/client.ts ADDED
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,41 @@
1
+ declare module '*.avif?eclipsa-image' {
2
+ import type { ImageSource } from '@eclipsa/image'
3
+
4
+ const image: ImageSource
5
+ export default image
6
+ }
7
+
8
+ declare module '*.jpeg?eclipsa-image' {
9
+ import type { ImageSource } from '@eclipsa/image'
10
+
11
+ const image: ImageSource
12
+ export default image
13
+ }
14
+
15
+ declare module '*.jpg?eclipsa-image' {
16
+ import type { ImageSource } from '@eclipsa/image'
17
+
18
+ const image: ImageSource
19
+ export default image
20
+ }
21
+
22
+ declare module '*.png?eclipsa-image' {
23
+ import type { ImageSource } from '@eclipsa/image'
24
+
25
+ const image: ImageSource
26
+ export default image
27
+ }
28
+
29
+ declare module '*.svg?eclipsa-image' {
30
+ import type { ImageSource } from '@eclipsa/image'
31
+
32
+ const image: ImageSource
33
+ export default image
34
+ }
35
+
36
+ declare module '*.webp?eclipsa-image' {
37
+ import type { ImageSource } from '@eclipsa/image'
38
+
39
+ const image: ImageSource
40
+ export default image
41
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/mod.d.mts ADDED
@@ -0,0 +1,48 @@
1
+ //#region mod.d.ts
2
+ type ImageOutputFormat = 'avif' | 'jpeg' | 'png' | 'webp';
3
+ interface ImageVariant {
4
+ format: string;
5
+ height: number;
6
+ src: string;
7
+ width: number;
8
+ }
9
+ interface ImageSource {
10
+ format: string;
11
+ height: number;
12
+ src: string;
13
+ variants: ImageVariant[];
14
+ width: number;
15
+ }
16
+ interface ImageProps extends Record<string, unknown> {
17
+ alt: string;
18
+ decoding?: 'async' | 'auto' | 'sync';
19
+ height?: number;
20
+ loading?: 'eager' | 'lazy';
21
+ sizes?: string;
22
+ src: ImageSource | string;
23
+ srcset?: string;
24
+ width?: number;
25
+ }
26
+ interface EclipsaImageOptions {
27
+ formats?: ImageOutputFormat[];
28
+ quality?: number;
29
+ widths?: number[];
30
+ }
31
+ declare const Image: ({
32
+ alt,
33
+ decoding,
34
+ height,
35
+ loading,
36
+ sizes,
37
+ src,
38
+ srcset,
39
+ width,
40
+ ...props
41
+ }: ImageProps) => {
42
+ isStatic: boolean;
43
+ props: Record<string, unknown>;
44
+ type: string;
45
+ };
46
+ //#endregion
47
+ export { EclipsaImageOptions, Image, ImageOutputFormat, ImageProps, ImageSource, ImageVariant };
48
+ //# sourceMappingURL=mod.d.mts.map
package/dist/mod.mjs ADDED
@@ -0,0 +1,33 @@
1
+ //#region mod.ts
2
+ const createImageElement = (props) => ({
3
+ isStatic: false,
4
+ props,
5
+ type: "img"
6
+ });
7
+ const Image = ({ alt, decoding = "async", height, loading = "lazy", sizes, src, srcset, width, ...props }) => {
8
+ return createImageElement({ ...typeof src === "string" ? {
9
+ ...props,
10
+ alt,
11
+ decoding,
12
+ height,
13
+ loading,
14
+ sizes,
15
+ src,
16
+ srcset,
17
+ width
18
+ } : {
19
+ ...props,
20
+ alt,
21
+ decoding,
22
+ height: height ?? src.height,
23
+ loading,
24
+ sizes: sizes ?? (src.variants.length > 1 ? "100vw" : void 0),
25
+ src: src.src,
26
+ srcset: srcset ?? src.variants.map((variant) => `${variant.src} ${variant.width}w`).join(", "),
27
+ width: width ?? src.width
28
+ } });
29
+ };
30
+ //#endregion
31
+ export { Image };
32
+
33
+ //# sourceMappingURL=mod.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mod.mjs","names":[],"sources":["../mod.ts"],"sourcesContent":["export type ImageOutputFormat = 'avif' | 'jpeg' | 'png' | 'webp'\n\nexport interface ImageVariant {\n format: string\n height: number\n src: string\n width: number\n}\n\nexport interface ImageSource {\n format: string\n height: number\n src: string\n variants: ImageVariant[]\n width: number\n}\n\nexport interface ImageProps extends Record<string, unknown> {\n alt: string\n decoding?: 'async' | 'auto' | 'sync'\n height?: number\n loading?: 'eager' | 'lazy'\n sizes?: string\n src: ImageSource | string\n srcset?: string\n width?: number\n}\n\nexport interface EclipsaImageOptions {\n formats?: ImageOutputFormat[]\n quality?: number\n widths?: number[]\n}\n\nconst createImageElement = (props: Record<string, unknown>) => ({\n isStatic: false,\n props,\n type: 'img',\n})\n\nexport const Image = ({\n alt,\n decoding = 'async',\n height,\n loading = 'lazy',\n sizes,\n src,\n srcset,\n width,\n ...props\n}: ImageProps) => {\n const imageProps =\n typeof src === 'string'\n ? {\n ...props,\n alt,\n decoding,\n height,\n loading,\n sizes,\n src,\n srcset,\n width,\n }\n : {\n ...props,\n alt,\n decoding,\n height: height ?? src.height,\n loading,\n sizes: sizes ?? (src.variants.length > 1 ? '100vw' : undefined),\n src: src.src,\n srcset:\n srcset ?? src.variants.map((variant) => `${variant.src} ${variant.width}w`).join(', '),\n width: width ?? src.width,\n }\n\n return createImageElement({\n ...imageProps,\n })\n}\n"],"mappings":";AAkCA,MAAM,sBAAsB,WAAoC;CAC9D,UAAU;CACV;CACA,MAAM;CACP;AAED,MAAa,SAAS,EACpB,KACA,WAAW,SACX,QACA,UAAU,QACV,OACA,KACA,QACA,OACA,GAAG,YACa;AA2BhB,QAAO,mBAAmB,EACxB,GA1BA,OAAO,QAAQ,WACX;EACE,GAAG;EACH;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACD,GACD;EACE,GAAG;EACH;EACA;EACA,QAAQ,UAAU,IAAI;EACtB;EACA,OAAO,UAAU,IAAI,SAAS,SAAS,IAAI,UAAU,KAAA;EACrD,KAAK,IAAI;EACT,QACE,UAAU,IAAI,SAAS,KAAK,YAAY,GAAG,QAAQ,IAAI,GAAG,QAAQ,MAAM,GAAG,CAAC,KAAK,KAAK;EACxF,OAAO,SAAS,IAAI;EACrB,EAIN,CAAC"}
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@eclipsa/image",
3
+ "homepage": "https://github.com/pnsk-lab/eclipsa",
4
+ "bugs": {
5
+ "url": "https://github.com/pnsk-lab/eclipsa/issues"
6
+ },
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/pnsk-lab/eclipsa.git",
11
+ "directory": "packages/image"
12
+ },
13
+ "type": "module",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./mod.d.mts",
17
+ "import": "./mod.mjs"
18
+ },
19
+ "./client": {
20
+ "types": "./client.d.ts",
21
+ "import": "./client.mjs"
22
+ },
23
+ "./vite": {
24
+ "types": "./vite.d.mts",
25
+ "import": "./vite.mjs"
26
+ }
27
+ },
28
+ "dependencies": {
29
+ "eclipsa": "0.2.0-alpha.0",
30
+ "sharp": "^0.34.5"
31
+ },
32
+ "peerDependencies": {
33
+ "vite": "*"
34
+ },
35
+ "version": "0.0.0",
36
+ "private": false
37
+ }
@@ -0,0 +1,19 @@
1
+ import { EclipsaImageOptions } from "./mod.mjs";
2
+ import { Plugin, ResolvedConfig } from "vite";
3
+
4
+ //#region vite.d.ts
5
+ interface LoadedImage {
6
+ format: string;
7
+ height: number;
8
+ source: Buffer;
9
+ width: number;
10
+ }
11
+ declare const toContentType: (format: string) => string;
12
+ declare const resolveImageWidths: (sourceWidth: number, configuredWidths: readonly number[]) => number[];
13
+ declare const readLocalImage: (filePath: string) => Promise<LoadedImage>;
14
+ declare const isAllowedImagePath: (filePath: string, config: Pick<ResolvedConfig, "root" | "server">) => Promise<boolean>;
15
+ declare const createAssetName: (filePath: string, width: number, format: string) => string;
16
+ declare const eclipsaImage: (options?: EclipsaImageOptions) => Plugin;
17
+ //#endregion
18
+ export { createAssetName, eclipsaImage, isAllowedImagePath, readLocalImage, resolveImageWidths, toContentType };
19
+ //# sourceMappingURL=vite.d.mts.map
package/dist/vite.mjs ADDED
@@ -0,0 +1,263 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import sharp from "sharp";
5
+ //#region vite.ts
6
+ const IMAGE_QUERY_FLAG = "eclipsa-image";
7
+ const VIRTUAL_IMAGE_PREFIX = "\0eclipsa-image:";
8
+ const DEV_IMAGE_ENDPOINT = "/__eclipsa/image";
9
+ const DEFAULT_WIDTHS = [
10
+ 320,
11
+ 640,
12
+ 960,
13
+ 1280,
14
+ 1600
15
+ ];
16
+ const DEFAULT_QUALITY = 80;
17
+ const SUPPORTED_OUTPUT_FORMATS = new Set([
18
+ "avif",
19
+ "jpeg",
20
+ "png",
21
+ "webp"
22
+ ]);
23
+ const splitId = (id) => {
24
+ const queryIndex = id.indexOf("?");
25
+ return queryIndex === -1 ? {
26
+ pathname: id,
27
+ query: ""
28
+ } : {
29
+ pathname: id.slice(0, queryIndex),
30
+ query: id.slice(queryIndex + 1)
31
+ };
32
+ };
33
+ const toOutputExtension = (format) => format === "jpeg" ? "jpg" : format;
34
+ const toContentType = (format) => {
35
+ if (format === "svg") return "image/svg+xml";
36
+ if (format === "jpeg") return "image/jpeg";
37
+ return `image/${toOutputExtension(format)}`;
38
+ };
39
+ const toRoundedHeight = (sourceWidth, sourceHeight, targetWidth) => Math.max(1, Math.round(sourceHeight * targetWidth / sourceWidth));
40
+ const resolveImageWidths = (sourceWidth, configuredWidths) => {
41
+ const widths = configuredWidths.map((value) => Math.floor(value)).filter((value) => Number.isFinite(value) && value > 0 && value < sourceWidth).sort((left, right) => left - right);
42
+ return [...new Set(widths), sourceWidth];
43
+ };
44
+ const readLocalImage = async (filePath) => {
45
+ const source = await fs.readFile(filePath);
46
+ const metadata = await sharp(source, { animated: true }).metadata();
47
+ if (!metadata.width || !metadata.height || !metadata.format) throw new Error(`Unable to read image metadata for ${filePath}`);
48
+ return {
49
+ format: metadata.format,
50
+ height: metadata.height,
51
+ source,
52
+ width: metadata.width
53
+ };
54
+ };
55
+ const resolveRealPath = async (filePath) => {
56
+ const resolvedPath = path.resolve(filePath);
57
+ try {
58
+ return await fs.realpath(resolvedPath);
59
+ } catch {
60
+ return resolvedPath;
61
+ }
62
+ };
63
+ const isInsideDirectory = (filePath, directoryPath) => {
64
+ const relative = path.relative(directoryPath, filePath);
65
+ return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
66
+ };
67
+ const isAllowedImagePath = async (filePath, config) => {
68
+ const allowedRoots = /* @__PURE__ */ new Set();
69
+ for (const allowedPath of [config.root, ...config.server.fs.allow ?? []]) allowedRoots.add(await resolveRealPath(path.resolve(config.root, allowedPath)));
70
+ const resolvedFilePath = await resolveRealPath(filePath);
71
+ for (const allowedRoot of allowedRoots) if (isInsideDirectory(resolvedFilePath, allowedRoot)) return true;
72
+ return false;
73
+ };
74
+ const parseWidths = (value) => value?.split(/[;,]/).map((part) => Number(part.trim())).filter((part) => Number.isFinite(part) && part > 0) ?? [];
75
+ const normalizeOutputFormat = (sourceFormat, requestedFormat, configuredFormats) => {
76
+ if (requestedFormat) return requestedFormat;
77
+ if (sourceFormat === "svg") return null;
78
+ if (SUPPORTED_OUTPUT_FORMATS.has(sourceFormat)) return sourceFormat;
79
+ return configuredFormats[0] ?? "webp";
80
+ };
81
+ const parseImageRequest = (id, options) => {
82
+ const { pathname, query } = splitId(id);
83
+ const params = new URLSearchParams(query);
84
+ if (!params.has(IMAGE_QUERY_FLAG)) return null;
85
+ const format = params.get("format");
86
+ if (format && !SUPPORTED_OUTPUT_FORMATS.has(format)) throw new Error(`Unsupported @eclipsa/image format "${format}". Expected one of avif, jpeg, png, webp.`);
87
+ const widths = parseWidths(params.get("widths"));
88
+ return {
89
+ filePath: pathname,
90
+ format: format ?? null,
91
+ widths: widths.length > 0 ? widths : options.widths ?? [...DEFAULT_WIDTHS]
92
+ };
93
+ };
94
+ const applyOutputFormat = (pipeline, format, quality) => {
95
+ switch (format) {
96
+ case "avif": return pipeline.avif({ quality });
97
+ case "jpeg": return pipeline.jpeg({
98
+ mozjpeg: true,
99
+ quality
100
+ });
101
+ case "png": return pipeline.png({
102
+ compressionLevel: 9,
103
+ quality
104
+ });
105
+ case "webp": return pipeline.webp({ quality });
106
+ }
107
+ };
108
+ const buildVariantAssets = async (image, variantWidths, format, quality) => {
109
+ if (format === null) return [{
110
+ buffer: image.source,
111
+ format: image.format,
112
+ height: image.height,
113
+ width: image.width
114
+ }];
115
+ return Promise.all(variantWidths.map(async (width) => {
116
+ const targetHeight = toRoundedHeight(image.width, image.height, width);
117
+ return {
118
+ buffer: await applyOutputFormat(sharp(image.source, { animated: true }).resize({
119
+ fit: "inside",
120
+ height: targetHeight,
121
+ width,
122
+ withoutEnlargement: true
123
+ }), format, quality).toBuffer(),
124
+ format,
125
+ height: targetHeight,
126
+ width
127
+ };
128
+ }));
129
+ };
130
+ const createAssetName = (filePath, width, format) => {
131
+ return `${path.basename(filePath, path.extname(filePath))}-${createHash("sha1").update(path.normalize(filePath)).digest("hex").slice(0, 8)}-${width}w.${toOutputExtension(format)}`;
132
+ };
133
+ const createBuildModule = (variants, filePath, emitFile) => {
134
+ const references = variants.map((variant) => emitFile({
135
+ fileName: `assets/${createAssetName(filePath, variant.width, variant.format)}`,
136
+ name: createAssetName(filePath, variant.width, variant.format),
137
+ source: variant.buffer,
138
+ type: "asset"
139
+ }));
140
+ const sourceIndex = variants.length - 1;
141
+ return `const variants = [
142
+ ${variants.map((variant, index) => ` { format: ${JSON.stringify(variant.format)}, height: ${variant.height}, src: import.meta.ROLLUP_FILE_URL_${references[index]}, width: ${variant.width} },`).join("\n")}
143
+ ];
144
+
145
+ export default {
146
+ format: ${JSON.stringify(variants[sourceIndex].format)},
147
+ height: ${variants[sourceIndex].height},
148
+ src: import.meta.ROLLUP_FILE_URL_${references[sourceIndex]},
149
+ variants,
150
+ width: ${variants[sourceIndex].width},
151
+ };
152
+ `;
153
+ };
154
+ const createDevModule = (variants, filePath) => {
155
+ const sourceIndex = variants.length - 1;
156
+ const entries = variants.map((variant) => {
157
+ const params = new URLSearchParams({
158
+ format: variant.format,
159
+ path: filePath,
160
+ width: String(variant.width)
161
+ });
162
+ return {
163
+ format: variant.format,
164
+ height: variant.height,
165
+ src: `${DEV_IMAGE_ENDPOINT}?${params.toString()}`,
166
+ width: variant.width
167
+ };
168
+ });
169
+ return `const variants = ${JSON.stringify(entries)};
170
+
171
+ export default {
172
+ format: ${JSON.stringify(entries[sourceIndex].format)},
173
+ height: ${entries[sourceIndex].height},
174
+ src: ${JSON.stringify(entries[sourceIndex].src)},
175
+ variants,
176
+ width: ${entries[sourceIndex].width},
177
+ };
178
+ `;
179
+ };
180
+ const writeDevImageResponse = async (req, res, config) => {
181
+ const requestUrl = new URL(req.url ?? "/", "http://localhost");
182
+ if (requestUrl.pathname !== DEV_IMAGE_ENDPOINT) return false;
183
+ const filePath = requestUrl.searchParams.get("path");
184
+ const width = Number(requestUrl.searchParams.get("width"));
185
+ const format = requestUrl.searchParams.get("format");
186
+ if (!filePath || !Number.isFinite(width) || width <= 0) {
187
+ res.statusCode = 400;
188
+ res.end("Invalid image request.");
189
+ return true;
190
+ }
191
+ if (!await isAllowedImagePath(filePath, config)) {
192
+ res.statusCode = 403;
193
+ res.end("Image path is not allowed.");
194
+ return true;
195
+ }
196
+ const image = await readLocalImage(filePath);
197
+ if (format === "svg") {
198
+ res.setHeader("Cache-Control", "no-store");
199
+ res.setHeader("Content-Type", "image/svg+xml");
200
+ res.end(image.source);
201
+ return true;
202
+ }
203
+ if (!format || !SUPPORTED_OUTPUT_FORMATS.has(format)) {
204
+ res.statusCode = 400;
205
+ res.end("Invalid image format.");
206
+ return true;
207
+ }
208
+ const [variant] = await buildVariantAssets(image, [Math.min(width, image.width)], format, DEFAULT_QUALITY);
209
+ if (!variant) {
210
+ res.statusCode = 500;
211
+ res.end("Failed to render image variant.");
212
+ return true;
213
+ }
214
+ res.setHeader("Cache-Control", "no-store");
215
+ res.setHeader("Content-Type", toContentType(variant.format));
216
+ res.end(variant.buffer);
217
+ return true;
218
+ };
219
+ const eclipsaImage = (options = {}) => {
220
+ let config = null;
221
+ return {
222
+ configResolved(resolvedConfig) {
223
+ config = resolvedConfig;
224
+ },
225
+ configureServer(server) {
226
+ server.middlewares.use(async (req, res, next) => {
227
+ try {
228
+ if (!config) {
229
+ next(/* @__PURE__ */ new Error("vite-plugin-eclipsa-image requires a resolved Vite config."));
230
+ return;
231
+ }
232
+ if (await writeDevImageResponse(req, res, config)) return;
233
+ } catch (error) {
234
+ next(error);
235
+ return;
236
+ }
237
+ next();
238
+ });
239
+ },
240
+ enforce: "pre",
241
+ async load(id) {
242
+ const resolved = id.startsWith(VIRTUAL_IMAGE_PREFIX) ? parseImageRequest(id.slice(15), options) : null;
243
+ if (!resolved) return null;
244
+ const image = await readLocalImage(resolved.filePath);
245
+ const format = normalizeOutputFormat(image.format, resolved.format, options.formats ?? []);
246
+ const variants = await buildVariantAssets(image, format === null ? [image.width] : resolveImageWidths(image.width, resolved.widths), format, options.quality ?? DEFAULT_QUALITY);
247
+ return config?.command === "build" ? createBuildModule(variants, resolved.filePath, this.emitFile.bind(this)) : createDevModule(variants, resolved.filePath);
248
+ },
249
+ name: "vite-plugin-eclipsa-image",
250
+ async resolveId(source, importer) {
251
+ const requested = parseImageRequest(source, options);
252
+ if (!requested) return null;
253
+ const resolved = await this.resolve(requested.filePath, importer, { skipSelf: true });
254
+ if (!resolved) return null;
255
+ const params = new URLSearchParams(splitId(source).query);
256
+ return `${VIRTUAL_IMAGE_PREFIX}${resolved.id}?${params.toString()}`;
257
+ }
258
+ };
259
+ };
260
+ //#endregion
261
+ export { createAssetName, eclipsaImage, isAllowedImagePath, readLocalImage, resolveImageWidths, toContentType };
262
+
263
+ //# sourceMappingURL=vite.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite.mjs","names":[],"sources":["../vite.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from 'node:http'\nimport { createHash } from 'node:crypto'\nimport * as fs from 'node:fs/promises'\nimport * as path from 'node:path'\nimport sharp from 'sharp'\nimport type { Plugin, ResolvedConfig } from 'vite'\nimport type { EclipsaImageOptions, ImageOutputFormat } from './mod.ts'\n\nconst IMAGE_QUERY_FLAG = 'eclipsa-image'\nconst VIRTUAL_IMAGE_PREFIX = '\\0eclipsa-image:'\nconst DEV_IMAGE_ENDPOINT = '/__eclipsa/image'\nconst DEFAULT_WIDTHS = [320, 640, 960, 1280, 1600] as const\nconst DEFAULT_QUALITY = 80\nconst SUPPORTED_OUTPUT_FORMATS = new Set(['avif', 'jpeg', 'png', 'webp'] as const)\n\ninterface ParsedImageRequest {\n filePath: string\n format: ImageOutputFormat | null\n widths: number[]\n}\n\ninterface LoadedImage {\n format: string\n height: number\n source: Buffer\n width: number\n}\n\ninterface ImageVariantAsset {\n buffer: Buffer\n format: string\n height: number\n width: number\n}\n\ntype PluginContext = {\n emitFile: (emittedFile: {\n fileName?: string\n name?: string\n source: string | Uint8Array\n type: 'asset'\n }) => string\n}\n\nconst splitId = (id: string) => {\n const queryIndex = id.indexOf('?')\n return queryIndex === -1\n ? { pathname: id, query: '' }\n : {\n pathname: id.slice(0, queryIndex),\n query: id.slice(queryIndex + 1),\n }\n}\n\nconst toOutputExtension = (format: string) => (format === 'jpeg' ? 'jpg' : format)\n\nexport const toContentType = (format: string) => {\n if (format === 'svg') {\n return 'image/svg+xml'\n }\n if (format === 'jpeg') {\n return 'image/jpeg'\n }\n return `image/${toOutputExtension(format)}`\n}\n\nconst toRoundedHeight = (sourceWidth: number, sourceHeight: number, targetWidth: number) =>\n Math.max(1, Math.round((sourceHeight * targetWidth) / sourceWidth))\n\nexport const resolveImageWidths = (sourceWidth: number, configuredWidths: readonly number[]) => {\n const widths = configuredWidths\n .map((value) => Math.floor(value))\n .filter((value) => Number.isFinite(value) && value > 0 && value < sourceWidth)\n .sort((left, right) => left - right)\n\n return [...new Set(widths), sourceWidth]\n}\n\nexport const readLocalImage = async (filePath: string): Promise<LoadedImage> => {\n const source = await fs.readFile(filePath)\n const metadata = await sharp(source, { animated: true }).metadata()\n if (!metadata.width || !metadata.height || !metadata.format) {\n throw new Error(`Unable to read image metadata for ${filePath}`)\n }\n\n return {\n format: metadata.format,\n height: metadata.height,\n source,\n width: metadata.width,\n }\n}\n\nconst resolveRealPath = async (filePath: string) => {\n const resolvedPath = path.resolve(filePath)\n try {\n return await fs.realpath(resolvedPath)\n } catch {\n return resolvedPath\n }\n}\n\nconst isInsideDirectory = (filePath: string, directoryPath: string) => {\n const relative = path.relative(directoryPath, filePath)\n return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))\n}\n\nexport const isAllowedImagePath = async (\n filePath: string,\n config: Pick<ResolvedConfig, 'root' | 'server'>,\n) => {\n const allowedRoots = new Set<string>()\n for (const allowedPath of [config.root, ...(config.server.fs.allow ?? [])]) {\n allowedRoots.add(await resolveRealPath(path.resolve(config.root, allowedPath)))\n }\n\n const resolvedFilePath = await resolveRealPath(filePath)\n for (const allowedRoot of allowedRoots) {\n if (isInsideDirectory(resolvedFilePath, allowedRoot)) {\n return true\n }\n }\n return false\n}\n\nconst parseWidths = (value: string | null) =>\n value\n ?.split(/[;,]/)\n .map((part) => Number(part.trim()))\n .filter((part) => Number.isFinite(part) && part > 0) ?? []\n\nconst normalizeOutputFormat = (\n sourceFormat: string,\n requestedFormat: ImageOutputFormat | null,\n configuredFormats: readonly ImageOutputFormat[],\n): ImageOutputFormat | null => {\n if (requestedFormat) {\n return requestedFormat\n }\n if (sourceFormat === 'svg') {\n return null\n }\n if (SUPPORTED_OUTPUT_FORMATS.has(sourceFormat as ImageOutputFormat)) {\n return sourceFormat as ImageOutputFormat\n }\n return configuredFormats[0] ?? 'webp'\n}\n\nconst parseImageRequest = (id: string, options: EclipsaImageOptions): ParsedImageRequest | null => {\n const { pathname, query } = splitId(id)\n const params = new URLSearchParams(query)\n if (!params.has(IMAGE_QUERY_FLAG)) {\n return null\n }\n\n const format = params.get('format')\n if (format && !SUPPORTED_OUTPUT_FORMATS.has(format as ImageOutputFormat)) {\n throw new Error(\n `Unsupported @eclipsa/image format \"${format}\". Expected one of avif, jpeg, png, webp.`,\n )\n }\n\n const widths = parseWidths(params.get('widths'))\n\n return {\n filePath: pathname,\n format: (format as ImageOutputFormat | null) ?? null,\n widths: widths.length > 0 ? widths : (options.widths ?? [...DEFAULT_WIDTHS]),\n }\n}\n\nconst applyOutputFormat = (pipeline: sharp.Sharp, format: ImageOutputFormat, quality: number) => {\n switch (format) {\n case 'avif':\n return pipeline.avif({ quality })\n case 'jpeg':\n return pipeline.jpeg({ mozjpeg: true, quality })\n case 'png':\n return pipeline.png({ compressionLevel: 9, quality })\n case 'webp':\n return pipeline.webp({ quality })\n }\n}\n\nconst buildVariantAssets = async (\n image: LoadedImage,\n variantWidths: readonly number[],\n format: ImageOutputFormat | null,\n quality: number,\n): Promise<ImageVariantAsset[]> => {\n if (format === null) {\n return [\n {\n buffer: image.source,\n format: image.format,\n height: image.height,\n width: image.width,\n },\n ]\n }\n\n return Promise.all(\n variantWidths.map(async (width) => {\n const targetHeight = toRoundedHeight(image.width, image.height, width)\n const resized = await applyOutputFormat(\n sharp(image.source, { animated: true }).resize({\n fit: 'inside',\n height: targetHeight,\n width,\n withoutEnlargement: true,\n }),\n format,\n quality,\n ).toBuffer()\n\n return {\n buffer: resized,\n format,\n height: targetHeight,\n width,\n }\n }),\n )\n}\n\nexport const createAssetName = (filePath: string, width: number, format: string) => {\n const fileName = path.basename(filePath, path.extname(filePath))\n const fileHash = createHash('sha1').update(path.normalize(filePath)).digest('hex').slice(0, 8)\n return `${fileName}-${fileHash}-${width}w.${toOutputExtension(format)}`\n}\n\nconst createBuildModule = (\n variants: ImageVariantAsset[],\n filePath: string,\n emitFile: PluginContext['emitFile'],\n) => {\n const references = variants.map((variant) =>\n emitFile({\n fileName: `assets/${createAssetName(filePath, variant.width, variant.format)}`,\n name: createAssetName(filePath, variant.width, variant.format),\n source: variant.buffer,\n type: 'asset',\n }),\n )\n const sourceIndex = variants.length - 1\n\n return `const variants = [\n${variants\n .map(\n (variant, index) =>\n ` { format: ${JSON.stringify(variant.format)}, height: ${variant.height}, src: import.meta.ROLLUP_FILE_URL_${references[index]}, width: ${variant.width} },`,\n )\n .join('\\n')}\n];\n\nexport default {\n format: ${JSON.stringify(variants[sourceIndex]!.format)},\n height: ${variants[sourceIndex]!.height},\n src: import.meta.ROLLUP_FILE_URL_${references[sourceIndex]!},\n variants,\n width: ${variants[sourceIndex]!.width},\n};\n`\n}\n\nconst createDevModule = (variants: ImageVariantAsset[], filePath: string) => {\n const sourceIndex = variants.length - 1\n const entries = variants.map((variant) => {\n const params = new URLSearchParams({\n format: variant.format,\n path: filePath,\n width: String(variant.width),\n })\n return {\n format: variant.format,\n height: variant.height,\n src: `${DEV_IMAGE_ENDPOINT}?${params.toString()}`,\n width: variant.width,\n }\n })\n\n return `const variants = ${JSON.stringify(entries)};\n\nexport default {\n format: ${JSON.stringify(entries[sourceIndex]!.format)},\n height: ${entries[sourceIndex]!.height},\n src: ${JSON.stringify(entries[sourceIndex]!.src)},\n variants,\n width: ${entries[sourceIndex]!.width},\n};\n`\n}\n\nconst writeDevImageResponse = async (\n req: IncomingMessage,\n res: ServerResponse,\n config: Pick<ResolvedConfig, 'root' | 'server'>,\n) => {\n const requestUrl = new URL(req.url ?? '/', 'http://localhost')\n if (requestUrl.pathname !== DEV_IMAGE_ENDPOINT) {\n return false\n }\n\n const filePath = requestUrl.searchParams.get('path')\n const width = Number(requestUrl.searchParams.get('width'))\n const format = requestUrl.searchParams.get('format')\n if (!filePath || !Number.isFinite(width) || width <= 0) {\n res.statusCode = 400\n res.end('Invalid image request.')\n return true\n }\n\n if (!(await isAllowedImagePath(filePath, config))) {\n res.statusCode = 403\n res.end('Image path is not allowed.')\n return true\n }\n\n const image = await readLocalImage(filePath)\n if (format === 'svg') {\n res.setHeader('Cache-Control', 'no-store')\n res.setHeader('Content-Type', 'image/svg+xml')\n res.end(image.source)\n return true\n }\n\n if (!format || !SUPPORTED_OUTPUT_FORMATS.has(format as ImageOutputFormat)) {\n res.statusCode = 400\n res.end('Invalid image format.')\n return true\n }\n\n const [variant] = await buildVariantAssets(\n image,\n [Math.min(width, image.width)],\n format as ImageOutputFormat,\n DEFAULT_QUALITY,\n )\n if (!variant) {\n res.statusCode = 500\n res.end('Failed to render image variant.')\n return true\n }\n\n res.setHeader('Cache-Control', 'no-store')\n res.setHeader('Content-Type', toContentType(variant.format))\n res.end(variant.buffer)\n return true\n}\n\nexport const eclipsaImage = (options: EclipsaImageOptions = {}): Plugin => {\n let config: ResolvedConfig | null = null\n\n return {\n configResolved(resolvedConfig) {\n config = resolvedConfig\n },\n configureServer(server) {\n server.middlewares.use(async (req, res, next) => {\n try {\n if (!config) {\n next(new Error('vite-plugin-eclipsa-image requires a resolved Vite config.'))\n return\n }\n if (await writeDevImageResponse(req, res, config)) {\n return\n }\n } catch (error) {\n next(error as Error)\n return\n }\n next()\n })\n },\n enforce: 'pre',\n async load(id) {\n const resolved = id.startsWith(VIRTUAL_IMAGE_PREFIX)\n ? parseImageRequest(id.slice(VIRTUAL_IMAGE_PREFIX.length), options)\n : null\n if (!resolved) {\n return null\n }\n\n const image = await readLocalImage(resolved.filePath)\n const format = normalizeOutputFormat(image.format, resolved.format, options.formats ?? [])\n const widths =\n format === null ? [image.width] : resolveImageWidths(image.width, resolved.widths)\n const variants = await buildVariantAssets(\n image,\n widths,\n format,\n options.quality ?? DEFAULT_QUALITY,\n )\n\n return config?.command === 'build'\n ? createBuildModule(variants, resolved.filePath, this.emitFile.bind(this))\n : createDevModule(variants, resolved.filePath)\n },\n name: 'vite-plugin-eclipsa-image',\n async resolveId(source, importer) {\n const requested = parseImageRequest(source, options)\n if (!requested) {\n return null\n }\n\n const resolved = await this.resolve(requested.filePath, importer, { skipSelf: true })\n if (!resolved) {\n return null\n }\n\n const params = new URLSearchParams(splitId(source).query)\n return `${VIRTUAL_IMAGE_PREFIX}${resolved.id}?${params.toString()}`\n },\n }\n}\n"],"mappings":"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"}
package/mod.test.tsx ADDED
@@ -0,0 +1,116 @@
1
+ import * as fs from 'node:fs/promises'
2
+ import * as path from 'node:path'
3
+ import { tmpdir } from 'node:os'
4
+ import sharp from 'sharp'
5
+ import { describe, expect, it } from 'vitest'
6
+ import { Image } from './mod.ts'
7
+ import {
8
+ createAssetName,
9
+ isAllowedImagePath,
10
+ readLocalImage,
11
+ resolveImageWidths,
12
+ toContentType,
13
+ } from './vite.ts'
14
+
15
+ describe('@eclipsa/image helpers', () => {
16
+ it('keeps configured widths ordered and appends the source width', () => {
17
+ expect(resolveImageWidths(1200, [960, 320, 320, 1600, -5])).toEqual([320, 960, 1200])
18
+ })
19
+
20
+ it('reads local image metadata', async () => {
21
+ const root = await fs.mkdtemp(path.join(tmpdir(), 'eclipsa-image-'))
22
+ const filePath = path.join(root, 'sample.png')
23
+
24
+ await sharp({
25
+ create: {
26
+ background: { alpha: 1, b: 200, g: 120, r: 40 },
27
+ channels: 4,
28
+ height: 600,
29
+ width: 900,
30
+ },
31
+ })
32
+ .png()
33
+ .toFile(filePath)
34
+
35
+ await expect(readLocalImage(filePath)).resolves.toMatchObject({
36
+ format: 'png',
37
+ height: 600,
38
+ width: 900,
39
+ })
40
+ })
41
+
42
+ it('returns the correct jpeg mime type', () => {
43
+ expect(toContentType('jpeg')).toBe('image/jpeg')
44
+ expect(toContentType('png')).toBe('image/png')
45
+ })
46
+
47
+ it('creates distinct emitted asset names for duplicate basenames', () => {
48
+ expect(createAssetName('/tmp/one/hero.png', 320, 'png')).not.toBe(
49
+ createAssetName('/tmp/two/hero.png', 320, 'png'),
50
+ )
51
+ })
52
+
53
+ it('only serves dev image paths inside the configured allowlist', async () => {
54
+ const root = await fs.mkdtemp(path.join(tmpdir(), 'eclipsa-image-root-'))
55
+ const allowed = path.join(root, 'allowed')
56
+ const denied = await fs.mkdtemp(path.join(tmpdir(), 'eclipsa-image-denied-'))
57
+ const allowedFile = path.join(allowed, 'hero.png')
58
+ const deniedFile = path.join(denied, 'hero.png')
59
+
60
+ await fs.mkdir(allowed, { recursive: true })
61
+ await fs.writeFile(allowedFile, 'ok')
62
+ await fs.writeFile(deniedFile, 'nope')
63
+
64
+ const config = {
65
+ root,
66
+ server: {
67
+ fs: {
68
+ allow: [allowed],
69
+ },
70
+ },
71
+ } as const
72
+
73
+ await expect(isAllowedImagePath(allowedFile, config as any)).resolves.toBe(true)
74
+ await expect(isAllowedImagePath(deniedFile, config as any)).resolves.toBe(false)
75
+ })
76
+
77
+ it('renders img defaults from imported metadata', () => {
78
+ const element = Image({
79
+ alt: 'Preview',
80
+ src: {
81
+ format: 'webp',
82
+ height: 400,
83
+ src: '/assets/example-960w.webp',
84
+ variants: [
85
+ {
86
+ format: 'webp',
87
+ height: 133,
88
+ src: '/assets/example-320w.webp',
89
+ width: 320,
90
+ },
91
+ {
92
+ format: 'webp',
93
+ height: 400,
94
+ src: '/assets/example-960w.webp',
95
+ width: 960,
96
+ },
97
+ ],
98
+ width: 960,
99
+ },
100
+ })
101
+
102
+ expect(element).toMatchObject({
103
+ props: {
104
+ alt: 'Preview',
105
+ decoding: 'async',
106
+ height: 400,
107
+ loading: 'lazy',
108
+ sizes: '100vw',
109
+ src: '/assets/example-960w.webp',
110
+ srcset: '/assets/example-320w.webp 320w, /assets/example-960w.webp 960w',
111
+ width: 960,
112
+ },
113
+ type: 'img',
114
+ })
115
+ })
116
+ })
package/mod.ts ADDED
@@ -0,0 +1,81 @@
1
+ export type ImageOutputFormat = 'avif' | 'jpeg' | 'png' | 'webp'
2
+
3
+ export interface ImageVariant {
4
+ format: string
5
+ height: number
6
+ src: string
7
+ width: number
8
+ }
9
+
10
+ export interface ImageSource {
11
+ format: string
12
+ height: number
13
+ src: string
14
+ variants: ImageVariant[]
15
+ width: number
16
+ }
17
+
18
+ export interface ImageProps extends Record<string, unknown> {
19
+ alt: string
20
+ decoding?: 'async' | 'auto' | 'sync'
21
+ height?: number
22
+ loading?: 'eager' | 'lazy'
23
+ sizes?: string
24
+ src: ImageSource | string
25
+ srcset?: string
26
+ width?: number
27
+ }
28
+
29
+ export interface EclipsaImageOptions {
30
+ formats?: ImageOutputFormat[]
31
+ quality?: number
32
+ widths?: number[]
33
+ }
34
+
35
+ const createImageElement = (props: Record<string, unknown>) => ({
36
+ isStatic: false,
37
+ props,
38
+ type: 'img',
39
+ })
40
+
41
+ export const Image = ({
42
+ alt,
43
+ decoding = 'async',
44
+ height,
45
+ loading = 'lazy',
46
+ sizes,
47
+ src,
48
+ srcset,
49
+ width,
50
+ ...props
51
+ }: ImageProps) => {
52
+ const imageProps =
53
+ typeof src === 'string'
54
+ ? {
55
+ ...props,
56
+ alt,
57
+ decoding,
58
+ height,
59
+ loading,
60
+ sizes,
61
+ src,
62
+ srcset,
63
+ width,
64
+ }
65
+ : {
66
+ ...props,
67
+ alt,
68
+ decoding,
69
+ height: height ?? src.height,
70
+ loading,
71
+ sizes: sizes ?? (src.variants.length > 1 ? '100vw' : undefined),
72
+ src: src.src,
73
+ srcset:
74
+ srcset ?? src.variants.map((variant) => `${variant.src} ${variant.width}w`).join(', '),
75
+ width: width ?? src.width,
76
+ }
77
+
78
+ return createImageElement({
79
+ ...imageProps,
80
+ })
81
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@eclipsa/image",
3
+ "private": false,
4
+ "version": "0.0.0",
5
+ "homepage": "https://github.com/pnsk-lab/eclipsa",
6
+ "bugs": {
7
+ "url": "https://github.com/pnsk-lab/eclipsa/issues"
8
+ },
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/pnsk-lab/eclipsa.git",
13
+ "directory": "packages/image"
14
+ },
15
+ "type": "module",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./mod.ts",
19
+ "import": "./mod.ts"
20
+ },
21
+ "./client": {
22
+ "types": "./client.d.ts",
23
+ "import": "./client.ts"
24
+ },
25
+ "./vite": {
26
+ "types": "./vite.ts",
27
+ "import": "./vite.ts"
28
+ }
29
+ },
30
+ "publishConfig": {
31
+ "exports": {
32
+ ".": {
33
+ "types": "./mod.d.mts",
34
+ "import": "./mod.mjs"
35
+ },
36
+ "./client": {
37
+ "types": "./client.d.ts",
38
+ "import": "./client.mjs"
39
+ },
40
+ "./vite": {
41
+ "types": "./vite.d.mts",
42
+ "import": "./vite.mjs"
43
+ }
44
+ }
45
+ },
46
+ "scripts": {
47
+ "build": "vp pack && bun ../../scripts/release/write-dist-package-json.ts",
48
+ "pack": "vp pack && bun ../../scripts/release/write-dist-package-json.ts",
49
+ "test": "vp test --run",
50
+ "typecheck": "bun x tsc -p ../../tsconfig.json --noEmit"
51
+ },
52
+ "dependencies": {
53
+ "eclipsa": "0.2.0-alpha.0",
54
+ "sharp": "^0.34.5"
55
+ },
56
+ "peerDependencies": {
57
+ "vite": "*"
58
+ }
59
+ }
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import config from './vite.config.ts'
3
+
4
+ describe('@eclipsa/image vite pack config', () => {
5
+ it('builds every published entrypoint with declarations', () => {
6
+ expect(config.pack).toMatchObject({
7
+ clean: true,
8
+ copy: ['client.d.ts'],
9
+ dts: true,
10
+ entry: ['mod.ts', 'client.ts', 'vite.ts'],
11
+ format: ['esm'],
12
+ sourcemap: true,
13
+ })
14
+ })
15
+ })
package/vite.config.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from 'vite-plus'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['*.test.ts', '*.test.tsx'],
6
+ environment: 'node',
7
+ },
8
+ pack: {
9
+ copy: ['client.d.ts'],
10
+ entry: ['mod.ts', 'client.ts', 'vite.ts'],
11
+ dts: true,
12
+ format: ['esm'],
13
+ clean: true,
14
+ sourcemap: true,
15
+ },
16
+ })
package/vite.ts ADDED
@@ -0,0 +1,415 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import { createHash } from 'node:crypto'
3
+ import * as fs from 'node:fs/promises'
4
+ import * as path from 'node:path'
5
+ import sharp from 'sharp'
6
+ import type { Plugin, ResolvedConfig } from 'vite'
7
+ import type { EclipsaImageOptions, ImageOutputFormat } from './mod.ts'
8
+
9
+ const IMAGE_QUERY_FLAG = 'eclipsa-image'
10
+ const VIRTUAL_IMAGE_PREFIX = '\0eclipsa-image:'
11
+ const DEV_IMAGE_ENDPOINT = '/__eclipsa/image'
12
+ const DEFAULT_WIDTHS = [320, 640, 960, 1280, 1600] as const
13
+ const DEFAULT_QUALITY = 80
14
+ const SUPPORTED_OUTPUT_FORMATS = new Set(['avif', 'jpeg', 'png', 'webp'] as const)
15
+
16
+ interface ParsedImageRequest {
17
+ filePath: string
18
+ format: ImageOutputFormat | null
19
+ widths: number[]
20
+ }
21
+
22
+ interface LoadedImage {
23
+ format: string
24
+ height: number
25
+ source: Buffer
26
+ width: number
27
+ }
28
+
29
+ interface ImageVariantAsset {
30
+ buffer: Buffer
31
+ format: string
32
+ height: number
33
+ width: number
34
+ }
35
+
36
+ type PluginContext = {
37
+ emitFile: (emittedFile: {
38
+ fileName?: string
39
+ name?: string
40
+ source: string | Uint8Array
41
+ type: 'asset'
42
+ }) => string
43
+ }
44
+
45
+ const splitId = (id: string) => {
46
+ const queryIndex = id.indexOf('?')
47
+ return queryIndex === -1
48
+ ? { pathname: id, query: '' }
49
+ : {
50
+ pathname: id.slice(0, queryIndex),
51
+ query: id.slice(queryIndex + 1),
52
+ }
53
+ }
54
+
55
+ const toOutputExtension = (format: string) => (format === 'jpeg' ? 'jpg' : format)
56
+
57
+ export const toContentType = (format: string) => {
58
+ if (format === 'svg') {
59
+ return 'image/svg+xml'
60
+ }
61
+ if (format === 'jpeg') {
62
+ return 'image/jpeg'
63
+ }
64
+ return `image/${toOutputExtension(format)}`
65
+ }
66
+
67
+ const toRoundedHeight = (sourceWidth: number, sourceHeight: number, targetWidth: number) =>
68
+ Math.max(1, Math.round((sourceHeight * targetWidth) / sourceWidth))
69
+
70
+ export const resolveImageWidths = (sourceWidth: number, configuredWidths: readonly number[]) => {
71
+ const widths = configuredWidths
72
+ .map((value) => Math.floor(value))
73
+ .filter((value) => Number.isFinite(value) && value > 0 && value < sourceWidth)
74
+ .sort((left, right) => left - right)
75
+
76
+ return [...new Set(widths), sourceWidth]
77
+ }
78
+
79
+ export const readLocalImage = async (filePath: string): Promise<LoadedImage> => {
80
+ const source = await fs.readFile(filePath)
81
+ const metadata = await sharp(source, { animated: true }).metadata()
82
+ if (!metadata.width || !metadata.height || !metadata.format) {
83
+ throw new Error(`Unable to read image metadata for ${filePath}`)
84
+ }
85
+
86
+ return {
87
+ format: metadata.format,
88
+ height: metadata.height,
89
+ source,
90
+ width: metadata.width,
91
+ }
92
+ }
93
+
94
+ const resolveRealPath = async (filePath: string) => {
95
+ const resolvedPath = path.resolve(filePath)
96
+ try {
97
+ return await fs.realpath(resolvedPath)
98
+ } catch {
99
+ return resolvedPath
100
+ }
101
+ }
102
+
103
+ const isInsideDirectory = (filePath: string, directoryPath: string) => {
104
+ const relative = path.relative(directoryPath, filePath)
105
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
106
+ }
107
+
108
+ export const isAllowedImagePath = async (
109
+ filePath: string,
110
+ config: Pick<ResolvedConfig, 'root' | 'server'>,
111
+ ) => {
112
+ const allowedRoots = new Set<string>()
113
+ for (const allowedPath of [config.root, ...(config.server.fs.allow ?? [])]) {
114
+ allowedRoots.add(await resolveRealPath(path.resolve(config.root, allowedPath)))
115
+ }
116
+
117
+ const resolvedFilePath = await resolveRealPath(filePath)
118
+ for (const allowedRoot of allowedRoots) {
119
+ if (isInsideDirectory(resolvedFilePath, allowedRoot)) {
120
+ return true
121
+ }
122
+ }
123
+ return false
124
+ }
125
+
126
+ const parseWidths = (value: string | null) =>
127
+ value
128
+ ?.split(/[;,]/)
129
+ .map((part) => Number(part.trim()))
130
+ .filter((part) => Number.isFinite(part) && part > 0) ?? []
131
+
132
+ const normalizeOutputFormat = (
133
+ sourceFormat: string,
134
+ requestedFormat: ImageOutputFormat | null,
135
+ configuredFormats: readonly ImageOutputFormat[],
136
+ ): ImageOutputFormat | null => {
137
+ if (requestedFormat) {
138
+ return requestedFormat
139
+ }
140
+ if (sourceFormat === 'svg') {
141
+ return null
142
+ }
143
+ if (SUPPORTED_OUTPUT_FORMATS.has(sourceFormat as ImageOutputFormat)) {
144
+ return sourceFormat as ImageOutputFormat
145
+ }
146
+ return configuredFormats[0] ?? 'webp'
147
+ }
148
+
149
+ const parseImageRequest = (id: string, options: EclipsaImageOptions): ParsedImageRequest | null => {
150
+ const { pathname, query } = splitId(id)
151
+ const params = new URLSearchParams(query)
152
+ if (!params.has(IMAGE_QUERY_FLAG)) {
153
+ return null
154
+ }
155
+
156
+ const format = params.get('format')
157
+ if (format && !SUPPORTED_OUTPUT_FORMATS.has(format as ImageOutputFormat)) {
158
+ throw new Error(
159
+ `Unsupported @eclipsa/image format "${format}". Expected one of avif, jpeg, png, webp.`,
160
+ )
161
+ }
162
+
163
+ const widths = parseWidths(params.get('widths'))
164
+
165
+ return {
166
+ filePath: pathname,
167
+ format: (format as ImageOutputFormat | null) ?? null,
168
+ widths: widths.length > 0 ? widths : (options.widths ?? [...DEFAULT_WIDTHS]),
169
+ }
170
+ }
171
+
172
+ const applyOutputFormat = (pipeline: sharp.Sharp, format: ImageOutputFormat, quality: number) => {
173
+ switch (format) {
174
+ case 'avif':
175
+ return pipeline.avif({ quality })
176
+ case 'jpeg':
177
+ return pipeline.jpeg({ mozjpeg: true, quality })
178
+ case 'png':
179
+ return pipeline.png({ compressionLevel: 9, quality })
180
+ case 'webp':
181
+ return pipeline.webp({ quality })
182
+ }
183
+ }
184
+
185
+ const buildVariantAssets = async (
186
+ image: LoadedImage,
187
+ variantWidths: readonly number[],
188
+ format: ImageOutputFormat | null,
189
+ quality: number,
190
+ ): Promise<ImageVariantAsset[]> => {
191
+ if (format === null) {
192
+ return [
193
+ {
194
+ buffer: image.source,
195
+ format: image.format,
196
+ height: image.height,
197
+ width: image.width,
198
+ },
199
+ ]
200
+ }
201
+
202
+ return Promise.all(
203
+ variantWidths.map(async (width) => {
204
+ const targetHeight = toRoundedHeight(image.width, image.height, width)
205
+ const resized = await applyOutputFormat(
206
+ sharp(image.source, { animated: true }).resize({
207
+ fit: 'inside',
208
+ height: targetHeight,
209
+ width,
210
+ withoutEnlargement: true,
211
+ }),
212
+ format,
213
+ quality,
214
+ ).toBuffer()
215
+
216
+ return {
217
+ buffer: resized,
218
+ format,
219
+ height: targetHeight,
220
+ width,
221
+ }
222
+ }),
223
+ )
224
+ }
225
+
226
+ export const createAssetName = (filePath: string, width: number, format: string) => {
227
+ const fileName = path.basename(filePath, path.extname(filePath))
228
+ const fileHash = createHash('sha1').update(path.normalize(filePath)).digest('hex').slice(0, 8)
229
+ return `${fileName}-${fileHash}-${width}w.${toOutputExtension(format)}`
230
+ }
231
+
232
+ const createBuildModule = (
233
+ variants: ImageVariantAsset[],
234
+ filePath: string,
235
+ emitFile: PluginContext['emitFile'],
236
+ ) => {
237
+ const references = variants.map((variant) =>
238
+ emitFile({
239
+ fileName: `assets/${createAssetName(filePath, variant.width, variant.format)}`,
240
+ name: createAssetName(filePath, variant.width, variant.format),
241
+ source: variant.buffer,
242
+ type: 'asset',
243
+ }),
244
+ )
245
+ const sourceIndex = variants.length - 1
246
+
247
+ return `const variants = [
248
+ ${variants
249
+ .map(
250
+ (variant, index) =>
251
+ ` { format: ${JSON.stringify(variant.format)}, height: ${variant.height}, src: import.meta.ROLLUP_FILE_URL_${references[index]}, width: ${variant.width} },`,
252
+ )
253
+ .join('\n')}
254
+ ];
255
+
256
+ export default {
257
+ format: ${JSON.stringify(variants[sourceIndex]!.format)},
258
+ height: ${variants[sourceIndex]!.height},
259
+ src: import.meta.ROLLUP_FILE_URL_${references[sourceIndex]!},
260
+ variants,
261
+ width: ${variants[sourceIndex]!.width},
262
+ };
263
+ `
264
+ }
265
+
266
+ const createDevModule = (variants: ImageVariantAsset[], filePath: string) => {
267
+ const sourceIndex = variants.length - 1
268
+ const entries = variants.map((variant) => {
269
+ const params = new URLSearchParams({
270
+ format: variant.format,
271
+ path: filePath,
272
+ width: String(variant.width),
273
+ })
274
+ return {
275
+ format: variant.format,
276
+ height: variant.height,
277
+ src: `${DEV_IMAGE_ENDPOINT}?${params.toString()}`,
278
+ width: variant.width,
279
+ }
280
+ })
281
+
282
+ return `const variants = ${JSON.stringify(entries)};
283
+
284
+ export default {
285
+ format: ${JSON.stringify(entries[sourceIndex]!.format)},
286
+ height: ${entries[sourceIndex]!.height},
287
+ src: ${JSON.stringify(entries[sourceIndex]!.src)},
288
+ variants,
289
+ width: ${entries[sourceIndex]!.width},
290
+ };
291
+ `
292
+ }
293
+
294
+ const writeDevImageResponse = async (
295
+ req: IncomingMessage,
296
+ res: ServerResponse,
297
+ config: Pick<ResolvedConfig, 'root' | 'server'>,
298
+ ) => {
299
+ const requestUrl = new URL(req.url ?? '/', 'http://localhost')
300
+ if (requestUrl.pathname !== DEV_IMAGE_ENDPOINT) {
301
+ return false
302
+ }
303
+
304
+ const filePath = requestUrl.searchParams.get('path')
305
+ const width = Number(requestUrl.searchParams.get('width'))
306
+ const format = requestUrl.searchParams.get('format')
307
+ if (!filePath || !Number.isFinite(width) || width <= 0) {
308
+ res.statusCode = 400
309
+ res.end('Invalid image request.')
310
+ return true
311
+ }
312
+
313
+ if (!(await isAllowedImagePath(filePath, config))) {
314
+ res.statusCode = 403
315
+ res.end('Image path is not allowed.')
316
+ return true
317
+ }
318
+
319
+ const image = await readLocalImage(filePath)
320
+ if (format === 'svg') {
321
+ res.setHeader('Cache-Control', 'no-store')
322
+ res.setHeader('Content-Type', 'image/svg+xml')
323
+ res.end(image.source)
324
+ return true
325
+ }
326
+
327
+ if (!format || !SUPPORTED_OUTPUT_FORMATS.has(format as ImageOutputFormat)) {
328
+ res.statusCode = 400
329
+ res.end('Invalid image format.')
330
+ return true
331
+ }
332
+
333
+ const [variant] = await buildVariantAssets(
334
+ image,
335
+ [Math.min(width, image.width)],
336
+ format as ImageOutputFormat,
337
+ DEFAULT_QUALITY,
338
+ )
339
+ if (!variant) {
340
+ res.statusCode = 500
341
+ res.end('Failed to render image variant.')
342
+ return true
343
+ }
344
+
345
+ res.setHeader('Cache-Control', 'no-store')
346
+ res.setHeader('Content-Type', toContentType(variant.format))
347
+ res.end(variant.buffer)
348
+ return true
349
+ }
350
+
351
+ export const eclipsaImage = (options: EclipsaImageOptions = {}): Plugin => {
352
+ let config: ResolvedConfig | null = null
353
+
354
+ return {
355
+ configResolved(resolvedConfig) {
356
+ config = resolvedConfig
357
+ },
358
+ configureServer(server) {
359
+ server.middlewares.use(async (req, res, next) => {
360
+ try {
361
+ if (!config) {
362
+ next(new Error('vite-plugin-eclipsa-image requires a resolved Vite config.'))
363
+ return
364
+ }
365
+ if (await writeDevImageResponse(req, res, config)) {
366
+ return
367
+ }
368
+ } catch (error) {
369
+ next(error as Error)
370
+ return
371
+ }
372
+ next()
373
+ })
374
+ },
375
+ enforce: 'pre',
376
+ async load(id) {
377
+ const resolved = id.startsWith(VIRTUAL_IMAGE_PREFIX)
378
+ ? parseImageRequest(id.slice(VIRTUAL_IMAGE_PREFIX.length), options)
379
+ : null
380
+ if (!resolved) {
381
+ return null
382
+ }
383
+
384
+ const image = await readLocalImage(resolved.filePath)
385
+ const format = normalizeOutputFormat(image.format, resolved.format, options.formats ?? [])
386
+ const widths =
387
+ format === null ? [image.width] : resolveImageWidths(image.width, resolved.widths)
388
+ const variants = await buildVariantAssets(
389
+ image,
390
+ widths,
391
+ format,
392
+ options.quality ?? DEFAULT_QUALITY,
393
+ )
394
+
395
+ return config?.command === 'build'
396
+ ? createBuildModule(variants, resolved.filePath, this.emitFile.bind(this))
397
+ : createDevModule(variants, resolved.filePath)
398
+ },
399
+ name: 'vite-plugin-eclipsa-image',
400
+ async resolveId(source, importer) {
401
+ const requested = parseImageRequest(source, options)
402
+ if (!requested) {
403
+ return null
404
+ }
405
+
406
+ const resolved = await this.resolve(requested.filePath, importer, { skipSelf: true })
407
+ if (!resolved) {
408
+ return null
409
+ }
410
+
411
+ const params = new URLSearchParams(splitId(source).query)
412
+ return `${VIRTUAL_IMAGE_PREFIX}${resolved.id}?${params.toString()}`
413
+ },
414
+ }
415
+ }