@heroiclands/package-build 0.6.0 → 3.0.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/CHANGELOG-content-build.md +965 -0
- package/CHANGELOG.md +95 -0
- package/CONTENT.md +824 -0
- package/MIGRATING.md +127 -0
- package/README.md +29 -17
- package/bin/content-build.mjs +1082 -0
- package/bin/package-build.mjs +2 -2
- package/bin/report.mjs +2 -2
- package/config.mjs +21 -18
- package/content-config.mjs +1424 -0
- package/coverage.mjs +1 -1
- package/e2e.mjs +1 -1
- package/engine/abbreviations.mjs +0 -0
- package/engine/base-compiler.mjs +608 -0
- package/engine/code-fences.mjs +311 -0
- package/engine/compendiums.mjs +353 -0
- package/engine/content-address.mjs +141 -0
- package/engine/content-links.mjs +438 -0
- package/engine/content-lint.mjs +204 -0
- package/engine/content-package.mjs +67 -0
- package/engine/content-slug.mjs +169 -0
- package/engine/content-tables.mjs +1216 -0
- package/engine/content-tree.mjs +56 -0
- package/engine/diagnostics.mjs +270 -0
- package/engine/field-reference.mjs +242 -0
- package/engine/field-spec.mjs +218 -0
- package/engine/foreign-catalog.mjs +450 -0
- package/engine/foreign-manifests.mjs +129 -0
- package/engine/frontmatter-lint.mjs +343 -0
- package/engine/frontmatter.mjs +258 -0
- package/engine/generate.mjs +305 -0
- package/engine/helpers.mjs +809 -0
- package/engine/ids.mjs +192 -0
- package/engine/index.mjs +142 -0
- package/engine/item-docs.mjs +153 -0
- package/engine/item-registry.mjs +129 -0
- package/engine/journals.mjs +374 -0
- package/engine/kb-manifest.mjs +473 -0
- package/engine/macros.mjs +333 -0
- package/engine/manifest-emit.mjs +339 -0
- package/engine/map-notes.mjs +1302 -0
- package/engine/pack-config.mjs +479 -0
- package/engine/pack-router.mjs +254 -0
- package/engine/prose-config.mjs +238 -0
- package/engine/prose-lint.mjs +294 -0
- package/engine/region-events.mjs +79 -0
- package/engine/scene-levels.mjs +420 -0
- package/engine/scenes.mjs +528 -0
- package/engine/site-build.mjs +778 -0
- package/engine/site-index.mjs +332 -0
- package/engine/web-wikilinks.mjs +330 -0
- package/engine/wikilink-syntax.mjs +105 -0
- package/engine/wikilinks.mjs +645 -0
- package/index.mjs +42 -25
- package/lang.mjs +1 -1
- package/manifest.mjs +79 -5
- package/markdownlint-config.mjs +45 -0
- package/package.json +78 -34
- package/prettier-config.mjs +34 -0
- package/sohl/actors.mjs +581 -0
- package/sohl/affiliation-standings.mjs +44 -0
- package/sohl/being-info.mjs +173 -0
- package/sohl/default-item-art.mjs +82 -0
- package/sohl/index.mjs +55 -0
- package/sohl/item-builders.mjs +114 -0
- package/sohl/item-fields.mjs +867 -0
- package/sohl/items.mjs +197 -0
- package/sohl/kb-passes.mjs +207 -0
- package/sohl/note-schemas.mjs +322 -0
- package/templates.mjs +1 -1
- package/types/config.d.mts +1 -1
- package/types/content-config.d.mts +571 -0
- package/types/engine/abbreviations.d.mts +44 -0
- package/types/engine/base-compiler.d.mts +320 -0
- package/types/engine/code-fences.d.mts +102 -0
- package/types/engine/compendiums.d.mts +77 -0
- package/types/engine/content-address.d.mts +61 -0
- package/types/engine/content-links.d.mts +87 -0
- package/types/engine/content-lint.d.mts +47 -0
- package/types/engine/content-package.d.mts +36 -0
- package/types/engine/content-slug.d.mts +59 -0
- package/types/engine/content-tables.d.mts +131 -0
- package/types/engine/content-tree.d.mts +12 -0
- package/types/engine/diagnostics.d.mts +152 -0
- package/types/engine/field-reference.d.mts +20 -0
- package/types/engine/field-spec.d.mts +181 -0
- package/types/engine/foreign-catalog.d.mts +103 -0
- package/types/engine/foreign-manifests.d.mts +43 -0
- package/types/engine/frontmatter-lint.d.mts +74 -0
- package/types/engine/frontmatter.d.mts +99 -0
- package/types/engine/generate.d.mts +56 -0
- package/types/engine/helpers.d.mts +348 -0
- package/types/engine/ids.d.mts +126 -0
- package/types/engine/index.d.mts +33 -0
- package/types/engine/item-docs.d.mts +73 -0
- package/types/engine/item-registry.d.mts +57 -0
- package/types/engine/journals.d.mts +121 -0
- package/types/engine/kb-manifest.d.mts +214 -0
- package/types/engine/macros.d.mts +201 -0
- package/types/engine/manifest-emit.d.mts +137 -0
- package/types/engine/map-notes.d.mts +283 -0
- package/types/engine/pack-config.d.mts +77 -0
- package/types/engine/pack-router.d.mts +53 -0
- package/types/engine/prose-config.d.mts +121 -0
- package/types/engine/prose-lint.d.mts +59 -0
- package/types/engine/region-events.d.mts +43 -0
- package/types/engine/scene-levels.d.mts +36 -0
- package/types/engine/scenes.d.mts +48 -0
- package/types/engine/site-build.d.mts +214 -0
- package/types/engine/site-index.d.mts +141 -0
- package/types/engine/web-wikilinks.d.mts +67 -0
- package/types/engine/wikilink-syntax.d.mts +101 -0
- package/types/engine/wikilinks.d.mts +172 -0
- package/types/index.d.mts +3 -0
- package/types/manifest.d.mts +52 -4
- package/types/markdownlint-config.d.mts +8 -0
- package/types/prettier-config.d.mts +1 -0
- package/types/sohl/actors.d.mts +65 -0
- package/types/sohl/affiliation-standings.d.mts +22 -0
- package/types/sohl/being-info.d.mts +78 -0
- package/types/sohl/default-item-art.d.mts +28 -0
- package/types/sohl/index.d.mts +7 -0
- package/types/sohl/item-builders.d.mts +11 -0
- package/types/sohl/item-fields.d.mts +20 -0
- package/types/sohl/items.d.mts +19 -0
- package/types/sohl/kb-passes.d.mts +71 -0
- package/types/sohl/note-schemas.d.mts +7 -0
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
4
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
5
|
+
*
|
|
6
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
7
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
8
|
+
*
|
|
9
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
10
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
11
|
+
*
|
|
12
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `content-build` command line — compile / unpack / clean LevelDB packs.
|
|
17
|
+
*
|
|
18
|
+
* A thin `yargs` front end over `../engine/compendiums.mjs`. **Every side
|
|
19
|
+
* effect the pack pipeline has lives here**: argv parsing, `loglevel`
|
|
20
|
+
* configuration, directory creation, reading the shipped Foundry package
|
|
21
|
+
* manifest, and the process exit code. The library itself is import-safe, so a
|
|
22
|
+
* consuming repository's build — or a test — can call it without any of this
|
|
23
|
+
* happening (#1507).
|
|
24
|
+
*
|
|
25
|
+
* The side effects that need *configuration* live inside the command handler,
|
|
26
|
+
* not at module scope, so `--version` and `--help` answer in a directory that
|
|
27
|
+
* has neither a `package-build.config.yaml` nor a package manifest (#2).
|
|
28
|
+
* Running an actual command still resolves both, and still fails loudly when
|
|
29
|
+
* either is missing.
|
|
30
|
+
*
|
|
31
|
+
* Every path and pack name it hands the library comes from the consuming
|
|
32
|
+
* repository's `package-build.config.yaml` (#1508), located by
|
|
33
|
+
* `engine/pack-config.mjs`; nothing about any one repository's layout is
|
|
34
|
+
* written here.
|
|
35
|
+
*
|
|
36
|
+
* Usage:
|
|
37
|
+
* npx content-build package compile [pack]
|
|
38
|
+
* npx content-build package unpack [pack] [entry]
|
|
39
|
+
* npx content-build package clean [pack] [entry]
|
|
40
|
+
* npx content-build docs item-fields [--out <path>] [--title <title>]
|
|
41
|
+
* npx content-build lint [root] [--no-references]
|
|
42
|
+
* npx content-build links [root] [--manifests <dir>]
|
|
43
|
+
* npx content-build format [paths..] [--write]
|
|
44
|
+
* npx content-build markdown [paths..] [--fix]
|
|
45
|
+
* npx content-build manifest [root] [--out <dir>]
|
|
46
|
+
* npx content-build site [--out <dir>]
|
|
47
|
+
* npx content-build reachability <dir> [file] [--index <shortcode>]
|
|
48
|
+
*
|
|
49
|
+
* In a consuming repository, wrapped as npm scripts — SoHL spells them:
|
|
50
|
+
* npm run build:compiledb // → … package compile (all packs)
|
|
51
|
+
* npm run build:unpackdb // → … package unpack
|
|
52
|
+
* npm run docs:item-fields // → … docs item-fields --out …
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import fs from "fs";
|
|
56
|
+
import path from "node:path";
|
|
57
|
+
import log from "loglevel";
|
|
58
|
+
import prefix from "loglevel-plugin-prefix";
|
|
59
|
+
import yargs from "yargs";
|
|
60
|
+
import { hideBin } from "yargs/helpers";
|
|
61
|
+
import {
|
|
62
|
+
compilePacks,
|
|
63
|
+
cleanPacks,
|
|
64
|
+
unpackPacks,
|
|
65
|
+
} from "../engine/compendiums.mjs";
|
|
66
|
+
import { loadPackConfig } from "../engine/pack-config.mjs";
|
|
67
|
+
import {
|
|
68
|
+
fetchAllCatalogs,
|
|
69
|
+
fetchCatalogFromPath,
|
|
70
|
+
itemCatalogRelationships,
|
|
71
|
+
} from "../engine/foreign-catalog.mjs";
|
|
72
|
+
import { renderItemFieldReference } from "../engine/field-reference.mjs";
|
|
73
|
+
import { lintContentTree } from "../engine/content-lint.mjs";
|
|
74
|
+
import { lintFrontmatter } from "../engine/frontmatter-lint.mjs";
|
|
75
|
+
// The one vocabulary, loaded whole. Every content project authors the full type
|
|
76
|
+
// set — an adventure module ships skills, beings and magic swords — so no
|
|
77
|
+
// consumer gets a subset (#19, #20).
|
|
78
|
+
import { NOTE_SCHEMAS } from "../sohl/note-schemas.mjs";
|
|
79
|
+
import { checkFormatting, lintMarkdown } from "../engine/prose-lint.mjs";
|
|
80
|
+
import { emitLinkManifest } from "../engine/manifest-emit.mjs";
|
|
81
|
+
import {
|
|
82
|
+
buildSite,
|
|
83
|
+
gatesFailed,
|
|
84
|
+
formatUnaddressableFinding as formatUnaddressable,
|
|
85
|
+
} from "../engine/site-build.mjs";
|
|
86
|
+
import {
|
|
87
|
+
auditLinks,
|
|
88
|
+
buildLinkIndex,
|
|
89
|
+
walkReachability,
|
|
90
|
+
} from "../engine/content-links.mjs";
|
|
91
|
+
import { emitDiagnostic, positionOfLiteral } from "../engine/diagnostics.mjs";
|
|
92
|
+
import {
|
|
93
|
+
formatUnaddressableFinding,
|
|
94
|
+
unaddressableForeignPackages,
|
|
95
|
+
} from "../engine/foreign-manifests.mjs";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The packs `unpack` extracts.
|
|
99
|
+
*
|
|
100
|
+
* From the configuration's own pack list, which is where the build already
|
|
101
|
+
* knows them. It used to come out of the shipped manifest — a second
|
|
102
|
+
* declaration of the same list, in a second format, with nothing checking that
|
|
103
|
+
* the two agreed. The manifest is generated from this list now
|
|
104
|
+
* (package-build#9), so reading it back would be a round trip through an
|
|
105
|
+
* artifact that need not exist.
|
|
106
|
+
*
|
|
107
|
+
* Read on demand rather than at load, so `--version` and `--help` still answer
|
|
108
|
+
* with no configuration present (#2).
|
|
109
|
+
*
|
|
110
|
+
* @returns {Array<{name: string}>}
|
|
111
|
+
*/
|
|
112
|
+
function configuredPacks() {
|
|
113
|
+
return loadPackConfig().packDirectories.map((name) => ({ name }));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* This package's own version, for `--version`.
|
|
118
|
+
*
|
|
119
|
+
* Read from the package's `package.json` rather than left to yargs, which
|
|
120
|
+
* defaults to the *nearest* `package.json` walking up from the working
|
|
121
|
+
* directory — inside a consuming repository that is the consumer's manifest, so
|
|
122
|
+
* `content-build --version` reported the consumer's version instead of the
|
|
123
|
+
* toolchain's (#1557).
|
|
124
|
+
*
|
|
125
|
+
* @returns {string} The `version` field of this package's manifest.
|
|
126
|
+
*/
|
|
127
|
+
function ownVersion() {
|
|
128
|
+
return JSON.parse(
|
|
129
|
+
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
130
|
+
).version;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Configure loglevel
|
|
134
|
+
log.setLevel("info"); // Set desired logging level
|
|
135
|
+
|
|
136
|
+
// Configure prefix
|
|
137
|
+
prefix.reg(log);
|
|
138
|
+
prefix.apply(log, {
|
|
139
|
+
format(level, _name, timestamp) {
|
|
140
|
+
return `[${timestamp}] [${level.toUpperCase()}]:`;
|
|
141
|
+
},
|
|
142
|
+
timestampFormatter(date) {
|
|
143
|
+
return date.toISOString();
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const argv = yargs(hideBin(process.argv))
|
|
148
|
+
.command(packageCommand())
|
|
149
|
+
.command(depsCommand())
|
|
150
|
+
.command(docsCommand())
|
|
151
|
+
.command(lintCommand())
|
|
152
|
+
.command(linksCommand())
|
|
153
|
+
.command(formatCommand())
|
|
154
|
+
.command(markdownCommand())
|
|
155
|
+
.command(manifestCommand())
|
|
156
|
+
.command(siteCommand())
|
|
157
|
+
.command(reachabilityCommand())
|
|
158
|
+
.version(ownVersion())
|
|
159
|
+
.help()
|
|
160
|
+
.alias("help", "h")
|
|
161
|
+
// Every invocation this CLI accepts must be one it performs (#57). yargs
|
|
162
|
+
// gives neither guarantee by default: without `demandCommand` a bare
|
|
163
|
+
// `content-build` exits 0 in silence, and without `strict` an unknown
|
|
164
|
+
// command or option is ignored rather than reported. Both used to read as
|
|
165
|
+
// success from a `run-s` chain, so a typo in a build script passed the step
|
|
166
|
+
// it was meant to run. The sibling toolchain `@heroiclands/package-build`
|
|
167
|
+
// opts into the same two.
|
|
168
|
+
.demandCommand(1, "Name a command.")
|
|
169
|
+
.strict().argv;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `docs item-fields` — render this repository's item-frontmatter reference.
|
|
173
|
+
*
|
|
174
|
+
* The page is generated from the `fields` each `itemBuilders` entry declares,
|
|
175
|
+
* so every consuming repository documents *its own* registry with the same
|
|
176
|
+
* command (#22).
|
|
177
|
+
*
|
|
178
|
+
* **The framing comes from configuration**, because the tables are the only
|
|
179
|
+
* part that is the same everywhere. A repository's `docs.itemFields` says what
|
|
180
|
+
* the page is called, where it is filed, and what a reader is told before the
|
|
181
|
+
* tables start — the "See also" line its section carries, the paragraph
|
|
182
|
+
* explaining what the page covers. Those were the whole reason a consumer wrote
|
|
183
|
+
* a script around this renderer instead of calling the command.
|
|
184
|
+
*
|
|
185
|
+
* `--check` compares against the file already there rather than writing it, so
|
|
186
|
+
* a repository can gate on the page being current without a temporary file or a
|
|
187
|
+
* second implementation of the comparison. Staleness is a property of the whole
|
|
188
|
+
* generated file, so there is no line to name.
|
|
189
|
+
*
|
|
190
|
+
* `--out` and `--title` still override, for a one-off render.
|
|
191
|
+
*
|
|
192
|
+
* @returns {object} The yargs command module.
|
|
193
|
+
*/
|
|
194
|
+
// eslint-disable-next-line
|
|
195
|
+
function docsCommand() {
|
|
196
|
+
return {
|
|
197
|
+
command: "docs <action>",
|
|
198
|
+
describe: "Generate documentation from the configured registries",
|
|
199
|
+
builder: (yargs) => {
|
|
200
|
+
// Required and honoured. It used to be optional and never read:
|
|
201
|
+
// the handler rendered the item-field reference whatever it was
|
|
202
|
+
// given, so the positional constrained what could be typed and
|
|
203
|
+
// selected nothing (#57).
|
|
204
|
+
yargs.positional("action", {
|
|
205
|
+
describe: "The document to render.",
|
|
206
|
+
type: "string",
|
|
207
|
+
choices: ["item-fields"],
|
|
208
|
+
});
|
|
209
|
+
yargs.option("out", {
|
|
210
|
+
describe:
|
|
211
|
+
"Write to this file instead of the configured location.",
|
|
212
|
+
type: "string",
|
|
213
|
+
});
|
|
214
|
+
yargs.option("check", {
|
|
215
|
+
describe:
|
|
216
|
+
"Compare against the file already there; write nothing.",
|
|
217
|
+
type: "boolean",
|
|
218
|
+
default: false,
|
|
219
|
+
});
|
|
220
|
+
yargs.option("title", {
|
|
221
|
+
describe: "The page's H1.",
|
|
222
|
+
type: "string",
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
handler: (argv) => {
|
|
226
|
+
try {
|
|
227
|
+
const { action, title, check } = argv;
|
|
228
|
+
// Dispatched on, so a second document added here cannot
|
|
229
|
+
// silently render the first. yargs' `choices` has already
|
|
230
|
+
// rejected anything unlisted, so the default is unreachable by
|
|
231
|
+
// a caller — it guards a choice added above without a branch.
|
|
232
|
+
if (action !== "item-fields") {
|
|
233
|
+
throw new Error(`docs: unhandled document "${action}".`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const config = loadPackConfig();
|
|
237
|
+
const spec = config.docs?.itemFields ?? {};
|
|
238
|
+
const destination =
|
|
239
|
+
argv.out ??
|
|
240
|
+
(spec.out ? path.resolve(config.rootDir, spec.out) : null);
|
|
241
|
+
|
|
242
|
+
const page = `${renderItemFieldReference({
|
|
243
|
+
...((title ?? spec.title) ?
|
|
244
|
+
{ title: title ?? spec.title }
|
|
245
|
+
: {}),
|
|
246
|
+
...(spec.preamble ? { preamble: spec.preamble } : {}),
|
|
247
|
+
generatedBy: "`content-build docs item-fields`",
|
|
248
|
+
config,
|
|
249
|
+
})}\n`;
|
|
250
|
+
|
|
251
|
+
if (check) {
|
|
252
|
+
if (!destination) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
"docs: --check needs a file to compare against. " +
|
|
255
|
+
"Declare `docs.itemFields.out` in " +
|
|
256
|
+
"package-build.config.yaml, or pass --out.",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const relative = path.relative(config.rootDir, destination);
|
|
260
|
+
const current =
|
|
261
|
+
fs.existsSync(destination) ?
|
|
262
|
+
fs.readFileSync(destination, "utf8")
|
|
263
|
+
: "";
|
|
264
|
+
if (current !== page) {
|
|
265
|
+
// Staleness belongs to the whole file, so no line is
|
|
266
|
+
// named — the diagnostics contract drops a field it
|
|
267
|
+
// cannot supply rather than guessing one.
|
|
268
|
+
log.error(
|
|
269
|
+
`${relative}: error: out of date with the ` +
|
|
270
|
+
`item-field declarations — run ` +
|
|
271
|
+
`\`content-build docs item-fields\` and commit ` +
|
|
272
|
+
`the regenerated file`,
|
|
273
|
+
);
|
|
274
|
+
process.exitCode = 1;
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
log.info(`${relative} is up to date.`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (destination) {
|
|
282
|
+
fs.mkdirSync(path.dirname(destination), {
|
|
283
|
+
recursive: true,
|
|
284
|
+
});
|
|
285
|
+
fs.writeFileSync(destination, page);
|
|
286
|
+
log.info(
|
|
287
|
+
`Wrote ${path.relative(config.rootDir, destination)}`,
|
|
288
|
+
);
|
|
289
|
+
} else {
|
|
290
|
+
process.stdout.write(page);
|
|
291
|
+
}
|
|
292
|
+
} catch (err) {
|
|
293
|
+
log.error(err.message);
|
|
294
|
+
process.exitCode = 1;
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* `content-build lint` — check a content tree's addresses.
|
|
302
|
+
*
|
|
303
|
+
* Deliberately independent of the pack pipeline: it compiles nothing, opens no
|
|
304
|
+
* LevelDB and needs no Foundry manifest, so it runs in a second and can gate a
|
|
305
|
+
* commit. The content root comes from the consuming repository's
|
|
306
|
+
* `package-build.config.yaml` unless one is named on the command line, so the
|
|
307
|
+
* usual invocation takes no arguments at all.
|
|
308
|
+
*
|
|
309
|
+
* @returns {object} The yargs command module.
|
|
310
|
+
*/
|
|
311
|
+
// eslint-disable-next-line
|
|
312
|
+
function lintCommand() {
|
|
313
|
+
return {
|
|
314
|
+
command: "lint [root]",
|
|
315
|
+
describe: "Check a content tree's addresses and frontmatter",
|
|
316
|
+
builder: (yargs) => {
|
|
317
|
+
yargs.positional("root", {
|
|
318
|
+
describe:
|
|
319
|
+
"Content tree to lint. Defaults to the configured contentBase.",
|
|
320
|
+
type: "string",
|
|
321
|
+
});
|
|
322
|
+
yargs.option("references", {
|
|
323
|
+
describe:
|
|
324
|
+
"Check that a frontmatter shortcode reference lands. Turn off for a tree whose cross-package references it cannot see.",
|
|
325
|
+
type: "boolean",
|
|
326
|
+
default: true,
|
|
327
|
+
});
|
|
328
|
+
yargs.option("manifests", {
|
|
329
|
+
describe:
|
|
330
|
+
"Directory of vendored foreign link manifests, for the reference check. Defaults to the configured `paths.manifests`.",
|
|
331
|
+
type: "string",
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
handler: (argv) => {
|
|
335
|
+
try {
|
|
336
|
+
const config = loadPackConfig();
|
|
337
|
+
const root = argv.root ?? config.paths.content;
|
|
338
|
+
const manifestDir = argv.manifests ?? config.paths.manifests;
|
|
339
|
+
|
|
340
|
+
const addresses = lintContentTree(root);
|
|
341
|
+
// One index, built once, for the reference check. It is the
|
|
342
|
+
// same resolver the wikilink audit uses, so a frontmatter
|
|
343
|
+
// reference and a body link answer the same way.
|
|
344
|
+
const index = buildLinkIndex(root, {
|
|
345
|
+
manifestDir,
|
|
346
|
+
skipDirectories: config.skipDirectories,
|
|
347
|
+
});
|
|
348
|
+
const frontmatter = lintFrontmatter(index, {
|
|
349
|
+
schemas: NOTE_SCHEMAS,
|
|
350
|
+
references: argv.references,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const findings = [
|
|
354
|
+
...addresses.findings,
|
|
355
|
+
...frontmatter.findings,
|
|
356
|
+
];
|
|
357
|
+
for (const finding of findings) emitDiagnostic(finding);
|
|
358
|
+
if (findings.length) {
|
|
359
|
+
log.error(
|
|
360
|
+
`${findings.length} finding(s) across ${addresses.notes} note(s).`,
|
|
361
|
+
);
|
|
362
|
+
process.exitCode = 1;
|
|
363
|
+
} else {
|
|
364
|
+
log.info(
|
|
365
|
+
`Addresses and frontmatter are well-formed ` +
|
|
366
|
+
`(${addresses.keys} across ${addresses.notes} note(s)).`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
} catch (err) {
|
|
370
|
+
log.error(err.message);
|
|
371
|
+
process.exitCode = 1;
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* `content-build format` — Prettier, with the shared configuration.
|
|
379
|
+
*
|
|
380
|
+
* Deliberately **not** scoped to the content tree, and deliberately free of the
|
|
381
|
+
* pack configuration: a repository's formatting covers everything it holds, and
|
|
382
|
+
* a repository that has not configured this package at all must still be able
|
|
383
|
+
* to format itself. The root is therefore the working directory, not
|
|
384
|
+
* `paths.content`.
|
|
385
|
+
*
|
|
386
|
+
* What ships here is a default. A consumer's own Prettier config wins wherever
|
|
387
|
+
* it has one, and its `.prettierignore` is the only place a path is excluded —
|
|
388
|
+
* which paths a repository skips is knowledge about that repository's layout,
|
|
389
|
+
* and it stays there.
|
|
390
|
+
*
|
|
391
|
+
* @returns {object} The yargs command module.
|
|
392
|
+
*/
|
|
393
|
+
// eslint-disable-next-line
|
|
394
|
+
function formatCommand() {
|
|
395
|
+
return {
|
|
396
|
+
command: "format [paths..]",
|
|
397
|
+
describe: "Check formatting with the shared Prettier configuration",
|
|
398
|
+
builder: (yargs) => {
|
|
399
|
+
yargs.positional("paths", {
|
|
400
|
+
describe:
|
|
401
|
+
"Files or directories to check. Defaults to the whole repository.",
|
|
402
|
+
type: "string",
|
|
403
|
+
});
|
|
404
|
+
yargs.option("write", {
|
|
405
|
+
describe:
|
|
406
|
+
"Rewrite unformatted files in place instead of reporting them.",
|
|
407
|
+
type: "boolean",
|
|
408
|
+
default: false,
|
|
409
|
+
});
|
|
410
|
+
yargs.option("check", {
|
|
411
|
+
describe:
|
|
412
|
+
"Report unformatted files without rewriting them (the default).",
|
|
413
|
+
type: "boolean",
|
|
414
|
+
});
|
|
415
|
+
},
|
|
416
|
+
handler: async (argv) => {
|
|
417
|
+
try {
|
|
418
|
+
// `--check` is the default, so it only has to be honoured when
|
|
419
|
+
// it contradicts `--write`; naming both is a mistake worth
|
|
420
|
+
// saying out loud rather than silently resolving.
|
|
421
|
+
if (argv.check === true && argv.write) {
|
|
422
|
+
log.error(
|
|
423
|
+
"--check and --write ask for opposite things; name one.",
|
|
424
|
+
);
|
|
425
|
+
process.exitCode = 1;
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const root = process.cwd();
|
|
429
|
+
const { findings, checked, written } = await checkFormatting(
|
|
430
|
+
root,
|
|
431
|
+
{ paths: argv.paths, write: argv.write },
|
|
432
|
+
);
|
|
433
|
+
if (argv.write) {
|
|
434
|
+
log.info(
|
|
435
|
+
written.length ?
|
|
436
|
+
`Formatted ${written.length} of ${checked} file(s).`
|
|
437
|
+
: `Already formatted (${checked} file(s)).`,
|
|
438
|
+
);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
for (const finding of findings) emitDiagnostic(finding);
|
|
442
|
+
if (findings.length) {
|
|
443
|
+
log.error(
|
|
444
|
+
`${findings.length} of ${checked} file(s) are not formatted.`,
|
|
445
|
+
);
|
|
446
|
+
process.exitCode = 1;
|
|
447
|
+
} else {
|
|
448
|
+
log.info(`Formatting is clean (${checked} file(s)).`);
|
|
449
|
+
}
|
|
450
|
+
} catch (err) {
|
|
451
|
+
log.error(err.message);
|
|
452
|
+
process.exitCode = 1;
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* `content-build markdown` — markdownlint, with the shared rule set.
|
|
460
|
+
*
|
|
461
|
+
* The structural checks Prettier cannot make: a heading level that skips, two
|
|
462
|
+
* sibling headings claiming one anchor, a reversed link, an emphasis marker
|
|
463
|
+
* that is not the one these repositories write. Like `format`, it runs over the
|
|
464
|
+
* repository rather than the content tree, and takes its rules from this
|
|
465
|
+
* package unless the consumer declares its own.
|
|
466
|
+
*
|
|
467
|
+
* @returns {object} The yargs command module.
|
|
468
|
+
*/
|
|
469
|
+
// eslint-disable-next-line
|
|
470
|
+
function markdownCommand() {
|
|
471
|
+
return {
|
|
472
|
+
command: "markdown [paths..]",
|
|
473
|
+
describe: "Lint markdown with the shared markdownlint rule set",
|
|
474
|
+
builder: (yargs) => {
|
|
475
|
+
yargs.positional("paths", {
|
|
476
|
+
describe:
|
|
477
|
+
"Globs to lint. Defaults to every markdown file in the repository.",
|
|
478
|
+
type: "string",
|
|
479
|
+
});
|
|
480
|
+
yargs.option("fix", {
|
|
481
|
+
describe: "Apply the fixes markdownlint can make.",
|
|
482
|
+
type: "boolean",
|
|
483
|
+
default: false,
|
|
484
|
+
});
|
|
485
|
+
},
|
|
486
|
+
handler: async (argv) => {
|
|
487
|
+
try {
|
|
488
|
+
const { findings } = await lintMarkdown(process.cwd(), {
|
|
489
|
+
paths: argv.paths,
|
|
490
|
+
fix: argv.fix,
|
|
491
|
+
});
|
|
492
|
+
for (const finding of findings) emitDiagnostic(finding);
|
|
493
|
+
if (findings.length) {
|
|
494
|
+
log.error(`${findings.length} markdown finding(s).`);
|
|
495
|
+
process.exitCode = 1;
|
|
496
|
+
} else {
|
|
497
|
+
log.info("Markdown is clean.");
|
|
498
|
+
}
|
|
499
|
+
} catch (err) {
|
|
500
|
+
log.error(err.message);
|
|
501
|
+
process.exitCode = 1;
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* `content-build links` — check that every link in a content tree lands.
|
|
509
|
+
*
|
|
510
|
+
* Reports a dead `#anchor`, a dead qualified address, and a wikilink authored
|
|
511
|
+
* in frontmatter, plus a vendored manifest that has drifted out of reach. All
|
|
512
|
+
* of it is package-agnostic, so a consumer needs no script of its own: the
|
|
513
|
+
* manifest directory is the only thing it might name, and that comes from its
|
|
514
|
+
* configuration.
|
|
515
|
+
*
|
|
516
|
+
* @returns {object} The yargs command module.
|
|
517
|
+
*/
|
|
518
|
+
// eslint-disable-next-line
|
|
519
|
+
function linksCommand() {
|
|
520
|
+
return {
|
|
521
|
+
command: "links [root]",
|
|
522
|
+
describe: "Check that every link in a content tree lands somewhere",
|
|
523
|
+
builder: (yargs) => {
|
|
524
|
+
yargs.positional("root", {
|
|
525
|
+
describe:
|
|
526
|
+
"Content tree to check. Defaults to the configured contentBase.",
|
|
527
|
+
type: "string",
|
|
528
|
+
});
|
|
529
|
+
yargs.option("manifests", {
|
|
530
|
+
describe:
|
|
531
|
+
"Directory of vendored foreign link manifests. Defaults " +
|
|
532
|
+
"to the configured `paths.manifests`.",
|
|
533
|
+
type: "string",
|
|
534
|
+
});
|
|
535
|
+
},
|
|
536
|
+
handler: (argv) => {
|
|
537
|
+
try {
|
|
538
|
+
const config = loadPackConfig();
|
|
539
|
+
const contentBase = argv.root ?? config.paths.content;
|
|
540
|
+
const manifestDir = argv.manifests ?? config.paths.manifests;
|
|
541
|
+
|
|
542
|
+
const index = buildLinkIndex(contentBase, { manifestDir });
|
|
543
|
+
|
|
544
|
+
// An unusable manifest would otherwise surface as a pile of
|
|
545
|
+
// dead addresses pointing at the notes that cite it, rather
|
|
546
|
+
// than at the file at fault.
|
|
547
|
+
if (index.foreign.stale.length) {
|
|
548
|
+
for (const s of index.foreign.stale) {
|
|
549
|
+
emitDiagnostic({
|
|
550
|
+
file: path.join(manifestDir, `${s.package}.json`),
|
|
551
|
+
severity: "error",
|
|
552
|
+
message: `unusable link manifest: ${s.reason}`,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
log.error(
|
|
556
|
+
"Refresh the vendored copy from that package's own build.",
|
|
557
|
+
);
|
|
558
|
+
process.exitCode = 1;
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Readable is not the same as addressable: a key shape the
|
|
563
|
+
// lookup cannot parse makes every cross-package link miss, and
|
|
564
|
+
// the audit then blames the *notes*.
|
|
565
|
+
const drifted = unaddressableForeignPackages(
|
|
566
|
+
index.foreign.index,
|
|
567
|
+
);
|
|
568
|
+
if (drifted.length) {
|
|
569
|
+
for (const f of drifted) {
|
|
570
|
+
console.error(
|
|
571
|
+
formatUnaddressableFinding(f, manifestDir),
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
process.exitCode = 1;
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const {
|
|
579
|
+
deadAnchors,
|
|
580
|
+
deadAddresses,
|
|
581
|
+
frontmatterLinks,
|
|
582
|
+
usedManifest,
|
|
583
|
+
} = auditLinks(index);
|
|
584
|
+
|
|
585
|
+
for (const d of deadAnchors) {
|
|
586
|
+
emitDiagnostic({
|
|
587
|
+
file: d.note.file,
|
|
588
|
+
...positionOfLiteral(d.note.raw, d.text, d.occurrence),
|
|
589
|
+
severity: "error",
|
|
590
|
+
message:
|
|
591
|
+
`link [[${d.link}]] points at an anchor no ` +
|
|
592
|
+
`heading in ${d.dest.rel} declares`,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
for (const d of deadAddresses) {
|
|
596
|
+
emitDiagnostic({
|
|
597
|
+
file: d.note.file,
|
|
598
|
+
...positionOfLiteral(d.note.raw, d.text, d.occurrence),
|
|
599
|
+
severity: "error",
|
|
600
|
+
message: `dead address [[${d.target}]] — no document has that identity`,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
for (const f of frontmatterLinks) {
|
|
604
|
+
emitDiagnostic({
|
|
605
|
+
file: f.note.file,
|
|
606
|
+
...positionOfLiteral(f.note.raw, f.link),
|
|
607
|
+
severity: "error",
|
|
608
|
+
message:
|
|
609
|
+
`wikilink ${f.link} authored in frontmatter at ` +
|
|
610
|
+
`${f.path} — frontmatter is data and is never resolved`,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const failures =
|
|
615
|
+
deadAnchors.length +
|
|
616
|
+
deadAddresses.length +
|
|
617
|
+
frontmatterLinks.length;
|
|
618
|
+
if (failures) {
|
|
619
|
+
log.error(
|
|
620
|
+
`${failures} link problem(s) across ${index.notes.length} note(s).`,
|
|
621
|
+
);
|
|
622
|
+
process.exitCode = 1;
|
|
623
|
+
} else {
|
|
624
|
+
log.info(
|
|
625
|
+
`${index.notes.length} notes: every anchor link lands ` +
|
|
626
|
+
`and every qualified address resolves ` +
|
|
627
|
+
`(${usedManifest.size} cross-package reference(s) ` +
|
|
628
|
+
`via manifest), no wikilink in frontmatter.`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
} catch (err) {
|
|
632
|
+
log.error(err.message);
|
|
633
|
+
process.exitCode = 1;
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* `content-build manifest` — emit this package's cross-package link manifest.
|
|
641
|
+
*
|
|
642
|
+
* The last capability the library exposed without a command (#58). Every
|
|
643
|
+
* consumer that publishes a manifest had to write the walk, the address
|
|
644
|
+
* derivation, the anchor pass and the entry assembly for itself, and the two
|
|
645
|
+
* that did drifted apart: one routed its UUIDs through the pack router and one
|
|
646
|
+
* did not, so a repository shipping several packs of a type published UUIDs
|
|
647
|
+
* naming the wrong one.
|
|
648
|
+
*
|
|
649
|
+
* Takes no paths. The content tree, the output directory, the content and
|
|
650
|
+
* Foundry package identities and the address scheme all come from
|
|
651
|
+
* `package-build.config.yaml`; `[root]` and `--out` exist to point the same
|
|
652
|
+
* derivation at a scratch tree, not because a build needs to name them.
|
|
653
|
+
*
|
|
654
|
+
* @returns {object} The yargs command module.
|
|
655
|
+
*/
|
|
656
|
+
// eslint-disable-next-line
|
|
657
|
+
function manifestCommand() {
|
|
658
|
+
return {
|
|
659
|
+
command: "manifest [root]",
|
|
660
|
+
describe: "Emit this package's cross-package link manifest",
|
|
661
|
+
builder: (yargs) => {
|
|
662
|
+
yargs.positional("root", {
|
|
663
|
+
describe:
|
|
664
|
+
"Content tree to read. Defaults to the configured contentBase.",
|
|
665
|
+
type: "string",
|
|
666
|
+
});
|
|
667
|
+
yargs.option("out", {
|
|
668
|
+
describe:
|
|
669
|
+
"Directory to write into. Defaults to the configured " +
|
|
670
|
+
"`paths.manifestOut`.",
|
|
671
|
+
type: "string",
|
|
672
|
+
});
|
|
673
|
+
},
|
|
674
|
+
handler: (argv) => {
|
|
675
|
+
try {
|
|
676
|
+
const config = loadPackConfig();
|
|
677
|
+
const { written, notes, skipped } = emitLinkManifest({
|
|
678
|
+
config,
|
|
679
|
+
...(argv.root ? { contentBase: argv.root } : {}),
|
|
680
|
+
...(argv.out ? { outDir: argv.out } : {}),
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
for (const { package: pkg, file, count } of written) {
|
|
684
|
+
log.info(
|
|
685
|
+
`${pkg} → ${path.relative(process.cwd(), file)} ` +
|
|
686
|
+
`(${count} entries, from ${notes} addressable ` +
|
|
687
|
+
`note(s))`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Reported rather than fatal: a note with no address is
|
|
692
|
+
// ordinary — a template, a stub, a `doc` with no category —
|
|
693
|
+
// and failing the build on one would make the manifest
|
|
694
|
+
// unemittable for a reason that is not about the manifest.
|
|
695
|
+
// Silence is the thing to avoid, since a note that quietly
|
|
696
|
+
// lost its address becomes a dead link in every consumer.
|
|
697
|
+
for (const s of skipped) {
|
|
698
|
+
emitDiagnostic({
|
|
699
|
+
file: path.join(
|
|
700
|
+
argv.root ?? config.paths.content,
|
|
701
|
+
s.file,
|
|
702
|
+
),
|
|
703
|
+
severity: "warning",
|
|
704
|
+
message: `no address, so it is absent from the manifest: ${s.reason}`,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
} catch (err) {
|
|
708
|
+
log.error(err.message);
|
|
709
|
+
process.exitCode = 1;
|
|
710
|
+
}
|
|
711
|
+
},
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* `content-build site` — publish the content tree as a website.
|
|
717
|
+
*
|
|
718
|
+
* The sibling of `package compile`: the same tree, rendered as pages instead of
|
|
719
|
+
* compiled into packs (#63). Everything a consumer used to write for itself —
|
|
720
|
+
* the walk, the address derivation, the address index, table expansion,
|
|
721
|
+
* wikilink resolution, code-fence protection, the foreign-manifest merge and
|
|
722
|
+
* the section-landing backfill — happens here, from configuration.
|
|
723
|
+
*
|
|
724
|
+
* **Each gate is reported and the run stops at the first that fires.** They are
|
|
725
|
+
* ordered so the report names the cause rather than its symptoms: an unusable
|
|
726
|
+
* manifest, reported after the links that failed because of it, reads as a pile
|
|
727
|
+
* of broken notes.
|
|
728
|
+
*
|
|
729
|
+
* @returns {object} The yargs command module.
|
|
730
|
+
*/
|
|
731
|
+
// eslint-disable-next-line
|
|
732
|
+
function siteCommand() {
|
|
733
|
+
return {
|
|
734
|
+
command: "site",
|
|
735
|
+
describe: "Build a Hugo content tree from the content tree",
|
|
736
|
+
builder: (yargs) => {
|
|
737
|
+
yargs.option("out", {
|
|
738
|
+
describe:
|
|
739
|
+
"Write the mount here instead of the configured `site.out`.",
|
|
740
|
+
type: "string",
|
|
741
|
+
});
|
|
742
|
+
},
|
|
743
|
+
handler: (argv) => {
|
|
744
|
+
try {
|
|
745
|
+
const result = buildSite({
|
|
746
|
+
...(argv.out ? { outRoot: argv.out } : {}),
|
|
747
|
+
});
|
|
748
|
+
const { gates } = result;
|
|
749
|
+
|
|
750
|
+
for (const f of gates.frontmatterLinks) {
|
|
751
|
+
emitDiagnostic({
|
|
752
|
+
file: f.file,
|
|
753
|
+
severity: "error",
|
|
754
|
+
message:
|
|
755
|
+
`wikilink ${f.link} authored in frontmatter at ` +
|
|
756
|
+
`${f.path} — frontmatter is data, is copied to the ` +
|
|
757
|
+
`page verbatim, and reaches the reader as brackets`,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
for (const f of gates.slugErrors) {
|
|
761
|
+
emitDiagnostic({
|
|
762
|
+
file: f.file,
|
|
763
|
+
severity: "error",
|
|
764
|
+
message: `cannot derive a URL: ${f.reason}`,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
for (const c of gates.collisions) {
|
|
768
|
+
log.error(`${c.url} claimed by ${c.sources.join(", ")}`);
|
|
769
|
+
}
|
|
770
|
+
for (const s of gates.staleManifests) {
|
|
771
|
+
emitDiagnostic({
|
|
772
|
+
file: path.join(
|
|
773
|
+
loadPackConfig().paths.manifests,
|
|
774
|
+
`${s.package}.json`,
|
|
775
|
+
),
|
|
776
|
+
severity: "error",
|
|
777
|
+
message: `unusable link manifest: ${s.reason}`,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
for (const f of gates.unaddressable) {
|
|
781
|
+
console.error(
|
|
782
|
+
formatUnaddressable(
|
|
783
|
+
f,
|
|
784
|
+
loadPackConfig().paths.manifests,
|
|
785
|
+
),
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
for (const c of gates.conflicts) {
|
|
789
|
+
log.error(
|
|
790
|
+
`address ${c.key} is also published by ${c.package}`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
if (gatesFailed(gates)) {
|
|
794
|
+
process.exitCode = 1;
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Reported after the write rather than before it: both are
|
|
799
|
+
// failures of individual notes, and stopping the whole build
|
|
800
|
+
// before anything is emitted would make a single bad table
|
|
801
|
+
// hide every other problem in the tree.
|
|
802
|
+
for (const e of result.tableErrors) {
|
|
803
|
+
log.error(`bad content table: ${e.reason} (${e.source})`);
|
|
804
|
+
}
|
|
805
|
+
for (const e of result.wikiErrors) {
|
|
806
|
+
log.error(
|
|
807
|
+
`bad wikilink [[${e.target}]]: ${e.reason} (${e.file})`,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
if (result.tableErrors.length || result.wikiErrors.length) {
|
|
811
|
+
process.exitCode = 1;
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (result.manifests && !result.manifests.complete) {
|
|
816
|
+
log.warn(
|
|
817
|
+
`cross-package address checking is OFF — no manifest ` +
|
|
818
|
+
`for ${result.manifests.missing.join(", ")}. ` +
|
|
819
|
+
`Unresolved addresses are tolerated until every ` +
|
|
820
|
+
`package publishes one.`,
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const s = result.stats;
|
|
825
|
+
log.info(
|
|
826
|
+
`wrote ${s.content ?? 0} content page(s) + ` +
|
|
827
|
+
`${s.tree ?? 0} tree page(s) + ${s.landings} ` +
|
|
828
|
+
`landing(s) to ${path.relative(process.cwd(), s.out)}`,
|
|
829
|
+
);
|
|
830
|
+
} catch (err) {
|
|
831
|
+
log.error(err.message);
|
|
832
|
+
process.exitCode = 1;
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* `content-build reachability <dir> [file]` — check that a corpus reads through.
|
|
840
|
+
*
|
|
841
|
+
* The corpus is named on the command line rather than declared in code, because
|
|
842
|
+
* it never changes for a given repository: a consumer hardcodes the invocation
|
|
843
|
+
* in `package.json` and gets the check without writing a script.
|
|
844
|
+
*
|
|
845
|
+
* content-build reachability Rules --index glossary
|
|
846
|
+
* content-build reachability User_Guide --index glossary
|
|
847
|
+
*
|
|
848
|
+
* @returns {object} The yargs command module.
|
|
849
|
+
*/
|
|
850
|
+
// eslint-disable-next-line
|
|
851
|
+
function reachabilityCommand() {
|
|
852
|
+
return {
|
|
853
|
+
command: "reachability <dir> [file]",
|
|
854
|
+
describe: "Check that every document in a corpus is reachable",
|
|
855
|
+
builder: (yargs) => {
|
|
856
|
+
yargs.positional("dir", {
|
|
857
|
+
describe:
|
|
858
|
+
"The corpus directory, relative to the content tree root.",
|
|
859
|
+
type: "string",
|
|
860
|
+
});
|
|
861
|
+
yargs.positional("file", {
|
|
862
|
+
describe: "The corpus's entry page within that directory.",
|
|
863
|
+
type: "string",
|
|
864
|
+
default: "README.md",
|
|
865
|
+
});
|
|
866
|
+
yargs.option("index", {
|
|
867
|
+
describe:
|
|
868
|
+
"Shortcode of a page walked *to* but not *through*. " +
|
|
869
|
+
"Repeatable. An index links to nearly everything it " +
|
|
870
|
+
"covers, so walking one makes the check vacuous.",
|
|
871
|
+
type: "string",
|
|
872
|
+
array: true,
|
|
873
|
+
default: [],
|
|
874
|
+
});
|
|
875
|
+
yargs.option("root", {
|
|
876
|
+
describe:
|
|
877
|
+
"Content tree to read. Defaults to the configured contentBase.",
|
|
878
|
+
type: "string",
|
|
879
|
+
});
|
|
880
|
+
},
|
|
881
|
+
handler: (argv) => {
|
|
882
|
+
try {
|
|
883
|
+
const contentBase = argv.root ?? loadPackConfig().paths.content;
|
|
884
|
+
const dir = String(argv.dir).replace(/\/+$/, "");
|
|
885
|
+
const index = buildLinkIndex(contentBase);
|
|
886
|
+
const indexes = new Set(argv.index.map(String));
|
|
887
|
+
|
|
888
|
+
const { orphans } = walkReachability(index, {
|
|
889
|
+
root: `${dir}/${argv.file}`,
|
|
890
|
+
scope: (n) => n.rel.startsWith(`${dir}/`),
|
|
891
|
+
stopAt: (n) => indexes.has(String(n.fm.shortcode)),
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
const total = index.notes.filter((n) =>
|
|
895
|
+
n.rel.startsWith(`${dir}/`),
|
|
896
|
+
).length;
|
|
897
|
+
|
|
898
|
+
for (const o of orphans) {
|
|
899
|
+
// Unreachability is a property of the whole document, so
|
|
900
|
+
// there is no line to name.
|
|
901
|
+
emitDiagnostic({
|
|
902
|
+
file: o.file,
|
|
903
|
+
severity: "error",
|
|
904
|
+
message:
|
|
905
|
+
`unreachable from ${dir}/${argv.file} — nothing ` +
|
|
906
|
+
`in ${dir} links to it`,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (orphans.length) {
|
|
911
|
+
log.error(
|
|
912
|
+
`${orphans.length} of ${total} document(s) in ${dir} ` +
|
|
913
|
+
`cannot be arrived at by reading. A corpus is a ` +
|
|
914
|
+
`book, not a pile of notes: link each one from the ` +
|
|
915
|
+
`chapter or section that owns it.`,
|
|
916
|
+
);
|
|
917
|
+
process.exitCode = 1;
|
|
918
|
+
} else {
|
|
919
|
+
log.info(
|
|
920
|
+
`All ${total} document(s) in ${dir} are reachable ` +
|
|
921
|
+
`from ${argv.file}.`,
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
} catch (err) {
|
|
925
|
+
log.error(err.message);
|
|
926
|
+
process.exitCode = 1;
|
|
927
|
+
}
|
|
928
|
+
},
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// eslint-disable-next-line
|
|
933
|
+
/**
|
|
934
|
+
* `deps fetch` — fill the item-catalogue cache for every dependency that
|
|
935
|
+
* declares `itemCatalog: true`.
|
|
936
|
+
*
|
|
937
|
+
* Its own command rather than a step of `package compile`, so that a compile
|
|
938
|
+
* never reaches the network. A build that downloads silently is not
|
|
939
|
+
* reproducible, breaks offline, and hides a dependency's version change behind
|
|
940
|
+
* a passing run.
|
|
941
|
+
*
|
|
942
|
+
* `--from` fills the cache from a locally built artifact instead of a release,
|
|
943
|
+
* which is what makes iterating across packages possible: change the system,
|
|
944
|
+
* build it, and see the effect on every consumer **before** any of it ships.
|
|
945
|
+
* Otherwise testing a dependency change against its consumers costs a release
|
|
946
|
+
* round-trip, which makes releasing a debugging tool rather than a publishing
|
|
947
|
+
* decision.
|
|
948
|
+
*
|
|
949
|
+
* @returns {object} The yargs command module.
|
|
950
|
+
*/
|
|
951
|
+
/**
|
|
952
|
+
* Resolve which declared dependency `--from` supplies, and cache it.
|
|
953
|
+
*
|
|
954
|
+
* @param {object} config - The resolved build configuration.
|
|
955
|
+
* @param {{from: string, id?: string}} argv - The parsed arguments.
|
|
956
|
+
* @returns {Promise<void>}
|
|
957
|
+
*/
|
|
958
|
+
async function fetchFromLocalArtifact(config, argv) {
|
|
959
|
+
const rels = itemCatalogRelationships(config);
|
|
960
|
+
const named = rels.map((r) => r.id).join(", ") || "none";
|
|
961
|
+
const rel =
|
|
962
|
+
argv.id ? rels.find((r) => r.id === argv.id)
|
|
963
|
+
: rels.length === 1 ? rels[0]
|
|
964
|
+
: undefined;
|
|
965
|
+
if (!rel) {
|
|
966
|
+
// Name the choices: the id must match a declared relationship, and the
|
|
967
|
+
// config is the only place that says which those are.
|
|
968
|
+
throw new Error(
|
|
969
|
+
argv.id ?
|
|
970
|
+
`no dependency "${argv.id}" declares \`itemCatalog: true\` (declared: ${named})`
|
|
971
|
+
: `--from needs --id when several dependencies declare \`itemCatalog: true\` (declared: ${named})`,
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
await fetchCatalogFromPath(config, rel, argv.from);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function depsCommand() {
|
|
978
|
+
return {
|
|
979
|
+
command: "deps <action>",
|
|
980
|
+
describe: "Manage build-time dependencies on other packages",
|
|
981
|
+
builder: (yargs) => {
|
|
982
|
+
// Required, for the reason `package <action>` is (#57): an optional
|
|
983
|
+
// action exits 0 having done nothing.
|
|
984
|
+
yargs.positional("action", {
|
|
985
|
+
describe: "The action to perform.",
|
|
986
|
+
type: "string",
|
|
987
|
+
choices: ["fetch"],
|
|
988
|
+
});
|
|
989
|
+
yargs.option("from", {
|
|
990
|
+
describe:
|
|
991
|
+
"Fill the cache from a locally built artifact — a package " +
|
|
992
|
+
"zip or the directory it was built from — instead of a " +
|
|
993
|
+
"release. Use it to test a consumer against changes that " +
|
|
994
|
+
"have not shipped.",
|
|
995
|
+
type: "string",
|
|
996
|
+
});
|
|
997
|
+
yargs.option("id", {
|
|
998
|
+
describe:
|
|
999
|
+
"Which declared dependency `--from` supplies. Only needed " +
|
|
1000
|
+
"when more than one declares `itemCatalog: true`.",
|
|
1001
|
+
type: "string",
|
|
1002
|
+
});
|
|
1003
|
+
},
|
|
1004
|
+
handler: async (argv) => {
|
|
1005
|
+
try {
|
|
1006
|
+
const config = loadPackConfig();
|
|
1007
|
+
if (argv.from) {
|
|
1008
|
+
await fetchFromLocalArtifact(config, argv);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
const count = await fetchAllCatalogs(config);
|
|
1012
|
+
if (count)
|
|
1013
|
+
log.info(`Fetched ${count} dependency catalogue(s).`);
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
log.error(err.message);
|
|
1016
|
+
process.exitCode = 1;
|
|
1017
|
+
}
|
|
1018
|
+
},
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function packageCommand() {
|
|
1023
|
+
return {
|
|
1024
|
+
command: "package <action> [pack] [entry]",
|
|
1025
|
+
describe: "Manage packages",
|
|
1026
|
+
builder: (yargs) => {
|
|
1027
|
+
// Required, not optional: the action *is* the work, and an
|
|
1028
|
+
// optional one meant `content-build package` fell through the
|
|
1029
|
+
// switch below and exited 0 having compiled nothing (#57).
|
|
1030
|
+
yargs.positional("action", {
|
|
1031
|
+
describe: "The action to perform.",
|
|
1032
|
+
type: "string",
|
|
1033
|
+
choices: ["compile", "unpack", "clean"],
|
|
1034
|
+
});
|
|
1035
|
+
yargs.positional("pack", {
|
|
1036
|
+
describe: "Name of the pack upon which to work.",
|
|
1037
|
+
type: "string",
|
|
1038
|
+
});
|
|
1039
|
+
yargs.positional("entry", {
|
|
1040
|
+
describe:
|
|
1041
|
+
"Name of any entry within a pack upon which to work. Only applicable to extract & clean commands.",
|
|
1042
|
+
type: "string",
|
|
1043
|
+
});
|
|
1044
|
+
},
|
|
1045
|
+
handler: async (argv) => {
|
|
1046
|
+
const { action, pack, entry } = argv;
|
|
1047
|
+
// yargs does not await this handler, so a rejection would surface as
|
|
1048
|
+
// an unhandled-rejection stack trace. Report the message and set a
|
|
1049
|
+
// failing exit code, so a build guard reads as a build failure.
|
|
1050
|
+
try {
|
|
1051
|
+
// The one directory the pipeline creates rather than expects:
|
|
1052
|
+
// `unpack` writes the extracted JSON there and `compile` reads
|
|
1053
|
+
// it back. Created here rather than at module scope so that
|
|
1054
|
+
// asking the CLI its version needs no configuration (#2).
|
|
1055
|
+
fs.mkdirSync(loadPackConfig().paths.unpack, {
|
|
1056
|
+
recursive: true,
|
|
1057
|
+
});
|
|
1058
|
+
switch (action) {
|
|
1059
|
+
// Every path and pack list the library needs is defaulted
|
|
1060
|
+
// from the resolved configuration, so nothing is restated
|
|
1061
|
+
// here (#1508).
|
|
1062
|
+
case "compile":
|
|
1063
|
+
return await compilePacks({ packName: pack });
|
|
1064
|
+
case "clean":
|
|
1065
|
+
return await cleanPacks({
|
|
1066
|
+
packName: pack,
|
|
1067
|
+
entryName: entry,
|
|
1068
|
+
});
|
|
1069
|
+
case "unpack":
|
|
1070
|
+
return await unpackPacks({
|
|
1071
|
+
packs: configuredPacks(),
|
|
1072
|
+
packName: pack,
|
|
1073
|
+
entryName: entry,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
log.error(err.message);
|
|
1078
|
+
process.exitCode = 1;
|
|
1079
|
+
}
|
|
1080
|
+
},
|
|
1081
|
+
};
|
|
1082
|
+
}
|