@ankhorage/devtools 1.10.13 → 1.10.14

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.
@@ -4,6 +4,117 @@ import { readFile } from 'node:fs/promises';
4
4
  import { dirname, join, parse, resolve } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
 
7
+ interface OwnerRequirement {
8
+ exports: readonly string[];
9
+ minimumVersion: string;
10
+ packageName: string;
11
+ specifier: string;
12
+ }
13
+
14
+ interface Diagnostic extends Record<string, unknown> {
15
+ severity?: unknown;
16
+ }
17
+
18
+ interface ComputedTheme extends Record<string, unknown> {
19
+ diagnostics: Diagnostic[];
20
+ }
21
+
22
+ interface DesignCompilation extends Record<string, unknown> {
23
+ computedTheme: ComputedTheme;
24
+ diagnostics: Diagnostic[];
25
+ }
26
+
27
+ interface ManifestComposition extends Record<string, unknown> {
28
+ diagnostics: Diagnostic[];
29
+ status: string;
30
+ }
31
+
32
+ interface ColorTheoryApi extends Record<string, unknown> {
33
+ COLOR_HARMONIES: readonly string[];
34
+ COLOR_HARMONY_CATALOG: readonly Record<string, unknown>[];
35
+ }
36
+
37
+ interface ContractsApi extends Record<string, unknown> {
38
+ APP_CATEGORIES: readonly string[];
39
+ NAVIGATOR_TYPES: readonly string[];
40
+ }
41
+
42
+ interface TemplatesApi extends Record<string, unknown> {
43
+ CATEGORY_PRESETS: Record<string, Record<string, unknown>>;
44
+ TONE_PAIR_CATALOG: readonly Record<string, unknown>[];
45
+ assertTemplateManifestReady: (composition: ManifestComposition) => Record<string, unknown>;
46
+ compileCategoryDesign: (category: string, theme: Record<string, unknown>) => DesignCompilation;
47
+ composeCategoryAppManifest: (input: Record<string, unknown>) => ManifestComposition;
48
+ resolveCategoryDesignPreset: (...arguments_: unknown[]) => unknown;
49
+ resolveTonePair: (...arguments_: unknown[]) => unknown;
50
+ validateTemplateManifest: (
51
+ manifest: Record<string, unknown>,
52
+ authoringState: string,
53
+ ) => ManifestComposition;
54
+ }
55
+
56
+ interface ZoraThemeApi extends Record<string, unknown> {
57
+ compileZoraTheme: (...arguments_: unknown[]) => unknown;
58
+ }
59
+
60
+ interface EventMetadata extends Record<string, unknown> {
61
+ description: string;
62
+ eventType: string;
63
+ label: string;
64
+ payloadFields?: unknown;
65
+ }
66
+
67
+ interface ComponentMetadata extends Record<string, unknown> {
68
+ allowedChildren: readonly string[];
69
+ directManifestNode: boolean;
70
+ events?: Record<string, EventMetadata>;
71
+ manifestPolicy?: { kind?: unknown };
72
+ name: string;
73
+ props: Record<string, unknown>;
74
+ }
75
+
76
+ interface RecipeFieldMetadata extends Record<string, unknown> {
77
+ options?: readonly string[];
78
+ type: string;
79
+ }
80
+
81
+ interface RecipeMetadata extends Record<string, unknown> {
82
+ fields: Record<string, RecipeFieldMetadata | undefined>;
83
+ kind: string;
84
+ }
85
+
86
+ interface ZoraMetadataApi extends Record<string, unknown> {
87
+ ZORA_COMPONENT_META: Record<string, ComponentMetadata | undefined>;
88
+ ZORA_THEME_RECIPE_META: Record<string, RecipeMetadata | undefined>;
89
+ }
90
+
91
+ interface ManifestNode extends Record<string, unknown> {
92
+ children?: ManifestNode[];
93
+ id: string;
94
+ props?: Record<string, unknown>;
95
+ type: string;
96
+ }
97
+
98
+ interface ManifestScreen extends Record<string, unknown> {
99
+ root: ManifestNode;
100
+ }
101
+
102
+ interface Region extends Record<string, unknown> {
103
+ component?: unknown;
104
+ evidenceId?: unknown;
105
+ id: string;
106
+ parentNodeId?: unknown;
107
+ props?: unknown;
108
+ reason?: unknown;
109
+ requestedCapability: string;
110
+ screenId: string;
111
+ }
112
+
113
+ interface LoadedOwnerModule {
114
+ module: Record<string, unknown>;
115
+ version: string;
116
+ }
117
+
7
118
  const OWNER_RELEASES = {
8
119
  colorTheory: { packageName: '@ankhorage/color-theory', minimumVersion: '0.3.0' },
9
120
  contracts: { packageName: '@ankhorage/contracts', minimumVersion: '8.2.0' },
@@ -48,38 +159,52 @@ const OWNER_REQUIREMENTS = {
48
159
  },
49
160
  };
