@gradial/aci 0.1.45 → 0.1.46

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 (39) hide show
  1. package/dist/audience-data-models.d.ts +142 -2
  2. package/dist/audience-data-models.js +2 -0
  3. package/dist/cli/compile-registry.d.mts +1 -5
  4. package/dist/cli/compile-registry.mjs +10 -25
  5. package/dist/cli/validate-content.d.mts +1 -31
  6. package/dist/cli/validate-content.mjs +12 -50
  7. package/dist/cli/verify-next-cdn-routes.mjs +1 -0
  8. package/dist/content/provider.d.ts +1 -1
  9. package/dist/content/resolve-slots.js +0 -1
  10. package/dist/content/types.d.ts +0 -2
  11. package/dist/internal/render-proof.d.ts +1 -0
  12. package/dist/internal/render-proof.js +197 -0
  13. package/dist/next/middleware.js +9 -54
  14. package/dist/next/page.js +9 -4
  15. package/dist/next/personalization-host.d.ts +5 -0
  16. package/dist/next/server.js +88 -75
  17. package/dist/next/vercel-collect-sdk-route.js +6 -30
  18. package/dist/next/vercel-edge-projection-v1.d.ts +24 -0
  19. package/dist/next/vercel-edge-projection-v1.js +880 -0
  20. package/dist/next/vercel-s3-personalization.js +7 -11
  21. package/dist/providers/s3.d.ts +2 -4
  22. package/dist/providers/s3.js +173 -19
  23. package/dist/types/block-ref.d.ts +0 -2
  24. package/dist/types/block-ref.js +0 -1
  25. package/package.json +2 -2
  26. package/src/cli/compile-registry.mjs +10 -24
  27. package/src/cli/validate-content.mjs +13 -52
  28. package/src/cli/verify-next-cdn-routes.mjs +6 -0
  29. package/src/types/block-ref.ts +0 -2
  30. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +6 -7
  31. package/templates/astro/starter-content/src/fixtures/composition.ts +2 -3
  32. package/templates/astro/starter-content/src/fixtures/sections.ts +1 -2
  33. package/templates/common/.agents/skills/authoring/SKILL.md +3 -11
  34. package/templates/common/.agents/skills/authoring/references/block-slots.md +0 -3
  35. package/templates/common/.content/pages/home/_index.json +0 -10
  36. package/templates/common/starter-content/.content/pages/about/_index.json +6 -6
  37. package/templates/common/starter-content/.content/pages/home/_index.json +38 -22
  38. package/src/types/block-ref.test.ts +0 -45
  39. package/templates/nextjs/template/src/app/collect/route.ts +0 -18
@@ -2,6 +2,7 @@ import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from
2
2
  import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
3
3
  import { getUncachedEdgeConfigValue } from './edge-config.js';
4
4
  import { IdentityErasureFencedError } from './personalization-host.js';
5
+ import { decodeEdgeRouteProjectionV1, edgeRoutesV1Key, } from './vercel-edge-projection-v1.js';
5
6
  const ENVELOPE_DIGEST_METADATA_KEY = 'aci-envelope-sha256';
6
7
  const COLLECTION_GATE_ETAG_METADATA_KEY = 'aci-collection-gate-etag';
7
8
  const COLLECTION_GATE_STATE_METADATA_KEY = 'aci-collection-gate-state';
@@ -406,12 +407,13 @@ export class VercelEdgeConfigRouteStore {
406
407
  this.siteId = siteId;
407
408
  }
408
409
  async readEdgeManifest(releaseId) {
409
- const value = await getUncachedEdgeConfigValue(this.edgeConfig, edgeRoutesKey(this.siteId, releaseId));
410
- if (!value)
410
+ const value = await getUncachedEdgeConfigValue(this.edgeConfig, edgeRoutesV1Key(this.siteId, releaseId));
411
+ if (value == null)
411
412
  return null;
412
- if (typeof value === 'string')
413
- return JSON.parse(value);
414
- return value;
413
+ return decodeEdgeRouteProjectionV1(value, {
414
+ siteId: this.siteId,
415
+ releaseId,
416
+ });
415
417
  }
416
418
  }
