@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
package/CONTENT.md CHANGED
@@ -25,8 +25,10 @@ A consuming repository declares one `package-build.config.yaml` at its root:
25
25
  # every canonical address, the name of the content index it emits, and the
26
26
  # package a cross-package wikilink writes to reach one of its notes.
27
27
  contentPackage: thalorna
28
- # Where Foundry installs it: "systems" or "modules". Also decides the served
29
- # asset root a note's `img:` resolves to — `modules/sohl-thalorna/assets/…`.
28
+ # What kind of package this is. "systems" and "modules" are where Foundry
29
+ # installs it, and each decides the served asset root a note's `img:` resolves
30
+ # to — `modules/sohl-thalorna/assets/…`. "documentation" is a package Foundry
31
+ # never installs; see "A package that compiles nothing" below.
30
32
  packageKind: modules
31
33
 
32
34
  # The Foundry core range this package supports. `minimum` is stamped into every
@@ -65,6 +67,35 @@ itemBuilders: sohl
65
67
  # Directory names the content walk ignores wherever they appear.
66
68
  skipDirectories: [Templates]
67
69
 
70
+ # Optional. The icon fonts this package ships and the names its notes draw from
71
+ # them. Nothing is supplied by default: an entry is a promise that a glyph will
72
+ # render, and only the package shipping the font can keep it.
73
+ #
74
+ # Either the registry itself, or a path to a file holding it — the file form is
75
+ # what a real package wants, because a registry is generated from what the
76
+ # interface draws and a generated document inlined here conflicts on every
77
+ # regeneration.
78
+ icons: assets/icon-registry.yaml
79
+ #
80
+ # The same, written inline:
81
+ #
82
+ # icons:
83
+ # families:
84
+ # # `class` is the stylesheet prefix; `styles` are the weights the font
85
+ # # ships, and `[]` means it has none.
86
+ # fontawesome: { class: fa, styles: [solid, regular, brands], describe: Font Awesome Free }
87
+ # game-icons: { class: ginf, styles: [], describe: the Game-Icons webfont }
88
+ # # Which family an entry with no `family` belongs to. Optional where exactly
89
+ # # one is declared, since then there is nothing to choose between.
90
+ # defaultFamily: fontawesome
91
+ # icons:
92
+ # # A name is what a note writes between the colons, in the charset an
93
+ # # address segment uses. `fixedWidth` asks for a full advance, which a
94
+ # # glyph like an ellipsis needs to sit in a column of controls.
95
+ # being: { style: solid, icon: user, label: being }
96
+ # context-menu: { style: solid, icon: ellipsis-vertical, fixedWidth: true, label: context menu }
97
+ # vehicle: { family: game-icons, icon: old-wagon, label: vehicle }
98
+
68
99
  # Optional; each path is relative to this file's directory and defaults to the
69
100
  # conventional layout shown here.
70
101
  paths:
@@ -132,13 +163,13 @@ packageBuild:
132
163
  assets:
133
164
  - { from: assets/icons, to: assets/icons }
134
165
 
135
- # Three independent switches every combination is real plus the address
136
- # scheme both `manifest` and `site` derive addresses under. `site` is a mode,
137
- # not a boolean: `homepage` (the default) publishes the authored homepage and
138
- # no other page; `content` publishes it plus every page the tree compiles to.
166
+ # How much of the package reaches the web, plus the address scheme both
167
+ # `manifest` and `site` derive addresses under. `site` is a mode, not a
168
+ # boolean: `homepage` (the default) publishes the authored homepage and no
169
+ # other page; `content` publishes it plus every page the tree compiles to, and
170
+ # is also what builds the book.
139
171
  publish:
140
172
  site: content
141
- manifests: { publish: true, consume: true }
142
173
  address:
143
174
  prefix: kb/
144
175
 
@@ -153,8 +184,8 @@ site:
153
184
 
154
185
  The loader validates the document, resolves every path against the directory
155
186
  the file sits in, fills the optional halves with their defaults
156
- (`skipDirectories: []`, `packageBuild: {}`, the conventional `paths`, both
157
- manifest switches off and `publish.site` at its `homepage` floor),
187
+ (`skipDirectories: []`, `packageBuild: {}`, the conventional `paths` and
188
+ `publish.site` at its `homepage` floor),
158
189
  derives `assetRoot`, `packDirectories`, `itemTypes` and `docEntryTypes`, and
159
190
  freezes the result. A malformed configuration throws a `TypeError` naming the
160
191
  offending field — and the line and column it was written on, in the
@@ -183,6 +214,69 @@ never existed. A module declaring no usable system relationship fails the build
183
214
  rather than guessing — a wrong `_stats.systemVersion` is invisible until
184
215
  something migrates on it.
185
216
 
217
+ ### A package that compiles nothing
218
+
219
+ `packageKind: documentation` is the answer "not a Foundry package at all". It
220
+ publishes a website and the book built from the same notes, and compiles no
221
+ compendium: nothing is installed into a Foundry data directory, so there is no
222
+ manifest, no pack and no document.
223
+
224
+ ```yaml
225
+ contentPackage: toolkit
226
+ packageKind: documentation
227
+
228
+ # Required, and `content`: publishing the tree is the whole of what this kind
229
+ # does. `homepage` would leave one authored page, no book and nothing compiled.
230
+ publish:
231
+ site: content
232
+ address: { prefix: guide/ }
233
+
234
+ site:
235
+ out: site/content
236
+
237
+ pdf:
238
+ title: The Toolkit
239
+ document: book.yaml
240
+ fonts: { serif: Libertinus Serif }
241
+ ```
242
+
243
+ `contentPackage`, `paths`, `skipDirectories`, `icons`, `site`, `pdf` and
244
+ `packageBuild` mean exactly what they mean anywhere else. **Every key that
245
+ describes a Foundry package is refused**, by name, with the line and column it
246
+ was written on — `packs`, `itemBuilders`, `docs`, `compatibility`,
247
+ `relationships`, `systems`, `requiresSystem`, `stats` and `foundryPackage`:
248
+
249
+ ```text
250
+ package-build.config.yaml:7:1: error: package-build config: `packs` is refused in a `documentation` package, which compiles no compendium, so there are no packs to declare.
251
+ ```
252
+
253
+ A key that cannot mean anything in this shape fails at load rather than being
254
+ read and ignored, which is the difference between a configuration that is wrong
255
+ and a build that is quietly wrong.
256
+
257
+ What follows from the kind:
258
+
259
+ - **`foundryPackage` is not derived.** For every other kind the loader reads it
260
+ from the adjacent `package.json`; there is no Foundry package here to carry an
261
+ id, so `foundryPackage` and `assetRoot` are both `null`. A note's `img:`
262
+ therefore names the owning package (`systems/…`, `modules/…`) or a URL — a
263
+ path this package would have to serve itself is refused, because Foundry
264
+ serves no files for a package it does not install.
265
+ - **`package-build manifest` refuses**, rather than emitting a `module.json`
266
+ advertising an installable package with no id, no packs and no compatibility
267
+ range. So does `content-build package compile`, which would otherwise exit 0
268
+ having compiled nothing.
269
+ - **Notes need no `id:`.** An id is derived from a note's address, and a
270
+ hand-assigned one is only ever a pin for a document that already shipped —
271
+ which needs a compendium, and there is none.
272
+ - **The note vocabulary is `doc` and `homepage`.** Every other type exists to
273
+ become a Foundry document, so a note carrying one has no destination;
274
+ `content-build lint` reports it at its `type:` line.
275
+ - **A content index is still published.** `content-build content-index` emits
276
+ `<contentPackage>-metadata.jsonl` exactly as it does elsewhere, so another
277
+ package can resolve an address into this one and a wikilink from its notes can
278
+ reach a page here.
279
+
186
280
  ### A note's package is the repository's, not the note's
