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,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module tree-builder
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Document } from './document.js';
|
|
6
|
+
import { Element } from './element.js';
|
|
7
|
+
import { Text, Comment } from './node.js';
|
|
8
|
+
import { VOID_ELEMENTS, HEAD_ELEMENTS, FOREIGN_ROOT_ELEMENTS, AUTO_CLOSED_BY } from './html-tags.js';
|
|
9
|
+
|
|
10
|
+
const WHITESPACE_ONLY_PATTERN = /^[\t\n\f\r ]*$/;
|
|
11
|
+
|
|
12
|
+
// Builds the tree from a token stream, tracking just enough state to
|
|
13
|
+
// implement the pragmatic subset described in the implementation plan: an
|
|
14
|
+
// open-element stack, two flags for whether <html> and <body> have been
|
|
15
|
+
// opened, and a foreign-content depth counter for <svg>/<math>. There is no
|
|
16
|
+
// scope-checking insertion-mode state machine.
|
|
17
|
+
//
|
|
18
|
+
// "Are we still routing content into <head>" is derived from the stack
|
|
19
|
+
// itself (the current insertion point is exactly document.head) rather than
|
|
20
|
+
// tracked as separate state, so it naturally stops being true the moment
|
|
21
|
+
// head is popped for any reason — an explicit </head>, or the head being
|
|
22
|
+
// closed to open body — without needing every call site to agree on a
|
|
23
|
+
// phase transition.
|
|
24
|
+
class TreeBuilder {
|
|
25
|
+
#document = new Document();
|
|
26
|
+
#stack = [];
|
|
27
|
+
#htmlOpened = false;
|
|
28
|
+
#bodyOpened = false;
|
|
29
|
+
#foreignRootDepth = 0;
|
|
30
|
+
#doctypeSet = false;
|
|
31
|
+
#errors = [];
|
|
32
|
+
|
|
33
|
+
// Text-merge tracking: the most recently appended Text node and the
|
|
34
|
+
// parent it was appended to, so that a run of text tokens broken only
|
|
35
|
+
// by a discarded token (a stray end tag, an implied auto-close) still
|
|
36
|
+
// collapses into a single Text node rather than one per token.
|
|
37
|
+
#openTextNode = null;
|
|
38
|
+
#openTextParent = null;
|
|
39
|
+
|
|
40
|
+
get document() {
|
|
41
|
+
return this.#document;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get errors() {
|
|
45
|
+
return this.#errors;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#recordError(code, message, offset) {
|
|
49
|
+
this.#errors.push({ code, message, offset });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#currentParent() {
|
|
53
|
+
return this.#stack.length > 0 ? this.#stack[this.#stack.length - 1] : this.#document;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#atHeadInsertionPoint() {
|
|
57
|
+
return !this.#bodyOpened && this.#currentParent() === this.#document.head;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#invalidateTextMerge() {
|
|
61
|
+
this.#openTextNode = null;
|
|
62
|
+
this.#openTextParent = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#appendText(parent, data) {
|
|
66
|
+
if (!data) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (this.#openTextNode && this.#openTextParent === parent) {
|
|
71
|
+
this.#openTextNode.data += data;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const node = new Text(data);
|
|
76
|
+
parent.appendChild(node);
|
|
77
|
+
this.#openTextNode = node;
|
|
78
|
+
this.#openTextParent = parent;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#appendNode(parent, node) {
|
|
82
|
+
parent.appendChild(node);
|
|
83
|
+
this.#invalidateTextMerge();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Pops the top of the open-element stack. `implied` records an
|
|
87
|
+
// implied-end-tag error, used when an element closes as a side effect
|
|
88
|
+
// of auto-closing or an end tag scanning past it, rather than because
|
|
89
|
+
// its own end tag (or a void/self-closing start tag) closed it.
|
|
90
|
+
#popTopElement(implied, offset) {
|
|
91
|
+
const element = this.#stack.pop();
|
|
92
|
+
|
|
93
|
+
if (element.isForeign && FOREIGN_ROOT_ELEMENTS.has(element.localName)) {
|
|
94
|
+
this.#foreignRootDepth -= 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (implied) {
|
|
98
|
+
this.#recordError('implied-end-tag', `"${ element.localName }" was closed implicitly.`, offset);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.#invalidateTextMerge();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#ensureHtmlOpen(attributes) {
|
|
105
|
+
if (this.#htmlOpened) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const html = new Element('html', 'html', attributes ?? [], false);
|
|
110
|
+
this.#document.appendChild(html);
|
|
111
|
+
this.#document.setDocumentElement(html);
|
|
112
|
+
this.#stack.push(html);
|
|
113
|
+
|
|
114
|
+
const head = new Element('head', 'head', [], false);
|
|
115
|
+
html.appendChild(head);
|
|
116
|
+
this.#document.setHead(head);
|
|
117
|
+
this.#stack.push(head);
|
|
118
|
+
|
|
119
|
+
this.#htmlOpened = true;
|
|
120
|
+
this.#invalidateTextMerge();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Closes <head> (if it is the current insertion point) and opens
|
|
124
|
+
// <body>. Every path that ends up needing a body — an explicit <body>
|
|
125
|
+
// tag, a non-head start tag, non-whitespace text, or reaching the end
|
|
126
|
+
// of the token stream — funnels through here.
|
|
127
|
+
#openBody(attributes) {
|
|
128
|
+
this.#ensureHtmlOpen();
|
|
129
|
+
|
|
130
|
+
if (this.#bodyOpened) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (this.#atHeadInsertionPoint()) {
|
|
135
|
+
this.#popTopElement(false, null);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const html = this.#document.documentElement;
|
|
139
|
+
const body = new Element('body', 'body', attributes, false);
|
|
140
|
+
html.appendChild(body);
|
|
141
|
+
this.#document.setBody(body);
|
|
142
|
+
this.#stack.push(body);
|
|
143
|
+
this.#bodyOpened = true;
|
|
144
|
+
this.#invalidateTextMerge();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#handleDoctype(token) {
|
|
148
|
+
if (!this.#doctypeSet) {
|
|
149
|
+
this.#document.setDoctype({ name: token.name, publicId: token.publicId, systemId: token.systemId });
|
|
150
|
+
this.#doctypeSet = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#handleComment(token) {
|
|
155
|
+
const parent = this.#htmlOpened ? this.#currentParent() : this.#document;
|
|
156
|
+
this.#appendNode(parent, new Comment(token.data));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#handleText(token) {
|
|
160
|
+
const isWhitespaceOnly = WHITESPACE_ONLY_PATTERN.test(token.data);
|
|
161
|
+
|
|
162
|
+
if (!this.#htmlOpened) {
|
|
163
|
+
if (isWhitespaceOnly) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.#openBody([]);
|
|
167
|
+
this.#appendText(this.#currentParent(), token.data);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (this.#atHeadInsertionPoint() && !isWhitespaceOnly) {
|
|
172
|
+
this.#openBody([]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.#appendText(this.#currentParent(), token.data);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#insertElement(token, isForeignNow) {
|
|
179
|
+
const element = new Element(token.name, token.rawName, token.attributes, isForeignNow);
|
|
180
|
+
this.#appendNode(this.#currentParent(), element);
|
|
181
|
+
|
|
182
|
+
if (VOID_ELEMENTS.has(token.name)) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (isForeignNow) {
|
|
187
|
+
if (token.selfClosing) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
} else if (token.selfClosing) {
|
|
191
|
+
this.#recordError(
|
|
192
|
+
'non-void-html-element-start-tag-with-trailing-solidus',
|
|
193
|
+
`"${ token.name }" is not a void element; "/>" is ignored.`,
|
|
194
|
+
token.offset,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (FOREIGN_ROOT_ELEMENTS.has(token.name)) {
|
|
199
|
+
this.#foreignRootDepth += 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
this.#stack.push(element);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#handleStartTag(token) {
|
|
206
|
+
const { name } = token;
|
|
207
|
+
const isForeignNow = this.#foreignRootDepth > 0 || FOREIGN_ROOT_ELEMENTS.has(name);
|
|
208
|
+
|
|
209
|
+
if (name === 'html') {
|
|
210
|
+
this.#ensureHtmlOpen(token.attributes);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this.#ensureHtmlOpen();
|
|
215
|
+
|
|
216
|
+
if (name === 'body') {
|
|
217
|
+
if (!this.#bodyOpened) {
|
|
218
|
+
this.#openBody(token.attributes);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (this.#atHeadInsertionPoint()) {
|
|
224
|
+
if (name === 'head') {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (HEAD_ELEMENTS.has(name)) {
|
|
228
|
+
this.#insertElement(token, isForeignNow);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
this.#openBody([]);
|
|
232
|
+
} else if (name === 'head') {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
while (this.#stack.length > 0) {
|
|
237
|
+
const top = this.#stack[this.#stack.length - 1];
|
|
238
|
+
const closers = !top.isForeign && AUTO_CLOSED_BY.get(top.localName);
|
|
239
|
+
|
|
240
|
+
if (closers && closers.has(name)) {
|
|
241
|
+
this.#popTopElement(true, token.offset);
|
|
242
|
+
} else {
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
this.#insertElement(token, isForeignNow);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#handleEndTag(token) {
|
|
251
|
+
const isForeignNow = this.#foreignRootDepth > 0;
|
|
252
|
+
const matchKey = isForeignNow ? token.rawName : token.name;
|
|
253
|
+
|
|
254
|
+
let matchIndex = -1;
|
|
255
|
+
for (let index = this.#stack.length - 1; index >= 0; index -= 1) {
|
|
256
|
+
const candidate = this.#stack[index];
|
|
257
|
+
const key = candidate.isForeign ? candidate.rawName : candidate.localName;
|
|
258
|
+
if (key === matchKey) {
|
|
259
|
+
matchIndex = index;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (matchIndex === -1) {
|
|
265
|
+
this.#recordError('stray-end-tag', `No open element matches end tag "${ token.name }".`, token.offset);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
while (this.#stack.length > matchIndex + 1) {
|
|
270
|
+
this.#popTopElement(true, token.offset);
|
|
271
|
+
}
|
|
272
|
+
this.#popTopElement(false, null);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
consume(tokens) {
|
|
276
|
+
for (const token of tokens) {
|
|
277
|
+
switch (token.type) {
|
|
278
|
+
case 'parseError':
|
|
279
|
+
this.#recordError(token.code, token.message, token.offset);
|
|
280
|
+
break;
|
|
281
|
+
case 'doctype':
|
|
282
|
+
this.#handleDoctype(token);
|
|
283
|
+
break;
|
|
284
|
+
case 'comment':
|
|
285
|
+
this.#handleComment(token);
|
|
286
|
+
break;
|
|
287
|
+
case 'text':
|
|
288
|
+
this.#handleText(token);
|
|
289
|
+
break;
|
|
290
|
+
case 'startTag':
|
|
291
|
+
this.#handleStartTag(token);
|
|
292
|
+
break;
|
|
293
|
+
case 'endTag':
|
|
294
|
+
this.#handleEndTag(token);
|
|
295
|
+
break;
|
|
296
|
+
default:
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Implied structure is always built, even for empty input.
|
|
302
|
+
this.#openBody([]);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Consumes a token stream and returns the resulting `Document`, along with
|
|
308
|
+
* the unresolved parse errors collected along the way (each `{code,
|
|
309
|
+
* message, offset}`, without `line`/`column` — the caller resolves those
|
|
310
|
+
* against the preprocessed source, since this module never reads source
|
|
311
|
+
* text itself).
|
|
312
|
+
* @param {Iterable<Object>} tokens - Tokens from `tokenize()`.
|
|
313
|
+
* @returns {{document: Document, errors: {code: string, message: string, offset: number}[]}}
|
|
314
|
+
*/
|
|
315
|
+
export function buildTree(tokens) {
|
|
316
|
+
const builder = new TreeBuilder();
|
|
317
|
+
builder.consume(tokens);
|
|
318
|
+
return { document: builder.document, errors: builder.errors };
|
|
319
|
+
}
|
package/mod.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module html-dom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { parseHTML } from './lib/parse-html.js';
|
|
6
|
+
export { Node, ParentNode, CharacterData, Text, Comment } from './lib/node.js';
|
|
7
|
+
export { Element } from './lib/element.js';
|
|
8
|
+
export { Document } from './lib/document.js';
|
|
9
|
+
export { SelectorSyntaxError } from './lib/selector-parser.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "parse-html-dom",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Parse an HTML string into a virtual Document Object Model",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./mod.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./mod.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"lib/",
|
|
12
|
+
"mod.js",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.3.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node run-linter.js && node run-tests.js",
|
|
21
|
+
"lint": "node run-linter.js"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/kixxauth/html-dom.git"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"html",
|
|
29
|
+
"parser",
|
|
30
|
+
"dom"
|
|
31
|
+
],
|
|
32
|
+
"author": "Kris Walker (www.kriswalker.me)",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/kixxauth/html-dom/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/kixxauth/html-dom",
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"kixx-assert": "2.1.1",
|
|
40
|
+
"kixx-linting": "1.1.2",
|
|
41
|
+
"kixx-test": "3.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|