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
10 changes: 10 additions & 0 deletions .changeset/20578-sdui-parser-base-props-one-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/sdui-parser': minor
---

`@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.

- 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.
- 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`.

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.
84 changes: 84 additions & 0 deletions packages/sdui-parser/src/__tests__/base-props-one-list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* The base-prop list, ported in lockstep from objectui (objectui#11008 added
* `bind` / `hidden`; objectui#11044 made ONE list with an `every-node` and a
* `where-undeclared` scope) at the console pin `db11afd4967c`.
*
* Three facts, kept apart:
* 1. every `every-node` member draws no `unknown-prop`;
* 2. a `where-undeclared` member draws none where the type declares no input
* of that name;
* 3. a `where-undeclared` member the type DECLARES keeps its declared type
* check (the reason it is not `every-node`).
* Measured on the tracked `sdui.manifest.json`: no component declares any of
* the five newly-`every-node` keys, so no declared type check moves.
*/
import { describe, expect, it } from 'vitest';
import { generateDts, manifestFromConfigs, validateTree } from '../index.js';
import { SDUI_BASE_PROPS } from '../validate.js';
import type { Manifest, SchemaElement } from '../types.js';

const manifest: Manifest = manifestFromConfigs([
{ type: 'leaf', namespace: 'ui', inputs: [{ name: 'content', type: 'string' }] },
// `label` declared as a string, the `badge` / `button` shape
{ type: 'labelled', namespace: 'ui', inputs: [{ name: 'label', type: 'string' }] },
]);

const codes = (node: SchemaElement): string[] =>
validateTree(node, manifest).diagnostics.map((d) => d.code);

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

describe('base props — one list, two scopes (objectui#11008, #11044)', () => {
it.each(EVERY_NODE)('every-node `%s` draws no unknown-prop', (key) => {
const node = { type: 'leaf', [key]: key === 'children' ? [] : 'x' } as SchemaElement;
expect(codes(node)).not.toContain('unknown-prop');
});

it.each(WHERE_UNDECLARED)('undeclared `%s` draws no unknown-prop', (key) => {
expect(codes({ type: 'leaf', [key]: 'x' } as SchemaElement)).toEqual([]);
});

it('a DECLARED where-undeclared key keeps its type check', () => {
const diags = validateTree({ type: 'labelled', label: 5 } as SchemaElement, manifest).diagnostics;
expect(diags.map((d) => d.code)).toEqual(['type-mismatch']);
expect(validateTree({ type: 'labelled', label: 'ok' } as SchemaElement, manifest).diagnostics).toEqual([]);
});

it('the list is the 20 entries, `type` alone having no attribute type', () => {
expect(SDUI_BASE_PROPS.map((p) => p.name)).toEqual(['type', ...EVERY_NODE, ...WHERE_UNDECLARED]);
expect(SDUI_BASE_PROPS.filter((p) => p.tsType === null).map((p) => p.name)).toEqual(['type']);
expect(SDUI_BASE_PROPS.filter((p) => p.scope === 'every-node').map((p) => p.name)).toEqual(['type', ...EVERY_NODE]);
});

it('a non-base key is still unknown', () => {
expect(codes({ type: 'leaf', nonsense: 1 } as SchemaElement)).toEqual(['unknown-prop']);
});
});

