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,481 @@
1
+ /**
2
+ * @module tokenizer
3
+ */
4
+
5
+ import { RAW_TEXT_ELEMENTS, RCDATA_ELEMENTS } from './html-tags.js';
6
+ import { decodeCharacterReferences } from './character-references.js';
7
+
8
+ function isAsciiLetter(char) {
9
+ return Boolean(char) && /^[A-Za-z]$/.test(char);
10
+ }
11
+
12
+ function isAsciiWhitespace(char) {
13
+ return char === ' ' || char === '\t' || char === '\n' || char === '\f' || char === '\r';
14
+ }
15
+
16
+ function skipWhitespace(source, cursor) {
17
+ let index = cursor;
18
+
19
+ while (index < source.length && isAsciiWhitespace(source[index])) {
20
+ index += 1;
21
+ }
22
+
23
+ return index;
24
+ }
25
+
26
+ function parseErrorToken(code, message, offset) {
27
+ return { type: 'parseError', code, message, offset };
28
+ }
29
+
30
+ // Scans a comment body starting where "<!--" ends and returns the closing
31
+ // delimiter's position, or -1 when the source runs out first.
32
+ function findCommentEnd(source, contentStart) {
33
+ return source.indexOf('-->', contentStart);
34
+ }
35
+
36
+ function scanComment(source, index) {
37
+ const contentStart = index + 4;
38
+ const endIndex = findCommentEnd(source, contentStart);
39
+
40
+ if (endIndex === -1) {
41
+ return {
42
+ tokens: [
43
+ { type: 'comment', data: source.slice(contentStart), offset: index },
44
+ parseErrorToken('eof-in-comment', 'Unexpected end of input inside a comment.', index),
45
+ ],
46
+ nextCursor: source.length,
47
+ };
48
+ }
49
+
50
+ return {
51
+ tokens: [
52
+ { type: 'comment', data: source.slice(contentStart, endIndex), offset: index },
53
+ ],
54
+ nextCursor: endIndex + 3,
55
+ };
56
+ }
57
+
58
+ // Consumes source up to (and including) the next ">" for markup this
59
+ // tokenizer treats as a bogus comment: malformed "<!...>", CDATA sections,
60
+ // and "</" not followed by a letter. All three degrade to a comment node
61
+ // carrying the raw text between the opening delimiter and ">".
62
+ function scanBogusComment(source, index, dataStart, code, message) {
63
+ const gtIndex = source.indexOf('>', dataStart);
64
+
65
+ if (gtIndex === -1) {
66
+ return {
67
+ tokens: [
68
+ { type: 'comment', data: source.slice(dataStart), offset: index },
69
+ parseErrorToken(code, message, index),
70
+ ],
71
+ nextCursor: source.length,
72
+ };
73
+ }
74
+
75
+ return {
76
+ tokens: [
77
+ { type: 'comment', data: source.slice(dataStart, gtIndex), offset: index },
78
+ parseErrorToken(code, message, index),
79
+ ],
80
+ nextCursor: gtIndex + 1,
81
+ };
82
+ }
83
+
84
+ // Reads a single-quoted or double-quoted string starting at `cursor`, which
85
+ // must point at the opening quote. Returns null when the quote is never
86
+ // closed, so the caller can decide how to recover.
87
+ function scanQuotedString(source, cursor) {
88
+ const quote = source[cursor];
89
+ const contentStart = cursor + 1;
90
+ const endIndex = source.indexOf(quote, contentStart);
91
+
92
+ if (endIndex === -1) {
93
+ return null;
94
+ }
95
+
96
+ return { value: source.slice(contentStart, endIndex), nextCursor: endIndex + 1 };
97
+ }
98
+
99
+ function scanDoctype(source, index) {
100
+ let cursor = skipWhitespace(source, index + 9);
101
+
102
+ let nameEnd = cursor;
103
+ while (nameEnd < source.length && !isAsciiWhitespace(source[nameEnd]) && source[nameEnd] !== '>') {
104
+ nameEnd += 1;
105
+ }
106
+
107
+ const name = source.slice(cursor, nameEnd).toLowerCase() || null;
108
+ cursor = skipWhitespace(source, nameEnd);
109
+
110
+ let publicId = null;
111
+ let systemId = null;
112
+
113
+ const keyword = source.slice(cursor, cursor + 6).toUpperCase();
114
+
115
+ if (keyword === 'PUBLIC') {
116
+ cursor = skipWhitespace(source, cursor + 6);
117
+ if (source[cursor] === '"' || source[cursor] === '\'') {
118
+ const quoted = scanQuotedString(source, cursor);
119
+ if (quoted) {
120
+ publicId = quoted.value;
121
+ cursor = skipWhitespace(source, quoted.nextCursor);
122
+ }
123
+ }
124
+ if (source[cursor] === '"' || source[cursor] === '\'') {
125
+ const quoted = scanQuotedString(source, cursor);
126
+ if (quoted) {
127
+ systemId = quoted.value;
128
+ cursor = quoted.nextCursor;
129
+ }
130
+ }
131
+ } else if (keyword === 'SYSTEM') {
132
+ cursor = skipWhitespace(source, cursor + 6);
133
+ if (source[cursor] === '"' || source[cursor] === '\'') {
134
+ const quoted = scanQuotedString(source, cursor);
135
+ if (quoted) {
136
+ systemId = quoted.value;
137
+ cursor = quoted.nextCursor;
138
+ }
139
+ }
140
+ }
141
+
142
+ const gtIndex = source.indexOf('>', cursor);
143
+ const doctypeToken = {
144
+ type: 'doctype', name, publicId, systemId, offset: index,
145
+ };
146
+
147
+ if (gtIndex === -1) {
148
+ return {
149
+ tokens: [
150
+ doctypeToken,
151
+ parseErrorToken('eof-in-doctype', 'Unexpected end of input inside a doctype.', index),
152
+ ],
153
+ nextCursor: source.length,
154
+ };
155
+ }
156
+
157
+ return { tokens: [ doctypeToken ], nextCursor: gtIndex + 1 };
158
+ }
159
+
160
+ function scanEndTag(source, index) {
161
+ if (!isAsciiLetter(source[index + 2])) {
162
+ return scanBogusComment(
163
+ source,
164
+ index,
165
+ index + 2,
166
+ 'invalid-first-character-of-tag-name',
167
+ 'An end tag name must start with an ASCII letter.',
168
+ );
169
+ }
170
+
171
+ let cursor = index + 2;
172
+ const nameStart = cursor;
173
+
174
+ while (cursor < source.length && !isAsciiWhitespace(source[cursor]) && source[cursor] !== '/' && source[cursor] !== '>') {
175
+ cursor += 1;
176
+ }
177
+
178
+ const rawName = source.slice(nameStart, cursor);
179
+ const name = rawName.toLowerCase();
180
+
181
+ // Attributes on an end tag are non-standard and ignored, but their
182
+ // quoted values may contain ">"; skip them without splitting the tag.
183
+ while (cursor < source.length && source[cursor] !== '>') {
184
+ if (source[cursor] === '"' || source[cursor] === '\'') {
185
+ const quoted = scanQuotedString(source, cursor);
186
+ cursor = quoted ? quoted.nextCursor : source.length;
187
+ } else {
188
+ cursor += 1;
189
+ }
190
+ }
191
+
192
+ if (cursor >= source.length) {
193
+ return {
194
+ tokens: [
195
+ parseErrorToken('eof-in-tag', 'Unexpected end of input inside a tag.', index),
196
+ ],
197
+ nextCursor: source.length,
198
+ };
199
+ }
200
+
201
+ return {
202
+ tokens: [
203
+ { type: 'endTag', name, rawName, offset: index },
204
+ ],
205
+ nextCursor: cursor + 1,
206
+ };
207
+ }
208
+
209
+ // Parses one attribute starting at `cursor`, which must not be whitespace,
210
+ // ">", or "/". Returns null only when the name is empty, which happens for
211
+ // a stray "=" the caller should skip past to keep the scan progressing.
212
+ function scanAttribute(source, cursor) {
213
+ const nameStart = cursor;
214
+ let index = cursor;
215
+
216
+ while (index < source.length
217
+ && !isAsciiWhitespace(source[index])
218
+ && source[index] !== '/'
219
+ && source[index] !== '>'
220
+ && source[index] !== '=') {
221
+ index += 1;
222
+ }
223
+
224
+ if (index === nameStart) {
225
+ return null;
226
+ }
227
+
228
+ const rawName = source.slice(nameStart, index);
229
+ const name = rawName.toLowerCase();
230
+
231
+ index = skipWhitespace(source, index);
232
+
233
+ let value = '';
234
+
235
+ if (source[index] === '=') {
236
+ index = skipWhitespace(source, index + 1);
237
+
238
+ if (source[index] === '"' || source[index] === '\'') {
239
+ const quoted = scanQuotedString(source, index);
240
+ if (quoted) {
241
+ value = quoted.value;
242
+ index = quoted.nextCursor;
243
+ } else {
244
+ value = source.slice(index + 1);
245
+ index = source.length;
246
+ }
247
+ } else {
248
+ const valueStart = index;
249
+ while (index < source.length && !isAsciiWhitespace(source[index]) && source[index] !== '>') {
250
+ index += 1;
251
+ }
252
+ value = source.slice(valueStart, index);
253
+ }
254
+
255
+ value = decodeCharacterReferences(value);
256
+ }
257
+
258
+ return { name, rawName, value, nextCursor: index };
259
+ }
260
+
261
+ function scanStartTag(source, index) {
262
+ let cursor = index + 1;
263
+ const nameStart = cursor;
264
+
265
+ while (cursor < source.length && !isAsciiWhitespace(source[cursor]) && source[cursor] !== '/' && source[cursor] !== '>') {
266
+ cursor += 1;
267
+ }
268
+
269
+ const rawName = source.slice(nameStart, cursor);
270
+ const name = rawName.toLowerCase();
271
+
272
+ const attributes = [];
273
+ const errors = [];
274
+ let selfClosing = false;
275
+
276
+ for (;;) {
277
+ cursor = skipWhitespace(source, cursor);
278
+
279
+ if (cursor >= source.length) {
280
+ return {
281
+ tokens: [
282
+ ...errors,
283
+ parseErrorToken('eof-in-tag', 'Unexpected end of input inside a tag.', index),
284
+ ],
285
+ nextCursor: source.length,
286
+ };
287
+ }
288
+
289
+ if (source[cursor] === '>') {
290
+ cursor += 1;
291
+ break;
292
+ }
293
+
294
+ if (source[cursor] === '/') {
295
+ if (source[cursor + 1] === '>') {
296
+ selfClosing = true;
297
+ cursor += 2;
298
+ break;
299
+ }
300
+ cursor += 1;
301
+ continue;
302
+ }
303
+
304
+ const attribute = scanAttribute(source, cursor);
305
+
306
+ if (!attribute) {
307
+ // A stray "=" with no name before it; skip one character so the
308
+ // scan always makes progress.
309
+ cursor += 1;
310
+ continue;
311
+ }
312
+
313
+ if (attributes.some((existing) => existing.name === attribute.name)) {
314
+ errors.push(parseErrorToken(
315
+ 'duplicate-attribute',
316
+ `Attribute "${ attribute.name }" already appeared on this tag.`,
317
+ cursor,
318
+ ));
319
+ } else {
320
+ attributes.push({ name: attribute.name, rawName: attribute.rawName, value: attribute.value });
321
+ }
322
+
323
+ cursor = attribute.nextCursor;
324
+ }
325
+
326
+ const startTagToken = {
327
+ type: 'startTag', name, rawName, attributes, selfClosing, offset: index,
328
+ };
329
+
330
+ return { tokens: [ ...errors, startTagToken ], nextCursor: cursor };
331
+ }
332
+
333
+ // Finds the next "</name" end tag for a raw text / RCDATA element,
334
+ // matching the name case-insensitively and requiring it be followed by
335
+ // whitespace, "/", ">", or end of input, per the tokenizer's raw text
336
+ // end tag open state.
337
+ function findRawTextEndTag(source, searchStart, name) {
338
+ let searchIndex = searchStart;
339
+
340
+ for (;;) {
341
+ const candidate = source.indexOf('</', searchIndex);
342
+ if (candidate === -1) {
343
+ return null;
344
+ }
345
+
346
+ const nameStart = candidate + 2;
347
+ const nameEnd = nameStart + name.length;
348
+ const candidateName = source.slice(nameStart, nameEnd);
349
+ const delimiter = source[nameEnd];
350
+ const isDelimiterValid = delimiter === undefined
351
+ || isAsciiWhitespace(delimiter)
352
+ || delimiter === '/'
353
+ || delimiter === '>';
354
+
355
+ if (candidateName.toLowerCase() === name && isDelimiterValid) {
356
+ return candidate;
357
+ }
358
+
359
+ searchIndex = candidate + 2;
360
+ }
361
+ }
362
+
363
+ function scanRawTextContent(source, contentStart, name, decode) {
364
+ const endTagIndex = findRawTextEndTag(source, contentStart, name);
365
+
366
+ if (endTagIndex === null) {
367
+ const data = source.slice(contentStart);
368
+ return {
369
+ tokens: data ? [ { type: 'text', data: decode ? decodeCharacterReferences(data) : data, offset: contentStart } ] : [],
370
+ nextCursor: source.length,
371
+ };
372
+ }
373
+
374
+ const data = source.slice(contentStart, endTagIndex);
375
+ const tokens = data
376
+ ? [ { type: 'text', data: decode ? decodeCharacterReferences(data) : data, offset: contentStart } ]
377
+ : [];
378
+
379
+ const endTag = scanEndTag(source, endTagIndex);
380
+ tokens.push(...endTag.tokens);
381
+
382
+ return { tokens, nextCursor: endTag.nextCursor };
383
+ }
384
+
385
+ // Classifies the markup construct starting at `index`, where
386
+ // source[index] === '<'. Returns null when nothing recognizable follows,
387
+ // so the caller treats the "<" as literal text.
388
+ function scanConstruct(source, index) {
389
+ const next = source[index + 1];
390
+
391
+ if (next === '!') {
392
+ if (source.startsWith('<!--', index)) {
393
+ return scanComment(source, index);
394
+ }
395
+ if (source.slice(index + 2, index + 9).toLowerCase() === 'doctype') {
396
+ return scanDoctype(source, index);
397
+ }
398
+ if (source.startsWith('<![CDATA[', index)) {
399
+ return scanBogusComment(
400
+ source,
401
+ index,
402
+ index + 2,
403
+ 'cdata-in-html-content',
404
+ 'CDATA sections are only recognized inside foreign content.',
405
+ );
406
+ }
407
+ return scanBogusComment(
408
+ source,
409
+ index,
410
+ index + 2,
411
+ 'incorrectly-opened-comment',
412
+ 'Expected "<!--" to start a comment.',
413
+ );
414
+ }
415
+
416
+ if (next === '/') {
417
+ return scanEndTag(source, index);
418
+ }
419
+
420
+ if (isAsciiLetter(next)) {
421
+ const startTag = scanStartTag(source, index);
422
+
423
+ const tagToken = startTag.tokens[startTag.tokens.length - 1];
424
+ const isRawText = RAW_TEXT_ELEMENTS.has(tagToken.name);
425
+ const isRcdata = RCDATA_ELEMENTS.has(tagToken.name);
426
+
427
+ if (isRawText || isRcdata) {
428
+ const content = scanRawTextContent(source, startTag.nextCursor, tagToken.name, isRcdata);
429
+ return {
430
+ tokens: [ ...startTag.tokens, ...content.tokens ],
431
+ nextCursor: content.nextCursor,
432
+ };
433
+ }
434
+
435
+ return startTag;
436
+ }
437
+
438
+ return null;
439
+ }
440
+
441
+ /**
442
+ * Scans an HTML string into a stream of tokens. Never throws: malformed
443
+ * markup yields a `parseError` token describing the recovery and scanning
444
+ * continues.
445
+ * @param {string} source - Preprocessed HTML source (BOM stripped, line
446
+ * endings normalized).
447
+ * @returns {Generator<Object>} Tokens in source order. See the module
448
+ * documentation for the shape of each `type`.
449
+ */
450
+ export function* tokenize(source) {
451
+ let cursor = 0;
452
+ let textStart = 0;
453
+
454
+ while (cursor < source.length) {
455
+ const ltIndex = source.indexOf('<', cursor);
456
+
457
+ if (ltIndex === -1) {
458
+ break;
459
+ }
460
+
461
+ const construct = scanConstruct(source, ltIndex);
462
+
463
+ if (!construct) {
464
+ cursor = ltIndex + 1;
465
+ continue;
466
+ }
467
+
468
+ if (ltIndex > textStart) {
469
+ yield { type: 'text', data: decodeCharacterReferences(source.slice(textStart, ltIndex)), offset: textStart };
470
+ }
471
+
472
+ yield* construct.tokens;
473
+
474
+ cursor = construct.nextCursor;
475
+ textStart = cursor;
476
+ }
477
+
478
+ if (textStart < source.length) {
479
+ yield { type: 'text', data: decodeCharacterReferences(source.slice(textStart)), offset: textStart };
480
+ }
481
+ }