@stratusagent/tool-web 0.11.6 → 0.11.7
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/README.md +42 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/readability.d.ts +4 -0
- package/dist/readability.d.ts.map +1 -1
- package/dist/readability.js +1573 -15
- package/dist/readability.js.map +1 -1
- package/package.json +13 -5
package/dist/readability.js
CHANGED
|
@@ -27,6 +27,1451 @@ const DROPPED_ELEMENTS = [
|
|
|
27
27
|
'aside',
|
|
28
28
|
'form',
|
|
29
29
|
];
|
|
30
|
+
/**
|
|
31
|
+
* Elements with no content and no end tag: pairing one of these with a
|
|
32
|
+
* later end tag of the same name would drop everything in between.
|
|
33
|
+
*/
|
|
34
|
+
const VOID_ELEMENTS = new Set([
|
|
35
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
|
|
36
|
+
'source', 'track', 'wbr',
|
|
37
|
+
// Void to the parser though obsolete, or read as a void one: `image` is
|
|
38
|
+
// an `img`, and `frame` outside a frameset is dropped.
|
|
39
|
+
'basefont', 'bgsound', 'frame', 'image', 'keygen', 'param',
|
|
40
|
+
]);
|
|
41
|
+
/**
|
|
42
|
+
* Whitespace as HTML and CSS both define it: five ASCII characters. Not
|
|
43
|
+
* JavaScript's `\s` or `trim()`, which also take U+00A0 and the rest of
|
|
44
|
+
* Unicode's spaces — `x=a\u00A0hidden` is one unquoted value to a
|
|
45
|
+
* browser, and `none\u00A0` is not the keyword `none`.
|
|
46
|
+
*/
|
|
47
|
+
const isAsciiWhitespace = (char) => char === ' ' || char === '\t' || char === '\n' || char === '\f' || char === '\r';
|
|
48
|
+
const trimAscii = (value) => value.replace(/^[ \t\n\f\r]+|[ \t\n\f\r]+$/g, '');
|
|
49
|
+
/**
|
|
50
|
+
* Lowercase as HTML compares names and CSS compares keywords: ASCII only.
|
|
51
|
+
* `toLowerCase()` also folds U+212A KELVIN SIGN to `k`, which would read
|
|
52
|
+
* `bloc\u212A` as the `block` a browser does not see, and `<lin\u212A>` as a
|
|
53
|
+
* void `link` rather than the unknown element that holds its text.
|
|
54
|
+
*/
|
|
55
|
+
const asciiLower = (value) => value.replace(/[A-Z]/g, (char) => char.toLowerCase());
|
|
56
|
+
const isNameStart = (char) => /^[a-zA-Z_\u0080-\uffff]$/.test(char);
|
|
57
|
+
const isNameChar = (char) => isNameStart(char) || /^[0-9-]$/.test(char);
|
|
58
|
+
const isDigit = (char) => char >= '0' && char <= '9';
|
|
59
|
+
const CSS_NUMBER = /[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?/y;
|
|
60
|
+
/**
|
|
61
|
+
* An inline style's tokens, as CSS Syntax tokenizes it. Every question
|
|
62
|
+
* below is asked of these, because each shortcut that read the characters
|
|
63
|
+
* instead was a page the browser read one way and this another:
|
|
64
|
+
* - a comment separates tokens and is otherwise nothing, so `n`, a
|
|
65
|
+
* comment, and `one` are two words, not `none`;
|
|
66
|
+
* - escapes are decoded, so `n\6f ne` is `none`, `v\61r(` is a `var()`,
|
|
67
|
+
* and `none\20` is one word with a space in it;
|
|
68
|
+
* - a string, or an unquoted `url(...)`, is one token whatever it spells —
|
|
69
|
+
* `"var("` calls nothing, and a `;` inside either ends no declaration;
|
|
70
|
+
* - `1var` is a dimension and `#var` a hash, so neither starts a function;
|
|
71
|
+
* - a string left open ends at the newline, and what follows is read again.
|
|
72
|
+
* One pass, linear in the attribute. A hex escape past U+10FFFF, a
|
|
73
|
+
* surrogate, or NUL reads as U+FFFD, as for HTML.
|
|
74
|
+
*/
|
|
75
|
+
const cssTokens = (input) => {
|
|
76
|
+
const css = input.replace(/\r\n?|\f/g, '\n').replace(/\0/g, '\uFFFD');
|
|
77
|
+
const tokens = [];
|
|
78
|
+
let index = 0;
|
|
79
|
+
const validEscape = (at) => css[at] === '\\' && css[at + 1] !== '\n';
|
|
80
|
+
/** The character the escape at `index` stands for, consuming it. */
|
|
81
|
+
const escape = () => {
|
|
82
|
+
index += 1;
|
|
83
|
+
const hex = /^[0-9a-fA-F]{1,6}/.exec(css.slice(index, index + 6))?.[0];
|
|
84
|
+
if (hex !== undefined) {
|
|
85
|
+
index += hex.length;
|
|
86
|
+
if (isAsciiWhitespace(css[index] ?? ''))
|
|
87
|
+
index += 1;
|
|
88
|
+
return codePointText(Number.parseInt(hex, 16));
|
|
89
|
+
}
|
|
90
|
+
const char = css[index];
|
|
91
|
+
if (char === undefined)
|
|
92
|
+
return '\uFFFD';
|
|
93
|
+
index += 1;
|
|
94
|
+
return char;
|
|
95
|
+
};
|
|
96
|
+
const name = () => {
|
|
97
|
+
let text = '';
|
|
98
|
+
while (index < css.length) {
|
|
99
|
+
const char = css[index] ?? '';
|
|
100
|
+
if (isNameChar(char)) {
|
|
101
|
+
text += char;
|
|
102
|
+
index += 1;
|
|
103
|
+
}
|
|
104
|
+
else if (validEscape(index)) {
|
|
105
|
+
text += escape();
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return text;
|
|
112
|
+
};
|
|
113
|
+
const startsIdent = (at) => {
|
|
114
|
+
const first = css[at] ?? '';
|
|
115
|
+
if (first === '-')
|
|
116
|
+
return isNameStart(css[at + 1] ?? '') || css[at + 1] === '-' || validEscape(at + 1);
|
|
117
|
+
return isNameStart(first) || validEscape(at);
|
|
118
|
+
};
|
|
119
|
+
const startsNumber = (at) => {
|
|
120
|
+
const first = css[at] ?? '';
|
|
121
|
+
const next = css[at + 1] ?? '';
|
|
122
|
+
if (first === '+' || first === '-')
|
|
123
|
+
return isDigit(next) || (next === '.' && isDigit(css[at + 2] ?? ''));
|
|
124
|
+
return isDigit(first) || (first === '.' && isDigit(next));
|
|
125
|
+
};
|
|
126
|
+
while (index < css.length) {
|
|
127
|
+
const char = css[index] ?? '';
|
|
128
|
+
if (char === '/' && css[index + 1] === '*') {
|
|
129
|
+
const close = css.indexOf('*/', index + 2);
|
|
130
|
+
index = close === -1 ? css.length : close + 2;
|
|
131
|
+
}
|
|
132
|
+
else if (isAsciiWhitespace(char)) {
|
|
133
|
+
while (isAsciiWhitespace(css[index] ?? ''))
|
|
134
|
+
index += 1;
|
|
135
|
+
tokens.push({ type: 'space', value: ' ' });
|
|
136
|
+
}
|
|
137
|
+
else if (char === '"' || char === "'") {
|
|
138
|
+
index += 1;
|
|
139
|
+
let text = '';
|
|
140
|
+
while (index < css.length && css[index] !== char && css[index] !== '\n') {
|
|
141
|
+
if (css[index] !== '\\') {
|
|
142
|
+
text += css[index];
|
|
143
|
+
index += 1;
|
|
144
|
+
}
|
|
145
|
+
else if (css[index + 1] === '\n') {
|
|
146
|
+
index += 2;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
text += escape();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Ended by a newline rather than its quote, it is a bad string.
|
|
153
|
+
const closed = css[index] !== '\n';
|
|
154
|
+
if (css[index] === char)
|
|
155
|
+
index += 1;
|
|
156
|
+
tokens.push({ type: closed ? 'string' : 'bad-string', value: text });
|
|
157
|
+
}
|
|
158
|
+
else if (startsNumber(index)) {
|
|
159
|
+
CSS_NUMBER.lastIndex = index;
|
|
160
|
+
CSS_NUMBER.exec(css);
|
|
161
|
+
index = CSS_NUMBER.lastIndex;
|
|
162
|
+
if (startsIdent(index))
|
|
163
|
+
name();
|
|
164
|
+
else if (css[index] === '%')
|
|
165
|
+
index += 1;
|
|
166
|
+
tokens.push({ type: 'number', value: '' });
|
|
167
|
+
}
|
|
168
|
+
else if (startsIdent(index)) {
|
|
169
|
+
const text = name();
|
|
170
|
+
if (css[index] !== '(') {
|
|
171
|
+
tokens.push({ type: 'ident', value: text });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
index += 1;
|
|
175
|
+
let body = index;
|
|
176
|
+
while (isAsciiWhitespace(css[body] ?? ''))
|
|
177
|
+
body += 1;
|
|
178
|
+
if (asciiLower(text) === 'url' && css[body] !== '"' && css[body] !== "'") {
|
|
179
|
+
// A URL token runs to its first unescaped `)`. It is a bad one if
|
|
180
|
+
// it holds a quote, a `(`, a control character, a bad escape, or
|
|
181
|
+
// whitespace anywhere but at its end.
|
|
182
|
+
index = body;
|
|
183
|
+
let bad = false;
|
|
184
|
+
while (index < css.length && css[index] !== ')') {
|
|
185
|
+
const at = css[index] ?? '';
|
|
186
|
+
if (isAsciiWhitespace(at)) {
|
|
187
|
+
while (isAsciiWhitespace(css[index] ?? ''))
|
|
188
|
+
index += 1;
|
|
189
|
+
if (index < css.length && css[index] !== ')')
|
|
190
|
+
bad = true;
|
|
191
|
+
}
|
|
192
|
+
else if (at === '\\') {
|
|
193
|
+
// A whole escape, hex digits and the space that may end them.
|
|
194
|
+
if (validEscape(index)) {
|
|
195
|
+
escape();
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
bad = true;
|
|
199
|
+
index += 1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
if (at === '"' || at === "'" || at === '(' || /^[\0-\x08\x0B\x0E-\x1F\x7F]$/.test(at))
|
|
204
|
+
bad = true;
|
|
205
|
+
index += 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
index += 1;
|
|
209
|
+
tokens.push({ type: bad ? 'bad-url' : 'url', value: '' });
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
tokens.push({ type: 'function', value: asciiLower(text) });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else if (char === '#' && (isNameChar(css[index + 1] ?? '') || validEscape(index + 1))) {
|
|
216
|
+
index += 1;
|
|
217
|
+
tokens.push({ type: 'hash', value: name() });
|
|
218
|
+
}
|
|
219
|
+
else if (char === '@' && startsIdent(index + 1)) {
|
|
220
|
+
index += 1;
|
|
221
|
+
tokens.push({ type: 'at', value: name() });
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
index += 1;
|
|
225
|
+
const punctuation = ['(', ')', '[', ']', '{', '}', ';', ':'];
|
|
226
|
+
const type = punctuation.find((candidate) => candidate === char);
|
|
227
|
+
tokens.push({ type: type ?? 'delim', value: char });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return tokens;
|
|
231
|
+
};
|
|
232
|
+
/** The token that closes each opening one. A function token opens a `(` block. */
|
|
233
|
+
const CSS_CLOSERS = { function: ')', '(': ')', '[': ']', '{': '}' };
|
|
234
|
+
/**
|
|
235
|
+
* A style attribute's declarations: split on a `;` outside every block —
|
|
236
|
+
* `--x:{;display:none;}` is one declaration — and kept only where they are
|
|
237
|
+
* `name: value`, as a browser keeps them. `!important` is the last two
|
|
238
|
+
* tokens, `!` and the ident, whatever whitespace or comments sit between.
|
|
239
|
+
*/
|
|
240
|
+
const cssDeclarations = (style) => {
|
|
241
|
+
const runs = [[]];
|
|
242
|
+
const malformed = new Set();
|
|
243
|
+
// The tokens at a declaration's top level, outside every block: only
|
|
244
|
+
// those can be its `!important`, which inside an unclosed var() is part
|
|
245
|
+
// of the argument.
|
|
246
|
+
const topLevel = new Set();
|
|
247
|
+
const blocks = [];
|
|
248
|
+
for (const token of cssTokens(style)) {
|
|
249
|
+
const run = runs.at(-1) ?? [];
|
|
250
|
+
if (blocks.length === 0)
|
|
251
|
+
topLevel.add(token);
|
|
252
|
+
if (token.type === ';' && blocks.length === 0) {
|
|
253
|
+
runs.push([]);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const closer = CSS_CLOSERS[token.type];
|
|
257
|
+
if (closer !== undefined)
|
|
258
|
+
blocks.push(closer);
|
|
259
|
+
else if (token.type === blocks.at(-1))
|
|
260
|
+
blocks.pop();
|
|
261
|
+
else if (token.type === ')' || token.type === ']' || token.type === '}')
|
|
262
|
+
malformed.add(run);
|
|
263
|
+
if (token.type === 'bad-string' || token.type === 'bad-url')
|
|
264
|
+
malformed.add(run);
|
|
265
|
+
if (token.type !== 'space')
|
|
266
|
+
run.push(token);
|
|
267
|
+
}
|
|
268
|
+
const declarations = [];
|
|
269
|
+
for (const run of runs) {
|
|
270
|
+
const [name, colon, ...value] = run;
|
|
271
|
+
if (name?.type !== 'ident' || colon?.type !== ':')
|
|
272
|
+
continue;
|
|
273
|
+
const bang = value.at(-2);
|
|
274
|
+
const last = value.at(-1);
|
|
275
|
+
const important = bang?.type === 'delim' && bang.value === '!' && topLevel.has(bang)
|
|
276
|
+
&& last?.type === 'ident' && asciiLower(last.value) === 'important' && topLevel.has(last);
|
|
277
|
+
declarations.push({
|
|
278
|
+
property: asciiLower(name.value),
|
|
279
|
+
value: important ? value.slice(0, -2) : value,
|
|
280
|
+
important,
|
|
281
|
+
malformed: malformed.has(run),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return declarations;
|
|
285
|
+
};
|
|
286
|
+
const CSS_WIDE_KEYWORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer']);
|
|
287
|
+
/**
|
|
288
|
+
* `display` values that stand alone — exactly the ones Chromium accepts
|
|
289
|
+
* (`CSS.supports`), which is not the spec's list: it takes the legacy
|
|
290
|
+
* `-webkit-flex`, and rejects `run-in` and three of the four ruby values.
|
|
291
|
+
* Accepting a value it rejects reads `display:none;display:run-in` as shown.
|
|
292
|
+
*/
|
|
293
|
+
const DISPLAY_SINGLE = new Set([
|
|
294
|
+
'none', 'contents', 'block', 'inline', 'flow', 'flow-root', 'table', 'flex', 'grid', 'ruby',
|
|
295
|
+
'list-item', 'math', 'inline-block', 'inline-flex', 'inline-grid', 'inline-table',
|
|
296
|
+
'table-row-group', 'table-header-group', 'table-footer-group', 'table-row', 'table-cell',
|
|
297
|
+
'table-column-group', 'table-column', 'table-caption', 'ruby-text',
|
|
298
|
+
'-webkit-box', '-webkit-inline-box', '-webkit-flex', '-webkit-inline-flex',
|
|
299
|
+
]);
|
|
300
|
+
const DISPLAY_OUTSIDE = new Set(['block', 'inline']);
|
|
301
|
+
const DISPLAY_INSIDE = new Set(['flow', 'flow-root', 'table', 'flex', 'grid', 'ruby', 'math']);
|
|
302
|
+
/**
|
|
303
|
+
* Whether `display` accepts these words: one keyword that stands alone,
|
|
304
|
+
* an outside and an inside keyword in either order, or `list-item` with at
|
|
305
|
+
* most one outside keyword and one of `flow` and `flow-root`. A run of
|
|
306
|
+
* valid keywords is not a valid value — `block none` is rejected, and the
|
|
307
|
+
* `none` before it stands.
|
|
308
|
+
*/
|
|
309
|
+
const validDisplay = (words) => {
|
|
310
|
+
if (words.length === 1)
|
|
311
|
+
return DISPLAY_SINGLE.has(words[0] ?? '');
|
|
312
|
+
if (new Set(words).size !== words.length)
|
|
313
|
+
return false;
|
|
314
|
+
const outside = words.filter((word) => DISPLAY_OUTSIDE.has(word)).length;
|
|
315
|
+
if (words.includes('list-item')) {
|
|
316
|
+
const flows = words.filter((word) => word === 'flow' || word === 'flow-root').length;
|
|
317
|
+
return words.length <= 3 && outside <= 1 && flows <= 1 && outside + flows + 1 === words.length;
|
|
318
|
+
}
|
|
319
|
+
const inside = words.filter((word) => DISPLAY_INSIDE.has(word)).length;
|
|
320
|
+
return words.length === 2 && outside === 1 && inside === 1;
|
|
321
|
+
};
|
|
322
|
+
/** A value's words, if it is nothing but identifiers — the only values `display` and `visibility` take. */
|
|
323
|
+
const wordsOf = (value) => value.every((token) => token.type === 'ident') ? value.map((token) => asciiLower(token.value)) : undefined;
|
|
324
|
+
/**
|
|
325
|
+
* Whether every `var()` in a value is one Chromium parses: a custom
|
|
326
|
+
* property name (`--` and more) followed by `)`, a comma, or the end of
|
|
327
|
+
* the value, and no `;` or `!` directly in its fallback or at the top of
|
|
328
|
+
* the value. A malformed one rejects the whole declaration at parse time,
|
|
329
|
+
* so `display:none;display:var(x)` stays hidden.
|
|
330
|
+
*/
|
|
331
|
+
const varsValid = (value) => {
|
|
332
|
+
const blocks = [];
|
|
333
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
334
|
+
const token = value[index];
|
|
335
|
+
if (token === undefined)
|
|
336
|
+
continue;
|
|
337
|
+
if (token.type === 'function' && token.value === 'var') {
|
|
338
|
+
const name = value[index + 1];
|
|
339
|
+
const after = value[index + 2];
|
|
340
|
+
if (name?.type !== 'ident' || !name.value.startsWith('--') || name.value.length <= 2)
|
|
341
|
+
return false;
|
|
342
|
+
if (after !== undefined && after.type !== ')' && !(after.type === 'delim' && after.value === ','))
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
const closer = CSS_CLOSERS[token.type];
|
|
346
|
+
if (closer !== undefined) {
|
|
347
|
+
blocks.push({ closer, isVar: token.type === 'function' && token.value === 'var' });
|
|
348
|
+
}
|
|
349
|
+
else if (token.type === blocks.at(-1)?.closer) {
|
|
350
|
+
blocks.pop();
|
|
351
|
+
}
|
|
352
|
+
else if (token.type === ';' || (token.type === 'delim' && token.value === '!')) {
|
|
353
|
+
if (blocks.length === 0 || blocks.at(-1)?.isVar === true)
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return true;
|
|
358
|
+
};
|
|
359
|
+
/**
|
|
360
|
+
* Whether a value is one a browser would accept for the property. A
|
|
361
|
+
* declaration it would not is dropped from the cascade rather than
|
|
362
|
+
* overriding the one before it: `display:none;display:bogus` is hidden.
|
|
363
|
+
* A `var()` is accepted, as a browser accepts it until computed time, and
|
|
364
|
+
* reads as not hiding — the direction that keeps text.
|
|
365
|
+
*/
|
|
366
|
+
const validFor = (property, value) => {
|
|
367
|
+
if (value.some((token) => token.type === 'function' && token.value === 'var'))
|
|
368
|
+
return varsValid(value);
|
|
369
|
+
const words = wordsOf(value);
|
|
370
|
+
if (words === undefined)
|
|
371
|
+
return false;
|
|
372
|
+
if (words.length === 1 && CSS_WIDE_KEYWORDS.has(words[0] ?? ''))
|
|
373
|
+
return true;
|
|
374
|
+
if (property === 'visibility')
|
|
375
|
+
return words.length === 1 && ['visible', 'hidden', 'collapse'].includes(words[0] ?? '');
|
|
376
|
+
if (property === 'content-visibility')
|
|
377
|
+
return words.length === 1 && ['visible', 'auto', 'hidden'].includes(words[0] ?? '');
|
|
378
|
+
if (property === 'float')
|
|
379
|
+
return words.length === 1 && ['none', 'left', 'right', 'inline-start', 'inline-end'].includes(words[0] ?? '');
|
|
380
|
+
if (property === 'position')
|
|
381
|
+
return words.length === 1 && ['static', 'relative', 'absolute', 'fixed', 'sticky'].includes(words[0] ?? '');
|
|
382
|
+
return validDisplay(words);
|
|
383
|
+
};
|
|
384
|
+
/**
|
|
385
|
+
* The properties that decide whether an element's text is drawn, all of
|
|
386
|
+
* which `all` resets: `float` and `position` among them, because a floated
|
|
387
|
+
* or absolutely positioned box is made a block, which `content-visibility`
|
|
388
|
+
* then contains.
|
|
389
|
+
*/
|
|
390
|
+
const HIDING_PROPERTIES = ['display', 'visibility', 'content-visibility', 'float', 'position'];
|
|
391
|
+
/**
|
|
392
|
+
* What an inline `style` declares for each of HIDING_PROPERTIES: its
|
|
393
|
+
* keywords, space-separated (`inline flow-root`), or `undefined` for a
|
|
394
|
+
* value that is valid but not keywords (a `var()`). A property absent from the
|
|
395
|
+
* map was not validly declared. The last declaration wins unless an
|
|
396
|
+
* earlier one was `!important` — `display:none;display:block` is shown —
|
|
397
|
+
* and `all` sets each, so `display:none;all:initial` is shown too.
|
|
398
|
+
*/
|
|
399
|
+
const styleDeclares = (style) => {
|
|
400
|
+
const declared = new Map();
|
|
401
|
+
const declare = (property, word, important) => {
|
|
402
|
+
if (declared.get(property)?.important === true && !important)
|
|
403
|
+
return;
|
|
404
|
+
declared.set(property, { word, important });
|
|
405
|
+
};
|
|
406
|
+
for (const { property, value, important, malformed } of cssDeclarations(style)) {
|
|
407
|
+
if (malformed)
|
|
408
|
+
continue;
|
|
409
|
+
const words = wordsOf(value);
|
|
410
|
+
const word = words?.join(' ');
|
|
411
|
+
if (property === 'all') {
|
|
412
|
+
// Only a CSS-wide keyword, or a var(), and it resets each.
|
|
413
|
+
const valid = value.some((token) => token.type === 'function' && token.value === 'var')
|
|
414
|
+
? varsValid(value)
|
|
415
|
+
: word !== undefined && CSS_WIDE_KEYWORDS.has(word);
|
|
416
|
+
if (!valid)
|
|
417
|
+
continue;
|
|
418
|
+
for (const hiding of HIDING_PROPERTIES)
|
|
419
|
+
declare(hiding, word, important);
|
|
420
|
+
}
|
|
421
|
+
else if (HIDING_PROPERTIES.includes(property) && validFor(property, value)) {
|
|
422
|
+
declare(property, word, important);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return new Map([...declared].map(([property, { word }]) => [property, word]));
|
|
426
|
+
};
|
|
427
|
+
/**
|
|
428
|
+
* Each element's `display` before any author style, where it is not
|
|
429
|
+
* `inline` — the subset of Chromium's user-agent stylesheet that decides
|
|
430
|
+
* whether `content-visibility` takes effect. An element this does not name,
|
|
431
|
+
* a custom one included, is inline.
|
|
432
|
+
*/
|
|
433
|
+
const DEFAULT_DISPLAY = {
|
|
434
|
+
...Object.fromEntries([
|
|
435
|
+
'address', 'article', 'aside', 'blockquote', 'body', 'center', 'dd', 'details', 'dialog', 'dir', 'div', 'dl',
|
|
436
|
+
'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
437
|
+
'header', 'hgroup', 'hr', 'html', 'legend', 'listing', 'main', 'menu', 'nav', 'ol', 'optgroup', 'option', 'p',
|
|
438
|
+
'plaintext', 'pre', 'search', 'section', 'summary', 'ul', 'xmp',
|
|
439
|
+
].map((name) => [name, 'block'])),
|
|
440
|
+
...Object.fromEntries(['button', 'input', 'marquee', 'meter', 'progress', 'select', 'textarea']
|
|
441
|
+
.map((name) => [name, 'inline-block'])),
|
|
442
|
+
li: 'list-item',
|
|
443
|
+
table: 'table',
|
|
444
|
+
caption: 'table-caption',
|
|
445
|
+
colgroup: 'table-column-group',
|
|
446
|
+
col: 'table-column',
|
|
447
|
+
tbody: 'table-row-group',
|
|
448
|
+
thead: 'table-header-group',
|
|
449
|
+
tfoot: 'table-footer-group',
|
|
450
|
+
tr: 'table-row',
|
|
451
|
+
td: 'table-cell',
|
|
452
|
+
th: 'table-cell',
|
|
453
|
+
ruby: 'ruby',
|
|
454
|
+
rt: 'ruby-text',
|
|
455
|
+
slot: 'contents',
|
|
456
|
+
};
|
|
457
|
+
/**
|
|
458
|
+
* Single `display` keywords whose box `content-visibility: hidden` can
|
|
459
|
+
* contain — exactly the ones Chromium applies it to. Not `inline`, whose
|
|
460
|
+
* box is fragments of the line around it, nor `contents`, which has no box;
|
|
461
|
+
* not `ruby` or `ruby-text`; and not a table, a row, a row group, or a
|
|
462
|
+
* caption, which Chromium leaves shown, though it hides a cell.
|
|
463
|
+
*/
|
|
464
|
+
const CONTAINED_DISPLAYS = new Set([
|
|
465
|
+
'block', 'flow-root', 'list-item', 'flex', 'grid', 'inline-block', 'inline-flex', 'inline-grid', 'table-cell',
|
|
466
|
+
'table-column', 'table-column-group', '-webkit-box', '-webkit-inline-box', '-webkit-flex', '-webkit-inline-flex',
|
|
467
|
+
]);
|
|
468
|
+
/**
|
|
469
|
+
* Elements whose box Chromium lays out itself, whatever `display` says,
|
|
470
|
+
* so it decides alone whether `content-visibility: hidden` takes: a form
|
|
471
|
+
* control is always a contained box (a button or fieldset stops being one
|
|
472
|
+
* only at `display: contents`, which drops its box), and an option and an
|
|
473
|
+
* option group are drawn by their select, which ignores it.
|
|
474
|
+
*/
|
|
475
|
+
const NO_ATTRIBUTES = new Map();
|
|
476
|
+
const CONTAINED_WHATEVER_DISPLAY = new Set(['select', 'textarea', 'meter', 'progress', 'details']);
|
|
477
|
+
const CONTAINED_UNLESS_CONTENTS = new Set(['button', 'fieldset']);
|
|
478
|
+
const NEVER_CONTAINED = new Set(['option', 'optgroup']);
|
|
479
|
+
/**
|
|
480
|
+
* Elements made a block whatever their `display`: a dialog is absolutely
|
|
481
|
+
* positioned by default, and a legend is laid out as a block.
|
|
482
|
+
*/
|
|
483
|
+
const BLOCKIFIED = new Set(['dialog', 'legend']);
|
|
484
|
+
/** `display` keywords whose box makes each child box a block: a flex or grid container's items. */
|
|
485
|
+
const BLOCKIFYING_CONTAINERS = new Set(['flex', 'grid', 'inline-flex', 'inline-grid', '-webkit-flex', '-webkit-inline-flex']);
|
|
486
|
+
/**
|
|
487
|
+
* A `display` made block-level, as CSS does to a floated or absolutely
|
|
488
|
+
* positioned box and to a flex or grid item: `inline` and `inline-block`
|
|
489
|
+
* become `block`, `inline-table` a `table`, a row or a ruby box a block.
|
|
490
|
+
* `contents` and `none` stay, having no box to change.
|
|
491
|
+
*/
|
|
492
|
+
const blockify = (display) => {
|
|
493
|
+
if (display === 'contents' || display === 'none')
|
|
494
|
+
return display;
|
|
495
|
+
const words = display.split(' ');
|
|
496
|
+
if (words.length > 1)
|
|
497
|
+
return words.map((word) => (word === 'inline' ? 'block' : word)).join(' ');
|
|
498
|
+
const single = {
|
|
499
|
+
'inline-table': 'table',
|
|
500
|
+
'inline-flex': 'flex',
|
|
501
|
+
'-webkit-inline-flex': 'flex',
|
|
502
|
+
'inline-grid': 'grid',
|
|
503
|
+
'-webkit-inline-box': '-webkit-box',
|
|
504
|
+
ruby: 'block ruby',
|
|
505
|
+
math: 'block math',
|
|
506
|
+
};
|
|
507
|
+
if (single[display] !== undefined)
|
|
508
|
+
return single[display];
|
|
509
|
+
return CONTAINED_DISPLAYS.has(display) || display === 'table' ? display : 'block';
|
|
510
|
+
};
|
|
511
|
+
/**
|
|
512
|
+
* Whether `content-visibility: hidden` hides the content of this element
|
|
513
|
+
* with this `display`. Two and three keywords follow the single ones they
|
|
514
|
+
* name: `inline flow-root` is `inline-block`, and contained; `inline
|
|
515
|
+
* list-item` is an inline box, and not; a table inside either outside
|
|
516
|
+
* keyword is not.
|
|
517
|
+
*/
|
|
518
|
+
const contentVisibilityApplies = (name, display) => {
|
|
519
|
+
if (CONTAINED_WHATEVER_DISPLAY.has(name))
|
|
520
|
+
return true;
|
|
521
|
+
if (NEVER_CONTAINED.has(name))
|
|
522
|
+
return false;
|
|
523
|
+
if (CONTAINED_UNLESS_CONTENTS.has(name))
|
|
524
|
+
return display !== 'contents';
|
|
525
|
+
const words = display.split(' ');
|
|
526
|
+
if (words.length === 1)
|
|
527
|
+
return CONTAINED_DISPLAYS.has(display);
|
|
528
|
+
if (words.includes('list-item'))
|
|
529
|
+
return !words.includes('inline') || words.includes('flow-root');
|
|
530
|
+
const inside = words.find((word) => !DISPLAY_OUTSIDE.has(word)) ?? '';
|
|
531
|
+
if (inside === 'table')
|
|
532
|
+
return false;
|
|
533
|
+
return words.includes('block') || ['flow-root', 'flex', 'grid'].includes(inside);
|
|
534
|
+
};
|
|
535
|
+
/**
|
|
536
|
+
* Whether an element's attributes hide it where it is placed, and the
|
|
537
|
+
* style they give it there, which an `inherit` beneath it takes. Both
|
|
538
|
+
* depend on the parent, so a copy the tree builder makes elsewhere is
|
|
539
|
+
* judged again: a `b` rebuilt as a flex item is a block. They arrive parsed by
|
|
540
|
+
* `scanTags`, never searched for in the tag's text: a search for `hidden`
|
|
541
|
+
* also finds `data-hidden`, `aria-hidden="false"`, and `title="hidden
|
|
542
|
+
* gem"`, and a second parser would disagree with the scanner about where
|
|
543
|
+
* an attribute ends — `<div x=a"hidden>` has no `hidden` attribute.
|
|
544
|
+
*
|
|
545
|
+
* `hidden` is a `display: none` beneath every author style, as Chromium
|
|
546
|
+
* applies it: any valid inline `display` overrides it, `revert` included,
|
|
547
|
+
* so `<p hidden style="display:block">` is shown, while `revert-layer`
|
|
548
|
+
* and an invalid value do not. `hidden="until-found"` is the same with
|
|
549
|
+
* `content-visibility: hidden`, which hides an element's content and is
|
|
550
|
+
* itself a way to hide it — on an element whose box can contain it, which
|
|
551
|
+
* a `span` cannot: `<span hidden=until-found>` is shown, though a block
|
|
552
|
+
* inside it that inherits `content-visibility` is not.
|
|
553
|
+
*/
|
|
554
|
+
const styleOf = (node, styleAt) => {
|
|
555
|
+
const { name, attributes, parent } = node;
|
|
556
|
+
const hidden = attributes.get('hidden');
|
|
557
|
+
const declaredStyle = attributes.get('style');
|
|
558
|
+
const declared = declaredStyle === undefined
|
|
559
|
+
? new Map()
|
|
560
|
+
: styleDeclares(decodeAttribute(declaredStyle));
|
|
561
|
+
const untilFound = hidden !== undefined && asciiLower(decodeAttribute(hidden)) === 'until-found';
|
|
562
|
+
const inherited = parent === undefined ? ROOT_STYLE : styleAt(parent);
|
|
563
|
+
const style = computeStyle(name, declared, inherited, parent, styleAt, untilFound);
|
|
564
|
+
const hides = () => {
|
|
565
|
+
if (asciiLower(trimAscii(decodeAttribute(attributes.get('aria-hidden') ?? ''))) === 'true')
|
|
566
|
+
return true;
|
|
567
|
+
const visibility = declared.get('visibility');
|
|
568
|
+
if (visibility === 'hidden' || visibility === 'collapse')
|
|
569
|
+
return true;
|
|
570
|
+
if (declared.get('display') === 'none')
|
|
571
|
+
return true;
|
|
572
|
+
if (style.contentVisibility === 'hidden' && contentVisibilityApplies(name, style.display))
|
|
573
|
+
return true;
|
|
574
|
+
if (hidden === undefined || untilFound)
|
|
575
|
+
return false;
|
|
576
|
+
// The attribute's own rule, beneath every inline `display` but `revert-layer`.
|
|
577
|
+
return !declared.has('display') || declared.get('display') === 'revert-layer';
|
|
578
|
+
};
|
|
579
|
+
return { hidden: hides(), style };
|
|
580
|
+
};
|
|
581
|
+
/** What the root of the page computes to, for an element with no parent open. */
|
|
582
|
+
const ROOT_STYLE = { display: 'block', float: 'none', position: 'static', contentVisibility: 'visible' };
|
|
583
|
+
/**
|
|
584
|
+
* A property that is not inherited, as it computes from its inline
|
|
585
|
+
* declaration: `inherit` takes the parent's value, `revert-layer` the
|
|
586
|
+
* value beneath every author style (where `hidden="until-found"` sets
|
|
587
|
+
* `content-visibility`), and any other CSS-wide keyword or a `var()` the
|
|
588
|
+
* initial value — what an unresolved one falls back to, and the direction
|
|
589
|
+
* that keeps text.
|
|
590
|
+
*/
|
|
591
|
+
const computeKeyword = (declared, property, inherited, initial, beneath = initial) => {
|
|
592
|
+
if (!declared.has(property))
|
|
593
|
+
return beneath;
|
|
594
|
+
const value = declared.get(property);
|
|
595
|
+
if (value === 'inherit')
|
|
596
|
+
return inherited;
|
|
597
|
+
if (value === 'revert-layer')
|
|
598
|
+
return beneath;
|
|
599
|
+
return value === undefined || CSS_WIDE_KEYWORDS.has(value) ? initial : value;
|
|
600
|
+
};
|
|
601
|
+
/**
|
|
602
|
+
* An element's computed style, from its inline declarations, the default
|
|
603
|
+
* for its name, and where it is. A box is made a block by a `float`, an
|
|
604
|
+
* absolute or fixed `position`, or a flex or grid container — the nearest
|
|
605
|
+
* ancestor with a box, since one with `display: contents` has none and
|
|
606
|
+
* passes its children through. A `display` that is a `var()` reads as
|
|
607
|
+
* `inline`, its fallback and the direction that keeps text.
|
|
608
|
+
*/
|
|
609
|
+
const computeStyle = (name, declared, inherited, parent, styleAt, untilFound) => {
|
|
610
|
+
const float = computeKeyword(declared, 'float', inherited.float, 'none');
|
|
611
|
+
const position = computeKeyword(declared, 'position', inherited.position, 'static');
|
|
612
|
+
const contentVisibility = computeKeyword(declared, 'content-visibility', inherited.contentVisibility, 'visible', untilFound ? 'hidden' : 'visible');
|
|
613
|
+
const ownDefault = DEFAULT_DISPLAY[name] ?? 'inline';
|
|
614
|
+
let display = !declared.has('display')
|
|
615
|
+
? ownDefault
|
|
616
|
+
: declaredDisplay(declared.get('display'), ownDefault, inherited.display);
|
|
617
|
+
let box = parent;
|
|
618
|
+
while (box !== undefined && styleAt(box).display === 'contents')
|
|
619
|
+
box = box.parent;
|
|
620
|
+
const container = (box === undefined ? 'block' : styleAt(box).display).split(' ');
|
|
621
|
+
const blockified = BLOCKIFIED.has(name) || float !== 'none' || position === 'absolute' || position === 'fixed'
|
|
622
|
+
|| container.some((word) => BLOCKIFYING_CONTAINERS.has(word));
|
|
623
|
+
if (blockified) {
|
|
624
|
+
display = blockify(display);
|
|
625
|
+
}
|
|
626
|
+
else if (container.includes('ruby') && display.split(' ').includes('list-item')) {
|
|
627
|
+
// A ruby container makes its children inline-level. Of what that
|
|
628
|
+
// changes, only a list item matters here: `inline list-item` contains
|
|
629
|
+
// nothing, while a block made `inline-block` still contains.
|
|
630
|
+
display = ['inline', ...display.split(' ').filter((word) => !DISPLAY_OUTSIDE.has(word))].join(' ');
|
|
631
|
+
}
|
|
632
|
+
return { display, float, position, contentVisibility };
|
|
633
|
+
};
|
|
634
|
+
const declaredDisplay = (display, ownDefault, parentDisplay) => {
|
|
635
|
+
if (display === undefined || display === 'initial' || display === 'unset')
|
|
636
|
+
return 'inline';
|
|
637
|
+
if (display === 'revert' || display === 'revert-layer')
|
|
638
|
+
return ownDefault;
|
|
639
|
+
if (display === 'inherit')
|
|
640
|
+
return parentDisplay;
|
|
641
|
+
return display;
|
|
642
|
+
};
|
|
643
|
+
/**
|
|
644
|
+
* An attribute value as the browser hands it to CSS. ` ` is U+00A0
|
|
645
|
+
* there, which is not whitespace to CSS — `display:none ` is invalid,
|
|
646
|
+
* and the text it is on is shown. `decodeEntities` reads it as a space
|
|
647
|
+
* because its output is text to be read, where that is what it looks like.
|
|
648
|
+
*/
|
|
649
|
+
const decodeAttribute = (value) => decodeEntities(value.replace(/ /g, '\u00A0'));
|
|
650
|
+
/**
|
|
651
|
+
* Elements whose content is text up to their own end tag, whatever it
|
|
652
|
+
* looks like: `<textarea></div></textarea>` holds the characters `</div>`,
|
|
653
|
+
* and pairing them as an end tag would close a hidden element early and
|
|
654
|
+
* read out the rest of it.
|
|
655
|
+
*/
|
|
656
|
+
const RAW_TEXT_ELEMENTS = new Set([
|
|
657
|
+
'script', 'style', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript',
|
|
658
|
+
]);
|
|
659
|
+
/**
|
|
660
|
+
* Start tags that close an open `p` before they open — the tree builder's
|
|
661
|
+
* "close a p element" steps. Without them a hidden paragraph that a `div`
|
|
662
|
+
* has already ended pairs with a literal `</p>` further on, and drops the
|
|
663
|
+
* visible text between: `<p hidden>gone<div>shown</div></p>`.
|
|
664
|
+
*/
|
|
665
|
+
const CLOSES_PARAGRAPH = new Set([
|
|
666
|
+
'address', 'article', 'aside', 'blockquote', 'center', 'details', 'dialog', 'dir', 'div', 'dl', 'dd', 'dt',
|
|
667
|
+
'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup',
|
|
668
|
+
'hr', 'li', 'listing', 'main', 'menu', 'nav', 'ol', 'p', 'plaintext', 'pre', 'search', 'section', 'summary',
|
|
669
|
+
'table', 'ul', 'xmp',
|
|
670
|
+
]);
|
|
671
|
+
/** Elements whose end tag removes only themselves: the tree builder's adoption agency, which leaves blocks open. */
|
|
672
|
+
const FORMATTING_ELEMENTS = new Set(['a', 'b', 'big', 'code', 'em', 'font', 'i', 'nobr', 's', 'small', 'strike', 'strong', 'tt', 'u']);
|
|
673
|
+
const HEADINGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
|
|
674
|
+
/** Where a search for an open element stops: the tree builder's default scope. */
|
|
675
|
+
const SCOPE_BOUNDARIES = ['applet', 'caption', 'html', 'table', 'td', 'th', 'marquee', 'object', 'template'];
|
|
676
|
+
/** Where a search for an open table part stops: the tree builder's table scope. */
|
|
677
|
+
const TABLE_SCOPE = ['html', 'table', 'template'];
|
|
678
|
+
const TABLE_PARTS = new Set(['table', 'caption', 'tbody', 'thead', 'tfoot', 'tr', 'td', 'th']);
|
|
679
|
+
/** Start tags that end an open sibling of their own kind, and the list element the search stops at. */
|
|
680
|
+
const IMPLIED_SIBLING_ENDS = {
|
|
681
|
+
option: { closes: ['option'], stopAt: [...SCOPE_BOUNDARIES, 'select', 'datalist', 'optgroup'] },
|
|
682
|
+
optgroup: { closes: ['option', 'optgroup'], stopAt: [...SCOPE_BOUNDARIES, 'select', 'datalist'] },
|
|
683
|
+
button: { closes: ['button'], stopAt: SCOPE_BOUNDARIES },
|
|
684
|
+
};
|
|
685
|
+
/**
|
|
686
|
+
* A table part's start tag, and the parts it belongs directly inside:
|
|
687
|
+
* everything open above the nearest of them closes first, as the tree
|
|
688
|
+
* builder clears the stack back to that context. So a cell ends the open
|
|
689
|
+
* cell, a row the open row, and a caption or row group everything back to
|
|
690
|
+
* the table. With none of them open the tag is outside any table, where
|
|
691
|
+
* the tree builder ignores it — and its `hidden` with it.
|
|
692
|
+
*/
|
|
693
|
+
const TABLE_PART_CONTEXT = {
|
|
694
|
+
caption: ['table'],
|
|
695
|
+
colgroup: ['table'],
|
|
696
|
+
col: ['colgroup', 'table'],
|
|
697
|
+
tbody: ['table'],
|
|
698
|
+
thead: ['table'],
|
|
699
|
+
tfoot: ['table'],
|
|
700
|
+
tr: ['tbody', 'thead', 'tfoot', 'table'],
|
|
701
|
+
td: ['tr', 'tbody', 'thead', 'tfoot', 'table'],
|
|
702
|
+
th: ['tr', 'tbody', 'thead', 'tfoot', 'table'],
|
|
703
|
+
};
|
|
704
|
+
/** The document's own elements, which this pass neither hides nor closes. */
|
|
705
|
+
const DOCUMENT_ELEMENTS = new Set(['html', 'head', 'body']);
|
|
706
|
+
/**
|
|
707
|
+
* End tags that close the element they name only when it is in scope.
|
|
708
|
+
* Any other non-formatting end tag stops at the nearest special element.
|
|
709
|
+
*/
|
|
710
|
+
const SCOPED_END_TAGS = new Set([
|
|
711
|
+
'address', 'applet', 'article', 'aside', 'blockquote', 'button', 'center', 'dd', 'details', 'dialog', 'dir',
|
|
712
|
+
'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'header', 'hgroup', 'listing', 'main',
|
|
713
|
+
'marquee', 'menu', 'nav', 'object', 'ol', 'pre', 'search', 'section', 'select', 'summary', 'template', 'ul',
|
|
714
|
+
]);
|
|
715
|
+
/**
|
|
716
|
+
* Start tags that end an open list item of these names. The search stops
|
|
717
|
+
* at any special element but `address`, `div`, and `p`: an `li` inside a
|
|
718
|
+
* `ul` inside an `li` is the inner list's, and leaves the outer one open.
|
|
719
|
+
*/
|
|
720
|
+
const LIST_ITEM_ENDS = {
|
|
721
|
+
li: ['li'],
|
|
722
|
+
dd: ['dd', 'dt'],
|
|
723
|
+
dt: ['dd', 'dt'],
|
|
724
|
+
};
|
|
725
|
+
/** The parts a table part's start tag implies, by the element it is opened in. */
|
|
726
|
+
const IMPLIED_TABLE_PARTS = {
|
|
727
|
+
td: { table: ['tbody', 'tr'], tbody: ['tr'], thead: ['tr'], tfoot: ['tr'] },
|
|
728
|
+
th: { table: ['tbody', 'tr'], tbody: ['tr'], thead: ['tr'], tfoot: ['tr'] },
|
|
729
|
+
tr: { table: ['tbody'] },
|
|
730
|
+
col: { table: ['colgroup'] },
|
|
731
|
+
};
|
|
732
|
+
/** What the tree builder's "generate implied end tags" closes, while one is the current element. */
|
|
733
|
+
const IMPLIED_END_ELEMENTS = new Set(['dd', 'dt', 'li', 'optgroup', 'option', 'p', 'rb', 'rp', 'rt', 'rtc']);
|
|
734
|
+
/**
|
|
735
|
+
* Elements that put a marker in the list of active formatting elements:
|
|
736
|
+
* formatting opened outside one is not rebuilt inside it.
|
|
737
|
+
*/
|
|
738
|
+
const MARKER_ELEMENTS = new Set(['applet', 'marquee', 'object', 'td', 'th', 'caption', 'template']);
|
|
739
|
+
/**
|
|
740
|
+
* The marker elements whose closing always clears the list back to their
|
|
741
|
+
* marker. The other three clear it only at their own end tag: an `object`
|
|
742
|
+
* popped by a table part leaves its marker, and with it, formatting
|
|
743
|
+
* opened after it out of reach of a later end tag and of the rebuild.
|
|
744
|
+
*/
|
|
745
|
+
const CLEARED_ON_CLOSE = new Set(['td', 'th', 'caption', 'template']);
|
|
746
|
+
/**
|
|
747
|
+
* Start tags the tree builder inserts without first rebuilding the
|
|
748
|
+
* formatting a closed block left open — blocks, headings, lists, table
|
|
749
|
+
* parts, and what it handles as head content. Every other start tag, and
|
|
750
|
+
* every run of text, rebuilds it first.
|
|
751
|
+
*/
|
|
752
|
+
const INSERTED_WITHOUT_REBUILDING = new Set([
|
|
753
|
+
...CLOSES_PARAGRAPH, 'base', 'basefont', 'bgsound', 'body', 'caption', 'col', 'colgroup', 'frame', 'head',
|
|
754
|
+
'html', 'iframe', 'link', 'meta', 'noembed', 'noframes', 'noscript', 'rb', 'rp', 'rt', 'rtc', 'script',
|
|
755
|
+
'style', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'title', 'tr',
|
|
756
|
+
].filter((name) => name !== 'xmp'));
|
|
757
|
+
/**
|
|
758
|
+
* How many formatting elements the rebuild list holds past its last
|
|
759
|
+
* marker. The tree builder bounds only identical ones (three, below);
|
|
760
|
+
* without this, a page of `<p><b id=N>x</p>` makes every paragraph rebuild
|
|
761
|
+
* every earlier `b`, which is quadratic. The oldest past the bound is
|
|
762
|
+
* forgotten, so its text is kept — the direction this pass falls back to.
|
|
763
|
+
*/
|
|
764
|
+
const MAX_ACTIVE_FORMATTING = 16;
|
|
765
|
+
/**
|
|
766
|
+
* How deep the stack goes. Chromium's parser stops nesting at 512 too,
|
|
767
|
+
* and the bound is what keeps this pass linear: every end tag searches the
|
|
768
|
+
* stack, and an unbounded one let 80,000 nested `<div>`s and as many stray
|
|
769
|
+
* end tags cost their product. An element past it is not tracked, so its
|
|
770
|
+
* text is kept — the direction this pass falls back to throughout.
|
|
771
|
+
*/
|
|
772
|
+
const MAX_OPEN_ELEMENTS = 512;
|
|
773
|
+
/**
|
|
774
|
+
* The tree builder's "special" elements: a formatting end tag that finds
|
|
775
|
+
* one of these opened inside it moves it out (the adoption agency's
|
|
776
|
+
* furthest block); one that finds none simply closes everything inside.
|
|
777
|
+
*/
|
|
778
|
+
const SPECIAL_ELEMENTS = new Set([
|
|
779
|
+
'address', 'applet', 'area', 'article', 'aside', 'base', 'basefont', 'bgsound', 'blockquote', 'body', 'br',
|
|
780
|
+
'button', 'caption', 'center', 'col', 'colgroup', 'dd', 'details', 'dir', 'div', 'dl', 'dt', 'embed', 'fieldset',
|
|
781
|
+
'figcaption', 'figure', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header',
|
|
782
|
+
'hgroup', 'hr', 'html', 'iframe', 'img', 'input', 'keygen', 'li', 'link', 'listing', 'main', 'marquee', 'menu',
|
|
783
|
+
'meta', 'nav', 'noembed', 'noframes', 'noscript', 'object', 'ol', 'p', 'param', 'plaintext', 'pre', 'script',
|
|
784
|
+
'search', 'section', 'select', 'source', 'style', 'summary', 'table', 'tbody', 'td', 'template', 'textarea',
|
|
785
|
+
'tfoot', 'th', 'thead', 'title', 'tr', 'track', 'ul', 'wbr', 'xmp',
|
|
786
|
+
]);
|
|
787
|
+
/**
|
|
788
|
+
* Elements whose own content model is table structure, where other content
|
|
789
|
+
* is placed before the table. A `colgroup` holds only columns: anything
|
|
790
|
+
* else closes it and is placed the same way.
|
|
791
|
+
*/
|
|
792
|
+
const TABLE_CONTEXT = new Set(['table', 'tbody', 'thead', 'tfoot', 'tr', 'colgroup']);
|
|
793
|
+
/** Start tags a table context holds itself; any other is placed before the table. */
|
|
794
|
+
const TABLE_CONTENT = new Set([
|
|
795
|
+
'caption', 'colgroup', 'col', 'tbody', 'thead', 'tfoot', 'tr', 'td', 'th', 'script', 'style', 'template', 'form',
|
|
796
|
+
'input', 'table',
|
|
797
|
+
]);
|
|
798
|
+
/**
|
|
799
|
+
* The page's tags, in one pass, read the way the HTML tokenizer reads them
|
|
800
|
+
* rather than up to the first `>`. Attributes go through the tokenizer's
|
|
801
|
+
* own states, because each shortcut is a page the browser reads one way
|
|
802
|
+
* and this read another: a quote opens a value only where a value starts,
|
|
803
|
+
* so `<div title="1>0" hidden>` is hidden; an `=` or a quote inside an
|
|
804
|
+
* unquoted value is a character of it, so `<div x=a=">` ends at its `>`
|
|
805
|
+
* rather than swallowing the page. A tag still open at the end of the page
|
|
806
|
+
* is dropped, as a browser drops it, and nothing after it is a tag; nor is
|
|
807
|
+
* anything after `<plaintext>`, which has no end tag at all. Every
|
|
808
|
+
* character is visited a bounded number of times, so a hostile page costs
|
|
809
|
+
* its length.
|
|
810
|
+
*/
|
|
811
|
+
const scanTags = (html) => {
|
|
812
|
+
const tags = [];
|
|
813
|
+
let index = 0;
|
|
814
|
+
for (;;) {
|
|
815
|
+
const start = html.indexOf('<', index);
|
|
816
|
+
if (start === -1)
|
|
817
|
+
break;
|
|
818
|
+
let cursor = start + 1;
|
|
819
|
+
const closing = html[cursor] === '/';
|
|
820
|
+
if (closing)
|
|
821
|
+
cursor += 1;
|
|
822
|
+
if (!/[a-zA-Z]/.test(html[cursor] ?? '')) {
|
|
823
|
+
index = start + 1;
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
const nameStart = cursor;
|
|
827
|
+
while (cursor < html.length && !isAsciiWhitespace(html[cursor] ?? '') && html[cursor] !== '/' && html[cursor] !== '>') {
|
|
828
|
+
cursor += 1;
|
|
829
|
+
}
|
|
830
|
+
const name = asciiLower(html.slice(nameStart, cursor));
|
|
831
|
+
const attributes = new Map();
|
|
832
|
+
const keep = (attribute, value) => {
|
|
833
|
+
if (!attributes.has(attribute))
|
|
834
|
+
attributes.set(attribute, value);
|
|
835
|
+
};
|
|
836
|
+
let state = 'beforeName';
|
|
837
|
+
let attribute = '';
|
|
838
|
+
let mark = cursor;
|
|
839
|
+
let end = -1;
|
|
840
|
+
while (cursor < html.length) {
|
|
841
|
+
const char = html[cursor] ?? '';
|
|
842
|
+
if (state === 'beforeValue' && (char === '"' || char === "'")) {
|
|
843
|
+
const close = html.indexOf(char, cursor + 1);
|
|
844
|
+
if (close === -1) {
|
|
845
|
+
cursor = html.length;
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
keep(attribute, html.slice(cursor + 1, close));
|
|
849
|
+
cursor = close + 1;
|
|
850
|
+
state = 'beforeName';
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (char === '>') {
|
|
854
|
+
if (state === 'name')
|
|
855
|
+
keep(asciiLower(html.slice(mark, cursor)), '');
|
|
856
|
+
else if (state === 'afterName' || state === 'beforeValue')
|
|
857
|
+
keep(attribute, '');
|
|
858
|
+
else if (state === 'unquoted')
|
|
859
|
+
keep(attribute, html.slice(mark, cursor));
|
|
860
|
+
end = cursor;
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
const space = isAsciiWhitespace(char);
|
|
864
|
+
if (state === 'beforeName') {
|
|
865
|
+
if (!space && char !== '/') {
|
|
866
|
+
state = 'name';
|
|
867
|
+
mark = cursor;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
else if (state === 'name') {
|
|
871
|
+
if (space || char === '/' || char === '=') {
|
|
872
|
+
attribute = asciiLower(html.slice(mark, cursor));
|
|
873
|
+
if (char === '=') {
|
|
874
|
+
state = 'beforeValue';
|
|
875
|
+
}
|
|
876
|
+
else if (space) {
|
|
877
|
+
state = 'afterName';
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
keep(attribute, '');
|
|
881
|
+
state = 'beforeName';
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
else if (state === 'afterName') {
|
|
886
|
+
if (char === '=') {
|
|
887
|
+
state = 'beforeValue';
|
|
888
|
+
}
|
|
889
|
+
else if (char === '/') {
|
|
890
|
+
keep(attribute, '');
|
|
891
|
+
state = 'beforeName';
|
|
892
|
+
}
|
|
893
|
+
else if (!space) {
|
|
894
|
+
keep(attribute, '');
|
|
895
|
+
state = 'name';
|
|
896
|
+
mark = cursor;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
else if (state === 'beforeValue') {
|
|
900
|
+
if (!space) {
|
|
901
|
+
state = 'unquoted';
|
|
902
|
+
mark = cursor;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
else if (space) {
|
|
906
|
+
keep(attribute, html.slice(mark, cursor));
|
|
907
|
+
state = 'beforeName';
|
|
908
|
+
}
|
|
909
|
+
cursor += 1;
|
|
910
|
+
}
|
|
911
|
+
if (end === -1)
|
|
912
|
+
break;
|
|
913
|
+
tags.push({ start, end: end + 1, closing, name, attributes });
|
|
914
|
+
index = end + 1;
|
|
915
|
+
if (closing)
|
|
916
|
+
continue;
|
|
917
|
+
if (name === 'plaintext')
|
|
918
|
+
break;
|
|
919
|
+
if (RAW_TEXT_ELEMENTS.has(name)) {
|
|
920
|
+
const endTag = new RegExp(`</${name}[ \\t\\n\\f\\r/>]`, 'gi');
|
|
921
|
+
endTag.lastIndex = index;
|
|
922
|
+
const found = endTag.exec(html);
|
|
923
|
+
if (found === null)
|
|
924
|
+
break;
|
|
925
|
+
index = found.index;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return tags;
|
|
929
|
+
};
|
|
930
|
+
/**
|
|
931
|
+
* Drops the elements a browser would not show: the `hidden` attribute, an
|
|
932
|
+
* inline `display: none`, `visibility: hidden`, or `content-visibility:
|
|
933
|
+
* hidden` on an element whose box can contain it, and `aria-hidden="true"`.
|
|
934
|
+
* The last is still drawn on screen; it is here because it is what the
|
|
935
|
+
* page withholds from a reader who cannot see it, and a model reading this
|
|
936
|
+
* extraction is that reader.
|
|
937
|
+
*
|
|
938
|
+
* This is the extraction matching what a browser renders, not a
|
|
939
|
+
* prompt-injection filter, and must not be read as one. Text hidden by a
|
|
940
|
+
* stylesheet or a class is kept, because without a browser there is no
|
|
941
|
+
* cascade to evaluate, and neither are opacity, a zero font size, or
|
|
942
|
+
* off-screen positioning chased: a filter that mostly works is worse than
|
|
943
|
+
* the `external` label that always does, because it invites trust
|
|
944
|
+
* (docs/roadmap/30-provenance.md). `raw: true` exists for a page this gets
|
|
945
|
+
* wrong, including the one case where it drops text a browser shows: a
|
|
946
|
+
* descendant of a `visibility: hidden` element that sets `visible` again.
|
|
947
|
+
*
|
|
948
|
+
* Each element is paired with its own end tag on one stack of open
|
|
949
|
+
* elements, so `<div hidden><div>a</div>b</div>` drops through the outer
|
|
950
|
+
* end tag rather than the first `</div>` — one pass, linear in the page,
|
|
951
|
+
* where a search for each element's end would rescan the rest of the page
|
|
952
|
+
* for every one.
|
|
953
|
+
*
|
|
954
|
+
* Where text lands is the HTML tree builder's "in body" and table rules,
|
|
955
|
+
* as far as they decide that: implied ends (`<li hidden>a<li>b` hides `a`
|
|
956
|
+
* and shows `b`), scopes, foster parenting, implied table parts, the list
|
|
957
|
+
* of active formatting elements and its rebuilding, and the adoption
|
|
958
|
+
* agency. Each rule here was checked against Chromium, page by page and by
|
|
959
|
+
* comparing random malformed pages; foreign content (`svg`, `math`) and
|
|
960
|
+
* `select`'s own rendering are not modelled. An element still open at the
|
|
961
|
+
* end of the page keeps its text: a closing rule this pass does not model
|
|
962
|
+
* must not erase the article behind it, and keeping is what this extractor
|
|
963
|
+
* did before it read visibility at all.
|
|
964
|
+
*/
|
|
965
|
+
const dropHiddenElements = (html, tail) => {
|
|
966
|
+
// One stack of open elements, as the tree builder keeps, rather than one
|
|
967
|
+
// per tag name: an end tag closes everything opened inside the element
|
|
968
|
+
// it names, so in `<div><span hidden>gone</div>shown</span>` the span
|
|
969
|
+
// ends at `</div>` and `shown` is visible.
|
|
970
|
+
//
|
|
971
|
+
// And text is judged where the tree builder puts it, not where it sits
|
|
972
|
+
// in the source. Each run between tags belongs to the element that would
|
|
973
|
+
// receive it — usually the top of the stack, but text or a tag a table
|
|
974
|
+
// cannot hold is placed before the table, so in `<table hidden>visible`
|
|
975
|
+
// the text is the table's parent's, and shown. A run is dropped when a
|
|
976
|
+
// hidden element on its ancestry was closed; one still open at the end
|
|
977
|
+
// of the page confirms nothing, and its text is kept.
|
|
978
|
+
const stack = [];
|
|
979
|
+
const onStack = new Set();
|
|
980
|
+
// Every element closed before the end of the page, by id: a copy shares
|
|
981
|
+
// its original's, so closing either closes both.
|
|
982
|
+
const closed = new Set();
|
|
983
|
+
// Whether any element could hide anything; a page with none is returned as it is.
|
|
984
|
+
let mayHide = false;
|
|
985
|
+
const segments = [];
|
|
986
|
+
let nextId = 0;
|
|
987
|
+
const active = [];
|
|
988
|
+
// The form element pointer: while a form is open, another form start tag
|
|
989
|
+
// is ignored, and `</form>` removes that form alone.
|
|
990
|
+
let form;
|
|
991
|
+
const nearest = (names, stopAt) => {
|
|
992
|
+
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
|
993
|
+
const name = stack[index]?.name ?? '';
|
|
994
|
+
if (names.includes(name))
|
|
995
|
+
return index;
|
|
996
|
+
if (stopAt.includes(name))
|
|
997
|
+
return -1;
|
|
998
|
+
}
|
|
999
|
+
return -1;
|
|
1000
|
+
};
|
|
1001
|
+
const inScope = (index) => !stack.slice(index + 1).some((inside) => SCOPE_BOUNDARIES.includes(inside.name));
|
|
1002
|
+
const push = (element) => {
|
|
1003
|
+
if (stack.length >= MAX_OPEN_ELEMENTS)
|
|
1004
|
+
return false;
|
|
1005
|
+
stack.push(element);
|
|
1006
|
+
onStack.add(element);
|
|
1007
|
+
return true;
|
|
1008
|
+
};
|
|
1009
|
+
const clearToMarker = () => {
|
|
1010
|
+
while (active.length > 0 && active.pop() !== 'marker') {
|
|
1011
|
+
// Everything after the last marker goes with it.
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
const removed = (element) => {
|
|
1015
|
+
onStack.delete(element);
|
|
1016
|
+
closed.add(element.id);
|
|
1017
|
+
if (CLEARED_ON_CLOSE.has(element.name))
|
|
1018
|
+
clearToMarker();
|
|
1019
|
+
};
|
|
1020
|
+
const closeFrom = (index) => {
|
|
1021
|
+
// Innermost first, so a cell's marker is cleared after what it holds.
|
|
1022
|
+
for (const element of stack.splice(index).reverse())
|
|
1023
|
+
removed(element);
|
|
1024
|
+
};
|
|
1025
|
+
/** The last entry past the last marker that `matches`, as an index into the list. */
|
|
1026
|
+
const lastActive = (matches) => {
|
|
1027
|
+
for (let index = active.length - 1; index >= 0; index -= 1) {
|
|
1028
|
+
const entry = active[index];
|
|
1029
|
+
if (entry === 'marker' || entry === undefined)
|
|
1030
|
+
return -1;
|
|
1031
|
+
if (matches(entry))
|
|
1032
|
+
return index;
|
|
1033
|
+
}
|
|
1034
|
+
return -1;
|
|
1035
|
+
};
|
|
1036
|
+
const forget = (element) => {
|
|
1037
|
+
const index = lastActive((entry) => entry.element === element);
|
|
1038
|
+
if (index !== -1)
|
|
1039
|
+
active.splice(index, 1);
|
|
1040
|
+
};
|
|
1041
|
+
/**
|
|
1042
|
+
* Where foster-parented content goes: into the last open table's parent
|
|
1043
|
+
* in the tree, before the table — which is not always the element below
|
|
1044
|
+
* it on the stack. `<a hidden><table><a>` takes the hidden link off the
|
|
1045
|
+
* stack and leaves the table in it, so what is fostered is hidden too.
|
|
1046
|
+
*/
|
|
1047
|
+
const fosterParent = () => stack[nearest(['table'], [])]?.node.parent;
|
|
1048
|
+
/** Where new content goes: the top, or a table's parent for what a table cannot hold. */
|
|
1049
|
+
const parentFor = (fostered) => {
|
|
1050
|
+
const top = stack.at(-1);
|
|
1051
|
+
return top !== undefined && fostered && TABLE_CONTEXT.has(top.name) ? fosterParent() : top?.node;
|
|
1052
|
+
};
|
|
1053
|
+
const element = (name, attributes, parent, id = nextId++) => {
|
|
1054
|
+
if (attributes.has('hidden') || attributes.has('style') || attributes.has('aria-hidden'))
|
|
1055
|
+
mayHide = true;
|
|
1056
|
+
return { id, name, attributes, node: { id, name, attributes, parent } };
|
|
1057
|
+
};
|
|
1058
|
+
/**
|
|
1059
|
+
* The tree builder's reconstruction: reopen, in order, the formatting a
|
|
1060
|
+
* block closed around. A copy keeps its original's id, so a copy of a
|
|
1061
|
+
* hidden element hides as the original did — which was closed, or it
|
|
1062
|
+
* would not need rebuilding.
|
|
1063
|
+
*/
|
|
1064
|
+
const rebuild = (fostered) => {
|
|
1065
|
+
// In a table, only what is foster-parented rebuilds — before the table.
|
|
1066
|
+
if (TABLE_CONTEXT.has(stack.at(-1)?.name ?? '') && !fostered)
|
|
1067
|
+
return;
|
|
1068
|
+
let first = active.length;
|
|
1069
|
+
while (first > 0) {
|
|
1070
|
+
const entry = active[first - 1];
|
|
1071
|
+
if (entry === 'marker' || entry === undefined || onStack.has(entry.element))
|
|
1072
|
+
break;
|
|
1073
|
+
first -= 1;
|
|
1074
|
+
}
|
|
1075
|
+
for (let index = first; index < active.length; index += 1) {
|
|
1076
|
+
const entry = active[index];
|
|
1077
|
+
if (entry === 'marker' || entry === undefined)
|
|
1078
|
+
continue;
|
|
1079
|
+
const original = entry.element;
|
|
1080
|
+
const copy = element(original.name, original.attributes, parentFor(true), original.id);
|
|
1081
|
+
if (!push(copy))
|
|
1082
|
+
return;
|
|
1083
|
+
active[index] = { element: copy, key: entry.key };
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
/**
|
|
1087
|
+
* Formatting enters the list, at most three identical — same name, same
|
|
1088
|
+
* attributes — past the last marker, as the tree builder's "Noah's Ark"
|
|
1089
|
+
* clause keeps it, and at most MAX_ACTIVE_FORMATTING in all.
|
|
1090
|
+
*/
|
|
1091
|
+
const remember = (opened, attributes) => {
|
|
1092
|
+
const key = [opened.name, ...[...attributes].map(([name, value]) => `${name}=${value}`).sort()].join('\u0000');
|
|
1093
|
+
let since = active.length;
|
|
1094
|
+
while (since > 0 && active[since - 1] !== 'marker')
|
|
1095
|
+
since -= 1;
|
|
1096
|
+
const same = active.slice(since).filter((entry) => entry !== 'marker' && entry.key === key);
|
|
1097
|
+
if (same.length >= 3 && same[0] !== undefined)
|
|
1098
|
+
active.splice(active.indexOf(same[0]), 1);
|
|
1099
|
+
if (active.length - since >= MAX_ACTIVE_FORMATTING)
|
|
1100
|
+
active.splice(since, 1);
|
|
1101
|
+
active.push({ element: opened, key });
|
|
1102
|
+
};
|
|
1103
|
+
/**
|
|
1104
|
+
* The end tag the tree builder calls "any other": it closes the nearest
|
|
1105
|
+
* open element of its name unless a special element is opened inside
|
|
1106
|
+
* that, in which case it is ignored — `</span>` does not reach out of a
|
|
1107
|
+
* `div` opened in the span.
|
|
1108
|
+
*/
|
|
1109
|
+
const anyOtherEnd = (name) => {
|
|
1110
|
+
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
|
1111
|
+
const open = stack[index];
|
|
1112
|
+
if (open?.name === name)
|
|
1113
|
+
return index;
|
|
1114
|
+
if (SPECIAL_ELEMENTS.has(open?.name ?? ''))
|
|
1115
|
+
return -1;
|
|
1116
|
+
}
|
|
1117
|
+
return -1;
|
|
1118
|
+
};
|
|
1119
|
+
/**
|
|
1120
|
+
* The adoption agency, for an end tag naming formatting, in the terms
|
|
1121
|
+
* this pass keeps: each element's ancestry. The element is the last of
|
|
1122
|
+
* its name in the formatting list, not the stack — so after
|
|
1123
|
+
* `<em hidden><p><em>x</p>`, `</em>` names the inner `em`, already
|
|
1124
|
+
* closed, and the hidden one stays open. Content already read stays
|
|
1125
|
+
* where it is, under copies of the element; what moves is the block that
|
|
1126
|
+
* was opened inside it, which leaves it, so in `<b hidden>x<p>y</b>z`
|
|
1127
|
+
* the `z` is in the paragraph and shown.
|
|
1128
|
+
*/
|
|
1129
|
+
const adopt = (name) => {
|
|
1130
|
+
for (let round = 0; round < 8; round += 1) {
|
|
1131
|
+
const entryIndex = lastActive((entry) => entry.element.name === name);
|
|
1132
|
+
const formatting = active[entryIndex];
|
|
1133
|
+
if (formatting === undefined || formatting === 'marker') {
|
|
1134
|
+
const index = anyOtherEnd(name);
|
|
1135
|
+
if (index !== -1)
|
|
1136
|
+
closeFrom(index);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
const index = stack.lastIndexOf(formatting.element);
|
|
1140
|
+
if (index === -1) {
|
|
1141
|
+
active.splice(entryIndex, 1);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (!inScope(index))
|
|
1145
|
+
return;
|
|
1146
|
+
const block = stack.findIndex((open, at) => at > index && SPECIAL_ELEMENTS.has(open.name));
|
|
1147
|
+
if (block === -1) {
|
|
1148
|
+
active.splice(entryIndex, 1);
|
|
1149
|
+
closeFrom(index);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
// The block is moved under the element's parent — or, when that is a
|
|
1153
|
+
// table part, foster-parented before the table, as anything is — and
|
|
1154
|
+
// into copies of the formatting between them within three of the
|
|
1155
|
+
// block. Anything else between is closed, and the block leaves it:
|
|
1156
|
+
// in `<b><span hidden><p>x</b>` the `p` and its `x` are not hidden.
|
|
1157
|
+
const common = stack[index - 1];
|
|
1158
|
+
let ancestry = common !== undefined && TABLE_CONTEXT.has(common.name) ? fosterParent() : common?.node;
|
|
1159
|
+
const between = [];
|
|
1160
|
+
for (let at = index + 1; at < block; at += 1) {
|
|
1161
|
+
const open = stack[at];
|
|
1162
|
+
if (open === undefined)
|
|
1163
|
+
continue;
|
|
1164
|
+
const entry = lastActive((candidate) => candidate.element === open);
|
|
1165
|
+
const found = active[entry];
|
|
1166
|
+
if (found !== undefined && found !== 'marker' && block - at <= 3) {
|
|
1167
|
+
const copy = element(open.name, open.attributes, ancestry, open.id);
|
|
1168
|
+
ancestry = copy.node;
|
|
1169
|
+
active[entry] = { element: copy, key: found.key };
|
|
1170
|
+
between.push(copy);
|
|
1171
|
+
}
|
|
1172
|
+
else if (entry !== -1) {
|
|
1173
|
+
active.splice(entry, 1);
|
|
1174
|
+
}
|
|
1175
|
+
removed(open);
|
|
1176
|
+
}
|
|
1177
|
+
const blockElement = stack[block];
|
|
1178
|
+
if (blockElement === undefined)
|
|
1179
|
+
return;
|
|
1180
|
+
onStack.delete(blockElement);
|
|
1181
|
+
// What the block held so far moves into a copy of the formatting
|
|
1182
|
+
// element inside it, which the next round closes. The block's node
|
|
1183
|
+
// becomes that copy, keeping everything already in it, and the
|
|
1184
|
+
// block takes a new node in its new place.
|
|
1185
|
+
const holder = blockElement.node;
|
|
1186
|
+
const moved = { ...blockElement, node: { ...blockElement.node, parent: ancestry } };
|
|
1187
|
+
holder.id = formatting.element.id;
|
|
1188
|
+
holder.name = formatting.element.name;
|
|
1189
|
+
holder.attributes = formatting.element.attributes;
|
|
1190
|
+
holder.parent = moved.node;
|
|
1191
|
+
const copy = { ...formatting.element, node: holder };
|
|
1192
|
+
active[entryIndex] = { element: copy, key: formatting.key };
|
|
1193
|
+
removed(formatting.element);
|
|
1194
|
+
const inside = stack.slice(block + 1);
|
|
1195
|
+
stack.splice(index);
|
|
1196
|
+
for (const open of [...between, moved, copy, ...inside]) {
|
|
1197
|
+
stack.push(open);
|
|
1198
|
+
onStack.add(open);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
/** The tree builder's "generate implied end tags", optionally sparing one name. */
|
|
1203
|
+
const impliedEnds = (except) => {
|
|
1204
|
+
for (let top = stack.at(-1); top !== undefined && IMPLIED_END_ELEMENTS.has(top.name) && top.name !== except; top = stack.at(-1)) {
|
|
1205
|
+
closeFrom(stack.length - 1);
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
let cursor = 0;
|
|
1209
|
+
for (const tag of scanTags(html)) {
|
|
1210
|
+
if (tag.start > cursor) {
|
|
1211
|
+
const fostered = /[^ \t\n\f\r]/.test(html.slice(cursor, tag.start));
|
|
1212
|
+
rebuild(fostered);
|
|
1213
|
+
segments.push({ start: cursor, end: tag.start, node: parentFor(fostered) });
|
|
1214
|
+
}
|
|
1215
|
+
cursor = tag.end;
|
|
1216
|
+
const ignore = () => {
|
|
1217
|
+
segments.push({ start: tag.start, end: tag.end, node: parentFor(false) });
|
|
1218
|
+
};
|
|
1219
|
+
// The document's own elements are never hidden or closed here: an
|
|
1220
|
+
// `html` or `body` hidden until a script reveals it is the whole page,
|
|
1221
|
+
// not part of it, and `</body>` ends nothing — content after it is the
|
|
1222
|
+
// body's again.
|
|
1223
|
+
if (DOCUMENT_ELEMENTS.has(tag.name)) {
|
|
1224
|
+
ignore();
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
if (!tag.closing) {
|
|
1228
|
+
// A second form while the form pointer is set is ignored before it
|
|
1229
|
+
// does anything else — before it closes a paragraph, too.
|
|
1230
|
+
if (tag.name === 'form' && form !== undefined && nearest(['template'], []) === -1) {
|
|
1231
|
+
ignore();
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
// Before the void check, because `hr` both closes a paragraph and
|
|
1235
|
+
// holds nothing.
|
|
1236
|
+
if (CLOSES_PARAGRAPH.has(tag.name)) {
|
|
1237
|
+
const paragraph = nearest(['p'], [...SCOPE_BOUNDARIES, 'button']);
|
|
1238
|
+
if (paragraph !== -1)
|
|
1239
|
+
closeFrom(paragraph);
|
|
1240
|
+
}
|
|
1241
|
+
const items = LIST_ITEM_ENDS[tag.name];
|
|
1242
|
+
if (items !== undefined) {
|
|
1243
|
+
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
|
1244
|
+
const name = stack[index]?.name ?? '';
|
|
1245
|
+
if (items.includes(name)) {
|
|
1246
|
+
closeFrom(index);
|
|
1247
|
+
break;
|
|
1248
|
+
}
|
|
1249
|
+
if (SPECIAL_ELEMENTS.has(name) && name !== 'address' && name !== 'div' && name !== 'p')
|
|
1250
|
+
break;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
const sibling = IMPLIED_SIBLING_ENDS[tag.name];
|
|
1254
|
+
if (sibling !== undefined) {
|
|
1255
|
+
const found = nearest(sibling.closes, sibling.stopAt);
|
|
1256
|
+
if (found !== -1)
|
|
1257
|
+
closeFrom(found);
|
|
1258
|
+
}
|
|
1259
|
+
const context = TABLE_PART_CONTEXT[tag.name];
|
|
1260
|
+
if (context !== undefined) {
|
|
1261
|
+
const found = nearest(context, ['template']);
|
|
1262
|
+
if (found === -1) {
|
|
1263
|
+
ignore();
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
closeFrom(found + 1);
|
|
1267
|
+
// The parts the tree builder implies: a cell in a table or a row
|
|
1268
|
+
// group gets a row, a row in a table a row group, a column a column
|
|
1269
|
+
// group — so a later `</tr>` has a row to close, as it does there.
|
|
1270
|
+
const holder = stack[found]?.name ?? '';
|
|
1271
|
+
for (const implied of IMPLIED_TABLE_PARTS[tag.name]?.[holder] ?? [])
|
|
1272
|
+
push(element(implied, NO_ATTRIBUTES, parentFor(false)));
|
|
1273
|
+
}
|
|
1274
|
+
// A table directly in a table — not in one of its cells or its
|
|
1275
|
+
// caption, where it nests — ends the first; the second is its sibling.
|
|
1276
|
+
if (tag.name === 'table') {
|
|
1277
|
+
const found = nearest(['table', 'caption', 'td', 'th'], ['template']);
|
|
1278
|
+
if (stack[found]?.name === 'table')
|
|
1279
|
+
closeFrom(found);
|
|
1280
|
+
}
|
|
1281
|
+
// A heading never holds another: `<h1 hidden>gone<h2>shown` ends the
|
|
1282
|
+
// h1 at the h2 — but only when the h1 is the current element.
|
|
1283
|
+
if (HEADINGS.includes(tag.name) && HEADINGS.includes(stack.at(-1)?.name ?? ''))
|
|
1284
|
+
closeFrom(stack.length - 1);
|
|
1285
|
+
// Ruby annotations are siblings: each closes the open `rb`, `rp`,
|
|
1286
|
+
// `rt` (and, for `rb` and `rtc`, `rtc`) before it.
|
|
1287
|
+
if (['rb', 'rtc', 'rp', 'rt'].includes(tag.name) && nearest(['ruby'], SCOPE_BOUNDARIES) !== -1) {
|
|
1288
|
+
impliedEnds(tag.name === 'rp' || tag.name === 'rt' ? 'rtc' : undefined);
|
|
1289
|
+
}
|
|
1290
|
+
// A select never holds a select: the second closes the first and is
|
|
1291
|
+
// itself dropped. An input closes it too, and is inserted after.
|
|
1292
|
+
if (tag.name === 'select' || tag.name === 'input') {
|
|
1293
|
+
const found = nearest(['select'], SCOPE_BOUNDARIES);
|
|
1294
|
+
if (found !== -1) {
|
|
1295
|
+
closeFrom(found);
|
|
1296
|
+
if (tag.name === 'select') {
|
|
1297
|
+
ignore();
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
// Nor does a link hold a link, and a second `nobr` ends the first:
|
|
1303
|
+
// the tree builder runs the end tag for the open one first.
|
|
1304
|
+
if (tag.name === 'a' && lastActive((entry) => entry.element.name === 'a') !== -1) {
|
|
1305
|
+
const link = active[lastActive((entry) => entry.element.name === 'a')];
|
|
1306
|
+
adopt('a');
|
|
1307
|
+
if (link !== undefined && link !== 'marker') {
|
|
1308
|
+
forget(link.element);
|
|
1309
|
+
const index = stack.lastIndexOf(link.element);
|
|
1310
|
+
if (index !== -1) {
|
|
1311
|
+
const [left] = stack.splice(index, 1);
|
|
1312
|
+
if (left !== undefined)
|
|
1313
|
+
removed(left);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
const fostered = !TABLE_CONTENT.has(tag.name);
|
|
1318
|
+
if (tag.name === 'nobr') {
|
|
1319
|
+
rebuild(fostered);
|
|
1320
|
+
if (nearest(['nobr'], SCOPE_BOUNDARIES) !== -1)
|
|
1321
|
+
adopt('nobr');
|
|
1322
|
+
}
|
|
1323
|
+
if (!INSERTED_WITHOUT_REBUILDING.has(tag.name))
|
|
1324
|
+
rebuild(fostered);
|
|
1325
|
+
const opened = element(tag.name, tag.attributes, parentFor(fostered));
|
|
1326
|
+
segments.push({ start: tag.start, end: tag.end, node: opened.node });
|
|
1327
|
+
// A void element holds no text, but a hidden one still has an
|
|
1328
|
+
// effect to withhold — `one<br hidden>two` is one line — so its tag
|
|
1329
|
+
// is dropped, and it is closed the moment it opens.
|
|
1330
|
+
if (VOID_ELEMENTS.has(tag.name)) {
|
|
1331
|
+
closed.add(opened.id);
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
// A form opened directly in a table is the form pointer, but the tree
|
|
1335
|
+
// builder pops it at once: it holds nothing, and what follows is
|
|
1336
|
+
// placed as if it were not there.
|
|
1337
|
+
if (tag.name === 'form' && TABLE_CONTEXT.has(stack.at(-1)?.name ?? '')) {
|
|
1338
|
+
if (nearest(['template'], []) === -1)
|
|
1339
|
+
form = opened;
|
|
1340
|
+
closed.add(opened.id);
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
if (!push(opened))
|
|
1344
|
+
continue;
|
|
1345
|
+
if (FORMATTING_ELEMENTS.has(tag.name))
|
|
1346
|
+
remember(opened, tag.attributes);
|
|
1347
|
+
if (MARKER_ELEMENTS.has(tag.name))
|
|
1348
|
+
active.push('marker');
|
|
1349
|
+
// Not inside a template, whose content is a fragment of its own: a
|
|
1350
|
+
// form there, closed with the template, would otherwise leave the
|
|
1351
|
+
// pointer set and the next real form ignored.
|
|
1352
|
+
if (tag.name === 'form' && nearest(['template'], []) === -1)
|
|
1353
|
+
form = opened;
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
if (FORMATTING_ELEMENTS.has(tag.name)) {
|
|
1357
|
+
ignore();
|
|
1358
|
+
adopt(tag.name);
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
// `</form>` removes the form it opened and nothing inside it: in
|
|
1362
|
+
// `<form hidden><div>a</form>b</div>` the `b` is still in the form.
|
|
1363
|
+
// Inside a template, the form pointer is not used: the end tag closes
|
|
1364
|
+
// the form in scope there, and the document's pointer stays set.
|
|
1365
|
+
if (tag.name === 'form' && nearest(['template'], []) !== -1) {
|
|
1366
|
+
const index = nearest(['form'], SCOPE_BOUNDARIES);
|
|
1367
|
+
segments.push({ start: tag.start, end: tag.end, node: stack[index]?.node ?? parentFor(false) });
|
|
1368
|
+
if (index !== -1)
|
|
1369
|
+
closeFrom(index);
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
if (tag.name === 'form') {
|
|
1373
|
+
const pointer = form;
|
|
1374
|
+
form = undefined;
|
|
1375
|
+
const index = pointer === undefined ? -1 : stack.lastIndexOf(pointer);
|
|
1376
|
+
segments.push({ start: tag.start, end: tag.end, node: pointer?.node ?? parentFor(false) });
|
|
1377
|
+
if (pointer !== undefined && index !== -1 && inScope(index)) {
|
|
1378
|
+
impliedEnds();
|
|
1379
|
+
const [left] = stack.splice(stack.lastIndexOf(pointer), 1);
|
|
1380
|
+
if (left !== undefined)
|
|
1381
|
+
removed(left);
|
|
1382
|
+
}
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
// Where each end tag looks for its element: a table part in table
|
|
1386
|
+
// scope, past an open cell — `</table>` from inside a `td` closes the
|
|
1387
|
+
// cell and the table; any heading for any heading; `</p>` and `</li>`
|
|
1388
|
+
// in their own scopes; a block in the default scope; and anything else
|
|
1389
|
+
// only up to the nearest special element.
|
|
1390
|
+
let index;
|
|
1391
|
+
if (TABLE_PARTS.has(tag.name))
|
|
1392
|
+
index = nearest([tag.name], TABLE_SCOPE.filter((name) => name !== tag.name));
|
|
1393
|
+
else if (HEADINGS.includes(tag.name))
|
|
1394
|
+
index = nearest(HEADINGS, SCOPE_BOUNDARIES);
|
|
1395
|
+
else if (tag.name === 'p')
|
|
1396
|
+
index = nearest(['p'], [...SCOPE_BOUNDARIES, 'button']);
|
|
1397
|
+
else if (tag.name === 'li')
|
|
1398
|
+
index = nearest(['li'], [...SCOPE_BOUNDARIES, 'ol', 'ul']);
|
|
1399
|
+
else if (SCOPED_END_TAGS.has(tag.name))
|
|
1400
|
+
index = nearest([tag.name], SCOPE_BOUNDARIES.filter((name) => name !== tag.name));
|
|
1401
|
+
else
|
|
1402
|
+
index = anyOtherEnd(tag.name);
|
|
1403
|
+
// An end tag with nothing of its name open is a stray, and closes nothing.
|
|
1404
|
+
if (index === -1) {
|
|
1405
|
+
ignore();
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
segments.push({ start: tag.start, end: tag.end, node: stack[index]?.node });
|
|
1409
|
+
closeFrom(index);
|
|
1410
|
+
if (MARKER_ELEMENTS.has(tag.name) && !CLEARED_ON_CLOSE.has(tag.name))
|
|
1411
|
+
clearToMarker();
|
|
1412
|
+
}
|
|
1413
|
+
if (cursor < html.length) {
|
|
1414
|
+
const fostered = /[^ \t\n\f\r]/.test(html.slice(cursor));
|
|
1415
|
+
rebuild(fostered);
|
|
1416
|
+
segments.push({ start: cursor, end: html.length, node: parentFor(fostered) });
|
|
1417
|
+
}
|
|
1418
|
+
// The text after the page's last `>`, which the caller keeps apart from
|
|
1419
|
+
// its patterns, is still read into the tree: formatting a block closed
|
|
1420
|
+
// around is rebuilt for it, so `<div><b hidden>x</div>tail` hides it.
|
|
1421
|
+
const tailFostered = /[^ \t\n\f\r]/.test(tail);
|
|
1422
|
+
if (tail !== '')
|
|
1423
|
+
rebuild(tailFostered);
|
|
1424
|
+
const tailNode = parentFor(tailFostered);
|
|
1425
|
+
if (!mayHide)
|
|
1426
|
+
return { text: html, tail };
|
|
1427
|
+
// Each node's style in the finished tree, parents first, remembered so
|
|
1428
|
+
// a page costs its nodes once.
|
|
1429
|
+
const judged = new Map();
|
|
1430
|
+
const judge = (node) => {
|
|
1431
|
+
const known = judged.get(node);
|
|
1432
|
+
if (known !== undefined)
|
|
1433
|
+
return known;
|
|
1434
|
+
const result = styleOf(node, (at) => judge(at).style);
|
|
1435
|
+
judged.set(node, result);
|
|
1436
|
+
return result;
|
|
1437
|
+
};
|
|
1438
|
+
// Whether a node is dropped: it, or a node it is in, is hidden and was
|
|
1439
|
+
// closed. Read once the page is done, since nodes move until then;
|
|
1440
|
+
// remembered per node, so a page costs its nodes once.
|
|
1441
|
+
const dropped = new Map();
|
|
1442
|
+
const isDropped = (node) => {
|
|
1443
|
+
const chain = [];
|
|
1444
|
+
let result = false;
|
|
1445
|
+
for (let at = node; at !== undefined; at = at.parent) {
|
|
1446
|
+
const known = dropped.get(at);
|
|
1447
|
+
if (known !== undefined) {
|
|
1448
|
+
result = known;
|
|
1449
|
+
break;
|
|
1450
|
+
}
|
|
1451
|
+
chain.push(at);
|
|
1452
|
+
}
|
|
1453
|
+
for (const at of chain.reverse()) {
|
|
1454
|
+
result = result || (closed.has(at.id) && judge(at).hidden);
|
|
1455
|
+
dropped.set(at, result);
|
|
1456
|
+
}
|
|
1457
|
+
return result;
|
|
1458
|
+
};
|
|
1459
|
+
// A dropped stretch, however many segments, becomes one space.
|
|
1460
|
+
let text = '';
|
|
1461
|
+
let dropping = false;
|
|
1462
|
+
for (const segment of segments) {
|
|
1463
|
+
if (isDropped(segment.node)) {
|
|
1464
|
+
if (!dropping)
|
|
1465
|
+
text += ' ';
|
|
1466
|
+
dropping = true;
|
|
1467
|
+
}
|
|
1468
|
+
else {
|
|
1469
|
+
text += html.slice(segment.start, segment.end);
|
|
1470
|
+
dropping = false;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
return { text, tail: tail !== '' && isDropped(tailNode) ? ' ' : tail };
|
|
1474
|
+
};
|
|
30
1475
|
/**
|
|
31
1476
|
* Formatting that wraps text which was already adjacent, so removing it
|
|
32
1477
|
* joins nothing that was apart. Deliberately a closed set: everything not
|
|
@@ -63,12 +1508,52 @@ const BLOCK_ELEMENTS = [
|
|
|
63
1508
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
64
1509
|
'ul', 'ol', 'table', 'tr', 'blockquote', 'pre',
|
|
65
1510
|
];
|
|
1511
|
+
/**
|
|
1512
|
+
* The named references this extractor decodes — a small part of HTML's
|
|
1513
|
+
* table, and case-sensitive as that table is (`∷` is `∷`, not `:`).
|
|
1514
|
+
* The punctuation names are here because a browser decodes an attribute
|
|
1515
|
+
* before CSS reads it, so `display:none` hides; one not listed stays
|
|
1516
|
+
* as written.
|
|
1517
|
+
*/
|
|
66
1518
|
const NAMED_ENTITIES = {
|
|
67
1519
|
amp: '&',
|
|
1520
|
+
AMP: '&',
|
|
68
1521
|
lt: '<',
|
|
1522
|
+
LT: '<',
|
|
69
1523
|
gt: '>',
|
|
1524
|
+
GT: '>',
|
|
70
1525
|
quot: '"',
|
|
1526
|
+
QUOT: '"',
|
|
71
1527
|
apos: "'",
|
|
1528
|
+
colon: ':',
|
|
1529
|
+
semi: ';',
|
|
1530
|
+
excl: '!',
|
|
1531
|
+
num: '#',
|
|
1532
|
+
period: '.',
|
|
1533
|
+
comma: ',',
|
|
1534
|
+
sol: '/',
|
|
1535
|
+
bsol: '\\',
|
|
1536
|
+
lpar: '(',
|
|
1537
|
+
rpar: ')',
|
|
1538
|
+
lsqb: '[',
|
|
1539
|
+
rsqb: ']',
|
|
1540
|
+
lbrace: '{',
|
|
1541
|
+
rbrace: '}',
|
|
1542
|
+
lowbar: '_',
|
|
1543
|
+
hyphen: '\u2010',
|
|
1544
|
+
dash: '\u2010',
|
|
1545
|
+
plus: '+',
|
|
1546
|
+
equals: '=',
|
|
1547
|
+
ast: '*',
|
|
1548
|
+
commat: '@',
|
|
1549
|
+
verbar: '|',
|
|
1550
|
+
vert: '|',
|
|
1551
|
+
grave: '`',
|
|
1552
|
+
Hat: '^',
|
|
1553
|
+
dollar: '$',
|
|
1554
|
+
percnt: '%',
|
|
1555
|
+
Tab: '\t',
|
|
1556
|
+
NewLine: '\n',
|
|
72
1557
|
nbsp: ' ',
|
|
73
1558
|
mdash: '—',
|
|
74
1559
|
ndash: '–',
|
|
@@ -78,35 +1563,108 @@ const NAMED_ENTITIES = {
|
|
|
78
1563
|
ldquo: '“',
|
|
79
1564
|
rdquo: '”',
|
|
80
1565
|
};
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
1566
|
+
/**
|
|
1567
|
+
* A numeric reference as a browser reads it: nothing past U+10FFFF, no
|
|
1568
|
+
* surrogate, and no NUL is a character, so each becomes U+FFFD. Handed to
|
|
1569
|
+
* `String.fromCodePoint`, the first throws — and one `�` anywhere
|
|
1570
|
+
* on a page, an attribute included, failed the whole fetch.
|
|
1571
|
+
*/
|
|
1572
|
+
const codePointText = (code) => code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff) ? '\uFFFD' : String.fromCodePoint(code);
|
|
1573
|
+
/**
|
|
1574
|
+
* Numeric references decode with or without their semicolon, as a browser
|
|
1575
|
+
* decodes them — `display:none` hides — and a named one only with it.
|
|
1576
|
+
*/
|
|
1577
|
+
export const decodeEntities = (value) => value.replace(/&#[xX]([0-9a-fA-F]+);?|&#([0-9]+);?|&([a-zA-Z][a-zA-Z0-9]*);/g, (match, hex, decimal, name) => {
|
|
1578
|
+
if (hex !== undefined || decimal !== undefined) {
|
|
1579
|
+
const code = hex !== undefined ? Number.parseInt(hex, 16) : Number.parseInt(decimal ?? '', 10);
|
|
1580
|
+
return Number.isFinite(code) ? codePointText(code) : '\uFFFD';
|
|
89
1581
|
}
|
|
90
|
-
return NAMED_ENTITIES[
|
|
1582
|
+
return NAMED_ENTITIES[name ?? ''] ?? match;
|
|
91
1583
|
});
|
|
1584
|
+
/**
|
|
1585
|
+
* Every `open … close` span removed, as `/open[\s\S]*?close/gi` would, in
|
|
1586
|
+
* one pass. The regex is quadratic when the closer is missing: every
|
|
1587
|
+
* opener scans to the end of the page looking for it, and a page of
|
|
1588
|
+
* openers with no closer — `<script` ten thousand times — is a page that
|
|
1589
|
+
* holds the daemon's only thread for minutes. Here the closer is looked
|
|
1590
|
+
* for once per opener, from where that opener ended, and the first one
|
|
1591
|
+
* that has none ends the pass, since no later opener can have one either.
|
|
1592
|
+
*
|
|
1593
|
+
* Both are searched for in the text itself, case-insensitively, never in a
|
|
1594
|
+
* lowercased copy: lowercasing can change a string's length (`İ` becomes
|
|
1595
|
+
* two code units), and an index into the copy would then cut the original
|
|
1596
|
+
* in the wrong place — a page could pick which of its script survives.
|
|
1597
|
+
*
|
|
1598
|
+
* `opener` must be global and must only match text that ends in `>`, so
|
|
1599
|
+
* that run over the markup `htmlToText` hands it, its own scan always
|
|
1600
|
+
* finds an end.
|
|
1601
|
+
*/
|
|
1602
|
+
const removeSpans = (text, opener, closer, replacement) => {
|
|
1603
|
+
let kept = '';
|
|
1604
|
+
let cursor = 0;
|
|
1605
|
+
opener.lastIndex = 0;
|
|
1606
|
+
for (let match = opener.exec(text); match !== null; match = opener.exec(text)) {
|
|
1607
|
+
closer.lastIndex = opener.lastIndex;
|
|
1608
|
+
const close = closer.exec(text);
|
|
1609
|
+
if (close === null) {
|
|
1610
|
+
break;
|
|
1611
|
+
}
|
|
1612
|
+
kept += text.slice(cursor, match.index) + replacement;
|
|
1613
|
+
cursor = close.index + close[0].length;
|
|
1614
|
+
opener.lastIndex = cursor;
|
|
1615
|
+
}
|
|
1616
|
+
return kept + text.slice(cursor);
|
|
1617
|
+
};
|
|
92
1618
|
export const extractTitle = (html) => {
|
|
93
|
-
|
|
94
|
-
|
|
1619
|
+
// Two searches rather than one lazy regex, for the reason `removeSpans`
|
|
1620
|
+
// gives: a page of `<title>` with no `</title>` made the regex rescan the
|
|
1621
|
+
// rest of the page from every one of them. The opener is found only
|
|
1622
|
+
// before the last `>`, where its own `[^>]*` always ends.
|
|
1623
|
+
const open = /<title[^>]*>/i.exec(html.slice(0, html.lastIndexOf('>') + 1));
|
|
1624
|
+
if (open === null) {
|
|
1625
|
+
return undefined;
|
|
1626
|
+
}
|
|
1627
|
+
const close = /<\/title>/gi;
|
|
1628
|
+
close.lastIndex = open.index + open[0].length;
|
|
1629
|
+
const end = close.exec(html);
|
|
1630
|
+
const title = end === null ? undefined : html.slice(open.index + open[0].length, end.index);
|
|
1631
|
+
return title ? decodeEntities(title).trim() : undefined;
|
|
95
1632
|
};
|
|
96
1633
|
export const htmlToText = (html) => {
|
|
1634
|
+
// Every pattern below ends in `>`, so nothing after the page's last `>`
|
|
1635
|
+
// can be part of one — and that tail is where they turned from linear to
|
|
1636
|
+
// worse. A `[^>]*` with no `>` ahead of it scans to the end of the page
|
|
1637
|
+
// and fails, from every `<` in turn, and the final sweep's two adjacent
|
|
1638
|
+
// quantifiers made each of those failures try every split as well:
|
|
1639
|
+
// fourteen kilobytes of `<script` took 104 seconds, on the one thread
|
|
1640
|
+
// every agent in the daemon shares. Cut there, every scan finds its `>`.
|
|
1641
|
+
// The tail is text, and joins the rest before entities are decoded.
|
|
1642
|
+
const end = html.lastIndexOf('>') + 1;
|
|
1643
|
+
const tail = html.slice(end);
|
|
97
1644
|
// Comments first, then the doctype and any processing instruction. These
|
|
98
1645
|
// are removed by name rather than by a blanket `<[^>]*>` sweep, because
|
|
99
1646
|
// that sweep reads `5 < 10 and 20 > 15` as a tag and deletes the middle
|
|
100
1647
|
// of the sentence — prose about arbitrary subjects is exactly what a
|
|
101
1648
|
// fetched page is.
|
|
102
|
-
|
|
1649
|
+
// A comment ends where the HTML tokenizer ends it: at `-->`, at `--!>`,
|
|
1650
|
+
// or at once in `<!-->` and `<!--->`. Read only to `-->`, a `--!>`
|
|
1651
|
+
// comment ran on and the markup inside it reached the visibility pass.
|
|
1652
|
+
let text = removeSpans(html.slice(0, end), /<!--/g, /(?<=<!--)-?>|--!?>/g, '');
|
|
103
1653
|
text = text.replace(/<[!?][^>]*>/g, ' ');
|
|
1654
|
+
// Before the dropped elements, which are cut out by name with no regard
|
|
1655
|
+
// for the tree: an end tag inside a `nav` closes its ancestors as much as
|
|
1656
|
+
// one anywhere, and in `<div hidden>a<nav></div></nav>b` cutting the nav
|
|
1657
|
+
// first left the div open and `a` kept. The scan already reads a script's
|
|
1658
|
+
// or a style's body as text, so a `</div>` in a script string closes
|
|
1659
|
+
// nothing either way.
|
|
1660
|
+
const visible = dropHiddenElements(text, tail);
|
|
1661
|
+
text = visible.text;
|
|
104
1662
|
for (const element of DROPPED_ELEMENTS) {
|
|
105
|
-
text = text
|
|
1663
|
+
text = removeSpans(text, new RegExp(`<${element}\\b[^>]*>`, 'gi'), new RegExp(`</${element}>`, 'gi'), ' ');
|
|
106
1664
|
// Unclosed or self-closing forms of the same elements.
|
|
107
1665
|
text = text.replace(new RegExp(`<${element}\\b[^>]*/?>`, 'gi'), ' ');
|
|
108
1666
|
}
|
|
109
|
-
text = text
|
|
1667
|
+
text = removeSpans(text, /<head\b[^>]*>/gi, /<\/head>/gi, ' ');
|
|
110
1668
|
text = text.replace(/<li\b[^>]*>/gi, '\n- ');
|
|
111
1669
|
for (const element of BLOCK_ELEMENTS) {
|
|
112
1670
|
text = text.replace(new RegExp(`</?${element}\\b[^>]*>`, 'gi'), '\n');
|
|
@@ -138,7 +1696,7 @@ export const htmlToText = (html) => {
|
|
|
138
1696
|
return '"';
|
|
139
1697
|
return INLINE_ELEMENTS.has(name) ? '' : ' ';
|
|
140
1698
|
});
|
|
141
|
-
text = decodeEntities(text);
|
|
1699
|
+
text = decodeEntities(text + visible.tail);
|
|
142
1700
|
return text
|
|
143
1701
|
.split('\n')
|
|
144
1702
|
.map((line) => line.replace(/[ \t ]+/g, ' ').trim())
|