@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.
- package/dist/tools/skills/assets/zora-designer/SKILL.md +13 -0
- package/dist/tools/skills/assets/zora-designer/references/artifact.md +7 -1
- package/dist/tools/skills/assets/zora-designer/references/workflow.md +21 -7
- package/dist/tools/skills/assets/zora-designer/scripts/audit.mjs +6 -0
- package/dist/tools/skills/assets/zora-designer/scripts/owner-api.mjs +2 -1
- package/dist/tools/skills/assets/zora-designer/scripts/scaffold-template.mjs +39 -6
- package/dist/tools/skills/assets/zora-designer/scripts/template-imagery.mjs +361 -0
- package/dist/tools/skills/zoraDesignerImagery.fixtures.d.ts +14 -0
- package/dist/tools/skills/zoraDesignerImagery.fixtures.js +141 -0
- package/package.json +1 -1
|
@@ -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,
|
|
121
|
-
|
|
122
|
-
variant `manifest.ts`, `template.ts`,
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
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({
|
|
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:
|
|
81
|
-
|
|
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
|
-
|
|
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