@mintlify/scraping 4.0.1192 → 4.0.1193

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mintlify/scraping",
3
- "version": "4.0.1192",
3
+ "version": "4.0.1193",
4
4
  "description": "Scrape documentation frameworks to Mintlify docs",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -87,5 +87,5 @@
87
87
  "optional": true
88
88
  }
89
89
  },
90
- "gitHead": "3590240b0aa99295166a574adbc3510cd2ea7c11"
90
+ "gitHead": "ab2c294d7086f0c5b03babf75baee85a9d4ab608"
91
91
  }
@@ -1,6 +1,7 @@
1
- import { hastToText } from '@mintlify/common';
1
+ import { hastToText, safeCleanHeadingId } from '@mintlify/common';
2
2
  import fse from 'fs-extra';
3
3
  import type { Element, ElementContent, Root as HastRoot } from 'hast';
4
+ import { randomUUID } from 'node:crypto';
4
5
  import path from 'node:path';
5
6
  import { visit, SKIP } from 'unist-util-visit';
6
7
 
@@ -8,7 +9,12 @@ import { htmlToHast } from '../../pipeline/root.js';
8
9
  import { turnChildrenIntoMdx } from '../../utils/children.js';
9
10
  import { removeHastComments } from '../../utils/hastComments.js';
10
11
  import { hastToMdx } from '../convert.js';
11
- import type { SdkNavGroup, SdkPage, SdkReference } from '../types.js';
12
+ import { escapeInline, jsxString, safeHref } from '../mdx.js';
13
+ import { rewriteSphinxImages } from '../sphinx-images.js';
14
+ import { splitSphinxModules, type SphinxModuleDoc } from '../sphinx-modules.js';
15
+ import { buildSphinxNavigation, type SphinxNavigationDoc } from '../sphinx-navigation.js';
16
+ import { renderSphinxLinkedCode, renderSphinxSignature } from '../sphinx-signatures.js';
17
+ import type { SdkPage, SdkReference } from '../types.js';
12
18
 
13
19
  const SKIP_PAGES = new Set(['genindex', 'search', 'py-modindex']);
14
20
  const LANG_MARKER = '@@mintlang:';
@@ -26,51 +32,71 @@ const ADMONITION_TAGS: Record<string, string> = {
26
32
  attention: 'Warning',
27
33
  };
28
34
 
29
- type SphinxDoc = { slug: string; title: string; body: string; tag?: string };
30
-
31
35
  export async function convertSphinx(sourcePath: string): Promise<SdkReference> {
32
- const docs = await loadDocs(sourcePath);
36
+ const docs = splitSphinxModules(await loadDocs(sourcePath));
33
37
  const slugs = new Set(docs.map((doc) => doc.slug));
38
+ const anchors = collectAnchors(docs);
39
+ return {
40
+ pages: docs.map((doc) => renderPage(doc, slugs, anchors)),
41
+ groups: buildSphinxNavigation(docs),
42
+ };
43
+ }
34
44
 
35
- const guides = docs
36
- .filter((doc) => !doc.tag)
37
- .sort((left, right) =>
38
- left.slug === 'index' ? -1 : right.slug === 'index' ? 1 : left.slug.localeCompare(right.slug)
39
- );
40
- const modules = docs
41
- .filter((doc) => doc.tag)
42
- .sort((left, right) => left.slug.localeCompare(right.slug));
43
-
44
- const pages = [...guides, ...modules].map((doc) => renderPage(doc, slugs));
45
- const groups: SdkNavGroup[] = [];
46
- if (guides.length) groups.push({ group: 'Getting Started', pages: guides.map((d) => d.slug) });
47
- if (modules.length) groups.push({ group: 'API Reference', pages: modules.map((d) => d.slug) });
45
+ type SphinxAnchors = Map<string, Map<string, string>>;
48
46
 
