Skip to content

Commit 5cb34e7

Browse files
committed
feat(sdui-parser): emit SduiBaseProps from the base-prop list and Omit declared base attributes, as objectui does
Claude-Session: https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv
1 parent 2ce8cea commit 5cb34e7

5 files changed

Lines changed: 90 additions & 27 deletions

File tree

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
---
2-
'@objectstack/sdui-parser': patch
2+
'@objectstack/sdui-parser': minor
33
---
44

5-
The save gate's base-prop list now matches objectui's `SDUI_BASE_PROPS` at the console pin `db11afd4967c` (objectui#11008, #11044). Five keys join the `every-node` set: `bind`, `hidden`, `visibleWhen`, `hiddenOn`, `testId`. Six `BaseSchema` keys become `where-undeclared`: `name`, `label`, `description`, `placeholder`, `data`, `ariaLabel` are accepted without `unknown-prop` on a type whose registration declares no input of that name, and a type that declares one keeps its declared type check.
5+
`@objectstack/sdui-parser` now reads one base-prop list, ported from objectui's `SDUI_BASE_PROPS` at the console pin `db11afd4967c` (objectui#11008, #11044). Both `validateTree` and the generated JSX types (`generateDts`'s `SduiBaseProps`) are driven by it.
66

7-
Effect: `os validate` no longer warns `unknown-prop` on those keys where the protocol declares them. Only warnings disappear. No component in the served manifest declares any of the five `every-node` keys, and a declared `where-undeclared` key is still checked exactly as before, so no error-severity diagnostic is removed and no previously-rejected page is newly accepted as an error. The list is module-local here (no export); `codegen.ts` is unchanged.
7+
- On every node, whatever the component declares: `bind`, `hidden`, `visibleWhen`, `hiddenOn`, `testId` are newly accepted. They no longer draw `unknown-prop`, and the generated types accept them as attributes.
8+
- Only on a type whose registration declares no input of that name: `name`, `label`, `description`, `placeholder`, `data`, `ariaLabel` are newly accepted. A type that declares one keeps its declared type check and its declared attribute type; its generated interface `Omit`s that key from `SduiBaseProps`.
9+
10+
Effect for consumers: `os validate` stops warning `unknown-prop` on those keys, and a `.tsx` page that authors them now type-checks against `generateDts` output where it was a TypeScript error before. Measured on the tracked `sdui.manifest.json` (107 components), no component declares any of the five every-node keys, and every declared where-undeclared key is checked as before, so no diagnostic of error severity is removed for that manifest. The wider type surface is why this is a minor, not a patch.

‎packages/sdui-parser/src/__tests__/base-props-one-list.test.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
* the five newly-`every-node` keys, so no declared type check moves.
1414
*/
1515
import { describe, expect, it } from 'vitest';
16-
import { manifestFromConfigs, validateTree } from '../index.js';
16+
import { generateDts, manifestFromConfigs, validateTree } from '../index.js';
17+
import { SDUI_BASE_PROPS } from '../validate.js';
1718
import type { Manifest, SchemaElement } from '../types.js';
1819

