@docsworth/cli 0.2.0 → 0.3.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/README.md +60 -8
- package/dist/cli.js +52 -3
- package/dist/core/acl.d.ts +9 -0
- package/dist/core/acl.js +40 -6
- package/dist/core/adopt.d.ts +68 -0
- package/dist/core/adopt.js +665 -0
- package/dist/core/config.d.ts +6 -1
- package/dist/core/config.js +46 -2
- package/dist/core/edits.d.ts +13 -0
- package/dist/core/edits.js +20 -0
- package/dist/core/frontmatter.d.ts +12 -0
- package/dist/core/frontmatter.js +16 -7
- package/dist/core/gfm.d.ts +13 -0
- package/dist/core/gfm.js +244 -0
- package/dist/core/links.d.ts +82 -0
- package/dist/core/links.js +345 -0
- package/dist/core/lint.d.ts +1 -2
- package/dist/core/lint.js +40 -6
- package/dist/core/mcp/server.js +11 -2
- package/dist/core/printable.d.ts +4 -0
- package/dist/core/printable.js +16 -0
- package/dist/core/references.d.ts +38 -3
- package/dist/core/references.js +543 -41
- package/dist/core/repo.d.ts +4 -0
- package/dist/core/repo.js +3 -1
- package/dist/core/schema.d.ts +4 -0
- package/dist/core/schema.js +8 -6
- package/dist/core/search.d.ts +8 -0
- package/dist/core/search.js +34 -0
- package/dist/core/serve/server.d.ts +1 -2
- package/dist/core/serve/sessions.d.ts +5 -4
- package/dist/core/serve/sessions.js +102 -29
- package/dist/core/service.d.ts +118 -20
- package/dist/core/service.js +249 -91
- package/dist/core/stale.js +2 -1
- package/dist/core/tool-registry.js +6 -3
- package/dist/core/verify-pages.d.ts +19 -0
- package/dist/core/verify-pages.js +110 -0
- package/dist/core/version.d.ts +2 -2
- package/dist/core/version.js +2 -2
- package/dist/web/assets/index-8J-FUbb0.js +29 -0
- package/dist/web/assets/index-BGpAZzeM.css +1 -0
- package/dist/web/index.html +2 -2
- package/docs/format-spec.md +40 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bin/docsworth-mcp.mjs +1 -1
- package/dist/web/assets/index-BglLMIyN.css +0 -1
- package/dist/web/assets/index-DugFPoRu.js +0 -25
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { join, posix, sep } from "node:path";
|
|
4
|
+
import { Parser } from "commonmark";
|
|
5
|
+
import { applyEdits } from "./edits.js";
|
|
6
|
+
import { resolveLink } from "./repo.js";
|
|
7
|
+
// A page's links as CommonMark reads them (docs/format-spec.md, "Links"), for lint and for
|
|
8
|
+
// adopt's rewriting.
|
|
9
|
+
// commonmark.js decides: no link in code or HTML, destinations with nested parentheses,
|
|
10
|
+
// link text with nested brackets, reference links through the definitions they use.
|
|
11
|
+
/** Every link and image destination in `body`, in order, as commonmark.js parses it. */
|
|
12
|
+
export function pageLinks(body) {
|
|
13
|
+
return destinations(new Parser().parse(body));
|
|
14
|
+
}
|
|
15
|
+
function destinations(doc) {
|
|
16
|
+
return linksIn(doc).map((l) => l.dest);
|
|
17
|
+
}
|
|
18
|
+
/** Each link and image in `doc`, in order: its destination, and what else tells it apart (kind, title, text). */
|
|
19
|
+
function linksIn(doc) {
|
|
20
|
+
const out = [];
|
|
21
|
+
const walker = doc.walker();
|
|
22
|
+
for (let e = walker.next(); e; e = walker.next()) {
|
|
23
|
+
if (!e.entering || (e.node.type !== "link" && e.node.type !== "image"))
|
|
24
|
+
continue;
|
|
25
|
+
// Its own text, not its subtree's: nested images would make that quadratic.
|
|
26
|
+
let text = "";
|
|
27
|
+
for (let c = e.node.firstChild; c; c = c.next)
|
|
28
|
+
if (c.type === "text" || c.type === "code")
|
|
29
|
+
text += c.literal ?? "";
|
|
30
|
+
out.push({ dest: e.node.destination ?? "", rest: `${e.node.type}\0${e.node.title ?? ""}\0${text}` });
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Where each link's destination is written in `body`, for rewriting it in place. A liberal
|
|
36
|
+
* scanner proposes candidates (after every `](`, and after a definition's `]:`); then
|
|
37
|
+
* commonmark.js confirms them, all at once. Each candidate gets a token of its own written
|
|
38
|
+
* just before it, the body is parsed once, and a candidate counts only if some link's or
|
|
39
|
+
* image's destination is its token followed by what CommonMark reads the candidate as.
|
|
40
|
+
* Text is only added, never taken away, so the page parses as it did: whatever CommonMark
|
|
41
|
+
* doesn't link (code, HTML, a definition no link uses) is left out by its own rules, and
|
|
42
|
+
* candidates may overlap.
|
|
43
|
+
*/
|
|
44
|
+
export function linkSpans(body, nonce = randomNonce) {
|
|
45
|
+
const byStart = new Map();
|
|
46
|
+
for (const c of [...inlineCandidates(body), ...definitionCandidates(body)])
|
|
47
|
+
if (!byStart.has(c.start))
|
|
48
|
+
byStart.set(c.start, c);
|
|
49
|
+
const candidates = [...byStart.values()].sort((a, b) => a.start - b.start);
|
|
50
|
+
if (!candidates.length)
|
|
51
|
+
return [];
|
|
52
|
+
// Tokens no destination the page already has could be mistaken for: absent from its text and
|
|
53
|
+
// from what CommonMark reads (an entity can spell one).
|
|
54
|
+
const original = linksIn(new Parser().parse(body));
|
|
55
|
+
const prefix = markerPrefix(body, original.map((l) => l.dest), nonce);
|
|
56
|
+
if (prefix === null)
|
|
57
|
+
return [];
|
|
58
|
+
const tokens = candidates.map((_, i) => `${prefix}${i}x`);
|
|
59
|
+
const marked = applyEdits(body, candidates.map((c, i) => ({ start: c.start, end: c.start, insert: tokens[i] })));
|
|
60
|
+
// A token takes no text away, but it can make a link: the line under a bare label (`[d]:`
|
|
61
|
+
// then `<div>`, or `---`) reads as its destination once a token starts it. So each link in
|
|
62
|
+
// the tokened page must use up one of the page's own, the same but for the token (its
|
|
63
|
+
// destination, kind, title and text); a candidate whose token makes one more is out.
|
|
64
|
+
const own = new Map();
|
|
65
|
+
const key = (dest, rest) => `${dest}\0${rest}`;
|
|
66
|
+
for (const l of original)
|
|
67
|
+
own.set(key(l.dest, l.rest), (own.get(key(l.dest, l.rest)) ?? 0) + 1);
|
|
68
|
+
const tokenAt = new RegExp(`^${prefix}(\\d+)x`);
|
|
69
|
+
const anyToken = new RegExp(`${prefix}\\d+x`, "g");
|
|
70
|
+
const seen = new Map(); // candidate → the destination its token led
|
|
71
|
+
const made = new Set();
|
|
72
|
+
for (const l of linksIn(new Parser().parse(marked))) {
|
|
73
|
+
const m = tokenAt.exec(l.dest);
|
|
74
|
+
const dest = m ? l.dest.slice(m[0].length) : l.dest;
|
|
75
|
+
const k = key(dest, l.rest.replace(anyToken, ""));
|
|
76
|
+
const left = own.get(k) ?? 0;
|
|
77
|
+
if (left > 0)
|
|
78
|
+
own.set(k, left - 1);
|
|
79
|
+
if (!m)
|
|
80
|
+
continue;
|
|
81
|
+
const i = Number(m[1]);
|
|
82
|
+
if (left === 0)
|
|
83
|
+
made.add(i);
|
|
84
|
+
seen.set(i, dest);
|
|
85
|
+
}
|
|
86
|
+
const spans = candidates.flatMap((c, i) => {
|
|
87
|
+
const dest = readDestination(c.raw, c.angle);
|
|
88
|
+
return seen.get(i) === dest && !made.has(i) ? [{ ...c, dest }] : [];
|
|
89
|
+
});
|
|
90
|
+
return nonOverlapping(spans);
|
|
91
|
+
}
|
|
92
|
+
const randomNonce = () => `dw${randomBytes(8).toString("hex")}m`;
|
|
93
|
+
/** A random marker prefix the page holds nowhere, in its text or its destinations; null after eight tries. */
|
|
94
|
+
function markerPrefix(body, already, nonce) {
|
|
95
|
+
for (let tries = 0; tries < 8; tries++) {
|
|
96
|
+
const prefix = nonce();
|
|
97
|
+
if (!body.includes(prefix) && !already.some((d) => d.includes(prefix)))
|
|
98
|
+
return prefix;
|
|
99
|
+
}
|
|
100
|
+
return null; // with 64 random bits a try, only a page built against a fixed nonce gets here
|
|
101
|
+
}
|
|
102
|
+
// Nested parentheses a destination may hold. CommonMark lets an implementation set a limit (at
|
|
103
|
+
// least 3); commonmark.js sets none, but without one each `](` on a run of them scanned on through
|
|
104
|
+
// every later `(`: 64 KB of `](` took 1.7 s. A deeper real destination is only unlocated, and
|
|
105
|
+
// lint judges it.
|
|
106
|
+
const MAX_PARENS = 32;
|
|
107
|
+
/** A destination at `at` (whitespace before it skipped), read as CommonMark reads one. */
|
|
108
|
+
function scanDestination(body, at) {
|
|
109
|
+
let i = at;
|
|
110
|
+
while (body[i] === " " || body[i] === "\t")
|
|
111
|
+
i++;
|
|
112
|
+
if (body[i] === "\r" && body[i + 1] === "\n")
|
|
113
|
+
i++;
|
|
114
|
+
if (body[i] === "\n") {
|
|
115
|
+
i++;
|
|
116
|
+
while (body[i] === " " || body[i] === "\t")
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
if (body[i] === "<") {
|
|
120
|
+
let j = i + 1;
|
|
121
|
+
while (j < body.length && body[j] !== ">" && body[j] !== "\n" && body[j] !== "<")
|
|
122
|
+
j += body[j] === "\\" ? 2 : 1;
|
|
123
|
+
return body[j] === ">" ? { raw: body.slice(i + 1, j), start: i + 1, end: j, angle: true } : null;
|
|
124
|
+
}
|
|
125
|
+
let depth = 0;
|
|
126
|
+
let j = i;
|
|
127
|
+
for (; j < body.length; j++) {
|
|
128
|
+
const c = body[j];
|
|
129
|
+
if (c === "\\" && j + 1 < body.length) {
|
|
130
|
+
j++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (c <= " " || c === "\x7f")
|
|
134
|
+
break;
|
|
135
|
+
if (c === "(" && ++depth > MAX_PARENS)
|
|
136
|
+
return null;
|
|
137
|
+
else if (c === ")") {
|
|
138
|
+
if (depth === 0)
|
|
139
|
+
break;
|
|
140
|
+
depth--;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return j > i && depth === 0 ? { raw: body.slice(i, j), start: i, end: j, angle: false } : null;
|
|
144
|
+
}
|
|
145
|
+
function inlineCandidates(body) {
|
|
146
|
+
const out = [];
|
|
147
|
+
for (let i = body.indexOf("]("); i >= 0; i = body.indexOf("](", i + 2)) {
|
|
148
|
+
const c = scanDestination(body, i + 2);
|
|
149
|
+
if (c)
|
|
150
|
+
out.push(c);
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
// A definition's label and colon, after any container markers: `>`, list items' bullets and
|
|
155
|
+
// numbers, and indentation.
|
|
156
|
+
const DEFINITION = /^(?:[ \t]*(?:>|(?:[-+*]|\d{1,9}[.)])(?=[ \t])))*[ \t]*\[(?:[^\]\\\n]|\\.){1,999}\]:/gm;
|
|
157
|
+
function definitionCandidates(body) {
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const m of body.matchAll(DEFINITION)) {
|
|
160
|
+
const c = scanDestination(body, m.index + m[0].length);
|
|
161
|
+
if (c)
|
|
162
|
+
out.push(c);
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
function nonOverlapping(cs) {
|
|
167
|
+
const sorted = [...cs].sort((a, b) => a.start - b.start);
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const c of sorted)
|
|
170
|
+
if (!out.length || c.start >= out[out.length - 1].end)
|
|
171
|
+
out.push(c);
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
/** A destination as written, read by CommonMark: escapes and entities applied, encoded. */
|
|
175
|
+
export function readDestination(raw, angle) {
|
|
176
|
+
const link = new Parser().parse(`[x](${angle ? `<${raw}>` : raw})`).firstChild?.firstChild;
|
|
177
|
+
return link?.type === "link" ? (link.destination ?? raw) : raw;
|
|
178
|
+
}
|
|
179
|
+
/** A destination that leaves the page's files: a scheme, `//`, or an in-page `#`. */
|
|
180
|
+
export const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
|
|
181
|
+
/** A destination's path: no query or fragment, still percent-encoded (`resolveLink` decodes it once). */
|
|
182
|
+
export function linkPath(dest) {
|
|
183
|
+
return dest.split(/[?#]/)[0];
|
|
184
|
+
}
|
|
185
|
+
/** A path from `linkPath`, decoded: to show, and to match against file names. */
|
|
186
|
+
export function shownPath(path) {
|
|
187
|
+
try {
|
|
188
|
+
return decodeURI(path);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return path;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* How lint judges a link's path (from `linkPath`) on the page at `fromPath`: `missing` when it
|
|
196
|
+
* resolves to nothing in the repo; under the knowledge root, `directory` or `extensionless`
|
|
197
|
+
* when it names no page or attachment (R8b), since redaction couldn't tell which page it
|
|
198
|
+
* means; otherwise null.
|
|
199
|
+
*/
|
|
200
|
+
export function linkFault(repoRoot, knowledgeRoot, fromPath, path, opts = {}) {
|
|
201
|
+
const aliases = opts.aliases ?? (() => []);
|
|
202
|
+
const target = resolveLink(fromPath, path);
|
|
203
|
+
const abs = join(repoRoot, target);
|
|
204
|
+
const rootDir = posix.normalize(knowledgeRoot).replace(/\/+$/, "");
|
|
205
|
+
const underRoot = target === rootDir || target.startsWith(`${rootDir}/`);
|
|
206
|
+
if (target.startsWith("..") || !existsSync(abs)) {
|
|
207
|
+
// A root-relative attachment (`/img/x.png`) a docs site serves from a static directory. A page
|
|
208
|
+
// isn't one: pages are under the knowledge root.
|
|
209
|
+
const served = path.startsWith("/") && isAttachment(target) && (opts.staticDirs ?? []).some((d) => servedFrom(repoRoot, d, target));
|
|
210
|
+
return { target, fault: served ? null : "missing", underRoot };
|
|
211
|
+
}
|
|
212
|
+
if (!underRoot) {
|
|
213
|
+
// A root-relative route that names a directory or an extensionless file of the repo's, and,
|
|
214
|
+
// read from the knowledge root as a docs site serves it, a page: redaction can't tell which.
|
|
215
|
+
if (path.startsWith("/") && (statSync(abs).isDirectory() || posix.extname(target).length <= 1)) {
|
|
216
|
+
const routes = [target, ...(target === "docs" || target.startsWith("docs/") ? [target.slice("docs/".length)] : [])];
|
|
217
|
+
const pages = [...new Set([...routePages(repoRoot, rootDir, target), ...routes.flatMap(aliases)])].sort();
|
|
218
|
+
if (pages.length)
|
|
219
|
+
return { target, fault: "route", underRoot, pages };
|
|
220
|
+
}
|
|
221
|
+
return { target, fault: null, underRoot };
|
|
222
|
+
}
|
|
223
|
+
// A directory first: one can be named `guide.md`.
|
|
224
|
+
if (statSync(abs).isDirectory())
|
|
225
|
+
return { target, fault: "directory", underRoot };
|
|
226
|
+
// `notes.` has no extension: extname gives a bare dot.
|
|
227
|
+
return { target, fault: target.endsWith(".md") || posix.extname(target).length > 1 ? null : "extensionless", underRoot };
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The page files under the knowledge root a root-relative route names on a docs site, with or
|
|
231
|
+
* without a leading `docs/`: each segment a directory or, last, a page, by its name or without
|
|
232
|
+
* its number prefix (`02-ops` is `ops`); a directory serves its category index. Lint fails a
|
|
233
|
+
* route this finds that also names a file of the repo's, and adopt lists one only a number prefix
|
|
234
|
+
* reaches. Dot-directories are skipped, as the loader skips them.
|
|
235
|
+
*/
|
|
236
|
+
export function routePages(repoRoot, rootDir, route) {
|
|
237
|
+
const named = (name, seg) => name === seg || unprefixed(name) === seg;
|
|
238
|
+
const list = (dir) => {
|
|
239
|
+
try {
|
|
240
|
+
return readdirSync(join(repoRoot, dir), { withFileTypes: true }).filter((e) => !e.name.startsWith(".") || e.isFile());
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
const out = new Set();
|
|
247
|
+
const routes = [route, ...(route === "docs" || route.startsWith("docs/") ? [route.slice("docs".length)] : [])];
|
|
248
|
+
for (const r of routes) {
|
|
249
|
+
const segs = r.split("/").filter(Boolean);
|
|
250
|
+
let dirs = [rootDir];
|
|
251
|
+
segs.forEach((seg, i) => {
|
|
252
|
+
const next = [];
|
|
253
|
+
for (const d of dirs)
|
|
254
|
+
for (const e of list(d)) {
|
|
255
|
+
if (e.isDirectory() && named(e.name, seg))
|
|
256
|
+
next.push(posix.join(d, e.name));
|
|
257
|
+
if (i === segs.length - 1 && e.isFile() && e.name.endsWith(".md") && named(e.name.slice(0, -3), seg))
|
|
258
|
+
out.add(posix.join(d, e.name));
|
|
259
|
+
}
|
|
260
|
+
dirs = next;
|
|
261
|
+
});
|
|
262
|
+
for (const d of dirs)
|
|
263
|
+
for (const e of list(d))
|
|
264
|
+
if (e.isFile() && e.name.endsWith(".md") && isCategoryIndex(e.name.slice(0, -3), d === rootDir ? undefined : posix.basename(d)))
|
|
265
|
+
out.add(posix.join(d, e.name));
|
|
266
|
+
}
|
|
267
|
+
return [...out].sort();
|
|
268
|
+
}
|
|
269
|
+
// Docusaurus's default number-prefix parser (plugin-content-docs, numberPrefix.ts): `01-intro`
|
|
270
|
+
// is `intro`, while a date's or version's digits (`2021-01-01-notes`, `1.0-x`) stay.
|
|
271
|
+
const IGNORED_PREFIX = /^\d+[-_.]\d+/;
|
|
272
|
+
const NUMBER_PREFIX = /^\d+\s*[-_.]+\s*([^-_.\s].*)$/;
|
|
273
|
+
/** A file or directory name as Docusaurus routes it: its number prefix removed. */
|
|
274
|
+
export function unprefixed(name) {
|
|
275
|
+
if (IGNORED_PREFIX.test(name))
|
|
276
|
+
return name;
|
|
277
|
+
return NUMBER_PREFIX.exec(name)?.[1] ?? name;
|
|
278
|
+
}
|
|
279
|
+
/** Docusaurus's category index: `index`, `README`, or a file named after its directory. */
|
|
280
|
+
export function isCategoryIndex(stem, dir) {
|
|
281
|
+
// By the names as written, as Docusaurus compares them: `04-other/other.md` isn't one (a
|
|
282
|
+
// real build served it at `…/other/other`, 2026-11-13).
|
|
283
|
+
return ["index", "readme", dir?.toLowerCase()].includes(stem.toLowerCase());
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Where a page's file puts it on a Docusaurus site, as a route without a leading slash: its
|
|
287
|
+
* path under the knowledge root without number prefixes, a category index at its directory.
|
|
288
|
+
*/
|
|
289
|
+
export function fileRoute(under) {
|
|
290
|
+
const parts = under.split("/");
|
|
291
|
+
const stem = parts.pop().replace(/\.md$/, "");
|
|
292
|
+
const index = isCategoryIndex(stem, parts.at(-1));
|
|
293
|
+
return posix.join(...parts.map(unprefixed), index ? "" : unprefixed(stem)).replace(/^\.$/, "");
|
|
294
|
+
}
|
|
295
|
+
/** The one route a docs site serves a page at: its `siteRoute`, else its `fileRoute`. */
|
|
296
|
+
export function pageRoute(under, slug, id) {
|
|
297
|
+
return siteRoute(under, slug, id) ?? fileRoute(under);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Where a page's own `slug`, or an `id` other than its file name, puts it on a Docusaurus site,
|
|
301
|
+
* as a route without a leading slash; null when that's where its file is. `under` is its path
|
|
302
|
+
* under the knowledge root. (A category index routes by its directory whatever its id.)
|
|
303
|
+
*/
|
|
304
|
+
export function siteRoute(under, slug, id) {
|
|
305
|
+
const parts = under.split("/");
|
|
306
|
+
const stem = parts.pop().replace(/\.md$/, "");
|
|
307
|
+
const dirs = parts.map(unprefixed);
|
|
308
|
+
const index = isCategoryIndex(stem, parts.at(-1));
|
|
309
|
+
const file = fileRoute(under);
|
|
310
|
+
let route;
|
|
311
|
+
if (typeof slug === "string")
|
|
312
|
+
route = slug.startsWith("/") ? slug : posix.join(...dirs, slug);
|
|
313
|
+
else if (typeof id === "string" && !index && id !== unprefixed(stem))
|
|
314
|
+
route = posix.join(...dirs, id);
|
|
315
|
+
else
|
|
316
|
+
return null;
|
|
317
|
+
route = posix.normalize(route).replace(/^\/+|\/+$/g, "").replace(/^\.$/, "");
|
|
318
|
+
return route === file ? null : route;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Whether `target` is a file inside static directory `dir`, by real path: a path that climbs out
|
|
322
|
+
* (`/../../package.json`), a link in it that leads out, or a directory that is itself a link out
|
|
323
|
+
* of the repo, is nothing the site serves there.
|
|
324
|
+
*/
|
|
325
|
+
export function servedFrom(repoRoot, dir, target) {
|
|
326
|
+
// A path that climbs (`/../static/a.png`, or `/x/../../static/a.png` once normalized) names what
|
|
327
|
+
// a browser would make of it, at the site's root: never this file, even when climbing out and
|
|
328
|
+
// back in lands on it.
|
|
329
|
+
const path = posix.normalize(target);
|
|
330
|
+
if (path === ".." || path.startsWith("../"))
|
|
331
|
+
return false;
|
|
332
|
+
try {
|
|
333
|
+
const repo = realpathSync(repoRoot);
|
|
334
|
+
const base = realpathSync(join(repoRoot, dir));
|
|
335
|
+
const file = realpathSync(join(base, path));
|
|
336
|
+
return base.startsWith(`${repo}${sep}`) && file.startsWith(`${base}${sep}`) && statSync(file).isFile();
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** A path a static directory can serve: a file with an extension, never a page (`.md`), which lives under the knowledge root. */
|
|
343
|
+
export function isAttachment(path) {
|
|
344
|
+
return !path.endsWith(".md") && posix.extname(path).length > 1;
|
|
345
|
+
}
|
package/dist/core/lint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Repo } from "./repo.js";
|
|
2
2
|
export type Severity = "error" | "warning";
|
|
3
3
|
export interface Diagnostic {
|
|
4
4
|
severity: Severity;
|
|
@@ -12,6 +12,5 @@ export interface LintResult {
|
|
|
12
12
|
warnings: number;
|
|
13
13
|
pages: number;
|
|
14
14
|
}
|
|
15
|
-
/** Lint a loaded knowledge repo. Errors fail CI; warnings never do (e.g. overdue pages). */
|
|
16
15
|
export declare function lintRepo(repo: Repo, now: Date): LintResult;
|
|
17
16
|
export declare function formatDiagnostics(result: LintResult): string;
|
package/dist/core/lint.js
CHANGED
|
@@ -4,16 +4,26 @@ import { join, posix } from "node:path";
|
|
|
4
4
|
import { matchRule } from "./codeowners.js";
|
|
5
5
|
import { CONFIG_PATH } from "./config.js";
|
|
6
6
|
import { pageStatus } from "./gate.js";
|
|
7
|
-
import {
|
|
7
|
+
import { EXTERNAL, linkFault, linkPath, pageLinks, shownPath, siteRoute } from "./links.js";
|
|
8
|
+
import { printable } from "./printable.js";
|
|
8
9
|
// Directories whose pages must be a given type; elsewhere any type is fine.
|
|
9
10
|
const DIR_TYPES = { runbooks: "runbook", decisions: "adr", services: "service" };
|
|
10
11
|
const ADR_FILENAME = /^ADR-\d{4}-[a-z0-9-]+\.md$/;
|
|
11
12
|
/** Lint a loaded knowledge repo. Errors fail CI; warnings never do (e.g. overdue pages). */
|
|
13
|
+
// Links as CommonMark reads them (R8: none in code or HTML, reference links through their
|
|
14
|
+
// definitions), relative ones only, as written apart from encoding, without query or fragment.
|
|
15
|
+
function lintedLinks(body) {
|
|
16
|
+
return pageLinks(body)
|
|
17
|
+
.filter((d) => d && !EXTERNAL.test(d))
|
|
18
|
+
.map(linkPath)
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
}
|
|
12
21
|
export function lintRepo(repo, now) {
|
|
13
22
|
const out = [];
|
|
14
23
|
const err = (path, rule, message) => out.push({ severity: "error", path, rule, message });
|
|
15
24
|
const warn = (path, rule, message) => out.push({ severity: "warning", path, rule, message });
|
|
16
25
|
const root = repo.config.knowledge_root;
|
|
26
|
+
const extraFields = new Set(repo.config.frontmatter.extra_fields);
|
|
17
27
|
// Repo-level structure.
|
|
18
28
|
if (!repo.codeowners.path)
|
|
19
29
|
err("CODEOWNERS", "codeowners-missing", "no CODEOWNERS file (.github/, root or docs/)");
|
|
@@ -82,20 +92,43 @@ export function lintRepo(repo, now) {
|
|
|
82
92
|
else
|
|
83
93
|
seen.set(p.id, p.path);
|
|
84
94
|
}
|
|
95
|
+
// The routes pages' slugs and ids give them, once, when a link first needs them.
|
|
96
|
+
let aliasMap;
|
|
97
|
+
const aliases = (route) => {
|
|
98
|
+
if (!aliasMap) {
|
|
99
|
+
aliasMap = new Map();
|
|
100
|
+
for (const p of repo.pages) {
|
|
101
|
+
const r = siteRoute(p.path.slice(root.length + 1), p.slug, p.id);
|
|
102
|
+
if (r !== null)
|
|
103
|
+
aliasMap.set(r, [...(aliasMap.get(r) ?? []), p.path]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return aliasMap.get(route) ?? [];
|
|
107
|
+
};
|
|
85
108
|
for (const page of repo.pages) {
|
|
86
109
|
const status = pageStatus(page, repo, now);
|
|
87
110
|
if (status.ownership_problem)
|
|
88
111
|
err(page.path, "ownership", status.ownership_problem);
|
|
89
|
-
for (const
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
for (const path of lintedLinks(page.body)) {
|
|
113
|
+
// Under the knowledge root a link names a page or an attachment. A directory or a file
|
|
114
|
+
// with no extension names no page redaction could recognize (a Docusaurus route, say).
|
|
115
|
+
const { target, fault, pages } = linkFault(repo.root, root, page.path, path, { aliases, staticDirs: repo.config.static_dirs });
|
|
116
|
+
const link = shownPath(path);
|
|
117
|
+
if (fault === "missing")
|
|
92
118
|
err(page.path, "broken-link", `link to \`${link}\` does not resolve (${target})`);
|
|
119
|
+
else if (fault === "directory")
|
|
120
|
+
err(page.path, "broken-link", `link to \`${link}\` names a directory, not a page; link the page (\`${link.replace(/\/+$/, "")}/….md\`)`);
|
|
121
|
+
else if (fault === "extensionless")
|
|
122
|
+
err(page.path, "broken-link", `link to \`${link}\` names a file with no extension, not a page; link a page (\`.md\`) or an attachment`);
|
|
123
|
+
else if (fault === "route")
|
|
124
|
+
err(page.path, "broken-link", `link to \`${link}\` names ${target}, outside the knowledge root, and on a docs site the page ${pages.join(" or ")}: link the one you mean, with its extension`);
|
|
93
125
|
}
|
|
94
126
|
for (const id of page.supersedes ?? [])
|
|
95
127
|
if (!seen.has(id))
|
|
96
128
|
warn(page.path, "supersedes-unknown", `supersedes \`${id}\`, which is not a page in this repo`);
|
|
97
129
|
for (const f of page.unknownFields)
|
|
98
|
-
|
|
130
|
+
if (!extraFields.has(f))
|
|
131
|
+
warn(page.path, "unknown-field", `unknown frontmatter field \`${f}\``);
|
|
99
132
|
const dir = page.path.slice(root.length + 1).split("/")[0] ?? "";
|
|
100
133
|
const wantType = DIR_TYPES[dir];
|
|
101
134
|
if (wantType && page.type !== wantType)
|
|
@@ -109,7 +142,8 @@ export function lintRepo(repo, now) {
|
|
|
109
142
|
return { diagnostics: out, errors, warnings: out.length - errors, pages: repo.pages.length + repo.invalid.length };
|
|
110
143
|
}
|
|
111
144
|
export function formatDiagnostics(result) {
|
|
112
|
-
|
|
145
|
+
// Paths and messages quote what pages hold: printed escaped, one diagnostic to a line.
|
|
146
|
+
const lines = result.diagnostics.map((d) => `${printable(d.path)}: ${d.severity} [${d.rule}] ${printable(d.message)}`);
|
|
113
147
|
lines.push(`${result.pages} pages, ${result.errors} errors, ${result.warnings} warnings`);
|
|
114
148
|
return lines.join("\n");
|
|
115
149
|
}
|
package/dist/core/mcp/server.js
CHANGED
|
@@ -87,6 +87,7 @@ export function createMcpServer(service, opts = {}) {
|
|
|
87
87
|
applies_to: z.string().optional().describe("Only pages that apply to this, e.g. `service:payments`"),
|
|
88
88
|
include_stale: z.boolean().optional().describe("Also return non-citable pages inline, labeled with `gate`"),
|
|
89
89
|
limit: z.number().int().min(1).max(25).optional(),
|
|
90
|
+
snippets: z.enum(["markdown", "text"]).optional().describe("`text`: snippets as a reader sees the page, without markup. Default `markdown`"),
|
|
90
91
|
},
|
|
91
92
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
92
93
|
}, (args) => after(run(() => service.search(args)))));
|
|
@@ -162,11 +163,19 @@ export function createMcpServer(service, opts = {}) {
|
|
|
162
163
|
if (opts.surface === "http") {
|
|
163
164
|
const view = { readOnlyHint: true, openWorldHint: false };
|
|
164
165
|
registered.set("ui_capabilities", server.registerTool("ui_capabilities", { title: "Capabilities", description: "For the Docsworth web app: who is served, in which mode, and whether writes would work.", inputSchema: {}, annotations: view }, () => after(run(() => service.uiCapabilities()))));
|
|
165
|
-
registered.set("ui_overview", server.registerTool("ui_overview", {
|
|
166
|
+
registered.set("ui_overview", server.registerTool("ui_overview", {
|
|
167
|
+
title: "Overview",
|
|
168
|
+
description: "For the Docsworth web app: counts of the caller's visible pages, by team, with what is due soon and what is stale.",
|
|
169
|
+
inputSchema: { list_limit: z.number().int().min(1).max(1000).optional().describe("At most this many of each list: the most pressing first") },
|
|
170
|
+
annotations: view,
|
|
171
|
+
}, (args) => after(run(() => service.uiOverview(args)))));
|
|
166
172
|
registered.set("ui_graph", server.registerTool("ui_graph", {
|
|
167
173
|
title: "Link graph",
|
|
168
174
|
description: "For the Docsworth web app: the caller's visible pages and the links between them, or one team's.",
|
|
169
|
-
inputSchema: {
|
|
175
|
+
inputSchema: {
|
|
176
|
+
owner_team: z.string().optional().describe("Only this team's pages"),
|
|
177
|
+
max_nodes: z.number().int().min(1).max(500).optional().describe("At most this many pages: the most-linked ones"),
|
|
178
|
+
},
|
|
170
179
|
annotations: view,
|
|
171
180
|
}, (args) => after(run(() => service.uiGraph(args)))));
|
|
172
181
|
registered.set("ui_page", server.registerTool("ui_page", {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** `text` with control and bidirectional characters written as escapes (`\x1b`, `\u202e`). */
|
|
2
|
+
export declare function printable(text: string): string;
|
|
3
|
+
/** `value` as JSON, every such character a `\u` escape: JSON.stringify leaves C1 and bidirectional ones as they are. */
|
|
4
|
+
export declare function printableJson(value: unknown): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Text a page holds, made safe to print on a terminal: a decoded link, a frontmatter value or a
|
|
2
|
+
// file name could carry escape sequences that clear or spoof a report, line breaks that fake
|
|
3
|
+
// another line, or bidirectional overrides that reorder it.
|
|
4
|
+
// C0 and C1 controls, Unicode's Bidi_Control set, and its line and paragraph separators.
|
|
5
|
+
const UNPRINTABLE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
6
|
+
// The same but C0, which JSON.stringify escapes inside strings and uses outside them (a newline).
|
|
7
|
+
const UNPRINTABLE_IN_JSON = /[\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
8
|
+
const hex = (c, width) => c.charCodeAt(0).toString(16).padStart(width, "0");
|
|
9
|
+
/** `text` with control and bidirectional characters written as escapes (`\x1b`, `\u202e`). */
|
|
10
|
+
export function printable(text) {
|
|
11
|
+
return text.replace(UNPRINTABLE, (c) => (c.charCodeAt(0) < 0x100 ? `\\x${hex(c, 2)}` : `\\u${hex(c, 4)}`));
|
|
12
|
+
}
|
|
13
|
+
/** `value` as JSON, every such character a `\u` escape: JSON.stringify leaves C1 and bidirectional ones as they are. */
|
|
14
|
+
export function printableJson(value) {
|
|
15
|
+
return JSON.stringify(value, null, 2).replace(UNPRINTABLE_IN_JSON, (c) => `\\u${hex(c, 4)}`);
|
|
16
|
+
}
|
|
@@ -29,6 +29,21 @@ export interface PageNames {
|
|
|
29
29
|
depth: number;
|
|
30
30
|
/** The knowledge repo's URL prefixes on GitHub (`github.com/acme/knowledge/`), if it's there. */
|
|
31
31
|
repoUrls: string[];
|
|
32
|
+
/** Each page's one route on a docs site (`pageRoute`) → whether it's hidden (hidden wins a shared route). */
|
|
33
|
+
routes: Map<string, boolean>;
|
|
34
|
+
/** Hidden pages' paths under the root without `.md`: a link by a file's stem (`sec/1.0-release`). */
|
|
35
|
+
stems: Set<string>;
|
|
36
|
+
/** For each loaded page's repo path, the directories its URL reads a relative link from. */
|
|
37
|
+
urlDirs: Map<string, string[]>;
|
|
38
|
+
/** `site_url`'s host (port included), path prefix (no trailing slash), host name, and where that name starts a token's URL. */
|
|
39
|
+
site?: {
|
|
40
|
+
host: string;
|
|
41
|
+
prefix: string;
|
|
42
|
+
name: string;
|
|
43
|
+
at: RegExp;
|
|
44
|
+
};
|
|
45
|
+
/** Whether a repo path, as written (not folded), is a file or directory in the repo. */
|
|
46
|
+
exists: (repoPath: string) => boolean;
|
|
32
47
|
/** The same, folded as rendered text (`foldRendered`), for the rendered view. */
|
|
33
48
|
rendered?: PageNames;
|
|
34
49
|
}
|
|
@@ -37,11 +52,31 @@ export declare function pageNames(repo: Repo, hidden: (page: Page) => boolean):
|
|
|
37
52
|
* Whether a link destination, as written on page `from`, leads to a hidden page: one
|
|
38
53
|
* that resolves to it, or a URL that ends with its path. A path under the knowledge root
|
|
39
54
|
* that is no loaded page fails closed: a page that didn't load has no trustworthy ACL.
|
|
40
|
-
* `html` for an attribute in raw HTML, where backslash escapes don't apply.
|
|
55
|
+
* `html` for an attribute in raw HTML, where backslash escapes don't apply. `links`, the
|
|
56
|
+
* text's CommonMark links (`linkPaths`): a route naming nothing fails closed only where it's
|
|
57
|
+
* one of them (or an attribute's), not where only the liberal reading finds it (`[^1]: The
|
|
58
|
+
* note`, `[term]: word`); a hidden page's route hides it wherever it stands.
|
|
41
59
|
*/
|
|
42
|
-
export declare function destinationHides(dest: string, from: string, names: PageNames, html?: boolean): boolean;
|
|
60
|
+
export declare function destinationHides(dest: string, from: string, names: PageNames, html?: boolean, links?: ReadonlySet<string>): boolean;
|
|
61
|
+
/** The paths of `text`'s links and images, as commonmark.js parses them (`commonmarkPath`). */
|
|
62
|
+
export declare function linkPaths(text: string): Set<string>;
|
|
63
|
+
/**
|
|
64
|
+
* CommonMark's link definitions in `text`, the first for each label (keyed as `normalLabel`
|
|
65
|
+
* keys a label) → its destination; undefined where commonmark.js doesn't say.
|
|
66
|
+
*/
|
|
67
|
+
export declare function definitionsIn(text: string): Map<string, string> | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* The lines of `text` holding a reference to one of `labels` (definitions that go): left, a
|
|
70
|
+
* reference shows its label as text, in whatever container (code review, round 1: `> [HSM]`
|
|
71
|
+
* over `> [HSM]: …`), or before a `:` (`[ref]:` that can't start a definition is one).
|
|
72
|
+
* Matched as CommonMark matches labels (case, runs of space, and space inside the brackets
|
|
73
|
+
* aside), anywhere on a line.
|
|
74
|
+
*/
|
|
75
|
+
export declare function labelLines(text: string, labels: Iterable<string>): Set<number>;
|
|
76
|
+
/** A label as CommonMark compares labels: runs of space one space, case folded as Unicode folds it (`ß` is `SS`: code review, round 2). */
|
|
77
|
+
export declare const normalLabel: (s: string) => string;
|
|
43
78
|
/** The lines of `text`, on page `from`, that name a hidden page: see the module comment. */
|
|
44
|
-
export declare function namingLines(text: string, from: string, names: PageNames, depth?: number, inherited?: string[]): Set<number>;
|
|
79
|
+
export declare function namingLines(text: string, from: string, names: PageNames, depth?: number, inherited?: string[], liberal?: boolean, gone?: Set<string>): Set<number>;
|
|
45
80
|
/**
|
|
46
81
|
* The lines of `text` where `find`, run over each view the sweep reads (as prose and
|
|
47
82
|
* joined, with and without CommonMark's escapes, and as rendered), reports a span. For
|