@stacksjs/image 0.70.163

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2023 Open Web Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @stacksjs/image
2
+
3
+ Native responsive image generation for Stacks, powered by `ts-images`.
4
+
5
+ ## Generate responsive images
6
+
7
+ ```ts
8
+ import { image } from '@stacksjs/image'
9
+
10
+ const manifest = await image('uploads/hero.jpg', {
11
+ root: process.cwd(),
12
+ outputDir: 'public/media/images',
13
+ publicPath: '/media/images',
14
+ })
15
+ .preset('hero')
16
+ .formats(['avif', 'webp', 'jpeg'])
17
+ .quality(82)
18
+ .generate()
19
+ ```
20
+
21
+ Presets cover `avatar`, `content`, `hero`, and `thumbnail`. Custom transforms can use `.widths()`, `.height()`, `.aspectRatio()`, `.fit()`, and `.position()`. Source-sized output is included where appropriate, and generation never upscales unless `upscale: true` is explicit.
22
+
23
+ ## S3-compatible storage and CloudFront
24
+
25
+ Pass any structural storage adapter. The adapter can wrap Stacks storage, S3, R2, B2, or another S3-compatible provider.
26
+
27
+ ```ts
28
+ const manifest = await image('uploads/avatar.png', {
29
+ authorize: (_path, request) => request.user.can('view-media'),
30
+ authorizationContext: request,
31
+ })
32
+ .preset('avatar')
33
+ .storage({
34
+ fileExists: key => disk.exists(key),
35
+ write: async (key, contents) => {
36
+ await disk.put(key, contents)
37
+ return { size: contents.byteLength }
38
+ },
39
+ stat: async key => ({ size: (await disk.stat(key)).size }),
40
+ publicUrl: key => disk.url(key),
41
+ })
42
+ .generate()
43
+ ```
44
+
45
+ The returned filenames are content-addressed and safe for an immutable CloudFront behavior. Use a short-lived signed transform for private originals:
46
+
47
+ ```ts
48
+ import { signImageTransform, verifyImageTransform } from '@stacksjs/image'
49
+
50
+ const expires = Math.floor(Date.now() / 1000) + 300
51
+ const signature = signImageTransform('/private/hero.jpg?w=1280&format=avif', expires, secret)
52
+
53
+ if (!verifyImageTransform(requestPath, expires, signature, secret))
54
+ throw new Response('Forbidden', { status: 403 })
55
+ ```
56
+
57
+ ## STX component and art direction
58
+
59
+ ```stx
60
+ <Image
61
+ src="{{ fallback.url }}"
62
+ alt="A product on a workbench"
63
+ width="{{ fallback.width }}"
64
+ height="{{ fallback.height }}"
65
+ :sources="[
66
+ { media: '(min-width: 960px)', type: 'image/avif', srcset: desktopAvif },
67
+ { media: '(max-width: 959px)', type: 'image/webp', srcset: mobileWebp },
68
+ ]"
69
+ sizes="(max-width: 959px) 100vw, 50vw"
70
+ priority
71
+ />
72
+ ```
73
+
74
+ Use `decorative` instead of empty alt text for decorative images. The component renders responsive, layout-stable HTML without requiring client JavaScript.
@@ -0,0 +1,28 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import type { ResizeFit } from 'ts-images';
3
+ export declare function resolveImageSource(source: string, root?: string): string;
4
+ export declare function image(source: string, options?: ImageOptions): ImageBuilder;
5
+ export declare function negotiateImageVariant(variants: readonly ImageVariant[], accept?: string, width?: number): ImageVariant | undefined;
6
+ export declare function imageResponseHeaders(variant: ImageVariant): Record<string, string>;
7
+ export declare function signImageTransform(path: string, expires: number, secret: string): string;
8
+ export declare function verifyImageTransform(path: string, expires: number, signature: string, secret: string, now?: number): boolean;
9
+ export declare interface ImageVariant { width: number, height: number, bytes: number, format: ImageFormat, mimeType: string, path: string, url: string, cacheKey: string }
10
+ export declare interface ImageManifest { source: { width: number, height: number, hash: string }, variants: ImageVariant[], placeholder: string }
11
+ export declare interface ImageStorageAdapter { fileExists: (_path: string) => Promise<boolean>, write: (_path: string, _contents: Uint8Array) => Promise<{ size: number }>, stat: (_path: string) => Promise<{ size: number }>, publicUrl: (_path: string) => Promise<string> }
12
+ export declare interface ImageOptions { root?: string, outputDir?: string, publicPath?: string, concurrency?: number, upscale?: boolean, authorize?: (_source: string, _context?: unknown) => boolean | Promise<boolean>, authorizationContext?: unknown }
13
+ export type ImageFormat = 'avif' | 'webp' | 'jpeg' | 'png';
14
+ export type ImagePreset = 'avatar' | 'content' | 'hero' | 'thumbnail';
15
+ export declare class ImageBuilder {
16
+ constructor(source: string, options?: ImageOptions);
17
+ widths(widths: number[]): this;
18
+ formats(formats: ImageFormat[]): this;
19
+ fit(fit: ResizeFit): this;
20
+ height(value: number): this;
21
+ aspectRatio(value: number): this;
22
+ position(value: typeof this.targetPosition): this;
23
+ preset(value: ImagePreset): this;
24
+ quality(value: number): this;
25
+ output(dir: string, publicPath?: string): this;
26
+ storage(adapter: ImageStorageAdapter, prefix?: string): this;
27
+ generate(): Promise<ImageManifest>;
28
+ }
package/dist/index.js ADDED
@@ -0,0 +1,165 @@
1
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { basename, extname, isAbsolute, relative, resolve } from "node:path";
4
+ import { decode, encode, imageToSplatHash, resize } from "ts-images";
5
+ const mime = { avif: "image/avif", webp: "image/webp", jpeg: "image/jpeg", png: "image/png" };
6
+ function integer(name, value, min, max) {
7
+ if (!Number.isInteger(value) || value < min || value > max)
8
+ throw TypeError(`${name} must be between ${min} and ${max}`);
9
+ }
10
+ export function resolveImageSource(source, root = process.cwd()) {
11
+ const allowed = resolve(root), candidate = isAbsolute(source) ? resolve(source) : resolve(allowed, source), relation = relative(allowed, candidate);
12
+ if (source.includes("\x00") || relation === ".." || relation.startsWith("../") || isAbsolute(relation))
13
+ throw Error("Image source must stay inside the configured root");
14
+ return candidate;
15
+ }
16
+
17
+ export class ImageBuilder {
18
+ source;
19
+ targetWidths = [480, 768, 1280, 1920];
20
+ targetFormats = ["avif", "webp", "jpeg"];
21
+ targetFit = "inside";
22
+ targetHeight;
23
+ targetAspectRatio;
24
+ targetPosition = "center";
25
+ includeOriginal = !0;
26
+ targetStorage;
27
+ targetQuality = 82;
28
+ options;
29
+ constructor(source, options = {}) {
30
+ this.source = source;
31
+ this.options = { ...options, root: options.root ?? process.cwd(), outputDir: options.outputDir ?? resolve("public/media/images"), publicPath: options.publicPath ?? "/media/images", concurrency: options.concurrency ?? 4, upscale: options.upscale ?? !1 };
32
+ }
33
+ widths(widths) {
34
+ if (!widths.length)
35
+ throw TypeError("Image widths are required");
36
+ widths.forEach((value) => integer("Image width", value, 1, 16384));
37
+ this.targetWidths = [...new Set(widths)].sort((a, b) => a - b);
38
+ return this;
39
+ }
40
+ formats(formats) {
41
+ if (!formats.length)
42
+ throw TypeError("Image formats are required");
43
+ this.targetFormats = [...new Set(formats)];
44
+ return this;
45
+ }
46
+ fit(fit) {
47
+ this.targetFit = fit;
48
+ return this;
49
+ }
50
+ height(value) {
51
+ integer("Image height", value, 1, 16384);
52
+ this.targetHeight = value;
53
+ this.targetAspectRatio = void 0;
54
+ return this;
55
+ }
56
+ aspectRatio(value) {
57
+ if (!Number.isFinite(value) || value <= 0 || value > 100)
58
+ throw TypeError("Image aspect ratio must be between 0 and 100");
59
+ this.targetAspectRatio = value;
60
+ this.targetHeight = void 0;
61
+ return this;
62
+ }
63
+ position(value) {
64
+ this.targetPosition = value;
65
+ return this;
66
+ }
67
+ preset(value) {
68
+ const preset = { avatar: { widths: [64, 128, 256, 512], ratio: 1, fit: "cover", original: !1 }, content: { widths: [320, 640, 960, 1280, 1920], fit: "inside", original: !0 }, hero: { widths: [640, 1280, 1920, 2560], ratio: 1.7777777777777777, fit: "cover", original: !0 }, thumbnail: { widths: [160, 320, 640], ratio: 1.7777777777777777, fit: "cover", original: !1 } }[value];
69
+ this.targetWidths = [...preset.widths];
70
+ this.targetFit = preset.fit;
71
+ this.targetAspectRatio = "ratio" in preset ? preset.ratio : void 0;
72
+ this.targetHeight = void 0;
73
+ this.includeOriginal = preset.original;
74
+ return this;
75
+ }
76
+ quality(value) {
77
+ integer("Image quality", value, 1, 100);
78
+ this.targetQuality = value;
79
+ return this;
80
+ }
81
+ output(dir, publicPath = "/media/images") {
82
+ this.options.outputDir = resolve(dir);
83
+ this.options.publicPath = `/${publicPath.replace(/^\/+|\/+$/g, "")}`;
84
+ return this;
85
+ }
86
+ storage(adapter, prefix = "media/images") {
87
+ this.targetStorage = { adapter, prefix: prefix.replace(/^\/+|\/+$/g, "") };
88
+ return this;
89
+ }
90
+ async generate() {
91
+ integer("Image concurrency", this.options.concurrency, 1, 32);
92
+ const sourcePath = resolveImageSource(this.source, this.options.root);
93
+ if (this.options.authorize && !await this.options.authorize(sourcePath, this.options.authorizationContext))
94
+ throw Error("Image delivery is not authorized");
95
+ const bytes = new Uint8Array(await readFile(sourcePath)), hash = createHash("sha256").update(bytes).digest("hex"), decoded = await decode(bytes), widths = this.targetWidths.filter((width) => this.options.upscale || width <= decoded.width);
96
+ if (this.includeOriginal && !widths.includes(decoded.width))
97
+ widths.push(decoded.width);
98
+ const tasks = [...new Set(widths)].sort((a, b) => a - b).flatMap((width) => this.targetFormats.map((format) => ({ width, format }))), variants = [];
99
+ let cursor = 0;
100
+ await Promise.all(Array.from({ length: Math.min(tasks.length, this.options.concurrency) }, async () => {
101
+ while (cursor < tasks.length) {
102
+ const task = tasks[cursor++];
103
+ variants.push(await this.variant(decoded, hash, sourcePath, task.width, task.format));
104
+ }
105
+ }));
106
+ variants.sort((a, b) => a.width - b.width || this.targetFormats.indexOf(a.format) - this.targetFormats.indexOf(b.format));
107
+ return { source: { width: decoded.width, height: decoded.height, hash }, variants, placeholder: Buffer.from(imageToSplatHash(decoded)).toString("base64url") };
108
+ }
109
+ async variant(source, hash, sourcePath, width, format) {
110
+ const height = this.targetHeight ?? (this.targetAspectRatio ? Math.max(1, Math.round(width / this.targetAspectRatio)) : void 0), output = width === source.width && height === void 0 ? source : resize(source, { width, height, fit: this.targetFit, position: this.targetPosition });
111
+ if (!this.options.upscale && (output.width > source.width || output.height > source.height))
112
+ throw TypeError(`Image variant ${output.width}x${output.height} would upscale the source`);
113
+ const cacheKey = createHash("sha256").update(`${hash}:${width}:${height ?? "auto"}:${format}:${this.targetFit}:${this.targetPosition}:${this.targetQuality}`).digest("hex"), filename = `${basename(sourcePath, extname(sourcePath)).replace(/[^a-zA-Z0-9_-]/g, "-") || "image"}-${output.width}x${output.height}-${cacheKey.slice(0, 16)}.${format === "jpeg" ? "jpg" : format}`, path = resolve(this.options.outputDir, filename);
114
+ if (this.targetStorage) {
115
+ const key = this.targetStorage.prefix ? `${this.targetStorage.prefix}/${filename}` : filename;
116
+ if (await this.targetStorage.adapter.fileExists(key)) {
117
+ const existing = await this.targetStorage.adapter.stat(key);
118
+ return { width: output.width, height: output.height, bytes: existing.size, format, mimeType: mime[format], path: key, url: await this.targetStorage.adapter.publicUrl(key), cacheKey };
119
+ }
120
+ const encoded = await encode(output, format, { quality: this.targetQuality, progressive: !0 }), written = await this.targetStorage.adapter.write(key, encoded);
121
+ return { width: output.width, height: output.height, bytes: written.size, format, mimeType: mime[format], path: key, url: await this.targetStorage.adapter.publicUrl(key), cacheKey };
122
+ }
123
+ const existing = await stat(path).catch(() => null);
124
+ if (existing)
125
+ return { width: output.width, height: output.height, bytes: existing.size, format, mimeType: mime[format], path, url: `${this.options.publicPath}/${filename}`, cacheKey };
126
+ const encoded = await encode(output, format, { quality: this.targetQuality, progressive: !0 });
127
+ await mkdir(this.options.outputDir, { recursive: !0 });
128
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
129
+ await writeFile(temporary, encoded);
130
+ await rename(temporary, path);
131
+ return { width: output.width, height: output.height, bytes: encoded.byteLength, format, mimeType: mime[format], path, url: `${this.options.publicPath}/${filename}`, cacheKey };
132
+ }
133
+ }
134
+ export function image(source, options = {}) {
135
+ return new ImageBuilder(source, options);
136
+ }
137
+ function accepted(accept, mimeType) {
138
+ const [type, subtype] = mimeType.split("/");
139
+ let best = 0;
140
+ for (const item of accept.split(",")) {
141
+ const [range = "*/*", ...params] = item.trim().toLowerCase().split(";").map((value) => value.trim()), [acceptedType, acceptedSubtype] = range.split("/");
142
+ if (acceptedType !== "*" && acceptedType !== type || acceptedSubtype !== "*" && acceptedSubtype !== subtype)
143
+ continue;
144
+ const raw = params.find((param) => param.startsWith("q="));
145
+ best = Math.max(best, raw ? Number.parseFloat(raw.slice(2)) || 0 : 1);
146
+ }
147
+ return best;
148
+ }
149
+ export function negotiateImageVariant(variants, accept = "*/*", width) {
150
+ const widths = [...new Set(variants.map((item) => item.width))].sort((a, b) => a - b), target = width === void 0 ? widths.at(-1) : widths.find((value) => value >= width) ?? widths.at(-1);
151
+ return variants.filter((item) => item.width === target).map((variant, index) => ({ variant, index, q: accepted(accept || "*/*", variant.mimeType) })).filter((item) => item.q > 0).sort((a, b) => b.q - a.q || a.index - b.index)[0]?.variant;
152
+ }
153
+ export function imageResponseHeaders(variant) {
154
+ return { "Content-Type": variant.mimeType, "Content-Length": String(variant.bytes), "Cache-Control": "public, max-age=31536000, immutable", ETag: `"${variant.cacheKey}"`, Vary: "Accept", "X-Image-Width": String(variant.width), "X-Image-Height": String(variant.height) };
155
+ }
156
+ export function signImageTransform(path, expires, secret) {
157
+ return createHmac("sha256", secret).update(`${path}
158
+ ${expires}`).digest("base64url");
159
+ }
160
+ export function verifyImageTransform(path, expires, signature, secret, now = Date.now()) {
161
+ if (!Number.isInteger(expires) || expires * 1000 <= now)
162
+ return !1;
163
+ const expected = Buffer.from(signImageTransform(path, expires, secret)), actual = Buffer.from(signature);
164
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
165
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@stacksjs/image",
3
+ "type": "module",
4
+ "sideEffects": false,
5
+ "version": "0.70.163",
6
+ "description": "Native responsive image delivery for Stacks.",
7
+ "author": "Chris Breuer",
8
+ "license": "MIT",
9
+ "funding": "https://github.com/sponsors/chrisbbreuer",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/stacksjs/stacks.git",
13
+ "directory": "./storage/framework/core/image"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "development": "./src/index.ts",
19
+ "bun": "./dist/index.js",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "module": "dist/index.js",
24
+ "types": "dist/index.d.ts",
25
+ "files": [
26
+ "README.md",
27
+ "dist"
28
+ ],
29
+ "scripts": {
30
+ "build": "bun build.ts",
31
+ "typecheck": "bun tsc --noEmit",
32
+ "prepublishOnly": "bun run build"
33
+ },
34
+ "dependencies": {
35
+ "ts-images": "^0.2.1"
36
+ },
37
+ "devDependencies": {
38
+ "better-dx": "^0.2.17"
39
+ }
40
+ }