@nodable/flexible-xml-parser 1.0.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.
@@ -0,0 +1,573 @@
1
+ import StringSource from './InputSource/StringSource.js';
2
+ import BufferSource from './InputSource/BufferSource.js';
3
+ import { readTagExp, readClosingTagName, flushAttributes } from './XmlPartReader.js';
4
+ import { StopNodeProcessor } from './StopNodeProcessor.js';
5
+ import { readComment, readCdata, readPiTag } from './XmlSpecialTagsReader.js';
6
+ import { Expression, ExpressionSet, Matcher } from 'path-expression-matcher';
7
+ import { readDocType } from './DocTypeReader.js';
8
+ import { isName, DANGEROUS_PROPERTY_NAMES, criticalProperties } from './util.js';
9
+ import AutoCloseHandler from './AutoCloseHandler.js';
10
+ import { ParseError, ErrorCode } from './ParseError.js';
11
+
12
+ class TagDetail {
13
+ /**
14
+ * @param {string} name - Tag name
15
+ * @param {number} line - 1-based line number where the opening tag began
16
+ * @param {number} col - 1-based column where the opening tag began
17
+ * @param {number} index - Character offset from document start
18
+ */
19
+ constructor(name, line = 0, col = 0, index = 0) {
20
+ this.name = name;
21
+ this.line = line;
22
+ this.col = col;
23
+ this.index = index;
24
+ }
25
+ }
26
+
27
+ export default class Xml2JsParser {
28
+ constructor(options) {
29
+ this.options = options;
30
+
31
+ this.currentTagDetail = null;
32
+ this.tagTextData = "";
33
+ this.tagsStack = [];
34
+
35
+ this.matcher = new Matcher();
36
+
37
+ //create once and reuse
38
+ this.readonlyMatcher = this.matcher.readOnly();
39
+
40
+ // AutoClose handler — created once per parser instance, reset on each parse
41
+ this.autoCloseHandler = options.autoClose
42
+ ? new AutoCloseHandler(options.autoClose)
43
+ : null;
44
+
45
+ this._unpairedSet = new Set(this.options.tags.unpaired);
46
+
47
+ // Reuse the sealed ExpressionSets built by OptionsBuilder.
48
+ // Each Expression carries its config ({ nested, skipEnclosures }) in .data.
49
+ // findMatch() returns the matched Expression directly — O(1) indexed lookup.
50
+ this.stopNodeExpressionsSet = this.options.tags.stopNodesSet ?? new ExpressionSet();
51
+ this.skipTagExpressionsSet = this.options.skip.tagsSet ?? new ExpressionSet();
52
+
53
+ // exitIf: optional predicate called after each opening tag is pushed.
54
+ // Stored directly — it's a plain function, not an ExpressionSet.
55
+ this._exitIf = typeof options.exitIf === 'function' ? options.exitIf : null;
56
+ }
57
+
58
+ initializeParser() {
59
+ this.tagTextData = "";
60
+ this.tagsStack = [];
61
+ this._stopNodeProcessor = null;
62
+ this._exitIfTriggered = false;
63
+
64
+ if (!this.matcher) {
65
+ this.matcher = new Matcher();
66
+ this.readonlyMatcher = this.matcher.readOnly();
67
+ }
68
+
69
+ this.outputBuilder = this._createOutputBuilder();
70
+
71
+ this.root = { root: true, name: "" };
72
+ this.currentTagDetail = this.root;
73
+ }
74
+
75
+ /**
76
+ * Create an OutputBuilder instance for this parse run.
77
+ * The output builder owns all value parser registration, including
78
+ * EntitiesValueParser — no injection needed from the parser side.
79
+ */
80
+ _createOutputBuilder() {
81
+ return this.options.OutputBuilder.getInstance(this.options, this.readonlyMatcher);
82
+ }
83
+
84
+ /**
85
+ * Returns true if the last parse call was terminated early by exitIf.
86
+ * Useful when the caller needs to know whether parsing completed or stopped.
87
+ */
88
+ wasExited() {
89
+ return this._exitIfTriggered === true;
90
+ }
91
+
92
+ parse(strData) {
93
+ this.source = new StringSource(strData);
94
+ this.initializeParser();
95
+ this._parseAndFinalize();
96
+ return this.outputBuilder.getOutput();
97
+ }
98
+
99
+ parseBytesArr(data) {
100
+ this.source = new BufferSource(data);
101
+ this.initializeParser();
102
+ this._parseAndFinalize();
103
+ return this.outputBuilder.getOutput();
104
+ }
105
+
106
+ /**
107
+ * Advance the parser state machine as far as the source buffer allows.
108
+ * Stops naturally when canRead() returns false — no EOF handling here.
109
+ * Call finalizeXml() once all input is consumed to validate end-of-document.
110
+ *
111
+ * parseStream() and feed()/end() call this per chunk; _parseAndFinalize()
112
+ * (used by parse() / parseBytesArr()) calls it then finalizeXml() immediately.
113
+ */
114
+ parseXml() {
115
+ while (this.source.canRead()) {
116
+ // exitIf triggered in this iteration — stop consuming input immediately.
117
+ if (this._exitIfTriggered) break;
118
+
119
+ // Level-0 outer mark: set before consuming any character so that if a
120
+ // '<' dispatch throws UNEXPECTED_END (chunk boundary mid-tag), feed()
121
+ // rewinds to here and the full token — including '<', '![', '</' etc. —
122
+ // is re-read on the next chunk. Inner reader functions use level-1 marks
123
+ // which never overwrite this position.
124
+ this.source.markTokenStart(0);
125
+
126
+ const ch = this.source.readCh();
127
+ if (ch === undefined || ch === '') break;
128
+
129
+ if (ch === '<') {
130
+ const nextChar = this.source.readChAt(0);
131
+ if (nextChar === '') throw new ParseError(
132
+ "Unexpected end of source after '<'",
133
+ ErrorCode.UNEXPECTED_END,
134
+ { line: this.source.line, col: this.source.cols, index: this.source.startIndex }
135
+ );
136
+
137
+ if (nextChar === '!' || nextChar === '?') {
138
+ this.source.updateBufferBoundary();
139
+ this.addTextNode();
140
+ this.readSpecialTag(nextChar);
141
+ } else if (nextChar === '/') {
142
+ this.source.updateBufferBoundary();
143
+ this.readClosingTag();
144
+ } else {
145
+ this.readOpeningTag();
146
+ }
147
+ } else {
148
+ // ch is already consumed. Peek ahead for more non-'<' chars and grab
149
+ // the whole run in one readStr call rather than concatenating one char
150
+ // at a time through every loop iteration.
151
+ let runLen = 0;
152
+ while (true) {
153
+ const c = this.source.readChAt(runLen);
154
+ if (c === '<' || c === undefined || c === '') break;
155
+ runLen++;
156
+ }
157
+ if (runLen > 0) {
158
+ this.tagTextData += ch + this.source.readStr(runLen, this.source.startIndex);
159
+ this.source.updateBufferBoundary(runLen);
160
+ } else {
161
+ this.tagTextData += ch;
162
+ }
163
+
164
+ //TODO: why does below code doesn't work
165
+ // const text = this.source.readUptoChar("<");
166
+ // this.tagTextData += text;
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Validate end-of-document state and apply autoClose recovery if configured.
173
+ * Must be called exactly once after all input has been consumed.
174
+ */
175
+ finalizeXml() {
176
+ // When exitIf fired, the parser already closed all open tags and notified
177
+ // the builder — treat the partial parse as complete and skip EOF checks.
178
+ if (this._exitIfTriggered) return;
179
+
180
+ const hasOpenTags = this.tagsStack.length > 0 ||
181
+ (this.currentTagDetail && !this.currentTagDetail.root);
182
+
183
+ const hasTrailingText =
184
+ !hasOpenTags &&
185
+ this.tagTextData !== undefined &&
186
+ this.tagTextData.trimEnd().length > 0;
187
+
188
+ if (hasOpenTags || hasTrailingText) {
189
+ if (this.autoCloseHandler && hasOpenTags && !hasTrailingText) {
190
+ this.autoCloseHandler.handleEof(this._parserState());
191
+ } else {
192
+ throw new ParseError('Unexpected data in the end of document', ErrorCode.UNEXPECTED_TRAILING_DATA);
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * One-shot helper used by parse() and parseBytesArr().
199
+ * Runs parseXml() with autoClose partial-tag recovery, then finalizeXml().
200
+ * @private
201
+ */
202
+ _parseAndFinalize() {
203
+ let partialTagError = null;
204
+ if (this.autoCloseHandler) this.autoCloseHandler.reset();
205
+
206
+ try {
207
+ this.parseXml();
208
+ } catch (err) {
209
+ if (this.autoCloseHandler && isSourceExhaustedError(err)) {
210
+ partialTagError = err;
211
+ } else {
212
+ throw err;
213
+ }
214
+ }
215
+
216
+ if (partialTagError) {
217
+ this.autoCloseHandler.handlePartialTag(partialTagError, this._parserState());
218
+ return;
219
+ }
220
+
221
+ this.finalizeXml();
222
+ }
223
+
224
+ readClosingTag() {
225
+ const tagName = this.processTagName(readClosingTagName(this.source));
226
+
227
+ if (this.isUnpaired(tagName) || this.isStopNode()) {
228
+ throw new ParseError(`Unexpected closing tag '${tagName}'`, ErrorCode.UNEXPECTED_CLOSE_TAG, { line: this.source.line, col: this.source.cols, index: this.source.startIndex });
229
+ }
230
+
231
+ if (tagName !== this.currentTagDetail.name) {
232
+ if (!this.autoCloseHandler) {
233
+ throw new ParseError(
234
+ `Unexpected closing tag '${tagName}' expecting '${this.currentTagDetail.name}'`,
235
+ ErrorCode.MISMATCHED_CLOSE_TAG,
236
+ { line: this.source.line, col: this.source.cols, index: this.source.startIndex }
237
+ );
238
+ }
239
+
240
+ const decision = this.autoCloseHandler.handleMismatch(tagName, this._parserState());
241
+
242
+ if (decision.action === 'discard') return;
243
+ // 'close-matched': handler updated currentTagDetail; fall through to normal close
244
+ }
245
+
246
+ if (!this.currentTagDetail.root) this.addTextNode();
247
+ this.popTag();
248
+ }
249
+
250
+ readOpeningTag() {
251
+ const options = this.options;
252
+ this.addTextNode();
253
+
254
+ // ── Stop-node resume ─────────────────────────────────────────────────────
255
+ // When a chunk boundary fell inside StopNodeProcessor.collect(), feed() caught
256
+ // UNEXPECTED_END and rewound the source to the '<' of the stop node's
257
+ // opening tag. On the next feed() we re-enter here with the processor active.
258
+ // Re-consume the opening tag (source was rewound to its '<'), then resume
259
+ // collection — the processor remembers all accumulated content and depth.
260
+ if (this._stopNodeProcessor && this._stopNodeProcessor.isActive()) {
261
+ const { tagDetail, isSkip } = this._stopNodeProcessorMeta;
262
+ this._stopNodeProcessor.resumeAfterOpenTag();
263
+ readTagExp(this); // re-consume the opening tag from the rewound source
264
+ const content = this._stopNodeProcessor.collect(this.source);
265
+ if (!isSkip) {
266
+ this.outputBuilder.addElement(tagDetail, this.readonlyMatcher);
267
+ this.outputBuilder.onStopNode?.(tagDetail, content, this.readonlyMatcher);
268
+ this.outputBuilder.addValue(content, this.readonlyMatcher);
269
+ this.outputBuilder.closeElement(this.readonlyMatcher);
270
+ }
271
+ this.matcher.pop();
272
+ this._stopNodeProcessor = null;
273
+ this._stopNodeProcessorMeta = null;
274
+ return;
275
+ }
276
+
277
+ let tagExp = readTagExp(this);
278
+ const processedTagName = this.processTagName(tagExp.tagName);
279
+ const tagDetail = new TagDetail(
280
+ processedTagName,
281
+ this.source.line,
282
+ this.source.cols,
283
+ this.source.startIndex,
284
+ );
285
+
286
+ // ── Limit: maxNestedTags ─────────────────────────────────────────────────
287
+ const maxNested = options.limits?.maxNestedTags;
288
+ if (maxNested !== undefined && maxNested !== null) {
289
+ const depth = this.tagsStack.length + 1;
290
+ if (depth > maxNested) {
291
+ throw new ParseError(
292
+ `Nesting depth ${depth} exceeds limit of ${maxNested} (tag: '${processedTagName}')`,
293
+ ErrorCode.LIMIT_MAX_NESTED_TAGS,
294
+ { line: tagDetail.line, col: tagDetail.col, index: tagDetail.index }
295
+ );
296
+ }
297
+ }
298
+
299
+ // ── Two-pass attribute handling ──────────────────────────────────────────
300
+ let rawAttributes = {};
301
+ let raeAttrLen = 0;
302
+ if (tagExp.rawAttributes) {
303
+ rawAttributes = tagExp.rawAttributes;
304
+ raeAttrLen = tagExp.rawAttributesLen;
305
+ }
306
+
307
+ this.matcher.push(processedTagName, {});
308
+ if (raeAttrLen > 0) {
309
+ this.matcher.updateCurrent(rawAttributes);
310
+ }
311
+
312
+ // Resolve skip/stop BEFORE touching the output builder
313
+ const stopNodeConfig = this.isStopNode();
314
+ const skipTagConfig = stopNodeConfig ? null : this.isSkipTag();
315
+
316
+ if (!options.skip.attributes && !skipTagConfig) {
317
+ flushAttributes(tagExp._attrsExp, this);
318
+ }
319
+
320
+ // Stop-node and skip-tag checks AFTER attributes are set so attribute conditions work.
321
+ // const stopNodeConfig = this.isStopNode();
322
+ // Skip tag is only checked when this tag is not already a stop node — they are mutually exclusive.
323
+ // const skipTagConfig = stopNodeConfig ? null : this.isSkipTag();
324
+
325
+ if (this.isUnpaired(processedTagName)) {
326
+ this.outputBuilder.addElement(tagDetail, this.readonlyMatcher);
327
+ this.outputBuilder.closeElement(this.readonlyMatcher);
328
+ this.matcher.pop();
329
+ } else if (tagExp.selfClosing) {
330
+ if (!skipTagConfig) {
331
+ this.outputBuilder.addElement(tagDetail, this.readonlyMatcher);
332
+ this.outputBuilder.closeElement(this.readonlyMatcher);
333
+ }
334
+ this.matcher.pop();
335
+ } else if (stopNodeConfig) {
336
+ // Create a fresh processor with the matching nested + skipEnclosures config.
337
+ this._stopNodeProcessor = new StopNodeProcessor(processedTagName, {
338
+ nested: stopNodeConfig.nested,
339
+ skipEnclosures: stopNodeConfig.skipEnclosures,
340
+ });
341
+ this._stopNodeProcessorMeta = { tagDetail, isSkip: false };
342
+ this._stopNodeProcessor.activate();
343
+ const content = this._stopNodeProcessor.collect(this.source);
344
+ this.outputBuilder.addElement(tagDetail, this.readonlyMatcher);
345
+ this.outputBuilder.onStopNode?.(tagDetail, content, this.readonlyMatcher);
346
+ this.outputBuilder.addValue(content, this.readonlyMatcher);
347
+ this.outputBuilder.closeElement(this.readonlyMatcher);
348
+ this.matcher.pop();
349
+ this._stopNodeProcessor = null;
350
+ this._stopNodeProcessorMeta = null;
351
+ } else if (skipTagConfig) {
352
+ // Skip tag: collect raw content (to advance the source past the closing tag)
353
+ // but call no output builder methods — the tag is silently dropped.
354
+ this._stopNodeProcessor = new StopNodeProcessor(processedTagName, {
355
+ nested: skipTagConfig.nested,
356
+ skipEnclosures: skipTagConfig.skipEnclosures,
357
+ });
358
+ this._stopNodeProcessorMeta = { tagDetail, isSkip: true };
359
+ this._stopNodeProcessor.activate();
360
+ this._stopNodeProcessor.collect(this.source); // advance source; content discarded
361
+ this.matcher.pop();
362
+ this._stopNodeProcessor = null;
363
+ this._stopNodeProcessorMeta = null;
364
+ } else if (this._exitIf && this._exitIf(this.readonlyMatcher)) {
365
+ // ── exitIf ───────────────────────────────────────────────────────────────
366
+ // Checked BEFORE addElement so the triggering tag is never added to the
367
+ // output builder. The matcher is already positioned (push + updateCurrent
368
+ // above), so attribute-based predicates work correctly.
369
+ //
370
+ // We pop the matcher entry for this tag (it was never added to the builder),
371
+ // then close all already-open ancestors so the builder can finalise its tree.
372
+
373
+ const exitDepth = this.tagsStack.length; // number of ancestors open before this tag
374
+ this.matcher.pop(); // undo the push for the triggering tag
375
+
376
+ while (this.currentTagDetail && !this.currentTagDetail.root) {
377
+ this.addTextNode();
378
+ this.popTag();
379
+ }
380
+
381
+ // Notify the output builder that parsing was intentionally truncated.
382
+ if (typeof this.outputBuilder.onExit === 'function') {
383
+ this.outputBuilder.onExit({
384
+ tagDetail,
385
+ matcher: this.readonlyMatcher,
386
+ depth: exitDepth,
387
+ });
388
+ }
389
+
390
+ this._exitIfTriggered = true;
391
+ } else {
392
+ this.pushTag(tagDetail);
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Push a tag onto the parser stack and notify the output builder.
398
+ * This is the single point of entry for opening a non-self-closing tag —
399
+ * both the parser-side stack (currentTagDetail / tagsStack) and the
400
+ * output builder are updated together, keeping them in sync.
401
+ *
402
+ * Custom OutputBuilder implementations that maintain their own tag stack
403
+ * should override addElement() rather than calling pushTag() directly.
404
+ *
405
+ * @param {TagDetail} tagDetail
406
+ */
407
+ pushTag(tagDetail) {
408
+ this.tagsStack.push(this.currentTagDetail);
409
+ this.outputBuilder.addElement(tagDetail, this.readonlyMatcher);
410
+ this.currentTagDetail = tagDetail;
411
+ }
412
+
413
+ /**
414
+ * Pop the current tag from the parser stack and notify the output builder.
415
+ * This is the single point of exit for closing a tag — both stacks are
416
+ * updated together.
417
+ */
418
+ popTag() {
419
+ this.outputBuilder.closeElement(this.readonlyMatcher);
420
+ this.matcher.pop();
421
+ this.currentTagDetail = this.tagsStack.pop();
422
+ }
423
+
424
+ readSpecialTag(startCh) {
425
+ if (startCh === "!") {
426
+ let nextChar = this.source.readCh();
427
+ if (nextChar === null || nextChar === undefined) throw new ParseError("Unexpected end of source after '<!'", ErrorCode.UNEXPECTED_END, { line: this.source.line, col: this.source.cols, index: this.source.startIndex });
428
+
429
+ if (nextChar === "-") {
430
+ readComment(this);
431
+ } else if (nextChar === "[") {
432
+ readCdata(this);
433
+ } else if (nextChar === "D") {
434
+ // DOCTYPE is always read to consume its content and advance the cursor.
435
+ // Entities are forwarded to the output builder only when doctypeOptions.enabled is true.
436
+ const docTypeEntities = readDocType(this);
437
+ if (this.options.doctypeOptions.enabled &&
438
+ docTypeEntities &&
439
+ Object.keys(docTypeEntities).length > 0) {
440
+ this.outputBuilder.addInputEntities(docTypeEntities);
441
+ }
442
+ }
443
+ } else if (startCh === "?") {
444
+ readPiTag(this);
445
+ } else {
446
+ throw new ParseError(`Invalid tag '<${startCh}'`, ErrorCode.INVALID_TAG, { line: this.source.line, col: this.source.cols, index: this.source.startIndex });
447
+ }
448
+ }
449
+
450
+ addTextNode() {
451
+ if (this.tagTextData !== undefined && this.tagTextData !== "") {
452
+ if (this.tagTextData.trim().length > 0) {
453
+ // Pass raw text — entity expansion is handled by 'entities' ValueParser in the chain
454
+ this.outputBuilder.addValue(this.tagTextData, this.readonlyMatcher);
455
+ }
456
+ this.tagTextData = "";
457
+ }
458
+ }
459
+
460
+ processAttrName(attrName) {
461
+ const options = this.options;
462
+ attrName = resolveNsPrefix(attrName, options.skip.nsPrefix);
463
+ if (!isName(attrName)) { //TODO: make it optional
464
+ throw new ParseError(`Invalid attribute name: ${attrName}`, ErrorCode.INVALID_ATTRIBUTE_NAME);
465
+ }
466
+ attrName = sanitizeName(attrName, options.onDangerousProperty);
467
+ if (options.strictReservedNames && attrName === options.attributes.groupBy) {
468
+ throw new ParseError(`Restricted attribute name: ${attrName}`, ErrorCode.SECURITY_RESTRICTED_NAME);
469
+ }
470
+ return attrName;
471
+ }
472
+
473
+ processTagName(tagName) {
474
+ const options = this.options;
475
+ const nameFor = options.nameFor;
476
+ tagName = resolveNsPrefix(tagName, options.skip.nsPrefix);
477
+ tagName = sanitizeName(tagName, options.onDangerousProperty);
478
+ if (options.strictReservedNames && (
479
+ tagName === nameFor.comment ||
480
+ tagName === nameFor.cdata ||
481
+ tagName === nameFor.text
482
+ )) {
483
+ throw new ParseError(`Restricted tag name: ${tagName}`, ErrorCode.SECURITY_RESTRICTED_NAME);
484
+ }
485
+ return tagName;
486
+ }
487
+
488
+ isUnpaired(tagName) {
489
+ return this._unpairedSet.has(tagName);
490
+ }
491
+
492
+ /**
493
+ * Returns the matched stop-node config `{ nested, skipEnclosures }` (from Expression.data)
494
+ * if the current matcher position matches any stop-node expression, or `null` if not.
495
+ * Uses ExpressionSet.findMatch() for O(1) indexed lookup.
496
+ */
497
+ isStopNode() {
498
+ if (this.stopNodeExpressionsSet.size === 0) return null;
499
+ const matched = this.stopNodeExpressionsSet.findMatch(this.matcher);
500
+ return matched ? matched.data : null;
501
+ }
502
+
503
+ /**
504
+ * Returns the matched skip-tag config `{ nested, skipEnclosures }` (from Expression.data)
505
+ * if the current matcher position matches any skip.tags expression, or `null` if not.
506
+ * Uses ExpressionSet.findMatch() for O(1) indexed lookup.
507
+ */
508
+ isSkipTag() {
509
+ if (this.skipTagExpressionsSet.size === 0) return null;
510
+ const matched = this.skipTagExpressionsSet.findMatch(this.matcher);
511
+ return matched ? matched.data : null;
512
+ }
513
+
514
+ /**
515
+ * Snapshot of mutable parser state passed to AutoCloseHandler.
516
+ * Returns a live object — properties read from it reflect current state.
517
+ */
518
+ _parserState() {
519
+ const self = this;
520
+ return {
521
+ get tagsStack() { return self.tagsStack; },
522
+ get currentTagDetail() { return self.currentTagDetail; },
523
+ set currentTagDetail(v) { self.currentTagDetail = v; },
524
+ get outputBuilder() { return self.outputBuilder; },
525
+ get readonlyMatcher() { return self.readonlyMatcher; },
526
+ get matcher() { return self.matcher; },
527
+ get source() { return self.source; },
528
+ get tagTextData() { return self.tagTextData; },
529
+ set tagTextData(v) { self.tagTextData = v; },
530
+ addTextNode: self.addTextNode.bind(self),
531
+ popTag: self.popTag.bind(self),
532
+ };
533
+ }
534
+ }
535
+
536
+ function resolveNsPrefix(name, skipNsPrefix) {
537
+ if (skipNsPrefix) {
538
+ const parts = name.split(':');
539
+ if (parts.length === 2) {
540
+ if (parts[0] === 'xmlns') return false; // drop xmlns declarations
541
+ return parts[1];
542
+ } else if (parts.length > 2) {
543
+ throw new ParseError(`Multiple namespaces in name: ${name}`, ErrorCode.MULTIPLE_NAMESPACES);
544
+ }
545
+ }
546
+ return name;
547
+ }
548
+
549
+ function sanitizeName(name, onDangerousProperty) {
550
+ if (criticalProperties.includes(name)) {
551
+ throw new ParseError(`[SECURITY] Invalid name: "${name}" is a reserved JavaScript keyword that could cause prototype pollution`, ErrorCode.SECURITY_PROTOTYPE_POLLUTION);
552
+ } else if (DANGEROUS_PROPERTY_NAMES.includes(name)) {
553
+ return onDangerousProperty(name);
554
+ }
555
+ return name;
556
+ }
557
+
558
+ /**
559
+ * Returns true for errors thrown by read functions when the source ran out
560
+ * mid-token — i.e. the document was truncated inside a tag.
561
+ * These are the only errors we intercept for autoClose recovery.
562
+ * Syntax errors (unclosed quotes) are NOT intercepted — they rethrow.
563
+ */
564
+ function isSourceExhaustedError(err) {
565
+ // Accept both ParseError (with codes) and plain Error from lower-level readers
566
+ if (err instanceof ParseError) {
567
+ return err.code === ErrorCode.UNEXPECTED_END;
568
+ }
569
+ return (
570
+ err.message.startsWith('Unexpected end of source') ||
571
+ err.message.startsWith('Unexpected closing of source')
572
+ );
573
+ }