@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,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
+ }