@khorsheed/dsh-reader 0.3.0 → 0.3.1
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/CHANGELOG.md +22 -0
- package/README.en.md +26 -121
- package/README.i18n.yaml +3 -3
- package/README.md +26 -121
- package/cordis.patch.yml +7 -0
- package/lib/client.js +29054 -6698
- package/lib/index.js +3416 -334
- package/lib/typert.host.js +2145 -259
- package/lib/typert.remote-client.d.ts +97 -3
- package/lib/typert.remote-client.js +1590 -250
- package/lib/types/captions.d.ts +99 -0
- package/lib/types/captions.js +236 -0
- package/lib/types/client/MediaPlayer.d.ts +55 -0
- package/lib/types/client/MediaPlayer.js +106 -0
- package/lib/types/client/NotesView.d.ts +57 -0
- package/lib/types/client/NotesView.js +124 -0
- package/lib/types/client/ReaderPane.d.ts +20 -3
- package/lib/types/client/ReaderPane.js +860 -164
- package/lib/types/client/SpeakerLegend.d.ts +18 -0
- package/lib/types/client/SpeakerLegend.js +26 -0
- package/lib/types/client/canvas-sync.d.ts +84 -0
- package/lib/types/client/canvas-sync.js +59 -0
- package/lib/types/client/contract.d.ts +130 -3
- package/lib/types/client/extract-article.d.ts +38 -1
- package/lib/types/client/extract-article.js +501 -28
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/icons.js +1 -1
- package/lib/types/client/index.js +81 -2
- package/lib/types/client/locales.d.ts +279 -11
- package/lib/types/client/locales.js +278 -10
- package/lib/types/client/notes.d.ts +142 -0
- package/lib/types/client/notes.js +334 -0
- package/lib/types/client/parse-rss.js +40 -0
- package/lib/types/client/preset-visibility.d.ts +93 -0
- package/lib/types/client/preset-visibility.js +115 -0
- package/lib/types/client/selectors.d.ts +5 -0
- package/lib/types/client/selectors.js +5 -0
- package/lib/types/client/session.d.ts +21 -1
- package/lib/types/client/session.js +12 -0
- package/lib/types/client/store.js +4 -1
- package/lib/types/client/translate.d.ts +26 -1
- package/lib/types/client/translate.js +29 -12
- package/lib/types/client/useLocalTranscript.d.ts +43 -0
- package/lib/types/client/useLocalTranscript.js +129 -0
- package/lib/types/client/useMedia.d.ts +68 -0
- package/lib/types/client/useMedia.js +299 -0
- package/lib/types/client/useMediaStates.d.ts +9 -0
- package/lib/types/client/useMediaStates.js +43 -0
- package/lib/types/client/useNotes.d.ts +110 -0
- package/lib/types/client/useNotes.js +423 -0
- package/lib/types/client/usePolish.d.ts +47 -0
- package/lib/types/client/usePolish.js +162 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/index.js +3 -0
- package/lib/types/local-transcribe.d.ts +171 -0
- package/lib/types/local-transcribe.js +473 -0
- package/lib/types/media-host.d.ts +91 -0
- package/lib/types/media-host.js +230 -0
- package/lib/types/media.d.ts +100 -0
- package/lib/types/media.js +207 -0
- package/lib/types/notes-types.d.ts +158 -0
- package/lib/types/notes-types.js +8 -0
- package/lib/types/notes.d.ts +213 -0
- package/lib/types/notes.js +718 -0
- package/lib/types/polish.d.ts +41 -0
- package/lib/types/polish.js +136 -0
- package/lib/types/remote.d.ts +118 -0
- package/lib/types/remote.js +151 -1
- package/lib/types/service.d.ts +71 -1
- package/lib/types/service.js +280 -59
- package/lib/types/skill.d.ts +20 -0
- package/lib/types/skill.js +78 -0
- package/lib/types/store.js +30 -8
- package/lib/types/tool.d.ts +23 -0
- package/lib/types/tool.js +40 -0
- package/lib/types/tools.d.ts +69 -0
- package/lib/types/tools.js +400 -0
- package/lib/types/types.d.ts +87 -0
- package/lib/types/types.js +66 -0
- package/lib/types/xiaoyuzhou.d.ts +43 -0
- package/lib/types/xiaoyuzhou.js +134 -0
- package/lib/types/ytdlp.d.ts +111 -0
- package/lib/types/ytdlp.js +269 -0
- package/package.json +29 -20
- package/lib/invariant.js +0 -38
- package/lib/types/invariant.d.ts +0 -29
- package/lib/types/invariant.js +0 -38
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
* - {@link normalizeRichText} takes markup that is ALREADY known to be a body
|
|
15
15
|
* (a feed's `content:encoded`, or the block extraction selected) and puts it
|
|
16
16
|
* through a tag whitelist, so the renderer never sees third-party scripts,
|
|
17
|
-
* styles, forms or event handlers.
|
|
17
|
+
* styles, forms or event handlers. The one sanctioned exception: a content
|
|
18
|
+
* embed (`embedElement`) survives as a sandboxed iframe — `allow-scripts`
|
|
19
|
+
* without `allow-same-origin`, an opaque origin that cannot see the pane —
|
|
20
|
+
* or as a native controls-only media element.
|
|
18
21
|
*
|
|
19
22
|
* Everything runs in the browser because the host runtime has no DOM parser at
|
|
20
23
|
* all; nothing here touches the network.
|
|
@@ -55,16 +58,21 @@ const KEEP_TAGS = new Set([
|
|
|
55
58
|
*/
|
|
56
59
|
const DROP_TAGS = new Set([
|
|
57
60
|
'script', 'style', 'template', 'nav', 'header', 'footer', 'aside',
|
|
58
|
-
'form', '
|
|
59
|
-
'
|
|
61
|
+
'form', 'annotation-xml', 'button', 'select', 'input', 'textarea',
|
|
62
|
+
'canvas', 'embed', 'link', 'meta', 'base', 'title',
|
|
63
|
+
// iframe/video/audio are NOT here: the embed is content the page published,
|
|
64
|
+
// and normalizeNode keeps it (see embedElement) — a sandboxed opaque-origin
|
|
65
|
+
// frame for an iframe, a native controls player for media. Trackers
|
|
66
|
+
// (hidden, pixel-sized, untitled-and-unsized iframes) stay dropped there.
|
|
60
67
|
// svg is NOT here: capture-rendered pages carry their rescued figures as
|
|
61
68
|
// inline SVG, which normalizeSvg() whitelists per element/attribute. The
|
|
62
69
|
// strip's selector gains the canonical-case members separately (SVG names are
|
|
63
|
-
// case-sensitive in an HTML document).
|
|
64
|
-
'
|
|
70
|
+
// case-sensitive in an HTML document). `foreignObject` is not here either: its
|
|
71
|
+
// HTML is a figure's labels, and normalizeSvg() walks it through this file's
|
|
72
|
+
// own whitelist.
|
|
65
73
|
]);
|
|
66
74
|
/** The strip selector's exact-case members (SVG camelCase names in an HTML document). */
|
|
67
|
-
const DROP_STRIP_EXTRA = [
|
|
75
|
+
const DROP_STRIP_EXTRA = [];
|
|
68
76
|
/** Inline elements that survive an inline-only pass. */
|
|
69
77
|
const INLINE_TAGS = new Set(['strong', 'em', 'b', 'i', 'u', 's', 'code', 'a', 'sub', 'sup', 'br']);
|
|
70
78
|
/**
|
|
@@ -143,6 +151,9 @@ const KEPT_ATTRIBUTES = {
|
|
|
143
151
|
// fallback), `display` marks block math (the pane scrolls it horizontally).
|
|
144
152
|
// Everything else — event handlers, `href` on <mi>, style hooks — is stripped.
|
|
145
153
|
math: ['alttext', 'display'],
|
|
154
|
+
// Set by settleListLabels(): the item writes its own label, so the pane
|
|
155
|
+
// hides the list's native marker for it.
|
|
156
|
+
li: ['data-labelled'],
|
|
146
157
|
};
|
|
147
158
|
/**
|
|
148
159
|
* Where lazy-loading scripts stash the real URL when `src` is a placeholder.
|
|
@@ -178,6 +189,8 @@ export function extractArticle(pageHtml, baseUrl) {
|
|
|
178
189
|
for (const node of Array.from(doc.querySelectorAll([...DROP_TAGS, ...DROP_STRIP_EXTRA].join(',')))) {
|
|
179
190
|
node.remove();
|
|
180
191
|
}
|
|
192
|
+
settleMathJaxTwins(doc.body);
|
|
193
|
+
settleListLabels(doc.body);
|
|
181
194
|
const scored = scoreCandidates(doc);
|
|
182
195
|
if (scored === undefined) {
|
|
183
196
|
return { ok: false, error: 'no text-bearing block found' };
|
|
@@ -189,6 +202,9 @@ export function extractArticle(pageHtml, baseUrl) {
|
|
|
189
202
|
const title = articleTitleOf(scored.element, docTitle);
|
|
190
203
|
const excerpt = articleExcerptOf(scored.element);
|
|
191
204
|
trimLeadingChrome(scored.element, doc);
|
|
205
|
+
// …and the lead image comes in AFTER the trim: grafted first, it would
|
|
206
|
+
// stop the masthead walk at its own border and keep the junk behind it.
|
|
207
|
+
graftLeadingFigures(doc, scored.element, baseUrl);
|
|
192
208
|
// The title block is a custom element, so it has no tag of its own: the
|
|
193
209
|
// normalizer UNWRAPS it and its `<br>`s land at the front of the body. Four
|
|
194
210
|
// blank lines before the first paragraph is not content by any reading, and
|
|
@@ -284,7 +300,12 @@ function trimLeadingChrome(root, doc) {
|
|
|
284
300
|
const text = normalize(node.textContent ?? '');
|
|
285
301
|
if (ROOT_STOPS.has(tag))
|
|
286
302
|
break;
|
|
287
|
-
|
|
303
|
+
// A picture or an embed stops the walk whether it IS this node or sits
|
|
304
|
+
// inside it — a bare leading <img> has no img DESCENDANT, and dropping it
|
|
305
|
+
// as "masthead" is how a text-column-less cover used to vanish.
|
|
306
|
+
if (tag === 'img' || tag === 'picture' || tag === 'figure' || tag === 'video' || tag === 'iframe' || tag === 'audio')
|
|
307
|
+
break;
|
|
308
|
+
if (node.querySelector('img, picture, canvas, video, figure, iframe, audio') !== null)
|
|
288
309
|
break;
|
|
289
310
|
if (text.length >= 40)
|
|
290
311
|
break;
|
|
@@ -313,13 +334,98 @@ function trimLeadingChrome(root, doc) {
|
|
|
313
334
|
* a masthead hides in, and a wrapper is judged by what it holds instead.
|
|
314
335
|
*/
|
|
315
336
|
const ROOT_STOPS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'blockquote', 'pre', 'ul', 'ol']);
|
|
337
|
+
/**
|
|
338
|
+
* The most lead images one graft brings in. A hero is one picture; the second
|
|
339
|
+
* slot covers the two-image masthead (a logo plate beside the cover). Anything
|
|
340
|
+
* beyond that is a gallery the page put before its prose, not a lead.
|
|
341
|
+
*/
|
|
342
|
+
const LEAD_GRAFT_MAX = 2;
|
|
343
|
+
/**
|
|
344
|
+
* The declared width that separates a lead image from an icon. SSR'd hero
|
|
345
|
+
* markup carries the design width (Next.js writes `width`/`height` on every
|
|
346
|
+
* `<img>` it optimizes); an icon or avatar that omits it, or declares a small
|
|
347
|
+
* one, is not the article's cover by any reading.
|
|
348
|
+
*/
|
|
349
|
+
const LEAD_IMAGE_MIN_WIDTH = 480;
|
|
350
|
+
/**
|
|
351
|
+
* Bring the page's lead image into the body when the scorer left it outside.
|
|
352
|
+
*
|
|
353
|
+
* The scorer keeps ONE container — the text-heavy block — and on magazine-style
|
|
354
|
+
* templates (Anthropic's research posts, measured 2026-10-14) the cover art
|
|
355
|
+
* sits in a sibling wrapper BEFORE that block: inside the `<article>` but not
|
|
356
|
+
* inside the winner, so the extracted body came back with zero images on a
|
|
357
|
+
* page whose hero was the one picture it had.
|
|
358
|
+
*
|
|
359
|
+
* Only what PRECEDES the winner qualifies: what follows the text is a
|
|
360
|
+
* related-stories grid, and a figure already inside the winner needs nothing.
|
|
361
|
+
* A `<figure>`/`<picture>` qualifies by markup alone (a figure IS content); a
|
|
362
|
+
* bare `<img>` must also declare a hero-sized width, or every site's top-bar
|
|
363
|
+
* logo would ride in. Grafting MOVES the elements, so the images normalize
|
|
364
|
+
* through the same resolver as everything else the body carries.
|
|
365
|
+
*
|
|
366
|
+
* @param doc - the parsed document (already stripped of noise subtrees).
|
|
367
|
+
* @param winner - the container the scorer picked.
|
|
368
|
+
* @param baseUrl - the page URL, for resolving candidate sources.
|
|
369
|
+
*/
|
|
370
|
+
function graftLeadingFigures(doc, winner, baseUrl) {
|
|
371
|
+
// The search scope: the OUTERMOST article/main ancestor, not the nearest —
|
|
372
|
+
// templates nest them (`main > article > wrapper > article > text column`),
|
|
373
|
+
// and the lead image sits under the outer one, beside the wrapper.
|
|
374
|
+
let scope = null;
|
|
375
|
+
for (let el = winner.parentElement; el !== null && el !== doc.body; el = el.parentElement) {
|
|
376
|
+
if (el.localName === 'article' || el.localName === 'main')
|
|
377
|
+
scope = el;
|
|
378
|
+
}
|
|
379
|
+
scope ??= doc.body;
|
|
380
|
+
if (scope === null || scope === winner)
|
|
381
|
+
return;
|
|
382
|
+
const grafted = [];
|
|
383
|
+
for (const element of Array.from(scope.querySelectorAll('figure, picture, img'))) {
|
|
384
|
+
if (grafted.length >= LEAD_GRAFT_MAX)
|
|
385
|
+
break;
|
|
386
|
+
if (winner.contains(element))
|
|
387
|
+
continue;
|
|
388
|
+
// `winner` FOLLOWING the element means the element precedes the body.
|
|
389
|
+
if ((element.compareDocumentPosition(winner) & 4 /* Node.DOCUMENT_POSITION_FOLLOWING */) === 0)
|
|
390
|
+
continue;
|
|
391
|
+
const tag = element.localName;
|
|
392
|
+
if (tag === 'figure') {
|
|
393
|
+
const img = element.querySelector('img');
|
|
394
|
+
if (img === null || imageSrc(img, baseUrl) === undefined)
|
|
395
|
+
continue;
|
|
396
|
+
grafted.push(element);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (tag === 'picture') {
|
|
400
|
+
// A figure's own picture rides the figure (it came first in the walk).
|
|
401
|
+
if (element.closest('figure') !== null)
|
|
402
|
+
continue;
|
|
403
|
+
if (pictureImg(element, baseUrl).length === 0)
|
|
404
|
+
continue;
|
|
405
|
+
grafted.push(element);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
// A bare <img>: covered when a figure/picture ancestor carries it.
|
|
409
|
+
if (element.closest('figure, picture') !== null)
|
|
410
|
+
continue;
|
|
411
|
+
const width = dimensionAttr(element.getAttribute('width'));
|
|
412
|
+
if (width === undefined || Number(width) < LEAD_IMAGE_MIN_WIDTH)
|
|
413
|
+
continue;
|
|
414
|
+
if (imageSrc(element, baseUrl) === undefined)
|
|
415
|
+
continue;
|
|
416
|
+
grafted.push(element);
|
|
417
|
+
}
|
|
418
|
+
for (const element of grafted.reverse())
|
|
419
|
+
winner.prepend(element);
|
|
420
|
+
}
|
|
316
421
|
/**
|
|
317
422
|
* Count the figures whose picture is not in the markup.
|
|
318
423
|
*
|
|
319
424
|
* "No picture" means no `<img>`, `<svg>`, `<canvas>`, `<video>` or `<picture>`
|
|
320
425
|
* anywhere inside: the page renders that illustration at runtime. A `<math>`
|
|
321
426
|
* 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)
|
|
427
|
+
* and so does an image-typed `<object>` (the LaTeXML vector-figure shape) and
|
|
428
|
+
* an iframe/audio embed, which normalizeNode turns into a link that opens it.
|
|
323
429
|
* A composite of styled containers carries its content as TEXT (a capture-
|
|
324
430
|
* rendered figure is a diagram plus property cards): enough non-caption words
|
|
325
431
|
* means the figure is here, even with no raster or vector island.
|
|
@@ -337,7 +443,7 @@ const ROOT_STOPS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'b
|
|
|
337
443
|
function countScriptFigures(root) {
|
|
338
444
|
let count = 0;
|
|
339
445
|
for (const figure of Array.from(root.querySelectorAll('figure'))) {
|
|
340
|
-
if (figure.querySelector('img, svg, canvas, video, picture, math, object[type^="image/"]') !== null)
|
|
446
|
+
if (figure.querySelector('img, svg, canvas, video, picture, math, object[type^="image/"], iframe[src], audio') !== null)
|
|
341
447
|
continue;
|
|
342
448
|
// A composite of styled containers CARRIES its content in the markup (a
|
|
343
449
|
// capture-rendered figure is boxes of text): what is not the caption is
|
|
@@ -556,6 +662,13 @@ function normalizeNode(node, baseUrl, inFigure = false) {
|
|
|
556
662
|
// either duplicate the image or lose it.
|
|
557
663
|
if (tag === 'picture')
|
|
558
664
|
return pictureImg(element, baseUrl);
|
|
665
|
+
// A video/audio file or an embedded live frame (Anthropic's research posts
|
|
666
|
+
// carry their charts as sandboxed iframe artifacts — 16 of them on one page)
|
|
667
|
+
// cannot PLAY here, but it is content the page published at that spot:
|
|
668
|
+
// leave a link that opens it where it renders, or the section reads as if
|
|
669
|
+
// the picture it discusses never existed.
|
|
670
|
+
if (tag === 'iframe' || tag === 'video' || tag === 'audio')
|
|
671
|
+
return embedElement(tag, element, baseUrl);
|
|
559
672
|
// The standard no-JS fallback: a lazy-loading script hides the real image in
|
|
560
673
|
// a <noscript>, and a fetch runs no scripts — so the ONLY copy the static
|
|
561
674
|
// markup holds lives here. Recover that image; drop everything else a
|
|
@@ -564,14 +677,19 @@ function normalizeNode(node, baseUrl, inFigure = false) {
|
|
|
564
677
|
return noscriptImg(element, baseUrl);
|
|
565
678
|
// Capture-rendered pages carry their rescued figures as inline SVG with
|
|
566
679
|
// styles inlined onto the elements; the subset walker below keeps the chart
|
|
567
|
-
// and strips the injection-shaped members (script, foreignObject
|
|
680
|
+
// and strips the injection-shaped members (script, SMIL) and whitelists foreignObject labels.
|
|
568
681
|
if (tag === 'svg')
|
|
569
|
-
return normalizeSvg(element);
|
|
682
|
+
return isResponsiveAlternate(element) ? '' : normalizeSvg(element, baseUrl);
|
|
570
683
|
if (tag === 'br')
|
|
571
684
|
return '<br>';
|
|
572
685
|
if (tag === 'hr')
|
|
573
686
|
return '<hr>';
|
|
574
|
-
const
|
|
687
|
+
const latexml = tag === 'span' ? latexmlSpan(element) : undefined;
|
|
688
|
+
let inner = normalizeChildren(element, baseUrl, inFigure || tag === 'figure');
|
|
689
|
+
if (latexml?.bold === true && inner.trim().length > 0)
|
|
690
|
+
inner = `<strong>${inner}</strong>`;
|
|
691
|
+
if (latexml?.italic === true && inner.trim().length > 0)
|
|
692
|
+
inner = `<em>${inner}</em>`;
|
|
575
693
|
// A figure's composite layout IS its content (a capture-rendered figure is a
|
|
576
694
|
// diagram plus property cards built from styled containers), so inside one
|
|
577
695
|
// the div/span boxes survive as elements with their allowlisted style —
|
|
@@ -581,8 +699,16 @@ function normalizeNode(node, baseUrl, inFigure = false) {
|
|
|
581
699
|
// A kept container with neither text nor style is a shell, not a layout.
|
|
582
700
|
if (inner.trim().length === 0 && style === undefined)
|
|
583
701
|
return '';
|
|
584
|
-
|
|
702
|
+
// A bare span is no layout either: it renders exactly as its content.
|
|
703
|
+
if (tag === 'span' && style === undefined && latexml?.paragraph !== true)
|
|
704
|
+
return inner;
|
|
705
|
+
// A LaTeXML paragraph span is a block in the page's stylesheet: kept as a
|
|
706
|
+
// span, a figure label's lines would run together.
|
|
707
|
+
const kept = latexml?.paragraph === true ? 'div' : tag;
|
|
708
|
+
return `<${kept}${style === undefined ? '' : ` style="${escapeAttribute(style)}"`}>${inner}</${kept}>`;
|
|
585
709
|
}
|
|
710
|
+
if (latexml?.paragraph === true)
|
|
711
|
+
return `\n${inner}\n`;
|
|
586
712
|
if (!KEEP_TAGS.has(tag)) {
|
|
587
713
|
// Unwrapped block-ish containers leave a word boundary: capture's
|
|
588
714
|
// serialization carries no inter-tag whitespace, and two sibling cards
|
|
@@ -605,6 +731,89 @@ function normalizeNode(node, baseUrl, inFigure = false) {
|
|
|
605
731
|
.join('');
|
|
606
732
|
return `<${tag}${attributes}>${inner}</${tag}>`;
|
|
607
733
|
}
|
|
734
|
+
/**
|
|
735
|
+
* What a LaTeXML (arXiv HTML) span means through its classes alone.
|
|
736
|
+
*
|
|
737
|
+
* LaTeXML writes block paragraphs and font changes as classed spans and lets
|
|
738
|
+
* its stylesheet do the rest; the extraction carries no stylesheet, so the
|
|
739
|
+
* meaning is read here or lost.
|
|
740
|
+
*/
|
|
741
|
+
function latexmlSpan(element) {
|
|
742
|
+
const classes = element.getAttribute('class') ?? '';
|
|
743
|
+
if (!classes.includes('ltx_'))
|
|
744
|
+
return undefined;
|
|
745
|
+
return {
|
|
746
|
+
paragraph: /\bltx_p\b/.test(classes),
|
|
747
|
+
bold: /\bltx_font_bold\b/.test(classes),
|
|
748
|
+
italic: /\bltx_font_italic\b/.test(classes),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
/** A list item label the list's native disc already draws. */
|
|
752
|
+
const BULLET_LABEL = /^[•∙◦▪▫‣⁃·–—*-]$/;
|
|
753
|
+
/** An item label worth keeping: `1.`, `(2)`, `a)`, `(iv)`. */
|
|
754
|
+
const ITEM_LABEL = /^(?:\(?\d{1,3}[.)]|\(\d{1,3}\)|\(?[a-z]{1,2}[.)]|\([a-z]{1,2}\)|\(?[ivxlc]{1,6}[.)]|\([ivxlc]{1,6}\))$/i;
|
|
755
|
+
/**
|
|
756
|
+
* Settle list items that write their own label in front of a block.
|
|
757
|
+
*
|
|
758
|
+
* LaTeXML (arXiv HTML) turns the native marker off and writes each label as a
|
|
759
|
+
* text tag before the item's paragraph: `<li><span>•</span> <div><p>…`. Without
|
|
760
|
+
* the page's stylesheet the native disc comes back next to the written one and
|
|
761
|
+
* the paragraph drops to the line below. A bare bullet is removed (the disc
|
|
762
|
+
* stands for it); a real label (`(a)`, `2.`) moves into the first paragraph and
|
|
763
|
+
* the item is marked `data-labelled` so the pane hides its native marker.
|
|
764
|
+
*
|
|
765
|
+
* Matched by structure, not class, so it settles stored bodies too: the label
|
|
766
|
+
* is the item's whole leading text, and a paragraph block follows it.
|
|
767
|
+
*
|
|
768
|
+
* @param root - the subtree to settle in place.
|
|
769
|
+
* @returns true when anything changed.
|
|
770
|
+
*/
|
|
771
|
+
function settleListLabels(root) {
|
|
772
|
+
let changed = false;
|
|
773
|
+
for (const item of Array.from(root.querySelectorAll('li'))) {
|
|
774
|
+
const label = firstMeaningful(item.firstChild);
|
|
775
|
+
if (label === null)
|
|
776
|
+
continue;
|
|
777
|
+
if (label.nodeType === 1) {
|
|
778
|
+
const element = label;
|
|
779
|
+
if (element.localName.toLowerCase() !== 'span' || element.children.length > 0)
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
else if (label.nodeType !== 3) {
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const block = firstMeaningful(label.nextSibling);
|
|
786
|
+
if (block === null || block.nodeType !== 1)
|
|
787
|
+
continue;
|
|
788
|
+
const blockTag = block.localName.toLowerCase();
|
|
789
|
+
if (blockTag !== 'p' && blockTag !== 'div')
|
|
790
|
+
continue;
|
|
791
|
+
const paragraph = blockTag === 'p' ? block : block.querySelector('p');
|
|
792
|
+
if (paragraph === null)
|
|
793
|
+
continue;
|
|
794
|
+
const text = (label.textContent ?? '').trim();
|
|
795
|
+
if (BULLET_LABEL.test(text)) {
|
|
796
|
+
label.remove();
|
|
797
|
+
}
|
|
798
|
+
else if (ITEM_LABEL.test(text)) {
|
|
799
|
+
label.remove();
|
|
800
|
+
paragraph.prepend(`${text} `);
|
|
801
|
+
item.setAttribute('data-labelled', '');
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
changed = true;
|
|
807
|
+
}
|
|
808
|
+
return changed;
|
|
809
|
+
}
|
|
810
|
+
/** The node itself or the first following sibling that is not blank text. */
|
|
811
|
+
function firstMeaningful(node) {
|
|
812
|
+
let current = node;
|
|
813
|
+
while (current !== null && current.nodeType === 3 && (current.textContent ?? '').trim().length === 0)
|
|
814
|
+
current = current.nextSibling;
|
|
815
|
+
return current;
|
|
816
|
+
}
|
|
608
817
|
/**
|
|
609
818
|
* Serialize one normalized image.
|
|
610
819
|
*
|
|
@@ -649,6 +858,77 @@ function objectImg(element, baseUrl) {
|
|
|
649
858
|
...(height === undefined ? {} : { height }),
|
|
650
859
|
});
|
|
651
860
|
}
|
|
861
|
+
/**
|
|
862
|
+
* An embed, kept: a sandboxed frame or a native media element.
|
|
863
|
+
*
|
|
864
|
+
* The pane used to drop iframes wholesale, then (2026-10-18, same report)
|
|
865
|
+
* left a placeholder link — the honest floor. But the framing check says the
|
|
866
|
+
* embeds readers actually hit (Anthropic's artifact charts, 16 on one page)
|
|
867
|
+
* carry no X-Frame-Options/frame-ancestors, so they CAN render here — the same
|
|
868
|
+
* way the source page renders them: sandboxed, `allow-scripts` and never
|
|
869
|
+
* `allow-same-origin`, so the frame's scripts run on an opaque origin with no
|
|
870
|
+
* sight of this page, no popups, no top navigation. The caption link stays as
|
|
871
|
+
* the way out (a frame that fails to load still names itself).
|
|
872
|
+
*
|
|
873
|
+
* Sizing comes from the page's own layout: the wrapper's aspect box
|
|
874
|
+
* (`padding-bottom: N%`, the Anthropic/ArtifactEmbed shape), else the frame's
|
|
875
|
+
* declared dimensions, else the pane's default height.
|
|
876
|
+
*
|
|
877
|
+
* The gate is what keeps trackers from ever GETTING a frame: a hidden or
|
|
878
|
+
* pixel-sized embed, or an iframe with no title AND no content-sized
|
|
879
|
+
* dimensions (a 200px floor — every measured content embed clears it, every
|
|
880
|
+
* measured tracker does not), stays dropped. Video/audio become native
|
|
881
|
+
* `controls` players with `preload="none"` — media elements run no scripts,
|
|
882
|
+
* and nothing downloads until the reader presses play.
|
|
883
|
+
*
|
|
884
|
+
* @param tag - the embed's tag (`iframe`, `video`, `audio`).
|
|
885
|
+
* @param element - the embed element.
|
|
886
|
+
* @param baseUrl - base for relative URL resolution.
|
|
887
|
+
* @returns the embed markup, or an empty string.
|
|
888
|
+
*/
|
|
889
|
+
function embedElement(tag, element, baseUrl) {
|
|
890
|
+
const style = element.getAttribute('style') ?? '';
|
|
891
|
+
if (/display\s*:\s*none|visibility\s*:\s*hidden/i.test(style))
|
|
892
|
+
return '';
|
|
893
|
+
const width = dimensionAttr(element.getAttribute('width'));
|
|
894
|
+
const height = dimensionAttr(element.getAttribute('height'));
|
|
895
|
+
if ((width !== undefined && Number(width) <= 2) || (height !== undefined && Number(height) <= 2))
|
|
896
|
+
return '';
|
|
897
|
+
const src = absolutize(element.getAttribute('src'), baseUrl)
|
|
898
|
+
?? absolutize(element.querySelector('source')?.getAttribute('src') ?? null, baseUrl);
|
|
899
|
+
if (src === undefined)
|
|
900
|
+
return '';
|
|
901
|
+
const title = (element.getAttribute('title') ?? '').trim();
|
|
902
|
+
if (tag === 'iframe' && title.length === 0
|
|
903
|
+
&& (width === undefined || Number(width) < 200 || height === undefined || Number(height) < 200))
|
|
904
|
+
return '';
|
|
905
|
+
let label = title;
|
|
906
|
+
if (label.length === 0) {
|
|
907
|
+
try {
|
|
908
|
+
label = new URL(src).host;
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
return '';
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
if (tag === 'video' || tag === 'audio') {
|
|
915
|
+
const poster = tag === 'video' ? absolutize(element.getAttribute('poster'), baseUrl) : undefined;
|
|
916
|
+
return `<${tag} controls preload="none" src="${escapeAttribute(src)}"${poster === undefined ? '' : ` poster="${escapeAttribute(poster)}"`}></${tag}>`;
|
|
917
|
+
}
|
|
918
|
+
let sizing = '';
|
|
919
|
+
const wrapper = element.parentElement?.getAttribute('style') ?? '';
|
|
920
|
+
const aspect = /padding-bottom\s*:\s*([\d.]+)%/i.exec(wrapper);
|
|
921
|
+
if (aspect !== null) {
|
|
922
|
+
const padding = Number(aspect[1]);
|
|
923
|
+
if (padding > 5 && padding < 2000)
|
|
924
|
+
sizing = ` style="aspect-ratio: ${(100 / padding).toFixed(4)}"`;
|
|
925
|
+
}
|
|
926
|
+
else if (width !== undefined && height !== undefined) {
|
|
927
|
+
sizing = ` style="aspect-ratio: ${width} / ${height}"`;
|
|
928
|
+
}
|
|
929
|
+
return `<div data-reader-embed="iframe"><iframe src="${escapeAttribute(src)}" title="${escapeAttribute(label)}" sandbox="allow-scripts" loading="lazy" referrerpolicy="no-referrer"${sizing}></iframe>`
|
|
930
|
+
+ `<a href="${escapeAttribute(src)}" rel="noopener noreferrer" target="_blank">${escapeHtml(label)}</a></div>`;
|
|
931
|
+
}
|
|
652
932
|
/* --------------------------------------------------------- the SVG subset */
|
|
653
933
|
/**
|
|
654
934
|
* Inline SVG: the chart markup a capture-rendered page carries.
|
|
@@ -662,9 +942,9 @@ function objectImg(element, baseUrl) {
|
|
|
662
942
|
* `use`/`symbol`), looked up case-insensitively and emitted in the canonical
|
|
663
943
|
* SVG casing — the HTML parser's foreign-content adjustment already gave us
|
|
664
944
|
* that casing, and the emitted string is re-parsed as HTML downstream;
|
|
665
|
-
* - `<script
|
|
666
|
-
*
|
|
667
|
-
*
|
|
945
|
+
* - `<script>` and every SMIL element (`animate`, `set`, …) drop with their
|
|
946
|
+
* subtrees — a static figure needs none of them; `<foreignObject>` keeps
|
|
947
|
+
* only what the HTML whitelist keeps (diagram labels live there);
|
|
668
948
|
* - attributes come from one whitelist (geometry, presentation, the inlined
|
|
669
949
|
* `style`, paint-server/clip refs, and `id` — `url(#…)` and `href="#…"` point
|
|
670
950
|
* at it), and any value containing `url(` must reference a same-document
|
|
@@ -684,7 +964,7 @@ const SVG_TAGS = new Map([
|
|
|
684
964
|
'linearGradient', 'radialGradient', 'stop', 'use', 'symbol', 'marker', 'desc',
|
|
685
965
|
].map(name => [name.toLowerCase(), name]));
|
|
686
966
|
/** Subtree-dropped inside SVG: script-capable or animated. (`title` collides with the HTML drop.) */
|
|
687
|
-
const SVG_DROP = new Set(['script', '
|
|
967
|
+
const SVG_DROP = new Set(['script', 'animate', 'animatemotion', 'animatetransform', 'set']);
|
|
688
968
|
/** Attributes kept on SVG elements; everything else (event handlers included) is stripped. */
|
|
689
969
|
const SVG_ATTRIBUTES = [
|
|
690
970
|
'id',
|
|
@@ -704,6 +984,8 @@ const SVG_ATTRIBUTES = [
|
|
|
704
984
|
'patternContentUnits', 'spreadMethod', 'preserveAspectRatio', 'startOffset',
|
|
705
985
|
'markerWidth', 'markerHeight', 'markerUnits', 'orient', 'refX', 'refY',
|
|
706
986
|
'marker-start', 'marker-mid', 'marker-end',
|
|
987
|
+
// a foreignObject label box may spill past its nominal size (LaTeXML sets it)
|
|
988
|
+
'overflow',
|
|
707
989
|
];
|
|
708
990
|
/** A kept value may only reference this same document: every `url(` must open a `#` fragment. */
|
|
709
991
|
function svgValueSafe(value) {
|
|
@@ -719,29 +1001,215 @@ function svgAttributes(element) {
|
|
|
719
1001
|
const value = element.getAttribute(name);
|
|
720
1002
|
if (value === null || !svgValueSafe(value))
|
|
721
1003
|
continue;
|
|
722
|
-
parts.push(` ${name}="${escapeAttribute(value)}"`);
|
|
1004
|
+
parts.push(` ${name}="${escapeAttribute(resolveSvgVars(value))}"`);
|
|
723
1005
|
}
|
|
724
1006
|
return parts.join('');
|
|
725
1007
|
}
|
|
1008
|
+
/** Custom-property names that read as the page's own ground, not as ink. */
|
|
1009
|
+
const SVG_GROUND_VAR = /(^|-)(bg|background|ground|surface|paper|canvas|page)(-|$)/i;
|
|
1010
|
+
/**
|
|
1011
|
+
* Rewrite every `var(--…)` in a kept SVG value to something that paints here.
|
|
1012
|
+
*
|
|
1013
|
+
* A statically served chart colors itself through the PAGE's custom
|
|
1014
|
+
* properties (`style="stroke:var(--line)"`), defined in a stylesheet the
|
|
1015
|
+
* extraction never carries. Left as-is, each reference resolves against the
|
|
1016
|
+
* reader's document instead: an undefined one drops the stroke (an axis-less
|
|
1017
|
+
* scatter of black dots — claude.dev's Figure 1), and a name that collides
|
|
1018
|
+
* with a host token paints in a color nobody chose. So no reference survives:
|
|
1019
|
+
* a declared fallback wins, a ground-like name becomes `none` (callout discs
|
|
1020
|
+
* stop hiding what lies under them), and every other one becomes
|
|
1021
|
+
* `currentColor` — the article's ink, legible in both themes. A
|
|
1022
|
+
* capture-rendered page never reaches this: its styles arrive computed.
|
|
1023
|
+
*
|
|
1024
|
+
* @param value - one attribute value.
|
|
1025
|
+
* @returns the value with no custom-property reference left.
|
|
1026
|
+
*/
|
|
1027
|
+
export function resolveSvgVars(value) {
|
|
1028
|
+
let out = value;
|
|
1029
|
+
for (let guard = 0; guard < 32; guard++) {
|
|
1030
|
+
const start = out.search(/var\(/i);
|
|
1031
|
+
if (start < 0)
|
|
1032
|
+
return out;
|
|
1033
|
+
// The matching paren, and the first top-level comma inside it.
|
|
1034
|
+
let depth = 0;
|
|
1035
|
+
let comma = -1;
|
|
1036
|
+
let end = -1;
|
|
1037
|
+
for (let index = start + 3; index < out.length; index++) {
|
|
1038
|
+
const char = out[index];
|
|
1039
|
+
if (char === '(')
|
|
1040
|
+
depth++;
|
|
1041
|
+
else if (char === ')') {
|
|
1042
|
+
depth--;
|
|
1043
|
+
if (depth === 0) {
|
|
1044
|
+
end = index;
|
|
1045
|
+
break;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
else if (char === ',' && depth === 1 && comma < 0)
|
|
1049
|
+
comma = index;
|
|
1050
|
+
}
|
|
1051
|
+
if (end < 0)
|
|
1052
|
+
return out.slice(0, start);
|
|
1053
|
+
const name = out.slice(start + 4, comma < 0 ? end : comma).trim();
|
|
1054
|
+
const fallback = comma < 0 ? '' : out.slice(comma + 1, end).trim();
|
|
1055
|
+
const replacement = fallback.length > 0
|
|
1056
|
+
? fallback
|
|
1057
|
+
: SVG_GROUND_VAR.test(name.replace(/^--/, '')) ? 'none' : 'currentColor';
|
|
1058
|
+
out = `${out.slice(0, start)}${replacement}${out.slice(end + 1)}`;
|
|
1059
|
+
}
|
|
1060
|
+
return out;
|
|
1061
|
+
}
|
|
1062
|
+
/** Id/class tokens that mark an `<svg>` as the small-screen copy of a figure. */
|
|
1063
|
+
const SVG_ALTERNATE_TOKEN = /^(mobile|narrow|small|compact|sm|xs)$/i;
|
|
1064
|
+
/** Whether an element's id or class names it a small-screen variant. */
|
|
1065
|
+
function namesAlternate(element) {
|
|
1066
|
+
const names = `${element.getAttribute('id') ?? ''} ${element.getAttribute('class') ?? ''}`;
|
|
1067
|
+
return names.split(/[\s_-]+/).some(token => SVG_ALTERNATE_TOKEN.test(token));
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Whether this `<svg>` is the small-screen duplicate of a sibling figure.
|
|
1071
|
+
*
|
|
1072
|
+
* Responsive pages ship one chart twice — a wide drawing and a narrow one —
|
|
1073
|
+
* and let a media query show one. The extraction carries no stylesheet, so
|
|
1074
|
+
* both would render back to back. The narrow copy drops only when a sibling
|
|
1075
|
+
* `<svg>` NOT marked narrow is there to stand for the figure.
|
|
1076
|
+
*
|
|
1077
|
+
* @param element - a top-level `<svg>`.
|
|
1078
|
+
* @returns true when the element is the duplicate to drop.
|
|
1079
|
+
*/
|
|
1080
|
+
function isResponsiveAlternate(element) {
|
|
1081
|
+
if (!namesAlternate(element))
|
|
1082
|
+
return false;
|
|
1083
|
+
const parent = element.parentElement;
|
|
1084
|
+
if (parent === null)
|
|
1085
|
+
return false;
|
|
1086
|
+
return Array.from(parent.children).some(sibling => sibling !== element && sibling.localName.toLowerCase() === 'svg' && !namesAlternate(sibling));
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Bring a body stored by an older extractor up to the current rules.
|
|
1090
|
+
*
|
|
1091
|
+
* Bodies are extracted once and cached, so the fixes above that can be
|
|
1092
|
+
* replayed on whitelisted markup (`resolveSvgVars`, `isResponsiveAlternate`,
|
|
1093
|
+
* `settleMathJaxTwins`, `settleListLabels`) would otherwise reach only
|
|
1094
|
+
* articles fetched after they shipped — a cached claude.dev post kept drawing
|
|
1095
|
+
* its figure as bare dots until its TTL ran out. This replays them at display
|
|
1096
|
+
* time. A body with no `<svg>` or `<li>`, or one the current extractor wrote,
|
|
1097
|
+
* comes back as the same string. (What the old extractor DROPPED — a
|
|
1098
|
+
* foreignObject's labels — is not here to restore; that takes a refetch.)
|
|
1099
|
+
*
|
|
1100
|
+
* @param html - a stored (already whitelisted) article body.
|
|
1101
|
+
* @returns the body under the current rules.
|
|
1102
|
+
*/
|
|
1103
|
+
export function repairStoredBody(html) {
|
|
1104
|
+
if (!/<(?:svg|li)[\s>]/i.test(html) || typeof DOMParser === 'undefined')
|
|
1105
|
+
return html;
|
|
1106
|
+
let doc;
|
|
1107
|
+
try {
|
|
1108
|
+
doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
|
1109
|
+
}
|
|
1110
|
+
catch {
|
|
1111
|
+
return html;
|
|
1112
|
+
}
|
|
1113
|
+
let changed = settleMathJaxTwins(doc.body);
|
|
1114
|
+
if (settleListLabels(doc.body))
|
|
1115
|
+
changed = true;
|
|
1116
|
+
for (const svg of Array.from(doc.body.querySelectorAll('svg'))) {
|
|
1117
|
+
// Nested drawings ride their top-level figure's pass.
|
|
1118
|
+
if ((svg.parentElement?.closest('svg') ?? null) !== null)
|
|
1119
|
+
continue;
|
|
1120
|
+
if (isResponsiveAlternate(svg)) {
|
|
1121
|
+
svg.remove();
|
|
1122
|
+
changed = true;
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
for (const element of [svg, ...Array.from(svg.querySelectorAll('*'))]) {
|
|
1126
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
1127
|
+
if (!/var\(/i.test(attribute.value))
|
|
1128
|
+
continue;
|
|
1129
|
+
element.setAttribute(attribute.name, resolveSvgVars(attribute.value));
|
|
1130
|
+
changed = true;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return changed ? doc.body.innerHTML : html;
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Keep MathJax's MathML copy of a formula and drop its glyph drawing.
|
|
1138
|
+
*
|
|
1139
|
+
* MathJax 2's SVG output renders each formula twice: an `<svg>` assembled
|
|
1140
|
+
* from `<use href="#MJ…">` references into a page-global glyph table, then
|
|
1141
|
+
* the same formula as MathML in a visually-hidden "assistive" span. The
|
|
1142
|
+
* extraction never carries that glyph table, so the drawing degrades to its
|
|
1143
|
+
* fraction bars — stray lines and blank gaps — while the hidden MathML is
|
|
1144
|
+
* the one copy that can still render. The `<svg>` drops, the MathML takes its
|
|
1145
|
+
* place out of its 1px clip wrapper, and the TeX escapes MathJax would have
|
|
1146
|
+
* drawn as bare characters (`\#` → `#`) are undone in its text runs.
|
|
1147
|
+
*
|
|
1148
|
+
* Runs at extraction and again at display time over stored bodies, so it is
|
|
1149
|
+
* idempotent: a settled body has no glyph-reference `<svg>` left to match.
|
|
1150
|
+
*
|
|
1151
|
+
* @param root - the subtree to settle in place.
|
|
1152
|
+
* @returns true when anything changed.
|
|
1153
|
+
*/
|
|
1154
|
+
function settleMathJaxTwins(root) {
|
|
1155
|
+
let changed = false;
|
|
1156
|
+
for (const svg of Array.from(root.querySelectorAll('svg'))) {
|
|
1157
|
+
if (!drawsMathJaxGlyphs(svg))
|
|
1158
|
+
continue;
|
|
1159
|
+
const twin = svg.nextElementSibling;
|
|
1160
|
+
if (twin === null)
|
|
1161
|
+
continue;
|
|
1162
|
+
let math = null;
|
|
1163
|
+
if (twin.localName.toLowerCase() === 'math') {
|
|
1164
|
+
math = twin;
|
|
1165
|
+
}
|
|
1166
|
+
else if (twin.children.length === 1 && twin.children[0].localName.toLowerCase() === 'math') {
|
|
1167
|
+
math = twin.children[0];
|
|
1168
|
+
twin.replaceWith(math);
|
|
1169
|
+
}
|
|
1170
|
+
if (math === null)
|
|
1171
|
+
continue;
|
|
1172
|
+
svg.remove();
|
|
1173
|
+
for (const text of Array.from(math.querySelectorAll('mtext'))) {
|
|
1174
|
+
const value = text.textContent ?? '';
|
|
1175
|
+
const unescaped = value.replace(/\\([#%&$_{}])/g, '$1');
|
|
1176
|
+
if (unescaped !== value)
|
|
1177
|
+
text.textContent = unescaped;
|
|
1178
|
+
}
|
|
1179
|
+
changed = true;
|
|
1180
|
+
}
|
|
1181
|
+
return changed;
|
|
1182
|
+
}
|
|
1183
|
+
/** Whether an `<svg>` is drawn from MathJax's page-global glyph table. */
|
|
1184
|
+
function drawsMathJaxGlyphs(svg) {
|
|
1185
|
+
return Array.from(svg.querySelectorAll('use')).some(use => /^#MJ/.test(use.getAttribute('href') ?? use.getAttribute('xlink:href') ?? ''));
|
|
1186
|
+
}
|
|
726
1187
|
/** One SVG subtree → whitelisted markup (or empty). */
|
|
727
|
-
function normalizeSvg(element) {
|
|
1188
|
+
function normalizeSvg(element, baseUrl) {
|
|
728
1189
|
const name = element.localName.toLowerCase();
|
|
729
1190
|
if (SVG_DROP.has(name))
|
|
730
1191
|
return '';
|
|
1192
|
+
// A diagram's node labels (LaTeXML's TikZ output writes every one this way).
|
|
1193
|
+
// The HTML inside walks the body's own whitelist — the same subset any
|
|
1194
|
+
// article markup gets — so the injection vector it was dropped for is gone.
|
|
1195
|
+
if (name === 'foreignobject') {
|
|
1196
|
+
const inner = normalizeChildren(element, baseUrl, true);
|
|
1197
|
+
return inner.trim().length === 0 ? '' : `<foreignObject${svgAttributes(element)}>${inner}</foreignObject>`;
|
|
1198
|
+
}
|
|
731
1199
|
const canonical = SVG_TAGS.get(name);
|
|
732
1200
|
if (canonical === undefined)
|
|
733
|
-
return normalizeSvgChildren(element);
|
|
1201
|
+
return normalizeSvgChildren(element, baseUrl);
|
|
734
1202
|
if (canonical === 'use' || canonical === 'textPath') {
|
|
735
1203
|
// A fragment reference only: an external one drops the element outright.
|
|
736
1204
|
const href = (element.getAttribute('href') ?? element.getAttribute('xlink:href'))?.trim();
|
|
737
1205
|
if (href === undefined || !/^#\S+$/.test(href))
|
|
738
1206
|
return '';
|
|
739
|
-
return `<${canonical} href="${escapeAttribute(href)}"${svgAttributes(element)}>${normalizeSvgChildren(element)}</${canonical}>`;
|
|
1207
|
+
return `<${canonical} href="${escapeAttribute(href)}"${svgAttributes(element)}>${normalizeSvgChildren(element, baseUrl)}</${canonical}>`;
|
|
740
1208
|
}
|
|
741
|
-
return `<${canonical}${svgAttributes(element)}>${normalizeSvgChildren(element)}</${canonical}>`;
|
|
1209
|
+
return `<${canonical}${svgAttributes(element)}>${normalizeSvgChildren(element, baseUrl)}</${canonical}>`;
|
|
742
1210
|
}
|
|
743
1211
|
/** Every child of an SVG node: text is escaped, elements walk the subset. */
|
|
744
|
-
function normalizeSvgChildren(element) {
|
|
1212
|
+
function normalizeSvgChildren(element, baseUrl) {
|
|
745
1213
|
const parts = [];
|
|
746
1214
|
for (const child of Array.from(element.childNodes)) {
|
|
747
1215
|
if (child.nodeType === 3 /* text */) {
|
|
@@ -750,7 +1218,7 @@ function normalizeSvgChildren(element) {
|
|
|
750
1218
|
}
|
|
751
1219
|
if (child.nodeType !== 1 /* element */)
|
|
752
1220
|
continue;
|
|
753
|
-
parts.push(normalizeSvg(child));
|
|
1221
|
+
parts.push(normalizeSvg(child, baseUrl));
|
|
754
1222
|
}
|
|
755
1223
|
return parts.join('');
|
|
756
1224
|
}
|
|
@@ -817,7 +1285,10 @@ function imageSrc(element, baseUrl) {
|
|
|
817
1285
|
if (resolved !== undefined)
|
|
818
1286
|
return resolved;
|
|
819
1287
|
}
|
|
820
|
-
|
|
1288
|
+
// lazysizes-style markup keeps the candidate list in `data-srcset` and leaves
|
|
1289
|
+
// `srcset` to the script that never runs here: read both, the real one first.
|
|
1290
|
+
return bestSrcset(element.getAttribute('srcset'), baseUrl)
|
|
1291
|
+
?? bestSrcset(element.getAttribute('data-srcset'), baseUrl);
|
|
821
1292
|
}
|
|
822
1293
|
/**
|
|
823
1294
|
* The largest candidate of a `srcset` list.
|
|
@@ -878,7 +1349,8 @@ function pictureImg(element, baseUrl) {
|
|
|
878
1349
|
for (const source of Array.from(element.children)) {
|
|
879
1350
|
if (source.localName !== 'source')
|
|
880
1351
|
continue;
|
|
881
|
-
const url = bestSrcset(source.getAttribute('srcset'), baseUrl)
|
|
1352
|
+
const url = bestSrcset(source.getAttribute('srcset'), baseUrl)
|
|
1353
|
+
?? bestSrcset(source.getAttribute('data-srcset'), baseUrl);
|
|
882
1354
|
if (url !== undefined)
|
|
883
1355
|
return emitImg(url, alt);
|
|
884
1356
|
}
|
|
@@ -963,8 +1435,9 @@ function serializeInline(node) {
|
|
|
963
1435
|
continue;
|
|
964
1436
|
// A noscript's fallback image means nothing in an inline summary; the rest
|
|
965
1437
|
// of its content ("please enable JavaScript") was never content at all.
|
|
966
|
-
// An object's fallback text
|
|
967
|
-
|
|
1438
|
+
// An object's fallback text, a chart's text labels and an embed's
|
|
1439
|
+
// placeholder are the same chrome.
|
|
1440
|
+
if (tag === 'noscript' || tag === 'object' || tag === 'svg' || tag === 'iframe' || tag === 'video' || tag === 'audio')
|
|
968
1441
|
continue;
|
|
969
1442
|
if (tag === 'br') {
|
|
970
1443
|
out += ' ';
|