@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.
- package/CHANGELOG.md +0 -28
- package/README.md +11 -4
- package/dist/astro/index.d.ts +0 -4
- package/dist/astro/index.js +0 -32
- package/dist/astro/page.d.ts +1 -1
- package/dist/astro/page.js +5 -2
- package/dist/cli/compile-registry.mjs +6 -34
- package/dist/content/index.d.ts +2 -1
- package/dist/content/index.js +4 -1
- package/dist/content/provider.d.ts +5 -1
- package/dist/content/resolve-slots.d.ts +24 -2
- package/dist/content/resolve-slots.js +127 -21
- package/dist/content/types.d.ts +3 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/mintlify/blocks.js +4 -1
- package/dist/mintlify/index.d.ts +1 -1
- package/dist/mintlify/index.js +1 -1
- package/dist/mintlify/materialize.d.ts +3 -1
- package/dist/mintlify/materialize.js +105 -10
- package/dist/mintlify/types.d.ts +7 -2
- package/dist/mintlify/validate.js +6 -2
- package/dist/next/config.js +0 -24
- package/dist/next/error-seat-payload.d.ts +2 -1
- package/dist/next/error-seat-payload.js +14 -24
- package/dist/next/error-seat.js +4 -2
- package/dist/next/render.d.ts +1 -1
- package/dist/next/render.js +7 -4
- package/dist/next/server.js +2 -1
- package/dist/testing/conformance/root-identity.js +1 -1
- package/dist/types/block-ref.js +2 -2
- package/dist/types/config.d.ts +0 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.js +0 -1
- package/dist/types/page.d.ts +0 -1
- package/package.json +7 -7
- package/src/cli/compile-registry.mjs +6 -30
- package/src/types/block-ref.test.ts +16 -1
- package/src/types/block-ref.ts +2 -2
- package/src/types/config.ts +0 -1
- package/src/types/index.ts +0 -1
- package/src/types/page.ts +0 -1
- package/templates/astro/template/.aci.yaml.tmpl +0 -1
- package/templates/common/.agents/skills/aci/SKILL.md +1 -1
- package/templates/common/.agents/skills/authoring/SKILL.md +190 -39
- package/templates/common/.agents/skills/authoring/references/block-registry.md +4 -3
- package/templates/common/.agents/skills/authoring/references/contracts.md +9 -30
- package/templates/common/.agents/skills/authoring/references/repo-structure.md +5 -4
- package/templates/common/.agents/skills/authoring/references/sdk-server-functions.md +3 -3
- package/templates/common/.agents/skills/migration/references/canon-migration.md +18 -67
- package/templates/common/.content/fragments/navbar/_index.json +24 -0
- package/templates/common/.content/fragments/site-footer/_index.json +61 -0
- package/templates/common/.content/pages/404/_index.json +4 -2
- package/templates/common/.content/pages/_index.json +0 -1
- package/templates/common/.content/pages/error/_index.json +4 -2
- package/templates/common/.content/templates/error.json +24 -0
- package/templates/common/.content/templates/home.json +28 -0
- package/templates/common/.content/templates/site.json +8 -0
- package/templates/common/.content/templates/standard.json +24 -0
- package/templates/common/starter-content/.content/pages/404/_index.json +0 -1
- package/templates/common/starter-content/.content/pages/_index.json +12 -1
- package/templates/common/starter-content/.content/pages/about/_index.json +1 -1
- package/templates/common/starter-content/.content/pages/error/_index.json +0 -1
- package/templates/common/starter-content/.content/patterns/components/button.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/container.json +8 -3
- package/templates/common/starter-content/.content/patterns/components/feature_card.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/feature_grid.json +5 -3
- package/templates/common/starter-content/.content/patterns/components/home_hero.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +42 -0
- package/templates/common/starter-content/.content/patterns/components/site-footer.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/site-navigation.json +2 -2
- package/templates/common/starter-content/.content/patterns/components/stats_bar.json +2 -2
- package/templates/nextjs/template/.aci.yaml +0 -1
- package/templates/nextjs/template/.aci.yaml.tmpl +0 -1
- package/dist/define-layout.d.ts +0 -6
- package/dist/define-layout.js +0 -14
- package/dist/types/layout.d.ts +0 -24
- package/dist/types/layout.js +0 -1
- package/src/types/layout.ts +0 -29
- package/templates/astro/template/src/cms/contracts/layouts/index.ts +0 -18
- package/templates/common/starter-content/.content/patterns/layouts/default.json +0 -14
- 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 }) =>
|
|
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
|
|
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
|
|
46
|
-
return
|
|
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
|
|
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
|
|
51
|
-
|
|
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
|
|
64
|
-
const
|
|
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
|
+
}
|
package/dist/mintlify/types.d.ts
CHANGED
|
@@ -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(
|
|
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,
|
|
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: {
|
package/dist/next/config.js
CHANGED
|
@@ -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
|
-
* -
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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).
|
package/dist/next/error-seat.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/next/render.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/next/render.js
CHANGED
|
@@ -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] })] },
|
|
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 },
|
|
20
|
+
return _jsx(Component, { ...componentProps }, key);
|
|
21
21
|
}
|
|
22
22
|
export async function renderBlocks(blocks, registry, extraProps) {
|
|
23
|
-
|
|
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
|
}
|
package/dist/next/server.js
CHANGED
|
@@ -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
|
|
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);
|
package/dist/types/block-ref.js
CHANGED
|
@@ -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) {
|
package/dist/types/config.d.ts
CHANGED
package/dist/types/index.d.ts
CHANGED
package/dist/types/index.js
CHANGED
package/dist/types/page.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gradial/aci",
|
|
3
|
-
"version": "0.1.52-rc.
|
|
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.
|
|
163
|
-
"@gradial/aci-cli-darwin-x64": "0.1.52-rc.
|
|
164
|
-
"@gradial/aci-cli-linux-arm64": "0.1.52-rc.
|
|
165
|
-
"@gradial/aci-cli-linux-x64": "0.1.52-rc.
|
|
166
|
-
"@gradial/aci-cli-win32-arm64": "0.1.52-rc.
|
|
167
|
-
"@gradial/aci-cli-win32-x64": "0.1.52-rc.
|
|
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
|
-
|
|
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.
|
|
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', '
|
|
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
|
+
});
|
package/src/types/block-ref.ts
CHANGED
|
@@ -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
|
);
|
package/src/types/config.ts
CHANGED
package/src/types/index.ts
CHANGED
package/src/types/page.ts
CHANGED
|
@@ -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
|