@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.
- package/.turbo/turbo-build.log +16 -0
- package/.turbo/turbo-pack.log +16 -0
- package/.turbo/turbo-test.log +11 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/client.d.ts +41 -0
- package/client.ts +1 -0
- package/dist/client.d.mts +1 -0
- package/dist/client.d.ts +41 -0
- package/dist/client.mjs +1 -0
- package/dist/mod.d.mts +48 -0
- package/dist/mod.mjs +33 -0
- package/dist/mod.mjs.map +1 -0
- package/dist/package.json +37 -0
- package/dist/vite.d.mts +19 -0
- package/dist/vite.mjs +263 -0
- package/dist/vite.mjs.map +1 -0
- package/mod.test.tsx +116 -0
- package/mod.ts +81 -0
- package/package.json +59 -0
- package/vite-config.test.ts +15 -0
- package/vite.config.ts +16 -0
- package/vite.ts +415 -0
|
@@ -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 { };
|
package/dist/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/dist/client.mjs
ADDED
|
@@ -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
|
package/dist/mod.mjs.map
ADDED
|
@@ -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
|
+
}
|
package/dist/vite.d.mts
ADDED
|
@@ -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":";;;;;AAQA,MAAM,mBAAmB;AACzB,MAAM,uBAAuB;AAC7B,MAAM,qBAAqB;AAC3B,MAAM,iBAAiB;CAAC;CAAK;CAAK;CAAK;CAAM;CAAK;AAClD,MAAM,kBAAkB;AACxB,MAAM,2BAA2B,IAAI,IAAI;CAAC;CAAQ;CAAQ;CAAO;CAAO,CAAU;AA+BlF,MAAM,WAAW,OAAe;CAC9B,MAAM,aAAa,GAAG,QAAQ,IAAI;AAClC,QAAO,eAAe,KAClB;EAAE,UAAU;EAAI,OAAO;EAAI,GAC3B;EACE,UAAU,GAAG,MAAM,GAAG,WAAW;EACjC,OAAO,GAAG,MAAM,aAAa,EAAE;EAChC;;AAGP,MAAM,qBAAqB,WAAoB,WAAW,SAAS,QAAQ;AAE3E,MAAa,iBAAiB,WAAmB;AAC/C,KAAI,WAAW,MACb,QAAO;AAET,KAAI,WAAW,OACb,QAAO;AAET,QAAO,SAAS,kBAAkB,OAAO;;AAG3C,MAAM,mBAAmB,aAAqB,cAAsB,gBAClE,KAAK,IAAI,GAAG,KAAK,MAAO,eAAe,cAAe,YAAY,CAAC;AAErE,MAAa,sBAAsB,aAAqB,qBAAwC;CAC9F,MAAM,SAAS,iBACZ,KAAK,UAAU,KAAK,MAAM,MAAM,CAAC,CACjC,QAAQ,UAAU,OAAO,SAAS,MAAM,IAAI,QAAQ,KAAK,QAAQ,YAAY,CAC7E,MAAM,MAAM,UAAU,OAAO,MAAM;AAEtC,QAAO,CAAC,GAAG,IAAI,IAAI,OAAO,EAAE,YAAY;;AAG1C,MAAa,iBAAiB,OAAO,aAA2C;CAC9E,MAAM,SAAS,MAAM,GAAG,SAAS,SAAS;CAC1C,MAAM,WAAW,MAAM,MAAM,QAAQ,EAAE,UAAU,MAAM,CAAC,CAAC,UAAU;AACnE,KAAI,CAAC,SAAS,SAAS,CAAC,SAAS,UAAU,CAAC,SAAS,OACnD,OAAM,IAAI,MAAM,qCAAqC,WAAW;AAGlE,QAAO;EACL,QAAQ,SAAS;EACjB,QAAQ,SAAS;EACjB;EACA,OAAO,SAAS;EACjB;;AAGH,MAAM,kBAAkB,OAAO,aAAqB;CAClD,MAAM,eAAe,KAAK,QAAQ,SAAS;AAC3C,KAAI;AACF,SAAO,MAAM,GAAG,SAAS,aAAa;SAChC;AACN,SAAO;;;AAIX,MAAM,qBAAqB,UAAkB,kBAA0B;CACrE,MAAM,WAAW,KAAK,SAAS,eAAe,SAAS;AACvD,QAAO,aAAa,MAAO,CAAC,SAAS,WAAW,KAAK,IAAI,CAAC,KAAK,WAAW,SAAS;;AAGrF,MAAa,qBAAqB,OAChC,UACA,WACG;CACH,MAAM,+BAAe,IAAI,KAAa;AACtC,MAAK,MAAM,eAAe,CAAC,OAAO,MAAM,GAAI,OAAO,OAAO,GAAG,SAAS,EAAE,CAAE,CACxE,cAAa,IAAI,MAAM,gBAAgB,KAAK,QAAQ,OAAO,MAAM,YAAY,CAAC,CAAC;CAGjF,MAAM,mBAAmB,MAAM,gBAAgB,SAAS;AACxD,MAAK,MAAM,eAAe,aACxB,KAAI,kBAAkB,kBAAkB,YAAY,CAClD,QAAO;AAGX,QAAO;;AAGT,MAAM,eAAe,UACnB,OACI,MAAM,OAAO,CACd,KAAK,SAAS,OAAO,KAAK,MAAM,CAAC,CAAC,CAClC,QAAQ,SAAS,OAAO,SAAS,KAAK,IAAI,OAAO,EAAE,IAAI,EAAE;AAE9D,MAAM,yBACJ,cACA,iBACA,sBAC6B;AAC7B,KAAI,gBACF,QAAO;AAET,KAAI,iBAAiB,MACnB,QAAO;AAET,KAAI,yBAAyB,IAAI,aAAkC,CACjE,QAAO;AAET,QAAO,kBAAkB,MAAM;;AAGjC,MAAM,qBAAqB,IAAY,YAA4D;CACjG,MAAM,EAAE,UAAU,UAAU,QAAQ,GAAG;CACvC,MAAM,SAAS,IAAI,gBAAgB,MAAM;AACzC,KAAI,CAAC,OAAO,IAAI,iBAAiB,CAC/B,QAAO;CAGT,MAAM,SAAS,OAAO,IAAI,SAAS;AACnC,KAAI,UAAU,CAAC,yBAAyB,IAAI,OAA4B,CACtE,OAAM,IAAI,MACR,sCAAsC,OAAO,2CAC9C;CAGH,MAAM,SAAS,YAAY,OAAO,IAAI,SAAS,CAAC;AAEhD,QAAO;EACL,UAAU;EACV,QAAS,UAAuC;EAChD,QAAQ,OAAO,SAAS,IAAI,SAAU,QAAQ,UAAU,CAAC,GAAG,eAAe;EAC5E;;AAGH,MAAM,qBAAqB,UAAuB,QAA2B,YAAoB;AAC/F,SAAQ,QAAR;EACE,KAAK,OACH,QAAO,SAAS,KAAK,EAAE,SAAS,CAAC;EACnC,KAAK,OACH,QAAO,SAAS,KAAK;GAAE,SAAS;GAAM;GAAS,CAAC;EAClD,KAAK,MACH,QAAO,SAAS,IAAI;GAAE,kBAAkB;GAAG;GAAS,CAAC;EACvD,KAAK,OACH,QAAO,SAAS,KAAK,EAAE,SAAS,CAAC;;;AAIvC,MAAM,qBAAqB,OACzB,OACA,eACA,QACA,YACiC;AACjC,KAAI,WAAW,KACb,QAAO,CACL;EACE,QAAQ,MAAM;EACd,QAAQ,MAAM;EACd,QAAQ,MAAM;EACd,OAAO,MAAM;EACd,CACF;AAGH,QAAO,QAAQ,IACb,cAAc,IAAI,OAAO,UAAU;EACjC,MAAM,eAAe,gBAAgB,MAAM,OAAO,MAAM,QAAQ,MAAM;AAYtE,SAAO;GACL,QAZc,MAAM,kBACpB,MAAM,MAAM,QAAQ,EAAE,UAAU,MAAM,CAAC,CAAC,OAAO;IAC7C,KAAK;IACL,QAAQ;IACR;IACA,oBAAoB;IACrB,CAAC,EACF,QACA,QACD,CAAC,UAAU;GAIV;GACA,QAAQ;GACR;GACD;GACD,CACH;;AAGH,MAAa,mBAAmB,UAAkB,OAAe,WAAmB;AAGlF,QAAO,GAFU,KAAK,SAAS,UAAU,KAAK,QAAQ,SAAS,CAAC,CAE7C,GADF,WAAW,OAAO,CAAC,OAAO,KAAK,UAAU,SAAS,CAAC,CAAC,OAAO,MAAM,CAAC,MAAM,GAAG,EAAE,CAC/D,GAAG,MAAM,IAAI,kBAAkB,OAAO;;AAGvE,MAAM,qBACJ,UACA,UACA,aACG;CACH,MAAM,aAAa,SAAS,KAAK,YAC/B,SAAS;EACP,UAAU,UAAU,gBAAgB,UAAU,QAAQ,OAAO,QAAQ,OAAO;EAC5E,MAAM,gBAAgB,UAAU,QAAQ,OAAO,QAAQ,OAAO;EAC9D,QAAQ,QAAQ;EAChB,MAAM;EACP,CAAC,CACH;CACD,MAAM,cAAc,SAAS,SAAS;AAEtC,QAAO;EACP,SACC,KACE,SAAS,UACR,eAAe,KAAK,UAAU,QAAQ,OAAO,CAAC,YAAY,QAAQ,OAAO,qCAAqC,WAAW,OAAO,WAAW,QAAQ,MAAM,KAC5J,CACA,KAAK,KAAK,CAAC;;;;YAIF,KAAK,UAAU,SAAS,aAAc,OAAO,CAAC;YAC9C,SAAS,aAAc,OAAO;qCACL,WAAW,aAAc;;WAEnD,SAAS,aAAc,MAAM;;;;AAKxC,MAAM,mBAAmB,UAA+B,aAAqB;CAC3E,MAAM,cAAc,SAAS,SAAS;CACtC,MAAM,UAAU,SAAS,KAAK,YAAY;EACxC,MAAM,SAAS,IAAI,gBAAgB;GACjC,QAAQ,QAAQ;GAChB,MAAM;GACN,OAAO,OAAO,QAAQ,MAAM;GAC7B,CAAC;AACF,SAAO;GACL,QAAQ,QAAQ;GAChB,QAAQ,QAAQ;GAChB,KAAK,GAAG,mBAAmB,GAAG,OAAO,UAAU;GAC/C,OAAO,QAAQ;GAChB;GACD;AAEF,QAAO,oBAAoB,KAAK,UAAU,QAAQ,CAAC;;;YAGzC,KAAK,UAAU,QAAQ,aAAc,OAAO,CAAC;YAC7C,QAAQ,aAAc,OAAO;SAChC,KAAK,UAAU,QAAQ,aAAc,IAAI,CAAC;;WAExC,QAAQ,aAAc,MAAM;;;;AAKvC,MAAM,wBAAwB,OAC5B,KACA,KACA,WACG;CACH,MAAM,aAAa,IAAI,IAAI,IAAI,OAAO,KAAK,mBAAmB;AAC9D,KAAI,WAAW,aAAa,mBAC1B,QAAO;CAGT,MAAM,WAAW,WAAW,aAAa,IAAI,OAAO;CACpD,MAAM,QAAQ,OAAO,WAAW,aAAa,IAAI,QAAQ,CAAC;CAC1D,MAAM,SAAS,WAAW,aAAa,IAAI,SAAS;AACpD,KAAI,CAAC,YAAY,CAAC,OAAO,SAAS,MAAM,IAAI,SAAS,GAAG;AACtD,MAAI,aAAa;AACjB,MAAI,IAAI,yBAAyB;AACjC,SAAO;;AAGT,KAAI,CAAE,MAAM,mBAAmB,UAAU,OAAO,EAAG;AACjD,MAAI,aAAa;AACjB,MAAI,IAAI,6BAA6B;AACrC,SAAO;;CAGT,MAAM,QAAQ,MAAM,eAAe,SAAS;AAC5C,KAAI,WAAW,OAAO;AACpB,MAAI,UAAU,iBAAiB,WAAW;AAC1C,MAAI,UAAU,gBAAgB,gBAAgB;AAC9C,MAAI,IAAI,MAAM,OAAO;AACrB,SAAO;;AAGT,KAAI,CAAC,UAAU,CAAC,yBAAyB,IAAI,OAA4B,EAAE;AACzE,MAAI,aAAa;AACjB,MAAI,IAAI,wBAAwB;AAChC,SAAO;;CAGT,MAAM,CAAC,WAAW,MAAM,mBACtB,OACA,CAAC,KAAK,IAAI,OAAO,MAAM,MAAM,CAAC,EAC9B,QACA,gBACD;AACD,KAAI,CAAC,SAAS;AACZ,MAAI,aAAa;AACjB,MAAI,IAAI,kCAAkC;AAC1C,SAAO;;AAGT,KAAI,UAAU,iBAAiB,WAAW;AAC1C,KAAI,UAAU,gBAAgB,cAAc,QAAQ,OAAO,CAAC;AAC5D,KAAI,IAAI,QAAQ,OAAO;AACvB,QAAO;;AAGT,MAAa,gBAAgB,UAA+B,EAAE,KAAa;CACzE,IAAI,SAAgC;AAEpC,QAAO;EACL,eAAe,gBAAgB;AAC7B,YAAS;;EAEX,gBAAgB,QAAQ;AACtB,UAAO,YAAY,IAAI,OAAO,KAAK,KAAK,SAAS;AAC/C,QAAI;AACF,SAAI,CAAC,QAAQ;AACX,2BAAK,IAAI,MAAM,6DAA6D,CAAC;AAC7E;;AAEF,SAAI,MAAM,sBAAsB,KAAK,KAAK,OAAO,CAC/C;aAEK,OAAO;AACd,UAAK,MAAe;AACpB;;AAEF,UAAM;KACN;;EAEJ,SAAS;EACT,MAAM,KAAK,IAAI;GACb,MAAM,WAAW,GAAG,WAAW,qBAAqB,GAChD,kBAAkB,GAAG,MAAM,GAA4B,EAAE,QAAQ,GACjE;AACJ,OAAI,CAAC,SACH,QAAO;GAGT,MAAM,QAAQ,MAAM,eAAe,SAAS,SAAS;GACrD,MAAM,SAAS,sBAAsB,MAAM,QAAQ,SAAS,QAAQ,QAAQ,WAAW,EAAE,CAAC;GAG1F,MAAM,WAAW,MAAM,mBACrB,OAFA,WAAW,OAAO,CAAC,MAAM,MAAM,GAAG,mBAAmB,MAAM,OAAO,SAAS,OAAO,EAIlF,QACA,QAAQ,WAAW,gBACpB;AAED,UAAO,QAAQ,YAAY,UACvB,kBAAkB,UAAU,SAAS,UAAU,KAAK,SAAS,KAAK,KAAK,CAAC,GACxE,gBAAgB,UAAU,SAAS,SAAS;;EAElD,MAAM;EACN,MAAM,UAAU,QAAQ,UAAU;GAChC,MAAM,YAAY,kBAAkB,QAAQ,QAAQ;AACpD,OAAI,CAAC,UACH,QAAO;GAGT,MAAM,WAAW,MAAM,KAAK,QAAQ,UAAU,UAAU,UAAU,EAAE,UAAU,MAAM,CAAC;AACrF,OAAI,CAAC,SACH,QAAO;GAGT,MAAM,SAAS,IAAI,gBAAgB,QAAQ,OAAO,CAAC,MAAM;AACzD,UAAO,GAAG,uBAAuB,SAAS,GAAG,GAAG,OAAO,UAAU;;EAEpE"}
|
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
|
+
}
|