From e8300aa13f6b45956375480e422d681db7d69627 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 04:38:34 +0000 Subject: [PATCH 1/3] feat(core,sdui-parser): declare the html tier's intrinsic elements in the manifest, marked tier html The published sdui.manifest.json is getPublicConfigs() serialised, and that read was the curated PUBLIC_BLOCKS vocabulary alone, so the objectstack gate that whitelists a kind:'html' page's tags from it refused every plain HTML tag the renderer accepts. Add a second roster, HTML_TIER_INTRINSICS (the html-elements.tsx TAGS, span, table, label and the seven sectioning tags), which getPublicConfigs() appends stamped tier:'html'; manifestFromConfigs carries exactly that stamp into a new optional ManifestComponent.tier, so a whitelist reader takes the tags by key unchanged while a reader that means the curated vocabulary filters on the stamp: generateBlockList sections the html tier under its own count and the kind:'react' JSX scope skips stamped entries. div stays undeclared (deprecated in favour of box); code (the field:code widget's bare fallback) and kbd are ledgered with reasons. PUBLIC_BLOCKS and html-elements.tsx are untouched; curated entries serialise byte-identically. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10735-html-tier-manifest.md | 49 +++ .../src/__tests__/html-tier-manifest.test.ts | 281 ++++++++++++++++++ .../src/__tests__/public-contract.test.ts | 15 +- content/docs/guide/plugin-development.md | 2 +- content/docs/guide/react-pages.md | 5 +- .../src/__tests__/react-page-scope.test.tsx | 29 ++ .../container-declaration-census.test.tsx | 30 +- .../src/renderers/layout/react-page.tsx | 10 +- packages/core/src/index.ts | 1 + packages/core/src/registry/Registry.ts | 39 ++- .../__tests__/html-tier-intrinsics.test.ts | 153 ++++++++++ .../core/src/registry/html-tier-intrinsics.ts | 106 +++++++ packages/core/src/registry/public-blocks.ts | 8 + packages/sdui-parser/scripts/gen-manifest.ts | 10 +- .../html-tier-manifest-10735.test.ts | 116 ++++++++ packages/sdui-parser/src/codegen.ts | 39 ++- packages/sdui-parser/src/index.ts | 15 +- packages/sdui-parser/src/types.ts | 17 ++ 18 files changed, 892 insertions(+), 33 deletions(-) create mode 100644 .changeset/10735-html-tier-manifest.md create mode 100644 apps/console/src/__tests__/html-tier-manifest.test.ts create mode 100644 packages/core/src/registry/__tests__/html-tier-intrinsics.test.ts create mode 100644 packages/core/src/registry/html-tier-intrinsics.ts create mode 100644 packages/sdui-parser/src/__tests__/html-tier-manifest-10735.test.ts diff --git a/.changeset/10735-html-tier-manifest.md b/.changeset/10735-html-tier-manifest.md new file mode 100644 index 0000000000..48e88b9a44 --- /dev/null +++ b/.changeset/10735-html-tier-manifest.md @@ -0,0 +1,49 @@ +--- +'@object-ui/core': minor +'@object-ui/sdui-parser': minor +'@object-ui/components': minor +--- + +feat(core,sdui-parser): the published manifest declares the html tier's registered intrinsic elements, marked `tier: 'html'` — `div` stays out + +A `kind:'html'` page may author the everyday HTML tags its renderer registers — +the flow/inline set of `html-elements.tsx` (h1–h6, p, a, lists, emphasis, +figure/img/hr/br, time, address, cite, q), `span`, `table`, `label` and the seven +sectioning tags — and the console has rendered such pages all along. The +published `sdui.manifest.json` never said so: it was the curated `PUBLIC_BLOCKS` +vocabulary alone, so the objectstack CLI gate, which whitelists an html page's +tags from that manifest, refused every plain HTML tag as `forbidden-tag` on +pages the renderer accepts (200 ledgered rows over the three shipped showcase +pages). The maintainer ruled A on objectstack#20112: the producer declares the +set, exactly as the registry declares it, and `div` stays deprecated. + +**What moves for a consumer.** `ComponentRegistry.getPublicConfigs()` — the +read both manifest producers serialise — now returns the curated tier AND the +html tier's intrinsic elements, the latter from the new `HTML_TIER_INTRINSICS` +roster (`@object-ui/core`) and each stamped `tier: 'html'` in the projection. +`manifestFromConfigs` carries that one value into a new optional +`ManifestComponent.tier` (`@object-ui/sdui-parser`), so the regenerated +`sdui.manifest.json` grows by the roster (47 entries, each with its registered +`inputs` and its child slot declared exactly where the registration renders a +child list — the void tags `img` / `hr` / `br` declare none) and +`sdui-intrinsics.d.ts` types them. A reader that whitelists tags by key — the +objectstack gate — needs no change and accepts the tags. A reader that means the +CURATED vocabulary filters on the stamp: `generateBlockList` now sections the +html tier under its own count, and the `kind:'react'` JSX scope skips stamped +entries (`@object-ui/components`, `react-page.tsx`), so no `P` / `A` / `Img` +wrapper is injected into react pages — on that tier a lowercase `

` is React's +own element. + +**What does not move.** `PUBLIC_BLOCKS` is unchanged and the two rosters are +pinned disjoint; every curated manifest entry serialises byte-identically +(`tier` is written as exactly `'html'` or omitted). `div` is not declared: the +gate keeps refusing `

` on an html page, and `box` remains the JSON +surface's replacement. `code` is not declared either — the bare `code` key is +the `field:code` widget's namespace fallback, not an element renderer — nor is +`kbd`; both are recorded, with reasons, in the console's exclusion ledger. +No registration changes; `html-elements.tsx` is untouched. + +The manifest the framework ships regenerates from objectui's built tree at the +pin (its `gen-sdui-manifest-node.mjs`); its lockstep copy of `manifestFromConfigs` +must take this port for the stamp to reach that file — until then the tags are +declared there without the marker, which the gate treats identically. diff --git a/apps/console/src/__tests__/html-tier-manifest.test.ts b/apps/console/src/__tests__/html-tier-manifest.test.ts new file mode 100644 index 0000000000..5c0b21957b --- /dev/null +++ b/apps/console/src/__tests__/html-tier-manifest.test.ts @@ -0,0 +1,281 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10735 — the published manifest declares the html tier's registered + * intrinsic elements, exactly as the registry declares them, and `div` stays out + * (the maintainer's ruling A on objectstack#20112). + * + * Built the way both manifest producers build it — `getPublicConfigs()` through + * `manifestFromConfigs`, over the console's REAL registration graph + * (`@object-ui/components` plus `src/register-plugins.ts`, the pair + * `dev/manifest-dump.tsx` loads) — so what is asserted here is what + * `sdui.manifest.json` will carry, not what a fixture was shaped to say. + * + * Three things are pinned: + * + * 1. COVERAGE — every roster tag reaches the manifest stamped `tier: 'html'`, + * with its registered `inputs` and its child slot declared exactly where + * the registration declares it (the void tags declare none); + * 2. THE CURATED TIER IS UNMOVED — the entries WITHOUT the stamp are exactly + * the registered `PUBLIC_BLOCKS`, so the JSON-surface vocabulary did not + * grow by a single tag; + * 3. THE CENSUS — every bare registration whose key is an HTML element name + * is on one of the two rosters or in the exclusion ledger below, WITH a + * reason. The population is derived from `@types/react`'s + * `JSX.IntrinsicElements` (the one machine-readable list of element names + * this repo already depends on), so a registration that newly squats on a + * tag name lands here by absence rather than by memory. + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { HtmlElementSchema, SemanticElementSchema } from '@object-ui/types/zod'; +import { compile, generateDts, manifestFromConfigs, type RegistryConfigLike } from '@object-ui/sdui-parser'; +import { + ComponentRegistry, + HTML_TIER_INTRINSICS, + HTML_TIER_INTRINSIC_SET, + PUBLIC_BLOCKS, + PUBLIC_BLOCK_SET, +} from '@object-ui/core'; +// The layout/content primitives (Tier B) and every html-tier renderer … +import '@object-ui/components'; +// … and the console's own plugin layer, from the module main.tsx boots from. +import '../register-plugins'; + +/** Exactly the generators' read (`dev/manifest-dump.tsx`, `sdui-parser/scripts/gen-manifest.ts`). */ +const manifest = manifestFromConfigs(ComponentRegistry.getPublicConfigs() as unknown as RegistryConfigLike[]); +/** What a consumer reads back off disk — `JSON.stringify` drops every `undefined`. */ +const published = JSON.parse(JSON.stringify(manifest)) as typeof manifest; +const entries = Object.values(published.components); + +const inputNames = (tag: string): string[] => (ComponentRegistry.getMeta(tag)?.inputs ?? []).map((i) => i.name); +const declaresSlot = (tag: string): boolean => + (ComponentRegistry.getMeta(tag)?.inputs ?? []).some((i) => i.name === 'children'); + +/** + * The tags `html-elements.tsx` renders WITHOUT a child list (`VOID_TAGS` + * there) — asserted against the registration rather than restated, below. + */ +const VOID_TAGS = ['img', 'hr', 'br']; + +/** + * HTML-named bare registrations that are on NEITHER roster, each with the + * reason. A forcing function, not an allowlist: a registration that squats on + * an element name and is absent from both rosters AND from here fails the + * census by absence, and a stale entry — one no longer registered, or one + * since admitted to a roster — fails the liveness pin. + */ +const EXCLUDED_HTML_NAMED: Record = { + div: + 'deprecated on the JSON surface in favour of `box` (objectui#3965, PR objectui#6878) and kept out of the ' + + 'published contract by the objectstack#20112 ruling A: the gate that reads the manifest refuses `
` on ' + + 'an html page. The renderer still registers it with a `json`-only deprecation (objectui#4000); reconciling ' + + 'that runtime exemption with this contract is a separate card, not a roster edit.', + code: + 'the bare fallback key of the `field:code` widget (namespace `field`, no declared inputs, no child slot) — a ' + + 'code editor reading `value`, not an html element renderer. The ruling listed `code` as "registered elsewhere"; ' + + 'measured, there is no element registration to declare, and declaring the widget would teach a tag whose ' + + 'children the runtime drops.', + summary: 'the bare fallback key of the `field:summary` widget — a field, not an element renderer.', + object: 'the bare fallback key of the `field:object` widget — a field, not an element renderer.', + view: 'the bare `view` registration of `@object-ui/plugin-view`, which happens to share its name with the SVG element.', + kbd: + 'a `ui` component rendering `keys` / `label` under an HTML tag name; not named by the objectstack#20112 ruling. ' + + 'Admitting it is additive on a named need, while retracting a declared tag is a narrowing — so it waits.', + form: 'a JSON-surface form component; the html tier excludes form controls by design (the exclusion list in `html-elements.tsx`).', + input: 'a JSON-surface field component; the html tier excludes form controls by design (the exclusion list in `html-elements.tsx`).', + textarea: 'a JSON-surface field component; the html tier excludes form controls by design (the exclusion list in `html-elements.tsx`).', + select: 'a JSON-surface field component; the html tier excludes form controls by design (the exclusion list in `html-elements.tsx`).', + switch: 'a JSON-surface field component; the html tier excludes form controls by design (the exclusion list in `html-elements.tsx`).', + progress: 'a `ui` component reading `value` under an HTML tag name, not an element passthrough; not named by the ruling.', + dialog: 'a `ui` overlay component under an HTML tag name, not an element passthrough; not named by the ruling.', +}; + +/** + * Every element name React knows — read off `@types/react`'s + * `JSX.IntrinsicElements`, the one declaration of the HTML/SVG tag set this + * repo already depends on. Resolved through the package's `package.json` + * because its `exports` map does not expose `index.d.ts` as a subpath. + */ +function reactIntrinsicNames(): Set { + const pkg = createRequire(import.meta.url).resolve('@types/react/package.json'); + const dts = readFileSync(join(dirname(pkg), 'index.d.ts'), 'utf8'); + const block = dts.match(/interface IntrinsicElements \{([\s\S]*?)\n\s*\}/)?.[1] ?? ''; + const names = new Set(); + for (const line of block.split('\n')) { + const m = line.match(/^\s*"?([a-zA-Z][a-zA-Z0-9-]*)"?\s*:/); + if (m) names.add(m[1]); + } + return names; +} + +const enumTags = (schema: { shape: { type: { options: readonly string[] } } }): string[] => [ + ...schema.shape.type.options, +]; + +describe('the manifest carries the html tier exactly as the registry declares it (objectui#10735)', () => { + it('every roster tag is registered under `ui` and reaches the manifest stamped `tier: html`', () => { + for (const tag of HTML_TIER_INTRINSICS) { + const comp = published.components[tag]; + expect(comp, `\`${tag}\` is on the roster and absent from the manifest`).toBeDefined(); + expect(comp.tier, `\`${tag}\` reached the manifest unstamped`).toBe('html'); + expect(comp.namespace, `\`${tag}\` is not a \`ui\` registration`).toBe('ui'); + expect(comp.type).toBe(tag); + } + }); + + it('carries each tag with its registered inputs — the manifest copies the declaration, verbatim', () => { + for (const tag of HTML_TIER_INTRINSICS) { + expect(published.components[tag].inputs.map((i) => i.name), `\`${tag}\` inputs drifted`).toEqual(inputNames(tag)); + } + }); + + it('declares the child slot exactly where the registration renders a child list', () => { + const flowInline = enumTags(HtmlElementSchema); + const sectioning = enumTags(SemanticElementSchema); + expect(flowInline.length).toBeGreaterThan(30); + expect(sectioning).toHaveLength(7); + + const slotDeclared = (tag: string): boolean => + published.components[tag].inputs.some((i) => i.name === 'children' && i.type === 'slot'); + + // The void tags declare NO slot — `
` taking children would be the lie + // in the other direction (objectui#9910) — and every other flow/inline tag, + // every sectioning tag and `span` declare one. + for (const tag of VOID_TAGS) { + expect(slotDeclared(tag), `void tag \`${tag}\` declares a child slot`).toBe(false); + } + for (const tag of [...flowInline.filter((t) => !VOID_TAGS.includes(t)), ...sectioning, 'span']) { + expect(slotDeclared(tag), `\`${tag}\` declares no child slot`).toBe(true); + } + // `table` and `label` are declared as registered: no slot, because their + // renderers read `columns` / `data` and `text`, not a child list. + for (const tag of ['table', 'label']) { + expect(slotDeclared(tag), `\`${tag}\` grew a child slot its renderer does not read`).toBe(false); + expect(declaresSlot(tag)).toBe(false); + } + }); + + it('leaves `div` out, and the two ledgered look-alikes `code` and `kbd` with it', () => { + for (const tag of ['div', 'code', 'kbd']) { + // Registered — the tier resolves them at runtime — and deliberately undeclared. + expect(ComponentRegistry.getKnownTypes(), `\`${tag}\` is no longer registered; re-read its ledger entry`).toContain(tag); + expect(published.components[tag], `\`${tag}\` reached the manifest`).toBeUndefined(); + } + }); +}); + +describe('the curated tier is unmoved — the JSON-surface vocabulary did not grow (objectui#10735)', () => { + it('the unstamped entries are exactly the registered PUBLIC_BLOCKS, in roster order', () => { + const curated = entries.filter((c) => c.tier !== 'html').map((c) => c.type); + expect(curated).toEqual(PUBLIC_BLOCKS.filter((tag) => tag in published.components)); + // Anti-vacuity: the curated tier resolved, and to the vocabulary it is. + expect(curated.length).toBeGreaterThan(50); + expect(curated).toEqual(expect.arrayContaining(['object-grid', 'box', 'html'])); + }); + + it('the stamped entries are exactly the roster, in roster order, and nothing else', () => { + expect(entries.filter((c) => c.tier === 'html').map((c) => c.type)).toEqual([...HTML_TIER_INTRINSICS]); + }); + + it('no entry is on both tiers, and every entry is on one of them', () => { + for (const c of entries) { + const onCurated = PUBLIC_BLOCK_SET.has(c.type) || ComponentRegistry.getMeta(c.type)?.tier === 'public'; + const onHtml = HTML_TIER_INTRINSIC_SET.has(c.type); + expect(onCurated !== onHtml, `\`${c.type}\` is on ${onCurated && onHtml ? 'both tiers' : 'neither tier'}`).toBe(true); + expect(c.tier === 'html', `\`${c.type}\` is stamped against its roster`).toBe(onHtml); + } + }); +}); + +describe('the census — every HTML-named bare registration is accounted for (objectui#10735)', () => { + const intrinsicNames = reactIntrinsicNames(); + const bareKnown = ComponentRegistry.getKnownTypes().filter((k) => !k.includes(':')); + const htmlNamed = bareKnown.filter((k) => intrinsicNames.has(k)).sort(); + + it('reads a real element-name population and a real registration population', () => { + // Lit controls, both sides: the instrument resolved React's element list + // (HTML and SVG), and the registry resolved this console's bare keys. + expect(intrinsicNames.size).toBeGreaterThan(100); + expect(intrinsicNames.has('div')).toBe(true); + expect(intrinsicNames.has('p')).toBe(true); + expect(intrinsicNames.has('svg')).toBe(true); + expect(intrinsicNames.has('object-grid')).toBe(false); + expect(bareKnown).toEqual(expect.arrayContaining(['object-grid', 'box', 'div', 'p'])); + // The intersection is the population this file is about, and it is not empty. + expect(htmlNamed.length).toBeGreaterThan(40); + }); + + it('places every HTML-named registration on a roster or in the ledger — none unaccounted', () => { + const unaccounted = htmlNamed.filter( + (tag) => !PUBLIC_BLOCK_SET.has(tag) && !HTML_TIER_INTRINSIC_SET.has(tag) && !(tag in EXCLUDED_HTML_NAMED), + ); + expect( + unaccounted, + `HTML-named registration(s) on neither roster and not in the ledger: ${unaccounted.join(', ')}. ` + + 'Admit each to HTML_TIER_INTRINSICS (an element renderer the html tier should declare), to PUBLIC_BLOCKS ' + + '(a curated block), or record it in EXCLUDED_HTML_NAMED with the reason.', + ).toEqual([]); + }); + + it('keeps every ledger entry live — registered, HTML-named, on neither roster, with a reason', () => { + for (const [tag, reason] of Object.entries(EXCLUDED_HTML_NAMED)) { + expect(htmlNamed, `\`${tag}\` is no longer an HTML-named registration; delete its entry`).toContain(tag); + expect(PUBLIC_BLOCK_SET.has(tag) || HTML_TIER_INTRINSIC_SET.has(tag), `\`${tag}\` is now on a roster; delete its entry`).toBe(false); + expect(reason.length, `\`${tag}\` states no reason`).toBeGreaterThan(40); + } + }); +}); + +describe('what an html-tier author gets from the gate that reads this manifest (objectui#10735)', () => { + const errors = (src: string) => compile(src, published).diagnostics.filter((d) => d.severity === 'error'); + const codes = (src: string) => compile(src, published).diagnostics.map((d) => `${d.code}:${d.tag ?? ''}`); + + it('accepts the everyday tags with their declared attributes', () => { + expect( + errors( + '

Title

Read the docs.

' + + '
  • one
  • two
x
x

', + ), + ).toEqual([]); + }); + + it('accepts `span`, the sectioning tags, and `table` / `label` written as registered', () => { + expect(errors('
k