@ankhorage/devtools 1.10.15 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -296,9 +296,9 @@ The canonical Bun policy is defined once in devtools and consumed by both packag
296
296
  <!-- devtools-bun-policy:start -->
297
297
 
298
298
  ```text
299
- Bun runtime 1.4.0
300
- packageManager bun@1.4.0
301
- @types/bun ^1.4.0
299
+ Bun runtime 1.4.1
300
+ packageManager bun@1.4.1
301
+ @types/bun ^1.4.1
302
302
  ```
303
303
 
304
304
  <!-- devtools-bun-policy:end -->
@@ -1,7 +1,7 @@
1
1
  export declare const bunRuntimePolicy: {
2
- readonly packageManager: "bun@1.4.0";
3
- readonly typesRange: "^1.4.0";
4
- readonly version: "1.4.0";
2
+ readonly packageManager: "bun@1.4.1";
3
+ readonly typesRange: "^1.4.1";
4
+ readonly version: "1.4.1";
5
5
  };
6
6
  /**
7
7
  * Canonical Node LTS baseline for Node-based Ankhorage tooling and CI execution.
@@ -4,7 +4,7 @@
4
4
  * Import these from `@ankhorage/devtools/policy` when another package needs to inspect
5
5
  * the managed Bun or Node baseline without defining an independent version authority.
6
6
  */
