@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,71 @@
1
+ // Collapse whitespace, trim, then strip leading/trailing `#` (GitHub permalinks
2
+ // append `<a>#</a>`, so heading textContent ends with `#`).
3
+ export function normalizeHeadingText(raw) {
4
+ const collapsed = raw.replace(/\s+/g, ' ').trim();
5
+ return collapsed.replace(/^#+|#+$/g, '').trim();
6
+ }
7
+ // GFM anchor algorithm (comrak `anchorize`): keep Unicode letters, marks,
8
+ // numbers, and connector punctuation; map spaces to hyphens. No hyphen collapse
9
+ // or trim, so anchors match GitHub and non-Latin scripts survive. An all-symbol
10
+ // heading yields an empty slug; fall back to `section` so anchors stay non-empty.
11
+ function slugify(text) {
12
+ const cleaned = text
13
+ .toLowerCase()
14
+ .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, '');
15
+ return cleaned.replace(/ /g, '-') || 'section';
16
+ }
17
+ // `href="#"` (empty fragment) does not win — fall through to slugify.
18
+ function linkAnchor(heading) {
19
+ for (const link of heading.querySelectorAll('a[href^="#"]')) {
20
+ const href = link.getAttribute('href');
21
+ if (!href) {
22
+ continue;
23
+ }
24
+ const fragment = href.slice(1);
25
+ if (fragment) {
26
+ return fragment;
27
+ }
28
+ }
29
+ return undefined;
30
+ }
31
+ function resolveCandidate(heading, text) {
32
+ const id = heading.getAttribute('id');
33
+ if (id) {
34
+ return { anchor: id, explicit: true };
35
+ }
36
+ const link = linkAnchor(heading);
37
+ if (link) {
38
+ return { anchor: link, explicit: true };
39
+ }
40
+ return { anchor: slugify(text), explicit: false };
41
+ }
42
+ // Explicit anchors are kept verbatim; generated slugs are collision-suffixed
43
+ // (`-1`, `-2`, …), first occurrence bare.
44
+ function dedupe(candidate, explicit, used) {
45
+ if (explicit || !used.has(candidate)) {
46
+ return candidate;
47
+ }
48
+ let suffix = 1;
49
+ while (used.has(`${candidate}-${suffix}`)) {
50
+ suffix++;
51
+ }
52
+ return `${candidate}-${suffix}`;
53
+ }
54
+ export function resolveOutline(document) {
55
+ const entries = [];
56
+ const used = new Set();
57
+ const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
58
+ headings.forEach(heading => {
59
+ const rawText = heading.textContent;
60
+ if (!rawText.trim()) {
61
+ return;
62
+ }
63
+ const text = normalizeHeadingText(rawText);
64
+ const level = Number.parseInt(heading.tagName.slice(1), 10);
65
+ const { anchor: candidate, explicit } = resolveCandidate(heading, text);
66
+ const anchor = dedupe(candidate, explicit, used);
67
+ used.add(anchor);
68
+ entries.push({ anchor, level, text });
69
+ });
70
+ return entries;
71
+ }
@@ -0,0 +1,89 @@
1
+ export interface OutlineEntry {
2
+ readonly anchor: string;
3
+ readonly level: number;
4
+ readonly text: string;
5
+ }
6
+
7
+ // Collapse whitespace, trim, then strip leading/trailing `#` (GitHub permalinks
8
+ // append `<a>#</a>`, so heading textContent ends with `#`).
9
+ export function normalizeHeadingText(raw: string): string {
10
+ const collapsed = raw.replace(/\s+/g, ' ').trim();
11
+ return collapsed.replace(/^#+|#+$/g, '').trim();
12
+ }
13
+
14
+ // GFM anchor algorithm (comrak `anchorize`): keep Unicode letters, marks,
15
+ // numbers, and connector punctuation; map spaces to hyphens. No hyphen collapse
16
+ // or trim, so anchors match GitHub and non-Latin scripts survive. An all-symbol
17
+ // heading yields an empty slug; fall back to `section` so anchors stay non-empty.
18
+ function slugify(text: string): string {
19
+ const cleaned = text
20
+ .toLowerCase()
21
+ .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, '');
22
+ return cleaned.replace(/ /g, '-') || 'section';
23
+ }
24
+
25
+ // `href="#"` (empty fragment) does not win — fall through to slugify.
26
+ function linkAnchor(heading: Element): string | undefined {
27
+ for (const link of heading.querySelectorAll('a[href^="#"]')) {
28
+ const href = link.getAttribute('href');
29
+ if (!href) {
30
+ continue;
31
+ }
32
+ const fragment = href.slice(1);
33
+ if (fragment) {
34
+ return fragment;
35
+ }
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ function resolveCandidate(
41
+ heading: Element,
42
+ text: string,
43
+ ): { readonly anchor: string; readonly explicit: boolean } {
44
+ const id = heading.getAttribute('id');
45
+ if (id) {
46
+ return { anchor: id, explicit: true };
47
+ }
48
+ const link = linkAnchor(heading);
49
+ if (link) {
50
+ return { anchor: link, explicit: true };
51
+ }
52
+ return { anchor: slugify(text), explicit: false };
53
+ }
54
+
55
+ // Explicit anchors are kept verbatim; generated slugs are collision-suffixed
56
+ // (`-1`, `-2`, …), first occurrence bare.
57
+ function dedupe(
58
+ candidate: string,
59
+ explicit: boolean,
60
+ used: Set<string>,
61
+ ): string {
62
+ if (explicit || !used.has(candidate)) {
63
+ return candidate;
64
+ }
65
+ let suffix = 1;
66
+ while (used.has(`${candidate}-${suffix}`)) {
67
+ suffix++;
68
+ }
69
+ return `${candidate}-${suffix}`;
70
+ }
71
+
72
+ export function resolveOutline(document: Document): OutlineEntry[] {
73
+ const entries: OutlineEntry[] = [];
74
+ const used = new Set<string>();
75
+ const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
76
+ headings.forEach(heading => {
77
+ const rawText = heading.textContent;
78
+ if (!rawText.trim()) {
79
+ return;
80
+ }
81
+ const text = normalizeHeadingText(rawText);
82
+ const level = Number.parseInt(heading.tagName.slice(1), 10);
83
+ const { anchor: candidate, explicit } = resolveCandidate(heading, text);
84
+ const anchor = dedupe(candidate, explicit, used);
85
+ used.add(anchor);
86
+ entries.push({ anchor, level, text });
87
+ });
88
+ return entries;
89
+ }
@@ -0,0 +1,79 @@
1
+ import { absolutize } from "../pipeline/urls.js";
2
+ // Short pagination-style link text only; matching prose like "next, we cover
3
+ // rendering" would burn an article's internal link as a next-page signal.
4
+ // `next ›/»/→` and bare arrows cover the common next-page glyphs in one place.
5
+ const NEXT_LINK_TEXT_RE = /^(next(\s+page)?|older(\s+posts?)?|[›»→]|next\s*[›»→]|older\s*[›»→])$/i;
6
+ function usableHref(href) {
7
+ if (!href || href === '#') {
8
+ return undefined;
9
+ }
10
+ return href;
11
+ }
12
+ function findPaginated(document, baseUrl) {
13
+ const linkNext = document.querySelector('link[rel="next"][href]');
14
+ if (linkNext) {
15
+ const href = usableHref(linkNext.getAttribute('href'));
16
+ if (href) {
17
+ return { type: 'paginated', nextUrl: absolutize(href, baseUrl) };
18
+ }
19
+ }
20
+ const aRelNext = document.querySelector('a[rel="next"][href]');
21
+ if (aRelNext) {
22
+ const href = usableHref(aRelNext.getAttribute('href'));
23
+ if (href) {
24
+ return { type: 'paginated', nextUrl: absolutize(href, baseUrl) };
25
+ }
26
+ }
27
+ for (const anchor of document.querySelectorAll('a[href]')) {
28
+ const text = anchor.textContent.trim();
29
+ if (!text || !NEXT_LINK_TEXT_RE.test(text)) {
30
+ continue;
31
+ }
32
+ const href = usableHref(anchor.getAttribute('href'));
33
+ if (!href) {
34
+ continue;
35
+ }
36
+ return { type: 'paginated', nextUrl: absolutize(href, baseUrl) };
37
+ }
38
+ return undefined;
39
+ }
40
+ const INFINITE_ATTR_SELECTORS = [
41
+ '[data-load-more]',
42
+ '[data-infinite-scroll]',
43
+ '[data-pagination]',
44
+ '[infinite-scroll]',
45
+ ];
46
+ const INFINITE_SUBSTRING_SELECTORS = [
47
+ '[class*="load-more"]',
48
+ '[class*="loadmore"]',
49
+ '[class*="infinite"]',
50
+ '[id*="load-more"]',
51
+ '[class*="sentinel"]',
52
+ ];
53
+ const LOAD_MORE_BUTTON_RE = /^(load more|show more|view more|more results|load more comments)$/i;
54
+ function findInfinite(document) {
55
+ for (const selector of INFINITE_ATTR_SELECTORS) {
56
+ const el = document.querySelector(selector);
57
+ if (el?.isConnected) {
58
+ return { selector, type: 'infinite' };
59
+ }
60
+ }
61
+ for (const selector of INFINITE_SUBSTRING_SELECTORS) {
62
+ const el = document.querySelector(selector);
63
+ if (el?.isConnected) {
64
+ return { selector, type: 'infinite' };
65
+ }
66
+ }
67
+ for (const button of document.querySelectorAll('button')) {
68
+ const text = button.textContent.trim();
69
+ if (text && LOAD_MORE_BUTTON_RE.test(text)) {
70
+ return { selector: 'button', type: 'infinite' };
71
+ }
72
+ }
73
+ return undefined;
74
+ }
75
+ // Paginated beats infinite: an explicit "next" link is a stronger signal than
76
+ // a load-more sentinel. Read-only — never follows or fetches.
77
+ export function detectPagination(document, baseUrl) {
78
+ return findPaginated(document, baseUrl) ?? findInfinite(document);
79
+ }
@@ -0,0 +1,104 @@
1
+ import { absolutize } from '../pipeline/urls.ts';
2
+
3
+ export interface PaginationSignal {
4
+ readonly nextUrl?: string;
5
+ readonly selector?: string;
6
+ readonly type: 'infinite' | 'paginated';
7
+ }
8
+
9
+ // Short pagination-style link text only; matching prose like "next, we cover
10
+ // rendering" would burn an article's internal link as a next-page signal.
11
+ // `next ›/»/→` and bare arrows cover the common next-page glyphs in one place.
12
+ const NEXT_LINK_TEXT_RE =
13
+ /^(next(\s+page)?|older(\s+posts?)?|[›»→]|next\s*[›»→]|older\s*[›»→])$/i;
14
+
15
+ function usableHref(href: null | string): string | undefined {
16
+ if (!href || href === '#') {
17
+ return undefined;
18
+ }
19
+ return href;
20
+ }
21
+
22
+ function findPaginated(
23
+ document: Document,
24
+ baseUrl: string | undefined,
25
+ ): PaginationSignal | undefined {
26
+ const linkNext = document.querySelector('link[rel="next"][href]');
27
+ if (linkNext) {
28
+ const href = usableHref(linkNext.getAttribute('href'));
29
+ if (href) {
30
+ return { type: 'paginated', nextUrl: absolutize(href, baseUrl) };
31
+ }
32
+ }
33
+
34
+ const aRelNext = document.querySelector('a[rel="next"][href]');
35
+ if (aRelNext) {
36
+ const href = usableHref(aRelNext.getAttribute('href'));
37
+ if (href) {
38
+ return { type: 'paginated', nextUrl: absolutize(href, baseUrl) };
39
+ }
40
+ }
41
+
42
+ for (const anchor of document.querySelectorAll('a[href]')) {
43
+ const text = anchor.textContent.trim();
44
+ if (!text || !NEXT_LINK_TEXT_RE.test(text)) {
45
+ continue;
46
+ }
47
+ const href = usableHref(anchor.getAttribute('href'));
48
+ if (!href) {
49
+ continue;
50
+ }
51
+ return { type: 'paginated', nextUrl: absolutize(href, baseUrl) };
52
+ }
53
+
54
+ return undefined;
55
+ }
56
+
57
+ const INFINITE_ATTR_SELECTORS = [
58
+ '[data-load-more]',
59
+ '[data-infinite-scroll]',
60
+ '[data-pagination]',
61
+ '[infinite-scroll]',
62
+ ] as const;
63
+
64
+ const INFINITE_SUBSTRING_SELECTORS = [
65
+ '[class*="load-more"]',
66
+ '[class*="loadmore"]',
67
+ '[class*="infinite"]',
68
+ '[id*="load-more"]',
69
+ '[class*="sentinel"]',
70
+ ] as const;
71
+
72
+ const LOAD_MORE_BUTTON_RE =
73
+ /^(load more|show more|view more|more results|load more comments)$/i;
74
+
75
+ function findInfinite(document: Document): PaginationSignal | undefined {
76
+ for (const selector of INFINITE_ATTR_SELECTORS) {
77
+ const el = document.querySelector(selector);
78
+ if (el?.isConnected) {
79
+ return { selector, type: 'infinite' };
80
+ }
81
+ }
82
+ for (const selector of INFINITE_SUBSTRING_SELECTORS) {
83
+ const el = document.querySelector(selector);
84
+ if (el?.isConnected) {
85
+ return { selector, type: 'infinite' };
86
+ }
87
+ }
88
+ for (const button of document.querySelectorAll('button')) {
89
+ const text = button.textContent.trim();
90
+ if (text && LOAD_MORE_BUTTON_RE.test(text)) {
91
+ return { selector: 'button', type: 'infinite' };
92
+ }
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ // Paginated beats infinite: an explicit "next" link is a stronger signal than
98
+ // a load-more sentinel. Read-only — never follows or fetches.
99
+ export function detectPagination(
100
+ document: Document,
101
+ baseUrl?: string,
102
+ ): PaginationSignal | undefined {
103
+ return findPaginated(document, baseUrl) ?? findInfinite(document);
104
+ }
@@ -0,0 +1,100 @@
1
+ const presets = new Map();
2
+ // Bumped on every store mutation so cache keys can fold in preset state
3
+ // without hashing the resolved scope: an entry extracted before a preset
4
+ // landed must never be served after it.
5
+ let generation = 0;
6
+ export function presetGeneration() {
7
+ return generation;
8
+ }
9
+ export function resetPresets() {
10
+ presets.clear();
11
+ generation += 1;
12
+ }
13
+ export function addPreset(preset) {
14
+ const key = normalizeSiteKey(preset.site);
15
+ if (!key) {
16
+ throw new Error(`invalid preset site: ${preset.site}`);
17
+ }
18
+ presets.set(key, preset);
19
+ generation += 1;
20
+ }
21
+ export function removePreset(site) {
22
+ const key = normalizeSiteKey(site);
23
+ const removed = key !== undefined && presets.delete(key);
24
+ if (removed) {
25
+ generation += 1;
26
+ }
27
+ return removed;
28
+ }
29
+ export function presetForSite(site) {
30
+ const key = normalizeSiteKey(site);
31
+ return key ? presets.get(key) : undefined;
32
+ }
33
+ // Accepts a full URL or a bare host; `www.` is the one prefix every site's own
34
+ // pages agree on, so exactly one strip — `www.www.com` keys as `www.com`.
35
+ export function normalizeSiteKey(value) {
36
+ const raw = value?.trim();
37
+ if (!raw) {
38
+ return undefined;
39
+ }
40
+ let host;
41
+ try {
42
+ host = new URL(raw).hostname.toLowerCase();
43
+ }
44
+ catch {
45
+ host = raw.toLowerCase();
46
+ }
47
+ const key = host.startsWith('www.') ? host.slice('www.'.length) : host;
48
+ return /^[a-z0-9.-]+$/.test(key) ? key : undefined;
49
+ }
50
+ // A selector that throws cannot match anything, so a malformed preset selector
51
+ // is a miss by the same predicate as a non-matching one — never a crash.
52
+ export function selectorMisses(scope, selector) {
53
+ try {
54
+ return scope.querySelector(selector) === null;
55
+ }
56
+ catch {
57
+ return true;
58
+ }
59
+ }
60
+ function presetMatches(document, preset) {
61
+ for (const detector of preset.detectors) {
62
+ if (selectorMisses(document, detector)) {
63
+ return false;
64
+ }
65
+ }
66
+ // The include must still match: an unmatched include is applySelectors'
67
+ // silent no-op, and reporting applied:true over baseline output is a lie.
68
+ if (preset.scope.include &&
69
+ selectorMisses(document.body, preset.scope.include)) {
70
+ return false;
71
+ }
72
+ // Excludes must execute but need not match — the debris they name is absent
73
+ // from some pages of the site, and that is not staleness.
74
+ for (const selector of preset.scope.exclude ?? []) {
75
+ try {
76
+ document.querySelectorAll(selector);
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ return true;
83
+ }
84
+ export function resolvePreset(document, baseUrl, overridden) {
85
+ const site = normalizeSiteKey(baseUrl);
86
+ if (!site) {
87
+ return undefined;
88
+ }
89
+ const preset = presets.get(site);
90
+ if (!preset) {
91
+ return undefined;
92
+ }
93
+ if (overridden) {
94
+ return { signal: { applied: false, reason: 'overridden', site } };
95
+ }
96
+ if (!presetMatches(document, preset)) {
97
+ return { signal: { applied: false, reason: 'detectors-missed', site } };
98
+ }
99
+ return { scope: preset.scope, signal: { applied: true, site } };
100
+ }
@@ -0,0 +1,142 @@
1
+ // Structural twin of the pipeline's SelectorScope — policy cannot import upward.
2
+ export interface PresetScope {
3
+ readonly exclude?: readonly string[];
4
+ readonly include?: string;
5
+ }
6
+
7
+ export interface SitePreset {
8
+ readonly detectors: readonly string[];
9
+ readonly scope: PresetScope;
10
+ readonly site: string;
11
+ }
12
+
13
+ export type PresetRejection = 'detectors-missed' | 'overridden';
14
+
15
+ export interface PresetSignal {
16
+ readonly applied: boolean;
17
+ readonly reason?: PresetRejection;
18
+ readonly site: string;
19
+ }
20
+
21
+ export interface PresetResolution {
22
+ readonly scope?: PresetScope;
23
+ readonly signal: PresetSignal;
24
+ }
25
+
26
+ const presets = new Map<string, SitePreset>();
27
+
28
+ // Bumped on every store mutation so cache keys can fold in preset state
29
+ // without hashing the resolved scope: an entry extracted before a preset
30
+ // landed must never be served after it.
31
+ let generation = 0;
32
+
33
+ export function presetGeneration(): number {
34
+ return generation;
35
+ }
36
+
37
+ export function resetPresets(): void {
38
+ presets.clear();
39
+ generation += 1;
40
+ }
41
+
42
+ export function addPreset(preset: SitePreset): void {
43
+ const key = normalizeSiteKey(preset.site);
44
+ if (!key) {
45
+ throw new Error(`invalid preset site: ${preset.site}`);
46
+ }
47
+ presets.set(key, preset);
48
+ generation += 1;
49
+ }
50
+
51
+ export function removePreset(site: string): boolean {
52
+ const key = normalizeSiteKey(site);
53
+ const removed = key !== undefined && presets.delete(key);
54
+ if (removed) {
55
+ generation += 1;
56
+ }
57
+ return removed;
58
+ }
59
+
60
+ export function presetForSite(
61
+ site: string | undefined,
62
+ ): SitePreset | undefined {
63
+ const key = normalizeSiteKey(site);
64
+ return key ? presets.get(key) : undefined;
65
+ }
66
+
67
+ // Accepts a full URL or a bare host; `www.` is the one prefix every site's own
68
+ // pages agree on, so exactly one strip — `www.www.com` keys as `www.com`.
69
+ export function normalizeSiteKey(
70
+ value: string | undefined,
71
+ ): string | undefined {
72
+ const raw = value?.trim();
73
+ if (!raw) {
74
+ return undefined;
75
+ }
76
+ let host: string;
77
+ try {
78
+ host = new URL(raw).hostname.toLowerCase();
79
+ } catch {
80
+ host = raw.toLowerCase();
81
+ }
82
+ const key = host.startsWith('www.') ? host.slice('www.'.length) : host;
83
+ return /^[a-z0-9.-]+$/.test(key) ? key : undefined;
84
+ }
85
+
86
+ // A selector that throws cannot match anything, so a malformed preset selector
87
+ // is a miss by the same predicate as a non-matching one — never a crash.
88
+ export function selectorMisses(scope: ParentNode, selector: string): boolean {
89
+ try {
90
+ return scope.querySelector(selector) === null;
91
+ } catch {
92
+ return true;
93
+ }
94
+ }
95
+
96
+ function presetMatches(document: Document, preset: SitePreset): boolean {
97
+ for (const detector of preset.detectors) {
98
+ if (selectorMisses(document, detector)) {
99
+ return false;
100
+ }
101
+ }
102
+ // The include must still match: an unmatched include is applySelectors'
103
+ // silent no-op, and reporting applied:true over baseline output is a lie.
104
+ if (
105
+ preset.scope.include &&
106
+ selectorMisses(document.body, preset.scope.include)
107
+ ) {
108
+ return false;
109
+ }
110
+ // Excludes must execute but need not match — the debris they name is absent
111
+ // from some pages of the site, and that is not staleness.
112
+ for (const selector of preset.scope.exclude ?? []) {
113
+ try {
114
+ document.querySelectorAll(selector);
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+ return true;
120
+ }
121
+
122
+ export function resolvePreset(
123
+ document: Document,
124
+ baseUrl: string | undefined,
125
+ overridden: boolean,
126
+ ): PresetResolution | undefined {
127
+ const site = normalizeSiteKey(baseUrl);
128
+ if (!site) {
129
+ return undefined;
130
+ }
131
+ const preset = presets.get(site);
132
+ if (!preset) {
133
+ return undefined;
134
+ }
135
+ if (overridden) {
136
+ return { signal: { applied: false, reason: 'overridden', site } };
137
+ }
138
+ if (!presetMatches(document, preset)) {
139
+ return { signal: { applied: false, reason: 'detectors-missed', site } };
140
+ }
141
+ return { scope: preset.scope, signal: { applied: true, site } };
142
+ }
@@ -0,0 +1,111 @@
1
+ import { isElement } from "../pipeline/dom.js";
2
+ import { normalizeHeadingText } from "./outline.js";
3
+ // Match the first heading by normalized text. Exact equality wins; otherwise
4
+ // fall back to the first substring match so a short query ("auth") still
5
+ // lands on a longer heading ("Authentication") without forcing an exact match.
6
+ function findHeading(document, query) {
7
+ const needle = normalizeHeadingText(query).toLowerCase();
8
+ if (!needle) {
9
+ return undefined;
10
+ }
11
+ const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
12
+ let substring;
13
+ for (const heading of headings) {
14
+ const text = normalizeHeadingText(heading.textContent).toLowerCase();
15
+ if (!text) {
16
+ continue;
17
+ }
18
+ const level = Number.parseInt(heading.tagName.slice(1), 10);
19
+ if (text === needle) {
20
+ return { heading, level };
21
+ }
22
+ if (!substring && text.includes(needle)) {
23
+ substring = { heading, level };
24
+ }
25
+ }
26
+ return substring;
27
+ }
28
+ // Elements whose direct children form the section flow. The matched heading's
29
+ // nearest such ancestor is the level at which the section's content lives as
30
+ // siblings: on docs sites (GitHub markdown, etc.) the heading is wrapped in
31
+ // <div class="markdown-heading">, so the body <p> is a sibling of the wrapper
32
+ // div, not of the <h2>. Walking the <h2>'s own siblings would capture only the
33
+ // permalink anchor and miss the body.
34
+ const FLOW_CONTAINERS = new Set([
35
+ 'ARTICLE',
36
+ 'ASIDE',
37
+ 'BLOCKQUOTE',
38
+ 'BODY',
39
+ 'DD',
40
+ 'DETAILS',
41
+ 'DT',
42
+ 'FOOTER',
43
+ 'HEADER',
44
+ 'LI',
45
+ 'MAIN',
46
+ 'NAV',
47
+ 'SECTION',
48
+ 'TD',
49
+ ]);
50
+ function findFlowContainer(heading) {
51
+ let ancestor = heading.parentElement;
52
+ while (ancestor) {
53
+ if (FLOW_CONTAINERS.has(ancestor.tagName)) {
54
+ return ancestor;
55
+ }
56
+ ancestor = ancestor.parentElement;
57
+ }
58
+ // BODY is in FLOW_CONTAINERS, so the loop reaches it for any attached
59
+ // heading; this fallback only covers a detached heading.
60
+ return heading.ownerDocument.body;
61
+ }
62
+ // Level of the first heading at or inside `el`, or undefined when `el` is
63
+ // neither a heading nor a wrapper around one. Used to test whether a following
64
+ // sibling terminates the section.
65
+ function firstHeadingLevel(el) {
66
+ const direct = /^H([1-6])$/.exec(el.tagName);
67
+ if (direct) {
68
+ return Number.parseInt(direct[1], 10);
69
+ }
70
+ const inner = el.querySelector('h1, h2, h3, h4, h5, h6');
71
+ if (inner) {
72
+ return Number.parseInt(inner.tagName.slice(1), 10);
73
+ }
74
+ return undefined;
75
+ }
76
+ // Wraps the matched heading and its subtree in a `<section data-rdrm-section-scope>`
77
+ // so the existing selectors.include path can isolate it. Returns false (and
78
+ // leaves the document untouched) when no heading matches.
79
+ export function scopeToHeading(document, headingText) {
80
+ const match = findHeading(document, headingText);
81
+ if (!match) {
82
+ return false;
83
+ }
84
+ const { heading, level } = match;
85
+ const container = findFlowContainer(heading);
86
+ let startChild = heading;
87
+ while (startChild.parentElement && startChild.parentElement !== container) {
88
+ startChild = startChild.parentElement;
89
+ }
90
+ const wrap = document.createElement('section');
91
+ wrap.setAttribute('data-rdrm-section-scope', '');
92
+ container.insertBefore(wrap, startChild);
93
+ let node = startChild;
94
+ while (node) {
95
+ const next = node.nextSibling;
96
+ wrap.appendChild(node);
97
+ // A section ends at the next same-or-higher-level heading (level <= L):
98
+ // deeper headings (h3 under an h2) belong to this section, peers and
99
+ // shallower headings start the next one. The terminating sibling may be a
100
+ // wrapper around the heading (e.g. <div class="markdown-heading">), so the
101
+ // check looks for a heading at OR inside the sibling.
102
+ if (next !== null && isElement(next)) {
103
+ const nextLevel = firstHeadingLevel(next);
104
+ if (nextLevel !== undefined && nextLevel <= level) {
105
+ break;
106
+ }
107
+ }
108
+ node = next;
109
+ }
110
+ return true;
111
+ }