@object-ui/sdui-parser 17.6.0 → 17.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/body-dialect.d.ts +52 -0
- package/dist/body-dialect.js +85 -0
- package/dist/codegen.d.ts +5 -0
- package/dist/codegen.js +91 -25
- package/dist/dashboard-widget-options.d.ts +49 -15
- package/dist/index.d.ts +51 -4
- package/dist/index.js +15 -1
- package/dist/parse.d.ts +12 -3
- package/dist/parse.js +324 -6
- package/dist/provenance.d.ts +7 -4
- package/dist/provenance.js +7 -4
- package/dist/types.d.ts +102 -4
- package/dist/validate.d.ts +111 -4
- package/dist/validate.js +246 -20
- package/package.json +5 -5
package/dist/validate.js
CHANGED
|
@@ -2,24 +2,129 @@
|
|
|
2
2
|
* ObjectUI — SDUI tree validation against the registry manifest (ADR-0080 §3/§6)
|
|
3
3
|
*
|
|
4
4
|
* Shallow, author-time validation: unknown component, unknown/missing prop,
|
|
5
|
-
* wrong coarse type, illegal enum value
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* wrong coarse type, illegal enum value, and containment — a child list under
|
|
6
|
+
* a component whose registration declares no `children` input draws
|
|
7
|
+
* `not-a-container` (objectui#9910; see `acceptsChildren`). Collects
|
|
8
|
+
* `requires` (plugin provenance) and binding sites the SERVER must resolve
|
|
9
|
+
* against object schema (we cannot resolve objects/fields here — that check is
|
|
10
|
+
* framework-side by design).
|
|
8
11
|
*/
|
|
9
12
|
import { inputTypeArms } from './input-type.js';
|
|
10
13
|
import { checkDashboardWidgetOptions } from './dashboard-widget-options.js';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
import { checkRetiredBodyDialect } from './body-dialect.js';
|
|
15
|
+
/**
|
|
16
|
+
* The protocol's ONE child-list key — `BaseSchema.children` — and therefore the
|
|
17
|
+
* name of the `inputs` entry a registration declares when its renderer puts
|
|
18
|
+
* that list on the page: `{ name: 'children', type: 'slot' }` (objectui#9910).
|
|
19
|
+
*/
|
|
20
|
+
export const CHILD_LIST_KEY = 'children';
|
|
21
|
+
/**
|
|
22
|
+
* Does this component ACCEPT an authored child list? (objectui#9910)
|
|
23
|
+
*
|
|
24
|
+
* Read from exactly ONE declaration: an input named {@link CHILD_LIST_KEY} in
|
|
25
|
+
* the component's `inputs`. That is the shape ten registrations already
|
|
26
|
+
* carried when the maintainer ruled it the containment contract
|
|
27
|
+
* (2026-09-24, objectui#9910 Q1-A "declare-and-pin"), and it is held in both
|
|
28
|
+
* directions by the runtime census in
|
|
29
|
+
* `packages/components/src/renderers/__tests__/container-declaration-ratchet.test.tsx`:
|
|
30
|
+
* a renderer that puts `schema.children` on the page without declaring the
|
|
31
|
+
* input is red, and a declaration whose renderer never renders the list is
|
|
32
|
+
* red too.
|
|
33
|
+
*
|
|
34
|
+
* ⛔ `isContainer` is NOT consulted and is NOT a fallback. It used to decide
|
|
35
|
+
* this branch and it lied both ways: a hand-kept flag drifted from the code
|
|
36
|
+
* (objectui#3900 / `7c9b044f4` / #6764 / #6779 found the same drift four times),
|
|
37
|
+
* and after objectui#6771 converged a dozen registrations onto `children`
|
|
38
|
+
* the flag put a FALSE `not-a-container` on the one key they read
|
|
39
|
+
* (objectui#9910). The flag now means LAYOUT containment only — the
|
|
40
|
+
* react-page JSX scope and the public layout ledger read it; this tier does
|
|
41
|
+
* not (objectui#6804, objectui#9910 Q2-A).
|
|
42
|
+
*/
|
|
43
|
+
export function acceptsChildren(comp) {
|
|
44
|
+
return comp.inputs.some((input) => input.name === CHILD_LIST_KEY);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The base props: the `BaseSchema` members this tier accepts on a node without
|
|
48
|
+
* a registration declaring them. ONE list, read by BOTH of its consumers —
|
|
49
|
+
* {@link validateTree}'s `unknown-prop` branch and `generateDts`'s
|
|
50
|
+
* `SduiBaseProps` (objectui#11044). Before this list the two were separate
|
|
51
|
+
* hand-kept copies and had drifted: the validator accepted `bind` and `hidden`
|
|
52
|
+
* (objectui#11008) while the generated types still refused both.
|
|
53
|
+
*
|
|
54
|
+
* `bind` and `hidden` are `'every-node'` (objectui#11008). `BaseSchema` declares
|
|
55
|
+
* both for every node and no registration declares either as an input, so
|
|
56
|
+
* before they joined, the undeclared-key branch below answered every authored
|
|
57
|
+
* one with `unknown-prop` — "has no prop" about a key the protocol declares,
|
|
58
|
+
* on the nodes that honour it: `hidden` is read for every node by
|
|
59
|
+
* `SchemaRenderer`'s hide chain, and `bind` by every renderer that calls
|
|
60
|
+
* `useDataScope` (`list`, `tree-view`, the `object-*` widgets). The
|
|
61
|
+
* declaration outranks the implementation, so the parser's view is the
|
|
62
|
+
* declared type's, not a per-registration subset. The cost is accepted and
|
|
63
|
+
* named: a `bind` on a node that does not read it — `data-table`
|
|
64
|
+
* (objectui#6575) — draws nothing here either, and its render-time console
|
|
65
|
+
* warning is the one signal left.
|
|
66
|
+
*
|
|
67
|
+
* `visibleWhen`, `hiddenOn` and `testId` are `'every-node'` for the same reason
|
|
68
|
+
* (objectui#11044): no registration declares any of them, `SchemaRenderer`'s
|
|
69
|
+
* hide chain reads the first two for every node, and it strips `testId` and
|
|
70
|
+
* re-emits it as `data-testid`. `visibleWhen` is the canonical ADR-0089
|
|
71
|
+
* predicate; before it joined, the deprecated `visibleOn` was silent while it
|
|
72
|
+
* drew `unknown-prop`.
|
|
73
|
+
*
|
|
74
|
+
* The `'where-undeclared'` members (objectui#11044, triage ruling) are the
|
|
75
|
+
* `BaseSchema` members some registrations DECLARE as typed inputs — the input
|
|
76
|
+
* family's `placeholder`, `label`, `name`, … . Skipping them the way the
|
|
77
|
+
* `'every-node'` members are skipped would silence those registrations'
|
|
78
|
+
* `type-mismatch`, so they are base props only where the type declares no
|
|
79
|
+
* input of that name. ⛔ Never move one to `'every-node'` to accept a key: that
|
|
80
|
+
* silences a declared type check.
|
|
81
|
+
*
|
|
82
|
+
* Held over the live registry by `base-props-one-list-11044.test.tsx` in
|
|
83
|
+
* `@object-ui/components` — every member a `BaseSchema` member, and `body` the
|
|
84
|
+
* one member left out.
|
|
85
|
+
*
|
|
86
|
+
* ⛔ `body` is NOT here and must not be added. It was `BaseSchema`'s second
|
|
87
|
+
* child-list spelling until objectui#6771 retired it; teaching this list the
|
|
88
|
+
* key was the option that ruling refused, because it would have blessed a
|
|
89
|
+
* second permanent spelling of one concept. `./body-dialect.ts` answers it by
|
|
90
|
+
* name instead.
|
|
91
|
+
*
|
|
92
|
+
* `children` IS here: the key is legal on every node, so it never draws
|
|
93
|
+
* `unknown-prop` and its declared `slot` input is never type-checked. Whether
|
|
94
|
+
* a given component RENDERS it is the containment question below, answered by
|
|
95
|
+
* {@link acceptsChildren} from the declared input.
|
|
96
|
+
*/
|
|
97
|
+
export const SDUI_BASE_PROPS = Object.freeze([
|
|
98
|
+
{ name: 'type', scope: 'every-node', tsType: null },
|
|
99
|
+
{ name: 'id', scope: 'every-node', tsType: 'string' },
|
|
100
|
+
{ name: 'className', scope: 'every-node', tsType: 'string' },
|
|
101
|
+
{ name: 'style', scope: 'every-node', tsType: 'Record<string, unknown>' },
|
|
102
|
+
{ name: 'visible', scope: 'every-node', tsType: 'boolean' },
|
|
103
|
+
{ name: 'visibleWhen', scope: 'every-node', tsType: 'string' },
|
|
104
|
+
{ name: 'visibleOn', scope: 'every-node', tsType: 'string' },
|
|
105
|
+
{ name: 'hidden', scope: 'every-node', tsType: 'boolean' },
|
|
106
|
+
{ name: 'hiddenOn', scope: 'every-node', tsType: 'string' },
|
|
107
|
+
{ name: 'disabled', scope: 'every-node', tsType: 'boolean' },
|
|
108
|
+
{ name: 'disabledOn', scope: 'every-node', tsType: 'string' },
|
|
109
|
+
{ name: 'bind', scope: 'every-node', tsType: 'string' },
|
|
110
|
+
{ name: 'testId', scope: 'every-node', tsType: 'string' },
|
|
111
|
+
{ name: CHILD_LIST_KEY, scope: 'every-node', tsType: 'unknown' },
|
|
112
|
+
{ name: 'name', scope: 'where-undeclared', tsType: 'string' },
|
|
113
|
+
{ name: 'label', scope: 'where-undeclared', tsType: 'string | Record<string, string>' },
|
|
114
|
+
{ name: 'description', scope: 'where-undeclared', tsType: 'string | Record<string, string>' },
|
|
115
|
+
{ name: 'placeholder', scope: 'where-undeclared', tsType: 'string' },
|
|
116
|
+
{ name: 'data', scope: 'where-undeclared', tsType: 'unknown' },
|
|
117
|
+
{
|
|
118
|
+
name: 'ariaLabel',
|
|
119
|
+
scope: 'where-undeclared',
|
|
120
|
+
tsType: 'string | { key: string; defaultValue?: string; params?: Record<string, unknown> }',
|
|
121
|
+
},
|
|
22
122
|
]);
|
|
123
|
+
const basePropNames = (scope) => new Set(SDUI_BASE_PROPS.filter((prop) => prop.scope === scope).map((prop) => prop.name));
|
|
124
|
+
/** The `'every-node'` members of {@link SDUI_BASE_PROPS}: never "unknown prop". */
|
|
125
|
+
const BASE_PROPS = basePropNames('every-node');
|
|
126
|
+
/** The `'where-undeclared'` members of {@link SDUI_BASE_PROPS}. */
|
|
127
|
+
const WHERE_UNDECLARED_BASE_PROPS = basePropNames('where-undeclared');
|
|
23
128
|
const isExpr = (v) => typeof v === 'object' && v !== null && '$expr' in v;
|
|
24
129
|
export function validateTree(tree, manifest) {
|
|
25
130
|
const diagnostics = [];
|
|
@@ -56,9 +161,23 @@ export function validateTree(tree, manifest) {
|
|
|
56
161
|
for (const [key, value] of Object.entries(node)) {
|
|
57
162
|
if (BASE_PROPS.has(key))
|
|
58
163
|
continue;
|
|
164
|
+
// A declaration outranks a `'where-undeclared'` base prop
|
|
165
|
+
// (objectui#11044): skipped only when this type declares no input of
|
|
166
|
+
// that name, so a declared one keeps its type check below.
|
|
167
|
+
if (WHERE_UNDECLARED_BASE_PROPS.has(key) && !byName.has(key))
|
|
168
|
+
continue;
|
|
59
169
|
const input = byName.get(key);
|
|
60
170
|
if (!input) {
|
|
61
|
-
|
|
171
|
+
// The retired `body` child-list dialect gets its replacement named
|
|
172
|
+
// rather than the bare "has no prop" every typo gets
|
|
173
|
+
// (objectui#6771). Asked INSIDE this branch, so a component that
|
|
174
|
+
// declares its own `body` input keeps its declared type check —
|
|
175
|
+
// mechanism in `./body-dialect.ts`. Its containment verdict is the
|
|
176
|
+
// SAME predicate the `children` branch below reads — the declared
|
|
177
|
+
// `children` input, never `isContainer` (objectui#9910) — so the two
|
|
178
|
+
// spellings cannot disagree about which components take a list.
|
|
179
|
+
const retiredBody = checkRetiredBodyDialect(node.type, key, value, acceptsChildren(comp));
|
|
180
|
+
diagnostics.push(retiredBody ?? {
|
|
62
181
|
severity: 'warning',
|
|
63
182
|
code: 'unknown-prop',
|
|
64
183
|
message: `<${node.type}> has no prop "${key}"`,
|
|
@@ -69,14 +188,64 @@ export function validateTree(tree, manifest) {
|
|
|
69
188
|
if (input.binding) {
|
|
70
189
|
bindings.push({ tag: node.type, input: key, kind: input.binding, value });
|
|
71
190
|
}
|
|
72
|
-
if (
|
|
191
|
+
if (isExpr(value)) {
|
|
192
|
+
// A braced value that failed JSON materialization compiled to the
|
|
193
|
+
// parser's deferred `{ $expr }` marker — and NOTHING downstream
|
|
194
|
+
// evaluates that marker: this tier parses, never executes
|
|
195
|
+
// (ADR-0080), and no renderer consumes `$expr`. The value therefore
|
|
196
|
+
// reaches the renderer as an opaque object, every defensive
|
|
197
|
+
// non-array/non-object read degrades it to "not declared", and the
|
|
198
|
+
// author's binding silently vanishes (objectui#6598: eight `columns`
|
|
199
|
+
// spellings on a data block, all eaten without a single diagnostic —
|
|
200
|
+
// rows rendered, zero data columns). ADR-0078 prohibits exactly this
|
|
201
|
+
// parsed-but-silently-inert state, so name it at compile time, with
|
|
202
|
+
// the fix in the message.
|
|
203
|
+
//
|
|
204
|
+
// The message must name the CURRENT accepted grammar, and objectui#6614
|
|
205
|
+
// (Q1-A, ruled 2026-08-28) moved it: `interpretBrace` now materializes
|
|
206
|
+
// the JS literal subset, so single-quoted strings and unquoted
|
|
207
|
+
// identifier keys REACH the renderer and can no longer draw this
|
|
208
|
+
// warning. The old wording ("write it as JSON, double-quoted") named a
|
|
209
|
+
// now-legal spelling as the illegal one — advice that would have sent
|
|
210
|
+
// an author to edit working source. What is left on this side of the
|
|
211
|
+
// boundary is a genuine expression, so that is what the message names.
|
|
212
|
+
//
|
|
213
|
+
// Warning, not error, per the `8d58f46b4` precedent for inert
|
|
214
|
+
// authored keys. ⛔ Escalation to error is objectui#6614 Q2 and is
|
|
215
|
+
// deliberately NOT part of this change: it belongs at the SAVE GATE,
|
|
216
|
+
// once the framework wires the registry manifest into
|
|
217
|
+
// `validate-jsx-pages` (objectstack#12719 records that gap).
|
|
218
|
+
diagnostics.push({
|
|
219
|
+
severity: 'warning',
|
|
220
|
+
code: 'inert-expression',
|
|
221
|
+
message: `<${node.type}> prop "${key}" is a braced expression this tier never evaluates — ` +
|
|
222
|
+
`the value will be silently ignored at render. This tier materializes LITERALS only ` +
|
|
223
|
+
`(strings, numbers, booleans, null, arrays, objects; quotes may be single or double, ` +
|
|
224
|
+
`object keys may be unquoted), e.g. columns={['name','amount']} works — ` +
|
|
225
|
+
`columns={rows.map((r) => r.name)} cannot`,
|
|
226
|
+
tag: node.type,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
73
230
|
const typeDiag = checkType(node.type, input, value);
|
|
74
|
-
if (typeDiag)
|
|
231
|
+
if (typeDiag) {
|
|
75
232
|
diagnostics.push(typeDiag);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
// Members only once the CONTAINER kind was accepted. Reporting a
|
|
236
|
+
// member of a value that is not even the declared container is two
|
|
237
|
+
// diagnostics for one mistake, and the second one names positions
|
|
238
|
+
// of a shape the author did not write (objectui#8067).
|
|
239
|
+
const memberDiag = checkMemberTypes(node.type, input, value);
|
|
240
|
+
if (memberDiag)
|
|
241
|
+
diagnostics.push(memberDiag);
|
|
242
|
+
}
|
|
76
243
|
}
|
|
77
244
|
}
|
|
78
|
-
// containment
|
|
79
|
-
|
|
245
|
+
// containment — decided by the declared `children` input and by NOTHING
|
|
246
|
+
// else (objectui#9910 Q1-A). ⛔ No `isContainer` fallback: see
|
|
247
|
+
// `acceptsChildren` for why the flag stopped deciding this.
|
|
248
|
+
if (node.children?.length && !acceptsChildren(comp)) {
|
|
80
249
|
diagnostics.push({
|
|
81
250
|
severity: 'warning',
|
|
82
251
|
code: 'not-a-container',
|
|
@@ -86,7 +255,7 @@ export function validateTree(tree, manifest) {
|
|
|
86
255
|
}
|
|
87
256
|
// Dashboard widgets: an `options` key riding the spec's `.passthrough()`
|
|
88
257
|
// that no renderer consumes is legal, silent and inert — warn, naming
|
|
89
|
-
// the consumed set (
|
|
258
|
+
// the consumed set (the 2026-08-23 ruling; census + scope in
|
|
90
259
|
// `./dashboard-widget-options.ts`). Like `not-a-container`, this runs
|
|
91
260
|
// only for a component the manifest knows: an unresolved tag already
|
|
92
261
|
// drew `unknown-component`, and deep diagnostics on it would be noise.
|
|
@@ -148,6 +317,63 @@ function armExpectation(arm, input) {
|
|
|
148
317
|
return 'a string';
|
|
149
318
|
}
|
|
150
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* The member positions of a container value, as `[position, member]` pairs, or
|
|
322
|
+
* `null` when the value has no member position to speak of.
|
|
323
|
+
*
|
|
324
|
+
* Arrays index by position and objects by key, which is exactly the pair
|
|
325
|
+
* `ComponentInput.of` describes: array ELEMENTS, and the VALUES of an object
|
|
326
|
+
* used as a map. A scalar returns `null` rather than an empty list, so a value
|
|
327
|
+
* that only satisfied a non-container arm of a union declaration
|
|
328
|
+
* (`type: ['string', 'array'], of: 'string'`) is not reported as an empty
|
|
329
|
+
* container that trivially conforms — it is simply not the arm `of` describes.
|
|
330
|
+
*/
|
|
331
|
+
function memberEntries(value) {
|
|
332
|
+
if (Array.isArray(value))
|
|
333
|
+
return value.map((member, index) => [String(index), member]);
|
|
334
|
+
if (typeof value === 'object' && value !== null)
|
|
335
|
+
return Object.entries(value);
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Coarse MEMBER check, over the arms `of` declares (objectui#8067).
|
|
340
|
+
*
|
|
341
|
+
* The same question `checkType` asks, one level down and with the same answer
|
|
342
|
+
* shape: ANY declared arm accepting a member clears it, a member no arm accepts
|
|
343
|
+
* is reported, and an input that declares no `of` is checked exactly as it was
|
|
344
|
+
* before the key existed — this function returns immediately on an empty arm
|
|
345
|
+
* list, so nothing published today changes severity or gains a diagnostic.
|
|
346
|
+
*
|
|
347
|
+
* ONE diagnostic per prop, naming every offending position, rather than one per
|
|
348
|
+
* member: a page that passes an array of the wrong member kind is one mistake
|
|
349
|
+
* made once, and N copies of it is the noise this repo treats as the thing that
|
|
350
|
+
* trains authors to dismiss real reports.
|
|
351
|
+
*
|
|
352
|
+
* Severity mirrors `checkType`'s rule for the same reason — `error` when an
|
|
353
|
+
* `enum` arm is present, because a closed list is the one fact this layer can
|
|
354
|
+
* be certain about; `warning` otherwise, since the coarse kind is a KIND claim
|
|
355
|
+
* and `os validate` / `os build` remain the judge of values.
|
|
356
|
+
*/
|
|
357
|
+
function checkMemberTypes(tag, input, value) {
|
|
358
|
+
const arms = inputTypeArms(input.of);
|
|
359
|
+
if (arms.length === 0)
|
|
360
|
+
return null;
|
|
361
|
+
const entries = memberEntries(value);
|
|
362
|
+
if (entries === null)
|
|
363
|
+
return null;
|
|
364
|
+
const offenders = entries.filter(([, member]) => !arms.some((arm) => armAccepts(arm, input, member)));
|
|
365
|
+
if (offenders.length === 0)
|
|
366
|
+
return null;
|
|
367
|
+
const expectation = arms.map((arm) => armExpectation(arm, input)).join(' or ');
|
|
368
|
+
return {
|
|
369
|
+
severity: arms.includes('enum') ? 'error' : 'warning',
|
|
370
|
+
code: 'member-type-mismatch',
|
|
371
|
+
message: `<${tag}> prop "${input.name}" expected every member to be ${expectation}` +
|
|
372
|
+
` — ${offenders.map(([position]) => `[${position}]`).join(', ')} ` +
|
|
373
|
+
`${offenders.length === 1 ? 'is' : 'are'} not`,
|
|
374
|
+
tag,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
151
377
|
/**
|
|
152
378
|
* Coarse type check, over the arms an input declares (objectui#3832).
|
|
153
379
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/sdui-parser",
|
|
3
|
-
"version": "17.
|
|
3
|
+
"version": "17.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
],
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@objectstack/spec": "^17.0.0",
|
|
29
|
-
"@object-ui/core": "17.
|
|
30
|
-
"@object-ui/react": "17.
|
|
29
|
+
"@object-ui/core": "17.7.0",
|
|
30
|
+
"@object-ui/react": "17.7.0"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"build": "tsc",
|
|
34
|
-
"test": "vitest run",
|
|
33
|
+
"build": "tsc && node ../../scripts/check-dist-completeness.mjs",
|
|
34
|
+
"test": "vitest run --root ../.. packages/sdui-parser/",
|
|
35
35
|
"type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
|
|
36
36
|
"lint": "eslint ."
|
|
37
37
|
}
|