7
- const BUN_VERSION = '1.4.0';
7
+ const BUN_VERSION = '1.4.1';
8
8
  export const bunRuntimePolicy = {
9
9
  packageManager: `bun@${BUN_VERSION}`,
10
10
  typesRange: `^${BUN_VERSION}`,
@@ -0,0 +1,64 @@
1
+ export declare const ICON_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path d=\"M2 2h20v20H2z\"/></svg>";
2
+ export declare const IMAGE_PNG: Buffer<ArrayBuffer>;
3
+ /*** Create manifest configuration shared by the asset scaffolding fixture. */
4
+ export declare function createFixtureManifest(theme: Record<string, unknown>): Record<string, unknown>;
5
+ /*** Create separate screen evidence, image content and SVG with runtime references. */
6
+ export declare function createAssetFixture(root: string): Promise<{
7
+ bundle: {
8
+ assets: {
9
+ mediaId: string;
10
+ role: string;
11
+ sourcePath: string;
12
+ targetPath: string;
13
+ contentType: string;
14
+ usages: string[];
15
+ }[];
16
+ screens: {
17
+ sourcePath: string;
18
+ targetPath: string;
19
+ }[];
20
+ };
21
+ bundlePath: string;
22
+ manifest: {
23
+ media: {
24
+ assets: {
25
+ [k: string]: {
26
+ id: string;
27
+ kind: string;
28
+ source: {
29
+ kind: string;
30
+ path: string;
31
+ };
32
+ contentType: string;
33
+ };
34
+ };
35
+ };
36
+ navigator: {
37
+ type: string;
38
+ routes: {
39
+ name: string;
40
+ screenId: string;
41
+ icon: {
42
+ source: {
43
+ mediaId: string;
44
+ };
45
+ };
46
+ }[];
47
+ };
48
+ screens: {
49
+ home: {
50
+ id: string;
51
+ name: string;
52
+ root: {
53
+ id: string;
54
+ type: string;
55
+ props: {
56
+ source: {
57
+ mediaId: string;
58
+ };
59
+ };
60
+ };
61
+ };
62
+ };
63
+ };
64
+ }>;
@@ -0,0 +1,81 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ export const ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2 2h20v20H2z"/></svg>';
4
+ export const IMAGE_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a7x8AAAAASUVORK5CYII=', 'base64');
5
+ /*** Create manifest configuration shared by the asset scaffolding fixture. */
6
+ export function createFixtureManifest(theme) {
7
+ return {
8
+ metadata: {
9
+ name: 'Evidence Board',
10
+ slug: 'evidence-board',
11
+ version: '1.0.0',
12
+ category: 'business_productivity',
13
+ themeId: 'evidence-theme',
14
+ },
15
+ themes: [theme],
16
+ activeThemeId: 'evidence-theme',
17
+ infra: { modules: [] },
18
+ settings: { localization: { defaultLocale: 'en', locales: ['en'] } },
19
+ };
20
+ }
21
+ /*** Create separate screen evidence, image content and SVG with runtime references. */
22
+ export async function createAssetFixture(root) {
23
+ await mkdir(join(root, 'assets/images/svg'), { recursive: true });
24
+ await mkdir(join(root, 'assets/screens'), { recursive: true });
25
+ await writeFile(join(root, 'assets/images/svg/book.svg'), ICON_SVG);
26
+ await writeFile(join(root, 'assets/images/cover.png'), IMAGE_PNG);
27
+ await writeFile(join(root, 'assets/screens/home.png'), IMAGE_PNG);
28
+ const assets = createAssetEntries();
29
+ const bundle = {
30
+ assets,
31
+ screens: [{ sourcePath: 'assets/screens/home.png', targetPath: 'assets/screens/home.png' }],
32
+ };
33
+ const bundlePath = join(root, 'design-assets.json');
34
+ await writeFile(bundlePath, JSON.stringify(bundle));
35
+ const manifest = {
36
+ media: {
37
+ assets: Object.fromEntries(assets.map((asset) => [
38
+ asset.mediaId,
39
+ {
40
+ id: asset.mediaId,
41
+ kind: 'image',
42
+ source: { kind: 'bundled', path: asset.targetPath },
43
+ contentType: asset.contentType,
44
+ },
45
+ ])),
46
+ },
47
+ navigator: {
48
+ type: 'stack',
49
+ routes: [{ name: 'home', screenId: 'home', icon: { source: { mediaId: 'book' } } }],
50
+ },
51
+ screens: {
52
+ home: {
53
+ id: 'home',
54
+ name: 'Home',
55
+ root: { id: 'cover-image', type: 'Image', props: { source: { mediaId: 'cover' } } },
56
+ },
57
+ },
58
+ };
59
+ return { bundle, bundlePath, manifest };
60
+ }
61
+ /*** Describe the reusable icon and image inputs independently of filesystem setup. */
62
+ function createAssetEntries() {
63
+ return [
64
+ {
65
+ mediaId: 'book',
66
+ role: 'icon',
67
+ sourcePath: 'assets/images/svg/book.svg',
68
+ targetPath: 'assets/images/svg/book.svg',
69
+ contentType: 'image/svg+xml',
70
+ usages: ['/navigator/routes/0/icon/source'],
71
+ },
72
+ {
73
+ mediaId: 'cover',
74
+ role: 'image',
75
+ sourcePath: 'assets/images/cover.png',
76
+ targetPath: 'assets/images/cover.png',
77
+ contentType: 'image/png',
78
+ usages: ['/screens/home/root/props/source'],
79
+ },
80
+ ];
81
+ }
@@ -59,6 +59,11 @@ work. Release validation still decides whether the complete manifest is shippabl
59
59
 
60
60
  ## Template output
61
61
 
62
+ For every creation mode, read [runtime-assets.md](references/runtime-assets.md). Inventory icons
63
+ and images during configuration, produce separate reusable files with the screens, and carry the
64
+ same checked asset bundle into template generation. Direct template requests prepare that bundle
65
+ before scaffolding as well.
66
+
62
67
  A Templates repository template is exactly one portable unit:
63
68
 
64
69
  ```text
@@ -67,11 +72,13 @@ src/templates/categories/{appCategory}/{slug}/
67
72
  assets/
68
73
  screens/
69
74
  images/
75
+ svg/
70
76
  ```
71
77
 
72
78
  `createAppManifest.ts` default-exports a function returning the complete `AppManifest`.
73
79
  `assets/screens/` contains design evidence only. Runtime media uses real application image regions
74
- under `assets/images/`; rebuild text, controls, icons, surfaces, and layout with ZORA.
80
+ under `assets/images/`, with vector icons under `assets/images/svg/`. Rebuild text, controls,
81
+ surfaces and layout with ZORA; use `Icon source` and `Image` for the corresponding asset references.
75
82
 
76
83
  Scaffold only a reviewed, release-valid manifest:
77
84
 
@@ -79,7 +86,8 @@ Scaffold only a reviewed, release-valid manifest:
79
86
  bun .agents/skills/zora-designer/scripts/scaffold-template.ts scaffold-input.json
80
87
  ```
81
88
 
82
- The helper creates the template directory and regenerates discovery from the filesystem. Do not add
89
+ The helper requires `assetBundlePath`, checks files and manifest references before output, copies
90
+ runtime assets and screen evidence, and regenerates discovery from the filesystem. Do not add
83
91
  category registries, seed definitions, fallback templates, compatibility paths, or per-template
84
92
  barrels.
85
93
 
@@ -93,4 +101,6 @@ barrels.
93
101
  output;
94
102
  - keep unsupported interactions and capability gaps explicit;
95
103
  - keep concept screens separate from runtime assets;
104
+ - validate the separate asset bundle before screen delivery and its manifest usages before template
105
+ scaffolding; report visual review and runtime verification separately;
96
106
  - for deterministic artifact shape, read [artifact.md](references/artifact.md).
@@ -31,6 +31,9 @@ is never invented:
31
31
  - `tokens`: current owner-computed token output, never copied token definitions;
32
32
  - `components`: metadata-backed recipe decisions and required states;
33
33
  - `screens`: ordered screen specifications and evidence relationships;
34
+ - `assets`: portable `bundlePath`, completion `status`, and `entries` describing each icon/image,
35
+ region usages, provenance, dimensions and visual review; planned assets remain explicit during
36
+ configuration and must be present for screen/template completion;
34
37
  - `validation`: scope, gates, application gate, owner/runtime drift, and blockers;
35
38
  - `auditInput`: criterion and release-gate assessments consumed by the canonical calculator;
36
39
  - `findings`, `risks`, `openDecisions`, and preserved `userNotes`.
@@ -0,0 +1,131 @@
1
+ # Separate runtime assets from screen evidence
2
+
3
+ Read this reference for `interactive`, `screen`, `screens`, and `template`. Every icon and real image
4
+ region used in a designed screen must have a separately usable asset before screen delivery and
5
+ template promotion. Reuse one file for repeated content across screens and states.
6
+
7
+ ## Asset-first design
8
+
9
+ During interactive configuration, inventory the intended icon and image regions alongside the
10
+ screen list. Configuration alone may leave files planned. Once the requested screen production
11
+ starts, produce the individual assets as part of that same deliverable. An explicit request for
12
+ assets during `interactive` also authorizes producing them; keep unresolved design choices visible.
13
+
14
+ For direct `template` requests, perform the same asset preparation before scaffolding, even when
15
+ no concept screens exist. A request that already authorizes creation does not require another
16
+ confirmation merely for these normal asset preparation steps.
17
+
18
+ - SVG icons: author real vector paths, or reuse matching licensed vector artwork with attribution.
19
+ Keep a consistent viewBox, geometry and stroke weight. Use `currentColor` for themeable icons.
20
+ Do not wrap a raster crop or embedded PNG in an SVG. A raster image generator cannot produce
21
+ true SVG geometry; use vector authoring and validate the XML with an available XML tool.
22
+ - Photos, covers, avatars, logos and illustrations: generate or reuse each distinct image as a
23
+ separate file with the required crop, resolution and transparency. Use an available image
24
+ generation/editing tool for new raster content. Do not claim generation when that tool is absent.
25
+ Preserve suitable user-supplied source assets. For an existing screen image, extract only the
26
+ image region when its quality is sufficient; otherwise regenerate that region separately.
27
+ - Generate assets before concept screens. Supply those files as image references when creating
28
+ the screens and inspect that the resulting content matches. For exact fidelity, render a ZORA
29
+ composition using the files. A concept model's approximation is not proof of exact reuse.
30
+ - Inspect each separate file and the screens visually. If a concept introduces another icon or
31
+ image, produce that asset and reconcile the inventory before calling the screen deliverable
32
+ complete. Do not substitute the whole screen image for application content.
33
+
34
+ Keep one portable design directory until template promotion:
35
+
36
+ ```text
37
+ design-assets.json
38
+ assets/screens/home.png
39
+ assets/images/cover.png
40
+ assets/images/svg/book.svg
41
+ ```
42
+
43
+ Use a task-specific artifact directory for screen-only work. Do not scaffold a template just to
44
+ store its assets. Carry this same bundle into `template`; do not generate a second inconsistent set.
45
+ Changing an asset or screen region invalidates its previous visual review and affected references.
46
+
47
+ ## Asset bundle contract
48
+
49
+ `design-assets.json` is a handoff inventory, not runtime manifest authority. Source paths are
50
+ relative to its directory; targets are relative to the future template root. Record every runtime
51
+ use as a JSON pointer to the `{ "mediaId": "..." }` reference in the planned/final manifest.
52
+ The normal app model and component schemas still come from the installed owners.
53
+
54
+ ```json
55
+ {
56
+ "assets": [
57
+ {
58
+ "mediaId": "book-icon",
59
+ "role": "icon",
60
+ "sourcePath": "assets/images/svg/book.svg",
61
+ "targetPath": "assets/images/svg/book.svg",
62
+ "contentType": "image/svg+xml",
63
+ "usages": ["/navigator/routes/0/icon/source"]
64
+ },
65
+ {
66
+ "mediaId": "book-cover",
67
+ "role": "image",
68
+ "sourcePath": "assets/images/cover.png",
69
+ "targetPath": "assets/images/cover.png",
70
+ "contentType": "image/png",
71
+ "usages": ["/screens/home/root/children/0/props/source"]
72
+ }
73
+ ],
74
+ "screens": [{ "sourcePath": "assets/screens/home.png", "targetPath": "assets/screens/home.png" }]
75
+ }
76
+ ```
77
+
78
+ The pointers above are examples; use the actual topology. Empty arrays explicitly declare no
79
+ runtime assets or no screen evidence. Supported portable image files are SVG, PNG, JPEG and WebP.
80
+ Preserve source attribution, generation prompts, region purpose, visual review and dimensions in
81
+ the design artifact's `assets.entries`; do not infer those facts from a filename.
82
+
83
+ Before handing off screens, run:
84
+
85
+ ```text
86
+ bun .agents/skills/zora-designer/scripts/asset-bundle.ts path/to/design-assets.json
87
+ ```
88
+
89
+ This checks the files, path confinement, duplicates, basic image signatures and standalone SVG
90
+ shape. It does not decode images, parse all XML, assess visual quality or prove runtime rendering.
91
+ Perform those relevant visual/XML checks separately and record their evidence. Missing assets
92
+ block asset completion, not unrelated configuration discussion.
93
+
94
+ ## Manifest and template promotion
95
+
96
+ Register every bundle asset in `manifest.media.assets` as `kind: image`, with matching `id`,
97
+ `contentType` and bundled `source.path`. SVG icons belong under `assets/images/svg/`; other images
98
+ belong under `assets/images/`. Screen evidence stays under `assets/screens/` and is never media.
99
+
100
+ Use the existing ZORA `Icon` with `source: { mediaId }` for these SVGs, including navigator icons.
101
+ Use ZORA `Image` with its owner-supported media source for application imagery, or an exact semantic
102
+ component's supported media prop. Do not add an SVG provider or a separate SVG component. Named
103
+ font icons remain valid for existing designs when requested, but do not substitute them for the
104
+ separate SVG assets of this workflow. Inspect current owner metadata and runtime media resolution.
105
+
106
+ Run the bundle checker with `manifest.json` to check registered assets and actual reference paths.
107
+ Pass the same file as `assetBundlePath` to the scaffolder:
108
+
109
+ ```json
110
+ {
111
+ "targetDirectory": "/path/to/templates",
112
+ "category": "books_reading",
113
+ "slug": "reader",
114
+ "assetBundlePath": "/path/to/design/design-assets.json",
115
+ "manifest": {}
116
+ }
117
+ ```
118
+
119
+ Replace the example empty manifest with the complete release-valid owner manifest. The scaffolder
120
+ validates and reads all asset bytes before creating output, copies them to their checked targets,
121
+ and returns every created file. It fails on missing files, dangling media references, unused bundle
122
+ entries, mismatched registrations or invalid paths. Owner release validation remains mandatory.
123
+
124
+ After scaffolding, inspect the exported template artifact and verify generated runtime usage through
125
+ the consuming app's media resolver. A passing bundle/manifest check alone does not prove that Studio
126
+ or another consumer renders the files. Report missing consumer support explicitly; never call an
127
+ untested runtime path verified.
128
+
129
+ For generated applications, verify that the installed runtime preserves image media references
130
+ until media resolution (`@ankhorage/runtime` 2.2.6 or newer). The Icon manifest node and normal
131
+ container support for Icon/Image require ZORA 4.2.0 or newer; the owner helper enforces this minimum.
@@ -28,7 +28,10 @@ For every screen record:
28
28
  4. route relationship and continuity of selected item, filters, progress, drafts, or other state;
29
29
  5. exact metadata-backed ZORA elements, supported events/actions, data needs, and capability gaps;
30
30
  6. safe areas, keyboard/overlay behavior, scroll ownership, and narrow/wide behavior;
31
- 7. real image content that must become an application asset rather than screenshot UI.
31
+ 7. every SVG icon and real image region, its reusable asset ID, and its intended manifest usage.
32
+
33
+ Read [runtime-assets.md](runtime-assets.md). Produce and inspect the separate icons and images before
34
+ concept rendering, use them as screen references, and deliver their checked bundle with the screens.
32
35
 
33
36
  Create one shared shell specification for a series: viewport, safe areas, gutters, header geometry,
34
37
  navigation geometry, surface treatment, type roles, icon family, content density, and state styling.
@@ -72,6 +75,7 @@ Generate one image per distinct screen or state. Every prompt must include:
72
75
  - exact screen title, copy that must be legible, hierarchy, components, and primary action;
73
76
  - content quantity that fits the viewport at the declared type scale;
74
77
  - invariants shared with the other screens;
78
+ - the prepared icon and image assets for every media region, retaining their visual identity;
75
79
  - prohibitions against device frames, presentation boards, watermarks, illegible labels, invented
76
80
  tabs, and oversized marketing typography on ordinary application screens.
77
81
 
@@ -42,7 +42,8 @@ Advance through this sequence. Skip only a value already supplied or reliably di
42
42
  recommendation first and allow the user to accept the recommended system settings together.
43
43
  7. **Screens.** Ask for the ordered screen list and purpose of each screen. Then establish the
44
44
  primary action, essential content, required data states, and continuity across the series. Do not
45
- generate images yet.
45
+ generate images yet. Inventory each icon and image region using
46
+ [runtime-assets.md](runtime-assets.md), including reused assets and their intended consumers.
46
47
  8. **Navigator.** After the screen topology exists, offer only Contracts navigator types and map
47
48
  every route, initial route, hidden/detail route, back/cancel path, and primary-navigation label.
48
49
  9. **Compile and confirm.** Compile both theme modes through the owner helper. Summarize high-impact
@@ -75,9 +76,10 @@ removing the intended flow or inventing a contract.
75
76
  - `interactive`: write or return the confirmed `zora-designer.md`; do not produce screen images or
76
77
  implementation unless requested.
77
78
  - `screen` or `screens`: read [screens.md](screens.md), create the screen specification, then produce
78
- only the requested image, code, or manifest deliverable.
79
+ the requested deliverable together with its separate SVG and image asset bundle.
79
80
  - `template`: author a complete `AppManifest`, validate it in release mode, generate or extract every
80
- required runtime image, and run the Templates scaffolder.
81
+ required SVG and runtime image, validate the asset bundle against the manifest, and run the
82
+ Templates scaffolder with `assetBundlePath`.
81
83
 
82
84
  For design-first templates, store reference images under `assets/screens/` and crop only real image
83
85
  content into `assets/images/`. For direct authoring, generate application images directly under
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { readFile } from 'node:fs/promises';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ import { assertAssetPath, assertImageContent, readAssetFile } from './asset-files.ts';
8
+
9
+ interface AssetEntry {
10
+ mediaId: string;
11
+ role: 'icon' | 'image';
12
+ sourcePath: string;
13
+ targetPath: string;
14
+ contentType: string;
15
+ usages: string[];
16
+ }
17
+
18
+ export interface PreparedAssetFile {
19
+ targetPath: string;
20
+ bytes: Buffer;
21
+ }
22
+
23
+ /*** Validate a portable design asset bundle before screen handoff or template writes. */
24
+ export async function prepareAssetBundle(
25
+ bundlePath: string,
26
+ manifest?: Record<string, unknown>,
27
+ ): Promise<PreparedAssetFile[]> {
28
+ const input: unknown = JSON.parse(await readFile(bundlePath, 'utf8'));
29
+ assertRecord(input);
30
+ if (!Array.isArray(input.assets) || !Array.isArray(input.screens)) {
31
+ throw new Error('Asset bundle requires assets and screens arrays, including when empty.');
32
+ }
33
+ const assets = input.assets.map(parseAssetEntry);
34
+ const ids = assets.map((asset) => asset.mediaId);
35
+ if (new Set(ids).size !== ids.length) throw new Error('Duplicate asset mediaId.');
36
+ if (manifest) validateManifestAssets(assets, manifest);
37
+ const root = dirname(resolve(bundlePath));
38
+ const files = await Promise.all(
39
+ assets.map(async (asset) => {
40
+ const bytes = await readAssetFile(root, asset.sourcePath);
41
+ assertImageContent(bytes, asset.contentType);
42
+ return { targetPath: asset.targetPath, bytes };
43
+ }),
44
+ );
45
+ for (const screen of input.screens) {
46
+ assertRecord(screen);
47
+ const sourcePath = requireString(screen.sourcePath);
48
+ const targetPath = requireString(screen.targetPath);
49
+ assertAssetPath(sourcePath, 'assets/screens/');
50
+ assertAssetPath(targetPath, 'assets/screens/');
51
+ files.push({ targetPath, bytes: await readAssetFile(root, sourcePath) });
52
+ }
53
+ if (new Set(files.map((file) => file.targetPath.toLowerCase())).size !== files.length) {
54
+ throw new Error('Duplicate asset targetPath.');
55
+ }
56
+ return files;
57
+ }
58
+
59
+ /*** Parse one generated runtime asset and its manifest JSON-pointer usages. */
60
+ function parseAssetEntry(value: unknown): AssetEntry {
61
+ assertRecord(value);
62
+ if (value.role !== 'icon' && value.role !== 'image')
63
+ throw new Error('Asset role must be icon or image.');
64
+ const sourcePath = requireString(value.sourcePath);
65
+ const targetPath = requireString(value.targetPath);
66
+ const contentType = requireString(value.contentType);
67
+ const prefix = value.role === 'icon' ? 'assets/images/svg/' : 'assets/images/';
68
+ assertAssetPath(sourcePath, prefix);
69
+ assertAssetPath(targetPath, prefix);
70
+ if (value.role === 'icon' && contentType !== 'image/svg+xml')
71
+ throw new Error('Icon assets must be SVG.');
72
+ const extensions: Partial<Record<string, string[]>> = {
73
+ 'image/svg+xml': ['svg'],
74
+ 'image/png': ['png'],
75
+ 'image/jpeg': ['jpg', 'jpeg'],
76
+ 'image/webp': ['webp'],
77
+ };
78
+ if (
79
+ ![sourcePath, targetPath].every((path) =>
80
+ extensions[contentType]?.includes(path.split('.').at(-1) ?? ''),
81
+ )
82
+ ) {
83
+ throw new Error('Asset extension must match contentType.');
84
+ }
85
+ if (!Array.isArray(value.usages) || value.usages.length === 0)
86
+ throw new Error('Asset usages must identify at least one manifest reference.');
87
+ const usages = value.usages.map(requireString);
88
+ if (usages.some((path) => !path.startsWith('/') || path.startsWith('/media/')))
89
+ throw new Error('Asset usages must point to runtime consumers outside /media/.');
90
+ return {
91
+ mediaId: requireString(value.mediaId),
92
+ role: value.role,
93
+ sourcePath,
94
+ targetPath,
95
+ contentType,
96
+ usages,
97
+ };
98
+ }
99
+
100
+ /*** Match bundled files to registered media and actual runtime references. */
101
+ function validateManifestAssets(assets: AssetEntry[], manifest: Record<string, unknown>): void {
102
+ const media = manifest.media ?? {};
103
+ assertRecord(media);
104
+ const registry = media.assets ?? {};
105
+ assertRecord(registry);
106
+ for (const [id, definition] of Object.entries(registry)) {
107
+ assertRecord(definition);
108
+ assertRecord(definition.source);
109
+ if (definition.source.kind !== 'bundled') continue;
110
+ const asset = assets.find((entry) => entry.mediaId === id);
111
+ if (
112
+ !asset ||
113
+ definition.id !== id ||
114
+ definition.kind !== 'image' ||
115
+ definition.source.path !== asset.targetPath ||
116
+ definition.contentType !== asset.contentType
117
+ ) {
118
+ throw new Error(`Bundled media must match an asset entry: ${id}`);
119
+ }
120
+ }
121
+ for (const asset of assets) {
122
+ const definition = registry[asset.mediaId];
123
+ assertRecord(definition);
124
+ assertRecord(definition.source);
125
+ if (definition.source.kind !== 'bundled')
126
+ throw new Error(`Asset must register bundled media: ${asset.mediaId}`);
127
+ for (const usage of asset.usages) {
128
+ const reference = resolvePointer(manifest, usage);
129
+ assertRecord(reference);
130
+ if (reference.mediaId !== asset.mediaId)
131
+ throw new Error(`Asset usage does not reference ${asset.mediaId}: ${usage}`);
132
+ }
133
+ }
134
+ const { media: _media, ...runtime } = manifest;
135
+ validateReferences(runtime, registry);
136
+ }
137
+
138
+ /*** Reject dangling media references anywhere in runtime manifest content. */
139
+ function validateReferences(value: unknown, registry: Record<string, unknown>): void {
140
+ if (Array.isArray(value)) {
141
+ value.forEach((item) => validateReferences(item, registry));
142
+ return;
143
+ }
144
+ if (typeof value !== 'object' || value === null) return;
145
+ if (
146
+ 'mediaId' in value &&
147
+ typeof value.mediaId === 'string' &&
148
+ !Object.hasOwn(registry, value.mediaId)
149
+ )
150
+ throw new Error(`Unknown runtime mediaId: ${value.mediaId}`);
151
+ Object.values(value).forEach((item) => validateReferences(item, registry));
152
+ }
153
+
154
+ /*** Resolve a standard JSON pointer without evaluating paths as code. */
155
+ function resolvePointer(root: unknown, pointer: string): unknown {
156
+ return pointer
157
+ .slice(1)
158
+ .split('/')
159
+ .reduce<unknown>((value, segment) => {
160
+ const key = segment.replaceAll('~1', '/').replaceAll('~0', '~');
161
+ if (Array.isArray(value)) return value[Number(key)];
162
+ assertRecord(value);
163
+ return value[key];
164
+ }, root);
165
+ }
166
+
167
+ /*** Require a JSON object at the asset input boundary. */
168
+ function assertRecord(value: unknown): asserts value is Record<string, unknown> {
169
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
170
+ throw new Error('Expected an asset or media object.');
171
+ }
172
+
173
+ /*** Require a nonempty asset input string. */
174
+ function requireString(value: unknown): string {
175
+ if (typeof value !== 'string' || value.trim() === '')
176
+ throw new Error('Expected a nonempty asset string.');
177
+ return value;
178
+ }
179
+
180
+ /*** Validate one asset bundle, optionally against a complete manifest. */
181
+ async function main(): Promise<void> {
182
+ const [bundlePath, manifestPath] = process.argv.slice(2);
183
+ if (!bundlePath) throw new Error('Usage: asset-bundle.ts <design-assets.json> [manifest.json]');
184
+ const manifest: unknown = manifestPath
185
+ ? JSON.parse(await readFile(manifestPath, 'utf8'))
186
+ : undefined;
187
+ if (manifest !== undefined) assertRecord(manifest);
188
+ const files = await prepareAssetBundle(bundlePath, manifest);
189
+ console.log(
190
+ JSON.stringify(
191
+ {
192
+ status: 'pass',
193
+ scope: manifest ? 'files-and-references' : 'files',
194
+ files: files.map((file) => file.targetPath),
195
+ },
196
+ null,
197
+ 2,
198
+ ),
199
+ );
200
+ }
201
+
202
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
203
+ main().catch((error: unknown) => {
204
+ console.error(error instanceof Error ? error.message : String(error));
205
+ process.exitCode = 1;
206
+ });
207
+ }
@@ -0,0 +1,45 @@
1
+ import { readFile, realpath, stat } from 'node:fs/promises';
2
+ import { resolve, sep } from 'node:path';
3
+
4
+ /*** Require a portable asset destination without traversal or ambiguous separators. */
5
+ export function assertAssetPath(value: string, prefix: string): void {
6
+ if (
7
+ !value.startsWith(prefix) ||
8
+ value.split('/').some((part) => !part || part === '.' || part === '..') ||
9
+ /[\\:]/u.test(value) ||
10
+ [...value].some((character) => character.charCodeAt(0) < 32)
11
+ ) {
12
+ throw new Error(`Asset path must be a portable file below ${prefix}: ${value}`);
13
+ }
14
+ }
15
+
16
+ /*** Read a nonempty regular source file confined to the asset bundle directory. */
17
+ export async function readAssetFile(root: string, path: string): Promise<Buffer> {
18
+ assertAssetPath(path, 'assets/');
19
+ const base = await realpath(root);
20
+ const source = await realpath(resolve(root, path));
21
+ if (!source.startsWith(`${base}${sep}`) || !(await stat(source)).isFile()) {
22
+ throw new Error(`Asset source must be a regular file inside the bundle: ${path}`);
23
+ }
24
+ const bytes = await readFile(source);
25
+ if (bytes.length === 0) throw new Error(`Asset file is empty: ${path}`);
26
+ return bytes;
27
+ }
28
+
29
+ /*** Check image signatures and require standalone vector markup for SVG icons. */
30
+ export function assertImageContent(bytes: Buffer, contentType: string): void {
31
+ const text = bytes.toString('utf8');
32
+ const signatures: Partial<Record<string, boolean>> = {
33
+ 'image/png': bytes.subarray(0, 8).toString('hex') === '89504e470d0a1a0a',
34
+ 'image/jpeg': bytes.subarray(0, 3).toString('hex') === 'ffd8ff',
35
+ 'image/webp':
36
+ bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP',
37
+ 'image/svg+xml':
38
+ /<svg\b[^>]*\bviewBox=["'][^"']+["']/u.test(text) &&
39
+ /<\/svg>\s*$/u.test(text) &&
40
+ !/<(?:image|script|foreignObject)\b|<!DOCTYPE|data:image|\b(?:href|onload)\s*=/iu.test(text),
41
+ };
42
+ if (signatures[contentType] !== true) {
43
+ throw new Error(`Asset bytes do not match a supported standalone image: ${contentType}`);
44
+ }
45
+ }
@@ -213,6 +213,7 @@ export function serializeArtifact(input: unknown, audit: unknown) {
213
213
  tokens: input.tokens ?? {},
214
214
  components: input.components ?? { stateRequirements: [], recipeDecisions: {} },
215
215
  screens: input.screens ?? [],
216
+ assets: input.assets ?? { bundlePath: null, status: 'not-run', entries: [] },
216
217
  validation: input.validation ?? {
217
218
  scope: documentKind === 'audit' ? 'audit' : 'configuration',
218
219
  status: 'not-run',
@@ -117,9 +117,9 @@ interface LoadedOwnerModule {
117
117
 
118
118
  const OWNER_RELEASES = {
119
119
  colorTheory: { packageName: '@ankhorage/color-theory', minimumVersion: '0.3.0' },
120
- contracts: { packageName: '@ankhorage/contracts', minimumVersion: '8.2.0' },
121
- templates: { packageName: '@ankhorage/templates', minimumVersion: '8.0.0' },
122
- zora: { packageName: '@ankhorage/zora', minimumVersion: '4.0.0' },
120
+ contracts: { packageName: '@ankhorage/contracts', minimumVersion: '10.1.0' },
121
+ templates: { packageName: '@ankhorage/templates', minimumVersion: '9.3.0' },
122
+ zora: { packageName: '@ankhorage/zora', minimumVersion: '4.2.0' },
123
123
  };
124
124
 
125
125
  const OWNER_REQUIREMENTS = {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
- import { join, relative, resolve, sep } from 'node:path';
4
+ import { dirname, join, relative, resolve, sep } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
 
7
7
  import { generateTemplateCatalog } from './generate-template-catalog.ts';
@@ -13,6 +13,7 @@ export async function scaffoldTemplate(input: unknown) {
13
13
  assertNonEmptyString(input.targetDirectory, 'targetDirectory');
14
14
  assertNonEmptyString(input.category, 'category');
15
15
  assertNonEmptyString(input.slug, 'slug');
16
+ assertNonEmptyString(input.assetBundlePath, 'assetBundlePath');
16
17
  const { category, slug, targetDirectory: inputTargetDirectory } = input;
17
18
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug)) {
18
19
  throw new Error('slug must be a kebab-case identifier.');
@@ -41,6 +42,10 @@ export async function scaffoldTemplate(input: unknown) {
41
42
  const owners = await loadOwnerApis(targetDirectory);
42
43
  const composition = owners.templates.validateTemplateManifest(inputManifest, 'release');
43
44
  const manifest = owners.templates.assertTemplateManifestReady(composition);
45
+ const assetFiles = await prepareAssetBundle(
46
+ resolve(targetDirectory, input.assetBundlePath),
47
+ manifest,
48
+ );
44
49
 
45
50
  const categoryDirectory = resolve(
46
51
  targetDirectory,
@@ -59,8 +64,14 @@ export async function scaffoldTemplate(input: unknown) {
59
64
 
60
65
  const screensDirectory = join(templateDirectory, 'assets', 'screens');
61
66
  const imagesDirectory = join(templateDirectory, 'assets', 'images');
67
+ const svgDirectory = join(imagesDirectory, 'svg');
62
68
  await mkdir(screensDirectory, { recursive: true });
63
- await mkdir(imagesDirectory, { recursive: true });
69
+ await mkdir(svgDirectory, { recursive: true });
70
+ for (const file of assetFiles) {
71
+ const destination = join(templateDirectory, file.targetPath);
72
+ await mkdir(dirname(destination), { recursive: true });
73
+ await writeFile(destination, file.bytes, { flag: 'wx' });
74
+ }
64
75
  await writeFile(
65
76
  join(templateDirectory, 'createAppManifest.ts'),
66
77
  createManifestSource(manifest),
@@ -72,10 +83,16 @@ export async function scaffoldTemplate(input: unknown) {
72
83
  return {
73
84
  targetDirectory,
74
85
  templateDirectory: relative(targetDirectory, templateDirectory),
75
- createdFiles: [relative(targetDirectory, join(templateDirectory, 'createAppManifest.ts'))],
86
+ createdFiles: [
87
+ relative(targetDirectory, join(templateDirectory, 'createAppManifest.ts')),
88
+ ...assetFiles.map((file) =>
89
+ relative(targetDirectory, join(templateDirectory, file.targetPath)),
90
+ ),
91
+ ],
76
92
  assetDirectories: [
77
93
  relative(targetDirectory, screensDirectory),
78
94
  relative(targetDirectory, imagesDirectory),
95
+ relative(targetDirectory, svgDirectory),
79
96
  ],
80
97
  };
81
98
  }
@@ -146,3 +163,4 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
146
163
  process.exitCode = 1;
147
164
  });
148
165
  }
166
+ import { prepareAssetBundle } from './asset-bundle.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ankhorage/devtools",
3
- "version": "1.10.15",
3
+ "version": "1.11.0",
4
4
  "description": "Shared development tools and repository standards for Ankhorage",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/ankhorage/devtools#readme",
@@ -106,10 +106,10 @@
106
106
  },
107
107
  "dependencies": {
108
108
  "@ankhorage/utility": "^0.3.0",
109
- "@changesets/cli": "^3.0.1",
109
+ "@changesets/cli": "^3.0.2",
110
110
  "@eslint/compat": "^2.1.1",
111
111
  "@eslint/js": "^10.0.1",
112
- "eslint": "^10.9.1",
112
+ "eslint": "^10.10.0",
113
113
  "eslint-config-prettier": "^10.1.8",
114
114
  "eslint-plugin-import": "^2.32.0",
115
115
  "eslint-plugin-prettier": "^5.5.6",
@@ -126,9 +126,9 @@
126
126
  "devDependencies": {
127
127
  "@ankhorage/ankh": "^0.8.10",
128
128
  "@ankhorage/doctor": "0.10.7",
129
- "@types/bun": "^1.4.0",
129
+ "@types/bun": "^1.4.1",
130
130
  "@types/node": "^26.4.1",
131
131
  "typescript": "~6.0.3"
132
132
  },
133
- "packageManager": "bun@1.4.0"
133
+ "packageManager": "bun@1.4.1"
134
134
  }