1920
const manifest: Manifest = manifestFromConfigs([
@@ -26,9 +27,12 @@ const codes = (node: SchemaElement): string[] =>
2627
validateTree(node, manifest).diagnostics.map((d) => d.code);
2728

2829
const EVERY_NODE = [
29-
'type', 'id', 'className', 'style', 'visible', 'visibleWhen', 'visibleOn', 'hidden',
30+
'id', 'className', 'style', 'visible', 'visibleWhen', 'visibleOn', 'hidden',
3031
'hiddenOn', 'disabled', 'disabledOn', 'bind', 'testId', 'children',
3132
];
33+
// `type` is the one `every-node` entry with no attribute form: on a node it
34+
// IS the tag, so a node cannot carry it without being the component under test.
35+
// It is pinned through the list below, not through `validateTree`.
3236
const WHERE_UNDECLARED = ['name', 'label', 'description', 'placeholder', 'data', 'ariaLabel'];
3337

3438
describe('base props — one list, two scopes (objectui#11008, #11044)', () => {
@@ -47,7 +51,34 @@ describe('base props — one list, two scopes (objectui#11008, #11044)', () => {
4751
expect(validateTree({ type: 'labelled', label: 'ok' } as SchemaElement, manifest).diagnostics).toEqual([]);
4852
});
4953

54+
it('the list is the 20 entries, `type` alone having no attribute type', () => {
55+
expect(SDUI_BASE_PROPS.map((p) => p.name)).toEqual(['type', ...EVERY_NODE, ...WHERE_UNDECLARED]);
56+
expect(SDUI_BASE_PROPS.filter((p) => p.tsType === null).map((p) => p.name)).toEqual(['type']);
57+
expect(SDUI_BASE_PROPS.filter((p) => p.scope === 'every-node').map((p) => p.name)).toEqual(['type', ...EVERY_NODE]);
58+
});
59+
5060
it('a non-base key is still unknown', () => {
5161
expect(codes({ type: 'leaf', nonsense: 1 } as SchemaElement)).toEqual(['unknown-prop']);
5262
});
5363
});
64+
65+
describe('generateDts — `SduiBaseProps` is emitted from the same list', () => {
66+
const dts = generateDts(manifest);
67+
const emitted = /export interface SduiBaseProps \{\n([\s\S]*?)\n\}/.exec(dts)?.[1].split('\n') ?? [];
68+
69+
it('carries every list entry that has an attribute type, in list order, and nothing else', () => {
70+
expect(emitted).toEqual(
71+
SDUI_BASE_PROPS.filter((p) => p.tsType !== null).map((p) => ` ${p.name}?: ${p.tsType};`),
72+
);
73+
expect(emitted).toContain(' bind?: string;');
74+
expect(emitted).toContain(' hidden?: boolean;');
75+
});
76+
77+
it('a component extends the whole base when it declares no base attribute', () => {
78+
expect(dts).toContain('export interface LeafProps extends SduiBaseProps {');
79+
});
80+
81+
it('a component that declares a base attribute Omits exactly that one from the base', () => {
82+
expect(dts).toContain('export interface LabelledProps extends Omit<SduiBaseProps, "label"> {\n label?: string;\n}');
83+
});
84+
});

‎packages/sdui-parser/src/__tests__/html-tier-manifest.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ describe('generateDts publishes the html tier into JSX.IntrinsicElements', () =>
124124
const dts = generateDts(published);
125125
expect(dts).toContain('"p": PProps;');
126126
expect(dts).toContain('"a": AProps;');
127-
expect(dts).toContain('export interface AProps extends SduiBaseProps {\n className?: string;\n href?: string;\n}');
127+
expect(dts).toContain('export interface AProps extends Omit<SduiBaseProps, "className"> {\n className?: string;\n href?: string;\n}');
128128
expect(dts).toContain('"flex": FlexProps;');
129129
});
130130
});

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

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,26 @@
1010

1111
import type { Manifest, ManifestComponent, ManifestInput, ManifestInputType } from './types.js';
1212
import { inputTypeArms } from './input-type.js';
13+
import { SDUI_BASE_PROPS } from './validate.js';
14+
15+
/**
16+
* `SduiBaseProps`, emitted from {@link SDUI_BASE_PROPS} — the list
17+
* `validateTree` reads — so the generated types and the validator cannot
18+
* disagree about a base prop (objectui#11044). ⛔ Never a second hand-kept
19+
* copy of the list here: that is how this interface came to refuse `bind` and
20+
* `hidden` after the validator accepted them.
21+
*/
22+
const emitBaseProps = (): string =>
23+
`export interface SduiBaseProps {\n${SDUI_BASE_PROPS.filter((prop) => prop.tsType !== null)
24+
.map((prop) => ` ${prop.name}?: ${prop.tsType};`)
25+
.join('\n')}\n}`;
26+
27+
/**
28+
* The attributes `SduiBaseProps` carries — every entry of the list but `type`,
29+
* whatever its scope. A declared input of the same name outranks each of them
30+
* in the generated types (objectui#11044, objectui#11075).
31+
*/
32+
const BASE_ATTRIBUTES = new Set(SDUI_BASE_PROPS.filter((prop) => prop.tsType !== null).map((prop) => prop.name));
1333

