@heroiclands/package-build 20.3.1 → 20.5.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 (195) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/CONTENT.md +322 -110
  3. package/README.md +21 -10
  4. package/bin/content-build.mjs +204 -92
  5. package/bin/package-build.mjs +48 -15
  6. package/config.mjs +6 -8
  7. package/content-config.mjs +518 -151
  8. package/docs/api.md +1352 -0
  9. package/docs/commands.md +1609 -0
  10. package/docs/configuration.md +1432 -0
  11. package/docs/content-format.md +73 -74
  12. package/docs/diagnostics.md +356 -0
  13. package/docs/getting-started.md +813 -0
  14. package/docs/project-setup.md +469 -0
  15. package/e2e.mjs +4 -4
  16. package/engine/actor-compiler.mjs +64 -61
  17. package/engine/address-charset.mjs +6 -6
  18. package/engine/address-diff.mjs +57 -52
  19. package/engine/anchored-sections.mjs +1 -1
  20. package/engine/anchors.mjs +1 -1
  21. package/engine/base-compiler.mjs +42 -38
  22. package/engine/bundle-notes.mjs +4 -4
  23. package/engine/bundles.mjs +17 -8
  24. package/engine/code-fences.mjs +3 -3
  25. package/engine/compendiums.mjs +8 -8
  26. package/engine/compile-corpus.mjs +4 -5
  27. package/engine/content-address.mjs +24 -24
  28. package/engine/content-charset.mjs +1 -1
  29. package/engine/content-format-check.mjs +24 -25
  30. package/engine/content-format.mjs +8 -8
  31. package/engine/content-html.mjs +202 -0
  32. package/engine/content-icons.mjs +196 -165
  33. package/engine/content-index.mjs +61 -40
  34. package/engine/content-links.mjs +74 -41
  35. package/engine/content-lint.mjs +64 -34
  36. package/engine/content-package.mjs +7 -9
  37. package/engine/content-slug.mjs +2 -2
  38. package/engine/content-tables.mjs +38 -34
  39. package/engine/content-tree.mjs +1 -1
  40. package/engine/derived-fields.mjs +174 -0
  41. package/engine/diagnostics.mjs +3 -4
  42. package/engine/document-subtypes.mjs +6 -7
  43. package/engine/field-reference.mjs +8 -8
  44. package/engine/field-spec.mjs +29 -30
  45. package/engine/folder-notes.mjs +18 -16
  46. package/engine/foreign-catalog.mjs +8 -9
  47. package/engine/foundry-entries.mjs +19 -20
  48. package/engine/frontmatter-lint.mjs +95 -103
  49. package/engine/frontmatter.mjs +11 -11
  50. package/engine/generate.mjs +44 -39
  51. package/engine/helpers.mjs +147 -84
  52. package/engine/homepage.mjs +20 -22
  53. package/engine/ids.mjs +15 -15
  54. package/engine/index-records.mjs +4 -4
  55. package/engine/index.mjs +26 -10
  56. package/engine/item-compiler.mjs +54 -29
  57. package/engine/item-docs.mjs +11 -13
  58. package/engine/item-registry.mjs +13 -13
  59. package/engine/journals.mjs +24 -15
  60. package/engine/macros.mjs +3 -3
  61. package/engine/map-notes.mjs +6 -6
  62. package/engine/metadata-index.mjs +5 -5
  63. package/engine/note-claims.mjs +51 -41
  64. package/engine/note-ids.mjs +5 -5
  65. package/engine/note-package.mjs +4 -4
  66. package/engine/note-renames.mjs +3 -3
  67. package/engine/note-schemas.mjs +3 -8
  68. package/engine/note-vocabulary.mjs +56 -54
  69. package/engine/pack-config.mjs +39 -25
  70. package/engine/pack-router.mjs +0 -0
  71. package/engine/pdf-build.mjs +464 -0
  72. package/engine/pdf-fonts.mjs +420 -0
  73. package/engine/pdf-render.mjs +798 -0
  74. package/engine/pdf-toc.mjs +525 -0
  75. package/engine/prose-config.mjs +9 -9
  76. package/engine/prose-lint.mjs +12 -13
  77. package/engine/region-events.mjs +2 -2
  78. package/engine/retired-fields.mjs +24 -22
  79. package/engine/runtime-only-fields.mjs +1 -1
  80. package/engine/scene-levels.mjs +1 -1
  81. package/engine/scenes.mjs +33 -25
  82. package/engine/schema-check.mjs +21 -21
  83. package/engine/schema-extract.mjs +1 -1
  84. package/engine/site-build.mjs +66 -50
  85. package/engine/site-index.mjs +17 -18
  86. package/engine/sql-tables.mjs +21 -13
  87. package/engine/subtype-registry.mjs +5 -4
  88. package/engine/system-block.mjs +18 -19
  89. package/engine/systems.mjs +2 -2
  90. package/engine/web-wikilinks.mjs +41 -38
  91. package/engine/wikilink-syntax.mjs +16 -16
  92. package/engine/wikilinks.mjs +40 -42
  93. package/hm3/actors.mjs +30 -22
  94. package/hm3/document-subtypes.mjs +5 -5
  95. package/hm3/index.mjs +2 -2
  96. package/hm3/item-builders.mjs +1 -1
  97. package/hm3/item-fields.mjs +3 -3
  98. package/hm3/items.mjs +28 -8
  99. package/hm3/template-priority.mjs +2 -2
  100. package/lang.mjs +3 -3
  101. package/manifest.mjs +17 -20
  102. package/package.json +1 -2
  103. package/release.mjs +66 -6
  104. package/sohl/actors.mjs +36 -28
  105. package/sohl/affiliation-standings.mjs +2 -2
  106. package/sohl/being-info.mjs +5 -5
  107. package/sohl/default-item-art.mjs +5 -5
  108. package/sohl/document-subtypes.mjs +5 -5
  109. package/sohl/index.mjs +3 -3
  110. package/sohl/item-builders.mjs +7 -7
  111. package/sohl/item-fields.mjs +8 -9
  112. package/sohl/items.mjs +28 -6
  113. package/sohl/kb-passes.mjs +5 -5
  114. package/sohl/note-schemas.mjs +12 -12
  115. package/sohl/skill-base.mjs +1 -1
  116. package/types/content-config.d.mts +91 -39
  117. package/types/e2e.d.mts +3 -3
  118. package/types/engine/actor-compiler.d.mts +45 -40
  119. package/types/engine/address-charset.d.mts +6 -6
  120. package/types/engine/address-diff.d.mts +60 -6
  121. package/types/engine/base-compiler.d.mts +27 -21
  122. package/types/engine/bundle-notes.d.mts +3 -3
  123. package/types/engine/bundles.d.mts +10 -1
  124. package/types/engine/code-fences.d.mts +3 -3
  125. package/types/engine/compendiums.d.mts +3 -3
  126. package/types/engine/compile-corpus.d.mts +1 -1
  127. package/types/engine/content-address.d.mts +20 -20
  128. package/types/engine/content-format-check.d.mts +6 -6
  129. package/types/engine/content-format.d.mts +2 -2
  130. package/types/engine/content-html.d.mts +78 -0
  131. package/types/engine/content-icons.d.mts +139 -92
  132. package/types/engine/content-index.d.mts +59 -15
  133. package/types/engine/content-links.d.mts +8 -8
  134. package/types/engine/content-lint.d.mts +8 -6
  135. package/types/engine/content-package.d.mts +6 -8
  136. package/types/engine/content-tables.d.mts +49 -18
  137. package/types/engine/derived-fields.d.mts +101 -0
  138. package/types/engine/diagnostics.d.mts +2 -2
  139. package/types/engine/document-subtypes.d.mts +3 -3
  140. package/types/engine/field-spec.d.mts +37 -39
  141. package/types/engine/folder-notes.d.mts +4 -5
  142. package/types/engine/foreign-catalog.d.mts +3 -3
  143. package/types/engine/foundry-entries.d.mts +6 -7
  144. package/types/engine/frontmatter-lint.d.mts +10 -9
  145. package/types/engine/frontmatter.d.mts +7 -7
  146. package/types/engine/generate.d.mts +7 -7
  147. package/types/engine/helpers.d.mts +128 -56
  148. package/types/engine/homepage.d.mts +16 -18
  149. package/types/engine/ids.d.mts +13 -13
  150. package/types/engine/index-records.d.mts +3 -3
  151. package/types/engine/index.d.mts +6 -0
  152. package/types/engine/item-compiler.d.mts +21 -5
  153. package/types/engine/item-docs.d.mts +2 -2
  154. package/types/engine/item-registry.d.mts +6 -6
  155. package/types/engine/journals.d.mts +12 -3
  156. package/types/engine/map-notes.d.mts +2 -2
  157. package/types/engine/metadata-index.d.mts +4 -4
  158. package/types/engine/note-claims.d.mts +30 -22
  159. package/types/engine/note-ids.d.mts +4 -4
  160. package/types/engine/note-package.d.mts +1 -1
  161. package/types/engine/note-renames.d.mts +3 -3
  162. package/types/engine/note-vocabulary.d.mts +31 -203
  163. package/types/engine/pack-config.d.mts +7 -7
  164. package/types/engine/pack-router.d.mts +1 -1
  165. package/types/engine/pdf-build.d.mts +42 -0
  166. package/types/engine/pdf-fonts.d.mts +30 -0
  167. package/types/engine/pdf-render.d.mts +144 -0
  168. package/types/engine/pdf-toc.d.mts +114 -0
  169. package/types/engine/prose-config.d.mts +9 -9
  170. package/types/engine/prose-lint.d.mts +3 -4
  171. package/types/engine/region-events.d.mts +2 -2
  172. package/types/engine/retired-fields.d.mts +10 -9
  173. package/types/engine/scenes.d.mts +10 -1
  174. package/types/engine/schema-check.d.mts +13 -13
  175. package/types/engine/site-build.d.mts +52 -25
  176. package/types/engine/site-index.d.mts +3 -4
  177. package/types/engine/sql-tables.d.mts +11 -5
  178. package/types/engine/subtype-registry.d.mts +3 -3
  179. package/types/engine/system-block.d.mts +3 -3
  180. package/types/engine/web-wikilinks.d.mts +7 -7
  181. package/types/engine/wikilink-syntax.d.mts +17 -17
  182. package/types/engine/wikilinks.d.mts +14 -14
  183. package/types/hm3/actors.d.mts +9 -1
  184. package/types/hm3/document-subtypes.d.mts +1 -1
  185. package/types/hm3/items.d.mts +9 -2
  186. package/types/hm3/template-priority.d.mts +1 -1
  187. package/types/manifest.d.mts +8 -8
  188. package/types/release.d.mts +15 -4
  189. package/types/sohl/actors.d.mts +12 -4
  190. package/types/sohl/affiliation-standings.d.mts +2 -2
  191. package/types/sohl/being-info.d.mts +2 -2
  192. package/types/sohl/document-subtypes.d.mts +1 -1
  193. package/types/sohl/items.d.mts +9 -2
  194. package/types/sohl/note-schemas.d.mts +1 -1
  195. package/MIGRATING.md +0 -608
