@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
@@ -22,7 +22,6 @@
22
22
  * packageKind: systems
23
23
  * compatibility: { minimum: "14.359", verified: "14.364" }
24
24
  * stats:
25
- * systemId: sohl
26
25
  * lastModifiedBy: sohlbuilder00000
27
26
  * itemBuilders: sohl
28
27
  * skipDirectories: [Templates]
@@ -42,44 +41,105 @@
42
41
  * a consumer's config is data, and the compilers read it.
43
42
  *
44
43
  * **This module validates; it does not load.** `engine/pack-config.mjs` is what
45
- * finds a repository's configuration and reads it, and it is where the three
44
+ * finds a repository's configuration and reads it, and it is where the four
46
45
  * fields absent from the YAML above are derived: `rootDir` (the directory the
47
- * file sits in), `stats.systemVersion` (the adjacent `package.json`), and the
48
- * `itemBuilders` table the name `sohl` stands for. All three are I/O or code,
49
- * and this module is deliberately neither — which is also why a consumer whose
50
- * item-builder registry is its own writes `package-build.config.mjs`, calling
51
- * `defineConfig` below directly with a `rootDir` of `import.meta.dirname`.
52
- * Both forms end here, so both are validated and frozen identically.
46
+ * file sits in), `foundryPackage` and `stats.systemVersion` (the adjacent
47
+ * `package.json`), and the `itemBuilders` table the name `sohl` stands for. All
48
+ * four are I/O or code, and this module is deliberately neither — which is also
49
+ * why a consumer whose item-builder registry is its own writes
50
+ * `package-build.config.mjs`, calling `defineConfig` below directly with a
51
+ * `rootDir` of `import.meta.dirname`. Both forms end here, so both are
52
+ * validated and frozen identically.
53
53
  *
54
54
  * **`rootDir` anchors every path**, so the build reads the same files whatever
