@ankhorage/devtools 1.10.2 → 1.10.3

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.
@@ -77,6 +77,19 @@ When a required capability or source is unavailable, report that deliverable or
77
77
  blocked. Do not simulate an image, browser observation, or runtime proof. Clearly distinguish
78
78
  generated concept images from authoritative runtime captures.
79
79
 
80
+ ## Persist imagery before template release
81
+
82
+ For a finalized template, retain three distinct image inventories: ordered generated concept
83
+ screens, ordered authoritative runtime captures, and the original runtime images used by final
84
+ ZORA `Image` nodes. A screenshot never substitutes for its underlying hero, card, avatar, or other
85
+ runtime asset.
86
+
87
+ When scaffolding in Templates, provide these inventories through the normal scaffold input. The
88
+ scaffold copies durable source files into the template asset tree, registers each runtime image once
89
+ in the canonical manifest media pool, validates the resulting bundle through released Templates,
90
+ and writes the inventory to `zora-designer.md`. Read [workflow.md](references/workflow.md) for the
91
+ input contract and release checks.
92
+
80
93
  ## Validate before handoff
81
94
 
82
95
  - Compile the category design and both theme modes through installed owner APIs.
@@ -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
+ - `imagery`: separate ordered `conceptSeries`, ordered `runtimeCaptures`, and `runtimeAssets` with
35
+ their durable template source path, generated-project target path where applicable, media ID,
36
+ dimensions, content type, byte size, origin, and provenance;
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`.
@@ -79,7 +82,10 @@ score impact, risks, passed rules, and not-assessable criteria. Keep unavailable
79
82
 
80
83
  An image or series records each original source separately with dimensions and order. A URL records
81
84
  the capture viewport, theme mode, state, and reproduction. Concept images are labeled generated;
82
- runtime captures are labeled observed/measured and include their capture source.
85
+ runtime captures are labeled observed/measured and include their capture source. Final runtime
86
+ assets additionally record the manifest media ID, checked-in Template source path, and
87
+ generated-project path. These are distinct inventories: a concept or runtime-capture screenshot
88
+ does not prove the underlying screen image asset was retained.
83
89
 
84
90
  ## Output and persistence
85
91
 
@@ -88,7 +88,9 @@ selected/focus states. Define narrow and wide behavior where applicable.
88
88
  For a series also define the shared shell, canonical route topology, state continuity, back/cancel
89
89
  behavior, shared recipes and terminology, and one common theme. A concept image series must remain
90
90
  ordered and visually coherent. Label every generated image as a concept; replace it with an actual
91
- runtime capture only after running the manifest.
91
+ runtime capture only after running the manifest. Before final template release, retain the original
92
+ files for every concept image, runtime capture, and final-screen runtime image with their measured
93
+ dimensions, mode/state/order, origin, and provenance.
92
94
 
93
95
  ## 5. Audit a URL, image, or series
94
96
 
@@ -117,12 +119,24 @@ When the target repository is `@ankhorage/templates`, scaffold reviewed composit
117
119
  bun .agents/skills/zora-designer/scripts/scaffold-template.mjs scaffold-input.json
118
120
  ```
119
121
 
120
- The scaffold input provides the ready manifest, category, template ID, label, description, and
121
- target root. The helper verifies the Templates repository and owner validation, creates a normal
122
- variant `manifest.ts`, `template.ts`, and `index.ts`, and updates the category registry import and
123
- definition deterministically. It refuses existing targets, unsafe identifiers, blocked manifests,
124
- and non-Templates repositories. Review and validate the generated production diff; do not hand-hide
125
- a systemic skill or owner defect.
122
+ The scaffold input provides the ready manifest, category, template ID, label, description, target
123
+ root, artifact input, and optional `imagery` inventory. The helper verifies the Templates repository
124
+ and owner validation, creates a normal variant `manifest.ts`, `template.ts`, `assets.ts`, and
125
+ `index.ts`, updates the category registry import and definition deterministically, and writes
126
+ `zora-designer.md` with durable visual-asset inventory.
127
+
128
+ Each `imagery.conceptSeries` and `imagery.runtimeCaptures` entry supplies `id`, `sourcePath`,
129
+ `contentType`, measured `width`/`height`, `mode`, `state`, `order`, `origin`, and `provenance`.
130
+ Each `imagery.runtimeAssets` entry supplies `mediaId`, `name`, `sourcePath`, `contentType`, measured
131
+ `width`/`height`, `origin`, and `provenance`. The helper preserves source bytes under deterministic
132
+ template evidence/runtime paths, derives collision-safe generated-project paths, creates the
133
+ canonical bundled media entries, and validates the complete asset bundle through the released
134
+ Templates API. Supply only durable local source files: `blob:` and `data:` URLs, symlinks, missing
135
+ files, unsupported image formats, incomplete metadata, and duplicate identities are blocked.
136
+
137
+ The helper refuses existing targets, unsafe identifiers, blocked manifests, and non-Templates
138
+ repositories. Review and validate the generated production diff; do not hand-hide a systemic skill
139
+ or owner defect.
126
140
 
