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.
- package/LICENSE +22 -0
- package/README.md +127 -0
- package/lib/character-reference-table.js +2249 -0
- package/lib/character-references.js +164 -0
- package/lib/document.js +125 -0
- package/lib/element.js +258 -0
- package/lib/html-tags.js +129 -0
- package/lib/node.js +259 -0
- package/lib/parse-html.js +96 -0
- package/lib/selector-matcher.js +315 -0
- package/lib/selector-parser.js +565 -0
- package/lib/serialize.js +142 -0
- package/lib/tokenizer.js +481 -0
- package/lib/tree-builder.js +319 -0
- package/mod.js +9 -0
- package/package.json +43 -0
|
@@ -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
|
+
}
|
package/lib/serialize.js
ADDED
|
@@ -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, '&')
|
|
15
|
+
.replace(/</gu, '<')
|
|
16
|
+
.replace(/>/gu, '>')
|
|
17
|
+
.replace(/\u00A0/gu, ' ');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function escapeAttributeValue(value) {
|
|
21
|
+
return value
|
|
22
|
+
.replace(/&/gu, '&')
|
|
23
|
+
.replace(/"/gu, '"')
|
|
24
|
+
.replace(/\u00A0/gu, ' ');
|
|
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
|
+
}
|