@gradial/aci 0.1.53-rc.2 → 0.1.53-rc.3
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 +74 -0
- package/README.md +4 -4
- package/dist/astro/index.js +2 -5
- package/dist/cli/emit-error-pages.d.mts +2 -1
- package/dist/cli/emit-error-pages.mjs +13 -270
- package/dist/content/component-block.d.ts +17 -0
- package/dist/content/component-block.js +40 -0
- package/dist/content/resolve-slots.js +2 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/internal/aci-root.d.ts +1 -0
- package/dist/internal/aci-root.js +49 -0
- package/dist/next/config.js +6 -13
- package/dist/next/error-seat-payload.d.ts +1 -1
- package/dist/next/error-seat-payload.js +1 -2
- package/dist/next/error-seat.js +3 -4
- package/dist/next/render.js +2 -8
- package/dist/platform-tests/adapters/astro.d.ts +2 -0
- package/dist/platform-tests/adapters/astro.js +101 -0
- package/dist/platform-tests/adapters/next.d.ts +9 -0
- package/dist/platform-tests/adapters/next.js +112 -0
- package/dist/platform-tests/adapters/site.d.ts +29 -0
- package/dist/platform-tests/adapters/site.js +92 -0
- package/dist/platform-tests/adapters/types.d.ts +27 -0
- package/dist/platform-tests/adapters/types.js +1 -0
- package/dist/platform-tests/index.d.ts +47 -0
- package/dist/platform-tests/index.js +50 -0
- package/dist/platform-tests/sample-props.d.ts +2 -0
- package/dist/platform-tests/sample-props.js +82 -0
- package/dist/platform-tests/site-check.d.ts +51 -0
- package/dist/platform-tests/site-check.js +193 -0
- package/dist/registry.d.ts +1 -1
- package/dist/testing/conformance/index.d.ts +2 -1
- package/dist/testing/conformance/index.js +2 -1
- package/dist/testing/conformance/next-stubs.d.ts +13 -4
- package/dist/testing/conformance/next-stubs.js +76 -4
- package/dist/testing/conformance/preset.js +1 -2
- package/dist/testing/conformance/root-identity.d.ts +0 -29
- package/dist/testing/conformance/root-identity.js +36 -344
- package/dist/testing/conformance/types.d.ts +1 -1
- package/dist/types/component.d.ts +11 -3
- package/dist/types/component.js +10 -1
- package/package.json +9 -8
- package/src/cli/emit-error-pages.mjs +13 -273
- package/src/types/component.test.ts +17 -0
- package/src/types/component.ts +18 -3
- package/templates/astro/starter-content/src/components/chrome/Footer.astro +3 -4
- package/templates/astro/starter-content/src/components/chrome/Header.astro +3 -4
- package/templates/astro/starter-content/src/components/elements/Button.astro +4 -4
- package/templates/astro/starter-content/src/components/elements/ButtonBlock.astro +3 -4
- package/templates/astro/starter-content/src/components/sections/ContainerSection.astro +4 -4
- package/templates/astro/starter-content/src/components/sections/FeatureCard.astro +4 -4
- package/templates/astro/starter-content/src/components/sections/FeatureGrid.astro +4 -4
- package/templates/astro/starter-content/src/components/sections/HomeHero.astro +3 -4
- package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +4 -4
- package/templates/astro/starter-content/src/components/sections/StatsBar.astro +4 -4
- package/templates/common/.agents/skills/authoring/SKILL.md +6 -0
- package/templates/common/.agents/skills/authoring/references/block-registry.md +41 -7
- package/templates/common/.agents/skills/authoring/references/sdk-migrations.md +87 -0
- package/templates/nextjs/starter-content/src/components/chrome/Footer.tsx +5 -4
- package/templates/nextjs/starter-content/src/components/chrome/Header.tsx +5 -4
- package/templates/nextjs/starter-content/src/components/elements/ButtonBlock.tsx +5 -4
- package/templates/nextjs/starter-content/src/components/sections/ContainerSection.tsx +9 -9
- package/templates/nextjs/starter-content/src/components/sections/FeatureCard.tsx +5 -4
- package/templates/nextjs/starter-content/src/components/sections/FeatureGrid.tsx +9 -9
- package/templates/nextjs/starter-content/src/components/sections/HomeHero.tsx +5 -4
- package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +10 -10
- package/templates/nextjs/starter-content/src/components/sections/StatsBar.tsx +5 -4
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Props for a component no content places, derived from its contract's Zod
|
|
3
|
+
* schema, so the rootIdentity check can render it without hand-written
|
|
4
|
+
* fixtures. The sample is the smallest value the schema accepts, with one
|
|
5
|
+
* item in every array so a component that reads `items[0]` still renders.
|
|
6
|
+
*
|
|
7
|
+
* Sound for rootIdentity only: the renderer injects the identity props after
|
|
8
|
+
* the content props, so whether they reach the root does not depend on what
|
|
9
|
+
* the content props are. A test about content must not use these.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
/** A props object the schema accepts, or undefined when it cannot be derived. */
|
|
13
|
+
export function samplePropsFromSchema(schema) {
|
|
14
|
+
let json;
|
|
15
|
+
try {
|
|
16
|
+
json = z.toJSONSchema(schema, { target: 'draft-2020-12', unrepresentable: 'any' });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const value = sample(json, json, 0);
|
|
22
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
const MAX_DEPTH = 8;
|
|
25
|
+
function sample(node, root, depth) {
|
|
26
|
+
if (depth > MAX_DEPTH)
|
|
27
|
+
return undefined;
|
|
28
|
+
if (node.$ref) {
|
|
29
|
+
const name = node.$ref.split('/').pop() ?? '';
|
|
30
|
+
return sample(root.$defs?.[name] ?? {}, root, depth + 1);
|
|
31
|
+
}
|
|
32
|
+
if (node.default !== undefined)
|
|
33
|
+
return node.default;
|
|
34
|
+
if (node.const !== undefined)
|
|
35
|
+
return node.const;
|
|
36
|
+
if (node.enum)
|
|
37
|
+
return node.enum[0];
|
|
38
|
+
const union = node.anyOf ?? node.oneOf;
|
|
39
|
+
if (union) {
|
|
40
|
+
// Prefer a branch that is not null, so an optional object still samples.
|
|
41
|
+
const branch = union.find((b) => b.type !== 'null') ?? union[0];
|
|
42
|
+
return sample(branch, root, depth + 1);
|
|
43
|
+
}
|
|
44
|
+
if (node.allOf)
|
|
45
|
+
return Object.assign({}, ...node.allOf.map((part) => sample(part, root, depth + 1) ?? {}));
|
|
46
|
+
const type = Array.isArray(node.type) ? node.type.find((t) => t !== 'null') : node.type;
|
|
47
|
+
switch (type) {
|
|
48
|
+
case 'string':
|
|
49
|
+
return 'sample';
|
|
50
|
+
case 'number':
|
|
51
|
+
case 'integer':
|
|
52
|
+
return 1;
|
|
53
|
+
case 'boolean':
|
|
54
|
+
return false;
|
|
55
|
+
case 'array': {
|
|
56
|
+
// A block slot renders other registered components; an empty slot is
|
|
57
|
+
// the honest sample, and the nested components are probed on their own.
|
|
58
|
+
if (!node.items || isBlockRef(node.items, root))
|
|
59
|
+
return [];
|
|
60
|
+
const item = sample(node.items, root, depth + 1);
|
|
61
|
+
return item === undefined ? [] : [item];
|
|
62
|
+
}
|
|
63
|
+
case 'object': {
|
|
64
|
+
if (isBlockRef(node, root))
|
|
65
|
+
return undefined;
|
|
66
|
+
const out = {};
|
|
67
|
+
for (const key of node.required ?? []) {
|
|
68
|
+
const value = sample(node.properties?.[key] ?? {}, root, depth + 1);
|
|
69
|
+
if (value !== undefined)
|
|
70
|
+
out[key] = value;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
default:
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** A nested block reference: an object carrying `component` and `props`. */
|
|
79
|
+
function isBlockRef(node, root) {
|
|
80
|
+
const resolved = node.$ref ? root.$defs?.[node.$ref.split('/').pop() ?? ''] ?? {} : node;
|
|
81
|
+
return resolved.type === 'object' && !!resolved.properties?.component && !!resolved.properties?.props;
|
|
82
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { KernelPageBlock } from '../content/types.js';
|
|
2
|
+
import type { AdapterId, LoadedSite } from './adapters/types.js';
|
|
3
|
+
/** A component, or a whole registry file, the isolated check could not look at. */
|
|
4
|
+
export interface RootIdentityNotRun {
|
|
5
|
+
/** Component id, or the registry file when the file itself did not load. */
|
|
6
|
+
subject: string;
|
|
7
|
+
file?: string;
|
|
8
|
+
reason: string;
|
|
9
|
+
}
|
|
10
|
+
export interface RootIdentityFailure {
|
|
11
|
+
component: string;
|
|
12
|
+
file?: string;
|
|
13
|
+
/** What the isolated render showed. */
|
|
14
|
+
defect: string;
|
|
15
|
+
/** The fix for the site's framework. */
|
|
16
|
+
hint: string;
|
|
17
|
+
}
|
|
18
|
+
/** Open the site through its framework adapter, check every component, close. */
|
|
19
|
+
export declare function checkSiteRootIdentity(framework: AdapterId, siteDir: string, options?: {
|
|
20
|
+
registryFiles?: readonly string[];
|
|
21
|
+
}): Promise<{
|
|
22
|
+
components: number;
|
|
23
|
+
checked: number;
|
|
24
|
+
failures: RootIdentityFailure[];
|
|
25
|
+
notRun: RootIdentityNotRun[];
|
|
26
|
+
}>;
|
|
27
|
+
/**
|
|
28
|
+
* Render each content-backed block, then every registry entry no block
|
|
29
|
+
* covers, through the adapter, and run the rootIdentity test over each
|
|
30
|
+
* render. An entry no content places is rendered with props derived from
|
|
31
|
+
* its contract's schema (sample-props.ts), then with empty props if that
|
|
32
|
+
* render throws. One failure per component, so one per source file.
|
|
33
|
+
* Only a render that completed can fail: a component that renders without
|
|
34
|
+
* its identity is a failure; a component this isolated environment cannot
|
|
35
|
+
* render (it throws, on empty props or on real content) is not-run, because
|
|
36
|
+
* that says nothing about identity, and publish checks it in the real
|
|
37
|
+
* renderer. A render that returns empty output is a failure: a block renders
|
|
38
|
+
* its root on every render, empty content included (sdk I-31).
|
|
39
|
+
*/
|
|
40
|
+
export declare function findRootIdentityFailures(site: Pick<LoadedSite, 'adapter' | 'registry'>, blocks: readonly KernelPageBlock[]): Promise<{
|
|
41
|
+
failures: RootIdentityFailure[];
|
|
42
|
+
notRun: RootIdentityNotRun[];
|
|
43
|
+
}>;
|
|
44
|
+
/** One line of coverage, then one line per thing the check could not look at. */
|
|
45
|
+
export declare function formatRootIdentityCoverage(result: {
|
|
46
|
+
components: number;
|
|
47
|
+
checked: number;
|
|
48
|
+
notRun: readonly RootIdentityNotRun[];
|
|
49
|
+
}): string;
|
|
50
|
+
/** The failure message: one line of what, one entry per source file, one line of why. */
|
|
51
|
+
export declare function formatRootIdentityFailures(failures: readonly RootIdentityFailure[]): string;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rootIdentity platform test over a site's registry, component by
|
|
3
|
+
* component: the conformance check calls this and hands each isolated render
|
|
4
|
+
* to the same verifier publish runs (ADR 0063 §One verifier).
|
|
5
|
+
*/
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fragmentToBlock } from '../content/resolve-slots.js';
|
|
8
|
+
import { FileContentProvider } from '../providers/file.js';
|
|
9
|
+
import { renderedAttributeValues, TESTS } from './index.js';
|
|
10
|
+
import { samplePropsFromSchema } from './sample-props.js';
|
|
11
|
+
/** Open the site through its framework adapter, check every component, close. */
|
|
12
|
+
export async function checkSiteRootIdentity(framework, siteDir, options = {}) {
|
|
13
|
+
const site = framework === 'astro'
|
|
14
|
+
? await (await import('./adapters/astro.js')).openAstroSite(siteDir, options.registryFiles)
|
|
15
|
+
: await (await import('./adapters/next.js')).openNextSite(siteDir, options.registryFiles);
|
|
16
|
+
try {
|
|
17
|
+
const { failures, notRun } = await findRootIdentityFailures(site, await compiledBlocks(siteDir));
|
|
18
|
+
const unloadable = site.unloadable.map((u) => ({ subject: u.file, file: u.file, reason: `registry did not load: ${u.reason}` }));
|
|
19
|
+
return {
|
|
20
|
+
components: site.registry.length,
|
|
21
|
+
checked: site.registry.length - notRun.length,
|
|
22
|
+
failures,
|
|
23
|
+
notRun: [...unloadable, ...notRun],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
await site.close();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Render each content-backed block, then every registry entry no block
|
|
32
|
+
* covers, through the adapter, and run the rootIdentity test over each
|
|
33
|
+
* render. An entry no content places is rendered with props derived from
|
|
34
|
+
* its contract's schema (sample-props.ts), then with empty props if that
|
|
35
|
+
* render throws. One failure per component, so one per source file.
|
|
36
|
+
* Only a render that completed can fail: a component that renders without
|
|
37
|
+
* its identity is a failure; a component this isolated environment cannot
|
|
38
|
+
* render (it throws, on empty props or on real content) is not-run, because
|
|
39
|
+
* that says nothing about identity, and publish checks it in the real
|
|
40
|
+
* renderer. A render that returns empty output is a failure: a block renders
|
|
41
|
+
* its root on every render, empty content included (sdk I-31).
|
|
42
|
+
*/
|
|
43
|
+
export async function findRootIdentityFailures(site, blocks) {
|
|
44
|
+
const { adapter, registry } = site;
|
|
45
|
+
const failures = new Map();
|
|
46
|
+
const rendered = new Set();
|
|
47
|
+
const threw = new Map();
|
|
48
|
+
// Nested blocks a parent's render left without identity. Who is at fault
|
|
49
|
+
// is decided after every component has been probed on its own (below).
|
|
50
|
+
const nestedMisses = [];
|
|
51
|
+
const report = (component, defect, hint = FIX_HINTS[adapter.id]) => {
|
|
52
|
+
if (failures.has(component))
|
|
53
|
+
return;
|
|
54
|
+
const file = adapter.sourceFileFor(component);
|
|
55
|
+
failures.set(component, { component, ...(file ? { file } : {}), defect, hint });
|
|
56
|
+
};
|
|
57
|
+
const probe = async (block, propsFrom) => {
|
|
58
|
+
if (failures.has(block.component))
|
|
59
|
+
return true;
|
|
60
|
+
let html;
|
|
61
|
+
try {
|
|
62
|
+
html = await adapter.renderBlock(block);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const reason = (err instanceof Error ? err.message : String(err)).split('\n')[0];
|
|
66
|
+
if (!threw.has(block.component))
|
|
67
|
+
threw.set(block.component, `render with ${PROPS_LABEL[propsFrom]} threw: ${reason}`);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
rendered.add(block.component);
|
|
71
|
+
// The verifier publish runs also reports the blocks nested in this one;
|
|
72
|
+
// the root check below owns the probe's own block.
|
|
73
|
+
const outcome = TESTS.rootIdentity.run({ route: `probe:${block.id}`, payload: { blocks: [block] }, html });
|
|
74
|
+
for (const finding of outcome.findings) {
|
|
75
|
+
if (finding.blockId !== block.id)
|
|
76
|
+
nestedMisses.push({ parent: block.component, blockId: finding.blockId, component: finding.component });
|
|
77
|
+
}
|
|
78
|
+
// Locally the bar is higher than publish's substring test (sdk I-31): the
|
|
79
|
+
// component's own root element carries both identity attributes.
|
|
80
|
+
if (failures.has(block.component))
|
|
81
|
+
return true;
|
|
82
|
+
const root = rootElement(html);
|
|
83
|
+
if (!root || !attributeIs(root.attrs, 'data-aci-block', block.id)) {
|
|
84
|
+
if (root)
|
|
85
|
+
report(block.component, `data-aci-block is not on the root <${root.tag}>`);
|
|
86
|
+
else
|
|
87
|
+
report(block.component, 'renders no root element', EMPTY_RENDER_HINT);
|
|
88
|
+
}
|
|
89
|
+
else if (!attributeIs(root.attrs, 'data-aci-component', block.component)) {
|
|
90
|
+
report(block.component, `root <${root.tag}> lacks data-aci-component`);
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
};
|
|
94
|
+
const covered = new Set();
|
|
95
|
+
for (const block of blocks) {
|
|
96
|
+
if (!registry.some((entry) => entry.id === block.component))
|
|
97
|
+
continue;
|
|
98
|
+
covered.add(block.component);
|
|
99
|
+
await probe(block, 'content');
|
|
100
|
+
}
|
|
101
|
+
for (const entry of registry) {
|
|
102
|
+
if (covered.has(entry.id))
|
|
103
|
+
continue;
|
|
104
|
+
const blockWith = (props) => ({ $type: 'component', id: `probe-${entry.id}`, component: entry.id, props });
|
|
105
|
+
const sampled = samplePropsFromSchema(entry.contract?.props);
|
|
106
|
+
if (sampled && await probe(blockWith(sampled), 'schema'))
|
|
107
|
+
continue;
|
|
108
|
+
await probe(blockWith({}), 'empty');
|
|
109
|
+
}
|
|
110
|
+
// A nested block without identity has one of two causes. If the nested
|
|
111
|
+
// component fails its own probe, it drops the props it is given, and that
|
|
112
|
+
// failure already names it. Otherwise the component is fine and the parent
|
|
113
|
+
// rendered it without passing the identity props: the parent is at fault.
|
|
114
|
+
for (const miss of nestedMisses) {
|
|
115
|
+
if (failures.has(miss.component))
|
|
116
|
+
continue;
|
|
117
|
+
report(miss.parent, `renders its nested block "${miss.blockId}" (${miss.component}) without identity`, NESTED_HINT);
|
|
118
|
+
}
|
|
119
|
+
const notRun = [];
|
|
120
|
+
for (const [component, reason] of threw) {
|
|
121
|
+
if (rendered.has(component) || failures.has(component))
|
|
122
|
+
continue;
|
|
123
|
+
const file = adapter.sourceFileFor(component);
|
|
124
|
+
notRun.push({ subject: component, ...(file ? { file } : {}), reason });
|
|
125
|
+
}
|
|
126
|
+
return { failures: [...failures.values()], notRun };
|
|
127
|
+
}
|
|
128
|
+
/** One line of coverage, then one line per thing the check could not look at. */
|
|
129
|
+
export function formatRootIdentityCoverage(result) {
|
|
130
|
+
const head = `rootIdentity: checked ${result.checked} of ${result.components} registered components`;
|
|
131
|
+
if (result.notRun.length === 0)
|
|
132
|
+
return head;
|
|
133
|
+
return [
|
|
134
|
+
`${head}; ${result.notRun.length} not run (publish checks them in the real renderer):`,
|
|
135
|
+
...result.notRun.map((n) => ` ${n.file ?? n.subject}${n.file && n.file !== n.subject ? ` (${n.subject})` : ''} ${n.reason}`),
|
|
136
|
+
].join('\n');
|
|
137
|
+
}
|
|
138
|
+
/** The failure message: one line of what, one entry per source file, one line of why. */
|
|
139
|
+
export function formatRootIdentityFailures(failures) {
|
|
140
|
+
const count = failures.length;
|
|
141
|
+
return [
|
|
142
|
+
`rootIdentity: ${count} component${count === 1 ? '' : 's'} do${count === 1 ? 'es' : ''} not render data-aci-block on the root element`,
|
|
143
|
+
'',
|
|
144
|
+
...failures.flatMap((f) => [
|
|
145
|
+
` ${f.file ?? f.component} (${f.component}) ${f.defect}`,
|
|
146
|
+
` ${f.hint === EMPTY_RENDER_HINT || f.hint === NESTED_HINT ? f.hint : `fix: ${f.hint}`}`,
|
|
147
|
+
]),
|
|
148
|
+
'',
|
|
149
|
+
'A component extends AciRootProps from @gradial/aci and places {...aciRootAttrs(props)} (or its rest props) on its root element.',
|
|
150
|
+
].join('\n');
|
|
151
|
+
}
|
|
152
|
+
function rootElement(html) {
|
|
153
|
+
// Hoisted resource hints (<link>, <meta>) and Astro's dev-mode markers are
|
|
154
|
+
// not the component's root element.
|
|
155
|
+
const body = html.trim().replace(/^(?:<(?:link|meta|style|script)\b[^>]*>(?:[^<]*<\/(?:style|script)>)?\s*)+/i, '');
|
|
156
|
+
const match = /^<([a-z][a-z0-9-]*)([^>]*)>/i.exec(body);
|
|
157
|
+
return match ? { tag: match[1], attrs: match[2] } : undefined;
|
|
158
|
+
}
|
|
159
|
+
function attributeIs(attrs, name, value) {
|
|
160
|
+
const written = new RegExp(`\\s${name}="([^"]*)"`).exec(attrs)?.[1];
|
|
161
|
+
return written !== undefined && renderedAttributeValues(value).includes(written);
|
|
162
|
+
}
|
|
163
|
+
const PROPS_LABEL = { content: 'its content props', schema: 'schema-derived props', empty: 'empty props' };
|
|
164
|
+
const NESTED_HINT = 'this component renders its child blocks without passing them data-aci-block and data-aci-component; render children with the injected renderChildren, or spread the props of each child block onto its component (<Component {...block.props} />) so the identity the SDK put there reaches the child.';
|
|
165
|
+
const EMPTY_RENDER_HINT = 'this block rendered nothing; a block must render its root element on every render, even when it has nothing to show (for example <div {...aciRootAttrs(props)} hidden />).';
|
|
166
|
+
const FIX_HINTS = {
|
|
167
|
+
astro: 'put {...aciRootAttrs(Astro.props)} (or its rest props) on the root element',
|
|
168
|
+
next: 'put {...aciRootAttrs(props)} (or its rest props) on the root element',
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Blocks from the site's compiled content, read through the manifest, when
|
|
172
|
+
* `npm run content:compile` has run. Without a manifest there are none.
|
|
173
|
+
*/
|
|
174
|
+
async function compiledBlocks(siteDir) {
|
|
175
|
+
const provider = new FileContentProvider(path.resolve(siteDir, process.env.ACI_CONTENT_ROOT || '.aci/compiled'));
|
|
176
|
+
let manifest;
|
|
177
|
+
try {
|
|
178
|
+
manifest = await provider.manifest();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
const blocks = [];
|
|
184
|
+
for (const route of Object.values(manifest.routes)) {
|
|
185
|
+
const page = await provider.getPage(route.path);
|
|
186
|
+
for (const region of Object.values(page.regions ?? {}))
|
|
187
|
+
blocks.push(...region);
|
|
188
|
+
}
|
|
189
|
+
for (const id of Object.keys(manifest.fragments)) {
|
|
190
|
+
blocks.push(fragmentToBlock(await provider.getFragment(id), id));
|
|
191
|
+
}
|
|
192
|
+
return blocks;
|
|
193
|
+
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface RegistryEntry {
|
|
|
8
8
|
export type Registry = readonly RegistryEntry[];
|
|
9
9
|
type ValidatedEntry<T> = T extends readonly [infer C extends ComponentContract, infer Comp] ? Comp extends (props: InferComponentProps<C>) => any ? RootIdentityChecked<C, Comp, T> : Comp extends (props: InferComponentProps<C> & infer _) => any ? RootIdentityChecked<C, Comp, T> : Comp extends new (props: InferComponentProps<C>, ...args: any[]) => any ? RootIdentityChecked<C, Comp, T> : Comp extends Record<string, any> ? T : never : never;
|
|
10
10
|
type DeclaredProps<Comp> = Comp extends (props: infer P) => any ? P : Comp extends abstract new (props: infer P, ...args: any[]) => any ? P : Record<string, any>;
|
|
11
|
-
type RootIdentityChecked<C extends ComponentContract, Comp, T> = 'data-aci-block' extends keyof DeclaredProps<Comp> ? 'data-aci-component' extends keyof DeclaredProps<Comp> ? T : `${ComponentContractID<C>}: props type must extend
|
|
11
|
+
type RootIdentityChecked<C extends ComponentContract, Comp, T> = 'data-aci-block' extends keyof DeclaredProps<Comp> ? 'data-aci-component' extends keyof DeclaredProps<Comp> ? T : `${ComponentContractID<C>}: props type must extend AciRootProps from @gradial/aci` : `${ComponentContractID<C>}: props type must extend AciRootProps from @gradial/aci`;
|
|
12
12
|
type ValidatedEntries<T extends readonly (readonly [ComponentContract, AnyComponent])[]> = {
|
|
13
13
|
[K in keyof T]: ValidatedEntry<T[K]>;
|
|
14
14
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { conformancePreset, runConformanceChecks } from './preset.js';
|
|
2
2
|
export { checkConfig, checkRegistry, checkPageEntry, checkMiddleware, checkNoFileContentProvider, checkNoCustomContent, checkNoRequestTimeAssetRoute, checkBuildSucceeds, checkContentCompile, checkSdkVersion, CHECK_REGISTRY, } from './checks.js';
|
|
3
|
-
export { checkRootIdentity
|
|
3
|
+
export { checkRootIdentity } from './root-identity.js';
|
|
4
|
+
export { formatRootIdentityFailures } from '../../platform-tests/site-check.js';
|
|
4
5
|
export type { ConformanceFramework, ConformanceCheckName, ConformancePresetOptions, ConformanceCheckResult, ConformanceResult, ConformanceContext, ConformanceCheckFn, } from './types.js';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { conformancePreset, runConformanceChecks } from './preset.js';
|
|
2
2
|
export { checkConfig, checkRegistry, checkPageEntry, checkMiddleware, checkNoFileContentProvider, checkNoCustomContent, checkNoRequestTimeAssetRoute, checkBuildSucceeds, checkContentCompile, checkSdkVersion, CHECK_REGISTRY, } from './checks.js';
|
|
3
|
-
export { checkRootIdentity
|
|
3
|
+
export { checkRootIdentity } from './root-identity.js';
|
|
4
|
+
export { formatRootIdentityFailures } from '../../platform-tests/site-check.js';
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
url: string;
|
|
3
|
-
}>): Promise<{
|
|
1
|
+
type Resolved = {
|
|
4
2
|
url: string;
|
|
5
3
|
shortCircuit?: boolean;
|
|
6
|
-
|
|
4
|
+
format?: string;
|
|
5
|
+
};
|
|
6
|
+
type Loaded = {
|
|
7
|
+
format: string;
|
|
8
|
+
source?: string;
|
|
9
|
+
shortCircuit?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function resolve(specifier: string, context: {
|
|
12
|
+
parentURL?: string;
|
|
13
|
+
}, nextResolve: (specifier: string, context: unknown) => Promise<Resolved>): Promise<Resolved>;
|
|
14
|
+
export declare function load(url: string, context: unknown, nextLoad: (url: string, context: unknown) => Promise<Loaded>): Promise<Loaded>;
|
|
15
|
+
export {};
|
|
@@ -1,9 +1,81 @@
|
|
|
1
|
-
// Node module customization
|
|
2
|
-
// bundler
|
|
3
|
-
//
|
|
1
|
+
// Node module customization hooks for the rootIdentity check. Outside Next's
|
|
2
|
+
// bundler a real site's component graph does not load: `next/link` and
|
|
3
|
+
// `next/image` resolve to CommonJS interop objects react-dom/server cannot
|
|
4
|
+
// render, and stylesheets, images, fonts, and `next/font` are things only a
|
|
5
|
+
// bundler understands. The check needs each component's markup, not its
|
|
6
|
+
// styling, so every one of those becomes an inert module. This mirrors what
|
|
7
|
+
// `next/jest` does for the same problem.
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
const STYLE_RE = /\.(css|scss|sass|less|styl)(\?.*)?$/i;
|
|
11
|
+
const ASSET_RE = /\.(png|jpe?g|gif|webp|avif|ico|bmp|svg|woff2?|ttf|otf|eot|mp4|webm|ogg|mp3|wav|pdf)(\?.*)?$/i;
|
|
12
|
+
const FONT_SPECIFIERS = new Set(['next/font/google', 'next/font/local']);
|
|
13
|
+
const STUB_SCHEME = 'aci-stub:';
|
|
4
14
|
export async function resolve(specifier, context, nextResolve) {
|
|
5
15
|
if (specifier === 'next/link' || specifier === 'next/image') {
|
|
6
|
-
return { url:
|
|
16
|
+
return { url: stubURL(specifier.replace('/', '-')), shortCircuit: true };
|
|
7
17
|
}
|
|
18
|
+
if (FONT_SPECIFIERS.has(specifier)) {
|
|
19
|
+
// ESM named imports must exist statically, so the stub is generated with
|
|
20
|
+
// the names the importing file asks for.
|
|
21
|
+
const names = importedNames(context.parentURL, specifier);
|
|
22
|
+
return { url: `${STUB_SCHEME}font?names=${names.join(',')}`, shortCircuit: true };
|
|
23
|
+
}
|
|
24
|
+
if (STYLE_RE.test(specifier))
|
|
25
|
+
return { url: `${STUB_SCHEME}style`, shortCircuit: true };
|
|
26
|
+
if (ASSET_RE.test(specifier))
|
|
27
|
+
return { url: `${STUB_SCHEME}asset?path=${encodeURIComponent(specifier)}`, shortCircuit: true };
|
|
8
28
|
return nextResolve(specifier, context);
|
|
9
29
|
}
|
|
30
|
+
export async function load(url, context, nextLoad) {
|
|
31
|
+
if (!url.startsWith(STUB_SCHEME))
|
|
32
|
+
return nextLoad(url, context);
|
|
33
|
+
const [kind, query = ''] = url.slice(STUB_SCHEME.length).split('?');
|
|
34
|
+
const params = new URLSearchParams(query);
|
|
35
|
+
if (kind === 'style') {
|
|
36
|
+
// A CSS module's default export maps class names to strings; any name
|
|
37
|
+
// resolves to itself. A plain stylesheet import ignores the export.
|
|
38
|
+
return module('export default new Proxy({}, { get: (_, key) => (typeof key === "string" ? key : undefined) });');
|
|
39
|
+
}
|
|
40
|
+
if (kind === 'asset') {
|
|
41
|
+
// Next's static image import shape, which also stringifies to a path.
|
|
42
|
+
const src = JSON.stringify(`/__aci_stub_asset/${params.get('path') ?? ''}`);
|
|
43
|
+
return module(`export default { src: ${src}, width: 1, height: 1, blurDataURL: '', toString() { return ${src}; } };`);
|
|
44
|
+
}
|
|
45
|
+
if (kind === 'font') {
|
|
46
|
+
const names = (params.get('names') ?? '').split(',').filter((n) => /^[A-Za-z_$][\w$]*$/.test(n));
|
|
47
|
+
const font = '() => ({ className: "", variable: "", style: {} })';
|
|
48
|
+
return module([`const font = ${font};`, 'export default font;', ...names.map((n) => `export const ${n} = font;`)].join('\n'));
|
|
49
|
+
}
|
|
50
|
+
return nextLoad(url, context);
|
|
51
|
+
}
|
|
52
|
+
/** A stub module: compiled `.js` in dist, `.ts` when the SDK runs from source. */
|
|
53
|
+
function stubURL(name) {
|
|
54
|
+
const js = new URL(`./stubs/${name}.js`, import.meta.url);
|
|
55
|
+
return existsSync(fileURLToPath(js)) ? js.href : new URL(`./stubs/${name}.ts`, import.meta.url).href;
|
|
56
|
+
}
|
|
57
|
+
function module(source) {
|
|
58
|
+
return { format: 'module', source, shortCircuit: true };
|
|
59
|
+
}
|
|
60
|
+
/** The names `parentURL` imports from `specifier`, read from its source. */
|
|
61
|
+
function importedNames(parentURL, specifier) {
|
|
62
|
+
if (!parentURL?.startsWith('file:'))
|
|
63
|
+
return [];
|
|
64
|
+
let source;
|
|
65
|
+
try {
|
|
66
|
+
source = readFileSync(fileURLToPath(parentURL), 'utf8');
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const names = new Set();
|
|
72
|
+
const escaped = specifier.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
73
|
+
for (const m of source.matchAll(new RegExp(`import\\s+(?:type\\s+)?(?:\\w+\\s*,\\s*)?\\{([^}]*)\\}\\s*from\\s*['"]${escaped}['"]`, 'g'))) {
|
|
74
|
+
for (const part of m[1].split(',')) {
|
|
75
|
+
const name = part.trim().split(/\s+as\s+/)[0].replace(/^type\s+/, '').trim();
|
|
76
|
+
if (name)
|
|
77
|
+
names.add(name);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return [...names];
|
|
81
|
+
}
|
|
@@ -30,8 +30,7 @@ const ALL_CHECKS = [
|
|
|
30
30
|
'noCustomContent',
|
|
31
31
|
'noRequestTimeAssetRoute',
|
|
32
32
|
'sdkVersion',
|
|
33
|
-
// Renders every registered component (ADR
|
|
34
|
-
// building first when absent.
|
|
33
|
+
// Renders every registered component in isolation (ADR 0063).
|
|
35
34
|
'rootIdentity',
|
|
36
35
|
// Expensive checks last — these shell out
|
|
37
36
|
'contentCompile',
|
|
@@ -1,31 +1,2 @@
|
|
|
1
1
|
import type { ConformanceCheckResult, ConformanceContext } from './types.js';
|
|
2
|
-
import type { Registry } from '../../registry.js';
|
|
3
|
-
import type { KernelPageBlock } from '../../content/types.js';
|
|
4
|
-
export interface RootIdentityFailure {
|
|
5
|
-
component: string;
|
|
6
|
-
file?: string;
|
|
7
|
-
missing: string[];
|
|
8
|
-
}
|
|
9
|
-
/** Minimal React surface the renderer needs; injected so tests can pass fakes. */
|
|
10
|
-
export interface ReactRuntime {
|
|
11
|
-
renderToStaticMarkup: (element: unknown) => string;
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Render each block through the SDK renderer and report every component
|
|
15
|
-
* whose root element lacks the identity attributes. `blocks` are the blocks
|
|
16
|
-
* to exercise; registry entries not covered by a block are probed with empty
|
|
17
|
-
* props so an unused component cannot hide a forgotten spread.
|
|
18
|
-
*/
|
|
19
|
-
export declare function findRootIdentityFailures(registry: Registry, blocks: readonly KernelPageBlock[], react: ReactRuntime, fileFor?: (component: string) => string | undefined): Promise<RootIdentityFailure[]>;
|
|
20
|
-
/** The failure message: one line of what, the list, one line of fix. */
|
|
21
|
-
export declare function formatRootIdentityFailures(failures: readonly RootIdentityFailure[], framework: 'next' | 'astro'): string;
|
|
22
2
|
export declare function checkRootIdentity(ctx: ConformanceContext): Promise<ConformanceCheckResult[]>;
|
|
23
|
-
/**
|
|
24
|
-
* Every block the Astro scan must find in dist/: page blocks by their own id,
|
|
25
|
-
* plus fragments (navbar, footer) by fragment id, which is the block id the
|
|
26
|
-
* renderer synthesizes when a layout slot default places them.
|
|
27
|
-
*/
|
|
28
|
-
export declare function astroAuthoredBlocks(contentDir: string): Array<{
|
|
29
|
-
id: string;
|
|
30
|
-
component: string;
|
|
31
|
-
}>;
|