127
141
  ## 7. Delivery gates
128
142
 
@@ -12,6 +12,7 @@ const HUMAN_SECTIONS = [
12
12
  'Layout, shape, elevation, and motion',
13
13
  'Component and interaction states',
14
14
  'Screen specifications',
15
+ 'Visual assets',
15
16
  'Accessibility and validation',
16
17
  'Audit summary',
17
18
  'Findings and remediation',
@@ -111,6 +112,11 @@ export function serializeArtifact(input, audit) {
111
112
  tokens: input.tokens ?? {},
112
113
  components: input.components ?? { stateRequirements: [], recipeDecisions: {} },
113
114
  screens: input.screens ?? [],
115
+ imagery: input.imagery ?? {
116
+ conceptSeries: [],
117
+ runtimeCaptures: [],
118
+ runtimeAssets: [],
119
+ },
114
120
  validation: input.validation ?? {
115
121
  scope: documentKind === 'audit' ? 'audit' : 'configuration',
116
122
  status: 'not-run',
@@ -5,7 +5,7 @@ import { dirname, join, parse, resolve } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
 
7
7
  const OWNER_RELEASES = {
8
- templates: { packageName: '@ankhorage/templates', minimumVersion: '8.0.0' },
8
+ templates: { packageName: '@ankhorage/templates', minimumVersion: '9.0.0' },
9
9
  zora: { packageName: '@ankhorage/zora', minimumVersion: '4.0.0' },
10
10
  };
11
11
 
@@ -22,6 +22,7 @@ const OWNER_REQUIREMENTS = {
22
22
  'composeCategoryAppManifest',
23
23
  'validateTemplateManifest',
24
24
  'assertTemplateManifestReady',
25
+ 'createStarterTemplateArtifact',
25
26
  ],
26
27
  },
27
28
  zoraTheme: {
@@ -4,7 +4,9 @@ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { join, relative, resolve, sep } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
 
7
+ import { serializeArtifact } from './audit.mjs';
7
8
  import { loadOwnerApis } from './owner-api.mjs';
9
+ import { prepareTemplateImagery, writeTemplateImagery } from './template-imagery.mjs';
8
10
 
9
11
  /*** Validate and scaffold one ready authored manifest into the normal Templates variant layout. */
10
12
  export async function scaffoldTemplate(input) {
@@ -54,38 +56,57 @@ export async function scaffoldTemplate(input) {
54
56
  );
55
57
  }
56
58
 
59
+ const imagery = await prepareTemplateImagery(input, targetDirectory, manifest, owners);
57
60
  const registryPath = join(categoryDirectory, 'index.ts');
58
61
  const registrySource = await readFile(registryPath, 'utf8');
59
62
  const symbol = toPascalCase(input.templateId);
60
63
  const factoryBase = symbol.endsWith('Starter') ? symbol.slice(0, -'Starter'.length) : symbol;
61
64
  const factoryName = `create${factoryBase}StarterTemplate`;
62
65
  const manifestName = `AUTHORED_${toConstantCase(input.templateId)}_MANIFEST`;
66
+ const assetsName = `AUTHORED_${toConstantCase(input.templateId)}_ASSETS`;
63
67
  const registrySourceUpdated = updateCategoryRegistry(registrySource, {
64
68
  templateId: input.templateId,
65
69
  label: input.label,
66
70
  description: input.description,
67
71
  factoryName,
72
+ assetsName,
68
73
  });
69
- const files = createTemplateFiles({ manifest, manifestName, factoryName });
74
+ const files = createTemplateFiles({
75
+ manifest: imagery.artifact.manifest,
76
+ manifestName,
77
+ factoryName,
78
+ assets: imagery.artifact.assets,
79
+ assetsName,
80
+ });
81
+ const artifactPath = resolve(targetDirectory, input.artifactPath ?? 'zora-designer.md');
82
+ assertInside(targetDirectory, artifactPath);
83
+ const artifact = serializeArtifact({ ...(input.artifact ?? {}), imagery: imagery.inventory });
70
84
 
71
85
  await mkdir(variantDirectory, { recursive: true });
72
86
  for (const [fileName, contents] of Object.entries(files)) {
73
87
  await writeFile(join(variantDirectory, fileName), contents);
74
88
  }
89
+ await writeTemplateImagery(imagery.files, targetDirectory, writeFile, mkdir);
75
90
  await writeFile(registryPath, registrySourceUpdated);
91
+ await writeFile(artifactPath, artifact);
76
92
 
77
93
  return {
78
94
  targetDirectory,
79
95
  registryPath: relative(targetDirectory, registryPath),
80
- createdFiles: Object.keys(files).map((fileName) =>
81
- relative(targetDirectory, join(variantDirectory, fileName)),
82
- ),
96
+ createdFiles: [
97
+ ...Object.keys(files).map((fileName) =>
98
+ relative(targetDirectory, join(variantDirectory, fileName)),
99
+ ),
100
+ ...imagery.files.map((file) => file.destinationPath),
101
+ relative(targetDirectory, artifactPath),
102
+ ].sort(),
83
103
  factoryName,
104
+ imagery: imagery.inventory,
84
105
  };
85
106
  }
86
107
 
87
108
  /*** Create normal manifest, factory, and entrypoint source for one authored starter variant. */
88
- function createTemplateFiles({ manifest, manifestName, factoryName }) {
109
+ function createTemplateFiles({ manifest, manifestName, factoryName, assets, assetsName }) {
89
110
  const manifestSource = `import type { AppManifest } from '@ankhorage/contracts';
90
111
 
91
112
  export const ${manifestName} = ${JSON.stringify(manifest, null, 2)} satisfies AppManifest;
@@ -114,8 +135,13 @@ export function ${factoryName}(seed: TemplateSeed): AppManifest {
114
135
  activeThemeId: theme.id,
115
136
  };
116
137
  }
138
+ `;
139
+ const assetSource = `import type { StarterTemplateAsset } from '../../../starter.assets';
140
+
141
+ export const ${assetsName} = ${JSON.stringify(assets, null, 2)} satisfies readonly StarterTemplateAsset[];
117
142
  `;
118
143
  return {
144
+ 'assets.ts': assetSource,
119
145
  'index.ts': `export { ${factoryName} } from './template';\n`,
120
146
  'manifest.ts': manifestSource,
121
147
  'template.ts': templateSource,
@@ -125,7 +151,12 @@ export function ${factoryName}(seed: TemplateSeed): AppManifest {
125
151
  /*** Add one stable import and definition to an existing category registry. */
126
152
  function updateCategoryRegistry(source, definition) {
127
153
  const importLine = `import { ${definition.factoryName} } from './${definition.templateId}';`;
128
- if (source.includes(`id: '${definition.templateId}'`) || source.includes(importLine)) {
154
+ const assetsImportLine = `import { ${definition.assetsName} } from './${definition.templateId}/assets';`;
155
+ if (
156
+ source.includes(`id: '${definition.templateId}'`) ||
157
+ source.includes(importLine) ||
158
+ source.includes(assetsImportLine)
159
+ ) {
129
160
  throw new Error(`Template is already registered: ${definition.templateId}`);
130
161
  }
131
162
  const exportMarker = '\nexport const ';
@@ -137,6 +168,7 @@ function updateCategoryRegistry(source, definition) {
137
168
  const relativeImports = [
138
169
  ...prefixLines.filter((line) => /^import .* from '\.\//u.test(line)),
139
170
  importLine,
171
+ assetsImportLine,
140
172
  ].sort((left, right) => left.localeCompare(right));
141
173
  const preservedPrefix = prefixLines.filter((line) => !/^import .* from '\.\//u.test(line));
142
174
  const withImport = `${[...preservedPrefix, ...relativeImports].join('\n')}\n${source.slice(exportIndex + 1)}`;
@@ -150,6 +182,7 @@ function updateCategoryRegistry(source, definition) {
150
182
  label: '${escapeSingleQuoted(definition.label)}',
151
183
  description: '${escapeSingleQuoted(definition.description)}',
152
184
  create: ${definition.factoryName},
185
+ assets: ${definition.assetsName},
153
186
  },
154
187
  `;
155
188
  return `${withImport.slice(0, closeIndex)}${entry}${withImport.slice(closeIndex)}`;
@@ -0,0 +1,361 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile } from 'node:fs/promises';
3
+ import { dirname, relative, resolve, sep } from 'node:path';
4
+
5
+ const IMAGE_CONTENT_TYPES = {
6
+ 'image/avif': '.avif',
7
+ 'image/gif': '.gif',
8
+ 'image/jpeg': '.jpg',
9
+ 'image/png': '.png',
10
+ 'image/svg+xml': '.svg',
11
+ 'image/webp': '.webp',
12
+ };
13
+
14
+ /*** Prepare immutable template evidence and runtime images through the released Templates asset API. */
15
+ export async function prepareTemplateImagery(input, targetDirectory, manifest, owners) {
16
+ const imagery = input.imagery ?? {};
17
+ assertRecord(imagery, 'imagery');
18
+ const root = `assets/templates/${input.category.replaceAll('_', '-')}/${input.templateId}`;
19
+ const conceptSeries = await prepareEvidenceSeries(
20
+ imagery.conceptSeries ?? [],
21
+ 'concept series',
22
+ `${root}/evidence/concepts`,
23
+ );
24
+ const runtimeCaptures = await prepareEvidenceSeries(
25
+ imagery.runtimeCaptures ?? [],
26
+ 'runtime captures',
27
+ `${root}/evidence/runtime-captures`,
28
+ );
29
+ const runtimeAssets = await prepareRuntimeAssets(
30
+ imagery.runtimeAssets ?? [],
31
+ `${root}/runtime`,
32
+ `assets/media/${input.templateId}`,
33
+ );
34
+ const resolvedManifest = registerRuntimeAssets(manifest, runtimeAssets);
35
+ assertRuntimeAssetsAreReferenced(resolvedManifest, runtimeAssets);
36
+ const composition = owners.templates.validateTemplateManifest(resolvedManifest, 'release');
37
+ if (composition.status !== 'ready') {
38
+ throw new Error(
39
+ `Manifest is not release-ready after imagery registration: ${composition.diagnostics.map((item) => item.message).join('; ')}`,
40
+ );
41
+ }
42
+ const readyManifest = owners.templates.assertTemplateManifestReady(composition);
43
+ const artifact = owners.templates.createStarterTemplateArtifact(
44
+ readyManifest,
45
+ runtimeAssets.map((asset) => asset.descriptor),
46
+ );
47
+ const files = [...conceptSeries, ...runtimeCaptures, ...runtimeAssets].sort((left, right) =>
48
+ left.destinationPath.localeCompare(right.destinationPath),
49
+ );
50
+ assertUniqueDestinations(files);
51
+
52
+ return {
53
+ artifact,
54
+ files,
55
+ inventory: {
56
+ conceptSeries: conceptSeries.map(toEvidenceInventory),
57
+ runtimeCaptures: runtimeCaptures.map(toEvidenceInventory),
58
+ runtimeAssets: runtimeAssets.map(toRuntimeAssetInventory),
59
+ },
60
+ };
61
+ }
62
+
63
+ /*** Write only prevalidated copied image bytes into their canonical checked-in Templates paths. */
64
+ export async function writeTemplateImagery(files, targetDirectory, writeFile, mkdir) {
65
+ for (const file of files) {
66
+ const destination = resolve(targetDirectory, file.destinationPath);
67
+ assertInside(targetDirectory, destination, 'Template imagery destination');
68
+ await assertDestinationIsMissing(destination, file.destinationPath);
69
+ await mkdir(dirname(destination), { recursive: true });
70
+ await writeFile(destination, file.contents);
71
+ }
72
+ }
73
+
74
+ /*** Reject accidental replacement of an existing checked-in image outside the new variant boundary. */
75
+ async function assertDestinationIsMissing(destination, relativeDestination) {
76
+ try {
77
+ await lstat(destination);
78
+ } catch (error) {
79
+ if (isNotFoundError(error)) return;
80
+ throw error;
81
+ }
82
+ throw new Error(`Template imagery destination already exists: ${relativeDestination}`);
83
+ }
84
+
85
+ /*** Prepare one ordered concept or runtime-capture series without confusing evidence for runtime media. */
86
+ async function prepareEvidenceSeries(items, label, destinationDirectory) {
87
+ if (!Array.isArray(items)) throw new Error(`${label} must be an array.`);
88
+ const prepared = await Promise.all(
89
+ items.map(async (item) => {
90
+ assertRecord(item, `${label} item`);
91
+ const id = readNonEmptyString(item.id, `${label} item id`);
92
+ const order = readOrder(item.order, `${label} item ${id} order`);
93
+ const mode = readNonEmptyString(item.mode, `${label} item ${id} mode`);
94
+ const state = readNonEmptyString(item.state, `${label} item ${id} state`);
95
+ const image = await readSourceImage(item, `${label} item ${id}`);
96
+ return {
97
+ ...image,
98
+ id,
99
+ order,
100
+ mode,
101
+ state,
102
+ destinationPath: `${destinationDirectory}/${String(order).padStart(3, '0')}-${safeStem(id)}${image.extension}`,
103
+ };
104
+ }),
105
+ );
106
+ assertUniqueIds(prepared, label);
107
+ assertUniqueOrders(prepared, label);
108
+ return prepared.sort(
109
+ (left, right) => left.order - right.order || left.id.localeCompare(right.id),
110
+ );
111
+ }
112
+
113
+ /*** Prepare a runtime image, deriving both its packaged source and generated-project target paths. */
114
+ async function prepareRuntimeAssets(items, sourceDirectory, targetDirectory) {
115
+ if (!Array.isArray(items)) throw new Error('runtimeAssets must be an array.');
116
+ const prepared = await Promise.all(
117
+ items.map(async (item) => {
118
+ assertRecord(item, 'runtime asset');
119
+ const mediaId = readNonEmptyString(item.mediaId, 'runtime asset mediaId');
120
+ const name = readNonEmptyString(item.name, `runtime asset ${mediaId} name`);
121
+ const image = await readSourceImage(item, `runtime asset ${mediaId}`);
122
+ const stem = safeStem(mediaId);
123
+ const sourcePath = `${sourceDirectory}/${stem}${image.extension}`;
124
+ const targetPath = `${targetDirectory}/${stem}${image.extension}`;
125
+ return {
126
+ ...image,
127
+ mediaId,
128
+ name,
129
+ destinationPath: sourcePath,
130
+ descriptor: {
131
+ sourcePath,
132
+ targetPath,
133
+ contentType: image.contentType,
134
+ sizeBytes: image.sizeBytes,
135
+ sha256: image.sha256,
136
+ },
137
+ };
138
+ }),
139
+ );
140
+ assertUniqueIds(prepared, 'runtime assets', 'mediaId');
141
+ return prepared.sort((left, right) => left.mediaId.localeCompare(right.mediaId));
142
+ }
143
+
144
+ /*** Read immutable image bytes and the complete provenance required for a durable template record. */
145
+ async function readSourceImage(item, label) {
146
+ const sourcePath = readNonEmptyString(item.sourcePath, `${label} sourcePath`);
147
+ if (sourcePath.startsWith('blob:') || sourcePath.startsWith('data:')) {
148
+ throw new Error(`${label} sourcePath must name a durable image file, not a transient URL.`);
149
+ }
150
+ const absoluteSourcePath = resolve(sourcePath);
151
+ const stats = await lstat(absoluteSourcePath);
152
+ if (!stats.isFile() || stats.isSymbolicLink()) {
153
+ throw new Error(`${label} sourcePath must be a regular non-symlink file.`);
154
+ }
155
+ const contentType = readContentType(item.contentType, label);
156
+ const width = readPositiveInteger(item.width, `${label} width`);
157
+ const height = readPositiveInteger(item.height, `${label} height`);
158
+ const origin = readNonEmptyString(item.origin, `${label} origin`);
159
+ assertRecord(item.provenance, `${label} provenance`);
160
+ const contents = await readFile(absoluteSourcePath);
161
+ if (contents.byteLength === 0) throw new Error(`${label} sourcePath must not be empty.`);
162
+ return {
163
+ contents,
164
+ contentType,
165
+ extension: IMAGE_CONTENT_TYPES[contentType],
166
+ height,
167
+ origin,
168
+ provenance: item.provenance,
169
+ sha256: createHash('sha256').update(contents).digest('hex'),
170
+ sizeBytes: contents.byteLength,
171
+ width,
172
+ };
173
+ }
174
+
175
+ /*** Register each checked-in runtime file once in the canonical manifest media pool. */
176
+ function registerRuntimeAssets(manifest, runtimeAssets) {
177
+ const resolvedManifest = structuredClone(manifest);
178
+ const media = resolvedManifest.media ?? { assets: {} };
179
+ const assets = media.assets ?? {};
180
+ for (const asset of runtimeAssets) {
181
+ if (assets[asset.mediaId] !== undefined) {
182
+ throw new Error(`Manifest media already defines runtime asset: ${asset.mediaId}`);
183
+ }
184
+ assets[asset.mediaId] = {
185
+ id: asset.mediaId,
186
+ name: asset.name,
187
+ kind: 'image',
188
+ source: { kind: 'bundled', path: asset.descriptor.targetPath },
189
+ contentType: asset.contentType,
190
+ metadata: {
191
+ sizeBytes: asset.sizeBytes,
192
+ width: asset.width,
193
+ height: asset.height,
194
+ },
195
+ };
196
+ }
197
+ resolvedManifest.media = { ...media, assets };
198
+ return resolvedManifest;
199
+ }
200
+
201
+ /*** Require every final runtime image asset to serve an exact canonical ZORA Image media reference. */
202
+ function assertRuntimeAssetsAreReferenced(manifest, runtimeAssets) {
203
+ const imageMediaIds = new Set();
204
+ for (const screen of Object.values(manifest.screens ?? {})) {
205
+ collectImageMediaIds(screen.root, imageMediaIds);
206
+ }
207
+ for (const runtimeAsset of runtimeAssets) {
208
+ if (!imageMediaIds.has(runtimeAsset.mediaId)) {
209
+ throw new Error(
210
+ `Runtime asset is not referenced by a ZORA Image mediaId: ${runtimeAsset.mediaId}`,
211
+ );
212
+ }
213
+ }
214
+ }
215
+
216
+ /*** Collect only explicit Image source media references from one recursively composed manifest tree. */
217
+ function collectImageMediaIds(node, imageMediaIds) {
218
+ if (!isRecord(node)) return;
219
+ if (node.type === 'Image') {
220
+ const mediaId = node.props?.source?.mediaId;
221
+ if (typeof mediaId !== 'string' || mediaId === '') {
222
+ throw new Error('Every final ZORA Image must use one canonical source.mediaId reference.');
223
+ }
224
+ imageMediaIds.add(mediaId);
225
+ }
226
+ for (const child of Array.isArray(node.children) ? node.children : []) {
227
+ collectImageMediaIds(child, imageMediaIds);
228
+ }
229
+ }
230
+
231
+ /*** Project evidence records into a portable artifact inventory without temporary input paths. */
232
+ function toEvidenceInventory(item) {
233
+ return {
234
+ id: item.id,
235
+ path: item.destinationPath,
236
+ order: item.order,
237
+ mode: item.mode,
238
+ state: item.state,
239
+ contentType: item.contentType,
240
+ sizeBytes: item.sizeBytes,
241
+ width: item.width,
242
+ height: item.height,
243
+ origin: item.origin,
244
+ provenance: item.provenance,
245
+ };
246
+ }
247
+
248
+ /*** Project a runtime image into artifact evidence and its canonical manifest/project references. */
249
+ function toRuntimeAssetInventory(item) {
250
+ return {
251
+ mediaId: item.mediaId,
252
+ sourcePath: item.descriptor.sourcePath,
253
+ targetPath: item.descriptor.targetPath,
254
+ contentType: item.contentType,
255
+ sizeBytes: item.sizeBytes,
256
+ width: item.width,
257
+ height: item.height,
258
+ origin: item.origin,
259
+ provenance: item.provenance,
260
+ };
261
+ }
262
+
263
+ /*** Reject duplicate final destinations before any target repository mutation begins. */
264
+ function assertUniqueDestinations(files) {
265
+ const destinations = new Set();
266
+ for (const file of files) {
267
+ if (destinations.has(file.destinationPath)) {
268
+ throw new Error(`Template imagery destination is duplicated: ${file.destinationPath}`);
269
+ }
270
+ destinations.add(file.destinationPath);
271
+ }
272
+ }
273
+
274
+ /*** Reject duplicate source identities while preserving explicit property-specific diagnostics. */
275
+ function assertUniqueIds(items, label, field = 'id') {
276
+ const ids = new Set();
277
+ for (const item of items) {
278
+ const id = item[field];
279
+ if (ids.has(id)) throw new Error(`${label} has a duplicate ${field}: ${id}`);
280
+ ids.add(id);
281
+ }
282
+ }
283
+
284
+ /*** Require a meaningful sequence position for each durable evidence image. */
285
+ function assertUniqueOrders(items, label) {
286
+ const orders = new Set();
287
+ for (const item of items) {
288
+ if (orders.has(item.order)) throw new Error(`${label} has a duplicate order: ${item.order}`);
289
+ orders.add(item.order);
290
+ }
291
+ }
292
+
293
+ /*** Turn arbitrary durable identities into stable collision-resistant portable file stems. */
294
+ function safeStem(value) {
295
+ const normalized = value
296
+ .normalize('NFKD')
297
+ .replace(/[^a-zA-Z0-9]+/gu, '-')
298
+ .replace(/^-+|-+$/gu, '')
299
+ .toLowerCase();
300
+ const readable = normalized === '' ? 'image' : normalized;
301
+ return `${readable}-${createHash('sha256').update(value).digest('hex').slice(0, 12)}`;
302
+ }
303
+
304
+ /*** Require one supported image content type and rely on that owner-compatible extension mapping. */
305
+ function readContentType(value, label) {
306
+ if (typeof value !== 'string' || !(value in IMAGE_CONTENT_TYPES)) {
307
+ throw new Error(`${label} contentType must be one supported bundled image content type.`);
308
+ }
309
+ return value;
310
+ }
311
+
312
+ /*** Require an explicit non-empty string rather than inferring immutable artifact metadata. */
313
+ function readNonEmptyString(value, label) {
314
+ if (typeof value !== 'string' || value.trim() === '')
315
+ throw new Error(`${label} must be non-empty.`);
316
+ return value;
317
+ }
318
+
319
+ /*** Require real measured dimensions and an ordered series index. */
320
+ function readPositiveInteger(value, label) {
321
+ if (!Number.isSafeInteger(value) || value <= 0)
322
+ throw new Error(`${label} must be a positive integer.`);
323
+ return value;
324
+ }
325
+
326
+ /*** Read an explicit nonnegative evidence-series position. */
327
+ function readOrder(value, label) {
328
+ if (!Number.isSafeInteger(value) || value < 0)
329
+ throw new Error(`${label} must be a nonnegative integer.`);
330
+ return value;
331
+ }
332
+
333
+ /*** Reject source and output path escapes at the filesystem handoff boundary. */
334
+ function assertInside(parentDirectory, childPath, label) {
335
+ const relativePath = relative(parentDirectory, childPath);
336
+ if (
337
+ relativePath === '' ||
338
+ relativePath === '..' ||
339
+ relativePath.startsWith(`..${sep}`) ||
340
+ relativePath.includes(`${sep}..${sep}`)
341
+ ) {
342
+ throw new Error(`${label} escapes its owner directory: ${childPath}`);
343
+ }
344
+ }
345
+
346
+ /*** Narrow an unknown input value to one non-array record. */
347
+ function assertRecord(value, label) {
348
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
349
+ throw new Error(`${label} must be an object.`);
350
+ }
351
+ }
352
+
353
+ /*** Narrow filesystem failures to the missing-path condition needed for no-overwrite protection. */
354
+ function isNotFoundError(error) {
355
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT';
356
+ }
357
+
358
+ /*** Narrow unknown manifest data to object-shaped node records. */
359
+ function isRecord(value) {
360
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
361
+ }
@@ -0,0 +1,14 @@
1
+ /*** Remove every temporary Templates fixture created by imagery behavior tests. */
2
+ export declare function cleanupTemplatesFixtures(): Promise<void>;
3
+ /*** Set up the minimum released owner surface required by the packaged skill script. */
4
+ export declare function createTemplatesFixture(): Promise<string>;
5
+ /*** Create one complete input whose manifest is ready before imagery registration. */
6
+ export declare function createScaffoldInput(targetDirectory: string, templateId: string): Record<string, unknown>;
7
+ /*** Write deterministic source images that the skill must retain inside the target template. */
8
+ export declare function writeFixtureImages(target: string): Promise<Record<'capture' | 'concept' | 'hero', string>>;
9
+ /*** Create one complete ordered evidence record for a durable fixture image. */
10
+ export declare function imageEvidence(id: string, sourcePath: string, contentType: string, width: number, height: number, mode: string, state: string, order: number): Record<string, unknown>;
11
+ /*** Write deterministic JSON input to the temporary target repository. */
12
+ export declare function writeJson(path: string, value: unknown): Promise<void>;
13
+ /*** Return the one category registry path owned by this focused fixture. */
14
+ export declare function categoryPath(target: string): string;
@@ -0,0 +1,141 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ const temporaryDirectories = [];
4
+ /*** Remove every temporary Templates fixture created by imagery behavior tests. */
5
+ export async function cleanupTemplatesFixtures() {
6
+ await Promise.all(temporaryDirectories
7
+ .splice(0)
8
+ .map((directory) => rm(directory, { force: true, recursive: true })));
9
+ }
10
+ /*** Set up the minimum released owner surface required by the packaged skill script. */
11
+ export async function createTemplatesFixture() {
12
+ const target = await mkdtemp('/tmp/devtools-zora-imagery-');
13
+ temporaryDirectories.push(target);
14
+ await writeJson(join(target, 'package.json'), {
15
+ name: '@ankhorage/templates',
16
+ version: '9.0.0',
17
+ type: 'module',
18
+ exports: { '.': './index.js', './package.json': './package.json' },
19
+ });
20
+ await writeFile(join(target, 'index.js'), TEMPLATES_FIXTURE_SOURCE);
21
+ await writeFixturePackage(target, '@ankhorage/zora', '4.0.0', {
22
+ './theme': './theme.js',
23
+ './metadata': './metadata.js',
24
+ './package.json': './package.json',
25
+ });
26
+ const zoraDirectory = join(target, 'node_modules/@ankhorage/zora');
27
+ await writeFile(join(zoraDirectory, 'theme.js'), 'export const compileZoraTheme = () => ({});\n');
28
+ await writeFile(join(zoraDirectory, 'metadata.js'), 'export const ZORA_COMPONENT_META = {}; export const ZORA_THEME_RECIPE_META = {};\n');
29
+ await mkdir(categoryPath(target), { recursive: true });
30
+ await writeFile(join(categoryPath(target), 'index.ts'), CATEGORY_REGISTRY_SOURCE);
31
+ return target;
32
+ }
33
+ /*** Create one complete input whose manifest is ready before imagery registration. */
34
+ export function createScaffoldInput(targetDirectory, templateId) {
35
+ return {
36
+ targetDirectory,
37
+ category: 'business_productivity',
38
+ templateId,
39
+ label: 'Evidence Board',
40
+ description: 'A metadata-backed evidence review starter.',
41
+ artifact: { status: 'resolved', source: { mode: 'template', inputs: [], evidence: [] } },
42
+ manifest: createManifest(),
43
+ };
44
+ }
45
+ /*** Write deterministic source images that the skill must retain inside the target template. */
46
+ export async function writeFixtureImages(target) {
47
+ const concept = join(target, 'concept.png');
48
+ const capture = join(target, 'capture.png');
49
+ const hero = join(target, 'hero.svg');
50
+ await Promise.all([
51
+ writeFile(concept, 'concept-image'),
52
+ writeFile(capture, 'runtime-capture'),
53
+ writeFile(hero, '<svg xmlns="http://www.w3.org/2000/svg"/>'),
54
+ ]);
55
+ return { concept, capture, hero };
56
+ }
57
+ /*** Create one complete ordered evidence record for a durable fixture image. */
58
+ export function imageEvidence(id, sourcePath, contentType, width, height, mode, state, order) {
59
+ return {
60
+ id,
61
+ sourcePath,
62
+ contentType,
63
+ width,
64
+ height,
65
+ mode,
66
+ state,
67
+ order,
68
+ origin: 'generated',
69
+ provenance: { tool: 'fixture' },
70
+ };
71
+ }
72
+ /*** Write deterministic JSON input to the temporary target repository. */
73
+ export async function writeJson(path, value) {
74
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
75
+ }
76
+ /*** Return the one category registry path owned by this focused fixture. */
77
+ export function categoryPath(target) {
78
+ return join(target, 'src/templates/starter/categories/business-productivity');
79
+ }
80
+ /*** Install one minimal public ESM package fixture under the target repository. */
81
+ async function writeFixturePackage(target, packageName, version, exports) {
82
+ const directory = join(target, 'node_modules', packageName);
83
+ await mkdir(directory, { recursive: true });
84
+ await writeJson(join(directory, 'package.json'), {
85
+ name: packageName,
86
+ version,
87
+ type: 'module',
88
+ exports,
89
+ });
90
+ }
91
+ /*** Create the released-ready base manifest used by the owner fixture. */
92
+ function createManifest() {
93
+ const theme = {
94
+ id: 'evidence-theme',
95
+ name: 'Evidence Theme',
96
+ light: { primaryColor: '#2563EB', harmony: 'complementary' },
97
+ dark: { primaryColor: '#2563EB', harmony: 'complementary' },
98
+ };
99
+ return {
100
+ metadata: {
101
+ name: 'Evidence Board',
102
+ slug: 'evidence-board',
103
+ version: '1.0.0',
104
+ category: 'business_productivity',
105
+ themeId: theme.id,
106
+ },
107
+ themes: [theme],
108
+ activeThemeId: theme.id,
109
+ infra: { modules: [] },
110
+ navigator: { type: 'stack', routes: [{ name: 'index', screenId: 'home' }] },
111
+ screens: {
112
+ home: {
113
+ id: 'home',
114
+ name: 'Home',
115
+ root: {
116
+ id: 'home-root',
117
+ type: 'View',
118
+ children: [
119
+ { id: 'hero-image', type: 'Image', props: { source: { mediaId: 'hero-image' } } },
120
+ ],
121
+ },
122
+ },
123
+ },
124
+ settings: { localization: { defaultLocale: 'en', locales: ['en'] } },
125
+ };
126
+ }
127
+ const CATEGORY_REGISTRY_SOURCE = `import type { CategoryStarterTemplateDefinition } from '../../starter.types';
128
+
129
+ export const businessProductivityStarterTemplates = [] satisfies readonly CategoryStarterTemplateDefinition[];
130
+ `;
131
+ const TEMPLATES_FIXTURE_SOURCE = `
132
+ export const CATEGORY_PRESETS = {};
133
+ export const TONE_PAIR_CATALOG = [];
134
+ export const resolveTonePair = () => null;
135
+ export const resolveCategoryDesignPreset = () => ({});
136
+ export const compileCategoryDesign = () => ({});
137
+ export const composeCategoryAppManifest = () => ({});
138
+ export const validateTemplateManifest = (manifest) => ({ manifest, diagnostics: [], status: 'ready' });
139
+ export const assertTemplateManifestReady = (composition) => composition.manifest;
140
+ export const createStarterTemplateArtifact = (manifest, assets) => ({ manifest, assets });
141
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ankhorage/devtools",
3
- "version": "1.10.2",
3
+ "version": "1.10.3",
4
4
  "description": "Shared development tools and repository standards for Ankhorage",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/ankhorage/devtools#readme",