1434
export interface CodegenOptions {
1535
/** include a self-contained minimal JSX namespace so the d.ts type-checks
@@ -38,16 +58,7 @@ export function generateDts(manifest: Manifest, options: CodegenOptions = {}): s
3858
return `// AUTO-GENERATED by @object-ui/sdui-parser — DO NOT EDIT.
3959
// Source of truth: ComponentRegistry inputs (ADR-0080 §3). Regenerate via codegen.
4060
41-
export interface SduiBaseProps {
42-
id?: string;
43-
className?: string;
44-
style?: Record<string, unknown>;
45-
visible?: boolean;
46-
visibleOn?: string;
47-
disabled?: boolean;
48-
disabledOn?: string;
49-
children?: unknown;
50-
}
61+
${emitBaseProps()}
5162
5263
${interfaces}
5364
@@ -70,13 +81,31 @@ export {};
7081
* those arms only (objectui#3832): the old test was `i.type !== 'slot'`, which a
7182
* union like `['slot', 'string']` would have passed while `tsType` fell through
7283
* to the default and typed it `string` anyway.
84+
*
85+
* A base attribute the component declares is `Omit`ted from the base it
86+
* extends, whatever its scope: the declared input wins with its type. So a
87+
* declared type the base one does not admit is no TS2430 conflict — neither
88+
* `label?: string | Record<string, unknown>` on a `'where-undeclared'` member
89+
* (objectui#11044) nor `record:alert`'s
90+
* `visible?: boolean | string | Record<string, unknown>` on an `'every-node'`
91+
* one (objectui#11075) — and one the base admits (`className?: string`) types
92+
* the same either way.
93+
*
94+
* The scope is the VALIDATOR's distinction — `validateTree` skips an
95+
* `'every-node'` key before the declared-input lookup — and does not reach this
96+
* surface. ⛔ Every declared base attribute is `Omit`ted, not only the ones
97+
* whose types conflict: assignability is TypeScript's judgement, and a
98+
* conflicts-only rule here would re-derive it from type strings.
99+
*
100+
* A slot-only input emits no attribute, so a declared `children: 'slot'` is
101+
* not `Omit`ted and the base `children` stays.
73102
*/
74103
function emitInterface(comp: ManifestComponent): string {
75-
const lines = comp.inputs
76-
.filter((i) => valueArms(i).length > 0)
77-
.map((i) => ` ${propLine(i)}`)
78-
.join('\n');
79-
return `export interface ${propsName(comp.type)} extends SduiBaseProps {\n${lines}\n}`;
104+
const emitted = comp.inputs.filter((i) => valueArms(i).length > 0);
105+
const lines = emitted.map((i) => ` ${propLine(i)}`).join('\n');
106+
const yielded = emitted.filter((i) => BASE_ATTRIBUTES.has(i.name)).map((i) => JSON.stringify(i.name));
107+
const base = yielded.length > 0 ? `Omit<SduiBaseProps, ${yielded.join(' | ')}>` : 'SduiBaseProps';
108+
return `export interface ${propsName(comp.type)} extends ${base} {\n${lines}\n}`;
80109
}
81110

82111
/** The arms that describe a VALUE (every arm except `'slot'`). */

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,10 @@ export function acceptsChildren(comp: Pick<ManifestComponent, 'inputs'>): boolea
6969
* that name. Where one does, the declared input wins, its `type-mismatch`
7070
* check included, and the generated JSX types take the declared type too.
7171
*/
72-
type SduiBasePropScope = 'every-node' | 'where-undeclared';
72+
export type SduiBasePropScope = 'every-node' | 'where-undeclared';
7373

7474
/** One entry of {@link SDUI_BASE_PROPS}. */
75-
interface SduiBaseProp {
75+
export interface SduiBaseProp {
7676
/** The `BaseSchema` member (`@object-ui/types`). */
7777
readonly name: string;
7878
readonly scope: SduiBasePropScope;
@@ -132,16 +132,16 @@ interface SduiBaseProp {
132132
*
133133
* LOCKSTEP: the list, its scopes and the two Sets below are the port of
134134
* objectui's `SDUI_BASE_PROPS` (objectui#11008, #11044, pin `db11afd4967c`).
135-
* Unlike objectui's, this copy is module-local and has ONE consumer,
136-
* {@link validateTree}: objectstack's `codegen.ts` emits no base-props
137-
* interface, so the "BOTH of its consumers" sentence above describes objectui.
135+
* Both consumers are ported too: {@link validateTree} and `codegen.ts`'s
136+
* `SduiBaseProps`. The list is exported for `codegen.ts` only; `index.ts` does
137+
* not re-export it.
138138
*
139139
* `children` IS here: the key is legal on every node, so it never draws
140140
* `unknown-prop` and its declared `slot` input is never type-checked. Whether
141141
* a given component RENDERS it is the containment question below, answered by
142142
* {@link acceptsChildren} from the declared input.
143143
*/
144-
const SDUI_BASE_PROPS: readonly SduiBaseProp[] = Object.freeze([
144+
export const SDUI_BASE_PROPS: readonly SduiBaseProp[] = Object.freeze([
145145
{ name: 'type', scope: 'every-node', tsType: null },
146146
{ name: 'id', scope: 'every-node', tsType: 'string' },
147147
{ name: 'className', scope: 'every-node', tsType: 'string' },

0 commit comments

Comments
 (0)