@v1nvn/readability 0.44.0 → 0.45.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 (132) hide show
  1. package/README.md +3 -3
  2. package/package.json +7 -9
  3. package/src/cli.js +99 -0
  4. package/src/cli.ts +134 -0
  5. package/src/config.js +29 -0
  6. package/src/config.ts +42 -0
  7. package/src/dev.js +55 -0
  8. package/src/dev.ts +77 -0
  9. package/src/errors.js +16 -0
  10. package/src/errors.ts +19 -0
  11. package/src/handler.js +4 -0
  12. package/src/handler.ts +8 -0
  13. package/src/host-sampling.js +57 -0
  14. package/src/host-sampling.ts +86 -0
  15. package/src/index.js +23 -0
  16. package/src/index.ts +27 -0
  17. package/src/logger.js +2 -0
  18. package/src/logger.ts +3 -0
  19. package/src/output/format.js +92 -0
  20. package/src/output/format.ts +132 -0
  21. package/src/pipeline/cell-text.js +51 -0
  22. package/src/pipeline/cell-text.ts +56 -0
  23. package/src/pipeline/context.js +1 -0
  24. package/src/pipeline/context.ts +88 -0
  25. package/src/pipeline/dom.js +16 -0
  26. package/src/pipeline/dom.ts +23 -0
  27. package/src/pipeline/footnotes.js +148 -0
  28. package/src/pipeline/footnotes.ts +181 -0
  29. package/src/pipeline/math.js +113 -0
  30. package/src/pipeline/math.ts +130 -0
  31. package/src/pipeline/normalize.js +507 -0
  32. package/src/pipeline/normalize.ts +594 -0
  33. package/src/pipeline/readability.js +10 -0
  34. package/src/pipeline/readability.ts +36 -0
  35. package/src/pipeline/resolver.js +113 -0
  36. package/src/pipeline/resolver.ts +143 -0
  37. package/src/pipeline/sanitize.js +26 -0
  38. package/src/pipeline/sanitize.ts +42 -0
  39. package/src/pipeline/tables.js +239 -0
  40. package/src/pipeline/tables.ts +286 -0
  41. package/src/pipeline/turndown.js +153 -0
  42. package/src/pipeline/turndown.ts +191 -0
  43. package/src/pipeline/urls.js +14 -0
  44. package/src/pipeline/urls.ts +13 -0
  45. package/src/policy/chunk.js +365 -0
  46. package/src/policy/chunk.ts +482 -0
  47. package/src/policy/diagnostics.js +53 -0
  48. package/src/policy/diagnostics.ts +96 -0
  49. package/src/policy/explain.js +104 -0
  50. package/src/policy/explain.ts +182 -0
  51. package/src/policy/fallback.js +68 -0
  52. package/src/policy/fallback.ts +110 -0
  53. package/src/policy/gating.js +90 -0
  54. package/src/policy/gating.ts +106 -0
  55. package/src/policy/grid-detector.js +207 -0
  56. package/src/policy/grid-detector.ts +313 -0
  57. package/src/policy/identifiers.js +62 -0
  58. package/src/policy/identifiers.ts +75 -0
  59. package/src/policy/images.js +49 -0
  60. package/src/policy/images.ts +64 -0
  61. package/src/policy/list-detector.js +232 -0
  62. package/src/policy/list-detector.ts +308 -0
  63. package/src/policy/lost-signal.js +44 -0
  64. package/src/policy/lost-signal.ts +69 -0
  65. package/src/policy/markdown.js +58 -0
  66. package/src/policy/markdown.ts +76 -0
  67. package/src/policy/metadata.js +216 -0
  68. package/src/policy/metadata.ts +312 -0
  69. package/src/policy/outline-chains.js +165 -0
  70. package/src/policy/outline-chains.ts +215 -0
  71. package/src/policy/outline.js +71 -0
  72. package/src/policy/outline.ts +89 -0
  73. package/src/policy/pagination.js +79 -0
  74. package/src/policy/pagination.ts +104 -0
  75. package/src/policy/presets.js +100 -0
  76. package/src/policy/presets.ts +142 -0
  77. package/src/policy/section.js +111 -0
  78. package/src/policy/section.ts +127 -0
  79. package/src/policy/selector-lint.js +143 -0
  80. package/src/policy/selector-lint.ts +186 -0
  81. package/src/policy/sibling-scan.js +92 -0
  82. package/src/policy/sibling-scan.ts +99 -0
  83. package/src/policy/text.js +18 -0
  84. package/src/policy/text.ts +36 -0
  85. package/src/policy/truncate.js +45 -0
  86. package/src/policy/truncate.ts +59 -0
  87. package/src/preset-cache.js +165 -0
  88. package/src/preset-cache.ts +221 -0
  89. package/src/resources.js +227 -0
  90. package/src/resources.ts +317 -0
  91. package/src/sampling.js +45 -0
  92. package/src/sampling.ts +74 -0
  93. package/src/server.js +73 -0
  94. package/src/server.ts +85 -0
  95. package/src/tools/chunk_text.js +40 -0
  96. package/src/tools/chunk_text.ts +65 -0
  97. package/src/tools/explain.js +200 -0
  98. package/src/tools/explain.ts +299 -0
  99. package/src/tools/extract.js +263 -0
  100. package/src/tools/extract.ts +368 -0
  101. package/src/tools/extract_grid.js +56 -0
  102. package/src/tools/extract_grid.ts +90 -0
  103. package/src/tools/extract_links.js +98 -0
  104. package/src/tools/extract_links.ts +138 -0
  105. package/src/tools/extract_list.js +66 -0
  106. package/src/tools/extract_list.ts +97 -0
  107. package/src/tools/extract_metadata.js +67 -0
  108. package/src/tools/extract_metadata.ts +102 -0
  109. package/src/tools/extract_section.js +56 -0
  110. package/src/tools/extract_section.ts +84 -0
  111. package/src/tools/extract_tables.js +59 -0
  112. package/src/tools/extract_tables.ts +101 -0
  113. package/src/tools/html-source.js +30 -0
  114. package/src/tools/html-source.ts +40 -0
  115. package/src/tools/html_to_markdown.js +113 -0
  116. package/src/tools/html_to_markdown.ts +180 -0
  117. package/src/tools/outline.js +47 -0
  118. package/src/tools/outline.ts +77 -0
  119. package/src/tools/output-schema.js +504 -0
  120. package/src/tools/output-schema.ts +715 -0
  121. package/src/tools/schemas.js +288 -0
  122. package/src/tools/schemas.ts +442 -0
  123. package/src/tools/suggest-preset.js +504 -0
  124. package/src/tools/suggest-preset.ts +708 -0
  125. package/src/types/jsdom.d.ts +15 -0
  126. package/src/types/turndown-plugin-gfm.d.ts +11 -0
  127. package/dist/assets/cli-Dfjg7dOQ.js +0 -76
  128. package/dist/assets/cli-Dfjg7dOQ.js.map +0 -1
  129. package/dist/assets/extract-CdDp-KwX.js +0 -2880
  130. package/dist/assets/extract-CdDp-KwX.js.map +0 -1
  131. package/dist/index.js +0 -2267
  132. package/dist/index.js.map +0 -1