describe('generateDts — `SduiBaseProps` is emitted from the same list', () => {
const dts = generateDts(manifest);
const emitted = /export interface SduiBaseProps \{\n([\s\S]*?)\n\}/.exec(dts)?.[1].split('\n') ?? [];

it('carries every list entry that has an attribute type, in list order, and nothing else', () => {
expect(emitted).toEqual(
SDUI_BASE_PROPS.filter((p) => p.tsType !== null).map((p) => ` ${p.name}?: ${p.tsType};`),
);
expect(emitted).toContain(' bind?: string;');
expect(emitted).toContain(' hidden?: boolean;');
});

it('a component extends the whole base when it declares no base attribute', () => {
expect(dts).toContain('export interface LeafProps extends SduiBaseProps {');
});

it('a component that declares a base attribute Omits exactly that one from the base', () => {
expect(dts).toContain('export interface LabelledProps extends Omit<SduiBaseProps, "label"> {\n label?: string;\n}');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ describe('generateDts publishes the html tier into JSX.IntrinsicElements', () =>
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('export interface AProps extends Omit<SduiBaseProps, "className"> {\n className?: string;\n href?: string;\n}');
expect(dts).toContain('"flex": FlexProps;');
});
});
59 changes: 44 additions & 15 deletions packages/sdui-parser/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@

import type { Manifest, ManifestComponent, ManifestInput, ManifestInputType } from './types.js';
import { inputTypeArms } from './input-type.js';
import { SDUI_BASE_PROPS } from './validate.js';

/**
* `SduiBaseProps`, emitted from {@link SDUI_BASE_PROPS} — the list
* `validateTree` reads — so the generated types and the validator cannot
* disagree about a base prop (objectui#11044). ⛔ Never a second hand-kept
* copy of the list here: that is how this interface came to refuse `bind` and
* `hidden` after the validator accepted them.
*/
const emitBaseProps = (): string =>
`export interface SduiBaseProps {\n${SDUI_BASE_PROPS.filter((prop) => prop.tsType !== null)
.map((prop) => ` ${prop.name}?: ${prop.tsType};`)
.join('\n')}\n}`;

/**
* The attributes `SduiBaseProps` carries — every entry of the list but `type`,
* whatever its scope. A declared input of the same name outranks each of them
* in the generated types (objectui#11044, objectui#11075).
*/
const BASE_ATTRIBUTES = new Set(SDUI_BASE_PROPS.filter((prop) => prop.tsType !== null).map((prop) => prop.name));

export interface CodegenOptions {
/** include a self-contained minimal JSX namespace so the d.ts type-checks
Expand Down Expand Up @@ -38,16 +58,7 @@ export function generateDts(manifest: Manifest, options: CodegenOptions = {}): s
return `// AUTO-GENERATED by @object-ui/sdui-parser — DO NOT EDIT.
// Source of truth: ComponentRegistry inputs (ADR-0080 §3). Regenerate via codegen.

export interface SduiBaseProps {
id?: string;
className?: string;
style?: Record<string, unknown>;
visible?: boolean;
visibleOn?: string;
disabled?: boolean;
disabledOn?: string;
children?: unknown;
}
${emitBaseProps()}

${interfaces}

Expand All @@ -70,13 +81,31 @@ export {};
* those arms only (objectui#3832): the old test was `i.type !== 'slot'`, which a
* union like `['slot', 'string']` would have passed while `tsType` fell through
* to the default and typed it `string` anyway.
*
* A base attribute the component declares is `Omit`ted from the base it
* extends, whatever its scope: the declared input wins with its type. So a
* declared type the base one does not admit is no TS2430 conflict — neither
* `label?: string | Record<string, unknown>` on a `'where-undeclared'` member
* (objectui#11044) nor `record:alert`'s
* `visible?: boolean | string | Record<string, unknown>` on an `'every-node'`
* one (objectui#11075) — and one the base admits (`className?: string`) types
* the same either way.
*
* The scope is the VALIDATOR's distinction — `validateTree` skips an
* `'every-node'` key before the declared-input lookup — and does not reach this
* surface. ⛔ Every declared base attribute is `Omit`ted, not only the ones
* whose types conflict: assignability is TypeScript's judgement, and a
* conflicts-only rule here would re-derive it from type strings.
*
* A slot-only input emits no attribute, so a declared `children: 'slot'` is
* not `Omit`ted and the base `children` stays.
*/
function emitInterface(comp: ManifestComponent): string {
const lines = comp.inputs
.filter((i) => valueArms(i).length > 0)
.map((i) => ` ${propLine(i)}`)
.join('\n');
return `export interface ${propsName(comp.type)} extends SduiBaseProps {\n${lines}\n}`;
const emitted = comp.inputs.filter((i) => valueArms(i).length > 0);
const lines = emitted.map((i) => ` ${propLine(i)}`).join('\n');
const yielded = emitted.filter((i) => BASE_ATTRIBUTES.has(i.name)).map((i) => JSON.stringify(i.name));
const base = yielded.length > 0 ? `Omit<SduiBaseProps, ${yielded.join(' | ')}>` : 'SduiBaseProps';
return `export interface ${propsName(comp.type)} extends ${base} {\n${lines}\n}`;
}

/** The arms that describe a VALUE (every arm except `'slot'`). */
Expand Down
126 changes: 114 additions & 12 deletions packages/sdui-parser/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,24 +60,122 @@ export function acceptsChildren(comp: Pick<ManifestComponent, 'inputs'>): boolea
}

/**
* Base props every node may carry (mirrors BaseSchema) — never "unknown prop".
* Where a base prop is legal without a declaration (objectui#11044).
*
* - `'every-node'` — on every node, and a registration's own input of the
* same name is not consulted: {@link validateTree} skips the key before the
* declared-input lookup.
* - `'where-undeclared'` — on a type whose registration declares NO input of
* that name. Where one does, the declared input wins, its `type-mismatch`
* check included, and the generated JSX types take the declared type too.
*/
export type SduiBasePropScope = 'every-node' | 'where-undeclared';

/** One entry of {@link SDUI_BASE_PROPS}. */
export interface SduiBaseProp {
/** The `BaseSchema` member (`@object-ui/types`). */
readonly name: string;
readonly scope: SduiBasePropScope;
/**
* The attribute's type in the generated JSX surface (`SduiBaseProps` in
* `sdui-intrinsics.d.ts`), or `null` for the one key that is no attribute:
* `type`, which the tag name carries (`parse.ts` refuses a `type` attribute).
*/
readonly tsType: string | null;
}

/**
* The base props: the `BaseSchema` members this tier accepts on a node without
* a registration declaring them. ONE list, read by BOTH of its consumers —
* {@link validateTree}'s `unknown-prop` branch and `generateDts`'s
* `SduiBaseProps` (objectui#11044). Before this list the two were separate
* hand-kept copies and had drifted: the validator accepted `bind` and `hidden`
* (objectui#11008) while the generated types still refused both.
*
* `bind` and `hidden` are `'every-node'` (objectui#11008). `BaseSchema` declares
* both for every node and no registration declares either as an input, so
* before they joined, the undeclared-key branch below answered every authored
* one with `unknown-prop` — "has no prop" about a key the protocol declares,
* on the nodes that honour it: `hidden` is read for every node by
* `SchemaRenderer`'s hide chain, and `bind` by every renderer that calls
* `useDataScope` (`list`, `tree-view`, the `object-*` widgets). The
* declaration outranks the implementation, so the parser's view is the
* declared type's, not a per-registration subset. The cost is accepted and
* named: a `bind` on a node that does not read it — `data-table`
* (objectui#6575) — draws nothing here either, and its render-time console
* warning is the one signal left.
*
* `visibleWhen`, `hiddenOn` and `testId` are `'every-node'` for the same reason
* (objectui#11044): no registration declares any of them, `SchemaRenderer`'s
* hide chain reads the first two for every node, and it strips `testId` and
* re-emits it as `data-testid`. `visibleWhen` is the canonical ADR-0089
* predicate; before it joined, the deprecated `visibleOn` was silent while it
* drew `unknown-prop`.
*
* The `'where-undeclared'` members (objectui#11044, triage ruling) are the
* `BaseSchema` members some registrations DECLARE as typed inputs — the input
* family's `placeholder`, `label`, `name`, … . Skipping them the way the
* `'every-node'` members are skipped would silence those registrations'
* `type-mismatch`, so they are base props only where the type declares no
* input of that name. ⛔ Never move one to `'every-node'` to accept a key: that
* silences a declared type check.
*
* Held over the live registry by `base-props-one-list-11044.test.tsx` in
* `@object-ui/components` — every member a `BaseSchema` member, and `body` the
* one member left out.
*
* ⛔ `body` is NOT here and must not be added. It was `BaseSchema`'s second
* child-list spelling until objectui#6771 retired it; teaching this list the
* key was the option that ruling refused, because it would have blessed a
* second permanent spelling of one concept. `./body-dialect.ts` answers it by
* name instead.
*
* LOCKSTEP: the list, its scopes and the two Sets below are the port of
* objectui's `SDUI_BASE_PROPS` (objectui#11008, #11044, pin `db11afd4967c`).
* Both consumers are ported too: {@link validateTree} and `codegen.ts`'s
* `SduiBaseProps`. The list is exported for `codegen.ts` only; `index.ts` does
* not re-export it.
*
* `children` IS here: the key is legal on every node, so it never draws
* `unknown-prop` and its declared `slot` input is never type-checked. Whether
* a given component RENDERS it is the containment question below, answered by
* {@link acceptsChildren} from the declared input.
*/
const BASE_PROPS = new Set([
'type',
'id',
'className',
'style',
'visible',
'visibleOn',
'disabled',
'disabledOn',
CHILD_LIST_KEY,
]);
export const SDUI_BASE_PROPS: readonly SduiBaseProp[] = Object.freeze([
{ name: 'type', scope: 'every-node', tsType: null },
{ name: 'id', scope: 'every-node', tsType: 'string' },
{ name: 'className', scope: 'every-node', tsType: 'string' },
{ name: 'style', scope: 'every-node', tsType: 'Record<string, unknown>' },
{ name: 'visible', scope: 'every-node', tsType: 'boolean' },
{ name: 'visibleWhen', scope: 'every-node', tsType: 'string' },
{ name: 'visibleOn', scope: 'every-node', tsType: 'string' },
{ name: 'hidden', scope: 'every-node', tsType: 'boolean' },
{ name: 'hiddenOn', scope: 'every-node', tsType: 'string' },
{ name: 'disabled', scope: 'every-node', tsType: 'boolean' },
{ name: 'disabledOn', scope: 'every-node', tsType: 'string' },
{ name: 'bind', scope: 'every-node', tsType: 'string' },
{ name: 'testId', scope: 'every-node', tsType: 'string' },
{ name: CHILD_LIST_KEY, scope: 'every-node', tsType: 'unknown' },
{ name: 'name', scope: 'where-undeclared', tsType: 'string' },
{ name: 'label', scope: 'where-undeclared', tsType: 'string | Record<string, string>' },
{ name: 'description', scope: 'where-undeclared', tsType: 'string | Record<string, string>' },
{ name: 'placeholder', scope: 'where-undeclared', tsType: 'string' },
{ name: 'data', scope: 'where-undeclared', tsType: 'unknown' },
{
name: 'ariaLabel',
scope: 'where-undeclared',
tsType: 'string | { key: string; defaultValue?: string; params?: Record<string, unknown> }',
},
] satisfies SduiBaseProp[]);

const basePropNames = (scope: SduiBasePropScope): Set<string> =>
new Set(SDUI_BASE_PROPS.filter((prop) => prop.scope === scope).map((prop) => prop.name));

/** The `'every-node'` members of {@link SDUI_BASE_PROPS}: never "unknown prop". */
const BASE_PROPS = basePropNames('every-node');

/** The `'where-undeclared'` members of {@link SDUI_BASE_PROPS}. */
const WHERE_UNDECLARED_BASE_PROPS = basePropNames('where-undeclared');

const isExpr = (v: unknown): boolean =>
typeof v === 'object' && v !== null && '$expr' in (v as Record<string, unknown>);
Expand Down Expand Up @@ -116,6 +214,10 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Va
// each provided prop
for (const [key, value] of Object.entries(node)) {
if (BASE_PROPS.has(key)) continue;
// A declaration outranks a `'where-undeclared'` base prop
// (objectui#11044): skipped only when this type declares no input of
// that name, so a declared one keeps its type check below.
if (WHERE_UNDECLARED_BASE_PROPS.has(key) && !byName.has(key)) continue;
// The `object-kanban` Quick Add pair (objectui#8285): a key
// `@objectstack/spec` still publishes and the renderer cannot honour,
// because the control is gated on a RUNTIME SLOT no parsed page can
Expand Down
Loading