417
419
  export function createVercelS3PersonalizationStore(config) {
@@ -531,12 +533,6 @@ function cleanKey(value) {
531
533
  function joinKey(...parts) {
532
534
  return parts.map(cleanKey).filter(Boolean).join('/');
533
535
  }
534
- function edgeRoutesKey(siteId, releaseId) {
535
- return `edgeRoutes_${edgeKeySegment(siteId)}_${edgeKeySegment(releaseId)}`;
536
- }
537
- function edgeKeySegment(value) {
538
- return value.replace(/[^A-Za-z0-9_-]/g, '_');
539
- }
540
536
  function isNotFound(error) {
541
537
  const value = error;
542
538
  return value?.$metadata?.httpStatusCode === 404 || value?.name === 'NoSuchKey' || value?.name === 'NotFound';
@@ -32,10 +32,8 @@ export declare class S3ContentProvider implements ContentProvider {
32
32
  getSiteConfig<T = unknown>(): Promise<T>;
33
33
  getPage<T = unknown>(route: string): Promise<T>;
34
34
  /**
35
- * getPageByRef serves the exact payload the edge selected (the
36
- * X-Aci-Content-Ref contract): base page or variant. Only refs the
37
- * release manifest declares are served — the header names published
38
- * content, it cannot address arbitrary keys.
35
+ * getPageByRef serves an exact payload the manifest declares. The stricter
36
+ * route-bound selection used by server rendering stays package-internal.
39
37
  */
40
38
  getPageByRef<T = unknown>(payloadRef: string): Promise<T>;
41
39
  getFragment<T = unknown>(id: string): Promise<T>;
@@ -1,8 +1,9 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
3
3
  import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
4
- import { FragmentNotFoundError, LayoutNotFoundError, PageNotFoundError } from '../content/provider.js';
4
+ import { FragmentNotFoundError, LayoutNotFoundError, PageNotFoundError, } from '../content/provider.js';
5
5
  import { normalizeRoute } from '../content/routes.js';
6
+ import { createVerifiedPageReceipt, selectVerifiedPage, validateVerifiedPageReceipt, } from '../internal/render-proof.js';
6
7
  // ---------------------------------------------------------------------------
7
8
  // S3 content provider — reads compiled content files from S3
8
9
  // ---------------------------------------------------------------------------
@@ -23,29 +24,21 @@ export class S3ContentProvider {
23
24
  return await this.getJSON(manifest.siteConfigRef);
24
25
  }
25
26
  async getPage(route) {
26
- const manifest = await this.manifest();
27
- const normalized = normalizeRoute(route);
28
- const entry = Object.values(manifest.routes).find((candidate) => normalizeRoute(candidate.path) === normalized);
29
- if (!entry) {
30
- throw new PageNotFoundError(route);
31
- }
32
- try {
33
- return await this.getJSON(entry.payloadRef);
34
- }
35
- catch (error) {
36
- throw new PageNotFoundError(route, error);
37
- }
27
+ return (await this.#selectPage({
28
+ route,
29
+ contentRef: '',
30
+ contentRefPresent: false,
31
+ variantKey: '',
32
+ variantKeyPresent: false,
33
+ })).page;
38
34
  }
39
35
  /**
40
- * getPageByRef serves the exact payload the edge selected (the
41
- * X-Aci-Content-Ref contract): base page or variant. Only refs the
42
- * release manifest declares are served — the header names published
43
- * content, it cannot address arbitrary keys.
36
+ * getPageByRef serves an exact payload the manifest declares. The stricter
37
+ * route-bound selection used by server rendering stays package-internal.
44
38
  */
45
39
  async getPageByRef(payloadRef) {
46
40
  const manifest = await this.manifest();
47
- const known = Object.values(manifest.routes).some((entry) => entry.payloadRef === payloadRef ||
48
- Object.values(entry.variants ?? {}).some((variant) => variant.payloadRef === payloadRef));
41
+ const known = Object.values(manifest.routes).some((entry) => routeContainsRef(entry, payloadRef));
49
42
  if (!known) {
50
43
  throw new PageNotFoundError(payloadRef);
51
44
  }
@@ -56,6 +49,20 @@ export class S3ContentProvider {
56
49
  throw new PageNotFoundError(payloadRef, error);
57
50
  }
58
51
  }
52
+ /** @internal */
53
+ async [selectVerifiedPage](request) {
54
+ const selection = await this.#selectPage(request, request.releaseId);
55
+ return Object.freeze({
56
+ page: selection.page,
57
+ receipt: createVerifiedPageReceipt({
58
+ releaseId: request.releaseId,
59
+ route: selection.route,
60
+ contentRef: selection.contentRef,
61
+ contentSha256: selection.contentSha256,
62
+ variantKey: selection.variantKey,
63
+ }),
64
+ });
65
+ }
59
66
  async getFragment(id) {
60
67
  const manifest = await this.manifest();
61
68
  const entry = manifest.fragments[id];
@@ -92,6 +99,149 @@ export class S3ContentProvider {
92
99
  async getJSON(key) {
93
100
  return await getS3JSON(joinKey(this.config.keyPrefix, key), this.config, this.#client);
94
101
  }
102
+ async #selectPage(request, releaseId) {
103
+ if (request.variantKeyPresent && !request.contentRefPresent) {
104
+ throw pageIntegrityError('variant header requires a content ref');
105
+ }
106
+ const manifest = await this.manifest();
107
+ const normalized = normalizeRoute(request.route);
108
+ const routeMatches = Object.values(manifest.routes).filter((candidate) => normalizeRoute(candidate.path) === normalized);
109
+ if (routeMatches.length === 0) {
110
+ if (request.contentRefPresent) {
111
+ const elsewhere = Object.values(manifest.routes)
112
+ .some((candidate) => routeContainsRef(candidate, request.contentRef));
113
+ const reason = elsewhere ? 'cross-route content ref' : 'undeclared route content ref';
114
+ throw pageIntegrityError(`${reason}: ${request.contentRef}`);
115
+ }
116
+ throw new PageNotFoundError(request.route);
117
+ }
118
+ if (routeMatches.length !== 1) {
119
+ throw pageIntegrityError(`route ${normalized} is ambiguous`);
120
+ }
121
+ const entry = routeMatches[0];
122
+ const route = normalizeRoute(entry.path);
123
+ const tuples = [
124
+ {
125
+ contentRef: entry.payloadRef,
126
+ digest: entry.digest,
127
+ variantKey: '',
128
+ isVariant: false,
129
+ },
130
+ ...Object.values(entry.variants ?? {}).map(pageTupleForVariant),
131
+ ];
132
+ let tuple;
133
+ if (!request.contentRefPresent) {
134
+ tuple = tuples[0];
135
+ }
136
+ else {
137
+ const matches = tuples.filter((candidate) => {
138
+ if (candidate.contentRef !== request.contentRef)
139
+ return false;
140
+ if (!request.variantKeyPresent)
141
+ return true;
142
+ return candidate.isVariant && candidate.variantKey === request.variantKey;
143
+ });
144
+ if (matches.length === 0) {
145
+ const elsewhere = Object.values(manifest.routes)
146
+ .filter((candidate) => candidate !== entry)
147
+ .some((candidate) => routeContainsRef(candidate, request.contentRef));
148
+ const reason = elsewhere ? 'cross-route content ref' : 'undeclared route content ref';
149
+ throw pageIntegrityError(`${reason}: ${request.contentRef}`);
150
+ }
151
+ if (matches.length !== 1) {
152
+ throw pageIntegrityError(`ambiguous route content ref: ${request.contentRef}`);
153
+ }
154
+ tuple = matches[0];
155
+ }
156
+ const digestMatch = /^sha256:([0-9a-f]{64})$/.exec(tuple.digest);
157
+ if (!digestMatch) {
158
+ throw pageIntegrityError(`invalid manifest digest for ${tuple.contentRef}`);
159
+ }
160
+ const contentSha256 = digestMatch[1];
161
+ if (releaseId !== undefined) {
162
+ validateVerifiedPageReceipt({
163
+ releaseId,
164
+ route,
165
+ contentRef: tuple.contentRef,
166
+ contentSha256,
167
+ variantKey: tuple.variantKey,
168
+ });
169
+ }
170
+ const key = joinManifestContentKey(this.config.keyPrefix, tuple.contentRef);
171
+ let response;
172
+ try {
173
+ response = await getS3Raw(key, this.config, this.#client);
174
+ }
175
+ catch (cause) {
176
+ throw pageIntegrityError(`fetch failed for ${tuple.contentRef}`, cause);
177
+ }
178
+ if (!response.ok) {
179
+ const detail = await safeResponseText(response);
180
+ throw pageIntegrityError(`fetch failed for ${tuple.contentRef} with status ${response.status}${detail ? `: ${detail}` : ''}`);
181
+ }
182
+ let bytes;
183
+ try {
184
+ bytes = new Uint8Array(await response.arrayBuffer());
185
+ }
186
+ catch (cause) {
187
+ throw pageIntegrityError(`response body failed for ${tuple.contentRef}`, cause);
188
+ }
189
+ const actualDigest = crypto.createHash('sha256').update(bytes).digest('hex');
190
+ if (actualDigest !== contentSha256) {
191
+ throw pageIntegrityError(`digest mismatch for ${tuple.contentRef}: got ${actualDigest}, want ${contentSha256}`);
192
+ }
193
+ let source = '';
194
+ try {
195
+ source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
196
+ }
197
+ catch (cause) {
198
+ throw pageIntegrityError(`invalid UTF-8 for ${tuple.contentRef}`, cause);
199
+ }
200
+ let page;
201
+ try {
202
+ page = JSON.parse(source);
203
+ }
204
+ catch (cause) {
205
+ throw pageIntegrityError(`invalid JSON for ${tuple.contentRef}`, cause);
206
+ }
207
+ return Object.freeze({
208
+ page,
209
+ route,
210
+ contentRef: tuple.contentRef,
211
+ contentSha256,
212
+ variantKey: tuple.variantKey,
213
+ });
214
+ }
215
+ }
216
+ function pageTupleForVariant(variant) {
217
+ if (typeof variant.variantKey !== 'string' || variant.variantKey === '') {
218
+ throw pageIntegrityError('variant manifest tuple has an empty variant key');
219
+ }
220
+ return {
221
+ contentRef: variant.payloadRef,
222
+ digest: variant.digest,
223
+ variantKey: variant.variantKey,
224
+ isVariant: true,
225
+ };
226
+ }
227
+ function routeContainsRef(entry, contentRef) {
228
+ return entry.payloadRef === contentRef
229
+ || Object.values(entry.variants ?? {})
230
+ .some((variant) => variant.payloadRef === contentRef);
231
+ }
232
+ function pageIntegrityError(message, cause) {
233
+ const error = new Error(`ACI S3 page integrity error: ${message}`);
234
+ if (cause !== undefined)
235
+ error.cause = cause;
236
+ return error;
237
+ }
238
+ async function safeResponseText(response) {
239
+ try {
240
+ return await response.text();
241
+ }
242
+ catch {
243
+ return '';
244
+ }
95
245
  }
96
246
  // ---------------------------------------------------------------------------
97
247
  // S3 fetch with AWS SigV4 signing
@@ -278,3 +428,7 @@ function joinKey(...parts) {
278
428
  .filter(Boolean)
279
429
  .join('/');
280
430
  }
431
+ function joinManifestContentKey(prefix, contentRef) {
432
+ const normalizedPrefix = joinKey(prefix);
433
+ return normalizedPrefix ? `${normalizedPrefix}/${contentRef}` : contentRef;
434
+ }
@@ -5,7 +5,6 @@ export interface BlockRefMeta {
5
5
  multiple: boolean;
6
6
  }
7
7
  declare const blockRefBase: z.ZodObject<{
8
- $type: z.ZodLiteral<"component">;
9
8
  id: z.ZodString;
10
9
  component: z.ZodString;
11
10
  props: z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -13,7 +12,6 @@ declare const blockRefBase: z.ZodObject<{
13
12
  type InferContractProps<T> = T extends ComponentContract<string, infer Schema> ? Schema extends z.ZodType<infer P> ? P : Record<string, unknown> : Record<string, unknown>;
14
13
  type InferContractId<T> = T extends ComponentContract<infer Id, unknown> ? Id : never;
15
14
  export type BlockRefEntry<T extends ComponentContract> = {
16
- $type: 'component';
17
15
  id: string;
18
16
  component: InferContractId<T>;
19
17
  props: InferContractProps<T>;
@@ -1,7 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  const BLOCK_REF_KEY = '_gradialBlockRef';
3
3
  const blockRefBase = z.object({
4
- $type: z.literal('component'),
5
4
  id: z.string().min(1),
6
5
  component: z.string().min(1),
7
6
  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.45",
3
+ "version": "0.1.46",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/gradial/bare-metal-go.git",
@@ -114,7 +114,7 @@
114
114
  }
115
115
  },
116
116
  "scripts": {
117
- "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node scripts/build-collect.mjs",
117
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json && node scripts/assert-render-proof-public-surface.mjs && node scripts/build-collect.mjs",
118
118
  "test": "node --import tsx --test \"src/**/*.test.ts\"",
119
119
  "build:collect": "node scripts/build-collect.mjs",
120
120
  "build:cli": "cd ../.. && ./scripts/build-cli.sh",
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from 'node:fs';
3
2
  import { mkdir, writeFile } from 'node:fs/promises';
4
3
  import path from 'node:path';
5
- import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { pathToFileURL } from 'node:url';
6
5
  import { z } from 'zod';
7
6
  import { ImageSchema } from '../types/image.ts';
8
7
  import { VideoSchema } from '../types/video.ts';
@@ -20,15 +19,14 @@ try {
20
19
  if (!globalThis.React) globalThis.React = React;
21
20
  } catch { /* react not available — component files without JSX will still work */ }
22
21
 
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
- }
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;
32
30
  }
33
31
 
34
32
  async function compileRegistry(options) {
@@ -432,10 +430,9 @@ async function generatePageSchema(schemaDir, componentEntries, outDir) {
432
430
  const defs = {
433
431
  baseBlock: {
434
432
  type: 'object',
435
- required: ['$type', 'id', 'component', 'props'],
433
+ required: ['id', 'component', 'props'],
436
434
  additionalProperties: true,
437
435
  properties: {
438
- $type: { const: 'component' },
439
436
  id: { type: 'string', minLength: 1 },
440
437
  component: { type: 'string' },
441
438
  props: { type: 'object' },
@@ -744,14 +741,3 @@ function parseArgs(argv) {
744
741
  }
745
742
  return out;
746
743
  }
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,24 +19,21 @@
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';
23
22
  import { readdir, readFile } from 'node:fs/promises';
24
23
  import path from 'node:path';
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
- }
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;
40
37
  }
41
38
 
42
39
  // ---------------------------------------------------------------------------
@@ -98,24 +95,6 @@ async function validateContent(options) {
98
95
  for (const block of blocks) {
99
96
  blocksValidated++;
100
97
 
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
-
119
98
  if (!block.component) {
120
99
  collector.push({
121
100
  page: relativePath,
@@ -401,13 +380,6 @@ function validateNestedSlots(parentBlock, componentMap, schemaMap, path = 'props
401
380
  issues.push({ path: nestedPath, message: `${parentBlock.component}: block ref child must be a block` });
402
381
  continue;
403
382
  }
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
- }
411
383
 
412
384
  if (!componentMap.has(nestedBlock.component)) {
413
385
  issues.push({ path: `${nestedPath}.component`, message: `${parentBlock.component}: unknown nested component "${nestedBlock.component}"` });
@@ -478,14 +450,3 @@ function parseArgs(argv) {
478
450
  }
479
451
  return out;
480
452
  }
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 };
@@ -71,6 +71,12 @@ checkMissing(
71
71
  'The root catch-all handles release-scoped rewrites. Do not create a separate __r route folder.',
72
72
  );
73
73
 
74
+ checkMissing(
75
+ 'platform-owned collect route is absent',
76
+ 'src/app/collect/route.ts',
77
+ 'ACI injects the reserved /collect function during Vercel Build Output composition; customer source must not own it.',
78
+ );
79
+
74
80
  checkFile(
75
81
  'next.config uses withAci',
76
82
  'next.config.mjs',
@@ -9,7 +9,6 @@ export interface BlockRefMeta {
9
9
  }
10
10
 
11
11
  const blockRefBase = z.object({
12
- $type: z.literal('component'),
13
12
  id: z.string().min(1),
14
13
  component: z.string().min(1),
15
14
  props: z.record(z.string(), z.unknown()),
@@ -22,7 +21,6 @@ type InferContractProps<T> = T extends ComponentContract<string, infer Schema>
22
21
  type InferContractId<T> = T extends ComponentContract<infer Id, unknown> ? Id : never;
23
22
 
24
23
  export type BlockRefEntry<T extends ComponentContract> = {
25
- $type: 'component';
26
24
  id: string;
27
25
  component: InferContractId<T>;
28
26
  props: InferContractProps<T>;
@@ -13,18 +13,17 @@ export const atomicBlockStoryFixtures: AtomicBlockStoryFixture[] = [
13
13
  storybookId: 'core-atomic-blocks-button--variants',
14
14
  title: 'Button / All Variants',
15
15
  block: {
16
- $type: 'component',
17
16
  id: 'fixture-button-variants',
18
17
  component: 'container',
19
18
  props: {
20
19
  theme: 'auto',
21
20
  blocks: [
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' } }
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' } }
28
27
  ]
29
28
  }
30
29
  }
@@ -14,15 +14,14 @@ export const compositionStoryFixtures: CompositionStoryFixture[] = [
14
14
  storybookId: 'composition-container--buttons',
15
15
  title: 'Container / Buttons',
16
16
  block: {
17
- $type: 'component',
18
17
  id: 'fixture-container-buttons',
19
18
  component: 'container',
20
19
  props: {
21
20
  theme: 'auto',
22
21
  className: 'space-y-4',
23
22
  blocks: [
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' } }
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' } }
26
25
  ]
27
26
  }
28
27
  }
@@ -14,7 +14,6 @@ export const sectionStoryFixtures: SectionStoryFixture[] = [
14
14
  storybookId: 'sections-home-hero--default',
15
15
  title: 'Home Hero',
16
16
  block: {
17
- $type: 'component',
18
17
  id: 'fixture-home-hero',
19
18
  component: 'home_hero',
20
19
  props: {
@@ -23,7 +22,7 @@ export const sectionStoryFixtures: SectionStoryFixture[] = [
23
22
  description: 'One content model, three renderers. Build once in Next.js, Astro, and SvelteKit—then prove parity with shared fixtures.',
24
23
  ctaLabel: 'Get started',
25
24
  ctaHref: '/docs',
26
- image: {
25
+ image: {
27
26
  $type: 'gradial.image',
28
27
  assetId: 'fixture_seattle',
29
28
  versionId: 'v0001',
@@ -98,7 +98,6 @@ The `home` slug is special-cased to route `/`. All other slugs map directly.
98
98
 
99
99
  ```json
100
100
  {
101
- "$type": "component",
102
101
  "id": "hero-section",
103
102
  "component": "home_hero",
104
103
  "props": {
@@ -110,7 +109,6 @@ The `home` slug is special-cased to route `/`. All other slugs map directly.
110
109
  ```
111
110
 
112
111
  - `component` must match a contract `id` exactly.
113
- - Every embedded block must declare `"$type": "component"`.
114
112
  - `props` must validate against the contract's Zod schema.
115
113
  - `id` is unique within the page.
116
114
 
@@ -143,14 +141,12 @@ export const containerContract = defineComponentContract({
143
141
  **Content JSON:**
144
142
  ```json
145
143
  {
146
- "$type": "component",
147
144
  "id": "cta-section",
148
145
  "component": "container",
149
146
  "props": {
150
147
  "className": "space-y-4",
151
148
  "content": [
152
149
  {
153
- "$type": "component",
154
150
  "id": "primary-cta",
155
151
  "component": "button",
156
152
  "props": {
@@ -160,7 +156,6 @@ export const containerContract = defineComponentContract({
160
156
  }
161
157
  },
162
158
  {
163
- "$type": "component",
164
159
  "id": "secondary-cta",
165
160
  "component": "button",
166
161
  "props": {
@@ -187,7 +182,6 @@ The `button` component is a common atomic block:
187
182
 
188
183
  ```json
189
184
  {
190
- "$type": "component",
191
185
  "id": "cta-button",
192
186
  "component": "button",
193
187
  "props": {
@@ -417,7 +411,6 @@ framework.
417
411
  ```json
418
412
  // .content/pages/home/_index.json (inside regions.main)
419
413
  {
420
- "$type": "component",
421
414
  "id": "features",
422
415
  "component": "feature_showcase",
423
416
  "props": {
@@ -506,10 +499,9 @@ export default [
506
499
  ];
507
500
  ```
508
501
 
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.
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.
513
505
 
514
506
  The `layout` field in page JSON is **required** — there is no implicit
515
507
  default. Every page must declare which layout it uses.
@@ -113,14 +113,12 @@ type RenderChildren<T extends readonly BlockRef[]> = (blocks: T) => Promise<Reac
113
113
 
114
114
  ```json
115
115
  {
116
- "$type": "component",
117
116
  "id": "cta-section",
118
117
  "component": "container",
119
118
  "props": {
120
119
  "theme": "dark",
121
120
  "blocks": [
122
121
  {
123
- "$type": "component",
124
122
  "id": "primary-cta",
125
123
  "component": "callout",
126
124
  "props": {
@@ -129,7 +127,6 @@ type RenderChildren<T extends readonly BlockRef[]> = (blocks: T) => Promise<Reac
129
127
  }
130
128
  },
131
129
  {
132
- "$type": "component",
133
130
  "id": "heading",
134
131
  "component": "heading",
135
132
  "props": {