49
- return { pages, groups };
47
+ function collectAnchors(docs: SphinxModuleDoc[]): SphinxAnchors {
48
+ const anchors: SphinxAnchors = new Map();
49
+ const ordered = [
50
+ ...docs.filter((doc) => !doc.sourceSlug),
51
+ ...docs.filter((doc) => doc.sourceSlug),
52
+ ];
53
+ for (const doc of ordered) {
54
+ const source = doc.sourceSlug ?? doc.slug;
55
+ const targets = anchors.get(source) ?? new Map<string, string>();
56
+ visit(htmlToHast(doc.body), 'element', (node: Element) => {
57
+ if (typeof node.properties.id === 'string')
58
+ targets.set(anchorId(node.properties.id), doc.slug);
59
+ });
60
+ anchors.set(source, targets);
61
+ }
62
+ return anchors;
50
63
  }
51
64
 
52
- async function loadDocs(sourcePath: string): Promise<SphinxDoc[]> {
53
- const docs: SphinxDoc[] = [];
65
+ async function loadDocs(sourcePath: string): Promise<SphinxNavigationDoc[]> {
66
+ const docs: SphinxNavigationDoc[] = [];
54
67
  for (const file of await collectFjsonFiles(sourcePath)) {
55
- const raw = (await fse.readJson(file)) as {
56
- body?: string;
57
- title?: string;
58
- current_page_name?: string;
59
- };
68
+ const raw: unknown = await fse.readJson(file);
69
+ if (!raw || typeof raw !== 'object' || !('body' in raw)) continue;
70
+ if (typeof raw.body !== 'string' || !raw.body.trim()) continue;
60
71
  const slug =
61
- raw.current_page_name ??
62
- path
63
- .relative(sourcePath, file)
64
- .split(path.sep)
65
- .join('/')
66
- .replace(/\.fjson$/, '');
72
+ 'current_page_name' in raw && typeof raw.current_page_name === 'string'
73
+ ? raw.current_page_name
74
+ : path
75
+ .relative(sourcePath, file)
76
+ .split(path.sep)
77
+ .join('/')
78
+ .replace(/\.fjson$/, '');
67
79
  if (SKIP_PAGES.has(slug) || slug.startsWith('_modules')) continue;
68
- if (!raw.body?.trim()) continue;
80
+ const parents: { link: string }[] = [];
81
+ if ('parents' in raw && Array.isArray(raw.parents)) {
82
+ for (const parent of raw.parents) {
83
+ if (
84
+ parent &&
85
+ typeof parent === 'object' &&
86
+ 'link' in parent &&
87
+ typeof parent.link === 'string'
88
+ ) {
89
+ parents.push({ link: parent.link });
90
+ }
91
+ }
92
+ }
69
93
  docs.push({
70
94
  slug,
71
- title: cleanTitle(raw.title ?? slug),
95
+ title: cleanTitle('title' in raw && typeof raw.title === 'string' ? raw.title : slug),
72
96
  body: raw.body,
73
97
  tag: /<dl class="py[ "]/.test(raw.body) ? 'MODULE' : undefined,
98
+ toc: 'toc' in raw && typeof raw.toc === 'string' ? raw.toc : undefined,
99
+ parents,
74
100
  });
75
101
  }
76
102
  return docs;
@@ -87,18 +113,38 @@ async function collectFjsonFiles(dir: string): Promise<string[]> {
87
113
  return files.sort();
88
114
  }
89
115
 
90
- function renderPage(doc: SphinxDoc, slugs: Set<string>): SdkPage {
116
+ function renderPage(doc: SphinxModuleDoc, slugs: Set<string>, anchors: SphinxAnchors): SdkPage {
91
117
  const hast = htmlToHast(doc.body);
118
+ const generated = new Map<string, string>();
119
+ const prefix = `MINTSPHINX${randomUUID().replaceAll('-', '')}`;
120
+ function marker(content: string): string {
121
+ const marker = `${prefix}BLOCK${generated.size}END`;
122
+ generated.set(marker, content);
123
+ return marker;
124
+ }
125
+ function block(content: string): Element {
126
+ return {
127
+ type: 'element',
128
+ tagName: 'p',
129
+ properties: {},
130
+ children: [{ type: 'text', value: marker(content) }],
131
+ };
132
+ }
92
133
  removeHastComments(hast);
93
- removeSphinxChrome(hast);
134
+ removeSphinxChrome(hast, doc.title);
94
135
  const description = extractDescription(hast);
95
- rewriteLinks(hast, doc.slug, slugs);
96
- transformAdmonitions(hast);
136
+ rewriteLinks(hast, doc, slugs, anchors);
137
+ rewriteSphinxImages(hast, doc.sourceSlug ?? doc.slug, doc.slug);
97
138
  markHighlightLanguages(hast);
98
- transformPyDls(hast, 0);
99
- const content = hastToMdx(hast)
139
+ preserveAnchors(hast, block);
140
+ transformPyDls(hast, 0, block);
141
+ preserveLinkedCode(hast, marker);
142
+ transformAdmonitions(hast);
143
+ let content = hastToMdx(hast)
100
144
  .replace(FENCE_MARKER, '$1$2\n')
101
145
  .replace(/^[> ]*@@mintlang:[\w+-]+@@\n?/gm, '');
146
+ for (const [marker, replacement] of generated)
147
+ content = content.replaceAll(marker, () => replacement);
102
148
  return {
103
149
  slug: doc.slug,
104
150
  title: doc.title,
@@ -108,8 +154,7 @@ function renderPage(doc: SphinxDoc, slugs: Set<string>): SdkPage {
108
154
  };
109
155
  }
110
156
 
111
- function removeSphinxChrome(hast: HastRoot): void {
112
- let removedH1 = false;
157
+ function removeSphinxChrome(hast: HastRoot, title: string): void {
113
158
  visit(hast, 'element', (node: Element, index, parent) => {
114
159
  if (!parent || typeof index !== 'number') return;
115
160
  const isHeaderlink = node.tagName === 'a' && classList(node).includes('headerlink');
@@ -118,33 +163,120 @@ function removeSphinxChrome(hast: HastRoot): void {
118
163
  node.children.some(
119
164
  (child) => child.type === 'element' && classList(child).includes('viewcode-link')
120
165
  );
121
- const isFirstH1 = node.tagName === 'h1' && !removedH1;
122
- if (isFirstH1) removedH1 = true;
123
- if (isHeaderlink || isViewcode || isFirstH1) {
166
+ if (isHeaderlink || isViewcode) {
124
167
  parent.children.splice(index, 1);
125
168
  return index;
126
169
  }
127
170
  });
171
+ let removedTitle = false;
172
+ visit(hast, 'element', (node: Element, index, parent) => {
173
+ if (removedTitle || !parent || typeof index !== 'number') return;
174
+ if (
175
+ node.tagName !== 'h1' &&
176
+ !(/^h[1-6]$/.test(node.tagName) && cleanTitle(hastToText(node)) === title)
177
+ )
178
+ return;
179
+ removedTitle = true;
180
+ parent.children.splice(index, 1);
181
+ return index;
182
+ });
128
183
  }
129
184
 
130
- function rewriteLinks(hast: HastRoot, fromSlug: string, slugs: Set<string>): void {
185
+ function rewriteLinks(
186
+ hast: HastRoot,
187
+ doc: SphinxModuleDoc,
188
+ slugs: Set<string>,
189
+ anchors: SphinxAnchors
190
+ ): void {
131
191
  visit(hast, 'element', (node: Element) => {
132
192
  if (node.tagName !== 'a' || typeof node.properties.href !== 'string') return;
133
- const rewritten = rewriteHref(node.properties.href, fromSlug, slugs);
193
+ const rewritten = rewriteHref(node.properties.href, doc, slugs, anchors);
134
194
  if (rewritten === undefined) delete node.properties.href;
135
195
  else node.properties.href = rewritten;
136
196
  });
137
197
  }
138
198
 
139
- function rewriteHref(href: string, fromSlug: string, slugs: Set<string>): string | undefined {
140
- if (/^(https?:|mailto:|#)/.test(href)) return href;
141
- const [target = ''] = href.split('#');
142
- let resolved = path.posix
143
- .normalize(path.posix.join(path.posix.dirname(fromSlug), target))
144
- .replace(/\/$/, '')
145
- .replace(/\.html?$/, '');
146
- if (resolved === '.' || resolved === '') resolved = 'index';
147
- return slugs.has(resolved) ? `/${resolved}` : undefined;
199
+ function rewriteHref(
200
+ href: string,
201
+ doc: SphinxModuleDoc,
202
+ slugs: Set<string>,
203
+ anchors: SphinxAnchors
204
+ ): string | undefined {
205
+ const fromSlug = doc.sourceSlug ?? doc.slug;
206
+ const safe = safeHref(href);
207
+ if (safe === undefined || /^(https?:|mailto:)/i.test(safe)) return safe;
208
+ const hashIndex = safe.indexOf('#');
209
+ const hash = hashIndex < 0 ? '' : `#${anchorId(safe.slice(hashIndex + 1))}`;
210
+ const beforeHash = hashIndex < 0 ? safe : safe.slice(0, hashIndex);
211
+ const queryIndex = beforeHash.indexOf('?');
212
+ const query = queryIndex < 0 ? '' : beforeHash.slice(queryIndex);
213
+ let target = queryIndex < 0 ? beforeHash : beforeHash.slice(0, queryIndex);
214
+ function destination(slug: string): string {
215
+ let owner = slug;
216
+ if (hash.length > 1) owner = anchors.get(slug)?.get(hash.slice(1)) ?? slug;
217
+ if (!target && owner === doc.slug) return `${query}${hash}`;
218
+ return `/${owner}${query}${hash}`;
219
+ }
220
+ if (!target) return destination(fromSlug);
221
+ try {
222
+ target = decodeURIComponent(target);
223
+ } catch {
224
+ return undefined;
225
+ }
226
+ const base = path.posix.dirname(fromSlug);
227
+ let candidates = [path.posix.join(base, target), path.posix.join(fromSlug, target)];
228
+ if (target.endsWith('/') && path.posix.basename(fromSlug) !== 'index') candidates.reverse();
229
+ if (target.startsWith('/')) candidates = [target.slice(1)];
230
+ for (const candidate of candidates) {
231
+ let resolved = path.posix
232
+ .normalize(candidate)
233
+ .replace(/\.(?:html?|fjson)$/, '')
234
+ .replace(/\/$/, '');
235
+ if (resolved === '.' || resolved === '') resolved = 'index';
236
+ if (slugs.has(resolved)) return destination(resolved);
237
+ const index = path.posix.join(resolved, 'index');
238
+ if (slugs.has(index)) return destination(index);
239
+ }
240
+ return undefined;
241
+ }
242
+
243
+ function anchorId(value: string): string {
244
+ return safeCleanHeadingId(value)
245
+ .replace(/[<>"&]/g, '')
246
+ .replace(/\s+/g, '-');
247
+ }
248
+
249
+ function preserveAnchors(node: HastRoot | Element, block: (content: string) => Element): void {
250
+ const children: ElementContent[] = [];
251
+ for (const child of node.children) {
252
+ if (child.type === 'element') {
253
+ const id = child.properties.id;
254
+ if (typeof id === 'string' && !(child.tagName === 'dt' && classList(child).includes('sig'))) {
255
+ children.push(block(`<span id=${jsxString(anchorId(id))} />`));
256
+ }
257
+ preserveAnchors(child, block);
258
+ }
259
+ if (child.type !== 'doctype' && child.type !== 'mdxjsEsm') children.push(child);
260
+ }
261
+ node.children = children;
262
+ }
263
+
264
+ function preserveLinkedCode(node: HastRoot | Element, marker: (content: string) => string): void {
265
+ const children: ElementContent[] = [];
266
+ for (const child of node.children) {
267
+ if (child.type === 'element') {
268
+ if (child.tagName === 'code') {
269
+ const content = renderSphinxLinkedCode(child);
270
+ if (content) {
271
+ children.push({ type: 'text', value: marker(content) });
272
+ continue;
273
+ }
274
+ }
275
+ if (child.tagName !== 'pre') preserveLinkedCode(child, marker);
276
+ }
277
+ if (child.type !== 'doctype' && child.type !== 'mdxjsEsm') children.push(child);
278
+ }
279
+ node.children = children;
148
280
  }
149
281
 
150
282
  function transformAdmonitions(hast: HastRoot): void {
@@ -176,30 +308,48 @@ function markHighlightLanguages(hast: HastRoot): void {
176
308
  });
177
309
  }
178
310
 
179
- function transformPyDls(node: HastRoot | Element, depth: number): void {
311
+ function transformPyDls(
312
+ node: HastRoot | Element,
313
+ depth: number,
314
+ block: (content: string) => Element
315
+ ): void {
180
316
  const children: ElementContent[] = [];
181
- for (const child of node.children as ElementContent[]) {
317
+ for (const child of node.children) {
182
318
  if (child.type === 'element' && child.tagName === 'dl' && classList(child).includes('py')) {
183
- children.push(...explodePyDl(child, depth));
319
+ children.push(...explodePyDl(child, depth, block));
184
320
  } else {
185
- if (child.type === 'element') transformPyDls(child, depth);
186
- children.push(child);
321
+ if (child.type === 'element') transformPyDls(child, depth, block);
322
+ if (child.type !== 'doctype' && child.type !== 'mdxjsEsm') children.push(child);
187
323
  }
188
324
  }
189
325
  node.children = children;
190
326
  }
191
327
 
192
- function explodePyDl(dl: Element, depth: number): ElementContent[] {
328
+ function explodePyDl(
329
+ dl: Element,
330
+ depth: number,
331
+ block: (content: string) => Element
332
+ ): ElementContent[] {
193
333
  const callable = classList(dl).some((name) => ['method', 'function'].includes(name));
194
334
  const out: ElementContent[] = [];
195
- for (const child of dl.children) {
335
+ for (const [index, child] of dl.children.entries()) {
196
336
  if (child.type !== 'element') continue;
197
337
  if (child.tagName === 'dt' && classList(child).includes('sig')) {
198
338
  const name = findDescName(child);
199
- if (name) out.push(heading(Math.min(3 + depth, 6), callable ? `${name}()` : name));
200
- out.push(pythonCodeBlock(hastToText(child).replace(/\s+/g, ' ').trim()));
339
+ if (name) {
340
+ const title = escapeInline(callable ? `${name}()` : name);
341
+ const id = child.properties.id;
342
+ const suffix = typeof id === 'string' ? ` {#${anchorId(id)}}` : '';
343
+ out.push(block(`${'#'.repeat(Math.min(3 + depth, 6))} ${title}${suffix}`));
344
+ }
345
+ const description = dl.children
346
+ .slice(index + 1)
347
+ .find(
348
+ (sibling): sibling is Element => sibling.type === 'element' && sibling.tagName === 'dd'
349
+ );
350
+ out.push(block(renderSphinxSignature(child, description)));
201
351
  } else if (child.tagName === 'dd') {
202
- transformPyDls(child, depth + 1);
352
+ transformPyDls(child, depth + 1, block);
203
353
  out.push(...child.children);
204
354
  }
205
355
  }
@@ -216,24 +366,6 @@ function findDescName(node: Element): string | undefined {
216
366
  return name;
217
367
  }
218
368
 
219
- function heading(level: number, text: string): Element {
220
- return {
221
- type: 'element',
222
- tagName: `h${level}`,
223
- properties: {},
224
- children: [{ type: 'text', value: text }],
225
- };
226
- }
227
-
228
- function pythonCodeBlock(code: string): Element {
229
- return {
230
- type: 'element',
231
- tagName: 'pre',
232
- properties: {},
233
- children: [{ type: 'text', value: `${LANG_MARKER}python@@\n${code}` }],
234
- };
235
- }
236
-
237
369
  function extractDescription(hast: HastRoot): string | undefined {
238
370
  let description: string | undefined;
239
371
  visit(hast, 'element', (node: Element, _index, parent) => {
@@ -249,11 +381,9 @@ function extractDescription(hast: HastRoot): string | undefined {
249
381
 
250
382
  function classList(node: Element): string[] {
251
383
  const value = node.properties.className;
252
- return Array.isArray(value)
253
- ? value.map(String)
254
- : typeof value === 'string'
255
- ? value.split(' ')
256
- : [];
384
+ if (Array.isArray(value)) return value.map(String);
385
+ if (typeof value === 'string') return value.split(' ');
386
+ return [];
257
387
  }
258
388
 
259
389
  function cleanTitle(title: string): string {
@@ -0,0 +1,25 @@
1
+ import type { Element, Root } from 'hast';
2
+ import path from 'node:path';
3
+ import { visit } from 'unist-util-visit';
4
+
5
+ import { safeHref } from './mdx.js';
6
+
7
+ export function rewriteSphinxImages(hast: Root, sourceSlug: string, destinationSlug: string): void {
8
+ const base = path.posix.relative(
9
+ path.posix.dirname(destinationSlug),
10
+ path.posix.dirname(sourceSlug)
11
+ );
12
+ if (!base) return;
13
+ visit(hast, 'element', (node: Element) => {
14
+ if (node.tagName !== 'img' || typeof node.properties.src !== 'string') return;
15
+ const href = safeHref(node.properties.src);
16
+ if (!href || href.startsWith('/') || href.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(href))
17
+ return;
18
+ const suffixStart = href.search(/[?#]/);
19
+ const file = suffixStart === -1 ? href : href.slice(0, suffixStart);
20
+ if (!file) return;
21
+ const suffix = suffixStart === -1 ? '' : href.slice(suffixStart);
22
+ const relative = path.posix.join(base, file);
23
+ node.properties.src = `${relative.startsWith('../') ? relative : `./${relative}`}${suffix}`;
24
+ });
25
+ }
@@ -0,0 +1,124 @@
1
+ import type { Element, ElementContent, Root } from 'hast';
2
+
3
+ import { htmlToHast } from '../pipeline/root.js';
4
+ import type { SphinxNavigationDoc } from './sphinx-navigation.js';
5
+
6
+ export type SphinxModuleDoc = SphinxNavigationDoc & { sourceSlug?: string };
7
+
8
+ type ModuleSection = { node: Element; name: string; start: number; end: number };
9
+
10
+ export function splitSphinxModules(docs: SphinxNavigationDoc[]): SphinxModuleDoc[] {
11
+ const occupied = new Set(docs.map((doc) => doc.slug));
12
+ const result: SphinxModuleDoc[] = [];
13
+ for (const doc of docs) {
14
+ const sections = moduleSections(htmlToHast(doc.body)).sort(
15
+ (left, right) => left.start - right.start
16
+ );
17
+ const slugs = sections.map((section) => `${doc.slug}/${section.name}`);
18
+ if (
19
+ sections.length < 2 ||
20
+ new Set(slugs).size !== slugs.length ||
21
+ slugs.some((slug) => occupied.has(slug))
22
+ ) {
23
+ result.push(doc);
24
+ continue;
25
+ }
26
+
27
+ let body = '';
28
+ let cursor = 0;
29
+ const aliases = new Set<string>();
30
+ const modules: SphinxModuleDoc[] = [];
31
+ for (const section of sections) {
32
+ const slug = `${doc.slug}/${section.name}`;
33
+ const title = sectionTitle(section.node) || section.name;
34
+ body += doc.body.slice(cursor, section.start);
35
+ for (const id of sectionIds(section.node)) {
36
+ if (aliases.has(id)) continue;
37
+ aliases.add(id);
38
+ body += `<span id="${escapeHtml(id)}"></span>`;
39
+ }
40
+ body += `<div class="toctree-wrapper"><ul><li><a class="reference internal" href="/${escapeHtml(slug)}">${escapeHtml(title)}</a></li></ul></div>`;
41
+ cursor = section.end;
42
+ occupied.add(slug);
43
+ modules.push({
44
+ slug,
45
+ title,
46
+ body: doc.body.slice(section.start, section.end),
47
+ tag: 'MODULE',
48
+ sourceSlug: doc.slug,
49
+ parents: [{ link: `/${doc.slug}` }],
50
+ });
51
+ }
52
+ body += doc.body.slice(cursor);
53
+ result.push({ ...doc, body, tag: 'MODULE' }, ...modules);
54
+ }
55
+ return result;
56
+ }
57
+
58
+ function moduleSections(node: Root | Element): ModuleSection[] {
59
+ const peers: ModuleSection[] = [];
60
+ const containers: Element[] = [];
61
+ for (const child of node.children) {
62
+ if (child.type !== 'element') continue;
63
+ const name = moduleName(child);
64
+ if (name) {
65
+ const start = child.position?.start.offset;
66
+ const end = child.position?.end.offset;
67
+ if (typeof start === 'number' && typeof end === 'number') {
68
+ peers.push({ node: child, name, start, end });
69
+ }
70
+ } else containers.push(child);
71
+ }
72
+ const sections = containers.flatMap(moduleSections);
73
+ if (peers.length > 1) return [...peers, ...sections];
74
+ return sections;
75
+ }
76
+
77
+ function moduleName(node: Element): string | undefined {
78
+ const id = node.properties.id;
79
+ if (typeof id !== 'string' || !id.startsWith('module-')) return;
80
+ if (node.tagName !== 'section' && node.tagName !== 'div') return;
81
+ if (node.tagName === 'div' && !classes(node).includes('section')) return;
82
+ const name = id.slice('module-'.length);
83
+ if (!/^[\p{ID_Start}_][\p{ID_Continue}]*(?:\.[\p{ID_Start}_][\p{ID_Continue}]*)*$/u.test(name))
84
+ return;
85
+ return name;
86
+ }
87
+
88
+ function sectionTitle(node: Element): string | undefined {
89
+ for (const child of node.children) {
90
+ if (child.type !== 'element') continue;
91
+ if (/^h[1-6]$/.test(child.tagName)) return textContent(child).replace(/\s+/g, ' ').trim();
92
+ }
93
+ return;
94
+ }
95
+
96
+ function sectionIds(node: Element): string[] {
97
+ const ids: string[] = [];
98
+ if (typeof node.properties.id === 'string') ids.push(node.properties.id);
99
+ for (const child of node.children) {
100
+ if (child.type === 'element') ids.push(...sectionIds(child));
101
+ }
102
+ return ids;
103
+ }
104
+
105
+ function textContent(node: ElementContent): string {
106
+ if (node.type === 'text') return node.value;
107
+ if (node.type !== 'element' || classes(node).includes('headerlink')) return '';
108
+ return node.children.map(textContent).join('');
109
+ }
110
+
111
+ function classes(node: Element): string[] {
112
+ const value = node.properties.className;
113
+ if (Array.isArray(value)) return value.map(String);
114
+ if (typeof value === 'string') return value.split(/\s+/);
115
+ return [];
116
+ }
117
+
118
+ function escapeHtml(value: string): string {
119
+ return value
120
+ .replaceAll('&', '&amp;')
121
+ .replaceAll('<', '&lt;')
122
+ .replaceAll('>', '&gt;')
123
+ .replaceAll('"', '&quot;');
124
+ }