@gradial/aci 0.1.52-rc.26 → 0.1.52-rc.28

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 (82) hide show
  1. package/CHANGELOG.md +0 -28
  2. package/README.md +11 -4
  3. package/dist/astro/index.d.ts +0 -4
  4. package/dist/astro/index.js +0 -32
  5. package/dist/astro/page.d.ts +1 -1
  6. package/dist/astro/page.js +5 -2
  7. package/dist/cli/compile-registry.mjs +6 -34
  8. package/dist/content/index.d.ts +2 -1
  9. package/dist/content/index.js +4 -1
  10. package/dist/content/provider.d.ts +5 -1
  11. package/dist/content/resolve-slots.d.ts +24 -2
  12. package/dist/content/resolve-slots.js +127 -21
  13. package/dist/content/types.d.ts +3 -1
  14. package/dist/index.d.ts +0 -1
  15. package/dist/index.js +0 -1
  16. package/dist/mintlify/blocks.js +4 -1
  17. package/dist/mintlify/index.d.ts +1 -1
  18. package/dist/mintlify/index.js +1 -1
  19. package/dist/mintlify/materialize.d.ts +3 -1
  20. package/dist/mintlify/materialize.js +105 -10
  21. package/dist/mintlify/types.d.ts +7 -2
  22. package/dist/mintlify/validate.js +6 -2
  23. package/dist/next/config.js +0 -24
  24. package/dist/next/error-seat-payload.d.ts +2 -1
  25. package/dist/next/error-seat-payload.js +14 -24
  26. package/dist/next/error-seat.js +4 -2
  27. package/dist/next/render.d.ts +1 -1
  28. package/dist/next/render.js +7 -4
  29. package/dist/next/server.js +2 -1
  30. package/dist/testing/conformance/root-identity.js +1 -1
  31. package/dist/types/block-ref.js +2 -2
  32. package/dist/types/config.d.ts +0 -1
  33. package/dist/types/index.d.ts +0 -1
  34. package/dist/types/index.js +0 -1
  35. package/dist/types/page.d.ts +0 -1
  36. package/package.json +7 -7
  37. package/src/cli/compile-registry.mjs +6 -30
  38. package/src/types/block-ref.test.ts +16 -1
  39. package/src/types/block-ref.ts +2 -2
  40. package/src/types/config.ts +0 -1
  41. package/src/types/index.ts +0 -1
  42. package/src/types/page.ts +0 -1
  43. package/templates/astro/template/.aci.yaml.tmpl +0 -1
  44. package/templates/common/.agents/skills/aci/SKILL.md +1 -1
  45. package/templates/common/.agents/skills/authoring/SKILL.md +190 -39
  46. package/templates/common/.agents/skills/authoring/references/block-registry.md +4 -3
  47. package/templates/common/.agents/skills/authoring/references/contracts.md +9 -30
  48. package/templates/common/.agents/skills/authoring/references/repo-structure.md +5 -4
  49. package/templates/common/.agents/skills/authoring/references/sdk-server-functions.md +3 -3
  50. package/templates/common/.agents/skills/migration/references/canon-migration.md +18 -67
  51. package/templates/common/.content/fragments/navbar/_index.json +24 -0
  52. package/templates/common/.content/fragments/site-footer/_index.json +61 -0
  53. package/templates/common/.content/pages/404/_index.json +4 -2
  54. package/templates/common/.content/pages/_index.json +0 -1
  55. package/templates/common/.content/pages/error/_index.json +4 -2
  56. package/templates/common/.content/templates/error.json +24 -0
  57. package/templates/common/.content/templates/home.json +28 -0
  58. package/templates/common/.content/templates/site.json +8 -0
  59. package/templates/common/.content/templates/standard.json +24 -0
  60. package/templates/common/starter-content/.content/pages/404/_index.json +0 -1
  61. package/templates/common/starter-content/.content/pages/_index.json +12 -1
  62. package/templates/common/starter-content/.content/pages/about/_index.json +1 -1
  63. package/templates/common/starter-content/.content/pages/error/_index.json +0 -1
  64. package/templates/common/starter-content/.content/patterns/components/button.json +2 -2
  65. package/templates/common/starter-content/.content/patterns/components/container.json +8 -3
  66. package/templates/common/starter-content/.content/patterns/components/feature_card.json +2 -2
  67. package/templates/common/starter-content/.content/patterns/components/feature_grid.json +5 -3
  68. package/templates/common/starter-content/.content/patterns/components/home_hero.json +2 -2
  69. package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +42 -0
  70. package/templates/common/starter-content/.content/patterns/components/site-footer.json +2 -2
  71. package/templates/common/starter-content/.content/patterns/components/site-navigation.json +2 -2
  72. package/templates/common/starter-content/.content/patterns/components/stats_bar.json +2 -2
  73. package/templates/nextjs/template/.aci.yaml +0 -1
  74. package/templates/nextjs/template/.aci.yaml.tmpl +0 -1
  75. package/dist/define-layout.d.ts +0 -6
  76. package/dist/define-layout.js +0 -14
  77. package/dist/types/layout.d.ts +0 -24
  78. package/dist/types/layout.js +0 -1
  79. package/src/types/layout.ts +0 -29
  80. package/templates/astro/template/src/cms/contracts/layouts/index.ts +0 -18
  81. package/templates/common/starter-content/.content/patterns/layouts/default.json +0 -14
  82. package/templates/nextjs/template/src/cms/contracts/layouts/index.ts +0 -16
