@v1nvn/readability 0.44.0 → 0.45.0

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 (71) hide show
  1. package/README.md +3 -3
  2. package/package.json +7 -9
  3. package/src/cli.ts +134 -0
  4. package/src/config.ts +42 -0
  5. package/src/dev.ts +77 -0
  6. package/src/errors.ts +19 -0
  7. package/src/handler.ts +8 -0
  8. package/src/host-sampling.ts +86 -0
  9. package/src/index.ts +27 -0
  10. package/src/logger.ts +3 -0
  11. package/src/output/format.ts +132 -0
  12. package/src/pipeline/cell-text.ts +56 -0
  13. package/src/pipeline/context.ts +88 -0
  14. package/src/pipeline/dom.ts +23 -0
  15. package/src/pipeline/footnotes.ts +181 -0
  16. package/src/pipeline/math.ts +130 -0
  17. package/src/pipeline/normalize.ts +594 -0
  18. package/src/pipeline/readability.ts +36 -0
  19. package/src/pipeline/resolver.ts +143 -0
  20. package/src/pipeline/sanitize.ts +42 -0
  21. package/src/pipeline/tables.ts +286 -0
  22. package/src/pipeline/turndown.ts +191 -0
  23. package/src/pipeline/urls.ts +13 -0
  24. package/src/policy/chunk.ts +482 -0
  25. package/src/policy/diagnostics.ts +96 -0
  26. package/src/policy/explain.ts +182 -0
  27. package/src/policy/fallback.ts +110 -0
  28. package/src/policy/gating.ts +106 -0
  29. package/src/policy/grid-detector.ts +313 -0
  30. package/src/policy/identifiers.ts +75 -0
  31. package/src/policy/images.ts +64 -0
  32. package/src/policy/list-detector.ts +308 -0
  33. package/src/policy/lost-signal.ts +69 -0
  34. package/src/policy/markdown.ts +76 -0
  35. package/src/policy/metadata.ts +312 -0
  36. package/src/policy/outline-chains.ts +215 -0
  37. package/src/policy/outline.ts +89 -0
  38. package/src/policy/pagination.ts +104 -0
  39. package/src/policy/presets.ts +142 -0
  40. package/src/policy/section.ts +127 -0
  41. package/src/policy/selector-lint.ts +186 -0
  42. package/src/policy/sibling-scan.ts +99 -0
  43. package/src/policy/text.ts +36 -0
  44. package/src/policy/truncate.ts +59 -0
  45. package/src/preset-cache.ts +221 -0
  46. package/src/resources.ts +317 -0
  47. package/src/sampling.ts +74 -0
  48. package/src/server.ts +85 -0
  49. package/src/tools/chunk_text.ts +65 -0
  50. package/src/tools/explain.ts +299 -0
  51. package/src/tools/extract.ts +368 -0
  52. package/src/tools/extract_grid.ts +90 -0
  53. package/src/tools/extract_links.ts +138 -0
  54. package/src/tools/extract_list.ts +97 -0
  55. package/src/tools/extract_metadata.ts +102 -0
  56. package/src/tools/extract_section.ts +84 -0
  57. package/src/tools/extract_tables.ts +101 -0
  58. package/src/tools/html-source.ts +40 -0
  59. package/src/tools/html_to_markdown.ts +180 -0
  60. package/src/tools/outline.ts +77 -0
  61. package/src/tools/output-schema.ts +715 -0
  62. package/src/tools/schemas.ts +442 -0
  63. package/src/tools/suggest-preset.ts +708 -0
  64. package/src/types/jsdom.d.ts +15 -0
  65. package/src/types/turndown-plugin-gfm.d.ts +11 -0
  66. package/dist/assets/cli-Dfjg7dOQ.js +0 -76
  67. package/dist/assets/cli-Dfjg7dOQ.js.map +0 -1
  68. package/dist/assets/extract-CdDp-KwX.js +0 -2880
  69. package/dist/assets/extract-CdDp-KwX.js.map +0 -1
  70. package/dist/index.js +0 -2267
  71. package/dist/index.js.map +0 -1
