@stacksjs/image 0.70.257 → 0.70.259

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/dist/app-icons.js CHANGED
@@ -1,13 +1 @@
1
- import process from "node:process";
2
- import { generateAppIcons, generateFavicons } from "ts-images";
3
- import { projectFile, requireProjectFile } from "./theme";
4
- export async function generateAppIconSet(images, root = process.cwd()) {
5
- const appIcons = images.appIcons;
6
- if (appIcons?.enabled !== !0 || !appIcons.source)
7
- return { icons: [], favicons: [] };
8
- const source = requireProjectFile(appIcons.source, root, "App icon source"), platforms = appIcons.platforms?.length ? appIcons.platforms : ["ios", "macos"], icons = await generateAppIcons(source, {
9
- outputDir: projectFile(appIcons.outputDir ?? "resources/app-icons", root),
10
- platform: platforms.length === 1 ? platforms[0] : "all"
11
- }), favicons = appIcons.favicon ? await generateFavicons(source, projectFile(appIcons.faviconDir ?? "public", root)) : [];
12
- return { icons, favicons };
13
- }
1
+ import process from"node:process";import{generateAppIcons,generateFavicons}from"ts-images";import{projectFile,requireProjectFile}from"./theme";export async function generateAppIconSet(images,root=process.cwd()){const appIcons=images.appIcons;if(appIcons?.enabled!==!0||!appIcons.source)return{icons:[],favicons:[]};const source=requireProjectFile(appIcons.source,root,"App icon source"),platforms=appIcons.platforms?.length?appIcons.platforms:["ios","macos"],icons=await generateAppIcons(source,{outputDir:projectFile(appIcons.outputDir??"resources/app-icons",root),platform:platforms.length===1?platforms[0]:"all"}),favicons=appIcons.favicon?await generateFavicons(source,projectFile(appIcons.faviconDir??"public",root)):[];return{icons,favicons}}
package/dist/app-store.js CHANGED
@@ -1,45 +1 @@
1
- import { mkdir } from "node:fs/promises";
2
- import process from "node:process";
3
- import { APP_STORE_MAX_SCREENSHOTS, generateAppStoreScreenshots } from "ts-images";
4
- import { loadFonts } from "./fonts";
5
- import { background, color, device, markPainter, projectFile, requireProjectFile, themed } from "./theme";
6
- export async function generateAppStoreScreenshotSet(images, root = process.cwd()) {
7
- if (images.appStore?.enabled !== !0 || !images.appStore.slides?.length)
8
- return {};
9
- const appStore = themed(images, images.appStore), declared = images.appStore.slides, displays = appStore.displays?.length ? appStore.displays : ["APP_IPHONE_67"], fonts = await loadFonts(images.fonts, root), outputDir = projectFile(appStore.outputDir ?? "resources/app-store/screenshots", root);
10
- await mkdir(outputDir, { recursive: !0 });
11
- const shared = {
12
- outputDir,
13
- titleFont: fonts.title,
14
- bodyFont: fonts.body,
15
- brand: appStore.brand,
16
- drawMark: await markPainter(appStore.mark, root),
17
- markPlate: appStore.markPlate === !1 ? void 0 : color(appStore.markPlate),
18
- background: background(appStore.background, root),
19
- color: color(appStore.color),
20
- mutedColor: color(appStore.mutedColor),
21
- device: device(appStore.device),
22
- layout: appStore.layout,
23
- format: appStore.format ?? "png",
24
- quality: appStore.quality
25
- }, results = {};
26
- for (const display of displays) {
27
- const slides = declared.filter((slide) => !slide.displays?.length || slide.displays.includes(display)).map((slide) => ({
28
- capture: requireProjectFile(slide.capture, root, `Capture for the "${slide.headline}" slide`),
29
- headline: slide.headline,
30
- subheadline: slide.subheadline,
31
- background: background(slide.background, root)
32
- }));
33
- if (!slides.length)
34
- continue;
35
- if (slides.length > APP_STORE_MAX_SCREENSHOTS)
36
- throw Error(`[image] ${display} has ${slides.length} slides; App Store Connect accepts at most ${APP_STORE_MAX_SCREENSHOTS}`);
37
- const rendered = await generateAppStoreScreenshots({
38
- ...shared,
39
- slides,
40
- displayTypes: [display]
41
- });
42
- results[display] = rendered[display] ?? [];
43
- }
44
- return results;
45
- }
1
+ import{mkdir}from"node:fs/promises";import process from"node:process";import{APP_STORE_MAX_SCREENSHOTS,generateAppStoreScreenshots}from"ts-images";import{loadFonts}from"./fonts";import{background,color,device,markPainter,projectFile,requireProjectFile,themed}from"./theme";export async function generateAppStoreScreenshotSet(images,root=process.cwd()){if(images.appStore?.enabled!==!0||!images.appStore.slides?.length)return{};const appStore=themed(images,images.appStore),declared=images.appStore.slides,displays=appStore.displays?.length?appStore.displays:["APP_IPHONE_67"],fonts=await loadFonts(images.fonts,root),outputDir=projectFile(appStore.outputDir??"resources/app-store/screenshots",root);await mkdir(outputDir,{recursive:!0});const shared={outputDir,titleFont:fonts.title,bodyFont:fonts.body,brand:appStore.brand,drawMark:await markPainter(appStore.mark,root),markPlate:appStore.markPlate===!1?void 0:color(appStore.markPlate),background:background(appStore.background,root),color:color(appStore.color),mutedColor:color(appStore.mutedColor),device:device(appStore.device),layout:appStore.layout,format:appStore.format??"png",quality:appStore.quality},results={};for(const display of displays){const slides=declared.filter((slide)=>!slide.displays?.length||slide.displays.includes(display)).map((slide)=>({capture:requireProjectFile(slide.capture,root,`Capture for the "${slide.headline}" slide`),headline:slide.headline,subheadline:slide.subheadline,background:background(slide.background,root)}));if(!slides.length)continue;if(slides.length>APP_STORE_MAX_SCREENSHOTS)throw Error(`[image] ${display} has ${slides.length} slides; App Store Connect accepts at most ${APP_STORE_MAX_SCREENSHOTS}`);const rendered=await generateAppStoreScreenshots({...shared,slides,displayTypes:[display]});results[display]=rendered[display]??[]}return results}
package/dist/fonts.js CHANGED
@@ -1,25 +1,3 @@
1
- import { existsSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
- import { isAbsolute, resolve } from "node:path";
4
- import process from "node:process";
5
- import { loadFont } from "ts-images";
6
- export function resolveFontPath(value, root = process.cwd()) {
7
- if (isAbsolute(value))
8
- return value;
9
- const local = resolve(root, value);
10
- if (existsSync(local))
11
- return local;
12
- try {
13
- return Bun.resolveSync(value, root);
14
- } catch {
15
- throw Error(`[image] Font not found: ${value}
1
+ import{existsSync}from"node:fs";import{readFile}from"node:fs/promises";import{isAbsolute,resolve}from"node:path";import process from"node:process";import{loadFont}from"ts-images";export function resolveFontPath(value,root=process.cwd()){if(isAbsolute(value))return value;const local=resolve(root,value);if(existsSync(local))return local;try{return Bun.resolveSync(value,root)}catch{throw Error(`[image] Font not found: ${value}
16
2
  Looked for ${local} and for a module resolvable from ${root}.
17
- Set \`images.fonts.title\` in config/images.ts to a TrueType (.ttf) file. OpenType/CFF (.otf) and WOFF2 are not TrueType outlines and cannot be read.`);
18
- }
19
- }
20
- export async function loadFonts(fonts, root = process.cwd()) {
21
- if (!fonts?.title)
22
- throw Error("[image] No font configured. Set `images.fonts.title` in config/images.ts to a TrueType (.ttf) file \u2014 " + "generated cards and screenshots draw real glyphs and cannot fall back to a system face.");
23
- const title = loadFont(new Uint8Array(await readFile(resolveFontPath(fonts.title, root)))), body = fonts.body ? loadFont(new Uint8Array(await readFile(resolveFontPath(fonts.body, root)))) : title;
24
- return { title, body };
25
- }
3
+ Set \`images.fonts.title\` in config/images.ts to a TrueType (.ttf) file. OpenType/CFF (.otf) and WOFF2 are not TrueType outlines and cannot be read.`)}}export async function loadFonts(fonts,root=process.cwd()){if(!fonts?.title)throw Error("[image] No font configured. Set `images.fonts.title` in config/images.ts to a TrueType (.ttf) file \u2014 "+"generated cards and screenshots draw real glyphs and cannot fall back to a system face.");const title=loadFont(new Uint8Array(await readFile(resolveFontPath(fonts.title,root)))),body=fonts.body?loadFont(new Uint8Array(await readFile(resolveFontPath(fonts.body,root)))):title;return{title,body}}
package/dist/generate.js CHANGED
@@ -1,19 +1 @@
1
- import process from "node:process";
2
- import { generateAppIconSet } from "./app-icons";
3
- import { generateAppStoreScreenshotSet } from "./app-store";
4
- import { generateSocialCardSet } from "./social";
5
- export async function generateImages(images, options = {}) {
6
- const root = options.root ?? process.cwd(), wanted = (target) => !options.only?.length || options.only.includes(target);
7
- return {
8
- social: wanted("social") ? await generateSocialCardSet(images, root) : [],
9
- appStore: wanted("app-store") ? await generateAppStoreScreenshotSet(images, root) : {},
10
- appIcons: wanted("app-icons") ? await generateAppIconSet(images, root) : { icons: [], favicons: [] }
11
- };
12
- }
13
- export function countGeneratedImages(result) {
14
- return {
15
- social: result.social.reduce((total, card) => total + Object.keys(card.files).length, 0),
16
- appStore: Object.values(result.appStore).reduce((total, paths) => total + (paths?.length ?? 0), 0),
17
- appIcons: result.appIcons.icons.reduce((total, set) => total + set.sizes.length, 0) + result.appIcons.favicons.length
18
- };
19
- }
1
+ import process from"node:process";import{generateAppIconSet}from"./app-icons";import{generateAppStoreScreenshotSet}from"./app-store";import{generateSocialCardSet}from"./social";export async function generateImages(images,options={}){const root=options.root??process.cwd(),wanted=(target)=>!options.only?.length||options.only.includes(target);return{social:wanted("social")?await generateSocialCardSet(images,root):[],appStore:wanted("app-store")?await generateAppStoreScreenshotSet(images,root):{},appIcons:wanted("app-icons")?await generateAppIconSet(images,root):{icons:[],favicons:[]}}}export function countGeneratedImages(result){return{social:result.social.reduce((total,card)=>total+Object.keys(card.files).length,0),appStore:Object.values(result.appStore).reduce((total,paths)=>total+(paths?.length??0),0),appIcons:result.appIcons.icons.reduce((total,set)=>total+set.sizes.length,0)+result.appIcons.favicons.length}}
package/dist/index.js CHANGED
@@ -1,172 +1,2 @@
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
-
6
- export * from "./app-icons";
7
- export * from "./app-store";
8
- export * from "./fonts";
9
- export * from "./generate";
10
- export * from "./social";
11
- export * from "./theme";
12
- const mime = { avif: "image/avif", webp: "image/webp", jpeg: "image/jpeg", png: "image/png" };
13
- function integer(name, value, min, max) {
14
- if (!Number.isInteger(value) || value < min || value > max)
15
- throw TypeError(`${name} must be between ${min} and ${max}`);
16
- }
17
- export function resolveImageSource(source, root = process.cwd()) {
18
- const allowed = resolve(root), candidate = isAbsolute(source) ? resolve(source) : resolve(allowed, source), relation = relative(allowed, candidate);
19
- if (source.includes("\x00") || relation === ".." || relation.startsWith("../") || isAbsolute(relation))
20
- throw Error("Image source must stay inside the configured root");
21
- return candidate;
22
- }
23
-
24
- export class ImageBuilder {
25
- source;
26
- targetWidths = [480, 768, 1280, 1920];
27
- targetFormats = ["avif", "webp", "jpeg"];
28
- targetFit = "inside";
29
- targetHeight;
30
- targetAspectRatio;
31
- targetPosition = "center";
32
- includeOriginal = !0;
33
- targetStorage;
34
- targetQuality = 82;
35
- options;
36
- constructor(source, options = {}) {
37
- this.source = source;
38
- 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 };
39
- }
40
- widths(widths) {
41
- if (!widths.length)
42
- throw TypeError("Image widths are required");
43
- widths.forEach((value) => integer("Image width", value, 1, 16384));
44
- this.targetWidths = [...new Set(widths)].sort((a, b) => a - b);
45
- return this;
46
- }
47
- formats(formats) {
48
- if (!formats.length)
49
- throw TypeError("Image formats are required");
50
- this.targetFormats = [...new Set(formats)];
51
- return this;
52
- }
53
- fit(fit) {
54
- this.targetFit = fit;
55
- return this;
56
- }
57
- height(value) {
58
- integer("Image height", value, 1, 16384);
59
- this.targetHeight = value;
60
- this.targetAspectRatio = void 0;
61
- return this;
62
- }
63
- aspectRatio(value) {
64
- if (!Number.isFinite(value) || value <= 0 || value > 100)
65
- throw TypeError("Image aspect ratio must be between 0 and 100");
66
- this.targetAspectRatio = value;
67
- this.targetHeight = void 0;
68
- return this;
69
- }
70
- position(value) {
71
- this.targetPosition = value;
72
- return this;
73
- }
74
- preset(value) {
75
- 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];
76
- this.targetWidths = [...preset.widths];
77
- this.targetFit = preset.fit;
78
- this.targetAspectRatio = "ratio" in preset ? preset.ratio : void 0;
79
- this.targetHeight = void 0;
80
- this.includeOriginal = preset.original;
81
- return this;
82
- }
83
- quality(value) {
84
- integer("Image quality", value, 1, 100);
85
- this.targetQuality = value;
86
- return this;
87
- }
88
- output(dir, publicPath = "/media/images") {
89
- this.options.outputDir = resolve(dir);
90
- this.options.publicPath = `/${publicPath.replace(/^\/+|\/+$/g, "")}`;
91
- return this;
92
- }
93
- storage(adapter, prefix = "media/images") {
94
- this.targetStorage = { adapter, prefix: prefix.replace(/^\/+|\/+$/g, "") };
95
- return this;
96
- }
97
- async generate() {
98
- integer("Image concurrency", this.options.concurrency, 1, 32);
99
- const sourcePath = resolveImageSource(this.source, this.options.root);
100
- if (this.options.authorize && !await this.options.authorize(sourcePath, this.options.authorizationContext))
101
- throw Error("Image delivery is not authorized");
102
- 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);
103
- if (this.includeOriginal && !widths.includes(decoded.width))
104
- widths.push(decoded.width);
105
- const tasks = [...new Set(widths)].sort((a, b) => a - b).flatMap((width) => this.targetFormats.map((format) => ({ width, format }))), variants = [];
106
- let cursor = 0;
107
- await Promise.all(Array.from({ length: Math.min(tasks.length, this.options.concurrency) }, async () => {
108
- while (cursor < tasks.length) {
109
- const task = tasks[cursor++];
110
- variants.push(await this.variant(decoded, hash, sourcePath, task.width, task.format));
111
- }
112
- }));
113
- variants.sort((a, b) => a.width - b.width || this.targetFormats.indexOf(a.format) - this.targetFormats.indexOf(b.format));
114
- return { source: { width: decoded.width, height: decoded.height, hash }, variants, placeholder: Buffer.from(imageToSplatHash(decoded)).toString("base64url") };
115
- }
116
- async variant(source, hash, sourcePath, width, format) {
117
- 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 });
118
- if (!this.options.upscale && (output.width > source.width || output.height > source.height))
119
- throw TypeError(`Image variant ${output.width}x${output.height} would upscale the source`);
120
- 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);
121
- if (this.targetStorage) {
122
- const key = this.targetStorage.prefix ? `${this.targetStorage.prefix}/${filename}` : filename;
123
- if (await this.targetStorage.adapter.fileExists(key)) {
124
- const existing = await this.targetStorage.adapter.stat(key);
125
- return { width: output.width, height: output.height, bytes: existing.size, format, mimeType: mime[format], path: key, url: await this.targetStorage.adapter.publicUrl(key), cacheKey };
126
- }
127
- const encoded = await encode(output, format, { quality: this.targetQuality, progressive: !0 }), written = await this.targetStorage.adapter.write(key, encoded);
128
- return { width: output.width, height: output.height, bytes: written.size, format, mimeType: mime[format], path: key, url: await this.targetStorage.adapter.publicUrl(key), cacheKey };
129
- }
130
- const existing = await stat(path).catch(() => null);
131
- if (existing)
132
- return { width: output.width, height: output.height, bytes: existing.size, format, mimeType: mime[format], path, url: `${this.options.publicPath}/${filename}`, cacheKey };
133
- const encoded = await encode(output, format, { quality: this.targetQuality, progressive: !0 });
134
- await mkdir(this.options.outputDir, { recursive: !0 });
135
- const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
136
- await writeFile(temporary, encoded);
137
- await rename(temporary, path);
138
- return { width: output.width, height: output.height, bytes: encoded.byteLength, format, mimeType: mime[format], path, url: `${this.options.publicPath}/${filename}`, cacheKey };
139
- }
140
- }
141
- export function image(source, options = {}) {
142
- return new ImageBuilder(source, options);
143
- }
144
- function accepted(accept, mimeType) {
145
- const [type, subtype] = mimeType.split("/");
146
- let best = 0;
147
- for (const item of accept.split(",")) {
148
- const [range = "*/*", ...params] = item.trim().toLowerCase().split(";").map((value) => value.trim()), [acceptedType, acceptedSubtype] = range.split("/");
149
- if (acceptedType !== "*" && acceptedType !== type || acceptedSubtype !== "*" && acceptedSubtype !== subtype)
150
- continue;
151
- const raw = params.find((param) => param.startsWith("q="));
152
- best = Math.max(best, raw ? Number.parseFloat(raw.slice(2)) || 0 : 1);
153
- }
154
- return best;
155
- }
156
- export function negotiateImageVariant(variants, accept = "*/*", width) {
157
- 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);
158
- 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;
159
- }
160
- export function imageResponseHeaders(variant) {
161
- 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) };
162
- }
163
- export function signImageTransform(path, expires, secret) {
164
- return createHmac("sha256", secret).update(`${path}
165
- ${expires}`).digest("base64url");
166
- }
167
- export function verifyImageTransform(path, expires, signature, secret, now = Date.now()) {
168
- if (!Number.isInteger(expires) || expires * 1000 <= now)
169
- return !1;
170
- const expected = Buffer.from(signImageTransform(path, expires, secret)), actual = Buffer.from(signature);
171
- return expected.length === actual.length && timingSafeEqual(expected, actual);
172
- }
1
+ import{createHash,createHmac,randomUUID,timingSafeEqual}from"node:crypto";import{mkdir,readFile,rename,stat,writeFile}from"node:fs/promises";import{basename,extname,isAbsolute,relative,resolve}from"node:path";import{decode,encode,imageToSplatHash,resize}from"ts-images";export*from"./app-icons";export*from"./app-store";export*from"./fonts";export*from"./generate";export*from"./social";export*from"./theme";const mime={avif:"image/avif",webp:"image/webp",jpeg:"image/jpeg",png:"image/png"};function integer(name,value,min,max){if(!Number.isInteger(value)||value<min||value>max)throw TypeError(`${name} must be between ${min} and ${max}`)}export function resolveImageSource(source,root=process.cwd()){const allowed=resolve(root),candidate=isAbsolute(source)?resolve(source):resolve(allowed,source),relation=relative(allowed,candidate);if(source.includes("\x00")||relation===".."||relation.startsWith("../")||isAbsolute(relation))throw Error("Image source must stay inside the configured root");return candidate}export class ImageBuilder{source;targetWidths=[480,768,1280,1920];targetFormats=["avif","webp","jpeg"];targetFit="inside";targetHeight;targetAspectRatio;targetPosition="center";includeOriginal=!0;targetStorage;targetQuality=82;options;constructor(source,options={}){this.source=source;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}}widths(widths){if(!widths.length)throw TypeError("Image widths are required");widths.forEach((value)=>integer("Image width",value,1,16384));this.targetWidths=[...new Set(widths)].sort((a,b)=>a-b);return this}formats(formats){if(!formats.length)throw TypeError("Image formats are required");this.targetFormats=[...new Set(formats)];return this}fit(fit){this.targetFit=fit;return this}height(value){integer("Image height",value,1,16384);this.targetHeight=value;this.targetAspectRatio=void 0;return this}aspectRatio(value){if(!Number.isFinite(value)||value<=0||value>100)throw TypeError("Image aspect ratio must be between 0 and 100");this.targetAspectRatio=value;this.targetHeight=void 0;return this}position(value){this.targetPosition=value;return this}preset(value){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];this.targetWidths=[...preset.widths];this.targetFit=preset.fit;this.targetAspectRatio="ratio"in preset?preset.ratio:void 0;this.targetHeight=void 0;this.includeOriginal=preset.original;return this}quality(value){integer("Image quality",value,1,100);this.targetQuality=value;return this}output(dir,publicPath="/media/images"){this.options.outputDir=resolve(dir);this.options.publicPath=`/${publicPath.replace(/^\/+|\/+$/g,"")}`;return this}storage(adapter,prefix="media/images"){this.targetStorage={adapter,prefix:prefix.replace(/^\/+|\/+$/g,"")};return this}async generate(){integer("Image concurrency",this.options.concurrency,1,32);const sourcePath=resolveImageSource(this.source,this.options.root);if(this.options.authorize&&!await this.options.authorize(sourcePath,this.options.authorizationContext))throw Error("Image delivery is not authorized");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);if(this.includeOriginal&&!widths.includes(decoded.width))widths.push(decoded.width);const tasks=[...new Set(widths)].sort((a,b)=>a-b).flatMap((width)=>this.targetFormats.map((format)=>({width,format}))),variants=[];let cursor=0;await Promise.all(Array.from({length:Math.min(tasks.length,this.options.concurrency)},async()=>{while(cursor<tasks.length){const task=tasks[cursor++];variants.push(await this.variant(decoded,hash,sourcePath,task.width,task.format))}}));variants.sort((a,b)=>a.width-b.width||this.targetFormats.indexOf(a.format)-this.targetFormats.indexOf(b.format));return{source:{width:decoded.width,height:decoded.height,hash},variants,placeholder:Buffer.from(imageToSplatHash(decoded)).toString("base64url")}}async variant(source,hash,sourcePath,width,format){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});if(!this.options.upscale&&(output.width>source.width||output.height>source.height))throw TypeError(`Image variant ${output.width}x${output.height} would upscale the source`);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);if(this.targetStorage){const key=this.targetStorage.prefix?`${this.targetStorage.prefix}/${filename}`:filename;if(await this.targetStorage.adapter.fileExists(key)){const existing=await this.targetStorage.adapter.stat(key);return{width:output.width,height:output.height,bytes:existing.size,format,mimeType:mime[format],path:key,url:await this.targetStorage.adapter.publicUrl(key),cacheKey}}const encoded=await encode(output,format,{quality:this.targetQuality,progressive:!0}),written=await this.targetStorage.adapter.write(key,encoded);return{width:output.width,height:output.height,bytes:written.size,format,mimeType:mime[format],path:key,url:await this.targetStorage.adapter.publicUrl(key),cacheKey}}const existing=await stat(path).catch(()=>null);if(existing)return{width:output.width,height:output.height,bytes:existing.size,format,mimeType:mime[format],path,url:`${this.options.publicPath}/${filename}`,cacheKey};const encoded=await encode(output,format,{quality:this.targetQuality,progressive:!0});await mkdir(this.options.outputDir,{recursive:!0});const temporary=`${path}.${process.pid}.${randomUUID()}.tmp`;await writeFile(temporary,encoded);await rename(temporary,path);return{width:output.width,height:output.height,bytes:encoded.byteLength,format,mimeType:mime[format],path,url:`${this.options.publicPath}/${filename}`,cacheKey}}}export function image(source,options={}){return new ImageBuilder(source,options)}function accepted(accept,mimeType){const[type,subtype]=mimeType.split("/");let best=0;for(const item of accept.split(",")){const[range="*/*",...params]=item.trim().toLowerCase().split(";").map((value)=>value.trim()),[acceptedType,acceptedSubtype]=range.split("/");if(acceptedType!=="*"&&acceptedType!==type||acceptedSubtype!=="*"&&acceptedSubtype!==subtype)continue;const raw=params.find((param)=>param.startsWith("q="));best=Math.max(best,raw?Number.parseFloat(raw.slice(2))||0:1)}return best}export function negotiateImageVariant(variants,accept="*/*",width){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);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}export function imageResponseHeaders(variant){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)}}export function signImageTransform(path,expires,secret){return createHmac("sha256",secret).update(`${path}
2
+ ${expires}`).digest("base64url")}export function verifyImageTransform(path,expires,signature,secret,now=Date.now()){if(!Number.isInteger(expires)||expires*1000<=now)return!1;const expected=Buffer.from(signImageTransform(path,expires,secret)),actual=Buffer.from(signature);return expected.length===actual.length&&timingSafeEqual(expected,actual)}
package/dist/social.js CHANGED
@@ -1,80 +1 @@
1
- import { mkdir } from "node:fs/promises";
2
- import process from "node:process";
3
- import { generateSocialCards } from "ts-images";
4
- import { loadFonts } from "./fonts";
5
- import { background, color, device, markPainter, projectFile, requireProjectFile, themed } from "./theme";
6
- const PRESET_SIZES = {
7
- og: { width: 1200, height: 630 },
8
- twitter: { width: 1200, height: 600 },
9
- square: { width: 1200, height: 1200 },
10
- portrait: { width: 1200, height: 1500 }
11
- };
12
- export function socialCardName(path) {
13
- const trimmed = path.replace(/^\/+|\/+$/g, "");
14
- return trimmed === "" ? "og" : trimmed.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
15
- }
16
- export async function generateSocialCardSet(images, root = process.cwd()) {
17
- if (images.social?.enabled !== !0)
18
- return [];
19
- const social = themed(images, images.social), fonts = await loadFonts(images.fonts, root), outputDir = projectFile(social.outputDir ?? "public/social", root), publicPath = `/${(social.publicPath ?? "/social").replace(/^\/+|\/+$/g, "")}`, presets = social.presets?.length ? social.presets : ["og"], format = social.format ?? "jpeg";
20
- await mkdir(outputDir, { recursive: !0 });
21
- const drawMark = await markPainter(social.mark, root), shared = {
22
- titleFont: fonts.title,
23
- bodyFont: fonts.body,
24
- brand: social.brand,
25
- drawMark,
26
- markPlate: social.markPlate === !1 ? void 0 : color(social.markPlate),
27
- surface: background(social.background, root),
28
- color: color(social.color),
29
- mutedColor: color(social.mutedColor),
30
- accent: color(social.accent),
31
- format,
32
- quality: social.quality,
33
- presets
34
- }, pages = social.pages?.length ? social.pages : [{ path: "/", title: social.brand ?? "Home" }], deviceOptions = device(social.device), results = [];
35
- for (const page of pages) {
36
- const name = socialCardName(page.path), shot = page.foreground ?? social.foreground, files = await generateSocialCards(outputDir, {
37
- ...shared,
38
- name,
39
- title: page.title,
40
- eyebrow: page.eyebrow,
41
- subtitle: page.subtitle,
42
- foreground: shot ? {
43
- image: requireProjectFile(shot, root, `Product shot for ${page.path}`),
44
- radius: deviceOptions?.radius,
45
- borderColor: deviceOptions?.borderColor,
46
- shadow: deviceOptions?.shadow,
47
- scale: deviceOptions?.scale
48
- } : void 0
49
- }), urls = Object.fromEntries(Object.entries(files).map(([preset, file]) => [
50
- preset,
51
- `${publicPath}/${file.slice(file.lastIndexOf("/") + 1)}`
52
- ]));
53
- results.push({
54
- path: page.path,
55
- name,
56
- files,
57
- urls,
58
- width: PRESET_SIZES[presets[0]].width,
59
- height: PRESET_SIZES[presets[0]].height,
60
- title: page.title
61
- });
62
- }
63
- return results;
64
- }
65
- export function socialMetaTags(card, siteUrl, format = "jpeg") {
66
- const primary = `${siteUrl.replace(/\/+$/, "")}${card.urls.og ?? Object.values(card.urls)[0]}`, mimeType = format === "jpeg" ? "image/jpeg" : `image/${format}`;
67
- return [
68
- `<meta property="og:image" content="${primary}">`,
69
- `<meta property="og:image:type" content="${mimeType}">`,
70
- `<meta property="og:image:width" content="${card.width}">`,
71
- `<meta property="og:image:height" content="${card.height}">`,
72
- `<meta property="og:image:alt" content="${escapeAttribute(card.title)}">`,
73
- '<meta name="twitter:card" content="summary_large_image">',
74
- `<meta name="twitter:image" content="${primary}">`,
75
- `<meta name="twitter:image:alt" content="${escapeAttribute(card.title)}">`
76
- ];
77
- }
78
- function escapeAttribute(value) {
79
- return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
80
- }
1
+ import{mkdir}from"node:fs/promises";import process from"node:process";import{generateSocialCards}from"ts-images";import{loadFonts}from"./fonts";import{background,color,device,markPainter,projectFile,requireProjectFile,themed}from"./theme";const PRESET_SIZES={og:{width:1200,height:630},twitter:{width:1200,height:600},square:{width:1200,height:1200},portrait:{width:1200,height:1500}};export function socialCardName(path){const trimmed=path.replace(/^\/+|\/+$/g,"");return trimmed===""?"og":trimmed.replace(/[^a-z0-9]+/gi,"-").toLowerCase()}export async function generateSocialCardSet(images,root=process.cwd()){if(images.social?.enabled!==!0)return[];const social=themed(images,images.social),fonts=await loadFonts(images.fonts,root),outputDir=projectFile(social.outputDir??"public/social",root),publicPath=`/${(social.publicPath??"/social").replace(/^\/+|\/+$/g,"")}`,presets=social.presets?.length?social.presets:["og"],format=social.format??"jpeg";await mkdir(outputDir,{recursive:!0});const drawMark=await markPainter(social.mark,root),shared={titleFont:fonts.title,bodyFont:fonts.body,brand:social.brand,drawMark,markPlate:social.markPlate===!1?void 0:color(social.markPlate),surface:background(social.background,root),color:color(social.color),mutedColor:color(social.mutedColor),accent:color(social.accent),format,quality:social.quality,presets},pages=social.pages?.length?social.pages:[{path:"/",title:social.brand??"Home"}],deviceOptions=device(social.device),results=[];for(const page of pages){const name=socialCardName(page.path),shot=page.foreground??social.foreground,files=await generateSocialCards(outputDir,{...shared,name,title:page.title,eyebrow:page.eyebrow,subtitle:page.subtitle,foreground:shot?{image:requireProjectFile(shot,root,`Product shot for ${page.path}`),radius:deviceOptions?.radius,borderColor:deviceOptions?.borderColor,shadow:deviceOptions?.shadow,scale:deviceOptions?.scale}:void 0}),urls=Object.fromEntries(Object.entries(files).map(([preset,file])=>[preset,`${publicPath}/${file.slice(file.lastIndexOf("/")+1)}`]));results.push({path:page.path,name,files,urls,width:PRESET_SIZES[presets[0]].width,height:PRESET_SIZES[presets[0]].height,title:page.title})}return results}export function socialMetaTags(card,siteUrl,format="jpeg"){const primary=`${siteUrl.replace(/\/+$/,"")}${card.urls.og??Object.values(card.urls)[0]}`,mimeType=format==="jpeg"?"image/jpeg":`image/${format}`;return[`<meta property="og:image" content="${primary}">`,`<meta property="og:image:type" content="${mimeType}">`,`<meta property="og:image:width" content="${card.width}">`,`<meta property="og:image:height" content="${card.height}">`,`<meta property="og:image:alt" content="${escapeAttribute(card.title)}">`,'<meta name="twitter:card" content="summary_large_image">',`<meta name="twitter:image" content="${primary}">`,`<meta name="twitter:image:alt" content="${escapeAttribute(card.title)}">`]}function escapeAttribute(value){return value.replace(/&/g,"&amp;").replace(/"/g,"&quot;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}
package/dist/theme.js CHANGED
@@ -1,60 +1,2 @@
1
- import { existsSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
- import { isAbsolute, resolve } from "node:path";
4
- import process from "node:process";
5
- import { decode, drawImage, parseColor } from "ts-images";
6
- export function projectFile(path, root = process.cwd()) {
7
- return isAbsolute(path) ? path : resolve(root, path);
8
- }
9
- export function requireProjectFile(path, root, describe) {
10
- const resolved = projectFile(path, root);
11
- if (!existsSync(resolved))
12
- throw Error(`[image] ${describe} not found: ${path}
13
- Looked in ${resolved}. Capture it before generating \u2014 \`buddy generate:images\` frames existing captures, it does not take them.`);
14
- return resolved;
15
- }
16
- export function color(value) {
17
- return value === void 0 ? void 0 : parseColor(value);
18
- }
19
- export function background(value, root = process.cwd()) {
20
- if (!value)
21
- return;
22
- return {
23
- color: color(value.color),
24
- gradient: value.gradient && {
25
- angle: value.gradient.angle,
26
- stops: value.gradient.stops.map((stop) => ({ offset: stop.offset, color: parseColor(stop.color) }))
27
- },
28
- glows: value.glows?.map((glow) => ({ ...glow, color: parseColor(glow.color) })),
29
- image: value.image ? projectFile(value.image, root) : void 0
30
- };
31
- }
32
- export function device(value) {
33
- if (!value)
34
- return;
35
- return {
36
- radius: value.radius,
37
- scale: value.scale,
38
- borderColor: color(value.borderColor),
39
- shadow: value.shadow === !1 ? void 0 : { ...value.shadow ?? {}, color: color(value.shadow?.color) }
40
- };
41
- }
42
- export async function markPainter(path, root = process.cwd()) {
43
- if (!path)
44
- return;
45
- const mark = await decode(new Uint8Array(await readFile(projectFile(path, root))));
46
- return (canvas, box) => {
47
- drawImage(canvas, mark, { x: box.x, y: box.y, width: box.size, height: box.size, fit: "contain" });
48
- };
49
- }
50
- export function themed(images, section) {
51
- return {
52
- ...section ?? {},
53
- background: section?.background ?? images.background,
54
- color: section?.color ?? images.color,
55
- mutedColor: section?.mutedColor ?? images.mutedColor,
56
- device: section?.device ?? images.device,
57
- brand: section?.brand ?? images.brand,
58
- mark: section?.mark ?? images.mark
59
- };
60
- }
1
+ import{existsSync}from"node:fs";import{readFile}from"node:fs/promises";import{isAbsolute,resolve}from"node:path";import process from"node:process";import{decode,drawImage,parseColor}from"ts-images";export function projectFile(path,root=process.cwd()){return isAbsolute(path)?path:resolve(root,path)}export function requireProjectFile(path,root,describe){const resolved=projectFile(path,root);if(!existsSync(resolved))throw Error(`[image] ${describe} not found: ${path}
2
+ Looked in ${resolved}. Capture it before generating \u2014 \`buddy generate:images\` frames existing captures, it does not take them.`);return resolved}export function color(value){return value===void 0?void 0:parseColor(value)}export function background(value,root=process.cwd()){if(!value)return;return{color:color(value.color),gradient:value.gradient&&{angle:value.gradient.angle,stops:value.gradient.stops.map((stop)=>({offset:stop.offset,color:parseColor(stop.color)}))},glows:value.glows?.map((glow)=>({...glow,color:parseColor(glow.color)})),image:value.image?projectFile(value.image,root):void 0}}export function device(value){if(!value)return;return{radius:value.radius,scale:value.scale,borderColor:color(value.borderColor),shadow:value.shadow===!1?void 0:{...value.shadow??{},color:color(value.shadow?.color)}}}export async function markPainter(path,root=process.cwd()){if(!path)return;const mark=await decode(new Uint8Array(await readFile(projectFile(path,root))));return(canvas,box)=>{drawImage(canvas,mark,{x:box.x,y:box.y,width:box.size,height:box.size,fit:"contain"})}}export function themed(images,section){return{...section??{},background:section?.background??images.background,color:section?.color??images.color,mutedColor:section?.mutedColor??images.mutedColor,device:section?.device??images.device,brand:section?.brand??images.brand,mark:section?.mark??images.mark}}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/image",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.257",
5
+ "version": "0.70.259",
6
6
  "description": "Native responsive image delivery for Stacks.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  "prepublishOnly": "bun run build"
32
32
  },
33
33
  "dependencies": {
34
- "@stacksjs/types": "0.70.257",
34
+ "@stacksjs/types": "0.70.259",
35
35
  "ts-images": "^0.2.7"
36
36
  },
37
37
  "devDependencies": {