@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,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
|
+
}
|