Repository navigation
Expand file tree
/
Copy pathcheckDocs.ts
More file actions
150 lines (140 loc) · 5.58 KB
/
Copy pathcheckDocs.ts
File metadata and controls
150 lines (140 loc) · 5.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors
//
// SPDX-License-Identifier: MIT
/**
* `npm run check:docs`: docs/docs.ttl, the docs' index, is as the docs'
* build reads it (readDocsIndex), lists every page in docs/, and every
* page it lists is there (docs/testing.md#documentation,
* docs/markdown.md#the-docs-index). For a page it does not list, it
* prints the block to add. And every `npm run <script>` in the
* repository's Markdown names a script of the root package.json, or of
* the workspace it names with `-w`.
*/
import { execFileSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import {
DOCS_INDEX,
type DocsProblem,
listingProblems,
pageBlock,
readDocsIndex,
} from "@solid-memo/site/docsIndex";
/** The scripts of the root package (`root`) and of each workspace, by its name. */
export type Scripts = { root: string[]; workspaces: Record<string, string[]> };
export type DocsIo = {
pages(): string[];
index(): string;
/** The repository's Markdown, by its path. */
markdown(): Map<string, string>;
scripts(): Scripts;
log(message: string): void;
};
type Manifest = { name: string; scripts: Record<string, string>; workspaces: string[] };
export function defaultIo(root: string): DocsIo {
const docs = join(root, "docs");
const manifest = (dir: string): Manifest =>
JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
return {
pages: () => readdirSync(docs).filter((file) => file.endsWith(".md")),
index: () => readFileSync(join(root, DOCS_INDEX), "utf8"),
// Tracked or new, and still there: a file deleted but not yet staged is gone.
markdown: () =>
new Map(
execFileSync(
"git",
[
"-C",
root,
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
"--",
"*.md",
],
{ encoding: "utf8" },
)
.split("\0")
.filter((path) => path !== "" && existsSync(join(root, path)))
.map((path) => [path, readFileSync(join(root, path), "utf8")]),
),
scripts: () => {
const top = manifest(root);
// The workspaces are `<group>/*` globs (package.json).
const workspaces = top.workspaces.flatMap((glob) => {
const group = join(root, glob.slice(0, -"/*".length));
return readdirSync(group)
.filter((folder) => existsSync(join(group, folder, "package.json")))
.map((folder) => manifest(join(group, folder)));
});
return {
root: Object.keys(top.scripts),
workspaces: Object.fromEntries(
workspaces.map((workspace) => [workspace.name, Object.keys(workspace.scripts)]),
),
};
},
log: (message) => console.log(message),
};
}
// `npm run <script>`, then `-w <workspace>` or `--workspace=<workspace>`
// if it names one; a script named by a placeholder (`<script>`) is none.
const NPM_RUN = /npm run\s+([a-z][\w:.-]*)(?:\s+(?:-w|--workspace)(?:\s+|=)([@\w/.-]+))?/g;
/**
* Each `npm run` in the Markdown that names a script the package it runs
* in does not have, as lines to print. CHANGELOG.md is left out: it
* records each release as it was, scripts since renamed included.
*/
export function scriptProblems(markdown: ReadonlyMap<string, string>, scripts: Scripts): string[] {
return [...markdown]
.filter(([file]) => file !== "CHANGELOG.md")
.flatMap(([file, text]) =>
[...text.matchAll(NPM_RUN)].flatMap(({ 1: script, 2: workspace, index }) => {
const where = `${file}:${text.slice(0, index).split("\n").length}`;
if (workspace === undefined)
return scripts.root.includes(script)
? []
: [`${where}: npm run ${script}: the root package.json has no such script`];
const own = scripts.workspaces[workspace];
if (own === undefined)
return [`${where}: npm run ${script} -w ${workspace}: no such workspace`];
return own.includes(script)
? []
: [`${where}: npm run ${script} -w ${workspace}: the workspace has no such script`];
}),
);
}
/** What is wrong with the index, as lines to print; none when the check passes. */
export function docsIndexProblems(pages: readonly string[], text: string): string[] {
const { index, problems } = readDocsIndex(text);
const where = ({ file, line, what }: DocsProblem) =>
`${file}${line === null ? "" : `:${line}`}: ${what}`;
if (index === null) return problems.map(where);
return [
...problems.map(where),
...listingProblems(index, pages).map((problem) =>
problem.file === DOCS_INDEX
? where(problem)
: `${where(problem)}; add it to a group there, for example:\n\n${pageBlock(index, problem.file.slice("docs/".length))}\n`,
),
];
}
/** Exit code: 0 when the index and the pages agree, and every script named is there. */
export function main(io: DocsIo): number {
const problems = [
...docsIndexProblems(io.pages(), io.index()),
...scriptProblems(io.markdown(), io.scripts()),
];
for (const problem of problems) io.log(problem);
if (problems.length > 0) return 1;
io.log(
`${DOCS_INDEX} lists every page in docs/, and every npm run in the Markdown names a script.`,
);
return 0;
}
/** The script entry: `node -e "import('./scripts/checkDocs.ts').then((m) => m.run(process))"`. */
export function run(process: { exitCode?: number | string | null | undefined }): void {
process.exitCode = main(defaultIo(resolve(import.meta.dirname, "..")));
}