@object-ui/sdui-parser 17.5.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 +123 -32
- package/dist/dashboard-widget-options.d.ts +129 -0
- package/dist/dashboard-widget-options.js +73 -0
- package/dist/index.d.ts +62 -5
- package/dist/index.js +24 -15
- package/dist/input-type.d.ts +46 -0
- package/dist/input-type.js +69 -0
- package/dist/parse.d.ts +12 -3
- package/dist/parse.js +357 -9
- package/dist/provenance.d.ts +68 -0
- package/dist/provenance.js +77 -0
- package/dist/types.d.ts +117 -6
- package/dist/types.js +1 -1
- package/dist/validate.d.ts +111 -4
- package/dist/validate.js +337 -46
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export * from './types.js';
|
|
9
9
|
export { parseJsx, interpretBrace } from './parse.js';
|
|
10
|
-
export {
|
|
10
|
+
export { HTML_TIER_NODE, isHtmlTierNode, markHtmlTierNode } from './provenance.js';
|
|
11
|
+
export { validateTree, acceptsChildren, CHILD_LIST_KEY, SDUI_BASE_PROPS } 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';
|
|
15
|
+
export { checkRetiredBodyDialect, RETIRED_CHILD_LIST_KEY } from './body-dialect.js';
|
|
12
16
|
import { parseJsx } from './parse.js';
|
|
13
17
|
import { validateTree } from './validate.js';
|
|
18
|
+
import { canonicalizeInputType } from './input-type.js';
|
|
14
19
|
/**
|
|
15
20
|
* The authoritative pipeline: parse (with the manifest's tags as the whitelist)
|
|
16
21
|
* → validate against the manifest → derive `requires` + binding sites.
|
|
@@ -52,19 +57,10 @@ export function assertFullyLoaded(configs) {
|
|
|
52
57
|
`so every prop an author passes them would be reported as unknown.\n` +
|
|
53
58
|
`Add an eager \`import\` of each block's plugin to the generator entry.`);
|
|
54
59
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
'enum',
|
|
60
|
-
'array',
|
|
61
|
-
'object',
|
|
62
|
-
'color',
|
|
63
|
-
'date',
|
|
64
|
-
'code',
|
|
65
|
-
'file',
|
|
66
|
-
'slot',
|
|
67
|
-
]);
|
|
60
|
+
/* The arm vocabulary and the two projections over it now live in
|
|
61
|
+
* `input-type.ts` — `manifestFromConfigs` below, `validateTree` and the codegen
|
|
62
|
+
* all read `ManifestInput.type` and must agree on how, since it holds one arm
|
|
63
|
+
* or an array of them (objectui#3832). */
|
|
68
64
|
/**
|
|
69
65
|
* Project registry configs into the SDUI manifest.
|
|
70
66
|
*
|
|
@@ -93,9 +89,22 @@ export function manifestFromConfigs(configs, opts = {}) {
|
|
|
93
89
|
type: c.type,
|
|
94
90
|
namespace: c.namespace,
|
|
95
91
|
isContainer: c.isContainer,
|
|
92
|
+
// The html tier's stamp, and ONLY that stamp (objectui#10735): a
|
|
93
|
+
// registration's `'public'` / `'internal'` is registry mechanics the
|
|
94
|
+
// manifest never carried, and `undefined` is dropped by `JSON.stringify`,
|
|
95
|
+
// so every curated entry serialises exactly as before this key existed.
|
|
96
|
+
tier: c.tier === 'html' ? 'html' : undefined,
|
|
96
97
|
inputs: (c.inputs ?? []).map((i) => ({
|
|
97
98
|
name: i.name,
|
|
98
|
-
type: (
|
|
99
|
+
type: canonicalizeInputType(i.type),
|
|
100
|
+
// Undefined stays undefined rather than going through
|
|
101
|
+
// `canonicalizeInputType`, whose no-arms fallback is `'string'`: an
|
|
102
|
+
// input that declares no member kind must publish NO `of`, or every
|
|
103
|
+
// array in every manifest would suddenly claim string members it was
|
|
104
|
+
// never told it had. `JSON.stringify` drops the undefined key, so the
|
|
105
|
+
// published artifact is byte-identical for every input that does not
|
|
106
|
+
// declare one (objectui#8067).
|
|
107
|
+
of: i.of === undefined ? undefined : canonicalizeInputType(i.of),
|
|
99
108
|
required: i.required,
|
|
100
109
|
enum: i.enum,
|
|
101
110
|
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.d.ts
CHANGED
|
@@ -17,8 +17,17 @@ import type { ParseOptions, ParseResult } from './types.js';
|
|
|
17
17
|
export declare function parseJsx(source: string, options?: ParseOptions): ParseResult;
|
|
18
18
|
/**
|
|
19
19
|
* Interpret a braced attribute value `{...}`.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
*
|
|
21
|
+
* Strict-JSON values are materialized by `JSON.parse`, exactly as they always
|
|
22
|
+
* were. Beyond that, the JS **literal subset** below is materialized too
|
|
23
|
+
* (objectui#6614 Q1-A, maintainer ruling 2026-08-28). Anything left over — a
|
|
24
|
+
* genuine expression — is kept as the deferred marker `{ $expr }`: typed and
|
|
25
|
+
* validated later, drawing `inert-expression`, and NEVER evaluated here.
|
|
26
|
+
*
|
|
27
|
+
* ORDER IS LOAD-BEARING. `JSON.parse` runs FIRST and is untouched, so every
|
|
28
|
+
* input JSON accepts takes byte-identically the path it took before the literal
|
|
29
|
+
* subset existed. The reader below only ever sees strings `JSON.parse` has
|
|
30
|
+
* already thrown on, which makes strict-JSON invariance a property of the
|
|
31
|
+
* structure rather than of a test.
|
|
23
32
|
*/
|
|
24
33
|
export declare function interpretBrace(raw: string): unknown;
|
package/dist/parse.js
CHANGED
|
@@ -13,9 +13,51 @@
|
|
|
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']);
|
|
20
|
+
/**
|
|
21
|
+
* The envelope's own discriminator, which on THIS tier the tag name sets.
|
|
22
|
+
*
|
|
23
|
+
* An authored `type=` attribute is a NAME COLLISION with it, and the parser
|
|
24
|
+
* refuses it at parse time (maintainer ruling 2026-09-01, recorded as an
|
|
25
|
+
* append-only amendment on ADR-0080 — 「响亮拒绝」, quoted verbatim there;
|
|
26
|
+
* objectui#7235 ports it to this copy, objectstack#13957 landed the other).
|
|
27
|
+
* One diagnostic naming BOTH the tag and the attribute replaces two bad
|
|
28
|
+
* outcomes:
|
|
29
|
+
*
|
|
30
|
+
* - the value named another REGISTERED type (`<flex type="grid">`) — the tree
|
|
31
|
+
* carried `type:'grid'`, `validateTree` found `grid` in the manifest, every
|
|
32
|
+
* check passed, and the page rendered a grid where the author wrote a flex.
|
|
33
|
+
* ZERO diagnostics. On the one tier whose whole premise is that unreviewed
|
|
34
|
+
* and AI-authored source is safe to accept.
|
|
35
|
+
* - the value named NOTHING registered (`<object-chart type="bar">`, the shape
|
|
36
|
+
* a react-tier author carries across) — loud, but `unknown-component`
|
|
37
|
+
* naming `"bar"` reads as a missing plugin, never as a bad prop.
|
|
38
|
+
*
|
|
39
|
+
* ⛔ NOT rescued as `specType` the way the react tier rescues it (objectui#2880):
|
|
40
|
+
* that is consumer-side tolerance, and it would spread an alias concept to a
|
|
41
|
+
* second tier. ⛔ NOT a warning grace period either — the same ruling declined
|
|
42
|
+
* a staged rollout. ⛔ And NOT fixable at the warning layer: `type` is in
|
|
43
|
+
* `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
|
|
44
|
+
* member), so removing it there would make every legitimate node warn. The
|
|
45
|
+
* refusal belongs here, at parse.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ The code is the EXISTING `forbidden-attr`, not a new one, and that is
|
|
48
|
+
* load-bearing rather than lazy: objectstack's `scripts/check-sdui-lockstep.mjs`
|
|
49
|
+
* holds ITS copy's diagnostic-code set equal to THIS one's at the pinned
|
|
50
|
+
* revision, so a code minted on one side only IS the dialect split that gate
|
|
51
|
+
* exists to catch (objectstack#12719). `forbidden-attr` already carries this
|
|
52
|
+
* shape — an attribute this tier refuses, named beside its element — and both
|
|
53
|
+
* copies stamp it.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ This copy is the RENDERER's (and the console's live edit preview); the
|
|
56
|
+
* save gate runs objectstack's. Until this landed the two differed in accepted
|
|
57
|
+
* grammar: a page objectstack refused still compiled silently while its author
|
|
58
|
+
* was typing.
|
|
59
|
+
*/
|
|
60
|
+
const DISCRIMINATOR_ATTR = 'type';
|
|
19
61
|
export function parseJsx(source, options = {}) {
|
|
20
62
|
return new Parser(source, options).parseDocument();
|
|
21
63
|
}
|
|
@@ -84,7 +126,16 @@ class Parser {
|
|
|
84
126
|
const attr = this.parseAttr(start, tag);
|
|
85
127
|
if (!attr)
|
|
86
128
|
break;
|
|
87
|
-
|
|
129
|
+
// `drop` is set only for the refused discriminator attribute, and only so
|
|
130
|
+
// that ONE diagnostic is what the author gets. The `__forbidden_<name>`
|
|
131
|
+
// sentinel the other refusals park in `props` reaches `validateTree`,
|
|
132
|
+
// which knows no such prop and adds `unknown-prop` naming a key nobody
|
|
133
|
+
// wrote — loud, and pointing at the wrong thing, which is the species of
|
|
134
|
+
// diagnostic this whole change exists to remove. The existing sentinel
|
|
135
|
+
// behaviour is left exactly as it was for the attributes that already had
|
|
136
|
+
// it (`ref`, `key`, `dangerouslySetInnerHTML`, `on*`).
|
|
137
|
+
if (!attr.drop)
|
|
138
|
+
props[attr.name] = attr.value;
|
|
88
139
|
}
|
|
89
140
|
this.skipWs();
|
|
90
141
|
let children;
|
|
@@ -98,7 +149,30 @@ class Parser {
|
|
|
98
149
|
else {
|
|
99
150
|
this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
|
|
100
151
|
}
|
|
101
|
-
|
|
152
|
+
// Stamp html-tier provenance at the point of production (objectui#4000).
|
|
153
|
+
// Renderers whose type carries authoring advice aimed at the JSON surface
|
|
154
|
+
// read this to tell "the author wrote a tag in OUR tier, and there is no
|
|
155
|
+
// other spelling available to them" apart from "the author wrote this type
|
|
156
|
+
// in JSON, where the advice applies". Symbol-keyed, so it is invisible to
|
|
157
|
+
// JSON, to the DOM, and to anything an authored document could forge — see
|
|
158
|
+
// provenance.ts. Values reached through a braced attribute are NOT marked:
|
|
159
|
+
// that JSON was written by hand, and the JSON surface's advice does apply.
|
|
160
|
+
// DEFENSE IN DEPTH (ruled together with the refusal above). `props` used to
|
|
161
|
+
// be spread AFTER `type: tag`, so an authored `type` attribute overwrote the
|
|
162
|
+
// discriminator the tag established and nothing downstream restored it —
|
|
163
|
+
// `compile()` returns this tree as-is and `validateTree` then looks up
|
|
164
|
+
// `manifest.components[node.type]`, i.e. the value the author wrote, not the
|
|
165
|
+
// tag they wrote. The refusal makes that overwrite unreachable; the order
|
|
166
|
+
// here makes it impossible. ⚠️ Reversing the order ALONE would have been a
|
|
167
|
+
// regression of its own — the authored value would then be dropped in
|
|
168
|
+
// silence, trading one silence for another. It is correct only BECAUSE the
|
|
169
|
+
// attribute is refused loudly one function up.
|
|
170
|
+
//
|
|
171
|
+
// ⚠️ The `markHtmlTierNode` wrapper is objectui-only and survives the
|
|
172
|
+
// reversal: it stamps the object the spread produced, so the marker is
|
|
173
|
+
// applied after every key is in place regardless of their order. Pinned in
|
|
174
|
+
// provenance.test.ts, because this is the one edit that could drop it.
|
|
175
|
+
const node = markHtmlTierNode({ ...props, type: tag });
|
|
102
176
|
if (children && children.length)
|
|
103
177
|
node.children = children;
|
|
104
178
|
return node;
|
|
@@ -117,6 +191,14 @@ class Parser {
|
|
|
117
191
|
this.skipWs();
|
|
118
192
|
value = this.parseAttrValue(tag);
|
|
119
193
|
}
|
|
194
|
+
if (name === DISCRIMINATOR_ATTR) {
|
|
195
|
+
// ONE diagnostic naming both the tag and the attribute — see
|
|
196
|
+
// DISCRIMINATOR_ATTR above for why it replaces both prior outcomes.
|
|
197
|
+
this.error('forbidden-attr', `Attribute "${DISCRIMINATOR_ATTR}" is not allowed on <${tag}> — on this tier the tag name IS the `
|
|
198
|
+
+ `component, so <${tag}> already means type "${tag}". Delete the attribute, or write the tag of the `
|
|
199
|
+
+ 'component you meant.', elStart, tag);
|
|
200
|
+
return { name, value: undefined, drop: true };
|
|
201
|
+
}
|
|
120
202
|
if (EVENT_ATTR.test(name) || FORBIDDEN_ATTRS.has(name)) {
|
|
121
203
|
this.error('forbidden-attr', `Attribute "${name}" is not allowed on <${tag}>`, elStart, tag);
|
|
122
204
|
return { name: `__forbidden_${name}`, value: undefined };
|
|
@@ -178,10 +260,31 @@ class Parser {
|
|
|
178
260
|
continue;
|
|
179
261
|
}
|
|
180
262
|
// text
|
|
263
|
+
//
|
|
264
|
+
// HTML collapses a whitespace run to ONE space; it does not delete it. A
|
|
265
|
+
// bare `.trim()` here deleted the space that separates a text run from an
|
|
266
|
+
// adjacent sibling element, so `A <strong>x</strong> page` compiled to
|
|
267
|
+
// `A`/`page` and rendered as `Axpage` (objectui#5661). The rule: collapse
|
|
268
|
+
// the run, then keep a single leading space only when a sibling precedes
|
|
269
|
+
// the run, and a single trailing space only when a sibling element
|
|
270
|
+
// follows it. At the parent's own start/end the edge space is still
|
|
271
|
+
// dropped, so `<p> hi </p>` stays `hi`. A whitespace-only run survives
|
|
272
|
+
// as one space only when it sits BETWEEN siblings — that is the bounded
|
|
273
|
+
// over-generosity of this rule inside block containers like `<ul>`,
|
|
274
|
+
// pinned in the tests.
|
|
181
275
|
const text = this.readTextRun();
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
276
|
+
const collapsed = text.replace(/\s+/g, ' ');
|
|
277
|
+
const core = collapsed.trim();
|
|
278
|
+
const afterSibling = children.length > 0;
|
|
279
|
+
const beforeElement = this.peek() === '<' && !this.src.startsWith('</', this.pos);
|
|
280
|
+
if (core) {
|
|
281
|
+
const lead = afterSibling && collapsed.startsWith(' ') ? ' ' : '';
|
|
282
|
+
const trail = beforeElement && collapsed.endsWith(' ') ? ' ' : '';
|
|
283
|
+
children.push(`${lead}${core}${trail}`);
|
|
284
|
+
}
|
|
285
|
+
else if (collapsed && afterSibling && beforeElement) {
|
|
286
|
+
children.push(' ');
|
|
287
|
+
}
|
|
185
288
|
}
|
|
186
289
|
return children;
|
|
187
290
|
}
|
|
@@ -271,9 +374,18 @@ class Parser {
|
|
|
271
374
|
}
|
|
272
375
|
/**
|
|
273
376
|
* Interpret a braced attribute value `{...}`.
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
377
|
+
*
|
|
378
|
+
* Strict-JSON values are materialized by `JSON.parse`, exactly as they always
|
|
379
|
+
* were. Beyond that, the JS **literal subset** below is materialized too
|
|
380
|
+
* (objectui#6614 Q1-A, maintainer ruling 2026-08-28). Anything left over — a
|
|
381
|
+
* genuine expression — is kept as the deferred marker `{ $expr }`: typed and
|
|
382
|
+
* validated later, drawing `inert-expression`, and NEVER evaluated here.
|
|
383
|
+
*
|
|
384
|
+
* ORDER IS LOAD-BEARING. `JSON.parse` runs FIRST and is untouched, so every
|
|
385
|
+
* input JSON accepts takes byte-identically the path it took before the literal
|
|
386
|
+
* subset existed. The reader below only ever sees strings `JSON.parse` has
|
|
387
|
+
* already thrown on, which makes strict-JSON invariance a property of the
|
|
388
|
+
* structure rather than of a test.
|
|
277
389
|
*/
|
|
278
390
|
export function interpretBrace(raw) {
|
|
279
391
|
const trimmed = raw.trim();
|
|
@@ -281,6 +393,242 @@ export function interpretBrace(raw) {
|
|
|
281
393
|
return JSON.parse(trimmed);
|
|
282
394
|
}
|
|
283
395
|
catch {
|
|
284
|
-
|
|
396
|
+
const literal = readLiteral(trimmed);
|
|
397
|
+
return literal === NOT_LITERAL ? { $expr: trimmed } : literal;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/* ---------------------- the JS literal subset (#6614) ---------------------- */
|
|
401
|
+
/**
|
|
402
|
+
* EXACTLY TWO widenings over JSON, and nothing else:
|
|
403
|
+
*
|
|
404
|
+
* 1. **single-quoted strings** — `{'name'}`, `{['name','amount']}`, and in
|
|
405
|
+
* key position `{{'pageSize': 25}}`;
|
|
406
|
+
* 2. **unquoted identifier object keys** — `{{pageSize: 25}}`.
|
|
407
|
+
*
|
|
408
|
+
* Everything else JSON refuses is still refused and still becomes `{ $expr }`:
|
|
409
|
+
* trailing commas, comments, array holes, spreads, `undefined` / `NaN` /
|
|
410
|
+
* `Infinity`, `+1` / `.5` / `1.` / `0x1f`, template literals, and every genuine
|
|
411
|
+
* expression — identifiers, member access, calls, operators, ternaries.
|
|
412
|
+
*
|
|
413
|
+
* That list is deliberately short. This is a VALUE grammar, not an evaluator:
|
|
414
|
+
* it contains no identifier lookup and no operator, so there is nothing here to
|
|
415
|
+
* execute (ADR-0080 — this tier parses, never executes). The widening moves the
|
|
416
|
+
* spellings an author writes by habit onto the materialized side; it does not
|
|
417
|
+
* move the boundary between data and code.
|
|
418
|
+
*/
|
|
419
|
+
const NOT_LITERAL = Symbol('not-a-literal');
|
|
420
|
+
/** JSON's whitespace set, not JS's — narrower, and one less thing to diverge. */
|
|
421
|
+
const LITERAL_WS = /[ \t\n\r]/;
|
|
422
|
+
const IDENT_START = /[A-Za-z_$]/;
|
|
423
|
+
const IDENT_CHAR = /[A-Za-z0-9_$]/;
|
|
424
|
+
/** JSON's number grammar verbatim: no leading `+`, no `.5`, no `1.`, no hex. */
|
|
425
|
+
const NUMBER = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/;
|
|
426
|
+
/** JSON's escape set. `\'` is added for single-quoted strings only. */
|
|
427
|
+
const SIMPLE_ESCAPE = {
|
|
428
|
+
'"': '"',
|
|
429
|
+
'\\': '\\',
|
|
430
|
+
'/': '/',
|
|
431
|
+
b: '\b',
|
|
432
|
+
f: '\f',
|
|
433
|
+
n: '\n',
|
|
434
|
+
r: '\r',
|
|
435
|
+
t: '\t',
|
|
436
|
+
};
|
|
437
|
+
function readLiteral(src) {
|
|
438
|
+
const reader = new LiteralReader(src);
|
|
439
|
+
const value = reader.value();
|
|
440
|
+
if (value === NOT_LITERAL)
|
|
441
|
+
return NOT_LITERAL;
|
|
442
|
+
reader.ws();
|
|
443
|
+
// Trailing anything means the input was an expression that merely STARTS with
|
|
444
|
+
// a literal (`['a'] + x`, `1 + 2`). Refuse the whole input.
|
|
445
|
+
return reader.done() ? value : NOT_LITERAL;
|
|
446
|
+
}
|
|
447
|
+
class LiteralReader {
|
|
448
|
+
constructor(src) {
|
|
449
|
+
Object.defineProperty(this, "src", {
|
|
450
|
+
enumerable: true,
|
|
451
|
+
configurable: true,
|
|
452
|
+
writable: true,
|
|
453
|
+
value: src
|
|
454
|
+
});
|
|
455
|
+
Object.defineProperty(this, "pos", {
|
|
456
|
+
enumerable: true,
|
|
457
|
+
configurable: true,
|
|
458
|
+
writable: true,
|
|
459
|
+
value: 0
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
done() {
|
|
463
|
+
return this.pos >= this.src.length;
|
|
464
|
+
}
|
|
465
|
+
ws() {
|
|
466
|
+
while (this.pos < this.src.length && LITERAL_WS.test(this.src[this.pos]))
|
|
467
|
+
this.pos++;
|
|
468
|
+
}
|
|
469
|
+
value() {
|
|
470
|
+
this.ws();
|
|
471
|
+
const c = this.src[this.pos];
|
|
472
|
+
if (c === undefined)
|
|
473
|
+
return NOT_LITERAL;
|
|
474
|
+
if (c === '"' || c === "'")
|
|
475
|
+
return this.string(c);
|
|
476
|
+
if (c === '[')
|
|
477
|
+
return this.array();
|
|
478
|
+
if (c === '{')
|
|
479
|
+
return this.object();
|
|
480
|
+
if (this.keyword('true'))
|
|
481
|
+
return true;
|
|
482
|
+
if (this.keyword('false'))
|
|
483
|
+
return false;
|
|
484
|
+
if (this.keyword('null'))
|
|
485
|
+
return null;
|
|
486
|
+
return this.number();
|
|
487
|
+
}
|
|
488
|
+
/** A keyword only when it is not the prefix of a longer identifier. */
|
|
489
|
+
keyword(word) {
|
|
490
|
+
if (!this.src.startsWith(word, this.pos))
|
|
491
|
+
return false;
|
|
492
|
+
const after = this.src[this.pos + word.length];
|
|
493
|
+
if (after !== undefined && IDENT_CHAR.test(after))
|
|
494
|
+
return false;
|
|
495
|
+
this.pos += word.length;
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
number() {
|
|
499
|
+
const m = NUMBER.exec(this.src.slice(this.pos));
|
|
500
|
+
if (!m)
|
|
501
|
+
return NOT_LITERAL;
|
|
502
|
+
this.pos += m[0].length;
|
|
503
|
+
return Number(m[0]);
|
|
504
|
+
}
|
|
505
|
+
string(quote) {
|
|
506
|
+
this.pos++; // opening quote
|
|
507
|
+
let out = '';
|
|
508
|
+
for (;;) {
|
|
509
|
+
const c = this.src[this.pos];
|
|
510
|
+
if (c === undefined)
|
|
511
|
+
return NOT_LITERAL; // unterminated
|
|
512
|
+
if (c === quote) {
|
|
513
|
+
this.pos++;
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
if (c === '\\') {
|
|
517
|
+
const esc = this.src[this.pos + 1];
|
|
518
|
+
if (esc === undefined)
|
|
519
|
+
return NOT_LITERAL;
|
|
520
|
+
if (esc === 'u') {
|
|
521
|
+
const hex = this.src.slice(this.pos + 2, this.pos + 6);
|
|
522
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex))
|
|
523
|
+
return NOT_LITERAL;
|
|
524
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
525
|
+
this.pos += 6;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
// `\'` is legal inside a single-quoted string only — JSON's set otherwise.
|
|
529
|
+
if (esc === "'" && quote === "'") {
|
|
530
|
+
out += "'";
|
|
531
|
+
this.pos += 2;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
const simple = SIMPLE_ESCAPE[esc];
|
|
535
|
+
if (simple === undefined)
|
|
536
|
+
return NOT_LITERAL; // `\x41`, `\0`, line continuation
|
|
537
|
+
out += simple;
|
|
538
|
+
this.pos += 2;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
// JSON forbids raw control characters inside a string; so does this.
|
|
542
|
+
if (c < ' ')
|
|
543
|
+
return NOT_LITERAL;
|
|
544
|
+
out += c;
|
|
545
|
+
this.pos++;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
array() {
|
|
549
|
+
this.pos++; // '['
|
|
550
|
+
const out = [];
|
|
551
|
+
this.ws();
|
|
552
|
+
if (this.src[this.pos] === ']') {
|
|
553
|
+
this.pos++;
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
for (;;) {
|
|
557
|
+
const item = this.value();
|
|
558
|
+
if (item === NOT_LITERAL)
|
|
559
|
+
return NOT_LITERAL;
|
|
560
|
+
out.push(item);
|
|
561
|
+
this.ws();
|
|
562
|
+
const c = this.src[this.pos];
|
|
563
|
+
// A trailing comma leaves `value()` facing `]`, which it refuses — so
|
|
564
|
+
// `['a',]` is NOT in the subset. Only two widenings were ruled.
|
|
565
|
+
if (c === ',') {
|
|
566
|
+
this.pos++;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (c === ']') {
|
|
570
|
+
this.pos++;
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
return NOT_LITERAL;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
object() {
|
|
577
|
+
this.pos++; // '{'
|
|
578
|
+
const out = {};
|
|
579
|
+
this.ws();
|
|
580
|
+
if (this.src[this.pos] === '}') {
|
|
581
|
+
this.pos++;
|
|
582
|
+
return out;
|
|
583
|
+
}
|
|
584
|
+
for (;;) {
|
|
585
|
+
this.ws();
|
|
586
|
+
const key = this.key();
|
|
587
|
+
if (key === NOT_LITERAL)
|
|
588
|
+
return NOT_LITERAL;
|
|
589
|
+
this.ws();
|
|
590
|
+
if (this.src[this.pos] !== ':')
|
|
591
|
+
return NOT_LITERAL;
|
|
592
|
+
this.pos++;
|
|
593
|
+
const item = this.value();
|
|
594
|
+
if (item === NOT_LITERAL)
|
|
595
|
+
return NOT_LITERAL;
|
|
596
|
+
// ⚠️ Plain `out[key] = item` would hand an authored `__proto__` key the
|
|
597
|
+
// prototype SETTER. `JSON.parse` creates an ordinary own data property,
|
|
598
|
+
// and this path must too: the whole point of this tier is that untrusted
|
|
599
|
+
// source is safe to parse, so a widening must not open a
|
|
600
|
+
// prototype-pollution lever the strict-JSON path never had.
|
|
601
|
+
Object.defineProperty(out, key, {
|
|
602
|
+
value: item,
|
|
603
|
+
writable: true,
|
|
604
|
+
enumerable: true,
|
|
605
|
+
configurable: true,
|
|
606
|
+
});
|
|
607
|
+
this.ws();
|
|
608
|
+
const c = this.src[this.pos];
|
|
609
|
+
if (c === ',') {
|
|
610
|
+
this.pos++;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (c === '}') {
|
|
614
|
+
this.pos++;
|
|
615
|
+
return out;
|
|
616
|
+
}
|
|
617
|
+
return NOT_LITERAL;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/** A quoted string, or a bare identifier — the second ruled widening. */
|
|
621
|
+
key() {
|
|
622
|
+
const c = this.src[this.pos];
|
|
623
|
+
if (c === '"' || c === "'")
|
|
624
|
+
return this.string(c);
|
|
625
|
+
if (c !== undefined && IDENT_START.test(c)) {
|
|
626
|
+
const start = this.pos;
|
|
627
|
+
this.pos++;
|
|
628
|
+
while (this.pos < this.src.length && IDENT_CHAR.test(this.src[this.pos]))
|
|
629
|
+
this.pos++;
|
|
630
|
+
return this.src.slice(start, this.pos);
|
|
631
|
+
}
|
|
632
|
+
return NOT_LITERAL;
|
|
285
633
|
}
|
|
286
634
|
}
|