187
281
 
188
282
  `contentPackage` is the **address namespace** every note in the tree is
@@ -211,7 +305,7 @@ package's id to _be_ its system id.
211
305
  Because it is a segment of an address, the value is **validated** rather than
212
306
  taken as written, and a violation fails the build naming the line it is on:
213
307
 
214
- - **Alphanumeric** (`^[A-Za-z0-9]+$`). An address is read by counting
308
+ - **Lowercase alphanumeric** (`^[a-z0-9]+$`). An address is read by counting
215
309
  hyphen-separated segments, so the hyphen has to be purely a separator — which
216
310
  is why `harn-adventures` is configured as `harnadventures`. This is the same
217
311
  rule `shortcode` is already held to, and the two are one constant.
@@ -228,7 +322,7 @@ taken as written, and a violation fails the build naming the line it is on:
228
322
  refused.
229
323
 
230
324
  ```text
231
- package-build.config.yaml:1:1: error: package-build config: `contentPackage` is `harn-adventures`, which is not alphanumeric. It is the first segment of every address this package publishes (`harn-adventures-<system>-<type>-<shortcode>`), and an address is read by counting hyphen-separated segments — so anything outside `[A-Za-z0-9]` here makes those addresses unreadable rather than merely ugly. `harn-adventures` became `harnadventures`.
325
+ package-build.config.yaml:1:1: error: package-build config: `contentPackage` is `harn-adventures`, which is not lowercase alphanumeric (^[a-z0-9]+$). It is the first segment of every address this package publishes (`harn-adventures-<system>-<type>-<shortcode>`), and an address is read by counting hyphen-separated segments — so anything outside that here makes those addresses unreadable rather than merely ugly. `harn-adventures` became `harnadventures`.
232
326
  ```
233
327
 
234
328
  **`package:` in a note's frontmatter is retired, and declaring it fails the
@@ -283,7 +377,7 @@ export default defineConfig({
283
377
  foundryPackage: "sohl-kethira-basic",
284
378
  packageKind: "modules",
285
379
  compatibility: { minimum: "14.359", verified: "14.364" },
286
- stats: { systemId: "sohl", systemVersion: "0.4.3", lastModifiedBy: "…" },
380
+ stats: { lastModifiedBy: "…" },
287
381
  itemBuilders: ITEM_BUILDERS,
288
382
  packs: [{ name: "items", type: "Item" }],
289
383
  });
@@ -349,7 +443,7 @@ pack: mysteries
349
443
  that type's default implicitly; a type with several designates one with
350
444
  `default: true`. Where several exist and none is marked, a declaration is
351
445
  **mandatory** and an undeclared note fails the build.
