@stratusagent/tool-web 0.11.6 → 0.11.8

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.
@@ -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. `&nbsp;` is U+00A0
645
+ * there, which is not whitespace to CSS — `display:none&nbsp;` 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(/&nbsp;/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 (`&Colon;` is `∷`, not `:`).
1514
+ * The punctuation names are here because a browser decodes an attribute
1515
+ * before CSS reads it, so `display&colon;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
- export const decodeEntities = (value) => value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity) => {
82
- if (entity.startsWith('#x') || entity.startsWith('#X')) {
83
- const code = Number.parseInt(entity.slice(2), 16);
84
- return Number.isFinite(code) ? String.fromCodePoint(code) : match;
85
- }
86
- if (entity.startsWith('#')) {
87
- const code = Number.parseInt(entity.slice(1), 10);
88
- return Number.isFinite(code) ? String.fromCodePoint(code) : match;
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 `&#1114112;` 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&#58none` 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[entity.toLowerCase()] ?? match;
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
- const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
94
- return match?.[1] ? decodeEntities(match[1]).trim() : undefined;
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
- let text = html.replace(/<!--[\s\S]*?-->/g, '');
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.replace(new RegExp(`<${element}\\b[^>]*>[\\s\\S]*?</${element}>`, 'gi'), ' ');
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.replace(/<head\b[^>]*>[\s\S]*?<\/head>/gi, ' ');
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())