@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.
- package/CHANGELOG.md +0 -0
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/lib/fxp.d.cts +652 -0
- package/package.json +80 -0
- package/src/AttributeProcessor.js +107 -0
- package/src/AutoCloseHandler.js +257 -0
- package/src/CharsSymbol.js +16 -0
- package/src/DocTypeReader.js +522 -0
- package/src/InputSource/BufferSource.js +228 -0
- package/src/InputSource/FeedableSource.js +340 -0
- package/src/InputSource/StreamSource.js +49 -0
- package/src/InputSource/StringSource.js +225 -0
- package/src/OptionsBuilder.js +400 -0
- package/src/ParseError.js +91 -0
- package/src/StopNodeProcessor.js +573 -0
- package/src/XMLParser.js +293 -0
- package/src/Xml2JsParser.js +573 -0
- package/src/XmlPartReader.js +183 -0
- package/src/XmlSpecialTagsReader.js +82 -0
- package/src/fxp.d.ts +619 -0
- package/src/fxp.js +8 -0
- package/src/util.js +58 -0
|
@@ -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
|
+
}
|