@object-ui/sdui-parser 17.5.0 → 17.6.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/codegen.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * fiction — no real React intrinsic named `flex` exists; the interpreter
8
8
  * fulfills it via the registry at render time.
9
9
  */
10
+ import { inputTypeArms } from './input-type.js';
10
11
  export function generateDts(manifest, options = {}) {
11
12
  const { standaloneJsx = true } = options;
12
13
  const comps = Object.values(manifest.components).sort((a, b) => a.type.localeCompare(b.type));
@@ -50,19 +51,35 @@ ${intrinsics}
50
51
  export {};
51
52
  `;
52
53
  }
54
+ /**
55
+ * A `'slot'` arm names a child position, not a prop value — `SduiBaseProps`
56
+ * already types `children`, so a slot input contributes no attribute. An input
57
+ * is therefore emitted when it has at least one NON-slot arm, and typed from
58
+ * those arms only (objectui#3832): the old test was `i.type !== 'slot'`, which a
59
+ * union like `['slot', 'string']` would have passed while `tsType` fell through
60
+ * to the default and typed it `string` anyway.
61
+ */
53
62
  function emitInterface(comp) {
54
63
  const lines = comp.inputs
55
- .filter((i) => i.type !== 'slot')
64
+ .filter((i) => valueArms(i).length > 0)
56
65
  .map((i) => ` ${propLine(i)}`)
57
66
  .join('\n');
58
67
  return `export interface ${propsName(comp.type)} extends SduiBaseProps {\n${lines}\n}`;
59
68
  }
69
+ /** The arms that describe a VALUE (every arm except `'slot'`). */
70
+ const valueArms = (input) => inputTypeArms(input.type).filter((arm) => arm !== 'slot');
60
71
  function propLine(input) {
61
72
  const opt = input.required ? '' : '?';
62
73
  return `${quoteKeyIfNeeded(input.name)}${opt}: ${tsType(input)};`;
63
74
  }
64
- function tsType(input) {
65
- switch (input.type) {
75
+ /**
76
+ * The TypeScript type for one arm. Kinds that are a string with a narrower
77
+ * authoring control (`color`, `date`, `code`, `file`) are `string` here, as
78
+ * before — the JSX surface types the VALUE, and the control kind is the
79
+ * designer's business.
80
+ */
81
+ function armTsType(arm, input) {
82
+ switch (arm) {
66
83
  case 'number':
67
84
  return 'number';
68
85
  case 'boolean':
@@ -75,15 +92,23 @@ function tsType(input) {
75
92
  const vals = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e));
76
93
  return vals.length ? vals.map((v) => JSON.stringify(v)).join(' | ') : 'string';
77
94
  }
78
- case 'string':
79
- case 'color':
80
- case 'date':
81
- case 'code':
82
- case 'file':
83
95
  default:
84
96
  return 'string';
85
97
  }
86
98
  }
99
+ /**
100
+ * A union declaration emits a TypeScript union, so the `.d.ts` an author
101
+ * type-checks their page against accepts exactly the arms the manifest gate
102
+ * accepts. Arms that collapse to the same TS type (`string` and `color`, say)
103
+ * are de-duplicated rather than emitted as `string | string`.
104
+ */
105
+ function tsType(input) {
106
+ const arms = valueArms(input);
107
+ if (arms.length === 0)
108
+ return 'string';
109
+ const emitted = [...new Set(arms.map((arm) => armTsType(arm, input)))];
110
+ return emitted.join(' | ');
111
+ }
87
112
  const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
88
113
  const quoteKeyIfNeeded = (name) => (IDENT.test(name) ? name : JSON.stringify(name));
89
114
  /** 'object-grid' -> 'ObjectGrid', 'record:details' -> 'RecordDetails' */
@@ -0,0 +1,95 @@
1
+ /**
2
+ * ObjectUI — unconsumed dashboard-widget `options` keys (objectui#5709)
3
+ *
4
+ * `@objectstack/spec`'s `DashboardWidgetOptionsSchema` ends in `.passthrough()`
5
+ * ("declared query keys + open renderer extras"), so ANY key parses, validates
6
+ * and lints cleanly — including one no renderer reads. That is how a showcase
7
+ * dashboard shipped `options: { invert: true }` on a gauge with a comment
8
+ * saying what it was believed to do, and rendered the un-inverted measure with
9
+ * no diagnostic anywhere (objectui#5709). The 2026-08-23 maintainer ruling:
10
+ * open extras stay open — they just stop being SILENT. A key that reaches no
11
+ * renderer draws a WARNING naming the consumed set. Not an error: no gate
12
+ * weakening and no new red gates were ruled.
13
+ *
14
+ * ## The census (measured on origin/main @ 8689166f6, spec 17.1.0)
15
+ *
16
+ * The spec REQUIRES `dataset` on every widget (`DashboardWidgetSchema`), and
17
+ * both dashboard surfaces route a dataset-bound widget to `DatasetWidget`
18
+ * (`DashboardRenderer.tsx` renders `<DatasetWidget>` when `widget.dataset` is
19
+ * set; `DashboardGridLayout.tsx` mirrors the fork, objectui#4614). On that —
20
+ * the only spec-legal — path, the renderer-consumed `options` keys are exactly
21
+ * the five the spec DECLARES, all read as direct `options.<key>` accesses in
22
+ * `packages/plugin-dashboard/src/DatasetWidget.tsx`:
23
+ *
24
+ * dateGranularity, sortBy, sortOrder, limit (query-affecting, framework#3588)
25
+ * stageOrder (funnel/pyramid stage order)
26
+ *
27
+ * plus ONE undeclared key with a real read site:
28
+ *
29
+ * description — the metric-card sub-caption channel. Read at
30
+ * `DashboardRenderer.tsx` (`(widget.options as …)?.description`,
31
+ * objectui#4032 item 4); the server's `translateDashboard` OVERLAYS the
32
+ * `widgets.{id}.subCaption` translation onto this key (objectstack#8056,
33
+ * objectstack#5428 item-4: 「两个作者字段两个 key」). Warning on a key the
34
+ * platform's own translation pipeline writes would be a false positive on
35
+ * legal metadata, so it is in the accepted set even though the dataset-bound
36
+ * render path does not currently display it.
37
+ *
38
+ * Notably NOT consumed anywhere in this repository: `thresholds` (zero read
39
+ * sites repo-wide) and `format` — the dataset-bound value is formatted with
40
+ * the MEASURE's own metadata (`measureField(...).format`, from the dataset
41
+ * definition), not with `options.format`. Both were widely believed to work;
42
+ * both draw this warning, which is the point.
43
+ *
44
+ * ## Scope — where the warning deliberately does NOT fire
45
+ *
46
+ * - Widgets WITHOUT `dataset`: the legacy inline forms (`options.data`
47
+ * arrays, `provider: 'object'` bags) consume a much larger, spread-shaped
48
+ * key set (`{ ...options }` into `metric` / `object-metric` /
49
+ * `data-table` / …), whose true reach is each child component's prop
50
+ * surface. That form is spec-illegal today (`dataset` is required) and its
51
+ * census would be the unmaintainable one; skipping it keeps every warning
52
+ * this module emits a statement about the path the widget actually renders
53
+ * through.
54
+ * - Widgets in the legacy COMPONENT format (`widget.component`): `options`
55
+ * is not part of that contract.
56
+ * - Widgets carrying the spec's own escape hatch
57
+ * `suppressWarnings: ['unconsumed-widget-option']` — the spec models
58
+ * per-widget diagnostic suppression (`DashboardWidgetSchema`), so an
59
+ * author with a genuine out-of-band consumer can say so in metadata.
60
+ *
61
+ * ## Maintenance — how this list stays true
62
+ *
63
+ * `__tests__/dashboard-widget-options-census.test.ts` re-runs the census on
64
+ * every test run: it re-derives the five declared keys from the installed
65
+ * `@objectstack/spec`, re-extracts the `options.<key>` reads from
66
+ * `DatasetWidget.tsx` source text (and fails loudly if that file gains a
67
+ * consumption shape the extractor cannot see — a spread, a destructuring, a
68
+ * computed access), re-checks the sub-caption read site, and trips on any NEW
69
+ * file in `packages/*\/src` or `apps/*\/src` that starts reading
70
+ * `widget.options`. A renderer change that adds or removes a consumed key
71
+ * fails that test until this list is updated — the cost of keeping this
72
+ * warning honest is editing ONE array below plus re-reading the census notes.
73
+ */
74
+ import type { Diagnostic, SchemaElement } from './types.js';
75
+ /** The diagnostic `code` — also the id `suppressWarnings` suppresses. */
76
+ export declare const UNCONSUMED_WIDGET_OPTION = "unconsumed-widget-option";
77
+ /**
78
+ * Component types that host a dashboard `widgets` array. Both resolve to the
79
+ * surfaces measured by the census above (`DashboardRenderer`,
80
+ * `DashboardGridLayout`), which share one dispatch (`widgetDispatch.ts`).
81
+ */
82
+ export declare const DASHBOARD_WIDGET_HOST_TYPES: ReadonlySet<string>;
83
+ /**
84
+ * The accepted set: every `options` key with a renderer read site on the
85
+ * dataset-bound path, plus the sub-caption convention key. Alphabetical; the
86
+ * warning message prints it verbatim. Derivation and evidence: file header.
87
+ */
88
+ export declare const CONSUMED_WIDGET_OPTION_KEYS: readonly string[];
89
+ /**
90
+ * Diagnostics for `options` keys no renderer consumes, over one dashboard-host
91
+ * node's `widgets` array. Pure and shallow by design: it never descends into
92
+ * `children` (the caller's walk owns that) and answers `[]` for every shape
93
+ * outside its census — see the scope notes in the file header.
94
+ */
95
+ export declare function checkDashboardWidgetOptions(node: SchemaElement): Diagnostic[];
@@ -0,0 +1,73 @@
1
+ /** The diagnostic `code` — also the id `suppressWarnings` suppresses. */
2
+ export const UNCONSUMED_WIDGET_OPTION = 'unconsumed-widget-option';
3
+ /**
4
+ * Component types that host a dashboard `widgets` array. Both resolve to the
5
+ * surfaces measured by the census above (`DashboardRenderer`,
6
+ * `DashboardGridLayout`), which share one dispatch (`widgetDispatch.ts`).
7
+ */
8
+ export const DASHBOARD_WIDGET_HOST_TYPES = new Set([
9
+ 'dashboard',
10
+ 'dashboard-grid',
11
+ ]);
12
+ /**
13
+ * The accepted set: every `options` key with a renderer read site on the
14
+ * dataset-bound path, plus the sub-caption convention key. Alphabetical; the
15
+ * warning message prints it verbatim. Derivation and evidence: file header.
16
+ */
17
+ export const CONSUMED_WIDGET_OPTION_KEYS = [
18
+ 'dateGranularity',
19
+ 'description',
20
+ 'limit',
21
+ 'sortBy',
22
+ 'sortOrder',
23
+ 'stageOrder',
24
+ ];
25
+ const CONSUMED = new Set(CONSUMED_WIDGET_OPTION_KEYS);
26
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
27
+ /** The parser's deferred-expression marker — opaque here, never evaluated. */
28
+ const isExpr = (v) => isPlainObject(v) && '$expr' in v;
29
+ /**
30
+ * Diagnostics for `options` keys no renderer consumes, over one dashboard-host
31
+ * node's `widgets` array. Pure and shallow by design: it never descends into
32
+ * `children` (the caller's walk owns that) and answers `[]` for every shape
33
+ * outside its census — see the scope notes in the file header.
34
+ */
35
+ export function checkDashboardWidgetOptions(node) {
36
+ if (!DASHBOARD_WIDGET_HOST_TYPES.has(node.type))
37
+ return [];
38
+ const widgets = node.widgets;
39
+ if (!Array.isArray(widgets))
40
+ return [];
41
+ const diagnostics = [];
42
+ widgets.forEach((widget, index) => {
43
+ if (!isPlainObject(widget) || isExpr(widget))
44
+ return;
45
+ // Legacy component format: `options` is not part of that contract.
46
+ if (widget.component !== undefined)
47
+ return;
48
+ // Only the dataset-bound (spec-legal) path is censused — see file header.
49
+ if (widget.dataset === undefined || widget.dataset === null || widget.dataset === '')
50
+ return;
51
+ const options = widget.options;
52
+ if (!isPlainObject(options) || isExpr(options))
53
+ return;
54
+ if (Array.isArray(widget.suppressWarnings) &&
55
+ widget.suppressWarnings.includes(UNCONSUMED_WIDGET_OPTION)) {
56
+ return;
57
+ }
58
+ const label = typeof widget.id === 'string' && widget.id !== '' ? widget.id : `#${index}`;
59
+ const widgetType = typeof widget.type === 'string' && widget.type !== '' ? widget.type : 'widget';
60
+ for (const key of Object.keys(options)) {
61
+ if (CONSUMED.has(key))
62
+ continue;
63
+ diagnostics.push({
64
+ severity: 'warning',
65
+ code: UNCONSUMED_WIDGET_OPTION,
66
+ message: `<${node.type}> widget "${label}" (${widgetType}): options.${key} reaches no renderer — ` +
67
+ `dashboard widget renderers read only: ${CONSUMED_WIDGET_OPTION_KEYS.join(', ')}`,
68
+ tag: node.type,
69
+ });
70
+ }
71
+ });
72
+ return diagnostics;
73
+ }
package/dist/index.d.ts CHANGED
@@ -7,9 +7,12 @@
7
7
  */
8
8
  export * from './types.js';
9
9
  export { parseJsx, interpretBrace } from './parse.js';
10
+ export { HTML_TIER_NODE, isHtmlTierNode, markHtmlTierNode } from './provenance.js';
10
11
  export { validateTree } from './validate.js';
12
+ export { checkDashboardWidgetOptions, CONSUMED_WIDGET_OPTION_KEYS, DASHBOARD_WIDGET_HOST_TYPES, UNCONSUMED_WIDGET_OPTION, } from './dashboard-widget-options.js';
11
13
  export { generateDts, propsName, generateBlockList } from './codegen.js';
12
14
  export type { CodegenOptions } from './codegen.js';
15
+ export { inputTypeArms, canonicalizeInputType, MANIFEST_INPUT_TYPES } from './input-type.js';
13
16
  import type { Diagnostic, Manifest, SchemaElement, ManifestValidationResult } from './types.js';
14
17
  export interface CompileResult {
15
18
  tree: SchemaElement | null;
@@ -34,7 +37,14 @@ export interface RegistryConfigLike {
34
37
  category?: string;
35
38
  inputs?: Array<{
36
39
  name: string;
37
- type: string;
40
+ /**
41
+ * One coarse kind, or the arms of a union (objectui#3832). Typed loosely
42
+ * (`string`) on purpose — this interface is the STRUCTURAL boundary that
43
+ * keeps this package free of a dependency on the registry, so an
44
+ * off-vocabulary value has to be representable here and is normalized by
45
+ * `canonicalizeInputType` on the way in.
46
+ */
47
+ type: string | string[];
38
48
  required?: boolean;
39
49
  enum?: Array<string | {
40
50
  value: unknown;
package/dist/index.js CHANGED
@@ -7,10 +7,14 @@
7
7
  */
8
8
  export * from './types.js';
9
9
  export { parseJsx, interpretBrace } from './parse.js';
10
+ export { HTML_TIER_NODE, isHtmlTierNode, markHtmlTierNode } from './provenance.js';
10
11
  export { validateTree } from './validate.js';
12
+ export { checkDashboardWidgetOptions, CONSUMED_WIDGET_OPTION_KEYS, DASHBOARD_WIDGET_HOST_TYPES, UNCONSUMED_WIDGET_OPTION, } from './dashboard-widget-options.js';
11
13
  export { generateDts, propsName, generateBlockList } from './codegen.js';
14
+ export { inputTypeArms, canonicalizeInputType, MANIFEST_INPUT_TYPES } from './input-type.js';
12
15
  import { parseJsx } from './parse.js';
13
16
  import { validateTree } from './validate.js';
17
+ import { canonicalizeInputType } from './input-type.js';
14
18
  /**
15
19
  * The authoritative pipeline: parse (with the manifest's tags as the whitelist)
16
20
  * → validate against the manifest → derive `requires` + binding sites.
@@ -52,19 +56,10 @@ export function assertFullyLoaded(configs) {
52
56
  `so every prop an author passes them would be reported as unknown.\n` +
53
57
  `Add an eager \`import\` of each block's plugin to the generator entry.`);
54
58
  }
55
- const INPUT_TYPES = new Set([
56
- 'string',
57
- 'number',
58
- 'boolean',
59
- 'enum',
60
- 'array',
61
- 'object',
62
- 'color',
63
- 'date',
64
- 'code',
65
- 'file',
66
- 'slot',
67
- ]);
59
+ /* The arm vocabulary and the two projections over it now live in
60
+ * `input-type.ts` — `manifestFromConfigs` below, `validateTree` and the codegen
61
+ * all read `ManifestInput.type` and must agree on how, since it holds one arm
62
+ * or an array of them (objectui#3832). */
68
63
  /**
69
64
  * Project registry configs into the SDUI manifest.
70
65
  *
@@ -95,7 +90,7 @@ export function manifestFromConfigs(configs, opts = {}) {
95
90
  isContainer: c.isContainer,
96
91
  inputs: (c.inputs ?? []).map((i) => ({
97
92
  name: i.name,
98
- type: (INPUT_TYPES.has(i.type) ? i.type : 'string'),
93
+ type: canonicalizeInputType(i.type),
99
94
  required: i.required,
100
95
  enum: i.enum,
101
96
  binding: i.binding,
@@ -0,0 +1,46 @@
1
+ /**
2
+ * ObjectUI — the arms of a manifest input's coarse type (objectui#3832)
3
+ *
4
+ * `ManifestInput.type` carries ONE coarse kind, or an ARRAY of kinds when the
5
+ * key's contract is a union. Every reader of that field needs the same two
6
+ * decisions made the same way — how to see the arms, and which single form to
7
+ * publish — so they live here once instead of at each call site. A reader that
8
+ * forgets is not loud: `switch (input.type)` handed an array falls through to
9
+ * the default branch and reports NOTHING, which looks exactly like a value that
10
+ * validated cleanly.
11
+ */
12
+ import type { ManifestInput, ManifestInputType } from './types.js';
13
+ /** The eleven coarse kinds, as a runtime set for guarding untyped input. */
14
+ export declare const MANIFEST_INPUT_TYPES: ReadonlySet<string>;
15
+ /**
16
+ * The declared arms of an input's coarse type, always as an array.
17
+ *
18
+ * Exported (not merely internal) so a third-party manifest consumer — a
19
+ * designer panel, a codegen, a validator of its own — reads the arms through
20
+ * the same accessor this package's own gate does, rather than re-deriving the
21
+ * `Array.isArray` branch and getting it subtly wrong on the union form.
22
+ */
23
+ export declare function inputTypeArms(type: ManifestInput['type'] | undefined): ManifestInputType[];
24
+ /**
25
+ * Project a DECLARED type (which may come from an untyped registry config) into
26
+ * the canonical published form: a bare string for one arm, an array for a real
27
+ * union.
28
+ *
29
+ * Two deliberate asymmetries between the single and array forms:
30
+ *
31
+ * - A single unrecognized kind still becomes `'string'`. That coercion predates
32
+ * the union work and is kept exactly: with one arm there is no other
33
+ * information to fall back on, and a manifest whose `type` is off-vocabulary
34
+ * would make every consumer's switch silently inert.
35
+ * - An unrecognized arm INSIDE an array is DROPPED, not coerced. Promoting it
36
+ * to `'string'` would publish an arm the author never declared — a widening
37
+ * invented by the serializer — and the surviving arms already carry the
38
+ * declaration. If dropping empties the array, the single-arm fallback applies
39
+ * so the output is always a valid manifest.
40
+ *
41
+ * Collapsing one arm to the bare string is what keeps this a backward-compatible
42
+ * extension of `sdui.manifest.json`: every input declared today serializes to
43
+ * the byte-identical entry it does now, and arrays appear only where a union was
44
+ * really declared.
45
+ */
46
+ export declare function canonicalizeInputType(type: unknown): ManifestInputType | ManifestInputType[];
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ObjectUI — the arms of a manifest input's coarse type (objectui#3832)
3
+ *
4
+ * `ManifestInput.type` carries ONE coarse kind, or an ARRAY of kinds when the
5
+ * key's contract is a union. Every reader of that field needs the same two
6
+ * decisions made the same way — how to see the arms, and which single form to
7
+ * publish — so they live here once instead of at each call site. A reader that
8
+ * forgets is not loud: `switch (input.type)` handed an array falls through to
9
+ * the default branch and reports NOTHING, which looks exactly like a value that
10
+ * validated cleanly.
11
+ */
12
+ /** The eleven coarse kinds, as a runtime set for guarding untyped input. */
13
+ export const MANIFEST_INPUT_TYPES = new Set([
14
+ 'string',
15
+ 'number',
16
+ 'boolean',
17
+ 'enum',
18
+ 'array',
19
+ 'object',
20
+ 'color',
21
+ 'date',
22
+ 'code',
23
+ 'file',
24
+ 'slot',
25
+ ]);
26
+ /**
27
+ * The declared arms of an input's coarse type, always as an array.
28
+ *
29
+ * Exported (not merely internal) so a third-party manifest consumer — a
30
+ * designer panel, a codegen, a validator of its own — reads the arms through
31
+ * the same accessor this package's own gate does, rather than re-deriving the
32
+ * `Array.isArray` branch and getting it subtly wrong on the union form.
33
+ */
34
+ export function inputTypeArms(type) {
35
+ if (type === undefined)
36
+ return [];
37
+ return Array.isArray(type) ? type : [type];
38
+ }
39
+ /**
40
+ * Project a DECLARED type (which may come from an untyped registry config) into
41
+ * the canonical published form: a bare string for one arm, an array for a real
42
+ * union.
43
+ *
44
+ * Two deliberate asymmetries between the single and array forms:
45
+ *
46
+ * - A single unrecognized kind still becomes `'string'`. That coercion predates
47
+ * the union work and is kept exactly: with one arm there is no other
48
+ * information to fall back on, and a manifest whose `type` is off-vocabulary
49
+ * would make every consumer's switch silently inert.
50
+ * - An unrecognized arm INSIDE an array is DROPPED, not coerced. Promoting it
51
+ * to `'string'` would publish an arm the author never declared — a widening
52
+ * invented by the serializer — and the surviving arms already carry the
53
+ * declaration. If dropping empties the array, the single-arm fallback applies
54
+ * so the output is always a valid manifest.
55
+ *
56
+ * Collapsing one arm to the bare string is what keeps this a backward-compatible
57
+ * extension of `sdui.manifest.json`: every input declared today serializes to
58
+ * the byte-identical entry it does now, and arrays appear only where a union was
59
+ * really declared.
60
+ */
61
+ export function canonicalizeInputType(type) {
62
+ const arms = (Array.isArray(type) ? type : [type]).filter((arm) => typeof arm === 'string' && MANIFEST_INPUT_TYPES.has(arm));
63
+ const distinct = [...new Set(arms)];
64
+ if (distinct.length === 0)
65
+ return 'string';
66
+ if (distinct.length === 1)
67
+ return distinct[0];
68
+ return distinct;
69
+ }
package/dist/parse.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * child := element | text | jsx-block-comment
14
14
  * tag := [A-Za-z][A-Za-z0-9:_-]star (matches registry keys)
15
15
  */
16
+ import { markHtmlTierNode } from './provenance.js';
16
17
  /** Event handlers and raw-HTML injection are never allowed (parse ≠ execute). */
17
18
  const EVENT_ATTR = /^on[A-Z]/;
18
19
  const FORBIDDEN_ATTRS = new Set(['dangerouslySetInnerHTML', 'ref', 'key']);
@@ -98,7 +99,15 @@ class Parser {
98
99
  else {
99
100
  this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
100
101
  }
101
- const node = { type: tag, ...props };
102
+ // Stamp html-tier provenance at the point of production (objectui#4000).
103
+ // Renderers whose type carries authoring advice aimed at the JSON surface
104
+ // read this to tell "the author wrote a tag in OUR tier, and there is no
105
+ // other spelling available to them" apart from "the author wrote this type
106
+ // in JSON, where the advice applies". Symbol-keyed, so it is invisible to
107
+ // JSON, to the DOM, and to anything an authored document could forge — see
108
+ // provenance.ts. Values reached through a braced attribute are NOT marked:
109
+ // that JSON was written by hand, and the JSON surface's advice does apply.
110
+ const node = markHtmlTierNode({ type: tag, ...props });
102
111
  if (children && children.length)
103
112
  node.children = children;
104
113
  return node;
@@ -178,10 +187,31 @@ class Parser {
178
187
  continue;
179
188
  }
180
189
  // text
190
+ //
191
+ // HTML collapses a whitespace run to ONE space; it does not delete it. A
192
+ // bare `.trim()` here deleted the space that separates a text run from an
193
+ // adjacent sibling element, so `A <strong>x</strong> page` compiled to
194
+ // `A`/`page` and rendered as `Axpage` (objectui#5661). The rule: collapse
195
+ // the run, then keep a single leading space only when a sibling precedes
196
+ // the run, and a single trailing space only when a sibling element
197
+ // follows it. At the parent's own start/end the edge space is still
198
+ // dropped, so `<p> hi </p>` stays `hi`. A whitespace-only run survives
199
+ // as one space only when it sits BETWEEN siblings — that is the bounded
200
+ // over-generosity of this rule inside block containers like `<ul>`,
201
+ // pinned in the tests.
181
202
  const text = this.readTextRun();
182
- const trimmed = text.replace(/\s+/g, ' ').trim();
183
- if (trimmed)
184
- children.push(trimmed);
203
+ const collapsed = text.replace(/\s+/g, ' ');
204
+ const core = collapsed.trim();
205
+ const afterSibling = children.length > 0;
206
+ const beforeElement = this.peek() === '<' && !this.src.startsWith('</', this.pos);
207
+ if (core) {
208
+ const lead = afterSibling && collapsed.startsWith(' ') ? ' ' : '';
209
+ const trail = beforeElement && collapsed.endsWith(' ') ? ' ' : '';
210
+ children.push(`${lead}${core}${trail}`);
211
+ }
212
+ else if (collapsed && afterSibling && beforeElement) {
213
+ children.push(' ');
214
+ }
185
215
  }
186
216
  return children;
187
217
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ObjectUI — html-tier provenance (ADR-0080, objectui#4000)
3
+ *
4
+ * Records the one fact a renderer cannot otherwise recover: whether the node it
5
+ * is about to render was EMITTED BY THIS PARSER (an author's `kind:'html'`
6
+ * source, compiled tag-name-straight-through) or AUTHORED AS JSON metadata.
7
+ *
8
+ * Why a renderer needs it: the two surfaces have different vocabularies. A type
9
+ * the JSON surface is steering authors away from can still be a first-class,
10
+ * permanently-supported tag in the html tier — the plain box element is exactly
11
+ * that. Advice aimed at the JSON surface, delivered to an html-tier author, is
12
+ * advice they cannot act on: in that tier there is no other spelling. The
13
+ * deprecation notice fired at them anyway (objectui#4000), which is also why the
14
+ * type could never be retired — the engine's own compiler keeps emitting it.
15
+ *
16
+ * The judgement therefore has to be PROVENANCE, established by the producer,
17
+ * not a guess made at the consumer from the node's shape.
18
+ *
19
+ * ## Why a symbol, and why `Symbol.for`
20
+ *
21
+ * The marker is deliberately NOT a JSON-visible key:
22
+ *
23
+ * - `JSON.stringify` skips symbol keys, so provenance never lands in a
24
+ * persisted tree. That is the point, not a limitation: "our parser produced
25
+ * this node in this process" is not a fact about the saved document, and a
26
+ * saved document must not be able to REPLAY it. A string key like
27
+ * `_provenance: 'html'` would be copyable — an authored (or AI-generated)
28
+ * JSON page could paste it in and buy itself silence from a notice that is
29
+ * meant for it. Provenance that authored metadata can forge is not
30
+ * provenance.
31
+ * - `Object.keys` / `for...in` skip symbol keys, so the marker cannot leak out
32
+ * of a renderer onto a host element as a stray DOM attribute.
33
+ *
34
+ * It IS enumerable, which matters more than it looks: `SchemaRenderer` hands
35
+ * renderers a shallow copy of the node (`{ ...schema }`), and object spread
36
+ * copies own ENUMERABLE symbol keys while dropping non-enumerable ones. A
37
+ * non-enumerable marker would be lost in that copy and the misfire would come
38
+ * back — silently, and only in the render path. `render.test.tsx` and the
39
+ * components-side pin both go through the real `SchemaRenderer` for this reason.
40
+ *
41
+ * `Symbol.for` (the cross-realm registry) rather than a module-local `Symbol()`:
42
+ * this package ships as both ESM and CJS and is consumed from several packages,
43
+ * so two module instances are possible. Two local symbols would not compare
44
+ * equal, and the failure mode of that mismatch is the ORIGINAL BUG returning
45
+ * with no error anywhere. A registry key cannot drift.
46
+ */
47
+ /**
48
+ * The provenance marker itself. Exported for tests and for any consumer that
49
+ * needs to reason about the key; prefer {@link isHtmlTierNode} for reading.
50
+ */
51
+ export declare const HTML_TIER_NODE: symbol;
52
+ /**
53
+ * Stamp a node as html-tier output. Called by the parser as it builds each
54
+ * element — the producer is the only place that knows this for certain.
55
+ */
56
+ export declare function markHtmlTierNode<T extends object>(node: T): T;
57
+ /**
58
+ * True when this node came out of the html tier's parser.
59
+ *
60
+ * Deliberately a plain boolean read of the marker — no fallback to a
61
+ * string-keyed spelling, no shape heuristic ("looks like a raw tag"). Either
62
+ * the producer marked it or it did not; a consumer-side guess is precisely what
63
+ * this replaces.
64
+ */
65
+ export declare function isHtmlTierNode(node: unknown): boolean;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * ObjectUI — html-tier provenance (ADR-0080, objectui#4000)
3
+ *
4
+ * Records the one fact a renderer cannot otherwise recover: whether the node it
5
+ * is about to render was EMITTED BY THIS PARSER (an author's `kind:'html'`
6
+ * source, compiled tag-name-straight-through) or AUTHORED AS JSON metadata.
7
+ *
8
+ * Why a renderer needs it: the two surfaces have different vocabularies. A type
9
+ * the JSON surface is steering authors away from can still be a first-class,
10
+ * permanently-supported tag in the html tier — the plain box element is exactly
11
+ * that. Advice aimed at the JSON surface, delivered to an html-tier author, is
12
+ * advice they cannot act on: in that tier there is no other spelling. The
13
+ * deprecation notice fired at them anyway (objectui#4000), which is also why the
14
+ * type could never be retired — the engine's own compiler keeps emitting it.
15
+ *
16
+ * The judgement therefore has to be PROVENANCE, established by the producer,
17
+ * not a guess made at the consumer from the node's shape.
18
+ *
19
+ * ## Why a symbol, and why `Symbol.for`
20
+ *
21
+ * The marker is deliberately NOT a JSON-visible key:
22
+ *
23
+ * - `JSON.stringify` skips symbol keys, so provenance never lands in a
24
+ * persisted tree. That is the point, not a limitation: "our parser produced
25
+ * this node in this process" is not a fact about the saved document, and a
26
+ * saved document must not be able to REPLAY it. A string key like
27
+ * `_provenance: 'html'` would be copyable — an authored (or AI-generated)
28
+ * JSON page could paste it in and buy itself silence from a notice that is
29
+ * meant for it. Provenance that authored metadata can forge is not
30
+ * provenance.
31
+ * - `Object.keys` / `for...in` skip symbol keys, so the marker cannot leak out
32
+ * of a renderer onto a host element as a stray DOM attribute.
33
+ *
34
+ * It IS enumerable, which matters more than it looks: `SchemaRenderer` hands
35
+ * renderers a shallow copy of the node (`{ ...schema }`), and object spread
36
+ * copies own ENUMERABLE symbol keys while dropping non-enumerable ones. A
37
+ * non-enumerable marker would be lost in that copy and the misfire would come
38
+ * back — silently, and only in the render path. `render.test.tsx` and the
39
+ * components-side pin both go through the real `SchemaRenderer` for this reason.
40
+ *
41
+ * `Symbol.for` (the cross-realm registry) rather than a module-local `Symbol()`:
42
+ * this package ships as both ESM and CJS and is consumed from several packages,
43
+ * so two module instances are possible. Two local symbols would not compare
44
+ * equal, and the failure mode of that mismatch is the ORIGINAL BUG returning
45
+ * with no error anywhere. A registry key cannot drift.
46
+ */
47
+ /** Registry key for the marker. Stable — it is a cross-instance contract. */
48
+ const HTML_TIER_NODE_KEY = '@object-ui/sdui-parser.html-tier-node';
49
+ /**
50
+ * The provenance marker itself. Exported for tests and for any consumer that
51
+ * needs to reason about the key; prefer {@link isHtmlTierNode} for reading.
52
+ */
53
+ export const HTML_TIER_NODE = Symbol.for(HTML_TIER_NODE_KEY);
54
+ /**
55
+ * Stamp a node as html-tier output. Called by the parser as it builds each
56
+ * element — the producer is the only place that knows this for certain.
57
+ */
58
+ export function markHtmlTierNode(node) {
59
+ node[HTML_TIER_NODE] = true;
60
+ return node;
61
+ }
62
+ /**
63
+ * True when this node came out of the html tier's parser.
64
+ *
65
+ * Deliberately a plain boolean read of the marker — no fallback to a
66
+ * string-keyed spelling, no shape heuristic ("looks like a raw tag"). Either
67
+ * the producer marked it or it did not; a consumer-side guess is precisely what
68
+ * this replaces.
69
+ */
70
+ export function isHtmlTierNode(node) {
71
+ return (typeof node === 'object' &&
72
+ node !== null &&
73
+ node[HTML_TIER_NODE] === true);
74
+ }
package/dist/types.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * ObjectUI — SDUI JSX-source parser (ADR-0080)
3
3
  *
4
4
  * Types shared by the constrained JSX-source compiler. The parser turns a
5
- * constrained JSX/HTML+Tailwind *text* into the existing SDUI `SchemaNode`
5
+ * constrained JSX *text* into the existing SDUI `SchemaNode`
6
6
  * tree. It PARSES — it never executes. No `import`, no `eval`, no JS.
7
7
  */
8
8
  /** A node in the compiled SDUI tree. Mirrors `@object-ui/types` BaseSchema. */
@@ -39,7 +39,20 @@ export interface ParseResult {
39
39
  export type ManifestInputType = 'string' | 'number' | 'boolean' | 'enum' | 'array' | 'object' | 'color' | 'date' | 'code' | 'file' | 'slot';
40
40
  export interface ManifestInput {
41
41
  name: string;
42
- type: ManifestInputType;
42
+ /**
43
+ * The input's coarse type: ONE kind, or an ARRAY of kinds when the key's
44
+ * contract is a union (objectui#3832).
45
+ *
46
+ * A value passes {@link validateTree}'s coarse check when ANY arm accepts it,
47
+ * and is reported when none does — the array widens what is legal, it does
48
+ * not switch the check off.
49
+ *
50
+ * The single-kind form is unchanged and stays the canonical spelling for a
51
+ * one-arm key: `manifestFromConfigs` collapses a one-element array back to
52
+ * the bare string, so a manifest gains arrays only where a union was really
53
+ * declared and every already-published entry serializes byte-identically.
54
+ */
55
+ type: ManifestInputType | ManifestInputType[];
43
56
  required?: boolean;
44
57
  /** allowed values for `enum` inputs */
45
58
  enum?: Array<string | {
package/dist/types.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * ObjectUI — SDUI JSX-source parser (ADR-0080)
3
3
  *
4
4
  * Types shared by the constrained JSX-source compiler. The parser turns a
5
- * constrained JSX/HTML+Tailwind *text* into the existing SDUI `SchemaNode`
5
+ * constrained JSX *text* into the existing SDUI `SchemaNode`
6
6
  * tree. It PARSES — it never executes. No `import`, no `eval`, no JS.
7
7
  */
8
8
  export {};
package/dist/validate.js CHANGED
@@ -6,6 +6,8 @@
6
6
  * and binding sites the SERVER must resolve against object schema (we cannot
7
7
  * resolve objects/fields here — that check is framework-side by design).
8
8
  */
9
+ import { inputTypeArms } from './input-type.js';
10
+ import { checkDashboardWidgetOptions } from './dashboard-widget-options.js';
9
11
  /** Base props every node may carry (mirrors BaseSchema) — never "unknown prop". */
10
12
  const BASE_PROPS = new Set([
11
13
  'type',
@@ -82,6 +84,13 @@ export function validateTree(tree, manifest) {
82
84
  tag: node.type,
83
85
  });
84
86
  }
87
+ // Dashboard widgets: an `options` key riding the spec's `.passthrough()`
88
+ // that no renderer consumes is legal, silent and inert — warn, naming
89
+ // the consumed set (objectui#5709 ruling; census + scope in
90
+ // `./dashboard-widget-options.ts`). Like `not-a-container`, this runs
91
+ // only for a component the manifest knows: an unresolved tag already
92
+ // drew `unknown-component`, and deep diagnostics on it would be noise.
93
+ diagnostics.push(...checkDashboardWidgetOptions(node));
85
94
  }
86
95
  if (node.children)
87
96
  node.children.forEach(visit);
@@ -90,42 +99,98 @@ export function validateTree(tree, manifest) {
90
99
  visit(tree);
91
100
  return { diagnostics, requires: [...requires], bindings };
92
101
  }
93
- function checkType(tag, input, value) {
94
- const mismatch = (expected) => ({
95
- severity: 'warning',
96
- code: 'type-mismatch',
97
- message: `<${tag}> prop "${input.name}" expected ${expected}`,
98
- tag,
99
- });
100
- switch (input.type) {
102
+ /** The values an `enum` arm admits, flattened from either declaration form. */
103
+ const enumValues = (input) => (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e));
104
+ /**
105
+ * Does ONE coarse arm accept this value?
106
+ *
107
+ * An arm outside the vocabulary — and `'slot'`, which describes a child
108
+ * position rather than a value — accepts everything, preserving the old
109
+ * `default: return null` branch: those inputs never drew a diagnostic and must
110
+ * not start now.
111
+ */
112
+ function armAccepts(arm, input, value) {
113
+ switch (arm) {
101
114
  case 'number':
102
- return typeof value === 'number' ? null : mismatch('a number');
115
+ return typeof value === 'number';
103
116
  case 'boolean':
104
- return typeof value === 'boolean' ? null : mismatch('a boolean');
117
+ return typeof value === 'boolean';
105
118
  case 'string':
106
119
  case 'color':
107
120
  case 'date':
108
121
  case 'code':
109
122
  case 'file':
110
- return typeof value === 'string' ? null : mismatch('a string');
123
+ return typeof value === 'string';
111
124
  case 'array':
112
- return Array.isArray(value) ? null : mismatch('an array');
125
+ return Array.isArray(value);
113
126
  case 'object':
114
- return typeof value === 'object' && value !== null && !Array.isArray(value)
115
- ? null
116
- : mismatch('an object');
117
- case 'enum': {
118
- const allowed = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e));
119
- return allowed.includes(value)
120
- ? null
121
- : {
122
- severity: 'error',
123
- code: 'invalid-enum',
124
- message: `<${tag}> prop "${input.name}"=${JSON.stringify(value)} is not one of ${JSON.stringify(allowed)}`,
125
- tag,
126
- };
127
- }
127
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
128
+ case 'enum':
129
+ return enumValues(input).includes(value);
128
130
  default:
129
- return null;
131
+ return true;
130
132
  }
131
133
  }
134
+ /** How one arm is named in a diagnostic message. */
135
+ function armExpectation(arm, input) {
136
+ switch (arm) {
137
+ case 'number':
138
+ return 'a number';
139
+ case 'boolean':
140
+ return 'a boolean';
141
+ case 'array':
142
+ return 'an array';
143
+ case 'object':
144
+ return 'an object';
145
+ case 'enum':
146
+ return `one of ${JSON.stringify(enumValues(input))}`;
147
+ default:
148
+ return 'a string';
149
+ }
150
+ }
151
+ /**
152
+ * Coarse type check, over the arms an input declares (objectui#3832).
153
+ *
154
+ * ANY arm accepting the value clears the prop — that is what lets a key whose
155
+ * contract is a union (`string | number`, or a string plus an inline
156
+ * translation map) be declared honestly instead of picking one arm and having
157
+ * this function report the other arm's legal values.
158
+ *
159
+ * When NO arm accepts it the prop is still reported; a union widens what counts
160
+ * as legal, it does not turn the check off. Two properties of the reporting are
161
+ * deliberate:
162
+ *
163
+ * - A single-arm input produces the byte-identical diagnostic it always did,
164
+ * `invalid-enum` included. This change adds a form; it does not restate the
165
+ * old one.
166
+ * - A multi-arm input produces ONE diagnostic naming every arm, at the
167
+ * STRICTEST arm's severity — `error` when an `enum` arm is present, because
168
+ * an enum's closed list is the one fact this layer can be certain about, and
169
+ * a value outside it should not become dismissible merely because a second
170
+ * arm was added next to it. Its code is `type-mismatch` (not `invalid-enum`)
171
+ * since the reported fact is "fits none of the declared arms", and the
172
+ * message carries the allowed values so the author still sees the list.
173
+ */
174
+ function checkType(tag, input, value) {
175
+ const arms = inputTypeArms(input.type);
176
+ if (arms.length === 0)
177
+ return null;
178
+ if (arms.some((arm) => armAccepts(arm, input, value)))
179
+ return null;
180
+ if (arms.length === 1 && arms[0] === 'enum') {
181
+ return {
182
+ severity: 'error',
183
+ code: 'invalid-enum',
184
+ message: `<${tag}> prop "${input.name}"=${JSON.stringify(value)} is not one of ${JSON.stringify(enumValues(input))}`,
185
+ tag,
186
+ };
187
+ }
188
+ return {
189
+ severity: arms.includes('enum') ? 'error' : 'warning',
190
+ code: 'type-mismatch',
191
+ message: `<${tag}> prop "${input.name}" expected ${arms
192
+ .map((arm) => armExpectation(arm, input))
193
+ .join(' or ')}`,
194
+ tag,
195
+ };
196
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/sdui-parser",
3
- "version": "17.5.0",
3
+ "version": "17.6.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -25,9 +25,9 @@
25
25
  "dist"
26
26
  ],
27
27
  "devDependencies": {
28
- "@objectstack/spec": "^17.0.0-rc.6",
29
- "@object-ui/core": "17.5.0",
30
- "@object-ui/react": "17.5.0"
28
+ "@objectstack/spec": "^17.0.0",
29
+ "@object-ui/core": "17.6.0",
30
+ "@object-ui/react": "17.6.0"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc",