@kontourai/corpus 0.0.0-bootstrap.0 → 0.8.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.
@@ -0,0 +1,547 @@
1
+ import path from 'node:path';
2
+ import GithubSlugger, { slug } from 'github-slugger';
3
+ import { Parser, Tokenizer } from 'htmlparser2';
4
+ import { Lexer, marked } from 'marked';
5
+
6
+ /** GitHub heading slug for one heading, via github-slugger (what GitHub uses). */
7
+ export function githubSlug(text) {
8
+ return slug(text);
9
+ }
10
+
11
+ /** Per-document slugger: repeated headings get `-1`, `-2`, ... */
12
+ export function createSlugger() {
13
+ const slugger = new GithubSlugger();
14
+ return (text) => slugger.slug(text);
15
+ }
16
+
17
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
18
+
19
+ export function decodeEntities(value) {
20
+ return value.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (match, body) => {
21
+ if (body[0] === '#') {
22
+ const code = body[1].toLowerCase() === 'x' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
23
+ if (!Number.isFinite(code)) return match;
24
+ // As HTML (and marked) render them: NUL and out-of-range code points become U+FFFD.
25
+ return code === 0 || code > 0x10ffff ? '\uFFFD' : String.fromCodePoint(code);
26
+ }
27
+ return ENTITIES[body.toLowerCase()] ?? match;
28
+ });
29
+ }
30
+
31
+ /** The text content a browser would show for inline tokens (raw HTML tags dropped). */
32
+ export function plainText(tokens = []) {
33
+ let text = '';
34
+ for (const token of tokens) {
35
+ if (token.type === 'html') continue;
36
+ if (token.type === 'image') text += token.text ?? '';
37
+ else if (token.tokens?.length) text += plainText(token.tokens);
38
+ else if (token.type === 'br') text += ' ';
39
+ else text += decodeEntities(token.text ?? '');
40
+ }
41
+ return text;
42
+ }
43
+
44
+ const URL_ATTRIBUTES = new Set(['href', 'src', 'srcset', 'poster']);
45
+ const ANCHOR_ATTRIBUTES = new Set(['id', 'name']);
46
+
47
+ /**
48
+ * Every attribute of every start tag in an HTML fragment, from htmlparser2's
49
+ * tokenizer, so comments, quoting (double, single, unquoted), and `<!--` or
50
+ * `>` inside attribute values follow the HTML tokenizer rather than a regex.
51
+ * `start`/`end` cover the attribute (name through value) so callers can
52
+ * rewrite it in place. Values are entity-decoded.
53
+ */
54
+ export function scanHtmlAttributes(html) {
55
+ const attributes = [];
56
+ let current = null;
57
+ const tokenizer = new Tokenizer(
58
+ { decodeEntities: false },
59
+ {
60
+ onattribname(start, endIndex) {
61
+ current = { name: html.slice(start, endIndex).toLowerCase(), raw: '', start };
62
+ },
63
+ onattribdata(start, endIndex) {
64
+ if (current) current.raw += html.slice(start, endIndex);
65
+ },
66
+ onattribend(_quote, endIndex) {
67
+ if (!current) return;
68
+ attributes.push({ name: current.name, value: decodeEntities(current.raw), start: current.start, end: endIndex });
69
+ current = null;
70
+ },
71
+ onattribentity() {},
72
+ oncdata() {},
73
+ onclosetag() {},
74
+ oncomment() {},
75
+ ondeclaration() {},
76
+ onend() {},
77
+ onopentagend() {},
78
+ onopentagname() {},
79
+ onprocessinginstruction() {},
80
+ onselfclosingtag() {},
81
+ ontext() {},
82
+ ontextentity() {},
83
+ },
84
+ );
85
+ tokenizer.write(html);
86
+ tokenizer.end();
87
+ return attributes;
88
+ }
89
+
90
+ /**
91
+ * Parse a srcset value into { url, descriptor } candidates, following the
92
+ * HTML algorithm: a URL is a run of non-whitespace (so commas inside a data:
93
+ * URI stay in the URL), trailing commas end it, and descriptors run to the
94
+ * next comma outside parentheses.
95
+ */
96
+ export function parseSrcset(value) {
97
+ const candidates = [];
98
+ let i = 0;
99
+ while (i < value.length) {
100
+ while (i < value.length && /[\s,]/.test(value[i])) i++;
101
+ if (i >= value.length) break;
102
+ let url = '';
103
+ while (i < value.length && !/\s/.test(value[i])) url += value[i++];
104
+ let descriptor = '';
105
+ if (/,+$/.test(url)) url = url.replace(/,+$/, '');
106
+ else {
107
+ let depth = 0;
108
+ while (i < value.length) {
109
+ const char = value[i];
110
+ if (char === '(') depth++;
111
+ else if (char === ')') depth = Math.max(0, depth - 1);
112
+ else if (char === ',' && depth === 0) break;
113
+ descriptor += char;
114
+ i++;
115
+ }
116
+ }
117
+ if (url) candidates.push({ url, descriptor: descriptor.trim() });
118
+ }
119
+ return candidates;
120
+ }
121
+
122
+ /** Raw-HTML references: { attr, url } for href, src, poster, and each srcset candidate. */
123
+ export function extractHtmlReferences(html) {
124
+ const references = [];
125
+ for (const { name, value } of scanHtmlAttributes(html)) {
126
+ if (!URL_ATTRIBUTES.has(name)) continue;
127
+ // An empty href/src is a legal "same document" reference in raw HTML.
128
+ if (name === 'srcset') for (const { url } of parseSrcset(value)) references.push({ attr: name, url });
129
+ else if (value.trim() !== '') references.push({ attr: name, url: value });
130
+ }
131
+ return references;
132
+ }
133
+
134
+ function escapeAttributeValue(value) {
135
+ return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
136
+ }
137
+
138
+ /** Rewrite every href/src/srcset/poster URL with `rewrite(url, attr)`; output is double-quoted. */
139
+ export function rewriteHtmlReferences(html, rewrite) {
140
+ const targets = scanHtmlAttributes(html).filter((attribute) => URL_ATTRIBUTES.has(attribute.name));
141
+ let output = html;
142
+ for (const attribute of targets.reverse()) {
143
+ const value =
144
+ attribute.name === 'srcset'
145
+ ? parseSrcset(attribute.value)
146
+ .map(({ url, descriptor }) => [rewrite(url, 'srcset'), descriptor].filter(Boolean).join(' '))
147
+ .join(', ')
148
+ : attribute.value.trim() === ''
149
+ ? attribute.value
150
+ : rewrite(attribute.value, attribute.name);
151
+ output = `${output.slice(0, attribute.start)}${attribute.name}="${escapeAttributeValue(value)}"${output.slice(attribute.end)}`;
152
+ }
153
+ return output;
154
+ }
155
+
156
+ /** `html` without any attribute named in `names` (a Set of lowercase names), in any quoting. */
157
+ export function removeHtmlAttributes(html, names) {
158
+ let output = html;
159
+ for (const attribute of scanHtmlAttributes(html).filter((entry) => names.has(entry.name)).reverse())
160
+ output = `${output.slice(0, attribute.start).replace(/[ \t\n\r\f]+$/, ' ')}${output.slice(attribute.end)}`;
161
+ return output;
162
+ }
163
+
164
+ /** Fragment targets declared in HTML: `id` and `name` attributes in any quoting. */
165
+ export function extractHtmlAnchors(html) {
166
+ return scanHtmlAttributes(html)
167
+ .filter((attribute) => ANCHOR_ATTRIBUTES.has(attribute.name) && attribute.value !== '')
168
+ .map((attribute) => attribute.value);
169
+ }
170
+
171
+ export const FRONT_MATTER_MAX_LINES = 2000;
172
+ const FRONT_MATTER_OPEN = /^---[ \t]*$/;
173
+ const FRONT_MATTER_CLOSE = /^(?:---|\.\.\.)[ \t]*$/;
174
+ const FRONT_MATTER_KEY = /^[A-Za-z_][\w.-]*\s*:(?:\s|$)/;
175
+
176
+ /**
177
+ * Split a leading YAML front-matter block, Jekyll style: `---` on line 1 and
178
+ * a closing `---` or `...` line within the first FRONT_MATTER_MAX_LINES
179
+ * lines, after dropping a leading UTF-8 BOM. The block must hold at least one
180
+ * top-level `key:` line; without one, a leading `---` is a thematic break
181
+ * and the next `---` is content, so stripping would delete prose. An
182
+ * unclosed or keyless block is not front matter, so nothing is stripped.
183
+ * Returns { body, lineOffset, title }: `lineOffset` is the number of source
184
+ * lines removed, so body line N is source line N + lineOffset, and `title`
185
+ * is a simple top-level `title: value` (optionally quoted) or null. No YAML
186
+ * parser: any other form of title is ignored.
187
+ */
188
+ export function splitFrontMatter(text) {
189
+ // A leading BOM is dropped in every case: it is invisible, and it would stop
190
+ // both `---` and a first-line `# heading` from being recognized.
191
+ const source = text.replace(/^\uFEFF/, '');
192
+ const none = { body: source, lineOffset: 0, title: null };
193
+ const lines = source.split('\n');
194
+ if (lines.length < 2 || !FRONT_MATTER_OPEN.test(lines[0].replace(/\r$/, ''))) return none;
195
+ const limit = Math.min(lines.length, FRONT_MATTER_MAX_LINES);
196
+ for (let close = 1; close < limit; close++) {
197
+ if (!FRONT_MATTER_CLOSE.test(lines[close].replace(/\r$/, ''))) continue;
198
+ const block = lines.slice(1, close).map((line) => line.replace(/\r$/, ''));
199
+ if (!block.some((line) => FRONT_MATTER_KEY.test(line))) return none;
200
+ return { body: lines.slice(close + 1).join('\n'), lineOffset: close + 1, title: frontMatterTitle(block) };
201
+ }
202
+ return none;
203
+ }
204
+
205
+ function frontMatterTitle(lines) {
206
+ for (const line of lines) {
207
+ const match = /^title:(?:[ \t]+(.*))?$/.exec(line);
208
+ if (!match) continue;
209
+ const value = (match[1] ?? '').trim();
210
+ let title;
211
+ const quoted = /^"((?:[^"\\]|\\.)*)"(?:[ \t]+#.*)?$/.exec(value) ?? /^'((?:[^']|'')*)'(?:[ \t]+#.*)?$/.exec(value);
212
+ if (quoted) title = value[0] === '"' ? quoted[1].replace(/\\(["\\])/g, '$1') : quoted[1].replaceAll("''", "'");
213
+ // Unquoted: a plain scalar up to a ` #` comment. Block scalars, flow
214
+ // collections, anchors, tags, and null are not the simple form.
215
+ else if (/^[|>[{&*!'"%@`]/.test(value)) return null;
216
+ else title = value.replace(/[ \t]+#.*$/, '');
217
+ title = title.trim();
218
+ return title === '' || (!quoted && /^(?:~|null)$/i.test(title)) ? null : title;
219
+ }
220
+ return null;
221
+ }
222
+
223
+ function lineAt(source, index) {
224
+ let line = 1;
225
+ for (let i = 0; i < index && i < source.length; i++) if (source.charCodeAt(i) === 10) line++;
226
+ return line;
227
+ }
228
+
229
+ /**
230
+ * Lex a Markdown document once and report what the gates and the builder
231
+ * need: headings with GitHub slugs, every anchor (heading slugs plus raw-HTML
232
+ * id/name), every Markdown link and image plus raw-HTML href/src/srcset, and
233
+ * the title (first top-level Markdown H1, else the first top-level raw-HTML
234
+ * `<h1>` with text, else `frontMatterTitle`). `source` is the body after
235
+ * front matter; `lineOffset` maps its line numbers back to the file.
236
+ */
237
+ export function analyzeMarkdown(source, { lineOffset = 0, frontMatterTitle = null } = {}) {
238
+ const tokens = Lexer.lex(source, { gfm: true });
239
+ const slug = createSlugger();
240
+ const headings = [];
241
+ const anchors = new Set();
242
+ const links = [];
243
+ const topLevel = new Set(tokens);
244
+ let cursor = 0;
245
+
246
+ marked.walkTokens(tokens, (token) => {
247
+ if (token.type === 'heading') {
248
+ const text = plainText(token.tokens).trim();
249
+ const id = slug(text);
250
+ token.slugId = id;
251
+ anchors.add(id);
252
+ headings.push({ depth: token.depth, text, slug: id, topLevel: topLevel.has(token), token });
253
+ } else if (token.type === 'html' && token.text) {
254
+ for (const anchor of extractHtmlAnchors(token.text)) anchors.add(anchor);
255
+ const references = extractHtmlReferences(token.text);
256
+ if (references.length > 0) {
257
+ let index = source.indexOf(token.raw, cursor);
258
+ if (index === -1) index = source.indexOf(token.raw);
259
+ if (index !== -1) cursor = index + token.raw.length;
260
+ for (const { attr, url } of references)
261
+ links.push({ kind: 'html', attr, href: url, text: attr, line: index === -1 ? null : lineAt(source, index) + lineOffset });
262
+ }
263
+ } else if ((token.type === 'link' || token.type === 'image') && typeof token.href === 'string') {
264
+ let index = source.indexOf(token.raw, cursor);
265
+ if (index === -1) index = source.indexOf(token.raw);
266
+ if (index !== -1) cursor = index + token.raw.length;
267
+ links.push({
268
+ kind: token.type,
269
+ href: token.href,
270
+ text: token.type === 'image' ? token.text : plainText(token.tokens) || token.text,
271
+ line: index === -1 ? null : lineAt(source, index) + lineOffset,
272
+ });
273
+ }
274
+ });
275
+
276
+ const h1 = headings.find((heading) => heading.depth === 1 && heading.topLevel);
277
+ return { tokens, headings, anchors, links, title: h1?.text || htmlH1Text(tokens) || frontMatterTitle };
278
+ }
279
+
280
+ /**
281
+ * Lex a Markdown document and give every heading token the `slugId` that
282
+ * `analyzeMarkdown` gives it (the same slugger over the same walk, so nested
283
+ * headings count toward repeats the same way), in time linear in the
284
+ * document. Search uses this instead of `analyzeMarkdown`, whose link line
285
+ * numbers it does not need. It walks the tokens itself because
286
+ * `marked.walkTokens` concatenates its result array once per token, which is
287
+ * quadratic in the number of sibling tokens (a paragraph of many links).
288
+ */
289
+ export function lexWithSlugs(source) {
290
+ const tokens = Lexer.lex(source, { gfm: true });
291
+ const slug = createSlugger();
292
+ const visit = (list) => {
293
+ for (const token of list) {
294
+ if (token.type === 'heading') token.slugId = slug(plainText(token.tokens).trim());
295
+ visit(childTokens(token));
296
+ }
297
+ };
298
+ visit(tokens);
299
+ return tokens;
300
+ }
301
+
302
+ /**
303
+ * Text of the first raw-HTML `<h1>` with text in a top-level HTML block, as a
304
+ * browser would show it (tags dropped, entities decoded, whitespace
305
+ * collapsed). An `<h1>` holding only an image has no text and is skipped.
306
+ */
307
+ function htmlH1Text(tokens) {
308
+ for (const token of tokens) {
309
+ if (token.type !== 'html' || !token.text) continue;
310
+ const found = [];
311
+ let depth = 0;
312
+ let text = '';
313
+ const parser = new Parser(
314
+ {
315
+ onopentag(name) {
316
+ if (name === 'h1' && depth++ === 0) text = '';
317
+ },
318
+ ontext(chunk) {
319
+ if (depth > 0) text += chunk;
320
+ },
321
+ onclosetag(name) {
322
+ if (name === 'h1' && depth > 0 && --depth === 0) found.push(text);
323
+ },
324
+ },
325
+ { decodeEntities: true },
326
+ );
327
+ parser.write(token.text);
328
+ parser.end();
329
+ // An <h1> left open at the end of the block (closed in a later block) still counts.
330
+ if (depth > 0) found.push(text);
331
+ const title = found.map((value) => value.replace(/\s+/g, ' ').trim()).find(Boolean);
332
+ if (title) return title;
333
+ }
334
+ return null;
335
+ }
336
+
337
+ function newlines(text) {
338
+ let count = 0;
339
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) count++;
340
+ return count;
341
+ }
342
+
343
+ /** 1-based [start, end] lines of `text`'s non-whitespace extent, where `text` starts on `firstLine`; null if blank. */
344
+ function extentLines(text, firstLine) {
345
+ const first = text.search(/\S/);
346
+ if (first === -1) return null;
347
+ const last = text.length - (text.length - text.trimEnd().length) - 1;
348
+ return [firstLine + newlines(text.slice(0, first)), firstLine + newlines(text.slice(0, last))];
349
+ }
350
+
351
+ /**
352
+ * Source lines of each top-level block token, as [{ token, start, end }],
353
+ * 1-based in the lexed text (add a front-matter `lineOffset` for file lines).
354
+ * Top-level token raws concatenate to the lexed source (the lexer normalizes
355
+ * CRLF to LF, which keeps the line count), so a running newline count gives
356
+ * each block's first line. Blank `space` tokens are skipped.
357
+ */
358
+ export function blockLines(tokens) {
359
+ const blocks = [];
360
+ let line = 1;
361
+ for (const token of tokens) {
362
+ const raw = token.raw ?? '';
363
+ const extent = token.type === 'space' ? null : extentLines(raw, line);
364
+ if (extent) blocks.push({ token, start: extent[0], end: extent[1] });
365
+ line += newlines(raw);
366
+ }
367
+ return blocks;
368
+ }
369
+
370
+ const LINE_ANNOTATED = new Set(['paragraph', 'heading', 'code', 'blockquote']);
371
+
372
+ /**
373
+ * Mark tokens with the file lines they came from, for `data-src-line` on the
374
+ * rendered page: top-level paragraphs, headings, code blocks and blockquotes
375
+ * get `srcLine` ("start-end"), each item of a top-level list gets `srcLine`,
376
+ * and a top-level table gets `srcRows`, one entry per row (header first; a
377
+ * GFM row is one line, after the header and the delimiter line). Lines are
378
+ * 1-based file lines: `lineOffset` is the number of front-matter lines removed.
379
+ */
380
+ export function annotateSourceLines(tokens, lineOffset = 0) {
381
+ const range = (start, end) => `${start + lineOffset}-${end + lineOffset}`;
382
+ for (const { token, start, end } of blockLines(tokens)) {
383
+ if (LINE_ANNOTATED.has(token.type)) token.srcLine = range(start, end);
384
+ else if (token.type === 'list') {
385
+ let cursor = 0;
386
+ for (const item of token.items) {
387
+ const at = token.raw.indexOf(item.raw, cursor);
388
+ if (at === -1) continue;
389
+ cursor = at + item.raw.length;
390
+ const extent = extentLines(item.raw, start + newlines(token.raw.slice(token.raw.search(/\S/), at)));
391
+ if (extent) item.srcLine = range(extent[0], extent[1]);
392
+ }
393
+ } else if (token.type === 'table') {
394
+ token.srcRows = [range(start, start), ...token.rows.map((_, index) => range(start + 2 + index, start + 2 + index))];
395
+ }
396
+ }
397
+ return tokens;
398
+ }
399
+
400
+ /**
401
+ * Document title: the manifest `title` override, else the first Markdown H1,
402
+ * else the first raw-HTML `<h1>`, else the front-matter `title`, else the
403
+ * file name without its extension.
404
+ */
405
+ export function documentTitle(analysis, source, override = null) {
406
+ return override || analysis.title || path.posix.basename(source).replace(/\.md$/i, '');
407
+ }
408
+
409
+ /**
410
+ * Markdown of one heading's section: the heading itself through the token
411
+ * before the next top-level heading of the same or a higher level.
412
+ */
413
+ export function extractSection(analysis, slug) {
414
+ const heading = analysis.headings.find((entry) => entry.slug === slug && entry.topLevel);
415
+ if (!heading) return null;
416
+ const start = analysis.tokens.indexOf(heading.token);
417
+ let raw = '';
418
+ for (let i = start; i < analysis.tokens.length; i++) {
419
+ const token = analysis.tokens[i];
420
+ if (i > start && token.type === 'heading' && token.depth <= heading.depth) break;
421
+ raw += token.raw;
422
+ }
423
+ return `${raw.trim()}\n`;
424
+ }
425
+
426
+ /** First prose paragraph as plain text, for page descriptions and cards. */
427
+ export function firstParagraph(analysis, max = 200) {
428
+ const paragraph = analysis.tokens.find((token) => token.type === 'paragraph');
429
+ if (!paragraph) return '';
430
+ const text = plainText(paragraph.tokens).replace(/\s+/g, ' ').trim();
431
+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
432
+ }
433
+
434
+ /**
435
+ * Classify a link target written in a document at `fromSource`.
436
+ * - external: has a URL scheme or is protocol-relative; never fetched
437
+ * - fragment: `#anchor` within the same document
438
+ * - local: a repo-relative `path` (POSIX, normalized) plus optional fragment;
439
+ * a leading `/` is repository-root-relative, as GitHub renders it
440
+ * - invalid: escapes the repository or cannot be decoded
441
+ */
442
+ export function resolveLink(href, fromSource) {
443
+ const value = href.trim();
444
+ if (value === '') return { kind: 'invalid', reason: 'empty link target' };
445
+ if (/^[a-z][a-z\d+.-]*:/i.test(value) || value.startsWith('//')) return { kind: 'external' };
446
+ const hashIndex = value.indexOf('#');
447
+ const pathPart = hashIndex === -1 ? value : value.slice(0, hashIndex);
448
+ const rawFragment = hashIndex === -1 ? '' : value.slice(hashIndex + 1);
449
+ let fragment;
450
+ let decodedPath;
451
+ try {
452
+ fragment = decodeURIComponent(rawFragment);
453
+ decodedPath = decodeURIComponent(pathPart.split('?')[0]);
454
+ } catch {
455
+ return { kind: 'invalid', reason: 'malformed percent-encoding' };
456
+ }
457
+ if (decodedPath === '') return { kind: 'fragment', fragment, rawFragment };
458
+ if (decodedPath.includes('\0') || decodedPath.includes('\\'))
459
+ return { kind: 'invalid', reason: 'unsupported characters in path' };
460
+ const joined = decodedPath.startsWith('/')
461
+ ? path.posix.normalize(decodedPath.slice(1) || '.')
462
+ : path.posix.normalize(path.posix.join(path.posix.dirname(fromSource), decodedPath));
463
+ const normalized = joined.replace(/\/$/, '');
464
+ if (normalized === '..' || normalized.startsWith('../'))
465
+ return { kind: 'invalid', reason: 'escapes the repository root' };
466
+ return {
467
+ kind: 'local',
468
+ path: normalized === '.' ? '' : normalized,
469
+ fragment,
470
+ rawFragment,
471
+ };
472
+ }
473
+
474
+ /** Child tokens in document order, as marked.walkTokens visits them. */
475
+ function childTokens(token) {
476
+ if (token.type === 'table') return [...token.header, ...token.rows.flat()].flatMap((cell) => cell.tokens ?? []);
477
+ if (token.type === 'list') return token.items;
478
+ return token.tokens ?? [];
479
+ }
480
+
481
+ /** Destination in a link/image/def `raw`, as [start, end, angled], or null when it cannot be located exactly. */
482
+ function destinationSpan(token) {
483
+ const { raw, href } = token;
484
+ const forms = token.type === 'def' ? [raw.indexOf(']:') + 2] : [];
485
+ if (token.type !== 'def')
486
+ // The destination follows the last `](` whose text starts with it; the link text may contain `](`.
487
+ for (let i = raw.lastIndexOf(']('); i !== -1; i = raw.lastIndexOf('](', i - 1)) forms.push(i + 2);
488
+ for (const after of forms) {
489
+ if (after < 2) continue;
490
+ let start = after;
491
+ while (start < raw.length && /[ \t\n]/.test(raw[start])) start++;
492
+ if (raw.startsWith(`<${href}>`, start)) return [start, start + href.length + 2, true];
493
+ if (raw.startsWith(href, start)) return [start, start + href.length, false];
494
+ }
495
+ return null;
496
+ }
497
+
498
+ /**
499
+ * Rewrite the destination of every Markdown link, image, and reference
500
+ * definition, and every raw-HTML href/src/srcset/poster, in the Markdown
501
+ * source text. `rewrite(href, { image })` returns the new destination. Code
502
+ * blocks and code spans are never touched. Positions come from the lexer:
503
+ * top-level token raws concatenate to the source, and nested tokens are found
504
+ * in order inside their block. A destination written with backslash escapes,
505
+ * which the lexer unescapes, cannot be located exactly and is left as written.
506
+ */
507
+ export function rewriteMarkdownLinks(source, rewrite) {
508
+ const tokens = Lexer.lex(source, { gfm: true });
509
+ const edits = [];
510
+ const visit = (token, from, limit) => {
511
+ const at = token.raw ? source.indexOf(token.raw, from) : -1;
512
+ const found = at !== -1 && at + token.raw.length <= limit;
513
+ if (found && (token.type === 'link' || token.type === 'image' || token.type === 'def')) {
514
+ // `[text][ref]` carries the definition's href; the `def` token is rewritten instead.
515
+ const span = typeof token.href === 'string' ? destinationSpan(token) : null;
516
+ const next = span && rewrite(token.href, { image: token.type === 'image' });
517
+ if (span && next !== token.href) {
518
+ const angled = span[2] || /[\s()<>]/.test(next);
519
+ edits.push([at + span[0], at + span[1], angled ? `<${next}>` : next]);
520
+ }
521
+ return at + token.raw.length;
522
+ }
523
+ if (found && token.type === 'html') {
524
+ let changed = false;
525
+ const rewritten = rewriteHtmlReferences(token.raw, (url, attr) => {
526
+ const next = rewrite(url, { image: attr !== 'href' });
527
+ changed ||= next !== url;
528
+ return next;
529
+ });
530
+ // rewriteHtmlReferences re-quotes every URL attribute; keep the block as written unless a URL changed.
531
+ if (changed) edits.push([at, at + token.raw.length, rewritten]);
532
+ return at + token.raw.length;
533
+ }
534
+ if (token.type === 'code' || token.type === 'codespan') return found ? at + token.raw.length : from;
535
+ let cursor = found ? at : from;
536
+ for (const child of childTokens(token)) cursor = visit(child, cursor, found ? at + token.raw.length : limit);
537
+ return found ? at + token.raw.length : cursor;
538
+ };
539
+ let offset = 0;
540
+ for (const token of tokens) {
541
+ visit(token, offset, offset + token.raw.length);
542
+ offset += token.raw.length;
543
+ }
544
+ let output = source;
545
+ for (const [start, end, text] of edits.sort((a, b) => b[0] - a[0])) output = `${output.slice(0, start)}${text}${output.slice(end)}`;
546
+ return output;
547
+ }