@@ -1,3 +1,5 @@
1
+ import { resolveSlots, resolveFragmentNodes } from '../content/resolve-slots.js';
2
+ import { FragmentNotFoundError } from '../content/provider.js';
1
3
  import { mkdir, readFile, readdir, writeFile, } from 'node:fs/promises';
2
4
  import path from 'node:path';
3
5
  import { resolveConfigReferences, toDocsJSON } from './config.js';
@@ -20,10 +22,19 @@ export async function loadMintlifySource(sourceDir) {
20
22
  readOptionalJSONResources(path.join(sourceDir, 'config', 'mintlify')),
21
23
  readOptionalJSONResources(path.join(sourceDir, 'data', 'mintlify-api')),
22
24
  ]);
25
+ const sourceFragments = new Map(fragmentResources.map(({ value, relative }) => [relative.slice(0, -'/_index.json'.length), value]));
26
+ const getFragment = async (id) => {
27
+ if (!sourceFragments.has(id))
28
+ throw new FragmentNotFoundError(id);
29
+ return sourceFragments.get(id);
30
+ };
23
31
  return {
24
32
  site: validateSite(site, sitePath),
25
- documents: pageResources.map(({ value, file, relative }) => validateDocument(value, file, relative === '_index.json' ? 'index' : relative.slice(0, -'/_index.json'.length))),
26
- snippets: fragmentResources.map(({ value, file, relative }) => validateSnippet(value, file, `${relative.slice(0, -'/_index.json'.length)}.mdx`)),
33
+ documents: await Promise.all(pageResources.map(async ({ value, file, relative }) => validateDocument(await expandDocument(value, getFragment), file, relative === '_index.json' ? 'index' : relative.slice(0, -'/_index.json'.length)))),
34
+ snippets: await Promise.all(fragmentResources.map(async ({ value, file, relative }) => {
35
+ const id = relative.slice(0, -'/_index.json'.length);
36
+ return validateSnippet(await expandSnippet(value, id, getFragment), file, `${id}.mdx`);
37
+ })),
27
38
  configFragments: configFragments.map(({ value, file }) => validateConfigFragment(value, file)),
28
39
  apiSpecs: apiSpecs.map(({ value, file }) => validateAPISpec(value, file)),
29
40
  };