50
161
 
162
+ /*** Expose the runtime owner gates so tests and callers never duplicate managed versions. */
163
+ export function inspectOwnerRequirements() {
164
+ return structuredClone(OWNER_RELEASES);
165
+ }
166
+
51
167
  /*** Load and validate every released owner API from one target repository. */
52
168
  export async function loadOwnerApis(targetDirectory = process.cwd()) {
53
- const loaded = {};
54
-
55
- for (const [ownerKey, requirement] of Object.entries(OWNER_REQUIREMENTS)) {
56
- loaded[ownerKey] = await loadOwnerModule(targetDirectory, requirement);
57
- }
169
+ const colorTheory = await loadOwnerModule(targetDirectory, OWNER_REQUIREMENTS.colorTheory);
170
+ const contracts = await loadOwnerModule(targetDirectory, OWNER_REQUIREMENTS.contracts);
171
+ const templates = await loadOwnerModule(targetDirectory, OWNER_REQUIREMENTS.templates);
172
+ const zoraTheme = await loadOwnerModule(targetDirectory, OWNER_REQUIREMENTS.zoraTheme);
173
+ const zoraMetadata = await loadOwnerModule(targetDirectory, OWNER_REQUIREMENTS.zoraMetadata);
174
+ assertColorTheoryApi(colorTheory.module);
175
+ assertContractsApi(contracts.module);
176
+ assertTemplatesApi(templates.module);
177
+ assertZoraThemeApi(zoraTheme.module);
178
+ assertZoraMetadataApi(zoraMetadata.module);
58
179
 
59
180
  return {
60
- colorTheory: loaded.colorTheory.module,
61
- contracts: loaded.contracts.module,
62
- templates: loaded.templates.module,
63
- zoraTheme: loaded.zoraTheme.module,
64
- zoraMetadata: loaded.zoraMetadata.module,
181
+ colorTheory: colorTheory.module,
182
+ contracts: contracts.module,
183
+ templates: templates.module,
184
+ zoraTheme: zoraTheme.module,
185
+ zoraMetadata: zoraMetadata.module,
65
186
  versions: {
66
- colorTheory: loaded.colorTheory.version,
67
- contracts: loaded.contracts.version,
68
- templates: loaded.templates.version,
69
- zora: loaded.zoraTheme.version,
187
+ colorTheory: colorTheory.version,
188
+ contracts: contracts.version,
189
+ templates: templates.version,
190
+ zora: zoraTheme.version,
70
191
  },
71
192
  };
72
193
  }
73
194
 
74
195
  /*** Load only Contracts for tooling that runs before another owner package has been built. */
