@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,91 @@
1
+ /**
2
+ * ParseError — structured error class for flex-xml-parser.
3
+ *
4
+ * All errors thrown by the parser are instances of ParseError so callers can
5
+ * distinguish library errors from generic runtime errors and reliably inspect
6
+ * position information.
7
+ *
8
+ * @property {string} code - Machine-readable error code (e.g. 'UNEXPECTED_CLOSE_TAG')
9
+ * @property {number|undefined} line - 1-based line number where the error occurred (when available)
10
+ * @property {number|undefined} col - 1-based column where the error occurred (when available)
11
+ * @property {number|undefined} index - 0-based character offset from document start (when available)
12
+ */
13
+ export class ParseError extends Error {
14
+ /**
15
+ * @param {string} message - Human-readable error message
16
+ * @param {string} code - Machine-readable error code
17
+ * @param {object} [position] - Optional position info
18
+ * @param {number} [position.line]
19
+ * @param {number} [position.col]
20
+ * @param {number} [position.index]
21
+ */
22
+ constructor(message, code, position = {}) {
23
+ super(message);
24
+ this.name = 'ParseError';
25
+ this.code = code;
26
+
27
+ this.line = position.line ?? undefined;
28
+ this.col = position.col ?? undefined;
29
+ this.index = position.index ?? undefined;
30
+ }
31
+
32
+ toString() {
33
+ const pos = this._posStr();
34
+ return pos ? `${this.name} [${this.code}] at ${pos}: ${this.message}` : `${this.name} [${this.code}]: ${this.message}`;
35
+ }
36
+
37
+ _posStr() {
38
+ if (this.line !== undefined && this.col !== undefined) {
39
+ return `line ${this.line}, col ${this.col}`;
40
+ }
41
+ if (this.index !== undefined) {
42
+ return `index ${this.index}`;
43
+ }
44
+ return null;
45
+ }
46
+ }
47
+
48
+ // ─── Error codes ─────────────────────────────────────────────────────────────
49
+
50
+ export const ErrorCode = Object.freeze({
51
+ // Input type errors
52
+ INVALID_INPUT: 'INVALID_INPUT',
53
+ INVALID_STREAM: 'INVALID_STREAM',
54
+
55
+ // Streaming / feed API
56
+ ALREADY_STREAMING: 'ALREADY_STREAMING',
57
+ NOT_STREAMING: 'NOT_STREAMING',
58
+ DATA_MUST_BE_STRING: 'DATA_MUST_BE_STRING',
59
+
60
+ // Tag structure
61
+ UNEXPECTED_END: 'UNEXPECTED_END',
62
+ UNEXPECTED_CLOSE_TAG: 'UNEXPECTED_CLOSE_TAG',
63
+ MISMATCHED_CLOSE_TAG: 'MISMATCHED_CLOSE_TAG',
64
+ UNEXPECTED_TRAILING_DATA: 'UNEXPECTED_TRAILING_DATA',
65
+ INVALID_TAG: 'INVALID_TAG',
66
+ UNCLOSED_QUOTE: 'UNCLOSED_QUOTE',
67
+
68
+ // Namespace
69
+ MULTIPLE_NAMESPACES: 'MULTIPLE_NAMESPACES',
70
+
71
+ // Security
72
+ SECURITY_PROTOTYPE_POLLUTION: 'SECURITY_PROTOTYPE_POLLUTION',
73
+ SECURITY_RESERVED_OPTION: 'SECURITY_RESERVED_OPTION',
74
+ SECURITY_RESTRICTED_NAME: 'SECURITY_RESTRICTED_NAME',
75
+
76
+ // Limits (DoS prevention)
77
+ LIMIT_MAX_NESTED_TAGS: 'LIMIT_MAX_NESTED_TAGS',
78
+ LIMIT_MAX_ATTRIBUTES: 'LIMIT_MAX_ATTRIBUTES',
79
+
80
+ // Entity limits
81
+ ENTITY_MAX_COUNT: 'ENTITY_MAX_COUNT',
82
+ ENTITY_MAX_SIZE: 'ENTITY_MAX_SIZE',
83
+ ENTITY_MAX_EXPANSIONS: 'ENTITY_MAX_EXPANSIONS',
84
+ ENTITY_MAX_EXPANDED_LENGTH: 'ENTITY_MAX_EXPANDED_LENGTH',
85
+
86
+ // Entity registration
87
+ ENTITY_INVALID_KEY: 'ENTITY_INVALID_KEY',
88
+ ENTITY_INVALID_VALUE: 'ENTITY_INVALID_VALUE',
89
+ });
90
+
91
+ export default ParseError;
@@ -0,0 +1,573 @@
1
+ import { ParseError, ErrorCode } from './ParseError.js';
2
+
3
+ /**
4
+ * Well-known enclosure presets.
5
+ *
6
+ * Import these in your parser config to compose skipEnclosures arrays:
7
+ *
8
+ * import { xmlEnclosures, quoteEnclosures } from 'flex-xml-parser';
9
+ *
10
+ * stopNodes: [
11
+ * "..script", // plain — no enclosures (default)
12
+ * { expression: "body..pre", skipEnclosures: [...xmlEnclosures] },
13
+ * { expression: "head..style", skipEnclosures: [...xmlEnclosures, ...quoteEnclosures] },
14
+ * { expression: "root.stopNode", nested: true, skipEnclosures: [{ open: '<!--', close: '-->' }] },
15
+ * ]
16
+ */
17
+
18
+ /** XML structural delimiters — comments, CDATA, processing instructions. */
19
+ export const xmlEnclosures = [
20
+ { open: '<!--', close: '-->' }, // comment
21
+ { open: '<![CDATA[', close: ']]>' }, // CDATA section
22
+ { open: '<?', close: '?>' }, // processing instruction
23
+ ];
24
+
25
+ /** String literal delimiters — useful for JS / CSS stop-node content. */
26
+ export const quoteEnclosures = [
27
+ { open: "'", close: "'" },
28
+ { open: '"', close: '"' },
29
+ { open: '`', close: '`' }, // template literal
30
+ ];
31
+
32
+ /**
33
+ * StopNodeProcessor — self-contained processor for stop nodes.
34
+ *
35
+ * A stop node is a "sealed envelope": the parser goes blind the moment it
36
+ * enters one, collecting raw characters until the matching closing tag is
37
+ * found. The content is returned as a raw string and never parsed by the
38
+ * XML engine.
39
+ *
40
+ * ### Modes
41
+ *
42
+ * The behaviour is controlled by two independent flags:
43
+ *
44
+ * **`nested`** (boolean, default false):
45
+ * When true, the processor tracks the depth of nested same-name opening
46
+ * tags. The stop node ends only when the depth returns to zero — i.e. the
47
+ * closing tag that matches the original opening tag. When false (default),
48
+ * the very first `</tagName>` ends the stop node regardless of nesting.
49
+ *
50
+ * **`skipEnclosures`** (array, default []):
51
+ * A list of `{ open: string, close: string }` pairs. When the processor
52
+ * encounters an open marker it consumes everything up to the close marker
53
+ * wholesale, suppressing all closing-tag (and depth) logic for that span.
54
+ * Enclosures are checked in array order; the first match wins. When the
55
+ * array is empty, no enclosure skipping is performed.
56
+ *
57
+ * The two flags compose freely:
58
+ *
59
+ * | nested | skipEnclosures | Behaviour |
60
+ * |--------|----------------|------------------------------------------------------------------------|
61
+ * | false | [] | Plain: stop at first `</tagName>`. |
62
+ * | true | [] | Depth-only: track nested open tags, no enclosures. |
63
+ * | false | [...] | Enclosure-only: skip interiors, stop at first close tag outside them. |
64
+ * | true | [...] | Full: depth tracking + enclosure skipping. |
65
+ *
66
+ * ### Chunk-boundary survival (feedable / stream sources)
67
+ *
68
+ * When input runs out mid-collection, `collect()` throws `UNEXPECTED_END`.
69
+ * The caller (`feed()` in XMLParser) catches it and rewinds the source to the
70
+ * outer mark (the `<` of the stop node's opening tag). On the next `feed()`
71
+ * call `readOpeningTag()` sees the reader is already active (`isActive()`) and
72
+ * calls `resumeAfterOpenTag()` to re-consume the opening tag before calling
73
+ * `collect()` again. All accumulated content and state are preserved in
74
+ * instance fields between attempts.
75
+ */
76
+ export class StopNodeProcessor {
77
+ /**
78
+ * @param {string} tagName
79
+ * The stop-node tag name to watch for.
80
+ * @param {object} [opts]
81
+ * @param {boolean} [opts.nested=false]
82
+ * When true, nested same-name open tags increment a depth counter; the
83
+ * stop node ends only when depth returns to zero.
84
+ * @param {Array<{open:string,close:string}>} [opts.skipEnclosures=[]]
85
+ * Enclosure pairs whose interiors suppress closing-tag detection.
86
+ */
87
+ constructor(tagName, { nested = false, skipEnclosures = [] } = {}) {
88
+ this._tagName = tagName;
89
+ this._nested = nested;
90
+ this._enclosures = skipEnclosures;
91
+
92
+ // Runtime state — reset in activate() / resumeAfterOpenTag()
93
+ this._content = '';
94
+ this._depth = 1; // already inside the opening tag
95
+ this._active = false;
96
+ }
97
+
98
+ /** True once activated; cleared when `collect()` returns successfully. */
99
+ isActive() {
100
+ return this._active;
101
+ }
102
+
103
+ /**
104
+ * Activate this processor. Called by `readOpeningTag` the first time it
105
+ * encounters the stop node (after `readTagExp` has consumed the opening tag).
106
+ */
107
+ activate() {
108
+ this._active = true;
109
+ this._content = '';
110
+ this._depth = 1;
111
+ }
112
+
113
+ /**
114
+ * Called on resume (chunk boundary): the source was rewound to the `<` of
115
+ * the stop node's opening tag, so the caller must re-consume the opening tag
116
+ * via `readTagExp` before calling `collect()`.
117
+ *
118
+ * Because the rewind replays the entire opening tag, any content accumulated
119
+ * during the failed attempt is invalid. Reset to a clean post-activation
120
+ * state so the next `collect()` starts fresh from right after the opening tag.
121
+ */
122
+ resumeAfterOpenTag() {
123
+ this._content = '';
124
+ this._depth = 1;
125
+ }
126
+
127
+ /**
128
+ * Collect raw content from `source` until the matching closing tag is found.
129
+ *
130
+ * Dispatches to one of four internal strategies based on the `nested` flag
131
+ * and whether `skipEnclosures` is non-empty:
132
+ *
133
+ * - Plain (`nested:false`, no enclosures): fastest path — scan for
134
+ * the literal `</tagName>` string and stop immediately.
135
+ * - Depth-only (`nested:true`, no enclosures): track open/close tags
136
+ * for depth, no enclosure skipping.
137
+ * - Enclosure-only (`nested:false`, enclosures): skip enclosure interiors,
138
+ * stop at the first closing tag found outside them.
139
+ * - Full (`nested:true`, enclosures): depth tracking AND
140
+ * enclosure skipping.
141
+ *
142
+ * Progress (`_content`, `_depth`) is stored in instance fields so a
143
+ * chunk-boundary `UNEXPECTED_END` can be retried seamlessly.
144
+ *
145
+ * @param {object} source Any source object with the standard read interface.
146
+ * @returns {string} Raw content between the opening and closing tags.
147
+ */
148
+ collect(source) {
149
+ source.markTokenStart(1);
150
+
151
+ const enclosuresLen = this._enclosures.length;//dont inline
152
+
153
+ if (!this._nested && enclosuresLen === 0) {
154
+ return this._collectPlain(source);
155
+ }
156
+ if (this._nested && enclosuresLen === 0) {
157
+ return this._collectDepthOnly(source);
158
+ }
159
+ if (!this._nested && enclosuresLen > 0) {
160
+ return this._collectEnclosureOnly(source);
161
+ }
162
+ // nested && enclosures.length > 0
163
+ return this._collectFull(source);
164
+ }
165
+
166
+ // ── Strategy 1: Plain ──────────────────────────────────────────────────────
167
+
168
+ /**
169
+ * Fastest path. No depth tracking, no enclosure skipping.
170
+ * Scans for the literal `</tagName>` followed by optional whitespace then `>`.
171
+ */
172
+ _collectPlain(source) {
173
+ const needed = '</' + this._tagName;
174
+
175
+ while (source.canRead()) {
176
+ const ch = source.readChAt(0);
177
+
178
+ if (ch !== '<') {
179
+ this._content += source.readCh();
180
+ continue;
181
+ }
182
+
183
+ // At '<' — check whether this is our closing tag
184
+ if (this._peekMatch(source, needed)) {
185
+ let offset = needed.length;
186
+ let validClose = false;
187
+ while (true) {
188
+ const c = source.readChAt(offset);
189
+ if (c === '>') { validClose = true; break; }
190
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { offset++; continue; }
191
+ break;
192
+ }
193
+
194
+ if (validClose) {
195
+ // Consume `</tagName + optional whitespace + >`
196
+ this._skipChars(source, needed.length);
197
+ while (source.canRead()) {
198
+ const c = source.readCh();
199
+ if (c === '>') break;
200
+ }
201
+ return this._finish();
202
+ }
203
+ }
204
+
205
+ this._content += source.readCh();
206
+ }
207
+
208
+ throw new ParseError(
209
+ `Unclosed stop node <${this._tagName}> — unexpected end of input`,
210
+ ErrorCode.UNEXPECTED_END,
211
+ );
212
+ }
213
+
214
+ // ── Strategy 2: Depth-only ─────────────────────────────────────────────────
215
+
216
+ /**
217
+ * Depth tracking without enclosure skipping.
218
+ * Properly handles nested same-name open tags. No enclosure awareness.
219
+ */
220
+ _collectDepthOnly(source) {
221
+ while (this._depth > 0) {
222
+ if (!source.canRead()) {
223
+ throw new ParseError(
224
+ `Unclosed stop node <${this._tagName}> — unexpected end of input`,
225
+ ErrorCode.UNEXPECTED_END,
226
+ );
227
+ }
228
+
229
+ const ch = source.readChAt(0);
230
+
231
+ if (ch !== '<') {
232
+ this._content += source.readCh();
233
+ continue;
234
+ }
235
+
236
+ // Consume '<'
237
+ source.readCh();
238
+
239
+ if (!source.canRead()) {
240
+ throw new ParseError(
241
+ `Unclosed stop node <${this._tagName}> — unexpected end after '<'`,
242
+ ErrorCode.UNEXPECTED_END,
243
+ );
244
+ }
245
+
246
+ const c0 = source.readChAt(0);
247
+
248
+ if (c0 === '/') {
249
+ // Closing tag
250
+ source.readCh(); // consume '/'
251
+ const closeName = this._readTagName(source);
252
+ const closeSuffix = this._readToAngleClose(source);
253
+
254
+ if (closeName === this._tagName) {
255
+ this._depth--;
256
+ if (this._depth === 0) return this._finish();
257
+ }
258
+ this._content += '</' + closeName + closeSuffix;
259
+ continue;
260
+ }
261
+
262
+ // Opening tag (including self-closing)
263
+ const openName = this._readTagName(source);
264
+ this._content += '<' + openName;
265
+
266
+ const { selfClosing, attrText } = this._readTagTail(source);
267
+ this._content += attrText;
268
+
269
+ if (!selfClosing && openName === this._tagName) {
270
+ this._depth++;
271
+ }
272
+ }
273
+
274
+ /* istanbul ignore next */
275
+ throw new ParseError(
276
+ `Unclosed stop node <${this._tagName}> — unexpected end of input`,
277
+ ErrorCode.UNEXPECTED_END,
278
+ );
279
+ }
280
+
281
+ // ── Strategy 3: Enclosure-only ─────────────────────────────────────────────
282
+
283
+ /**
284
+ * Enclosure skipping without depth tracking.
285
+ * Skips enclosure interiors; stops at the first `</tagName>` found outside them.
286
+ */
287
+ _collectEnclosureOnly(source) {
288
+ while (source.canRead()) {
289
+ // Enclosure openers take priority over everything else
290
+ const encIdx = this._matchEnclosureOpen(source);
291
+ if (encIdx !== -1) {
292
+ const enc = this._enclosures[encIdx];
293
+ this._skipChars(source, enc.open.length);
294
+ this._content += enc.open;
295
+ const interior = this._readUpto(source, enc.close);
296
+ this._content += interior + enc.close;
297
+ continue;
298
+ }
299
+
300
+ const ch = source.readChAt(0);
301
+
302
+ if (ch !== '<') {
303
+ this._content += source.readCh();
304
+ continue;
305
+ }
306
+
307
+ // At '<' outside any enclosure — check for our closing tag
308
+ const needed = '</' + this._tagName;
309
+ if (this._peekMatch(source, needed)) {
310
+ let offset = needed.length;
311
+ let validClose = false;
312
+ while (true) {
313
+ const c = source.readChAt(offset);
314
+ if (c === '>') { validClose = true; break; }
315
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { offset++; continue; }
316
+ break;
317
+ }
318
+
319
+ if (validClose) {
320
+ this._skipChars(source, needed.length);
321
+ while (source.canRead()) {
322
+ const c = source.readCh();
323
+ if (c === '>') break;
324
+ }
325
+ return this._finish();
326
+ }
327
+ }
328
+
329
+ this._content += source.readCh();
330
+ }
331
+
332
+ throw new ParseError(
333
+ `Unclosed stop node <${this._tagName}> — unexpected end of input`,
334
+ ErrorCode.UNEXPECTED_END,
335
+ );
336
+ }
337
+
338
+ // ── Strategy 4: Full (nested + enclosures) ─────────────────────────────────
339
+
340
+ /**
341
+ * Full mode: enclosure skipping AND depth tracking.
342
+ * Enclosure interiors suppress all closing-tag and depth logic for their span.
343
+ * Depth tracks nested same-name open tags; the stop node ends at depth zero.
344
+ */
345
+ _collectFull(source) {
346
+ while (this._depth > 0) {
347
+ if (!source.canRead()) {
348
+ throw new ParseError(
349
+ `Unclosed stop node <${this._tagName}> — unexpected end of input`,
350
+ ErrorCode.UNEXPECTED_END,
351
+ );
352
+ }
353
+
354
+ // Enclosure openers take priority over tag scanning
355
+ const encIdx = this._matchEnclosureOpen(source);
356
+ if (encIdx !== -1) {
357
+ const enc = this._enclosures[encIdx];
358
+ this._skipChars(source, enc.open.length);
359
+ this._content += enc.open;
360
+ const interior = this._readUpto(source, enc.close);
361
+ this._content += interior + enc.close;
362
+ continue;
363
+ }
364
+
365
+ const ch = source.readChAt(0);
366
+
367
+ if (ch !== '<') {
368
+ this._content += source.readCh();
369
+ continue;
370
+ }
371
+
372
+ // Consume '<'
373
+ source.readCh();
374
+
375
+ if (!source.canRead()) {
376
+ throw new ParseError(
377
+ `Unclosed stop node <${this._tagName}> — unexpected end after '<'`,
378
+ ErrorCode.UNEXPECTED_END,
379
+ );
380
+ }
381
+
382
+ const c0 = source.readChAt(0);
383
+
384
+ if (c0 === '/') {
385
+ // Closing tag
386
+ source.readCh(); // consume '/'
387
+ const closeName = this._readTagName(source);
388
+ const closeSuffix = this._readToAngleClose(source);
389
+
390
+ if (closeName === this._tagName) {
391
+ this._depth--;
392
+ if (this._depth === 0) return this._finish();
393
+ }
394
+ this._content += '</' + closeName + closeSuffix;
395
+ continue;
396
+ }
397
+
398
+ // Opening tag (including self-closing)
399
+ const openName = this._readTagName(source);
400
+ this._content += '<' + openName;
401
+
402
+ const { selfClosing, attrText } = this._readTagTail(source);
403
+ this._content += attrText;
404
+
405
+ if (!selfClosing && openName === this._tagName) {
406
+ this._depth++;
407
+ }
408
+ }
409
+
410
+ /* istanbul ignore next */
411
+ throw new ParseError(
412
+ `Unclosed stop node <${this._tagName}> — unexpected end of input`,
413
+ ErrorCode.UNEXPECTED_END,
414
+ );
415
+ }
416
+
417
+ // ── Shared finish helper ───────────────────────────────────────────────────
418
+
419
+ /**
420
+ * Reset runtime state and return the accumulated content.
421
+ * Called by every strategy when the closing tag is confirmed.
422
+ * @returns {string}
423
+ */
424
+ _finish() {
425
+ const result = this._content;
426
+ this._active = false;
427
+ this._content = '';
428
+ this._depth = 1;
429
+ return result;
430
+ }
431
+
432
+ // ── Private helpers ────────────────────────────────────────────────────────
433
+
434
+ /**
435
+ * Check whether any enclosure's `open` marker starts at the current source
436
+ * position (without consuming). Returns the index of the first matching
437
+ * enclosure, or -1 if none match.
438
+ */
439
+ _matchEnclosureOpen(source) {
440
+ const enclosuresLen = this._enclosures.length;
441
+ for (let i = 0; i < enclosuresLen; i++) {
442
+ if (this._peekMatch(source, this._enclosures[i].open)) return i;
443
+ }
444
+ return -1;
445
+ }
446
+
447
+ /**
448
+ * Read until `stopStr` is found, consuming `stopStr` itself.
449
+ * Returns the text before `stopStr`. Throws UNEXPECTED_END if input runs out.
450
+ */
451
+ _readUpto(source, stopStr) {
452
+ const s0 = stopStr[0];
453
+ const sLen = stopStr.length;
454
+ const start = source.startIndex;
455
+ let len = 0;
456
+
457
+ while (source.canRead()) {
458
+ if (source.readChAt(0) === s0 && this._peekMatch(source, stopStr)) {
459
+ const text = source.readStr(len, start);
460
+ this._skipChars(source, sLen);
461
+ return text;
462
+ }
463
+ source.readCh();
464
+ len++;
465
+ }
466
+
467
+ throw new ParseError(
468
+ `Unclosed stop node <${this._tagName}> — unexpected end looking for '${stopStr}'`,
469
+ ErrorCode.UNEXPECTED_END,
470
+ );
471
+ }
472
+
473
+ /**
474
+ * Check whether the source (starting at current position) starts with `str`.
475
+ * Does NOT consume.
476
+ */
477
+ _peekMatch(source, str) {
478
+ const strLen = str.length;
479
+ for (let i = 0; i < strLen; i++) {
480
+ if (source.readChAt(i) !== str[i]) return false;
481
+ }
482
+ return true;
483
+ }
484
+
485
+ /**
486
+ * Consume exactly `n` characters from source (discarding them — the caller
487
+ * is responsible for appending to `_content` if needed).
488
+ */
489
+ _skipChars(source, n) {
490
+ for (let i = 0; i < n; i++) source.readCh();
491
+ }
492
+
493
+ /**
494
+ * Read an XML name (tag name) from the current source position.
495
+ * Stops at `>`, `/`, or any whitespace. Does NOT consume the delimiter.
496
+ */
497
+ _readTagName(source) {
498
+ let name = '';
499
+ while (source.canRead()) {
500
+ const ch = source.readChAt(0);
501
+ if (ch === '>' || ch === '/' || ch === ' ' || ch === '\t' ||
502
+ ch === '\n' || ch === '\r') break;
503
+ name += source.readCh();
504
+ }
505
+ return name;
506
+ }
507
+
508
+ /**
509
+ * Read from after the tag name up to and including the closing `>`,
510
+ * detecting self-closing `/>` and respecting quoted attribute values so
511
+ * a `>` inside a value does not prematurely end the tag.
512
+ *
513
+ * Returns `{ selfClosing: boolean, attrText: string }` where `attrText`
514
+ * includes everything from the first attribute character up to and
515
+ * including the closing `>` (or `/>`).
516
+ */
517
+ _readTagTail(source) {
518
+ const start = source.startIndex;
519
+ let len = 0;
520
+ let inSingle = false;
521
+ let inDouble = false;
522
+
523
+ while (source.canRead()) {
524
+ const ch = source.readCh();
525
+ len++;
526
+
527
+ if (ch === "'" && !inDouble) {
528
+ inSingle = !inSingle;
529
+ } else if (ch === '"' && !inSingle) {
530
+ inDouble = !inDouble;
531
+ } else if (!inSingle && !inDouble) {
532
+ if (ch === '>') {
533
+ return { selfClosing: false, attrText: source.readStr(len, start) };
534
+ }
535
+ if (ch === '/' && source.canRead() && source.readChAt(0) === '>') {
536
+ source.readCh(); // consume '>'
537
+ len++;
538
+ return { selfClosing: true, attrText: source.readStr(len, start) };
539
+ }
540
+ }
541
+ }
542
+
543
+ throw new ParseError(
544
+ `Unclosed stop node <${this._tagName}> — unexpected end inside tag`,
545
+ ErrorCode.UNEXPECTED_END,
546
+ );
547
+ }
548
+
549
+ /**
550
+ * After reading a closing tag name, read optional whitespace and the `>`
551
+ * returning them as a raw string (e.g. `' >'` or `'>'`).
552
+ * Preserves original spacing when reconstructing inner closing tags.
553
+ */
554
+ _readToAngleClose(source) {
555
+ const start = source.startIndex;
556
+ let len = 0;
557
+ while (source.canRead()) {
558
+ const ch = source.readCh();
559
+ len++;
560
+ if (ch === '>') return source.readStr(len, start);
561
+ if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r') {
562
+ throw new ParseError(
563
+ `Malformed closing tag for </${this._tagName}>`,
564
+ ErrorCode.UNEXPECTED_END,
565
+ );
566
+ }
567
+ }
568
+ throw new ParseError(
569
+ `Unclosed stop node <${this._tagName}> — unexpected end looking for '>'`,
570
+ ErrorCode.UNEXPECTED_END,
571
+ );
572
+ }
573
+ }