@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,665 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { Parser } from "commonmark";
|
|
6
|
+
import { Document, isMap, isSeq, parse as parseYaml, parseDocument, stringify } from "yaml";
|
|
7
|
+
import { describeOwnershipProblem, loadCodeowners, parseCodeowners, resolveTeamOwner } from "./codeowners.js";
|
|
8
|
+
import { CONFIG_PATH, ConfigSchema, loadConfig } from "./config.js";
|
|
9
|
+
import { DURATION_RE, formatDate, utcToday } from "./duration.js";
|
|
10
|
+
import { applyEdits } from "./edits.js";
|
|
11
|
+
import { UserError } from "./errors.js";
|
|
12
|
+
import { frontmatterSpan, splitFrontmatter } from "./frontmatter.js";
|
|
13
|
+
import { EXTERNAL, isAttachment, isCategoryIndex, linkFault, linkPath, linkSpans, pageLinks, readDestination, routePages, servedFrom, shownPath, siteRoute, unprefixed } from "./links.js";
|
|
14
|
+
export { unprefixed };
|
|
15
|
+
import { ID_RE, OPTIONAL_FIELDS, REQUIRED_FIELDS, SLUG_RE, validateFrontmatter } from "./schema.js";
|
|
16
|
+
// `docsworth adopt`: give existing markdown docs the frontmatter Docsworth needs, and turn
|
|
17
|
+
// their route links into links to page files, in the working tree only. A page's id is its
|
|
18
|
+
// own, else its file name, so a Docusaurus site's URLs don't move. Pages are drafts until a
|
|
19
|
+
// team verifies them. A page adopt can't settle is left byte for byte, and reported.
|
|
20
|
+
export class AdoptError extends UserError {
|
|
21
|
+
}
|
|
22
|
+
/** Whether `child` is `parent` or under it, by path components (so `/repo-other` isn't under `/repo`). */
|
|
23
|
+
export function within(parent, child) {
|
|
24
|
+
const rel = relative(parent, child);
|
|
25
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
26
|
+
}
|
|
27
|
+
/** R9's checks, before any page is read: the config and the knowledge root are inside the repo. */
|
|
28
|
+
export function repoPaths(root, Err = AdoptError, verb = "adopt") {
|
|
29
|
+
let repo;
|
|
30
|
+
try {
|
|
31
|
+
repo = realpathSync(root);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new Err(`no directory at ${root}`);
|
|
35
|
+
}
|
|
36
|
+
const configAbs = join(repo, CONFIG_PATH);
|
|
37
|
+
const configPath = existsSync(configAbs) ? realpathSync(configAbs) : configAbs;
|
|
38
|
+
if (!within(repo, configPath))
|
|
39
|
+
throw new Err(`${CONFIG_PATH} leads outside the repo (to ${configPath}); ${verb} writes only inside it`);
|
|
40
|
+
const config = loadConfig(repo);
|
|
41
|
+
const knowledgeAbs = join(repo, config.knowledge_root);
|
|
42
|
+
const knowledge = existsSync(knowledgeAbs) ? realpathSync(knowledgeAbs) : knowledgeAbs;
|
|
43
|
+
if (!within(repo, knowledge))
|
|
44
|
+
throw new Err(`knowledge_root \`${config.knowledge_root}\` is outside the repo; ${verb} writes only inside it`);
|
|
45
|
+
if (!existsSync(knowledge) || !statSync(knowledge).isDirectory())
|
|
46
|
+
throw new Err(`no knowledge root at ${config.knowledge_root} (knowledge_root in ${CONFIG_PATH})`);
|
|
47
|
+
// The loader names pages by the path it was given, CODEOWNERS and git by the real one: a
|
|
48
|
+
// link between them would give one page two names.
|
|
49
|
+
if (knowledge !== resolve(repo, config.knowledge_root))
|
|
50
|
+
throw new Err(`knowledge_root \`${config.knowledge_root}\` is a symbolic link, or under one (to ${knowledge}); set knowledge_root to the directory itself`);
|
|
51
|
+
// A config linked to a file in the knowledge root would be walked, and written, as a page.
|
|
52
|
+
if (within(knowledge, configPath))
|
|
53
|
+
throw new Err(`${CONFIG_PATH} is a file in the knowledge root (${configPath}), which ${verb} would read as a page; keep it outside`);
|
|
54
|
+
return { repo, config, configPath, knowledge };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Why a file with other hard links is left, or null: the other path could be anywhere,
|
|
58
|
+
* outside the repo too, and a write here would change the file there (R9).
|
|
59
|
+
*/
|
|
60
|
+
export function hardLinked(abs, verb) {
|
|
61
|
+
const n = lstatSync(abs).nlink;
|
|
62
|
+
return n > 1 ? `it has ${n} hard links: ${verb} writes no file another path shares` : null;
|
|
63
|
+
}
|
|
64
|
+
/** Why a symbolic link in the knowledge root is left. */
|
|
65
|
+
export const symlinkReason = (verb) => `a symbolic link: Docsworth reads no page through one, and ${verb} writes none`;
|
|
66
|
+
/**
|
|
67
|
+
* The knowledge root's pages, as the loader finds them (directories starting with a dot
|
|
68
|
+
* skipped), by real path. It follows no symbolic link: the loader reads none, and a link is
|
|
69
|
+
* the one way a walk could leave the root (R9). A link that names a page or a directory is
|
|
70
|
+
* reported.
|
|
71
|
+
*/
|
|
72
|
+
export function walk(dir, out) {
|
|
73
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
74
|
+
const abs = join(dir, e.name);
|
|
75
|
+
if (e.isSymbolicLink()) {
|
|
76
|
+
let isDir = false;
|
|
77
|
+
try {
|
|
78
|
+
isDir = statSync(abs).isDirectory();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* a dangling link names nothing */
|
|
82
|
+
}
|
|
83
|
+
if (isDir || e.name.endsWith(".md"))
|
|
84
|
+
out.symlinks.push(abs);
|
|
85
|
+
}
|
|
86
|
+
else if (e.isDirectory()) {
|
|
87
|
+
if (!e.name.startsWith("."))
|
|
88
|
+
walk(abs, out);
|
|
89
|
+
}
|
|
90
|
+
else if (e.isFile() && e.name.endsWith(".md")) {
|
|
91
|
+
out.files.push(abs);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const TYPE_DIRS = new Map([
|
|
96
|
+
["runbooks", "runbook"],
|
|
97
|
+
["decisions", "adr"],
|
|
98
|
+
["adr", "adr"],
|
|
99
|
+
["services", "service"],
|
|
100
|
+
["policies", "policy"],
|
|
101
|
+
]);
|
|
102
|
+
/** A page's type from its closest directory that names one, else `guide`. */
|
|
103
|
+
function typeFor(dirs) {
|
|
104
|
+
for (const d of [...dirs].reverse()) {
|
|
105
|
+
const t = TYPE_DIRS.get(d.toLowerCase());
|
|
106
|
+
if (t)
|
|
107
|
+
return t;
|
|
108
|
+
}
|
|
109
|
+
return "guide";
|
|
110
|
+
}
|
|
111
|
+
/** A heading's text and code, its line breaks as spaces (a setext heading can run over lines). */
|
|
112
|
+
function headingText(node) {
|
|
113
|
+
let out = "";
|
|
114
|
+
const walker = node.walker();
|
|
115
|
+
for (let e = walker.next(); e; e = walker.next()) {
|
|
116
|
+
if (!e.entering)
|
|
117
|
+
continue;
|
|
118
|
+
if (e.node.type === "text" || e.node.type === "code")
|
|
119
|
+
out += e.node.literal ?? "";
|
|
120
|
+
else if (e.node.type === "softbreak" || e.node.type === "linebreak")
|
|
121
|
+
out += " ";
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/** The text of the first level-one heading (ATX or setext) outside code, as CommonMark reads it. */
|
|
126
|
+
function firstHeading(body) {
|
|
127
|
+
const walker = new Parser().parse(body).walker();
|
|
128
|
+
for (let e = walker.next(); e; e = walker.next()) {
|
|
129
|
+
if (e.entering && e.node.type === "heading" && e.node.level === 1) {
|
|
130
|
+
const text = headingText(e.node).replace(/\s+/g, " ").trim();
|
|
131
|
+
if (text)
|
|
132
|
+
return text;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
/** Each file's last commit date (author, YYYY-MM-DD), by repo-relative path; empty outside git. */
|
|
138
|
+
function lastCommitDates(repo, knowledgeRel) {
|
|
139
|
+
const out = new Map();
|
|
140
|
+
// --literal-pathspecs: the root is a path, whatever it's called (`:(attr:x)kb` is pathspec magic).
|
|
141
|
+
const r = spawnSync("git", ["-C", repo, "--literal-pathspecs", "log", "--format=%x00dwdate:%as", "--name-only", "-z", "--relative", "--", knowledgeRel], {
|
|
142
|
+
encoding: "utf8",
|
|
143
|
+
maxBuffer: 1 << 28,
|
|
144
|
+
});
|
|
145
|
+
if (r.status !== 0 || typeof r.stdout !== "string")
|
|
146
|
+
return out;
|
|
147
|
+
let date = "";
|
|
148
|
+
for (const token of r.stdout.split("\0")) {
|
|
149
|
+
if (token.startsWith("dwdate:"))
|
|
150
|
+
date = token.slice("dwdate:".length);
|
|
151
|
+
else if (token) {
|
|
152
|
+
const path = token.replace(/^\n/, "");
|
|
153
|
+
if (!out.has(path))
|
|
154
|
+
out.set(path, date);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
/** A YAML scalar for one field. Title and id are quoted where Docusaurus's YAML 1.1 would read a date or a boolean. */
|
|
160
|
+
function scalar(value, docusaurusString) {
|
|
161
|
+
const plain = stringify(value, { lineWidth: 0 }).trimEnd();
|
|
162
|
+
if (docusaurusString && parseYaml(plain, { schema: "yaml-1.1" }) !== value)
|
|
163
|
+
return JSON.stringify(value);
|
|
164
|
+
return plain;
|
|
165
|
+
}
|
|
166
|
+
const KNOWN_FIELDS = new Set([...REQUIRED_FIELDS, ...OPTIONAL_FIELDS]);
|
|
167
|
+
/**
|
|
168
|
+
* The config with `keys` added to `frontmatter.extra_fields`, its comments and everything else
|
|
169
|
+
* byte for byte; or why not: YAML wouldn't write it back as it was, or the result wouldn't be
|
|
170
|
+
* a config that loads, the same but for those keys.
|
|
171
|
+
*/
|
|
172
|
+
function withExtraFields(text, keys) {
|
|
173
|
+
const doc = parseDocument(text);
|
|
174
|
+
const cannot = { why: "adopt couldn't add them and keep a config that loads, the same but for them" };
|
|
175
|
+
let next;
|
|
176
|
+
if (!doc.errors.length && doc.toString({ lineWidth: 0 }) === text) {
|
|
177
|
+
try {
|
|
178
|
+
const list = doc.getIn(["frontmatter", "extra_fields"]);
|
|
179
|
+
if (isSeq(list))
|
|
180
|
+
for (const k of keys)
|
|
181
|
+
list.add(doc.createNode(k));
|
|
182
|
+
else
|
|
183
|
+
doc.setIn(["frontmatter", "extra_fields"], doc.createNode(keys));
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return cannot; // `frontmatter:` holding something other than a mapping
|
|
187
|
+
}
|
|
188
|
+
next = doc.toString({ lineWidth: 0 });
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
// YAML would write it differently (a flow mapping inside, say): where the top level is a
|
|
192
|
+
// block mapping without the key, the list goes after the last line, as text.
|
|
193
|
+
if (doc.errors.length || !isMap(doc.contents) || doc.contents.flow || doc.has("frontmatter"))
|
|
194
|
+
return { why: "YAML wouldn't write this config back byte for byte, and adopt can't append to it" };
|
|
195
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
196
|
+
const lines = ["frontmatter:", " extra_fields:", ...keys.map((k) => ` - ${stringify(k, { lineWidth: 0 }).trimEnd()}`)];
|
|
197
|
+
next = `${text}${text === "" || text.endsWith("\n") ? "" : eol}${lines.join(eol)}${eol}`;
|
|
198
|
+
}
|
|
199
|
+
const was = parseYaml(text, { schema: "core" });
|
|
200
|
+
const want = { ...was, frontmatter: { ...was.frontmatter, extra_fields: [...(was.frontmatter?.extra_fields ?? []), ...keys] } };
|
|
201
|
+
let now;
|
|
202
|
+
try {
|
|
203
|
+
now = parseYaml(next, { schema: "core" });
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return cannot; // text appended after a document end (`...`), say
|
|
207
|
+
}
|
|
208
|
+
return isDeepStrictEqual(now, want) && ConfigSchema.safeParse(now).success ? { text: next } : cannot;
|
|
209
|
+
}
|
|
210
|
+
/** A page's line ending: its frontmatter's opening line's, else its first line's. */
|
|
211
|
+
function eol(text) {
|
|
212
|
+
return frontmatterSpan(text)?.eol ?? (/^[^\n]*\r\n/.test(text) ? "\r\n" : "\n");
|
|
213
|
+
}
|
|
214
|
+
function read(abs, repo, knowledge) {
|
|
215
|
+
const path = relative(repo, abs).split(sep).join("/");
|
|
216
|
+
const parts = relative(knowledge, abs).split(sep);
|
|
217
|
+
const stem = parts.pop().replace(/\.md$/, "");
|
|
218
|
+
const text = readFileSync(abs, "utf8");
|
|
219
|
+
const src = { abs, path, dirs: parts, stem, index: isCategoryIndex(stem, parts.at(-1)), text, data: null, body: text };
|
|
220
|
+
const shared = hardLinked(abs, "adopt");
|
|
221
|
+
if (shared) {
|
|
222
|
+
// Never written, but its own id is still the page's: read it, for the count.
|
|
223
|
+
const doc = splitFrontmatter(text);
|
|
224
|
+
return { ...src, data: doc.data, body: doc.body, problem: shared };
|
|
225
|
+
}
|
|
226
|
+
if (text.startsWith(""))
|
|
227
|
+
return { ...src, problem: "it starts with a byte-order mark, which Docsworth doesn't read frontmatter past; remove it, then rerun adopt" };
|
|
228
|
+
const doc = splitFrontmatter(text);
|
|
229
|
+
if (!doc.data) {
|
|
230
|
+
if (doc.error === "missing frontmatter block")
|
|
231
|
+
return { ...src, data: {} };
|
|
232
|
+
return { ...src, problem: `its frontmatter doesn't parse: ${doc.error}` };
|
|
233
|
+
}
|
|
234
|
+
const span = frontmatterSpan(text);
|
|
235
|
+
const contents = parseDocument(text.slice(span.start, span.end)).contents;
|
|
236
|
+
// The loader reads a flow mapping: its data stays, so its own id is counted, though it's left.
|
|
237
|
+
if (!isMap(contents) || contents.flow)
|
|
238
|
+
return { ...src, data: doc.data, body: doc.body, problem: "its frontmatter is a flow mapping (`{…}`); adopt adds fields to a block mapping only" };
|
|
239
|
+
return { ...src, data: doc.data, body: doc.body };
|
|
240
|
+
}
|
|
241
|
+
/** Each page's id, or why it gets none: its own, else its file name, unique across the root. */
|
|
242
|
+
function assignIds(sources) {
|
|
243
|
+
const out = new Map();
|
|
244
|
+
const holders = new Map(); // id → pages with it as their own
|
|
245
|
+
const wanting = new Map(); // id → pages whose file name makes it
|
|
246
|
+
const add = (m, id, s) => m.set(id, [...(m.get(id) ?? []), s]);
|
|
247
|
+
// Docusaurus routes a page by its directories and its id, else its file name: a new id
|
|
248
|
+
// moves it, unless a slug pins it where it is.
|
|
249
|
+
const keep = (s, baseId) => ` and, on a Docusaurus site, \`slug: /${[...s.dirs.map(unprefixed), baseId].join("/")}\` to keep its URL`;
|
|
250
|
+
for (const s of sources) {
|
|
251
|
+
if (!s.data)
|
|
252
|
+
continue;
|
|
253
|
+
if (s.problem) {
|
|
254
|
+
// A page adopt leaves keeps its own id, if the loader can read one: no other page gets it.
|
|
255
|
+
if (typeof s.data.id === "string" && ID_RE.test(s.data.id))
|
|
256
|
+
add(holders, s.data.id, s);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (s.data.id !== undefined) {
|
|
260
|
+
if (typeof s.data.id === "string" && ID_RE.test(s.data.id))
|
|
261
|
+
add(holders, s.data.id, s);
|
|
262
|
+
else
|
|
263
|
+
out.set(s, { problem: `its id \`${String(s.data.id)}\` isn't a Docsworth id (lowercase letters, digits and dashes): set one by hand${keep(s, String(s.data.id))}` });
|
|
264
|
+
}
|
|
265
|
+
else if (s.index) {
|
|
266
|
+
const route = s.dirs.map(unprefixed);
|
|
267
|
+
const say = route.length ? route.join("-").toLowerCase().replace(/[^a-z0-9-]+/g, "-") : "index";
|
|
268
|
+
out.set(s, {
|
|
269
|
+
problem: `a category index: Docusaurus routes it by its directory, so it takes no id from its file name. Set an \`id\` by hand (say \`${say}\`) and, on a Docusaurus site, \`slug: /${route.join("/")}\` to keep its URL`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
const id = unprefixed(s.stem);
|
|
274
|
+
if (ID_RE.test(id))
|
|
275
|
+
add(wanting, id, s);
|
|
276
|
+
else
|
|
277
|
+
out.set(s, { problem: `its file name doesn't make an id (\`${id}\`: lowercase letters, digits and dashes only): set an \`id\` by hand${keep(s, id)}` });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const others = (all, s) => all.filter((o) => o !== s).map((o) => o.path).join(", ");
|
|
281
|
+
for (const [id, pages] of holders) {
|
|
282
|
+
for (const s of pages)
|
|
283
|
+
out.set(s, pages.length === 1 ? { id } : { problem: `its id \`${id}\` is also on ${others(pages, s)}: set one by hand${keep(s, id)}` });
|
|
284
|
+
}
|
|
285
|
+
for (const [id, pages] of wanting) {
|
|
286
|
+
const all = [...(holders.get(id) ?? []), ...pages];
|
|
287
|
+
for (const s of pages)
|
|
288
|
+
out.set(s, all.length === 1 ? { id } : { problem: `its file name makes the id \`${id}\`, which ${others(all, s)} has or would get too: set an \`id\` by hand${keep(s, id)}` });
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
export function adoptRepo(root, opts) {
|
|
293
|
+
if (opts.team !== undefined && !SLUG_RE.test(opts.team))
|
|
294
|
+
throw new AdoptError(`--team \`${opts.team}\` isn't a team slug (like \`platform\`: no @, no org)`);
|
|
295
|
+
const reviewEvery = opts.reviewEvery ?? "90d";
|
|
296
|
+
if (!DURATION_RE.test(reviewEvery))
|
|
297
|
+
throw new AdoptError(`--review-every \`${reviewEvery}\` isn't a period like 30d, 12w, 6m or 1y`);
|
|
298
|
+
const { repo, config, configPath, knowledge } = repoPaths(root);
|
|
299
|
+
const codeowners = loadCodeowners(repo);
|
|
300
|
+
const found = { files: [], symlinks: [] };
|
|
301
|
+
walk(knowledge, found);
|
|
302
|
+
const result = { dryRun: opts.dryRun === true, adopted: [], unchanged: [], left: [], hints: [], linksRewritten: 0, linksLeft: [], extraFields: [] };
|
|
303
|
+
const rel = (abs) => relative(repo, abs).split(sep).join("/");
|
|
304
|
+
for (const abs of found.symlinks)
|
|
305
|
+
result.left.push({ path: rel(abs), reason: symlinkReason("adopt") });
|
|
306
|
+
const sources = found.files.sort().map((abs) => read(abs, repo, knowledge));
|
|
307
|
+
const ids = assignIds(sources);
|
|
308
|
+
const knowledgeRel = rel(knowledge);
|
|
309
|
+
const dates = lastCommitDates(repo, knowledgeRel);
|
|
310
|
+
const today = formatDate(utcToday(opts.now));
|
|
311
|
+
const unowned = new Map(); // CODEOWNERS problem → pages --team owns despite it
|
|
312
|
+
const writes = [];
|
|
313
|
+
const unknownKeys = new Set();
|
|
314
|
+
const leftDests = []; // the links left, with CommonMark's destination for each
|
|
315
|
+
/** The fields a page lacks, as lines to insert, and its frontmatter with them added; or why it gets none. */
|
|
316
|
+
const addedFields = (s, missing) => {
|
|
317
|
+
// A date adopt gave a page that says it's canonical would make it citable unverified (R5).
|
|
318
|
+
if (s.data.status === "canonical" && missing.includes("last_verified"))
|
|
319
|
+
return { problem: "it says `status: canonical` but carries no `last_verified`, and adopt dates no verification it didn't see: set `status: draft`, or verify it by hand" };
|
|
320
|
+
const id = ids.get(s);
|
|
321
|
+
if ("problem" in id)
|
|
322
|
+
return id;
|
|
323
|
+
const owner = missing.includes("owner_team") ? ownerFor(s.path, codeowners, config, opts.team) : { team: String(s.data.owner_team) };
|
|
324
|
+
if (!("team" in owner))
|
|
325
|
+
return owner;
|
|
326
|
+
const values = {
|
|
327
|
+
title: firstHeading(s.body) ?? s.stem,
|
|
328
|
+
id: id.id,
|
|
329
|
+
type: typeFor(s.dirs),
|
|
330
|
+
status: "draft",
|
|
331
|
+
owner_team: owner.team,
|
|
332
|
+
last_verified: dates.get(s.path) || today,
|
|
333
|
+
review_every: reviewEvery,
|
|
334
|
+
};
|
|
335
|
+
const merged = { ...s.data, ...Object.fromEntries(missing.map((f) => [f, values[f]])) };
|
|
336
|
+
const valid = validateFrontmatter(merged);
|
|
337
|
+
if (!valid.ok)
|
|
338
|
+
return { problem: `its own frontmatter isn't valid (${valid.issues.map((i) => i.message).join("; ")}); fix it, then rerun adopt` };
|
|
339
|
+
const lines = missing.map((f) => `${f}: ${scalar(values[f], f === "title" || f === "id")}${eol(s.text)}`).join("");
|
|
340
|
+
return { lines, merged, ownerProblem: "problem" in owner ? owner.problem : undefined };
|
|
341
|
+
};
|
|
342
|
+
/** A page with every field: adopt adds none, but leaves it alone if its id or values aren't valid. */
|
|
343
|
+
const asItIs = (s) => {
|
|
344
|
+
const id = ids.get(s);
|
|
345
|
+
if (id && "problem" in id)
|
|
346
|
+
return id;
|
|
347
|
+
const valid = validateFrontmatter(s.data);
|
|
348
|
+
if (!valid.ok)
|
|
349
|
+
return { problem: `its own frontmatter isn't valid (${valid.issues.map((i) => i.message).join("; ")}); fix it, then rerun adopt` };
|
|
350
|
+
return { lines: "", merged: s.data };
|
|
351
|
+
};
|
|
352
|
+
const links = { repo, knowledgeRel, pages: new Set(sources.map((s) => s.path)), byDir: new Map(), moved: new Set(), aliases: new Map(), staticDirs: config.static_dirs };
|
|
353
|
+
for (const s of sources) {
|
|
354
|
+
const dir = posix.dirname(s.path);
|
|
355
|
+
links.byDir.set(dir, [...(links.byDir.get(dir) ?? []), s.path]);
|
|
356
|
+
const route = siteRoute([...s.dirs, `${s.stem}.md`].join("/"), s.data?.slug, s.data?.id);
|
|
357
|
+
if (route === null)
|
|
358
|
+
continue;
|
|
359
|
+
links.moved.add(s.path);
|
|
360
|
+
links.aliases.set(route, [...(links.aliases.get(route) ?? []), s.path]);
|
|
361
|
+
}
|
|
362
|
+
for (const s of sources) {
|
|
363
|
+
const leave = (reason) => result.left.push({ path: s.path, reason });
|
|
364
|
+
if (s.problem || !s.data) {
|
|
365
|
+
leave(s.problem);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const missing = REQUIRED_FIELDS.filter((f) => s.data[f] === undefined);
|
|
369
|
+
const fields = missing.length ? addedFields(s, missing) : asItIs(s);
|
|
370
|
+
if ("problem" in fields) {
|
|
371
|
+
leave(fields.problem);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const converted = convertLinks(s, links);
|
|
375
|
+
result.linksLeft.push(...converted.left.map((l) => ({ path: s.path, link: l.link, why: l.why })));
|
|
376
|
+
leftDests.push(...converted.left);
|
|
377
|
+
if (!fields.lines && !converted.edits.length) {
|
|
378
|
+
result.unchanged.push(s.path);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const span = frontmatterSpan(s.text);
|
|
382
|
+
const bodyStart = span?.bodyStart ?? 0;
|
|
383
|
+
const edits = converted.edits.map((e) => ({ ...e, start: e.start + bodyStart, end: e.end + bodyStart }));
|
|
384
|
+
if (fields.lines)
|
|
385
|
+
edits.push(span ? { start: span.start, end: span.start, insert: fields.lines } : { start: 0, end: 0, insert: `---${eol(s.text)}${fields.lines}---${eol(s.text)}` });
|
|
386
|
+
const next = applyEdits(s.text, edits);
|
|
387
|
+
// The page's own keys must say what they said, and its body be what the link edits made it.
|
|
388
|
+
const after = splitFrontmatter(next);
|
|
389
|
+
if (!after.data || after.body !== applyEdits(s.body, converted.edits) || !isDeepStrictEqual(after.data, fields.merged)) {
|
|
390
|
+
leave("adding fields to its frontmatter would change what the page's own keys say; add them by hand");
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
writes.push({ abs: s.abs, text: next, inside: knowledge });
|
|
394
|
+
result.adopted.push(s.path);
|
|
395
|
+
for (const key of Object.keys(fields.merged))
|
|
396
|
+
if (!KNOWN_FIELDS.has(key))
|
|
397
|
+
unknownKeys.add(key);
|
|
398
|
+
result.linksRewritten += converted.edits.length;
|
|
399
|
+
if (fields.ownerProblem)
|
|
400
|
+
unowned.set(fields.ownerProblem, [...(unowned.get(fields.ownerProblem) ?? []), s.path]);
|
|
401
|
+
}
|
|
402
|
+
// Root-relative files a docs site serves from a `static/` directory the config doesn't list.
|
|
403
|
+
const fromStatic = new Map();
|
|
404
|
+
// Read as lint reads them, by CommonMark's destination (`\\/img/x.png` and `/img/x%2Epng` too).
|
|
405
|
+
const candidates = leftDests.filter((l) => l.why === NAMES_NO_PAGE && l.dest.startsWith("/") && isAttachment(shownPath(linkPath(l.dest))));
|
|
406
|
+
const dirs = candidates.length ? staticDirsIn(repo) : [];
|
|
407
|
+
for (const l of candidates) {
|
|
408
|
+
const target = shownPath(linkPath(l.dest)).replace(/^\/+/, "");
|
|
409
|
+
const dir = dirs.find((d) => servedFrom(repo, d, target));
|
|
410
|
+
if (dir)
|
|
411
|
+
fromStatic.set(dir, (fromStatic.get(dir) ?? 0) + 1);
|
|
412
|
+
}
|
|
413
|
+
if (fromStatic.size) {
|
|
414
|
+
// The whole list, the config's and these: a second `static_dirs:` key would be invalid YAML.
|
|
415
|
+
// Written as one flow list, so YAML quotes what a flow list would split (`website/a,b/static`).
|
|
416
|
+
const seq = new Document([...config.static_dirs, ...fromStatic.keys()]);
|
|
417
|
+
if (isSeq(seq.contents))
|
|
418
|
+
seq.contents.flow = true;
|
|
419
|
+
const list = `static_dirs: ${seq.toString({ flowCollectionPadding: false, lineWidth: 0 }).trimEnd()}`;
|
|
420
|
+
const n = [...fromStatic.values()].reduce((a, b) => a + b, 0);
|
|
421
|
+
const where = config.static_dirs.length ? `set \`${list}\` in` : `add \`${list}\` to`;
|
|
422
|
+
const found = [...fromStatic.keys()];
|
|
423
|
+
// One inside another directory named static could be a monorepo's `static` package holding a
|
|
424
|
+
// site, or a site root's own `static/` asset folder: nothing here tells them apart, so ask.
|
|
425
|
+
const outer = found.flatMap((d) => dirs.filter((a) => d.startsWith(`${a}/`)));
|
|
426
|
+
if (outer.length)
|
|
427
|
+
result.hints.push(`if your docs site serves ${found.join(" and ")} at its root (it's inside ${[...new Set(outer)].join(" and ")}, which may be the root instead), ${where} ${CONFIG_PATH}: ${n === 1 ? "1 link names a file" : `${n} links name files`} there`);
|
|
428
|
+
else
|
|
429
|
+
result.hints.push(`${where} ${CONFIG_PATH}: ${n === 1 ? "1 link names a file" : `${n} links name files`} a docs site serves from ${found.join(" and ")}, and lint reads them there once it's listed`);
|
|
430
|
+
}
|
|
431
|
+
for (const [problem, paths] of unowned) {
|
|
432
|
+
const n = paths.length === 1 ? "1 adopted page" : `${paths.length} adopted pages`;
|
|
433
|
+
const line = problem === "no_rule" ? codeownersLine(knowledgeRel, config.github.org, opts.team, paths) : null;
|
|
434
|
+
result.hints.push(problem !== "no_rule"
|
|
435
|
+
? `${problem}: lint fails on ${n} until CODEOWNERS names one team of \`${config.github.org}\` for them`
|
|
436
|
+
: line
|
|
437
|
+
? `add \`${line}\` to ${codeowners.path ?? "CODEOWNERS"}: no rule there covers ${n}, and lint needs one team for each`
|
|
438
|
+
: `give \`${knowledgeRel}/\` one team in ${codeowners.path ?? "CODEOWNERS"}: no line adopt can write matches it, and lint needs one team for each of ${n}`);
|
|
439
|
+
}
|
|
440
|
+
const listed = new Set(config.frontmatter.extra_fields);
|
|
441
|
+
const added = [...unknownKeys].filter((k) => !listed.has(k)).sort();
|
|
442
|
+
if (added.length) {
|
|
443
|
+
const shared = existsSync(configPath) && lstatSync(configPath).nlink > 1 ? lstatSync(configPath).nlink : 0;
|
|
444
|
+
const next = shared ? { why: `it has ${shared} hard links, and adopt writes no file another path shares` } : withExtraFields(readFileSync(configPath, "utf8"), added);
|
|
445
|
+
if ("why" in next)
|
|
446
|
+
result.hints.push(`add ${added.map((k) => `\`${k}\``).join(", ")} to frontmatter.extra_fields in ${CONFIG_PATH} by hand: ${next.why}`);
|
|
447
|
+
else {
|
|
448
|
+
result.extraFields = added;
|
|
449
|
+
writes.push({ abs: configPath, text: next.text, inside: repo });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (!opts.dryRun) {
|
|
453
|
+
// The walk followed no link; this checks again, before the first write, that each file is
|
|
454
|
+
// still a regular file where it was. (Defense in depth: no test races the walk.)
|
|
455
|
+
for (const w of writes) {
|
|
456
|
+
if (!lstatSync(w.abs).isFile() || lstatSync(w.abs).nlink > 1 || !within(w.inside, realpathSync(w.abs)))
|
|
457
|
+
throw new AdoptError(`${rel(w.abs)} changed while adopt ran; nothing written`);
|
|
458
|
+
}
|
|
459
|
+
for (const w of writes)
|
|
460
|
+
writeFileSync(w.abs, w.text);
|
|
461
|
+
}
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Directories named `static` in the repo, however deep (hidden directories, links and
|
|
466
|
+
* `node_modules` aside): where a docs site keeps the files it serves at its root.
|
|
467
|
+
*/
|
|
468
|
+
function staticDirsIn(repo) {
|
|
469
|
+
const out = [];
|
|
470
|
+
const walk = (dir) => {
|
|
471
|
+
let entries;
|
|
472
|
+
try {
|
|
473
|
+
entries = readdirSync(join(repo, dir), { withFileTypes: true });
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
for (const e of entries) {
|
|
479
|
+
if (!e.isDirectory() || e.name.startsWith(".") || e.name === "node_modules")
|
|
480
|
+
continue;
|
|
481
|
+
const p = dir ? `${dir}/${e.name}` : e.name;
|
|
482
|
+
if (e.name === "static")
|
|
483
|
+
out.push(p);
|
|
484
|
+
walk(p); // a static directory can hold another (a monorepo's `static` package)
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
walk("");
|
|
488
|
+
return out.sort();
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* The CODEOWNERS line giving the knowledge root to `team`, its whitespace and `#` escaped; null
|
|
492
|
+
* unless CODEOWNERS's own parser reads it as giving each of `paths` to that team.
|
|
493
|
+
*/
|
|
494
|
+
function codeownersLine(knowledgeRel, org, team, paths) {
|
|
495
|
+
const pattern = `/${knowledgeRel.replace(/[\s#\\]/g, (c) => `\\${c}`)}/`;
|
|
496
|
+
const line = `${pattern} @${org}/${team}`;
|
|
497
|
+
const co = parseCodeowners(line);
|
|
498
|
+
const ok = paths.every((p) => {
|
|
499
|
+
const owner = resolveTeamOwner(co, p, org);
|
|
500
|
+
return owner.ok && owner.team === team;
|
|
501
|
+
});
|
|
502
|
+
return !co.errors.length && ok ? line : null;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* The owning team: CODEOWNERS's, else --team (with what's wrong in CODEOWNERS: `no_rule`, or
|
|
506
|
+
* the problem's description), else why there's none.
|
|
507
|
+
*/
|
|
508
|
+
function ownerFor(path, co, config, team) {
|
|
509
|
+
const owner = resolveTeamOwner(co, path, config.github.org);
|
|
510
|
+
if (owner.ok)
|
|
511
|
+
return { team: owner.team };
|
|
512
|
+
if (team)
|
|
513
|
+
return { team, problem: owner.problem.kind === "no_rule" ? "no_rule" : describeOwnershipProblem(owner.problem, config.github.org) };
|
|
514
|
+
return { problem: `no owning team: ${describeOwnershipProblem(owner.problem, config.github.org)}; pass --team <slug>, or give the path one team in CODEOWNERS` };
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* The page files a link's path could mean, read each way a Docusaurus site or GitHub reads
|
|
518
|
+
* it (R2). A root-relative path is read from the knowledge root, from the repo root, and
|
|
519
|
+
* without a leading `/docs`; a relative one from the page's directory, and from a category
|
|
520
|
+
* index's URL. Each reading names `p` itself, `p.md`, or the category index in directory `p`.
|
|
521
|
+
*/
|
|
522
|
+
function pagesNamed(path, s, ctx) {
|
|
523
|
+
const bare = path.replace(/\/+$/, "");
|
|
524
|
+
const root = ctx.knowledgeRel;
|
|
525
|
+
const bases = [];
|
|
526
|
+
const named = new Set();
|
|
527
|
+
if (path.startsWith("/")) {
|
|
528
|
+
const rest = bare.replace(/^\/+/, "");
|
|
529
|
+
bases.push(posix.join(root, rest), rest);
|
|
530
|
+
if (rest === "docs" || rest.startsWith("docs/"))
|
|
531
|
+
bases.push(posix.join(root, rest.slice("docs".length)));
|
|
532
|
+
// A page a slug or an id moved answers to its route too: a reading that names it.
|
|
533
|
+
for (const r of [rest, ...(rest === "docs" || rest.startsWith("docs/") ? [rest.slice("docs".length).replace(/^\//, "")] : [])])
|
|
534
|
+
for (const p of ctx.aliases.get(r) ?? [])
|
|
535
|
+
named.add(p);
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
const dir = posix.dirname(s.path);
|
|
539
|
+
bases.push(posix.join(dir, bare));
|
|
540
|
+
// A category index's URL is its directory, with no trailing slash, so a browser reads its
|
|
541
|
+
// relative links from the directory above.
|
|
542
|
+
if (s.index && dir !== root)
|
|
543
|
+
bases.push(posix.join(posix.dirname(dir), bare));
|
|
544
|
+
// And the routes the site resolves them to, from the page's URL (its directories without
|
|
545
|
+
// their number prefixes): a page a slug or an id puts there is named too.
|
|
546
|
+
const urlDir = posix.join(".", ...s.dirs.map(unprefixed));
|
|
547
|
+
const routes = [posix.join(urlDir, bare), ...(s.index && s.dirs.length ? [posix.join(posix.dirname(urlDir), bare)] : [])];
|
|
548
|
+
for (const r of routes)
|
|
549
|
+
for (const p of ctx.aliases.get(posix.normalize(r).replace(/^\.(\/|$)/, "").replace(/\/+$/, "")) ?? [])
|
|
550
|
+
named.add(p);
|
|
551
|
+
}
|
|
552
|
+
for (const base of bases) {
|
|
553
|
+
const b = posix.normalize(base || ".");
|
|
554
|
+
if (ctx.pages.has(b))
|
|
555
|
+
named.add(b);
|
|
556
|
+
if (ctx.pages.has(`${b}.md`))
|
|
557
|
+
named.add(`${b}.md`);
|
|
558
|
+
for (const p of ctx.byDir.get(b) ?? [])
|
|
559
|
+
if (isCategoryIndex(posix.basename(p, ".md"), b === root ? undefined : posix.basename(b)))
|
|
560
|
+
named.add(p);
|
|
561
|
+
}
|
|
562
|
+
return [...named].sort();
|
|
563
|
+
}
|
|
564
|
+
// A name `resolveLink`'s decodeURI can't give back: it leaves %23 and %3F encoded, and a bare
|
|
565
|
+
// `%` makes it throw.
|
|
566
|
+
const NO_LINK_NAMES = /[%#?]/;
|
|
567
|
+
/**
|
|
568
|
+
* A path segment written so CommonMark reads back the same name, inside a link and around
|
|
569
|
+
* it: whitespace, controls, `<` `>` `(` `)` `\\`, and the backticks and brackets that could
|
|
570
|
+
* open a code span or a link across it. `decodeURI` gives each back. Unicode stays as it is.
|
|
571
|
+
*/
|
|
572
|
+
function escapeSegment(segment) {
|
|
573
|
+
return segment.replace(/[\x00-\x20\x7f<>()\\`[\]]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
|
|
574
|
+
}
|
|
575
|
+
/** A destination's query and fragment, from its first `?` or `#` (as lint splits it). */
|
|
576
|
+
const suffixOf = (dest) => {
|
|
577
|
+
const i = dest.search(/[?#]/);
|
|
578
|
+
return i < 0 ? "" : dest.slice(i);
|
|
579
|
+
};
|
|
580
|
+
/** The same, as written: a backslash escaping that `?` or `#` kept with it. */
|
|
581
|
+
const rawSuffixOf = (raw) => {
|
|
582
|
+
const i = raw.search(/\\?[?#]/);
|
|
583
|
+
return i < 0 ? "" : raw.slice(i);
|
|
584
|
+
};
|
|
585
|
+
const NAMES_NO_PAGE = "it names no page";
|
|
586
|
+
/** One link's destination rewritten as a relative `.md` link to the page it names, or why not. */
|
|
587
|
+
function rewriteLink(span, s, ctx) {
|
|
588
|
+
const route = shownPath(linkPath(span.dest));
|
|
589
|
+
// A relative route on a page a slug moved follows the slug's URL, not the file's.
|
|
590
|
+
if (!route.startsWith("/") && ctx.moved.has(s.path) && typeof s.data?.slug === "string")
|
|
591
|
+
return { why: "its page's slug gives it another URL, which a relative route follows: link it by hand" };
|
|
592
|
+
const named = pagesNamed(route, s, ctx);
|
|
593
|
+
if (!named.length) {
|
|
594
|
+
// A page the site serves at the route through a number prefix: not followed, but listed.
|
|
595
|
+
const viaPrefix = route.startsWith("/") ? routePages(ctx.repo, ctx.knowledgeRel, route.replace(/^\/+|\/+$/g, "")) : [];
|
|
596
|
+
return { why: viaPrefix.length ? `on a docs site it's the route of ${viaPrefix.join(" or ")}, through a number prefix adopt doesn't follow: link it by hand` : NAMES_NO_PAGE };
|
|
597
|
+
}
|
|
598
|
+
if (named.length > 1)
|
|
599
|
+
return { why: `it could name ${named.join(" or ")}: link the one you mean` };
|
|
600
|
+
const target = named[0];
|
|
601
|
+
// Its URL isn't its file's: which page the route meant is routing adopt doesn't model.
|
|
602
|
+
if (ctx.moved.has(target))
|
|
603
|
+
return { why: `${target} has a slug or an id that gives it another URL: link it by hand` };
|
|
604
|
+
const rel = posix.relative(posix.dirname(s.path), target);
|
|
605
|
+
if (NO_LINK_NAMES.test(rel))
|
|
606
|
+
return { why: "its page's path holds `%`, `#` or `?`, which no link here can name: rename the file, or link it by hand" };
|
|
607
|
+
let path = rel.split("/").map(escapeSegment).join("/");
|
|
608
|
+
if (EXTERNAL.test(path))
|
|
609
|
+
path = `./${path}`; // a first segment that would read as a scheme
|
|
610
|
+
const raw = path + rawSuffixOf(span.raw);
|
|
611
|
+
// CommonMark must read it back as naming the page, with the query and fragment it had.
|
|
612
|
+
const dest = readDestination(raw, span.angle);
|
|
613
|
+
if (EXTERNAL.test(dest) || linkFault(ctx.repo, ctx.knowledgeRel, s.path, linkPath(dest), { aliases: (r) => ctx.aliases.get(r) ?? [], staticDirs: ctx.staticDirs }).target !== target || suffixOf(dest) !== suffixOf(span.dest))
|
|
614
|
+
return { why: "adopt couldn't write a link that names it: link it by hand" };
|
|
615
|
+
return { raw, dest };
|
|
616
|
+
}
|
|
617
|
+
function convertLinks(s, ctx) {
|
|
618
|
+
// `fails`: lint fails it. `route`: lint takes it for a directory or an extensionless file
|
|
619
|
+
// outside the knowledge root (`/guides` naming the repo's `guides/`), but on the site it's a
|
|
620
|
+
// page's route; rewritten if it names one page, and otherwise left as lint accepts it.
|
|
621
|
+
const judge = (dest) => {
|
|
622
|
+
const path = dest && !EXTERNAL.test(dest) ? linkPath(dest) : "";
|
|
623
|
+
if (!path)
|
|
624
|
+
return null;
|
|
625
|
+
const { target, fault, underRoot } = linkFault(ctx.repo, ctx.knowledgeRel, s.path, path, { aliases: (r) => ctx.aliases.get(r) ?? [], staticDirs: ctx.staticDirs });
|
|
626
|
+
if (fault)
|
|
627
|
+
return "fails";
|
|
628
|
+
// (A file served from a static directory exists only there: it's no route.)
|
|
629
|
+
const abs = join(ctx.repo, target);
|
|
630
|
+
return !underRoot && existsSync(abs) && (posix.extname(target) === "" || statSync(abs).isDirectory()) ? "route" : null;
|
|
631
|
+
};
|
|
632
|
+
const edits = [];
|
|
633
|
+
const left = [];
|
|
634
|
+
const rewritten = [];
|
|
635
|
+
const renamed = new Map(); // destination → its rewrite (one per page: same path, same page)
|
|
636
|
+
const spans = linkSpans(s.body);
|
|
637
|
+
for (const span of spans) {
|
|
638
|
+
const verdict = judge(span.dest);
|
|
639
|
+
if (!verdict)
|
|
640
|
+
continue;
|
|
641
|
+
const r = rewriteLink(span, s, ctx);
|
|
642
|
+
if ("why" in r) {
|
|
643
|
+
if (verdict === "fails" || r.why !== NAMES_NO_PAGE)
|
|
644
|
+
left.push({ link: span.raw, dest: span.dest, why: r.why });
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
edits.push({ start: span.start, end: span.end, insert: r.raw });
|
|
648
|
+
rewritten.push(span);
|
|
649
|
+
renamed.set(span.dest, r.dest);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const before = pageLinks(s.body);
|
|
653
|
+
// A link CommonMark reads that adopt found nowhere in the source: listed, for lint to judge.
|
|
654
|
+
const located = new Set(spans.map((sp) => sp.dest));
|
|
655
|
+
for (const dest of new Set(before))
|
|
656
|
+
if (judge(dest) === "fails" && !located.has(dest))
|
|
657
|
+
left.push({ link: dest, dest, why: "adopt couldn't find where it's written: link it by hand" });
|
|
658
|
+
// A whole parse, not linkSpans's: its markers hide what a destination holds (a backtick that
|
|
659
|
+
// would open a code span across the rest of the line, say).
|
|
660
|
+
if (edits.length && !isDeepStrictEqual(pageLinks(applyEdits(s.body, edits)), before.map((d) => renamed.get(d) ?? d))) {
|
|
661
|
+
const why = "rewriting it would change how the page's links parse: link it by hand";
|
|
662
|
+
return { edits: [], left: [...left, ...rewritten.map((sp) => ({ link: sp.raw, dest: sp.dest, why }))] };
|
|
663
|
+
}
|
|
664
|
+
return { edits, left };
|
|
665
|
+
}
|