@khorsheed/dsh-reader 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +90 -0
- package/README.i18n.yaml +6 -0
- package/README.md +90 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +15119 -0
- package/lib/index.js +3660 -0
- package/lib/invariant.js +38 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +1738 -0
- package/lib/typert.remote-client.d.ts +82 -0
- package/lib/typert.remote-client.js +1115 -0
- package/lib/types/arxiv.d.ts +47 -0
- package/lib/types/arxiv.js +76 -0
- package/lib/types/client/ReaderPane.d.ts +71 -0
- package/lib/types/client/ReaderPane.js +3135 -0
- package/lib/types/client/contract.d.ts +254 -0
- package/lib/types/client/contract.js +1 -0
- package/lib/types/client/definition.d.ts +27 -0
- package/lib/types/client/definition.js +32 -0
- package/lib/types/client/extract-article.d.ts +102 -0
- package/lib/types/client/extract-article.js +1024 -0
- package/lib/types/client/icons.d.ts +27 -0
- package/lib/types/client/icons.js +11 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/index.js +222 -0
- package/lib/types/client/locales.d.ts +391 -0
- package/lib/types/client/locales.js +382 -0
- package/lib/types/client/parse-rss.d.ts +69 -0
- package/lib/types/client/parse-rss.js +656 -0
- package/lib/types/client/quote.d.ts +89 -0
- package/lib/types/client/quote.js +119 -0
- package/lib/types/client/selectors.d.ts +167 -0
- package/lib/types/client/selectors.js +413 -0
- package/lib/types/client/session.d.ts +171 -0
- package/lib/types/client/session.js +223 -0
- package/lib/types/client/store.d.ts +181 -0
- package/lib/types/client/store.js +226 -0
- package/lib/types/client/translate.d.ts +379 -0
- package/lib/types/client/translate.js +873 -0
- package/lib/types/index.d.ts +48 -0
- package/lib/types/index.js +25 -0
- package/lib/types/invariant.d.ts +29 -0
- package/lib/types/invariant.js +38 -0
- package/lib/types/link-resolvers.d.ts +107 -0
- package/lib/types/link-resolvers.js +299 -0
- package/lib/types/remote.d.ts +224 -0
- package/lib/types/remote.js +275 -0
- package/lib/types/schedule.d.ts +78 -0
- package/lib/types/schedule.js +114 -0
- package/lib/types/service.d.ts +633 -0
- package/lib/types/service.js +1697 -0
- package/lib/types/store.d.ts +224 -0
- package/lib/types/store.js +916 -0
- package/lib/types/types.d.ts +710 -0
- package/lib/types/types.js +182 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +128 -0
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Article extraction and rich-text normalization for the browser half.
|
|
3
|
+
*
|
|
4
|
+
* Two exports, one for each shape of input the reader handles:
|
|
5
|
+
*
|
|
6
|
+
* - {@link extractArticle} takes a fetched HTML page and finds its body. The
|
|
7
|
+
* honest problem here is that which block is "the article" is a heuristic:
|
|
8
|
+
* this module scores candidate blocks the way readability implementations do
|
|
9
|
+
* (paragraph mass, minus link mass, with wrapper blocks demoted) and adds the
|
|
10
|
+
* one signal that a single-language scorer gets wrong — Chinese paragraphs
|
|
11
|
+
* are short by nature, so a length threshold tuned for English rejects them
|
|
12
|
+
* and the scorer falls back to the whole page container (measured: 473 <p>
|
|
13
|
+
* and 56 images instead of the article).
|
|
14
|
+
* - {@link normalizeRichText} takes markup that is ALREADY known to be a body
|
|
15
|
+
* (a feed's `content:encoded`, or the block extraction selected) and puts it
|
|
16
|
+
* through a tag whitelist, so the renderer never sees third-party scripts,
|
|
17
|
+
* styles, forms or event handlers.
|
|
18
|
+
*
|
|
19
|
+
* Everything runs in the browser because the host runtime has no DOM parser at
|
|
20
|
+
* all; nothing here touches the network.
|
|
21
|
+
*
|
|
22
|
+
* @module @khorsheed/dsh-reader/client/extract-article
|
|
23
|
+
*/
|
|
24
|
+
/** Elements kept in normalized output, mapped to themselves. */
|
|
25
|
+
const KEEP_TAGS = new Set([
|
|
26
|
+
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre',
|
|
27
|
+
'code', 'strong', 'em', 'b', 'i', 'u', 's', 'a', 'img', 'figure', 'figcaption',
|
|
28
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'sub', 'sup', 'dl', 'dt', 'dd',
|
|
29
|
+
// The presentation MathML subset: arXiv's HTML papers (LaTeXML) carry their
|
|
30
|
+
// formulas as `<math alttext="…">`, and the render target (Chromium ≥ 153)
|
|
31
|
+
// renders this subset natively — no script, no font download. Anything NOT
|
|
32
|
+
// in this list (`mglyph`, `mpadded`, …) is unwrapped to its children, which
|
|
33
|
+
// for a formula is its readable text.
|
|
34
|
+
'math', 'mrow', 'mi', 'mo', 'mn', 'ms', 'mtext', 'msup', 'msub', 'msubsup',
|
|
35
|
+
'mfrac', 'msqrt', 'mroot', 'mspace', 'mtable', 'mtr', 'mtd', 'munder',
|
|
36
|
+
'mover', 'munderover', 'semantics', 'annotation',
|
|
37
|
+
]);
|
|
38
|
+
/**
|
|
39
|
+
* Elements whose entire subtree is dropped (never merely unwrapped).
|
|
40
|
+
*
|
|
41
|
+
* `noscript` is deliberately NOT here: its subtree is where the standard no-JS
|
|
42
|
+
* image fallback lives (`<noscript><img src="real.jpg"></noscript>` behind a
|
|
43
|
+
* lazy-loading script), and dropping it wholesale loses pictures whose URL is
|
|
44
|
+
* right there in the static markup. The normalizer recovers that image and
|
|
45
|
+
* drops everything else a noscript carries.
|
|
46
|
+
*
|
|
47
|
+
* `annotation-xml` IS here while `math` is not: the presentation MathML subset
|
|
48
|
+
* renders natively, but an `annotation-xml` with `encoding="text/html"` parses
|
|
49
|
+
* its children as HTML — the one MathML-shaped HTML-injection vector — so the
|
|
50
|
+
* subtree never reaches the normalizer.
|
|
51
|
+
*
|
|
52
|
+
* `object` is no longer here either: LaTeXML (arXiv's HTML papers) embeds
|
|
53
|
+
* vector figures as `<object type="image/svg+xml" data="…">`, so the
|
|
54
|
+
* normalizer converts image-typed objects to `<img>` and drops the rest whole.
|
|
55
|
+
*/
|
|
56
|
+
const DROP_TAGS = new Set([
|
|
57
|
+
'script', 'style', 'template', 'nav', 'header', 'footer', 'aside',
|
|
58
|
+
'form', 'iframe', 'annotation-xml', 'button', 'select', 'input', 'textarea', 'video',
|
|
59
|
+
'audio', 'canvas', 'embed', 'link', 'meta', 'base', 'title',
|
|
60
|
+
// svg is NOT here: capture-rendered pages carry their rescued figures as
|
|
61
|
+
// inline SVG, which normalizeSvg() whitelists per element/attribute. The
|
|
62
|
+
// strip's selector gains the canonical-case members separately (SVG names are
|
|
63
|
+
// case-sensitive in an HTML document).
|
|
64
|
+
'foreignobject',
|
|
65
|
+
]);
|
|
66
|
+
/** The strip selector's exact-case members (SVG camelCase names in an HTML document). */
|
|
67
|
+
const DROP_STRIP_EXTRA = ['foreignObject'];
|
|
68
|
+
/** Inline elements that survive an inline-only pass. */
|
|
69
|
+
const INLINE_TAGS = new Set(['strong', 'em', 'b', 'i', 'u', 's', 'code', 'a', 'sub', 'sup', 'br']);
|
|
70
|
+
/**
|
|
71
|
+
* Unwrapped containers that must leave a word boundary behind.
|
|
72
|
+
*
|
|
73
|
+
* Unwrapping a non-whitelist element keeps its children but no longer marks
|
|
74
|
+
* any separation: a capture's outerHTML serialization carries NO inter-tag
|
|
75
|
+
* whitespace, so `<div>A</div><div>B</div>` used to normalize into `AB` —
|
|
76
|
+
* measured live as a composite figure's cards reading as one concatenated
|
|
77
|
+
* run. These block-ish containers (plus any custom element) emit a boundary;
|
|
78
|
+
* inline-ish unknowns keep the old flush behavior.
|
|
79
|
+
*/
|
|
80
|
+
const UNWRAP_BOUNDARY_TAGS = new Set([
|
|
81
|
+
'div', 'section', 'article', 'main', 'details', 'summary', 'hgroup', 'fieldset', 'center',
|
|
82
|
+
]);
|
|
83
|
+
/**
|
|
84
|
+
* The style properties a figure's containers may keep: layout and paint, never
|
|
85
|
+
* behavior. `position` is further value-gated (no fixed/sticky), `z-index` is
|
|
86
|
+
* bounded, and any `url(…)` must be a same-document fragment — no external
|
|
87
|
+
* loads or script tricks ride an inlined style into the reader.
|
|
88
|
+
*/
|
|
89
|
+
const FIGURE_STYLE_EXACT = new Set([
|
|
90
|
+
'display', 'position', 'top', 'right', 'bottom', 'left', 'inset', 'width', 'height',
|
|
91
|
+
'margin', 'padding', 'gap', 'order', 'color', 'background', 'overflow',
|
|
92
|
+
'text-align', 'vertical-align', 'line-height', 'white-space', 'transform',
|
|
93
|
+
'transform-origin', 'opacity', 'visibility', 'z-index', 'flex', 'grid',
|
|
94
|
+
]);
|
|
95
|
+
const FIGURE_STYLE_PREFIXES = [
|
|
96
|
+
'flex-', 'grid-', 'max-', 'min-', 'margin-', 'padding-', 'gap-',
|
|
97
|
+
'justify-', 'align-', 'place-', 'border', 'font-', 'background-', 'overflow-', 'text-',
|
|
98
|
+
];
|
|
99
|
+
/**
|
|
100
|
+
* Filter one `style` attribute to the figure allowlist.
|
|
101
|
+
*
|
|
102
|
+
* @param style - the attribute's raw value.
|
|
103
|
+
* @returns the kept declarations, or `undefined` when none survive.
|
|
104
|
+
*/
|
|
105
|
+
function figureStyle(style) {
|
|
106
|
+
if (style === null)
|
|
107
|
+
return undefined;
|
|
108
|
+
const kept = [];
|
|
109
|
+
for (const declaration of style.split(';')) {
|
|
110
|
+
const colon = declaration.indexOf(':');
|
|
111
|
+
if (colon === -1)
|
|
112
|
+
continue;
|
|
113
|
+
const prop = declaration.slice(0, colon).trim().toLowerCase();
|
|
114
|
+
const value = declaration.slice(colon + 1).trim();
|
|
115
|
+
if (prop.length === 0 || value.length === 0)
|
|
116
|
+
continue;
|
|
117
|
+
if (!FIGURE_STYLE_EXACT.has(prop) && !FIGURE_STYLE_PREFIXES.some(prefix => prop.startsWith(prefix)))
|
|
118
|
+
continue;
|
|
119
|
+
// Value gates: no external loads, no script tricks, no pinning/overlay.
|
|
120
|
+
if (/javascript:|expression\s*\(|behavior\s*:/i.test(prop + ':' + value))
|
|
121
|
+
continue;
|
|
122
|
+
if (/url\(/i.test(value) && !svgValueSafe(value))
|
|
123
|
+
continue;
|
|
124
|
+
if (prop === 'position' && !/^(static|relative|absolute)$/i.test(value))
|
|
125
|
+
continue;
|
|
126
|
+
if (prop === 'z-index' && (!/^\d+$/.test(value) || Number(value) > 5))
|
|
127
|
+
continue;
|
|
128
|
+
kept.push(`${prop}: ${value}`);
|
|
129
|
+
}
|
|
130
|
+
return kept.length === 0 ? undefined : kept.join('; ');
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Attributes preserved per element (everything else is stripped).
|
|
134
|
+
*
|
|
135
|
+
* The `img` row documents what an emitted image carries; the img branch of
|
|
136
|
+
* `normalizeNode` builds that tag itself, because the source URL is RESOLVED
|
|
137
|
+
* (lazy-loading attributes, srcset, a picture's sources) rather than copied.
|
|
138
|
+
*/
|
|
139
|
+
const KEPT_ATTRIBUTES = {
|
|
140
|
+
a: ['href'],
|
|
141
|
+
img: ['src', 'alt', 'referrerpolicy'],
|
|
142
|
+
// `alttext` is the formula's plain-text twin (accessibility and a readable
|
|
143
|
+
// fallback), `display` marks block math (the pane scrolls it horizontally).
|
|
144
|
+
// Everything else — event handlers, `href` on <mi>, style hooks — is stripped.
|
|
145
|
+
math: ['alttext', 'display'],
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Where lazy-loading scripts stash the real URL when `src` is a placeholder.
|
|
149
|
+
*
|
|
150
|
+
* Ordered by how often each name carries the article's image; the list is kept
|
|
151
|
+
* tight on purpose — an attribute nobody publishes buys nothing but surface.
|
|
152
|
+
*/
|
|
153
|
+
const LAZY_IMAGE_ATTRIBUTES = ['data-src', 'data-original', 'data-lazy-src', 'data-url', 'data-actualsrc'];
|
|
154
|
+
/** Thresholds tuned so a Chinese and an English article both score their body. */
|
|
155
|
+
const THRESHOLDS = { latin: 40, cjk: 18 };
|
|
156
|
+
/**
|
|
157
|
+
* Extract the article body from a fetched page.
|
|
158
|
+
*
|
|
159
|
+
* @param pageHtml - the raw HTML the host fetched.
|
|
160
|
+
* @param baseUrl - the page URL, used to absolutize relative links and images.
|
|
161
|
+
* @returns the normalized body HTML, or the reason nothing usable was found.
|
|
162
|
+
*/
|
|
163
|
+
export function extractArticle(pageHtml, baseUrl) {
|
|
164
|
+
if (pageHtml.trim().length === 0)
|
|
165
|
+
return { ok: false, error: 'empty page' };
|
|
166
|
+
let doc;
|
|
167
|
+
try {
|
|
168
|
+
doc = new DOMParser().parseFromString(pageHtml, 'text/html');
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
return { ok: false, error: `not parseable as HTML: ${errorMessage(error)}` };
|
|
172
|
+
}
|
|
173
|
+
// `<title>` is in DROP_TAGS, so read the document title before the strip
|
|
174
|
+
// below removes the element it is computed from.
|
|
175
|
+
const docTitle = doc.title ?? '';
|
|
176
|
+
// Strip the noise subtrees BEFORE scoring, so every candidate is measured on
|
|
177
|
+
// the same terms (a sidebar full of links otherwise wins on raw text mass).
|
|
178
|
+
for (const node of Array.from(doc.querySelectorAll([...DROP_TAGS, ...DROP_STRIP_EXTRA].join(',')))) {
|
|
179
|
+
node.remove();
|
|
180
|
+
}
|
|
181
|
+
const scored = scoreCandidates(doc);
|
|
182
|
+
if (scored === undefined) {
|
|
183
|
+
return { ok: false, error: 'no text-bearing block found' };
|
|
184
|
+
}
|
|
185
|
+
const scriptFigures = countScriptFigures(scored.element);
|
|
186
|
+
// Read the title and excerpt BEFORE the chrome trim: the body's own first
|
|
187
|
+
// heading is exactly what the trim removes (the pane renders the title
|
|
188
|
+
// itself), and the excerpt wants the first prose paragraph either way.
|
|
189
|
+
const title = articleTitleOf(scored.element, docTitle);
|
|
190
|
+
const excerpt = articleExcerptOf(scored.element);
|
|
191
|
+
trimLeadingChrome(scored.element, doc);
|
|
192
|
+
// The title block is a custom element, so it has no tag of its own: the
|
|
193
|
+
// normalizer UNWRAPS it and its `<br>`s land at the front of the body. Four
|
|
194
|
+
// blank lines before the first paragraph is not content by any reading, and
|
|
195
|
+
// stripping the head of the string is independent of where they came from.
|
|
196
|
+
const normalized = normalizeElement(scored.element, baseUrl).replace(/^(?:\s|<br[^>]*>)+/i, "");
|
|
197
|
+
const textLength = textLengthOf(scored.element);
|
|
198
|
+
if (textLength < 120) {
|
|
199
|
+
return { ok: false, error: `extracted body is too small (${textLength} chars)` };
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
html: normalized,
|
|
204
|
+
textLength,
|
|
205
|
+
...(title === undefined ? {} : { title }),
|
|
206
|
+
...(excerpt === undefined ? {} : { excerpt }),
|
|
207
|
+
...(scriptFigures === 0 ? {} : { scriptFigures }),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The article's own title, when one is recoverable.
|
|
212
|
+
*
|
|
213
|
+
* The body's first `<h1>` wins (on every measured shape it is the post's name);
|
|
214
|
+
* the document `<title>` is the fallback (it often carries a site-name suffix,
|
|
215
|
+
* which is why it does not lead). Capped and collapsed; empty reads as absent.
|
|
216
|
+
*
|
|
217
|
+
* @param root - the element the body was extracted from (pre-chrome-trim).
|
|
218
|
+
* @param docTitle - the document's `<title>`, read before the noise strip.
|
|
219
|
+
* @returns the title, or `undefined`.
|
|
220
|
+
*/
|
|
221
|
+
function articleTitleOf(root, docTitle) {
|
|
222
|
+
const heading = collapse(root.querySelector('h1')?.textContent ?? '');
|
|
223
|
+
const title = heading.length > 0 ? heading : collapse(docTitle);
|
|
224
|
+
return title.length === 0 ? undefined : truncateText(title, 200);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* A short excerpt for the card: the first paragraph substantial enough to be
|
|
228
|
+
* prose by the scorer's own per-script rule, at the card's measure.
|
|
229
|
+
*
|
|
230
|
+
* @param root - the element the body was extracted from.
|
|
231
|
+
* @returns the excerpt, or `undefined` when no paragraph qualifies.
|
|
232
|
+
*/
|
|
233
|
+
function articleExcerptOf(root) {
|
|
234
|
+
for (const p of Array.from(root.querySelectorAll('p'))) {
|
|
235
|
+
const text = collapse(p.textContent ?? '');
|
|
236
|
+
if (text.length >= paragraphThreshold(text))
|
|
237
|
+
return truncateText(text, 280);
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
/** Truncate plain text at a cap with an ellipsis. */
|
|
242
|
+
function truncateText(text, max) {
|
|
243
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Drop the page's own title block and masthead from the top of the body.
|
|
247
|
+
*
|
|
248
|
+
* Measured on the acceptance instance's transformer-circuits.pub paper: the
|
|
249
|
+
* extracted body opened with an empty logo link, the site name, the article
|
|
250
|
+
* title **twice** (the page carries two `<h1>`s), three `<br>`s, and only then
|
|
251
|
+
* the byline. The detail view already renders the entry title above the body,
|
|
252
|
+
* so the reader saw the same heading three times before any prose — reported as
|
|
253
|
+
* "why does it fetch repeated content at the start".
|
|
254
|
+
*
|
|
255
|
+
* Two narrow rules, both text-based: a top-level heading that repeats the
|
|
256
|
+
* document title (or the body's own first heading) goes, and the leading
|
|
257
|
+
* masthead (links and whitespace before the first heading or real paragraph)
|
|
258
|
+
* goes with it. Anything that carries prose stops the walk, so a byline,
|
|
259
|
+
* abstract or section heading is never touched.
|
|
260
|
+
*
|
|
261
|
+
* @param root - the element the body was extracted from (mutated in place).
|
|
262
|
+
* @param doc - the parsed document, for its title.
|
|
263
|
+
*/
|
|
264
|
+
function trimLeadingChrome(root, doc) {
|
|
265
|
+
const normalize = (value) => value.replace(/\s+/g, ' ').trim();
|
|
266
|
+
const title = normalize(doc.title ?? '');
|
|
267
|
+
const firstHeading = root.querySelector('h1, h2, h3');
|
|
268
|
+
const ownTitle = firstHeading === null ? '' : normalize(firstHeading.textContent ?? '');
|
|
269
|
+
for (const heading of Array.from(root.querySelectorAll('h1, h2'))) {
|
|
270
|
+
const text = normalize(heading.textContent ?? '');
|
|
271
|
+
if (text.length === 0)
|
|
272
|
+
continue;
|
|
273
|
+
if (text === title || (ownTitle.length > 0 && text === ownTitle))
|
|
274
|
+
heading.remove();
|
|
275
|
+
}
|
|
276
|
+
// The masthead walk: drop leading nodes until something that IS content —
|
|
277
|
+
// a stop tag, a picture, or 40+ characters of prose. The site header on the
|
|
278
|
+
// measured page is a `<div>` holding a logo `<svg>` (no img), so pictures are
|
|
279
|
+
// counted by `img`/`figure`, never by `svg` alone.
|
|
280
|
+
let node = root.firstElementChild;
|
|
281
|
+
while (node !== null) {
|
|
282
|
+
const next = node.nextElementSibling;
|
|
283
|
+
const tag = node.localName;
|
|
284
|
+
const text = normalize(node.textContent ?? '');
|
|
285
|
+
if (ROOT_STOPS.has(tag))
|
|
286
|
+
break;
|
|
287
|
+
if (node.querySelector('img, picture, canvas, video, figure') !== null)
|
|
288
|
+
break;
|
|
289
|
+
if (text.length >= 40)
|
|
290
|
+
break;
|
|
291
|
+
node.remove();
|
|
292
|
+
node = next;
|
|
293
|
+
}
|
|
294
|
+
// The page's title block is a custom element (`<d-title>`): it has no tag in
|
|
295
|
+
// the whitelist, so its leftovers are flattened INTO the body — and its own
|
|
296
|
+
// leading `<br>`s then become the body's first nodes. Trim them down the left
|
|
297
|
+
// spine, which is where "the article starts with four blank lines" comes from.
|
|
298
|
+
let spine = root;
|
|
299
|
+
while (spine !== null) {
|
|
300
|
+
let child = spine.firstElementChild;
|
|
301
|
+
while (child !== null && child.localName === 'br') {
|
|
302
|
+
const following = child.nextElementSibling;
|
|
303
|
+
child.remove();
|
|
304
|
+
child = following;
|
|
305
|
+
}
|
|
306
|
+
spine = spine.firstElementChild;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Tags that mean real content has begun; the masthead walk stops there.
|
|
311
|
+
*
|
|
312
|
+
* Deliberately excludes `div`/`section`/`header`: those are exactly the wrappers
|
|
313
|
+
* a masthead hides in, and a wrapper is judged by what it holds instead.
|
|
314
|
+
*/
|
|
315
|
+
const ROOT_STOPS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'blockquote', 'pre', 'ul', 'ol']);
|
|
316
|
+
/**
|
|
317
|
+
* Count the figures whose picture is not in the markup.
|
|
318
|
+
*
|
|
319
|
+
* "No picture" means no `<img>`, `<svg>`, `<canvas>`, `<video>` or `<picture>`
|
|
320
|
+
* anywhere inside: the page renders that illustration at runtime. A `<math>`
|
|
321
|
+
* subtree counts as content — a formula-only figure's picture IS its markup —
|
|
322
|
+
* and so does an image-typed `<object>` (the LaTeXML vector-figure shape).
|
|
323
|
+
* A composite of styled containers carries its content as TEXT (a capture-
|
|
324
|
+
* rendered figure is a diagram plus property cards): enough non-caption words
|
|
325
|
+
* means the figure is here, even with no raster or vector island.
|
|
326
|
+
*
|
|
327
|
+
* The figures themselves are KEPT, caption included. Dropping them was tried
|
|
328
|
+
* and reverted: a caption is text the page published — it says what the figure
|
|
329
|
+
* shows and carries its number — and hiding it turns "this picture cannot be
|
|
330
|
+
* fetched" into "this paragraph lost its data". The count travels to the detail
|
|
331
|
+
* view, which explains once, above the body, why the captions below have no
|
|
332
|
+
* picture under them.
|
|
333
|
+
*
|
|
334
|
+
* @param root - the element the body was extracted from.
|
|
335
|
+
* @returns the number of figures whose picture the fetch could not bring.
|
|
336
|
+
*/
|
|
337
|
+
function countScriptFigures(root) {
|
|
338
|
+
let count = 0;
|
|
339
|
+
for (const figure of Array.from(root.querySelectorAll('figure'))) {
|
|
340
|
+
if (figure.querySelector('img, svg, canvas, video, picture, math, object[type^="image/"]') !== null)
|
|
341
|
+
continue;
|
|
342
|
+
// A composite of styled containers CARRIES its content in the markup (a
|
|
343
|
+
// capture-rendered figure is boxes of text): what is not the caption is
|
|
344
|
+
// the figure's own words, and enough of them means the picture is here.
|
|
345
|
+
const own = Array.from(figure.children)
|
|
346
|
+
.filter(child => child.localName !== 'figcaption')
|
|
347
|
+
.map(child => child.textContent ?? '')
|
|
348
|
+
.join('');
|
|
349
|
+
if (collapse(own).length >= 20)
|
|
350
|
+
continue;
|
|
351
|
+
count += 1;
|
|
352
|
+
}
|
|
353
|
+
return count;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Put trusted-shape markup through the whitelist.
|
|
357
|
+
*
|
|
358
|
+
* Feed bodies take this path: a feed's `content:encoded` is markup from the
|
|
359
|
+
* same publisher as the page, so it earns the same whitelist rather than the
|
|
360
|
+
* extraction heuristic.
|
|
361
|
+
*
|
|
362
|
+
* @param html - markup from a feed or from extraction.
|
|
363
|
+
* @param baseUrl - base for relative URLs (feed bodies usually use absolute ones).
|
|
364
|
+
* @returns whitelisted HTML, or an empty string when nothing survives.
|
|
365
|
+
*/
|
|
366
|
+
export function normalizeRichText(html, baseUrl) {
|
|
367
|
+
if (html.trim().length === 0)
|
|
368
|
+
return '';
|
|
369
|
+
let doc;
|
|
370
|
+
try {
|
|
371
|
+
doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// A non-DOM host (the host half) has no parser: fall back to the
|
|
375
|
+
// text-safe strip so this never throws in an unexpected environment.
|
|
376
|
+
return escapeHtml(html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim());
|
|
377
|
+
}
|
|
378
|
+
const body = doc.body;
|
|
379
|
+
if (body === null)
|
|
380
|
+
return '';
|
|
381
|
+
for (const node of Array.from(body.querySelectorAll([...DROP_TAGS, ...DROP_STRIP_EXTRA].join(',')))) {
|
|
382
|
+
node.remove();
|
|
383
|
+
}
|
|
384
|
+
// Same edge trim as normalizeElement: boundaries at the extremes are dead.
|
|
385
|
+
return normalizeChildren(body, baseUrl).trim();
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Score every plausible body container and return the best one.
|
|
389
|
+
*
|
|
390
|
+
* `undefined` means no container carried paragraph-like text at all.
|
|
391
|
+
*
|
|
392
|
+
* @param doc - the document, already stripped of noise subtrees.
|
|
393
|
+
* @returns the winning candidate.
|
|
394
|
+
*/
|
|
395
|
+
function scoreCandidates(doc) {
|
|
396
|
+
const containers = Array.from(doc.querySelectorAll('div, article, main, section, td, body'));
|
|
397
|
+
let best;
|
|
398
|
+
for (const element of containers) {
|
|
399
|
+
const score = scoreElement(element);
|
|
400
|
+
if (score <= 0)
|
|
401
|
+
continue;
|
|
402
|
+
if (best === undefined || score > best.score)
|
|
403
|
+
best = { element, score };
|
|
404
|
+
}
|
|
405
|
+
return best;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* The readability-style score for one container: paragraph mass, plus a boost
|
|
409
|
+
* for text and images, minus link mass, with `article`/`main` preferred and a
|
|
410
|
+
* wrapper that merely contains a better child demoted.
|
|
411
|
+
*
|
|
412
|
+
* @param element - the candidate container.
|
|
413
|
+
* @returns the score; `0` when the container holds no paragraph-like text.
|
|
414
|
+
*/
|
|
415
|
+
function scoreElement(element) {
|
|
416
|
+
let paragraphMass = 0;
|
|
417
|
+
let paragraphCount = 0;
|
|
418
|
+
let imageCount = 0;
|
|
419
|
+
let linkMass = 0;
|
|
420
|
+
for (const node of Array.from(element.querySelectorAll('p'))) {
|
|
421
|
+
const text = collapse(node.textContent ?? '');
|
|
422
|
+
if (text.length >= paragraphThreshold(text)) {
|
|
423
|
+
paragraphMass += text.length;
|
|
424
|
+
paragraphCount += 1;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (paragraphCount === 0)
|
|
428
|
+
return 0;
|
|
429
|
+
for (const node of Array.from(element.querySelectorAll('a'))) {
|
|
430
|
+
linkMass += collapse(node.textContent ?? '').length;
|
|
431
|
+
}
|
|
432
|
+
for (const node of Array.from(element.querySelectorAll('img'))) {
|
|
433
|
+
// An image counts for a little: article bodies have them, navigation does
|
|
434
|
+
// not. One inside a <noscript> is the no-JS fallback for a picture the
|
|
435
|
+
// markup already holds — counting the duplicate would double the boost.
|
|
436
|
+
if (node.closest('noscript') !== null)
|
|
437
|
+
continue;
|
|
438
|
+
if (node.getAttribute('src') !== null)
|
|
439
|
+
imageCount += 1;
|
|
440
|
+
}
|
|
441
|
+
const isCjk = cjkRatio(collapse(element.textContent ?? '')) > 0.2;
|
|
442
|
+
const bodyLength = collapse(element.textContent ?? '').length;
|
|
443
|
+
let score = paragraphMass + bodyLength * 0.12 + imageCount * 40;
|
|
444
|
+
if (isCjk) {
|
|
445
|
+
// Link-heavy Chinese list posts (weekly digests) inflate link mass far more
|
|
446
|
+
// than their prose does; penalize links the same way but expect a lower
|
|
447
|
+
// paragraph bar, which paragraphThreshold already applies.
|
|
448
|
+
score -= linkMass * 0.75;
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
score -= linkMass * 0.55;
|
|
452
|
+
}
|
|
453
|
+
if (element.localName === 'article')
|
|
454
|
+
score *= 1.3;
|
|
455
|
+
else if (element.localName === 'main')
|
|
456
|
+
score *= 1.15;
|
|
457
|
+
// A wrapper holding a child with most of the same paragraph mass is not the
|
|
458
|
+
// body — it is the page around the body. Demote it so the inner block wins.
|
|
459
|
+
let bestChildMass = 0;
|
|
460
|
+
for (const child of Array.from(element.children)) {
|
|
461
|
+
bestChildMass = Math.max(bestChildMass, paragraphMassOf(child));
|
|
462
|
+
}
|
|
463
|
+
if (bestChildMass > paragraphMass * 0.85)
|
|
464
|
+
score *= 0.55;
|
|
465
|
+
return score;
|
|
466
|
+
}
|
|
467
|
+
/** Paragraph mass alone, for the wrapper-demotion comparison. */
|
|
468
|
+
function paragraphMassOf(element) {
|
|
469
|
+
let mass = 0;
|
|
470
|
+
for (const node of Array.from(element.querySelectorAll('p'))) {
|
|
471
|
+
const text = collapse(node.textContent ?? '');
|
|
472
|
+
if (text.length >= paragraphThreshold(text))
|
|
473
|
+
mass += text.length;
|
|
474
|
+
}
|
|
475
|
+
return mass;
|
|
476
|
+
}
|
|
477
|
+
/** The paragraph bar for a block, chosen by its dominant script. */
|
|
478
|
+
function paragraphThreshold(text) {
|
|
479
|
+
return cjkRatio(text) > 0.2 ? THRESHOLDS.cjk : THRESHOLDS.latin;
|
|
480
|
+
}
|
|
481
|
+
/** Fraction of characters that are CJK ideographs. */
|
|
482
|
+
function cjkRatio(text) {
|
|
483
|
+
if (text.length === 0)
|
|
484
|
+
return 0;
|
|
485
|
+
const cjk = text.match(/[\u3400-\u4dbf\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g);
|
|
486
|
+
return (cjk?.length ?? 0) / text.length;
|
|
487
|
+
}
|
|
488
|
+
/** Visible text length of an element. */
|
|
489
|
+
function textLengthOf(element) {
|
|
490
|
+
return collapse(element.textContent ?? '').length;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Normalize one element's subtree into whitelisted HTML.
|
|
494
|
+
*
|
|
495
|
+
* @param element - the container whose children are normalized.
|
|
496
|
+
* @param baseUrl - base for relative URL resolution.
|
|
497
|
+
* @returns the normalized HTML string.
|
|
498
|
+
*/
|
|
499
|
+
function normalizeElement(element, baseUrl) {
|
|
500
|
+
// Edge trim: the unwrap boundary leaves a dead newline at either extreme,
|
|
501
|
+
// which renders as nothing and would only dirty the stored body's bytes.
|
|
502
|
+
return normalizeChildren(element, baseUrl).trim();
|
|
503
|
+
}
|
|
504
|
+
/** Normalize every child of a node. */
|
|
505
|
+
function normalizeChildren(node, baseUrl, inFigure = false) {
|
|
506
|
+
const parts = [];
|
|
507
|
+
for (const child of Array.from(node.childNodes)) {
|
|
508
|
+
const normalized = normalizeNode(child, baseUrl, inFigure);
|
|
509
|
+
if (normalized.length > 0)
|
|
510
|
+
parts.push(normalized);
|
|
511
|
+
}
|
|
512
|
+
return parts.join('');
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Normalize one node: text is escaped, whitelisted elements are rebuilt with a
|
|
516
|
+
* filtered attribute set, anything else is unwrapped (its children survive) or
|
|
517
|
+
* dropped entirely.
|
|
518
|
+
*
|
|
519
|
+
* @param node - the node to normalize.
|
|
520
|
+
* @param baseUrl - base for relative URL resolution.
|
|
521
|
+
* @returns the normalized HTML for this node.
|
|
522
|
+
*/
|
|
523
|
+
function normalizeNode(node, baseUrl, inFigure = false) {
|
|
524
|
+
if (node.nodeType === 3 /* text */) {
|
|
525
|
+
const text = node.textContent ?? '';
|
|
526
|
+
// Collapse runs of whitespace: HTML source formatting is not content, and
|
|
527
|
+
// the renderer re-wraps lines itself.
|
|
528
|
+
return escapeHtml(text.replace(/\s+/g, ' '));
|
|
529
|
+
}
|
|
530
|
+
if (node.nodeType !== 1 /* element */)
|
|
531
|
+
return '';
|
|
532
|
+
const element = node;
|
|
533
|
+
const tag = element.localName.toLowerCase();
|
|
534
|
+
if (DROP_TAGS.has(tag))
|
|
535
|
+
return '';
|
|
536
|
+
// Cloudflare's email obfuscation: the visible text is a placeholder that its
|
|
537
|
+
// own script decodes at runtime. We never run that script, so keeping the
|
|
538
|
+
// element would ship a literal "[email protected]" into the reader — a real
|
|
539
|
+
// artifact seen in the Chinese fixture. Drop it instead.
|
|
540
|
+
if (element.hasAttribute('data-cfemail') || (element.getAttribute('class') ?? '').includes('__cf_email__'))
|
|
541
|
+
return '';
|
|
542
|
+
if (tag === 'img') {
|
|
543
|
+
const src = imageSrc(element, baseUrl);
|
|
544
|
+
if (src === undefined)
|
|
545
|
+
return '';
|
|
546
|
+
return emitImg(src, element.getAttribute('alt'));
|
|
547
|
+
}
|
|
548
|
+
// LaTeXML embeds vector figures as image-typed objects: an image in every
|
|
549
|
+
// sense that matters here. Any other object (a PDF, a movie) keeps the old
|
|
550
|
+
// DROP behavior — subtree and fallback content alike.
|
|
551
|
+
if (tag === 'object')
|
|
552
|
+
return objectImg(element, baseUrl);
|
|
553
|
+
// A <picture> is one image with several spellings: the first usable <source>
|
|
554
|
+
// srcset, else the fallback <img>. Emitting one normalized <img> is the whole
|
|
555
|
+
// point — the whitelist has no picture/source, so keeping the wrapper would
|
|
556
|
+
// either duplicate the image or lose it.
|
|
557
|
+
if (tag === 'picture')
|
|
558
|
+
return pictureImg(element, baseUrl);
|
|
559
|
+
// The standard no-JS fallback: a lazy-loading script hides the real image in
|
|
560
|
+
// a <noscript>, and a fetch runs no scripts — so the ONLY copy the static
|
|
561
|
+
// markup holds lives here. Recover that image; drop everything else a
|
|
562
|
+
// noscript carries (the "please enable JavaScript" text is not content).
|
|
563
|
+
if (tag === 'noscript')
|
|
564
|
+
return noscriptImg(element, baseUrl);
|
|
565
|
+
// Capture-rendered pages carry their rescued figures as inline SVG with
|
|
566
|
+
// styles inlined onto the elements; the subset walker below keeps the chart
|
|
567
|
+
// and strips the injection-shaped members (script, foreignObject, SMIL).
|
|
568
|
+
if (tag === 'svg')
|
|
569
|
+
return normalizeSvg(element);
|
|
570
|
+
if (tag === 'br')
|
|
571
|
+
return '<br>';
|
|
572
|
+
if (tag === 'hr')
|
|
573
|
+
return '<hr>';
|
|
574
|
+
const inner = normalizeChildren(element, baseUrl, inFigure || tag === 'figure');
|
|
575
|
+
// A figure's composite layout IS its content (a capture-rendered figure is a
|
|
576
|
+
// diagram plus property cards built from styled containers), so inside one
|
|
577
|
+
// the div/span boxes survive as elements with their allowlisted style —
|
|
578
|
+
// everywhere else they unwrap, as before.
|
|
579
|
+
if (inFigure && (tag === 'div' || tag === 'span')) {
|
|
580
|
+
const style = figureStyle(element.getAttribute('style'));
|
|
581
|
+
// A kept container with neither text nor style is a shell, not a layout.
|
|
582
|
+
if (inner.trim().length === 0 && style === undefined)
|
|
583
|
+
return '';
|
|
584
|
+
return `<${tag}${style === undefined ? '' : ` style="${escapeAttribute(style)}"`}>${inner}</${tag}>`;
|
|
585
|
+
}
|
|
586
|
+
if (!KEEP_TAGS.has(tag)) {
|
|
587
|
+
// Unwrapped block-ish containers leave a word boundary: capture's
|
|
588
|
+
// serialization carries no inter-tag whitespace, and two sibling cards
|
|
589
|
+
// must never concatenate into one run of text.
|
|
590
|
+
return UNWRAP_BOUNDARY_TAGS.has(tag) || tag.includes('-') ? `\n${inner}\n` : inner;
|
|
591
|
+
}
|
|
592
|
+
if (tag === 'a') {
|
|
593
|
+
const href = absolutize(element.getAttribute('href'), baseUrl);
|
|
594
|
+
// An anchor without a usable target keeps its text but loses the link, so
|
|
595
|
+
// the renderer never emits a dead or hostile href.
|
|
596
|
+
if (href === undefined)
|
|
597
|
+
return inner;
|
|
598
|
+
return `<a href="${escapeAttribute(href)}" rel="noopener noreferrer" target="_blank">${inner}</a>`;
|
|
599
|
+
}
|
|
600
|
+
const attributes = (KEPT_ATTRIBUTES[tag] ?? [])
|
|
601
|
+
.map(name => {
|
|
602
|
+
const value = element.getAttribute(name);
|
|
603
|
+
return value === null ? '' : ` ${name}="${escapeAttribute(value)}"`;
|
|
604
|
+
})
|
|
605
|
+
.join('');
|
|
606
|
+
return `<${tag}${attributes}>${inner}</${tag}>`;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Serialize one normalized image.
|
|
610
|
+
*
|
|
611
|
+
* `referrerpolicy="no-referrer"` is emitted, never copied: the sites that
|
|
612
|
+
* lazy-load are also the ones whose CDNs answer a hotlink with a 403, and the
|
|
613
|
+
* reader has no page context to refer from anyway.
|
|
614
|
+
*/
|
|
615
|
+
function emitImg(src, alt, dimensions = {}) {
|
|
616
|
+
const width = dimensions.width === undefined ? '' : ` width="${dimensions.width}"`;
|
|
617
|
+
const height = dimensions.height === undefined ? '' : ` height="${dimensions.height}"`;
|
|
618
|
+
return `<img src="${escapeAttribute(src)}"${alt === null ? '' : ` alt="${escapeAttribute(alt)}"`}${width}${height} referrerpolicy="no-referrer">`;
|
|
619
|
+
}
|
|
620
|
+
/** An integer dimension worth carrying onto the emitted image (nothing else is trusted). */
|
|
621
|
+
function dimensionAttr(value) {
|
|
622
|
+
if (value === null)
|
|
623
|
+
return undefined;
|
|
624
|
+
const trimmed = value.trim();
|
|
625
|
+
return /^[1-9]\d{0,4}$/.test(trimmed) ? trimmed : undefined;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Resolve an `<object>` to a normalized `<img>`, or to nothing.
|
|
629
|
+
*
|
|
630
|
+
* Only `type="image/…"` objects with a fetchable `data` address qualify — that
|
|
631
|
+
* is the LaTeXML figure shape (`<object type="image/svg+xml" data="fig.svg"
|
|
632
|
+
* width height>`). The page's own width/height ride along when they are plain
|
|
633
|
+
* integers: an image with known dimensions is the difference between a stable
|
|
634
|
+
* layout and the growing document the reading-position note is about. The
|
|
635
|
+
* fallback children an object may carry are dropped with it — they duplicate
|
|
636
|
+
* the caption, not the picture.
|
|
637
|
+
*/
|
|
638
|
+
function objectImg(element, baseUrl) {
|
|
639
|
+
const type = (element.getAttribute('type') ?? '').trim();
|
|
640
|
+
if (!/^image\//i.test(type))
|
|
641
|
+
return '';
|
|
642
|
+
const src = absolutize(element.getAttribute('data'), baseUrl);
|
|
643
|
+
if (src === undefined)
|
|
644
|
+
return '';
|
|
645
|
+
const width = dimensionAttr(element.getAttribute('width'));
|
|
646
|
+
const height = dimensionAttr(element.getAttribute('height'));
|
|
647
|
+
return emitImg(src, element.getAttribute('alt'), {
|
|
648
|
+
...(width === undefined ? {} : { width }),
|
|
649
|
+
...(height === undefined ? {} : { height }),
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
/* --------------------------------------------------------- the SVG subset */
|
|
653
|
+
/**
|
|
654
|
+
* Inline SVG: the chart markup a capture-rendered page carries.
|
|
655
|
+
*
|
|
656
|
+
* The capture package (the ingest proposal's M1) returns the RENDERED page:
|
|
657
|
+
* figures the origin site paints with scripts arrive as inline SVG with the
|
|
658
|
+
* computed styles inlined onto the elements. Keeping a static subset is what
|
|
659
|
+
* lets those figures into the body at all. The rules:
|
|
660
|
+
*
|
|
661
|
+
* - a fixed element set (shapes, text, defs/clip/mask/pattern, gradients,
|
|
662
|
+
* `use`/`symbol`), looked up case-insensitively and emitted in the canonical
|
|
663
|
+
* SVG casing — the HTML parser's foreign-content adjustment already gave us
|
|
664
|
+
* that casing, and the emitted string is re-parsed as HTML downstream;
|
|
665
|
+
* - `<script>`, `<foreignObject>` (the HTML-injection vector inside SVG) and
|
|
666
|
+
* every SMIL element (`animate`, `set`, …) drop with their subtrees — a
|
|
667
|
+
* static figure needs none of them;
|
|
668
|
+
* - attributes come from one whitelist (geometry, presentation, the inlined
|
|
669
|
+
* `style`, paint-server/clip refs, and `id` — `url(#…)` and `href="#…"` point
|
|
670
|
+
* at it), and any value containing `url(` must reference a same-document
|
|
671
|
+
* fragment, so no external paint server, font or pixel loads;
|
|
672
|
+
* - `use`/`textPath` keep `href` only when it IS a fragment (`#…`) — an
|
|
673
|
+
* external reference drops the element (and legacy `xlink:href` normalizes
|
|
674
|
+
* to `href`).
|
|
675
|
+
*
|
|
676
|
+
* No per-element size cap: the body's own size machinery (the fetch cap, the
|
|
677
|
+
* sidecar threshold) bounds the whole markup, and a chart's `<path>` data is
|
|
678
|
+
* the content the cap exists to bound.
|
|
679
|
+
*/
|
|
680
|
+
/** Canonical SVG names by lowercase form (the parser adjusts them; garbage may not). */
|
|
681
|
+
const SVG_TAGS = new Map([
|
|
682
|
+
'svg', 'g', 'path', 'circle', 'ellipse', 'rect', 'line', 'polyline', 'polygon',
|
|
683
|
+
'text', 'tspan', 'textPath', 'defs', 'clipPath', 'mask', 'pattern',
|
|
684
|
+
'linearGradient', 'radialGradient', 'stop', 'use', 'symbol', 'marker', 'desc',
|
|
685
|
+
].map(name => [name.toLowerCase(), name]));
|
|
686
|
+
/** Subtree-dropped inside SVG: script-capable or animated. (`title` collides with the HTML drop.) */
|
|
687
|
+
const SVG_DROP = new Set(['script', 'foreignobject', 'animate', 'animatemotion', 'animatetransform', 'set']);
|
|
688
|
+
/** Attributes kept on SVG elements; everything else (event handlers included) is stripped. */
|
|
689
|
+
const SVG_ATTRIBUTES = [
|
|
690
|
+
'id',
|
|
691
|
+
// geometry
|
|
692
|
+
'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'points',
|
|
693
|
+
'width', 'height', 'viewBox', 'transform', 'dx', 'dy',
|
|
694
|
+
// presentation
|
|
695
|
+
'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-linecap',
|
|
696
|
+
'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-miterlimit',
|
|
697
|
+
'opacity', 'fill-rule', 'clip-rule', 'text-anchor', 'font-size', 'font-family',
|
|
698
|
+
'font-weight', 'font-style', 'dominant-baseline', 'textLength', 'letter-spacing',
|
|
699
|
+
// the capture pipeline's inlined computed style (CSS is non-executable)
|
|
700
|
+
'style',
|
|
701
|
+
// references and paint servers
|
|
702
|
+
'clip-path', 'mask', 'offset', 'stop-color', 'stop-opacity',
|
|
703
|
+
'gradientUnits', 'gradientTransform', 'patternUnits', 'patternTransform',
|
|
704
|
+
'patternContentUnits', 'spreadMethod', 'preserveAspectRatio', 'startOffset',
|
|
705
|
+
'markerWidth', 'markerHeight', 'markerUnits', 'orient', 'refX', 'refY',
|
|
706
|
+
'marker-start', 'marker-mid', 'marker-end',
|
|
707
|
+
];
|
|
708
|
+
/** A kept value may only reference this same document: every `url(` must open a `#` fragment. */
|
|
709
|
+
function svgValueSafe(value) {
|
|
710
|
+
const refs = value.match(/url\(/gi);
|
|
711
|
+
if (refs === null)
|
|
712
|
+
return true;
|
|
713
|
+
return refs.length === (value.match(/url\(\s*['"]?#/gi) ?? []).length;
|
|
714
|
+
}
|
|
715
|
+
/** The whitelisted attributes of one SVG element, in the whitelist's order. */
|
|
716
|
+
function svgAttributes(element) {
|
|
717
|
+
const parts = [];
|
|
718
|
+
for (const name of SVG_ATTRIBUTES) {
|
|
719
|
+
const value = element.getAttribute(name);
|
|
720
|
+
if (value === null || !svgValueSafe(value))
|
|
721
|
+
continue;
|
|
722
|
+
parts.push(` ${name}="${escapeAttribute(value)}"`);
|
|
723
|
+
}
|
|
724
|
+
return parts.join('');
|
|
725
|
+
}
|
|
726
|
+
/** One SVG subtree → whitelisted markup (or empty). */
|
|
727
|
+
function normalizeSvg(element) {
|
|
728
|
+
const name = element.localName.toLowerCase();
|
|
729
|
+
if (SVG_DROP.has(name))
|
|
730
|
+
return '';
|
|
731
|
+
const canonical = SVG_TAGS.get(name);
|
|
732
|
+
if (canonical === undefined)
|
|
733
|
+
return normalizeSvgChildren(element);
|
|
734
|
+
if (canonical === 'use' || canonical === 'textPath') {
|
|
735
|
+
// A fragment reference only: an external one drops the element outright.
|
|
736
|
+
const href = (element.getAttribute('href') ?? element.getAttribute('xlink:href'))?.trim();
|
|
737
|
+
if (href === undefined || !/^#\S+$/.test(href))
|
|
738
|
+
return '';
|
|
739
|
+
return `<${canonical} href="${escapeAttribute(href)}"${svgAttributes(element)}>${normalizeSvgChildren(element)}</${canonical}>`;
|
|
740
|
+
}
|
|
741
|
+
return `<${canonical}${svgAttributes(element)}>${normalizeSvgChildren(element)}</${canonical}>`;
|
|
742
|
+
}
|
|
743
|
+
/** Every child of an SVG node: text is escaped, elements walk the subset. */
|
|
744
|
+
function normalizeSvgChildren(element) {
|
|
745
|
+
const parts = [];
|
|
746
|
+
for (const child of Array.from(element.childNodes)) {
|
|
747
|
+
if (child.nodeType === 3 /* text */) {
|
|
748
|
+
parts.push(escapeHtml((child.textContent ?? '').replace(/\s+/g, ' ')));
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (child.nodeType !== 1 /* element */)
|
|
752
|
+
continue;
|
|
753
|
+
parts.push(normalizeSvg(child));
|
|
754
|
+
}
|
|
755
|
+
return parts.join('');
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* The payload size that separates a real inlined figure from a 1px tracking
|
|
759
|
+
* placeholder. A transparent-gif placeholder is ~70 characters of base64; the
|
|
760
|
+
* smallest real chart is thousands — the threshold sits far from both, so no
|
|
761
|
+
* borderline page ever decides anything. (transformer-circuits.pub inlines its
|
|
762
|
+
* real figures as multi-hundred-KB data URIs; the old all-data:-is-placeholder
|
|
763
|
+
* rule deleted them.)
|
|
764
|
+
*/
|
|
765
|
+
export const DATA_IMAGE_MIN_PAYLOAD = 512;
|
|
766
|
+
/** The image types a kept data: URI may declare. */
|
|
767
|
+
const DATA_IMAGE_MIME = /^image\/(?:png|jpe?g|gif|webp|svg\+xml|avif)$/i;
|
|
768
|
+
/**
|
|
769
|
+
* A `data:` URI worth keeping as an image, or `undefined` (which reads as
|
|
770
|
+
* ABSENT — the caller falls through to the lazy-loading attributes).
|
|
771
|
+
*
|
|
772
|
+
* The gate is MIME plus payload size; base64 payloads are whitespace-stripped
|
|
773
|
+
* because pages line-wrap them (a raw newline inside an emitted attribute is
|
|
774
|
+
* legal HTML, but the stripped form is what browsers parse either way).
|
|
775
|
+
*/
|
|
776
|
+
export function substantiveDataImage(value) {
|
|
777
|
+
const match = /^\s*data:([^;,]+)((?:;[^;,]+)*),([\s\S]*)$/i.exec(value);
|
|
778
|
+
if (match === null)
|
|
779
|
+
return undefined;
|
|
780
|
+
const [, mime, parameters, payload] = match;
|
|
781
|
+
if (mime === undefined || payload === undefined || !DATA_IMAGE_MIME.test(mime))
|
|
782
|
+
return undefined;
|
|
783
|
+
const body = /;base64/i.test(parameters ?? '') ? payload.replace(/\s+/g, '') : payload;
|
|
784
|
+
if (body.length < DATA_IMAGE_MIN_PAYLOAD)
|
|
785
|
+
return undefined;
|
|
786
|
+
return `data:${mime}${parameters ?? ''},${body}`;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Resolve the URL an image actually loads, past the lazy-loading tricks.
|
|
790
|
+
*
|
|
791
|
+
* The order is the order of trust: a real `src` first (a `data:` URI there is
|
|
792
|
+
* the classic 1px placeholder and counts as ABSENT — UNLESS it is a substantive
|
|
793
|
+
* inlined image, see {@link substantiveDataImage}), then the lazy-loading
|
|
794
|
+
* attributes, then the best `srcset` candidate. Only when nothing usable
|
|
795
|
+
* remains is the image dropped.
|
|
796
|
+
*
|
|
797
|
+
* @param element - the `<img>` element.
|
|
798
|
+
* @param baseUrl - base for relative URL resolution.
|
|
799
|
+
* @returns the absolute URL, or `undefined`.
|
|
800
|
+
*/
|
|
801
|
+
function imageSrc(element, baseUrl) {
|
|
802
|
+
const src = element.getAttribute('src');
|
|
803
|
+
if (src !== null) {
|
|
804
|
+
if (/^\s*data:/i.test(src)) {
|
|
805
|
+
const inlined = substantiveDataImage(src);
|
|
806
|
+
if (inlined !== undefined)
|
|
807
|
+
return inlined;
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
const resolved = absolutize(src, baseUrl);
|
|
811
|
+
if (resolved !== undefined)
|
|
812
|
+
return resolved;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
for (const name of LAZY_IMAGE_ATTRIBUTES) {
|
|
816
|
+
const resolved = absolutize(element.getAttribute(name), baseUrl);
|
|
817
|
+
if (resolved !== undefined)
|
|
818
|
+
return resolved;
|
|
819
|
+
}
|
|
820
|
+
return bestSrcset(element.getAttribute('srcset'), baseUrl);
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* The largest candidate of a `srcset` list.
|
|
824
|
+
*
|
|
825
|
+
* Candidates are ranked by their descriptor's number — `800w` beats `400w`,
|
|
826
|
+
* `2x` beats `1x`, a bare URL is the smallest. The two descriptor kinds are
|
|
827
|
+
* never meaningfully mixed in one list, so one ranking serves both.
|
|
828
|
+
*
|
|
829
|
+
* @param value - the srcset attribute, or null.
|
|
830
|
+
* @param baseUrl - base for relative URL resolution.
|
|
831
|
+
* @returns the absolute URL of the biggest candidate, or `undefined`.
|
|
832
|
+
*/
|
|
833
|
+
function bestSrcset(value, baseUrl) {
|
|
834
|
+
if (value === null)
|
|
835
|
+
return undefined;
|
|
836
|
+
const candidates = [];
|
|
837
|
+
for (const piece of value.split(',')) {
|
|
838
|
+
const previous = candidates[candidates.length - 1];
|
|
839
|
+
// A data: URL carries a comma of its own (`data:image/png;base64,…`), so a
|
|
840
|
+
// naive split cuts it in two — and the payload tail then parses as a
|
|
841
|
+
// candidate that can WIN. Rejoin the halves: the whole candidate is then
|
|
842
|
+
// judged by the same gate as any src (a substantive inlined image is kept,
|
|
843
|
+
// a placeholder is not), but its tail must never stand alone.
|
|
844
|
+
if (previous !== undefined && /^\s*data:[^,]*$/i.test(previous)) {
|
|
845
|
+
candidates[candidates.length - 1] = `${previous},${piece}`;
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
candidates.push(piece);
|
|
849
|
+
}
|
|
850
|
+
let best;
|
|
851
|
+
for (const candidate of candidates) {
|
|
852
|
+
const parts = candidate.trim().split(/\s+/);
|
|
853
|
+
const raw = parts[0];
|
|
854
|
+
if (raw === undefined || raw === '')
|
|
855
|
+
continue;
|
|
856
|
+
// A data: candidate is judged by the same gate as a data: src.
|
|
857
|
+
const url = /^\s*data:/i.test(raw) ? substantiveDataImage(raw) : absolutize(raw, baseUrl);
|
|
858
|
+
if (url === undefined)
|
|
859
|
+
continue;
|
|
860
|
+
const descriptor = parts[1];
|
|
861
|
+
const size = descriptor === undefined ? Number.NaN : Number.parseFloat(descriptor);
|
|
862
|
+
const score = Number.isFinite(size) ? size : 0;
|
|
863
|
+
if (best === undefined || score > best.score)
|
|
864
|
+
best = { url, score };
|
|
865
|
+
}
|
|
866
|
+
return best?.url;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Resolve a `<picture>` to its one normalized `<img>`, or to nothing.
|
|
870
|
+
*
|
|
871
|
+
* @param element - the `<picture>` element.
|
|
872
|
+
* @param baseUrl - base for relative URL resolution.
|
|
873
|
+
* @returns the normalized image HTML, or an empty string.
|
|
874
|
+
*/
|
|
875
|
+
function pictureImg(element, baseUrl) {
|
|
876
|
+
const fallback = Array.from(element.children).find(child => child.localName === 'img');
|
|
877
|
+
const alt = fallback?.getAttribute('alt') ?? null;
|
|
878
|
+
for (const source of Array.from(element.children)) {
|
|
879
|
+
if (source.localName !== 'source')
|
|
880
|
+
continue;
|
|
881
|
+
const url = bestSrcset(source.getAttribute('srcset'), baseUrl);
|
|
882
|
+
if (url !== undefined)
|
|
883
|
+
return emitImg(url, alt);
|
|
884
|
+
}
|
|
885
|
+
if (fallback !== undefined) {
|
|
886
|
+
const url = imageSrc(fallback, baseUrl);
|
|
887
|
+
if (url !== undefined)
|
|
888
|
+
return emitImg(url, alt);
|
|
889
|
+
}
|
|
890
|
+
return '';
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Recover the no-JS fallback image a `<noscript>` carries, when it carries one.
|
|
894
|
+
*
|
|
895
|
+
* A fetched page parses with scripting disabled, so the content is usually
|
|
896
|
+
* already elements; a parse with scripting on holds the same markup as TEXT
|
|
897
|
+
* instead, and then it is parsed here a second time. Either way, anything that
|
|
898
|
+
* is not a usable image is dropped, exactly as before.
|
|
899
|
+
*
|
|
900
|
+
* @param element - the `<noscript>` element.
|
|
901
|
+
* @param baseUrl - base for relative URL resolution.
|
|
902
|
+
* @returns the normalized image HTML, or an empty string.
|
|
903
|
+
*/
|
|
904
|
+
function noscriptImg(element, baseUrl) {
|
|
905
|
+
let img = element.querySelector('img');
|
|
906
|
+
if (img === null) {
|
|
907
|
+
const text = element.textContent ?? '';
|
|
908
|
+
if (!/<img[\s/>]/i.test(text))
|
|
909
|
+
return '';
|
|
910
|
+
try {
|
|
911
|
+
img = new DOMParser().parseFromString(`<body>${text}</body>`, 'text/html').querySelector('img');
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
return '';
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (img === null)
|
|
918
|
+
return '';
|
|
919
|
+
const src = imageSrc(img, baseUrl);
|
|
920
|
+
if (src === undefined)
|
|
921
|
+
return '';
|
|
922
|
+
return emitImg(src, img.getAttribute('alt'));
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Turn an inline-only rendering of a node into plain text-safe markup. Feed
|
|
926
|
+
* summaries use this shape when the caller wants emphasis but no blocks.
|
|
927
|
+
*
|
|
928
|
+
* @param html - markup.
|
|
929
|
+
* @returns normalized inline markup.
|
|
930
|
+
*/
|
|
931
|
+
export function normalizeInline(html) {
|
|
932
|
+
let doc;
|
|
933
|
+
try {
|
|
934
|
+
doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
return escapeHtml(html.replace(/<[^>]*>/g, ''));
|
|
938
|
+
}
|
|
939
|
+
const body = doc.body;
|
|
940
|
+
if (body === null)
|
|
941
|
+
return '';
|
|
942
|
+
return serializeInline(body).trim();
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Serialize a node's children as inline-only markup: text is escaped, inline
|
|
946
|
+
* elements are rebuilt, a `<br>` becomes a space, and every block element is
|
|
947
|
+
* flattened (its content survives, its structure does not).
|
|
948
|
+
*
|
|
949
|
+
* @param node - the node whose children are serialized.
|
|
950
|
+
* @returns inline markup.
|
|
951
|
+
*/
|
|
952
|
+
function serializeInline(node) {
|
|
953
|
+
let out = '';
|
|
954
|
+
for (const child of Array.from(node.childNodes)) {
|
|
955
|
+
if (child.nodeType === 3 /* text */) {
|
|
956
|
+
out += escapeHtml((child.textContent ?? '').replace(/\s+/g, ' '));
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
if (child.nodeType !== 1 /* element */)
|
|
960
|
+
continue;
|
|
961
|
+
const tag = child.localName.toLowerCase();
|
|
962
|
+
if (DROP_TAGS.has(tag))
|
|
963
|
+
continue;
|
|
964
|
+
// A noscript's fallback image means nothing in an inline summary; the rest
|
|
965
|
+
// of its content ("please enable JavaScript") was never content at all.
|
|
966
|
+
// An object's fallback text and a chart's text labels are the same chrome.
|
|
967
|
+
if (tag === 'noscript' || tag === 'object' || tag === 'svg')
|
|
968
|
+
continue;
|
|
969
|
+
if (tag === 'br') {
|
|
970
|
+
out += ' ';
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
const inner = serializeInline(child);
|
|
974
|
+
// Blocks and links are flattened: the summary carries the words, not the
|
|
975
|
+
// structure. Emphasis survives because it is cheap and often meaningful.
|
|
976
|
+
// The trailing space is the word boundary the block used to mark — two
|
|
977
|
+
// flattened siblings must never read as one word (the composite-figure bug).
|
|
978
|
+
if (!INLINE_TAGS.has(tag) || tag === 'a') {
|
|
979
|
+
out += `${inner} `;
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
out += `<${tag}>${inner}</${tag}>`;
|
|
983
|
+
}
|
|
984
|
+
return out;
|
|
985
|
+
}
|
|
986
|
+
/** Resolve a possibly-relative URL against the page URL; reject unsafe schemes. */
|
|
987
|
+
function absolutize(value, baseUrl) {
|
|
988
|
+
if (value === null)
|
|
989
|
+
return undefined;
|
|
990
|
+
const trimmed = value.trim();
|
|
991
|
+
if (trimmed.length === 0)
|
|
992
|
+
return undefined;
|
|
993
|
+
if (/^(javascript|data|vbscript|file):/i.test(trimmed))
|
|
994
|
+
return undefined;
|
|
995
|
+
if (/^https?:\/\//i.test(trimmed))
|
|
996
|
+
return trimmed;
|
|
997
|
+
if (baseUrl === undefined)
|
|
998
|
+
return undefined;
|
|
999
|
+
try {
|
|
1000
|
+
return new URL(trimmed, baseUrl).toString();
|
|
1001
|
+
}
|
|
1002
|
+
catch {
|
|
1003
|
+
return undefined;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
/** Collapse whitespace and trim. */
|
|
1007
|
+
function collapse(text) {
|
|
1008
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
1009
|
+
}
|
|
1010
|
+
/** Escape text for HTML output. */
|
|
1011
|
+
function escapeHtml(text) {
|
|
1012
|
+
return text
|
|
1013
|
+
.replace(/&/g, '&')
|
|
1014
|
+
.replace(/</g, '<')
|
|
1015
|
+
.replace(/>/g, '>');
|
|
1016
|
+
}
|
|
1017
|
+
/** Escape a value for a double-quoted HTML attribute. */
|
|
1018
|
+
function escapeAttribute(value) {
|
|
1019
|
+
return escapeHtml(value).replace(/"/g, '"');
|
|
1020
|
+
}
|
|
1021
|
+
/** Coerce an unknown thrown value into a message (duplicated to keep this leaf module dependency-free). */
|
|
1022
|
+
function errorMessage(error) {
|
|
1023
|
+
return error instanceof Error ? error.message : String(error);
|
|
1024
|
+
}
|