@v1nvn/readability 0.45.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 (64) hide show
  1. package/README.md +3 -3
  2. package/package.json +4 -4
  3. package/src/cli.js +99 -0
  4. package/src/config.js +29 -0
  5. package/src/dev.js +55 -0
  6. package/src/errors.js +16 -0
  7. package/src/handler.js +4 -0
  8. package/src/host-sampling.js +57 -0
  9. package/src/index.js +23 -0
  10. package/src/index.ts +0 -0
  11. package/src/logger.js +2 -0
  12. package/src/output/format.js +92 -0
  13. package/src/pipeline/cell-text.js +51 -0
  14. package/src/pipeline/context.js +1 -0
  15. package/src/pipeline/dom.js +16 -0
  16. package/src/pipeline/footnotes.js +148 -0
  17. package/src/pipeline/math.js +113 -0
  18. package/src/pipeline/normalize.js +507 -0
  19. package/src/pipeline/readability.js +10 -0
  20. package/src/pipeline/resolver.js +113 -0
  21. package/src/pipeline/sanitize.js +26 -0
  22. package/src/pipeline/tables.js +239 -0
  23. package/src/pipeline/turndown.js +153 -0
  24. package/src/pipeline/urls.js +14 -0
  25. package/src/policy/chunk.js +365 -0
  26. package/src/policy/diagnostics.js +53 -0
  27. package/src/policy/explain.js +104 -0
  28. package/src/policy/fallback.js +68 -0
  29. package/src/policy/gating.js +90 -0
  30. package/src/policy/grid-detector.js +207 -0
  31. package/src/policy/identifiers.js +62 -0
  32. package/src/policy/images.js +49 -0
  33. package/src/policy/list-detector.js +232 -0
  34. package/src/policy/lost-signal.js +44 -0
  35. package/src/policy/markdown.js +58 -0
  36. package/src/policy/metadata.js +216 -0
  37. package/src/policy/outline-chains.js +165 -0
  38. package/src/policy/outline.js +71 -0
  39. package/src/policy/pagination.js +79 -0
  40. package/src/policy/presets.js +100 -0
  41. package/src/policy/section.js +111 -0
  42. package/src/policy/selector-lint.js +143 -0
  43. package/src/policy/sibling-scan.js +92 -0
  44. package/src/policy/text.js +18 -0
  45. package/src/policy/truncate.js +45 -0
  46. package/src/preset-cache.js +165 -0
  47. package/src/resources.js +227 -0
  48. package/src/sampling.js +45 -0
  49. package/src/server.js +73 -0
  50. package/src/tools/chunk_text.js +40 -0
  51. package/src/tools/explain.js +200 -0
  52. package/src/tools/extract.js +263 -0
  53. package/src/tools/extract_grid.js +56 -0
  54. package/src/tools/extract_links.js +98 -0
  55. package/src/tools/extract_list.js +66 -0
  56. package/src/tools/extract_metadata.js +67 -0
  57. package/src/tools/extract_section.js +56 -0
  58. package/src/tools/extract_tables.js +59 -0
  59. package/src/tools/html-source.js +30 -0
  60. package/src/tools/html_to_markdown.js +113 -0
  61. package/src/tools/outline.js +47 -0
  62. package/src/tools/output-schema.js +504 -0
  63. package/src/tools/schemas.js +288 -0
  64. package/src/tools/suggest-preset.js +504 -0