@@ -20,7 +20,7 @@
20
20
  * configuration, directory creation, reading the shipped Foundry package
21
21
  * manifest, and the process exit code. The library itself is import-safe, so a
22
22
  * consuming repository's build — or a test — can call it without any of this
23
- * happening (#1507).
23
+ * happening.
24
24
  *
25
25
  * The side effects that need *configuration* live inside the command handler,
26
26
  * not at module scope, so `--version` and `--help` answer in a directory that
@@ -29,7 +29,7 @@
29
29
  * either is missing.
30
30
  *
31
31
  * Every path and pack name it hands the library comes from the consuming
32
- * repository's `package-build.config.yaml` (#1508), located by
32
+ * repository's `package-build.config.yaml`, located by
33
33
  * `engine/pack-config.mjs`; nothing about any one repository's layout is
34
34
  * written here.
35
35
  *
@@ -63,7 +63,9 @@ import prefix from "loglevel-plugin-prefix";
63
63
  import yargs from "yargs";
64
64
  import { hideBin } from "yargs/helpers";
65
65
  import { compilePacks, cleanPacks, unpackPacks } from "../engine/compendiums.mjs";
66
+ import { compilesFoundryDocuments } from "../content-config.mjs";
66
67
  import { loadPackConfig } from "../engine/pack-config.mjs";
68
+ import { buildPdf } from "../engine/pdf-build.mjs";
67
69
  import {
68
70
  fetchAllCatalogs,
69
71
  fetchCatalogFromPath,
@@ -80,6 +82,7 @@ import {
80
82
  import { renderItemFieldReference } from "../engine/field-reference.mjs";
81
83
  import { lintContentTree } from "../engine/content-lint.mjs";
82
84
  import { lintContentCharset } from "../engine/content-charset.mjs";
85
+ import { lintContentHtml } from "../engine/content-html.mjs";
83
86
  import { lintContentIcons } from "../engine/content-icons.mjs";
84
87
  import { declaredSystems, lintFrontmatter, systemBlocksFor } from "../engine/frontmatter-lint.mjs";
85
88
  import { loadContentFormat } from "../engine/content-format.mjs";
@@ -96,14 +99,14 @@ import {
96
99
  } from "../engine/schema-check.mjs";
97
100
  // The one vocabulary, loaded whole. Every content project authors the full type
98
101
  // set — an adventure module ships skills, beings and magic swords — so no
99
- // consumer gets a subset (#19, #20).
102
+ // consumer gets a subset.
100
103
  import { NOTE_SCHEMAS } from "../sohl/note-schemas.mjs";
101
104
  // The shipped declarations, so this repository can check its own specification
102
- // against them without standing up a consumer's configuration (#136).
105
+ // against them without standing up a consumer's configuration.
103
106
  import { ITEM_FIELDS } from "../sohl/item-fields.mjs";
104
107
  import { HM3_ITEM_FIELDS } from "../hm3/item-fields.mjs";
105
108
  // The engine's own types, merged under the registry's so the vocabulary stands
106
- // in a package that configures no `itemBuilders` at all (#51).
109
+ // in a package that configures no `itemBuilders` at all.
107
110
  import { ENGINE_NOTE_SCHEMAS } from "../engine/note-schemas.mjs";
108
111
  import { schemaSubtypeOf } from "../engine/subtype-registry.mjs";
109
112
  import { NOTE_VOCABULARY } from "../engine/note-vocabulary.mjs";
@@ -122,7 +125,7 @@ import {
122
125
  } from "../engine/site-build.mjs";
123
126
  import { auditLinks, buildLinkIndex, walkReachability } from "../engine/content-links.mjs";
124
127
  import { prepareTreeSqlTables } from "../engine/sql-tables.mjs";
125
- // The one place a link finding is worded, shared with both builds (#184).
128
+ // The one place a link finding is worded, shared with both builds.
126
129
  import { linkFindingMessage } from "../engine/wikilink-syntax.mjs";
127
130
  import {
128
131
  emitDiagnostic,
@@ -144,7 +147,7 @@ import { emittedArtFor, itemPackJsonDirs } from "../engine/generate.mjs";
144
147
  * The packs `unpack` extracts.
145
148
  *
146
149
  * From the configuration's own pack list, which is where the build already
147
- * knows them. It used to come out of the shipped manifest — a second
150
+ * knows them. It does not come out of the shipped manifest — a second
148
151
  * declaration of the same list, in a second format, with nothing checking that
149
152
  * the two agreed. The manifest is generated from this list now
150
153
  * (package-build#9), so reading it back would be a round trip through an
@@ -166,7 +169,7 @@ function configuredPacks() {
166
169
  * defaults to the *nearest* `package.json` walking up from the working
167
170
  * directory — inside a consuming repository that is the consumer's manifest, so
168
171
  * `content-build --version` reported the consumer's version instead of the
169
- * toolchain's (#1557).
172
+ * toolchain's.
170
173
  *
171
174
  * @returns {string} The `version` field of this package's manifest.
172
175
  */
@@ -191,8 +194,8 @@ prefix.apply(log, {
191
194
  /**
192
195
  * Report a command's failure.
193
196
  *
194
- * A configuration error carries its own `file:line:column: error: ` locator
195
- * (#95), and `loglevel`'s `[timestamp] [ERROR]:` prefix occupies exactly the
197
+ * A configuration error carries its own `file:line:column: error: ` locator,
198
+ * and `loglevel`'s `[timestamp] [ERROR]:` prefix occupies exactly the
196
199
  * position a parser reads the path from — so a located failure is printed
197
200
  * unprefixed, as `emitDiagnostic` prints every other finding. Everything else
198
201
  * is ordinary prose and keeps the log line it always had.
@@ -244,15 +247,16 @@ const argv = yargs(hideBin(process.argv))
244
247
  .command(markdownCommand())
245
248
  .command(contentIndexCommand())
246
249
  .command(siteCommand())
250
+ .command(pdfCommand())
247
251
  .command(reachabilityCommand())
248
252
  .command(addressesCommand())
249
253
  .version(ownVersion())
250
254
  .help()
251
255
  .alias("help", "h")
252
- // Every invocation this CLI accepts must be one it performs (#57). yargs
256
+ // Every invocation this CLI accepts must be one it performs. yargs
253
257
  // gives neither guarantee by default: without `demandCommand` a bare
254
258
  // `content-build` exits 0 in silence, and without `strict` an unknown
255
- // command or option is ignored rather than reported. Both used to read as
259
+ // command or option is ignored rather than reported. Both would read as
256
260
  // success from a `run-s` chain, so a typo in a build script passed the step
257
261
  // it was meant to run. The sibling toolchain `@heroiclands/package-build`
258
262
  // opts into the same two.
@@ -264,7 +268,7 @@ const argv = yargs(hideBin(process.argv))
264
268
  *
265
269
  * The page is generated from the `fields` each `itemBuilders` entry declares,
266
270
  * so every consuming repository documents *its own* registry with the same
267
- * command (#22).
271
+ * command.
268
272
  *
269
273
  * **The framing comes from configuration**, because the tables are the only
270
274
  * part that is the same everywhere. A repository's `docs.itemFields` says what
@@ -288,10 +292,10 @@ function docsCommand() {
288
292
  command: "docs <action>",
289
293
  describe: "Generate documentation from the configured registries",
290
294
  builder: (yargs) => {
291
- // Required and honoured. It used to be optional and never read:
295
+ // Required and honoured, not optional and unread:
292
296
  // the handler rendered the item-field reference whatever it was
293
297
  // given, so the positional constrained what could be typed and
294
- // selected nothing (#57).
298
+ // selected nothing.
295
299
  yargs.positional("action", {
296
300
  describe: "The document to render.",
297
301
  type: "string",
@@ -348,13 +352,19 @@ function docsCommand() {
348
352
  if (current !== page) {
349
353
  // Staleness belongs to the whole file, so no line is
350
354
  // named — the diagnostics contract drops a field it
351
- // cannot supply rather than guessing one.
352
- log.error(
353
- `${relative}: error: out of date with the ` +
354
- `item-field declarationsrun ` +
355
- `\`content-build docs item-fields\` and commit ` +
356
- `the regenerated file`,
357
- );
355
+ // cannot supply rather than guessing one. Routed
356
+ // through `emitDiagnostic`, not `log.error`, so the
357
+ // path starts the line unprefixed by loglevel's
358
+ // `[timestamp] [ERROR]:` banner the same reason
359
+ // every other located failure in this file uses it.
360
+ emitDiagnostic({
361
+ file: relative,
362
+ severity: "error",
363
+ message:
364
+ "out of date with the item-field declarations " +
365
+ "— run `content-build docs item-fields` and " +
366
+ "commit the regenerated file",
367
+ });
358
368
  process.exitCode = 1;
359
369
  return;
360
370
  }
@@ -380,7 +390,7 @@ function docsCommand() {
380
390
  }
381
391
 
382
392
  /**
383
- * `content-build content-format` — check the format specification itself (#130).
393
+ * `content-build content-format` — check the format specification itself.
384
394
  *
385
395
  * Two checks, because the specification makes claims about two different
386
396
  * worlds, and they fail for different reasons and at different times:
@@ -389,10 +399,9 @@ function docsCommand() {
389
399
  * naming system's published `schema.json`. A failure means the specification
390
400
  * and the system disagree, which is a defect in one of the two.
391
401
  * - `fields` compares the per-type tables against the field declarations that
392
- * compile them, so the hand-written half cannot drift from the generated one
393
- * (#136).
402
+ * compile them, so the hand-written half cannot drift from the generated one.
394
403
  * - `notes` measures a content tree against the vocabulary the document
395
- * declares per type. During #127 it is the migration's progress bar rather
404
+ * declares per type. During the migration it is the progress bar rather
396
405
  * than a gate, so it **reports** by default and `--strict` makes it fatal —
397
406
  * turned on one class at a time as each slice lands.
398
407
  *
@@ -535,7 +544,7 @@ function declarationsFrom(argv) {
535
544
  * The specification hand-writes a `data` table under most of its type sections,
536
545
  * covering ground {@link module:engine/field-reference} already generates from
537
546
  * the `fields` on each `itemBuilders` entry — the duplication that module exists
538
- * to prevent, one document over (#136).
547
+ * to prevent, one document over.
539
548
  *
540
549
  * **Checked, not merged.** The document's vocabulary spans note types that
541
550
  * produce Scenes, Macros and JournalEntries, which no item registry covers, so
@@ -566,7 +575,7 @@ function contentFormatFieldsCommand() {
566
575
  });
567
576
  yargs.option("coverage", {
568
577
  describe:
569
- "List, per type, the fields only one side names. They are not findings — the two vocabularies differ by design until #127 lands.",
578
+ "List, per type, the fields only one side names. They are not findings — the two vocabularies differ by design until the corpus moves.",
570
579
  type: "boolean",
571
580
  default: false,
572
581
  });
@@ -627,8 +636,8 @@ function contentFormatFieldsCommand() {
627
636
  *
628
637
  * **A report, not a gate.** Every authored note predates the format, so a
629
638
  * failing check would be red in every repository from the day it lands and
630
- * would stay red for the length of #127 — which is a check nobody can act on.
631
- * `--strict` raises the findings to errors, and #127 turns it on slice by
639
+ * would stay red for the length of the migration — which is a check nobody
640
+ * can act on. `--strict` raises the findings to errors, turned on slice by
632
641
  * slice as each class of finding reaches zero.
633
642
  *
634
643
  * @returns {object} The yargs command module.
@@ -649,7 +658,7 @@ function contentFormatNotesCommand() {
649
658
  });
650
659
  yargs.option("strict", {
651
660
  describe:
652
- "Fail on the findings instead of reporting them. Turned on per slice of #127, as each class reaches zero.",
661
+ "Fail on the findings instead of reporting them. Turned on per slice, as each class reaches zero.",
653
662
  type: "boolean",
654
663
  default: false,
655
664
  });
@@ -660,7 +669,7 @@ function contentFormatNotesCommand() {
660
669
  const root = argv.root ?? config.paths.content;
661
670
  const format = specFrom(argv);
662
671
 
663
- // The corpus from the index, like every other check (#243).
672
+ // The corpus from the index, like every other check.
664
673
  // A report measuring the tree against the declared vocabulary
665
674
  // has to be looking at the same tree the compile will, or its
666
675
  // counts describe a corpus nobody builds.
@@ -745,14 +754,14 @@ function lintCommand() {
745
754
  const root = argv.root ?? config.paths.content;
746
755
 
747
756
  // The corpus, enumerated once for this command and handed to
748
- // every pass below, rather than derived again by each (#243).
757
+ // every pass below, rather than derived again by each.
749
758
  // The address lint reads it, the `sql` tables select over it
750
759
  // and the link index is built from it, so no two findings this
751
760
  // command reports can be drawn from different ideas of which
752
761
  // files the content is.
753
762
  //
754
763
  // A note the index cannot record is reported like any other
755
- // finding rather than thrown (#243): one malformed note must
764
+ // finding rather than thrown: one malformed note must
756
765
  // not take every other finding in the tree with it, and the
757
766
  // reader needs a line to open, not a stack.
758
767
  const corpusProblems = [];
@@ -768,7 +777,7 @@ function lintCommand() {
768
777
  // would call the tree clean while silently omitting a note.
769
778
  if (corpusProblems.length) process.exitCode = 1;
770
779
 
771
- // The package is passed for the homepage rule (#52), which
780
+ // The package is passed for the homepage rule, which
772
781
  // names the address a tree with no front page fails to serve.
773
782
  const addresses = lintContentTree(root, {
774
783
  contentPackage: config.contentPackage,
@@ -790,7 +799,7 @@ function lintCommand() {
790
799
  }),
791
800
  });
792
801
  // Which system blocks this tree carries, derived from what it
793
- // declares it ships for (#58). Read before the lint so the
802
+ // declares it ships for. Read before the lint so the
794
803
  // systems it will *not* check can be said out loud below.
795
804
  // `schemaSystem` names whose vocabulary the `schemas` below
796
805
  // are. They are `sohl/note-schemas.mjs`, imported here
@@ -823,18 +832,18 @@ function lintCommand() {
823
832
  }
824
833
  const frontmatter = lintFrontmatter(index, {
825
834
  schemas: { ...ENGINE_NOTE_SCHEMAS, ...NOTE_SCHEMAS },
826
- // The closed frontmatter regions (#128). Passed in rather
835
+ // The closed frontmatter regions. Passed in rather
827
836
  // than reached for, so the linter stays a checker of
828
837
  // whatever it is handed and this stays the one place that
829
838
  // decides which vocabulary a tree is held to.
830
839
  vocabulary: NOTE_VOCABULARY,
831
840
  // The pack names, for a `data:` field keyed by pack — a
832
- // folder's `parent` is one (#288). Companions included:
841
+ // folder's `parent` is one. Companions included:
833
842
  // the compile asks the map for whichever pack it is
834
843
  // writing, and a companion is a pack it writes.
835
844
  packs: config.packDirectories,
836
845
  // What art each type actually reaches its document through,
837
- // asked of the passes rather than listed here (#349). This
846
+ // asked of the passes rather than listed here. This
838
847
  // is the one place that decides what a tree is held to, so
839
848
  // it is where the derivation is handed over — the linter
840
849
  // states no list of iconless types of its own.
@@ -850,7 +859,7 @@ function lintCommand() {
850
859
  });
851
860
 
852
861
  // What the builders emit, against what the receiving system
853
- // declares (#60). Reported here rather than at compile: it is
862
+ // declares. Reported here rather than at compile: it is
854
863
  // a property of the *declarations*, not of any one note, so it
855
864
  // is the same answer for every document and belongs where a
856
865
  // reader is already being told about the vocabulary.
@@ -900,7 +909,7 @@ function lintCommand() {
900
909
  artifact: schema.artifact,
901
910
  // A schema is keyed by document subtype and a field
902
911
  // declaration by note type. Those were one string until
903
- // #78 renamed three of them, and joining them by name
912
+ // Three are renamed, and joining them by name
904
913
  // after that would drop `armorgear`'s findings without
905
914
  // saying so — the seam exists for exactly this.
906
915
  subtypeOf: (type) => schemaSubtypeOf(config.stats?.systemId, type),
@@ -924,25 +933,33 @@ function lintCommand() {
924
933
  }
925
934
  }
926
935
 
927
- // The charset the tree is held to, so a book can pick its face
928
- // (#377). Run over the raw files rather than the parsed notes:
936
+ // The charset the tree is held to, so a book can pick its face.
937
+ // Run over the raw files rather than the parsed notes:
929
938
  // the subject is every character authored, including the ones
930
939
  // that stopped a note parsing at all.
931
940
  const charset = lintContentCharset(root, {
932
941
  skipDirectories: config.skipDirectories,
933
942
  });
934
943
 
935
- // The names the charset check leaves room for (#378). An icon
944
+ // The names the charset check leaves room for. An icon
936
945
  // the registry does not declare renders as its own literal
937
946
  // text, which is visible but easy to publish, so it is reported
938
947
  // here rather than left for a reader to notice.
939
948
  //
940
- // The shipped registry, with no per-package override yet: an
941
- // `icons:` configuration key is a change to the configuration
942
- // contract and belongs with its own validation, rather than
943
- // being read here before anything declares it.
949
+ // The configured registry the shipped table with this
950
+ // package's own entries merged over it, which is where a
951
+ // Game-Icons glyph becomes a name a note may write.
944
952
  const icons = lintContentIcons(root, {
945
953
  skipDirectories: config.skipDirectories,
954
+ registry: config.icons,
955
+ });
956
+
957
+ // The markup the charset check cannot see. Class names are
958
+ // ASCII, so a page of `<i class="fa-…">` is allowlist-clean and
959
+ // still unrenderable in a book — the character rule one level
960
+ // up.
961
+ const html = lintContentHtml(root, {
962
+ skipDirectories: config.skipDirectories,
946
963
  });
947
964
 
948
965
  const findings = [
@@ -951,9 +968,10 @@ function lintCommand() {
951
968
  ...schemaFindings,
952
969
  ...charset.findings,
953
970
  ...icons.findings,
971
+ ...html.findings,
954
972
  ];
955
973
  // Only an **error** fails the run. Every finding was an error
956
- // until #142, so this changed nothing on the day it landed
974
+ // by then, so this changes nothing on its own
957
975
  // but a field retired in favour of another is reported while
958
976
  // both spellings still compile, and failing a build over a note
959
977
  // that produces the correct document would red a tree that has
@@ -1002,7 +1020,7 @@ function lintCommand() {
1002
1020
  * reports, as warnings, each shared convention the repository's own
1003
1021
  * configuration resolves differently — or that it declares no configuration at
1004
1022
  * all, which guarantees an editor and a bare `npx prettier` disagree with this
1005
- * command about the same tree (#133). Nothing there fails the run: the point is
1023
+ * command about the same tree. Nothing there fails the run: the point is
1006
1024
  * that a local choice is deliberate rather than silent.
1007
1025
  *
1008
1026
  * @returns {object} The yargs command module.
@@ -1040,7 +1058,7 @@ function formatCommand() {
1040
1058
  const root = process.cwd();
1041
1059
  // Before the per-file report, because it is the context for it:
1042
1060
  // which rules this run applied, and whether anything else in
1043
- // the repository applies the same ones (#133). Warnings only —
1061
+ // the repository applies the same ones. Warnings only —
1044
1062
  // a consumer's config wins by design, so none of this touches
1045
1063
  // the exit code.
1046
1064
  const conventions = await checkPrettierConventions(root);
@@ -1065,7 +1083,7 @@ function formatCommand() {
1065
1083
  // `--write` collects findings too — a file Prettier cannot
1066
1084
  // parse, or one that will not format to a fixpoint — and
1067
1085
  // used to discard them, so a run that had left files
1068
- // unformatted still reported success and exited 0 (#125).
1086
+ // unformatted still reported success and exited 0.
1069
1087
  for (const finding of findings) emitDiagnostic(finding);
1070
1088
  if (findings.length) {
1071
1089
  log.error(
@@ -1166,7 +1184,7 @@ function linksCommand() {
1166
1184
  const contentBase = argv.root ?? config.paths.content;
1167
1185
 
1168
1186
  const scope = { skipDirectories: config.skipDirectories };
1169
- // Enumerated once and shared, as in `lint` (#243), and a note
1187
+ // Enumerated once and shared, as in `lint`, and a note
1170
1188
  // it cannot record is reported rather than thrown.
1171
1189
  const corpusProblems = [];
1172
1190
  const records = indexRecordsFor({
@@ -1238,8 +1256,8 @@ function linksCommand() {
1238
1256
  `heading in ${d.dest.rel} declares`,
1239
1257
  });
1240
1258
  }
1241
- // Every link is an address (#180) and every address must
1242
- // resolve (#184), so all of these are errors — but they read
1259
+ // Every link is an address and every address must
1260
+ // resolve, so all of these are errors — but they read
1243
1261
  // differently because the corrections differ. The wording comes
1244
1262
  // from the shared table, so the checker cannot describe a
1245
1263
  // defect differently from the build that also refuses it.
@@ -1265,7 +1283,7 @@ function linksCommand() {
1265
1283
  // The package homepage. Its addresses are markdown links and
1266
1284
  // `landing:` url/href fields rather than wikilinks — it is
1267
1285
  // published verbatim, so nothing resolves a wikilink on it —
1268
- // and until #54 nothing looked at them at all.
1286
+ // and nothing else looks at them.
1269
1287
  for (const h of homepageLinks) {
1270
1288
  emitDiagnostic({
1271
1289
  file: h.note.file,
@@ -1307,7 +1325,7 @@ function linksCommand() {
1307
1325
  *
1308
1326
  * Every build already walks the tree and parses every note's frontmatter, then
1309
1327
  * throws the result away, so nothing outside a build can ask a question about
1310
- * the content (#224). This publishes that walk as JSON Lines: one record per
1328
+ * the content. This publishes that walk as JSON Lines: one record per
1311
1329
  * note, carrying the whole frontmatter plus the note's place in the tree.
1312
1330
  *
1313
1331
  * It is a command of its own rather than only a build step because the point of
@@ -1354,11 +1372,92 @@ function contentIndexCommand() {
1354
1372
  };
1355
1373
  }
1356
1374
 
1375
+ /**
1376
+ * `content-build pdf` — build the book the content tree publishes as.
1377
+ *
1378
+ * The third surface, beside `package compile` and `site`. It takes the same
1379
+ * `--out` override the site command does, and reports what it found in the same
1380
+ * `file:line:column: severity: message` shape every other pass here uses — so
1381
+ * one editor, one CI annotator and one `grep` read all three.
1382
+ *
1383
+ * **Not building is a normal outcome, and it exits 0.** A package publishing
1384
+ * only a homepage, a package with no `pdf:` block and a package with no content
1385
+ * tree have each said they publish no book; saying so on stderr and failing the
1386
+ * command would break the release of every package that is not a book.
1387
+ *
1388
+ * @returns {object} The yargs command module.
1389
+ */
1390
+ function pdfCommand() {
1391
+ return {
1392
+ command: "pdf",
1393
+ describe: "Build the content tree as a PDF",
1394
+ builder: (yargs) => {
1395
+ yargs
1396
+ .option("out", {
1397
+ describe: "Write the book here instead of the configured `pdf.out`.",
1398
+ type: "string",
1399
+ })
1400
+ .option("book-version", {
1401
+ describe: "Stamp this version on the title page and in the file name.",
1402
+ type: "string",
1403
+ })
1404
+ // Declared positively so yargs derives `--no-compile` from it;
1405
+ // an option literally named `no-compile` is rejected as unknown
1406
+ // when it is actually passed.
1407
+ .option("compile", {
1408
+ describe: "Run the Typst compiler. `--no-compile` emits the source and stops.",
1409
+ type: "boolean",
1410
+ default: true,
1411
+ });
1412
+ },
1413
+ handler: async (argv) => {
1414
+ try {
1415
+ const result = await buildPdf({
1416
+ ...(argv.out ? { out: argv.out } : {}),
1417
+ ...(argv.bookVersion ? { version: String(argv.bookVersion) } : {}),
1418
+ compile: argv.compile !== false,
1419
+ });
1420
+
1421
+ for (const finding of result.findings) emitDiagnostic(finding);
1422
+
1423
+ if (!result.built) {
1424
+ // A reason is a deliberate no-op — the fence, an absent
1425
+ // block, an absent tree. No reason means the findings above
1426
+ // say what went wrong, and those decide the exit code.
1427
+ if (result.reason) {
1428
+ log.info(`No book built: ${result.reason}`);
1429
+ return;
1430
+ }
1431
+ process.exitCode = 1;
1432
+ return;
1433
+ }
1434
+
1435
+ const { stats } = result;
1436
+ log.info(
1437
+ `${stats.notes} entr(ies) and ${stats.prose} prose file(s) across ` +
1438
+ `${stats.sections} section(s)` +
1439
+ (stats.repeated ?
1440
+ `, ${stats.repeated} of them printed more than once`
1441
+ : ""),
1442
+ );
1443
+ log.info(`Typst source: ${result.typ}`);
1444
+ if (result.pdf) log.info(`Book: ${result.pdf}`);
1445
+ // Findings are reported, never fatal — a filter that selected
1446
+ // nothing is worth fixing and is not worth refusing to publish
1447
+ // the other two thousand entries over.
1448
+ } catch (err) {
1449
+ reportFailure(err);
1450
+ process.exitCode = 1;
1451
+ }
1452
+ },
1453
+ };
1454
+ }
1455
+
1357
1456
  /**
1358
1457
  * `content-build site` — publish the content tree as a website.
1359
1458
  *
1360
1459
  * The sibling of `package compile`: the same tree, rendered as pages instead of
1361
- * compiled into packs (#63). Everything a consumer used to write for itself —
1460
+ * compiled into packs. Everything a consumer would otherwise write for itself —
1362
1461
  * the walk, the address derivation, the address index, table expansion,
1363
1462
  * wikilink resolution, code-fence protection, the foreign-manifest merge and
1364
1463
  * the section-landing backfill — happens here, from configuration.
@@ -1394,7 +1493,7 @@ function siteCommand() {
1394
1493
  // First, because it is decided before the tree is walked and
1395
1494
  // before the output is cleared: a package with no front page,
1396
1495
  // or two competing for it, has nothing to say about its pages
1397
- // yet (#52).
1496
+ // yet.
1398
1497
  for (const f of gates.homepages) emitDiagnostic(f);
1399
1498
  for (const f of gates.frontmatterLinks) {
1400
1499
  emitDiagnostic({
@@ -1440,7 +1539,7 @@ function siteCommand() {
1440
1539
  // was prose with a timestamp where a parser reads the path,
1441
1540
  // which made one authored table produce a machine-readable
1442
1541
  // diagnostic from one build and something ungreppable from the
1443
- // other (#223).
1542
+ // other.
1444
1543
  for (const e of result.tableErrors) {
1445
1544
  emitDiagnostic({
1446
1545
  file: e.source,
@@ -1452,7 +1551,7 @@ function siteCommand() {
1452
1551
  }
1453
1552
  // Reported the way the pack build reports the very same
1454
1553
  // finding: `file:line:column: error: message`, path first, and
1455
- // the message from the shared table (#184). It used to be a
1554
+ // the message from the shared table, not a
1456
1555
  // `log.error` whose timestamp prefix sat where a parser reads
1457
1556
  // the path from, and whose text named a `reason` code rather
1458
1557
  // than saying what to do — so one authored link produced a
@@ -1531,7 +1630,7 @@ function reachabilityCommand() {
1531
1630
  try {
1532
1631
  // Resolved once and passed on, so every pass below runs
1533
1632
  // against the configuration this command resolved rather than
1534
- // whichever one the working directory answers with (#243).
1633
+ // whichever one the working directory answers with.
1535
1634
  const config = loadPackConfig();
1536
1635
  const contentBase = argv.root ?? config.paths.content;
1537
1636
  const dir = String(argv.dir).replace(/\/+$/, "");
@@ -1602,30 +1701,6 @@ function reachabilityCommand() {
1602
1701
  }
1603
1702
 
1604
1703
  // eslint-disable-next-line
1605
- /**
1606
- * `deps fetch` — fill the caches this build resolves other packages through:
1607
- * the **content index** of every declared dependency (#239), and the **item
1608
- * catalogue** of those additionally declaring `itemCatalog: true`.
1609
- *
1610
- * The two sets differ deliberately. Citing another package's *addresses* and
1611
- * embedding its *items* are separate edges, and a package may have either
1612
- * without the other — `harn-ensemble` cites no foreign address and embeds
1613
- * 324,016 item references.
1614
- *
1615
- * Its own command rather than a step of `package compile`, so that a compile
1616
- * never reaches the network. A build that downloads silently is not
1617
- * reproducible, breaks offline, and hides a dependency's version change behind
1618
- * a passing run.
1619
- *
1620
- * `--from` fills the cache from a locally built artifact instead of a release,
1621
- * which is what makes iterating across packages possible: change the system,
1622
- * build it, and see the effect on every consumer **before** any of it ships.
1623
- * Otherwise testing a dependency change against its consumers costs a release
1624
- * round-trip, which makes releasing a debugging tool rather than a publishing
1625
- * decision.
1626
- *
1627
- * @returns {object} The yargs command module.
1628
- */
1629
1704
  /**
1630
1705
  * Resolve which declared dependency `--from` supplies, and cache it.
1631
1706
  *
@@ -1659,12 +1734,36 @@ async function fetchFromLocalArtifact(config, argv) {
1659
1734
  }
1660
1735
  }
1661
1736
 
1737
+ /**
1738
+ * `deps fetch` — fill the caches this build resolves other packages through:
1739
+ * the **content index** of every declared dependency, and the **item
1740
+ * catalogue** of those additionally declaring `itemCatalog: true`.
1741
+ *
1742
+ * The two sets differ deliberately. Citing another package's *addresses* and
1743
+ * embedding its *items* are separate edges, and a package may have either
1744
+ * without the other — `harn-ensemble` cites no foreign address and embeds
1745
+ * 324,016 item references.
1746
+ *
1747
+ * Its own command rather than a step of `package compile`, so that a compile
1748
+ * never reaches the network. A build that downloads silently is not
1749
+ * reproducible, breaks offline, and hides a dependency's version change behind
1750
+ * a passing run.
1751
+ *
1752
+ * `--from` fills the cache from a locally built artifact instead of a release,
1753
+ * which is what makes iterating across packages possible: change the system,
1754
+ * build it, and see the effect on every consumer **before** any of it ships.
1755
+ * Otherwise testing a dependency change against its consumers costs a release
1756
+ * round-trip, which makes releasing a debugging tool rather than a publishing
1757
+ * decision.
1758
+ *
1759
+ * @returns {object} The yargs command module.
1760
+ */
1662
1761
  function depsCommand() {
1663
1762
  return {
1664
1763
  command: "deps <action>",
1665
1764
  describe: "Manage build-time dependencies on other packages",
1666
1765
  builder: (yargs) => {
1667
- // Required, for the reason `package <action>` is (#57): an optional
1766
+ // Required, for the reason `package <action>` is: an optional
1668
1767
  // action exits 0 having done nothing.
1669
1768
  yargs.positional("action", {
1670
1769
  describe: "The action to perform.",
@@ -1761,7 +1860,7 @@ async function diffAddresses(config, argv) {
1761
1860
  );
1762
1861
  }
1763
1862
 
1764
- // Stated by the caller, like every other corpus read in this file (#243):
1863
+ // Stated by the caller, like every other corpus read in this file:
1765
1864
  // the two tree reads below must agree with each other and with the compile
1766
1865
  // about which files are the corpus. They now do so by construction — the
1767
1866
  // corpus is derived once, here, and handed to both.
@@ -1823,7 +1922,7 @@ function addressesCommand() {
1823
1922
  command: "addresses <action>",
1824
1923
  describe: "Compare the addresses this build publishes against a release's",
1825
1924
  builder: (yargs) => {
1826
- // Required, for the reason every other action is (#57): an
1925
+ // Required, for the reason every other action is: an
1827
1926
  // optional one exits 0 having compared nothing.
1828
1927
  yargs.positional("action", {
1829
1928
  describe: "The action to perform.",
@@ -1875,7 +1974,7 @@ function packageCommand() {
1875
1974
  builder: (yargs) => {
1876
1975
  // Required, not optional: the action *is* the work, and an
1877
1976
  // optional one meant `content-build package` fell through the
1878
- // switch below and exited 0 having compiled nothing (#57).
1977
+ // switch below and exited 0 having compiled nothing.
1879
1978
  yargs.positional("action", {
1880
1979
  describe: "The action to perform.",
1881
1980
  type: "string",
@@ -1897,17 +1996,30 @@ function packageCommand() {
1897
1996
  // an unhandled-rejection stack trace. Report the message and set a
1898
1997
  // failing exit code, so a build guard reads as a build failure.
1899
1998
  try {
1999
+ const config = loadPackConfig();
2000
+ // The compile passes do not run for a package that declares no
2001
+ // packs by rule. Refused rather than exiting 0 having compiled
2002
+ // nothing: a build that succeeds and produces no documents is
2003
+ // the quiet failure this toolchain reports everywhere else.
2004
+ if (!compilesFoundryDocuments(config)) {
2005
+ throw new Error(
2006
+ `\`packageKind: ${config.packageKind}\` compiles no ` +
2007
+ `compendium, so there is nothing to ${action}. Build ` +
2008
+ `its site with \`content-build site\` and its book ` +
2009
+ `with \`content-build pdf\`.`,
2010
+ );
2011
+ }
1900
2012
  // The one directory the pipeline creates rather than expects:
1901
2013
  // `unpack` writes the extracted JSON there and `compile` reads
1902
2014
  // it back. Created here rather than at module scope so that
1903
2015
  // asking the CLI its version needs no configuration (#2).
1904
- fs.mkdirSync(loadPackConfig().paths.unpack, {
2016
+ fs.mkdirSync(config.paths.unpack, {
1905
2017
  recursive: true,
1906
2018
  });
1907
2019
  switch (action) {
1908
2020
  // Every path and pack list the library needs is defaulted
1909
2021
  // from the resolved configuration, so nothing is restated
1910
- // here (#1508).
2022
+ // here.
1911
2023
  case "compile":
1912
2024
  return await compilePacks({ packName: pack });
1913
2025
  case "clean":