diff --git a/.changeset/25712-sdui-parser-one-body.md b/.changeset/25712-sdui-parser-one-body.md new file mode 100644 index 00000000000..18a7d5f1ccb --- /dev/null +++ b/.changeset/25712-sdui-parser-one-body.md @@ -0,0 +1,37 @@ +--- +'@objectstack/sdui-parser': patch +--- + +feat(sdui-parser)!: one parser body: the renderer copy's additions join `@objectstack/sdui-parser` (#25712) + +Clause-②: yes (narrowing: `validateTree` now also judges the nodes held in a manifest entry's declared `slots`, so a caller whose manifest carries `slots` gets diagnostics on slot-held nodes it did not get before) +Metadata-protocol: none + +Two copies of this parser existed: this package, which the save gate runs, and `@object-ui/sdui-parser`, which the renderer runs. `check:sdui-lockstep` held them to one grammar and one code set, and they drifted everywhere else. This change makes this package the one body. The renderer moves onto it and the second copy is deleted in follow-up changes under the same card. + +**Added (from the renderer's copy).** + +- Node-slot validation (objectui#11170). `ManifestComponent.slots` lists the positions besides `children` where a renderer reads nodes, such as `trigger`, `items[].content` and `regions[].components`. `validateTree` descends each one the way it descends `children`. `manifestFromConfigs` gains a `slotsFor` option that writes the list, and a manifest built without it carries none, so the walk follows `children` alone. +- The retired `body` child-list key (objectui#6771). Before, `body` drew the bare `unknown-prop`. Now the message names `children`. A `body` child list under a component that takes no children draws `not-a-container`, the code the `children` spelling draws. Both remain warnings. Exported as `checkRetiredBodyDialect` and `RETIRED_CHILD_LIST_KEY`. +- html-tier provenance (objectui#4000). `parseJsx` stamps every element it builds with an enumerable symbol-keyed marker, `Symbol.for('@object-ui/sdui-parser.html-tier-node')`. `JSON.stringify` and `Object.keys` do not see it. Exported as `HTML_TIER_NODE`, `isHtmlTierNode` and `markHtmlTierNode`. The registry key keeps its original package name on purpose, so a node either copy stamped reads the same. +- More exports: + - `acceptsChildren`, `CHILD_LIST_KEY`, `SDUI_BASE_PROPS`, `SduiBaseProp` and `SduiBasePropScope`; + - `assertFullyLoaded`, the manifest generators' guard against lazy stubs, with `RegistryConfigLike.lazy`; + - `NodeSlotDeclarationLike`. +- The generated `.d.ts` header gains `/* eslint-disable */` and names this package. + +**Renamed, with the old names kept.** `ParsedSchemaNode` is the parsed node and `ManifestValidationResult` is `validateTree`'s result. These are the names the renderer's copy took so it would not collide with `@object-ui/types`' `SchemaNode` or the spec's `ValidationResult` (objectui#6349, objectui#3161). `SchemaNode` and `ValidationResult` stay exported as `@deprecated` aliases of them, so nothing exported is removed. + +**Unchanged.** + +- Every `type-mismatch` and `member-type-mismatch` is still an `error`. The renderer's copy graded them `error` only beside an `enum` arm; this package's rule is the one kept. +- `unconsumed-widget-option` is still emitted as an inline literal. +- The grammar, the 25 diagnostic codes and the containment predicate. + +**BREAKING** (a narrowing of `validateTree` for a manifest that carries `slots`), shipped as `patch` because no metadata protocol kind moves. The save gate's verdicts do not move, and that was measured: the manifest that `objectstack validate`, `build`, `lint` and `serve` read is the tracked `sdui.manifest.json`, which `@objectstack/console` ships. It carries `slots` on 0 of its 106 entries, because its generator calls `manifestFromConfigs` without `slotsFor`. So no save that was accepted before is refused now. The walk has work only for a caller that builds its manifest with `slotsFor`, and today that is the renderer, whose own copy already walks the slots. + +Migration, for a TypeScript caller: none is required. To move off the deprecated names, change `SchemaNode` to `ParsedSchemaNode` and `ValidationResult` to `ManifestValidationResult`. + +`body-dialect.ts` and `provenance.ts` moved in from the MIT-licensed `@object-ui/sdui-parser` and keep that licence. Each carries the notice, and the package now ships `LICENSE-MIT`. + + diff --git a/packages/sdui-parser/LICENSE-MIT b/packages/sdui-parser/LICENSE-MIT new file mode 100644 index 00000000000..cf2ca28574c --- /dev/null +++ b/packages/sdui-parser/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 ObjectQL + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index 500a20eb225..6f65a693467 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -49,7 +49,8 @@ "files": [ "dist", "README.md", - "CHANGELOG.md" + "CHANGELOG.md", + "LICENSE-MIT" ], "engines": { "node": ">=22.0.0" diff --git a/packages/sdui-parser/src/__tests__/base-props-bind-hidden-11008.test.ts b/packages/sdui-parser/src/__tests__/base-props-bind-hidden-11008.test.ts new file mode 100644 index 00000000000..eedcfa8cf08 --- /dev/null +++ b/packages/sdui-parser/src/__tests__/base-props-bind-hidden-11008.test.ts @@ -0,0 +1,88 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE-MIT file beside this package's manifest. + */ + +/** + * `bind` and `hidden` are base props of the tier (objectui#11008). + * + * `BaseSchema` (`@object-ui/types`) declares both for every node, and no + * registration declares either as an input. Before they joined `BASE_PROPS`, + * the undeclared-key branch answered every authored one with `unknown-prop` + * ("has no prop"), including on the nodes that honour it: `SchemaRenderer` + * reads `hidden` for every node, and every `useDataScope` renderer reads + * `bind`. This file pins the TIER's reading of a manifest; the live-registry + * half — `list`, `tree-view` and `data-table` — is + * `ui/packages/components/src/renderers/__tests__/bind-base-prop-parser-tier-11008.test.tsx`. + * + * Three facts, kept apart because each one fails differently: + * + * - both keys draw nothing, in every value shape `BaseSchema` admits for + * them — the fix; + * - a near-miss spelling (`bindTo`, `hide`) on the SAME node still draws + * `unknown-prop` naming it — the lit control. Without it, "draws nothing" + * would also pass against a tier that stopped warning on undeclared keys; + * - a registration that DECLARES `placeholder` keeps its `type-mismatch`. + * `placeholder` is a `BaseSchema` member too, and it was left out of the + * `every-node` members because they skip the declared-input lookup. It + * joined later as a `where-undeclared` member (objectui#11044), which a + * declared input outranks: this row goes red if it is ever made an + * `every-node` member the way `bind` and `hidden` were. + */ +import { describe, expect, it } from 'vitest'; +import { manifestFromConfigs, validateTree } from '../index.js'; +import type { Diagnostic, Manifest, SchemaElement } from '../types.js'; + +const manifest: Manifest = manifestFromConfigs([ + // a leaf that declares neither key, as no live registration does + { type: 'leaf', namespace: 'ui', inputs: [{ name: 'content', type: 'string' }] }, + // the input-family shape: `placeholder` declared as a typed input + { type: 'field', namespace: 'ui', inputs: [{ name: 'placeholder', type: 'string' }] }, +] as unknown as Parameters[0]); + +const diagnose = (node: unknown): Diagnostic[] => + validateTree(node as SchemaElement, manifest).diagnostics; + +const LEAF = { type: 'leaf', content: 'x' }; + +/** Every value shape `BaseSchema` declares for the two keys. */ +const BASE_VALUES: Record<'bind' | 'hidden', unknown[]> = { + bind: ['users', 'app.settings.users'], + hidden: [true, false, '${data.role !== "admin"}', { dialect: 'cel', source: "record.status == 'draft'" }], +}; + +describe('objectui#11008 — `bind` and `hidden` are base props of the parser tier', () => { + it('the control node draws nothing', () => { + expect(diagnose(LEAF)).toEqual([]); + }); + + it.each(Object.keys(BASE_VALUES) as Array)( + '`%s` draws no diagnostic, in every value shape BaseSchema admits', + (key) => { + const drew = BASE_VALUES[key] + .map((value) => [value, diagnose({ ...LEAF, [key]: value })] as const) + .filter(([, found]) => found.length > 0) + .map(([value, found]) => `${JSON.stringify(value)} → ${JSON.stringify(found.map((d) => d.code))}`); + expect(drew).toEqual([]); + }, + ); + + it.each([ + ['bindTo', 'users'], + ['hide', true], + ] as const)('lit control: the near-miss `%s` still draws one `unknown-prop` naming it', (key, value) => { + const found = diagnose({ ...LEAF, [key]: value }); + expect(found.map((d) => d.code)).toEqual(['unknown-prop']); + expect(found[0]!.message).toContain(`"${key}"`); + }); + + it('a registration that declares `placeholder` keeps its type check — why `placeholder` is not `every-node`', () => { + expect(diagnose({ type: 'field', placeholder: 'Search…' })).toEqual([]); + const found = diagnose({ type: 'field', placeholder: 42 }); + expect(found.map((d) => d.code)).toEqual(['type-mismatch']); + expect(found[0]!.message).toContain('"placeholder"'); + }); +}); diff --git a/packages/sdui-parser/src/__tests__/base-props-one-list-11044.test.ts b/packages/sdui-parser/src/__tests__/base-props-one-list-11044.test.ts new file mode 100644 index 00000000000..8f8360e7410 --- /dev/null +++ b/packages/sdui-parser/src/__tests__/base-props-one-list-11044.test.ts @@ -0,0 +1,265 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE-MIT file beside this package's manifest. + */ + +/** + * The base props are ONE list, and both of its readers read it (objectui#11044). + * + * `validateTree`'s `unknown-prop` branch and `generateDts`'s `SduiBaseProps` + * used to keep separate hand-written copies of the base-prop list. They + * drifted: after objectui#11008 the validator accepted `bind` and `hidden` on + * every node, and the generated JSX types still refused both + * (`TS2322 … Property bind does not exist on type ListProps`). Both now read + * `SDUI_BASE_PROPS`. This file pins the tier's reading of a manifest; the + * live-registry half is + * `ui/packages/components/src/renderers/__tests__/base-props-one-list-11044.test.tsx`. + * + * Four facts, kept apart because each fails differently: + * + * - ONE LIST: the generated `SduiBaseProps` block is exactly the list's + * attribute entries, and every entry is accepted on a node that declares + * nothing, while a near-miss spelling still draws `unknown-prop` (the lit + * control — without it, "draws nothing" would also pass against a tier + * that stopped warning on undeclared keys); + * - MEMBER 1: `visibleWhen`, `hiddenOn` and `testId` are accepted on every + * node, as `bind` and `hidden` are; + * - MEMBER 2 (the triage ruling): a `'where-undeclared'` member is a base + * prop only where the type declares no input of that name. Where one does, + * the declared input wins, `type-mismatch` included; + * - MEMBER 3: the generated `.d.ts`, compiled by `tsc`, accepts the base + * props on a type that declares none of them, refuses a near-miss, and + * lets a declared input's type win without a TS2430 conflict — for an + * `'every-node'` member too (objectui#11075): `notice` declares `visible` + * wider than the base `boolean`, as `record:alert` does in the live + * registry. The real public manifest's half is + * `ui/apps/console/src/__tests__/sdui-intrinsics-compile-11075.test.ts`. + */ +import { describe, expect, it } from 'vitest'; +import ts from 'typescript'; +import { generateDts, manifestFromConfigs, SDUI_BASE_PROPS, validateTree } from '../index.js'; +import type { Diagnostic, SchemaElement } from '../types.js'; + +/** + * `leaf` declares none of the base props, as no live registration declares + * any `'every-node'` member added here. `field` declares every + * `'where-undeclared'` member as a typed input — the input-family shape — and + * two of them with a type the base one does not admit (`label` takes any + * object, `data` only an array). `notice` declares three `'every-node'` + * members (objectui#11075): `visible` with the three arms `record:alert` + * declares, which the base `boolean` does not admit, and `disabled` / + * `className` with the base type; its `children` input is a slot. + */ +const manifest = manifestFromConfigs([ + { type: 'leaf', namespace: 'ui', inputs: [{ name: 'content', type: 'string' }] }, + { + type: 'field', + namespace: 'ui', + inputs: [ + { name: 'name', type: 'string' }, + { name: 'label', type: ['string', 'object'] }, + { name: 'description', type: 'string' }, + { name: 'placeholder', type: 'string' }, + { name: 'data', type: 'array' }, + { name: 'ariaLabel', type: 'string' }, + ], + }, + { + type: 'list', + namespace: 'ui', + inputs: [ + { name: 'title', type: 'string' }, + { name: 'items', type: 'array' }, + ], + }, + { + type: 'notice', + namespace: 'ui', + inputs: [ + { name: 'message', type: 'string' }, + { name: 'visible', type: ['boolean', 'string', 'object'] }, + { name: 'disabled', type: 'boolean' }, + { name: 'className', type: 'string' }, + { name: 'children', type: 'slot' }, + ], + }, +] as unknown as Parameters[0]); + +const diagnose = (node: unknown): Diagnostic[] => + validateTree(node as SchemaElement, manifest).diagnostics; + +const LEAF = { type: 'leaf', content: 'x' }; + +/** A value of the kind `BaseSchema` declares for each member. */ +const WELL_TYPED: Record = { + id: 'n1', + className: 'p-2', + style: { padding: 4 }, + visible: true, + visibleWhen: "data.role == 'admin'", + visibleOn: "data.role == 'admin'", + hidden: true, + hiddenOn: "data.role != 'admin'", + disabled: false, + disabledOn: 'data.locked', + bind: 'users', + testId: 'users-list', + children: [], + name: 'email', + label: 'Email', + description: 'Where we write', + placeholder: 'you@example.com', + data: [{ id: 1 }], + ariaLabel: 'Email address', +}; + +const WHERE_UNDECLARED = SDUI_BASE_PROPS.filter((p) => p.scope === 'where-undeclared').map((p) => p.name); + +describe('objectui#11044 — the base props are one list, read by the validator and the codegen', () => { + it('the list names each key once, and every attribute entry has a value to test it with', () => { + const names = SDUI_BASE_PROPS.map((p) => p.name); + expect(new Set(names).size).toBe(names.length); + // `type` is the one entry that is no attribute: the tag name carries it. + expect(SDUI_BASE_PROPS.filter((p) => p.tsType === null).map((p) => p.name)).toEqual(['type']); + expect(Object.keys(WELL_TYPED).sort()).toEqual(names.filter((n) => n !== 'type').sort()); + }); + + it('the generated `SduiBaseProps` is exactly the list, in order, with each entry’s type', () => { + const block = generateDts(manifest).match(/export interface SduiBaseProps \{\n([\s\S]*?)\n\}\n/); + expect(block, 'no SduiBaseProps interface in the generated d.ts').toBeTruthy(); + expect(block![1].split('\n')).toEqual( + SDUI_BASE_PROPS.filter((p) => p.tsType !== null).map((p) => ` ${p.name}?: ${p.tsType};`), + ); + }); + + it('the control node draws nothing', () => { + expect(diagnose(LEAF)).toEqual([]); + }); + + it.each(Object.keys(WELL_TYPED))('`%s` draws nothing on a node that declares no input of that name', (key) => { + expect(diagnose({ ...LEAF, [key]: WELL_TYPED[key] })).toEqual([]); + }); + + it.each([ + ['testid', 'n1'], + ['visibleIf', 'data.x'], + ['hiddenWhen', 'data.x'], + ['aria-label', 'x'], + ] as const)('lit control: the near-miss `%s` still draws one `unknown-prop` naming it', (key, value) => { + const found = diagnose({ ...LEAF, [key]: value }); + expect(found.map((d) => d.code)).toEqual(['unknown-prop']); + expect(found[0]!.message).toContain(`"${key}"`); + }); +}); + +describe('objectui#11044 member 1 — `visibleWhen`, `hiddenOn` and `testId` are base props of every node', () => { + it.each(['visibleWhen', 'hiddenOn', 'testId'])('`%s` is an `every-node` member', (key) => { + expect(SDUI_BASE_PROPS.find((p) => p.name === key)?.scope).toBe('every-node'); + expect(diagnose({ ...LEAF, [key]: WELL_TYPED[key] })).toEqual([]); + }); +}); + +describe('objectui#11044 member 2 — a declared input outranks a `where-undeclared` base prop', () => { + it('the six subset-declared `BaseSchema` members are the `where-undeclared` ones', () => { + expect([...WHERE_UNDECLARED].sort()).toEqual(['ariaLabel', 'data', 'description', 'label', 'name', 'placeholder']); + }); + + it.each(WHERE_UNDECLARED)('`%s`: where the type declares it, a wrong-typed value keeps its `type-mismatch`', (key) => { + expect(diagnose({ type: 'field', [key]: WELL_TYPED[key] })).toEqual([]); + const found = diagnose({ type: 'field', [key]: 424242 }); + expect(found.map((d) => d.code)).toEqual(['type-mismatch']); + expect(found[0]!.message).toContain(`"${key}"`); + }); + + it.each(WHERE_UNDECLARED)('`%s`: where the type does not declare it, it is a base prop and draws nothing', (key) => { + expect(diagnose({ ...LEAF, [key]: WELL_TYPED[key] })).toEqual([]); + }); +}); + +/** One statement per line: diagnostics are mapped back to cases BY LINE. */ +const TSX_CASES: ReadonlyArray<{ what: string; code: string; rejected: boolean }> = [ + { what: 'control: declared inputs', rejected: false, code: `export const c0 = ;` }, + { what: '`bind` (objectui#11008)', rejected: false, code: `export const c1 = ;` }, + { what: '`hidden` (objectui#11008)', rejected: false, code: `export const c2 =