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
package/lib/node.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { querySelector as findFirstMatch, querySelectorAll as findAllMatches } from './selector-matcher.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Base class for every node in the tree. Structure — `parentNode` and, for
|
|
9
|
+
* container nodes, children — is set once during parsing and never changes
|
|
10
|
+
* afterward. The constructor is not a supported way to build a tree outside
|
|
11
|
+
* this package; `parseHTML` owns construction.
|
|
12
|
+
*/
|
|
13
|
+
export class Node {
|
|
14
|
+
/** @type {Node|null} */
|
|
15
|
+
#parentNode = null;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {number} nodeType - The DOM node type number.
|
|
19
|
+
*/
|
|
20
|
+
constructor(nodeType) {
|
|
21
|
+
this.nodeType = nodeType;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @returns {Node|null} The parent node, or `null` at the root.
|
|
26
|
+
*/
|
|
27
|
+
get parentNode() {
|
|
28
|
+
return this.#parentNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @returns {Element|null} The parent when it is an `Element`, otherwise
|
|
33
|
+
* `null` — so an element directly under `Document` reports `null`
|
|
34
|
+
* rather than a `Document`. Checked by `nodeType` rather than
|
|
35
|
+
* `instanceof Element` so this module has no dependency on
|
|
36
|
+
* element.js, which itself depends on this one.
|
|
37
|
+
*/
|
|
38
|
+
get parentElement() {
|
|
39
|
+
return this.#parentNode && this.#parentNode.nodeType === 1 ? this.#parentNode : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Internal: called only by the tree builder while assembling a node's
|
|
43
|
+
// parent, never exposed as a public mutator.
|
|
44
|
+
setParentNode(node) {
|
|
45
|
+
this.#parentNode = node;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A `Node` that can contain children: `Document` and `Element`. Kept
|
|
51
|
+
* separate from `Node` so that `Text` and `Comment` do not inherit
|
|
52
|
+
* `children` or the query methods, which would be meaningless on them.
|
|
53
|
+
*/
|
|
54
|
+
export class ParentNode extends Node {
|
|
55
|
+
/** @type {Node[]} */
|
|
56
|
+
#childNodes = [];
|
|
57
|
+
|
|
58
|
+
/** @type {Node[]|null} */
|
|
59
|
+
#frozenChildNodes = null;
|
|
60
|
+
|
|
61
|
+
/** @type {Element[]|null} */
|
|
62
|
+
#frozenChildren = null;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @returns {Node[]} Every child node — elements, text, and comments — in
|
|
66
|
+
* source order. The same frozen Array instance is returned on every
|
|
67
|
+
* access.
|
|
68
|
+
*/
|
|
69
|
+
get childNodes() {
|
|
70
|
+
if (!this.#frozenChildNodes) {
|
|
71
|
+
this.#frozenChildNodes = Object.freeze(this.#childNodes.slice());
|
|
72
|
+
}
|
|
73
|
+
return this.#frozenChildNodes;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @returns {Element[]} Child `Element` nodes only, in source order. The
|
|
78
|
+
* same frozen Array instance is returned on every access. Filtered by
|
|
79
|
+
* `nodeType` rather than `instanceof Element` — see the note on
|
|
80
|
+
* `parentElement`.
|
|
81
|
+
*/
|
|
82
|
+
get children() {
|
|
83
|
+
if (!this.#frozenChildren) {
|
|
84
|
+
this.#frozenChildren = Object.freeze(
|
|
85
|
+
this.#childNodes.filter((node) => node.nodeType === 1),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return this.#frozenChildren;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @returns {string|null} The concatenated data of every descendant
|
|
93
|
+
* `Text` node in document order, ignoring comments. `null` when this
|
|
94
|
+
* node is a `Document`, per the DOM.
|
|
95
|
+
*/
|
|
96
|
+
get textContent() {
|
|
97
|
+
if (this.nodeType === 9) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let result = '';
|
|
102
|
+
const stack = this.#childNodes.slice().reverse();
|
|
103
|
+
|
|
104
|
+
while (stack.length > 0) {
|
|
105
|
+
const node = stack.pop();
|
|
106
|
+
if (node instanceof Text) {
|
|
107
|
+
result += node.data;
|
|
108
|
+
} else if (node instanceof ParentNode) {
|
|
109
|
+
for (let index = node.childNodes.length - 1; index >= 0; index -= 1) {
|
|
110
|
+
stack.push(node.childNodes[index]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string} name - Element local name to match, or `'*'` for any
|
|
120
|
+
* element. Matched case-insensitively against HTML elements and
|
|
121
|
+
* case-sensitively against foreign elements.
|
|
122
|
+
* @returns {Element[]} Descendant elements in document order, excluding
|
|
123
|
+
* this node itself.
|
|
124
|
+
*/
|
|
125
|
+
getElementsByTagName(name) {
|
|
126
|
+
const isWildcard = name === '*';
|
|
127
|
+
const lowerName = name.toLowerCase();
|
|
128
|
+
const results = [];
|
|
129
|
+
|
|
130
|
+
const visit = (node) => {
|
|
131
|
+
for (const child of node.children) {
|
|
132
|
+
const matches = isWildcard
|
|
133
|
+
|| (child.isForeign ? child.tagName === name : child.localName === lowerName);
|
|
134
|
+
|
|
135
|
+
if (matches) {
|
|
136
|
+
results.push(child);
|
|
137
|
+
}
|
|
138
|
+
visit(child);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
visit(this);
|
|
143
|
+
|
|
144
|
+
return Object.freeze(results);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string} names - One or more class names separated by ASCII
|
|
149
|
+
* whitespace. An element must carry all of them, matched
|
|
150
|
+
* case-sensitively.
|
|
151
|
+
* @returns {Element[]} Descendant elements in document order, excluding
|
|
152
|
+
* this node itself.
|
|
153
|
+
*/
|
|
154
|
+
getElementsByClassName(names) {
|
|
155
|
+
const required = names.split(/\s+/).filter(Boolean);
|
|
156
|
+
const results = [];
|
|
157
|
+
|
|
158
|
+
const visit = (node) => {
|
|
159
|
+
for (const child of node.children) {
|
|
160
|
+
if (required.every((token) => child.classList.contains(token))) {
|
|
161
|
+
results.push(child);
|
|
162
|
+
}
|
|
163
|
+
visit(child);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
visit(this);
|
|
168
|
+
|
|
169
|
+
return Object.freeze(results);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {string} selector - A CSS selector.
|
|
174
|
+
* @returns {Element|null} The first descendant matching `selector`, in
|
|
175
|
+
* document order, or `null`.
|
|
176
|
+
* @throws {SelectorSyntaxError} When `selector` is invalid.
|
|
177
|
+
*/
|
|
178
|
+
querySelector(selector) {
|
|
179
|
+
return findFirstMatch(this, selector);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {string} selector - A CSS selector.
|
|
184
|
+
* @returns {Element[]} Every descendant matching `selector`, in
|
|
185
|
+
* document order, deduplicated across a comma-separated selector
|
|
186
|
+
* list.
|
|
187
|
+
* @throws {SelectorSyntaxError} When `selector` is invalid.
|
|
188
|
+
*/
|
|
189
|
+
querySelectorAll(selector) {
|
|
190
|
+
return findAllMatches(this, selector);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Internal: called only by the tree builder while appending a child.
|
|
194
|
+
appendChild(node) {
|
|
195
|
+
this.#childNodes.push(node);
|
|
196
|
+
node.setParentNode(this);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A node holding a single string of data: `Text` or `Comment`.
|
|
202
|
+
*/
|
|
203
|
+
export class CharacterData extends Node {
|
|
204
|
+
/**
|
|
205
|
+
* @param {number} nodeType - The DOM node type number.
|
|
206
|
+
* @param {string} data - The node's text data.
|
|
207
|
+
*/
|
|
208
|
+
constructor(nodeType, data) {
|
|
209
|
+
super(nodeType);
|
|
210
|
+
this.data = data;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @returns {string} The node's data. Provided so `Text` and `Comment`
|
|
215
|
+
* match the DOM's `textContent` contract at the leaf level.
|
|
216
|
+
*/
|
|
217
|
+
get textContent() {
|
|
218
|
+
return this.data;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* A text node.
|
|
224
|
+
*/
|
|
225
|
+
export class Text extends CharacterData {
|
|
226
|
+
/**
|
|
227
|
+
* @param {string} data - The text content.
|
|
228
|
+
*/
|
|
229
|
+
constructor(data) {
|
|
230
|
+
super(3, data);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @returns {string} Always `'#text'`.
|
|
235
|
+
*/
|
|
236
|
+
get nodeName() {
|
|
237
|
+
return '#text';
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A comment node.
|
|
243
|
+
*/
|
|
244
|
+
export class Comment extends CharacterData {
|
|
245
|
+
/**
|
|
246
|
+
* @param {string} data - The comment content, excluding `<!--` and
|
|
247
|
+
* `-->`.
|
|
248
|
+
*/
|
|
249
|
+
constructor(data) {
|
|
250
|
+
super(8, data);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @returns {string} Always `'#comment'`.
|
|
255
|
+
*/
|
|
256
|
+
get nodeName() {
|
|
257
|
+
return '#comment';
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module parse-html
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { tokenize } from './tokenizer.js';
|
|
6
|
+
import { buildTree } from './tree-builder.js';
|
|
7
|
+
|
|
8
|
+
const BOM = '';
|
|
9
|
+
|
|
10
|
+
// Normalizes line endings to LF and strips a leading BOM before tokenizing,
|
|
11
|
+
// so every offset the tokenizer and tree builder record indexes into this
|
|
12
|
+
// normalized string rather than the caller's original one.
|
|
13
|
+
function preprocess(html) {
|
|
14
|
+
let source = html;
|
|
15
|
+
|
|
16
|
+
if (source.startsWith(BOM)) {
|
|
17
|
+
source = source.slice(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return source.replace(/\r\n?/g, '\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Builds a sorted index of the offset just after every "\n" in `source`, so
|
|
24
|
+
// that resolveErrorPositions() can turn a byte offset into a 1-based line
|
|
25
|
+
// and column with a binary search instead of rescanning the source once per
|
|
26
|
+
// error.
|
|
27
|
+
function buildLineStartIndex(source) {
|
|
28
|
+
const lineStarts = [ 0 ];
|
|
29
|
+
|
|
30
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
31
|
+
if (source[index] === '\n') {
|
|
32
|
+
lineStarts.push(index + 1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return lineStarts;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findLineIndex(lineStarts, offset) {
|
|
40
|
+
let low = 0;
|
|
41
|
+
let high = lineStarts.length - 1;
|
|
42
|
+
|
|
43
|
+
while (low < high) {
|
|
44
|
+
const mid = Math.ceil((low + high) / 2);
|
|
45
|
+
if (lineStarts[mid] <= offset) {
|
|
46
|
+
low = mid;
|
|
47
|
+
} else {
|
|
48
|
+
high = mid - 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return low;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Resolving line/column is deferred until we know at least one error
|
|
56
|
+
// happened, so a clean document never pays for the line-start scan.
|
|
57
|
+
function resolveErrorPositions(errors, source) {
|
|
58
|
+
if (errors.length === 0) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const lineStarts = buildLineStartIndex(source);
|
|
63
|
+
|
|
64
|
+
return errors.map((error) => {
|
|
65
|
+
const lineIndex = findLineIndex(lineStarts, error.offset);
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
code: error.code,
|
|
68
|
+
message: error.message,
|
|
69
|
+
offset: error.offset,
|
|
70
|
+
line: lineIndex + 1,
|
|
71
|
+
column: error.offset - lineStarts[lineIndex] + 1,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parses an HTML string into a read-only `Document`. Never throws for
|
|
78
|
+
* malformed markup — recoveries are recorded on the returned document's
|
|
79
|
+
* `parseErrors` — but does throw for a caller error: a non-string argument.
|
|
80
|
+
* @param {string} html - The HTML source to parse.
|
|
81
|
+
* @returns {Document} The parsed document. `documentElement`, `head`, and
|
|
82
|
+
* `body` are always non-null, even for empty input.
|
|
83
|
+
* @throws {TypeError} When `html` is not a string.
|
|
84
|
+
*/
|
|
85
|
+
export function parseHTML(html) {
|
|
86
|
+
if (typeof html !== 'string') {
|
|
87
|
+
throw new TypeError(`parseHTML() expects a string, received ${ typeof html }.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const source = preprocess(html);
|
|
91
|
+
const { document, errors } = buildTree(tokenize(source));
|
|
92
|
+
|
|
93
|
+
document.setParseErrors(resolveErrorPositions(errors, source));
|
|
94
|
+
|
|
95
|
+
return document;
|
|
96
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module selector-matcher
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parseSelector } from './selector-parser.js';
|
|
6
|
+
|
|
7
|
+
function typeKey(element) {
|
|
8
|
+
return element.isForeign ? element.rawName : element.localName;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function previousElementSibling(element) {
|
|
12
|
+
const parent = element.parentNode;
|
|
13
|
+
if (!parent) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const siblings = parent.childNodes;
|
|
18
|
+
const index = siblings.indexOf(element);
|
|
19
|
+
|
|
20
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
21
|
+
if (siblings[i].nodeType === 1) {
|
|
22
|
+
return siblings[i];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Element-only siblings (including `element` itself) in document order,
|
|
30
|
+
// optionally narrowed to those sharing `element`'s type — the distinction
|
|
31
|
+
// between the plain "-child" and "-of-type" pseudo-classes.
|
|
32
|
+
function siblingsFor(element, sameTypeOnly) {
|
|
33
|
+
const parent = element.parentNode;
|
|
34
|
+
const all = parent ? parent.children : [ element ];
|
|
35
|
+
|
|
36
|
+
if (!sameTypeOnly) {
|
|
37
|
+
return all;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const key = typeKey(element);
|
|
41
|
+
return all.filter((candidate) => typeKey(candidate) === key);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function positionAmongSiblings(element, sameTypeOnly) {
|
|
45
|
+
const siblings = siblingsFor(element, sameTypeOnly);
|
|
46
|
+
return siblings.indexOf(element) + 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function positionFromEndAmongSiblings(element, sameTypeOnly) {
|
|
50
|
+
const siblings = siblingsFor(element, sameTypeOnly);
|
|
51
|
+
return siblings.length - siblings.indexOf(element);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// An, per the CSS An+B microsyntax: true when there exists an integer n >= 0
|
|
55
|
+
// such that a*n + b === position.
|
|
56
|
+
function matchesNth(position, { a, b }) {
|
|
57
|
+
if (a === 0) {
|
|
58
|
+
return position === b;
|
|
59
|
+
}
|
|
60
|
+
const n = (position - b) / a;
|
|
61
|
+
return Number.isInteger(n) && n >= 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isEmptyElement(element) {
|
|
65
|
+
return element.childNodes.every((node) => {
|
|
66
|
+
if (node.nodeType === 1) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
if (node.nodeType === 3) {
|
|
70
|
+
return node.data.length === 0;
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function matchesAttribute(element, attribute) {
|
|
77
|
+
const value = element.getAttribute(attribute.name);
|
|
78
|
+
|
|
79
|
+
if (value === null) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (attribute.operator === null) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let actual = value;
|
|
87
|
+
let expected = attribute.value;
|
|
88
|
+
|
|
89
|
+
if (attribute.caseInsensitive) {
|
|
90
|
+
actual = actual.toLowerCase();
|
|
91
|
+
expected = expected.toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
switch (attribute.operator) {
|
|
95
|
+
case '=':
|
|
96
|
+
return actual === expected;
|
|
97
|
+
case '~=':
|
|
98
|
+
return actual.split(/\s+/u).filter(Boolean).includes(expected);
|
|
99
|
+
case '|=':
|
|
100
|
+
return actual === expected || actual.startsWith(`${ expected }-`);
|
|
101
|
+
case '^=':
|
|
102
|
+
return expected !== '' && actual.startsWith(expected);
|
|
103
|
+
case '$=':
|
|
104
|
+
return expected !== '' && actual.endsWith(expected);
|
|
105
|
+
case '*=':
|
|
106
|
+
return expected !== '' && actual.includes(expected);
|
|
107
|
+
default:
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function matchesPseudo(element, pseudo) {
|
|
113
|
+
switch (pseudo.name) {
|
|
114
|
+
case 'root':
|
|
115
|
+
return Boolean(element.parentNode) && element.parentNode.nodeType === 9;
|
|
116
|
+
case 'empty':
|
|
117
|
+
return isEmptyElement(element);
|
|
118
|
+
case 'first-child':
|
|
119
|
+
return positionAmongSiblings(element, false) === 1;
|
|
120
|
+
case 'last-child':
|
|
121
|
+
return positionFromEndAmongSiblings(element, false) === 1;
|
|
122
|
+
case 'only-child':
|
|
123
|
+
return positionAmongSiblings(element, false) === 1 && positionFromEndAmongSiblings(element, false) === 1;
|
|
124
|
+
case 'first-of-type':
|
|
125
|
+
return positionAmongSiblings(element, true) === 1;
|
|
126
|
+
case 'last-of-type':
|
|
127
|
+
return positionFromEndAmongSiblings(element, true) === 1;
|
|
128
|
+
case 'only-of-type':
|
|
129
|
+
return positionAmongSiblings(element, true) === 1 && positionFromEndAmongSiblings(element, true) === 1;
|
|
130
|
+
case 'nth-child':
|
|
131
|
+
return matchesNth(positionAmongSiblings(element, false), pseudo.argument);
|
|
132
|
+
case 'nth-last-child':
|
|
133
|
+
return matchesNth(positionFromEndAmongSiblings(element, false), pseudo.argument);
|
|
134
|
+
case 'nth-of-type':
|
|
135
|
+
return matchesNth(positionAmongSiblings(element, true), pseudo.argument);
|
|
136
|
+
case 'nth-last-of-type':
|
|
137
|
+
return matchesNth(positionFromEndAmongSiblings(element, true), pseudo.argument);
|
|
138
|
+
case 'not':
|
|
139
|
+
return !matchesAnySelector(element, pseudo.argument);
|
|
140
|
+
case 'is':
|
|
141
|
+
case 'where':
|
|
142
|
+
return matchesAnySelector(element, pseudo.argument);
|
|
143
|
+
default:
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function matchesCompound(element, compound) {
|
|
149
|
+
if (compound.tag !== null) {
|
|
150
|
+
const matchesTag = element.isForeign
|
|
151
|
+
? element.rawName === compound.rawTag
|
|
152
|
+
: element.localName === compound.tag;
|
|
153
|
+
|
|
154
|
+
if (!matchesTag) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (compound.id !== null && element.id !== compound.id) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!compound.classes.every((token) => element.classList.contains(token))) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!compound.attributes.every((attribute) => matchesAttribute(element, attribute))) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return compound.pseudos.every((pseudo) => matchesPseudo(element, pseudo));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Matches right to left: the rightmost compound is tested against `element`
|
|
175
|
+
// first, and only on success does matching walk left through the
|
|
176
|
+
// combinator chain — so a candidate that fails on its own compound never
|
|
177
|
+
// pays for a tree walk.
|
|
178
|
+
function matchesComplex(element, chain) {
|
|
179
|
+
if (!matchesCompound(element, chain.compound)) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
if (chain.combinator === null) {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
return matchesCombinatorLeft(element, chain.combinator, chain.left);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function matchesCombinatorLeft(element, combinator, leftChain) {
|
|
189
|
+
if (combinator === '>') {
|
|
190
|
+
const parent = element.parentElement;
|
|
191
|
+
return Boolean(parent) && matchesComplex(parent, leftChain);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (combinator === ' ') {
|
|
195
|
+
let ancestor = element.parentElement;
|
|
196
|
+
while (ancestor) {
|
|
197
|
+
if (matchesComplex(ancestor, leftChain)) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
ancestor = ancestor.parentElement;
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (combinator === '+') {
|
|
206
|
+
const sibling = previousElementSibling(element);
|
|
207
|
+
return Boolean(sibling) && matchesComplex(sibling, leftChain);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// '~'
|
|
211
|
+
let sibling = previousElementSibling(element);
|
|
212
|
+
while (sibling) {
|
|
213
|
+
if (matchesComplex(sibling, leftChain)) {
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
sibling = previousElementSibling(sibling);
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function matchesAnySelector(element, selectorList) {
|
|
222
|
+
return selectorList.some((chain) => matchesComplex(element, chain));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Yields every descendant Element of `root` in document order, using an
|
|
226
|
+
// explicit stack rather than recursion so a pathologically deep tree cannot
|
|
227
|
+
// overflow the call stack.
|
|
228
|
+
function* iterateDescendants(root) {
|
|
229
|
+
const stack = [];
|
|
230
|
+
const children = root.children;
|
|
231
|
+
for (let i = children.length - 1; i >= 0; i -= 1) {
|
|
232
|
+
stack.push(children[i]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
while (stack.length > 0) {
|
|
236
|
+
const node = stack.pop();
|
|
237
|
+
yield node;
|
|
238
|
+
|
|
239
|
+
const kids = node.children;
|
|
240
|
+
for (let i = kids.length - 1; i >= 0; i -= 1) {
|
|
241
|
+
stack.push(kids[i]);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {Element} element - The element to test.
|
|
248
|
+
* @param {string} selectorText - A CSS selector.
|
|
249
|
+
* @returns {boolean} Whether `element` matches `selectorText`.
|
|
250
|
+
* @throws {SelectorSyntaxError} When `selectorText` is invalid.
|
|
251
|
+
*/
|
|
252
|
+
export function matches(element, selectorText) {
|
|
253
|
+
return matchesAnySelector(element, parseSelector(selectorText));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* @param {ParentNode} scope - The element or document to search within.
|
|
258
|
+
* @param {string} selectorText - A CSS selector.
|
|
259
|
+
* @returns {Element|null} The first descendant of `scope` matching
|
|
260
|
+
* `selectorText`, in document order, or `null`.
|
|
261
|
+
* @throws {SelectorSyntaxError} When `selectorText` is invalid.
|
|
262
|
+
*/
|
|
263
|
+
export function querySelector(scope, selectorText) {
|
|
264
|
+
const ast = parseSelector(selectorText);
|
|
265
|
+
|
|
266
|
+
for (const element of iterateDescendants(scope)) {
|
|
267
|
+
if (matchesAnySelector(element, ast)) {
|
|
268
|
+
return element;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* @param {ParentNode} scope - The element or document to search within.
|
|
277
|
+
* @param {string} selectorText - A CSS selector.
|
|
278
|
+
* @returns {Element[]} Every descendant of `scope` matching `selectorText`,
|
|
279
|
+
* in document order, deduplicated across a comma-separated selector list.
|
|
280
|
+
* @throws {SelectorSyntaxError} When `selectorText` is invalid.
|
|
281
|
+
*/
|
|
282
|
+
export function querySelectorAll(scope, selectorText) {
|
|
283
|
+
const ast = parseSelector(selectorText);
|
|
284
|
+
const results = [];
|
|
285
|
+
|
|
286
|
+
for (const element of iterateDescendants(scope)) {
|
|
287
|
+
if (matchesAnySelector(element, ast)) {
|
|
288
|
+
results.push(element);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return Object.freeze(results);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param {Element} element - The element to start from.
|
|
297
|
+
* @param {string} selectorText - A CSS selector.
|
|
298
|
+
* @returns {Element|null} `element` itself if it matches, otherwise the
|
|
299
|
+
* nearest matching ancestor, stopping at `documentElement`. `null` when
|
|
300
|
+
* nothing matches.
|
|
301
|
+
* @throws {SelectorSyntaxError} When `selectorText` is invalid.
|
|
302
|
+
*/
|
|
303
|
+
export function closest(element, selectorText) {
|
|
304
|
+
const ast = parseSelector(selectorText);
|
|
305
|
+
|
|
306
|
+
let current = element;
|
|
307
|
+
while (current) {
|
|
308
|
+
if (matchesAnySelector(current, ast)) {
|
|
309
|
+
return current;
|
|
310
|
+
}
|
|
311
|
+
current = current.parentElement;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return null;
|
|
315
|
+
}
|