@gradial/aci 0.1.42 → 0.1.43

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 (63) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  3. package/dist/audience-data-models.d.ts +286 -0
  4. package/dist/audience-data-models.js +46 -0
  5. package/dist/cli/compile-registry.d.mts +5 -1
  6. package/dist/cli/compile-registry.mjs +25 -10
  7. package/dist/cli/validate-content.d.mts +31 -1
  8. package/dist/cli/validate-content.mjs +50 -12
  9. package/dist/collect/aci.js +1 -0
  10. package/dist/collect/aci.js.sha256 +1 -0
  11. package/dist/collect/batcher.d.ts +2 -0
  12. package/dist/collect/batcher.js +18 -6
  13. package/dist/collect/consent/envelope.d.ts +9 -3
  14. package/dist/collect/consent/envelope.js +58 -40
  15. package/dist/collect/index.js +9 -2
  16. package/dist/collect/linkclicks.js +4 -2
  17. package/dist/collect/transport.d.ts +2 -0
  18. package/dist/collect/transport.js +6 -3
  19. package/dist/consent/portable-envelope.d.ts +9 -0
  20. package/dist/consent/portable-envelope.js +110 -0
  21. package/dist/consent-banner/index.js +2 -1
  22. package/dist/content/assets.d.ts +1 -0
  23. package/dist/content/assets.js +1 -0
  24. package/dist/content/resolve-slots.js +1 -0
  25. package/dist/content/types.d.ts +2 -0
  26. package/dist/next/collect-route.d.ts +2 -2
  27. package/dist/next/collect-route.js +90 -19
  28. package/dist/next/envelope-digest.d.ts +21 -0
  29. package/dist/next/envelope-digest.js +58 -0
  30. package/dist/next/middleware.d.ts +16 -2
  31. package/dist/next/middleware.js +190 -10
  32. package/dist/next/personalization-host.d.ts +16 -7
  33. package/dist/next/personalization-host.js +14 -33
  34. package/dist/next/server.d.ts +1 -0
  35. package/dist/next/server.js +3 -0
  36. package/dist/next/vercel-collect-sdk-route.d.ts +27 -0
  37. package/dist/next/vercel-collect-sdk-route.js +205 -0
  38. package/dist/next/vercel-personalization.d.ts +3 -2
  39. package/dist/next/vercel-personalization.js +3 -2
  40. package/dist/next/vercel-s3-personalization.d.ts +25 -4
  41. package/dist/next/vercel-s3-personalization.js +368 -14
  42. package/dist/providers/s3.d.ts +19 -4
  43. package/dist/providers/s3.js +92 -22
  44. package/dist/testing/conformance/checks.js +75 -4
  45. package/dist/types/block-ref.d.ts +2 -0
  46. package/dist/types/block-ref.js +1 -0
  47. package/package.json +27 -16
  48. package/src/cli/compile-registry.mjs +24 -10
  49. package/src/cli/validate-content.mjs +52 -13
  50. package/src/types/block-ref.test.ts +45 -0
  51. package/src/types/block-ref.ts +2 -0
  52. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
  53. package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
  54. package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
  55. package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
  56. package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
  57. package/templates/common/.content/pages/home/_index.json +10 -0
  58. package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
  59. package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
  60. package/templates/nextjs/starter-content/src/middleware.ts +31 -1
  61. package/templates/nextjs/template/src/app/collect/route.ts +2 -2
  62. package/templates/nextjs/template/src/middleware.ts +31 -1
  63. package/templates/nextjs/template/src/middleware.ts.tmpl +31 -1
