Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/20112-sdui-parser-html-tier-stamp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/sdui-parser': minor
'@objectstack/console': minor
---

The SDUI manifest now marks the html tier's intrinsic tags `tier: 'html'`, and this copy of the parser carries that marker the way the renderer's copy does.

objectui's copy of `packages/sdui-parser` gained the marker when its registry started declaring the intrinsic HTML tags a `kind:'html'` page may author (`h1`–`h6`, `p`, `a`, `code`, `span`, `table`, the sectioning tags and the rest of the roster; never `div`). This copy still declared only `'public' | 'internal'` and dropped the key. The repo-root `sdui.manifest.json` is serialised through this copy, and `@objectstack/console` ships it in its `dist`, so the published manifest listed those tags with no marker. A reader could not tell them from curated blocks. The port is byte-faithful to objectui at the console pin `dd3f7e1be356`:

- `RegistryConfigLike.tier` accepts `'html'`, the stamp objectui's `getPublicConfigs()` puts on the roster in its projection.
- `ManifestComponent.tier?: 'html'` is new. `manifestFromConfigs` writes exactly `'html'` or omits the key, so every other entry serialises byte-identically to before.
- `generateBlockList` counts only curated blocks in its title and lists the html tier in a section of its own.

**What moved in the published manifest:** `"tier": "html"` on 48 entries, and nothing else. It still has the same 107 components in the same order, and no other field on any entry changed.

