@heroiclands/package-build 0.6.1 → 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.
Files changed (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +58 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -35
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. 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
+ }