diff --git a/.changeset/10735-html-tier-manifest.md b/.changeset/10735-html-tier-manifest.md new file mode 100644 index 0000000000..2bdcd4e0d3 --- /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 findings 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/.changeset/6740-flex-is-container.md b/.changeset/6740-flex-is-container.md index 251aae955c..c151c0f144 100644 --- a/.changeset/6740-flex-is-container.md +++ b/.changeset/6740-flex-is-container.md @@ -35,6 +35,8 @@ wrote `` moves from silently swallowing its children to the page-level error panel naming the identifier, with that page's documented remedy being real HTML: `
`. +⚠️ **Dated note, 2026-09-27 — the skip line quoted above has since gained a third arm — objectui#10735.** At this change the line read `if (!tag || cfg.isContainer) continue;`; it now reads `if (!tag || cfg.isContainer || cfg.tier === 'html') continue;`, because `getPublicConfigs()` also returns the html tier's intrinsic elements stamped `tier: 'html'` and the scope skips those too. The container arm this entry is about is unchanged; the rest of this entry is kept as the reading of this change. + Pinned over the family rather than over `flex` alone: the defect's shape was "three declare it and one does not", and a pin covering only the one that was missing would let the next registration rot the same way. 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..8b7374c401 --- /dev/null +++ b/apps/console/src/__tests__/html-tier-manifest.test.ts @@ -0,0 +1,299 @@ +/** + * 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 { LayoutSchema } 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; +} + +/** + * The two tag families the JSON layout union declares by `z.enum` — the + * flow/inline set (holding `p`) and the sectioning set (holding `main`). Read + * off `LayoutSchema`'s arms, because `HtmlElementSchema` and + * `SemanticElementSchema` are deliberately not exported (objectui#9067). + */ +type EnumTypeArm = { shape?: { type?: { def?: { type?: string }; options?: readonly string[] } } }; +const enumArms = (): string[][] => + (LayoutSchema as unknown as { options: EnumTypeArm[] }).options + .filter((arm) => arm.shape?.type?.def?.type === 'enum') + .map((arm) => [...(arm.shape?.type?.options ?? [])]); +const flowInlineTags = (): string[] => enumArms().find((arm) => arm.includes('p')) ?? []; +const sectioningTags = (): string[] => enumArms().find((arm) => arm.includes('main')) ?? []; + +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', () => { + expect(enumArms()).toHaveLength(2); + const flowInline = flowInlineTags(); + const sectioning = sectioningTags(); + 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