md-verified 0.1.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,423 @@
1
+ /**
2
+ * Markdown -> anchors.
3
+ *
4
+ * An *anchor* is a blockquote of the form
5
+ *
6
+ * > 🛠️ **Verified Data:** `validateOrder`
7
+ * > **Schema:** `[itemsTotal: Currency, total: Currency]`
8
+ *
9
+ * immediately followed by a native Markdown asset -- a table, a Mermaid code
10
+ * block, or a list. The blockquote renders as an ordinary callout everywhere;
11
+ * nothing here requires a custom renderer.
12
+ *
13
+ * Binding is done by *lookahead*: we take the next block-level node, skipping
14
+ * only the HTML comments this framework writes itself (so a file that has
15
+ * already been annotated with `<!-- ERROR: ... -->` still binds correctly on
16
+ * the next run).
17
+ */
18
+ import { fromMarkdown } from 'mdast-util-from-markdown';
19
+ import { gfmTable } from 'micromark-extension-gfm-table';
20
+ import { gfmTaskListItem } from 'micromark-extension-gfm-task-list-item';
21
+ import { gfmTableFromMarkdown, gfmTaskListItemFromMarkdown } from "./mdast-gfm.js";
22
+ import { coerce } from "./coerce.js";
23
+ import { parseMermaid } from "./mermaid.js";
24
+ import { KNOWN_GLYPHS, STATUS_GLYPH, } from "./types.js";
25
+ /** First line of an anchor: optional glyph, bold label, backticked id. */
26
+ const ANCHOR_RE = /^\s*(?:(?<status>[^\s*`]+)\s+)?\*\*\s*Verified\s+(?<label>[A-Za-z][A-Za-z0-9 _-]*?)\s*:?\s*\*\*\s*:?\s*`(?<id>[^`]+)`\s*(?<rest>.*)$/u;
27
+ /** A review blockquote: `> [glyph] **Reviewed:** `id``. Binds to no asset. */
28
+ const REVIEW_RE = /^\s*(?:(?<status>[^\s*`]+)\s+)?\*\*\s*Reviewed\s*:?\s*\*\*\s*:?\s*`(?<id>[^`]+)`\s*(?<rest>.*)$/u;
29
+ /** Subsequent lines: `**Key:** value`. */
30
+ const META_RE = /^\s*\*\*\s*(?<key>[A-Za-z][A-Za-z0-9 _-]*?)\s*:?\s*\*\*\s*:?\s*(?<value>.*)$/u;
31
+ /**
32
+ * Comments the lookahead steps over when binding an anchor to its asset.
33
+ *
34
+ * Broader than the set we are allowed to *rewrite* (see `MANAGED_LINE_RE` in
35
+ * `report.ts`): an author's `<!-- verify: ./glue.ts -->` hint is skipped here
36
+ * so it cannot break a binding, but it is never ours to delete.
37
+ */
38
+ export const SKIPPABLE_COMMENT_RE = /^<!--\s*(?:ERROR|REVIEW|verify)\b/i;
39
+ /** Human labels -> the asset kind they bind to. */
40
+ export const LABEL_KINDS = {
41
+ data: 'table',
42
+ table: 'table',
43
+ rows: 'table',
44
+ examples: 'table',
45
+ cases: 'table',
46
+ dataset: 'table',
47
+ matrix: 'table',
48
+ flow: 'mermaid',
49
+ diagram: 'mermaid',
50
+ graph: 'mermaid',
51
+ mermaid: 'mermaid',
52
+ flowchart: 'mermaid',
53
+ states: 'mermaid',
54
+ sequence: 'mermaid',
55
+ list: 'list',
56
+ steps: 'list',
57
+ rules: 'list',
58
+ checklist: 'list',
59
+ items: 'list',
60
+ };
61
+ /** Optional pointer to glue code: `<!-- verify: ./spec.verify.ts -->`. */
62
+ const GLUE_HINT_RE = /<!--\s*verify(?:-glue)?:\s*(?<path>[^\s>]+?)\s*-->/i;
63
+ export function parseMarkdown(source, file = '<memory>') {
64
+ const tree = fromMarkdown(source, {
65
+ extensions: [gfmTable(), gfmTaskListItem()],
66
+ mdastExtensions: [gfmTableFromMarkdown(), gfmTaskListItemFromMarkdown()],
67
+ });
68
+ const anchors = [];
69
+ const reviews = [];
70
+ const problems = [];
71
+ const children = tree.children;
72
+ for (let i = 0; i < children.length; i++) {
73
+ const node = children[i];
74
+ if (node.type !== 'blockquote')
75
+ continue;
76
+ const lines = quoteLines(source, node);
77
+ // A review covers the section it sits in; it binds to no asset, so there
78
+ // is no lookahead to do.
79
+ const reviewHead = REVIEW_RE.exec(lines[0] ?? '');
80
+ if (reviewHead) {
81
+ reviews.push(buildReview(source, children, i, node, reviewHead, lines));
82
+ continue;
83
+ }
84
+ const head = ANCHOR_RE.exec(lines[0] ?? '');
85
+ if (!head)
86
+ continue;
87
+ const line = node.position.start.line;
88
+ const id = head.groups.id.trim();
89
+ const label = head.groups.label.trim();
90
+ const declared = LABEL_KINDS[label.toLowerCase()] ?? null;
91
+ const status = statusFromGlyph(head.groups.status);
92
+ const meta = parseMeta(lines.slice(1));
93
+ // Lookahead: the next block node, skipping comments we wrote ourselves.
94
+ let j = i + 1;
95
+ while (j < children.length && isManagedComment(children[j]))
96
+ j++;
97
+ const target = children[j];
98
+ if (!target) {
99
+ problems.push({ id, line, message: `anchor \`${id}\` has nothing after it to verify` });
100
+ continue;
101
+ }
102
+ const actual = kindOfNode(target);
103
+ if (!actual) {
104
+ problems.push({
105
+ id,
106
+ line,
107
+ message: `anchor \`${id}\` is followed by a ${describe(target)}; expected a table, a mermaid code block, or a list`,
108
+ });
109
+ continue;
110
+ }
111
+ if (declared && declared !== actual) {
112
+ problems.push({
113
+ id,
114
+ line,
115
+ message: `anchor \`${id}\` says "Verified ${label}" (a ${declared}) but the next block is a ${actual}`,
116
+ });
117
+ continue;
118
+ }
119
+ // An asset we cannot read is a *defect*, not a reason to drop the anchor:
120
+ // the anchor still binds, still fails, and still gets annotated, which is
121
+ // the whole point of writing state back into the document.
122
+ let data;
123
+ let defect = null;
124
+ try {
125
+ data = extract(source, target, actual, meta);
126
+ }
127
+ catch (err) {
128
+ defect = err.message;
129
+ data = emptyData(actual);
130
+ }
131
+ anchors.push({
132
+ id,
133
+ kind: actual,
134
+ label,
135
+ status,
136
+ meta,
137
+ defect,
138
+ data,
139
+ line,
140
+ quoteRange: { start: node.position.start.offset, end: node.position.end.offset },
141
+ targetRange: { start: target.position.start.offset, end: target.position.end.offset },
142
+ gapRange: { start: node.position.end.offset, end: target.position.start.offset },
143
+ });
144
+ i = j - 1; // resume scanning just before the bound target
145
+ }
146
+ return { file, source, tree, anchors, reviews, problems };
147
+ }
148
+ /**
149
+ * Read a `<!-- verify: ./glue.ts -->` hint, if the document carries one.
150
+ *
151
+ * Scans HTML nodes rather than the raw source, so a hint shown as an *example*
152
+ * inside a fenced code block is not mistaken for a real one. Documentation
153
+ * about this tool is the obvious case, and it is exactly the kind of thing a
154
+ * regex over the whole file gets wrong.
155
+ */
156
+ export function findGlueHint(source) {
157
+ const tree = fromMarkdown(source, {
158
+ extensions: [gfmTable(), gfmTaskListItem()],
159
+ mdastExtensions: [gfmTableFromMarkdown(), gfmTaskListItemFromMarkdown()],
160
+ });
161
+ let hint = null;
162
+ const walk = (node) => {
163
+ if (hint)
164
+ return;
165
+ if (node.type === 'html') {
166
+ const found = GLUE_HINT_RE.exec(node.value)?.groups?.path;
167
+ if (found) {
168
+ hint = found;
169
+ return;
170
+ }
171
+ }
172
+ for (const child of node.children ?? [])
173
+ walk(child);
174
+ };
175
+ walk(tree);
176
+ return hint;
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // extraction
180
+ // ---------------------------------------------------------------------------
181
+ function extract(source, node, kind, meta) {
182
+ if (kind === 'table')
183
+ return extractTable(source, node, meta);
184
+ if (kind === 'mermaid')
185
+ return parseMermaid(node.value ?? '');
186
+ return extractList(source, node);
187
+ }
188
+ function extractTable(source, node, meta) {
189
+ const rows = node.children ?? [];
190
+ if (rows.length === 0)
191
+ throw new Error('table has no header row');
192
+ const headers = (rows[0].children ?? []).map((cell) => cellText(source, cell));
193
+ const align = (node.align ?? []).map((a) => a === 'left' || a === 'right' || a === 'center' ? a : null);
194
+ const schema = meta.Schema ? parseSchema(meta.Schema) : null;
195
+ if (schema && schema.length !== headers.length) {
196
+ throw new Error(`schema declares ${schema.length} field(s) but the table has ${headers.length} column(s)`);
197
+ }
198
+ const dataRows = [];
199
+ const defects = [];
200
+ for (let r = 1; r < rows.length; r++) {
201
+ const cells = (rows[r].children ?? []).map((cell) => cellText(source, cell));
202
+ // GFM pads short rows and drops extra cells.
203
+ while (cells.length < headers.length)
204
+ cells.push('');
205
+ cells.length = headers.length;
206
+ const raw = {};
207
+ const row = {};
208
+ let failure = null;
209
+ headers.forEach((header, c) => {
210
+ const text = cells[c];
211
+ raw[header] = text;
212
+ const field = schema?.[c];
213
+ let value = text;
214
+ if (field) {
215
+ try {
216
+ value = coerce(text, field.type, field.optional);
217
+ }
218
+ catch (err) {
219
+ // Record the first bad cell and keep the raw text, so the row is
220
+ // reported once rather than once per column.
221
+ failure ??= `column ${JSON.stringify(header)}: ${err.message}`;
222
+ }
223
+ }
224
+ row[header] = value;
225
+ // Schema field names are aliases onto the same coerced value.
226
+ if (field && field.name !== header)
227
+ row[field.name] = value;
228
+ });
229
+ const line = rows[r].position?.start.line ?? 0;
230
+ if (failure) {
231
+ // A row we cannot trust never reaches a handler.
232
+ defects.push({ index: r - 1, line, message: failure });
233
+ continue;
234
+ }
235
+ define(row, '$index', r - 1);
236
+ define(row, '$line', line);
237
+ define(row, '$raw', Object.freeze(raw));
238
+ define(row, '$cells', Object.freeze(cells));
239
+ define(row, '$headers', Object.freeze([...headers]));
240
+ dataRows.push(row);
241
+ }
242
+ return { headers, align, rows: dataRows, defects, schema };
243
+ }
244
+ function extractList(source, node) {
245
+ const flat = [];
246
+ const walk = (list, depth) => (list.children ?? []).map((li, index) => {
247
+ const nested = (li.children ?? []).filter((c) => c.type === 'list');
248
+ const body = (li.children ?? []).filter((c) => c.type !== 'list');
249
+ const text = body
250
+ .map((c) => source.slice(c.position.start.offset, c.position.end.offset))
251
+ .join('\n')
252
+ .trim();
253
+ const item = {
254
+ text,
255
+ checked: typeof li.checked === 'boolean' ? li.checked : null,
256
+ depth,
257
+ index,
258
+ line: li.position?.start.line ?? 0,
259
+ children: nested.flatMap((n) => walk(n, depth + 1)),
260
+ };
261
+ flat.push(item);
262
+ return item;
263
+ });
264
+ const items = walk(node, 0);
265
+ // `flat` is built depth-first as a side effect; re-sort into document order.
266
+ flat.sort((a, b) => a.line - b.line);
267
+ return { ordered: Boolean(node.ordered), items, flat };
268
+ }
269
+ /** `[itemsTotal: Currency, tax: Percentage]` -> fields. */
270
+ export function parseSchema(raw) {
271
+ let text = raw.trim().replace(/^`+|`+$/g, '').trim();
272
+ if (text.startsWith('[') && text.endsWith(']'))
273
+ text = text.slice(1, -1);
274
+ return splitTopLevel(text)
275
+ .map((part) => part.trim())
276
+ .filter(Boolean)
277
+ .map((part) => {
278
+ const idx = part.indexOf(':');
279
+ if (idx === -1) {
280
+ throw new Error(`schema field ${JSON.stringify(part)} is missing a type (want "name: Type")`);
281
+ }
282
+ let name = part.slice(0, idx).trim();
283
+ const type = part.slice(idx + 1).trim();
284
+ const optional = name.endsWith('?');
285
+ if (optional)
286
+ name = name.slice(0, -1).trim();
287
+ if (!name)
288
+ throw new Error(`schema field ${JSON.stringify(part)} is missing a name`);
289
+ if (!type)
290
+ throw new Error(`schema field ${JSON.stringify(name)} is missing a type`);
291
+ return { name, type, optional };
292
+ });
293
+ }
294
+ /** Split on commas that are not nested inside brackets. */
295
+ function splitTopLevel(text) {
296
+ const out = [];
297
+ let depth = 0;
298
+ let current = '';
299
+ for (const ch of text) {
300
+ if ('[({<'.includes(ch))
301
+ depth++;
302
+ else if ('])}>'.includes(ch))
303
+ depth--;
304
+ if (ch === ',' && depth === 0) {
305
+ out.push(current);
306
+ current = '';
307
+ }
308
+ else {
309
+ current += ch;
310
+ }
311
+ }
312
+ out.push(current);
313
+ return out;
314
+ }
315
+ function buildReview(source, children, index, node, head, lines) {
316
+ const meta = parseMeta(lines.slice(1));
317
+ const id = head.groups.id.trim();
318
+ let covers = [];
319
+ let defect = null;
320
+ try {
321
+ covers = parseCovers(meta.Covers ?? '');
322
+ if (covers.length === 0) {
323
+ defect = `review \`${id}\` declares no **Covers:** targets, so there is nothing to go stale against`;
324
+ }
325
+ }
326
+ catch (err) {
327
+ defect = `review \`${id}\`: ${err.message}`;
328
+ }
329
+ // Error comments go between the review and whatever follows it.
330
+ let j = index + 1;
331
+ while (j < children.length && isManagedComment(children[j]))
332
+ j++;
333
+ const gapEnd = children[j]?.position?.start.offset ?? source.length;
334
+ return {
335
+ id,
336
+ status: statusFromGlyph(head.groups.status),
337
+ covers,
338
+ digest: meta.Digest ? meta.Digest.replace(/`/g, '').trim() || null : null,
339
+ defect,
340
+ meta,
341
+ line: node.position.start.line,
342
+ quoteRange: { start: node.position.start.offset, end: node.position.end.offset },
343
+ gapRange: { start: node.position.end.offset, end: gapEnd },
344
+ };
345
+ }
346
+ /** `` `./a.ts#x`, `./b.ts` `` -> targets. */
347
+ export function parseCovers(raw) {
348
+ return splitTopLevel(raw.replace(/`/g, ''))
349
+ .map((part) => part.trim())
350
+ .filter(Boolean);
351
+ }
352
+ /** A safe placeholder for an anchor whose asset could not be read. */
353
+ function emptyData(kind) {
354
+ if (kind === 'table')
355
+ return { headers: [], align: [], rows: [], defects: [], schema: null };
356
+ if (kind === 'list')
357
+ return { ordered: false, items: [], flat: [] };
358
+ return parseMermaid('');
359
+ }
360
+ // ---------------------------------------------------------------------------
361
+ // small helpers
362
+ // ---------------------------------------------------------------------------
363
+ /** Blockquote source with the `>` markers stripped, one entry per line. */
364
+ function quoteLines(source, node) {
365
+ const raw = source.slice(node.position.start.offset, node.position.end.offset);
366
+ return raw.split('\n').map((l) => l.replace(/^\s*>\s?/, ''));
367
+ }
368
+ function parseMeta(lines) {
369
+ const meta = {};
370
+ for (const line of lines) {
371
+ const m = META_RE.exec(line);
372
+ if (!m)
373
+ continue;
374
+ meta[m.groups.key.trim()] = m.groups.value.trim();
375
+ }
376
+ return meta;
377
+ }
378
+ function statusFromGlyph(glyph) {
379
+ if (!glyph)
380
+ return 'pending';
381
+ const bare = glyph.replace(/️/g, '');
382
+ for (const [status, g] of Object.entries(STATUS_GLYPH)) {
383
+ if (g.replace(/️/g, '') === bare)
384
+ return status;
385
+ }
386
+ return 'pending';
387
+ }
388
+ function isManagedComment(node) {
389
+ return node.type === 'html' && SKIPPABLE_COMMENT_RE.test(node.value.trim());
390
+ }
391
+ function kindOfNode(node) {
392
+ if (node.type === 'table')
393
+ return 'table';
394
+ if (node.type === 'list')
395
+ return 'list';
396
+ if (node.type === 'code' && (node.lang ?? '').toLowerCase() === 'mermaid')
397
+ return 'mermaid';
398
+ return null;
399
+ }
400
+ function describe(node) {
401
+ if (node.type === 'code')
402
+ return `${node.lang ?? 'plain'} code block`;
403
+ return `${node.type} block`;
404
+ }
405
+ function cellText(source, cell) {
406
+ const start = cell?.position?.start?.offset;
407
+ const end = cell?.position?.end?.offset;
408
+ if (typeof start !== 'number' || typeof end !== 'number')
409
+ return '';
410
+ // Cell tokens span their delimiters; strip the outer pipes but leave any
411
+ // escaped `\|` inside the content alone.
412
+ let text = source.slice(start, end);
413
+ if (text.startsWith('|'))
414
+ text = text.slice(1);
415
+ if (text.endsWith('|') && !text.endsWith('\\|'))
416
+ text = text.slice(0, -1);
417
+ return text.trim();
418
+ }
419
+ /** Attach `$`-prefixed metadata without polluting `Object.keys(row)`. */
420
+ function define(target, key, value) {
421
+ Object.defineProperty(target, key, { value, enumerable: false, writable: false });
422
+ }
423
+ export { KNOWN_GLYPHS };
@@ -0,0 +1,33 @@
1
+ import type { Root } from 'mdast';
2
+ import type { ParseProblem, ParseResult } from './types.ts';
3
+ /** A link, image, or link definition found in the prose. */
4
+ export interface Reference {
5
+ kind: 'link' | 'image' | 'definition';
6
+ /** The URL exactly as written, or the identifier for a shorthand reference. */
7
+ url: string;
8
+ /** Path portion, decoded. `null` for in-document and shorthand references. */
9
+ target: string | null;
10
+ /** Fragment after `#`, decoded. */
11
+ fragment: string | null;
12
+ line: number;
13
+ column: number;
14
+ }
15
+ export interface ReferenceOptions {
16
+ /**
17
+ * Check that fragment-linked symbols exist. On by default. Modules are read,
18
+ * never imported, so nothing in the checked project is executed.
19
+ */
20
+ symbols?: boolean;
21
+ }
22
+ /** Collect every reference in a document, in source order. */
23
+ export declare function collectReferences(tree: Root): Reference[];
24
+ /**
25
+ * Check every reference in a parsed document. Returns diagnostics in the same
26
+ * shape as parse problems, so they flow through the runner unchanged.
27
+ */
28
+ export declare function checkReferences(parsed: ParseResult, options?: ReferenceOptions): Promise<ParseProblem[]>;
29
+ /** Forget cached lookups. Tests that write fixtures on the fly need this. */
30
+ export declare function clearReferenceCache(): void;
31
+ /** GitHub-compatible heading slugs, including its `-1` disambiguation. */
32
+ export declare function headingSlugs(tree: Root): Set<string>;
33
+ export declare function slugify(text: string): string;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Referential integrity for the prose around the anchors.
3
+ *
4
+ * Anchors verify the *assets* in a document. This pass verifies its
5
+ * *references*: links to files that have been moved, in-document anchors that
6
+ * no longer resolve, and -- where the author asks for it with a fragment --
7
+ * symbols that no longer exist.
8
+ *
9
+ * It deliberately checks nothing implicit. A document opts in by linking; bare
10
+ * inline code is never treated as a symbol, because `$10.00`, `--write` and
11
+ * `[itemsTotal: Currency]` are all inline code in a perfectly healthy spec.
12
+ */
13
+ import { dirname, extname, resolve as resolvePath } from 'node:path';
14
+ import { existsSync } from 'node:fs';
15
+ import { readFile } from 'node:fs/promises';
16
+ import { clearSymbolCache, exportedNames } from "./symbols.js";
17
+ /** File extensions we are willing to import to enumerate exports. */
18
+ const MODULE_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']);
19
+ /** Extensions we can read headings out of. */
20
+ const MARKDOWN_EXTS = new Set(['.md', '.markdown', '.mdx']);
21
+ /** Collect every reference in a document, in source order. */
22
+ export function collectReferences(tree) {
23
+ const found = [];
24
+ // `linkReference` nodes are not collected: their target is the `definition`
25
+ // they resolve to, which is checked directly. A reference with no definition
26
+ // never becomes a node at all -- CommonMark leaves it as literal text -- so
27
+ // there is nothing in the tree to flag.
28
+ walk(tree, (node) => {
29
+ if (node.type === 'link' || node.type === 'image' || node.type === 'definition') {
30
+ found.push({ ...split(node.url), kind: node.type, ...at(node) });
31
+ }
32
+ });
33
+ return found;
34
+ }
35
+ /**
36
+ * Check every reference in a parsed document. Returns diagnostics in the same
37
+ * shape as parse problems, so they flow through the runner unchanged.
38
+ */
39
+ export async function checkReferences(parsed, options = {}) {
40
+ const docPath = resolvePath(parsed.file);
41
+ // An in-memory document has no directory to resolve relative links against.
42
+ if (!existsSync(docPath))
43
+ return [];
44
+ const dir = dirname(docPath);
45
+ const problems = [];
46
+ const headings = headingSlugs(parsed.tree);
47
+ const report = (ref, message) => problems.push({ id: null, line: ref.line, column: ref.column, message });
48
+ for (const ref of collectReferences(parsed.tree)) {
49
+ if (isExternal(ref.url))
50
+ continue;
51
+ // A bare `#fragment` points inside this document.
52
+ if (ref.target === null || ref.target === '') {
53
+ if (ref.fragment && !headings.has(ref.fragment)) {
54
+ report(ref, `broken anchor: #${ref.fragment}${suggest(ref.fragment, [...headings])}`);
55
+ }
56
+ continue;
57
+ }
58
+ const targetPath = resolvePath(dir, ref.target);
59
+ if (!existsSync(targetPath)) {
60
+ report(ref, `broken link: ${ref.url} (no such file)`);
61
+ continue;
62
+ }
63
+ if (!ref.fragment)
64
+ continue;
65
+ const ext = extname(targetPath).toLowerCase();
66
+ if (MARKDOWN_EXTS.has(ext)) {
67
+ const slugs = await markdownSlugs(targetPath);
68
+ if (slugs && !slugs.has(ref.fragment)) {
69
+ report(ref, `broken anchor: ${ref.url} (no such heading)${suggest(ref.fragment, [...slugs])}`);
70
+ }
71
+ continue;
72
+ }
73
+ if (MODULE_EXTS.has(ext) && options.symbols !== false) {
74
+ const exports = exportedNames(targetPath);
75
+ if (exports instanceof Error) {
76
+ report(ref, `could not read ${ref.target}: ${exports.message}`);
77
+ }
78
+ else if (!exports.has(ref.fragment)) {
79
+ report(ref, `broken symbol: ${ref.url} (no export named \`${ref.fragment}\`)${suggest(ref.fragment, [...exports])}`);
80
+ }
81
+ }
82
+ }
83
+ return problems;
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // resolution
87
+ // ---------------------------------------------------------------------------
88
+ const slugCache = new Map();
89
+ async function markdownSlugs(path) {
90
+ const cached = slugCache.get(path);
91
+ if (cached !== undefined)
92
+ return cached;
93
+ let slugs = null;
94
+ try {
95
+ // Imported lazily: only documents that are actually anchor-linked are read.
96
+ const { parseMarkdown } = await import("./parser.js");
97
+ slugs = headingSlugs(parseMarkdown(await readFile(path, 'utf8'), path).tree);
98
+ }
99
+ catch {
100
+ slugs = null;
101
+ }
102
+ slugCache.set(path, slugs);
103
+ return slugs;
104
+ }
105
+ /** Forget cached lookups. Tests that write fixtures on the fly need this. */
106
+ export function clearReferenceCache() {
107
+ slugCache.clear();
108
+ clearSymbolCache();
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // helpers
112
+ // ---------------------------------------------------------------------------
113
+ function walk(node, visit) {
114
+ visit(node);
115
+ for (const child of node.children ?? [])
116
+ walk(child, visit);
117
+ }
118
+ function at(node) {
119
+ return { line: node.position?.start.line ?? 0, column: node.position?.start.column ?? 0 };
120
+ }
121
+ function isExternal(url) {
122
+ return /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('//');
123
+ }
124
+ function split(url) {
125
+ const hash = url.indexOf('#');
126
+ if (hash === -1)
127
+ return { url, target: decode(url), fragment: null };
128
+ return {
129
+ url,
130
+ target: hash === 0 ? null : decode(url.slice(0, hash)),
131
+ fragment: decode(url.slice(hash + 1)),
132
+ };
133
+ }
134
+ function decode(s) {
135
+ try {
136
+ return decodeURIComponent(s);
137
+ }
138
+ catch {
139
+ return s;
140
+ }
141
+ }
142
+ /** GitHub-compatible heading slugs, including its `-1` disambiguation. */
143
+ export function headingSlugs(tree) {
144
+ const slugs = new Set();
145
+ const seen = new Map();
146
+ walk(tree, (node) => {
147
+ if (node.type !== 'heading')
148
+ return;
149
+ const base = slugify(textOf(node));
150
+ if (!base)
151
+ return;
152
+ const n = seen.get(base) ?? 0;
153
+ seen.set(base, n + 1);
154
+ slugs.add(n === 0 ? base : `${base}-${n}`);
155
+ });
156
+ return slugs;
157
+ }
158
+ export function slugify(text) {
159
+ return text
160
+ .trim()
161
+ .toLowerCase()
162
+ .replace(/[^\p{L}\p{N}\s_-]/gu, '')
163
+ .replace(/\s/g, '-');
164
+ }
165
+ function textOf(node) {
166
+ let out = '';
167
+ walk(node, (n) => {
168
+ if (n.type === 'text' || n.type === 'inlineCode')
169
+ out += n.value;
170
+ });
171
+ return out;
172
+ }
173
+ /** " (did you mean `x`?)" when something close enough exists. */
174
+ function suggest(needle, haystack) {
175
+ let best = null;
176
+ let bestScore = Infinity;
177
+ for (const candidate of haystack) {
178
+ const score = distance(needle.toLowerCase(), candidate.toLowerCase());
179
+ if (score < bestScore) {
180
+ bestScore = score;
181
+ best = candidate;
182
+ }
183
+ }
184
+ // Close enough to be a typo rather than a different thing entirely.
185
+ const limit = Math.max(2, Math.floor(needle.length / 3));
186
+ return best && bestScore <= limit ? ` (did you mean \`${best}\`?)` : '';
187
+ }
188
+ function distance(a, b) {
189
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
190
+ for (let i = 1; i <= a.length; i++) {
191
+ const row = [i];
192
+ for (let j = 1; j <= b.length; j++) {
193
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
194
+ }
195
+ prev = row;
196
+ }
197
+ return prev[b.length];
198
+ }