@a2f0/agent-tool 0.1.12 → 0.1.13

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/README.md CHANGED
@@ -11,6 +11,9 @@ version management, and portable skills for Claude Code, Codex, and OpenCode.
11
11
  `bun.lock` in sync.
12
12
  - **Share skills**: install the same workflow instructions where each harness
13
13
  discovers them, and check them in CI.
14
+ - **Render brand images**: build app icons, splash screens, favicons, and store
15
+ graphics from the SVGs in `assets/brand/`, with every size and destination
16
+ declared in `agent-tool.json`.
14
17
  - **Compact output**: run noisy commands with full private logs and bounded
15
18
  diagnostics, keeping routine output out of an agent's context.
16
19
 
@@ -109,6 +112,8 @@ agent-tool skills install --apply
109
112
  | `skills list` | List embedded portable skills |
110
113
  | `skills install [--harness all\|claude\|codex\|opencode] [--apply]` | Preview by default; install or update managed skills |
111
114
  | `skills check [--harness all\|claude\|codex\|opencode]` | Read-only CI check for current managed skills |
115
+ | `images list [target...]` | List the files each image target writes, without rendering |
116
+ | `images build [target...] [--out dir]` | Render image targets from `assets/brand/*.svg`; `--out` redirects one target |
112
117
  | `init` / `config show` | Create or inspect data-only project policy |
113
118
  | `doctor` | Inspect local versions and required CLI flags without running a model |
114
119
 
@@ -256,6 +261,38 @@ the commit restores the manifests, lockfile, and index, unless HEAD or other
256
261
  paths changed, in which case it leaves that state for inspection. Repeating
257
262
  against the same base creates no commit.
258
263
 
264
+ ### Images
265
+
266
+ `images` declares named targets of brand images rendered from SVG sources in
267
+ `assets/brand/`. Each output names its file, pixel size or variants (such as
268
+ Android densities or `@2x`/`@3x`), and the layers composed into it:
269
+
270
+ ```json
271
+ {
272
+ "schemaVersion": 1,
273
+ "images": {
274
+ "targets": {
275
+ "ios": {
276
+ "dir": "ios/App/Assets.xcassets",
277
+ "outputs": [
278
+ {
279
+ "path": "AppIcon.appiconset/AppIcon.png",
280
+ "size": 1024,
281
+ "opaque": true,
282
+ "layers": [{ "source": "background", "fit": "cover" }, { "source": "logo", "scale": 0.7 }]
283
+ }
284
+ ]
285
+ }
286
+ }
287
+ }
288
+ }
289
+ ```
290
+
291
+ `agent-tool images build ios` writes PNG, ICO, SVG, and Android color resource
292
+ outputs with a bundled WebAssembly renderer, so builds need no ImageMagick or
293
+ librsvg and render identically on every machine. See the
294
+ [image reference](docs/images.md) for every setting.
295
+
259
296
  ## Portable skills
260
297
 
261
298
  Canonical skills live in `skills/`. The build embeds them, and the installer
@@ -393,6 +430,7 @@ yet.
393
430
  ## Further reading
394
431
 
395
432
  - [Design](docs/design.md): component boundaries and implementation choices.
433
+ - [Images](docs/images.md): brand image sources, targets, and output settings.
396
434
  - [Validation record](docs/validation.md): observed test results and their
397
435
  limits.
398
436
  - [Extraction record](docs/extraction.md): the projects this tool was extracted
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/agent-tool",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "description": "Portable cross-agent review, PR workflows, and shared skills",
6
6
  "license": "UNLICENSED",
@@ -33,5 +33,8 @@
33
33
  "devDependencies": {
34
34
  "@types/bun": "1.4.2",
35
35
  "typescript": "7.0.2"
36
+ },
37
+ "dependencies": {
38
+ "@resvg/resvg-wasm": "2.6.2"
36
39
  }
37
40
  }
package/src/cli.ts CHANGED
@@ -32,6 +32,8 @@ Usage: agent-tool [--repo <directory>] [--config <file>] <command>
32
32
  skills list
33
33
  skills install [--harness <all|claude|codex|opencode>] [--apply]
34
34
  skills check [--harness <all|claude|codex|opencode>]