@@ -0,0 +1,148 @@
1
+ import { buildDocument } from "./dom.js";
2
+ // jsdom-parsing every turndown call would dominate hot paths; this pre-check
3
+ // gates on a string scan so footnote-less articles pay nothing.
4
+ const FOOTNOTE_SIGNAL_RE = /cite_note|cite_ref|class="footnotes"|class="references"|data-footnote|role="doc-endnote|<sup\b[^>]*>\s*<a\s[^>]*href="#/i;
5
+ // First match wins per group; an `<ol class="footnotes">` is the common case.
6
+ const DEFINITION_CONTAINER_SELECTORS = [
7
+ 'ol.footnotes',
8
+ 'ol[class*="footnotes"]',
9
+ 'ol.references',
10
+ 'ol[class*="references"]',
11
+ 'section[class*="footnote"]',
12
+ 'div[class*="footnote"]',
13
+ '[role="doc-endnotes"]',
14
+ '[role="doc-bibliography"]',
15
+ ];
16
+ const STANDALONE_DEF_ID_RE = /^(?:fn|cite_note|footnote|note)[:_-]/i;
17
+ // Some sites prepend a back-to-text caret or "Jump to" label to each definition.
18
+ // Stripping only a leading occurrence keeps accidental mid-text carets intact.
19
+ const BACKREF_LEADING_RE = /^(?:↑\s?|↩\s?|\^\s|Jump to\s*)/;
20
+ function cleanDefText(text) {
21
+ return text.replace(/\s+/g, ' ').trim().replace(BACKREF_LEADING_RE, '');
22
+ }
23
+ function collectDefinitions(document) {
24
+ const containers = new Set();
25
+ const defs = new Map();
26
+ const standaloneIds = new Set();
27
+ for (const selector of DEFINITION_CONTAINER_SELECTORS) {
28
+ let matched;
29
+ try {
30
+ matched = document.querySelectorAll(selector);
31
+ }
32
+ catch {
33
+ continue;
34
+ }
35
+ for (const container of Array.from(matched)) {
36
+ if (!containers.has(container)) {
37
+ containers.add(container);
38
+ }
39
+ for (const item of Array.from(container.querySelectorAll('li, [role="doc-endnote"]'))) {
40
+ const id = item.id;
41
+ if (!id || defs.has(id)) {
42
+ continue;
43
+ }
44
+ const text = cleanDefText(item.textContent);
45
+ if (text) {
46
+ defs.set(id, text);
47
+ }
48
+ }
49
+ }
50
+ }
51
+ // Some sites scatter `<li id="fn1">` outside any container; collect those too.
52
+ for (const li of Array.from(document.querySelectorAll('li[id]'))) {
53
+ const id = li.id;
54
+ if (!id || defs.has(id) || !STANDALONE_DEF_ID_RE.test(id)) {
55
+ continue;
56
+ }
57
+ const text = cleanDefText(li.textContent);
58
+ if (text) {
59
+ defs.set(id, text);
60
+ standaloneIds.add(id);
61
+ }
62
+ }
63
+ return { containers, defs, standaloneIds };
64
+ }
65
+ export function processFootnotes(html) {
66
+ if (!html || !FOOTNOTE_SIGNAL_RE.test(html)) {
67
+ return null;
68
+ }
69
+ let document;
70
+ try {
71
+ document = buildDocument(html).document;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ const { containers, defs, standaloneIds } = collectDefinitions(document);
77
+ if (defs.size === 0) {
78
+ return null;
79
+ }
80
+ const refHits = [];
81
+ const defIdToNumber = new Map();
82
+ for (const sup of Array.from(document.querySelectorAll('sup'))) {
83
+ if (!sup.isConnected) {
84
+ continue;
85
+ }
86
+ try {
87
+ const anchor = sup.querySelector('a[href^="#"]');
88
+ if (!anchor) {
89
+ continue;
90
+ }
91
+ const frag = (anchor.getAttribute('href') ?? '').slice(1);
92
+ if (!frag || !defs.has(frag)) {
93
+ continue;
94
+ }
95
+ let n = defIdToNumber.get(frag);
96
+ if (n === undefined) {
97
+ n = defIdToNumber.size + 1;
98
+ defIdToNumber.set(frag, n);
99
+ }
100
+ refHits.push({ defId: frag, n, sup });
101
+ }
102
+ catch {
103
+ // Defensive: malformed <sup> must not abort the whole pass.
104
+ }
105
+ }
106
+ if (refHits.length === 0) {
107
+ return null;
108
+ }
109
+ for (const { n, sup } of refHits) {
110
+ sup.replaceWith(document.createTextNode(`[^${n}]`));
111
+ }
112
+ // Drop the rendered references list so it doesn't duplicate the appended
113
+ // `[^N]:` block. Removing containers also takes care of the standalone <li>
114
+ // elements that lived inside them; only out-of-container standalones need
115
+ // individual removal here.
116
+ for (const container of containers) {
117
+ if (container.isConnected) {
118
+ container.remove();
119
+ }
120
+ }
121
+ for (const id of standaloneIds) {
122
+ const el = document.getElementById(id);
123
+ if (el?.isConnected) {
124
+ el.remove();
125
+ }
126
+ }
127
+ const numberToDefId = new Map();
128
+ for (const [id, n] of defIdToNumber) {
129
+ numberToDefId.set(n, id);
130
+ }
131
+ const footnoteDefs = [];
132
+ for (let n = 1; n <= numberToDefId.size; n++) {
133
+ const id = numberToDefId.get(n);
134
+ if (id === undefined) {
135
+ break;
136
+ }
137
+ const text = defs.get(id);
138
+ if (text !== undefined) {
139
+ footnoteDefs.push(text);
140
+ }
141
+ }
142
+ // buildDocument wraps both full documents and bare fragments in a <body>;
143
+ // serializing its innerHTML returns the equivalent of the input region.
144
+ return {
145
+ footnoteDefs,
146
+ html: document.body.innerHTML,
147
+ };
148
+ }
@@ -0,0 +1,181 @@
1
+ import { buildDocument } from './dom.ts';
2
+
3
+ export interface FootnoteResult {
4
+ readonly footnoteDefs: readonly string[];
5
+ readonly html: string;
6
+ }
7
+
8
+ // jsdom-parsing every turndown call would dominate hot paths; this pre-check
9
+ // gates on a string scan so footnote-less articles pay nothing.
10
+ const FOOTNOTE_SIGNAL_RE =
11
+ /cite_note|cite_ref|class="footnotes"|class="references"|data-footnote|role="doc-endnote|<sup\b[^>]*>\s*<a\s[^>]*href="#/i;
12
+
13
+ // First match wins per group; an `<ol class="footnotes">` is the common case.
14
+ const DEFINITION_CONTAINER_SELECTORS = [
15
+ 'ol.footnotes',
16
+ 'ol[class*="footnotes"]',
17
+ 'ol.references',
18
+ 'ol[class*="references"]',
19
+ 'section[class*="footnote"]',
20
+ 'div[class*="footnote"]',
21
+ '[role="doc-endnotes"]',
22
+ '[role="doc-bibliography"]',
23
+ ] as const;
24
+
25
+ const STANDALONE_DEF_ID_RE = /^(?:fn|cite_note|footnote|note)[:_-]/i;
26
+
27
+ interface RefHit {
28
+ readonly defId: string;
29
+ readonly n: number;
30
+ readonly sup: Element;
31
+ }
32
+
33
+ // Some sites prepend a back-to-text caret or "Jump to" label to each definition.
34
+ // Stripping only a leading occurrence keeps accidental mid-text carets intact.
35
+ const BACKREF_LEADING_RE = /^(?:↑\s?|↩\s?|\^\s|Jump to\s*)/;
36
+
37
+ function cleanDefText(text: string): string {
38
+ return text.replace(/\s+/g, ' ').trim().replace(BACKREF_LEADING_RE, '');
39
+ }
40
+
41
+ function collectDefinitions(document: Document): {
42
+ readonly containers: ReadonlySet<Element>;
43
+ readonly defs: ReadonlyMap<string, string>;
44
+ readonly standaloneIds: ReadonlySet<string>;
45
+ } {
46
+ const containers = new Set<Element>();
47
+ const defs = new Map<string, string>();
48
+ const standaloneIds = new Set<string>();
49
+
50
+ for (const selector of DEFINITION_CONTAINER_SELECTORS) {
51
+ let matched: NodeListOf<Element>;
52
+ try {
53
+ matched = document.querySelectorAll(selector);
54
+ } catch {
55
+ continue;
56
+ }
57
+ for (const container of Array.from(matched)) {
58
+ if (!containers.has(container)) {
59
+ containers.add(container);
60
+ }
61
+ for (const item of Array.from(
62
+ container.querySelectorAll('li, [role="doc-endnote"]'),
63
+ )) {
64
+ const id = item.id;
65
+ if (!id || defs.has(id)) {
66
+ continue;
67
+ }
68
+ const text = cleanDefText(item.textContent);
69
+ if (text) {
70
+ defs.set(id, text);
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ // Some sites scatter `<li id="fn1">` outside any container; collect those too.
77
+ for (const li of Array.from(document.querySelectorAll('li[id]'))) {
78
+ const id = li.id;
79
+ if (!id || defs.has(id) || !STANDALONE_DEF_ID_RE.test(id)) {
80
+ continue;
81
+ }
82
+ const text = cleanDefText(li.textContent);
83
+ if (text) {
84
+ defs.set(id, text);
85
+ standaloneIds.add(id);
86
+ }
87
+ }
88
+
89
+ return { containers, defs, standaloneIds };
90
+ }
91
+
92
+ export function processFootnotes(html: string): FootnoteResult | null {
93
+ if (!html || !FOOTNOTE_SIGNAL_RE.test(html)) {
94
+ return null;
95
+ }
96
+
97
+ let document: Document;
98
+ try {
99
+ document = buildDocument(html).document;
100
+ } catch {
101
+ return null;
102
+ }
103
+
104
+ const { containers, defs, standaloneIds } = collectDefinitions(document);
105
+ if (defs.size === 0) {
106
+ return null;
107
+ }
108
+
109
+ const refHits: RefHit[] = [];
110
+ const defIdToNumber = new Map<string, number>();
111
+ for (const sup of Array.from(document.querySelectorAll('sup'))) {
112
+ if (!sup.isConnected) {
113
+ continue;
114
+ }
115
+ try {
116
+ const anchor = sup.querySelector('a[href^="#"]');
117
+ if (!anchor) {
118
+ continue;
119
+ }
120
+ const frag = (anchor.getAttribute('href') ?? '').slice(1);
121
+ if (!frag || !defs.has(frag)) {
122
+ continue;
123
+ }
124
+ let n = defIdToNumber.get(frag);
125
+ if (n === undefined) {
126
+ n = defIdToNumber.size + 1;
127
+ defIdToNumber.set(frag, n);
128
+ }
129
+ refHits.push({ defId: frag, n, sup });
130
+ } catch {
131
+ // Defensive: malformed <sup> must not abort the whole pass.
132
+ }
133
+ }
134
+
135
+ if (refHits.length === 0) {
136
+ return null;
137
+ }
138
+
139
+ for (const { n, sup } of refHits) {
140
+ sup.replaceWith(document.createTextNode(`[^${n}]`));
141
+ }
142
+
143
+ // Drop the rendered references list so it doesn't duplicate the appended
144
+ // `[^N]:` block. Removing containers also takes care of the standalone <li>
145
+ // elements that lived inside them; only out-of-container standalones need
146
+ // individual removal here.
147
+ for (const container of containers) {
148
+ if (container.isConnected) {
149
+ container.remove();
150
+ }
151
+ }
152
+ for (const id of standaloneIds) {
153
+ const el = document.getElementById(id);
154
+ if (el?.isConnected) {
155
+ el.remove();
156
+ }
157
+ }
158
+
159
+ const numberToDefId = new Map<number, string>();
160
+ for (const [id, n] of defIdToNumber) {
161
+ numberToDefId.set(n, id);
162
+ }
163
+ const footnoteDefs: string[] = [];
164
+ for (let n = 1; n <= numberToDefId.size; n++) {
165
+ const id = numberToDefId.get(n);
166
+ if (id === undefined) {
167
+ break;
168
+ }
169
+ const text = defs.get(id);
170
+ if (text !== undefined) {
171
+ footnoteDefs.push(text);
172
+ }
173
+ }
174
+
175
+ // buildDocument wraps both full documents and bare fragments in a <body>;
176
+ // serializing its innerHTML returns the equivalent of the input region.
177
+ return {
178
+ footnoteDefs,
179
+ html: document.body.innerHTML,
180
+ };
181
+ }
@@ -0,0 +1,113 @@
1
+ // Math renderers leave the original LaTeX in a small, engine-agnostic pocket:
2
+ // the `<annotation encoding="application/x-tex">` child of a `<math>` element.
3
+ // KaTeX wraps it inside `.katex`, arXiv/LaTeXML (ar5iv) and MDN ship it in a
4
+ // bare `<math display="…">`, and MathJax v2/v3 do not use `<annotation>` at
5
+ // all — their source lives in `<script type="math/tex">` that the
6
+ // script-removal pass strips. Move each into one controlled marker span BEFORE
7
+ // normalize's `<script>` removal so every engine shares a single turndown rule
8
+ // that emits the LaTeX verbatim.
9
+ const MARKER_CLASS = 'rdrm-math';
10
+ const DISPLAY_ATTR = 'data-display';
11
+ const KATEX_CLASS = 'katex';
12
+ const KATEX_DISPLAY_CLASS = 'katex-display';
13
+ const TEX_ANNOTATION_SELECTOR = 'annotation[encoding="application/x-tex"]';
14
+ const MATHJAX_INLINE_TYPE = 'math/tex';
15
+ const MATHJAX_DISPLAY_TYPE = 'math/tex; mode=display';
16
+ // arXiv/LaTeXML (`ltx_*`) and common docs-site display-math wrappers. A bare
17
+ // `<math display="block">` also flags display, but pages often omit the
18
+ // attribute and signal block context only through the wrapper class.
19
+ const MATHML_DISPLAY_SELECTOR = '.ltx_equation, .ltx_displaymath, .equation-display, .math-display';
20
+ // Placeholder for a math container whose annotation and alttext are both empty
21
+ // or absent; rendered markup has no recoverable LaTeX, so emit a token rather
22
+ // than crash.
23
+ const BROKEN_PLACEHOLDER = '[?]';
24
+ function createMarker(document, tex, display) {
25
+ const marker = document.createElement('span');
26
+ marker.className = MARKER_CLASS;
27
+ marker.setAttribute(DISPLAY_ATTR, display ? 'true' : 'false');
28
+ marker.textContent = tex;
29
+ return marker;
30
+ }
31
+ // Drives off the annotation, not the wrapper, so one pass recovers LaTeX from
32
+ // every engine that emits `<annotation encoding="application/x-tex">` — KaTeX
33
+ // inside `.katex`, arXiv/MDN inside a bare `<math>`. The container replaced is
34
+ // engine-specific: the `.katex` span (which also drops the `.katex-html`
35
+ // rendered sibling), or the bare `<math>` element. Replacing a container
36
+ // detaches other annotations that lived inside it, so disconnected nodes
37
+ // reached later in document order are skipped. arXiv carries the LaTeX in
38
+ // `<math alttext="…">` as well, which is the fallback when the annotation text
39
+ // is empty.
40
+ function convertAnnotations(document) {
41
+ const annotations = document.querySelectorAll(TEX_ANNOTATION_SELECTOR);
42
+ for (const annotation of Array.from(annotations)) {
43
+ if (!annotation.isConnected) {
44
+ continue;
45
+ }
46
+ try {
47
+ const katex = annotation.closest(`.${KATEX_CLASS}`);
48
+ const math = annotation.closest('math');
49
+ let container = annotation;
50
+ let display = false;
51
+ if (katex) {
52
+ container = katex;
53
+ display = katex.closest(`.${KATEX_DISPLAY_CLASS}`) !== null;
54
+ }
55
+ else if (math) {
56
+ container = math;
57
+ display =
58
+ math.getAttribute('display') === 'block' ||
59
+ math.closest(MATHML_DISPLAY_SELECTOR) !== null;
60
+ }
61
+ const tex = annotation.textContent.trim() ||
62
+ (math?.getAttribute('alttext') ?? '').trim();
63
+ container.replaceWith(createMarker(document, tex || BROKEN_PLACEHOLDER, display));
64
+ }
65
+ catch {
66
+ annotation.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, false));
67
+ }
68
+ }
69
+ }
70
+ // A `.katex` that survived the annotation pass had no recoverable annotation
71
+ // (stripped or never present). Its `.katex-html` rendered tree carries no
72
+ // recoverable LaTeX, so swap the whole span for a placeholder marker rather
73
+ // than let rendered spans leak into turndown.
74
+ function convertOrphanedKatex(document) {
75
+ for (const katex of Array.from(document.getElementsByClassName(KATEX_CLASS))) {
76
+ if (!katex.isConnected) {
77
+ continue;
78
+ }
79
+ const display = katex.closest(`.${KATEX_DISPLAY_CLASS}`) !== null;
80
+ katex.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, display));
81
+ }
82
+ }
83
+ // MathJax v2/v3 inline + display source scripts. These `<script>` nodes are
84
+ // removed by normalize's generic script-removal AND by DOMPurify, so they must
85
+ // be converted here, before that loop runs. MathJax scripts do not use
86
+ // `<annotation>`, so they are handled separately from `convertAnnotations`.
87
+ function convertMathJax(document) {
88
+ const scripts = document.querySelectorAll(`script[type="${MATHJAX_INLINE_TYPE}"], script[type="${MATHJAX_DISPLAY_TYPE}"]`);
89
+ for (const script of Array.from(scripts)) {
90
+ if (!script.isConnected) {
91
+ continue;
92
+ }
93
+ try {
94
+ const rawType = script.getAttribute('type') ?? '';
95
+ const display = rawType.includes('mode=display');
96
+ const tex = script.textContent.trim();
97
+ script.replaceWith(createMarker(document, tex || BROKEN_PLACEHOLDER, display));
98
+ }
99
+ catch {
100
+ script.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, false));
101
+ }
102
+ }
103
+ }
104
+ // Walks the pre-normalize document and rewrites every math container —
105
+ // annotation-bearing (KaTeX, bare MathML) or MathJax script — into a
106
+ // `<span class="rdrm-math" data-display="…">LATEX</span>` marker. Idempotent:
107
+ // markers carry no `.katex`/`<math>`/annotation, so a second pass is a no-op.
108
+ // Defensive per node so malformed markup never aborts the pass.
109
+ export function extractMath(document) {
110
+ convertAnnotations(document);
111
+ convertOrphanedKatex(document);
112
+ convertMathJax(document);
113
+ }
@@ -0,0 +1,130 @@
1
+ // Math renderers leave the original LaTeX in a small, engine-agnostic pocket:
2
+ // the `<annotation encoding="application/x-tex">` child of a `<math>` element.
3
+ // KaTeX wraps it inside `.katex`, arXiv/LaTeXML (ar5iv) and MDN ship it in a
4
+ // bare `<math display="…">`, and MathJax v2/v3 do not use `<annotation>` at
5
+ // all — their source lives in `<script type="math/tex">` that the
6
+ // script-removal pass strips. Move each into one controlled marker span BEFORE
7
+ // normalize's `<script>` removal so every engine shares a single turndown rule
8
+ // that emits the LaTeX verbatim.
9
+
10
+ const MARKER_CLASS = 'rdrm-math';
11
+ const DISPLAY_ATTR = 'data-display';
12
+ const KATEX_CLASS = 'katex';
13
+ const KATEX_DISPLAY_CLASS = 'katex-display';
14
+ const TEX_ANNOTATION_SELECTOR = 'annotation[encoding="application/x-tex"]';
15
+ const MATHJAX_INLINE_TYPE = 'math/tex';
16
+ const MATHJAX_DISPLAY_TYPE = 'math/tex; mode=display';
17
+ // arXiv/LaTeXML (`ltx_*`) and common docs-site display-math wrappers. A bare
18
+ // `<math display="block">` also flags display, but pages often omit the
19
+ // attribute and signal block context only through the wrapper class.
20
+ const MATHML_DISPLAY_SELECTOR =
21
+ '.ltx_equation, .ltx_displaymath, .equation-display, .math-display';
22
+ // Placeholder for a math container whose annotation and alttext are both empty
23
+ // or absent; rendered markup has no recoverable LaTeX, so emit a token rather
24
+ // than crash.
25
+ const BROKEN_PLACEHOLDER = '[?]';
26
+
27
+ function createMarker(
28
+ document: Document,
29
+ tex: string,
30
+ display: boolean,
31
+ ): HTMLElement {
32
+ const marker = document.createElement('span');
33
+ marker.className = MARKER_CLASS;
34
+ marker.setAttribute(DISPLAY_ATTR, display ? 'true' : 'false');
35
+ marker.textContent = tex;
36
+ return marker;
37
+ }
38
+
39
+ // Drives off the annotation, not the wrapper, so one pass recovers LaTeX from
40
+ // every engine that emits `<annotation encoding="application/x-tex">` — KaTeX
41
+ // inside `.katex`, arXiv/MDN inside a bare `<math>`. The container replaced is
42
+ // engine-specific: the `.katex` span (which also drops the `.katex-html`
43
+ // rendered sibling), or the bare `<math>` element. Replacing a container
44
+ // detaches other annotations that lived inside it, so disconnected nodes
45
+ // reached later in document order are skipped. arXiv carries the LaTeX in
46
+ // `<math alttext="…">` as well, which is the fallback when the annotation text
47
+ // is empty.
48
+ function convertAnnotations(document: Document): void {
49
+ const annotations = document.querySelectorAll(TEX_ANNOTATION_SELECTOR);
50
+ for (const annotation of Array.from(annotations)) {
51
+ if (!annotation.isConnected) {
52
+ continue;
53
+ }
54
+ try {
55
+ const katex = annotation.closest(`.${KATEX_CLASS}`);
56
+ const math = annotation.closest('math');
57
+ let container: Element = annotation;
58
+ let display = false;
59
+ if (katex) {
60
+ container = katex;
61
+ display = katex.closest(`.${KATEX_DISPLAY_CLASS}`) !== null;
62
+ } else if (math) {
63
+ container = math;
64
+ display =
65
+ math.getAttribute('display') === 'block' ||
66
+ math.closest(MATHML_DISPLAY_SELECTOR) !== null;
67
+ }
68
+ const tex =
69
+ annotation.textContent.trim() ||
70
+ (math?.getAttribute('alttext') ?? '').trim();
71
+ container.replaceWith(
72
+ createMarker(document, tex || BROKEN_PLACEHOLDER, display),
73
+ );
74
+ } catch {
75
+ annotation.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, false));
76
+ }
77
+ }
78
+ }
79
+
80
+ // A `.katex` that survived the annotation pass had no recoverable annotation
81
+ // (stripped or never present). Its `.katex-html` rendered tree carries no
82
+ // recoverable LaTeX, so swap the whole span for a placeholder marker rather
83
+ // than let rendered spans leak into turndown.
84
+ function convertOrphanedKatex(document: Document): void {
85
+ for (const katex of Array.from(
86
+ document.getElementsByClassName(KATEX_CLASS),
87
+ )) {
88
+ if (!katex.isConnected) {
89
+ continue;
90
+ }
91
+ const display = katex.closest(`.${KATEX_DISPLAY_CLASS}`) !== null;
92
+ katex.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, display));
93
+ }
94
+ }
95
+
96
+ // MathJax v2/v3 inline + display source scripts. These `<script>` nodes are
97
+ // removed by normalize's generic script-removal AND by DOMPurify, so they must
98
+ // be converted here, before that loop runs. MathJax scripts do not use
99
+ // `<annotation>`, so they are handled separately from `convertAnnotations`.
100
+ function convertMathJax(document: Document): void {
101
+ const scripts = document.querySelectorAll(
102
+ `script[type="${MATHJAX_INLINE_TYPE}"], script[type="${MATHJAX_DISPLAY_TYPE}"]`,
103
+ );
104
+ for (const script of Array.from(scripts)) {
105
+ if (!script.isConnected) {
106
+ continue;
107
+ }
108
+ try {
109
+ const rawType = script.getAttribute('type') ?? '';
110
+ const display = rawType.includes('mode=display');
111
+ const tex = script.textContent.trim();
112
+ script.replaceWith(
113
+ createMarker(document, tex || BROKEN_PLACEHOLDER, display),
114
+ );
115
+ } catch {
116
+ script.replaceWith(createMarker(document, BROKEN_PLACEHOLDER, false));
117
+ }
118
+ }
119
+ }
120
+
121
+ // Walks the pre-normalize document and rewrites every math container —
122
+ // annotation-bearing (KaTeX, bare MathML) or MathJax script — into a
123
+ // `<span class="rdrm-math" data-display="…">LATEX</span>` marker. Idempotent:
124
+ // markers carry no `.katex`/`<math>`/annotation, so a second pass is a no-op.
125
+ // Defensive per node so malformed markup never aborts the pass.
126
+ export function extractMath(document: Document): void {
127
+ convertAnnotations(document);
128
+ convertOrphanedKatex(document);
129
+ convertMathJax(document);
130
+ }