@@ -40,16 +51,27 @@ export async function loadCompiledMintlifySource(sourceDir, compiledDir) {
40
51
  if (!isObject(manifestValue) || !isObject(manifestValue.routes) || !isObject(manifestValue.fragments)) {
41
52
  throw new Error(`ACI compiled manifest is invalid: ${manifestPath}`);
42
53
  }
43
- const documents = await Promise.all(Object.entries(manifestValue.routes).map(async ([slug, entry]) => {
54
+ const fragments = await Promise.all(Object.entries(manifestValue.fragments).map(async ([id, entry]) => {
44
55
  if (!isObject(entry) || typeof entry.payloadRef !== 'string')
45
- throw new Error(`ACI compiled route is invalid: ${slug}`);
46
- return validateDocument(await readJSON(safeOutputPath(compiledDir, entry.payloadRef)), entry.payloadRef, slug || 'index');
56
+ throw new Error(`ACI compiled fragment is invalid: ${id}`);
57
+ return { id, entry, value: await readJSON(safeOutputPath(compiledDir, entry.payloadRef)) };
47
58
  }));
48
- const snippets = await Promise.all(Object.entries(manifestValue.fragments).map(async ([id, entry]) => {
59
+ const byId = new Map(fragments.map(({ id, value }) => [id, value]));
60
+ const getFragment = async (id) => {
61
+ if (!byId.has(id))
62
+ throw new FragmentNotFoundError(id);
63
+ return byId.get(id);
64
+ };
65
+ const documents = await Promise.all(Object.entries(manifestValue.routes).map(async ([slug, entry]) => {
49
66
  if (!isObject(entry) || typeof entry.payloadRef !== 'string')
50
- throw new Error(`ACI compiled fragment is invalid: ${id}`);
51
- return validateSnippet(await readJSON(safeOutputPath(compiledDir, entry.payloadRef)), entry.payloadRef, `${id}.mdx`);
67
+ throw new Error(`ACI compiled route is invalid: ${slug}`);
68
+ const value = await readJSON(safeOutputPath(compiledDir, entry.payloadRef));
69
+ const layout = isObject(value) && typeof value.layout === 'string' && isObject(manifestValue.layouts)
70
+ ? manifestValue.layouts[value.layout] : undefined;
71
+ const plan = requireMintlifyLayout(value, layout, entry.payloadRef);
72
+ return validateDocument(await expandDocument(value, getFragment, plan), entry.payloadRef, slug || 'index');
52
73
  }));
74
+ const snippets = await Promise.all(fragments.map(async ({ id, entry, value }) => validateSnippet(await expandSnippet(value, id, getFragment), String(entry.payloadRef), `${id}.mdx`)));
53
75
  return {
54
76
  site: validateSite(siteValue, sitePath), documents, snippets,
55
77
  configFragments: configFragments.map(({ value, file }) => validateConfigFragment(value, file)),
@@ -60,8 +82,9 @@ export async function loadCompiledMintlifySource(sourceDir, compiledDir) {
60
82
  export async function materializeMintlifySite(options) {
61
83
  const outputDir = path.resolve(options.outputDir);
62
84
  const site = validateSite(options.site, 'site');
63
- const documents = options.documents.map((document, index) => validateDocument(document, `documents[${index}]`, document.path));
64
- const snippets = (options.snippets ?? []).map((snippet, index) => validateSnippet(snippet, `snippets[${index}]`, snippet.path));
85
+ const getFragment = fragmentLoader(options.snippets ?? []);
86
+ const documents = await Promise.all(options.documents.map(async (document, index) => validateDocument(await expandDocument(document, getFragment), `documents[${index}]`, document.path)));
87
+ const snippets = await Promise.all((options.snippets ?? []).map(async (snippet, index) => validateSnippet(await expandSnippet(snippet, snippet.id, getFragment), `snippets[${index}]`, snippet.path)));
65
88
  const configFragments = (options.configFragments ?? []).map((fragment, index) => validateConfigFragment(fragment, `configFragments[${index}]`));
66
89
  const apiSpecs = (options.apiSpecs ?? []).map((spec, index) => validateAPISpec(spec, `apiSpecs[${index}]`));
67
90
  const nativeConfig = toDocsJSON(site);
@@ -157,3 +180,75 @@ function safeOutputPath(root, relativePath) {
157
180
  throw new Error(`ACI Mintlify generated path escapes output directory: ${relativePath}`);
158
181
  return target;
159
182
  }
183
+ function fragmentLoader(fragments) {
184
+ const byId = new Map();
185
+ for (const fragment of fragments) {
186
+ if (!isObject(fragment) || typeof fragment.id !== 'string')
187
+ continue;
188
+ if (byId.has(fragment.id))
189
+ throw new Error(`ACI fragment ID is duplicated: ${fragment.id}`);
190
+ byId.set(fragment.id, fragment);
191
+ }
192
+ return async (id) => {
193
+ const fragment = byId.get(id);
194
+ if (!fragment)
195
+ throw new FragmentNotFoundError(id);
196
+ return fragment;
197
+ };
198
+ }
199
+ function requireMintlifyLayout(page, value, label) {
200
+ if (!isObject(page) || !isObject(value) || typeof value.name !== 'string'
201
+ || page.layout !== value.name || !Array.isArray(value.slots)) {
202
+ throw new Error(`ACI Mintlify document ${label} requires its exact bound template render plan`);
203
+ }
204
+ for (const slot of value.slots) {
205
+ if (!isObject(slot) || slot.name !== 'main') {
206
+ throw new Error(`ACI Mintlify template ${value.name} has unsupported region ${JSON.stringify(isObject(slot) ? slot.name : slot)}; only main is supported (${label})`);
207
+ }
208
+ }
209
+ if (value.slots.length !== 1)
210
+ throw new Error(`ACI Mintlify template ${value.name} must declare exactly one main region (${label})`);
211
+ if (value.defaults !== undefined) {
212
+ if (!isObject(value.defaults))
213
+ throw new Error(`ACI Mintlify template ${value.name} has invalid defaults (${label})`);
214
+ for (const [region, nodes] of Object.entries(value.defaults)) {
215
+ if (region !== 'main')
216
+ throw new Error(`ACI Mintlify template ${value.name} has unsupported default region ${JSON.stringify(region)}; only main is supported (${label})`);
217
+ if (!Array.isArray(nodes))
218
+ throw new Error(`ACI Mintlify template ${value.name} has invalid main defaults (${label})`);
219
+ }
220
+ }
221
+ return value;
222
+ }
223
+ async function expandDocument(value, getFragment, layout) {
224
+ if (!isObject(value) || !isObject(value.regions))
225
+ return value;
226
+ if (Object.hasOwn(value.regions, 'main') && !Array.isArray(value.regions.main))
227
+ return value;
228
+ if (layout) {
229
+ const resolved = await resolveSlots(layout, value.regions, getFragment);
230
+ // Preserve unexpected authored keys so validateDocument can refuse them.
231
+ return { ...value, regions: { ...value.regions, main: resolved.main ?? [] } };
232
+ }
233
+ if (!Array.isArray(value.regions.main))
234
+ return value;
235
+ return { ...value, regions: { ...value.regions, main: await resolveFragmentNodes(value.regions.main, getFragment) } };
236
+ }
237
+ /** Project a runtime page using only its selected, immutable fragment bindings. */
238
+ export async function resolveMintlifyDocument(value, fragments, documentPath, layout) {
239
+ const getFragment = async (id) => {
240
+ if (!Object.hasOwn(fragments, id))
241
+ throw new FragmentNotFoundError(id);
242
+ return fragments[id];
243
+ };
244
+ const plan = requireMintlifyLayout(value, layout, documentPath);
245
+ return validateDocument(await expandDocument(value, getFragment, plan), documentPath, documentPath);
246
+ }
247
+ // Standalone snippets traverse the root reference too, so a root self-reference
248
+ // uses the same ancestry/depth checks as a page placement.
249
+ async function expandSnippet(value, id, getFragment) {
250
+ if (!isObject(value))
251
+ return value;
252
+ const [block] = await resolveFragmentNodes([{ kind: 'fragment-ref', fragmentId: id }], getFragment);
253
+ return { ...value, id: value.id ?? id, block };
254
+ }
@@ -1,3 +1,4 @@
1
+ import type { FragmentRefNode } from '../types/page.js';
1
2
  export type MintlifyJSONValue = null | boolean | number | string | MintlifyJSONValue[] | {
2
3
  [key: string]: MintlifyJSONValue;
3
4
  };
@@ -23,7 +24,6 @@ export interface MintlifyDocument {
23
24
  $type: 'page';
24
25
  id: string;
25
26
  status: string;
26
- layout: string;
27
27
  path: string;
28
28
  metadata: {
29
29
  title: string;
@@ -64,6 +64,11 @@ export interface MintlifySource {
64
64
  configFragments?: MintlifyConfigFragment[];
65
65
  apiSpecs?: MintlifyAPISpec[];
66
66
  }
67
- export interface MaterializeMintlifySiteOptions extends MintlifySource {
67
+ export interface MaterializeMintlifySiteOptions extends Omit<MintlifySource, 'documents'> {
68
+ documents: Array<Omit<MintlifyDocument, 'regions'> & {
69
+ regions: {
70
+ main: Array<MintlifyBlock | FragmentRefNode>;
71
+ };
72
+ }>;
68
73
  outputDir: string;
69
74
  }
@@ -41,9 +41,13 @@ export function validateSite(value, label) {
41
41
  export function validateDocument(value, label, sourcePath) {
42
42
  const metadata = isObject(value) && isObject(value.metadata) ? value.metadata : undefined;
43
43
  const regions = isObject(value) && isObject(value.regions) ? value.regions : undefined;
44
- if (!isObject(value) || value.$type !== 'page' || !nonEmptyString(value.id) || !nonEmptyString(value.status) || !nonEmptyString(value.layout) || !nonEmptyString(sourcePath) || !metadata || !nonEmptyString(metadata.title) || !regions || !Array.isArray(regions.main)) {
44
+ if (!isObject(value) || value.$type !== 'page' || !nonEmptyString(value.id) || !nonEmptyString(value.status) || !nonEmptyString(sourcePath) || !metadata || !nonEmptyString(metadata.title) || !regions || !Array.isArray(regions.main)) {
45
45
  throw new Error(`ACI Mintlify document is invalid: ${label}`);
46
46
  }
47
+ for (const region of Object.keys(regions)) {
48
+ if (region !== 'main')
49
+ throw new Error(`ACI Mintlify document ${label} has unsupported region ${JSON.stringify(region)}; only main is supported`);
50
+ }
47
51
  const documentPath = cleanDocumentPath(sourcePath);
48
52
  if (!documentPath || documentPath !== sourcePath)
49
53
  throw new Error(`ACI Mintlify document path is invalid: ${sourcePath}`);
@@ -51,7 +55,7 @@ export function validateDocument(value, label, sourcePath) {
51
55
  const blocks = regions.main.map((block, index) => validateBlock(block, `${label}.regions.main[${index}]`, ids));
52
56
  const renderer = isObject(metadata.mintlify) ? metadata.mintlify : {};
53
57
  return {
54
- $type: 'page', id: value.id, status: value.status, layout: value.layout, path: documentPath,
58
+ $type: 'page', id: value.id, status: value.status, path: documentPath,
55
59
  metadata: { title: metadata.title,
56
60
  ...(nonEmptyString(metadata.description) ? { description: metadata.description } : {}),
57
61
  mintlify: {
@@ -33,33 +33,9 @@ export function withAci(nextConfig = {}, options = {}) {
33
33
  const userRewrites = nextConfig.rewrites;
34
34
  const userHeaders = nextConfig.headers;
35
35
  const userWebpack = nextConfig.webpack;
36
- // Build assets (_next/static/*) are content-addressed and stored globally.
37
- // When ACI_ASSET_BASE_TPL is an absolute CDN URL, strip __RELEASE_ID__
38
- // (release-independent) and use it as Next.js's native assetPrefix so the
39
- // build writes absolute CDN URLs for _next/static chunks — no post-
40
- // processing needed.
41
- // - Managed tenants (edge handler): "/__r/__RELEASE_ID__/" or unset →
42
- // do not set assetPrefix; the SSG writes relative paths and the edge
43
- // handler rewrites to the global S3 key.
44
- // - External CDN tenants: "https://cdn.gradial.com/" → absolute CDN URLs.
45
- const rawAssetBaseTpl = process.env.ACI_ASSET_BASE_TPL || '';
46
- const assetPathLayout = (process.env.ACI_ASSET_PATH_LAYOUT || 'preserve').trim();
47
- if (assetPathLayout !== 'preserve' && assetPathLayout !== 'flat-assets') {
48
- throw new Error('ACI_ASSET_PATH_LAYOUT must be preserve or flat-assets');
49
- }
50
- if (assetPathLayout === 'flat-assets') {
51
- throw new Error('ACI_ASSET_PATH_LAYOUT=flat-assets currently supports Astro builds only');
52
- }
53
- const cdnAssetPrefix = rawAssetBaseTpl
54
- .replace('__RELEASE_ID__/', '')
55
- .replace('__RELEASE_ID__', '')
56
- .replace(/\/+$/, '/');
57
- const isCdnUrl = cdnAssetPrefix.startsWith('http://') || cdnAssetPrefix.startsWith('https://');
58
- const nextAssetPrefix = (isCdnUrl && cdnAssetPrefix !== '/') ? cdnAssetPrefix : undefined;
59
36
  return {
60
37
  ...nextConfig,
61
38
  output: 'standalone',
62
- assetPrefix: nextAssetPrefix ?? nextConfig.assetPrefix,
63
39
  outputFileTracingRoot: nextConfig.outputFileTracingRoot ?? process.cwd(),
64
40
  headers: async () => {
65
41
  const gradialHeaders = [
@@ -13,7 +13,8 @@ import type { ErrorSeatPayload } from './error-seat.js';
13
13
  * - present-but-broken bindings — exactly the enumerated REQ-5 failures
14
14
  * (malformed route, unresolved page, unpublished page, audience/experiment
15
15
  * overlays — REQ-11) → `errors` in BOTH postures;
16
- * - ANY other exception (stale/corrupt compiled content, old manifest
16
+ * - selected fragment binding or expansion failures are errors in both postures;
17
+ * - other exceptions (stale/corrupt compiled content, old manifest
17
18
  * shapes) warns-and-defaults under warn and errors only under enforce, so
18
19
  * a stale .aci/compiled tree can never break a binding-less build.
19
20
  *
@@ -1,3 +1,4 @@
1
+ import { FragmentResolutionError, resolveSlotsSync } from '../content/resolve-slots.js';
1
2
  import { existsSync, readFileSync } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { ERROR_PAGE_ROUTE_PATTERN } from '../content/error-pages.js';
@@ -86,29 +87,13 @@ export function buildErrorSeatPayload(contentRoot, posture) {
86
87
  const route = bindings.catchAll;
87
88
  const page = readJSON(root, catchAllEntry.payloadRef);
88
89
  const layout = readJSON(root, `layouts/${page.layout}.json`);
89
- const slots = {};
90
- const slotOrder = [];
91
- for (const slot of layout.slots) {
92
- slotOrder.push(slot.name);
93
- const pageBlocks = page.regions?.[slot.name];
94
- if (pageBlocks?.length) {
95
- slots[slot.name] = pageBlocks;
96
- continue;
97
- }
98
- const defaults = layout.defaults?.[slot.name];
99
- if (!defaults?.length)
100
- continue;
101
- const blocks = [];
102
- for (const ref of defaults) {
103
- const fragmentRef = manifest.fragments[ref.fragmentId]?.payloadRef;
104
- if (!fragmentRef)
105
- continue;
106
- const fragment = readJSON(root, fragmentRef);
107
- blocks.push({ $type: 'component', id: fragment.id, component: fragment.component, props: fragment.props || {} });
108
- }
109
- if (blocks.length)
110
- slots[slot.name] = blocks;
111
- }
90
+ const slots = resolveSlotsSync(layout, page.regions ?? {}, (id) => {
91
+ const fragmentRef = manifest.fragments[id]?.payloadRef;
92
+ if (!fragmentRef)
93
+ throw new Error(`Fragment not found: ${id}`);
94
+ return readJSON(root, fragmentRef);
95
+ });
96
+ const slotOrder = layout.slots.map((slot) => slot.name);
112
97
  return {
113
98
  payload: {
114
99
  defaulted: false,
@@ -123,7 +108,12 @@ export function buildErrorSeatPayload(contentRoot, posture) {
123
108
  };
124
109
  }
125
110
  catch (error) {
126
- // Not one of the enumerated REQ-5 binding failures (those are pushed to
111
+ if (error instanceof FragmentResolutionError) {
112
+ errors.push(error.message);
113
+ return defaulted();
114
+ }
115
+ // Neither a selected-fragment failure nor an enumerated REQ-5 binding
116
+ // failure (those are pushed to
127
117
  // `errors` above): stale/corrupt compiled content or an old manifest
128
118
  // shape. Warn-and-default under the warn posture so it can never break a
129
119
  // build; fail closed only under enforce (quorum RISK-002).
@@ -69,7 +69,7 @@ function renderPayload(data, registry) {
69
69
  // async and server-oriented). Unknown components are skipped — the error
70
70
  // seat degrades, it never crashes.
71
71
  function renderClientBlocks(blocks, registry) {
72
- return blocks.map((block) => {
72
+ return blocks.map((block, index) => {
73
73
  const entry = registry.find((r) => r.id === block.component);
74
74
  if (!entry)
75
75
  return null;
@@ -81,6 +81,8 @@ function renderClientBlocks(blocks, registry) {
81
81
  // sdk I-19 (ADR 0051): the error seat is an injection seam too.
82
82
  componentProps['data-aci-block'] = block.id;
83
83
  componentProps['data-aci-component'] = block.component;
84
- return _jsx(Component, { ...componentProps }, block.id);
84
+ // Index+id composite: a repeated fragment placement repeats its authored
85
+ // id, and sibling keys must stay unique for React reconciliation.
86
+ return _jsx(Component, { ...componentProps }, `${index}:${block.id}`);
85
87
  });
86
88
  }
@@ -1,5 +1,5 @@
1
1
  import type { ReactElement } from 'react';
2
2
  import type { KernelPageBlock } from '../content/types.js';
3
3
  import type { Registry } from '../registry.js';
4
- export declare function renderBlock(block: KernelPageBlock, registry: Registry, extraProps?: Record<string, unknown>): Promise<ReactElement>;
4
+ export declare function renderBlock(block: KernelPageBlock, registry: Registry, key: string, extraProps?: Record<string, unknown>): Promise<ReactElement>;
5
5
  export declare function renderBlocks(blocks: readonly KernelPageBlock[], registry: Registry, extraProps?: Record<string, unknown>): Promise<ReactElement[]>;
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- export async function renderBlock(block, registry, extraProps) {
2
+ export async function renderBlock(block, registry, key, extraProps) {
3
3
  const entry = registry.find((r) => r.id === block.component);
4
4
  if (!entry) {
5
- return (_jsxs("div", { className: "mx-auto my-4 max-w-[var(--max-width-container)] rounded-card border-2 border-red-600 bg-red-50 px-5 py-4 font-mono text-sm leading-relaxed text-red-900", children: [_jsx("strong", { children: "Unknown block:" }), " ", _jsx("code", { children: block.component }), _jsx("br", {}), _jsxs("span", { className: "text-red-800 opacity-70", children: ["id: ", block.id] })] }, block.id));
5
+ return (_jsxs("div", { className: "mx-auto my-4 max-w-[var(--max-width-container)] rounded-card border-2 border-red-600 bg-red-50 px-5 py-4 font-mono text-sm leading-relaxed text-red-900", children: [_jsx("strong", { children: "Unknown block:" }), " ", _jsx("code", { children: block.component }), _jsx("br", {}), _jsxs("span", { className: "text-red-800 opacity-70", children: ["id: ", block.id] })] }, key));
6
6
  }
7
7
  const Component = entry.component;
8
8
  const hasBlockSlots = !!entry.contract.blockSlots;
@@ -17,8 +17,11 @@ export async function renderBlock(block, registry, extraProps) {
17
17
  if (hasBlockSlots) {
18
18
  componentProps.renderChildren = (blocks) => renderBlocks(blocks, registry, extraProps);
19
19
  }
20
- return _jsx(Component, { ...componentProps }, block.id);
20
+ return _jsx(Component, { ...componentProps }, key);
21
21
  }
22
22
  export async function renderBlocks(blocks, registry, extraProps) {
23
- return Promise.all(blocks.map((block) => renderBlock(block, registry, extraProps)));
23
+ // Sibling keys are index+id composites: one fragment placed twice repeats
24
+ // its authored block id, and React reconciliation keys must be unique per
25
+ // sibling (ADR 0051 covers analytics identity, not reconciliation).
26
+ return Promise.all(blocks.map((block, index) => renderBlock(block, registry, `${index}:${block.id}`, extraProps)));
24
27
  }
@@ -44,8 +44,9 @@ export async function getFragment(name, config = {}) {
44
44
  const runtimeInput = await getPageRuntimeRenderInput();
45
45
  if (runtimeInput) {
46
46
  const fragments = runtimeInput.fragments || {};
47
- if (name in fragments)
47
+ if (Object.hasOwn(fragments, name))
48
48
  return fragments[name];
49
+ throw new FragmentNotFoundError(name, new Error('fragment is not bound by the selected page'));
49
50
  }
50
51
  const provider = await resolveProviderWithFallback(config);
51
52
  return await provider.getFragment(name);
@@ -38,7 +38,7 @@ export async function findRootIdentityFailures(registry, blocks, react, fileFor
38
38
  const probe = async (block, contentBacked) => {
39
39
  if (failures.has(block.component))
40
40
  return;
41
- const element = await renderBlock(block, registry);
41
+ const element = await renderBlock(block, registry, `probe:${block.id}`);
42
42
  let html;
43
43
  try {
44
44
  html = await markupFor(element, react);
@@ -16,10 +16,10 @@ function attachMeta(schema, allowed, multiple) {
16
16
  return schema;
17
17
  }
18
18
  export function blockRefArray(contracts) {
19
- return attachMeta(z.array(blockRefBase), contracts.map((c) => c.id), true);
19
+ return attachMeta(z.array(blockRefBase.extend({ component: z.enum(contracts.map((c) => c.id)) })), contracts.map((c) => c.id), true);
20
20
  }
21
21
  export function blockRef(contracts) {
22
- return attachMeta(blockRefBase, contracts.map((c) => c.id), false);
22
+ return attachMeta(blockRefBase.extend({ component: z.enum(contracts.map((c) => c.id)) }), contracts.map((c) => c.id), false);
23
23
  }
24
24
  export function getBlockRefMeta(schema) {
25
25
  if (schema && typeof schema === 'object' && BLOCK_REF_KEY in schema) {
@@ -9,7 +9,6 @@ export interface Config {
9
9
  };
10
10
  componentRegistry: string;
11
11
  dataRegistry?: string;
12
- layoutRegistry: string;
13
12
  routes: RouteConfig;
14
13
  dam?: DAMConfig;
15
14
  externalDependencies?: ExternalDependency[];
@@ -1,7 +1,6 @@
1
1
  export * from './config.js';
2
2
  export * from './component.js';
3
3
  export * from './data.js';
4
- export * from './layout.js';
5
4
  export * from './page.js';
6
5
  export * from './rich-text.js';
7
6
  export * from './image.js';
@@ -1,7 +1,6 @@
1
1
  export * from './config.js';
2
2
  export * from './component.js';
3
3
  export * from './data.js';
4
- export * from './layout.js';
5
4
  export * from './page.js';
6
5
  export * from './rich-text.js';
7
6
  export * from './image.js';
@@ -32,5 +32,4 @@ export interface BlockNode {
32
32
  export interface FragmentRefNode {
33
33
  kind: 'fragment-ref';
34
34
  fragmentId: string;
35
- inline: boolean;
36
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.52-rc.26",
3
+ "version": "0.1.52-rc.28",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/gradial/bare-metal-go.git",
@@ -159,11 +159,11 @@
159
159
  "zod": "^4.0.0"
160
160
  },
161
161
  "optionalDependencies": {
162
- "@gradial/aci-cli-darwin-arm64": "0.1.52-rc.26",
163
- "@gradial/aci-cli-darwin-x64": "0.1.52-rc.26",
164
- "@gradial/aci-cli-linux-arm64": "0.1.52-rc.26",
165
- "@gradial/aci-cli-linux-x64": "0.1.52-rc.26",
166
- "@gradial/aci-cli-win32-arm64": "0.1.52-rc.26",
167
- "@gradial/aci-cli-win32-x64": "0.1.52-rc.26"
162
+ "@gradial/aci-cli-darwin-arm64": "0.1.52-rc.28",
163
+ "@gradial/aci-cli-darwin-x64": "0.1.52-rc.28",
164
+ "@gradial/aci-cli-linux-arm64": "0.1.52-rc.28",
165
+ "@gradial/aci-cli-linux-x64": "0.1.52-rc.28",
166
+ "@gradial/aci-cli-win32-arm64": "0.1.52-rc.28",
167
+ "@gradial/aci-cli-win32-x64": "0.1.52-rc.28"
168
168
  }
169
169
  }
@@ -32,13 +32,15 @@ if (isMainModule()) {
32
32
  }
33
33
 
34
34
  async function compileRegistry(options) {
35
- const required = ['components', 'layouts', 'output'];
35
+ if (options.layouts) {
36
+ throw new Error('--layouts is retired; author page composition under /templates instead');
37
+ }
38
+ const required = ['components', 'output'];
36
39
  for (const key of required) {
37
40
  if (!options[key]) throw new Error(`missing --${key}`);
38
41
  }
39
42
 
40
43
  const components = await loadDefaultArray(options.components, 'component registry');
41
- const layouts = await loadDefaultArray(options.layouts, 'layout registry');
42
44
  const dataModels = options.data
43
45
  ? await loadDefaultArray(options.data, 'data model registry')
44
46
  : [];
@@ -68,9 +70,6 @@ async function compileRegistry(options) {
68
70
  });
69
71
  }
70
72
 
71
- const seenLayouts = new Set();
72
- const layoutEntries = layouts.map((layout, index) => validateLayout(layout, index, seenLayouts));
73
-
74
73
  const seenDataModels = new Set();
75
74
  const dataModelEntries = [];
76
75
  if (dataModels.length > 0) {
@@ -104,7 +103,6 @@ async function compileRegistry(options) {
104
103
  manifestVersion: '1',
105
104
  generatedAt: new Date().toISOString(),
106
105
  components: componentEntries,
107
- layouts: layoutEntries,
108
106
  dataModels: dataModelEntries,
109
107
  };
110
108
  const registryPath = path.join(outDir, 'registry.json');
@@ -118,7 +116,7 @@ async function compileRegistry(options) {
118
116
 
119
117
  async function loadDefaultArray(modulePath, label) {
120
118
  const mod = await import(pathToFileURL(path.resolve(modulePath)).href);
121
- const value = mod.default ?? mod.registry ?? mod.components ?? mod.layouts ?? mod.dataModels;
119
+ const value = mod.default ?? mod.registry ?? mod.components ?? mod.dataModels;
122
120
  if (!Array.isArray(value)) throw new Error(`${label} must export an array`);
123
121
  return value;
124
122
  }
@@ -390,27 +388,6 @@ function unwrapSchema(schema) {
390
388
  return current;
391
389
  }
392
390
 
393
- function validateLayout(layout, index, seen) {
394
- if (!layout || typeof layout !== 'object') throw new Error(`layouts[${index}] must be an object`);
395
- if (!layout.name) throw new Error(`layouts[${index}].name is required`);
396
- if (seen.has(layout.name)) throw new Error(`duplicate layout name ${layout.name}`);
397
- seen.add(layout.name);
398
- if (!Array.isArray(layout.slots)) throw new Error(`layouts[${index}].slots must be an array`);
399
- const slots = [];
400
- const seenSlots = new Set();
401
- for (const [slotIndex, slot] of layout.slots.entries()) {
402
- if (!slot?.name) throw new Error(`layouts[${index}].slots[${slotIndex}].name is required`);
403
- if (seenSlots.has(slot.name)) throw new Error(`duplicate slot name ${slot.name} in layout ${layout.name}`);
404
- seenSlots.add(slot.name);
405
- slots.push({ name: slot.name, required: Boolean(slot.required) });
406
- }
407
- const entry = { name: layout.name, slots };
408
- if (layout.defaults && typeof layout.defaults === 'object') {
409
- entry.defaults = layout.defaults;
410
- }
411
- return entry;
412
- }
413
-
414
391
  function compileSchema(schema, name) {
415
392
  const toJSONSchema = z.toJSONSchema;
416
393
  if (typeof toJSONSchema !== 'function') {
@@ -505,13 +482,12 @@ async function generatePageSchema(schemaDir, componentEntries, outDir) {
505
482
  $id: 'https://baremetal.local/schemas/page.schema.json',
506
483
  title: 'bare-metal Page',
507
484
  type: 'object',
508
- required: ['$type', 'id', 'status', 'layout', 'metadata', 'regions'],
485
+ required: ['$type', 'id', 'status', 'metadata', 'regions'],
509
486
  additionalProperties: true,
510
487
  properties: {
511
488
  $type: { const: 'page' },
512
489
  id: { type: 'string', minLength: 1 },
513
490
  status: { type: 'string', minLength: 1 },
514
- layout: { type: 'string', minLength: 1 },
515
491
  metadata: {
516
492
  type: 'object',
517
493
  required: ['title'],
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
 
5
5
  import { compileSchema } from '../compiler/zod-to-jsonschema.js';
6
6
  import { defineComponentContract } from '../define-component.js';
7
- import { blockRefArray } from './block-ref.js';
7
+ import { blockRef, blockRefArray } from './block-ref.js';
8
8
 
9
9
  const childContract = defineComponentContract({
10
10
  id: 'child',
@@ -43,3 +43,18 @@ test('compiled block-reference schema exposes the component discriminator', () =
43
43
  assert.equal(compiled.items?.required?.includes('$type'), true);
44
44
  assert.equal(compiled.items?.additionalProperties, false);
45
45
  });
46
+
47
+
48
+ test('block-reference contracts enforce allowed child IDs at runtime and in compiled schemas', () => {
49
+ const other = defineComponentContract({ id: 'other', props: z.object({}) });
50
+ const allowed = { $type: 'component', id: 'child-1', component: 'child', props: { label: 'Hello' } };
51
+ const disallowed = { ...allowed, component: 'button' };
52
+ for (const multiple of [false, true]) {
53
+ const schema = multiple ? blockRefArray([childContract, other]) : blockRef([childContract, other]);
54
+ assert.equal(schema.safeParse(multiple ? [allowed] : allowed).success, true);
55
+ assert.equal(schema.safeParse(multiple ? [disallowed] : disallowed).success, false, 'a nested component outside the contract must be refused');
56
+ const compiled = compileSchema(schema) as any;
57
+ const block = multiple ? compiled.items : compiled;
58
+ assert.deepEqual(block.properties.component.enum, ['child', 'other'], 'published JSON Schema must enforce the same finite child set');
59
+ }
60
+ });
@@ -50,7 +50,7 @@ export function blockRefArray<const T extends readonly ComponentContract[]>(
50
50
  contracts: T,
51
51
  ): z.ZodArray<z.ZodType<BlockRefUnion<T>>> {
52
52
  return attachMeta(
53
- z.array(blockRefBase) as unknown as z.ZodArray<z.ZodType<BlockRefUnion<T>>>,
53
+ z.array(blockRefBase.extend({ component: z.enum(contracts.map((c) => c.id)) })) as unknown as z.ZodArray<z.ZodType<BlockRefUnion<T>>>,
54
54
  contracts.map((c) => c.id),
55
55
  true,
56
56
  );
@@ -60,7 +60,7 @@ export function blockRef<const T extends readonly ComponentContract[]>(
60
60
  contracts: T,
61
61
  ): z.ZodType<BlockRefUnion<T>> {
62
62
  return attachMeta(
63
- blockRefBase as unknown as z.ZodType<BlockRefUnion<T>>,
63
+ blockRefBase.extend({ component: z.enum(contracts.map((c) => c.id)) }) as unknown as z.ZodType<BlockRefUnion<T>>,
64
64
  contracts.map((c) => c.id),
65
65
  false,
66
66
  );
@@ -9,7 +9,6 @@ export interface Config {
9
9
  };
10
10
  componentRegistry: string;
11
11
  dataRegistry?: string;
12
- layoutRegistry: string;
13
12
  routes: RouteConfig;
14
13
  dam?: DAMConfig;
15
14
  externalDependencies?: ExternalDependency[];
@@ -1,7 +1,6 @@
1
1
  export * from './config.js';
2
2
  export * from './component.js';
3
3
  export * from './data.js';
4
- export * from './layout.js';
5
4
  export * from './page.js';
6
5
  export * from './rich-text.js';
7
6
  export * from './image.js';
package/src/types/page.ts CHANGED
@@ -34,5 +34,4 @@ export interface BlockNode {
34
34
  export interface FragmentRefNode {
35
35
  kind: 'fragment-ref';
36
36
  fragmentId: string;
37
- inline: boolean;
38
37
  }
@@ -9,7 +9,6 @@ source:
9
9
  publicDir: "public"
10
10
 
11
11
  componentRegistry: ./src/cms/contracts/components/index.ts
12
- layoutRegistry: ./src/cms/contracts/layouts/index.ts
13
12
 
14
13
  routes:
15
14
  cmsManaged: "/[...slug]"
@@ -244,10 +244,10 @@ If a value doesn't survive the round-trip, check:
244
244
  |------|---------|
245
245
  | `.aci.yaml` | ACI config: contract paths, framework, routes, capabilities |
246
246
  | `src/cms/contracts/components/index.ts` | Component contract barrel |
247
- | `src/cms/contracts/layouts/index.ts` | Layout contract barrel |
248
247
  | `src/cms/registry.ts` | Component registry (`createRegistry` with `[contract, component]` tuples) |
249
248
  | `src/components/` | Runtime component implementations |
250
249
  | `src/design-system/` | Design tokens and global styles |
250
+ | `.content/templates/` | Page composition contracts |
251
251
  | `.content/` | Local content for development |
252
252
 
253
253
  ## Deep Dive: Authoring