**What did not move:** `compile()` and `validateTree()` read the manifest as a whitelist of keys and never read `tier`. So no page's verdict changes. Measured on the three shipped `kind:'html'` pages of `examples/app-showcase`: the full `compile()` result is identical against the old and the new manifest.
130 changes: 130 additions & 0 deletions packages/sdui-parser/src/__tests__/html-tier-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* The manifest carries the html tier's intrinsic elements marked
* `tier: 'html'` — the objectui#10735 mechanism, ported into this copy in
* lockstep (objectstack#20112).
*
* objectui's `getPublicConfigs()` stamps the html tier's registered intrinsic
* set `tier: 'html'` in its projection, and the tracked repo-root
* `sdui.manifest.json` is serialised through THIS copy's `manifestFromConfigs`
* (`scripts/gen-sdui-manifest-node.mjs`). Before the port this copy's
* `RegistryConfigLike.tier` admitted only `'public' | 'internal'` and the
* serializer dropped the key, so the manifest declared the html tier's tags
* with no marker at all. `check:sdui-lockstep` compares diagnostic codes, the grammar
* region and the containment predicate — never a type union — so nothing
* surfaced the drift.
*
* The cases below are the ones objectui pins in its own
* `html-tier-manifest-10735.test.ts`, one per reader of the ONE flat
* `components` map:
*
* - `manifestFromConfigs` copies exactly `'html'` and nothing else, so every
* curated entry serialises byte-identically to before the key existed;
* - `compile` whitelists the html tags by key — the gate that reads the
* manifest needs no change — and still refuses `<div>` because the roster
* leaves it out;
* - `generateBlockList` sections the html tier under its own count instead
* of reading it as curated blocks;
* - `generateDts` emits the tags into `JSX.IntrinsicElements`, which is the
* authoring surface a `kind:'html'` page type-checks against.
*/

import { describe, expect, it } from 'vitest';
import { compile, generateBlockList, generateDts, manifestFromConfigs, type RegistryConfigLike } from '../index.js';

const configs: RegistryConfigLike[] = [
{ type: 'flex', namespace: 'ui', isContainer: true, inputs: [{ name: 'gap', type: 'number' }, { name: 'children', type: 'slot' }] },
{ type: 'my-widget', namespace: 'x', tier: 'public', inputs: [{ name: 'label', type: 'string' }] },
{ type: 'studio-admin', namespace: 'app-shell', tier: 'internal', inputs: [] },
{ type: 'p', namespace: 'ui', tier: 'html', inputs: [{ name: 'className', type: 'string' }, { name: 'children', type: 'slot' }] },
{ type: 'a', namespace: 'ui', tier: 'html', inputs: [{ name: 'className', type: 'string' }, { name: 'href', type: 'string' }, { name: 'children', type: 'slot' }] },
{ type: 'img', namespace: 'ui', tier: 'html', inputs: [{ name: 'src', type: 'string' }, { name: 'alt', type: 'string' }] },
{ type: 'main', namespace: 'ui', tier: 'html', isContainer: true, inputs: [{ name: 'className', type: 'string' }, { name: 'children', type: 'slot' }] },
];

const manifest = manifestFromConfigs(configs);
/** What a consumer reads back off disk — `JSON.stringify` drops every `undefined`. */
const published = JSON.parse(JSON.stringify(manifest)) as typeof manifest;

describe('manifestFromConfigs carries the html-tier stamp, and only that stamp', () => {
it('writes `tier: html` on a stamped config', () => {
for (const tag of ['p', 'a', 'img', 'main']) {
expect(published.components[tag].tier, `${tag} lost its stamp`).toBe('html');
}
});

it('publishes NO `tier` key for a curated, an opted-in or an internal config — byte-identical to before', () => {
for (const tag of ['flex', 'my-widget', 'studio-admin']) {
expect(Object.prototype.hasOwnProperty.call(published.components[tag], 'tier'), `${tag} grew a tier key`).toBe(false);
}
});

it('keeps every other field exactly as the registration declared it', () => {
expect(published.components.a.inputs.map((i) => i.name)).toEqual(['className', 'href', 'children']);
expect(published.components.main.isContainer).toBe(true);
expect(published.components.img.inputs.map((i) => i.name)).toEqual(['src', 'alt']);
});

it('`publicOnly` keeps the curated tier alone — the html tier is not `public`', () => {
expect(Object.keys(manifestFromConfigs(configs, { publicOnly: true }).components)).toEqual(['my-widget']);
});
});

describe('compile reads the manifest as a whitelist — the stamp changes nothing for that reader', () => {
it('accepts an html-tier tag with its declared inputs and child slot', () => {
const r = compile('<main className="x"><p className="lead">Hello <a href="/docs">docs</a></p><img src="/x.png" alt="x" /></main>', published);
expect(r.diagnostics.filter((d) => d.severity === 'error')).toEqual([]);
expect(r.ok).toBe(true);
expect(r.tree?.type).toBe('main');
});

it('refuses `<div>` — the roster leaves it out, so it is `forbidden-tag` like any unknown tag', () => {
const r = compile('<main><div>hi</div></main>', published);
expect(r.ok).toBe(false);
expect(r.diagnostics).toEqual(
expect.arrayContaining([expect.objectContaining({ severity: 'error', code: 'forbidden-tag', tag: 'div' })]),
);
// The lit control: the same source with `p` in place of `div` is clean.
expect(compile('<main><p>hi</p></main>', published).ok).toBe(true);
});

it('warns `not-a-container` on a void tag authored with children — the slot is declared per tag, honestly', () => {
const r = compile('<main><img src="/x.png" alt="x">stray</img></main>', published);
expect(r.diagnostics).toEqual(
expect.arrayContaining([expect.objectContaining({ severity: 'warning', code: 'not-a-container', tag: 'img' })]),
);
});
});

describe('generateBlockList sections the html tier under its own count', () => {
it('counts curated blocks in the title and lists the html tier separately', () => {
const list = generateBlockList(published);
// Curated title counts `flex`, `my-widget`, `studio-admin` — not the four html-tier rows.
expect(list).toContain('# SDUI public blocks (3)');
expect(list).toContain('## html tier intrinsic elements (4)');
// Rows land in the right section: the html rows come AFTER the section heading.
const heading = list.indexOf('## html tier intrinsic elements');
for (const tag of ['p', 'a', 'img', 'main']) {
expect(list.indexOf(`| \`${tag}\` |`), `${tag} row is missing or above the html section`).toBeGreaterThan(heading);
}
for (const tag of ['flex', 'my-widget']) {
expect(list.indexOf(`| \`${tag}\` |`), `${tag} row landed in the html section`).toBeLessThan(heading);
}
});

it('emits no html section for a manifest without html-tier entries — the curated list is unchanged', () => {
const curatedOnly = manifestFromConfigs(configs.filter((c) => c.tier !== 'html'));
const list = generateBlockList(curatedOnly);
expect(list).toContain('# SDUI public blocks (3)');
expect(list).not.toContain('html tier intrinsic elements');
});
});

describe('generateDts publishes the html tier into JSX.IntrinsicElements', () => {
it('emits an interface and an intrinsic entry per html-tier tag, beside the curated ones', () => {
const dts = generateDts(published);
expect(dts).toContain('"p": PProps;');
expect(dts).toContain('"a": AProps;');
expect(dts).toContain('export interface AProps extends SduiBaseProps {\n className?: string;\n href?: string;\n}');
expect(dts).toContain('"flex": FlexProps;');
});
});
39 changes: 28 additions & 11 deletions packages/sdui-parser/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,23 +165,40 @@ export function propsName(type: string): string {
/**
* Generate the human-facing PUBLIC block list (the curated "清单") from a
* manifest — a Markdown table. Derived, never hand-maintained (ADR-0046).
*
* The html tier's intrinsic elements (`tier: 'html'`, objectui#10735) are
* listed in a section of their own, under their own count: they are in the
* manifest because a `kind:'html'` page may author them, not because they are
* curated blocks, and a single table would read them as the latter.
*/
export function generateBlockList(manifest: Manifest): string {
const rows = Object.values(manifest.components)
.sort((a, b) => a.type.localeCompare(b.type))
.map((c) => {
const req = c.inputs.filter((i) => i.required).map((i) => i.name);
const binds = c.inputs.filter((i) => i.binding).map((i) => `${i.name}:${i.binding}`);
return `| \`${c.type}\` | ${c.namespace ?? '—'} | ${c.isContainer ? '✓' : ''} | ${req.join(', ') || '—'} | ${binds.join(', ') || '—'} |`;
});
const sorted = Object.values(manifest.components).sort((a, b) => a.type.localeCompare(b.type));
const curated = sorted.filter((c) => c.tier !== 'html');
const htmlTier = sorted.filter((c) => c.tier === 'html');
const row = (c: ManifestComponent): string => {
const req = c.inputs.filter((i) => i.required).map((i) => i.name);
const binds = c.inputs.filter((i) => i.binding).map((i) => `${i.name}:${i.binding}`);
return `| \`${c.type}\` | ${c.namespace ?? '—'} | ${c.isContainer ? '✓' : ''} | ${req.join(', ') || '—'} | ${binds.join(', ') || '—'} |`;
};
const header = ['| block | plugin | container | required props | bindings |', '|---|---|---|---|---|'];
return [
`# SDUI public blocks (${Object.keys(manifest.components).length})`,
`# SDUI public blocks (${curated.length})`,
'',
'> Auto-generated from the registry `tier:\'public\'` set (ADR-0080). Do not edit by hand.',
'',
'| block | plugin | container | required props | bindings |',
'|---|---|---|---|---|',
...rows,
...header,
...curated.map(row),
...(htmlTier.length
? [
'',
`## html tier intrinsic elements (${htmlTier.length})`,
'',
"> 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.",
'',
...header,
...htmlTier.map(row),
]
: []),
'',
].join('\n');
}
15 changes: 13 additions & 2 deletions packages/sdui-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,14 @@ export interface RegistryConfigLike {
type: string;
namespace?: string;
isContainer?: boolean;
/** ADR-0080 contract tier — only 'public' configs form the AI/contract surface. */
tier?: 'public' | 'internal';
/**
* ADR-0080 contract tier — only 'public' configs form the AI/contract
* surface under `publicOnly`. `'html'` is the projection-only stamp
* `ComponentRegistry.getPublicConfigs()` puts on the html tier's intrinsic
* elements (objectui#10735); it is the one value {@link manifestFromConfigs}
* carries into `ManifestComponent.tier`.
*/
tier?: 'public' | 'internal' | 'html';
label?: string;
category?: string;
inputs?: Array<{
Expand Down Expand Up @@ -131,6 +137,11 @@ export function manifestFromConfigs(
type: c.type,
namespace: c.namespace,
isContainer: c.isContainer,
// The html tier's stamp, and ONLY that stamp (objectui#10735): a
// registration's `'public'` / `'internal'` is registry mechanics the
// manifest never carried, and `undefined` is dropped by `JSON.stringify`,
// so every curated entry serialises exactly as before this key existed.
tier: c.tier === 'html' ? 'html' : undefined,
inputs: (c.inputs ?? []).map((i) => ({
name: i.name,
type: canonicalizeInputType(i.type),
Expand Down
17 changes: 17 additions & 0 deletions packages/sdui-parser/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,23 @@ export interface ManifestComponent {
* ⛔ Not "accepts children" — this tier's containment check does not read it.
*/
isContainer?: boolean;
/**
* The authoring tier this entry belongs to (objectui#10735).
*
* Absent = the curated PUBLIC tier (ADR-0080 §4), the JSON-surface
* AI-authoring vocabulary. `'html'` = one of the intrinsic HTML elements a
* `kind:'html'` page may author (ADR-0081 §2), declared by the registry's
* `HTML_TIER_INTRINSICS` roster and carried here so the ONE flat `components`
* map can serve both of its readers: a whitelist reader (`compile`, the
* objectstack gate) takes every key and needs no change; a reader that means
* the curated vocabulary — the `kind:'react'` scope, the block list, a census
* of curated blocks — filters this key out.
*
* ⛔ Not the registration's `tier` copied through. `manifestFromConfigs`
* writes exactly `'html'` or nothing, so every entry published before this
* key existed serialises byte-identically.
*/
tier?: 'html';
}

export interface Manifest {
Expand Down
4 changes: 2 additions & 2 deletions scripts/sdui-manifest.record.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"modulesRoot": ".cache/objectui-dd3f7e1be356/apps/console",
"objectuiWorkspaceVersion": "17.6.0",
"generator": "scripts/gen-sdui-manifest-node.mjs",
"generatedAt": "2026-09-28",
"sha256": "4073897dcdc1cd68966f12f1868d37c2c25a750ea044760ce58e181fce6f21bc",
"generatedAt": "2026-09-29",
"sha256": "d0666ac585db8fa974471d9ebf33b906be776d77a170e7e5ecac774095d7b26a",
"components": 107
}
Loading
Loading