75
- export async function loadContractsApi(targetDirectory = process.cwd()) {
76
- return (await loadOwnerModule(targetDirectory, OWNER_REQUIREMENTS.contracts)).module;
196
+ export async function loadContractsApi(targetDirectory = process.cwd()): Promise<ContractsApi> {
197
+ const loaded = await loadOwnerModule(targetDirectory, OWNER_REQUIREMENTS.contracts);
198
+ assertContractsApi(loaded.module);
199
+ return loaded.module;
77
200
  }
78
201
 
79
202
  /*** Return installed catalogs and metadata names without copying owner definitions. */
80
203
  export async function inspectOwnerApis(targetDirectory = process.cwd()) {
81
204
  const owners = await loadOwnerApis(targetDirectory);
82
- const componentMetadata = Object.values(owners.zoraMetadata.ZORA_COMPONENT_META);
205
+ const componentMetadata = Object.values(owners.zoraMetadata.ZORA_COMPONENT_META).filter(
206
+ (metadata) => metadata !== undefined,
207
+ );
83
208
  return {
84
209
  versions: owners.versions,
85
210
  appCategories: owners.contracts.APP_CATEGORIES,
@@ -109,20 +234,22 @@ export async function inspectOwnerApis(targetDirectory = process.cwd()) {
109
234
  }
110
235
 
111
236
  /*** Compose one design without turning missing runtime/UI capabilities into a design blocker. */
112
- export async function composeDesign(input, targetDirectory = process.cwd()) {
237
+ export async function composeDesign(input: unknown, targetDirectory = process.cwd()) {
113
238
  const owners = await loadOwnerApis(targetDirectory);
114
239
  assertRecord(input, 'Design input');
115
240
  assertNonEmptyString(input.category, 'category');
116
241
  assertRecord(input.navigator, 'navigator');
117
- assertRecord(input.screens, 'screens');
118
- assertSupportedThemeRecipes(input.theme?.recipes, owners.zoraMetadata.ZORA_THEME_RECIPE_META);
242
+ const screens = readManifestScreens(input.screens);
243
+ const theme = input.theme === undefined ? {} : input.theme;
244
+ assertRecord(theme, 'theme');
245
+ assertSupportedThemeRecipes(theme.recipes, owners.zoraMetadata.ZORA_THEME_RECIPE_META);
119
246
 
120
247
  const regionResult = resolveRegionNodes(
121
- input.screens,
248
+ screens,
122
249
  Array.isArray(input.regions) ? input.regions : [],
123
250
  owners.zoraMetadata.ZORA_COMPONENT_META,
124
251
  );
125
- const design = owners.templates.compileCategoryDesign(input.category, input.theme ?? {});
252
+ const design = owners.templates.compileCategoryDesign(input.category, theme);
126
253
  const { computedTheme, ...resolvedDesign } = design;
127
254
  const requestedAuthoringState = input.authoringState === 'release' ? 'release' : 'draft';
128
255
  const composition = owners.templates.composeCategoryAppManifest({
@@ -161,7 +288,11 @@ export async function composeDesign(input, targetDirectory = process.cwd()) {
161
288
  }
162
289
 
163
290
  /*** Resolve explicit region decisions using exact metadata or a visible non-blocking Box placeholder. */
164
- export function resolveRegionNodes(screens, regions, componentMeta) {
291
+ export function resolveRegionNodes(
292
+ screens: Record<string, ManifestScreen | undefined>,
293
+ regions: readonly unknown[],
294
+ componentMeta: Record<string, ComponentMetadata | undefined>,
295
+ ) {
165
296
  const resolvedScreens = structuredClone(screens);
166
297
  const diagnostics = [];
167
298
  const gaps = [];
@@ -171,6 +302,7 @@ export function resolveRegionNodes(screens, regions, componentMeta) {
171
302
  assertNonEmptyString(region.id, 'region.id');
172
303
  assertNonEmptyString(region.screenId, 'region.screenId');
173
304
  assertNonEmptyString(region.requestedCapability, 'region.requestedCapability');
305
+ assertRegion(region);
174
306
  const screen = resolvedScreens[region.screenId];
175
307
  if (!screen) {
176
308
  throw new Error(`Region "${region.id}" targets unknown screen "${region.screenId}".`);
@@ -187,7 +319,10 @@ export function resolveRegionNodes(screens, regions, componentMeta) {
187
319
  return { screens: resolvedScreens, diagnostics, gaps };
188
320
  }
189
321
 
190
- function resolveRegionNode(region, componentMeta) {
322
+ function resolveRegionNode(
323
+ region: Region,
324
+ componentMeta: Record<string, ComponentMetadata | undefined>,
325
+ ) {
191
326
  const component = typeof region.component === 'string' ? region.component : null;
192
327
  const meta = component === null ? null : componentMeta[component];
193
328
  if (meta && meta.directManifestNode && meta.manifestPolicy?.kind !== 'unresolved-element') {
@@ -223,7 +358,6 @@ function resolveRegionNode(region, componentMeta) {
223
358
  return {
224
359
  node: { id: region.id, type: placeholderMeta.name, props: {} },
225
360
  diagnostic: {
226
- regionId: region.id,
227
361
  status: 'placeholder',
228
362
  component: placeholderMeta.name,
229
363
  ...gap,
@@ -233,7 +367,11 @@ function resolveRegionNode(region, componentMeta) {
233
367
  }
234
368
 
235
369
  /*** Validate that manifest props are declared by the selected component metadata. */
236
- function assertSupportedProps(props, meta, regionId) {
370
+ function assertSupportedProps(
371
+ props: unknown,
372
+ meta: ComponentMetadata,
373
+ regionId: string,
374
+ ): Record<string, unknown> {
237
375
  assertRecord(props, `props for region "${regionId}"`);
238
376
  const unsupported = Object.keys(props).filter((name) => !(name in meta.props));
239
377
  if (unsupported.length > 0) {
@@ -245,7 +383,12 @@ function assertSupportedProps(props, meta, regionId) {
245
383
  }
246
384
 
247
385
  /*** Insert a region node under the declared parent or the target screen root. */
248
- function insertRegionNode(screen, parentNodeId, node, componentMeta) {
386
+ function insertRegionNode(
387
+ screen: ManifestScreen,
388
+ parentNodeId: unknown,
389
+ node: ManifestNode,
390
+ componentMeta: Record<string, ComponentMetadata | undefined>,
391
+ ): void {
249
392
  const parent =
250
393
  typeof parentNodeId === 'string' && parentNodeId !== ''
251
394
  ? findNode(screen.root, parentNodeId)
@@ -254,7 +397,7 @@ function insertRegionNode(screen, parentNodeId, node, componentMeta) {
254
397
  throw new Error(`Region parent node not found: ${String(parentNodeId)}`);
255
398
  }
256
399
  const parentMeta = componentMeta[parent.type];
257
- if (!parentMeta || !parentMeta.directManifestNode) {
400
+ if (!parentMeta?.directManifestNode) {
258
401
  throw new Error(`Region parent "${parent.id}" is absent from direct ZORA manifest metadata.`);
259
402
  }
260
403
  if (!parentMeta.allowedChildren.includes(node.type)) {
@@ -266,7 +409,10 @@ function insertRegionNode(screen, parentNodeId, node, componentMeta) {
266
409
  }
267
410
 
268
411
  /*** Validate persisted component and pattern recipe overrides against exact ZORA metadata fields. */
269
- function assertSupportedThemeRecipes(recipes, recipeMeta) {
412
+ function assertSupportedThemeRecipes(
413
+ recipes: unknown,
414
+ recipeMeta: Record<string, RecipeMetadata | undefined>,
415
+ ): void {
270
416
  if (recipes === undefined) return;
271
417
  assertRecord(recipes, 'theme.recipes');
272
418
  for (const [group, kind] of [
@@ -278,13 +424,16 @@ function assertSupportedThemeRecipes(recipes, recipeMeta) {
278
424
  assertRecord(overrides, `theme.recipes.${group}`);
279
425
  for (const [recipeName, fields] of Object.entries(overrides)) {
280
426
  const meta = recipeMeta[recipeName];
281
- if (!meta || meta.kind !== kind) {
427
+ if (meta?.kind !== kind) {
282
428
  throw new Error(`Unknown ZORA ${kind} theme recipe: ${recipeName}.`);
283
429
  }
284
430
  assertRecord(fields, `theme recipe ${recipeName}`);
285
431
  for (const [fieldName, value] of Object.entries(fields)) {
286
432
  const fieldMeta = meta.fields[fieldName];
287
- if (!fieldMeta || !isRecipeValueSupported(value, fieldMeta)) {
433
+ if (fieldMeta === undefined) {
434
+ throw new Error(`Unsupported ZORA theme recipe value: ${recipeName}.${fieldName}.`);
435
+ }
436
+ if (!isRecipeValueSupported(value, fieldMeta)) {
288
437
  throw new Error(`Unsupported ZORA theme recipe value: ${recipeName}.${fieldName}.`);
289
438
  }
290
439
  }
@@ -293,14 +442,14 @@ function assertSupportedThemeRecipes(recipes, recipeMeta) {
293
442
  }
294
443
 
295
444
  /*** Validate one recipe value using its owner-defined boolean, choice, or token field metadata. */
296
- function isRecipeValueSupported(value, fieldMeta) {
445
+ function isRecipeValueSupported(value: unknown, fieldMeta: RecipeFieldMetadata): boolean {
297
446
  if (fieldMeta.type === 'boolean') return typeof value === 'boolean';
298
447
  if (typeof value !== 'string') return false;
299
- return fieldMeta.type !== 'choice' || fieldMeta.options.includes(value);
448
+ return fieldMeta.type !== 'choice' || fieldMeta.options?.includes(value) === true;
300
449
  }
301
450
 
302
451
  /*** Find a manifest node recursively by stable node ID. */
303
- function findNode(node, nodeId) {
452
+ function findNode(node: ManifestNode, nodeId: string): ManifestNode | null {
304
453
  if (node.id === nodeId) return node;
305
454
  for (const child of Array.isArray(node.children) ? node.children : []) {
306
455
  const found = findNode(child, nodeId);
@@ -310,8 +459,11 @@ function findNode(node, nodeId) {
310
459
  }
311
460
 
312
461
  /*** Resolve, version-check, and import one public owner module from the target repository. */
313
- async function loadOwnerModule(targetDirectory, requirement) {
314
- let packageManifestPath;
462
+ async function loadOwnerModule(
463
+ targetDirectory: string,
464
+ requirement: OwnerRequirement,
465
+ ): Promise<LoadedOwnerModule> {
466
+ let packageManifestPath: string;
315
467
  try {
316
468
  packageManifestPath = await findInstalledPackageManifest(
317
469
  targetDirectory,
@@ -325,8 +477,9 @@ async function loadOwnerModule(targetDirectory, requirement) {
325
477
  );
326
478
  }
327
479
 
328
- const packageManifest = JSON.parse(await readFile(packageManifestPath, 'utf8'));
329
- const version = packageManifest.version;
480
+ const packageManifest: unknown = JSON.parse(await readFile(packageManifestPath, 'utf8'));
481
+ assertRecord(packageManifest, `${requirement.packageName} package manifest`);
482
+ const { version } = packageManifest;
330
483
  if (typeof version !== 'string' || compareVersions(version, requirement.minimumVersion) < 0) {
331
484
  throw ownerError(
332
485
  requirement,
@@ -339,7 +492,8 @@ async function loadOwnerModule(targetDirectory, requirement) {
339
492
  requirement.specifier,
340
493
  requirement,
341
494
  );
342
- const ownerModule = await import(pathToFileURL(modulePath).href);
495
+ const ownerModule: unknown = await import(pathToFileURL(modulePath).href);
496
+ assertRecord(ownerModule, `${requirement.packageName} public module`);
343
497
  const missingExports = requirement.exports.filter((name) => !(name in ownerModule));
344
498
  if (missingExports.length > 0) {
345
499
  throw ownerError(requirement, `is missing public exports: ${missingExports.join(', ')}`);
@@ -348,11 +502,15 @@ async function loadOwnerModule(targetDirectory, requirement) {
348
502
  }
349
503
 
350
504
  /*** Find the nearest installed package manifest without falling back to the skill's own tree. */
351
- async function findInstalledPackageManifest(targetDirectory, packageName) {
505
+ async function findInstalledPackageManifest(
506
+ targetDirectory: string,
507
+ packageName: string,
508
+ ): Promise<string> {
352
509
  const resolvedTarget = resolve(targetDirectory);
353
510
  const selfManifestPath = join(resolvedTarget, 'package.json');
354
511
  try {
355
- const selfManifest = JSON.parse(await readFile(selfManifestPath, 'utf8'));
512
+ const selfManifest: unknown = JSON.parse(await readFile(selfManifestPath, 'utf8'));
513
+ assertRecord(selfManifest, 'Target package manifest');
356
514
  if (selfManifest.name === packageName) return selfManifestPath;
357
515
  } catch (error) {
358
516
  if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
@@ -360,7 +518,7 @@ async function findInstalledPackageManifest(targetDirectory, packageName) {
360
518
 
361
519
  const filesystemRoot = parse(resolvedTarget).root;
362
520
  let directory = resolvedTarget;
363
- while (true) {
521
+ for (;;) {
364
522
  const candidate = join(directory, 'node_modules', ...packageName.split('/'), 'package.json');
365
523
  try {
366
524
  await readFile(candidate, 'utf8');
@@ -375,7 +533,12 @@ async function findInstalledPackageManifest(targetDirectory, packageName) {
375
533
  }
376
534
 
377
535
  /*** Resolve one import-condition public export from an installed package manifest. */
378
- function resolvePublicExportPath(packageManifest, packageManifestPath, specifier, requirement) {
536
+ function resolvePublicExportPath(
537
+ packageManifest: Record<string, unknown>,
538
+ packageManifestPath: string,
539
+ specifier: string,
540
+ requirement: OwnerRequirement,
541
+ ): string {
379
542
  const exportKey =
380
543
  specifier === requirement.packageName
381
544
  ? '.'
@@ -383,14 +546,14 @@ function resolvePublicExportPath(packageManifest, packageManifestPath, specifier
383
546
  const exportsField = packageManifest.exports;
384
547
  const rawExport = isRecord(exportsField) ? exportsField[exportKey] : null;
385
548
  const exportTarget = selectImportExportTarget(rawExport);
386
- if (exportTarget === null || !exportTarget.startsWith('./')) {
549
+ if (!exportTarget?.startsWith('./')) {
387
550
  throw ownerError(requirement, `does not expose the import target for ${specifier}`);
388
551
  }
389
552
  return resolve(dirname(packageManifestPath), exportTarget);
390
553
  }
391
554
 
392
555
  /*** Select the canonical ESM import target without resolving a CommonJS compatibility condition. */
393
- function selectImportExportTarget(rawExport) {
556
+ function selectImportExportTarget(rawExport: unknown): string | null {
394
557
  if (typeof rawExport === 'string') return rawExport;
395
558
  if (!isRecord(rawExport)) return null;
396
559
  for (const condition of ['import', 'default', 'bun', 'browser', 'react-native']) {
@@ -400,7 +563,7 @@ function selectImportExportTarget(rawExport) {
400
563
  }
401
564
 
402
565
  /*** Create an actionable released-owner diagnostic without offering a compatibility fallback. */
403
- function ownerError(requirement, detail, cause) {
566
+ function ownerError(requirement: OwnerRequirement, detail: string, cause?: unknown): Error {
404
567
  return new Error(
405
568
  `zora-designer requires ${requirement.packageName} >=${requirement.minimumVersion}; ${detail}. ` +
406
569
  `Update the target dependency through its normal Renovate/release workflow and rerun inspection.`,
@@ -409,7 +572,7 @@ function ownerError(requirement, detail, cause) {
409
572
  }
410
573
 
411
574
  /*** Compare stable semantic versions needed by the released public API gates. */
412
- function compareVersions(left, right) {
575
+ function compareVersions(left: string, right: string): number {
413
576
  const leftParts = parseVersion(left);
414
577
  const rightParts = parseVersion(right);
415
578
  for (let index = 0; index < 3; index += 1) {
@@ -420,26 +583,165 @@ function compareVersions(left, right) {
420
583
  }
421
584
 
422
585
  /*** Parse the numeric major, minor, and patch tuple from a semantic version. */
423
- function parseVersion(version) {
586
+ function parseVersion(version: string): [number, number, number] {
424
587
  const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/u.exec(version);
425
588
  if (!match) return [-1, -1, -1];
426
- return match.slice(1, 4).map(Number);
589
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
590
+ }
591
+
592
+ /*** Read and validate the screen tree boundary supplied by portable JSON input. */
593
+ function readManifestScreens(value: unknown): Record<string, ManifestScreen | undefined> {
594
+ assertRecord(value, 'screens');
595
+ const screens: Record<string, ManifestScreen> = {};
596
+ for (const [screenId, screen] of Object.entries(value)) {
597
+ assertRecord(screen, `screen "${screenId}"`);
598
+ assertManifestNode(screen.root, `screen "${screenId}" root`);
599
+ screens[screenId] = { ...screen, root: screen.root };
600
+ }
601
+ return screens;
602
+ }
603
+
604
+ /*** Validate one manifest node recursively before region insertion. */
605
+ function assertManifestNode(value: unknown, label: string): asserts value is ManifestNode {
606
+ assertRecord(value, label);
607
+ assertNonEmptyString(value.id, `${label}.id`);
608
+ assertNonEmptyString(value.type, `${label}.type`);
609
+ if (value.props !== undefined) assertRecord(value.props, `${label}.props`);
610
+ if (value.children !== undefined) {
611
+ if (!Array.isArray(value.children)) throw new Error(`${label}.children must be an array.`);
612
+ value.children.forEach((child, index) =>
613
+ assertManifestNode(child, `${label}.children[${index}]`),
614
+ );
615
+ }
616
+ }
617
+
618
+ /*** Finish narrowing a region after its required string fields have been checked. */
619
+ function assertRegion(value: Record<string, unknown>): asserts value is Region {
620
+ if (value.props !== undefined) {
621
+ assertRecord(value.props, `props for region "${String(value.id)}"`);
622
+ }
623
+ }
624
+
625
+ /*** Narrow the released Color Theory capability surface used by this script. */
626
+ function assertColorTheoryApi(value: Record<string, unknown>): asserts value is ColorTheoryApi {
627
+ assertStringArray(value.COLOR_HARMONIES, 'COLOR_HARMONIES');
628
+ assertRecordArray(value.COLOR_HARMONY_CATALOG, 'COLOR_HARMONY_CATALOG');
629
+ }
630
+
631
+ /*** Narrow the released Contracts capability surface used by this script. */
632
+ function assertContractsApi(value: Record<string, unknown>): asserts value is ContractsApi {
633
+ assertStringArray(value.APP_CATEGORIES, 'APP_CATEGORIES');
634
+ assertStringArray(value.NAVIGATOR_TYPES, 'NAVIGATOR_TYPES');
635
+ }
636
+
637
+ /*** Narrow the released Templates capability surface used by this script. */
638
+ function assertTemplatesApi(value: Record<string, unknown>): asserts value is TemplatesApi {
639
+ assertRecord(value.CATEGORY_PRESETS, 'CATEGORY_PRESETS');
640
+ assertRecordArray(value.TONE_PAIR_CATALOG, 'TONE_PAIR_CATALOG');
641
+ for (const exportName of [
642
+ 'assertTemplateManifestReady',
643
+ 'compileCategoryDesign',
644
+ 'composeCategoryAppManifest',
645
+ 'resolveCategoryDesignPreset',
646
+ 'resolveTonePair',
647
+ 'validateTemplateManifest',
648
+ ]) {
649
+ if (typeof value[exportName] !== 'function') {
650
+ throw new Error(`${exportName} must be a function.`);
651
+ }
652
+ }
653
+ }
654
+
655
+ /*** Narrow the released ZORA theme compiler capability. */
656
+ function assertZoraThemeApi(value: Record<string, unknown>): asserts value is ZoraThemeApi {
657
+ if (typeof value.compileZoraTheme !== 'function') {
658
+ throw new Error('compileZoraTheme must be a function.');
659
+ }
660
+ }
661
+
662
+ /*** Narrow released ZORA metadata into only the fields the orchestration needs. */
663
+ function assertZoraMetadataApi(value: Record<string, unknown>): asserts value is ZoraMetadataApi {
664
+ assertRecord(value.ZORA_COMPONENT_META, 'ZORA_COMPONENT_META');
665
+ for (const [name, metadata] of Object.entries(value.ZORA_COMPONENT_META)) {
666
+ assertComponentMetadata(metadata, name);
667
+ }
668
+ assertRecord(value.ZORA_THEME_RECIPE_META, 'ZORA_THEME_RECIPE_META');
669
+ for (const [name, metadata] of Object.entries(value.ZORA_THEME_RECIPE_META)) {
670
+ assertRecipeMetadata(metadata, name);
671
+ }
672
+ }
673
+
674
+ /*** Validate one component metadata entry obtained from the released owner. */
675
+ function assertComponentMetadata(value: unknown, name: string): asserts value is ComponentMetadata {
676
+ assertRecord(value, `ZORA component metadata ${name}`);
677
+ assertNonEmptyString(value.name, `ZORA component metadata ${name}.name`);
678
+ if (typeof value.directManifestNode !== 'boolean') {
679
+ throw new Error(`ZORA component metadata ${name}.directManifestNode must be a boolean.`);
680
+ }
681
+ assertStringArray(value.allowedChildren, `ZORA component metadata ${name}.allowedChildren`);
682
+ assertRecord(value.props, `ZORA component metadata ${name}.props`);
683
+ if (value.events !== undefined) {
684
+ assertRecord(value.events, `ZORA component metadata ${name}.events`);
685
+ for (const [eventName, event] of Object.entries(value.events)) {
686
+ assertEventMetadata(event, `${name}.${eventName}`);
687
+ }
688
+ }
689
+ if (value.manifestPolicy !== undefined) {
690
+ assertRecord(value.manifestPolicy, `ZORA component metadata ${name}.manifestPolicy`);
691
+ }
692
+ }
693
+
694
+ /*** Validate one event metadata entry used by interactive discovery. */
695
+ function assertEventMetadata(value: unknown, name: string): asserts value is EventMetadata {
696
+ assertRecord(value, `ZORA event metadata ${name}`);
697
+ assertNonEmptyString(value.eventType, `ZORA event metadata ${name}.eventType`);
698
+ assertNonEmptyString(value.label, `ZORA event metadata ${name}.label`);
699
+ assertNonEmptyString(value.description, `ZORA event metadata ${name}.description`);
700
+ }
701
+
702
+ /*** Validate one theme recipe metadata entry and its supported fields. */
703
+ function assertRecipeMetadata(value: unknown, name: string): asserts value is RecipeMetadata {
704
+ assertRecord(value, `ZORA theme recipe metadata ${name}`);
705
+ assertNonEmptyString(value.kind, `ZORA theme recipe metadata ${name}.kind`);
706
+ assertRecord(value.fields, `ZORA theme recipe metadata ${name}.fields`);
707
+ for (const [fieldName, field] of Object.entries(value.fields)) {
708
+ assertRecord(field, `ZORA theme recipe field ${name}.${fieldName}`);
709
+ assertNonEmptyString(field.type, `ZORA theme recipe field ${name}.${fieldName}.type`);
710
+ if (field.options !== undefined) {
711
+ assertStringArray(field.options, `ZORA theme recipe field ${name}.${fieldName}.options`);
712
+ }
713
+ }
714
+ }
715
+
716
+ /*** Require an array containing only non-empty strings. */
717
+ function assertStringArray(value: unknown, label: string): asserts value is string[] {
718
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
719
+ value.forEach((entry, index) => assertNonEmptyString(entry, `${label}[${index}]`));
720
+ }
721
+
722
+ /*** Require an array containing only records. */
723
+ function assertRecordArray(
724
+ value: unknown,
725
+ label: string,
726
+ ): asserts value is Record<string, unknown>[] {
727
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
728
+ value.forEach((entry, index) => assertRecord(entry, `${label}[${index}]`));
427
729
  }
428
730
 
429
731
  /*** Require an object-shaped input value. */
430
- function assertRecord(value, label) {
732
+ function assertRecord(value: unknown, label: string): asserts value is Record<string, unknown> {
431
733
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
432
734
  throw new Error(`${label} must be an object.`);
433
735
  }
434
736
  }
435
737
 
436
738
  /*** Narrow unknown package export metadata to a record. */
437
- function isRecord(value) {
739
+ function isRecord(value: unknown): value is Record<string, unknown> {
438
740
  return typeof value === 'object' && value !== null && !Array.isArray(value);
439
741
  }
440
742
 
441
743
  /*** Require a non-empty string input field. */
442
- function assertNonEmptyString(value, label) {
744
+ function assertNonEmptyString(value: unknown, label: string): asserts value is string {
443
745
  if (typeof value !== 'string' || value.trim() === '') {
444
746
  throw new Error(`${label} must be a non-empty string.`);
445
747
  }
@@ -453,11 +755,11 @@ async function main() {
453
755
  return;
454
756
  }
455
757
  if (command === 'compose' && inputPath) {
456
- const input = JSON.parse(await readFile(inputPath, 'utf8'));
758
+ const input: unknown = JSON.parse(await readFile(inputPath, 'utf8'));
457
759
  console.log(JSON.stringify(await composeDesign(input), null, 2));
458
760
  return;
459
761
  }
460
- throw new Error('Usage: owner-api.mjs inspect | owner-api.mjs compose <input.json>');
762
+ throw new Error('Usage: owner-api.ts inspect | owner-api.ts compose <input.json>');
461
763
  }
462
764
 
463
765
  if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {