Skip to content

Commit 9a77e00

Browse files
hotlongclaude
andcommitted
feat(sdui-parser): port the html-tier tier stamp from objectui's lockstep copy
objectui's sdui-parser, at the console pin dd3f7e1be356, admits `tier: 'html'` on RegistryConfigLike, declares ManifestComponent.tier, has manifestFromConfigs write exactly `'html'` or omit the key, and has generateBlockList section the html tier under its own count. This copy still declared `'public' | 'internal'` and dropped the key, so the tracked manifest serialised through it carried the html tier's intrinsic tags with no marker. The three hunks are copied byte-for-byte from the pin; the tests are objectui's own cases for the same mechanism. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
1 parent f11b5f2 commit 9a77e00

4 files changed

Lines changed: 188 additions & 13 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* The manifest carries the html tier's intrinsic elements marked
3+
* `tier: 'html'` — the objectui#10735 mechanism, ported into this copy in
4+
* lockstep (objectstack#20112).
5+
*
6+
* objectui's `getPublicConfigs()` stamps the html tier's registered intrinsic
7+
* set `tier: 'html'` in its projection, and the tracked repo-root
8+
* `sdui.manifest.json` is serialised through THIS copy's `manifestFromConfigs`
9+
* (`scripts/gen-sdui-manifest-node.mjs`). Before the port this copy's
10+
* `RegistryConfigLike.tier` admitted only `'public' | 'internal'` and the
11+
* serializer dropped the key, so the manifest declared the 47 tags with no
12+
* marker at all. `check:sdui-lockstep` compares diagnostic codes, the grammar
13+
* region and the containment predicate — never a type union — so nothing
14+
* surfaced the drift.
15+
*
16+
* The cases below are the ones objectui pins in its own
17+
* `html-tier-manifest-10735.test.ts`, one per reader of the ONE flat
18+
* `components` map:
19+
*
20+
* - `manifestFromConfigs` copies exactly `'html'` and nothing else, so every
21+
* curated entry serialises byte-identically to before the key existed;
22+
* - `compile` whitelists the html tags by key — the gate that reads the
23+
* manifest needs no change — and still refuses `<div>` because the roster
24+
* leaves it out;
25+
* - `generateBlockList` sections the html tier under its own count instead
26+
* of reading it as curated blocks;
27+
* - `generateDts` emits the tags into `JSX.IntrinsicElements`, which is the
28+
* authoring surface a `kind:'html'` page type-checks against.
29+
*/
30+
31+
import { describe, expect, it } from 'vitest';
32+
import { compile, generateBlockList, generateDts, manifestFromConfigs, type RegistryConfigLike } from '../index.js';
33+
34+
const configs: RegistryConfigLike[] = [
35+
{ type: 'flex', namespace: 'ui', isContainer: true, inputs: [{ name: 'gap', type: 'number' }, { name: 'children', type: 'slot' }] },
36+
{ type: 'my-widget', namespace: 'x', tier: 'public', inputs: [{ name: 'label', type: 'string' }] },
37+
{ type: 'studio-admin', namespace: 'app-shell', tier: 'internal', inputs: [] },
38+
{ type: 'p', namespace: 'ui', tier: 'html', inputs: [{ name: 'className', type: 'string' }, { name: 'children', type: 'slot' }] },
39+
{ type: 'a', namespace: 'ui', tier: 'html', inputs: [{ name: 'className', type: 'string' }, { name: 'href', type: 'string' }, { name: 'children', type: 'slot' }] },
40+
{ type: 'img', namespace: 'ui', tier: 'html', inputs: [{ name: 'src', type: 'string' }, { name: 'alt', type: 'string' }] },
41+
{ type: 'main', namespace: 'ui', tier: 'html', isContainer: true, inputs: [{ name: 'className', type: 'string' }, { name: 'children', type: 'slot' }] },
42+
];
43+
44+
const manifest = manifestFromConfigs(configs);
45+
/** What a consumer reads back off disk — `JSON.stringify` drops every `undefined`. */
46+
const published = JSON.parse(JSON.stringify(manifest)) as typeof manifest;
47+
48+
describe('manifestFromConfigs carries the html-tier stamp, and only that stamp', () => {
49+
it('writes `tier: html` on a stamped config', () => {
50+
for (const tag of ['p', 'a', 'img', 'main']) {
51+
expect(published.components[tag].tier, `${tag} lost its stamp`).toBe('html');
52+
}
53+
});
54+
55+
it('publishes NO `tier` key for a curated, an opted-in or an internal config — byte-identical to before', () => {
56+
for (const tag of ['flex', 'my-widget', 'studio-admin']) {
57+
expect(Object.prototype.hasOwnProperty.call(published.components[tag], 'tier'), `${tag} grew a tier key`).toBe(false);
58+
}
59+
});
60+
61+
it('keeps every other field exactly as the registration declared it', () => {
62+
expect(published.components.a.inputs.map((i) => i.name)).toEqual(['className', 'href', 'children']);
63+
expect(published.components.main.isContainer).toBe(true);
64+
expect(published.components.img.inputs.map((i) => i.name)).toEqual(['src', 'alt']);
65+
});
66+
67+
it('`publicOnly` keeps the curated tier alone — the html tier is not `public`', () => {
68+
expect(Object.keys(manifestFromConfigs(configs, { publicOnly: true }).components)).toEqual(['my-widget']);
69+
});
70+
});
71+
72+
describe('compile reads the manifest as a whitelist — the stamp changes nothing for that reader', () => {
73+
it('accepts an html-tier tag with its declared inputs and child slot', () => {
74+
const r = compile('<main className="x"><p className="lead">Hello <a href="/docs">docs</a></p><img src="/x.png" alt="x" /></main>', published);
75+
expect(r.diagnostics.filter((d) => d.severity === 'error')).toEqual([]);
76+
expect(r.ok).toBe(true);
77+
expect(r.tree?.type).toBe('main');
78+
});
79+
80+
it('refuses `<div>` — the roster leaves it out, so it is `forbidden-tag` like any unknown tag', () => {
81+
const r = compile('<main><div>hi</div></main>', published);
82+
expect(r.ok).toBe(false);
83+
expect(r.diagnostics).toEqual(
84+
expect.arrayContaining([expect.objectContaining({ severity: 'error', code: 'forbidden-tag', tag: 'div' })]),
85+
);
86+
// The lit control: the same source with `p` in place of `div` is clean.
87+
expect(compile('<main><p>hi</p></main>', published).ok).toBe(true);
88+
});
89+
90+
it('warns `not-a-container` on a void tag authored with children — the slot is declared per tag, honestly', () => {
91+
const r = compile('<main><img src="/x.png" alt="x">stray</img></main>', published);
92+
expect(r.diagnostics).toEqual(
93+
expect.arrayContaining([expect.objectContaining({ severity: 'warning', code: 'not-a-container', tag: 'img' })]),
94+
);
95+
});
96+
});
97+
98+
describe('generateBlockList sections the html tier under its own count', () => {
99+
it('counts curated blocks in the title and lists the html tier separately', () => {
100+
const list = generateBlockList(published);
101+
// Curated title counts `flex`, `my-widget`, `studio-admin` — not the four html-tier rows.
102+
expect(list).toContain('# SDUI public blocks (3)');
103+
expect(list).toContain('## html tier intrinsic elements (4)');
104+
// Rows land in the right section: the html rows come AFTER the section heading.
105+
const heading = list.indexOf('## html tier intrinsic elements');
106+
for (const tag of ['p', 'a', 'img', 'main']) {
107+
expect(list.indexOf(`| \`${tag}\` |`), `${tag} row is missing or above the html section`).toBeGreaterThan(heading);
108+
}
109+
for (const tag of ['flex', 'my-widget']) {
110+
expect(list.indexOf(`| \`${tag}\` |`), `${tag} row landed in the html section`).toBeLessThan(heading);
111+
}
112+
});
113+
114+
it('emits no html section for a manifest without html-tier entries — the curated list is unchanged', () => {
115+
const curatedOnly = manifestFromConfigs(configs.filter((c) => c.tier !== 'html'));
116+
const list = generateBlockList(curatedOnly);
117+
expect(list).toContain('# SDUI public blocks (3)');
118+
expect(list).not.toContain('html tier intrinsic elements');
119+
});
120+
});
121+
122+
describe('generateDts publishes the html tier into JSX.IntrinsicElements', () => {
123+
it('emits an interface and an intrinsic entry per html-tier tag, beside the curated ones', () => {
124+
const dts = generateDts(published);
125+
expect(dts).toContain('"p": PProps;');
126+
expect(dts).toContain('"a": AProps;');
127+
expect(dts).toContain('export interface AProps extends SduiBaseProps {\n className?: string;\n href?: string;\n}');
128+
expect(dts).toContain('"flex": FlexProps;');
129+
});
130+
});

‎packages/sdui-parser/src/codegen.ts‎

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -165,23 +165,40 @@ export function propsName(type: string): string {
165165
/**
166166
* Generate the human-facing PUBLIC block list (the curated "清单") from a
167167
* manifest — a Markdown table. Derived, never hand-maintained (ADR-0046).
168+
*
169+
* The html tier's intrinsic elements (`tier: 'html'`, objectui#10735) are
170+
* listed in a section of their own, under their own count: they are in the
171+
* manifest because a `kind:'html'` page may author them, not because they are
172+
* curated blocks, and a single table would read them as the latter.
168173
*/
169174
export function generateBlockList(manifest: Manifest): string {
170-
const rows = Object.values(manifest.components)
171-
.sort((a, b) => a.type.localeCompare(b.type))
172-
.map((c) => {
173-
const req = c.inputs.filter((i) => i.required).map((i) => i.name);
174-
const binds = c.inputs.filter((i) => i.binding).map((i) => `${i.name}:${i.binding}`);
175-
return `| \`${c.type}\` | ${c.namespace ?? '—'} | ${c.isContainer ? '✓' : ''} | ${req.join(', ') || '—'} | ${binds.join(', ') || '—'} |`;
176-
});
175+
const sorted = Object.values(manifest.components).sort((a, b) => a.type.localeCompare(b.type));
176+
const curated = sorted.filter((c) => c.tier !== 'html');
177+
const htmlTier = sorted.filter((c) => c.tier === 'html');
178+
const row = (c: ManifestComponent): string => {
179+
const req = c.inputs.filter((i) => i.required).map((i) => i.name);
180+
const binds = c.inputs.filter((i) => i.binding).map((i) => `${i.name}:${i.binding}`);
181+
return `| \`${c.type}\` | ${c.namespace ?? '—'} | ${c.isContainer ? '✓' : ''} | ${req.join(', ') || '—'} | ${binds.join(', ') || '—'} |`;
182+
};
183+
const header = ['| block | plugin | container | required props | bindings |', '|---|---|---|---|---|'];
177184
return [
178-
`# SDUI public blocks (${Object.keys(manifest.components).length})`,
185+
`# SDUI public blocks (${curated.length})`,
179186
'',
180187
'> Auto-generated from the registry `tier:\'public\'` set (ADR-0080). Do not edit by hand.',
181188
'',
182-
'| block | plugin | container | required props | bindings |',
183-
'|---|---|---|---|---|',
184-
...rows,
189+
...header,
190+
...curated.map(row),
191+
...(htmlTier.length
192+
? [
193+
'',
194+
`## html tier intrinsic elements (${htmlTier.length})`,
195+
'',
196+
"> The intrinsic HTML tags a `kind:'html'` page may author (ADR-0081 §2), declared by the registry's `HTML_TIER_INTRINSICS` roster and marked `tier: 'html'` in the manifest. Not part of the curated vocabulary above.",
197+
'',
198+
...header,
199+
...htmlTier.map(row),
200+
]
201+
: []),
185202
'',
186203
].join('\n');
187204
}

‎packages/sdui-parser/src/index.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,14 @@ export interface RegistryConfigLike {
6868
type: string;
6969
namespace?: string;
7070
isContainer?: boolean;
71-
/** ADR-0080 contract tier — only 'public' configs form the AI/contract surface. */
72-
tier?: 'public' | 'internal';
71+
/**
72+
* ADR-0080 contract tier — only 'public' configs form the AI/contract
73+
* surface under `publicOnly`. `'html'` is the projection-only stamp
74+
* `ComponentRegistry.getPublicConfigs()` puts on the html tier's intrinsic
75+
* elements (objectui#10735); it is the one value {@link manifestFromConfigs}
76+
* carries into `ManifestComponent.tier`.
77+
*/
78+
tier?: 'public' | 'internal' | 'html';
7379
label?: string;
7480
category?: string;
7581
inputs?: Array<{
@@ -131,6 +137,11 @@ export function manifestFromConfigs(
131137
type: c.type,
132138
namespace: c.namespace,
133139
isContainer: c.isContainer,
140+
// The html tier's stamp, and ONLY that stamp (objectui#10735): a
141+
// registration's `'public'` / `'internal'` is registry mechanics the
142+
// manifest never carried, and `undefined` is dropped by `JSON.stringify`,
143+
// so every curated entry serialises exactly as before this key existed.
144+
tier: c.tier === 'html' ? 'html' : undefined,
134145
inputs: (c.inputs ?? []).map((i) => ({
135146
name: i.name,
136147
type: canonicalizeInputType(i.type),

‎packages/sdui-parser/src/types.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,23 @@ export interface ManifestComponent {
172172
* ⛔ Not "accepts children" — this tier's containment check does not read it.
173173
*/
174174
isContainer?: boolean;
175+
/**
176+
* The authoring tier this entry belongs to (objectui#10735).
177+
*
178+
* Absent = the curated PUBLIC tier (ADR-0080 §4), the JSON-surface
179+
* AI-authoring vocabulary. `'html'` = one of the intrinsic HTML elements a
180+
* `kind:'html'` page may author (ADR-0081 §2), declared by the registry's
181+
* `HTML_TIER_INTRINSICS` roster and carried here so the ONE flat `components`
182+
* map can serve both of its readers: a whitelist reader (`compile`, the
183+
* objectstack gate) takes every key and needs no change; a reader that means
184+
* the curated vocabulary — the `kind:'react'` scope, the block list, a census
185+
* of curated blocks — filters this key out.
186+
*
187+
* ⛔ Not the registration's `tier` copied through. `manifestFromConfigs`
188+
* writes exactly `'html'` or nothing, so every entry published before this
189+
* key existed serialises byte-identically.
190+
*/
191+
tier?: 'html';
175192
}
176193

177194
export interface Manifest {

0 commit comments

Comments
 (0)