@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/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 { validateTree } from './validate.js';
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
- 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
- ]);
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: (INPUT_TYPES.has(i.type) ? i.type : 'string'),
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
- * JSON-literal values (numbers, booleans, null, strings, arrays, objects with
21
- * quoted keys) are materialized. Anything else is kept as a deferred expression
22
- * marker `{ $expr }` — typed and validated later, NEVER evaluated here.
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
- props[attr.name] = attr.value;
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
- const node = { type: tag, ...props };
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 trimmed = text.replace(/\s+/g, ' ').trim();
183
- if (trimmed)
184
- children.push(trimmed);
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
- * JSON-literal values (numbers, booleans, null, strings, arrays, objects with
275
- * quoted keys) are materialized. Anything else is kept as a deferred expression
276
- * marker `{ $expr }` — typed and validated later, NEVER evaluated here.
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
- return { $expr: trimmed };
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
  }