@@ -278,7 +278,37 @@ export function checkMiddleware(ctx) {
278
278
  // the SDK's canonical value. Next.js requires config.matcher to be
279
279
  // statically analyzable, so importing the matcher from the SDK breaks the
280
280
  // build. Instead the template inlines the value and this check detects drift.
281
- const canonicalMatcher = "['/((?!_next(?:/|$)|api(?:/|$)|favicon\\\\.ico$|\\\\.gradial-dam(?:/|$)).*)']";
281
+ const canonicalMatcher = String.raw `[
282
+ '/((?!_next(?:/|$)|api(?:/|$)|collect(?:/|$)|gradial/assets(?:/|$)|favicon\\.ico$).*)',
283
+ {
284
+ source: '/:path*',
285
+ has: [{ type: 'header', key: 'x-gradial-framework-origin-auth' }],
286
+ },
287
+ {
288
+ source: '/:path*',
289
+ has: [{ type: 'header', key: 'x-baremetal-site-id' }],
290
+ },
291
+ {
292
+ source: '/:path*',
293
+ has: [{ type: 'header', key: 'x-baremetal-release-id' }],
294
+ },
295
+ {
296
+ source: '/:path*',
297
+ has: [{ type: 'header', key: 'x-aci-content-ref' }],
298
+ },
299
+ {
300
+ source: '/:path*',
301
+ has: [{ type: 'header', key: 'x-baremetal-content-base-url' }],
302
+ },
303
+ {
304
+ source: '/:path*',
305
+ has: [{ type: 'header', key: 'x-aci-content-path' }],
306
+ },
307
+ {
308
+ source: '/:path*',
309
+ has: [{ type: 'query', key: '__aci_content_path' }],
310
+ },
311
+ ]`;
282
312
  // Check for actual import/usage of aciMiddlewareMatcher (not just a mention
283
313
  // in a comment). Look for `matcher: aciMiddlewareMatcher` or
284
314
  // `import { ... aciMiddlewareMatcher ... }`.
@@ -289,9 +319,9 @@ export function checkMiddleware(ctx) {
289
319
  }
290
320
  else {
291
321
  // Verify the inlined value matches the SDK canonical
292
- const matcherMatch = middlewareFile.content.match(/matcher\s*:\s*(\[.*?\])/s);
293
- if (matcherMatch) {
294
- const normalizedInlined = matcherMatch[1].replace(/\s+/g, '');
322
+ const matcherLiteral = extractArrayPropertyLiteral(middlewareFile.content, 'matcher');
323
+ if (matcherLiteral) {
324
+ const normalizedInlined = matcherLiteral.replace(/\s+/g, '');
295
325
  const normalizedCanonical = canonicalMatcher.replace(/\s+/g, '');
296
326
  results.push(normalizedInlined === normalizedCanonical
297
327
  ? ok('middleware', 'middleware matcher matches SDK canonical', rel)
@@ -303,6 +333,47 @@ export function checkMiddleware(ctx) {
303
333
  }
304
334
  return results;
305
335
  }
336
+ function extractArrayPropertyLiteral(content, propertyName) {
337
+ const property = new RegExp(`\\b${propertyName}\\s*:`).exec(content);
338
+ if (!property)
339
+ return null;
340
+ const start = content.indexOf('[', property.index + property[0].length);
341
+ if (start < 0)
342
+ return null;
343
+ let depth = 0;
344
+ let quote = '';
345
+ let escaped = false;
346
+ for (let index = start; index < content.length; index += 1) {
347
+ const character = content[index];
348
+ if (quote) {
349
+ if (escaped) {
350
+ escaped = false;
351
+ }
352
+ else if (character === '\\') {
353
+ escaped = true;
354
+ }
355
+ else if (character === quote) {
356
+ quote = '';
357
+ }
358
+ continue;
359
+ }
360
+ if (character === "'" || character === '"' || character === '`') {
361
+ quote = character;
362
+ continue;
363
+ }
364
+ if (character === '[') {
365
+ depth += 1;
366
+ continue;
367
+ }
368
+ if (character === ']') {
369
+ depth -= 1;
370
+ if (depth === 0) {
371
+ return content.slice(start, index + 1);
372
+ }
373
+ }
374
+ }
375
+ return null;
376
+ }
306
377
  // ---------------------------------------------------------------------------
307
378
  // Check: noFileContentProvider — no direct FileContentProvider imports in app code
308
379
  // ---------------------------------------------------------------------------
@@ -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,11 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/gradial/bare-metal-go.git",
7
+ "directory": "packages/sdk"
8
+ },
4
9
  "type": "module",
5
10
  "main": "./dist/index.js",
6
11
  "types": "./dist/index.d.ts",
@@ -72,6 +77,11 @@
72
77
  "import": "./dist/next/vercel-personalization.js",
73
78
  "default": "./dist/next/vercel-personalization.js"
74
79
  },
80
+ "./next/vercel-collect-sdk-route": {
81
+ "types": "./dist/next/vercel-collect-sdk-route.d.ts",
82
+ "import": "./dist/next/vercel-collect-sdk-route.js",
83
+ "default": "./dist/next/vercel-collect-sdk-route.js"
84
+ },
75
85
  "./next/asset-route": {
76
86
  "types": "./dist/next/asset-route.d.ts",
77
87
  "import": "./dist/next/asset-route.js",
@@ -103,6 +113,15 @@
103
113
  "import": "./dist/testing/conformance/index.js"
104
114
  }
105
115
  },
116
+ "scripts": {
117
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node scripts/build-collect.mjs",
118
+ "test": "node --import tsx --test \"src/**/*.test.ts\"",
119
+ "build:collect": "node scripts/build-collect.mjs",
120
+ "build:cli": "cd ../.. && ./scripts/build-cli.sh",
121
+ "generate-starters": "cd ../.. && tsx scripts/generate-starters.ts",
122
+ "compile-registry": "tsx src/cli/compile-registry.mjs",
123
+ "prepack": "npm run generate-starters && npm run build && node scripts/ensure-bin-modes.mjs"
124
+ },
106
125
  "dependencies": {
107
126
  "@aws-sdk/client-s3": "^3.883.0",
108
127
  "@vercel/oidc-aws-credentials-provider": "^3.3.0",
@@ -145,19 +164,11 @@
145
164
  "zod": "^4.0.0"
146
165
  },
147
166
  "optionalDependencies": {
148
- "@gradial/aci-cli-darwin-arm64": "0.1.41",
149
- "@gradial/aci-cli-darwin-x64": "0.1.41",
150
- "@gradial/aci-cli-linux-arm64": "0.1.41",
151
- "@gradial/aci-cli-linux-x64": "0.1.41",
152
- "@gradial/aci-cli-win32-arm64": "0.1.41",
153
- "@gradial/aci-cli-win32-x64": "0.1.41"
154
- },
155
- "scripts": {
156
- "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
157
- "test": "node --import tsx --test \"src/**/*.test.ts\"",
158
- "build:collect": "node scripts/build-collect.mjs",
159
- "build:cli": "cd ../.. && ./scripts/build-cli.sh",
160
- "generate-starters": "cd ../.. && tsx scripts/generate-starters.ts",
161
- "compile-registry": "tsx src/cli/compile-registry.mjs"
167
+ "@gradial/aci-cli-darwin-arm64": "0.1.43",
168
+ "@gradial/aci-cli-darwin-x64": "0.1.43",
169
+ "@gradial/aci-cli-linux-arm64": "0.1.43",
170
+ "@gradial/aci-cli-linux-x64": "0.1.43",
171
+ "@gradial/aci-cli-win32-arm64": "0.1.43",
172
+ "@gradial/aci-cli-win32-x64": "0.1.43"
162
173
  }
163
- }
174
+ }
@@ -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
  }
@@ -14,6 +14,7 @@ export const sectionStoryFixtures: SectionStoryFixture[] = [
14
14
  storybookId: 'sections-home-hero--default',
15
15
  title: 'Home Hero',
16
16
  block: {
17
+ $type: 'component',
17
18
  id: 'fixture-home-hero',
18
19
  component: 'home_hero',
19
20
  props: {
@@ -22,7 +23,7 @@ export const sectionStoryFixtures: SectionStoryFixture[] = [
22
23
  description: 'One content model, three renderers. Build once in Next.js, Astro, and SvelteKit—then prove parity with shared fixtures.',
23
24
  ctaLabel: 'Get started',
24
25
  ctaHref: '/docs',
25
- image: {
26
+ image: {
26
27
  $type: 'gradial.image',
27
28
  assetId: 'fixture_seattle',
28
29
  versionId: 'v0001',
@@ -98,6 +98,7 @@ The `home` slug is special-cased to route `/`. All other slugs map directly.
98
98
 
99
99
  ```json
100
100
  {
101
+ "$type": "component",
101
102
  "id": "hero-section",
102
103
  "component": "home_hero",
103
104
  "props": {
@@ -109,6 +110,7 @@ The `home` slug is special-cased to route `/`. All other slugs map directly.
109
110
  ```
110
111
 
111
112
  - `component` must match a contract `id` exactly.
113
+ - Every embedded block must declare `"$type": "component"`.
112
114
  - `props` must validate against the contract's Zod schema.
113
115
  - `id` is unique within the page.
114
116
 
@@ -141,12 +143,14 @@ export const containerContract = defineComponentContract({
141
143
  **Content JSON:**
142
144
  ```json
143
145
  {
146
+ "$type": "component",
144
147
  "id": "cta-section",
145
148
  "component": "container",
146
149
  "props": {
147
150
  "className": "space-y-4",
148
151
  "content": [
149
152
  {
153
+ "$type": "component",
150
154
  "id": "primary-cta",
151
155
  "component": "button",
152
156
  "props": {
@@ -156,6 +160,7 @@ export const containerContract = defineComponentContract({
156
160
  }
157
161
  },
158
162
  {
163
+ "$type": "component",
159
164
  "id": "secondary-cta",
160
165
  "component": "button",
161
166
  "props": {
@@ -182,6 +187,7 @@ The `button` component is a common atomic block:
182
187
 
183
188
  ```json
184
189
  {
190
+ "$type": "component",
185
191
  "id": "cta-button",
186
192
  "component": "button",
187
193
  "props": {
@@ -411,6 +417,7 @@ framework.
411
417
  ```json
412
418
  // .content/pages/home/_index.json (inside regions.main)
413
419
  {
420
+ "$type": "component",
414
421
  "id": "features",
415
422
  "component": "feature_showcase",
416
423
  "props": {
@@ -499,9 +506,10 @@ export default [
499
506
  ];
500
507
  ```
501
508
 
502
- Fragment content lives in `.content/fragments/{id}/_index.json` and has
503
- a `component` + `props` just like page blocks. Chrome components (Header,
504
- Footer) are registered in the registry alongside page components.
509
+ Fragment content lives in `.content/fragments/{id}/_index.json` and retains
510
+ the resource envelope `"$type": "fragment"` while declaring `component` +
511
+ `props`. Chrome components (Header, Footer) are registered in the registry
512
+ alongside page components.
505
513
 
506
514
  The `layout` field in page JSON is **required** — there is no implicit
507
515
  default. Every page must declare which layout it uses.
@@ -113,12 +113,14 @@ type RenderChildren<T extends readonly BlockRef[]> = (blocks: T) => Promise<Reac
113
113
 
114
114
  ```json
115
115
  {
116
+ "$type": "component",
116
117
  "id": "cta-section",
117
118
  "component": "container",
118
119
  "props": {
119
120
  "theme": "dark",
120
121
  "blocks": [
121
122
  {
123
+ "$type": "component",
122
124
  "id": "primary-cta",
123
125
  "component": "callout",
124
126
  "props": {
@@ -127,6 +129,7 @@ type RenderChildren<T extends readonly BlockRef[]> = (blocks: T) => Promise<Reac
127
129
  }
128
130
  },
129
131
  {
132
+ "$type": "component",
130
133
  "id": "heading",
131
134
  "component": "heading",
132
135
  "props": {
@@ -11,6 +11,7 @@
11
11
  "regions": {
12
12
  "main": [
13
13
  {
14
+ "$type": "component",
14
15
  "id": "hero",
15
16
  "component": "home_hero",
16
17
  "props": {
@@ -27,6 +28,7 @@
27
28
  }
28
29
  },
29
30
  {
31
+ "$type": "component",
30
32
  "id": "why-meridian",
31
33
  "component": "feature_grid",
32
34
  "props": {
@@ -34,6 +36,7 @@
34
36
  "subtitle": "Engineered for the edges of the world.",
35
37
  "features": [
36
38
  {
39
+ "$type": "component",
37
40
  "id": "global-coverage",
38
41
  "component": "feature_card",
39
42
  "props": {
@@ -43,6 +46,7 @@
43
46
  }
44
47
  },
45
48
  {
49
+ "$type": "component",
46
50
  "id": "low-latency",
47
51
  "component": "feature_card",
48
52
  "props": {
@@ -52,6 +56,7 @@
52
56
  }
53
57
  },
54
58
  {
59
+ "$type": "component",
55
60
  "id": "easy-setup",
56
61
  "component": "feature_card",
57
62
  "props": {
@@ -64,6 +69,7 @@
64
69
  }
65
70
  },
66
71
  {
72
+ "$type": "component",
67
73
  "id": "personalization-choice",
68
74
  "component": "personalization_choice",
69
75
  "props": {
@@ -75,6 +81,7 @@
75
81
  }
76
82
  },
77
83
  {
84
+ "$type": "component",
78
85
  "id": "stats",
79
86
  "component": "stats_bar",
80
87
  "props": {
@@ -87,6 +94,7 @@
87
94
  }
88
95
  },
89
96
  {
97
+ "$type": "component",
90
98
  "id": "cta",
91
99
  "component": "container",
92
100
  "props": {
@@ -94,6 +102,7 @@
94
102
  "className": "items-center justify-center text-center",
95
103
  "content": [
96
104
  {
105
+ "$type": "component",
97
106
  "id": "cta-primary",
98
107
  "component": "button",
99
108
  "props": {
@@ -105,6 +114,7 @@
105
114
  }
106
115
  },
107
116
  {
117
+ "$type": "component",
108
118
  "id": "cta-secondary",
109
119
  "component": "button",
110
120
  "props": {
@@ -11,6 +11,7 @@
11
11
  "regions": {
12
12
  "main": [
13
13
  {
14
+ "$type": "component",
14
15
  "id": "about-hero",
15
16
  "component": "home_hero",
16
17
  "props": {
@@ -27,12 +28,14 @@
27
28
  }
28
29
  },
29
30
  {
31
+ "$type": "component",
30
32
  "id": "about-actions",
31
33
  "component": "container",
32
34
  "props": {
33
35
  "className": "items-center justify-center text-center",
34
36
  "content": [
35
37
  {
38
+ "$type": "component",
36
39
  "id": "home-link",
37
40
  "component": "button",
38
41
  "props": {