@stacksjs/image 0.70.232 → 0.70.234

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ import type { AppIconResult, FaviconResult } from 'ts-images';
2
+ import type { ImagesConfig } from '@stacksjs/types';
3
+ export declare function generateAppIconSet(images: ImagesConfig, root?: string): Promise<AppIconSetResult>;
4
+ /**
5
+ * Generate the platform icon sets from one square source.
6
+ *
7
+ * The failure mode this removes is an icon set that is complete on the day it
8
+ * is made and incomplete forever after: Apple adds a size, the favicon set
9
+ * misses the one format a browser wants, and nobody notices because each file
10
+ * was placed by hand. From one source, all of them regenerate together.
11
+ */
12
+ export declare interface AppIconSetResult {
13
+ icons: AppIconResult[]
14
+ favicons: FaviconResult[]
15
+ }
@@ -0,0 +1,13 @@
1
+ import process from "node:process";
2
+ import { generateAppIcons, generateFavicons } from "ts-images";
3
+ import { projectFile } 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 = projectFile(appIcons.source, root), 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
+ }
@@ -0,0 +1,14 @@
1
+ import type { AppStoreDisplay, ImagesConfig } from '@stacksjs/types';
2
+ /**
3
+ * Generate the App Store screenshot set a project declares.
4
+ *
5
+ * App Store Connect takes ten screenshots per device class and most listings
6
+ * ship one, because keeping ten in step with the product — across iPhone, iPad
7
+ * and Mac, every release — is not something anyone does by hand twice. Declared
8
+ * as slides, they regenerate from fresh captures whenever the app changes.
9
+ *
10
+ * Returns the paths keyed by display type, which is the shape
11
+ * `extension.safariAppStore.screenshots` takes, so the result can be handed
12
+ * straight to the upload step.
13
+ */
14
+ export declare function generateAppStoreScreenshotSet(images: ImagesConfig, root?: string): Promise<Partial<Record<AppStoreDisplay, string[]>>>;
@@ -0,0 +1,45 @@
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, 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: projectFile(slide.capture, root),
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
+ }
@@ -0,0 +1,21 @@
1
+ import type { Font } from 'ts-images';
2
+ import type { ImageFontConfig } from '@stacksjs/types';
3
+ export declare function resolveFontPath(value: string, root?: string): string;
4
+ export declare function loadFonts(fonts: ImageFontConfig | undefined, root?: string): Promise<ResolvedFonts>;
5
+ /**
6
+ * Resolve the faces the generators draw with.
7
+ *
8
+ * The renderer reads TrueType outlines directly — no browser, no system font
9
+ * stack — so the face has to be a file the project actually ships or depends
10
+ * on. Reaching for whatever the machine happens to have installed would make
11
+ * a card render differently in CI than on a laptop, which is worse than not
12
+ * rendering at all.
13
+ *
14
+ * A configured value may be a project-relative path or a module specifier, so
15
+ * a font that arrives as a dependency (`@expo-google-fonts/inter/Inter_700Bold
16
+ * .ttf`) works without vendoring the binary into the repository.
17
+ */
18
+ export declare interface ResolvedFonts {
19
+ title: Font
20
+ body: Font
21
+ }
package/dist/fonts.js ADDED
@@ -0,0 +1,25 @@
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}
16
+ 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
+ }
@@ -0,0 +1,25 @@
1
+ import type { AppIconSetResult } from './app-icons';
2
+ import type { AppStoreDisplay, ImagesConfig } from '@stacksjs/types';
3
+ import type { SocialCardResult } from './social';
4
+ export declare function generateImages(images: ImagesConfig, options?: GenerateImagesOptions): Promise<GenerateImagesResult>;
5
+ /** Count what a run produced, for a one-line summary on the CLI. */
6
+ export declare function countGeneratedImages(result: GenerateImagesResult): { social: number, appStore: number, appIcons: number };
7
+ export declare interface GenerateImagesResult {
8
+ social: SocialCardResult[]
9
+ appStore: Partial<Record<AppStoreDisplay, string[]>>
10
+ appIcons: AppIconSetResult
11
+ }
12
+ export declare interface GenerateImagesOptions {
13
+ only?: ImageTarget[]
14
+ root?: string
15
+ }
16
+ /**
17
+ * Run every image generator a project has declared.
18
+ *
19
+ * The three are independent — a site with cards has no App Store listing, an
20
+ * app with a listing may not have a marketing site — so each is skipped
21
+ * silently when it is not configured. What they share is the reason for
22
+ * existing at all: generated imagery goes stale without anyone noticing,
23
+ * because nothing fails when it does.
24
+ */
25
+ export type ImageTarget = 'social' | 'app-store' | 'app-icons';
@@ -0,0 +1,19 @@
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
+ }
package/dist/index.d.ts CHANGED
@@ -26,3 +26,13 @@ export declare class ImageBuilder {
26
26
  storage(adapter: ImageStorageAdapter, prefix?: string): this;
27
27
  generate(): Promise<ImageManifest>;
28
28
  }
