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.
- package/LICENSE +21 -0
- package/README.md +493 -0
- package/dist/check.d.ts +2 -0
- package/dist/check.js +342 -0
- package/dist/src/assertions.d.ts +25 -0
- package/dist/src/assertions.js +84 -0
- package/dist/src/coerce.d.ts +19 -0
- package/dist/src/coerce.js +122 -0
- package/dist/src/covers.d.ts +38 -0
- package/dist/src/covers.js +59 -0
- package/dist/src/framework.d.ts +64 -0
- package/dist/src/framework.js +73 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/index.js +13 -0
- package/dist/src/mdast-gfm.d.ts +22 -0
- package/dist/src/mdast-gfm.js +90 -0
- package/dist/src/mermaid.d.ts +17 -0
- package/dist/src/mermaid.js +304 -0
- package/dist/src/parser.d.ts +26 -0
- package/dist/src/parser.js +423 -0
- package/dist/src/references.d.ts +33 -0
- package/dist/src/references.js +198 -0
- package/dist/src/report.d.ts +52 -0
- package/dist/src/report.js +278 -0
- package/dist/src/reviews.d.ts +19 -0
- package/dist/src/reviews.js +137 -0
- package/dist/src/runner.d.ts +94 -0
- package/dist/src/runner.js +353 -0
- package/dist/src/symbols.d.ts +15 -0
- package/dist/src/symbols.js +112 -0
- package/dist/src/types.d.ts +278 -0
- package/dist/src/types.js +27 -0
- package/package.json +70 -0
|
@@ -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
|
+
}
|