parse-html-dom 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,565 @@
1
+ /**
2
+ * @module selector-parser
3
+ */
4
+
5
+ const CACHE_CAPACITY = 500;
6
+ const cache = new Map();
7
+
8
+ const SUPPORTED_SIMPLE_PSEUDOS = new Set([
9
+ 'first-child',
10
+ 'last-child',
11
+ 'only-child',
12
+ 'first-of-type',
13
+ 'last-of-type',
14
+ 'only-of-type',
15
+ 'empty',
16
+ 'root',
17
+ ]);
18
+
19
+ const SUPPORTED_NTH_PSEUDOS = new Set([
20
+ 'nth-child',
21
+ 'nth-last-child',
22
+ 'nth-of-type',
23
+ 'nth-last-of-type',
24
+ ]);
25
+
26
+ const SUPPORTED_SELECTOR_LIST_PSEUDOS = new Set([ 'not', 'is', 'where' ]);
27
+
28
+ const ATTRIBUTE_OPERATORS = [ '~=', '|=', '^=', '$=', '*=' ];
29
+
30
+ /**
31
+ * Thrown for a malformed selector string. `name` is `'SyntaxError'` — not
32
+ * the class name `SelectorSyntaxError` — so that generic error handling
33
+ * written against `error.name` (matching what a browser's `DOMException`
34
+ * reports) still works.
35
+ */
36
+ export class SelectorSyntaxError extends SyntaxError {
37
+ /**
38
+ * @param {string} message - Description of what went wrong.
39
+ * @param {string} selector - The full selector string being parsed.
40
+ * @param {number} position - 0-based index into `selector`.
41
+ */
42
+ constructor(message, selector, position) {
43
+ super(message);
44
+ this.name = 'SyntaxError';
45
+ this.selector = selector;
46
+ this.position = position;
47
+ }
48
+ }
49
+
50
+ // Thrown internally during parsing and always caught at the top of
51
+ // parseSelector(), which has the full selector string needed to build a
52
+ // SelectorSyntaxError. Keeping parse failures lightweight (message +
53
+ // position only) avoids threading the original string through every
54
+ // recursive-descent function.
55
+ class ParseFailure extends Error {
56
+ constructor(message, position) {
57
+ super(message);
58
+ this.position = position;
59
+ }
60
+ }
61
+
62
+ function fail(message, position) {
63
+ throw new ParseFailure(message, position);
64
+ }
65
+
66
+ function isWhitespace(char) {
67
+ return char === ' ' || char === '\t' || char === '\n' || char === '\f' || char === '\r';
68
+ }
69
+
70
+ function skipWhitespace(str, pos) {
71
+ let index = pos;
72
+ while (index < str.length && isWhitespace(str[index])) {
73
+ index += 1;
74
+ }
75
+ return index;
76
+ }
77
+
78
+ function isHexDigit(char) {
79
+ return Boolean(char) && /^[0-9A-Fa-f]$/.test(char);
80
+ }
81
+
82
+ function isIdentStartChar(char) {
83
+ return Boolean(char) && (/^[A-Za-z_-]$/.test(char) || char.codePointAt(0) > 0x7F || char === '\\');
84
+ }
85
+
86
+ function isIdentChar(char) {
87
+ return Boolean(char) && (/^[A-Za-z0-9_-]$/.test(char) || char.codePointAt(0) > 0x7F || char === '\\');
88
+ }
89
+
90
+ // Consumes one CSS escape starting at str[pos] === '\\': either one to six
91
+ // hex digits (plus one optional trailing whitespace character) naming a
92
+ // code point, or a single literal character.
93
+ function scanEscape(str, pos) {
94
+ const first = str[pos + 1];
95
+
96
+ if (isHexDigit(first)) {
97
+ let index = pos + 1;
98
+ let hex = '';
99
+ while (index < str.length && hex.length < 6 && isHexDigit(str[index])) {
100
+ hex += str[index];
101
+ index += 1;
102
+ }
103
+ if (isWhitespace(str[index])) {
104
+ index += 1;
105
+ }
106
+ return { value: String.fromCodePoint(parseInt(hex, 16)), nextPos: index };
107
+ }
108
+
109
+ if (first === undefined) {
110
+ return { value: '', nextPos: pos + 1 };
111
+ }
112
+
113
+ return { value: first, nextPos: pos + 2 };
114
+ }
115
+
116
+ // Scans an identifier (element name, id, class name, attribute name,
117
+ // pseudo-class name, or attribute value), decoding escapes as it goes. This
118
+ // is what makes ".foo\.bar" scan to a single class named "foo.bar".
119
+ function scanIdentifier(str, pos) {
120
+ let result = '';
121
+ let index = pos;
122
+
123
+ while (index < str.length) {
124
+ const char = str[index];
125
+ if (char === '\\') {
126
+ const escape = scanEscape(str, index);
127
+ result += escape.value;
128
+ index = escape.nextPos;
129
+ } else if (isIdentChar(char)) {
130
+ result += char;
131
+ index += 1;
132
+ } else {
133
+ break;
134
+ }
135
+ }
136
+
137
+ return { value: result, nextPos: index };
138
+ }
139
+
140
+ function parseNth(str, pos) {
141
+ const index = skipWhitespace(str, pos);
142
+
143
+ if (str.slice(index, index + 3).toLowerCase() === 'odd' && !isIdentChar(str[index + 3])) {
144
+ return { value: { a: 2, b: 1 }, nextPos: index + 3 };
145
+ }
146
+ if (str.slice(index, index + 4).toLowerCase() === 'even' && !isIdentChar(str[index + 4])) {
147
+ return { value: { a: 2, b: 0 }, nextPos: index + 4 };
148
+ }
149
+
150
+ let cursor = index;
151
+ let sign = 1;
152
+
153
+ if (str[cursor] === '+') {
154
+ cursor += 1;
155
+ } else if (str[cursor] === '-') {
156
+ sign = -1;
157
+ cursor += 1;
158
+ }
159
+
160
+ let digits = '';
161
+ while (/[0-9]/.test(str[cursor])) {
162
+ digits += str[cursor];
163
+ cursor += 1;
164
+ }
165
+
166
+ if (str[cursor] === 'n' || str[cursor] === 'N') {
167
+ const a = sign * (digits === '' ? 1 : parseInt(digits, 10));
168
+ cursor += 1;
169
+
170
+ const afterN = skipWhitespace(str, cursor);
171
+ if (str[afterN] === '+' || str[afterN] === '-') {
172
+ const bSign = str[afterN] === '-' ? -1 : 1;
173
+ const bStart = skipWhitespace(str, afterN + 1);
174
+ let bDigits = '';
175
+ let bCursor = bStart;
176
+ while (/[0-9]/.test(str[bCursor])) {
177
+ bDigits += str[bCursor];
178
+ bCursor += 1;
179
+ }
180
+ if (bDigits === '') {
181
+ fail('Expected digits after the sign in an nth expression.', bCursor);
182
+ }
183
+ return { value: { a, b: bSign * parseInt(bDigits, 10) }, nextPos: bCursor };
184
+ }
185
+
186
+ return { value: { a, b: 0 }, nextPos: cursor };
187
+ }
188
+
189
+ if (digits === '') {
190
+ fail('Expected a number or "n" in an nth expression.', cursor);
191
+ }
192
+
193
+ return { value: { a: 0, b: sign * parseInt(digits, 10) }, nextPos: cursor };
194
+ }
195
+
196
+ function parseAttributeSelector(str, pos) {
197
+ let index = skipWhitespace(str, pos + 1);
198
+
199
+ const nameIdent = scanIdentifier(str, index);
200
+ if (nameIdent.nextPos === index) {
201
+ fail('Expected an attribute name.', index);
202
+ }
203
+ const name = nameIdent.value.toLowerCase();
204
+ index = skipWhitespace(str, nameIdent.nextPos);
205
+
206
+ if (str[index] === ']') {
207
+ return { attribute: { name, operator: null, value: null, caseInsensitive: false }, nextPos: index + 1 };
208
+ }
209
+
210
+ let operator = null;
211
+ const twoChar = str.slice(index, index + 2);
212
+
213
+ if (ATTRIBUTE_OPERATORS.includes(twoChar)) {
214
+ operator = twoChar;
215
+ index += 2;
216
+ } else if (str[index] === '=') {
217
+ operator = '=';
218
+ index += 1;
219
+ } else {
220
+ fail('Expected an attribute operator.', index);
221
+ }
222
+
223
+ index = skipWhitespace(str, index);
224
+
225
+ let value;
226
+ if (str[index] === '"' || str[index] === '\'') {
227
+ const quote = str[index];
228
+ const end = str.indexOf(quote, index + 1);
229
+ if (end === -1) {
230
+ fail('Unterminated attribute value.', index);
231
+ }
232
+ value = str.slice(index + 1, end);
233
+ index = end + 1;
234
+ } else {
235
+ const valueIdent = scanIdentifier(str, index);
236
+ if (valueIdent.nextPos === index) {
237
+ fail('Expected an attribute value.', index);
238
+ }
239
+ value = valueIdent.value;
240
+ index = valueIdent.nextPos;
241
+ }
242
+
243
+ index = skipWhitespace(str, index);
244
+
245
+ let caseInsensitive = false;
246
+ if ((str[index] === 'i' || str[index] === 'I') && !isIdentChar(str[index + 1])) {
247
+ caseInsensitive = true;
248
+ index = skipWhitespace(str, index + 1);
249
+ }
250
+
251
+ if (str[index] !== ']') {
252
+ fail('Expected "]" to close an attribute selector.', index);
253
+ }
254
+
255
+ return { attribute: { name, operator, value, caseInsensitive }, nextPos: index + 1 };
256
+ }
257
+
258
+ function parsePseudo(str, pos) {
259
+ const start = pos + 1;
260
+
261
+ if (str[start] === ':') {
262
+ fail('Pseudo-elements ("::") are not supported.', pos);
263
+ }
264
+
265
+ const nameIdent = scanIdentifier(str, start);
266
+ if (nameIdent.nextPos === start) {
267
+ fail('Expected a pseudo-class name after ":".', start);
268
+ }
269
+ const name = nameIdent.value.toLowerCase();
270
+ let index = nameIdent.nextPos;
271
+
272
+ if (str[index] === '(') {
273
+ index = skipWhitespace(str, index + 1);
274
+
275
+ if (SUPPORTED_NTH_PSEUDOS.has(name)) {
276
+ const nth = parseNth(str, index);
277
+ index = skipWhitespace(str, nth.nextPos);
278
+ if (str[index] !== ')') {
279
+ fail(`Expected ")" to close ":${ name }()".`, index);
280
+ }
281
+ return { pseudo: { name, argument: nth.value }, nextPos: index + 1 };
282
+ }
283
+
284
+ if (SUPPORTED_SELECTOR_LIST_PSEUDOS.has(name)) {
285
+ const list = parseSelectorListInternal(str, index, ')');
286
+ index = skipWhitespace(str, list.nextPos);
287
+ if (str[index] !== ')') {
288
+ fail(`Expected ")" to close ":${ name }()".`, index);
289
+ }
290
+ return { pseudo: { name, argument: list.selectors }, nextPos: index + 1 };
291
+ }
292
+
293
+ fail(`":${ name }()" is a recognized but unsupported pseudo-class.`, pos);
294
+ }
295
+
296
+ if (SUPPORTED_SIMPLE_PSEUDOS.has(name)) {
297
+ return { pseudo: { name, argument: null }, nextPos: index };
298
+ }
299
+
300
+ fail(`":${ name }" is a recognized but unsupported pseudo-class.`, pos);
301
+ return null;
302
+ }
303
+
304
+ // Parses one compound selector (a type/universal selector plus any run of
305
+ // #id, .class, [attr], and :pseudo pieces with no combinator between them).
306
+ // Returns null when `pos` is not the start of a compound at all, which
307
+ // happens at the end of input or just before a combinator/comma/")".
308
+ function parseCompound(str, pos) {
309
+ let index = pos;
310
+ let tag = null;
311
+ let rawTag = null;
312
+ let matchedAnything = false;
313
+
314
+ if (str[index] === '*') {
315
+ index += 1;
316
+ matchedAnything = true;
317
+ } else if (isIdentStartChar(str[index])) {
318
+ const ident = scanIdentifier(str, index);
319
+ tag = ident.value.toLowerCase();
320
+ rawTag = ident.value;
321
+ index = ident.nextPos;
322
+ matchedAnything = true;
323
+ }
324
+
325
+ let id = null;
326
+ const classes = [];
327
+ const attributes = [];
328
+ const pseudos = [];
329
+
330
+ for (;;) {
331
+ const char = str[index];
332
+
333
+ if (char === '#') {
334
+ const ident = scanIdentifier(str, index + 1);
335
+ if (ident.nextPos === index + 1) {
336
+ fail('Expected an id after "#".', index);
337
+ }
338
+ id = ident.value;
339
+ index = ident.nextPos;
340
+ } else if (char === '.') {
341
+ const ident = scanIdentifier(str, index + 1);
342
+ if (ident.nextPos === index + 1) {
343
+ fail('Expected a class name after ".".', index);
344
+ }
345
+ classes.push(ident.value);
346
+ index = ident.nextPos;
347
+ } else if (char === '[') {
348
+ const result = parseAttributeSelector(str, index);
349
+ attributes.push(result.attribute);
350
+ index = result.nextPos;
351
+ } else if (char === ':') {
352
+ const result = parsePseudo(str, index);
353
+ pseudos.push(result.pseudo);
354
+ index = result.nextPos;
355
+ } else {
356
+ break;
357
+ }
358
+
359
+ matchedAnything = true;
360
+ }
361
+
362
+ if (!matchedAnything) {
363
+ return null;
364
+ }
365
+
366
+ return { compound: { tag, rawTag, id, classes, attributes, pseudos }, nextPos: index };
367
+ }
368
+
369
+ // Parses one complex selector — a chain of compounds joined by combinators —
370
+ // stopping at end of input or at any character in `stopChars` (a comma at
371
+ // the top level, or ")" inside a pseudo-class argument list). Returns the
372
+ // chain already anchored at the rightmost compound: see the module's
373
+ // SelectorAst typedef.
374
+ function parseComplexSelector(str, pos, stopChars) {
375
+ let index = skipWhitespace(str, pos);
376
+ const first = parseCompound(str, index);
377
+
378
+ if (!first) {
379
+ fail('Expected a selector.', index);
380
+ }
381
+
382
+ const nodes = [ { compound: first.compound, combinator: null } ];
383
+ index = first.nextPos;
384
+
385
+ for (;;) {
386
+ const beforeWhitespace = index;
387
+ const afterWhitespace = skipWhitespace(str, index);
388
+
389
+ if (afterWhitespace >= str.length || stopChars.includes(str[afterWhitespace])) {
390
+ index = afterWhitespace;
391
+ break;
392
+ }
393
+
394
+ let combinator;
395
+ let compoundStart;
396
+
397
+ if (str[afterWhitespace] === '>' || str[afterWhitespace] === '+' || str[afterWhitespace] === '~') {
398
+ combinator = str[afterWhitespace];
399
+ compoundStart = skipWhitespace(str, afterWhitespace + 1);
400
+ } else if (afterWhitespace > beforeWhitespace) {
401
+ combinator = ' ';
402
+ compoundStart = afterWhitespace;
403
+ } else {
404
+ fail('Unexpected character in selector.', afterWhitespace);
405
+ }
406
+
407
+ const next = parseCompound(str, compoundStart);
408
+ if (!next) {
409
+ fail('Expected a selector after combinator.', compoundStart);
410
+ }
411
+
412
+ nodes.push({ compound: next.compound, combinator });
413
+ index = next.nextPos;
414
+ }
415
+
416
+ let chain = null;
417
+ for (const node of nodes) {
418
+ chain = { compound: node.compound, combinator: node.combinator, left: chain };
419
+ }
420
+
421
+ return { chain, nextPos: index };
422
+ }
423
+
424
+ function parseSelectorListInternal(str, pos, stopChar) {
425
+ const stopChars = [ ',', stopChar ];
426
+ const selectors = [];
427
+ let index = pos;
428
+
429
+ for (;;) {
430
+ const result = parseComplexSelector(str, index, stopChars);
431
+ selectors.push(result.chain);
432
+ index = skipWhitespace(str, result.nextPos);
433
+
434
+ if (str[index] === ',') {
435
+ index = skipWhitespace(str, index + 1);
436
+ continue;
437
+ }
438
+ break;
439
+ }
440
+
441
+ return { selectors, nextPos: index };
442
+ }
443
+
444
+ function freezeChain(node) {
445
+ if (!node) {
446
+ return;
447
+ }
448
+
449
+ const { compound } = node;
450
+ Object.freeze(compound.classes);
451
+ for (const attribute of compound.attributes) {
452
+ Object.freeze(attribute);
453
+ }
454
+ Object.freeze(compound.attributes);
455
+ for (const pseudo of compound.pseudos) {
456
+ if (Array.isArray(pseudo.argument)) {
457
+ pseudo.argument.forEach(freezeChain);
458
+ Object.freeze(pseudo.argument);
459
+ } else if (pseudo.argument) {
460
+ Object.freeze(pseudo.argument);
461
+ }
462
+ Object.freeze(pseudo);
463
+ }
464
+ Object.freeze(compound.pseudos);
465
+ Object.freeze(compound);
466
+
467
+ freezeChain(node.left);
468
+ Object.freeze(node);
469
+ }
470
+
471
+ /**
472
+ * @typedef {Object} AttributeMatcher
473
+ * @property {string} name - Lowercase attribute name.
474
+ * @property {string|null} operator - One of `=`, `~=`, `|=`, `^=`, `$=`,
475
+ * `*=`, or `null` for a bare `[name]` presence check.
476
+ * @property {string|null} value - The comparison value, or `null` for a
477
+ * presence check.
478
+ * @property {boolean} caseInsensitive - Whether the trailing `i` flag was
479
+ * given.
480
+ */
481
+
482
+ /**
483
+ * @typedef {Object} PseudoMatcher
484
+ * @property {string} name - Lowercase pseudo-class name.
485
+ * @property {null|{a: number, b: number}|SelectorAst[]} argument - `null`
486
+ * for an argument-less pseudo-class, a compiled `{a, b}` for an `:nth-*()`
487
+ * one, or a selector list for `:not()`/`:is()`/`:where()`.
488
+ */
489
+
490
+ /**
491
+ * @typedef {Object} Compound
492
+ * @property {string|null} tag - Lowercase type selector, or `null` for `*`
493
+ * or when no type selector was given. Matched case-insensitively against
494
+ * an HTML element's local name.
495
+ * @property {string|null} rawTag - The type selector exactly as written, or
496
+ * `null` alongside `tag`. Matched case-sensitively against a foreign
497
+ * element's `rawName`, since foreign tag names (`clipPath`) are
498
+ * case-sensitive.
499
+ * @property {string|null} id - `#id`, or `null`.
500
+ * @property {string[]} classes - `.class` tokens.
501
+ * @property {AttributeMatcher[]} attributes - `[attr...]` selectors.
502
+ * @property {PseudoMatcher[]} pseudos - `:pseudo` selectors.
503
+ */
504
+
505
+ /**
506
+ * @typedef {Object} SelectorAst
507
+ * @property {Compound} compound - The rightmost compound of this selector
508
+ * (or sub-selector, when nested inside a pseudo-class argument).
509
+ * @property {string|null} combinator - One of `' '`, `'>'`, `'+'`, `'~'`
510
+ * connecting `compound` to `left`, or `null` when `compound` is the
511
+ * leftmost (first-written) compound in the chain.
512
+ * @property {SelectorAst|null} left - The chain continuing to the left, or
513
+ * `null` when `compound` is the leftmost compound.
514
+ */
515
+
516
+ /**
517
+ * Parses a CSS selector string into a selector list — one `SelectorAst` per
518
+ * comma-separated alternative — right-anchored so matching can test the
519
+ * rightmost compound first. Selectors Level 3 structural subset: type,
520
+ * universal, id, class, attribute (all six operators, with the `i` flag),
521
+ * the four combinators, `:not()`/`:is()`/`:where()`, the child/type/index
522
+ * structural pseudo-classes and their `:nth-*()` forms, `:empty`, and
523
+ * `:root`. Not supported: `:has()`, state pseudo-classes (`:hover`,
524
+ * `:checked`, ...), pseudo-elements, and namespace syntax — each throws
525
+ * naming what was rejected, distinct from a generic syntax error.
526
+ *
527
+ * Results are cached by selector string (capped at 500 entries, cleared
528
+ * wholesale past the cap) and returned frozen, so parsing the same string
529
+ * twice returns the identical array.
530
+ * @param {string} selectorText - The selector to parse.
531
+ * @returns {SelectorAst[]} One entry per comma-separated selector.
532
+ * @throws {SelectorSyntaxError} When `selectorText` is not a valid selector
533
+ * in the supported grammar.
534
+ */
535
+ export function parseSelector(selectorText) {
536
+ const cached = cache.get(selectorText);
537
+ if (cached) {
538
+ return cached;
539
+ }
540
+
541
+ let selectors;
542
+ try {
543
+ const result = parseSelectorListInternal(selectorText, 0, '');
544
+ const end = skipWhitespace(selectorText, result.nextPos);
545
+ if (end !== selectorText.length) {
546
+ fail('Unexpected trailing content in selector.', end);
547
+ }
548
+ selectors = result.selectors;
549
+ } catch (error) {
550
+ if (error instanceof ParseFailure) {
551
+ throw new SelectorSyntaxError(error.message, selectorText, error.position);
552
+ }
553
+ throw error;
554
+ }
555
+
556
+ selectors.forEach(freezeChain);
557
+ const ast = Object.freeze(selectors);
558
+
559
+ if (cache.size >= CACHE_CAPACITY) {
560
+ cache.clear();
561
+ }
562
+ cache.set(selectorText, ast);
563
+
564
+ return ast;
565
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @module serialize
3
+ */
4
+
5
+ import { VOID_ELEMENTS, RAW_TEXT_ELEMENTS } from './html-tags.js';
6
+
7
+ // Two distinct escapers. Text content and attribute values escape a
8
+ // different set of characters — an attribute value may contain a literal
9
+ // "<" or ">" but not an unescaped '"', and text is the other way around —
10
+ // and "&" must be replaced first in both, or the "&" introduced by
11
+ // replacing the other characters would itself get escaped.
12
+ function escapeText(text) {
13
+ return text
14
+ .replace(/&/gu, '&amp;')
15
+ .replace(/</gu, '&lt;')
16
+ .replace(/>/gu, '&gt;')
17
+ .replace(/\u00A0/gu, '&nbsp;');
18
+ }
19
+
20
+ function escapeAttributeValue(value) {
21
+ return value
22
+ .replace(/&/gu, '&amp;')
23
+ .replace(/"/gu, '&quot;')
24
+ .replace(/\u00A0/gu, '&nbsp;');
25
+ }
26
+
27
+ // The tag name as written to output: lowercase local name for HTML,
28
+ // rawName verbatim for foreign elements, so "svg" and "clipPath" survive.
29
+ function tagNameFor(element) {
30
+ return element.isForeign ? element.rawName : element.localName;
31
+ }
32
+
33
+ function serializeStartTag(element) {
34
+ let result = `<${ tagNameFor(element) }`;
35
+
36
+ for (const attr of element.getAttributesForSerialization()) {
37
+ const name = element.isForeign ? attr.rawName : attr.name;
38
+ result += ` ${ name }="${ escapeAttributeValue(attr.value) }"`;
39
+ }
40
+
41
+ return `${ result }>`;
42
+ }
43
+
44
+ function serializeEndTag(element) {
45
+ return `</${ tagNameFor(element) }>`;
46
+ }
47
+
48
+ function isRawTextElement(element) {
49
+ return !element.isForeign && RAW_TEXT_ELEMENTS.has(element.localName);
50
+ }
51
+
52
+ function isVoidElement(element) {
53
+ return !element.isForeign && VOID_ELEMENTS.has(element.localName);
54
+ }
55
+
56
+ // Serializes `startNodes` (and everything beneath them) onto `parts`, using
57
+ // an explicit stack rather than recursion so a pathologically deep tree —
58
+ // thousands of nested elements, which is realistic input for a scraping
59
+ // library — cannot overflow the call stack. A `{ closeOf }` marker stands
60
+ // in for "emit this element's end tag now that its children are done."
61
+ function serializeNodes(startNodes, parts) {
62
+ const stack = [];
63
+ for (let index = startNodes.length - 1; index >= 0; index -= 1) {
64
+ stack.push(startNodes[index]);
65
+ }
66
+
67
+ while (stack.length > 0) {
68
+ const item = stack.pop();
69
+
70
+ if (item.closeOf) {
71
+ parts.push(serializeEndTag(item.closeOf));
72
+ continue;
73
+ }
74
+
75
+ const node = item;
76
+
77
+ if (node.nodeType === 3) {
78
+ parts.push(escapeText(node.data));
79
+ continue;
80
+ }
81
+
82
+ if (node.nodeType === 8) {
83
+ parts.push(`<!--${ node.data }-->`);
84
+ continue;
85
+ }
86
+
87
+ // node.nodeType === 1: Element.
88
+ parts.push(serializeStartTag(node));
89
+
90
+ if (isVoidElement(node)) {
91
+ continue;
92
+ }
93
+
94
+ if (isRawTextElement(node)) {
95
+ // Never escaped: the children of <script> and <style> are
96
+ // output verbatim, since escaping them would corrupt inline
97
+ // script and stylesheet content.
98
+ for (const child of node.childNodes) {
99
+ if (child.nodeType === 3) {
100
+ parts.push(child.data);
101
+ }
102
+ }
103
+ parts.push(serializeEndTag(node));
104
+ continue;
105
+ }
106
+
107
+ stack.push({ closeOf: node });
108
+ const { childNodes } = node;
109
+ for (let index = childNodes.length - 1; index >= 0; index -= 1) {
110
+ stack.push(childNodes[index]);
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * @param {Element} element - The element to serialize.
117
+ * @returns {string} HTML for `element`'s children only, matching
118
+ * `Element.innerHTML`.
119
+ */
120
+ export function serializeInnerHTML(element) {
121
+ if (isRawTextElement(element)) {
122
+ return element.childNodes
123
+ .filter((child) => child.nodeType === 3)
124
+ .map((child) => child.data)
125
+ .join('');
126
+ }
127
+
128
+ const parts = [];
129
+ serializeNodes(element.childNodes, parts);
130
+ return parts.join('');
131
+ }
132
+
133
+ /**
134
+ * @param {Element} element - The element to serialize.
135
+ * @returns {string} HTML for `element` including its own tags, matching
136
+ * `Element.outerHTML`.
137
+ */
138
+ export function serializeOuterHTML(element) {
139
+ const parts = [];
140
+ serializeNodes([ element ], parts);
141
+ return parts.join('');
142
+ }