29
+ // Generated imagery: social cards, App Store screenshots, app icons. Declared
30
+ // in `config/images.ts` and produced by `buddy generate:images`. Kept in this
31
+ // package rather than a build script because the same wrappers back the CLI,
32
+ // the build pipeline, and anything a project wants to call directly.
33
+ export * from './app-icons';
34
+ export * from './app-store';
35
+ export * from './fonts';
36
+ export * from './generate';
37
+ export * from './social';
38
+ export * from './theme';
package/dist/index.js CHANGED
@@ -2,6 +2,13 @@ import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto
2
2
  import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, extname, isAbsolute, relative, resolve } from "node:path";
4
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";
5
12
  const mime = { avif: "image/avif", webp: "image/webp", jpeg: "image/jpeg", png: "image/png" };
6
13
  function integer(name, value, min, max) {
7
14
  if (!Number.isInteger(value) || value < min || value > max)
@@ -0,0 +1,38 @@
1
+ import type { ImagesConfig } from '@stacksjs/types';
2
+ /**
3
+ * Name a card after its route.
4
+ *
5
+ * `/` is the site-wide card and keeps the bare `og` name so its URL never
6
+ * moves; everything else is its path with the separators flattened, which
7
+ * makes the file recognisable in a directory listing and stable across
8
+ * regenerations.
9
+ */
10
+ export declare function socialCardName(path: string): string;
11
+ export declare function generateSocialCardSet(images: ImagesConfig, root?: string): Promise<SocialCardResult[]>;
12
+ /**
13
+ * The meta tags a page needs so a scraper renders the card at full size.
14
+ *
15
+ * Emitting the image alone is not enough: X falls back to a small square
16
+ * thumbnail unless `twitter:card` says otherwise, and a scraper that cannot
17
+ * fetch the image has nothing to reserve layout with unless the dimensions are
18
+ * declared alongside it.
19
+ */
20
+ export declare function socialMetaTags(card: SocialCardResult, siteUrl: string, format?: 'jpeg' | 'png' | 'webp' | 'avif'): string[];
21
+ /**
22
+ * Generate the link-preview cards a site declares.
23
+ *
24
+ * The failure this addresses is mundane and universal: a page ships with its
25
+ * favicon as `og:image`, so every share of it renders as a small square icon
26
+ * next to the URL, and the preview — the only part of the page most people
27
+ * ever see — says nothing. Declaring cards in `config/images.ts` and building
28
+ * them with the site keeps them right as the copy changes.
29
+ */
30
+ export declare interface SocialCardResult {
31
+ path: string
32
+ name: string
33
+ files: Record<string, string>
34
+ urls: Record<string, string>
35
+ width: number
36
+ height: number
37
+ title: string
38
+ }
package/dist/social.js ADDED
@@ -0,0 +1,85 @@
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, 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", "square", "portrait"], 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: projectFile(shot, root),
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 base = siteUrl.replace(/\/+$/, ""), primary = `${base}${card.urls.og ?? Object.values(card.urls)[0]}`, mimeType = format === "jpeg" ? "image/jpeg" : `image/${format}`, tags = [
67
+ `<meta property="og:image" content="${primary}">`,
68
+ `<meta property="og:image:type" content="${mimeType}">`,
69
+ `<meta property="og:image:width" content="${card.width}">`,
70
+ `<meta property="og:image:height" content="${card.height}">`,
71
+ `<meta property="og:image:alt" content="${escapeAttribute(card.title)}">`,
72
+ '<meta name="twitter:card" content="summary_large_image">',
73
+ `<meta name="twitter:image" content="${primary}">`,
74
+ `<meta name="twitter:image:alt" content="${escapeAttribute(card.title)}">`
75
+ ];
76
+ for (const [preset, url] of Object.entries(card.urls)) {
77
+ if (preset === "og")
78
+ continue;
79
+ tags.push(`<meta property="og:image" content="${base}${url}">`);
80
+ }
81
+ return tags;
82
+ }
83
+ function escapeAttribute(value) {
84
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
85
+ }
@@ -0,0 +1,45 @@
1
+ import type { ImageBackgroundConfig, ImageColor, ImageDeviceConfig, ImagesConfig } from '@stacksjs/types';
2
+ import type { ImageData, RGBA, SurfaceBackground } from 'ts-images';
3
+ /**
4
+ * Turn the declarative half of `config/images.ts` into the shapes ts-images
5
+ * takes.
6
+ *
7
+ * Configuration carries colours as strings and paths as project-relative, both
8
+ * of which have to become something concrete before a pixel is drawn. Doing it
9
+ * in one place means the three generators agree on what `background` means,
10
+ * which is the point of having a shared palette at all.
11
+ */
12
+ export declare function projectFile(path: string, root?: string): string;
13
+ export declare function color(value: ImageColor | undefined): RGBA | undefined;
14
+ export declare function background(value: ImageBackgroundConfig | undefined, root?: string): SurfaceBackground | undefined;
15
+ /**
16
+ * `shadow` is tri-state in configuration and in the renderer: absent means
17
+ * "the default shadow", `false` means "none", and an object means "this one".
18
+ * `undefined` cannot express the middle case, so the translation is explicit.
19
+ */
20
+ export declare function device(value: ImageDeviceConfig | undefined): {
21
+ radius?: number
22
+ borderColor?: RGBA
23
+ scale?: number
24
+ shadow?: { blur?: number, offsetX?: number, offsetY?: number, spread?: number, color?: RGBA }
25
+ } | undefined;
26
+ /**
27
+ * Build a `drawMark` callback from an image path.
28
+ *
29
+ * ts-images hands back a box and lets the caller paint the mark, because a
30
+ * library cannot know what a brand's mark looks like. For a Stacks project it
31
+ * is nearly always a file already in the repository — the app icon — so the
32
+ * callback is just a placement.
33
+ */
34
+ export declare function markPainter(path: string | undefined, root?: string): Promise<((canvas: ImageData, box: { x: number, y: number, size: number }) => void) | undefined>;
35
+ /** Fold the shared palette into a generator's own, letting the generator win. */
36
+ export declare function themed<T extends ImageTheme>(images: ImagesConfig, section: T | undefined): T & ImageTheme;
37
+ /** The palette keys a generator inherits from the top level of the config. */
38
+ export declare interface ImageTheme {
39
+ background?: ImageBackgroundConfig
40
+ color?: ImageColor
41
+ mutedColor?: ImageColor
42
+ device?: ImageDeviceConfig
43
+ brand?: string
44
+ mark?: string
45
+ }
package/dist/theme.js ADDED
@@ -0,0 +1,52 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import process from "node:process";
4
+ import { decode, drawImage, parseColor } from "ts-images";
5
+ export function projectFile(path, root = process.cwd()) {
6
+ return isAbsolute(path) ? path : resolve(root, path);
7
+ }
8
+ export function color(value) {
9
+ return value === void 0 ? void 0 : parseColor(value);
10
+ }
11
+ export function background(value, root = process.cwd()) {
12
+ if (!value)
13
+ return;
14
+ return {
15
+ color: color(value.color),
16
+ gradient: value.gradient && {
17
+ angle: value.gradient.angle,
18
+ stops: value.gradient.stops.map((stop) => ({ offset: stop.offset, color: parseColor(stop.color) }))
19
+ },
20
+ glows: value.glows?.map((glow) => ({ ...glow, color: parseColor(glow.color) })),
21
+ image: value.image ? projectFile(value.image, root) : void 0
22
+ };
23
+ }
24
+ export function device(value) {
25
+ if (!value)
26
+ return;
27
+ return {
28
+ radius: value.radius,
29
+ scale: value.scale,
30
+ borderColor: color(value.borderColor),
31
+ shadow: value.shadow === !1 ? void 0 : { ...value.shadow ?? {}, color: color(value.shadow?.color) }
32
+ };
33
+ }
34
+ export async function markPainter(path, root = process.cwd()) {
35
+ if (!path)
36
+ return;
37
+ const mark = await decode(new Uint8Array(await readFile(projectFile(path, root))));
38
+ return (canvas, box) => {
39
+ drawImage(canvas, mark, { x: box.x, y: box.y, width: box.size, height: box.size, fit: "contain" });
40
+ };
41
+ }
42
+ export function themed(images, section) {
43
+ return {
44
+ ...section ?? {},
45
+ background: section?.background ?? images.background,
46
+ color: section?.color ?? images.color,
47
+ mutedColor: section?.mutedColor ?? images.mutedColor,
48
+ device: section?.device ?? images.device,
49
+ brand: section?.brand ?? images.brand,
50
+ mark: section?.mark ?? images.mark
51
+ };
52
+ }
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.232",
5
+ "version": "0.70.234",
6
6
  "description": "Native responsive image delivery for Stacks.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",
@@ -15,7 +15,6 @@
15
15
  "exports": {
16
16
  ".": {
17
17
  "types": "./dist/index.d.ts",
18
- "development": "./src/index.ts",
19
18
  "bun": "./dist/index.js",
20
19
  "import": "./dist/index.js"
21
20
  }
@@ -32,7 +31,8 @@
32
31
  "prepublishOnly": "bun run build"
33
32
  },
34
33
  "dependencies": {
35
- "ts-images": "^0.2.1"
34
+ "@stacksjs/types": "0.70.234",
35
+ "ts-images": "^0.2.6"
36
36
  },
37
37
  "devDependencies": {
38
38
  "better-dx": "^0.2.17"