@gradial/aci 0.1.52-rc.4 → 0.1.52-rc.6

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.
Files changed (41) hide show
  1. package/README.md +4 -4
  2. package/dist/cli/compile-registry.d.mts +5 -1
  3. package/dist/cli/compile-registry.mjs +25 -10
  4. package/dist/cli/validate-content.d.mts +31 -1
  5. package/dist/cli/validate-content.mjs +50 -12
  6. package/dist/content/provider.d.ts +0 -1
  7. package/dist/content/resolve-slots.d.ts +9 -2
  8. package/dist/content/resolve-slots.js +5 -3
  9. package/dist/content/types.d.ts +2 -0
  10. package/dist/next/error-seat-payload.js +1 -1
  11. package/dist/providers/s3.js +4 -11
  12. package/dist/types/block-ref.d.ts +2 -0
  13. package/dist/types/block-ref.js +1 -0
  14. package/package.json +7 -17
  15. package/src/cli/compile-registry.mjs +24 -10
  16. package/src/cli/validate-content.mjs +52 -13
  17. package/src/types/block-ref.test.ts +45 -0
  18. package/src/types/block-ref.ts +2 -0
  19. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
  20. package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
  21. package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
  22. package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
  23. package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
  24. package/templates/common/.content/pages/home/_index.json +10 -0
  25. package/templates/common/starter-content/.content/.rules-baseline.json +8 -0
  26. package/templates/common/starter-content/.content/content-rules/no-public-resource-paths.json +6 -0
  27. package/templates/common/starter-content/.content/fragments/navbar/_index.json +23 -20
  28. package/templates/common/starter-content/.content/fragments/site-footer/_index.json +59 -56
  29. package/templates/common/starter-content/.content/pages/404/_index.json +1 -0
  30. package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
  31. package/templates/common/starter-content/.content/pages/error/_index.json +1 -0
  32. package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
  33. package/templates/common/starter-content/.content/patterns/components/button.json +55 -0
  34. package/templates/common/starter-content/.content/patterns/components/container.json +62 -0
  35. package/templates/common/starter-content/.content/patterns/components/feature_card.json +30 -0
  36. package/templates/common/starter-content/.content/patterns/components/feature_grid.json +57 -0
  37. package/templates/common/starter-content/.content/patterns/components/home_hero.json +142 -0
  38. package/templates/common/starter-content/.content/patterns/components/site-footer.json +203 -0
  39. package/templates/common/starter-content/.content/patterns/components/site-navigation.json +115 -0
  40. package/templates/common/starter-content/.content/patterns/components/stats_bar.json +45 -0
  41. package/templates/common/starter-content/.content/patterns/layouts/default.json +14 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Agentic Content Infrastructure SDK
2
2
 
3
- `@gradial/aci` `0.1.52-rc.4` is a pre-1.0 public-surface reset for the ACI
3
+ `@gradial/aci` `0.1.52-rc.6` is a pre-1.0 public-surface reset for the ACI
4
4
  client SDK. Breaking changes from the old `0.1.x` accidental surface are
5
5
  intentional: the root import is framework-neutral, framework runtime behavior is
6
6
  behind explicit subpaths, and the first required customer path is React/Next.
@@ -8,7 +8,7 @@ behind explicit subpaths, and the first required customer path is React/Next.
8
8
  ## Install
9
9
 
10
10
  ```bash
11
- npm install @gradial/aci@0.1.52-rc.4 zod@^4
11
+ npm install @gradial/aci@0.1.52-rc.6 zod@^4
12
12
  ```
13
13
 
14
14
  ## Root Import
@@ -78,13 +78,13 @@ host bundles select and serve immutable release artifacts.
78
78
  The first RC scaffold path is Next:
79
79
 
80
80
  ```bash
81
- npx @gradial/aci@0.1.52-rc.4 init my-site --template next-starter
81
+ npx @gradial/aci@0.1.52-rc.6 init my-site --template next-starter
82
82
  ```
83
83
 
84
84
  List available templates:
85
85
 
86
86
  ```bash
87
- npx @gradial/aci@0.1.52-rc.4 init --list
87
+ npx @gradial/aci@0.1.52-rc.6 init --list
88
88
  ```
89
89
 
90
90
  ## Personalization: The One-Tag Install
