@gradial/aci 0.1.53-rc.2 → 0.1.53-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +26 -4
  3. package/dist/astro/index.js +2 -5
  4. package/dist/cli/emit-error-pages.d.mts +2 -1
  5. package/dist/cli/emit-error-pages.mjs +13 -270
  6. package/dist/content/component-block.d.ts +17 -0
  7. package/dist/content/component-block.js +40 -0
  8. package/dist/content/resolve-slots.js +2 -4
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +1 -0
  11. package/dist/internal/aci-root.d.ts +1 -0
  12. package/dist/internal/aci-root.js +49 -0
  13. package/dist/next/config.js +6 -13
  14. package/dist/next/error-seat-payload.d.ts +1 -1
  15. package/dist/next/error-seat-payload.js +1 -2
  16. package/dist/next/error-seat.js +3 -4
  17. package/dist/next/render.js +2 -8
  18. package/dist/platform-tests/adapters/astro.d.ts +2 -0
  19. package/dist/platform-tests/adapters/astro.js +101 -0
  20. package/dist/platform-tests/adapters/next.d.ts +9 -0
  21. package/dist/platform-tests/adapters/next.js +112 -0
  22. package/dist/platform-tests/adapters/site.d.ts +29 -0
  23. package/dist/platform-tests/adapters/site.js +92 -0
  24. package/dist/platform-tests/adapters/types.d.ts +27 -0
  25. package/dist/platform-tests/adapters/types.js +1 -0
  26. package/dist/platform-tests/index.d.ts +47 -0
  27. package/dist/platform-tests/index.js +50 -0
  28. package/dist/platform-tests/sample-props.d.ts +2 -0
  29. package/dist/platform-tests/sample-props.js +82 -0
  30. package/dist/platform-tests/site-check.d.ts +51 -0
  31. package/dist/platform-tests/site-check.js +193 -0
  32. package/dist/registry.d.ts +1 -1
  33. package/dist/testing/conformance/index.d.ts +2 -1
  34. package/dist/testing/conformance/index.js +2 -1
  35. package/dist/testing/conformance/next-stubs.d.ts +13 -4
  36. package/dist/testing/conformance/next-stubs.js +76 -4
  37. package/dist/testing/conformance/preset.js +1 -2
  38. package/dist/testing/conformance/root-identity.d.ts +0 -29
  39. package/dist/testing/conformance/root-identity.js +36 -344
  40. package/dist/testing/conformance/types.d.ts +1 -1
  41. package/dist/types/component.d.ts +11 -3
  42. package/dist/types/component.js +10 -1
  43. package/package.json +9 -8
  44. package/src/cli/emit-error-pages.mjs +13 -273
  45. package/src/types/component.test.ts +17 -0
  46. package/src/types/component.ts +18 -3
  47. package/templates/astro/starter-content/src/components/chrome/Footer.astro +3 -4
  48. package/templates/astro/starter-content/src/components/chrome/Header.astro +3 -4
  49. package/templates/astro/starter-content/src/components/elements/Button.astro +4 -4
  50. package/templates/astro/starter-content/src/components/elements/ButtonBlock.astro +3 -4
  51. package/templates/astro/starter-content/src/components/sections/ContainerSection.astro +4 -4
  52. package/templates/astro/starter-content/src/components/sections/FeatureCard.astro +4 -4
  53. package/templates/astro/starter-content/src/components/sections/FeatureGrid.astro +4 -4
  54. package/templates/astro/starter-content/src/components/sections/HomeHero.astro +3 -4
  55. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +4 -4
  56. package/templates/astro/starter-content/src/components/sections/StatsBar.astro +4 -4
  57. package/templates/astro/template/package.json.tmpl +1 -1
  58. package/templates/common/.agents/skills/authoring/SKILL.md +6 -0
  59. package/templates/common/.agents/skills/authoring/references/block-registry.md +41 -7
  60. package/templates/common/.agents/skills/authoring/references/sdk-migrations.md +87 -0
  61. package/templates/nextjs/starter-content/src/components/chrome/Footer.tsx +5 -4
  62. package/templates/nextjs/starter-content/src/components/chrome/Header.tsx +5 -4
  63. package/templates/nextjs/starter-content/src/components/elements/ButtonBlock.tsx +5 -4
  64. package/templates/nextjs/starter-content/src/components/sections/ContainerSection.tsx +9 -9
  65. package/templates/nextjs/starter-content/src/components/sections/FeatureCard.tsx +5 -4
  66. package/templates/nextjs/starter-content/src/components/sections/FeatureGrid.tsx +9 -9
  67. package/templates/nextjs/starter-content/src/components/sections/HomeHero.tsx +5 -4
  68. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +10 -10
  69. package/templates/nextjs/starter-content/src/components/sections/StatsBar.tsx +5 -4
  70. package/templates/nextjs/template/package.json.tmpl +1 -1
@@ -0,0 +1,49 @@
1
+ import { isComponentBlock } from '../content/component-block.js';
2
+ /**
3
+ * The one place a renderer seam applies the block root identity (sdk I-31,
4
+ * ADR 0063 §One verifier). The identity props go last, so neither content
5
+ * props nor tenant slotProps can overwrite them.
6
+ *
7
+ * @internal
8
+ */
9
+ export function withAciRootProps(props, block) {
10
+ return {
11
+ ...props,
12
+ 'data-aci-block': block.id,
13
+ 'data-aci-component': block.component,
14
+ };
15
+ }
16
+ /**
17
+ * A copy of a block's content props in which every nested component block
18
+ * (the `rootIdentity` predicate, at any depth: a block slot, or a list inside
19
+ * an item such as `tabs[].blocks`) carries its own identity in its `props`
20
+ * (sdk I-31). A container that renders a child with `{...child.props}` then
21
+ * emits the child's identity; one that calls `renderChildren` re-applies the
22
+ * same values, so nothing doubles. Walk only compiled content, never tenant
23
+ * slotProps: those can hold React elements.
24
+ *
25
+ * @internal
26
+ */
27
+ export function withNestedAciRootProps(props) {
28
+ return stampNested(props);
29
+ }
30
+ function stampNested(value) {
31
+ if (Array.isArray(value))
32
+ return value.map(stampNested);
33
+ if (!isPlainObject(value))
34
+ return value;
35
+ const out = {};
36
+ for (const [key, child] of Object.entries(value))
37
+ out[key] = stampNested(child);
38
+ if (isComponentBlock(out) && typeof out.id === 'string' && out.id !== ''
39
+ && typeof out.component === 'string' && isPlainObject(out.props)) {
40
+ out.props = withAciRootProps(out.props, { id: out.id, component: out.component });
41
+ }
42
+ return out;
43
+ }
44
+ function isPlainObject(value) {
45
+ if (typeof value !== 'object' || value === null)
46
+ return false;
47
+ const proto = Object.getPrototypeOf(value);
48
+ return proto === Object.prototype || proto === null;
49
+ }
@@ -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
- // Authorable error pages (D-9): the Next error seat is client-only, so the
17
- // bound catchAll page's content is serialized here — while next.config is
18
- // evaluated — and injected into the bundle via DefinePlugin below. The D-5
19
- // posture rides ACI_ERROR_PAGES_POSTURE (the same variable the Go stack
20
- // reads): under warn (default), absent bindings/content and non-REQ-5
21
- // serialization failures warn and serialize the shipped default (REQ-4);
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
- * Build-time serialization of the bound catchAll page for the client-only
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 the content compile before `next build` (the template build script\'s '
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
  }
@@ -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
- const componentProps = { ...block.props };
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}`);
@@ -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
- // sdk I-19 (ADR 0051): root identity props are applied last so neither
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,2 @@
1
+ import type { LoadedSite } from './types.js';
2
+ export declare function openAstroSite(siteDir: string, explicit?: readonly string[]): Promise<LoadedSite>;
@@ -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, '&amp;').replace(/"/g, '&quot;');
49
+ return [astro, astro.replace(/'/g, '&#x27;').replace(/</g, '&lt;').replace(/>/g, '&gt;')];
50
+ }
@@ -0,0 +1,2 @@
1
+ /** A props object the schema accepts, or undefined when it cannot be derived. */
2
+ export declare function samplePropsFromSchema(schema: unknown): Record<string, unknown> | undefined;