@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
package/dist/next/config.js
CHANGED
|
@@ -13,19 +13,12 @@ export function withAci(nextConfig = {}, options = {}) {
|
|
|
13
13
|
'`next dev` / `next build` without --turbopack.');
|
|
14
14
|
}
|
|
15
15
|
const publicPaths = collectPublicPaths(join(process.cwd(), 'public'));
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
// under enforce they fail the build closed, and unknown posture values
|
|
23
|
-
// fail closed too. Present-but-broken bindings fail closed always (REQ-5).
|
|
24
|
-
// A code artifact is independent of every content snapshot. Authored error
|
|
25
|
-
// bindings are validated when previewing/publishing the selected content.
|
|
26
|
-
const errorSeat = process.env.ACI_CODE_ARTIFACT_BUILD === '1'
|
|
27
|
-
? { payload: { defaulted: true, defaultHtml: defaultErrorPageFragmentHtml() }, warnings: [], errors: [] }
|
|
28
|
-
: buildErrorSeatPayload(undefined, process.env.ACI_ERROR_PAGES_POSTURE);
|
|
16
|
+
// Local dev previews the authored error seat. Production code builds must
|
|
17
|
+
// never bake a content snapshot into the native client-only error seat;
|
|
18
|
+
// the selected release's authored errors are materialized at publish.
|
|
19
|
+
const errorSeat = process.env.NODE_ENV === 'development'
|
|
20
|
+
? buildErrorSeatPayload(undefined, process.env.ACI_ERROR_PAGES_POSTURE)
|
|
21
|
+
: { payload: { defaulted: true, defaultHtml: defaultErrorPageFragmentHtml() }, warnings: [], errors: [] };
|
|
29
22
|
if (errorSeat.errors.length > 0) {
|
|
30
23
|
throw new Error(`ACI errorPages bindings are invalid (site.json#errorPages):\n- ${errorSeat.errors.join('\n- ')}`);
|
|
31
24
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ErrorSeatPayload } from './error-seat.js';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Local-development serialization of the bound catchAll page for the client-only
|
|
4
4
|
* Next error seat (authorable-error-pages D-9). Synchronous by design: it
|
|
5
5
|
* runs inside withAci() while next.config is evaluated, reading the compiled
|
|
6
6
|
* content tree directly (the async providers cannot be awaited there).
|
|
@@ -29,8 +29,7 @@ export function buildErrorSeatPayload(contentRoot, posture) {
|
|
|
29
29
|
const root = path.resolve(process.cwd(), contentRoot || process.env.ACI_CONTENT_ROOT || '.aci/compiled');
|
|
30
30
|
if (!existsSync(path.join(root, 'manifest.json'))) {
|
|
31
31
|
const message = `no compiled content at ${root}; the error seat will serve the shipped default page — `
|
|
32
|
-
+ 'run
|
|
33
|
-
+ '`emit-error-pages.mjs prepare` phase does this)';
|
|
32
|
+
+ 'run `npm run content:compile` to preview authored error pages locally';
|
|
34
33
|
if (effectivePosture === 'enforce') {
|
|
35
34
|
errors.push(`${message} (posture "enforce" fails closed, D-5)`);
|
|
36
35
|
}
|
package/dist/next/error-seat.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Component, Fragment } from 'react';
|
|
4
|
+
import { withAciRootProps, withNestedAciRootProps } from '../internal/aci-root.js';
|
|
4
5
|
import { ErrorPageInstrumentation } from './error-page-instrumentation.js';
|
|
5
6
|
import { ErrorPageMarker } from './error-page-marker.js';
|
|
6
7
|
/** The build-time serialized payload, or null when the define is absent. */
|
|
@@ -74,13 +75,11 @@ function renderClientBlocks(blocks, registry) {
|
|
|
74
75
|
if (!entry)
|
|
75
76
|
return null;
|
|
76
77
|
const Component = entry.component;
|
|
77
|
-
|
|
78
|
+
// sdk I-31 (ADR 0051): the error seat is an injection seam too.
|
|
79
|
+
const componentProps = withAciRootProps(withNestedAciRootProps(block.props), block);
|
|
78
80
|
if (entry.contract.blockSlots) {
|
|
79
81
|
componentProps.renderChildren = (children) => renderClientBlocks(children, registry);
|
|
80
82
|
}
|
|
81
|
-
// sdk I-19 (ADR 0051): the error seat is an injection seam too.
|
|
82
|
-
componentProps['data-aci-block'] = block.id;
|
|
83
|
-
componentProps['data-aci-component'] = block.component;
|
|
84
83
|
// Index+id composite: a repeated fragment placement repeats its authored
|
|
85
84
|
// id, and sibling keys must stay unique for React reconciliation.
|
|
86
85
|
return _jsx(Component, { ...componentProps }, `${index}:${block.id}`);
|
package/dist/next/render.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { withAciRootProps, withNestedAciRootProps } from '../internal/aci-root.js';
|
|
2
3
|
export async function renderBlock(block, registry, key, extraProps) {
|
|
3
4
|
const entry = registry.find((r) => r.id === block.component);
|
|
4
5
|
if (!entry) {
|
|
@@ -6,14 +7,7 @@ export async function renderBlock(block, registry, key, extraProps) {
|
|
|
6
7
|
}
|
|
7
8
|
const Component = entry.component;
|
|
8
9
|
const hasBlockSlots = !!entry.contract.blockSlots;
|
|
9
|
-
|
|
10
|
-
// content props nor tenant slotProps can overwrite them.
|
|
11
|
-
const componentProps = {
|
|
12
|
-
...block.props,
|
|
13
|
-
...extraProps,
|
|
14
|
-
'data-aci-block': block.id,
|
|
15
|
-
'data-aci-component': block.component,
|
|
16
|
-
};
|
|
10
|
+
const componentProps = withAciRootProps({ ...withNestedAciRootProps(block.props), ...extraProps }, block);
|
|
17
11
|
if (hasBlockSlots) {
|
|
18
12
|
componentProps.renderChildren = (blocks) => renderBlocks(blocks, registry, extraProps);
|
|
19
13
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Astro adapter: renders one registered component with the Astro Container
|
|
3
|
+
* API over a Vite server that Astro configures for the site
|
|
4
|
+
* (`getViteConfig`), the way Astro documents testing components. No site
|
|
5
|
+
* build and no emitted HTML, so static and server output are covered alike.
|
|
6
|
+
* astro, its container, and vite resolve from the site, never from the SDK.
|
|
7
|
+
*/
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { join, relative, sep } from 'node:path';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
import { createRenderChildren } from '../../astro/index.js';
|
|
12
|
+
import { classifyLoadError, findRegistryFiles, mergeRegistries, registryExport } from './site.js';
|
|
13
|
+
export async function openAstroSite(siteDir, explicit = []) {
|
|
14
|
+
// Every file that calls createRegistry(, plus any registry the site names explicitly.
|
|
15
|
+
const registryFiles = [...new Set([...findRegistryFiles(siteDir), ...explicit])].sort();
|
|
16
|
+
if (registryFiles.length === 0)
|
|
17
|
+
throw new Error('no registry file imports createRegistry from @gradial/aci');
|
|
18
|
+
const fromSite = createRequire(join(siteDir, 'package.json'));
|
|
19
|
+
const importFrom = async (resolve, specifier) => (await import(pathToFileURL(resolve(specifier)).href));
|
|
20
|
+
const { getViteConfig } = await importFrom(fromSite.resolve, 'astro/config');
|
|
21
|
+
const { experimental_AstroContainer } = await importFrom(fromSite.resolve, 'astro/container');
|
|
22
|
+
const fromAstro = createRequire(fromSite.resolve('astro/package.json'));
|
|
23
|
+
const { createServer } = await importFrom(fromAstro.resolve, 'vite');
|
|
24
|
+
const vite = await withoutContentWatch(async () => {
|
|
25
|
+
// An isolated SSR render needs no client dependency optimizer and no dev
|
|
26
|
+
// toolbar; both would otherwise run in the background past close().
|
|
27
|
+
const config = await getViteConfig({
|
|
28
|
+
logLevel: 'silent',
|
|
29
|
+
optimizeDeps: { noDiscovery: true, include: [] },
|
|
30
|
+
}, { root: siteDir, logLevel: 'silent', devToolbar: { enabled: false } })({ mode: 'development', command: 'serve' });
|
|
31
|
+
const server = (config.server ?? {});
|
|
32
|
+
return createServer({
|
|
33
|
+
...config,
|
|
34
|
+
server: { ...server, middlewareMode: true, hmr: false, ws: false, watch: null },
|
|
35
|
+
appType: 'custom',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
try {
|
|
39
|
+
// Every registry file is loaded through Astro's own Vite pipeline; one
|
|
40
|
+
// that cannot load is reported and does not hide the others.
|
|
41
|
+
const registries = [];
|
|
42
|
+
const unloadable = [];
|
|
43
|
+
for (const registryFile of registryFiles) {
|
|
44
|
+
const rel = relative(siteDir, registryFile);
|
|
45
|
+
try {
|
|
46
|
+
const registry = registryExport(await vite.ssrLoadModule('/' + rel.split(sep).join('/')));
|
|
47
|
+
if (registry)
|
|
48
|
+
registries.push(registry);
|
|
49
|
+
else
|
|
50
|
+
unloadable.push({ file: rel, reason: 'does not export the createRegistry result' });
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
unloadable.push({ file: rel, reason: classifyLoadError(err).reason });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const registry = mergeRegistries(registries);
|
|
57
|
+
const container = await experimental_AstroContainer.create();
|
|
58
|
+
return {
|
|
59
|
+
registry,
|
|
60
|
+
adapter: createAstroAdapter(registry, container, siteDir),
|
|
61
|
+
unloadable,
|
|
62
|
+
close: () => vite.close(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
await vite.close();
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function createAstroAdapter(registry, container, siteDir) {
|
|
71
|
+
const renderChildren = createRenderChildren(registry);
|
|
72
|
+
return {
|
|
73
|
+
id: 'astro',
|
|
74
|
+
async renderBlock(block) {
|
|
75
|
+
const [{ Component, props }] = renderChildren([block]);
|
|
76
|
+
if (!Component)
|
|
77
|
+
throw new Error(`no registered component "${block.component}"`);
|
|
78
|
+
return container.renderToString(Component, { props });
|
|
79
|
+
},
|
|
80
|
+
sourceFileFor(componentId) {
|
|
81
|
+
// Astro's compiler stamps every component factory with its source path.
|
|
82
|
+
const component = registry.find((entry) => entry.id === componentId)?.component;
|
|
83
|
+
return typeof component?.moduleId === 'string' ? relative(siteDir, component.moduleId) : undefined;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// withAci() recompiles content when a dev server starts; an isolated render
|
|
88
|
+
// needs neither the recompile nor the watcher.
|
|
89
|
+
async function withoutContentWatch(fn) {
|
|
90
|
+
const previous = process.env.ACI_DISABLE_CONTENT_WATCH;
|
|
91
|
+
process.env.ACI_DISABLE_CONTENT_WATCH = '1';
|
|
92
|
+
try {
|
|
93
|
+
return await fn();
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (previous === undefined)
|
|
97
|
+
delete process.env.ACI_DISABLE_CONTENT_WATCH;
|
|
98
|
+
else
|
|
99
|
+
process.env.ACI_DISABLE_CONTENT_WATCH = previous;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Registry } from '../../registry.js';
|
|
2
|
+
import type { FrameworkAdapter, LoadedSite } from './types.js';
|
|
3
|
+
/** The react-dom/server surface the adapter needs. */
|
|
4
|
+
interface ReactRuntime {
|
|
5
|
+
renderToStaticMarkup(element: unknown): string;
|
|
6
|
+
}
|
|
7
|
+
export declare function openNextSite(siteDir: string, explicit?: readonly string[]): Promise<LoadedSite>;
|
|
8
|
+
export declare function createNextAdapter(registry: Registry, react: ReactRuntime, sourceFileFor: (componentId: string) => string | undefined): FrameworkAdapter;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next adapter: renders one registered component with react-dom/server from
|
|
3
|
+
* the site's own React, through the SDK's `renderBlock`. `next/link` and
|
|
4
|
+
* `next/image` are stubbed (testing/conformance/next-stubs.ts) because their
|
|
5
|
+
* CommonJS interop objects cannot render outside Next's bundler.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { join, relative } from 'node:path';
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
|
+
import { renderBlock } from '../../next/render.js';
|
|
12
|
+
import { classifyLoadError, findRegistryFiles, LoaderMissingError, mergeRegistries, registryExport } from './site.js';
|
|
13
|
+
export async function openNextSite(siteDir, explicit = []) {
|
|
14
|
+
// Every file that calls createRegistry(, plus any registry the site names explicitly.
|
|
15
|
+
const registryFiles = [...new Set([...findRegistryFiles(siteDir), ...explicit])].sort();
|
|
16
|
+
if (registryFiles.length === 0)
|
|
17
|
+
throw new Error('no registry file imports createRegistry from @gradial/aci');
|
|
18
|
+
const fromSite = createRequire(join(siteDir, 'package.json'));
|
|
19
|
+
const react = fromSite('react-dom/server');
|
|
20
|
+
// The site's tsconfig may keep `jsx: preserve` for Next, in which case the
|
|
21
|
+
// test transform emits classic React.createElement calls.
|
|
22
|
+
globalThis.React = fromSite('react');
|
|
23
|
+
const { register } = await import('node:module');
|
|
24
|
+
register(stubHookURL());
|
|
25
|
+
// Every registry file is loaded; one that cannot load does not hide the
|
|
26
|
+
// others. A process with no TypeScript/JSX loader is a different failure:
|
|
27
|
+
// nothing can be checked and the fix is one line, so it throws.
|
|
28
|
+
const registries = [];
|
|
29
|
+
const unloadable = [];
|
|
30
|
+
const fileFor = new Map();
|
|
31
|
+
for (const registryFile of registryFiles) {
|
|
32
|
+
const rel = relative(siteDir, registryFile);
|
|
33
|
+
let mod;
|
|
34
|
+
try {
|
|
35
|
+
mod = (await import(pathToFileURL(registryFile).href));
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
const failure = classifyLoadError(err);
|
|
39
|
+
if (failure.kind === 'loader-missing')
|
|
40
|
+
throw new LoaderMissingError(rel, failure.reason);
|
|
41
|
+
unloadable.push({ file: rel, reason: failure.reason });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const registry = registryExport(mod);
|
|
45
|
+
if (!registry) {
|
|
46
|
+
unloadable.push({ file: rel, reason: 'does not export the createRegistry result' });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
registries.push(registry);
|
|
50
|
+
const resolve = componentFileResolver(readFileSync(registryFile, 'utf8'), registry);
|
|
51
|
+
for (const entry of registry) {
|
|
52
|
+
const file = resolve(entry.id);
|
|
53
|
+
if (file && !fileFor.has(entry.id))
|
|
54
|
+
fileFor.set(entry.id, file);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const registry = mergeRegistries(registries);
|
|
58
|
+
return {
|
|
59
|
+
registry,
|
|
60
|
+
adapter: createNextAdapter(registry, react, (id) => fileFor.get(id)),
|
|
61
|
+
unloadable,
|
|
62
|
+
close: async () => { },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function createNextAdapter(registry, react, sourceFileFor) {
|
|
66
|
+
return {
|
|
67
|
+
id: 'next',
|
|
68
|
+
async renderBlock(block) {
|
|
69
|
+
if (!registry.some((entry) => entry.id === block.component)) {
|
|
70
|
+
throw new Error(`no registered component "${block.component}"`);
|
|
71
|
+
}
|
|
72
|
+
return markupFor(await renderBlock(block, registry, `probe:${block.id}`), react);
|
|
73
|
+
},
|
|
74
|
+
sourceFileFor,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Slot components are async server components in Next; react-dom/server
|
|
79
|
+
* cannot render those, so the element the SDK produced is unwrapped by
|
|
80
|
+
* awaiting the component function directly.
|
|
81
|
+
*/
|
|
82
|
+
async function markupFor(element, react) {
|
|
83
|
+
const el = element;
|
|
84
|
+
if (typeof el.type === 'function' && el.type.constructor?.name === 'AsyncFunction') {
|
|
85
|
+
return react.renderToStaticMarkup(await el.type(el.props));
|
|
86
|
+
}
|
|
87
|
+
return react.renderToStaticMarkup(element);
|
|
88
|
+
}
|
|
89
|
+
/** The stub hook: compiled `.js` in dist, `.ts` when run from source. */
|
|
90
|
+
function stubHookURL() {
|
|
91
|
+
const js = new URL('../../testing/conformance/next-stubs.js', import.meta.url);
|
|
92
|
+
if (existsSync(fileURLToPath(js)))
|
|
93
|
+
return js;
|
|
94
|
+
return new URL('../../testing/conformance/next-stubs.ts', import.meta.url);
|
|
95
|
+
}
|
|
96
|
+
/** Map a registered component id to the import path the registry file names for it. */
|
|
97
|
+
function componentFileResolver(registrySource, registry) {
|
|
98
|
+
const imports = new Map();
|
|
99
|
+
for (const m of registrySource.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))\s+from\s+['"]([^'"]+)['"]/g)) {
|
|
100
|
+
const names = (m[1] ?? m[2]).split(',').map((n) => n.trim().split(/\s+as\s+/).pop()).filter(Boolean);
|
|
101
|
+
for (const name of names)
|
|
102
|
+
imports.set(name, m[3]);
|
|
103
|
+
}
|
|
104
|
+
const byId = new Map();
|
|
105
|
+
for (const entry of registry) {
|
|
106
|
+
const fn = entry.component;
|
|
107
|
+
const file = typeof fn === 'function' && fn.name ? imports.get(fn.name) : undefined;
|
|
108
|
+
if (file)
|
|
109
|
+
byId.set(entry.id, file);
|
|
110
|
+
}
|
|
111
|
+
return (componentId) => byId.get(componentId);
|
|
112
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Registry } from '../../registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* Every source file that calls `createRegistry(`, in a stable order: under
|
|
4
|
+
* `src/` when the site has one, so a build output directory (whose bundled
|
|
5
|
+
* chunks also contain the call) is never mistaken for a registry. A site may
|
|
6
|
+
* keep several (a main registry, an error-page registry); the check covers
|
|
7
|
+
* the union, so "passed" means every registered component was looked at.
|
|
8
|
+
*/
|
|
9
|
+
export declare function findRegistryFiles(siteDir: string): string[];
|
|
10
|
+
/** Merge registries by component id; the first file that registers an id wins. */
|
|
11
|
+
export declare function mergeRegistries(registries: readonly Registry[]): Registry;
|
|
12
|
+
/**
|
|
13
|
+
* Why a registry file could not be loaded. `loader-missing` means the test
|
|
14
|
+
* process itself cannot load TypeScript, JSX, or the site's path aliases:
|
|
15
|
+
* a site configuration defect with a one-line fix, and the check verified
|
|
16
|
+
* nothing. Anything else is a limit of the isolated loader, reported as
|
|
17
|
+
* not-run and left to publish.
|
|
18
|
+
*/
|
|
19
|
+
export type LoadFailure = {
|
|
20
|
+
kind: 'loader-missing' | 'unloadable';
|
|
21
|
+
reason: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function classifyLoadError(err: unknown): LoadFailure;
|
|
24
|
+
/** Thrown when the test process has no TypeScript/JSX loader at all. */
|
|
25
|
+
export declare class LoaderMissingError extends Error {
|
|
26
|
+
constructor(file: string, reason: string);
|
|
27
|
+
}
|
|
28
|
+
/** The `createRegistry` result among a module's exports. */
|
|
29
|
+
export declare function registryExport(mod: Record<string, unknown>): Registry | undefined;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Every source file that calls `createRegistry(`, in a stable order: under
|
|
5
|
+
* `src/` when the site has one, so a build output directory (whose bundled
|
|
6
|
+
* chunks also contain the call) is never mistaken for a registry. A site may
|
|
7
|
+
* keep several (a main registry, an error-page registry); the check covers
|
|
8
|
+
* the union, so "passed" means every registered component was looked at.
|
|
9
|
+
*/
|
|
10
|
+
export function findRegistryFiles(siteDir) {
|
|
11
|
+
const src = join(siteDir, 'src');
|
|
12
|
+
const underSrc = existsSync(src) ? walkForRegistries(src) : [];
|
|
13
|
+
return underSrc.length > 0 ? underSrc : walkForRegistries(siteDir);
|
|
14
|
+
}
|
|
15
|
+
function walkForRegistries(rootDir) {
|
|
16
|
+
const skip = new Set(['node_modules', '.next', '.aci', 'dist', '.git', 'tests', 'scripts']);
|
|
17
|
+
const found = [];
|
|
18
|
+
const walk = (dir) => {
|
|
19
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
20
|
+
if (entry.name.startsWith('.'))
|
|
21
|
+
continue;
|
|
22
|
+
const full = join(dir, entry.name);
|
|
23
|
+
if (entry.isDirectory()) {
|
|
24
|
+
if (!skip.has(entry.name))
|
|
25
|
+
walk(full);
|
|
26
|
+
}
|
|
27
|
+
else if (/\.(ts|tsx|js|mjs)$/.test(entry.name) && !/\.(test|spec)\./.test(entry.name)
|
|
28
|
+
&& readFileSync(full, 'utf8').includes('createRegistry(')) {
|
|
29
|
+
found.push(full);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
walk(rootDir);
|
|
34
|
+
return found;
|
|
35
|
+
}
|
|
36
|
+
/** Merge registries by component id; the first file that registers an id wins. */
|
|
37
|
+
export function mergeRegistries(registries) {
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
const merged = [];
|
|
40
|
+
for (const registry of registries) {
|
|
41
|
+
for (const entry of registry) {
|
|
42
|
+
if (seen.has(entry.id))
|
|
43
|
+
continue;
|
|
44
|
+
seen.add(entry.id);
|
|
45
|
+
merged.push(entry);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return merged;
|
|
49
|
+
}
|
|
50
|
+
export function classifyLoadError(err) {
|
|
51
|
+
const e = err;
|
|
52
|
+
const message = e?.message ?? String(err);
|
|
53
|
+
const code = e?.code ?? '';
|
|
54
|
+
const unknownExt = /Unknown file extension "(\.[a-z]+)"/i.exec(message)?.[1]?.toLowerCase();
|
|
55
|
+
if (code === 'ERR_UNKNOWN_FILE_EXTENSION' && unknownExt && ['.ts', '.tsx', '.mts', '.cts', '.jsx'].includes(unknownExt)) {
|
|
56
|
+
return { kind: 'loader-missing', reason: `cannot load ${unknownExt} files` };
|
|
57
|
+
}
|
|
58
|
+
if (code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX' || code === 'ERR_INVALID_TYPESCRIPT_SYNTAX'
|
|
59
|
+
|| code === 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING') {
|
|
60
|
+
return { kind: 'loader-missing', reason: 'cannot compile TypeScript or JSX' };
|
|
61
|
+
}
|
|
62
|
+
const missing = /Cannot find (?:package|module) '([^']+)'/.exec(message)?.[1];
|
|
63
|
+
if ((code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') && missing && /^[@~]\//.test(missing)) {
|
|
64
|
+
return { kind: 'loader-missing', reason: `cannot resolve the path alias "${missing}"` };
|
|
65
|
+
}
|
|
66
|
+
return { kind: 'unloadable', reason: firstLine(message) };
|
|
67
|
+
}
|
|
68
|
+
/** Thrown when the test process has no TypeScript/JSX loader at all. */
|
|
69
|
+
export class LoaderMissingError extends Error {
|
|
70
|
+
constructor(file, reason) {
|
|
71
|
+
super(`cannot load ${file}: this test process ${reason}.\n`
|
|
72
|
+
+ ' Run tests with: node --import tsx --test\n'
|
|
73
|
+
+ ' See the @gradial/aci CHANGELOG, "Migration required: test loader".');
|
|
74
|
+
this.name = 'LoaderMissingError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function firstLine(text) {
|
|
78
|
+
const line = text.split('\n').find((l) => l.trim()) ?? text;
|
|
79
|
+
return line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
|
80
|
+
}
|
|
81
|
+
/** The `createRegistry` result among a module's exports. */
|
|
82
|
+
export function registryExport(mod) {
|
|
83
|
+
// The canonical `registry` export may be empty; any other export must look
|
|
84
|
+
// like a registry to count as one.
|
|
85
|
+
if (Array.isArray(mod.registry) && mod.registry.length === 0)
|
|
86
|
+
return [];
|
|
87
|
+
return Object.values(mod).find(isRegistry);
|
|
88
|
+
}
|
|
89
|
+
function isRegistry(value) {
|
|
90
|
+
return Array.isArray(value) && value.length > 0
|
|
91
|
+
&& value.every((e) => e && typeof e === 'object' && typeof e.id === 'string' && 'component' in e && 'contract' in e);
|
|
92
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { KernelPageBlock } from '../../content/types.js';
|
|
2
|
+
import type { Registry } from '../../registry.js';
|
|
3
|
+
export type AdapterId = 'astro' | 'next';
|
|
4
|
+
/**
|
|
5
|
+
* The one framework seam the platform tests have (ADR 0063 §One verifier):
|
|
6
|
+
* render one registered component outside the site build, and map a
|
|
7
|
+
* registry entry to its source file. Everything else is framework-neutral.
|
|
8
|
+
*/
|
|
9
|
+
export interface FrameworkAdapter {
|
|
10
|
+
id: AdapterId;
|
|
11
|
+
/** Render one block in isolation through the SDK's own prop injection. */
|
|
12
|
+
renderBlock(block: KernelPageBlock): Promise<string>;
|
|
13
|
+
/** The site-relative source file of a registered component, when known. */
|
|
14
|
+
sourceFileFor(componentId: string): string | undefined;
|
|
15
|
+
}
|
|
16
|
+
/** A site's registry loaded for isolated renders, and its adapter. */
|
|
17
|
+
export interface LoadedSite {
|
|
18
|
+
/** The union of every registry file that loaded. */
|
|
19
|
+
registry: Registry;
|
|
20
|
+
adapter: FrameworkAdapter;
|
|
21
|
+
/** Registry files the isolated loader could not load, with the reason. */
|
|
22
|
+
unloadable: {
|
|
23
|
+
file: string;
|
|
24
|
+
reason: string;
|
|
25
|
+
}[];
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root-identity judgement conformance runs over a site's renders
|
|
3
|
+
* (`./testing`, site-check.ts).
|
|
4
|
+
*/
|
|
5
|
+
export { collectComponentBlocks, isComponentBlock, type ComponentBlock } from '../content/component-block.js';
|
|
6
|
+
export type TestVerdict = 'pass' | 'fail' | 'not-run';
|
|
7
|
+
export interface TestFinding {
|
|
8
|
+
route: string;
|
|
9
|
+
variantKey?: string;
|
|
10
|
+
blockId: string;
|
|
11
|
+
component: string;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
export interface TestOutcome {
|
|
15
|
+
test: string;
|
|
16
|
+
/** The test's own contract version, raised only on a breaking change. */
|
|
17
|
+
version: number;
|
|
18
|
+
verdict: TestVerdict;
|
|
19
|
+
findings: TestFinding[];
|
|
20
|
+
}
|
|
21
|
+
export interface TestContext {
|
|
22
|
+
/** Route path, e.g. "/" or "/about". */
|
|
23
|
+
route: string;
|
|
24
|
+
/** Personalization variant; absent for the base render. */
|
|
25
|
+
variantKey?: string;
|
|
26
|
+
/** The compiled page payload the renderer was given. */
|
|
27
|
+
payload: unknown;
|
|
28
|
+
/** The HTML the render produced. */
|
|
29
|
+
html: string;
|
|
30
|
+
}
|
|
31
|
+
export interface PlatformTest {
|
|
32
|
+
version: number;
|
|
33
|
+
run(ctx: TestContext): TestOutcome;
|
|
34
|
+
}
|
|
35
|
+
/** Every platform test the SDK declares. */
|
|
36
|
+
export declare const TESTS: {
|
|
37
|
+
readonly rootIdentity: {
|
|
38
|
+
readonly version: 1;
|
|
39
|
+
readonly run: typeof rootIdentity;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
declare function rootIdentity(ctx: TestContext): TestOutcome;
|
|
43
|
+
/**
|
|
44
|
+
* An attribute value as each SDK renderer writes it: Astro's addAttribute
|
|
45
|
+
* escapes only & and "; react-dom/server also escapes ' < >.
|
|
46
|
+
*/
|
|
47
|
+
export declare function renderedAttributeValues(value: string): [astro: string, react: string];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root-identity judgement conformance runs over a site's renders
|
|
3
|
+
* (`./testing`, site-check.ts).
|
|
4
|
+
*/
|
|
5
|
+
import { collectComponentBlocks } from '../content/component-block.js';
|
|
6
|
+
export { collectComponentBlocks, isComponentBlock } from '../content/component-block.js';
|
|
7
|
+
/** Every platform test the SDK declares. */
|
|
8
|
+
export const TESTS = {
|
|
9
|
+
rootIdentity: { version: 1, run: rootIdentity },
|
|
10
|
+
};
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// rootIdentity (ADR 0051, ADR 0063): every component block in the payload
|
|
13
|
+
// appears in the HTML as an element carrying data-aci-block="<id>".
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const ROOT_IDENTITY_MESSAGE = 'no element carries the block\'s data-aci-block attribute';
|
|
16
|
+
function rootIdentity(ctx) {
|
|
17
|
+
const outcome = (verdict, findings) => ({
|
|
18
|
+
test: 'rootIdentity',
|
|
19
|
+
version: TESTS.rootIdentity.version,
|
|
20
|
+
verdict,
|
|
21
|
+
findings,
|
|
22
|
+
});
|
|
23
|
+
if (typeof ctx?.html !== 'string' || typeof ctx.route !== 'string')
|
|
24
|
+
return outcome('not-run', []);
|
|
25
|
+
// One pass over the HTML: every attribute value as the renderer wrote it.
|
|
26
|
+
const rendered = new Set();
|
|
27
|
+
for (const match of ctx.html.matchAll(/\sdata-aci-block="([^"]*)"/g))
|
|
28
|
+
rendered.add(match[1]);
|
|
29
|
+
const findings = [];
|
|
30
|
+
for (const block of collectComponentBlocks(ctx.payload)) {
|
|
31
|
+
if (renderedAttributeValues(block.id).some((value) => rendered.has(value)))
|
|
32
|
+
continue;
|
|
33
|
+
findings.push({
|
|
34
|
+
route: ctx.route,
|
|
35
|
+
...(ctx.variantKey ? { variantKey: ctx.variantKey } : {}),
|
|
36
|
+
blockId: block.id,
|
|
37
|
+
component: block.component,
|
|
38
|
+
message: ROOT_IDENTITY_MESSAGE,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return outcome(findings.length > 0 ? 'fail' : 'pass', findings);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* An attribute value as each SDK renderer writes it: Astro's addAttribute
|
|
45
|
+
* escapes only & and "; react-dom/server also escapes ' < >.
|
|
46
|
+
*/
|
|
47
|
+
export function renderedAttributeValues(value) {
|
|
48
|
+
const astro = value.replace(/&/g, '&').replace(/"/g, '"');
|
|
49
|
+
return [astro, astro.replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>')];
|
|
50
|
+
}
|