@khorsheed/dsh-reader 0.2.1 → 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.
Files changed (89) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.en.md +26 -121
  3. package/README.i18n.yaml +3 -3
  4. package/README.md +26 -121
  5. package/cordis.patch.yml +7 -0
  6. package/lib/client.js +30580 -6892
  7. package/lib/index.js +4189 -621
  8. package/lib/typert.host.js +2265 -319
  9. package/lib/typert.remote-client.d.ts +99 -3
  10. package/lib/typert.remote-client.js +1618 -240
  11. package/lib/types/captions.d.ts +99 -0
  12. package/lib/types/captions.js +236 -0
  13. package/lib/types/client/MediaPlayer.d.ts +55 -0
  14. package/lib/types/client/MediaPlayer.js +106 -0
  15. package/lib/types/client/NotesView.d.ts +57 -0
  16. package/lib/types/client/NotesView.js +124 -0
  17. package/lib/types/client/ReaderPane.d.ts +20 -3
  18. package/lib/types/client/ReaderPane.js +905 -164
  19. package/lib/types/client/SpeakerLegend.d.ts +18 -0
  20. package/lib/types/client/SpeakerLegend.js +26 -0
  21. package/lib/types/client/canvas-sync.d.ts +84 -0
  22. package/lib/types/client/canvas-sync.js +59 -0
  23. package/lib/types/client/contract.d.ts +138 -3
  24. package/lib/types/client/extract-article.d.ts +38 -1
  25. package/lib/types/client/extract-article.js +501 -28
  26. package/lib/types/client/icons.d.ts +1 -1
  27. package/lib/types/client/icons.js +1 -1
  28. package/lib/types/client/index.js +82 -2
  29. package/lib/types/client/locales.d.ts +279 -11
  30. package/lib/types/client/locales.js +278 -10
  31. package/lib/types/client/notes.d.ts +142 -0
  32. package/lib/types/client/notes.js +334 -0
  33. package/lib/types/client/parse-rss.js +40 -0
  34. package/lib/types/client/preset-visibility.d.ts +93 -0
  35. package/lib/types/client/preset-visibility.js +115 -0
  36. package/lib/types/client/selectors.d.ts +5 -0
  37. package/lib/types/client/selectors.js +5 -0
  38. package/lib/types/client/session.d.ts +21 -1
  39. package/lib/types/client/session.js +12 -0
  40. package/lib/types/client/store.js +4 -1
  41. package/lib/types/client/translate.d.ts +26 -1
  42. package/lib/types/client/translate.js +29 -12
  43. package/lib/types/client/useLocalTranscript.d.ts +43 -0
  44. package/lib/types/client/useLocalTranscript.js +129 -0
  45. package/lib/types/client/useMedia.d.ts +68 -0
  46. package/lib/types/client/useMedia.js +299 -0
  47. package/lib/types/client/useMediaStates.d.ts +9 -0
  48. package/lib/types/client/useMediaStates.js +43 -0
  49. package/lib/types/client/useNotes.d.ts +110 -0
  50. package/lib/types/client/useNotes.js +423 -0
  51. package/lib/types/client/usePolish.d.ts +47 -0
  52. package/lib/types/client/usePolish.js +162 -0
  53. package/lib/types/image-fetch.d.ts +81 -0
  54. package/lib/types/image-fetch.js +385 -0
  55. package/lib/types/index.d.ts +6 -0
  56. package/lib/types/index.js +3 -0
  57. package/lib/types/local-transcribe.d.ts +171 -0
  58. package/lib/types/local-transcribe.js +473 -0
  59. package/lib/types/media-host.d.ts +91 -0
  60. package/lib/types/media-host.js +230 -0
  61. package/lib/types/media.d.ts +100 -0
  62. package/lib/types/media.js +207 -0
  63. package/lib/types/notes-types.d.ts +158 -0
  64. package/lib/types/notes-types.js +8 -0
  65. package/lib/types/notes.d.ts +213 -0
  66. package/lib/types/notes.js +718 -0
  67. package/lib/types/polish.d.ts +41 -0
  68. package/lib/types/polish.js +136 -0
  69. package/lib/types/remote.d.ts +127 -1
  70. package/lib/types/remote.js +162 -1
  71. package/lib/types/service.d.ts +100 -1
  72. package/lib/types/service.js +320 -59
  73. package/lib/types/skill.d.ts +20 -0
  74. package/lib/types/skill.js +78 -0
  75. package/lib/types/store.js +30 -8
  76. package/lib/types/tool.d.ts +23 -0
  77. package/lib/types/tool.js +40 -0
  78. package/lib/types/tools.d.ts +69 -0
  79. package/lib/types/tools.js +400 -0
  80. package/lib/types/types.d.ts +106 -0
  81. package/lib/types/types.js +66 -0
  82. package/lib/types/xiaoyuzhou.d.ts +43 -0
  83. package/lib/types/xiaoyuzhou.js +134 -0
  84. package/lib/types/ytdlp.d.ts +111 -0
  85. package/lib/types/ytdlp.js +269 -0
  86. package/package.json +29 -20
  87. package/lib/invariant.js +0 -38
  88. package/lib/types/invariant.d.ts +0 -29
  89. 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', 'iframe', 'annotation-xml', 'button', 'select', 'input', 'textarea', 'video',
59
- 'audio', 'canvas', 'embed', 'link', 'meta', 'base', 'title',
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
- 'foreignobject',
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 = ['foreignObject'];
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
- if (node.querySelector('img, picture, canvas, video, figure') !== null)
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, SMIL).
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 inner = normalizeChildren(element, baseUrl, inFigure || tag === 'figure');
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
- return `<${tag}${style === undefined ? '' : ` style="${escapeAttribute(style)}"`}>${inner}</${tag}>`;
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>`, `<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;
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', 'foreignobject', 'animate', 'animatemotion', 'animatetransform', 'set']);
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
- return bestSrcset(element.getAttribute('srcset'), baseUrl);
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 and a chart's text labels are the same chrome.
967
- if (tag === 'noscript' || tag === 'object' || tag === 'svg')
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 += ' ';