352
- - **A default is per system, not merely per type** (#58). The rule above counts
446
+ - **A default is per system, not merely per type**. The rule above counts
353
447
  every pack of a type together, so a tree shipping one Actor pack per system has
354
448
  two and would need a flag — except that asked _per system_ the layout is
355
449
  unambiguous, one pack each, for the same reason a single-pack type needs no
@@ -373,11 +467,11 @@ pack: mysteries
373
467
  it — an item's or a macro's prose, which compiles into a JournalEntry of its
374
468
  own — lands in the default pack of _that_ type.
375
469
  - **Every document a note produces needs a pack, and one that has none is a
376
- finding** (#152). A note produces more than one document as a matter of
470
+ finding**. A note produces more than one document as a matter of
377
471
  course: an item note an Item and the JournalEntry its prose becomes, a map
378
472
  note a Scene and a JournalEntry, an actor note an Actor and a JournalEntry
379
- since #337. Where the configuration declares no pack for one of them, that
380
- document used to be dropped while the rest of the note compiled into a pack
473
+ too. Where the configuration declares no pack for one of them, that
474
+ document would be dropped while the rest of the note compiled into a pack
381
475
  that does exist — a build that succeeds and ships half of what was written.
382
476
  The finding names the note, the class with no pack, and the class that did
383
477
  compile, which is what distinguishes it from a note **nothing** claims: that
@@ -388,7 +482,7 @@ pack: mysteries
388
482
  no doc — so a tree of deliberately description-less items loses nothing by
389
483
  having no JournalEntry pack, and is told nothing. And it names no system, so a
390
484
  type one system maps and another does not stays silent for the system that
391
- declines it, per #79.
485
+ declines it.
392
486
 
393
487
  **The configuration is found by walking up from the working directory, and from
394
488
  the installed package only if that finds nothing.** `engine/pack-config.mjs`
@@ -402,7 +496,7 @@ with **no `node_modules` of its own** resolves `@heroiclands/package-build` out
402
496
  of the parent's, because Node's resolution walks parent directories. Climbing
403
497
  from the module then landed on the _parent's_ configuration, and the build
404
498
  compiled the parent's content tree into the parent's `build/` and exited 0
405
- (#364). When both walks find a configuration and they disagree, the working
499
+ . When both walks find a configuration and they disagree, the working
406
500
  directory's is read and the ignored one is named in a warning — that
407
501
  disagreement is also the cheapest signal that this tree is building on another
408
502
  checkout's `node_modules`. Run `npm ci` in the worktree to give it its own.
@@ -435,7 +529,7 @@ resolved set at runtime; the compilers and the link-manifest emitter cannot come
435
529
  to disagree about which notes carry documentation.
436
530
 
437
531
  **Every note compiling into a system-bearing document carries documentation, and
438
- that includes actors** (#337). `docEntryTypes` is `itemTypes` plus the actor
532
+ that includes actors**. `docEntryTypes` is `itemTypes` plus the actor
439
533
  types the shipped subtype maps declare (`ACTOR_TYPES`, derived from them rather
440
534
  than listed again), plus `macro` and the map types. Only `doc` is outside it, for
441
535
  the reason that actually applies to it: its single document _is_ the prose.
@@ -487,7 +581,7 @@ than one registry declares, **throws** rather than answering with whichever was
487
581
  declared first.
488
582
 
489
583
  **`hm3` is a real registry, and the rest of the pipeline follows the same
490
- field.** Since #139 this package ships two system halves, `sohl/` and `hm3/`,
584
+ field.** This package ships two system halves, `sohl/` and `hm3/`,
491
585
  each with its own builders, its own default art and its own note-type →
492
586
  document-subtype map; they share the engine between them and import nothing from
493
587
  each other. A pack's `system:` is what selects among them — the Item and Actor
@@ -513,10 +607,9 @@ what its documents are stamped `_stats.systemId` and `systemVersion` from, and
513
607
  the version can only come from that block — or, for a package whose packs are
514
608
  all for its own system, from the package-wide stats. A pack naming a system that
515
609
  resolves to neither is refused at configuration time, naming the pack and the
516
- entry to add. It used to fall through and stamp `null` for both, which is the
517
- plausible lie #43 was about arriving by the one path the check did not cover:
518
- `harn-ensemble` shipped 2,513 compiled actors that way, out of a pack whose
519
- configuration says `system: sohl` on the line above.
610
+ entry to add. Falling through and stamping `null` for both is a plausible lie:
611
+ a pack whose configuration says `system: sohl` on the line above ships every
612
+ compiled actor with no system at all.
520
613
 
521
614
  No `default: true` anywhere, because each system has exactly one pack of each
522
615
  type and a default is resolved per system. Marking one is still allowed and
@@ -536,12 +629,12 @@ note says which by writing `hm3.type`. Nothing is inferred from the note's own
536
629
  `subType`, and a note that says nothing is an error naming the note and listing
537
630
  the permitted values.
538
631
 
539
- **Configuration is the source, and the manifest is generated from it.** That
540
- arrow used to point the other way: `paths.packageManifest` said where a
541
- hand-authored `system.template.json` lived, and the package-id guard and the
542
- `_stats.coreVersion` stamp both read out of it. Both are gone — the floor is the
543
- top-level `compatibility.minimum`, the id is derived from `package.json`
544
- `name`, and `@heroiclands/package-build` writes the manifest from this file.
632
+ **Configuration is the source, and the manifest is generated from it.** The
633
+ floor is the top-level `compatibility.minimum`, the id is derived from
634
+ `package.json` `name`, and `@heroiclands/package-build` writes the manifest from
635
+ this file. There is no `paths.packageManifest` pointing at a hand-authored
636
+ `system.template.json` for a package-id guard or a `_stats.coreVersion` stamp to
637
+ read out of.
545
638
 
546
639
  ### Declaration order is presentation, not compile order
547
640
 
@@ -572,11 +665,11 @@ passes that had to move. When the two orders differ the build says so:
572
665
  another's output compiles after it, whatever order `packs:` declares.
573
666
  ```
574
667
 
575
- This used to be the author's problem, and a nasty one: an Actor pack declared
668
+ This is not the author's problem, and it would be a nasty one: an Actor pack declaring
576
669
  first compiled only where an earlier run had already left `build/packs-json`
577
670
  populated. `build/` is gitignored, so it was green on every local tree that had
578
671
  built once and exit 1 on every fresh checkout and CI runner, over a message that
579
- named a missing directory rather than the ordering that caused it (#73). A
672
+ named a missing directory rather than the ordering that caused it. A
580
673
  consumer registering a compiler of its own declares its dependencies the same
581
674
  way; a type no pack of which is declared is simply not waited for.
582
675
 
@@ -630,7 +723,9 @@ and there are exactly three answers:
630
723
 
631
724
  `assetRoot` is derived, never authored: it is
632
725
  `<packageKind>/<foundryPackage>/assets`, and it is the one place `systems/sohl`
633
- (or `modules/sohl-thalorna`) is ever spelled. So `icons/relic.svg` in a module's
726
+ (or `modules/sohl-thalorna`) is ever spelled. The derivation is **conditional on
727
+ the kind** — a `documentation` package has no asset root, and the third row is
728
+ refused there rather than rooted. So `icons/relic.svg` in a module's
634
729
  registry compiles to `modules/sohl-relics/assets/icons/relic.svg`, and the same
635
730
  string in the system's compiles to `systems/sohl/assets/icons/relic.svg`. An
636
731
  already-served `systems/sohl/assets/icons/…` passes through untouched — which is
@@ -654,7 +749,7 @@ address different places — `img:` a file Foundry serves, `banner:` a file the
654
749
  CDN serves — so they are stated apart rather than reconciled. See the
655
750
  [content format specification](docs/content-format.md#banner-addresses-the-cdn-not-the-foundry-install).
656
751
 
657
- #### "Names no art" and "wants no art" are different (#218)
752
+ #### "Names no art" and "wants no art" are different
658
753
 
659
754
  A note has two ways to leave `img:` empty, and they mean opposite things:
660
755
 
@@ -668,7 +763,7 @@ A note has two ways to leave `img:` empty, and they mean opposite things:
668
763
  `resolveImg` returns `null` for the first two and `""` for the third, and every
669
764
  caller pairs its default with **nullish** coalescing — `resolveImg(fm.img) ?? itemArt(type)`.
670
765
  Never `||`: that collapses a deliberate blank back into the default and takes the
671
- distinction away again, which is exactly what the function used to do.
766
+ distinction away again.
672
767
 
673
768
  **`portrait` is the same field twice over.** A being carries `img` (its token
674
769
  art) and `portrait` (its sheet portrait) independently, and both resolve through
@@ -681,13 +776,11 @@ rather than two.
681
776
  > **The rule is `img`'s, and does not extend to `title`.** `title` is not art
682
777
  > and never reaches `resolveImg`, so nothing here applies to it.
683
778
  >
684
- > The reason used to be sharper, and is no longer true: a note's top-level
685
- > `title` was _also_ the shared source for an `affiliation` item's `system.title`
686
- > (`sohl/item-fields.mjs`, "the style of address the office carries"), so one
687
- > authored key fed two unrelated destinations that disagreed about what empty
688
- > means — and `title: null` stringified into the compiled document as the literal
689
- > `"null"`. The field declares `topLevelMeans` now, so the top-level key is no
690
- > longer a source for it (#218).
779
+ > The field declares `topLevelMeans`, so the top-level key is not a source for
780
+ > an `affiliation` item's `system.title`. Were it one, a single authored key
781
+ > would feed two unrelated destinations that disagree about what empty means,
782
+ > and `title: null` would stringify into the compiled document as the literal
783
+ > `"null"`.
691
784
  >
692
785
  > So `title: null` is a note declining to state a heading, and the site emitter's
693
786
  > `fm.title ?? name` falls back to `name.full`. `title: ""` publishes a
@@ -699,9 +792,9 @@ rather than two.
699
792
  > not answer for the page's heading. Twenty-eight `sohl-kethira-basic`
700
793
  > affiliations write `sohl.title: ""`, meaning an office with no style of
701
794
  > address, and every one of them was reported as publishing a blank heading
702
- > until the check honoured the declaration (#312).
795
+ > until the check honoured the declaration.
703
796
 
704
- Because `""` used to mean "unset", a note still carrying that spelling has
797
+ Because `""` reads as "unset" elsewhere, a note carrying that spelling has
705
798
  quietly changed meaning, and the frontmatter lint says so — for either art
706
799
  field:
707
800
 
@@ -736,7 +829,7 @@ one failed the build with an error naming a module in someone else's package.
736
829
 
737
830
  Widening that map was not the fix. It is deliberately SoHL data, shared with the
738
831
  runtime's `SohlItem.getDefaultArtwork` so that the build-time and runtime
739
- defaults are one list and cannot drift (SoHL#932/#1510). Pairing art with the
832
+ defaults are one list and cannot drift. Pairing art with the
740
833
  builder instead moves it onto the seam a type is _already_ declared through, and
741
834
  costs the `sohl` package nothing: `ITEM_BUILDERS` reads each entry's image out of
742
835
  that same map, so there is still exactly one map — and the drift a test used to
@@ -770,7 +863,7 @@ to be written at.
770
863
  reaches `sohl.system.templatePriority` and `hm3.flags.hm3.templatePriority` —
771
864
  exactly as `portrait` reaches two differently-named fields from one shared
772
865
  property. A number is a template at that priority, `null` is not a template, and
773
- absent is an authoring error (#126, #266). The legacy in-block and top-level
866
+ absent is an authoring error. The legacy in-block and top-level
774
867
  positions are still read, in that order after `data:`, so a tree sweeps on its
775
868
  own schedule; `archetype` is the retiring spelling of the same field, still read
776
869
  last but **refused by the frontmatter linter** — a priority and the `archetypes`
@@ -809,7 +902,7 @@ system `S` is:
809
902
  a **dotted path** (`data.portrait`) rather than a sibling key;
810
903
  4. the field's own default.
811
904
 
812
- `FieldSpec.name` is that declared source. It used to mean "frontmatter key under
905
+ `FieldSpec.name` is that declared source. Read as "frontmatter key under
813
906
  `sohl:`", which is the degenerate case where source and destination happen to
814
907
  share a name.
815
908
 
@@ -821,7 +914,7 @@ is _the title of the note_ — the heading its page publishes under, which the
821
914
  site emitter reads. An `affiliation` item's `system.title` is _the style of
822
915
  address the office carries_ — Ajaw, Warden, a person's style within the body.
823
916
  They are unrelated quantities, and step 3 used to feed the second from the first
824
- (#218).
917
+ .
825
918
 
826
919
  That was not merely untidy, because **step 3 answers without applying
827
920
  `field.default`** — only step 2 does — so an authored `title: null` reached the
@@ -841,7 +934,7 @@ side too.** If the two positions hold unrelated quantities, then the _in-block_
841
934
  position is not the note-level field either — so a check about a note-level field
842
935
  (the page's heading, an art path) reads past a block key the note's own type
843
936
  claims for something else. Declaring `topLevelMeans` settles both directions at
844
- once; it was read for the emitted field alone until #312, which is how an
937
+ once. Reading it for the emitted field alone is how an
845
938
  affiliation's office style came to answer for its page heading.
846
939
 
847
940
  **An exempted field is still authorable**, at the two positions that describe the
@@ -951,9 +1044,10 @@ npx content-build lint some/tree # or a tree named outright
951
1044
  Checks the three rules every note's **identity** is authored against, and reports
952
1045
  each finding in the located form below:
953
1046
 
954
- - **Shape** — a `shortcode` is strictly ASCII-alphanumeric. It is the identity
955
- key referenced from saved world data, and half of the `type-shortcode`
956
- address, whose parse needs the separating hyphen to be the only hyphen.
1047
+ - **Shape** — a `shortcode` is strictly lowercase ASCII-alphanumeric. It is
1048
+ the identity key referenced from saved world data, and half of the
1049
+ `type-shortcode` address, whose parse needs the separating hyphen to be the
1050
+ only hyphen.
957
1051
  - **Uniqueness** — `(type, shortcode)` names one note. A document is addressed
958
1052
  across _every_ pack of its document type, so routing two same-address notes to
959
1053
  different packs with `pack:` does not separate them.
@@ -968,14 +1062,14 @@ is exactly what a tree that failed to check out produces.
968
1062
  What that guard reports is an **empty walk**, not an empty set of addresses. A
969
1063
  note may be keyless — a folder document carries no `shortcode` — so a tree of
970
1064
  them is populated, correct and unkeyed. That tree passes; a tree holding no
971
- notes at all still fails. (The homepage used to be the headline example, because
1065
+ notes at all still fails. (The homepage is not the headline example, though it was once
972
1066
  it was addressed by the package rather than by a slug. It carries an address
973
- like every other note now (#182); the guard is unchanged, because what it reads
1067
+ like every other note now; the guard is unchanged, because what it reads
974
1068
  was never the key count.)
975
1069
 
976
1070
  ### Exactly one homepage
977
1071
 
978
- A content tree declares **exactly one** `type: homepage` note (#52). Zero is an
1072
+ A content tree declares **exactly one** `type: homepage` note. Zero is an
979
1073
  error and two is an error, at the same severity, because they are one defect: a
980
1074
  package whose front page is not the page a person chose.
981
1075
 
@@ -983,10 +1077,8 @@ package whose front page is not the page a person chose.
983
1077
  authored homepage exists to prevent, and a silent one: the site build reports
984
1078
  `wrote 0 homepage(s)` and exits 0.
985
1079
  - _Two_ and it serves a page nobody chose. **This is a cardinality rule, and
986
- since #182 it is only that.** It used to rest on the fixed destination every
987
- homepage shared the second overwrote the first — so the address rule
988
- enforced it as a side effect. A homepage is written at its own address now, so
989
- two of them publish two pages and collide over nothing: the duplicate-address
1080
+ only that.** A homepage is written at its own address, so two of them publish
1081
+ two pages and collide over nothing: the duplicate-address
990
1082
  check catches only the pair that happen to share a shortcode, and says nothing
991
1083
  at all about a `homepage-root` beside a `homepage-front`. Which of the two the
992
1084
  redirect at `/<package>/` should name is a question nothing here can answer,
@@ -1022,7 +1114,7 @@ assets/content/homepage.md:3:7: error: duplicate `type: homepage` note, also dec
1022
1114
  ### Frontmatter, against the schema its type declares
1023
1115
 
1024
1116
  The same command also checks that each note's `sohl:` block is what its **type**
1025
- allows (#19). Five classes, all of them mistakes that were previously reported
1117
+ allows. Five classes, all of them mistakes that were previously reported
1026
1118
  somewhere other than where they were made, or not at all:
1027
1119
 
1028
1120
  - **Unknown or retired type** — a note on a retired spelling is told what
@@ -1055,7 +1147,7 @@ Nothing here writes. A check reports and an author fixes.
1055
1147
 
1056
1148
  ### The `data:` container is closed; the top level is not
1057
1149
 
1058
- A note's frontmatter has three regions, and only one of them is open (#128):
1150
+ A note's frontmatter has three regions, and only one of them is open:
1059
1151
 
1060
1152
  | region | describes | an unknown key is |
1061
1153
  | ---------------- | -------------------------------------------- | ------------------ |
@@ -1071,7 +1163,7 @@ the page's description.
1071
1163
 
1072
1164
  **`data:` is deliberately closed**, and that is the point of having it. The
1073
1165
  type-specific facts about a subject — a weapon's weight, an affliction's
1074
- transmission, a being's species — used to sit at the top level, where the
1166
+ transmission, a being's species — do not sit at the top level, where the
1075
1167
  pass-through rule applied to them too. So a misspelled `wieght` became a theme
1076
1168
  parameter rather than a finding, indistinguishable from a weapon that weighs
1077
1169
  nothing. Under `data:` the same key is reported where it was written, with the
@@ -1082,14 +1174,14 @@ assets/content/Gear/Axe.md:14:5: error: "wieght" is not a `data:` property of a
1082
1174
  ```
1083
1175
 
1084
1176
  **A system block is closed too, and which blocks exist is the configuration's
1085
- answer** (#58). A package is held to the blocks named after the systems it
1177
+ answer**. A package is held to the blocks named after the systems it
1086
1178
  declares it ships for, read from the three places that already declare them:
1087
1179
  `systems:`, a pack's own `system:`, and `stats.systemId` where neither is
1088
1180
  written. So a package shipping for HM3 has its `hm3:` block checked and a
1089
- package shipping for SoHL its `sohl:`. It used to be a constant, and the
1090
- constant was `sohl`: an `hm3:` block was never read at all, so every key in it
1091
- was discarded at compile without a word, while the block that _was_ checked was
1092
- named after a system the package does not ship for.
1181
+ package shipping for SoHL its `sohl:`. Held constant at `sohl`, an `hm3:` block
1182
+ would never be read at all: every key in it would be discarded at compile
1183
+ without a word, while the block that _was_ checked would be named after a system
1184
+ the package does not ship for.
1093
1185
 
1094
1186
  A pack's `system:` counts because it is already authoritative at compile — a
1095
1187
  note routed to a pack declaring one and carrying no such block fails the build —
@@ -1125,25 +1217,25 @@ values. A `weapon` declares none — SoHL distinguishes a weapon's uses by strik
1125
1217
  mode rather than by kind — so `subType` on one is a finding; a `skill` declares
1126
1218
  ten, so `subType: crafte` is a finding naming `craft`.
1127
1219
 
1128
- **A `type` and a `subType` are both held to `^[A-Za-z0-9]+$`** (#206) — the same
1220
+ **A `type` and a `subType` are both held to `^[a-z0-9]+$`** — the same
1129
1221
  constant a `shortcode` is held to, read rather than restated. A type is a
1130
1222
  segment of every address — the first of the short form an author writes, the
1131
1223
  third of the canonical `package-system-type-shortcode` — so a hyphen in one is
1132
1224
  read back as a segment boundary nobody wrote. A `subType` reaches no address
1133
- since #204 retired sections, and keeps the rule anyway: it is a vocabulary term the toolchain keys
1225
+ sections are retired and keeps the rule anyway: it is a vocabulary term the toolchain keys
1134
1226
  on, and one charset that holds for every term is a rule an author can state. The
1135
1227
  rule is checked ahead of the closed-set check, which is what makes it reach a
1136
1228
  type whose values are declared but not yet enumerated:
1137
1229
 
1138
1230
  ```text
1139
- assets/content/Beings/Folk.md:3:1: error: `subType` "common-folk" is not a well-formed subType — a subType is letters and digits only (^[A-Za-z0-9]+$), the same charset a type, a shortcode and a contentPackage are held to. …
1231
+ assets/content/Beings/Folk.md:3:1: error: `subType` "common-folk" is not a well-formed subType — a subType is letters and digits only (^[a-z0-9]+$), the same charset a type, a shortcode and a contentPackage are held to. …
1140
1232
  ```
1141
1233
 
1142
1234
  One declared value broke that rule: a `doc`'s `user-guide`, now **`userguide`**.
1143
1235
  The old spelling was accepted for one transitional release, as a warning naming
1144
1236
  the replacement, because an error would have redded every tree that took the
1145
1237
  release before it had a chance to sweep. Every tree has swept, so the acceptance
1146
- is gone (#210): `user-guide` is refused by the charset check like any other
1238
+ is gone: `user-guide` is refused by the charset check like any other
1147
1239
  hyphenated value, and nothing retirement-specific was left to remove.
1148
1240
 
1149
1241
  The vocabulary lives in `engine/note-vocabulary.mjs`, one entry per note type,
@@ -1151,20 +1243,16 @@ taken from the content-format specification. It is note-format knowledge rather
1151
1243
  than any system's: `data:` holds what is true of the thing, and what a system
1152
1244
  makes of that value is declared in that system's own half.
1153
1245
 
1154
- **A third rule was retired (#79).** Every note used to be required to repeat its
1155
- own `type-shortcode` address in `aliases:`. That served one reader — Obsidian,
1156
- so `[[type-shortcode]]` resolved in the editor — and no build ever read it: both
1157
- resolvers parse the hyphen qualifier themselves. The project no longer authors
1158
- in Obsidian, so the rule cost a line of frontmatter per note for a reader that
1159
- does not exist. Removing it was verified output-neutral first: across 1,735
1160
- stripped notes, `package compile` produced byte-identical `build/packs-json` and
1161
- the site build byte-identical `site/content`.
1246
+ **There is deliberately no third rule** requiring every note to repeat its own
1247
+ `type-shortcode` address in `aliases:`. It would serve one reader — Obsidian, so
1248
+ `[[type-shortcode]]` resolves in the editor — and no build reads it: both
1249
+ resolvers parse the hyphen qualifier themselves. The field is retired.
1162
1250
 
1163
- **And the top-level field itself is now retired (#180).** `aliases:` fed the
1251
+ **And the top-level field itself is now retired.** `aliases:` fed the
1164
1252
  alias index, which is what a bare `[[Alias]]` was looked up in. That form
1165
1253
  resolved to nothing anywhere in the corpus, while the collision rule guarding it
1166
1254
  folded in every note's `name.full` and so decided what a note could be named
1167
- (#179). Both are gone: every wikilink is an address, written
1255
+ . Both are gone: every wikilink is an address, written
1168
1256
  `[[type-shortcode|Text]]`, and declaring `aliases:` is refused naming the file
1169
1257
  and the line.
1170
1258
 
@@ -1201,7 +1289,7 @@ that harmless: three segments name a _system_ whatever the first segment could
1201
1289
  also have meant, and four is the full form.
1202
1290
 
1203
1291
  **Parsing is plain segment counting**, the same rule the canonical key follows,
1204
- and it is sound because every segment is `^[A-Za-z0-9]+$` — so a hyphen is
1292
+ and it is sound because every segment is `^[a-z0-9]+$` — so a hyphen is
1205
1293
  purely a separator. A target with five segments is not a hyphenated shortcode;
1206
1294
  it is a name, and not an address.
1207
1295
 
@@ -1220,12 +1308,12 @@ nothing today will read it.
1220
1308
 
1221
1309
  A homepage declares a `shortcode` — conventionally `root` — and publishes at its
1222
1310
  address, `/<package>/homepage-root/`, written by the same rule as everything
1223
- else (#182). So `[[homepage-root|Read the introduction]]` is an ordinary
1311
+ else. So `[[homepage-root|Read the introduction]]` is an ordinary
1224
1312
  wikilink, resolving to the page the build actually writes.
1225
1313
 
1226
1314
  It did not use to be. A page's URL derived from `name.full` while a homepage's
1227
1315
  destination was fixed at `_index.md`, so `content-build lint` **refused** `name`
1228
- and `shortcode` on one (#53) — not out of tidiness, but because they were not
1316
+ and `shortcode` on one — not out of tidiness, but because they were not
1229
1317
  inert. A `shortcode` put the note in the address index, so
1230
1318
  `[[homepage-<shortcode>]]` resolved _green_ to a page the site build never
1231
1319
  wrote, and a build reporting a live link to a 404 is worse than one saying
@@ -1274,7 +1362,7 @@ is emitted into the published page, so an unrecognised key is a Hugo or theme
1274
1362
  parameter this build has never heard of and has no standing to reject; a closed
1275
1363
  list would make every new theme parameter wait on a package-build release.
1276
1364
  `aliases` is not in the class either — it is a retired field now, refused on
1277
- every note whatever its type (#180).
1365
+ every note whatever its type.
1278
1366
 
1279
1367
  **Where it fires: `content-build lint` only.** Unlike a rule about the shape of
1280
1368
  the _tree_, which the site build has its own reason to gate on, this is a
@@ -1348,7 +1436,7 @@ serves.
1348
1436
 
1349
1437
  ### The homepage's own links
1350
1438
 
1351
- The homepage is the page a reader arrives at, and until #54 it was the one page
1439
+ The homepage is the page a reader arrives at, and it is the one page
1352
1440
  nothing checked. SoHL's landing pointed at `kb/creature/` and `kb/character/`
1353
1441
  from the day those two types merged into `being` — two 404s on the package's
1354
1442
  front page, through every build, because a landing's links went through no
@@ -1484,7 +1572,7 @@ npx content-build markdown --fix # apply the fixes markdownlint can make
1484
1572
  ```
1485
1573
 
1486
1574
  Two conventions every content repository writes to, declared once here so a note
1487
- formatted in one is formatted the same way in the next (#69):
1575
+ formatted in one is formatted the same way in the next:
1488
1576
 
1489
1577
  - **`format`** runs Prettier. Same values SoHL has always used, so a module or a
1490
1578
  note moving between repositories does not reformat on arrival.
@@ -1518,7 +1606,7 @@ alone: a `prettier.config.mjs` that spreads `PRETTIER_BASE` without the `**/*.md
1518
1606
  override reindents every note at 4, and a partial `.prettierrc` such as
1519
1607
  `{"tabWidth": 2}` silently discards `printWidth: 100`, `trailingComma` and the
1520
1608
  rest. So every `format` run first names, as warnings, each shared convention this
1521
- repository resolves differently (#133):
1609
+ repository resolves differently:
1522
1610
 
1523
1611
  ```text
1524
1612
  prettier.config.mjs: warning: markdown `tabWidth` is 4 here; the shared configuration says 2
@@ -1722,20 +1810,20 @@ names a _document_; a URL names a _page_. So a consumer deriving a page address
1722
1810
  from a manifest key drops the package **and** the system, not the package
1723
1811
  alone.
1724
1812
 
1725
- It used to come from `name.full`. That made a display name load-bearing three
1813
+ It does not come from `name.full`. That would make a display name load-bearing three
1726
1814
  ways at once — a rename silently 404'd every inbound link, two notes in one
1727
1815
  section could derive the same URL so a uniqueness gate had to run, and long names
1728
1816
  had to be shortened through a table of 200 abbreviations. The header of the
1729
1817
  module doing it justified the cost by promising redirects "every change appends
1730
1818
  to the legacy-URL map" — and no such map was ever written, here or in any
1731
- consumer. All of it is gone (#181).
1819
+ consumer. All of it is gone.
1732
1820
 
1733
1821
  The `type-` half earns its place: it keeps every content address clear of the
1734
1822
  package's fixed mounts (`/<package>/` for the landing page, `/<package>/api/` for
1735
1823
  generated API docs), neither of which contains a hyphen or names a type. So the
1736
1824
  namespace is provably disjoint rather than conventionally so.
1737
1825
 
1738
- **A page is written flat, named by its address** (#204). It used to be filed
1826
+ **A page is written flat, named by its address**, not filed
1739
1827
  into `<section>/`, because Hugo derives a page's section from where the file is
1740
1828
  written and a section gave it a landing page, `.CurrentSection` and a per-section
1741
1829
  layout lookup. But a section appears in no address, so the note format was
@@ -1745,7 +1833,7 @@ order to satisfy a rendering engine's directory semantics. The file is now
1745
1833
  the package root, one level above.
1746
1834
 
1747
1835
  **A page states its address without the package base; everything pointing _at_
1748
- it composes one** (#217). They read as one quantity and are two:
1836
+ it composes one**. They read as one quantity and are two:
1749
1837
 
1750
1838
  | Written | Form | Because |
1751
1839
  | ------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------- |
@@ -1754,7 +1842,7 @@ it composes one** (#217). They read as one quantity and are two:
1754
1842
  | A link-manifest `path` | `<type>-<shortcode>/` | Measured against `site.base` and stripped; a consumer prefixes its own |
1755
1843
 
1756
1844
  `site.base` is the second and third of those and reaches the first not at all.
1757
- It used to be written into the `url:` as well, so every consumer's Hugo prefixed
1845
+ Writing it into the `url:` as well makes every consumer's Hugo prefix
1758
1846
  its own base to a value that already carried one and published every content
1759
1847
  page a segment too deep — `/sohl/sohl/doc-rulesintro/`, 404 at the address the
1760
1848
  manifest, the sitemap and every inbound link named.
@@ -1763,7 +1851,7 @@ manifest, the sitemap and every inbound link named.
1763
1851
  addressed the section itself; that is retired with the section. A page that
1764
1852
  introduces the notes of a type is an ordinary note addressed `doc-<type>`, with
1765
1853
  no build path of its own — exactly as the package's own front page is
1766
- `homepage-root` (#182).
1854
+ `homepage-root`.
1767
1855
 
1768
1856
  **Sections stay, as configuration.** `site.sections` still writes an `_index.md`
1769
1857
  per section, and that is now the _only_ thing that makes one exist — see
@@ -1794,13 +1882,12 @@ publish:
1794
1882
  never recorded here.
1795
1883
 
1796
1884
  `prefix` is the whole scheme. The `collection` subtype and the top-level
1797
- `section:` key are refused by name (#202).
1885
+ `section:` key are refused by name.
1798
1886
 
1799
1887
  A note's `subType` is checked against the values its type declares, and only
1800
1888
  those. It briefly had a second reading — a `README` landing's `subType` was the
1801
- _address_ it landed at, so the closed genre list could not answer for it (#197,
1802
- #198, #200, #201) and #204 removed the cause rather than widening the
1803
- vocabulary again.
1889
+ _address_ it landed at, so the closed genre list could not answer for it. The
1890
+ cause is removed rather than the vocabulary widened again.
1804
1891
 
1805
1892
  The only note the scheme yields no address for is one carrying no `shortcode`.
1806
1893
  It is **reported and omitted**, never guessed: the command prints one located
@@ -2046,7 +2133,7 @@ That is the whole envelope. A homepage **compiles into no compendium
2046
2133
  document**, and so appears in no pack and in no link manifest — which is why it
2047
2134
  still refuses `id`. Everything else about its address is ordinary: it declares a
2048
2135
  `shortcode`, publishes at `/<contentPackage>/homepage-root/`, and is cited as
2049
- `[[homepage-root|Text]]` (#182 — see
2136
+ `[[homepage-root|Text]]` (see
2050
2137
  [The homepage is addressed like every other note](#the-homepage-is-addressed-like-every-other-note)).
2051
2138
  `/<contentPackage>/` itself is a redirect the package authors, not a page this
2052
2139
  build writes. It is dispatched on `type` like every other note, not on a
@@ -2068,8 +2155,8 @@ note-format knowledge against game-system knowledge, and a homepage carries no
2068
2155
  | `content` | The homepage plus every page the content tree compiles to, and its extra trees. |
2069
2156
 
2070
2157
  There is no value meaning "no web presence": every package publishes its
2071
- homepage. It was a boolean until 5.0.0, and both spellings are now refused
2072
- naming the mode to write instead — see [MIGRATING.md](MIGRATING.md).
2158
+ homepage. A boolean is refused, with a message naming the mode to write
2159
+ instead.
2073
2160
 
2074
2161
  **Homepage-only is a first-class mode, not an accommodation.**
2075
2162
  `sohl-kethira-basic` (unofficial Hârn fan material under Keléstia Productions'
@@ -2094,7 +2181,7 @@ site root, one level above the content mount, which is where
2094
2181
  `publish.address.prefix` puts everything else — under the name its address gives
2095
2182
  it, `homepage-root.md`. As with every other page, the front matter's `url`
2096
2183
  decides where it publishes, and states it relative to the site root — `site.base`
2097
- does not reach it (#217).
2184
+ does not reach it.
2098
2185
 
2099
2186
  **What it does not do is decide addresses.** Those come from `publish.address`,
2100
2187
  the same setting the content index reads, so a page and its index record cannot
@@ -2140,7 +2227,7 @@ site:
2140
2227
 
2141
2228
  ### What a section may declare
2142
2229
 
2143
- **`sections` is what a section _is_ now** (#204). A content page is addressed
2230
+ **`sections` is what a section _is_ now**. A content page is addressed
2144
2231
  `(type, shortcode)` and written flat under the mount, so no page creates a
2145
2232
  directory and nothing else makes `/<package>/<prefix><section>/` answer at all.
2146
2233
  A site that wants that address says so here, and this build writes the
@@ -2209,7 +2296,7 @@ Three things about the spelling, each of them load-bearing:
2209
2296
  site chose; a type and a subType are addresses. They need not agree, and on
2210
2297
  `sohl` they do not: the section is `user-guide`, because that is a published
2211
2298
  URL, while the subType is `userguide` because an address segment is
2212
- alphanumeric (#207). Both values are checked against that charset here, so
2299
+ lowercase alphanumeric. Both values are checked against that charset here, so
2213
2300
  copying the section's name in is refused rather than quietly matching nothing.
2214
2301
  - **`listSubType` needs a `listType`.** A subType only tells pages apart within
2215
2302
  a type — `rules`, `userguide` and `reference` are all `doc` — so alone it
@@ -2263,8 +2350,8 @@ to a code span.
2263
2350
  Leaving it unset is the legitimate empty case — every `{@link}` degrades, and
2264
2351
  nothing is reported. Setting it to a path that cannot be read, cannot be parsed,
2265
2352
  or does not hold a name → page object **fails the build**, naming the file and
2266
- the reason: those were all indistinguishable from "no symbols" until #75, so a
2267
- site could publish 224 dead `{@link}` tags at exit 0. A map that is read reports
2353
+ the reason. Left unreported they are indistinguishable from "no symbols", and
2354
+ a site publishes dead `{@link}` tags at exit 0. A map that is read reports
2268
2355
  its symbol count at info level, which is the only way to tell a map that loaded
2269
2356
  from one that loaded empty without reading the emitted HTML.
2270
2357
 
@@ -2297,6 +2384,131 @@ None of them exits the process from inside the library; the command decides. Tha
2297
2384
  is what makes them testable, which the consumer scripts' inline `process.exit`
2298
2385
  calls were not.
2299
2386
 
2387
+ ## Publishing a book
2388
+
2389
+ The third surface the content tree publishes, beside the compendium packs and
2390
+ the website: one PDF, built by `content-build pdf` and by `package-build release`.
2391
+
2392
+ ```bash
2393
+ npx content-build pdf # build it where `pdf.out` says
2394
+ npx content-build pdf --out build/book # somewhere else
2395
+ npx content-build pdf --no-compile # emit the Typst source and stop
2396
+ npx content-build pdf --version 1.4.0 # stamp a version on the title page
2397
+ ```
2398
+
2399
+ ### A book is a selection, not a rendering of everything
2400
+
2401
+ The packs and the website publish the _whole_ tree: every note becomes a
2402
+ document and a page, and the three surfaces agreeing about what the content is
2403
+ is the point. **A book is not that.** It is a declared structure whose leaves
2404
+ pick notes out of the corpus, interleaved with prose that need not live in the
2405
+ content tree at all.
2406
+
2407
+ `pdf.document` names that structure — a YAML file of nested sections, each
2408
+ holding some mixture of prose files and `filter:` clauses:
2409
+
2410
+ ```yaml
2411
+ contents:
2412
+ - sectionName: Gear
2413
+ contents:
2414
+ - file: prose/gear-preamble.md
2415
+ - filter: "type = 'weapongear'"
2416
+ - sectionName: Armour
2417
+ contents:
2418
+ - filter: "type = 'armorgear'"
2419
+ - sectionName: The Cast
2420
+ contents:
2421
+ - filter: "type = 'being' AND subType = 'npc'"
2422
+ ```
2423
+
2424
+ Three consequences follow, and each is behaviour rather than oversight:
2425
+
2426
+ - **A note no clause selects is not in the book.** A project decides what its
2427
+ own volume carries, so an omission is an editorial act and nothing reports it.
2428
+ - **A note several clauses select appears several times**, each occurrence its
2429
+ own page, outline node and anchor. Inbound wikilinks are pointed at the
2430
+ first, so `[[weapongear-dagger]]` reaches one place however often it prints.
2431
+ - **A filter that selects nothing _is_ reported.** A note nobody asked for is
2432
+ expected; a clause matching nothing is either wrong or left over from a
2433
+ structure that has moved on.
2434
+
2435
+ The `WHERE` clause runs against the same content index the SQL tables read, and
2436
+ the build owns the `SELECT … FROM notes` — so a filter cannot name a table, and
2437
+ cannot reach another package's notes.
2438
+
2439
+ ### What it is fenced by
2440
+
2441
+ **`publish.site` decides whether a book is built, and it is the only switch.**
2442
+ `content` builds one; `homepage` does not — the same fence that stops the tree
2443
+ being walked for pages stops it being walked for a book. A PDF of the content
2444
+ tree is a content surface by any reading, arguably the most portable one there
2445
+ is, so a package that publishes only a homepage publishes no book however its
2446
+ `pdf:` block is written. The command says so and exits 0.
2447
+
2448
+ That is also the opt-out: a package with no `pdf:` block, no content tree, or
2449
+ `publish.site: homepage` builds nothing and fails nothing.
2450
+
2451
+ ### Configuration
2452
+
2453
+ ```yaml
2454
+ pdf:
2455
+ title: The Hârn Ensemble # required
2456
+ subtitle: a roster of the ready-made
2457
+ document: book.yaml # required — the document tree above
2458
+ out: build/dist # default
2459
+ front: # prose before the contents
2460
+ - prose/colophon.md
2461
+ fonts:
2462
+ serif: Libertinus Serif
2463
+ sans: Libertinus Sans
2464
+ mono: DejaVu Sans Mono
2465
+ path: assets/fonts # where to find them, beyond the system's
2466
+ iconFonts: # icon family → the font carrying its glyphs
2467
+ fontawesome: assets/fonts/fa-solid-900.ttf
2468
+ binary: typst # when it is not simply `typst` on PATH
2469
+ ```
2470
+
2471
+ Nothing here is an address or a brand: the title, the front matter and the faces
2472
+ are the publishing repository's to choose, which is why they are configuration.
2473
+
2474
+ ### Typst is a binary, not a dependency
2475
+
2476
+ The compiler is an external program, found on `PATH` or named by `pdf.binary`.
2477
+ Bundling a native compiler would put a platform-specific artefact into the
2478
+ dependency tree of three repositories, only one of which is mostly a book, and
2479
+ it would have to resolve on every consumer's CI runner before any of them could
2480
+ install the toolchain at all.
2481
+
2482
+ A missing binary is a **finding**, not a failure: the `.typ` source is written
2483
+ anyway, which is both the diagnostic and the thing a consumer can compile by
2484
+ hand.
2485
+
2486
+ ### What the book gets right, and why each matters
2487
+
2488
+ | Property | How |
2489
+ | ------------------------ | ----------------------------------------------------------------------------- |
2490
+ | Searchable | Real text, not page images — a roster nobody can search for a name is no use. |
2491
+ | Bookmark outline | Every section and entry is a heading, so a viewer's sidebar is the way in. |
2492
+ | Page-numbered contents | `#outline()`, shallower than the bookmarks — 2,500 entries would be 40 pages. |
2493
+ | Repeating table headers | `table.header`, so a property table spilling a page keeps its column names. |
2494
+ | Internal cross-reference | A wikilink between two notes of the book becomes an internal destination. |
2495
+ | External cross-reference | A cross-package link, and a note the book did not select, stay URLs. |
2496
+
2497
+ `{#anchor}` on a heading becomes an internal destination namespaced by its
2498
+ entry, so `[[being-jaslyne#appearance]]` reaches the section and two notes may
2499
+ both declare `{#appearance}`. A reference to a destination the book does not
2500
+ carry falls back to the entry that would have held it, or to plain text, and is
2501
+ reported — because Typst treats a dangling reference as fatal, and one mistyped
2502
+ anchor should not take a thousand-page book down at the last step.
2503
+
2504
+ ### The charset is what makes a face choosable
2505
+
2506
+ A PDF embeds the faces it sets, so every character in the corpus is a claim on
2507
+ the book's typeface — and Typst does not warn about a missing glyph, it falls
2508
+ back and exits 0. That is why `content-build lint` holds content to a charset,
2509
+ and why `:icon-…:` names an icon rather than pasting one. Both exist for this
2510
+ surface. See _Prose: formatting and markdown_.
2511
+
2300
2512
  ## Diffing published addresses
2301
2513
 
2302
2514
  ```bash
@@ -2442,7 +2654,7 @@ affiliation standings. Each has its own entry point —
2442
2654
  `.../engine/region-events`, `.../sohl/affiliation-standings` — so a client bundle
2443
2655
  reaches the constant without importing a barrel that grows to hold compilers
2444
2656
  reading the filesystem. Keeping one copy of each is the point: the build-time and
2445
- runtime values cannot disagree, which is the drift that produced #932.
2657
+ runtime values cannot disagree.
2446
2658
 
2447
2659
  `@heroiclands/package-build/content-config` exposes the configuration contract's own
2448
2660
  module, so a consumer can name its types (`ContentBuildConfig`, `PackSpec`) from
@@ -2479,7 +2691,7 @@ was the system repository, because the package was vendored inside it (#1).
2479
2691
  shipping side. This package spent its first six changes as a workspace inside
2480
2692
  the Song of Heroic Lands repository, where npm hoisted the root's
2481
2693
  `devDependencies` into the workspace root: an import this package never declared
2482
- still resolved, and failed nowhere but a consumer's install (#1557). The test
2694
+ still resolved, and failed nowhere but a consumer's install. The test
2483
2695
  walks every module named by the `files` field and holds each bare specifier to
2484
2696
  one of three cases — a Node builtin, this package addressing itself, or a
2485
2697
  declared `dependency` — and checks the converse: nothing shipped may import a
@@ -2497,7 +2709,7 @@ the copy before the rest run. A module that hoisted a configured value to import
2497
2709
  time fails there, and only there.
2498
2710
 
2499
2711
  `tests/config-from-working-directory.test.ts` describes the resolution order
2500
- itself (#364). It builds the shape no unit test can fake — a repository with the
2712
+ itself. It builds the shape no unit test can fake — a repository with the
2501
2713
  toolchain installed under it, and a second checkout nested inside that
2502
2714
  repository with its own configuration and no `node_modules` — and asserts which
2503
2715
  configuration a build run in each place reads. The nested case is the one that
@@ -2522,7 +2734,7 @@ bump and the rewritten `CHANGELOG.md`. That pull request _is_ the pending
2522
2734
  release: as long as something is merged but unpublished, there is an open pull
2523
2735
  request saying so. This is the whole point of the pipeline — the previous,
2524
2736
  hand-driven process failed by leaving _nothing_ behind when the final step was
2525
- forgotten, and on 2026-08-21 it did exactly that for two versions (#15).
2737
+ forgotten, and on 2026-08-21 it did exactly that for two versions.
2526
2738
 
2527
2739
  **Merging that publishes.** `changeset publish` puts the version on npm through
2528
2740
  Trusted Publishing (OIDC — there is no `NPM_TOKEN`), tags the commit `v<version>`