35
+ images list [target...] JSON files each image target writes
36
+ images build [target...] [--out <dir>] Render assets/brand/*.svg per agent-tool.json
35
37
  init Create agent-tool.json without overwriting
36
38
  config show
37
39
  doctor JSON versions and supported review flags
@@ -85,6 +87,22 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
85
87
  const result = doctor(process.cwd()); output(result);
86
88
  return result.tools.find(item => item.name === "git")?.compatible && result.tools.some(item => ["claude", "codex", "opencode"].includes(item.name) && item.compatible) ? 0 : 1;
87
89
  }
90
+ if (command === "images") {
91
+ const action = args.shift();
92
+ usage(action === "list" || action === "build", "Usage: agent-tool images <list|build> [target...] [--out <dir>]");
93
+ const names: string[] = [];
94
+ let out: string | undefined;
95
+ while (args.length) {
96
+ const value = args.shift()!;
97
+ if (value === "--out") { usage(action === "build" && out === undefined && !!args[0] && !args[0].startsWith("--"), "--out requires one directory and applies to build."); out = args.shift(); }
98
+ else { usage(!value.startsWith("-"), `Unknown images argument '${value}'.`); names.push(value); }
99
+ }
100
+ const { images } = loadConfig();
101
+ // Loaded on demand so other commands never initialize the renderer.
102
+ return import("./images/build").then(async ({ buildImages, listImages }) => {
103
+ output(action === "list" ? listImages(images, names) : await buildImages(process.cwd(), images, names, out)); return 0;
104
+ });
105
+ }
88
106
  if (command === "skills") {
89
107
  const action = args.shift();
90
108
  if (action === "list") { usage(args.length === 0, "Usage: agent-tool skills list"); output(Object.keys(BUNDLED_SKILLS)); return 0; }
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, lstatSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { parseImages, type ImagesConfig } from "./images/config";
3
4
 
4
5
  export interface RequiredCheck {
5
6
  name: string;
@@ -13,6 +14,7 @@ export interface AgentToolConfig {
13
14
  review: { claudeModel: string | null; codexModel: string | null; opencodeModel: string; opencodeVariants: Record<string, string>; timeoutMs: number };
14
15
  versions: { packages: string[] | null; bundles: string[]; lockfile: "bun.lock" | null; validate: string[][]; commitMessage: string };
15
16
  pr: { rejectClaudeBranding: boolean };
17
+ images: ImagesConfig;
16
18
  }
17
19
 
18
20
  export const DEFAULT_CONFIG: AgentToolConfig = {
@@ -26,6 +28,7 @@ export const DEFAULT_CONFIG: AgentToolConfig = {
26
28
  review: { claudeModel: null, codexModel: null, opencodeModel: "deepseek/deepseek-v4-pro", opencodeVariants: { low: "low", medium: "medium", high: "high", xhigh: "max", max: "max" }, timeoutMs: 600_000 },
27
29
  versions: { packages: null, bundles: [], lockfile: "bun.lock", validate: [], commitMessage: "chore: bump package versions" },
28
30
  pr: { rejectClaudeBranding: false },
31
+ images: { targets: {} },
29
32
  };
30
33
 
31
34
  function object(value: unknown, name: string): Record<string, unknown> {
@@ -57,6 +60,7 @@ export function parseConfig(source: string): AgentToolConfig {
57
60
  keys(settings, Object.keys(result[section]), section);
58
61
  Object.assign(result[section], settings);
59
62
  }
63
+ if (input.images !== undefined) result.images = parseImages(input.images);
60
64
  const invalid = (setting: string): never => { throw new Error(`Invalid config setting '${setting}'.`); };
61
65
  if (typeof result.subject.conventional !== "boolean") invalid("subject.conventional");
62
66
  if (!Number.isSafeInteger(result.subject.maxLength) || result.subject.maxLength < 1) invalid("subject.maxLength");
@@ -0,0 +1,160 @@
1
+ import { initWasm, Resvg } from "@resvg/resvg-wasm";
2
+ import wasm from "@resvg/resvg-wasm/index_bg.wasm" with { type: "file" };
3
+ import { randomUUID } from "node:crypto";
4
+ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { composeSvg, type Canvas } from "./compose";
7
+ import { expand, format, SOURCE_DIRECTORY, verbatim, type ImageOutput, type ImagesConfig, type ImageTarget } from "./config";
8
+ import { encodeIco, encodePng, fromPremultiplied, type Pixels } from "./png";
9
+ import { parseSvg, type SvgSource } from "./svg";
10
+
11
+ let ready: Promise<void> | undefined;
12
+
13
+ async function rasterize(svg: string, width: number, height: number, opaque: boolean): Promise<Pixels> {
14
+ await (ready ??= initWasm(readFileSync(wasm)));
15
+ // No system fonts: sources may not contain text, so nothing depends on the machine.
16
+ const renderer = new Resvg(svg, { fitTo: { mode: "original" }, font: { loadSystemFonts: false } });
17
+ const image = renderer.render();
18
+ try {
19
+ if (image.width !== width || image.height !== height) throw new Error(`Rendered ${image.width}x${image.height} instead of ${width}x${height}.`);
20
+ return fromPremultiplied(width, height, image.pixels, opaque);
21
+ } finally { image.free(); renderer.free(); }
22
+ }
23
+
24
+ class Sources {
25
+ private readonly loaded = new Map<string, { text: string; svg?: SvgSource }>();
26
+ constructor(private readonly root: string) {}
27
+ private entry(name: string) {
28
+ let entry = this.loaded.get(name);
29
+ if (!entry) {
30
+ const file = path.join(this.root, SOURCE_DIRECTORY, `${name}.svg`);
31
+ if (!existsSync(file)) throw new Error(`Image source '${name}' does not exist at ${SOURCE_DIRECTORY}/${name}.svg.`);
32
+ entry = { text: readFileSync(file, "utf8") };
33
+ this.loaded.set(name, entry);
34
+ }
35
+ return entry;
36
+ }
37
+ text(name: string): string { return this.entry(name).text; }
38
+ parsed(names: Iterable<string>): Map<string, SvgSource> {
39
+ return new Map([...names].map(name => { const entry = this.entry(name); return [name, entry.svg ??= parseSvg(name, entry.text)]; }));
40
+ }
41
+ }
42
+
43
+ function layerSources(output: Pick<ImageOutput, "layers">): string[] {
44
+ return output.layers.flatMap(layer => "source" in layer ? [layer.source] : []);
45
+ }
46
+
47
+ async function render(output: ImageOutput, canvas: Canvas, sources: Sources): Promise<Uint8Array | string> {
48
+ const kind = format(output.path);
49
+ if (kind === "xml") {
50
+ let colors = "";
51
+ for (const [name, color] of Object.entries(output.colors!)) {
52
+ let hex = typeof color === "string" ? color : "";
53
+ if (typeof color !== "string") {
54
+ // The source's mean color over white, on a canvas of its own proportions, for settings that take a color.
55
+ const parsed = sources.parsed([color.average]), [, , viewWidth, viewHeight] = parsed.get(color.average)!.viewBox;
56
+ const width = Math.max(1, Math.round(64 * Math.min(1, viewWidth / viewHeight))), height = Math.max(1, Math.round(64 * Math.min(1, viewHeight / viewWidth)));
57
+ const average = { layers: [{ source: color.average, fit: "cover" as const, scale: null, size: null, hide: [], tint: null, bleed: [], pixelSnap: false }], mask: null, opaque: true };
58
+ const { data } = await rasterize(composeSvg(average, { width, height, factor: 1 }, parsed), width, height, true);
59
+ const sums = [0, 0, 0];
60
+ for (let index = 0; index < data.length; index += 1) sums[index % 3]! += data[index]!;
61
+ hex = `#${sums.map(sum => Math.round(sum / (width * height)).toString(16).padStart(2, "0")).join("")}`;
62
+ }
63
+ if (hex.length === 4) hex = `#${[...hex.slice(1)].map(digit => digit + digit).join("")}`;
64
+ colors += ` <color name="${name}">${hex.toUpperCase()}</color>\n`;
65
+ }
66
+ return `<?xml version="1.0" encoding="utf-8"?>\n<!-- Generated by agent-tool images from agent-tool.json. Do not edit. -->\n<resources>\n${colors}</resources>\n`;
67
+ }
68
+ const copy = verbatim(output);
69
+ // Copies pass the same checks as composed sources: no scripts, handlers, or external references.
70
+ if (copy !== null) { sources.parsed([copy]); return sources.text(copy); }
71
+ const parsed = sources.parsed(layerSources(output));
72
+ if (kind === "svg") return composeSvg(output, canvas, parsed, true);
73
+ const pixels = async (width: number, height: number) => rasterize(composeSvg(output, { width, height, factor: canvas.factor }, parsed), width, height, output.opaque);
74
+ if (kind === "ico") return encodeIco(await Promise.all(output.sizes!.map(size => pixels(size, size))));
75
+ return encodePng(await pixels(canvas.width, canvas.height));
76
+ }
77
+
78
+ function select(config: ImagesConfig, names: string[], writable: boolean): [string, ImageTarget][] {
79
+ const available = Object.keys(config.targets);
80
+ if (!available.length) throw new Error("agent-tool.json defines no images.targets.");
81
+ for (const name of names) if (!config.targets[name]) throw new Error(`Unknown image target '${name}'; expected one of: ${available.join(", ")}.`);
82
+ // Without names, build leaves targets that need --out for an explicit request.
83
+ const selected = names.length ? [...new Set(names)] : available.filter(name => !writable || config.targets[name]!.dir !== null);
84
+ if (!selected.length) throw new Error("No image target has a dir; name one and pass --out <directory>.");
85
+ return selected.map(name => [name, config.targets[name]!]);
86
+ }
87
+
88
+ /** Refuses symbolic links below the base, so a committed link cannot redirect a write outside it. */
89
+ function assertNoLinks(base: string, destination: string): void {
90
+ let current = base;
91
+ for (const part of path.relative(base, destination).split(path.sep)) {
92
+ current = path.join(current, part);
93
+ let link: boolean;
94
+ try { link = lstatSync(current).isSymbolicLink(); } catch { return; }
95
+ if (link) throw new Error(`Refusing to write through symbolic link ${path.relative(base, current)}.`);
96
+ }
97
+ }
98
+
99
+ function directory(name: string, target: ImageTarget, out: string | undefined): string {
100
+ const dir = out ?? target.dir;
101
+ if (dir === null) throw new Error(`Image target '${name}' has no dir; pass --out <directory>.`);
102
+ return dir;
103
+ }
104
+
105
+ /** Every file each selected target writes, without rendering anything. */
106
+ export function listImages(config: ImagesConfig, names: string[]) {
107
+ return {
108
+ sources: SOURCE_DIRECTORY,
109
+ targets: select(config, names, false).map(([name, target]) => ({
110
+ name, dir: target.dir, files: target.outputs.flatMap(output => expand(output).map(file => file.path)),
111
+ })),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Renders the selected targets under the repository root. Files whose bytes
117
+ * already match are left alone, so native builds see no spurious changes, and
118
+ * the rest are replaced atomically.
119
+ */
120
+ export async function buildImages(root: string, config: ImagesConfig, names: string[], out?: string) {
121
+ if (out !== undefined && names.length !== 1) throw new Error("--out needs exactly one image target.");
122
+ const sources = new Sources(root);
123
+ // Render everything first, so a bad source or setting leaves no partial set behind.
124
+ const rendered: { name: string; dir: string; files: { relative: string; destination: string; bytes: Uint8Array }[] }[] = [];
125
+ const owners = new Map<string, string>();
126
+ for (const [name, target] of select(config, names, true)) {
127
+ const dir = directory(name, target, out);
128
+ const files = [];
129
+ for (const output of target.outputs) {
130
+ for (const file of expand(output)) {
131
+ const content = await render(output, { width: file.width, height: file.height, factor: file.factor }, sources);
132
+ const destination = path.resolve(root, dir, file.path);
133
+ // An explicit --out is the caller's choice; configured directories are checked from the repository root.
134
+ assertNoLinks(out === undefined ? root : path.resolve(root, out), destination);
135
+ // Compared without case, as macOS and Windows file systems do.
136
+ const owner = owners.get(destination.toLowerCase());
137
+ if (owner !== undefined) throw new Error(`Image targets '${owner}' and '${name}' both write ${path.relative(root, destination)}.`);
138
+ owners.set(destination.toLowerCase(), name);
139
+ files.push({ relative: path.join(dir, file.path), destination, bytes: typeof content === "string" ? Buffer.from(content) : content });
140
+ }
141
+ }
142
+ rendered.push({ name, dir, files });
143
+ }
144
+ const targets = rendered.map(({ name, dir, files }) => {
145
+ const written: string[] = [], unchanged: string[] = [];
146
+ for (const { relative, destination, bytes } of files) {
147
+ if (existsSync(destination) && Buffer.compare(readFileSync(destination), bytes) === 0) { unchanged.push(relative); continue; }
148
+ mkdirSync(path.dirname(destination), { recursive: true });
149
+ // An unpredictable name created exclusively, so no committed link can stand in for it.
150
+ const temporary = `${destination}.${randomUUID()}.tmp`;
151
+ try {
152
+ writeFileSync(temporary, bytes, { flag: "wx" });
153
+ renameSync(temporary, destination);
154
+ } catch (error) { rmSync(temporary, { force: true }); throw error; }
155
+ written.push(relative);
156
+ }
157
+ return { name, dir, written, unchanged };
158
+ });
159
+ return { targets };
160
+ }
@@ -0,0 +1,78 @@
1
+ import { pair, type ImageOutput, type SourceLayer } from "./config";
2
+ import { attribute, inlineSvg, renameReferences, type SvgSource } from "./svg";
3
+
4
+ export interface Canvas { width: number; height: number; factor: number | null }
5
+
6
+ /** Coordinates rounded so documents stay readable and byte-stable. */
7
+ function n(value: number): string { return String(Number(value.toFixed(6))); }
8
+
9
+ function nested(source: SvgSource, prefix: string, hide: Set<string>, box: [number, number, number, number], viewBox: number[], aspect: string): string {
10
+ const content = inlineSvg(source, prefix, hide);
11
+ // The source root's presentation attributes go on a group, so none can collide with or loosen the viewport's.
12
+ const presentation = source.attributes.map(([key, value]) => attribute(key, renameReferences(value, prefix))).join("");
13
+ return `<svg x="${n(box[0])}" y="${n(box[1])}" width="${n(box[2])}" height="${n(box[3])}" viewBox="${viewBox.map(n).join(" ")}" preserveAspectRatio="${aspect}" overflow="hidden">${presentation ? `<g${presentation}>${content}</g>` : content}</svg>`;
14
+ }
15
+
16
+ function placeSource(layer: SourceLayer, index: number, canvas: Canvas, source: SvgSource): string {
17
+ const { width, height } = canvas;
18
+ for (const id of layer.hide) if (!source.ids.has(id)) throw new Error(`Image source '${source.name}' has no element with id '${id}' to hide.`);
19
+ const hide = new Set(layer.hide);
20
+ const [vx, vy, vw, vh] = source.viewBox;
21
+ const [boxWidth, boxHeight] = layer.size === null ? [width * (layer.scale ?? 1), height * (layer.scale ?? 1)] : pair(layer.size).map(side => side * canvas.factor!);
22
+ const boxX = (width - boxWidth!) / 2, boxY = (height - boxHeight!) / 2;
23
+ const tint = (parts: string[]) => layer.tint === null ? parts.join("") : `<g filter="url(#agent-tool-tint-${index})">${parts.join("")}</g>`;
24
+ if (layer.fit === "cover") return tint([nested(source, `l${index}-`, hide, [boxX, boxY, boxWidth!, boxHeight!], source.viewBox, "xMidYMid slice")]);
25
+ let scale = Math.min(boxWidth! / vw, boxHeight! / vh);
26
+ if (layer.pixelSnap) {
27
+ if (!Number.isInteger(vw) || !Number.isInteger(vh)) throw new Error(`Image source '${source.name}' needs a whole-unit viewBox to snap to pixels.`);
28
+ scale = Math.floor(scale + 1e-9);
29
+ if (scale < 1) throw new Error(`Image source '${source.name}' (${vw}x${vh}) cannot snap to pixels within ${n(boxWidth!)}x${n(boxHeight!)}.`);
30
+ }
31
+ const drawnWidth = vw * scale, drawnHeight = vh * scale;
32
+ let x = (width - drawnWidth) / 2, y = (height - drawnHeight) / 2;
33
+ if (layer.pixelSnap) { x = Math.floor(x); y = Math.floor(y); }
34
+ const parts = [nested(source, `l${index}-`, hide, [x, y, drawnWidth, drawnHeight], source.viewBox, "none")];
35
+ // Bleeding repeats the source's outermost rendered pixel row or column out to the canvas edge.
36
+ const row = vh / drawnHeight, column = vw / drawnWidth;
37
+ const strips: Record<string, [[number, number, number, number], number[]]> = {
38
+ top: [[x, 0, drawnWidth, y], [vx, vy, vw, row]],
39
+ bottom: [[x, y + drawnHeight, drawnWidth, height - y - drawnHeight], [vx, vy + vh - row, vw, row]],
40
+ left: [[0, y, x, drawnHeight], [vx, vy, column, vh]],
41
+ right: [[x + drawnWidth, y, width - x - drawnWidth, drawnHeight], [vx + vw - column, vy, column, vh]],
42
+ };
43
+ for (const edge of layer.bleed) {
44
+ const [box, viewBox] = strips[edge]!;
45
+ if (box[2] > 0 && box[3] > 0) parts.push(nested(source, `l${index}${edge}-`, hide, box, viewBox, "none"));
46
+ }
47
+ return tint(parts);
48
+ }
49
+
50
+ /**
51
+ * One SVG document for an output at its pixel size. Each layer inlines its
52
+ * source in a nested viewport, so raster and SVG outputs share one geometry.
53
+ */
54
+ export function composeSvg(output: Pick<ImageOutput, "layers" | "mask" | "opaque">, canvas: Canvas, sources: ReadonlyMap<string, SvgSource>, standalone = false): string {
55
+ const { width, height } = canvas;
56
+ const namespaces: Record<string, string> = { xlink: "http://www.w3.org/1999/xlink" };
57
+ const defs: string[] = [], layers: string[] = [];
58
+ for (const [index, layer] of output.layers.entries()) {
59
+ if ("fill" in layer) { layers.push(`<rect width="${width}" height="${height}" fill="${layer.fill}"/>`); continue; }
60
+ const source = sources.get(layer.source)!;
61
+ for (const [prefix, uri] of Object.entries(source.namespaces)) {
62
+ if (namespaces[prefix] !== undefined && namespaces[prefix] !== uri) throw new Error(`Image sources bind xmlns:${prefix} to different namespaces.`);
63
+ namespaces[prefix] = uri;
64
+ }
65
+ if (layer.tint !== null) {
66
+ // User-space bounds: resvg measures nested viewports incorrectly for bounding-box filter regions.
67
+ defs.push(`<filter id="agent-tool-tint-${index}" filterUnits="userSpaceOnUse" x="0" y="0" width="${width}" height="${height}" color-interpolation-filters="sRGB"><feFlood flood-color="${layer.tint}"/><feComposite in2="SourceAlpha" operator="in"/></filter>`);
68
+ }
69
+ layers.push(placeSource(layer, index, canvas, source));
70
+ }
71
+ const shortest = Math.min(width, height);
72
+ if (output.mask === "circle") defs.push(`<clipPath id="agent-tool-mask"><circle cx="${n(width / 2)}" cy="${n(height / 2)}" r="${n(shortest / 2)}"/></clipPath>`);
73
+ else if (output.mask) defs.push(`<clipPath id="agent-tool-mask"><rect width="${width}" height="${height}" rx="${n(shortest * output.mask.cornerRadius)}"/></clipPath>`);
74
+ const size = standalone ? "" : ` width="${width}" height="${height}"`;
75
+ const declarations = Object.entries(namespaces).map(([prefix, uri]) => attribute(`xmlns:${prefix}`, uri)).join("");
76
+ const body = output.mask ? `<g clip-path="url(#agent-tool-mask)">${layers.join("")}</g>` : layers.join("");
77
+ return `<svg xmlns="http://www.w3.org/2000/svg"${declarations}${size} viewBox="0 0 ${width} ${height}">${defs.length ? `<defs>${defs.join("")}</defs>` : ""}${output.opaque ? `<rect width="${width}" height="${height}" fill="#ffffff"/>` : ""}${body}</svg>\n`;
78
+ }
@@ -0,0 +1,205 @@
1
+ /** The data-only `images` section of agent-tool.json: named targets of rendered brand images. */
2
+
3
+ export type Size = number | [number, number];
4
+ export type Edge = "top" | "right" | "bottom" | "left";
5
+ export interface FillLayer { fill: string }
6
+ export interface SourceLayer {
7
+ source: string;
8
+ fit: "contain" | "cover";
9
+ scale: number | null;
10
+ size: Size | null;
11
+ hide: string[];
12
+ tint: string | null;
13
+ bleed: Edge[];
14
+ pixelSnap: boolean;
15
+ }
16
+ export type ImageLayer = FillLayer | SourceLayer;
17
+ export type ColorValue = string | { average: string };
18
+ export interface ImageOutput {
19
+ path: string;
20
+ size: Size | null;
21
+ variants: Record<string, number> | Record<string, [number, number]> | null;
22
+ sizes: number[] | null;
23
+ opaque: boolean;
24
+ mask: "circle" | { cornerRadius: number } | null;
25
+ layers: ImageLayer[];
26
+ colors: Record<string, ColorValue> | null;
27
+ }
28
+ export interface ImageTarget { dir: string | null; outputs: ImageOutput[] }
29
+ export interface ImagesConfig { targets: Record<string, ImageTarget> }
30
+
31
+ /** Sources live at assets/brand/<name>.svg in every repository. */
32
+ export const SOURCE_DIRECTORY = "assets/brand";
33
+ export const ANDROID_DENSITIES = { mdpi: 1, hdpi: 1.5, xhdpi: 2, xxhdpi: 3, xxxhdpi: 4 };
34
+ export const MAX_PIXELS = 8192;
35
+ const NAME = /^[a-z][a-z0-9-]*$/;
36
+ const COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
37
+ const ID = /^[A-Za-z_][\w.:-]*$/;
38
+ const EDGES: Edge[] = ["top", "right", "bottom", "left"];
39
+ const FORMATS = ["png", "ico", "svg", "xml"] as const;
40
+ export type ImageFormat = typeof FORMATS[number];
41
+
42
+ type Json = Record<string, unknown>;
43
+ function fail(setting: string): never { throw new Error(`Invalid config setting '${setting}'.`); }
44
+ function object(value: unknown, setting: string, allowed: string[]): Json {
45
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail(setting);
46
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new Error(`Unknown ${setting} setting '${key}'.`);
47
+ return value as Json;
48
+ }
49
+ /** An object whose keys are names chosen by the project. */
50
+ function record(value: unknown, setting: string): Json {
51
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail(setting);
52
+ return value as Json;
53
+ }
54
+ function positive(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0; }
55
+ function size(value: unknown, setting: string): Size {
56
+ if (positive(value)) return value;
57
+ if (Array.isArray(value) && value.length === 2 && value.every(positive)) return [value[0], value[1]];
58
+ fail(setting);
59
+ }
60
+ export function pair(value: Size): [number, number] { return typeof value === "number" ? [value, value] : value; }
61
+ /** Repository-relative and inside it: no absolute paths, backslashes, or dot segments. */
62
+ export function relativePath(value: unknown): value is string {
63
+ return typeof value === "string" && !value.startsWith("/") && !value.includes("\\") && value.split("/").every(part => part && part !== "." && part !== "..");
64
+ }
65
+ export function format(path: string): ImageFormat {
66
+ return path.slice(path.lastIndexOf(".") + 1) as ImageFormat;
67
+ }
68
+
69
+ function parseLayer(value: unknown, setting: string): ImageLayer {
70
+ if (value && typeof value === "object" && "fill" in value) {
71
+ const layer = object(value, setting, ["fill"]);
72
+ if (typeof layer.fill !== "string" || !COLOR.test(layer.fill)) fail(`${setting}.fill`);
73
+ return { fill: layer.fill };
74
+ }
75
+ const layer = object(value, setting, ["source", "fit", "scale", "size", "hide", "tint", "bleed", "pixelSnap"]);
76
+ if (typeof layer.source !== "string" || !NAME.test(layer.source)) fail(`${setting}.source`);
77
+ const fit = layer.fit ?? "contain";
78
+ if (fit !== "contain" && fit !== "cover") fail(`${setting}.fit`);
79
+ if (layer.scale !== undefined && (!positive(layer.scale) || layer.scale > 4)) fail(`${setting}.scale`);
80
+ if (layer.scale !== undefined && layer.size !== undefined) throw new Error(`${setting} sets both scale and size; choose one.`);
81
+ const hide = layer.hide ?? [];
82
+ if (!Array.isArray(hide) || hide.some(id => typeof id !== "string" || !ID.test(id)) || new Set(hide).size !== hide.length) fail(`${setting}.hide`);
83
+ if (layer.tint !== undefined && (typeof layer.tint !== "string" || !COLOR.test(layer.tint))) fail(`${setting}.tint`);
84
+ const bleed = layer.bleed ?? [];
85
+ if (!Array.isArray(bleed) || bleed.some(edge => !EDGES.includes(edge)) || new Set(bleed).size !== bleed.length) fail(`${setting}.bleed`);
86
+ if (layer.pixelSnap !== undefined && typeof layer.pixelSnap !== "boolean") fail(`${setting}.pixelSnap`);
87
+ const result: SourceLayer = {
88
+ source: layer.source, fit, scale: layer.scale ?? null, size: layer.size === undefined ? null : size(layer.size, `${setting}.size`),
89
+ hide, tint: layer.tint ?? null, bleed, pixelSnap: layer.pixelSnap ?? false,
90
+ };
91
+ if (fit === "cover" && (result.bleed.length || result.pixelSnap)) throw new Error(`${setting} can only bleed or snap to pixels with fit "contain".`);
92
+ return result;
93
+ }
94
+
95
+ function parseOutput(value: unknown, setting: string): ImageOutput {
96
+ const input = object(value, setting, ["path", "size", "variants", "sizes", "opaque", "mask", "layers", "colors"]);
97
+ if (!relativePath(input.path) || !FORMATS.includes(format(input.path))) fail(`${setting}.path`);
98
+ const path = input.path, kind = format(path);
99
+ const allowed: Record<ImageFormat, string[]> = {
100
+ png: ["size", "variants", "opaque", "mask", "layers"],
101
+ svg: ["size", "variants", "opaque", "mask", "layers"],
102
+ ico: ["sizes", "opaque", "mask", "layers"],
103
+ xml: ["colors"],
104
+ };
105
+ for (const key of Object.keys(input)) if (key !== "path" && !allowed[kind].includes(key)) throw new Error(`${setting} is ${kind} output and cannot set '${key}'.`);
106
+
107
+ let variants: ImageOutput["variants"] = null;
108
+ if (input.variants !== undefined) {
109
+ const entries = Object.entries(input.variants === "android" ? ANDROID_DENSITIES : record(input.variants, `${setting}.variants`));
110
+ const factors = entries.every(([, factor]) => positive(factor)), pairs = entries.every(([, dimensions]) => Array.isArray(dimensions));
111
+ if (!entries.length || (!factors && !pairs) || entries.some(([key]) => !/^[\w@.-]*$/.test(key))) fail(`${setting}.variants`);
112
+ variants = Object.fromEntries(entries.map(([key, entry]) => [key, factors ? entry : pair(size(entry, `${setting}.variants.${key}`))])) as ImageOutput["variants"];
113
+ if (!path.includes("{variant}")) throw new Error(`${setting}.path needs {variant} to name each variant's file.`);
114
+ } else if (path.includes("{variant}")) throw new Error(`${setting}.path uses {variant} without variants.`);
115
+ const explicit = variants !== null && Object.values(variants).every(Array.isArray);
116
+ const base = input.size === undefined ? null : size(input.size, `${setting}.size`);
117
+ if (explicit && base !== null) throw new Error(`${setting} gives each variant its pixels; remove size.`);
118
+
119
+ let sizes: number[] | null = null;
120
+ if (kind === "ico") {
121
+ if (!Array.isArray(input.sizes) || !input.sizes.length || input.sizes.some(item => !Number.isInteger(item) || item < 1 || item > 256) || new Set(input.sizes).size !== input.sizes.length) fail(`${setting}.sizes`);
122
+ sizes = input.sizes as number[];
123
+ }
124
+ let colors: ImageOutput["colors"] = null;
125
+ if (kind === "xml") {
126
+ const entries = Object.entries(record(input.colors, `${setting}.colors`));
127
+ if (!entries.length) fail(`${setting}.colors`);
128
+ colors = {};
129
+ for (const [name, color] of entries) {
130
+ if (!/^[a-z_][a-z0-9_]*$/.test(name)) fail(`${setting}.colors.${name}`);
131
+ if (typeof color === "string" && COLOR.test(color)) colors[name] = color;
132
+ else {
133
+ const average = object(color, `${setting}.colors.${name}`, ["average"]).average;
134
+ if (typeof average !== "string" || !NAME.test(average)) fail(`${setting}.colors.${name}`);
135
+ colors[name] = { average };
136
+ }
137
+ }
138
+ return { path, size: null, variants: null, sizes: null, opaque: false, mask: null, layers: [], colors };
139
+ }
140
+
141
+ if (input.opaque !== undefined && typeof input.opaque !== "boolean") fail(`${setting}.opaque`);
142
+ let mask: ImageOutput["mask"] = null;
143
+ if (input.mask === "circle") mask = "circle";
144
+ else if (input.mask !== undefined) {
145
+ const radius = object(input.mask, `${setting}.mask`, ["cornerRadius"]).cornerRadius;
146
+ if (!positive(radius) || radius > 0.5) fail(`${setting}.mask.cornerRadius`);
147
+ mask = { cornerRadius: radius };
148
+ }
149
+ if (!Array.isArray(input.layers) || !input.layers.length) fail(`${setting}.layers`);
150
+ const layers = input.layers.map((layer, index) => parseLayer(layer, `${setting}.layers[${index}]`));
151
+ if ((explicit || kind === "ico") && layers.some(layer => "source" in layer && layer.size !== null)) throw new Error(`${setting} has no base size for a layer size; use scale.`);
152
+ const output: ImageOutput = { path, size: base, variants, sizes, opaque: input.opaque ?? false, mask, layers, colors };
153
+ if (kind === "png" && base === null && !explicit) throw new Error(`${setting} needs a size or sized variants.`);
154
+ if (kind === "svg" && base === null && !explicit && !verbatim(output)) throw new Error(`${setting} needs a size unless it copies one source unchanged.`);
155
+ for (const { width, height } of base === null && !explicit ? [] : expand(output)) {
156
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width > MAX_PIXELS || height > MAX_PIXELS) throw new Error(`${setting} renders ${width}x${height}; sizes must be whole pixels up to ${MAX_PIXELS}.`);
157
+ }
158
+ return output;
159
+ }
160
+
161
+ /** An SVG output that is one unmodified source is copied byte for byte. */
162
+ export function verbatim(output: ImageOutput): string | null {
163
+ const [layer] = output.layers;
164
+ if (format(output.path) !== "svg" || output.layers.length !== 1 || output.size !== null || output.variants !== null || output.opaque || output.mask || !layer || !("source" in layer)) return null;
165
+ return layer.fit === "contain" && layer.scale === null && layer.size === null && !layer.hide.length && layer.tint === null && !layer.bleed.length && !layer.pixelSnap ? layer.source : null;
166
+ }
167
+
168
+ export function parseImages(value: unknown): ImagesConfig {
169
+ const input = object(value, "images", ["targets"]);
170
+ const targets = record(input.targets ?? {}, "images.targets");
171
+ const result: ImagesConfig = { targets: {} };
172
+ for (const [name, entry] of Object.entries(targets)) {
173
+ const setting = `images.targets.${name}`;
174
+ if (!NAME.test(name)) fail(setting);
175
+ const target = object(entry, setting, ["dir", "outputs"]);
176
+ if (target.dir !== undefined && !relativePath(target.dir)) fail(`${setting}.dir`);
177
+ if (!Array.isArray(target.outputs) || !target.outputs.length) fail(`${setting}.outputs`);
178
+ const outputs = target.outputs.map((output, index) => parseOutput(output, `${setting}.outputs[${index}]`));
179
+ const paths = outputs.flatMap(output => expand(output).map(file => file.path));
180
+ const escaping = paths.find(path => !relativePath(path));
181
+ if (escaping !== undefined) throw new Error(`${setting} writes '${escaping}', which is not a path inside its directory.`);
182
+ // Compared without case, as macOS and Windows file systems do.
183
+ const folded = paths.map(path => path.toLowerCase());
184
+ const repeated = paths.find((_, index) => folded.indexOf(folded[index]!) !== index);
185
+ if (repeated) throw new Error(`${setting} writes ${repeated} more than once.`);
186
+ result.targets[name] = { dir: target.dir ?? null, outputs };
187
+ }
188
+ return result;
189
+ }
190
+
191
+ export interface ImageFile { path: string; width: number; height: number; factor: number | null }
192
+
193
+ /** One file per variant, with its pixel size and the factor that scales layer sizes. */
194
+ export function expand(output: ImageOutput): ImageFile[] {
195
+ if (output.variants === null) {
196
+ const [width, height] = output.size === null ? [0, 0] : pair(output.size);
197
+ return [{ path: output.path, width, height, factor: 1 }];
198
+ }
199
+ return Object.entries(output.variants).map(([key, value]) => {
200
+ const path = output.path.replaceAll("{variant}", key);
201
+ if (Array.isArray(value)) return { path, width: value[0], height: value[1], factor: null };
202
+ const [width, height] = pair(output.size!).map(side => side * value) as [number, number];
203
+ return { path, width, height, factor: value };
204
+ });
205
+ }
@@ -0,0 +1,110 @@
1
+ import { deflateSync } from "node:zlib";
2
+
3
+ export interface Pixels { width: number; height: number; channels: 3 | 4; data: Uint8Array }
4
+
5
+ const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, index) => {
6
+ let value = index;
7
+ for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
8
+ return value >>> 0;
9
+ });
10
+ function crc32(bytes: Uint8Array): number {
11
+ let crc = 0xffffffff;
12
+ for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
13
+ return (crc ^ 0xffffffff) >>> 0;
14
+ }
15
+
16
+ function chunk(type: string, data: Uint8Array): Uint8Array {
17
+ const bytes = new Uint8Array(data.length + 12);
18
+ const view = new DataView(bytes.buffer);
19
+ view.setUint32(0, data.length);
20
+ bytes.set(new TextEncoder().encode(type), 4);
21
+ bytes.set(data, 8);
22
+ view.setUint32(data.length + 8, crc32(bytes.subarray(4, data.length + 8)));
23
+ return bytes;
24
+ }
25
+
26
+ function paeth(left: number, up: number, upLeft: number): number {
27
+ const estimate = left + up - upLeft;
28
+ const toLeft = Math.abs(estimate - left), toUp = Math.abs(estimate - up), toUpLeft = Math.abs(estimate - upLeft);
29
+ return toLeft <= toUp && toLeft <= toUpLeft ? left : toUp <= toUpLeft ? up : upLeft;
30
+ }
31
+
32
+ /**
33
+ * Premultiplied RGBA from the renderer to straight-alpha RGBA, or to RGB when
34
+ * the image is opaque: app stores reject icons that carry an alpha channel.
35
+ */
36
+ export function fromPremultiplied(width: number, height: number, rgba: Uint8Array, opaque: boolean): Pixels {
37
+ const channels = opaque ? 3 : 4;
38
+ const data = new Uint8Array(width * height * channels);
39
+ for (let pixel = 0; pixel < width * height; pixel += 1) {
40
+ const alpha = rgba[pixel * 4 + 3]!;
41
+ if (opaque && alpha !== 255) throw new Error("An opaque image rendered a transparent pixel.");
42
+ for (let channel = 0; channel < 3; channel += 1) {
43
+ const value = rgba[pixel * 4 + channel]!;
44
+ data[pixel * channels + channel] = alpha === 0 ? 0 : alpha === 255 ? value : Math.min(255, Math.round(value * 255 / alpha));
45
+ }
46
+ if (!opaque) data[pixel * 4 + 3] = alpha;
47
+ }
48
+ return { width, height, channels, data };
49
+ }
50
+
51
+ /** A deterministic PNG: no timestamps or text, and each row's filter chosen by the usual minimum-sum heuristic. */
52
+ export function encodePng({ width, height, channels, data }: Pixels): Uint8Array {
53
+ const stride = width * channels;
54
+ const filtered = new Uint8Array((stride + 1) * height);
55
+ const candidates = Array.from({ length: 5 }, () => new Uint8Array(stride));
56
+ const empty = new Uint8Array(stride);
57
+ for (let row = 0; row < height; row += 1) {
58
+ const current = data.subarray(row * stride, (row + 1) * stride);
59
+ const previous = row ? data.subarray((row - 1) * stride, row * stride) : empty;
60
+ let best = 0, bestScore = Infinity;
61
+ for (let type = 0; type < 5; type += 1) {
62
+ const output = candidates[type]!;
63
+ let score = 0;
64
+ for (let index = 0; index < stride; index += 1) {
65
+ const left = index >= channels ? current[index - channels]! : 0;
66
+ const up = previous[index]!, upLeft = index >= channels ? previous[index - channels]! : 0;
67
+ const predictor = type === 0 ? 0 : type === 1 ? left : type === 2 ? up : type === 3 ? (left + up) >> 1 : paeth(left, up, upLeft);
68
+ const value = (current[index]! - predictor) & 0xff;
69
+ output[index] = value;
70
+ score += value < 128 ? value : 256 - value;
71
+ if (score >= bestScore) break;
72
+ }
73
+ if (score < bestScore) { best = type; bestScore = score; }
74
+ }
75
+ filtered[row * (stride + 1)] = best;
76
+ filtered.set(candidates[best]!, row * (stride + 1) + 1);
77
+ }
78
+ const header = new DataView(new ArrayBuffer(13));
79
+ header.setUint32(0, width);
80
+ header.setUint32(4, height);
81
+ header.setUint8(8, 8);
82
+ header.setUint8(9, channels === 3 ? 2 : 6);
83
+ return Buffer.concat([
84
+ Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10),
85
+ chunk("IHDR", new Uint8Array(header.buffer)),
86
+ chunk("IDAT", deflateSync(filtered, { level: 9 })),
87
+ chunk("IEND", new Uint8Array()),
88
+ ]);
89
+ }
90
+
91
+ /** A Windows icon that stores one PNG per size, which every current browser reads. */
92
+ export function encodeIco(images: readonly Pixels[]): Uint8Array {
93
+ const pngs = images.map(encodePng);
94
+ const directory = new DataView(new ArrayBuffer(6 + images.length * 16));
95
+ directory.setUint16(2, 1, true);
96
+ directory.setUint16(4, images.length, true);
97
+ let offset = directory.byteLength;
98
+ for (const [index, image] of images.entries()) {
99
+ const entry = 6 + index * 16;
100
+ // A stored dimension of 0 means 256 pixels.
101
+ directory.setUint8(entry, image.width % 256);
102
+ directory.setUint8(entry + 1, image.height % 256);
103
+ directory.setUint16(entry + 4, 1, true);
104
+ directory.setUint16(entry + 6, 32, true);
105
+ directory.setUint32(entry + 8, pngs[index]!.length, true);
106
+ directory.setUint32(entry + 12, offset, true);
107
+ offset += pngs[index]!.length;
108
+ }
109
+ return Buffer.concat([new Uint8Array(directory.buffer), ...pngs]);
110
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Just enough XML to inline brand sources in a composed document: one copy per
3
+ * layer with prefixed ids, named elements dropped, and features refused when
4
+ * they would render differently between machines, leak between layers, or
5
+ * script or fetch when an output is opened.
6
+ */
7
+
8
+ export interface SvgSource {
9
+ name: string;
10
+ viewBox: [number, number, number, number];
11
+ /** Root presentation attributes, such as fill or shape-rendering, that the inlined copy keeps. */
12
+ attributes: [string, string][];
13
+ namespaces: Record<string, string>;
14
+ ids: Set<string>;
15
+ tokens: Token[];
16
+ }
17
+
18
+ type Token =
19
+ | { kind: "raw"; text: string }
20
+ | { kind: "start"; name: string; attributes: [string, string][]; selfClosing: boolean }
21
+ | { kind: "end"; name: string };
22
+
23
+ const TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>|<!DOCTYPE[^>[]*(\[[\s\S]*?\])?\s*>|<\/([A-Za-z_][\w.:-]*)\s*>|<([A-Za-z_][\w.:-]*)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|[^<]+/y;
24
+ const ATTRIBUTE = /\s+([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
25
+ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
26
+ // Static SVG only: shapes, paint servers, clipping, masking, and filters. Animation, links, and markup
27
+ // from other namespaces could script or fetch when an output is opened, so they are refused, not trusted.
28
+ const ELEMENTS = new Set([
29
+ "circle", "clipPath", "defs", "desc", "ellipse", "feBlend", "feColorMatrix", "feComponentTransfer", "feComposite",
30
+ "feConvolveMatrix", "feDiffuseLighting", "feDisplacementMap", "feDistantLight", "feDropShadow", "feFlood", "feFuncA",
31
+ "feFuncB", "feFuncG", "feFuncR", "feGaussianBlur", "feImage", "feMerge", "feMergeNode", "feMorphology", "feOffset",
32
+ "fePointLight", "feSpecularLighting", "feSpotLight", "feTile", "feTurbulence", "filter", "g", "image", "line",
33
+ "linearGradient", "marker", "mask", "metadata", "path", "pattern", "polygon", "polyline", "radialGradient", "rect",
34
+ "stop", "svg", "switch", "symbol", "title", "use",
35
+ ]);
36
+ const REASONS: Record<string, string> = {
37
+ style: "a <style> element; use presentation attributes",
38
+ text: "text, which depends on installed fonts; convert it to paths",
39
+ };
40
+ const ROOT_GEOMETRY = new Set(["xmlns", "version", "baseProfile", "width", "height", "x", "y", "viewBox", "preserveAspectRatio", "overflow", "id"]);
41
+
42
+ const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
43
+
44
+ /** Attribute values as an XML parser sees them, so checks cannot be bypassed by encoding. */
45
+ function decode(name: string, value: string): string {
46
+ if (/&(?!(?:#x[0-9a-fA-F]{1,6}|#[0-9]{1,7}|amp|lt|gt|quot|apos);)/.test(value)) throw new Error(`Image source '${name}' uses an undefined entity in '${value.slice(0, 40)}'.`);
47
+ const decoded = value.replace(/&(?:#x([0-9a-fA-F]+)|#([0-9]+)|(amp|lt|gt|quot|apos));/g, (reference, hex?: string, decimal?: string, entity?: string) => {
48
+ if (entity) return ENTITIES[entity]!;
49
+ const code = hex ? Number.parseInt(hex, 16) : Number(decimal);
50
+ if (code < 1 || code > 0x10ffff) throw new Error(`Image source '${name}' has an invalid character reference '${reference}'.`);
51
+ return String.fromCodePoint(code);
52
+ });
53
+ // CSS escapes could spell url( in a way the reference check cannot see; SVG values never need them.
54
+ if (decoded.includes("\\")) throw new Error(`Image source '${name}' uses a CSS escape in '${value.slice(0, 40)}'.`);
55
+ return decoded;
56
+ }
57
+
58
+ function tokenize(name: string, text: string): Token[] {
59
+ const tokens: Token[] = [];
60
+ TOKEN.lastIndex = 0;
61
+ while (TOKEN.lastIndex < text.length) {
62
+ const match = TOKEN.exec(text);
63
+ if (!match) throw new Error(`Image source '${name}' is not well-formed SVG near offset ${TOKEN.lastIndex}.`);
64
+ if (match[1] !== undefined) throw new Error(`Image source '${name}' declares a DOCTYPE subset; remove it.`);
65
+ if (match[0].startsWith("<!DOCTYPE") && /["']/.test(match[0]) && !/^<!DOCTYPE\s+svg\s+PUBLIC\s+"[^"]*"\s+"http:\/\/www\.w3\.org\/[^"]*"\s*>$/.test(match[0])) {
66
+ throw new Error(`Image source '${name}' names a DOCTYPE other than the W3C SVG DTD; remove it.`);
67
+ }
68
+ if (match[2] !== undefined) tokens.push({ kind: "end", name: match[2] });
69
+ else if (match[3] !== undefined) {
70
+ const attributes: [string, string][] = [];
71
+ for (const attribute of match[4]!.matchAll(ATTRIBUTE)) {
72
+ if (attributes.some(([key]) => key === attribute[1])) throw new Error(`Image source '${name}' repeats attribute '${attribute[1]}'.`);
73
+ attributes.push([attribute[1]!, decode(name, attribute[2] ?? attribute[3]!)]);
74
+ }
75
+ tokens.push({ kind: "start", name: match[3], attributes, selfClosing: match[5] === "/" });
76
+ } else tokens.push({ kind: "raw", text: match[0] });
77
+ }
78
+ return tokens;
79
+ }
80
+
81
+ /** The name without its namespace prefix: any prefix may be bound to the SVG or XLink namespace. */
82
+ function local(name: string): string { return name.slice(name.indexOf(":") + 1); }
83
+
84
+ /** Event handlers, stylesheets, and references to anything outside the file except embedded images, are refused. */
85
+ function checkAttributes(name: string, element: string, attributes: [string, string][]): void {
86
+ for (const [key, value] of attributes) {
87
+ if (/^on/i.test(local(key))) throw new Error(`Image source '${name}' has a '${key}' event handler.`);
88
+ if (local(key) === "style") throw new Error(`Image source '${name}' has a style attribute; use presentation attributes.`);
89
+ if (local(key) === "base") throw new Error(`Image source '${name}' sets '${key}', which could resolve references elsewhere.`);
90
+ // CSS image functions can fetch without url(), for example through a mask presentation attribute.
91
+ if (key !== "id" && /(?:image-set|image|cross-fade|element|src)\s*\(/i.test(value)) throw new Error(`Image source '${name}' uses a CSS image function in '${value.slice(0, 40)}'.`);
92
+ const external = local(key) === "href"
93
+ ? !value.startsWith("#") && !(["image", "feImage"].includes(local(element)) && value.startsWith("data:"))
94
+ : value.split(/url\(/i).slice(1).some(target => !target.trimStart().replace(/^['"]/, "").startsWith("#"));
95
+ if (external) throw new Error(`Image source '${name}' references '${value.slice(0, 40)}'; embed it or reference an element in the file.`);
96
+ }
97
+ }
98
+
99
+ interface Scope { default: string | undefined; prefixes: Record<string, string> }
100
+ function declare(parent: Scope, attributes: [string, string][]): Scope {
101
+ const declarations = attributes.filter(([key]) => key === "xmlns" || key.startsWith("xmlns:"));
102
+ if (!declarations.length) return parent;
103
+ const scope: Scope = { default: parent.default, prefixes: { ...parent.prefixes } };
104
+ for (const [key, value] of declarations) if (key === "xmlns") scope.default = value; else scope.prefixes[key.slice(6)] = value;
105
+ return scope;
106
+ }
107
+
108
+ function number(value: string | undefined): number | undefined {
109
+ const parsed = value === undefined ? NaN : Number(value.trim().replace(/px$/, ""));
110
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
111
+ }
112
+
113
+ export function parseSvg(name: string, text: string): SvgSource {
114
+ const all = tokenize(name, text);
115
+ const start = all.findIndex(token => token.kind !== "raw");
116
+ const root = all[start];
117
+ const prologue = all.slice(0, Math.max(start, 0));
118
+ if (!root || root.kind !== "start" || root.name !== "svg" || prologue.some(token => token.kind === "raw" && !/^(?:\s+|<!--[\s\S]*-->|<\?xml\s[\s\S]*\?>|<!DOCTYPE[\s\S]*>)$/.test(token.text))) {
119
+ throw new Error(`Image source '${name}' must have an <svg> root element.`);
120
+ }
121
+ const rootAttributes = new Map(root.attributes);
122
+ checkAttributes(name, root.name, root.attributes);
123
+ if (rootAttributes.get("xmlns") !== SVG_NAMESPACE) throw new Error(`Image source '${name}' must declare xmlns="${SVG_NAMESPACE}".`);
124
+ const box = rootAttributes.get("viewBox")?.trim().split(/[\s,]+/).map(Number);
125
+ const width = number(rootAttributes.get("width")), height = number(rootAttributes.get("height"));
126
+ let viewBox: [number, number, number, number];
127
+ if (box) {
128
+ if (box.length !== 4 || box.some(value => !Number.isFinite(value)) || box[2]! <= 0 || box[3]! <= 0) throw new Error(`Image source '${name}' has an invalid viewBox.`);
129
+ viewBox = box as [number, number, number, number];
130
+ } else if (width && height) viewBox = [0, 0, width, height];
131
+ else throw new Error(`Image source '${name}' needs a viewBox or numeric width and height.`);
132
+
133
+ const tokens: Token[] = [], ids = new Set<string>(), stack: string[] = [], scopes = [declare({ default: undefined, prefixes: {} }, root.attributes)];
134
+ // Ends on the root's closing tag, or on the root itself when it is self-closing.
135
+ let index = start;
136
+ if (!root.selfClosing) {
137
+ for (index = start + 1; index < all.length; index += 1) {
138
+ const token = all[index]!;
139
+ if (token.kind === "end") {
140
+ if (stack.length === 0) { if (token.name !== "svg") throw new Error(`Image source '${name}' closes </${token.name}> without opening it.`); break; }
141
+ if (stack.pop() !== token.name) throw new Error(`Image source '${name}' closes </${token.name}> out of order.`);
142
+ scopes.pop();
143
+ } else if (token.kind === "start") {
144
+ const scope = declare(scopes.at(-1)!, token.attributes);
145
+ const prefix = token.name.includes(":") ? token.name.slice(0, token.name.indexOf(":")) : undefined;
146
+ if ((prefix === undefined ? scope.default : scope.prefixes[prefix]) !== SVG_NAMESPACE || !ELEMENTS.has(local(token.name))) {
147
+ throw new Error(`Image source '${name}' contains ${REASONS[local(token.name)] ?? `an unsupported <${token.name}> element; keep only static SVG shapes, paint, clipping, masking, and filters`}.`);
148
+ }
149
+ checkAttributes(name, token.name, token.attributes);
150
+ for (const [key, value] of token.attributes) if (key === "id") { if (ids.has(value)) throw new Error(`Image source '${name}' repeats id '${value}'.`); ids.add(value); }
151
+ if (!token.selfClosing) { stack.push(token.name); scopes.push(scope); }
152
+ } else if (token.text.startsWith("<?")) throw new Error(`Image source '${name}' contains a processing instruction.`);
153
+ tokens.push(token);
154
+ }
155
+ if (index === all.length) throw new Error(`Image source '${name}' never closes its <svg> root.`);
156
+ }
157
+ if (all.slice(index + 1).some(token => token.kind !== "raw" || !/^(?:\s+|<!--[\s\S]*-->)$/.test(token.text))) throw new Error(`Image source '${name}' has content after its <svg> root.`);
158
+
159
+ const namespaces: Record<string, string> = {};
160
+ const attributes: [string, string][] = [];
161
+ for (const [key, value] of root.attributes) {
162
+ if (key.startsWith("xmlns:")) namespaces[key.slice(6)] = value;
163
+ else if (!ROOT_GEOMETRY.has(key)) attributes.push([key, value]);
164
+ }
165
+ return { name, viewBox, attributes, namespaces, ids, tokens };
166
+ }
167
+
168
+ export function attribute(key: string, value: string): string {
169
+ return ` ${key}="${value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll('"', "&quot;")}"`;
170
+ }
171
+
172
+ /** Points `url(#id)` references at a copy whose ids carry the prefix. */
173
+ export function renameReferences(value: string, prefix: string): string {
174
+ return value.replace(/url\(\s*(['"]?)#([^'")\s]+)\1\s*\)/gi, (_, _quote, id: string) => `url(#${prefix}${id})`);
175
+ }
176
+
177
+ /** The source's children with every id prefixed, internal references rewritten, and hidden elements removed. */
178
+ export function inlineSvg(source: SvgSource, prefix: string, hide: ReadonlySet<string>): string {
179
+ const rename = (value: string) => renameReferences(value, prefix);
180
+ let markup = "", skipped = 0;
181
+ for (const token of source.tokens) {
182
+ if (token.kind === "start") {
183
+ if (skipped > 0 || token.attributes.some(([key, value]) => key === "id" && hide.has(value))) { if (!token.selfClosing) skipped += 1; continue; }
184
+ markup += `<${token.name}`;
185
+ for (const [key, value] of token.attributes) {
186
+ if (key === "id") markup += attribute(key, `${prefix}${value}`);
187
+ else if (local(key) === "href" && value.startsWith("#")) markup += attribute(key, `#${prefix}${value.slice(1)}`);
188
+ else markup += attribute(key, rename(value));
189
+ }
190
+ markup += token.selfClosing ? "/>" : ">";
191
+ } else if (token.kind === "end") {
192
+ if (skipped > 0) skipped -= 1;
193
+ else markup += `</${token.name}>`;
194
+ } else if (skipped === 0) markup += token.text;
195
+ }
196
+ return markup;
197
+ }
@@ -0,0 +1,5 @@
1
+ // Bun embeds the file in compiled executables and resolves it from node_modules otherwise.
2
+ declare module "@resvg/resvg-wasm/index_bg.wasm" {
3
+ const path: string;
4
+ export default path;
5
+ }