@@ -0,0 +1,312 @@
1
+ import type { Metadata, StructuredData } from '../pipeline/context.ts';
2
+ import type { ReadabilityParseResult } from '../pipeline/readability.ts';
3
+
4
+ import { estimateTokens, nonEmpty } from './text.ts';
5
+
6
+ const ARTICLE_TYPES = new Set([
7
+ 'Article',
8
+ 'BlogPosting',
9
+ 'NewsArticle',
10
+ 'Report',
11
+ 'ScholarlyArticle',
12
+ 'SocialMediaPosting',
13
+ 'TechArticle',
14
+ 'WebPage',
15
+ ]);
16
+
17
+ type JsonLdValue =
18
+ boolean | JsonLdObject | JsonLdValue[] | null | number | string;
19
+ interface JsonLdObject {
20
+ readonly [key: string]: JsonLdValue;
21
+ }
22
+
23
+ function first(...values: readonly (string | undefined)[]): string | undefined {
24
+ for (const value of values) {
25
+ const picked = nonEmpty(value);
26
+ if (picked) {
27
+ return picked;
28
+ }
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ function metaProperty(
34
+ document: Document,
35
+ property: string,
36
+ ): string | undefined {
37
+ return nonEmpty(
38
+ document
39
+ .querySelector(`meta[property="${property}"]`)
40
+ ?.getAttribute('content') ?? undefined,
41
+ );
42
+ }
43
+
44
+ function metaName(document: Document, name: string): string | undefined {
45
+ return nonEmpty(
46
+ document.querySelector(`meta[name="${name}"]`)?.getAttribute('content') ??
47
+ undefined,
48
+ );
49
+ }
50
+
51
+ // Flatten ld+json scripts (a script may hold an array or a @graph array).
52
+ // Invalid JSON is skipped — never crash extraction over malformed metadata.
53
+ function parseJsonLd(document: Document): JsonLdObject[] {
54
+ const nodes = document.querySelectorAll('script[type="application/ld+json"]');
55
+ const out: JsonLdObject[] = [];
56
+ nodes.forEach(node => {
57
+ const raw = node.textContent;
58
+ if (!raw.trim()) {
59
+ return;
60
+ }
61
+ let parsed: JsonLdValue;
62
+ try {
63
+ parsed = JSON.parse(raw) as JsonLdValue;
64
+ } catch {
65
+ return;
66
+ }
67
+ collectObjects(parsed, out);
68
+ });
69
+ return out;
70
+ }
71
+
72
+ function collectObjects(value: JsonLdValue, out: JsonLdObject[]): void {
73
+ if (!value || typeof value !== 'object') {
74
+ return;
75
+ }
76
+ if (Array.isArray(value)) {
77
+ for (const item of value) {
78
+ collectObjects(item, out);
79
+ }
80
+ return;
81
+ }
82
+ // `in` guard: the index signature types values as JsonLdValue (no undefined),
83
+ // so a `!== undefined` check has no overlap.
84
+ if ('@graph' in value) {
85
+ collectObjects(value['@graph'], out);
86
+ }
87
+ out.push(value);
88
+ }
89
+
90
+ function typeMatches(type: JsonLdValue): boolean {
91
+ const types = Array.isArray(type) ? type : [type];
92
+ return types.some(t => typeof t === 'string' && ARTICLE_TYPES.has(t));
93
+ }
94
+
95
+ function pickArticleNode(
96
+ candidates: readonly JsonLdObject[],
97
+ ): JsonLdObject | undefined {
98
+ if (candidates.length === 0) {
99
+ return undefined;
100
+ }
101
+ return candidates.find(node => typeMatches(node['@type'])) ?? candidates[0];
102
+ }
103
+
104
+ // Preference order for the primary structured object: richest schema.org
105
+ // main-entity first. Article types are the tail fallback — Readability already
106
+ // captured the article body, so they are low-value-but-recognizable. Generic
107
+ // graph scaffolding (WebSite/Organization/BreadcrumbList) is intentionally NOT
108
+ // listed: emitting it as `structured` would be noise, and we prefer undefined
109
+ // over a misleading trivial node.
110
+ const STRUCTURED_PRIORITY = [
111
+ 'Recipe',
112
+ 'Product',
113
+ 'Event',
114
+ 'HowTo',
115
+ 'Course',
116
+ 'Movie',
117
+ 'Book',
118
+ 'MusicRecording',
119
+ 'JobPosting',
120
+ 'FAQPage',
121
+ ...ARTICLE_TYPES,
122
+ ] as const;
123
+
124
+ function nodeTypeList(node: JsonLdObject): readonly string[] {
125
+ const type = node['@type'];
126
+ if (Array.isArray(type)) {
127
+ return type.filter((t): t is string => typeof t === 'string');
128
+ }
129
+ return typeof type === 'string' ? [type] : [];
130
+ }
131
+
132
+ // Iterate priority types in order so a Recipe wins over an earlier Article
133
+ // node, and document order wins among candidates of the same type.
134
+ function pickStructuredObject(
135
+ candidates: readonly JsonLdObject[],
136
+ ): JsonLdObject | undefined {
137
+ for (const priorityType of STRUCTURED_PRIORITY) {
138
+ const hit = candidates.find(node =>
139
+ nodeTypeList(node).includes(priorityType),
140
+ );
141
+ if (hit) {
142
+ return hit;
143
+ }
144
+ }
145
+ return undefined;
146
+ }
147
+
148
+ // Strip @context (always https://schema.org, pure noise) and normalize a
149
+ // multi-typed node to a "+"-joined string so the host sees a single type label.
150
+ // Data fields are kept verbatim — the host wants the real schema.org object.
151
+ function cleanStructured(obj: JsonLdObject): StructuredData {
152
+ const out: Record<string, unknown> = {};
153
+ for (const [key, value] of Object.entries(obj)) {
154
+ if (key === '@context') {
155
+ continue;
156
+ }
157
+ if (key === '@type') {
158
+ const types = Array.isArray(value)
159
+ ? value.filter((t): t is string => typeof t === 'string')
160
+ : typeof value === 'string'
161
+ ? [value]
162
+ : [];
163
+ out[key] = types.join('+');
164
+ continue;
165
+ }
166
+ out[key] = value;
167
+ }
168
+ return out;
169
+ }
170
+
171
+ // Author may be a string, a Person/Organization object, an array, or a
172
+ // {@list: [...]} container; reduce to a comma-joined byline.
173
+ function resolveJsonLdAuthor(author: JsonLdValue): string | undefined {
174
+ const names: string[] = [];
175
+ function visit(value: JsonLdValue): void {
176
+ if (typeof value === 'string') {
177
+ const trimmed = value.trim();
178
+ if (trimmed) {
179
+ names.push(trimmed);
180
+ }
181
+ return;
182
+ }
183
+ if (!value || typeof value !== 'object') {
184
+ return;
185
+ }
186
+ if (Array.isArray(value)) {
187
+ value.forEach(visit);
188
+ return;
189
+ }
190
+ // {@list: [...]} is an ordering wrapper; unwrap it.
191
+ const list = value['@list'];
192
+ if (Array.isArray(list)) {
193
+ list.forEach(visit);
194
+ return;
195
+ }
196
+ const name = value.name;
197
+ if (typeof name === 'string') {
198
+ const trimmed = name.trim();
199
+ if (trimmed) {
200
+ names.push(trimmed);
201
+ }
202
+ }
203
+ }
204
+ visit(author);
205
+ return names.length > 0 ? names.join(', ') : undefined;
206
+ }
207
+
208
+ function asString(value: JsonLdValue | undefined): string | undefined {
209
+ return typeof value === 'string' ? nonEmpty(value) : undefined;
210
+ }
211
+
212
+ function field(
213
+ obj: JsonLdValue | undefined,
214
+ key: string,
215
+ ): JsonLdValue | undefined {
216
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
217
+ return undefined;
218
+ }
219
+ return obj[key];
220
+ }
221
+
222
+ export interface MetadataInput {
223
+ readonly baseUrl?: string;
224
+ readonly document: Document;
225
+ readonly readability?: null | ReadabilityParseResult;
226
+ readonly readingTimeMin: number;
227
+ readonly textContent: string;
228
+ readonly wordCount: number;
229
+ }
230
+
231
+ export function resolveMetadata(input: Readonly<MetadataInput>): Metadata {
232
+ const { document, readability } = input;
233
+
234
+ const jsonLdObjects = parseJsonLd(document);
235
+ const jsonLd = pickArticleNode(jsonLdObjects);
236
+ const structuredRaw = pickStructuredObject(jsonLdObjects);
237
+ const htmlLang = nonEmpty(
238
+ document.documentElement.getAttribute('lang') ?? undefined,
239
+ );
240
+ const titleFromTitleTag = nonEmpty(document.title);
241
+
242
+ const title = first(
243
+ asString(jsonLd?.headline),
244
+ metaProperty(document, 'og:title'),
245
+ metaName(document, 'twitter:title'),
246
+ readability?.title ?? undefined,
247
+ titleFromTitleTag,
248
+ );
249
+
250
+ const byline = first(
251
+ jsonLd ? resolveJsonLdAuthor(jsonLd.author) : undefined,
252
+ metaProperty(document, 'article:author'),
253
+ metaName(document, 'author'),
254
+ readability?.byline ?? undefined,
255
+ );
256
+
257
+ const siteName = first(
258
+ asString(field(field(jsonLd, 'publisher'), 'name')),
259
+ metaProperty(document, 'og:site_name'),
260
+ readability?.siteName ?? undefined,
261
+ );
262
+
263
+ const lang = first(
264
+ asString(jsonLd?.inLanguage),
265
+ htmlLang,
266
+ readability?.lang ?? undefined,
267
+ );
268
+
269
+ const publishedTime = first(
270
+ asString(jsonLd?.datePublished),
271
+ metaProperty(document, 'article:published_time'),
272
+ // First <time datetime> is the canonical publish time when rendered inline.
273
+ nonEmpty(
274
+ document.querySelector('time[datetime]')?.getAttribute('datetime') ??
275
+ undefined,
276
+ ),
277
+ readability?.publishedTime ?? undefined,
278
+ );
279
+
280
+ const excerpt = first(
281
+ asString(jsonLd?.description),
282
+ metaProperty(document, 'og:description'),
283
+ metaName(document, 'twitter:description'),
284
+ metaName(document, 'description'),
285
+ readability?.excerpt ?? undefined,
286
+ );
287
+
288
+ // canonical/og:url are absolute by spec; returned raw so callers can tell
289
+ // the declared canonical apart from the input baseUrl (origin context only).
290
+ const canonical = first(
291
+ nonEmpty(
292
+ document.querySelector('link[rel="canonical"]')?.getAttribute('href') ??
293
+ undefined,
294
+ ),
295
+ metaProperty(document, 'og:url'),
296
+ );
297
+
298
+ return {
299
+ title,
300
+ byline,
301
+ siteName,
302
+ lang,
303
+ publishedTime,
304
+ excerpt,
305
+ canonical,
306
+ baseUrl: input.baseUrl,
307
+ wordCount: input.wordCount,
308
+ readingTimeMin: input.readingTimeMin,
309
+ ...estimateTokens(input.textContent),
310
+ ...(structuredRaw ? { structured: cleanStructured(structuredRaw) } : {}),
311
+ };
312
+ }
@@ -0,0 +1,215 @@
1
+ import type { SelectorScope } from '../pipeline/normalize.ts';
2
+
3
+ import { buildDocument } from '../pipeline/dom.ts';
4
+ import {
5
+ applySelectors,
6
+ normalizeDocument,
7
+ resolveLazyImages,
8
+ } from '../pipeline/normalize.ts';
9
+ import { isGeneratedIdentifier } from './identifiers.ts';
10
+
11
+ // Step-0 measured thresholds: 200 own chars is the chain view that produced a
12
+ // working proposal at ~630 tokens, and the debris that view misses (video
13
+ // controls, captions) holds under 200 own chars — the round-two sweep runs at
14
+ // 40 to surface it.
15
+ export const OUTLINE_OWN_TEXT_MIN = 200;
16
+ export const BLOCK_OWN_TEXT_MIN = 40;
17
+ export const OUTLINE_MAX_CHARS = 6000;
18
+ export const OUTLINE_MAX_CHAINS = 60;
19
+ const SAMPLE_MAX_CHARS = 80;
20
+
21
+ const NON_CONTENT_SELECTOR = 'script, style, noscript, template';
22
+
23
+ const HOP_ATTRIBUTE_WHITELIST = ['itemprop', 'role', 'data-testid'] as const;
24
+
25
+ export interface OutlineResult {
26
+ readonly chainCount: number;
27
+ readonly text: string;
28
+ readonly truncated: boolean;
29
+ }
30
+
31
+ export interface OutlineInput {
32
+ readonly baseUrl?: string;
33
+ readonly cleanChrome: boolean;
34
+ readonly html: string;
35
+ // `page` renders the round-one view of the whole normalized document; a
36
+ // scope renders the round-two view of the included subtree after the scope
37
+ // has been applied for real.
38
+ readonly mode: 'page' | { readonly scope: SelectorScope };
39
+ }
40
+
41
+ interface Candidate {
42
+ readonly element: Element;
43
+ readonly own: number;
44
+ }
45
+
46
+ // The document the outline renders is normalized exactly as extraction
47
+ // normalizes it, so a selector proposed from this material validates and
48
+ // applies against the same tree the pipeline will run it on.
49
+ export function buildChainOutline(input: OutlineInput): OutlineResult {
50
+ const { document } = buildDocument(input.html, input.baseUrl);
51
+ normalizeDocument(document, { cleanChrome: input.cleanChrome });
52
+ resolveLazyImages(document);
53
+ const scope = input.mode === 'page' ? undefined : input.mode.scope;
54
+ if (scope) {
55
+ applySelectors(document, scope);
56
+ }
57
+ const scopeRoot = scope?.include
58
+ ? (document.body.querySelector(scope.include) ?? undefined)
59
+ : undefined;
60
+
61
+ const min = scope ? BLOCK_OWN_TEXT_MIN : OUTLINE_OWN_TEXT_MIN;
62
+ const candidates: Candidate[] = [];
63
+ for (const element of (scopeRoot ?? document.body).querySelectorAll('*')) {
64
+ if (element.closest(NON_CONTENT_SELECTOR)) {
65
+ continue;
66
+ }
67
+ const own = ownTextLength(element);
68
+ if (own >= min) {
69
+ candidates.push({ element, own });
70
+ }
71
+ }
72
+
73
+ return scope
74
+ ? groupedOutline(candidates, scopeRoot)
75
+ : flatOutline(candidates);
76
+ }
77
+
78
+ // Round one: one full ancestor chain per qualifying node, step-0 format.
79
+ function flatOutline(candidates: Candidate[]): OutlineResult {
80
+ const lines: string[] = [];
81
+ let truncated = false;
82
+ for (const candidate of [...candidates].sort((a, b) => b.own - a.own)) {
83
+ if (lines.length >= OUTLINE_MAX_CHAINS) {
84
+ truncated = true;
85
+ break;
86
+ }
87
+ const hops = ancestorsUpTo(candidate.element, undefined).map(element =>
88
+ hopWithSizes(element),
89
+ );
90
+ const line = hops.join(' > ');
91
+ if (lines.join('\n---\n').length + line.length > OUTLINE_MAX_CHARS) {
92
+ truncated = true;
93
+ break;
94
+ }
95
+ lines.push(line);
96
+ }
97
+ return { chainCount: lines.length, text: lines.join('\n---\n'), truncated };
98
+ }
99
+
100
+ // Round two: the included subtree's remaining text blocks, grouped by
101
+ // structural chain so dozens of prose paragraphs collapse into one line and
102
+ // the odd control-text/caption container stands alone. Smallest own text
103
+ // first — that is where the debris lives.
104
+ function groupedOutline(
105
+ candidates: Candidate[],
106
+ scopeRoot: Element | undefined,
107
+ ): OutlineResult {
108
+ const groups = new Map<
109
+ string,
110
+ { chain: string; count: number; max: number; min: number; sample: string }
111
+ >();
112
+ for (const candidate of candidates) {
113
+ const chain = ancestorsUpTo(candidate.element, scopeRoot).map(element =>
114
+ renderHop(element),
115
+ );
116
+ if (scopeRoot) {
117
+ chain.push(renderHop(scopeRoot));
118
+ }
119
+ const key = chain.join(' > ');
120
+ const existing = groups.get(key);
121
+ if (existing) {
122
+ existing.count += 1;
123
+ existing.min = Math.min(existing.min, candidate.own);
124
+ existing.max = Math.max(existing.max, candidate.own);
125
+ } else {
126
+ groups.set(key, {
127
+ chain: key,
128
+ count: 1,
129
+ min: candidate.own,
130
+ max: candidate.own,
131
+ sample: sampleText(candidate.element),
132
+ });
133
+ }
134
+ }
135
+
136
+ const lines: string[] = [];
137
+ let truncated = false;
138
+ const sorted = [...groups.values()].sort((a, b) => a.min - b.min);
139
+ for (const group of sorted) {
140
+ if (lines.length >= OUTLINE_MAX_CHAINS) {
141
+ truncated = true;
142
+ break;
143
+ }
144
+ const line = `${group.chain} (own:${group.min}..${group.max}, ×${group.count}) sample: "${group.sample}"`;
145
+ if (lines.join('\n').length + line.length > OUTLINE_MAX_CHARS) {
146
+ truncated = true;
147
+ break;
148
+ }
149
+ lines.push(line);
150
+ }
151
+ return { chainCount: lines.length, text: lines.join('\n'), truncated };
152
+ }
153
+
154
+ // Nearest-to-leaf first; the walk stops below <body> (the root applySelectors
155
+ // never matches) and below the include root when one is given — the caller
156
+ // prepends that root's own hop where it is wanted.
157
+ function ancestorsUpTo(element: Element, stop: Element | undefined): Element[] {
158
+ const hops: Element[] = [];
159
+ let node: Element | null = element;
160
+ const body = element.ownerDocument.body;
161
+ while (node && node !== stop && node !== body) {
162
+ hops.push(node);
163
+ node = node.parentElement;
164
+ }
165
+ return hops;
166
+ }
167
+
168
+ function hopWithSizes(element: Element): string {
169
+ const own = ownTextLength(element);
170
+ const all = element.textContent.length;
171
+ return `${renderHop(element)} (own:${own}, all:${all})`;
172
+ }
173
+
174
+ function ownTextLength(element: Element): number {
175
+ let own = 0;
176
+ for (const node of element.childNodes) {
177
+ if (node.nodeType === node.TEXT_NODE) {
178
+ own += (node.textContent ?? '').length;
179
+ }
180
+ }
181
+ return own;
182
+ }
183
+
184
+ // Copy-safety: what renders here is what the suggester may type back. Every
185
+ // token is real CSS — real attribute names and values, and classes/ids that
186
+ // survive the generated-identifier lint, so a hash class can never be copied
187
+ // into a proposal because it was never shown.
188
+ function renderHop(element: Element): string {
189
+ let hop = element.tagName.toLowerCase();
190
+ const id = element.id;
191
+ if (id && !isGeneratedIdentifier(id)) {
192
+ hop += `#${id}`;
193
+ }
194
+ const classes = (element.getAttribute('class') ?? '')
195
+ .split(/\s+/)
196
+ .filter(Boolean)
197
+ .filter(className => !isGeneratedIdentifier(className));
198
+ if (classes.length > 0) {
199
+ hop += `.${classes.join('.')}`;
200
+ }
201
+ for (const attribute of HOP_ATTRIBUTE_WHITELIST) {
202
+ const value = element.getAttribute(attribute);
203
+ if (value) {
204
+ hop += `[${attribute}="${value}"]`;
205
+ }
206
+ }
207
+ return hop;
208
+ }
209
+
210
+ function sampleText(element: Element): string {
211
+ const text = element.textContent.trim().replace(/\s+/g, ' ');
212
+ return text.length > SAMPLE_MAX_CHARS
213
+ ? `${text.slice(0, SAMPLE_MAX_CHARS)}…`
214
+ : text;
215
+ }
@@ -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
+ }