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