55
- * directory it was launched from (#1508).
55
+ * directory it was launched from.
56
56
  *
57
57
  * The Foundry floor is declared here as top-level `compatibility`, and the
58
58
  * shipped manifest is generated *from* this file. That reverses an older rule —
59
59
  * configuration named where the manifest was and read the floor back out of it —
60
60
  * which was right while the manifest was hand-authored and became a round trip
61
- * through a generated artifact once it was not (#50, package-build#9).
61
+ * through a generated artifact once it was not.
62
62
  *
63
63
  * @module
64
64
  */
65
65
 
66
+ import fs from "node:fs";
66
67
  import path from "node:path";
68
+ import YAML from "yaml";
67
69
 
68
70
  // Leaves with no local imports of their own, so naming them here cannot close
69
71
  // a cycle around a consumer's config file (see `engine/pack-config.mjs`).
70
72
  import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./engine/address-charset.mjs";
73
+ import { EMPTY_ICON_REGISTRY, checkIconRegistry } from "./engine/content-icons.mjs";
71
74
  import { MAP_TYPES, PACK_BY_TYPE } from "./engine/ids.mjs";
72
75
  import { ACTOR_TYPES } from "./engine/subtype-registry.mjs";
73
76
  import { NOTE_VOCABULARY } from "./engine/note-vocabulary.mjs";
74
77
 
75
78
  /**
76
- * The two kinds of Foundry package a content module can be built into. The
77
- * value is also the directory Foundry installs the package under, which is why
78
- * it is plural.
79
+ * What kind of package this is.
80
+ *
81
+ * `systems` and `modules` are the two Foundry answers, and the value is also
82
+ * the directory Foundry installs the package under, which is why they are
83
+ * plural. `documentation` is the answer "not a Foundry package at all": it
84
+ * publishes a site and a book from its notes, installs into no Foundry data
85
+ * directory and compiles no compendium.
79
86
  *
80
87
  * @satisfies {readonly PackageKind[]}
81
88
  */
82
- export const PACKAGE_KINDS = /** @type {const} */ (["systems", "modules"]);
89
+ export const PACKAGE_KINDS = /** @type {const} */ (["systems", "modules", "documentation"]);
90
+
91
+ /**
92
+ * The kind that compiles no Foundry documents.
93
+ *
94
+ * Spelled once and read wherever a pass asks whether it applies, so the
95
+ * validator, the CLI and the compile passes cannot come to disagree about what
96
+ * the value means.
97
+ *
98
+ * @type {string}
99
+ */
100
+ export const DOCUMENTATION_KIND = "documentation";
101
+
102
+ /**
103
+ * Whether this package compiles Foundry documents at all.
104
+ *
105
+ * The one question every Foundry-side reader asks — the manifest writer, to
106
+ * decide whether there is a package for Foundry to install, and the pack
107
+ * compilers, to decide whether there is anything to compile.
108
+ *
109
+ * @param {{packageKind: string}} config - A resolved configuration.
110
+ * @returns {boolean} Whether the package compiles Foundry documents.
111
+ */
112
+ export function compilesFoundryDocuments(config) {
113
+ return config.packageKind !== DOCUMENTATION_KIND;
114
+ }
115
+
116
+ /**
117
+ * Every key a documentation package may not declare, and why.
118
+ *
119
+ * The value of the kind is as much in what it refuses as in what it accepts. A
120
+ * key here cannot mean anything in a package that compiles nothing and installs
121
+ * nowhere, so it fails at load naming the key — the loader resolves that name
122
+ * to a line and a column — rather than being read and ignored, which is the
123
+ * failure this contract exists to prevent.
124
+ *
125
+ * `foundryPackage` is on the list for the same reason as the rest, and is the
126
+ * one the YAML loader would otherwise supply: it derives the id from the
127
+ * adjacent `package.json`, and there is no Foundry package here to carry one.
128
+ *
129
+ * @type {Readonly<Record<string, string>>}
130
+ */
131
+ const DOCUMENTATION_REFUSES = Object.freeze({
132
+ packs: "compiles no compendium, so there are no packs to declare",
133
+ itemBuilders: "compiles no items, so there is no item-type registry to name",
134
+ docs: "compiles no items, so there are no item-field reference pages to frame",
135
+ compatibility:
136
+ "installs into no Foundry data directory, so there is no Foundry core range to support",
137
+ relationships: "is not a Foundry package, so it stands in no relationship to one",
138
+ systems: "compiles no documents, so it ships content for no game system",
139
+ requiresSystem: "compiles no documents, so there is no game system to gate its packs on",
140
+ stats: "compiles no documents, so there is no `_stats` block to stamp",
141
+ foundryPackage: "is not a Foundry package, so it has no Foundry package id",
142
+ });
83
143
 
84
144
  /**
85
145
  * The directories the build reads from and writes to, relative to `rootDir`,
@@ -92,7 +152,7 @@ export const DEFAULT_PATHS = /** @type {const} */ ({
92
152
  // Where `content-index` writes this package's note index. Under `build/`
93
153
  // because it is derived and disposable — regenerating it costs a
94
154
  // frontmatter parse — and emphatically not under `stage`, which is mirrored
95
- // into a Foundry data root (#224).
155
+ // into a Foundry data root.
96
156
  contentIndex: "build/content-index",
97
157
  packJson: "build/packs-json",
98
158
  stage: "build/stage/packs",
@@ -100,7 +160,7 @@ export const DEFAULT_PATHS = /** @type {const} */ ({
100
160
  // Where a dependency declaring `itemCatalog: true` is unpacked. Under
101
161
  // `build/` because it is derived, disposable, and version-keyed.
102
162
  foreignCache: "build/cache/foreign",
103
- // Where a dependency's published content index is fetched to (#239). A
163
+ // Where a dependency's published content index is fetched to. A
104
164
  // sibling of the item catalogue rather than a subdirectory of it: the two
105
165
  // are fetched for different dependency sets — a catalogue only where
106
166
  // `itemCatalog: true` is declared, an index for *every* declared
@@ -112,7 +172,7 @@ export const DEFAULT_PATHS = /** @type {const} */ ({
112
172
  /**
113
173
  * The Foundry document types a compendium pack may hold. This is the set the
114
174
  * toolchain is able to compile a pack of; a document type Foundry supports but
115
- * this toolchain does not compile is deliberately absent (see #1501 — playlists
175
+ * this toolchain does not compile is deliberately absent (playlists
116
176
  * and roll tables are out of scope).
117
177
  *
118
178
  * @satisfies {readonly PackDocumentType[]}
@@ -139,15 +199,11 @@ export const PACK_DOCUMENT_TYPES = /** @type {const} */ ([
139
199
  * key is **refused**, at the line it was written on, with a message that says
140
200
  * the mechanism is gone rather than naming a value to correct.
141
201
  *
142
- * **What `landing` did (#204).** It named which note addressed a whole section
202
+ * **`landing` is one such key.** It named which note addressed a whole section
143
203
  * rather than a page within one — a *landing page*, which therefore had no slug
144
- * of its own. #203 retired the second of its two rules and #204 retired the
145
- * concept both rules chose between: a section is a Hugo content directory that
146
- * the note format does not carry, a page's address names no directory, and so
147
- * no note lands anything. The key outlived its mechanism by one release only
148
- * because both publishing consumers still declared the then-true
149
- * `landing: readme`, and neither breaking them over a correct statement nor
150
- * accepting the key in silence was acceptable. Neither declares it now.
204
+ * of its own. There are no sections to address: a section is a Hugo content
205
+ * directory the note format does not carry, a page's address names no
206
+ * directory, and so no note lands anything.
151
207
  *
152
208
  * @type {Readonly<Record<string, string>>}
153
209
  */
@@ -168,7 +224,7 @@ export const RETIRED_ADDRESS_KEYS = Object.freeze({
168
224
  * `thalorna`, whose site is nothing but its content. It is not the package's
169
225
  * own mount point: where the package itself is served is the consuming build's
170
226
  * knowledge, held in `PACKAGE_BASE` (`engine/content-address.mjs`) and prefixed at
171
- * resolve time, so it is never recorded here (#1465).
227
+ * resolve time, so it is never recorded here.
172
228
  *
173
229
  * It is the whole scheme: `landing`, the key that named which note addressed a
174
230
  * whole section, is retired with the sections themselves — see
@@ -183,7 +239,7 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
183
239
  *
184
240
  * Every HeroicLands package publishes something: a top-level, human-authored
185
241
  * homepage at `https://www.heroiclands.org/<contentPackage>/` saying what the
186
- * module is, which system it needs and how to install it (#50). So there is no
242
+ * module is, which system it needs and how to install it. So there is no
187
243
  * value here meaning *no web presence at all* — homepage-only is the **floor**,
188
244
  * and the default.
189
245
  *
@@ -235,7 +291,7 @@ export function publishesContentPages(config) {
235
291
  }
236
292
 
237
293
  /**
238
- * @typedef {"systems" | "modules"} PackageKind
294
+ * @typedef {"systems" | "modules" | "documentation"} PackageKind
239
295
  */
240
296
 
241
297
  /**
@@ -251,7 +307,7 @@ export function publishesContentPages(config) {
251
307
  * type** receives its document. The two are orthogonal, and both are needed
252
308
  * once a repository groups same-type documents editorially — which it may have
253
309
  * to, since a compendium UUID carries its pack name and collapsing such a
254
- * layout breaks every stored reference (#1566).
310
+ * layout breaks every stored reference.
255
311
  *
256
312
  * @typedef {object} PackSpec
257
313
  * @property {string} name Pack name — the manifest `name`, and the
@@ -317,7 +373,7 @@ export function publishesContentPages(config) {
317
373
  * Inbound, and fetched rather than
318
374
  * committed.
319
375
  * @property {string} [metadataCache] Where a dependency's published content
320
- * index is fetched to (#239). Inbound,
376
+ * index is fetched to. Inbound,
321
377
  * for *every* declared dependency, not
322
378
  * only those supplying a catalogue.
323
379
  */
@@ -473,13 +529,13 @@ export function publishesContentPages(config) {
473
529
  * `fields` is what makes the type documentable: a builder function says
474
530
  * nothing about the vocabulary it consumes, so a consumer that declares its
475
531
  * fields can generate its own authoring reference and check its own notes,
476
- * while one that does not is simply undocumented rather than broken (#22).
532
+ * while one that does not is simply undocumented rather than broken.
477
533
  *
478
534
  * @typedef {((fm: object) => object)|{system: (fm: object) => object, img?: string, fields?: readonly object[]}} ItemBuilderEntry
479
535
  */
480
536
 
481
537
  /**
482
- * One **registry** of a declared set, and the system it belongs to (#58).
538
+ * One **registry** of a declared set, and the system it belongs to.
483
539
  *
484
540
  * A repository shipping content for two systems declares one of these per
485
541
  * system: the accepted type vocabulary is their union, and a type both declare
@@ -501,10 +557,20 @@ export function publishesContentPages(config) {
501
557
  * @property {string} contentPackage Content package name — the address
502
558
  * namespace every note in this
503
559
  * repository is published under.
504
- * @property {string} foundryPackage Foundry package id, as it appears in
560
+ * @property {string} [foundryPackage] Foundry package id, as it appears in
505
561
  * `system.json` / `module.json`.
506
- * @property {PackageKind} packageKind Whether the package is a system or a module.
507
- * @property {StatsSpec} stats Identity stamped into every document's `_stats`.
562
+ * Refused by a `documentation`
563
+ * package, which ships no Foundry
564
+ * package.
565
+ * @property {PackageKind} packageKind Whether the package is a system, a
566
+ * module, or documentation — the kind
567
+ * that publishes a site and a book
568
+ * while compiling nothing.
569
+ * @property {StatsSpec} [stats] Identity stamped into every
570
+ * document's `_stats`. Required of a
571
+ * package that compiles documents, and
572
+ * refused by a `documentation` one,
573
+ * which compiles none.
508
574
  * @property {Record<string, ItemBuilderEntry>|readonly ItemRegistrySpec[]} [itemBuilders]
509
575
  * The consumer's
510
576
  * item-type registry: each content `type`
@@ -518,14 +584,13 @@ export function publishesContentPages(config) {
518
584
  * two systems declares a **list** of
519
585
  * `{ system, builders }` registries
520
586
  * instead, and the accepted type
521
- * vocabulary is their union (#58).
587
+ * vocabulary is their union.
522
588
  * @property {PackSpec[]} packs Packs to compile. More than one entry
523
589
  * may share a `type`: a note then names
524
590
  * the pack it belongs in with its
525
591
  * `pack:` frontmatter, and one pack of
526
592
  * the type is marked `default: true` to
527
- * receive the notes that name none
528
- * (#1566).
593
+ * receive the notes that name none.
529
594
  * @property {PathsInput} [paths] Layout overrides. See {@link DEFAULT_PATHS}.
530
595
  * @property {string[]} [skipDirectories] Directory names the content walk ignores
531
596
  * wherever they appear (e.g. Obsidian's
@@ -554,12 +619,20 @@ export function publishesContentPages(config) {
554
619
  * @typedef {object} ContentBuildConfig
555
620
  * @property {string} rootDir
556
621
  * @property {string} contentPackage
557
- * @property {string} foundryPackage
622
+ * @property {string|null} foundryPackage `null` for a `documentation`
623
+ * package, which ships no Foundry package.
558
624
  * @property {PackageKind} packageKind
559
- * @property {string} assetRoot Derived: the served Foundry asset root,
560
- * `<packageKind>/<foundryPackage>/assets`.
625
+ * @property {string|null} assetRoot Derived, and **conditional**: the served
626
+ * Foundry asset root,
627
+ * `<packageKind>/<foundryPackage>/assets`,
628
+ * for a package Foundry installs — and
629
+ * `null` for a `documentation` package,
630
+ * which Foundry serves no files for. See
631
+ * {@link module:engine/helpers.resolveImg},
632
+ * the one reader of it.
561
633
  * @property {Readonly<ResolvedPaths>} paths
562
- * @property {Readonly<StatsSpec>} stats
634
+ * @property {Readonly<StatsSpec>|null} stats `null` for a `documentation`
635
+ * package, which stamps no `_stats`.
563
636
  * @property {Readonly<Record<string, Function>>} itemBuilders Derived: the
564
637
  * `system` builder of each entry, whichever
565
638
  * of the two spellings declared it.
@@ -571,12 +644,11 @@ export function publishesContentPages(config) {
571
644
  * the frontmatter fields each entry
572
645
  * declared. Sparse, like `itemArt` — a type
573
646
  * absent here compiles normally and is
574
- * simply undocumented (#22).
647
+ * simply undocumented.
575
648
  * @property {Readonly<Record<string, Readonly<Record<string, Function>>>>} itemBuildersBySystem
576
649
  * Derived: the same builders, kept per
577
650
  * declaring system. `{}` for the single
578
- * registry form, which names no system
579
- * (#58).
651
+ * registry form, which names no system.
580
652
  * @property {Readonly<Record<string, Readonly<Record<string, string>>>>} itemArtBySystem
581
653
  * Derived: the default art, per system.
582
654
  * @property {Readonly<Record<string, Readonly<Record<string, readonly object[]>>>>} itemFieldsBySystem
@@ -589,13 +661,17 @@ export function publishesContentPages(config) {
589
661
  * {@link ContentBuildConfigInput.itemBuilders},
590
662
  * unioned across every declared registry, so
591
663
  * the accepted item types and the builder
592
- * tables are one list (#1504).
664
+ * tables are one list.
593
665
  * @property {ReadonlySet<string>} docEntryTypes Derived: every type whose prose
594
666
  * compiles into a JournalEntry of its own —
595
667
  * the item types, plus `macro`, plus the map
596
668
  * types. The one set the compilers and the
597
669
  * link-manifest emitter both read.
598
670
  * @property {readonly string[]} skipDirectories
671
+ * @property {import("./engine/content-icons.mjs").IconRegistry} icons The
672
+ * fonts this package ships and the names it
673
+ * draws from them; empty when it declares
674
+ * none.
599
675
  * @property {readonly Readonly<ResolvedPackSpec>[]} packs
600
676
  * @property {readonly string[]} packDirectories Derived: every pack directory
601
677
  * the build produces, in compile order —
@@ -618,9 +694,11 @@ const CONFIG_KEYS = [
618
694
  "itemBuilders",
619
695
  "paths",
620
696
  "skipDirectories",
697
+ "icons",
621
698
  "packs",
622
699
  "docs",
623
700
  "site",
701
+ "pdf",
624
702
  "compatibility",
625
703
  "relationships",
626
704
  "systems",
@@ -644,6 +722,9 @@ const SITE_KEYS = [
644
722
  "backfillSections",
645
723
  ];
646
724
  const SITE_TREE_KEYS = ["from", "section"];
725
+ const PDF_KEYS = ["title", "subtitle", "document", "out", "front", "fonts", "iconFonts", "binary"];
726
+ const PDF_FONT_KEYS = ["serif", "sans", "mono", "path"];
727
+ const EMPTY_PDF_FONTS = Object.freeze({ serif: "", sans: "", mono: "", path: "" });
647
728
  const SECTION_META_KEYS = ["title", "banner", "description", "listType", "listSubType"];
648
729
  const DOC_PAGE_KEYS = ["title", "out", "preamble"];
649
730
  const RELATIONSHIP_KINDS = ["systems", "requires", "recommends", "conflicts"];
@@ -668,7 +749,7 @@ const STATS_KEYS = ["lastModifiedBy"];
668
749
  * How the loader hands {@link defineConfig} the system version it resolved.
669
750
  *
670
751
  * A **Symbol**, deliberately. `stats.systemVersion` is refused from an authored
671
- * configuration (#48), but the value still has to reach here from the loader —
752
+ * configuration, but the value still has to reach here from the loader —
672
753
  * which is the half that may do I/O, and which reads a system package's version
673
754
  * out of the adjacent `package.json`. A string key would be a second spelling of
674
755
  * the refused one, forgeable from YAML and reachable by `rejectUnknownKeys`; a
@@ -692,7 +773,7 @@ function isPlainObject(value) {
692
773
  * The dotted path is carried on the error as `field` as well as spelled into
693
774
  * the message, because the message alone is a good description and a bad
694
775
  * locator: the loader that read the file can resolve that path to a line and
695
- * column, and does (`locateConfigError` in `engine/pack-config.mjs`, #95).
776
+ * column, and does (`locateConfigError` in `engine/pack-config.mjs`).
696
777
  * Attaching it here rather than formatting here is what keeps this module
697
778
  * free of I/O — it is the leaf an `.mjs` configuration imports, so it may not
698
779
  * reach for the file it is validating.
@@ -739,14 +820,15 @@ function requireNonEmptyString(value, field) {
739
820
  * It is the first segment of every canonical address this repository publishes
740
821
  * (`package-system-type-shortcode`, so `sohl-none-doc-gear`), and an address is
741
822
  * read by counting hyphen-separated segments. So the value carries two
742
- * obligations that the rest of the configuration does not, and #59 asks for
823
+ * obligations that the rest of the configuration does not, and asks for
743
824
  * both to be **enforced rather than assumed** — the alternative is a package
744
825
  * whose addresses are simply unreadable, reported nowhere and discovered as
745
826
  * links that resolve to nothing.
746
827
  *
747
- * 1. _Alphanumeric_, so the hyphen stays purely a separator. `harn-adventures`
748
- * was the one violator, and its keys read as one segment too many and failed
749
- * as a `null` return from `readCanonicalKey` a silence, not an error.
828
+ * 1. _Lowercase alphanumeric_ (`ADDRESS_SEGMENT_PATTERN`), so the hyphen stays
829
+ * purely a separator. `harn-adventures` was the one violator, and its keys
830
+ * read as one segment too many and failed as a `null` return from
831
+ * `readCanonicalKey` — a silence, not an error.
750
832
  * 2. _Not a note type_, because a written address is a **partial** one: the
751
833
  * shorter forms drop segments from the left, so `skill-clmb` and
752
834
  * `sohl-skill-clmb` are both addresses and position alone no longer says
@@ -754,9 +836,9 @@ function requireNonEmptyString(value, field) {
754
836
  * by asking whether the name is a known package, and a name in both
755
837
  * vocabularies makes one target readable two ways with no defensible pick.
756
838
  * Keeping the two disjoint is what lets a name be taken at face value; that
757
- * the package and the type are no longer *adjacent* segments (#59 put the
758
- * system between them) changes nothing, because the hazard was never
759
- * adjacency — it is that a short form omits the slots in between.
839
+ * the package and the type are not *adjacent* segments, the system sitting
840
+ * between them, changes nothing: the hazard is not adjacency, it is that a
841
+ * short form omits the slots in between.
760
842
  * One such collision is structural and cannot be fixed — `sohl` is both a
761
843
  * content package and a system id, because Foundry requires a system
762
844
  * package's id to *be* its system id, and `sohl-sohl-skill-clmb` is the
@@ -775,11 +857,12 @@ function requireContentPackage(value, docEntryTypes) {
775
857
  if (!isAddressSegment(pkg)) {
776
858
  fail(
777
859
  "contentPackage",
778
- `is \`${pkg}\`, which is not alphanumeric. It is the first ` +
860
+ `is \`${pkg}\`, which is not lowercase alphanumeric ` +
861
+ `(${ADDRESS_SEGMENT_PATTERN.source}). It is the first ` +
779
862
  `segment of every address this package publishes ` +
780
863
  `(\`${pkg}-<system>-<type>-<shortcode>\`), and an address is read by ` +
781
864
  `counting hyphen-separated segments — so anything outside ` +
782
- "`[A-Za-z0-9]` here makes those addresses unreadable rather " +
865
+ "that here makes those addresses unreadable rather " +
783
866
  "than merely ugly. `harn-adventures` became `harnadventures`",
784
867
  );
785
868
  }
@@ -850,7 +933,7 @@ function optionalString(value, field) {
850
933
  function normalizePack(value, where, nested = false) {
851
934
  if (!isPlainObject(value)) fail(where, "must be an object");
852
935
  const pack = /** @type {Record<string, unknown>} */ (value);
853
- // Retired with the YAML it named (#260). Refused explicitly rather than
936
+ // Retired with the YAML it named. Refused explicitly rather than
854
937
  // left to the unknown-key check, because the useful thing to say is not
855
938
  // "no such key" but where the folders went: they are notes, and a pack
856
939
  // materialises the ones its documents reference.
@@ -955,6 +1038,128 @@ function normalizePack(value, where, nested = false) {
955
1038
  return Object.freeze(normalized);
956
1039
  }
957
1040
 
1041
+ /**
1042
+ * A package's icon registry — the fonts it ships and the names it draws from
1043
+ * them.
1044
+ *
1045
+ * **Nothing is supplied by default.** A registry entry is a promise that a
1046
+ * glyph will render, and only the package shipping the font can keep it: the
1047
+ * Game-Icons webfont is built by a consumer from its own templates, and Font
1048
+ * Awesome reaches neither the knowledgebase nor a printed page unless somebody
1049
+ * puts it there. A toolchain that shipped a starter table would be promising on
1050
+ * a consumer's behalf, and a name like `victory-star-tester` is one game
1051
+ * system's vocabulary besides.
1052
+ *
1053
+ * So a package declares both halves, and a package that declares neither names
1054
+ * no icons at all.
1055
+ *
1056
+ * **Two spellings, one shape.** The value is either the registry itself:
1057
+ *
1058
+ * ```yaml
1059
+ * icons:
1060
+ * families:
1061
+ * fontawesome: { class: fa, styles: [solid, regular, brands], describe: Font Awesome Free }
1062
+ * icons:
1063
+ * being: { style: solid, icon: user, label: being }
1064
+ * ```
1065
+ *
1066
+ * or a **path to a file holding it**, relative to this configuration:
1067
+ *
1068
+ * ```yaml
1069
+ * icons: assets/icon-registry.yaml
1070
+ * ```
1071
+ *
1072
+ * The file form is the one a real package wants. A registry is derived from
1073
+ * what the interface actually draws, so it is generated rather than hand-kept —
1074
+ * and a generated document inlined into a hand-edited configuration is a merge
1075
+ * conflict on every regeneration. Kept beside it, the generator owns one file
1076
+ * and the configuration owns the other.
1077
+ *
1078
+ * Validated with {@link module:engine/content-icons.checkIconRegistry}, whose
1079
+ * findings are warnings everywhere else and a **refusal** here: elsewhere the
1080
+ * question is whether one note is wrong, and here it is whether the table every
1081
+ * note is read against is.
1082
+ *
1083
+ * @param {unknown} value - The authored `icons:` value.
1084
+ * @param {string} rootDir - The configuration's own directory, which a relative
1085
+ * path is resolved against.
1086
+ * @returns {import("./engine/content-icons.mjs").IconRegistry} The frozen
1087
+ * registry.
1088
+ */
1089
+ function normalizeIcons(value, rootDir) {
1090
+ if (value === undefined) return EMPTY_ICON_REGISTRY;
1091
+
1092
+ let declared = value;
1093
+ let where = "icons";
1094
+ if (typeof value === "string") {
1095
+ if (!value.trim()) fail("icons", "is empty — name a file, or write the registry inline");
1096
+ const file = path.resolve(rootDir, value);
1097
+ let text;
1098
+ try {
1099
+ text = fs.readFileSync(file, "utf8");
1100
+ } catch {
1101
+ fail("icons", `names ${value}, which cannot be read from ${rootDir}`);
1102
+ }
1103
+ try {
1104
+ declared = YAML.parse(text);
1105
+ } catch (err) {
1106
+ fail("icons", `names ${value}, which is not readable YAML: ${err.message}`);
1107
+ }
1108
+ // A finding says which *file* is wrong, not which key of this one.
1109
+ where = value;
1110
+ if (declared === null || declared === undefined) {
1111
+ fail("icons", `names ${value}, which is empty`);
1112
+ }
1113
+ }
1114
+
1115
+ if (!isPlainObject(declared)) {
1116
+ fail(
1117
+ "icons",
1118
+ "must be a registry — `families` and `icons` — or a path to a file holding one",
1119
+ );
1120
+ }
1121
+
1122
+ const families = declared.families ?? {};
1123
+ const icons = declared.icons ?? {};
1124
+ if (!isPlainObject(families)) fail(`${where}.families`, "must be a mapping of name to family");
1125
+ if (!isPlainObject(icons)) fail(`${where}.icons`, "must be a mapping of name to icon entry");
1126
+
1127
+ for (const name of Object.keys(icons)) {
1128
+ // The name a note writes between the colons. Checked here rather than
1129
+ // left to the note, because an entry nothing can name is a silent
1130
+ // no-op: every use of it reports "no such icon" and the table says
1131
+ // otherwise.
1132
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
1133
+ fail(
1134
+ `${where}.icons.${name}`,
1135
+ "is not a name a note can write — `:icon-…:` takes lowercase " +
1136
+ "letters, digits and hyphens, the charset an address segment uses",
1137
+ );
1138
+ }
1139
+ }
1140
+
1141
+ const defaultFamily = declared.defaultFamily;
1142
+ if (defaultFamily !== undefined) {
1143
+ if (typeof defaultFamily !== "string" || !(defaultFamily in families)) {
1144
+ fail(
1145
+ `${where}.defaultFamily`,
1146
+ `names \`${defaultFamily}\`, which is not one of the declared families`,
1147
+ );
1148
+ }
1149
+ }
1150
+
1151
+ const findings = checkIconRegistry({ families, icons, defaultFamily }, where);
1152
+ if (findings.length) {
1153
+ fail("icons", findings.map((finding) => finding.message).join("; "));
1154
+ }
1155
+
1156
+ return Object.freeze({
1157
+ families: Object.freeze(families),
1158
+ defaultFamily,
1159
+ icons: Object.freeze(icons),
1160
+ });
1161
+ }
1162
+
958
1163
  /**
959
1164
  * Resolve the layout a consumer supplies against its `rootDir`, filling every
960
1165
  * unnamed directory from {@link DEFAULT_PATHS}.
@@ -994,7 +1199,9 @@ function normalizePaths(value, rootDir) {
994
1199
  }
995
1200
 
996
1201
  /**
997
- * @param {unknown} value
1202
+ * @param {unknown} value - The authored `stats:` block.
1203
+ * @param {{systemId: string, systemVersion: string}} derived - The package-wide
1204
+ * system and the version it stamps against, both derived by the caller.
998
1205
  * @returns {Readonly<StatsSpec>}
999
1206
  */
1000
1207
  function normalizeStats(value, derived) {
@@ -1002,7 +1209,7 @@ function normalizeStats(value, derived) {
1002
1209
  const input = /** @type {Record<string, unknown>} */ (value);
1003
1210
 
1004
1211
  // **`systemId` and `systemVersion` are derived, and authoring a derived
1005
- // value is an error rather than an override (#48).** `systems:` is the
1212
+ // value is an error rather than an override.** `systems:` is the
1006
1213
  // single source: it says which systems this package stamps against, and
1007
1214
  // `requiresSystem` — or a lone declared system — says which one the
1008
1215
  // package-wide block takes. A system package answers for itself.
@@ -1115,7 +1322,7 @@ function normalizeDocs(value) {
1115
1322
  * One section's landing metadata — what a section says about itself on the
1116
1323
  * `_index.md` this build generates for it.
1117
1324
  *
1118
- * A generated landing is the *only* place a section can speak, and since #204 it
1325
+ * A generated landing is the *only* place a section can speak, and it
1119
1326
  * is the only place a section **exists**: a content page is addressed
1120
1327
  * `(type, shortcode)` and written flat under the mount, so no page creates a
1121
1328
  * directory and nothing else makes `<prefix><section>/` answer. This is
@@ -1127,18 +1334,18 @@ function normalizeDocs(value) {
1127
1334
  * written fourteen to twenty times per build against a contract every package
1128
1335
  * and every section shares. Unbounded there, a mistyped `descrption:` publishes
1129
1336
  * into front matter, is read by nobody, and says nothing to anyone — which is
1130
- * the failure #91 was filed about, moved one step downstream where no build can
1337
+ * the same failure, moved one step downstream where no build can
1131
1338
  * see it. So the keys are named here, and the writers emit what this produced
1132
- * rather than transcribing a second list of their own (#91).
1339
+ * rather than transcribing a second list of their own.
1133
1340
  *
1134
1341
  * `banner` and `description` are optional — the hero images are external assets
1135
1342
  * and not every section has one, and a section may reasonably have nothing to
1136
1343
  * add to its title. Each is left off entirely rather than written as
1137
1344
  * `undefined`, which is not a value YAML can carry.
1138
1345
  *
1139
- * **`listType` / `listSubType` say what the section lists**
1140
- * (heroiclands-hugo-theme#50). Since #204 a section's directory holds nothing
1141
- * but the `_index.md` written here, so a layout reading Hugo's `.Pages` finds
1346
+ * **`listType` / `listSubType` say what the section lists.** A section's
1347
+ * directory holds nothing but the `_index.md` written here, so a layout
1348
+ * reading Hugo's `.Pages` finds
1142
1349
  * no members and renders an empty landing. The membership survives in this map
1143
1350
  * and nowhere a theme can reach it, so the landing states it and a layout
1144
1351
  * substitutes the equivalent `site.RegularPages` query — the same one `sohl`'s
@@ -1155,7 +1362,7 @@ function normalizeDocs(value) {
1155
1362
  * Both are checked as **address segments**, which is the trap this came from:
1156
1363
  * a section is named for the URL a consumer chose and a subType is an address
1157
1364
  * segment, and the two need not agree — `/sohl/kb/user-guide/` is the section,
1158
- * `userguide` the subType (#207). Copying the section's name into the
1365
+ * `userguide` the subType. Copying the section's name into the
1159
1366
  * declaration would select no page at all, and an empty landing reported by
1160
1367
  * nobody is the failure being fixed. A `listSubType` with no `listType` is
1161
1368
  * refused for the same reason: a subType is only distinguishing *within* a
@@ -1184,7 +1391,7 @@ function normalizeSectionMeta(value, where) {
1184
1391
  if (!isAddressSegment(segment)) {
1185
1392
  fail(
1186
1393
  `${where}.${key}`,
1187
- `is \`${segment}\`, which is not alphanumeric. It names a ` +
1394
+ `is \`${segment}\`, which is not lowercase alphanumeric. It names a ` +
1188
1395
  "content type or subType, and those are address segments " +
1189
1396
  `(${ADDRESS_SEGMENT_PATTERN.source}) — not the section's ` +
1190
1397
  "own name, which is a URL this site chose and need not " +
@@ -1309,6 +1516,114 @@ function normalizeSite(value) {
1309
1516
  });
1310
1517
  }
1311
1518
 
1519
+ /**
1520
+ * The `pdf` section — the book the content tree is published as.
1521
+ *
1522
+ * A third surface beside the packs and the website, and the one that is a
1523
+ * **selection** rather than a rendering of everything: `document:` names the
1524
+ * tree that says which notes the volume carries and in what order, because a
1525
+ * book is an editorial act where a site is an index. That file is the
1526
+ * consumer's, parsed by {@link module:engine/pdf-toc.parseDocumentTree}, and
1527
+ * nothing about its shape is validated here — this block says only where it is.
1528
+ *
1529
+ * **Nothing here is an address or a brand.** The title, the subtitle, the front
1530
+ * matter and the faces are every one of them the publishing repository's to
1531
+ * choose, which is the whole reason they are configuration: the engine that
1532
+ * sets the book must be able to set somebody else's book.
1533
+ *
1534
+ * **Declaring the block is not the switch.** Whether a PDF is built at all is
1535
+ * `publish.site` — `content` builds one, `homepage` does not — so a package
1536
+ * cannot end up with two switches that disagree about whether it publishes its
1537
+ * content tree. See {@link publishesContentPages}.
1538
+ *
1539
+ * @param {unknown} value - The `pdf` block, or `undefined`.
1540
+ * @param {string} rootDir - The repository root configured paths resolve against.
1541
+ * @returns {Readonly<object>|null} It, frozen; `null` when the block is absent.
1542
+ */
1543
+ function normalizePdf(value, rootDir) {
1544
+ if (value === undefined) return null;
1545
+ if (!isPlainObject(value)) fail("pdf", "must be a mapping");
1546
+ const input = /** @type {Record<string, unknown>} */ (value);
1547
+ rejectUnknownKeys(input, PDF_KEYS, "pdf.");
1548
+
1549
+ // Both required, and required together: a document with no tree has nothing
1550
+ // to print, and a tree with no title produces a file whose name and cover
1551
+ // say nothing about what a reader downloaded.
1552
+ const title = requireNonEmptyString(input.title, "pdf.title");
1553
+ const document = requireNonEmptyString(input.document, "pdf.document");
1554
+
1555
+ const front = [];
1556
+ if (input.front !== undefined) {
1557
+ if (!Array.isArray(input.front)) fail("pdf.front", "must be a list of markdown files");
1558
+ input.front.forEach((entry, i) => {
1559
+ front.push(requireNonEmptyString(entry, `pdf.front[${i}]`));
1560
+ });
1561
+ }
1562
+
1563
+ let fonts = EMPTY_PDF_FONTS;
1564
+ if (input.fonts !== undefined) {
1565
+ if (!isPlainObject(input.fonts)) fail("pdf.fonts", "must be a mapping");
1566
+ const declared = /** @type {Record<string, unknown>} */ (input.fonts);
1567
+ rejectUnknownKeys(declared, PDF_FONT_KEYS, "pdf.fonts.");
1568
+ fonts = Object.freeze({
1569
+ // Family *names*, not files: the renderer asks the font stack for a
1570
+ // family, and `path` is where it may look beyond the system's own.
1571
+ serif:
1572
+ declared.serif === undefined ?
1573
+ ""
1574
+ : requireNonEmptyString(declared.serif, "pdf.fonts.serif"),
1575
+ sans:
1576
+ declared.sans === undefined ?
1577
+ ""
1578
+ : requireNonEmptyString(declared.sans, "pdf.fonts.sans"),
1579
+ mono:
1580
+ declared.mono === undefined ?
1581
+ ""
1582
+ : requireNonEmptyString(declared.mono, "pdf.fonts.mono"),
1583
+ path:
1584
+ declared.path === undefined ?
1585
+ ""
1586
+ : path.resolve(rootDir, requireNonEmptyString(declared.path, "pdf.fonts.path")),
1587
+ });
1588
+ }
1589
+
1590
+ // Family name to the font file carrying its glyphs, for `:icon-…:`. A file
1591
+ // rather than a codepoint, because the font's own tables are the only
1592
+ // trustworthy source of which glyph a name resolves to — see
1593
+ // {@link module:engine/content-icons}, which states the style and the name
1594
+ // and deliberately holds no codepoints.
1595
+ const iconFonts = {};
1596
+ if (input.iconFonts !== undefined) {
1597
+ if (!isPlainObject(input.iconFonts)) {
1598
+ fail("pdf.iconFonts", "must be a mapping of icon family to font file");
1599
+ }
1600
+ for (const [family, file] of Object.entries(input.iconFonts)) {
1601
+ iconFonts[family] = path.resolve(
1602
+ rootDir,
1603
+ requireNonEmptyString(file, `pdf.iconFonts.${family}`),
1604
+ );
1605
+ }
1606
+ }
1607
+
1608
+ return Object.freeze({
1609
+ title,
1610
+ subtitle:
1611
+ input.subtitle === undefined ?
1612
+ ""
1613
+ : requireNonEmptyString(input.subtitle, "pdf.subtitle"),
1614
+ document: path.resolve(rootDir, document),
1615
+ out: input.out === undefined ? "" : requireNonEmptyString(input.out, "pdf.out"),
1616
+ front: Object.freeze(front.map((f) => path.resolve(rootDir, f))),
1617
+ fonts,
1618
+ iconFonts: Object.freeze(iconFonts),
1619
+ // Where the Typst binary is, when it is not simply `typst` on PATH.
1620
+ // Named rather than bundled: a native compiler would put a
1621
+ // platform-specific binary in the dependency tree of three repositories
1622
+ // that mostly do not build books.
1623
+ binary: input.binary === undefined ? "" : requireNonEmptyString(input.binary, "pdf.binary"),
1624
+ });
1625
+ }
1626
+
1312
1627
  /**
1313
1628
  * Validate a Foundry version range.
1314
1629
  *
@@ -1339,40 +1654,7 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
1339
1654
  }
1340
1655
 
1341
1656
  /**
1342
- * Validate the declared relationships.
1343
- *
1344
- * Only as far as this package needs to read them: enough that a system
1345
- * relationship can be found and its `verified` version trusted. The rest is
1346
- * passed through for the manifest generator to emit.
1347
- *
1348
- * @param {unknown} value - The `relationships` block, or `undefined`.
1349
- * @returns {Readonly<Relationships>} It, frozen; `{}` when absent.
1350
- */
1351
- /**
1352
- * The systems this package can stamp content against — declaration only (#48).
1353
- *
1354
- * **Declaring is not requiring, and that separation is the whole point.** The
1355
- * only place to state a system version used to be `relationships.systems`, and
1356
- * that list is a *restriction*: Foundry's `supportsSystem` drops a module from
1357
- * any world whose system it does not name. So a module shipping content for two
1358
- * systems — `harn-ensemble` ships an HM3 pack, a SoHL pack and a system-neutral
1359
- * journals pack — had to choose between naming its systems and remaining
1360
- * loadable, and choosing the second meant stamping no system version at all on
1361
- * content that certainly has one.
1362
- *
1363
- * Naming a system here restricts nothing. {@link normalizeRequiresSystem} is
1364
- * what restricts, and it is separate and optional.
1365
- *
1366
- * Each entry carries the same `compatibility` shape a relationship does, and
1367
- * `verified` is what a pack stamps: `_stats.systemVersion` records what the
1368
- * content was *built against*, not the floor it tolerates.
1369
- *
1370
- * @param {unknown} value - The declared `systems:` mapping.
1371
- * @returns {Readonly<Record<string, Readonly<object>>>} Frozen; `{}` when absent.
1372
- */
1373
- /**
1374
- * The **package-wide** system, or `null` where the configuration names none
1375
- * (#48).
1657
+ * The **package-wide** system, or `null` where the configuration names none.
1376
1658
  *
1377
1659
  * A *system* package is its own system, which is true by construction and needs
1378
1660
  * no declaration. A *module* takes the one it requires, or the one system it
@@ -1387,13 +1669,13 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
1387
1669
  * exists to remove. Several entries have no single answer and get none.
1388
1670
  *
1389
1671
  * **Written once and read twice**, which is why it is a function rather than the
1390
- * expression it used to be: the value stamped into `stats.systemId` and the
1672
+ * expression: the value stamped into `stats.systemId` and the
1391
1673
  * value a pack's `system:` is validated against are the same fact, and two
1392
1674
  * spellings of it would be free to disagree about exactly the case that has no
1393
1675
  * answer.
1394
1676
  *
1395
1677
  * @param {object} parts - The resolved pieces of the configuration.
1396
- * @param {string} parts.packageKind - `systems` or `modules`.
1678
+ * @param {string} parts.packageKind - One of {@link PACKAGE_KINDS}.
1397
1679
  * @param {unknown} parts.foundryPackage - The package id.
1398
1680
  * @param {string|null} parts.requiresSystem - The declared gate, if any.
1399
1681
  * @param {Readonly<Record<string, object>>} parts.systems - The `systems:` block.
@@ -1416,6 +1698,28 @@ function packageWideSystemId({
1416
1698
  return null;
1417
1699
  }
1418
1700
 
1701
+ /**
1702
+ * The systems this package can stamp content against — declaration only.
1703
+ *
1704
+ * **Declaring is not requiring, and that separation is the whole point.** The
1705
+ * only other place to state a system version is `relationships.systems`, and
1706
+ * that list is a *restriction*: Foundry's `supportsSystem` drops a module from
1707
+ * any world whose system it does not name. So a module shipping content for two
1708
+ * systems — `harn-ensemble` ships an HM3 pack, a SoHL pack and a system-neutral
1709
+ * journals pack — had to choose between naming its systems and remaining
1710
+ * loadable, and choosing the second meant stamping no system version at all on
1711
+ * content that certainly has one.
1712
+ *
1713
+ * Naming a system here restricts nothing. {@link normalizeRequiresSystem} is
1714
+ * what restricts, and it is separate and optional.
1715
+ *
1716
+ * Each entry carries the same `compatibility` shape a relationship does, and
1717
+ * `verified` is what a pack stamps: `_stats.systemVersion` records what the
1718
+ * content was *built against*, not the floor it tolerates.
1719
+ *
1720
+ * @param {unknown} value - The declared `systems:` mapping.
1721
+ * @returns {Readonly<Record<string, Readonly<object>>>} Frozen; `{}` when absent.
1722
+ */
1419
1723
  function normalizeSystems(value) {
1420
1724
  if (value === undefined || value === null) return Object.freeze({});
1421
1725
  if (!isPlainObject(value)) fail("systems", "must be a mapping of id to spec");
@@ -1458,7 +1762,7 @@ function normalizeSystems(value) {
1458
1762
  }
1459
1763
 
1460
1764
  /**
1461
- * The one system this package refuses to load without, or `null` (#48).
1765
+ * The one system this package refuses to load without, or `null`.
1462
1766
  *
1463
1767
  * The gate half of the split. Naming a system here emits
1464
1768
  * `relationships.systems` for it, which is what Foundry's `supportsSystem`
@@ -1479,6 +1783,16 @@ function normalizeRequiresSystem(value) {
1479
1783
  return requireNonEmptyString(value, "requiresSystem");
1480
1784
  }
1481
1785
 
1786
+ /**
1787
+ * Validate the declared relationships.
1788
+ *
1789
+ * Only as far as this package needs to read them: enough that a system
1790
+ * relationship can be found and its `verified` version trusted. The rest is
1791
+ * passed through for the manifest generator to emit.
1792
+ *
1793
+ * @param {unknown} value - The `relationships` block, or `undefined`.
1794
+ * @returns {Readonly<Relationships>} It, frozen; `{}` when absent.
1795
+ */
1482
1796
  function normalizeRelationships(value) {
1483
1797
  if (value === undefined) return Object.freeze({});
1484
1798
  if (!isPlainObject(value)) fail("relationships", "must be a mapping");
@@ -1557,7 +1871,7 @@ function normalizePackageBuild(value) {
1557
1871
  * The registry is *code* a consumer supplies — the only place the configuration
1558
1872
  * carries any — because the type list and the builder table have to be the same
1559
1873
  * list. They were two, and `trait` sat in the whitelist for a release with no
1560
- * builder behind it (#1504).
1874
+ * builder behind it.
1561
1875
  *
1562
1876
  * **An entry may be written two ways**, and the difference is only whether the
1563
1877
  * type brings default art:
@@ -1637,12 +1951,11 @@ function normalizeOneRegistry(value, at) {
1637
1951
  }
1638
1952
 
1639
1953
  /**
1640
- * The declared item-builder registries, and the vocabulary their union gives
1641
- * (#58).
1954
+ * The declared item-builder registries, and the vocabulary their union gives.
1642
1955
  *
1643
1956
  * **One registry is a ceiling, not a default.** The accepted type list is the
1644
1957
  * registry's keys, which is what makes a type impossible to accept without a
1645
- * builder behind it (#1504) — and, with one registry, impossible to accept a
1958
+ * builder behind it — and, with one registry, impossible to accept a
1646
1959
  * type a *second* system declares. A tree feeding two systems has both:
1647
1960
  * `spell`, `invocation` and `psionic` are HM3's, `mysticalability` and
1648
1961
  * `projectile` are SoHL's, and `skill` is both systems' under one name and
@@ -1768,7 +2081,7 @@ function normalizeItemBuilders(value) {
1768
2081
  }
1769
2082
 
1770
2083
  /**
1771
- * The publishing mode, refusing the boolean this setting used to be.
2084
+ * The publishing mode, refusing a boolean.
1772
2085
  *
1773
2086
  * A boolean is refused rather than mapped onto the nearest mode, because the
1774
2087
  * reading `false` invited — *this package has no web presence* — is exactly the
@@ -1827,7 +2140,7 @@ function normalizePublish(value) {
1827
2140
  // A retired key is refused by name, ahead of the vocabulary check: reported
1828
2141
  // as merely unrecognized it would read as a misspelling of the one key that
1829
2142
  // survives, and the author would correct the spelling rather than learn
1830
- // that the mechanism is gone (#215).
2143
+ // that the mechanism is gone.
1831
2144
  for (const key of Object.keys(address)) {
1832
2145
  if (Object.hasOwn(RETIRED_ADDRESS_KEYS, key)) {
1833
2146
  fail(`publish.address.${key}`, RETIRED_ADDRESS_KEYS[key]);
@@ -1847,7 +2160,7 @@ function normalizePublish(value) {
1847
2160
  }
1848
2161
  if (prefix.startsWith("/")) {
1849
2162
  // A leading slash would make the recorded address package-absolute,
1850
- // which is exactly the site-absolute shape #1465 removed.
2163
+ // which is exactly the site-absolute shape this avoids.
1851
2164
  fail("publish.address.prefix", "must not begin with a slash");
1852
2165
  }
1853
2166
 
@@ -1895,12 +2208,48 @@ export function defineConfig(config) {
1895
2208
  fail("packageKind", `must be one of: ${PACKAGE_KINDS.join(", ")}`);
1896
2209
  }
1897
2210
 
1898
- if (!Array.isArray(input.packs)) fail("packs", "must be an array");
1899
- if (input.packs.length === 0) fail("packs", "must declare at least one pack");
1900
- const packs = input.packs.map((pack, index) => normalizePack(pack, `packs[${index}]`));
2211
+ // A documentation package compiles nothing and installs nowhere, so every
2212
+ // key that describes a Foundry package is refused by name — ahead of the
2213
+ // checks below, which each assume a Foundry package is being described.
2214
+ const documentation = packageKind === DOCUMENTATION_KIND;
2215
+ if (documentation) {
2216
+ for (const [key, why] of Object.entries(DOCUMENTATION_REFUSES)) {
2217
+ if (input[key] === undefined) continue;
2218
+ fail(key, `is refused in a \`${DOCUMENTATION_KIND}\` package, which ${why}`);
2219
+ }
2220
+ // Publishing is what a documentation package is *for*, so the floor
2221
+ // every other package may sit at is not available to it: `homepage`
2222
+ // would leave a package that publishes one authored page, builds no
2223
+ // book, and compiles nothing at all.
2224
+ if (!isPlainObject(input.publish)) {
2225
+ fail(
2226
+ "publish",
2227
+ `is required in a \`${DOCUMENTATION_KIND}\` package: publishing ` +
2228
+ "the content tree is the whole of what it does. Write " +
2229
+ "`publish: {site: content}`",
2230
+ );
2231
+ }
2232
+ const mode = /** @type {Record<string, unknown>} */ (input.publish).site;
2233
+ if (mode !== "content") {
2234
+ fail(
2235
+ "publish.site",
2236
+ `must be \`content\` in a \`${DOCUMENTATION_KIND}\` package — ` +
2237
+ "`homepage` fences the content surfaces off, and a package " +
2238
+ "that compiles nothing and publishes nothing from its tree " +
2239
+ "would produce a single authored page and no book",
2240
+ );
2241
+ }
2242
+ }
2243
+
2244
+ if (!documentation) {
2245
+ if (!Array.isArray(input.packs)) fail("packs", "must be an array");
2246
+ if (input.packs.length === 0) fail("packs", "must declare at least one pack");
2247
+ }
2248
+ const declaredPacks = Array.isArray(input.packs) ? input.packs : [];
2249
+ const packs = declaredPacks.map((pack, index) => normalizePack(pack, `packs[${index}]`));
1901
2250
 
1902
2251
  // One list, so the compile order and the directory list cannot disagree —
1903
- // they used to be `PACK_CONFIGS` and `SOURCE_PACKS`, maintained apart (#1508).
2252
+ // as `PACK_CONFIGS` and `SOURCE_PACKS` they would be maintained apart.
1904
2253
  const packDirectories = packs.flatMap((pack) => [
1905
2254
  pack.name,
1906
2255
  ...pack.companions.map((companion) => companion.name),
@@ -1913,7 +2262,7 @@ export function defineConfig(config) {
1913
2262
  seen.add(name);
1914
2263
  }
1915
2264
 
1916
- // ── systems: declaring, and requiring, are separate decisions (#48) ──────
2265
+ // ── systems: declaring, and requiring, are separate decisions ──────
1917
2266
  const systems = normalizeSystems(input.systems);
1918
2267
  const requiresSystem = normalizeRequiresSystem(input.requiresSystem);
1919
2268
  const declaredSystems = new Set(Object.keys(systems));
@@ -1934,7 +2283,7 @@ export function defineConfig(config) {
1934
2283
  // A name that resolves to nothing is a build error rather than a
1935
2284
  // fall-through, in the spirit the rest of this file already follows: a pack
1936
2285
  // stamping a system nobody declared would stamp `undefined`, which is the
1937
- // plausible lie #43 was about.
2286
+ // plausible lie.
1938
2287
  if (requiresSystem !== null && !declaredSystems.has(requiresSystem)) {
1939
2288
  fail(
1940
2289
  "requiresSystem",
@@ -1951,14 +2300,14 @@ export function defineConfig(config) {
1951
2300
  // verified version `statsForPack` reads, or this package's own
1952
2301
  // package-wide system, whose stats answer for every pack of it.
1953
2302
  //
1954
- // This used to be skipped entirely when `systems:` was empty or absent
2303
+ // Skipping this entirely when `systems:` is empty or absent
1955
2304
  // — `declaredSystems.size &&` guarded it — which left the case the
1956
2305
  // comment above was written about wide open. `harn-ensemble` declares
1957
2306
  // `system: sohl` and `system: hm3` on its packs, no `systems:` block,
1958
2307
  // and no package-wide system, so every pack fell through to a
1959
2308
  // package-wide stat that is null: 2,513 compiled actors stamped
1960
2309
  // `_stats.systemId: null` in a pack that says `system: sohl` on the
1961
- // line above. That is the plausible lie #43 was about, reached by the
2310
+ // line above. That is the plausible lie, reached by the
1962
2311
  // one path this check did not cover, and the `requiresSystem` check ten
1963
2312
  // lines up already refuses its own version of it in as many words.
1964
2313
  if (!declaredSystems.has(pack.system) && pack.system !== packageWide) {
@@ -1994,7 +2343,7 @@ export function defineConfig(config) {
1994
2343
 
1995
2344
  // Several packs of one document type are allowed — editorial grouping of
1996
2345
  // same-type documents is ordinary Foundry practice, and collapsing such a
1997
- // layout breaks every stored compendium UUID (#1566). What is not allowed
2346
+ // layout breaks every stored compendium UUID. What is not allowed
1998
2347
  // is two candidates for the same undeclared note.
1999
2348
  const defaultsByType = new Map();
2000
2349
  for (const pack of packs) {
@@ -2018,7 +2367,10 @@ export function defineConfig(config) {
2018
2367
  requireNonEmptyString(name, `skipDirectories[${index}]`),
2019
2368
  );
2020
2369
 
2021
- const foundryPackage = requireNonEmptyString(input.foundryPackage, "foundryPackage");
2370
+ // Refused above for a documentation package, so there is nothing to read
2371
+ // and nothing to derive an asset root or a package-wide system from.
2372
+ const foundryPackage =
2373
+ documentation ? null : requireNonEmptyString(input.foundryPackage, "foundryPackage");
2022
2374
 
2023
2375
  const {
2024
2376
  itemBuilders,
@@ -2029,12 +2381,12 @@ export function defineConfig(config) {
2029
2381
  itemFieldsBySystem,
2030
2382
  itemTypesBySeveralSystems,
2031
2383
  } = normalizeItemBuilders(input.itemBuilders);
2032
- // The union across every declared registry (#58) — the flat table already
2384
+ // The union across every declared registry — the flat table already
2033
2385
  // holds every key any of them declares, so this stays "the registry's keys"
2034
- // rather than becoming a second list to keep in step (#1504).
2386
+ // rather than becoming a second list to keep in step.
2035
2387
  const itemTypes = Object.freeze(new Set(Object.keys(itemBuilders)));
2036
2388
  // Every note that compiles into a *system-bearing* document publishes its
2037
- // prose as a documentation JournalEntry, and that includes actors (#337).
2389
+ // prose as a documentation JournalEntry, and that includes actors.
2038
2390
  // A being was the one such note with no `none` address — its only address
2039
2391
  // named the Actor — so nothing a prose link wrote could land on its page.
2040
2392
  // `doc` stays out for the reason that actually applies to it: its single
@@ -2050,37 +2402,50 @@ export function defineConfig(config) {
2050
2402
  packageKind: /** @type {PackageKind} */ (packageKind),
2051
2403
  // Foundry serves a package's files from `<kind>/<id>/`, so this is the
2052
2404
  // one place `systems/sohl` (or `modules/sohl-thalorna`) is spelled.
2053
- assetRoot: `${packageKind}/${foundryPackage}/assets`,
2405
+ //
2406
+ // **Conditional on the kind.** `documentation` names no directory
2407
+ // Foundry serves, and there is no package id to put under one either, so
2408
+ // the derivation would read `documentation/null/assets` — an address
2409
+ // that resolves nowhere and would be written into every compiled `img`.
2410
+ // `null` says the package has no asset root instead, and
2411
+ // {@link module:engine/helpers.resolveImg} — the only reader — refuses
2412
+ // rather than rooting a path against nothing.
2413
+ assetRoot: documentation ? null : `${packageKind}/${foundryPackage}/assets`,
2054
2414
  paths: normalizePaths(input.paths, rootDir),
2055
- // The package-wide system, derived (#48). A **system** package is its
2415
+ // The package-wide system, derived. A **system** package is its
2056
2416
  // own system, which is true by construction and needs no declaration. A
2057
2417
  // **module** takes the one it requires, or the one system it declares
2058
2418
  // when there is exactly one; with several and no gate there is no
2059
2419
  // package-wide answer, and each pack carries its own.
2060
- stats: normalizeStats(input.stats, {
2061
- systemId: packageWideSystemId({
2062
- packageKind,
2063
- foundryPackage,
2064
- requiresSystem,
2065
- systems,
2066
- relationshipSystems,
2067
- }),
2068
- // Derived here where the answer is pure data — the `verified` of
2069
- // whichever system the package-wide block takes — and supplied by
2070
- // the loader otherwise. The loader is the half that may do I/O, and
2071
- // the two cases needing it are a *system* package (its own
2072
- // `package.json` version) and a module still deriving from
2073
- // `relationships.systems`.
2074
- systemVersion:
2075
- (() => {
2076
- const id =
2077
- requiresSystem ??
2078
- (Object.keys(systems).length === 1 ? Object.keys(systems)[0] : null);
2079
- return id ? (systems[id]?.compatibility?.verified ?? null) : null;
2080
- })() ??
2081
- (isPlainObject(input.stats) ? input.stats[DERIVED_SYSTEM_VERSION] : null) ??
2082
- null,
2083
- }),
2420
+ stats:
2421
+ documentation ? null : (
2422
+ normalizeStats(input.stats, {
2423
+ systemId: packageWideSystemId({
2424
+ packageKind,
2425
+ foundryPackage,
2426
+ requiresSystem,
2427
+ systems,
2428
+ relationshipSystems,
2429
+ }),
2430
+ // Derived here where the answer is pure data the `verified` of
2431
+ // whichever system the package-wide block takes and supplied by
2432
+ // the loader otherwise. The loader is the half that may do I/O, and
2433
+ // the two cases needing it are a *system* package (its own
2434
+ // `package.json` version) and a module still deriving from
2435
+ // `relationships.systems`.
2436
+ systemVersion:
2437
+ (() => {
2438
+ const id =
2439
+ requiresSystem ??
2440
+ (Object.keys(systems).length === 1 ?
2441
+ Object.keys(systems)[0]
2442
+ : null);
2443
+ return id ? (systems[id]?.compatibility?.verified ?? null) : null;
2444
+ })() ??
2445
+ (isPlainObject(input.stats) ? input.stats[DERIVED_SYSTEM_VERSION] : null) ??
2446
+ null,
2447
+ })
2448
+ ),
2084
2449
  itemBuilders,
2085
2450
  itemArt,
2086
2451
  itemFields,
@@ -2097,10 +2462,12 @@ export function defineConfig(config) {
2097
2462
  itemTypes,
2098
2463
  docEntryTypes,
2099
2464
  skipDirectories: Object.freeze(skipDirectories),
2465
+ icons: normalizeIcons(input.icons, rootDir),
2100
2466
  packs: Object.freeze(packs),
2101
2467
  packDirectories: Object.freeze(packDirectories),
2102
2468
  docs: normalizeDocs(input.docs),
2103
2469
  site: normalizeSite(input.site),
2470
+ pdf: normalizePdf(input.pdf, rootDir),
2104
2471
  compatibility: normalizeCompatibility(input.compatibility, "compatibility"),
2105
2472
  relationships: normalizeRelationships(input.relationships),
2106
2473
  systems,