@@ -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,507 @@
1
+ import { extractMath } from "./math.js";
2
+ import { KNOWN_LANGUAGE_TOKENS } from "./resolver.js";
3
+ const NONCE_ATTR = 'nonce';
4
+ export function normalizeDocument(document, options) {
5
+ // MathJax source lives in `<script type="math/tex">`, which the script-removal
6
+ // loop below strips — extract math into markers first so both engines share
7
+ // one turndown rule downstream.
8
+ extractMath(document);
9
+ const baseEls = document.querySelectorAll('base');
10
+ // Preserve <script type="application/ld+json">: structured metadata consumed by the cascade.
11
+ const scriptEls = document.querySelectorAll('script:not([type="application/ld+json"])');
12
+ baseEls.forEach(el => {
13
+ el.remove();
14
+ });
15
+ scriptEls.forEach(el => {
16
+ el.remove();
17
+ });
18
+ const withNonce = document.querySelectorAll(`[${NONCE_ATTR}]`);
19
+ withNonce.forEach(el => {
20
+ el.removeAttribute(NONCE_ATTR);
21
+ });
22
+ const chromeRemoved = options?.cleanChrome === false ? 0 : stripChrome(document);
23
+ const boilerplateRemoved = stripBoilerplate(document);
24
+ return {
25
+ boilerplateRemoved,
26
+ chromeRemoved,
27
+ iframes: 0,
28
+ scripts: scriptEls.length,
29
+ };
30
+ }
31
+ // Conservative, vendor-specific consent SDK selectors. Curated, not greedy —
32
+ // intentionally NOT `[class*="consent"]` (that eats real article widgets).
33
+ const CONSENT_SELECTORS = [
34
+ '[role="dialog"]',
35
+ '[aria-modal="true"]',
36
+ '#onetrust-banner-sdk',
37
+ '#onetrust-consent-sdk',
38
+ '#onetrust-pc-sdk',
39
+ '.cc-window',
40
+ '.cc-banner',
41
+ '.cc-revoke',
42
+ '.osano-cm-window',
43
+ '.osano-cm-dialog',
44
+ '.qc-cmp2-container',
45
+ '.qc-cmp-ui-container',
46
+ '#sp_message_container',
47
+ '[id^="sp_message_container_"]',
48
+ '#didomi-host',
49
+ '.didomi-popup-container',
50
+ '#truste-consent-track',
51
+ '#consent_blackbar',
52
+ '#cookie-banner',
53
+ '.cookie-banner',
54
+ '#consent-banner',
55
+ '.consent-banner',
56
+ '.cookie-bar',
57
+ '.gdpr-banner',
58
+ '.privacy-banner',
59
+ ];
60
+ // jsdom has no layout, so "covers the viewport" must be inferred from inline
61
+ // style. Requiring BOTH axes full is what protects a fixed nav bar: a nav is
62
+ // full-width but its height is a fixed px (not 100%/100vh), and inset:0 is not
63
+ // used. Dropping either axis from the conjunction nukes legit chrome.
64
+ function isFullViewportOverlay(style) {
65
+ if (!/position\s*:\s*(?:fixed|sticky)/i.test(style)) {
66
+ return false;
67
+ }
68
+ const zIndexMatch = /z-index\s*:\s*(\d+)/i.exec(style);
69
+ if (!zIndexMatch || Number.parseInt(zIndexMatch[1], 10) < 1000) {
70
+ return false;
71
+ }
72
+ // `inset:0` is shorthand for top/right/bottom/left = 0, so it satisfies BOTH
73
+ // axes at once and is checked up front.
74
+ if (/inset\s*:\s*0/i.test(style)) {
75
+ return true;
76
+ }
77
+ const widthFull = /width\s*:\s*100(?:%|vw)/i.test(style) ||
78
+ (/left\s*:\s*0/i.test(style) && /right\s*:\s*0/i.test(style));
79
+ const heightFull = /height\s*:\s*100(?:%|vh)/i.test(style) ||
80
+ (/top\s*:\s*0/i.test(style) && /bottom\s*:\s*0/i.test(style));
81
+ return widthFull && heightFull;
82
+ }
83
+ // Removes before Readability scores them — these poison density math and leak
84
+ // into the article. Inline-style matches require the full isFullViewportOverlay
85
+ // conjunction; `position:fixed` alone never qualifies.
86
+ export function stripChrome(document) {
87
+ let removed = 0;
88
+ const consentEls = document.querySelectorAll(CONSENT_SELECTORS.join(','));
89
+ for (const el of consentEls) {
90
+ if (!el.isConnected) {
91
+ continue;
92
+ }
93
+ el.remove();
94
+ removed++;
95
+ }
96
+ const styledEls = document.querySelectorAll('[style]');
97
+ for (const el of styledEls) {
98
+ if (!el.isConnected) {
99
+ continue;
100
+ }
101
+ if (isFullViewportOverlay(el.getAttribute('style') ?? '')) {
102
+ el.remove();
103
+ removed++;
104
+ }
105
+ }
106
+ return removed;
107
+ }
108
+ // Unambiguous boilerplate signatures. Bare `related`/`subscribe` are
109
+ // intentionally excluded — they match legit content (related-products section
110
+ // on a store, a "subscribe to RSS" line inside an article) far too often.
111
+ // Substring matching means `newsletter` also covers `newsletter-signup`, etc.;
112
+ // the longer spellings are kept as explicit documentation of intended targets.
113
+ const BOILERPLATE_TOKENS = [
114
+ 'newsletter',
115
+ 'newsletter-signup',
116
+ 'mailing-list',
117
+ 'email-signup',
118
+ 'subscribe-form',
119
+ 'signup-form',
120
+ 'related-posts',
121
+ 'related-post',
122
+ 'read-next',
123
+ 'more-from',
124
+ 'you-might-also',
125
+ 'recommended-posts',
126
+ 'recommended',
127
+ ];
128
+ function signatureMatches(el) {
129
+ const signature = `${el.getAttribute('class') ?? ''} ${el.getAttribute('id') ?? ''}`.toLowerCase();
130
+ return BOILERPLATE_TOKENS.some(token => signature.includes(token));
131
+ }
132
+ // A boilerplate block is always a structural CONTAINER. A heading or paragraph
133
+ // can carry a token in its `id` (an anchor target like `id="related-posts"`) or
134
+ // `class`, and individually such a leaf passes the footprint guard — so without
135
+ // a container restriction the rule deletes real article content (e.g. an in-body
136
+ // section heading whose id happens to match). Reject non-containers early.
137
+ const BOILERPLATE_CONTAINER_TAGS = new Set([
138
+ 'ASIDE',
139
+ 'DIV',
140
+ 'FORM',
141
+ 'NAV',
142
+ 'OL',
143
+ 'SECTION',
144
+ 'UL',
145
+ ]);
146
+ // Strips "related posts" / newsletter signup / "read next" blocks Readability
147
+ // sometimes retains. Subtractive and high-risk, so a token hit alone is never
148
+ // enough: the footprint guard requires the candidate to be a small fraction of
149
+ // the body. The body length is snapshotted once so every candidate is judged
150
+ // against the same baseline — recomputing after each removal would make the
151
+ // threshold depend on iteration order. A boilerplate block is always a small
152
+ // slice of a real article; capping at 25% guarantees the worst case is leaving
153
+ // a small block in, never deleting a section that is itself a substantial part
154
+ // of the content. Idempotent and order-independent.
155
+ //
156
+ // Only outermost boilerplate roots are candidates. A single signature CONTAINER
157
+ // (e.g. <aside class="newsletter-subscribe">) puts the token on every descendant
158
+ // ("newsletter-title", "newsletter-submit", …); stripping those leaves
159
+ // independently mangles the container when the footprint guard preserves the
160
+ // root — a newsletter form shorn of its title and submit button. The ancestor
161
+ // walk rejects any element whose class+id already matched on an ancestor.
162
+ // querySelectorAll yields outer-before-inner order, and removing a root detaches
163
+ // its descendants (caught by !isConnected above), so this walk uniformly rejects
164
+ // leaves of both removed roots and preserved roots.
165
+ export function stripBoilerplate(document) {
166
+ const bodyLength = document.body.textContent.length;
167
+ const limit = 0.25 * bodyLength;
168
+ let removed = 0;
169
+ for (const el of document.querySelectorAll('[class],[id]')) {
170
+ if (!el.isConnected) {
171
+ continue;
172
+ }
173
+ if (!BOILERPLATE_CONTAINER_TAGS.has(el.tagName)) {
174
+ continue;
175
+ }
176
+ if (!signatureMatches(el)) {
177
+ continue;
178
+ }
179
+ let ancestor = el.parentElement;
180
+ while (ancestor && !signatureMatches(ancestor)) {
181
+ ancestor = ancestor.parentElement;
182
+ }
183
+ if (ancestor) {
184
+ continue;
185
+ }
186
+ if (el.textContent.length >= limit) {
187
+ continue;
188
+ }
189
+ el.remove();
190
+ removed++;
191
+ }
192
+ return removed;
193
+ }
194
+ // Scope the document by CSS selector: drop every `exclude` match, then (if
195
+ // `include` matches) replace the body with the first matching subtree.
196
+ export function applySelectors(document, selectors) {
197
+ if (!selectors) {
198
+ return;
199
+ }
200
+ if (selectors.exclude) {
201
+ for (const selector of selectors.exclude) {
202
+ document.querySelectorAll(selector).forEach(el => {
203
+ el.remove();
204
+ });
205
+ }
206
+ }
207
+ if (selectors.include) {
208
+ const body = document.body;
209
+ const root = body.querySelector(selectors.include);
210
+ if (root && root !== body) {
211
+ body.innerHTML = '';
212
+ body.appendChild(root);
213
+ }
214
+ }
215
+ }
216
+ // Matched anywhere in a src (case-insensitive) to flag a lazy-load placeholder.
217
+ const PLACEHOLDER_TOKENS = [
218
+ 'placeholder',
219
+ 'blank',
220
+ 'spacer',
221
+ 'lazy',
222
+ 'loading',
223
+ '1x1',
224
+ 'transparent',
225
+ 'pixel',
226
+ 'dummy',
227
+ ];
228
+ export function isPlaceholderSrc(src) {
229
+ if (!src) {
230
+ return true;
231
+ }
232
+ if (src.startsWith('data:')) {
233
+ return true;
234
+ }
235
+ const lowered = src.toLowerCase();
236
+ return PLACEHOLDER_TOKENS.some(token => lowered.includes(token));
237
+ }
238
+ function usableAttr(el, names) {
239
+ for (const name of names) {
240
+ const value = el.getAttribute(name);
241
+ if (value) {
242
+ return value;
243
+ }
244
+ }
245
+ return undefined;
246
+ }
247
+ // Largest-descriptor candidate wins (`Nw`/`Nx`, never mixed in valid srcset);
248
+ // first candidate wins when none carry a descriptor. Malformed entries are skipped.
249
+ function pickLargestSrcset(srcset) {
250
+ let bestUrl;
251
+ let bestValue = -1;
252
+ let firstUrl;
253
+ let sawDescriptor = false;
254
+ for (const raw of srcset.split(',')) {
255
+ const entry = raw.trim();
256
+ if (!entry) {
257
+ continue;
258
+ }
259
+ const splitAt = entry.search(/\s/);
260
+ const rawUrl = splitAt === -1 ? entry : entry.slice(0, splitAt);
261
+ const descriptor = splitAt === -1 ? '' : entry.slice(splitAt).trim();
262
+ const url = rawUrl.length >= 2 &&
263
+ ((rawUrl.startsWith('"') && rawUrl.endsWith('"')) ||
264
+ (rawUrl.startsWith("'") && rawUrl.endsWith("'")))
265
+ ? rawUrl.slice(1, -1)
266
+ : rawUrl;
267
+ if (!url) {
268
+ continue;
269
+ }
270
+ if (firstUrl === undefined) {
271
+ firstUrl = url;
272
+ }
273
+ if (!descriptor) {
274
+ continue;
275
+ }
276
+ const value = /^(\d+(?:\.\d+)?)[wx]$/.exec(descriptor);
277
+ if (!value) {
278
+ continue;
279
+ }
280
+ sawDescriptor = true;
281
+ const numeric = Number.parseFloat(value[1]);
282
+ if (numeric > bestValue) {
283
+ bestValue = numeric;
284
+ bestUrl = url;
285
+ }
286
+ }
287
+ return sawDescriptor ? bestUrl : firstUrl;
288
+ }
289
+ // Skip media-constrained <source>: art-direction crops for specific viewports,
290
+ // not the default image the page shows.
291
+ function resolveFromPicture(img) {
292
+ const picture = img.closest('picture');
293
+ if (!picture) {
294
+ return undefined;
295
+ }
296
+ for (const source of picture.querySelectorAll('source')) {
297
+ if (source.hasAttribute('media')) {
298
+ continue;
299
+ }
300
+ const srcset = source.getAttribute('srcset');
301
+ if (srcset) {
302
+ const url = pickLargestSrcset(srcset);
303
+ if (url) {
304
+ return url;
305
+ }
306
+ }
307
+ }
308
+ return undefined;
309
+ }
310
+ // Precedence (first usable wins): data-src → <picture><source srcset> → own
311
+ // srcset (largest) → data-original → data-lazy-src.
312
+ function resolveRealSource(img) {
313
+ const dataSrc = usableAttr(img, ['data-src']);
314
+ if (dataSrc) {
315
+ return dataSrc;
316
+ }
317
+ const fromPicture = resolveFromPicture(img);
318
+ if (fromPicture) {
319
+ return fromPicture;
320
+ }
321
+ const ownSrcset = img.getAttribute('srcset');
322
+ if (ownSrcset) {
323
+ const url = pickLargestSrcset(ownSrcset);
324
+ if (url) {
325
+ return url;
326
+ }
327
+ }
328
+ return usableAttr(img, ['data-original', 'data-lazy-src']);
329
+ }
330
+ // Swap placeholder src values for the real source before Readability clones.
331
+ // Only a placeholder-like current src is rewritten, so a real src is left alone
332
+ // even if a data-src also exists. Idempotent.
333
+ export function resolveLazyImages(document) {
334
+ let resolved = 0;
335
+ for (const img of document.querySelectorAll('img')) {
336
+ const currentSrc = img.getAttribute('src') ?? '';
337
+ if (!isPlaceholderSrc(currentSrc)) {
338
+ continue;
339
+ }
340
+ const real = resolveRealSource(img);
341
+ if (real && real !== currentSrc) {
342
+ img.setAttribute('src', real);
343
+ resolved++;
344
+ }
345
+ }
346
+ return resolved;
347
+ }
348
+ // Readability keeps a class only by literal `classesToPreserve.includes(cls)`
349
+ // equality, so non-canonical code-block conventions — GitHub's
350
+ // `highlight-source-js`, sandpack's `sp-javascript`, SyntaxHighlighter's
351
+ // `brush: js` — are stripped before turndown runs and the fence loses its
352
+ // language tag. Rewrite each convention to the canonical
353
+ // `<code class="language-X">` form before Readability clones the document.
354
+ const HIGHLIGHT_SOURCE_PREFIX = 'highlight-source-';
355
+ const LANGUAGE_PREFIX = 'language-';
356
+ const LANG_PREFIX = 'lang-';
357
+ const SP_PREFIX = 'sp-';
358
+ const BRUSH_RE = /brush:\s*([A-Za-z][\w-]*)/;
359
+ const BOGUS_TOKENS = new Set(['', 'highlight', 'source', 'sp']);
360
+ function isValidToken(token) {
361
+ return !BOGUS_TOKENS.has(token);
362
+ }
363
+ function hasLanguageClass(el) {
364
+ for (const cls of el.classList) {
365
+ if (cls !== LANGUAGE_PREFIX && cls.startsWith(LANGUAGE_PREFIX)) {
366
+ return true;
367
+ }
368
+ }
369
+ return false;
370
+ }
371
+ // Pool the <pre>'s own classes, then ancestors up to the highlight wrapper
372
+ // (capped at two levels), then any descendant <code>'s classes — in that order.
373
+ function collectClassSources(pre, code) {
374
+ const sources = [
375
+ { classes: [...pre.classList], raw: pre.getAttribute('class') ?? '' },
376
+ ];
377
+ let depth = 0;
378
+ let el = pre.parentElement;
379
+ while (el && depth < 2) {
380
+ sources.push({
381
+ classes: [...el.classList],
382
+ raw: el.getAttribute('class') ?? '',
383
+ });
384
+ depth++;
385
+ if (el.tagName === 'DIV' &&
386
+ [...el.classList].some(c => c.startsWith('highlight'))) {
387
+ break;
388
+ }
389
+ el = el.parentElement;
390
+ }
391
+ if (code) {
392
+ sources.push({
393
+ classes: [...code.classList],
394
+ raw: code.getAttribute('class') ?? '',
395
+ });
396
+ }
397
+ return sources;
398
+ }
399
+ function resolveCodeToken(pre, code) {
400
+ const sources = collectClassSources(pre, code);
401
+ function findByPrefix(prefix) {
402
+ for (const src of sources) {
403
+ for (const cls of src.classes) {
404
+ if (cls.startsWith(prefix)) {
405
+ const token = cls.slice(prefix.length).toLowerCase();
406
+ if (isValidToken(token)) {
407
+ return token;
408
+ }
409
+ }
410
+ }
411
+ }
412
+ return null;
413
+ }
414
+ // Convention priority: highlight-source > language > lang > sp > brush.
415
+ // `brush:` is a class-attribute value, not a single class token, so it cannot
416
+ // be matched by prefix and is parsed from the raw attribute string instead.
417
+ const highlightToken = findByPrefix(HIGHLIGHT_SOURCE_PREFIX);
418
+ if (highlightToken) {
419
+ return { token: highlightToken, fromHighlightSource: true };
420
+ }
421
+ const languageToken = findByPrefix(LANGUAGE_PREFIX);
422
+ if (languageToken) {
423
+ return { token: languageToken, fromHighlightSource: false };
424
+ }
425
+ const langToken = findByPrefix(LANG_PREFIX);
426
+ if (langToken) {
427
+ return { token: langToken, fromHighlightSource: false };
428
+ }
429
+ // Sandpack emits infra classes alongside the language class (sp-cm,
430
+ // sp-pristine, sp-pre-placeholder); unlike the single-token highlight-source
431
+ // / language / lang conventions, SP must pick the class whose token is a
432
+ // recognized language or it grabs an infra string the preserve list strips.
433
+ for (const src of sources) {
434
+ for (const cls of src.classes) {
435
+ if (!cls.startsWith(SP_PREFIX)) {
436
+ continue;
437
+ }
438
+ const token = cls.slice(SP_PREFIX.length).toLowerCase();
439
+ if (KNOWN_LANGUAGE_TOKENS.has(token)) {
440
+ return { token, fromHighlightSource: false };
441
+ }
442
+ }
443
+ }
444
+ for (const src of sources) {
445
+ const match = BRUSH_RE.exec(src.raw);
446
+ if (match) {
447
+ const token = match[1].toLowerCase();
448
+ if (isValidToken(token)) {
449
+ return { token, fromHighlightSource: false };
450
+ }
451
+ }
452
+ }
453
+ return null;
454
+ }
455
+ function canonicalizePre(pre) {
456
+ const code = pre.querySelector('code');
457
+ // turndown reads the language from the <code> class; if one is present the
458
+ // block is already canonical and rewriting it can only drop information.
459
+ if (code && hasLanguageClass(code)) {
460
+ return false;
461
+ }
462
+ const match = resolveCodeToken(pre, code);
463
+ if (!match) {
464
+ return false;
465
+ }
466
+ let target = code;
467
+ if (!target) {
468
+ target = pre.ownerDocument.createElement('code');
469
+ while (pre.firstChild) {
470
+ target.appendChild(pre.firstChild);
471
+ }
472
+ pre.appendChild(target);
473
+ }
474
+ const classes = [`language-${match.token}`];
475
+ if (target.classList.contains('hljs')) {
476
+ classes.push('hljs');
477
+ }
478
+ target.setAttribute('class', classes.join(' '));
479
+ // GitHub carries the language on a <div class="highlight"> wrapper rather than
480
+ // the <pre>; once that hint is moved onto <code>, hoist the <pre> out so the
481
+ // block stands alone instead of inside a div Readability scores separately.
482
+ if (match.fromHighlightSource) {
483
+ const parent = pre.parentElement;
484
+ if (parent?.tagName === 'DIV' &&
485
+ [...parent.classList].some(c => c.startsWith('highlight'))) {
486
+ parent.replaceWith(pre);
487
+ }
488
+ }
489
+ return true;
490
+ }
491
+ export function canonicalizeCodeBlocks(document) {
492
+ let count = 0;
493
+ for (const pre of document.querySelectorAll('pre')) {
494
+ if (!pre.isConnected) {
495
+ continue;
496
+ }
497
+ try {
498
+ if (canonicalizePre(pre)) {
499
+ count++;
500
+ }
501
+ }
502
+ catch {
503
+ // Defensive: malformed markup must not break the extract pipeline.
504
+ }
505
+ }
506
+ return count;
507
+ }
@@ -0,0 +1,10 @@
1
+ import { isProbablyReaderable, Readability } from '@mozilla/readability';
2
+ export function isReaderable(document) {
3
+ return isProbablyReaderable(document);
4
+ }
5
+ export function parseArticle(document, options) {
6
+ // Readability mutates its input document; clone so the pipeline's doc is untouched.
7
+ const clone = document.cloneNode(true);
8
+ const reader = new Readability(clone, options);
9
+ return reader.parse();
10
+ }