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