@@ -1,2 +1,6 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export function compileRegistry(options: any): Promise<{
3
+ registry: string;
4
+ schemas: string[];
5
+ }>;
6
+ export function generatePageSchema(schemaDir: any, componentEntries: any, outDir: any): Promise<string>;
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
2
3
  import { mkdir, writeFile } from 'node:fs/promises';
3
4
  import path from 'node:path';
4
- import { pathToFileURL } from 'node:url';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
6
  import { z } from 'zod';
6
7
  import { ImageSchema } from '../types/image.ts';
7
8
  import { VideoSchema } from '../types/video.ts';
@@ -18,14 +19,16 @@ try {
18
19
  globalThis.React = React;
19
20
  }
20
21
  catch { /* react not available — component files without JSX will still work */ }
21
- const args = parseArgs(process.argv.slice(2));
22
- try {
23
- const result = await compileRegistry(args);
24
- console.log(JSON.stringify({ success: true, ...result, errors: [] }, null, 2));
25
- }
26
- catch (error) {
27
- console.error(JSON.stringify({ success: false, errors: [String(error?.message ?? error)] }, null, 2));
28
- process.exitCode = 1;
22
+ if (isMainModule()) {
23
+ const args = parseArgs(process.argv.slice(2));
24
+ try {
25
+ const result = await compileRegistry(args);
26
+ console.log(JSON.stringify({ success: true, ...result, errors: [] }, null, 2));
27
+ }
28
+ catch (error) {
29
+ console.error(JSON.stringify({ success: false, errors: [String(error?.message ?? error)] }, null, 2));
30
+ process.exitCode = 1;
31
+ }
29
32
  }
30
33
  async function compileRegistry(options) {
31
34
  const required = ['components', 'layouts', 'output'];
@@ -440,9 +443,10 @@ async function generatePageSchema(schemaDir, componentEntries, outDir) {
440
443
  const defs = {
441
444
  baseBlock: {
442
445
  type: 'object',
443
- required: ['id', 'component', 'props'],
446
+ required: ['$type', 'id', 'component', 'props'],
444
447
  additionalProperties: true,
445
448
  properties: {
449
+ $type: { const: 'component' },
446
450
  id: { type: 'string', minLength: 1 },
447
451
  component: { type: 'string' },
448
452
  props: { type: 'object' },
@@ -757,3 +761,14 @@ function parseArgs(argv) {
757
761
  }
758
762
  return out;
759
763
  }
764
+ function isMainModule() {
765
+ if (!process.argv[1])
766
+ return false;
767
+ try {
768
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(path.resolve(process.argv[1]));
769
+ }
770
+ catch {
771
+ return false;
772
+ }
773
+ }
774
+ export { compileRegistry, generatePageSchema };
@@ -1,2 +1,32 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export function validateContent(options: any): Promise<{
3
+ warnings?: any[] | undefined;
4
+ success: boolean;
5
+ pagesValidated: number;
6
+ blocksValidated: number;
7
+ errors: ({
8
+ page: string;
9
+ message: string;
10
+ fragment?: undefined;
11
+ component?: undefined;
12
+ path?: undefined;
13
+ } | {
14
+ fragment: string;
15
+ message: string;
16
+ page?: undefined;
17
+ component?: undefined;
18
+ path?: undefined;
19
+ } | {
20
+ fragment: string;
21
+ component: any;
22
+ message: string;
23
+ page?: undefined;
24
+ path?: undefined;
25
+ } | {
26
+ fragment: string;
27
+ component: any;
28
+ path: any;
29
+ message: any;
30
+ page?: undefined;
31
+ })[];
32
+ }>;
@@ -19,21 +19,25 @@
19
19
  * Uses the JSON schemas generated by compile-registry — portable, no React/CSS
20
20
  * imports needed, same schemas usable by Go backend or other tools.
21
21
  */
22
+ import { realpathSync } from 'node:fs';
22
23
  import { readdir, readFile } from 'node:fs/promises';
23
24
  import path from 'node:path';
24
- const args = parseArgs(process.argv.slice(2));
25
- try {
26
- const result = await validateContent(args);
27
- console.log(JSON.stringify(result, null, 2));
28
- if (!result.success)
25
+ import { fileURLToPath } from 'node:url';
26
+ if (isMainModule()) {
27
+ const args = parseArgs(process.argv.slice(2));
28
+ try {
29
+ const result = await validateContent(args);
30
+ console.log(JSON.stringify(result, null, 2));
31
+ if (!result.success)
32
+ process.exitCode = 1;
33
+ }
34
+ catch (error) {
35
+ console.error(JSON.stringify({
36
+ success: false,
37
+ errors: [{ message: String(error?.message ?? error) }],
38
+ }, null, 2));
29
39
  process.exitCode = 1;
30
- }
31
- catch (error) {
32
- console.error(JSON.stringify({
33
- success: false,
34
- errors: [{ message: String(error?.message ?? error) }],
35
- }, null, 2));
36
- process.exitCode = 1;
40
+ }
37
41
  }
38
42
  // ---------------------------------------------------------------------------
39
43
  // Main
@@ -86,6 +90,22 @@ async function validateContent(options) {
86
90
  const blocks = collectBlocks(pageData);
87
91
  for (const block of blocks) {
88
92
  blocksValidated++;
93
+ if (!block || typeof block !== 'object') {
94
+ collector.push({
95
+ page: relativePath,
96
+ blockId: '<unknown>',
97
+ message: 'page region entry must be a component block',
98
+ });
99
+ continue;
100
+ }
101
+ if (block.$type !== 'component') {
102
+ collector.push({
103
+ page: relativePath,
104
+ blockId: block.id ?? '<unknown>',
105
+ path: '$type',
106
+ message: 'block "$type" must be "component"',
107
+ });
108
+ }
89
109
  if (!block.component) {
90
110
  collector.push({
91
111
  page: relativePath,
@@ -348,6 +368,13 @@ function validateNestedSlots(parentBlock, componentMap, schemaMap, path = 'props
348
368
  issues.push({ path: nestedPath, message: `${parentBlock.component}: block ref child must be a block` });
349
369
  continue;
350
370
  }
371
+ if (nestedBlock.$type !== 'component') {
372
+ issues.push({
373
+ path: `${nestedPath}.$type`,
374
+ message: `${parentBlock.component}: block ref child "$type" must be "component"`,
375
+ });
376
+ continue;
377
+ }
351
378
  if (!componentMap.has(nestedBlock.component)) {
352
379
  issues.push({ path: `${nestedPath}.component`, message: `${parentBlock.component}: unknown nested component "${nestedBlock.component}"` });
353
380
  continue;
@@ -424,3 +451,14 @@ function parseArgs(argv) {
424
451
  }
425
452
  return out;
426
453
  }
454
+ function isMainModule() {
455
+ if (!process.argv[1])
456
+ return false;
457
+ try {
458
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(path.resolve(process.argv[1]));
459
+ }
460
+ catch {
461
+ return false;
462
+ }
463
+ }
464
+ export { validateContent };
@@ -21,7 +21,6 @@ export interface FragmentEntry {
21
21
  }
22
22
  export interface LayoutEntry {
23
23
  name: string;
24
- payloadRef: string;
25
24
  slots: LayoutSlot[];
26
25
  defaults?: Record<string, FragmentRef[]>;
27
26
  }
@@ -1,9 +1,16 @@
1
1
  import type { LayoutEntry } from './provider.js';
2
2
  import type { KernelPageBlock } from './types.js';
3
- export interface FragmentData {
4
- id: string;
3
+ export interface FragmentBlock {
4
+ $type: 'component';
5
5
  component: string;
6
6
  props?: Record<string, unknown>;
7
7
  }
8
+ export interface FragmentData {
9
+ fragmentId?: string;
10
+ id?: string;
11
+ block?: FragmentBlock;
12
+ component?: string;
13
+ props?: Record<string, unknown>;
14
+ }
8
15
  export type FragmentLoader = (id: string) => Promise<FragmentData>;
9
16
  export declare function resolveSlots(layout: LayoutEntry, pageRegions: Record<string, KernelPageBlock[]>, getFragment: FragmentLoader): Promise<Record<string, KernelPageBlock[]>>;
@@ -11,10 +11,12 @@ export async function resolveSlots(layout, pageRegions, getFragment) {
11
11
  const blocks = [];
12
12
  for (const ref of defaults) {
13
13
  const fragment = await getFragment(ref.fragmentId);
14
+ const block = fragment.block ?? { component: fragment.component ?? '', props: fragment.props };
14
15
  blocks.push({
15
- id: fragment.id,
16
- component: fragment.component,
17
- props: fragment.props || {},
16
+ $type: 'component',
17
+ id: fragment.id ?? fragment.fragmentId ?? ref.fragmentId,
18
+ component: block.component,
19
+ props: block.props || {},
18
20
  });
19
21
  }
20
22
  resolved[slot.name] = blocks;
@@ -42,11 +42,13 @@ export interface KernelPageSeo {
42
42
  canonical?: string;
43
43
  }
44
44
  export interface KernelPageBlock {
45
+ $type: 'component';
45
46
  id: string;
46
47
  component: string;
47
48
  props: Record<string, unknown>;
48
49
  }
49
50
  export interface KernelAtomicBlock {
51
+ $type: 'component';
50
52
  id: string;
51
53
  component: string;
52
54
  props: Record<string, unknown>;
@@ -104,7 +104,7 @@ export function buildErrorSeatPayload(contentRoot, posture) {
104
104
  if (!fragmentRef)
105
105
  continue;
106
106
  const fragment = readJSON(root, fragmentRef);
107
- blocks.push({ id: fragment.id, component: fragment.component, props: fragment.props || {} });
107
+ blocks.push({ $type: 'component', id: fragment.id, component: fragment.component, props: fragment.props || {} });
108
108
  }
109
109
  if (blocks.length)
110
110
  slots[slot.name] = blocks;
@@ -78,13 +78,12 @@ export class S3ContentProvider {
78
78
  }
79
79
  async getLayout(name) {
80
80
  const manifest = await this.manifest();
81
- const entry = manifest.layouts?.[name];
82
- if (!entry) {
81
+ const layouts = manifest.layouts;
82
+ if (!layouts || !layouts[name]) {
83
83
  throw new LayoutNotFoundError(name);
84
84
  }
85
- const ref = entry.payloadRef;
86
85
  try {
87
- return await this.getJSON(ref);
86
+ return await this.getJSON(`layouts/${name}.json`);
88
87
  }
89
88
  catch (error) {
90
89
  throw new LayoutNotFoundError(name, error);
@@ -200,13 +199,7 @@ export class S3ContentProvider {
200
199
  }
201
200
  let page;
202
201
  try {
203
- const parsed = JSON.parse(source);
204
- if (parsed?.schemaVersion === 'gradial.ssr-page.v1' && parsed.page !== undefined) {
205
- page = parsed.page;
206
- }
207
- else {
208
- page = parsed;
209
- }
202
+ page = JSON.parse(source);
210
203
  }
211
204
  catch (cause) {
212
205
  throw pageIntegrityError(`invalid JSON for ${tuple.contentRef}`, cause);
@@ -5,6 +5,7 @@ export interface BlockRefMeta {
5
5
  multiple: boolean;
6
6
  }
7
7
  declare const blockRefBase: z.ZodObject<{
8
+ $type: z.ZodLiteral<"component">;
8
9
  id: z.ZodString;
9
10
  component: z.ZodString;
10
11
  props: z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -12,6 +13,7 @@ declare const blockRefBase: z.ZodObject<{
12
13
  type InferContractProps<T> = T extends ComponentContract<string, infer Schema> ? Schema extends z.ZodType<infer P> ? P : Record<string, unknown> : Record<string, unknown>;
13
14
  type InferContractId<T> = T extends ComponentContract<infer Id, unknown> ? Id : never;
14
15
  export type BlockRefEntry<T extends ComponentContract> = {
16
+ $type: 'component';
15
17
  id: string;
16
18
  component: InferContractId<T>;
17
19
  props: InferContractProps<T>;
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  const BLOCK_REF_KEY = '_gradialBlockRef';
3
3
  const blockRefBase = z.object({
4
+ $type: z.literal('component'),
4
5
  id: z.string().min(1),
5
6
  component: z.string().min(1),
6
7
  props: z.record(z.string(), z.unknown()),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.52-rc.4",
3
+ "version": "0.1.52-rc.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/gradial/bare-metal-go.git",
@@ -76,16 +76,6 @@
76
76
  "import": "./dist/next/error-seat.js",
77
77
  "default": "./dist/next/error-seat.js"
78
78
  },
79
- "./next/middleware": {
80
- "types": "./dist/next/middleware.d.ts",
81
- "import": "./dist/next/middleware.js",
82
- "default": "./dist/next/middleware.js"
83
- },
84
- "./next/asset-route": {
85
- "types": "./dist/next/asset-route.d.ts",
86
- "import": "./dist/next/asset-route.js",
87
- "default": "./dist/next/asset-route.js"
88
- },
89
79
  "./next/server": {
90
80
  "types": "./dist/next/server.d.ts",
91
81
  "import": "./dist/next/server.js",
@@ -156,11 +146,11 @@
156
146
  "zod": "^4.0.0"
157
147
  },
158
148
  "optionalDependencies": {
159
- "@gradial/aci-cli-darwin-arm64": "0.1.52-rc.4",
160
- "@gradial/aci-cli-darwin-x64": "0.1.52-rc.4",
161
- "@gradial/aci-cli-linux-arm64": "0.1.52-rc.4",
162
- "@gradial/aci-cli-linux-x64": "0.1.52-rc.4",
163
- "@gradial/aci-cli-win32-arm64": "0.1.52-rc.4",
164
- "@gradial/aci-cli-win32-x64": "0.1.52-rc.4"
149
+ "@gradial/aci-cli-darwin-arm64": "0.1.52-rc.6",
150
+ "@gradial/aci-cli-darwin-x64": "0.1.52-rc.6",
151
+ "@gradial/aci-cli-linux-arm64": "0.1.52-rc.6",
152
+ "@gradial/aci-cli-linux-x64": "0.1.52-rc.6",
153
+ "@gradial/aci-cli-win32-arm64": "0.1.52-rc.6",
154
+ "@gradial/aci-cli-win32-x64": "0.1.52-rc.6"
165
155
  }
166
156
  }
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
2
3
  import { mkdir, writeFile } from 'node:fs/promises';
3
4
  import path from 'node:path';
4
- import { pathToFileURL } from 'node:url';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
6
  import { z } from 'zod';
6
7
  import { ImageSchema } from '../types/image.ts';
7
8
  import { VideoSchema } from '../types/video.ts';
@@ -19,14 +20,15 @@ try {
19
20
  if (!globalThis.React) globalThis.React = React;
20
21
  } catch { /* react not available — component files without JSX will still work */ }
21
22
 
22
- const args = parseArgs(process.argv.slice(2));
23
-
24
- try {
25
- const result = await compileRegistry(args);
26
- console.log(JSON.stringify({ success: true, ...result, errors: [] }, null, 2));
27
- } catch (error) {
28
- console.error(JSON.stringify({ success: false, errors: [String(error?.message ?? error)] }, null, 2));
29
- process.exitCode = 1;
23
+ if (isMainModule()) {
24
+ const args = parseArgs(process.argv.slice(2));
25
+ try {
26
+ const result = await compileRegistry(args);
27
+ console.log(JSON.stringify({ success: true, ...result, errors: [] }, null, 2));
28
+ } catch (error) {
29
+ console.error(JSON.stringify({ success: false, errors: [String(error?.message ?? error)] }, null, 2));
30
+ process.exitCode = 1;
31
+ }
30
32
  }
31
33
 
32
34
  async function compileRegistry(options) {
@@ -430,9 +432,10 @@ async function generatePageSchema(schemaDir, componentEntries, outDir) {
430
432
  const defs = {
431
433
  baseBlock: {
432
434
  type: 'object',
433
- required: ['id', 'component', 'props'],
435
+ required: ['$type', 'id', 'component', 'props'],
434
436
  additionalProperties: true,
435
437
  properties: {
438
+ $type: { const: 'component' },
436
439
  id: { type: 'string', minLength: 1 },
437
440
  component: { type: 'string' },
438
441
  props: { type: 'object' },
@@ -741,3 +744,14 @@ function parseArgs(argv) {
741
744
  }
742
745
  return out;
743
746
  }
747
+
748
+ function isMainModule() {
749
+ if (!process.argv[1]) return false;
750
+ try {
751
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(path.resolve(process.argv[1]));
752
+ } catch {
753
+ return false;
754
+ }
755
+ }
756
+
757
+ export { compileRegistry, generatePageSchema };
@@ -19,21 +19,24 @@
19
19
  * Uses the JSON schemas generated by compile-registry — portable, no React/CSS
20
20
  * imports needed, same schemas usable by Go backend or other tools.
21
21
  */
22
+ import { realpathSync } from 'node:fs';
22
23
  import { readdir, readFile } from 'node:fs/promises';
23
24
  import path from 'node:path';
24
-
25
- const args = parseArgs(process.argv.slice(2));
26
-
27
- try {
28
- const result = await validateContent(args);
29
- console.log(JSON.stringify(result, null, 2));
30
- if (!result.success) process.exitCode = 1;
31
- } catch (error) {
32
- console.error(JSON.stringify({
33
- success: false,
34
- errors: [{ message: String(error?.message ?? error) }],
35
- }, null, 2));
36
- process.exitCode = 1;
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ if (isMainModule()) {
28
+ const args = parseArgs(process.argv.slice(2));
29
+ try {
30
+ const result = await validateContent(args);
31
+ console.log(JSON.stringify(result, null, 2));
32
+ if (!result.success) process.exitCode = 1;
33
+ } catch (error) {
34
+ console.error(JSON.stringify({
35
+ success: false,
36
+ errors: [{ message: String(error?.message ?? error) }],
37
+ }, null, 2));
38
+ process.exitCode = 1;
39
+ }
37
40
  }
38
41
 
39
42
  // ---------------------------------------------------------------------------
@@ -95,6 +98,24 @@ async function validateContent(options) {
95
98
  for (const block of blocks) {
96
99
  blocksValidated++;
97
100
 
101
+ if (!block || typeof block !== 'object') {
102
+ collector.push({
103
+ page: relativePath,
104
+ blockId: '<unknown>',
105
+ message: 'page region entry must be a component block',
106
+ });
107
+ continue;
108
+ }
109
+
110
+ if (block.$type !== 'component') {
111
+ collector.push({
112
+ page: relativePath,
113
+ blockId: block.id ?? '<unknown>',
114
+ path: '$type',
115
+ message: 'block "$type" must be "component"',
116
+ });
117
+ }
118
+
98
119
  if (!block.component) {
99
120
  collector.push({
100
121
  page: relativePath,
@@ -380,6 +401,13 @@ function validateNestedSlots(parentBlock, componentMap, schemaMap, path = 'props
380
401
  issues.push({ path: nestedPath, message: `${parentBlock.component}: block ref child must be a block` });
381
402
  continue;
382
403
  }
404
+ if (nestedBlock.$type !== 'component') {
405
+ issues.push({
406
+ path: `${nestedPath}.$type`,
407
+ message: `${parentBlock.component}: block ref child "$type" must be "component"`,
408
+ });
409
+ continue;
410
+ }
383
411
 
384
412
  if (!componentMap.has(nestedBlock.component)) {
385
413
  issues.push({ path: `${nestedPath}.component`, message: `${parentBlock.component}: unknown nested component "${nestedBlock.component}"` });
@@ -450,3 +478,14 @@ function parseArgs(argv) {
450
478
  }
451
479
  return out;
452
480
  }
481
+
482
+ function isMainModule() {
483
+ if (!process.argv[1]) return false;
484
+ try {
485
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(path.resolve(process.argv[1]));
486
+ } catch {
487
+ return false;
488
+ }
489
+ }
490
+
491
+ export { validateContent };
@@ -0,0 +1,45 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { z } from 'zod';
4
+
5
+ import { compileSchema } from '../compiler/zod-to-jsonschema.js';
6
+ import { defineComponentContract } from '../define-component.js';
7
+ import { blockRefArray } from './block-ref.js';
8
+
9
+ const childContract = defineComponentContract({
10
+ id: 'child',
11
+ props: z.object({
12
+ label: z.string(),
13
+ }),
14
+ });
15
+
16
+ test('block references require and accept the component discriminator', () => {
17
+ const schema = blockRefArray([childContract]);
18
+ const typed = {
19
+ $type: 'component',
20
+ id: 'child-1',
21
+ component: 'child',
22
+ props: { label: 'Hello' },
23
+ };
24
+
25
+ assert.equal(schema.safeParse([typed]).success, true);
26
+ assert.equal(schema.safeParse([{
27
+ id: typed.id,
28
+ component: typed.component,
29
+ props: typed.props,
30
+ }]).success, false);
31
+ });
32
+
33
+ test('compiled block-reference schema exposes the component discriminator', () => {
34
+ const compiled = compileSchema(blockRefArray([childContract])) as {
35
+ items?: {
36
+ properties?: Record<string, { const?: string }>;
37
+ required?: string[];
38
+ additionalProperties?: boolean;
39
+ };
40
+ };
41
+
42
+ assert.equal(compiled.items?.properties?.$type?.const, 'component');
43
+ assert.equal(compiled.items?.required?.includes('$type'), true);
44
+ assert.equal(compiled.items?.additionalProperties, false);
45
+ });
@@ -9,6 +9,7 @@ export interface BlockRefMeta {
9
9
  }
10
10
 
11
11
  const blockRefBase = z.object({
12
+ $type: z.literal('component'),
12
13
  id: z.string().min(1),
13
14
  component: z.string().min(1),
14
15
  props: z.record(z.string(), z.unknown()),
@@ -21,6 +22,7 @@ type InferContractProps<T> = T extends ComponentContract<string, infer Schema>
21
22
  type InferContractId<T> = T extends ComponentContract<infer Id, unknown> ? Id : never;
22
23
 
23
24
  export type BlockRefEntry<T extends ComponentContract> = {
25
+ $type: 'component';
24
26
  id: string;
25
27
  component: InferContractId<T>;
26
28
  props: InferContractProps<T>;
@@ -13,17 +13,18 @@ export const atomicBlockStoryFixtures: AtomicBlockStoryFixture[] = [
13
13
  storybookId: 'core-atomic-blocks-button--variants',
14
14
  title: 'Button / All Variants',
15
15
  block: {
16
+ $type: 'component',
16
17
  id: 'fixture-button-variants',
17
18
  component: 'container',
18
19
  props: {
19
20
  theme: 'auto',
20
21
  blocks: [
21
- { id: 'btn-primary', component: 'button', props: { label: 'Primary button', href: '#', variant: 'primary' } },
22
- { id: 'btn-secondary', component: 'button', props: { label: 'Secondary button', href: '#', variant: 'secondary' } },
23
- { id: 'btn-tertiary', component: 'button', props: { label: 'Tertiary button', href: '#', variant: 'tertiary' } },
24
- { id: 'btn-icon-before', component: 'button', props: { label: 'Icon before', href: '#', variant: 'primary', icon: 'arrow-right', iconPosition: 'before' } },
25
- { id: 'btn-icon-after', component: 'button', props: { label: 'Icon after', href: '#', variant: 'secondary', icon: 'arrow-right', iconPosition: 'after' } },
26
- { id: 'btn-new-window', component: 'button', props: { label: 'Opens in new tab', href: 'https://example.com', variant: 'primary', newWindow: true, icon: 'arrow-right', iconPosition: 'after' } }
22
+ { $type: 'component', id: 'btn-primary', component: 'button', props: { label: 'Primary button', href: '#', variant: 'primary' } },
23
+ { $type: 'component', id: 'btn-secondary', component: 'button', props: { label: 'Secondary button', href: '#', variant: 'secondary' } },
24
+ { $type: 'component', id: 'btn-tertiary', component: 'button', props: { label: 'Tertiary button', href: '#', variant: 'tertiary' } },
25
+ { $type: 'component', id: 'btn-icon-before', component: 'button', props: { label: 'Icon before', href: '#', variant: 'primary', icon: 'arrow-right', iconPosition: 'before' } },
26
+ { $type: 'component', id: 'btn-icon-after', component: 'button', props: { label: 'Icon after', href: '#', variant: 'secondary', icon: 'arrow-right', iconPosition: 'after' } },
27
+ { $type: 'component', id: 'btn-new-window', component: 'button', props: { label: 'Opens in new tab', href: 'https://example.com', variant: 'primary', newWindow: true, icon: 'arrow-right', iconPosition: 'after' } }
27
28
  ]
28
29
  }
29
30
  }
@@ -14,14 +14,15 @@ export const compositionStoryFixtures: CompositionStoryFixture[] = [
14
14
  storybookId: 'composition-container--buttons',
15
15
  title: 'Container / Buttons',
16
16
  block: {
17
+ $type: 'component',
17
18
  id: 'fixture-container-buttons',
18
19
  component: 'container',
19
20
  props: {
20
21
  theme: 'auto',
21
22
  className: 'space-y-4',
22
23
  blocks: [
23
- { id: 'btn-1', component: 'button', props: { label: 'Primary action', href: '#', variant: 'primary', icon: 'arrow_forward', iconPosition: 'after' } },
24
- { id: 'btn-2', component: 'button', props: { label: 'Secondary action', href: '#', variant: 'secondary' } }
24
+ { $type: 'component', id: 'btn-1', component: 'button', props: { label: 'Primary action', href: '#', variant: 'primary', icon: 'arrow_forward', iconPosition: 'after' } },
25
+ { $type: 'component', id: 'btn-2', component: 'button', props: { label: 'Secondary action', href: '#', variant: 'secondary' } }
25
26
  ]
26
27
  }
27
28
  }