@heroiclands/package-build 22.15.0 → 22.16.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 (223) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +39 -678
  3. package/bin/{content-build.mjs → content-commands.mjs} +197 -118
  4. package/bin/package-build.mjs +303 -42
  5. package/bin/report.mjs +1 -1
  6. package/ci/ci-docker.mjs +6 -3
  7. package/ci/ci-steps.mjs +6 -4
  8. package/ci/content-format-reference.mjs +106 -0
  9. package/config.mjs +22 -2
  10. package/content-config.mjs +74 -134
  11. package/docs/api.md +294 -234
  12. package/docs/authoring/assets.md +31 -0
  13. package/docs/authoring/dates-and-calendars.md +33 -0
  14. package/docs/authoring/first-note.md +48 -0
  15. package/docs/authoring/frontmatter.md +40 -0
  16. package/docs/authoring/links-and-markup.md +137 -0
  17. package/docs/commands.md +631 -162
  18. package/docs/configuration.md +176 -140
  19. package/docs/content-format.md +50 -4229
  20. package/docs/design.md +32 -0
  21. package/docs/diagnostics.md +29 -22
  22. package/docs/getting-started.md +21 -877
  23. package/docs/guides/book.md +153 -0
  24. package/docs/guides/e2e.md +143 -0
  25. package/docs/guides/icons.md +126 -0
  26. package/docs/guides/localization.md +120 -0
  27. package/docs/guides/module-project.md +915 -0
  28. package/docs/guides/packs.md +167 -0
  29. package/docs/guides/reference-pages.md +148 -0
  30. package/docs/guides/release-and-deploy.md +114 -0
  31. package/docs/guides/site.md +152 -0
  32. package/docs/guides/system-project.md +45 -0
  33. package/docs/index.md +16 -0
  34. package/docs/project-setup.md +54 -35
  35. package/docs/reference/format-details.md +4420 -0
  36. package/docs/reference/note-types.md +531 -0
  37. package/engine/address-render.mjs +26 -0
  38. package/engine/address.mjs +3 -20
  39. package/engine/anchors.mjs +15 -6
  40. package/engine/asset-bindings.mjs +6 -2
  41. package/engine/base-compiler.mjs +47 -11
  42. package/engine/being-measurements.mjs +70 -0
  43. package/engine/calendar-artifacts.mjs +1 -1
  44. package/engine/calendar-choice.mjs +32 -0
  45. package/engine/calendar-human.mjs +71 -0
  46. package/engine/calendar-notes.mjs +41 -3
  47. package/engine/calendars.mjs +8 -1
  48. package/engine/changelog-lint.mjs +2 -2
  49. package/engine/code-fences.mjs +23 -97
  50. package/engine/compile-corpus.mjs +6 -7
  51. package/engine/content-admonitions.mjs +114 -0
  52. package/engine/content-captions.mjs +181 -0
  53. package/engine/content-embeds.mjs +2 -2
  54. package/engine/content-footnotes.mjs +34 -0
  55. package/engine/content-format-check.mjs +2 -2
  56. package/engine/content-format.mjs +127 -73
  57. package/engine/content-format.yaml +745 -0
  58. package/engine/content-html.mjs +1 -1
  59. package/engine/content-icons.mjs +45 -92
  60. package/engine/content-images.mjs +22 -74
  61. package/engine/content-index.mjs +12 -3
  62. package/engine/content-links.mjs +21 -26
  63. package/engine/content-lint.mjs +1 -5
  64. package/engine/content-secrets.mjs +3 -1
  65. package/engine/content-tables.mjs +47 -1257
  66. package/engine/culture-choice.mjs +32 -0
  67. package/engine/date-conversion.mjs +47 -0
  68. package/engine/declaration-check.mjs +87 -0
  69. package/engine/docs-checks.mjs +140 -0
  70. package/engine/documentation-links.mjs +31 -0
  71. package/engine/exported-scene.mjs +1 -1
  72. package/engine/extension-attributes.mjs +74 -0
  73. package/engine/field-reference.mjs +1 -1
  74. package/engine/field-spec.mjs +4 -0
  75. package/engine/foreign-catalog.mjs +5 -5
  76. package/engine/format-generated.mjs +15 -0
  77. package/engine/foundry-entries.mjs +3 -1
  78. package/engine/frontmatter-lint.mjs +5 -1
  79. package/engine/frontmatter.mjs +10 -0
  80. package/engine/generate.mjs +2 -2
  81. package/engine/helpers.mjs +71 -117
  82. package/engine/homepage.mjs +2 -3
  83. package/engine/index-records.mjs +1 -0
  84. package/engine/index.mjs +18 -3
  85. package/engine/infobox-registry.mjs +3 -1
  86. package/engine/infobox.mjs +38 -20
  87. package/engine/item-compiler.mjs +10 -8
  88. package/engine/item-docs.mjs +7 -6
  89. package/engine/itinerary-scenes.mjs +4 -2
  90. package/engine/journals.mjs +51 -10
  91. package/engine/macros.mjs +1 -1
  92. package/engine/map-build.mjs +1 -1
  93. package/engine/map-dot.mjs +1 -1
  94. package/engine/map-raster.mjs +4 -2
  95. package/engine/markdown-expressions.mjs +117 -0
  96. package/engine/metadata-index.mjs +7 -3
  97. package/engine/note-addresses.mjs +1 -0
  98. package/engine/note-dates.mjs +283 -61
  99. package/engine/note-package.mjs +5 -25
  100. package/engine/note-state.mjs +6 -2
  101. package/engine/note-vocabulary.mjs +74 -19
  102. package/engine/number-words.mjs +75 -0
  103. package/engine/office-holders.mjs +188 -0
  104. package/engine/pack-config.mjs +2 -15
  105. package/engine/pack-router.mjs +24 -5
  106. package/engine/packages.mjs +3 -2
  107. package/engine/pathnames.mjs +21 -24
  108. package/engine/pdf-build.mjs +217 -55
  109. package/engine/pdf-fonts.mjs +2 -2
  110. package/engine/pdf-images.mjs +82 -0
  111. package/engine/pdf-render.mjs +174 -22
  112. package/engine/populations.mjs +3 -2
  113. package/engine/project-init.mjs +499 -0
  114. package/engine/prose-config.mjs +1 -1
  115. package/engine/prose-lint.mjs +7 -6
  116. package/engine/readability-lint.mjs +86 -0
  117. package/engine/reckoning-markers.mjs +205 -93
  118. package/engine/scenes.mjs +5 -2
  119. package/engine/schema-check.mjs +3 -3
  120. package/engine/site-build.mjs +87 -37
  121. package/engine/site-config.mjs +8 -8
  122. package/engine/site-index.mjs +4 -0
  123. package/engine/site-maps.mjs +1 -1
  124. package/engine/site-preview.mjs +19 -8
  125. package/engine/social-ties.mjs +5 -1
  126. package/engine/sql-tables.mjs +92 -22
  127. package/engine/web-wikilinks.mjs +15 -31
  128. package/engine/wikilink-syntax.mjs +4 -1
  129. package/engine/wikilinks.mjs +12 -14
  130. package/hm3/item-fields.mjs +1 -1
  131. package/hm3/items.mjs +12 -45
  132. package/index.mjs +2 -14
  133. package/manifest.mjs +31 -4
  134. package/markdownlint-config.mjs +1 -1
  135. package/package.json +32 -11
  136. package/prettier-config.mjs +1 -1
  137. package/sohl/being-info.mjs +3 -145
  138. package/sohl/index.mjs +1 -1
  139. package/sohl/infobox.mjs +2 -1
  140. package/sohl/item-builders.mjs +3 -27
  141. package/sohl/item-fields.mjs +61 -22
  142. package/sohl/kb-passes.mjs +2 -2
  143. package/sohl/note-schemas.mjs +0 -8
  144. package/stage.mjs +19 -0
  145. package/types/config.d.mts +12 -3
  146. package/types/content-config.d.mts +15 -81
  147. package/types/engine/address-render.d.mts +13 -0
  148. package/types/engine/address.d.mts +2 -11
  149. package/types/engine/anchors.d.mts +3 -4
  150. package/types/engine/asset-bindings.d.mts +9 -2
  151. package/types/engine/base-compiler.d.mts +1 -2
  152. package/types/engine/being-measurements.d.mts +16 -0
  153. package/types/engine/calendar-choice.d.mts +14 -0
  154. package/types/engine/calendar-human.d.mts +4 -0
  155. package/types/engine/calendar-notes.d.mts +2 -0
  156. package/types/engine/calendars.d.mts +5 -3
  157. package/types/engine/code-fences.d.mts +6 -72
  158. package/types/engine/compile-corpus.d.mts +1 -3
  159. package/types/engine/content-admonitions.d.mts +24 -0
  160. package/types/engine/content-captions.d.mts +45 -0
  161. package/types/engine/content-footnotes.d.mts +10 -0
  162. package/types/engine/content-format-check.d.mts +17 -4
  163. package/types/engine/content-format.d.mts +24 -11
  164. package/types/engine/content-icons.d.mts +14 -23
  165. package/types/engine/content-images.d.mts +2 -2
  166. package/types/engine/content-index.d.mts +9 -2
  167. package/types/engine/content-links.d.mts +3 -5
  168. package/types/engine/content-tables.d.mts +11 -140
  169. package/types/engine/culture-choice.d.mts +14 -0
  170. package/types/engine/date-conversion.d.mts +4 -0
  171. package/types/engine/declaration-check.d.mts +24 -0
  172. package/types/engine/docs-checks.d.mts +28 -0
  173. package/types/engine/documentation-links.d.mts +7 -0
  174. package/types/engine/exported-scene.d.mts +6 -2
  175. package/types/engine/extension-attributes.d.mts +14 -0
  176. package/types/engine/field-spec.d.mts +9 -0
  177. package/types/engine/format-generated.d.mts +7 -0
  178. package/types/engine/frontmatter.d.mts +14 -4
  179. package/types/engine/generate.d.mts +1 -1
  180. package/types/engine/helpers.d.mts +37 -92
  181. package/types/engine/homepage.d.mts +2 -3
  182. package/types/engine/index.d.mts +5 -0
  183. package/types/engine/infobox-registry.d.mts +3 -1
  184. package/types/engine/infobox.d.mts +17 -6
  185. package/types/engine/item-compiler.d.mts +4 -4
  186. package/types/engine/item-docs.d.mts +4 -3
  187. package/types/engine/journals.d.mts +10 -7
  188. package/types/engine/map-graphviz.d.mts +1 -1
  189. package/types/engine/markdown-expressions.d.mts +17 -0
  190. package/types/engine/note-dates.d.mts +47 -1
  191. package/types/engine/note-package.d.mts +3 -19
  192. package/types/engine/note-state.d.mts +2 -1
  193. package/types/engine/note-vocabulary.d.mts +9 -0
  194. package/types/engine/number-words.d.mts +4 -0
  195. package/types/engine/office-holders.d.mts +14 -0
  196. package/types/engine/pack-config.d.mts +1 -1
  197. package/types/engine/packages.d.mts +3 -2
  198. package/types/engine/pdf-images.d.mts +38 -0
  199. package/types/engine/populations.d.mts +2 -2
  200. package/types/engine/project-init.d.mts +17 -0
  201. package/types/engine/prose-config.d.mts +1 -1
  202. package/types/engine/prose-lint.d.mts +3 -2
  203. package/types/engine/readability-lint.d.mts +22 -0
  204. package/types/engine/reckoning-markers.d.mts +4 -2
  205. package/types/engine/schema-check.d.mts +3 -3
  206. package/types/engine/site-build.d.mts +38 -28
  207. package/types/engine/site-config.d.mts +2 -2
  208. package/types/engine/site-index.d.mts +4 -0
  209. package/types/engine/site-preview.d.mts +1 -1
  210. package/types/engine/social-ties.d.mts +8 -2
  211. package/types/engine/sql-tables.d.mts +13 -2
  212. package/types/engine/web-wikilinks.d.mts +2 -1
  213. package/types/engine/wikilinks.d.mts +7 -1
  214. package/types/hm3/items.d.mts +5 -6
  215. package/types/labels.d.mts +40 -0
  216. package/types/manifest.d.mts +3 -3
  217. package/types/sohl/actors.d.mts +2 -2
  218. package/types/sohl/being-info.d.mts +3 -43
  219. package/types/sohl/index.d.mts +1 -1
  220. package/types/sohl/infobox.d.mts +5 -2
  221. package/types/sohl/item-fields.d.mts +4 -13
  222. package/types/stage.d.mts +9 -0
  223. package/CONTENT.md +0 -3239
package/CONTENT.md DELETED
@@ -1,3239 +0,0 @@
1
- # @heroiclands/package-build
2
-
3
- The shared toolchain that compiles a **HeroicLands content tree** — a folder of
4
- Markdown notes with YAML frontmatter — into **Foundry VTT compendium packs**.
5
-
6
- Every HeroicLands content module (`sohl`, `thalorna`, `kethira`, and the
7
- adventure modules) builds its packs from this one implementation, rather than
8
- from a copied `utils/packs/` tree.
9
-
10
- It ships a command line as well as a library — see
11
- [Command line](#command-line) for the whole surface.
12
-
13
- ## Install
14
-
15
- ```
16
- npm install -D @heroiclands/package-build
17
- ```
18
-
19
- ## Content references
20
-
21
- Notes may abbreviate Addresses according to the defaults of their field and
22
- system block. Builds preserve the authored files and write full Addresses to
23
- generated content under `build/`. A reference under `data:` or in body prose
24
- uses `none` as its system; a system-bearing target at `none` names its
25
- documentation. A reference to an Item or Actor states its system explicitly.
26
-
27
- Published SQL records use these complete identities too. Compare Address fields
28
- with full Addresses, not a target's bare Shortcode. A stub's identity remains
29
- available from its package, type and Shortcode even though it has no page address.
30
- See the [content format](docs/content-format.md#addresses) for defaults, accepted
31
- forms and examples.
32
-
33
- Shared `data.species` names species lore. HârnMaster's native `hm3.system.species`
34
- is independent character-sheet text; a shared lore reference does not populate it.
35
-
36
- ## Configure
37
-
38
- A consuming repository declares one `package-build.config.yaml` at its root:
39
-
40
- ```yaml
41
- # The package this repository's content is published as: the first segment of
42
- # every canonical address, the name of the content index it emits, and the
43
- # package a cross-package wikilink writes to reach one of its notes.
44
- contentPackage: thalorna
45
- # What kind of package this is. "systems" and "modules" are where Foundry
46
- # installs it, and each decides the served asset root a note's `img:` resolves
47
- # to — `modules/sohl-thalorna/assets/…`. "documentation" is a package Foundry
48
- # never installs; see "A package that compiles nothing" below.
49
- packageKind: modules
50
-
51
- # The Foundry core range this package supports. `minimum` is stamped into every
52
- # compiled document as `_stats.coreVersion`; `verified` names the newest build
53
- # the full suite has actually passed on — never an aspiration.
54
- compatibility:
55
- minimum: "14.359"
56
- verified: "14.364"
57
-
58
- # What this package declares about others, in Foundry's own shape. A module's
59
- # `_stats.systemVersion` comes from the `verified` version of the system it
60
- # targets — note that this `compatibility` is the *system's* range, not
61
- # Foundry's. Same key, different subject.
62
- relationships:
63
- systems:
64
- - id: sohl
65
- type: system
66
- manifest: https://github.com/HeroicLands/Song-of-Heroic-Lands-FoundryVTT/releases/latest/download/system.json
67
- compatibility:
68
- minimum: "0.4.0"
69
- verified: "0.4.3"
70
-
71
- # Stamped into every compiled document's `_stats`. `coreVersion` and
72
- # `systemVersion` are both absent on purpose — see the derived table below.
73
- stats:
74
- systemId: sohl
75
- lastModifiedBy: thalornabuild000
76
-
77
- # Which content types compile into Items, and what builds each one's `system`
78
- # block — named, because the registry is code. The registry's keys are the
79
- # accepted item types, so a type cannot be whitelisted without a builder behind
80
- # it. A module that ships no items omits this key. See "An item type's default
81
- # art" below, and "A registry of your own" for the `.mjs` form.
82
- itemBuilders: sohl
83
-
84
- # Directory names the content walk ignores wherever they appear.
85
- skipDirectories: [Templates]
86
-
87
- # Optional. The icon fonts this package ships and the names its notes draw from
88
- # them. Nothing is supplied by default: an entry is a promise that a glyph will
89
- # render, and only the package shipping the font can keep it.
90
- #
91
- # Either the registry itself, or a path to a file holding it — the file form is
92
- # what a real package wants, because a registry is generated from what the
93
- # interface draws and a generated document inlined here conflicts on every
94
- # regeneration.
95
- icons: assets/icon-registry.yaml
96
- #
97
- # The same, written inline:
98
- #
99
- # icons:
100
- # families:
101
- # # `class` is the stylesheet prefix; `styles` are the weights the font
102
- # # ships, and `[]` means it has none.
103
- # fontawesome: { class: fa, styles: [solid, regular, brands], describe: Font Awesome Free }
104
- # game-icons: { class: ginf, styles: [], describe: the Game-Icons webfont }
105
- # # Which family an entry with no `family` belongs to. Optional where exactly
106
- # # one is declared, since then there is nothing to choose between.
107
- # defaultFamily: fontawesome
108
- # icons:
109
- # # A name is what a note writes between the colons, in the charset an
110
- # # address segment uses. `fixedWidth` asks for a full advance, which a
111
- # # glyph like an ellipsis needs to sit in a column of controls.
112
- # being: { style: solid, icon: user, label: being }
113
- # context-menu: { style: solid, icon: ellipsis-vertical, fixedWidth: true, label: context menu }
114
- # vehicle: { family: game-icons, icon: old-wagon, label: vehicle }
115
-
116
- # Optional; each path is relative to this file's directory and defaults to the
117
- # conventional layout shown here.
118
- paths:
119
- content: assets/content
120
- # The asset roots' parent: `icons/`, `images/` and `audio/` sit directly under
121
- # it, and the content tree beside them.
122
- assets: assets
123
- # Vendored foreign manifests, read by `links`. Inbound.
124
- manifests: assets/manifests
125
- # Where `manifest` writes this package's own. Outbound, and a build artifact.
126
- manifestOut: build/manifests
127
- # Where `content-index` writes this package's note index. Derived and
128
- # disposable — never a source, and never inside `stage`.
129
- contentIndex: build/content-index
130
- packJson: build/packs-json
131
- stage: build/stage/packs
132
- unpack: build/tmp/packs
133
-
134
- # The one pack list. `packDirectories` and the manifest's `packs` array are both
135
- # derived from it, so order it for a reader browsing compendiums — the compile
136
- # order is worked out separately, from what each pass reads (see "Declaration
137
- # order is presentation" below).
138
- packs:
139
- - { name: items, type: Item, label: Items }
140
- - { name: journals, type: JournalEntry, label: Journals }
141
- # A companion is written by its parent's pass rather than one of its own.
142
- - name: scenes
143
- type: Scene
144
- companions:
145
- - { name: adventures, type: Adventure }
146
-
147
- # A pack whose per-document JSON is already built — checked in rather than
148
- # generated. `prebuilt` names where it lives, generation is skipped for it,
149
- # and `cleanPackEntry` and the Scene/Level integrity check still run. It may
150
- # not carry `companions` or `default`, and may not be a companion:
151
- # each of those describes a generation pass a prebuilt pack does not have.
152
- # When every configured pack is prebuilt the content walk is skipped
153
- # entirely, so a package with no `assets/content` builds.
154
- - name: adventures
155
- type: Adventure
156
- prebuilt: assets/packs/adventure
157
- # Foundry requires `system` on ActiveEffect, Actor and Item packs and on no
158
- # others, so it is declared per pack. Unset falls back to `stats.systemId`
159
- # — itself optional — and with neither the manifest omits the key. An
160
- # Adventure or Scene pack that names a system is hidden from every other
161
- # one, which is rarely what a package that declined to name one meant.
162
- system: null
163
-
164
- # How this repository frames the pages `content-build docs` generates. The
165
- # tables come from the itemBuilders registry and are the same everywhere; the
166
- # heading, the filing and what a reader is told first are this repository's.
167
- docs:
168
- itemFields:
169
- title: Item Note Frontmatter
170
- out: kb/dev-docs/content-creator/item-frontmatter.md
171
- preamble:
172
- - "See also: [The Authoring Workflow](authoring-workflow.md)"
173
- - ""
174
- - Every item note carries the frontmatter envelope described there. This
175
- page covers what each **type** adds to it.
176
-
177
- # Reserved for @heroiclands/package-build, which validates what is inside it.
178
- # One repository describes itself in one file; the two build packages split by
179
- # input, and neither learns the other's schema. Values package-build needs that
180
- # already live at the top level — `packageKind`, `foundryPackage` — it reads
181
- # from there rather than restating them here.
182
- packageBuild:
183
- assets:
184
- - { from: assets/icons, to: assets/icons }
185
-
186
- # How much of the package reaches the web, plus the address scheme both
187
- # `manifest` and `site` derive addresses under. `site` is a mode, not a
188
- # boolean: `homepage` (the default) publishes the authored homepage and no
189
- # other page; `content` publishes it plus every page the tree compiles to, and
190
- # is also what builds the book.
191
- publish:
192
- site: content
193
- address:
194
- prefix: kb/
195
-
196
- # How this repository frames the website `content-build site` publishes.
197
- # Framing only: addresses come from `publish.address` above.
198
- site:
199
- assets: https://cdn.heroiclands.org
200
- description: The rules, the setting, and the reference material.
201
- ```
202
-
203
- The loader validates the document, resolves every path against the directory
204
- the file sits in, fills the optional halves with their defaults
205
- (`skipDirectories: []`, `packageBuild: {}`, the conventional `paths` and
206
- `publish.site` at its `homepage` floor),
207
- derives `assetRoot`, `packDirectories`, `itemTypes` and `docEntryTypes`, and
208
- freezes the result. A malformed configuration throws a `TypeError` naming the
209
- offending field — and the line and column it was written on, in the
210
- [located form](#diagnostics) — so it fails at load rather than as an empty pack
211
- much later.
212
-
213
- **Four values are derived rather than authored**, because each is something a
214
- file can be asked for rather than told:
215
-
216
- | Field | Derived from |
217
- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
218
- | `rootDir` | the directory the configuration file sits in |
219
- | `foundryPackage` | the `name` of the adjacent `package.json`, verbatim |
220
- | `stats.systemVersion` | a **system**: that `package.json`'s `version`. A **module**: the `verified` version of the system it declares a relationship with |
221
- | `itemBuilders` | the named registry (`sohl`) — or a list of names — required lazily so importing costs nothing |
222
-
223
- **Authoring any of the first three is an error**, not an override. Each was
224
- previously transcribed from a file that already stated it, and a transcription
225
- is free to disagree with what it copies — `stats.systemVersion` froze at
226
- `0.6.0` for four releases before anyone noticed, and was still frozen there in
227
- two repositories afterwards.
228
-
229
- A module does **not** take its system version from its own `package.json`: that
230
- is the _module's_ version, and stamping it would claim a system version that
231
- never existed. A module declaring no usable system relationship fails the build
232
- rather than guessing — a wrong `_stats.systemVersion` is invisible until
233
- something migrates on it.
234
-
235
- ### A package that compiles nothing
236
-
237
- `packageKind: documentation` is the answer "not a Foundry package at all". It
238
- publishes a website and the book built from the same notes, and compiles no
239
- compendium: nothing is installed into a Foundry data directory, so there is no
240
- manifest, no pack and no document.
241
-
242
- ```yaml
243
- contentPackage: toolkit
244
- packageKind: documentation
245
-
246
- # Required, and `content`: publishing the tree is the whole of what this kind
247
- # does. `homepage` would leave one authored page, no book and nothing compiled.
248
- publish:
249
- site: content
250
- address: { prefix: guide/ }
251
-
252
- packageBuild:
253
- manifest:
254
- title: The Toolkit
255
-
256
- pdf:
257
- title: The Toolkit
258
- document: book.yaml
259
- fonts: { serif: Libertinus Serif }
260
- ```
261
-
262
- `contentPackage`, `paths`, `skipDirectories`, `icons`, `site`, `pdf` and
263
- `packageBuild` mean exactly what they mean anywhere else. **Every key that
264
- describes a Foundry package is refused**, by name, with the line and column it
265
- was written on — `packs`, `itemBuilders`, `docs`, `compatibility`,
266
- `relationships`, `systems`, `requiresSystem`, `stats` and `foundryPackage`:
267
-
268
- ```text
269
- 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.
270
- ```
271
-
272
- A key that cannot mean anything in this shape fails at load rather than being
273
- read and ignored, which is the difference between a configuration that is wrong
274
- and a build that is quietly wrong.
275
-
276
- What follows from the kind:
277
-
278
- - **`foundryPackage` is not derived.** For every other kind the loader reads it
279
- from the adjacent `package.json`; there is no Foundry package here to carry an
280
- id, so `foundryPackage` and `assetRoot` are both `null`. A note's `img:`
281
- therefore names a `/`-rooted path or a URL — a pathname this package would
282
- have to serve itself is refused, because Foundry serves no files for a
283
- package it does not install.
284
- - **`package-build manifest` refuses**, rather than emitting a `module.json`
285
- advertising an installable package with no id, no packs and no compatibility
286
- range. So does `content-build package compile`, which would otherwise exit 0
287
- having compiled nothing.
288
- - **Notes need no `id:`.** An id is derived from a note's address, and a
289
- hand-assigned one is only ever a pin for a document that already shipped —
290
- which needs a compendium, and there is none.
291
- - **The note vocabulary is `doc` and `homepage`.** Every other type exists to
292
- become a Foundry document, so a note carrying one has no destination;
293
- `content-build lint` reports it at its `type:` line.
294
- - **A content index is still published.** `content-build content-index` emits
295
- `<contentPackage>-metadata.jsonl` exactly as it does elsewhere, so another
296
- package can resolve an address into this one and a wikilink from its notes can
297
- reach a page here.
298
-
299
- ### A note's package is the repository's, not the note's
300
-
301
- `contentPackage` is the **address namespace** every note in the tree is
302
- published under: the first segment of every canonical key, the name of the link
303
- manifest this build emits (`sohl.json`), and the package a cross-package
304
- wikilink writes to reach one of these notes. It is the repository's identity in
305
- the address space — not a filter — and a note does not restate it.
306
-
307
- A canonical key has four segments, read by position:
308
-
309
- ```text
310
- <package>-<system>-<type>-<shortcode>
311
- ```
312
-
313
- `<system>` is a game system this toolchain compiles for — `sohl` or `hm3` — or
314
- the literal `none` for a document no game system defines: a JournalEntry, a
315
- Macro, a Scene, and an item's documentation journal, which is `none` however
316
- many system blocks the item itself carries. The registry of permitted values is
317
- `engine/systems.mjs`, and `none` is a **word** on purpose: `any` would read as a
318
- wildcard, which is the opposite of what it says, and a YAML null (`null`, `~`,
319
- an empty value) parses to an absent value and drops the segment altogether. So
320
- `sohl-none-doc-gear`, `sohl-sohl-skill-clmb` — the package and the system are
321
- independent slots that may hold the same word, because Foundry requires a system
322
- package's id to _be_ its system id.
323
-
324
- Because it is a segment of an address, the value is **validated** rather than
325
- taken as written, and a violation fails the build naming the line it is on:
326
-
327
- - **Lowercase alphanumeric** (`^[a-z0-9]+$`). An address is read by counting
328
- hyphen-separated segments, so the hyphen has to be purely a separator — which
329
- is why `harn-adventures` is configured as `harnadventures`. This is the same
330
- rule `shortcode` is already held to, and the two are one constant.
331
- - **Not a note type.** A written address is a _partial_ one — the shorter forms
332
- drop segments from the left, so `skill-clmb` and `sohl-skill-clmb` are both
333
- addresses — and position alone therefore no longer says which vocabulary a
334
- leading segment is drawn from. The reader settles that by asking whether the
335
- name is a known package, so a name belonging to both vocabularies makes one
336
- target readable two ways with no defensible pick. The package and the type are
337
- no longer _adjacent_ segments now that the system sits between them, and that
338
- changes nothing: the hazard was never adjacency, it is that a short form omits
339
- the slots in between. `doc`, `being`, every map type, and every item type this
340
- repository declares — with its `doc`-prefixed documentation form — are
341
- refused.
342
-
343
- ```text
344
- 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`.
345
- ```
346
-
347
- **`package:` in a note's frontmatter is retired, and declaring it fails the
348
- build**, naming the file, whatever the value says. An agreeing declaration is
349
- refused exactly as a disagreeing one is: there is no value that makes writing
350
- the field correct. The diagnostic says so, and says where the value comes from
351
- instead:
352
-
353
- ```text
354
- assets/content/Gear/Axe.md:12:1: error: `package: sohl` is a retired frontmatter field — delete it. A note's package is this repository's configured `contentPackage` ("sohl", in package-build.config.yaml), and every note in the tree belongs to it.
355
- ```
356
-
357
- `content-build lint` reports every such note in one pass; `content-build
358
- package compile` and `content-build content-index` refuse the tree.
359
-
360
- A generated table that scopes itself with `WHERE … and package = "<pkg>"` keeps
361
- working: the package is **synthesised** into what the table search sees,
362
- supplied from `contentPackage` rather than read off the note. It is a search
363
- value, never an authored one.
364
-
365
- It used to **select**: a note compiled when its `package:` matched and was
366
- skipped when it did not. Every content tree is single-package — each is
367
- single-sourced in the repository that ships it — so the field restated one
368
- constant thousands of times, while a tree whose notes named a package no
369
- configuration answered to compiled **zero notes and exited 0**. Deleting the
370
- field from a note is the fix; deleting the _configured_ value is not, since
371
- every address derives from it.
372
-
373
- Sweeping a tree is mechanical — the field is a whole line, and nothing else
374
- reads it:
375
-
376
- ```bash
377
- find assets/content -name '*.md' -print0 | xargs -0 sed -i '' '/^package: /d'
378
- ```
379
-
380
- ### A registry of your own
381
-
382
- `itemBuilders` is the one part of the contract that is code — a table of
383
- functions building each type's `system` block — so data can only _name_ one of
384
- the registries this package ships. A consumer supplying its own writes
385
- `package-build.config.mjs` instead, which is loaded in place of the YAML:
386
-
387
- ```js
388
- import { defineConfig } from "@heroiclands/package-build/content-config";
389
- import { ITEM_BUILDERS } from "./build/item-builders.mjs";
390
-
391
- export default defineConfig({
392
- // Stated, since a code configuration derives nothing: it is code, and can
393
- // read whatever it likes for itself.
394
- rootDir: import.meta.dirname,
395
- contentPackage: "kethira",
396
- foundryPackage: "sohl-kethira-basic",
397
- packageKind: "modules",
398
- compatibility: { minimum: "14.359", verified: "14.364" },
399
- stats: { lastModifiedBy: "…" },
400
- itemBuilders: ITEM_BUILDERS,
401
- packs: [{ name: "items", type: "Item" }],
402
- });
403
- ```
404
-
405
- The two forms end at the same `defineConfig`, so they are validated and frozen
406
- identically; a code config simply states the three fields above itself, which it
407
- can, because it is code. **Import `defineConfig` from
408
- `@heroiclands/package-build/content-config`, never from the package root** — the root
409
- barrel pulls in the compilers, the compilers read the resolved configuration,
410
- and resolving it loads this file, so importing the barrel here closes a cycle
411
- around the file's own evaluation. The `/config` entry point imports nothing but
412
- `node:path` and the id helpers, so it cannot.
413
-
414
- **One directory, one configuration.** A directory holding both a `.yaml` and an
415
- `.mjs` is an error, not a precedence question: picking one would let a
416
- repository mid-conversion build from the file its author is no longer editing,
417
- and look entirely healthy doing it.
418
-
419
- ### Several packs of one document type
420
-
421
- A repository may declare more than one pack of the same `type`, and route notes
422
- between them. Editorial grouping of same-type documents into separate
423
- compendiums is ordinary Foundry practice — "Core Spells" and "Expanded Spells"
424
- are two Item packs — and it matters beyond taste: a compendium UUID carries its
425
- pack name (`Compendium.<package>.<pack>.Item.<id>`), so collapsing several packs
426
- into one invalidates every reference an existing world holds.
427
-
428
- Two axes, deliberately orthogonal:
429
-
430
- - a pack's **`type`** selects the _compiler_ that fills it;
431
- - a note's **`pack:`** frontmatter selects _which pack of that type_ receives its
432
- document.
433
-
434
- ```yaml
435
- packs:
436
- - { name: characteristics, type: Item, default: true }
437
- - { name: mysteries, type: Item }
438
- - { name: journals, type: JournalEntry }
439
- ```
440
-
441
- ```yaml
442
- # A note that says nothing lands in `characteristics`, the default Item pack.
443
- ---
444
- name:
445
- full: Climbing
446
- type: skill
447
- id: ...
448
- ---
449
- # A note that names one lands there instead.
450
- ---
451
- name:
452
- full: Second Sight
453
- type: skill
454
- id: ...
455
- pack: mysteries
456
- ---
457
- ```
458
-
459
- - **`pack:` is optional, and silence means the default.** Every note written
460
- before this existed declares nothing, so an undeclared note must keep
461
- compiling exactly where it always did. A type with exactly **one** pack is
462
- that type's default implicitly; a type with several designates one with
463
- `default: true`. Where several exist and none is marked, a declaration is
464
- **mandatory** and an undeclared note fails the build.
465
- - **A default is per system, not merely per type**. The rule above counts
466
- every pack of a type together, so a tree shipping one Actor pack per system has
467
- two and would need a flag — except that asked _per system_ the layout is
468
- unambiguous, one pack each, for the same reason a single-pack type needs no
469
- flag. And a system is never answered with another system's pack: a type-wide
470
- default declaring `system: sohl` does not route the HM3 document, which
471
- otherwise had that pass see a pack name that was not its own and skip every
472
- note in the tree without a word.
473
- - **A `pack:` naming another system's pack** is refused where the _block_
474
- declares it — `hm3.pack` says where the HM3 document goes, so naming a SoHL
475
- pack is a contradiction, reported naming the note and the pack. At the top
476
- level it is no contradiction: the shared position is the value a note states
477
- once for every system, and a system-specific pack cannot be that value, so it
478
- simply does not answer for the other system, which falls through to its own
479
- default.
480
- - **A `pack:` naming no configured pack is a build error**, not a fall-through to
481
- the default. A typo'd name that quietly landed content in the wrong compendium
482
- would be silent partial compilation — the failure mode this toolchain's guards
483
- exist to eliminate. The same applies to a name that belongs to a pack of
484
- another document type, or to a companion (no note is ever routed into one).
485
- - **A note's `pack:` names where its _own_ document goes.** Anything derived from
486
- it — an item's or a macro's prose, which compiles into a JournalEntry of its
487
- own — lands in the default pack of _that_ type.
488
- - **Every document a note produces needs a pack, and one that has none is a
489
- finding**. A note produces more than one document as a matter of
490
- course: an item note an Item and the JournalEntry its prose becomes, a map
491
- note a Scene and a JournalEntry, an actor note an Actor and a JournalEntry
492
- too. Where the configuration declares no pack for one of them, that
493
- document would be dropped while the rest of the note compiled into a pack
494
- that does exist — a build that succeeds and ships half of what was written.
495
- The finding names the note, the class with no pack, and the class that did
496
- compile, which is what distinguishes it from a note **nothing** claims: that
497
- one is a `type:` to correct, this one a pack to declare.
498
-
499
- It is asked **per note, not per type**, because documentation is: `Journals`
500
- declines a doc-carrying note whose body is empty — an item with no prose gets
501
- no doc — so a tree of deliberately description-less items loses nothing by
502
- having no JournalEntry pack, and is told nothing. And it names no system, so a
503
- type one system maps and another does not stays silent for the system that
504
- declines it.
505
-
506
- - **`pack: none` compiles the note into no document, on purpose.** The note is
507
- walked, published as a page, present in the content index with an address
508
- and no UUID, and addressable by wikilink; every pass passes over it without
509
- a finding, and the finding above says nothing about it. It is accepted only
510
- on a type whose sole document is the JournalEntry its prose becomes — a
511
- `doc`, say — and refused by name on a type that compiles an Item, an Actor,
512
- a Macro, a Scene or an Adventure, where it would drop that document. A
513
- configured pack may not be called `none`. This is how a page of developer
514
- documentation lives in the content tree beside everything else: it is a
515
- note like any other, and it reaches no compendium.
516
-
517
- **The configuration is found by walking up from the working directory, and from
518
- the installed package only if that finds nothing.** `engine/pack-config.mjs`
519
- climbs from `process.cwd()` first, so a build reads the tree it was run in —
520
- from the repository root, from `packages/`, from anywhere below, since the walk
521
- climbs. Climbing from the module itself is the fallback, for an invocation from
522
- outside any repository.
523
-
524
- The order matters in one shape: a git worktree nested under its parent checkout
525
- with **no `node_modules` of its own** resolves `@heroiclands/package-build` out
526
- of the parent's, because Node's resolution walks parent directories. Climbing
527
- from the module then landed on the _parent's_ configuration, and the build
528
- compiled the parent's content tree into the parent's `build/` and exited 0
529
- . When both walks find a configuration and they disagree, the working
530
- directory's is read and the ignored one is named in a warning — that
531
- disagreement is also the cheapest signal that this tree is building on another
532
- checkout's `node_modules`. Run `npm ci` in the worktree to give it its own.
533
-
534
- Set `PACKAGE_BUILD_CONFIG` to point at the file explicitly if a consumer keeps
535
- it somewhere else; it skips both walks.
536
-
537
- **The configuration is resolved on first read, never at import.** Every module
538
- here can be imported — and `content-build --version` and `--help` answered — in a
539
- directory with no `package-build.config.yaml` and no Foundry package manifest, so
540
- a consumer can reach for one pure helper (`engine/content-address`,
541
- `engine/wikilinks`) without standing up a pack build. Anything derived from
542
- configuration is therefore an accessor rather than a hoisted constant —
543
- `loadPackConfig()`, `contentPackage()`, `foundryPackageId()`, `itemTypes()`,
544
- `docEntryTypes()`, `packRouter()`, `defaultTemplateDir()` — and each throws, with
545
- the same explicit message as before, the moment a build actually needs a value it
546
- cannot find. Absence is still a hard failure; only the moment it is reported
547
- moved (#2).
548
-
549
- The file is read synchronously — an `.mjs` one with `require` — so that reading
550
- a configured value stays an ordinary expression instead of making every module
551
- downstream of it an async one. The one shape that cannot be loaded is an `.mjs`
552
- config whose own module graph uses top-level `await`, which is reported as such.
553
-
554
- **`itemBuilders` is how the engine learns a consumer's item types without
555
- holding its data model.** `itemTypes` is its key set, and `docEntryTypes` — every
556
- type whose prose compiles into a JournalEntry of its own — is composed from it
557
- exactly once, here, and read through `loadPackConfig()` everywhere. There is one
558
- resolved set at runtime; the compilers and the link-manifest emitter cannot come
559
- to disagree about which notes carry documentation.
560
-
561
- **Every note compiling into a system-bearing document carries documentation, and
562
- that includes actors**. `docEntryTypes` is `itemTypes` plus the actor
563
- types the shipped subtype maps declare (`ACTOR_TYPES`, derived from them rather
564
- than listed again), plus `macro` and the map types. Only `doc` is outside it, for
565
- the reason that actually applies to it: its single document _is_ the prose.
566
-
567
- A being therefore has the same two addresses an item has — `<pkg>-<system>-being-<shortcode>`
568
- for the Actor and `<pkg>-none-docbeing-<shortcode>` for the page — where before
569
- it had only the first, and was the one system-bearing note a prose link could not
570
- name.
571
-
572
- **An actor keeps its prose inline as well, and that asymmetry is deliberate.** An
573
- item's description is an `@UUID` pointer into its journal, because one item is
574
- embedded across hundreds of beings and baking long prose into every copy bloats
575
- the compendium by the length of the text times the number of carriers. An actor
576
- is singular, so the same indirection costs a reader a click and saves nothing:
577
- `system.appearance` and `system.dossier` stay as rendered prose.
578
-
579
- The Item compiler **dispatches through that same resolved table**, via
580
- `engine/item-registry.mjs` (`itemTypes()` and `itemBuilder(type)`), so the types a
581
- consumer's notes are accepted for and the builders they compile with are one
582
- object. Supplying `itemBuilders` is therefore all a consumer does to define an
583
- item type of its own; a table this package ships is one possible value, not the
584
- one the compiler holds.
585
-
586
- **A tree feeding two systems declares a set of registries.** One registry is
587
- also a ceiling: the accepted vocabulary is its keys, so a type only the _other_
588
- system knows — `spell` and `invocation` are HM3's, `mysticalability` is SoHL's —
589
- cannot be accepted at all. So `itemBuilders` takes either form:
590
-
591
- ```yaml
592
- itemBuilders: sohl # one registry, and what every existing configuration says
593
- itemBuilders: [sohl, hm3] # a set; the vocabulary is their union
594
- ```
595
-
596
- A registry's **name is the system it belongs to**, which is what lets a data
597
- configuration declare a set without naming each system twice. In an `.mjs`
598
- configuration the same set is written out:
599
-
600
- ```js
601
- itemBuilders: [
602
- { system: "sohl", builders: SOHL_ITEM_BUILDERS },
603
- { system: "hm3", builders: HM3_ITEM_BUILDERS },
604
- ],
605
- ```
606
-
607
- A type **both** registries declare — `skill` is one name over two data models —
608
- keeps a builder on each side. `itemBuilder(type, system)` and `itemArt(type,
609
- system)` take the system that is asking; asking without one, for a type more
610
- than one registry declares, **throws** rather than answering with whichever was
611
- declared first.
612
-
613
- **`hm3` is a real registry, and the rest of the pipeline follows the same
614
- field.** This package ships two system halves, `sohl/` and `hm3/`,
615
- each with its own builders, its own default art and its own note-type →
616
- document-subtype map; they share the engine between them and import nothing from
617
- each other. A pack's `system:` is what selects among them — the Item and Actor
618
- compilers, the item catalogue a being resolves against, the published
619
- `schema.json` its emissions are checked against, and the `_stats` stamp all read
620
- that one field — so declaring one Item pack and one Actor pack per system is the
621
- whole of the configuration a dual-system tree needs:
622
-
623
- ```yaml
624
- itemBuilders: [sohl, hm3]
625
- systems:
626
- sohl: { compatibility: { verified: "0.9.0" } }
627
- hm3: { compatibility: { verified: "1.6.3" } }
628
- packs:
629
- - { name: items-sohl, type: Item, system: sohl }
630
- - { name: items-hm3, type: Item, system: hm3 }
631
- - { name: actors-sohl, type: Actor, system: sohl }
632
- - { name: actors-hm3, type: Actor, system: hm3 }
633
- ```
634
-
635
- **The `systems:` block is required here, not decorative.** A pack's `system:` is
636
- what its documents are stamped `_stats.systemId` and `systemVersion` from, and
637
- the version can only come from that block — or, for a package whose packs are
638
- all for its own system, from the package-wide stats. A pack naming a system that
639
- resolves to neither is refused at configuration time, naming the pack and the
640
- entry to add. Falling through and stamping `null` for both is a plausible lie:
641
- a pack whose configuration says `system: sohl` on the line above ships every
642
- compiled actor with no system at all.
643
-
644
- No `default: true` anywhere, because each system has exactly one pack of each
645
- type and a default is resolved per system. Marking one is still allowed and
646
- still means what it says — it designates that _system's_ default where a system
647
- has several packs of a type.
648
-
649
- A note carrying both a `sohl:` and an `hm3:` block then compiles **one document
650
- in each system**, each shaped by its own builders and stamped with its own
651
- system version. A note carrying only one block compiles only that system's
652
- document: the other system's pass passes over it, rather than failing it for a
653
- block it was never going to have.
654
-
655
- Four of HM3's rows are **one-to-many** — `mysticalability` becomes a `psionic`,
656
- a `spell` or an `invocation`; `trauma` an `injury` or a `trait`; `weapongear` a
657
- `weapongear` or a `missilegear`; `being` a `character` or a `creature` — and the
658
- note says which by writing `hm3.type`. Nothing is inferred from the note's own
659
- `subType`, and a note that says nothing is an error naming the note and listing
660
- the permitted values.
661
-
662
- **Configuration is the source, and the manifest is generated from it.** The
663
- floor is the top-level `compatibility.minimum`, the id is derived from
664
- `package.json` `name`, and `@heroiclands/package-build` writes the manifest from
665
- this file. There is no `paths.packageManifest` pointing at a hand-authored
666
- `system.template.json` for a package-id guard or a `_stats.coreVersion` stamp to
667
- read out of.
668
-
669
- ### Declaration order is presentation, not compile order
670
-
671
- `packs:` is the manifest's `packs` array as well, so a consumer orders it for a
672
- reader browsing compendiums. It is **not** the order the passes run in, and it
673
- does not have to be: the compile order is derived from what each pass reads.
674
-
675
- One pass reads another's output today. The actors pass resolves each being's
676
- embedded items against the JSON the item passes wrote — a being names an item by
677
- `(type, shortcode)` and never by the pack it ships in, so **every** Item pack has
678
- to be compiled before the Actor pass, not merely the first. A compiler states
679
- that on itself:
680
-
681
- ```js
682
- export class Actors extends BasePackCompiler {
683
- static readsPackOutputOf = Object.freeze(["Item"]);
684
- }
685
- ```
686
-
687
- The generator schedules each pass after the packs of every type it names, and
688
- does so with the **smallest** reordering that works — the earliest declared pass
689
- whose dependencies have all run goes next. A list already in a workable order is
690
- therefore compiled exactly as declared, and one that is not moves only the
691
- passes that had to move. When the two orders differ the build says so:
692
-
693
- ```text
694
- [INFO]: Pass order: characteristics, mysteries, characters — a pass that reads
695
- another's output compiles after it, whatever order `packs:` declares.
696
- ```
697
-
698
- This is not the author's problem, and it would be a nasty one: an Actor pack declaring
699
- first compiled only where an earlier run had already left `build/packs-json`
700
- populated. `build/` is gitignored, so it was green on every local tree that had
701
- built once and exit 1 on every fresh checkout and CI runner, over a message that
702
- named a missing directory rather than the ordering that caused it. A
703
- consumer registering a compiler of its own declares its dependencies the same
704
- way; a type no pack of which is declared is simply not waited for.
705
-
706
- **Compiling one pack by name is the case ordering cannot answer.**
707
- `content-build package compile <name>` runs the pass you asked for and no other,
708
- so a dependency that is neither in the run nor already on disk is reported
709
- rather than ordered around:
710
-
711
- ```text
712
- error: pack "characters" (Actor) reads the compiled output of the Item pack
713
- "characteristics", which this run does not compile and which
714
- build/packs-json/characteristics does not hold — compile the whole
715
- package, or compile "characteristics" first
716
- ```
717
-
718
- ### An item type's default art
719
-
720
- A note that names no art gets its type's **default art**, and a type declares
721
- that art in the same place it declares its builder. An `itemBuilders` entry may
722
- be written two ways:
723
-
724
- ```js
725
- itemBuilders: {
726
- // A bare builder. Every note of this type must carry its own `img:`.
727
- charm: buildCharm,
728
- // The same builder, paired with the art a note of this type gets when it
729
- // sets no `img:` of its own.
730
- relic: { system: buildRelic, img: "icons/relic.svg" },
731
- }
732
- ```
733
-
734
- Both spellings are equal; the difference is only whether the type brings art.
735
- `itemTypes` is still the key set either way, so a type is still impossible to
736
- whitelist without a builder behind it.
737
-
738
- **The path is spelled the way a note spells it.** Registry art goes through the
739
- same `resolveImg` rule as a note's `img:`, so one spelling means one thing
740
- wherever it is written.
741
-
742
- #### A pathname names the package that owns the file
743
-
744
- Every authored pathname — a registry `img:`, a note's `img:`, an actor's
745
- `data.portrait:`, a map's background, the address of an image in a body —
746
- answers "which package holds this file?" in its **first segment**, when an
747
- `assets/` follows it. Everything after that `assets/` is the _suffix_, and a
748
- pathname that does not open `<package>/assets/` belongs to the package being
749
- compiled, whole.
750
-
751
- Four surfaces derive an address from that one statement. For a `thalorna` note
752
- — the package Foundry installs as the module `sohl-thalorna` — writing
753
- `images/map.webp`:
754
-
755
- | Surface | Address |
756
- | ----------- | -------------------------------------------------------- |
757
- | **Foundry** | `modules/sohl-thalorna/assets/images/map.webp` |
758
- | **Local** | `assets/images/map.webp` |
759
- | **Web** | `https://cdn.heroiclands.org/thalorna/images/map.webp` |
760
- | **Book** | `assets/images/map.webp`, staged beside the Typst source |
761
-
762
- `sohl/assets/icons/relic.svg` names the `sohl` package's file wherever it is
763
- written, which is what lets a module pair a SoHL default with one of its own
764
- types. The package's name is not its Foundry id: `thalorna` is the content and
765
- `sohl-thalorna` the install, and only the Foundry form carries the second.
766
-
767
- The web host is `site.assets`, and a package-owned image on a page with none
768
- set is an error naming that key.
769
-
770
- **The rule is about ownership, not a list of directories.** A package owns its
771
- whole `assets/` tree, so a directory this toolchain has never heard of is still
772
- that package's: art under `assets/artwork/` is addressed `artwork/deity.webp`
773
- and resolved exactly as `icons/…` and `images/…` are. An address naming no
774
- package at all — an absolute URL, a `data:` URI, a `/`-rooted path — passes
775
- through on every surface, on the same rule rather than as an exception.
776
-
777
- **A `systems/…` or `modules/…` pathname is refused**, with a finding naming the
778
- replacement: it resolves for Foundry and for neither of the other surfaces, and
779
- deriving anything from it would put a wrong address on two of the three.
780
- `worlds/` is not such a root — a package may not ship files out of a world, so
781
- that path gets the ordinary reading and produces a plainly broken one rather
782
- than a plausible one that 404s unreported.
783
-
784
- A `documentation` package installs nothing, so it has no Foundry form to derive
785
- and addresses another package's file by a `/`-rooted path or a URL.
786
-
787
- **`banner:` is a path that does not follow this rule.** It reaches no compiled
788
- document and no book; it is a top-level key the Hugo theme reads, and the theme
789
- prefixes a relative value with `images/` and joins it onto
790
- `params.cdnBaseURL`. The two address different places — a pathname a file a
791
- package ships, `banner:` a hero image on the site's own asset host — so they are
792
- stated apart rather than reconciled. See the
793
- [content format specification](docs/content-format.md#banner-addresses-the-cdn-not-the-foundry-install).
794
-
795
- #### "Names no art" and "wants no art" are different
796
-
797
- A note has two ways to leave `img:` empty, and they mean opposite things:
798
-
799
- | a note writes | it means | it compiles with |
800
- | -------------- | ------------------------------------ | ---------------- |
801
- | nothing at all | _unset_ — name me no art | the type default |
802
- | `img: null` | the same thing, said out loud | the type default |
803
- | `img: ""` | _blank on purpose_ — I want no image | no image |
804
- | `img: <path>` | this art | that path |
805
-
806
- `resolveImg` returns `null` for the first two and `""` for the third, and every
807
- caller pairs its default with **nullish** coalescing — `resolveImg(fm.img) ?? itemArt(type)`.
808
- Never `||`: that collapses a deliberate blank back into the default and takes the
809
- distinction away again.
810
-
811
- **`portrait` is the same field twice over.** A being carries `img` (its token
812
- art) and `portrait` (its sheet portrait) independently, and both resolve through
813
- `resolveImg`, so the rule above is the rule for both.
814
-
815
- This is the convention the project already holds for an optional "not specified"
816
- DataModel string — `nullable, initial: null`, so "unset" is one honest value
817
- rather than two.
818
-
819
- > **The rule is `img`'s, and does not extend to `title`.** `title` is not art
820
- > and never reaches `resolveImg`, so nothing here applies to it.
821
- >
822
- > The field declares `topLevelMeans`, so the top-level key is not a source for
823
- > an `affiliation` item's `system.title`. Were it one, a single authored key
824
- > would feed two unrelated destinations that disagree about what empty means,
825
- > and `title: null` would stringify into the compiled document as the literal
826
- > `"null"`.
827
- >
828
- > So `title: null` is a note declining to state a heading, and the site emitter's
829
- > `fm.title ?? name` falls back to `name.full`. `title: ""` publishes a
830
- > deliberately blank heading and is warned about on its own account — as the
831
- > page's heading, not as an art path.
832
- >
833
- > **That warning reads the note's top level only.** The two spellings still name
834
- > unrelated quantities, so a `sohl.title` — the office's style of address — must
835
- > not answer for the page's heading. Twenty-eight `sohl-kethira-basic`
836
- > affiliations write `sohl.title: ""`, meaning an office with no style of
837
- > address, and every one of them was reported as publishing a blank heading
838
- > until the check honoured the declaration.
839
-
840
- Because `""` reads as "unset" elsewhere, a note carrying that spelling has
841
- quietly changed meaning, and the frontmatter lint says so — for either art
842
- field:
843
-
844
- ```
845
- Note.md:9:1: warning: `img: ""` means "ship no art at all" — it no longer falls
846
- back to this type's default. Write `img: null` for a note that simply names
847
- none; keep `""` only where the document is meant to have no image
848
- ```
849
-
850
- A warning, not an error: the note still compiles, to a document that is merely
851
- iconless.
852
-
853
- **A type with neither is a build error, deliberately.** When a note names no
854
- art and its type pairs none, the pack build aborts rather than shipping an
855
- item with a mismatched icon:
856
-
857
- ```
858
- No default art for item type "relic" — the note carries no `img:`, and the
859
- `itemBuilders` entry for "relic" in this repository's configuration
860
- pairs none with its builder.
861
- ```
862
-
863
- #### Why art travels with the builder (#7)
864
-
865
- It did not always. The item **type** whitelist was derived from a consumer's
866
- `itemBuilders` keys, while the **art** for those same types was looked up in
867
- `sohl/default-item-art.mjs` — a table this package ships for the `sohl` package
868
- and which a consumer cannot add to. A type was therefore configurable while its
869
- default art was not, and a second consumer's own item type compiled only if
870
- every one of its notes carried an explicit `img:`; the first note that omitted
871
- one failed the build with an error naming a module in someone else's package.
872
-
873
- Widening that map was not the fix. It is deliberately SoHL data, shared with the
874
- runtime's `SohlItem.getDefaultArtwork` so that the build-time and runtime
875
- defaults are one list and cannot drift. Pairing art with the
876
- builder instead moves it onto the seam a type is _already_ declared through, and
877
- costs the `sohl` package nothing: `ITEM_BUILDERS` reads each entry's image out of
878
- that same map, so there is still exactly one map — and the drift a test used to
879
- watch for is now unrepresentable, because building the registry throws if a type
880
- has no art.
881
-
882
- ## The per-system block
883
-
884
- A note is **system-agnostic**. The only system-specific things it carries are
885
- the properties named after a system, and one note may carry more than one — a
886
- `being` in `harn-ensemble` compiles into a SoHL `being` _and_ an HM3
887
- `character`.
888
-
889
- **The block is what makes a game document.** A note carrying none produces no
890
- Actor and no Item at all, in any package; its prose still compiles into a page,
891
- a PDF page and a JournalEntry, so a person can be written without being statted.
892
- Within a system's block:
893
-
894
- | property | maps to |
895
- | ------------------ | ------------------------------------------------------------- |
896
- | `<system>.system` | `document.system` — the DataModel schema, verbatim paths |
897
- | `<system>.type` | `document.type` — the subtype the note compiles into |
898
- | `<system>.img` | `document.img` |
899
- | `<system>.items` | `document.items` — actors only |
900
- | `<system>.effects` | `document.effects` |
901
- | `<system>.flags` | `document.flags` |
902
- | `<system>.pack` | _nothing on the document_ — a build directive naming the pack |
903
-
904
- Everything else a system declares sits directly under the block. `kbcat` is
905
- toolchain vocabulary, and the _generators_ `items` and `attributes` expand into
906
- embedded documents rather than mapping anywhere, so neither has a `system` path
907
- to be written at.
908
-
909
- `templatePriority` is the one key of this shape that _is_ a field, and it is
910
- **shared rather than per-system**: its home is `data.templatePriority`, and it
911
- reaches `sohl.system.templatePriority` and `hm3.flags.hm3.templatePriority` —
912
- exactly as `portrait` reaches two differently-named fields from one shared
913
- property. A number is a template at that priority, `null` is not a template, and
914
- absent is an authoring error. The legacy in-block and top-level
915
- positions are still read, in that order after `data:`, so a tree sweeps on its
916
- own schedule; `archetype` is the retiring spelling of the same field, still read
917
- last but **refused by the frontmatter linter** — a priority and the `archetypes`
918
- a being fits are different things, and one letter is not enough to tell them
919
- apart.
920
-
921
- ```yaml
922
- type: being # the content type — system-agnostic
923
- pack: actors # shared: unless a block says otherwise
924
- portrait: kaldor.webp # shared: reaches both systems' fields, differently named
925
- data:
926
- templatePriority: 1 # shared: → sohl `system.`, hm3 `flags.hm3.`
927
- hm3:
928
- type: character # this system's document subtype
929
- pack: actors-hm3 # overrides the shared one, for HM3 only
930
- system: # → document.system, verbatim
931
- species: human
932
- sunsign: ulandus
933
- attributes: { str: 10, sta: 14 } # a generator, not a system field
934
- sohl:
935
- type: being
936
- system:
937
- currentMoveMedium: walk
938
- ```
939
-
940
- **The shared fallback is declared, not name-matched.** `sohl.system.portrait`
941
- and `hm3.system.bioImage` both default from one shared property, and they are
942
- two real fields with different names — SoHL's `Actor.being` and HM3's
943
- `Actor.character` share **no** field name at all, so a rule matching on spelling
944
- would never fire. Each field declares its source instead, and resolution for a
945
- system `S` is:
946
-
947
- 1. `S.system.<to>` — authored directly, wins outright;
948
- 2. `S.<name>` — the legacy in-block position, until the corpus moves off it;
949
- 3. the shared top-level property the field declares as its source, which may be
950
- a **dotted path** (`data.portrait`) rather than a sibling key;
951
- 4. the field's own default.
952
-
953
- `FieldSpec.name` is that declared source. Read as "frontmatter key under
954
- `sohl:`", which is the degenerate case where source and destination happen to
955
- share a name.
956
-
957
- **A spelling that means two different things skips step 3.** Because a field's
958
- `name` doubles as its identity and as the shared property it draws from, the two
959
- coincide only while the note vocabulary and the system vocabulary agree about
960
- what a spelling means. `title` is where they do not. A note's top-level `title`
961
- is _the title of the note_ — the heading its page publishes under, which the
962
- site emitter reads. An `affiliation` item's `system.title` is _the style of
963
- address the office carries_ — Ajaw, Warden, a person's style within the body.
964
- They are unrelated quantities, and step 3 used to feed the second from the first
965
- .
966
-
967
- That was not merely untidy, because **step 3 answers without applying
968
- `field.default`** — only step 2 does — so an authored `title: null` reached the
969
- field's `String()` coercion unguarded and shipped as the literal string `"null"`.
970
-
971
- So a field may declare `topLevelMeans`: what the top-level key of that name means
972
- _instead_. Declaring it removes step 3 for that field, and the value is the
973
- reason rather than a bare flag, so the collision is legible where the field is
974
- declared and the generated field reference can print it. It is a per-field
975
- opt-out, not a change to the order — step 3 is right wherever the two levels
976
- state the same quantity, which is nearly everywhere: `subType` is the other
977
- declared item field spelled like a note-level key, and there the two agree by
978
- design.
979
-
980
- **The statement is symmetric, and the frontmatter linter reads it from the other
981
- side too.** If the two positions hold unrelated quantities, then the _in-block_
982
- position is not the note-level field either — so a check about a note-level field
983
- (the page's heading, an art path) reads past a block key the note's own type
984
- claims for something else. Declaring `topLevelMeans` settles both directions at
985
- once. Reading it for the emitted field alone is how an
986
- affiliation's office style came to answer for its page heading.
987
-
988
- **An exempted field is still authorable**, at the two positions that describe the
989
- document rather than the note:
990
-
991
- ```yaml
992
- title: The Order of the Silver Hand # the note's own heading — reaches the page
993
- type: affiliation
994
- subType: order
995
- sohl:
996
- system:
997
- title: Warden # → document.system.title, the style of address
998
- ```
999
-
1000
- `sohl.title`, the legacy in-block position, works the same way. A membership — a
1001
- `title` a particular being holds — is authored on the entry in that being's
1002
- `sohl.items`, whose `system` is overlaid on the catalogue document directly.
1003
- `data.title` is neither position: `title` is not a `data:` property any note type
1004
- declares, so the frontmatter lint refuses it.
1005
-
1006
- **`<system>.system` is written through verbatim**, at the DataModel's own paths,
1007
- with no renaming layer. A key the system's published `schema.json` does not
1008
- declare for the subtype the note compiles into is an **error naming the note**,
1009
- not a silent drop: Foundry discards an unknown `system` key at construction
1010
- without a word, so the alternative is a field the author wrote and nobody will
1011
- ever see. A path a declared field already writes is left to that field, so the
1012
- value goes through one coercion rather than two.
1013
-
1014
- **What the compiler writes on its own is checked too.** A compiled document
1015
- carries keys no field declaration and no note ever names — `shortcode`,
1016
- `templatePriority`, `actionDefs`, `notes`, `docHtml` — because the pass writes them
1017
- itself, and they were compared against nothing: the declaration-derived check
1018
- reads `itemBuilders`, the note-side check reads `<system>.system`, and these are
1019
- in neither. A compile now reads the `system` block each pass **assembled** and
1020
- checks its keys against the same published `schema.json`, so the emitted set is
1021
- observed rather than listed and a compiler that grows a key is covered without
1022
- anyone remembering to add it. A key the schema does not declare is an error,
1023
- reported once per subtype rather than once per document, and the message says
1024
- which of two things wrote it: a `fields:` entry, which the repository can change,
1025
- or the compiler, which it cannot — that one means the build is running ahead of
1026
- the system it compiles for, and the fixes are to declare the field there or to
1027
- hold this package at a build that does not write it. A subtree the schema
1028
- declares but describes no further — a discriminated `TypedSchemaField`, stored
1029
- flat — is left alone rather than reported wholesale.
1030
-
1031
- **An Actor or Item pass takes only notes carrying the block it reads.** A note
1032
- that says nothing about a system has no system data, and compiling it would emit
1033
- a hollow document — a subtype, and none of the fields the subtype exists for. So
1034
- a note carrying no block for any system produces no Actor and no Item anywhere,
1035
- and is passed over in silence; its prose still compiles into a page, a PDF page
1036
- and a JournalEntry. The question is asked of the pass's own system rather than
1037
- of the pack's `system:`: a pack declaring none is compiled by the fallback pass,
1038
- which reads one block like any other. A pass whose document is not system data
1039
- at all — journals, macros, scenes — is not subject to the rule.
1040
-
1041
- **A block for a system no pack of the class compiles is an error**, reported
1042
- once per note per system with the position of the block key, because the
1043
- authored data is discarded either way.
1044
-
1045
- **`(type, shortcode)` resolves inside one system's catalogue.** A being names its
1046
- embedded items by address and never by pack, so the Item packs are read as one
1047
- address space; with two systems in the tree that space stops being one, because
1048
- `skill:sword` exists under both names. An Actor pass reads the Item packs of its
1049
- **own** system plus the system-neutral ones.
1050
-
1051
- ## Command line
1052
-
1053
- ```
1054
- npx content-build package <compile|unpack|clean> [pack] [entry]
1055
- npx content-build docs item-fields [--out <path>] [--title <title>]
1056
- npx content-build lint [root] [--no-references]
1057
- npx content-build content-format schema --schema <system>=<path>
1058
- npx content-build content-format notes [root] [--strict]
1059
- npx content-build links [root] [--manifests <dir>]
1060
- npx content-build format [paths..] [--write]
1061
- npx content-build markdown [paths..] [--fix]
1062
- npx content-build content-index [root] [--out <dir>]
1063
- npx content-build site
1064
- npx content-build reachability <dir> [file] [--index <shortcode>]
1065
- npx content-build addresses diff --from <zip|dir> [--strict]
1066
- ```
1067
-
1068
- | Command | What it does |
1069
- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1070
- | `package` | Compile the content tree into LevelDB packs, unpack a shipped pack back to JSON, or clean one. See [Install](#install). |
1071
- | `docs` | Render a generated reference from the configured registries. `item-fields` is the item-frontmatter page. |
1072
- | `lint` | Check a content tree's addresses and its frontmatter. See [Linting a content tree](#linting-a-content-tree). |
1073
- | `content-format` | Check the content format specification itself. See [The content format specification](#the-content-format-specification). |
1074
- | `links` | Check that every link in the tree lands: dead anchors, dead qualified addresses, wikilinks in frontmatter, drifted manifests, and the package homepage's own addresses. |
1075
- | `format` | Prettier, with the shared configuration. See [Prose: formatting and markdown](#prose-formatting-and-markdown). |
1076
- | `markdown` | markdownlint, with the shared rule set — the structure Prettier is indifferent to. |
1077
- | `content-index` | Emit this package's note index as JSON Lines. See [Publishing a content index](#publishing-a-content-index). |
1078
- | `site` | Publish the content tree as a website. See [Publishing a website](#publishing-a-website). |
1079
- | `reachability` | Walk outward from an index note and report what no path reaches, for a tree meant to be navigable from one entry point. |
1080
- | `addresses` | Report every published item address this build has stopped publishing. See [Diffing published addresses](#diffing-published-addresses). |
1081
-
1082
- Every path, pack name and root it needs comes from the consuming repository's
1083
- `package-build.config.yaml`, so the usual invocation takes no arguments beyond
1084
- the command itself. What may be named on the command line overrides that.
1085
-
1086
- **Every invocation it accepts is one it performs.** A missing command, an
1087
- unknown command, a missing or unknown action, and an unknown option are each an
1088
- error that names what was wrong and exits non-zero — never a silent success. A
1089
- build chain can therefore treat a zero exit as "the work happened". `--version`
1090
- and `--help` still answer in a directory with no configuration at all.
1091
-
1092
- ## Linting a content tree
1093
-
1094
- ```bash
1095
- npx content-build lint # the configured `paths.content`
1096
- npx content-build lint some/tree # or a tree named outright
1097
- ```
1098
-
1099
- Checks the three rules every note's **identity** is authored against, and reports
1100
- each finding in the located form below:
1101
-
1102
- - **Shape** — a `shortcode` is strictly lowercase ASCII-alphanumeric. It is
1103
- the identity key referenced from saved world data, and half of the
1104
- `type-shortcode` address, whose parse needs the separating hyphen to be the
1105
- only hyphen.
1106
- - **Uniqueness** — `(type, shortcode)` names one note. A document is addressed
1107
- across _every_ pack of its document type, so routing two same-address notes to
1108
- different packs with `pack:` does not separate them.
1109
- - **One front page** — exactly one note declares `type: homepage`. See
1110
- [Exactly one homepage](#exactly-one-homepage) below.
1111
-
1112
- It compiles nothing, opens no LevelDB and needs no Foundry manifest, so it runs
1113
- in about a second and can gate a commit. An empty or untyped tree **fails**
1114
- rather than passing: "every one of nothing is unique" is a vacuous pass, and it
1115
- is exactly what a tree that failed to check out produces.
1116
-
1117
- What that guard reports is an **empty walk**, not an empty set of addresses. A
1118
- note may be keyless — a folder document carries no `shortcode` — so a tree of
1119
- them is populated, correct and unkeyed. That tree passes; a tree holding no
1120
- notes at all still fails. (The homepage is not the headline example, though it was once
1121
- it was addressed by the package rather than by a slug. It carries an address
1122
- like every other note now; the guard is unchanged, because what it reads
1123
- was never the key count.)
1124
-
1125
- ### An empty body is a stub, on purpose
1126
-
1127
- A note whose body is empty is a **stub**: it carries its frontmatter into the
1128
- content index and is queryable there, it keeps its `id`, and it publishes no
1129
- page, no address and no link target. A note with a body and the `draft` tag is a
1130
- **draft**, and one with a body and no tag is **full**.
1131
- `docs/content-format.md` states the rule; this is what the lint does with it.
1132
-
1133
- A draft says so on every surface it reaches — its journal entry, the Actor a
1134
- being compiles into, its page in the book — in one sentence at the head of the
1135
- entry, above the infobox and the prose. A stub says nothing anywhere, because it
1136
- publishes nothing to say it on.
1137
-
1138
- **A document that would be empty is not created**, and the index then names no
1139
- artefact — `uuid`, `path` and `address` are `null`. An unwritten note still
1140
- compiles every document derived from `data:`, so an arcane talent is still its
1141
- Item; what it compiles no journal for is its prose, because there is none. An
1142
- infobox does not count as content: the panel renders `data:` the index already
1143
- carries.
1144
-
1145
- Four findings and two reports:
1146
-
1147
- - **A stub carries a `description`.** With nothing in the body it is the only
1148
- sentence a reader gets, and it is what every table listing the note prints. An
1149
- **error**, at the note's `type:` line.
1150
- - **A stub is not tagged `draft`.** A thing not started is not a thing in
1151
- progress. An **error**: remove the tag, or write a body.
1152
- - **A body that reduces to nothing is an abandoned draft, not a stub.** A body
1153
- that is only headings, rules, comments and placeholder phrases — `TBD`, `To be
1154
- written`, `Coming soon`, `Placeholder`, `N/A` — publishes a page that tells a
1155
- reader nothing. An **error** naming the phrase and where it sits: empty the
1156
- body to make it a stub, or write it.
1157
- - **A short unmarked body may be a draft that forgot its marker.** Under
1158
- twenty-five words with no `draft` tag is a **warning, never an error**. A note
1159
- reading `See [[affiliation-meivor|Mëivōr]]` says everything it has to say, and
1160
- a corpus holds many of them.
1161
-
1162
- And two things the rules cannot decide, so they are reported as prose rather
1163
- than flagged:
1164
-
1165
- - **The oldest drafts are listed** by last modification. A long-standing draft
1166
- is unfinished, which the tag already says; a warning nobody can clear is how a
1167
- report teaches its reader to stop looking at it.
1168
- - **The counts are reported**, per package and per type:
1169
-
1170
- ```text
1171
- thalorna: 2572 notes — 2089 full, 464 draft, 19 stub
1172
- ```
1173
-
1174
- `content-build content-index` prints the same line, so the ratio of written to
1175
- unwritten is visible on every build rather than discovered in a year.
1176
-
1177
- #### Adopting it
1178
-
1179
- A package with no empty-bodied note is unaffected. A package that has them:
1180
-
1181
- 1. Take the release and run `content-build lint`. Act on the stub findings — a
1182
- `description` on every stub, and no stub tagged `draft`.
1183
- 2. Change the `sql` fences that **should** see stubs from `FROM notes` to
1184
- `FROM entries`. The ones that would gain rows are warned about at the fence,
1185
- so the list comes to you.
1186
- 3. Read the site's page count. A stub publishes no page, so a tree with stubs
1187
- publishes fewer pages than it did — which is the point, and worth confirming
1188
- against any `min-pages` gate the deploy declares.
1189
-
1190
- ### Exactly one homepage
1191
-
1192
- A content tree declares **exactly one** `type: homepage` note. Zero is an
1193
- error and two is an error, at the same severity, because they are one defect: a
1194
- package whose front page is not the page a person chose.
1195
-
1196
- - _Zero_ and the package serves nothing at `/<package>/` — the failure the
1197
- authored homepage exists to prevent, and a silent one: the site build reports
1198
- `wrote 0 homepage(s)` and exits 0.
1199
- - _Two_ and it serves a page nobody chose. **This is a cardinality rule, and
1200
- only that.** Both are written to the mount's `_index.md`, so the second
1201
- silently overwrites the first: the duplicate-address check catches only the
1202
- pair that happen to share a shortcode, and says nothing at all about a
1203
- `homepage-root` beside a `homepage-front`. Which of the two should be the
1204
- front page is a question nothing here can answer.
1205
-
1206
- Neither has a safe default, so neither is a warning: a build that proceeded past
1207
- either would publish the wrong front page while reporting success, which is
1208
- exactly what a warning tolerates.
1209
-
1210
- **Where it fires: `lint` _and_ `site`.** No single command reaches every
1211
- package — `HarnMaster-3-FoundryVTT` runs `content-build site` and no
1212
- `content-build lint`; `sohl-thalorna` runs `content-build lint` and its own site
1213
- builder — so a rule in one of them is a rule two of the six packages do not
1214
- have. Both call the same function, so there is one rule and two call sites
1215
- rather than two rules. In the site build it runs **before the output tree is
1216
- cleared**, so a failing gate cannot destroy a good site to report a bad tree.
1217
-
1218
- **It does not vary by `publish.site`.** That setting chooses whether the
1219
- _content_ surfaces are published; the homepage is the floor beneath both modes.
1220
- The lint call site reads no `site:` block at all, and so could not vary by mode
1221
- even if the rule wanted to.
1222
-
1223
- Zero has no file to name, so the locator is the **content root** — a real path,
1224
- and the directory the note has to be added to. No line or column is invented for
1225
- it. Two is reported once per offending note, located at its own `type:` value and
1226
- naming the other, because each note is a place an author has to open and edit:
1227
-
1228
- ```text
1229
- assets/content: error: holds no `type: homepage` note, so package "sohl" publishes nothing at its own address /sohl/ — a package's front page is one authored note in this tree, routed by `type:` rather than by filename
1230
- assets/content/homepage.md:3:7: error: duplicate `type: homepage` note, also declared by assets/content/Landing.md; a package has one front page, at /sohl/, and nothing here can say which of these it should be. Keep one, and make the rest ordinary notes
1231
- ```
1232
-
1233
- ### Frontmatter, against the schema its type declares
1234
-
1235
- The same command also checks that each note's `sohl:` block is what its **type**
1236
- allows. Five classes, all of them mistakes that were previously reported
1237
- somewhere other than where they were made, or not at all:
1238
-
1239
- - **Unknown or retired type** — a note on a retired spelling is told what
1240
- replaced it.
1241
- - **Missing required property** — `dimensions` on a map, `subType` on a skill.
1242
- - **Wrong value shape** — `weight: heavy` where a number belongs.
1243
- - **Unknown property** — _the allow-list made loud_. The builders discard a
1244
- `sohl:` key no field declares, with no warning and no effect on the exit code,
1245
- which is how 204 kethira mystical abilities shipped with no affiliation (#3).
1246
- A near miss is named: `Did you mean "masteryLevelBase"?`
1247
- - **Dead shortcode reference** — `assocSkillCode` naming a skill nothing
1248
- declares. Resolved through the same resolver `links` uses, so a cross-package
1249
- reference answered by a vendored manifest lands exactly as it would in a
1250
- wikilink. `--no-references` turns this one off for a tree whose cross-package
1251
- references it cannot see.
1252
- - **Dead Address reference** — an Address-valued `data:` field naming no local
1253
- note, asset or entry in a declared dependency index. The finding names the
1254
- field and the complete Address it names. A short form means this package;
1255
- another package requires the full Address. `--no-references` also turns off
1256
- this target check. Art slots report unresolved art through their own fallback
1257
- warning.
1258
-
1259
- **A schema says what a note may _write_, not what the compiler emits.** Those
1260
- are different, and the difference is the whole calibration of the check: a note
1261
- also feeds a knowledgebase and a website, and those read classification the pack
1262
- build never compiles — `kbcat` alone appears 51 times in SoHL's knowledgebase
1263
- layouts. Equating the vocabulary with the builder's allow-list reported 4,241
1264
- unknown properties against SoHL's own tree, every one correctly authored.
1265
-
1266
- Item types need no separate declaration: their field list already _is_ the
1267
- builder, so schema and compiler cannot disagree. The hand-written compilers —
1268
- `being`, `macro`, `doc` and the three map types — declare theirs in
1269
- `sohl/note-schemas.mjs`.
1270
-
1271
- **A place's `borders` and `routes` are checked from both ends.** A `place`
1272
- note states what it is next to and what it is reachable from as two `data:`
1273
- lists — `borders: [{ to, bearing }]` and
1274
- `routes: [{ to, bearing, mode, days, terrain?, leagues? }]` — and every value
1275
- is from a closed set: the eight bearings, `land | boat | ship`, a days scale of
1276
- `1 2 3 5 10 20 30 45 60 90 180 360`, and a terrain registry in which each
1277
- terrain names the modes that cross it. Every `to` must be the shortcode of a
1278
- place, here or in a fetched index. The other end must state the pair back —
1279
- at the opposite bearing, and for a route by the same mode in the same days.
1280
- A settlement states a border only when it shares a frontier; most settlements
1281
- use `parents` for location and `routes` for journeys. The terrain registry is
1282
- shared across packages. A neighbour stating nothing is a warning naming both notes; one stating a
1283
- different bearing, mode or days is an error, as is a border to the note's own
1284
- parent or child, or a pair listed twice by one mode. Each finding lands on the
1285
- entry that states it. The keys, the sets and the days scale are specified under
1286
- `type: place` in `docs/content-format.md`.
1287
-
1288
- **A settlement is held by someone.** An affiliation's `domains` names the
1289
- places it holds, and a `place` of subType `settlement`, `site` or `structure`
1290
- that no affiliation's `domains` names — in this tree or in a fetched index —
1291
- is a warning, `unheld land`, at the note's `type:` line, so a gap in tenure
1292
- shows. A region is held through its polity's `domains` and a feature by
1293
- nobody, so neither is checked.
1294
-
1295
- **Population figures agree from the region down.** A place states
1296
- `data.population` and an affiliation states how many people it counts, and four
1297
- warnings read those figures against each other. The polities whose `domains`
1298
- name a place must not count more people than the place states — `over-held
1299
- land` — with a polity subordinate to another holding the same place counted
1300
- once and a settlement exempt, its holder's figure counting a hinterland the
1301
- settlement does not. The regions whose `parents` name a place must not count
1302
- more than it states either — `over-full region` — a region naming both its
1303
- parent and its grandparent counting under the nearer. A settlement must not
1304
- hold more than any place containing it states, which is `oversized settlement`.
1305
- And a `doc` note carrying a figure beside a wikilink to the place or
1306
- affiliation it belongs to must state that note's figure, which is `disputed
1307
- figure`, located at the number it wrote. Figures are authored to two
1308
- significant digits, so a parent rounded up to five per cent below what it
1309
- contains is not a finding, and a place or affiliation stating no figure is
1310
- neither compared nor counted. A fetched index carries no figure, so a
1311
- dependency's people are counted nowhere. There is deliberately **no urban-share
1312
- rule**: a share computed from the named settlements against a region's total
1313
- measures how much of the region has been written, not how much of it is urban.
1314
-
1315
- **A settlement's market class is one of six.** `data.market` states what trade
1316
- a settlement supports — `1` a hamlet, with no market beyond what neighbours
1317
- trade among themselves; `2` a village and its weekly market; `3` a town with
1318
- several trades working full time; `4` a market town, its chartered fair and its
1319
- moneylender; `5` a city, where anything ordinary can be had in quantity; `6` a
1320
- great city that banks, imports as a matter of course, and is the market from
1321
- which the other markets buy. The numbers order the steps and do nothing else, so a value off the
1322
- scale is an error at the value that states it rather than a number rounded onto
1323
- a class the author did not write. The key is optional and the lint checks no
1324
- `subType` condition, as it checks none for `population`. The scale is stated
1325
- under `type: place` in `docs/content-format.md`.
1326
-
1327
- Nothing here writes. A check reports and an author fixes.
1328
-
1329
- **`content-build map` draws what those keys state.** `--tree` is the author's
1330
- check of `parents` — the containment tree, clustered by continent, with a
1331
- place no parent reaches, a parent no place declares and a cycle flagged red
1332
- and reported as findings. `--from <shortcode>` is the reader's map: the place
1333
- at the centre, north up, each neighbour at the angle of its bearing and on a
1334
- log-spaced ring of its days, a second hop drawn dimmer where the two hops
1335
- agree; `--from all` draws one for every place with a relation. `--travel` is
1336
- the whole route graph. Every drawing reads the content index, so a
1337
- dependency's places take part, and lands under `build/map/` as a `.dot`
1338
- beside its `.svg`; GraphViz draws, and is needed by this command alone. The
1339
- command and its rings are documented in `docs/commands.md`.
1340
-
1341
- ### The `data:` container is closed; the top level is not
1342
-
1343
- A note's frontmatter has three regions, and only one of them is open:
1344
-
1345
- | region | describes | an unknown key is |
1346
- | ---------------- | -------------------------------------------- | ------------------ |
1347
- | top level | the note as a published artefact | passed to the page |
1348
- | `data:` | the subject itself, whatever system reads it | an **error** |
1349
- | `sohl:` / `hm3:` | the subject as one system's documents | an **error** |
1350
-
1351
- **Top level is deliberately open**, for the reason the homepage rule above gives
1352
- at length: every key of it is copied into the generated page, so an
1353
- unrecognised one is a Hugo or theme parameter this build has no standing to
1354
- refuse. `description` is the everyday case — not a document field at all, but
1355
- the page's description.
1356
-
1357
- **`data:` is deliberately closed**, and that is the point of having it. The
1358
- type-specific facts about a subject — a weapon's weight, an affliction's
1359
- transmission, a being's species — do not sit at the top level, where the
1360
- pass-through rule applied to them too. So a misspelled `wieght` became a theme
1361
- parameter rather than a finding, indistinguishable from a weapon that weighs
1362
- nothing. Under `data:` the same key is reported where it was written, with the
1363
- key it was probably meant to be, drawn from that type's own vocabulary:
1364
-
1365
- ```text
1366
- assets/content/Gear/Axe.md:14:5: error: "wieght" is not a `data:` property of a weapongear; the container is closed, so unlike a top-level key it is not passed through to the page. Did you mean "weight"?
1367
- ```
1368
-
1369
- **A system block is closed too, and which blocks exist is the configuration's
1370
- answer**. A package is held to the blocks named after the systems it
1371
- declares it ships for, read from the three places that already declare them:
1372
- `systems:`, a pack's own `system:`, and `stats.systemId` where neither is
1373
- written. So a package shipping for HM3 has its `hm3:` block checked and a
1374
- package shipping for SoHL its `sohl:`. Held constant at `sohl`, an `hm3:` block
1375
- would never be read at all: every key in it would be discarded at compile
1376
- without a word, while the block that _was_ checked would be named after a system
1377
- the package does not ship for.
1378
-
1379
- A pack's `system:` counts because it is already authoritative at compile — a
1380
- note routed to a pack declaring one and carrying no such block fails the build —
1381
- so a lint blind to it would refuse a note for want of a block it never checked.
1382
- `harn-ensemble` declares its two systems that way and no other.
1383
-
1384
- **Each block is checked against its own system's vocabulary**, and that has two
1385
- sources. A system's **`itemBuilders` registry** covers its item types: `skill` is
1386
- one name over two data models, so a key SoHL's `skill` declares is not thereby a
1387
- key HM3's declares, and a block that borrowed its neighbour's field names would
1388
- accept the one mistake this check exists to report. The **note schemas** cover
1389
- the rest — `being` above all, which is an actor type and sits in no item
1390
- registry — and they belong to one system, SoHL, because that is the vocabulary
1391
- `content-build` is built with.
1392
-
1393
- A type neither source names — `mysticalability` is SoHL's, `invocation` is
1394
- HM3's — is a type that system says nothing about, and its block is left alone on
1395
- such a note rather than reported wholesale. A package that names no system at all
1396
- is system-agnostic on purpose: its packs are core document types carrying no
1397
- system data, so it has no system block and none is invented for it.
1398
-
1399
- **A block whose vocabulary nothing states is said out loud.** A package
1400
- declaring a system other than SoHL and no `itemBuilders` registry for it has
1401
- nothing that can say what that block may carry, so the block goes unchecked — and
1402
- `content-build lint` reports that once, naming the system and the registry to
1403
- declare, because a check that quietly does nothing is indistinguishable from one
1404
- that passed. `harn-ensemble` is the tree that gets it today: its `sohl:` block is
1405
- checked, and its `hm3:` waits on `itemBuilders: [hm3, sohl]`.
1406
-
1407
- **`subType` stays at the top level**, and is closed in its own way: a type
1408
- either declares a `subType` or does not, and a type that does declares its
1409
- values. A `weapon` declares none — SoHL distinguishes a weapon's uses by strike
1410
- mode rather than by kind — so `subType` on one is a finding; a `skill` declares
1411
- ten, so `subType: crafte` is a finding naming `craft`.
1412
-
1413
- **A `type` and a `subType` are both held to `^[a-z0-9]+$`** — the same
1414
- constant a `shortcode` is held to, read rather than restated. A type is a
1415
- segment of every address — the first of the short form an author writes, the
1416
- third of the canonical `package-system-type-shortcode` — so a hyphen in one is
1417
- read back as a segment boundary nobody wrote. A `subType` reaches no address
1418
- — sections are retired — and keeps the rule anyway: it is a vocabulary term the toolchain keys
1419
- on, and one charset that holds for every term is a rule an author can state. The
1420
- rule is checked ahead of the closed-set check, which is what makes it reach a
1421
- type whose values are declared but not yet enumerated:
1422
-
1423
- ```text
1424
- 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. …
1425
- ```
1426
-
1427
- One declared value broke that rule: a `doc`'s `user-guide`, now **`userguide`**.
1428
- The old spelling was accepted for one transitional release, as a warning naming
1429
- the replacement, because an error would have redded every tree that took the
1430
- release before it had a chance to sweep. Every tree has swept, so the acceptance
1431
- is gone: `user-guide` is refused by the charset check like any other
1432
- hyphenated value, and nothing retirement-specific was left to remove.
1433
-
1434
- The vocabulary lives in `engine/note-vocabulary.mjs`, one entry per note type,
1435
- taken from the content-format specification. It is note-format knowledge rather
1436
- than any system's: `data:` holds what is true of the thing, and what a system
1437
- makes of that value is declared in that system's own half.
1438
-
1439
- An Address field declares `kind: "address"`; an Address list declares
1440
- `kind: "list"` and `entryKind: "address"`. A keyed map keeps `kind: "map"`
1441
- and declares `keyKind`: `"address"` for affiliation relations, `"shortcode"`
1442
- for skill aptitudes. Aptitude keys also accept `subType:<skill-subtype>`.
1443
- `ref` supplies an omitted type and `accepts` states the permitted types;
1444
- these are independent declarations. Heterogeneous fields such as `economy`
1445
- and bundle `contents` require an explicit type. `economy` accepts only
1446
- `affiliation` and `lore` Addresses.
1447
-
1448
- The frontmatter lint checks these values and locates malformed entries without
1449
- requiring their targets to exist. `parseAddress(value, defaults, { declared:
1450
- true })` applies the shared grammar to a declared Address, validates every
1451
- segment, and returns a complete tuple or a reason. This mode leaves package
1452
- membership to resolution. The lint receives the builder's package separately
1453
- from target lookup, including when reference resolution is disabled.
1454
-
1455
- An affiliation authors native SoHL skill references at
1456
- `sohl.system.commonSkills`:
1457
-
1458
- ```yaml
1459
- sohl:
1460
- system:
1461
- commonSkills:
1462
- - herb
1463
- - sohl-sohl-skill-law
1464
- ```
1465
-
1466
- The first Address names this content package's SoHL skill; the second names a
1467
- skill in the declared SoHL dependency. The content index retains their complete
1468
- Addresses. Foundry compilation resolves each exact native skill Item in the
1469
- local or declared dependency index and writes its published Item UUID to the
1470
- affiliation's `system.commonSkills` array. The producer's module ID and pack
1471
- come from that index. An unresolved target or a target without a native Item
1472
- UUID is a located error. The shared `data:` container has no `commonSkills`
1473
- property. Infoboxes use a skill's published documentation journal when it has
1474
- one and show its known name as plain text otherwise.
1475
-
1476
- **There is deliberately no third rule** requiring every note to repeat its own
1477
- `type-shortcode` address in `aliases:`. It would serve one reader — Obsidian, so
1478
- `[[type-shortcode]]` resolves in the editor — and no build reads it: both
1479
- resolvers parse the hyphen qualifier themselves. The field is retired.
1480
-
1481
- **And the top-level field itself is now retired.** `aliases:` fed the
1482
- alias index, which is what a bare `[[Alias]]` was looked up in. That form
1483
- resolved to nothing anywhere in the corpus, while the collision rule guarding it
1484
- folded in every note's `name.full` and so decided what a note could be named
1485
- . Both are gone: every wikilink is an address, written
1486
- `[[type-shortcode|Text]]`, and declaring `aliases:` is refused naming the file
1487
- and the line.
1488
-
1489
- ### Writing a link: the address grammar
1490
-
1491
- Every wikilink is an address, and omission runs **strictly left to right**:
1492
-
1493
- ```text
1494
- [[[[<package>-]<system>-]<type>-]<shortcode>]
1495
- ```
1496
-
1497
- So the written forms are exactly the suffixes of the canonical address:
1498
-
1499
- | Form | Example | Means |
1500
- | ------------------------------- | ------------------------------------ | --------------------------------- |
1501
- | `type-shortcode` | `[[skill-clmb\|Climbing]]` | This package, any system. |
1502
- | `system-type-shortcode` | `[[sohl-skill-clmb\|Climbing]]` | This package, the `sohl` system. |
1503
- | `package-system-type-shortcode` | `[[thalorna-sohl-being-grod\|Grod]]` | Another package, fully qualified. |
1504
-
1505
- **There is no `package-type-shortcode`.** Naming a package means naming the
1506
- system before the type, because the segments are positional rather than tagged.
1507
- A link into another package must therefore be fully qualified — which is the
1508
- price of a grammar that needs no vocabulary to parse.
1509
-
1510
- **An omitted system is a wildcard; an omitted package is a default.** Most links
1511
- target items, which belong to a system, so a target naming none matches a note
1512
- under any of them and exactly one hit is required — two claimants is an
1513
- _ambiguity_, a different finding with a different fix from resolving nowhere. A
1514
- target naming no package means the citing note's own, so an unqualified link
1515
- resolves locally and only locally.
1516
-
1517
- **`sohl` is both a package and a system**, and positional counting is what makes
1518
- that harmless: three segments name a _system_ whatever the first segment could
1519
- also have meant, and four is the full form.
1520
-
1521
- **Parsing is plain segment counting**, the same rule the canonical key follows,
1522
- and it is sound because every segment is `^[a-z0-9]+$` — so a hyphen is
1523
- purely a separator. A target with five segments is not a hyphenated shortcode;
1524
- it is a name, and not an address.
1525
-
1526
- **`name.aliases` is kept, and is read by nothing.** It fed the same index and
1527
- lost the same reader, but unlike the top-level list it is **reserved** — held
1528
- for a use that does not exist yet. So it is the one field in the format that is
1529
- neither retired nor consulted: no index folds it in, no rule validates it,
1530
- nothing derives a name, address or URL from it, and no build branches on it. A
1531
- note carrying one compiles, resolves and addresses exactly as the same note
1532
- without it; it rides through into a page's emitted front matter untouched,
1533
- because the emitter spreads a note's frontmatter wholesale and stripping it
1534
- there would mean referencing it. Write it if you have a use for it later —
1535
- nothing today will read it.
1536
-
1537
- ### The homepage is the package root, addressed like every other note
1538
-
1539
- A homepage declares a `shortcode` — conventionally `root` — because that is
1540
- what a link is written with: `[[homepage-root|Read the introduction]]` is an
1541
- ordinary wikilink. It resolves to `/<package>/`, because the homepage _is_ the
1542
- package root: the site build writes it as the mount's `_index.md`, and Hugo
1543
- renders that as the `home` kind at `baseURL`. The shortcode names the page in
1544
- links; the address is the package root, and `[[thalorna-homepage-root]]` from
1545
- another package lands on `/thalorna/` for the same reason.
1546
-
1547
- A homepage that declares no `shortcode` is refused, located at the `type:`
1548
- value that makes it necessary:
1549
-
1550
- ```text
1551
- assets/content/homepage.md:3:7: error: a `type: homepage` note declares a `shortcode`, like every other note: it is addressed as `homepage-<shortcode>`, which is what `[[homepage-<shortcode>|Text]]` is written with to reach the package's front page at `/<package>/`. Write `shortcode: root` — the front page is `homepage-root` in every package
1552
- ```
1553
-
1554
- `root` is a **convention, not a rule**: the address only has to be unique within
1555
- the package, which `(type, shortcode)` already guarantees, and nothing here
1556
- knows better than an author what their front page is called. What the
1557
- convention buys is one spelling shared by every tree, so `[[homepage-root|…]]`
1558
- is the same link in every package.
1559
-
1560
- **Not a bare `[[homepage]]`.** With no hyphen it does not parse as an address,
1561
- so it would need a hardcoded single-token exception in the grammar.
1562
-
1563
- #### `id` and `landing` are refused
1564
-
1565
- Two top-level keys are refused, because neither decides anything on a page.
1566
- `id` is the Foundry document id a compendium UUID is built from, and a
1567
- homepage compiles into **no document**:
1568
-
1569
- ```text
1570
- assets/content/homepage.md:4:1: error: `id` decides nothing on a `type: homepage` note: it is the Foundry document id a compendium UUID is built from, and a homepage compiles into no document — it appears in no pack and in no link manifest. Delete it
1571
- ```
1572
-
1573
- `landing` is a card block. The homepage is a page with a body, rendered as
1574
- one, and no card block is read off it — an index of what the package
1575
- publishes is a `doc` note carrying a content table, linked from the homepage
1576
- like any other page:
1577
-
1578
- ```text
1579
- assets/content/homepage.md:5:1: error: `landing` decides nothing on a `type: homepage` note: the homepage is a page with a body, rendered as one, and no card block is read off it. Write the page's links in its body, and author an index of what the package publishes as a `doc` note carrying a content table. Delete it
1580
- ```
1581
-
1582
- That is also why a homepage states **no Foundry address**. A manifest entry is
1583
- how another package resolves a _document_; a cross-package link to a package's
1584
- front page is its bare `/<package>/` address, which needs no index.
1585
-
1586
- **A named class, not an allow-list.** The documented envelope is `type` and
1587
- `shortcode`, with `name`, `title`, `description` and `banner` legitimate beside
1588
- them — but an unknown top-level key is **not** refused, and that boundary is
1589
- the decision rather than an omission. A homepage's frontmatter is emitted into
1590
- the published page, so an unrecognised key is a Hugo or theme parameter this
1591
- build has never heard of and has no standing to reject; a closed list would
1592
- make every new theme parameter wait on a package-build release. `aliases` is
1593
- not in the class either — it is a retired field, refused on every note
1594
- whatever its type.
1595
-
1596
- **Where it fires: `content-build lint` only.** Unlike a rule about the shape of
1597
- the _tree_, which the site build has its own reason to gate on, this is a
1598
- _frontmatter-schema_ rule and `content-build site` runs none of them — wiring in
1599
- one type's field rule would have the site build refuse `id` on a homepage while
1600
- accepting `weight: heavy` on a weapon. The site build does refuse a homepage it
1601
- cannot address, because a link to it could not resolve otherwise, and it
1602
- reports that beside the count so the finding reaches `publish.site: homepage`
1603
- mode as well. The remaining gap is `HarnMaster-3-FoundryVTT`, which runs no
1604
- `content-build lint` at all and so receives no frontmatter finding of any kind;
1605
- that is a missing script in that repository, not a rule to duplicate one at a
1606
- time.
1607
-
1608
- ### `/<package>/` is the homepage, and nothing redirects
1609
-
1610
- The package's own address serves the homepage directly: Hugo renders the
1611
- mount's `_index.md` as the `home` kind at `baseURL`, which is
1612
- `https://www.heroiclands.org/<package>/`. `package-build site-root` writes no
1613
- `_redirects` beside the site — and removes one an earlier build left there,
1614
- since Cloudflare Pages would apply it. Its `_headers` suppress indexing on
1615
- every host-assigned address and nothing else: no `Cache-Control` is pinned on
1616
- the prefix root, because a lifetime on the homepage would hold a stale copy at
1617
- the most-linked address after a deploy. The same command then indexes the
1618
- rendered pages for search into `<package>/pagefind/`, served with the rest of
1619
- the site; `site.search: false` skips it.
1620
-
1621
- ### The homepage's own links
1622
-
1623
- The homepage is the page a reader arrives at, and it is the one page no
1624
- wikilink resolver reaches: a homepage is published verbatim in every
1625
- publishing mode, so its links are markdown links in its body, and `links`
1626
- audits them. A body link is emitted as written and resolved by the browser
1627
- against the homepage's own address, which _is_ the package root, so a
1628
- package-relative one (`kb/rules/`) lands where a reader expects.
1629
-
1630
- Three findings, and each one names the form to write instead:
1631
-
1632
- | Finding | Why |
1633
- | ---------------------------- | --------------------------------------------------------------------------------- |
1634
- | A **retired content type** | `kb/creature/` when `creature` became `being`. The engine knows what was retired. |
1635
- | A **hardcoded absolute URL** | Into this package's own prefix, or into one a fetched index names. |
1636
- | A **wikilink** | Nothing resolves one here: a homepage is published verbatim in every mode. |
1637
-
1638
- That last one is why a homepage does **not** get the wikilink resolution every
1639
- other note body gets. In `homepage` mode the content tree is never walked, so
1640
- there is no index for a wikilink to resolve against — and giving the page one
1641
- would make the mode depend on exactly the machinery its licensing fence exists
1642
- to not build. So a homepage addresses the web the way the web does, and a
1643
- wikilink on one is reported rather than resolved.
1644
-
1645
- **What is checkable, and what is not.** Only an address into this site is, and
1646
- only against facts the build already holds — the retired-type table and the
1647
- package prefixes a fetched index names. Two things are deliberately not
1648
- attempted:
1649
-
1650
- - **Whether an external URL answers.** There is no network at build time, and a
1651
- build must not go red because a third party is down.
1652
- - **Whether a live in-site address names a page that exists.** Several surfaces
1653
- a homepage routes to are produced by other tools entirely — generated API
1654
- documentation, say — so this build does not hold the set of published pages
1655
- and would report a working link as dead. A bare `/<package>/` names another
1656
- package's front page and needs no index: it is left alone because it cannot
1657
- be improved.
1658
-
1659
- ## The content format specification
1660
-
1661
- `docs/content-format.md` is the contract this package honours: how a note
1662
- becomes a Foundry document and a web page. Three frontmatter regions, a note
1663
- vocabulary with its own `type` and `subType`, a declared map onto each system's
1664
- document fields, the precedence between a shared source and a system's override,
1665
- and the wikilink address grammar.
1666
-
1667
- **It does not define the `sohl:` or `hm3:` schemas.** Each system defines its
1668
- own, and its published `schema.json` is the authoritative statement of it. The
1669
- format says which shared source feeds which system field; what fields exist, and
1670
- what they mean, belongs to the system.
1671
-
1672
- That makes a mapping row **checkable rather than declarative**, and two commands
1673
- check it. Both read the document's own tables, so editing the specification
1674
- changes what they assert — a transcribed copy would be free to drift from the
1675
- prose the moment either was edited, which is the failure they exist to prevent.
1676
-
1677
- ### `content-format schema` — the specification against a published schema
1678
-
1679
- ```bash
1680
- npx content-build content-format schema --schema sohl=./schema.json
1681
- ```
1682
-
1683
- Every `system.*` target the document names must appear in the naming system's
1684
- published `schema.json`, in the `version: 1` shape `package-build schema` emits.
1685
- A target no schema declares is an error naming the note type, the field, the
1686
- system and the version it was checked at — because a field may be perfectly well
1687
- defined on the system's `main` and simply unreleased, and that is the difference
1688
- between "the specification is wrong" and "the schema has not caught up".
1689
-
1690
- A target is resolved against the **union** of the system's document subtypes.
1691
- The mapping tables say which field a shared source reaches; _which subtype
1692
- receives it_ is the note-type → subtype map, which does not exist yet — so the
1693
- question asked is the one that can be answered honestly today, and it narrows to
1694
- the subtype when that map lands.
1695
-
1696
- `--schema` is repeatable and takes `<system>=<path>`, because a consumer holds
1697
- its system's artifact and this repository holds a committed fixture, and neither
1698
- arrangement should have to pretend to be the other. A system the document maps
1699
- onto but no schema was supplied for is reported as **unchecked**, with a count:
1700
- HM3 publishes no artifact today, so that is the ordinary case for a fifth of the
1701
- claims, and a check that skipped them in silence would read as one that passed.
1702
-
1703
- ### `content-format notes` — a content tree against the declared vocabulary
1704
-
1705
- ```bash
1706
- npx content-build content-format notes # the configured `paths.content`
1707
- npx content-build content-format notes --strict # and fail on what it finds
1708
- ```
1709
-
1710
- Measures every authored note against the per-type `data` tables, and counts the
1711
- findings by class:
1712
-
1713
- | class | what it means |
1714
- | ----------------------- | --------------------------------------------------------------------- |
1715
- | `unknown-type` | the format declares no `### type:` section for this note's `type` |
1716
- | `unknown-data-key` | a key in `data:`, which is closed, that the type does not declare |
1717
- | `top-level-data-key` | a declared `data` property written at top level instead |
1718
- | `system-block-data-key` | a declared shared source written straight into a `sohl:`/`hm3:` block |
1719
-
1720
- **It reports; it does not fail.** Every authored note predates the format, so a
1721
- failing check would be red in every repository on the day it lands and would
1722
- stay red for the length of the migration — which is a check nobody can act on
1723
- and everybody learns to skip. The counts are the migration's progress bar
1724
- instead, and each class is promoted to fatal, by turning `--strict` on, as it
1725
- reaches zero.
1726
-
1727
- What it deliberately leaves alone is a key inside a system block that the format
1728
- says nothing about. Those regions are closed against _the system's_ schema, not
1729
- against this document, and `content-build lint` already checks them against the
1730
- declared fields.
1731
-
1732
- ## Prose: formatting and markdown
1733
-
1734
- ```bash
1735
- npx content-build format # check the whole repository
1736
- npx content-build format --write # rewrite what is not formatted
1737
- npx content-build markdown # lint every markdown file
1738
- npx content-build markdown --fix # apply the fixes markdownlint can make
1739
- ```
1740
-
1741
- Two conventions every content repository writes to, declared once here so a note
1742
- formatted in one is formatted the same way in the next:
1743
-
1744
- - **`format`** runs Prettier. Same values SoHL has always used, so a module or a
1745
- note moving between repositories does not reformat on arrival.
1746
- - **`markdown`** runs markdownlint — the structural checks Prettier cannot make:
1747
- a heading level that skips, two sibling headings claiming one anchor, a
1748
- reversed `(text)[url]`, a bare URL, an empty link, a table row with the wrong
1749
- cell count, and the emphasis markers (`_emphasis_`, `**strong**`) these
1750
- repositories write.
1751
-
1752
- The rule set is **deliberately narrow**. markdownlint's defaults over a content
1753
- tree produce tens of thousands of findings, almost all of them line length, list
1754
- indentation and blank lines — Prettier's territory. So `default` is off and each
1755
- rule is enabled by name, with the reason it earns its place; add one only if it
1756
- can report that a page is _wrong_.
1757
-
1758
- Both run over the **repository**, not the content tree, and neither reads the
1759
- pack configuration — a repository's formatting covers everything it holds, and
1760
- one that has not configured this package at all can still format itself.
1761
-
1762
- **What ships here is a default, not an override.** A consumer's own Prettier
1763
- config or `.markdownlint-cli2.jsonc` wins wherever it has one. Which paths to
1764
- skip is knowledge about a repository's layout and stays with that repository, in
1765
- its own `.prettierignore` and `.gitignore` — both honoured, as Prettier and
1766
- markdownlint honour them natively. The one exception is `CHANGELOG.md`, which
1767
- `changeset version` regenerates in every repository here: linting it reports on
1768
- the generator, so it is skipped by default.
1769
-
1770
- **A default that says when it is not in force.** Because a local config wins
1771
- wholesale rather than merging, the conventions above otherwise hold by convention
1772
- alone: a `prettier.config.mjs` that spreads `PRETTIER_BASE` without the `**/*.md`
1773
- override reindents every note at 4, and a partial `.prettierrc` such as
1774
- `{"tabWidth": 2}` silently discards `printWidth: 100`, `trailingComma` and the
1775
- rest. So every `format` run first names, as warnings, each shared convention this
1776
- repository resolves differently:
1777
-
1778
- ```text
1779
- prettier.config.mjs: warning: markdown `tabWidth` is 4 here; the shared configuration says 2
1780
- .prettierrc: warning: `printWidth` is not set here, so Prettier's own default applies; the shared configuration says 100
1781
- ```
1782
-
1783
- A repository with **no** Prettier config is warned too, and it is the sharper
1784
- case: the shared conventions then reach this command and nothing else, so an
1785
- editor's format-on-save and a bare `npx prettier --check .` apply Prettier's own
1786
- defaults to the same tree and the two take turns rewriting the same lines. The
1787
- fix is the one-line re-export below.
1788
-
1789
- None of this fails a run. A deliberate local choice still wins — it just stops
1790
- being silent.
1791
-
1792
- Neither tool's file discovery is reimplemented, so `content-build format --check`
1793
- and a bare `prettier --check .` report the same thing. A file Prettier cannot
1794
- parse is a **finding**, with its position — not a crash that costs the report on
1795
- every other file.
1796
-
1797
- To make an editor agree with the lint chain, point its config at the same rules:
1798
-
1799
- ```js
1800
- // prettier.config.mjs
1801
- export { default } from "@heroiclands/package-build/prettier";
1802
- ```
1803
-
1804
- ```js
1805
- // .markdownlint-cli2.mjs — extending rather than replacing
1806
- import shared from "@heroiclands/package-build/markdownlint";
1807
- export default { ...shared, config: { ...shared.config, MD013: true } };
1808
- ```
1809
-
1810
- ## YAML: frontmatter, and every YAML file
1811
-
1812
- ```bash
1813
- npx package-build yaml # every YAML file git would consider
1814
- npx package-build yaml assets/content # or just these paths
1815
- ```
1816
-
1817
- Frontmatter carries a note's type, its shortcode, its address and the system
1818
- blocks a document is compiled from — and until this command existed nothing
1819
- checked it _as YAML_.
1820
-
1821
- **Worse than unchecked: a parse failure unmade the note.** `parseMarkdownFile`
1822
- caught the error, logged it at `warn`, and returned `{frontmatter: null}` — which
1823
- is not a note with bad frontmatter but, to every pass downstream, _a file with no
1824
- frontmatter_. It was skipped by the compiler, the linter, the link checker and
1825
- the index, and the build reported success. A duplicate key did not fail anything;
1826
- it removed a note from the corpus. The parser had detected it all along.
1827
-
1828
- **Frontmatter is linted through an ESLint processor** — the mechanism
1829
- `eslint-plugin-markdown` uses for fenced code blocks. Frontmatter is its easy
1830
- case: the block is always at the top of the file, so a finding maps back to the
1831
- line it came from with a constant `+1` for the opening `---`, and nothing after
1832
- the closing `---` is read as YAML.
1833
-
1834
- The rule set is **deliberately narrow**, for the same reason `markdown`'s is.
1835
- Prettier already owns YAML's whitespace, quoting and line breaks — including
1836
- inside a frontmatter fence — so a rule about any of those would duplicate the
1837
- formatter or fight it. What is left is the class a formatter cannot see: text
1838
- that parses to something other than what it looks like.
1839
-
1840
- | Reported | Why it is not a matter of taste |
1841
- | ------------------------------------- | -------------------------------------------------------------- |
1842
- | a parse error | A duplicate key, a tab indent, mis-aligned mapping items. |
1843
- | `yml/no-empty-mapping-value` | `folder:` and `folder: null` are one value and two statements. |
1844
- | `yml/no-irregular-whitespace` | A non-breaking space is invisible and part of the value. |
1845
- | `yml/no-empty-key`, `-empty-document` | A fence or a file that parses to nothing at all. |
1846
-
1847
- `folder:` and `folder: null` read as opposites — a decision, or a key somebody
1848
- began and did not finish — so the distinction is drawn where the text still
1849
- exists. **A key with a block under it is not empty**: `name:` followed by an
1850
- indented mapping, or by a sequence at its own indent, is an ordinary container.
1851
-
1852
- **GitHub workflows are exempt from the empty-value rule.** `on:`, `push:` and
1853
- `workflow_dispatch:` carry their meaning by being present, and writing
1854
- `push: null` to satisfy a linter would be worse YAML, not better. A real error in
1855
- a workflow is still reported.
1856
-
1857
- **The files are the ones git would consider** — `--cached --others
1858
- --exclude-standard`, tracked plus untracked-and-not-ignored, the same set
1859
- `gitignore: true` gives the markdown linter. Untracked is included so a note is
1860
- linted while it is being written rather than only once it has been staged.
1861
-
1862
- **A consumer needs no ESLint.** This ships as a command, not as a configuration
1863
- to adopt: there is no `eslint` dependency to add, no `eslint.config.js` and no
1864
- rules to declare. A repository that _has_ an ESLint of its own keeps it untouched
1865
- and unconsulted — the run sets `overrideConfigFile: true`, so no config file is
1866
- looked for at all.
1867
-
1868
- ## Publishing the content index
1869
-
1870
- Every package emits one file naming every note it publishes:
1871
-
1872
- ```bash
1873
- npx content-build content-index # the configured tree and output directory
1874
- ```
1875
-
1876
- `<contentPackage>-metadata.jsonl` holds one JSON record per line, keyed by the
1877
- canonical `package-system-type-shortcode` address and carrying every address
1878
- that note has: its `address.slug` on the web, a `foundry.<system>.uuid` in
1879
- Foundry, the `anchors` its named sections compiled to, and a `documentation`
1880
- pointer where an item's prose compiles into a JournalEntry of its own.
1881
-
1882
- **This is the artifact other packages resolve your addresses through.** It is
1883
- advertised in the emitted `system.json` / `module.json` as
1884
- `flags.metadataUrl` — derived, version-pinned, and not something you write down
1885
- — and published as a release asset beside the manifest and the `.zip`.
1886
-
1887
- | Setting | What it decides |
1888
- | -------------------- | --------------------------------------------- |
1889
- | `contentPackage` | The package emitted, and the file's name. |
1890
- | `foundryPackage` | The package every emitted `uuid` names. |
1891
- | `paths.content` | The tree walked. |
1892
- | `paths.contentIndex` | Where the file lands (`build/content-index`). |
1893
-
1894
- **Both addresses are optional, independently.** A note that compiles into no
1895
- document states no `foundry` block, and a package that ships compendiums and
1896
- publishes only a homepage serves no page at its addresses. Neither is an error,
1897
- and neither is guessed: inventing the missing one asserts a target that does not
1898
- exist, which is the silent dead link this whole mechanism exists to prevent.
1899
-
1900
- ## Resolving another package's addresses
1901
-
1902
- Declare what you depend on, and fetch it:
1903
-
1904
- ```bash
1905
- npx content-build deps fetch # every declared dependency
1906
- npx content-build deps fetch --from ../sohl # from a local build, unreleased
1907
- ```
1908
-
1909
- ```yaml
1910
- relationships:
1911
- systems:
1912
- - id: sohl
1913
- type: system
1914
- manifest: https://github.com/HeroicLands/Song-of-Heroic-Lands-FoundryVTT/releases/latest/download/system.json
1915
- compatibility: { minimum: "0.8.0", verified: "0.8.2" }
1916
- ```
1917
-
1918
- `deps fetch` reads that manifest, takes the `flags.metadataUrl` it advertises,
1919
- and pulls the index into `build/cache/metadata`. The whole chain is declared, so
1920
- nothing here holds an address of its own and a dependency that moves its release
1921
- assets does not break its consumers.
1922
-
1923
- **You may cite what you depend on, and nothing else.** The set is every entry in
1924
- `relationships.systems` and `relationships.requires`; `recommends` and
1925
- `conflicts` are declarations _about_ other packages rather than dependencies on
1926
- them, so a link into one is a defect in the citing note. An address into a
1927
- package you have not declared resolves nowhere and fails the build.
1928
-
1929
- **A build never reaches the network.** A declared dependency whose index has not
1930
- been fetched is an error naming `deps fetch`, rather than a download nobody
1931
- asked for — a build that downloads silently is not reproducible and fails
1932
- strangely offline. The cache is keyed by version, so changing the pinned version
1933
- is a miss rather than a silent overwrite.
1934
-
1935
- ### A relationship may be a Foundry dependency only
1936
-
1937
- `requires` and `systems` install with Foundry whether or not the tree cites
1938
- them, and a package may need the one without the other — thalornaaltart
1939
- `requires` Thalorna so Foundry installs the base module, and its one homepage
1940
- note links nowhere. Declare `contentIndex: false` on that entry to say so:
1941
-
1942
- ```yaml
1943
- relationships:
1944
- requires:
1945
- - id: thalorna
1946
- type: module
1947
- manifest: https://github.com/HeroicLands/thalorna/releases/latest/download/module.json
1948
- contentIndex: false
1949
- ```
1950
-
1951
- `deps fetch` fetches nothing for it — no cache directory, nothing to go stale —
1952
- and a wikilink into it fails at the link, naming `contentIndex`, rather than
1953
- resolving against a stale declaration or an index nobody fetched. It cannot be
1954
- combined with `itemCatalog: true`, which extracts items from the same index
1955
- this declares there is none of.
1956
-
1957
- ### `packagebuild` needs no declaration
1958
-
1959
- package-build ships a set of images of its own — section banners chiefly — and a
1960
- note reaches one without declaring anything:
1961
-
1962
- ```yaml
1963
- data:
1964
- banner: packagebuild-none-image-skillbnr
1965
- ```
1966
-
1967
- **There is nothing to declare and nothing to fetch.** package-build is an npm
1968
- dependency of every consumer rather than a Foundry package, so there is no
1969
- release archive behind the name and no reason to fetch one — the tree is already
1970
- on disk under `node_modules/@heroiclands/package-build/assets/`. The records are
1971
- walked from it on every load and join the index like any other package's, so a
1972
- cold cache is not a failure mode here and every lookup stays one path.
1973
-
1974
- `packagebuild` is a **reserved** name: no repository may configure it as its
1975
- `contentPackage`, and a configuration that tries is refused.
1976
-
1977
- **These addresses have no Foundry form.** Foundry installs no package for this
1978
- one, so a resolver returns "no Foundry address" deliberately rather than
1979
- deriving `modules/packagebuild/…`, which installs nowhere. That is not a
1980
- limitation in practice: the only slot that names them is `banner`, which reaches
1981
- no compiled document at all and is read by the website and the book.
1982
-
1983
- **A fetched catalogue is read one system at a time.** A dependency may ship a
1984
- pack per system, and the two hold the same `(type, shortcode)` addresses with
1985
- different data models — `skill:awar` is a real address in both vocabularies and
1986
- means two different documents. So a pack declaring `system: hm3` resolves its
1987
- embedded items against the dependency's `hm3` packs and its system-neutral ones,
1988
- never against another system's, exactly as it already does for this repository's
1989
- own packs. What each cached pack is comes from the dependency's manifest at
1990
- fetch time, so a cache filled before this rule existed is treated as incomplete
1991
- and `deps fetch` refills it — the alternative is a lookup that answers with the
1992
- wrong system's document and reports nothing.
1993
-
1994
- **Within one document, the shortcode itself comes from `system.shortcode`
1995
- where the data model declares such a field, and otherwise from
1996
- `flags.<systemId>.shortcode` in that same system's own flag namespace** — a
1997
- system writes its per-document handle into its own flags and never another
1998
- system's, so a document carrying its shortcode under a different system's flag
1999
- namespace is not resolved by it.
2000
-
2001
- **`--from` is for two packages changing together.** It fills the cache from a
2002
- locally built artifact — a package zip or the directory it was built from — so a
2003
- consumer can be built against a dependency that has not shipped. Without it,
2004
- testing a dependency change against its consumers would cost a release
2005
- round-trip.
2006
-
2007
- > **This replaced a vendored link manifest**, which every repository committed a
2008
- > copy of every other repository's file into. A copy went stale silently and one
2009
- > did — 2,101 entries in `Song-of-Heroic-Lands-FoundryVTT` pointed at URLs the
2010
- > `thalorna` site had stopped publishing, at the current format version, so the
2011
- > version gate saw nothing. Mutual vendoring also deadlocked: each package had to
2012
- > read the other's file before it could publish its own. A fetched artifact
2013
- > cannot drift from its producer, and a consumer reads one the producer has
2014
- > already shipped.
2015
-
2016
- ### A page's URL is its address
2017
-
2018
- ```text
2019
- /<package>/<type>-<shortcode>/
2020
- ```
2021
-
2022
- `(type, shortcode)` names one note within a package — that is the rule
2023
- `content-build lint` enforces — so the URL is **unique by construction**. There
2024
- is no collision check behind it, there never can be one to fail, and renaming a
2025
- note changes nothing: no part of the address comes from a display string.
2026
-
2027
- **A URL carries no `<system>` segment**, though the canonical address does. That
2028
- is deliberate rather than an omission: a note publishes one page however many
2029
- systems' documents it compiles into, so the segment would have nothing to
2030
- distinguish and would only split one page's URL in two. The canonical address
2031
- names a _document_; a URL names a _page_. So a consumer deriving a page address
2032
- from a manifest key drops the package **and** the system, not the package
2033
- alone.
2034
-
2035
- It does not come from `name.full`. That would make a display name load-bearing three
2036
- ways at once — a rename silently 404'd every inbound link, two notes in one
2037
- section could derive the same URL so a uniqueness gate had to run, and long names
2038
- had to be shortened through a table of 200 abbreviations. The header of the
2039
- module doing it justified the cost by promising redirects "every change appends
2040
- to the legacy-URL map" — and no such map was ever written, here or in any
2041
- consumer. All of it is gone.
2042
-
2043
- The `type-` half earns its place: it keeps every content address clear of the
2044
- package's fixed mounts (`/<package>/` for the homepage, `/<package>/api/` for
2045
- generated API docs), neither of which contains a hyphen or names a type. So the
2046
- namespace is provably disjoint rather than conventionally so.
2047
-
2048
- **A page is written flat, named by its address**, not filed into a
2049
- directory, because Hugo derives a page's section from where the file is
2050
- written, and a section appears in no address. The file is
2051
- `<mount>/<type>-<shortcode>.md` and the front-matter `url:` publishes it at
2052
- the package root, one level above; the `section` kind is disabled on every
2053
- site, so no directory would answer even if one were written.
2054
-
2055
- **A page states its address without the package base; everything pointing _at_
2056
- it composes one**. They read as one quantity and are two:
2057
-
2058
- | Written | Form | Because |
2059
- | ------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------- |
2060
- | A page's own `url:` front matter | `/<type>-<shortcode>/` | Hugo resolves it against `baseURL`, whose path already _is_ where the package is served |
2061
- | Every `href` this build renders into a body | `<base><type>-<shortcode>/` | A browser resolves it against nothing |
2062
- | A link-manifest `path` | `<type>-<shortcode>/` | Measured against `site.base` and stripped; a consumer prefixes its own |
2063
-
2064
- `site.base` is the second and third of those and reaches the first not at all.
2065
- Writing it into the `url:` as well makes every consumer's Hugo prefix
2066
- its own base to a value that already carried one and published every content
2067
- page a segment too deep — `/sohl/sohl/doc-rulesintro/`, 404 at the address the
2068
- manifest, the sitemap and every inbound link named.
2069
-
2070
- **There is no landing page and no section.** A page that introduces the notes
2071
- of a type is an ordinary note addressed `doc-<type>`, with no build path of its
2072
- own, carrying a content table over what it introduces. A site is its homepage
2073
- and its pages, and nothing is generated between them — see
2074
- [What the site publishes](#what-the-site-publishes).
2075
-
2076
- ### The address scheme
2077
-
2078
- Where the content tree mounts _inside the package_ differs between repositories
2079
- and is load-bearing. It is one setting, read by this command **and** by the page
2080
- emitter, so the address a manifest publishes is the address a page is emitted at
2081
- — stating it twice is how a manifest comes to assert a URL that resolves at build
2082
- time and 404s for the reader.
2083
-
2084
- ```yaml
2085
- publish:
2086
- site: content
2087
- manifests: { publish: true, consume: true }
2088
- address:
2089
- prefix: kb/ # default: "" — the package root
2090
- ```
2091
-
2092
- - **`prefix`** — the content tree's mount within the package: the Hugo directory
2093
- its pages are written under. `sohl` publishes a knowledgebase alongside
2094
- generated API docs, so its tree sits under `kb/` while its pages address the
2095
- package root (`affliction-aconite/`); `thalorna`'s site is nothing but its
2096
- content, so it has no prefix. It must end in a slash and must not begin with
2097
- one — where the _package_ is mounted is the consuming build's knowledge and is
2098
- never recorded here.
2099
-
2100
- `prefix` is the whole scheme. The `collection` subtype and the top-level
2101
- `section:` key are refused by name.
2102
-
2103
- A note's `subType` is checked against the values its type declares, and only
2104
- those.
2105
-
2106
- The only note the scheme yields no address for is one carrying no `shortcode`.
2107
- It is **reported and omitted**, never guessed: the command prints one located
2108
- diagnostic per note and still writes the file, because a note with no address is
2109
- ordinary while a manifest entry pointing at a page that does not exist is not.
2110
-
2111
- ## Publishing a content index
2112
-
2113
- Every content build walks the whole note tree and parses every note's
2114
- frontmatter — the pack compilers, the site build, and the content-table expander
2115
- each do it — and every one of them throws the result away. So nothing outside a
2116
- build can ask a question about the content. "Which beings carry no `kbcat`?",
2117
- "what does this table actually select?", "did that type rename leave anything
2118
- behind?" have no answer short of writing a throwaway script that re-walks the
2119
- tree, which is how eight dead Bestiary tables came to ship for weeks unnoticed.
2120
-
2121
- `content-index` publishes the walk:
2122
-
2123
- ```bash
2124
- npx content-build content-index
2125
- # sohl → build/content-index/sohl.jsonl (1606 notes, 1578 KiB)
2126
- ```
2127
-
2128
- One line of [JSON Lines](https://jsonlines.org/) per note, holding the note's
2129
- whole frontmatter plus where it sits in the tree:
2130
-
2131
- ```json
2132
- {
2133
- "type": "being",
2134
- "shortcode": "aurochs",
2135
- "package": "sohl",
2136
- "file": { "path": "Bestiary/Animal/Aurochs.md", "folder": "Bestiary/Animal", "name": "Aurochs" },
2137
- "sohl": { "kbcat": "animal", "body": { "weight": { "base": 1500 } } }
2138
- }
2139
- ```
2140
-
2141
- so a question is one line of `jq`:
2142
-
2143
- ```bash
2144
- jq -r 'select(.type == "being" and .sohl.kbcat == "animal") | .shortcode' \
2145
- build/content-index/sohl.jsonl
2146
- ```
2147
-
2148
- ### The record is the note, not a projection of it
2149
-
2150
- Nothing is selected, flattened, or renamed. A reader addresses
2151
- `sohl.body.weight.base` because that is what the note says — which is also,
2152
- not by accident, exactly what a `dataview` content-table query writes.
2153
-
2154
- That is a deliberate refusal to impose a schema, and the tree is why. In `sohl`,
2155
- frontmatter spreads **242 distinct leaf paths** unevenly over **15 types**, from
2156
- 9 on a `macro` to 72 on a `being`, and adding a field to one type is ordinary
2157
- authoring. A format with a fixed column set would turn that authoring into a
2158
- schema migration; a document format has no such problem.
2159
-
2160
- Three keys are **derived** rather than authored, and a note carrying one is an
2161
- error rather than a silent overwrite:
2162
-
2163
- | Key | What it holds |
2164
- | --------- | -------------------------------------------------------------------------------------------------- |
2165
- | `package` | The configured `contentPackage`. A note may not declare its own, and the expander reads the same. |
2166
- | `file` | `path`, `folder` and `name` below the content root — the same `file.*` a content-table query uses. |
2167
- | `asset` | The file an asset record addresses. Present on an asset's record and on no note's. |
2168
-
2169
- The location is namespaced under `file` precisely because `folder` is real
2170
- frontmatter on most notes; a record states both, and they mean different things.
2171
-
2172
- ### A file is a record too
2173
-
2174
- The same pass walks the package's **asset roots** — `assets/icons`,
2175
- `assets/images` and `assets/audio` — and emits one record per addressable file
2176
- into the same index. There is no second artifact and no asset-specific emitter:
2177
- a package whose tree holds only pictures publishes an ordinary content index
2178
- that happens to hold only asset records.
2179
-
2180
- The filename is the shortcode, the root supplies the type, and the layout in
2181
- between is the package's own business:
2182
-
2183
- ```json
2184
- {
2185
- "type": "icon",
2186
- "shortcode": "anvil",
2187
- "package": "sohl",
2188
- "address": { "canonical": "sohl-none-icon-anvil" },
2189
- "asset": {
2190
- "path": "icons/game-icons/lorc/anvil.svg",
2191
- "attribution": "Lorc",
2192
- "source": "http://lorcblog.blogspot.com",
2193
- "license": "CC-BY-3.0",
2194
- "notes": "From game-icons.net"
2195
- }
2196
- }
2197
- ```
2198
-
2199
- An asset record carries no frontmatter, no anchors and no `foundry` block, and
2200
- its `address` holds the canonical key and no page slug — a file declares nothing
2201
- about itself, compiles into no document, and publishes no page. The `asset`
2202
- block is what a reader tells the two shapes apart by.
2203
-
2204
- `path` is relative to the emitting package's own asset directory, so each
2205
- consumer joins its own root onto it and resolves in one step. Provenance comes
2206
- from a sibling `<filename.ext>.yaml` where one exists, and otherwise from the
2207
- nearest `provenance.yaml` above the file, searching no higher than the type
2208
- root. `docs/content-format.md` states the whole rule.
2209
-
2210
- Two files under one root sharing a basename are two claims on one address, and
2211
- the build fails naming both.
2212
-
2213
- ### Every note's address, and every anchor it defines
2214
-
2215
- A record states the address a wikilink writes to reach the note, and every
2216
- `{#slug}` anchor its body declares — unless the note is a **stub**, which
2217
- carries both as `null`: it publishes no page, so it holds no address and offers
2218
- no anchor to reach. Everything else it declares is kept, its `id` and `foundry`
2219
- block included, because a document is derived from `data:` rather than from
2220
- prose.
2221
-
2222
- The SQL views read that absence as the state: `entries` is every row plus a
2223
- derived `state` column, and `notes` is `entries` without the stubs. Nothing
2224
- about the state is stored, and no note may author it.
2225
-
2226
- ```json
2227
- {
2228
- "address": { "slug": "being-aurochs", "canonical": "sohl-sohl-being-aurochs" },
2229
- "file": { "path": "Bestiary/Animal/Aurochs.md", "folder": "Bestiary/Animal", "name": "Aurochs" },
2230
- "anchors": [
2231
- {
2232
- "slug": "appearance",
2233
- "name": "Appearance",
2234
- "level": 1,
2235
- "line": 348,
2236
- "link": "being-aurochs#appearance"
2237
- },
2238
- {
2239
- "slug": "dossier",
2240
- "name": "Dossier",
2241
- "level": 1,
2242
- "line": 352,
2243
- "link": "being-aurochs#dossier"
2244
- }
2245
- ]
2246
- }
2247
- ```
2248
-
2249
- A record also states `nameAscii`, the note's `name.full` reduced to printable
2250
- 7-bit ASCII:
2251
-
2252
- | `name.full` | `nameAscii` |
2253
- | --------------- | ----------------- |
2254
- | `Kûrbúl ¾-Helm` | `Kurbul 3/4-Helm` |
2255
- | `Kèthîra` | `Kethira` |
2256
- | `Ærling` | `AErling` |
2257
- | `Þorn` | `Thorn` |
2258
- | `Ðunhold` | `Dunhold` |
2259
- | `Straße` | `Strasse` |
2260
-
2261
- Names carry the setting's orthography and nobody types them, so anything
2262
- searching or completing over the index needs a form a keyboard produces. Stating
2263
- one means every consumer matches the same way, rather than each inventing a
2264
- slightly different fold and two searches over the same data disagreeing.
2265
-
2266
- It **transliterates rather than strips**, through the same `unidecode` table
2267
- `slugify` already runs — so an ASCII name and a slug can never disagree about a
2268
- character. Diacritics fold, ligatures expand, the runic letters spell out, and a
2269
- vulgar fraction becomes readable. Deleting the marks instead would reduce
2270
- `Kûrbúl` to `Krbl`, which is worse than the original for anyone trying to
2271
- recognise it. Whatever is still outside printable ASCII afterwards becomes a
2272
- space and runs of whitespace collapse — a space rather than nothing, so a
2273
- character that transliterates away cannot weld two words together.
2274
-
2275
- The value is emitted even when it equals the name, so a consumer matching on it
2276
- never has to branch on whether the name happened to be ASCII already; it is
2277
- `null` only when the note has no name at all. On the `sohl` tree, 25 of 1,606
2278
- notes differ from their `name.full`.
2279
-
2280
- `aliasesAscii` does the same for `name.aliases`, in the authored order. An alias
2281
- is the name a reader is at least as likely to reach for as the canonical one —
2282
- `Killer Whale` for an orca, `Ice Bear` for a polar bear, `Ix'balam` for a
2283
- jaguar — so anything searching the index has to match them too. It is an **empty
2284
- array**, never null, when a note has no aliases: an empty set of names is a fact
2285
- rather than a missing value, and a consumer iterating it should not have to check
2286
- first. An entry that is not a non-empty string is dropped rather than left as a
2287
- hole, since the array is a set of names to match and a null is not one.
2288
-
2289
- `address.slug` is what goes inside `[[…]]` within the package; `address.canonical`
2290
- is the fully qualified key the content index files the note under, carrying the
2291
- package and the system as well. The slug is the canonical key's **last two
2292
- segments**, not its whole tail: a page has no system to name, so the two forms
2293
- diverge by that segment rather than one trailing the other. Both are `null` for a
2294
- note with no type or no shortcode, which has no address at all — the record says
2295
- so rather than leaving each reader to rediscover the rule.
2296
-
2297
- **Neither is new information** — the slug derives from `type` and `shortcode`,
2298
- which every record already carries, and the canonical key adds only the system
2299
- those two already imply. What the fields add is the _rule_: the lowercasing
2300
- and the hyphen join live in one place, derived by the same `addressSlug` and
2301
- `canonicalKey` the manifest and the site build use, so an index cannot disagree
2302
- with either about where a note lives. A consumer that reimplements the join
2303
- slightly differently gets a lookup matching nothing and no explanation — which is
2304
- exactly how a resolver keyed on a bare `type/shortcode` silently misses every
2305
- canonical `pkg-system-type-shortcode` entry.
2306
-
2307
- **Anchors make a link checkable without a build.** Because the index states every
2308
- anchor a note defines, `[[being-aurochs#dossier]]` can be confirmed — or shown
2309
- dead — by a lookup, rather than by re-parsing the tree. Each anchor also carries
2310
- its **line in the file**, so an editor jumps straight to the heading instead of
2311
- searching for it, and a diagnostic about a section can name a real position.
2312
-
2313
- Only headings carrying an explicit `{#slug}` are listed. A bare `#` heading also
2314
- starts a journal page, but declares no slug, so nothing can address it with `#…`
2315
- and listing it would offer a link that cannot be written. What counts as an anchor
2316
- is kept identical to what `splitPages` matches — that pass decides which sections
2317
- become addressable journal pages — and a test asserts the two agree, so drift
2318
- fails the suite rather than advertising a link that resolves nowhere.
2319
-
2320
- **The path stays relative.** `file.path` is below the content root and is
2321
- deliberately never absolute: an absolute path is a fact about the machine that
2322
- built the index rather than about the content, so it would differ between two
2323
- checkouts of the same tree — costing the byte-stability the artifact depends on —
2324
- and a published copy would carry someone's home directory and be wrong for every
2325
- reader. Anyone holding the index knows the root it was built from, and
2326
- `root + file.path` is the absolute form whenever it is wanted.
2327
-
2328
- ### Why JSON Lines, and not a database
2329
-
2330
- The artifact has to survive the build that made it and be usable by anything — a
2331
- person with `jq`, an editor, a CI check, another package's build. A
2332
- line-per-note text file needs no server, no driver, and no schema; it is
2333
- readable by every language without an install; and it **diffs**, so a migration
2334
- that quietly empties a category shows up as a reviewable change rather than as a
2335
- silently different binary.
2336
-
2337
- Choosing it forfeits no SQL: DuckDB reads JSON Lines directly, with nested
2338
- access, so `FROM read_json_auto('build/content-index/sohl.jsonl')` is a query
2339
- away. A stored schema would forfeit the open shape, which is the asymmetry that
2340
- decides it.
2341
-
2342
- ### It is derived, disposable, and byte-stable
2343
-
2344
- The index is written under `build/`, gitignored with the rest of it, and
2345
- **nothing may be authored against it**. It is deliberately not in `paths.stage`:
2346
- that tree is mirrored destructively into a Foundry data root, so anything left
2347
- there ships inside the installed system to every player.
2348
-
2349
- Regenerating costs a frontmatter parse rather than a build, so the intended way
2350
- to use it is to rebuild it whenever it looks stale — which is why it is a
2351
- command of its own and not only a build step, and why it need never be
2352
- committed.
2353
-
2354
- That only holds if a rebuild is a no-op when nothing changed, so the output is
2355
- **byte-stable**: records are ordered by content path with the note id breaking
2356
- any tie, and every object's keys are sorted at every depth. A walk order is a
2357
- directory-read order, and directory-read order is not a fact about the content.
2358
-
2359
- An empty tree is an **error**, not an empty index. A reader takes the file as
2360
- authoritative, and an index stating that a package has no content is
2361
- indistinguishable from one built against a mis-pointed tree.
2362
-
2363
- ## Publishing a website
2364
-
2365
- ```bash
2366
- npx content-build site # the configured tree, under build/hugo/
2367
- ```
2368
-
2369
- The sibling of `package compile`: the same content tree, rendered as pages
2370
- instead of compiled into packs. It does the walk, the frontmatter read, the
2371
- address derivation, the address index, table expansion, wikilink resolution,
2372
- code-fence protection, the foreign-manifest merge and the page emission. A
2373
- site is its homepage and its pages — see
2374
- [What the site publishes](#what-the-site-publishes).
2375
-
2376
- ### The homepage, and how much else is published
2377
-
2378
- Every package is reachable at `https://www.heroiclands.org/<contentPackage>/`,
2379
- and what a reader finds there is a note in the content tree — one markdown file,
2380
- written by a person:
2381
-
2382
- ```markdown
2383
- ---
2384
- type: homepage
2385
- shortcode: root
2386
- title: HârnMaster Kethira Basic # optional; defaults to packageBuild.manifest.title
2387
- ---
2388
-
2389
- What the module is, which system it needs, how to install it.
2390
- ```
2391
-
2392
- A package declares **exactly one** of these, and both `content-build lint` and
2393
- `content-build site` require it — see
2394
- [Exactly one homepage](#exactly-one-homepage).
2395
-
2396
- That is the whole envelope. A homepage **compiles into no compendium
2397
- document**, and so appears in no pack and in no link manifest — which is why it
2398
- refuses `id`. It is the package root: written as the mount's `_index.md`,
2399
- rendered at `/<contentPackage>/`, and cited as `[[homepage-root|Text]]` by
2400
- the `shortcode` it declares (see
2401
- [The homepage is the package root](#the-homepage-is-the-package-root-addressed-like-every-other-note)).
2402
- It is dispatched on `type` like every other note, not on a filename — nothing
2403
- in this format is decided by a file's name, which is why `sohl-thalorna` can
2404
- keep a `README.md` in its content tree as a developer explainer about the
2405
- source tree.
2406
-
2407
- `type: homepage` is declared by the **engine**, not by the `sohl` item registry,
2408
- so a package that configures no `itemBuilders` at all — `HarnMaster-3-FoundryVTT`
2409
- and every HM3 module — can author one. The `engine/` ÷ `sohl/` line is
2410
- note-format knowledge against game-system knowledge, and a homepage carries no
2411
- `system` block.
2412
-
2413
- `publish.site` then says how much _else_ is published:
2414
-
2415
- | Mode | What is published |
2416
- | ---------- | ------------------------------------------------------------------------- |
2417
- | `homepage` | The authored homepage, and no other page. **The default, and the floor.** |
2418
- | `content` | The homepage plus every page the content tree compiles to. |
2419
-
2420
- There is no value meaning "no web presence": every package publishes its
2421
- homepage. A boolean is refused, with a message naming the mode to write
2422
- instead.
2423
-
2424
- **Homepage-only is a first-class mode, not an accommodation.**
2425
- `sohl-kethira-basic` (unofficial Hârn fan material under Keléstia Productions'
2426
- Fan Material Guidelines) and `harn-adventures` (HârnFanon under Lythia's terms)
2427
- must each publish a homepage and nothing beneath it. The boundary is _published
2428
- content_ — journal text, artwork, item descriptions, compiled notes — and a page
2429
- announcing the module discloses none of it. Because the failure mode is silent,
2430
- the mode **fences the content surfaces off** rather than trusting a
2431
- configuration to stay empty: in `homepage` mode the tree is never walked for
2432
- pages, whatever else the `site:` block declares.
2433
-
2434
- That is separate from the **dependency** edge, which such a module also
2435
- declines: being cited by another package is what would stop it being
2436
- withdrawable, and a homepage is one row in a routing table rather than an
2437
- address anyone links to. A package publishes its content index regardless — the
2438
- licensing constraint is against publishing _pages_, not against the artifact
2439
- existing — but nothing may declare it as a dependency.
2440
-
2441
- The homepage's file is the mount's `_index.md`, written at the root of the
2442
- content tree, `build/hugo/content/` — the package's own site root, one level
2443
- above the content mount, which is where `publish.address.prefix` puts
2444
- everything else. Hugo renders it as the `home` kind at `baseURL`, so it states
2445
- no `url` of its own.
2446
-
2447
- **What it does not do is decide addresses.** Those come from `publish.address`,
2448
- the same setting the content index reads, so a page and its index record cannot
2449
- disagree about where the page is. Everything under `site:` is _framing_ —
2450
- the repository's own body rewrites, and the residue of the generated Hugo
2451
- configuration that is this repository's own. What the site publishes is the
2452
- homepage and the content tree and nothing beside them: a page of
2453
- documentation is a note (`type: doc`, with `pack: none` where it compiles into
2454
- no document), so there is no second mechanism for mounting a directory of
2455
- markdown, and a `trees:` or `readmeSections:` key is refused with a message
2456
- saying so.
2457
-
2458
- ```yaml
2459
- site:
2460
- base: /sohl/ # default: /<contentPackage>/ — hrefs only, never a page's `url:`
2461
- packages: [sohl, thalorna] # default: just contentPackage
2462
- pass: sohlKb
2463
- passOptions:
2464
- apiBase: /sohl/api/
2465
- symbolMap: kb/data/api-symbols.json
2466
- notfound:
2467
- tagline: Song of Heroic Lands has no page at
2468
- sitenoun: site
2469
- links:
2470
- - { title: Rules, url: doc-rulesintro/, text: Success and opposed tests, injury, healing. }
2471
- ```
2472
-
2473
- | Key | What it decides |
2474
- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2475
- | `base` | Where the package is served: the prefix on every rendered `href`, and what a manifest `path` is measured against. It reaches no page's own `url:` — see [A page's URL is its address](#a-pages-url-is-its-address). Defaults to `/<contentPackage>/`. |
2476
- | `packages` | Which content packages this site renders. Defaults to its own. |
2477
- | `pass` | A named bundle of this repository's own body rewrites. |
2478
- | `passOptions` | That bundle's options. |
2479
- | `assets` | The host every package's imagery is served from; the generated `params.cdnBaseURL`. |
2480
- | `notfound` | The wording of the "page not found" page; the generated `params.notfound`. |
2481
- | `hugo` | A mapping deep-merged over the generated Hugo configuration, last. Every key the generator writes is refused here — see `docs/configuration.md`. |
2482
-
2483
- Where the tree is written is not among them. `content-build site` writes the
2484
- whole Hugo source tree under `build/hugo/` — the generated `hugo.toml`, the
2485
- content mount at `build/hugo/content/`, Hugo's cache — and the consumer's
2486
- script runs `hugo --source build/hugo` over it. The generated file's every
2487
- value has a source the repository already states; `docs/configuration.md`
2488
- lists them.
2489
-
2490
- ### What the site publishes
2491
-
2492
- **A site is its homepage and its pages, and nothing is generated between
2493
- them.** The `type: homepage` note is `/<package>/`; every other note is one
2494
- page at `/<package>/<type>-<shortcode>/`. No section directory is written, no
2495
- listing of a type, no tag page: the generated Hugo configuration disables the
2496
- `section`, `taxonomy`, `term` and `RSS` kinds on every site, so `home` and
2497
- `page` are the only kinds that render, and a request for `/<package>/being/`
2498
- is a 404.
2499
-
2500
- Every structure above the pages — which notes belong together, in what order,
2501
- under which headings, with which columns — is **authored**, as a `doc` note
2502
- carrying a content table over the content index:
2503
-
2504
- ````markdown
2505
- ---
2506
- type: doc
2507
- subType: reference
2508
- shortcode: beings
2509
- pack: none
2510
- name:
2511
- full: Beings
2512
- ---
2513
-
2514
- Every person and creature of the setting, by realm.
2515
-
2516
- ```dataview
2517
- TABLE name.full AS Name, subType AS Kind, data.realm AS Realm
2518
- FROM type = "being"
2519
- SORT data.realm, name.full
2520
- ```
2521
- ````
2522
-
2523
- ````
2524
-
2525
- That page is `doc-beings`, published at `/<package>/doc-beings/`, and it is the
2526
- index — linked from the homepage like any other page, and choosing its own
2527
- membership, order, headings and columns. A tag is a field such a table
2528
- filters on, not a page of its own.
2529
-
2530
- **One page per note, whatever it compiles into.** A system-bearing note — an
2531
- `affiliation`, a `being`, every item type — compiles into two Foundry
2532
- documents, the Item and the JournalEntry carrying its prose, addressed
2533
- `doc<type>-<shortcode>`. On the web it renders as one page, which is its own
2534
- documentation: the site index keys that page under both `<type>/<shortcode>`
2535
- and `doc<type>/<shortcode>`, so a link written either way lands on it, and a
2536
- content table that lists a type surfaces each note once.
2537
-
2538
- A configuration that asks the build to generate an index — `site.sections`
2539
- (with the `listType` / `listSubType` an entry carried), `site.landing`,
2540
- `site.backfillSections`, and `site.list`, which said how such a listing
2541
- renders — is refused by name, with one message:
2542
-
2543
- ```text
2544
- package-build config: `site.sections` is retired — a site is its homepage and
2545
- its pages, and any index between them is a `doc` note: write one with
2546
- `type: doc`, a `shortcode` and `pack: none`, carrying a content table over the
2547
- notes it lists, and link it from the homepage. Nothing is generated between
2548
- the homepage and the pages, so delete the key.
2549
- ````
2550
-
2551
- ### Every page carries what links to it, and what it links to
2552
-
2553
- The build resolves every wikilink on every page through the address index, so
2554
- at the moment it writes a page it holds the whole link graph. It writes the
2555
- part that concerns each page into that page's front matter:
2556
-
2557
- ```yaml
2558
- related:
2559
- backlinks: # pages that link to this one
2560
- - title: Afzandah Parnâzar
2561
- url: /thalorna/being-afzndhprnzr/
2562
- type: being
2563
- mentions: # pages this one links to
2564
- - title: Kethramír
2565
- url: /thalorna/place-kthrmr/
2566
- type: place
2567
- ```
2568
-
2569
- The theme's `related.html` partial reads both lists and renders them as one
2570
- "Related" card, grouped by `type`. What the build guarantees about them:
2571
-
2572
- - **A page appears once per list**, however many times it is linked, and
2573
- whichever address the link was written to: `docbeing-grod` and `being-grod`
2574
- are one page, and a page that cites it under both lists it once. A link from
2575
- a page to itself is not a connection and is dropped.
2576
- - **Each list is sorted by `type`, then `title`**, so the card's grouping is
2577
- the same on every build whatever order the notes were walked in.
2578
- - **Both lists are present whenever either has an entry**, so a theme reads one
2579
- shape; a page with no links either way carries **no `related` key at all**,
2580
- which is the partial's silent-disappear convention kept at the source.
2581
- - **`url` composes `<base><slug>/`**, like every other href this build renders
2582
- — see [A page's URL is its address](#a-pages-url-is-its-address). The page
2583
- states its own address as `/<slug>/`; everything pointing _at_ it, this
2584
- included, carries the base.
2585
- - **The homepage is a page like any other on both sides.** A content page
2586
- reaches it through `[[homepage-root|Text]]` and is listed in its backlinks;
2587
- its own body links are ordinary markdown, resolved against the package root
2588
- the way a browser resolves them, so `being-grod/`, `/thalorna/being-grod/`
2589
- and `./being-grod/#top` each count as a mention of that page. A link that
2590
- names a host leaves the site and counts as nothing.
2591
- - **Only a page of this site is an endpoint.** A link into another package
2592
- resolves to an href the build renders but no page it writes, so it is listed
2593
- on neither side; a backlink can be counted only on a page this build emits.
2594
-
2595
- `related` is derived, never authored: what links to a page is a fact about
2596
- every other page in the tree. A note that writes one has it replaced.
2597
-
2598
- ### A place page says what lies within it and who holds it; an affiliation page says what it holds
2599
-
2600
- Two keys the notes already carry answer three questions a reader asks of a
2601
- page. A place's `data.parents` is geography — what it sits within — and an
2602
- affiliation's `data.domains` is tenure — what it holds. The build reads both
2603
- across the tree and every fetched index, inverts them, and writes each page
2604
- the lists that concern it, shaped like `related`:
2605
-
2606
- ```yaml
2607
- # on a place
2608
- contains: # every place whose `parents` names this one
2609
- - title: Khaset-Mehtet
2610
- url: /thalorna/place-khasetmehtet/
2611
- type: place
2612
- subType: settlement
2613
- held_by: # every affiliation whose `domains` names this one
2614
- - title: The Nome of Ankhsetun
2615
- url: /thalorna/affiliation-nomenkhstn/
2616
- type: affiliation
2617
- subType: polity
2618
-
2619
- # on an affiliation
2620
- holdings: # every place its `domains` names
2621
- - title: Ankhsetun
2622
- url: /thalorna/place-ankhsetunnome/
2623
- type: place
2624
- subType: region
2625
- ```
2626
-
2627
- - **`contains` and `holdings` are sorted by `subType`, then `title`**;
2628
- `held_by` by `title`. An entry carries `subType` where the note declares
2629
- one.
2630
- - **A key is present only when it has an entry.** A place nobody holds and
2631
- nothing sits within carries neither key; an affiliation whose `domains` is
2632
- empty carries no `holdings`. The theme's silent-disappear convention, kept
2633
- at the source.
2634
- - **`domains` names what an affiliation holds directly, and is never
2635
- expanded.** A polity whose `domains` names a region holds the region; the
2636
- settlements within it are reached through the region's `contains`, and are
2637
- not repeated in the polity's `holdings`. Tenure runs through
2638
- `affiliation.parents` — a house of its earl, an earl of the crown — and
2639
- geography through `place.parents`. A subinfeudated manor sits in one region
2640
- by the second and under a lord of another polity by the first, and both
2641
- pages say so.
2642
- - **A dependency's places and affiliations take part.** A fetched index entry
2643
- carries the `parents` and `domains` its record stated, so a place another
2644
- package publishes is listed within a local region, and a house another
2645
- package publishes is named on the local manor it holds. A local note
2646
- declaring a shortcode shadows a fetched entry declaring the same one.
2647
- - **`url` composes `<base><slug>/`**, as every `related` entry does.
2648
-
2649
- All three are derived, never authored: a note that writes one has it
2650
- replaced. The theme renders each list as a table on the page.
2651
-
2652
- ### A place page carries the map from that place
2653
-
2654
- A place that states a border or a route, or is named in one, has a map from
2655
- it — the drawing `content-build map --from` makes, with the place at the
2656
- centre and each neighbour at its bearing. The site build draws that map for
2657
- every such place and writes it with the page: the page is a leaf bundle,
2658
- `place-<shortcode>/index.md`, the drawing sits beside it as
2659
- `from-<shortcode>.svg`, and the front matter names it:
2660
-
2661
- ```yaml
2662
- map: from-kthrmr.svg
2663
- ```
2664
-
2665
- The theme's "From here" panel reads the key, inlines the file as a page
2666
- resource, and so every place name on the map is a link to that place's page.
2667
- What the build guarantees:
2668
-
2669
- - **The address does not move.** A bundled page states the same `url` a flat
2670
- one does, `/<slug>/`, so `place-kthrmr/index.md` publishes exactly where
2671
- `place-kthrmr.md` would.
2672
- - **Every name links through the site base.** The map is drawn with the base
2673
- the pages are served under, so each `href` composes `<base><slug>/` the way
2674
- every other href the build renders does.
2675
- - **It is written for inlining.** No XML prologue, no DOCTYPE, no comment, no
2676
- reference to anything outside the file; the root carries a `viewBox` and no
2677
- fixed width or height, so the theme sizes it to the column.
2678
- - **A place with no relation carries nothing**: no file beside it, no `map`
2679
- key, and the page stays a flat file. So does every page that is not a place.
2680
- - **A site never fails for want of a map.** GraphViz draws; when it is not
2681
- installed the build says so once, as a warning naming what to install, and
2682
- every page is written as it would be without maps. `site.maps: false` in
2683
- `package-build.config.yaml` draws nothing and asks nothing of GraphViz.
2684
-
2685
- `map` is derived, never authored: a note that writes one has it replaced when
2686
- the build draws a map, and dropped when there is none to draw.
2687
-
2688
- The same relations supply a PDF book's full-page vector map immediately after
2689
- each selected place entry. When a Scene pack is configured, they also supply
2690
- gridless Foundry Scenes with raster backgrounds and pins to journals in an
2691
- Adventure. A place from a dependency receives a pin through an importable
2692
- journal link when its published index includes a JournalEntry UUID. An
2693
- authored `regionalmap` with SVG art keeps the SVG for site and book while the
2694
- Foundry build stages a PNG. Its `data.scale` states the distance and unit per
2695
- grid unit.
2696
-
2697
- Battle and local maps can carry a Foundry Scene export in `data.scene`. The
2698
- Scene's canvas and embedded documents stay as authored. A pin whose text is
2699
- `#anchor` opens the JournalEntry page with that heading in the map note and
2700
- displays the heading text. A `totm` map uses a background image and dimensions
2701
- to create a gridless Scene for theatre-of-the-mind play.
2702
- An exported Scene can declare `data.fixup` entries that resolve asset Addresses
2703
- into specific Scene fields. A fixup path can select an embedded document by its
2704
- stable `_id`, so reordering the export does not redirect the replacement.
2705
-
2706
- ### Why the output location is fixed
2707
-
2708
- The content mount is a build artifact and is **deleted on every run**, so that a
2709
- page whose note was renamed cannot linger and keep publishing. A configurable
2710
- location is a location that can be unset — and an unset one resolves to the
2711
- repository root, where the wipe deletes the working tree. A fixed one under
2712
- `build/` can point nowhere else, so `site.out` is refused by name and the wipe
2713
- needs no guard.
2714
-
2715
- ### Consumer passes are named, not imported
2716
-
2717
- A repository's own body rewrites are code, and a configuration is data, so a
2718
- configuration **names** a bundle and the toolchain resolves it — exactly as
2719
- `itemBuilders` names an item registry. `sohlKb` is the bundle for the `sohl`
2720
- knowledgebase: it resolves `{@link}` tags against a TypeDoc symbol map and
2721
- rewrites repository-relative links in the developer docs to their published or
2722
- GitHub addresses. Neither rewrite can fail a build; an unknown `{@link}` degrades
2723
- to a code span.
2724
-
2725
- `symbolMap` is resolved **against the repository root**, not the process cwd, so
2726
- `content-build site` reads the same map whatever directory it was invoked from.
2727
- Leaving it unset is the legitimate empty case — every `{@link}` degrades, and
2728
- nothing is reported. Setting it to a path that cannot be read, cannot be parsed,
2729
- or does not hold a name → page object **fails the build**, naming the file and
2730
- the reason. Left unreported they are indistinguishable from "no symbols", and
2731
- a site publishes dead `{@link}` tags at exit 0. A map that is read reports
2732
- its symbol count at info level, which is the only way to tell a map that loaded
2733
- from one that loaded empty without reading the emitted HTML.
2734
-
2735
- A bundle supplies one hook, `beforeLinks`, which runs on every page before
2736
- wikilinks resolve — a `{@link}` tag may sit in prose a wikilink also touches.
2737
- It runs inside code-fence protection, so it cannot rewrite a fenced example.
2738
-
2739
- ### The gates
2740
-
2741
- Every integrity check reports and the run stops at the first that fires, so the
2742
- output names the cause rather than its symptoms — an unusable manifest reported
2743
- after the links that failed because of it reads as a pile of broken notes.
2744
-
2745
- | Gate | What it catches |
2746
- | ---------------------- | ---------------------------------------------------------------------------------- |
2747
- | Frontmatter wikilinks | A link in frontmatter, which is copied verbatim and reaches the reader as `[[…]]`. |
2748
- | Addresses | A note with no shortcode to be addressed by, or no section to be filed under. |
2749
- | Unusable manifest | A vendored manifest this build cannot read. |
2750
- | Unaddressable manifest | One it can read but cannot look anything up in. |
2751
- | Tables and wikilinks | A table directive that cannot be honoured, or a link that lands nowhere. |
2752
-
2753
- None of them exits the process from inside the library; the command decides. That
2754
- is what makes them testable, which the consumer scripts' inline `process.exit`
2755
- calls were not.
2756
-
2757
- ## Publishing a book
2758
-
2759
- The third surface the content tree publishes, beside the compendium packs and
2760
- the website: one PDF, built by `content-build pdf` and by `package-build release`.
2761
-
2762
- ```bash
2763
- npx content-build pdf # build it where `pdf.out` says
2764
- npx content-build pdf --out build/book # somewhere else
2765
- npx content-build pdf --no-compile # emit the Typst source and stop
2766
- npx content-build pdf --version 1.4.0 # stamp a version on the title page
2767
- ```
2768
-
2769
- ### A book is a selection, not a rendering of everything
2770
-
2771
- The packs and the website publish the _whole_ tree: every note becomes a
2772
- document and a page, and the three surfaces agreeing about what the content is
2773
- is the point. **A book is not that.** It is a declared structure whose leaves
2774
- pick notes out of the corpus, interleaved with prose that need not live in the
2775
- content tree at all.
2776
-
2777
- `pdf.document` names that structure — a YAML file of nested sections, each
2778
- holding some mixture of prose files and `filter:` clauses:
2779
-
2780
- ```yaml
2781
- contents:
2782
- - sectionName: Gear
2783
- contents:
2784
- - file: prose/gear-preamble.md
2785
- - filter: "type = 'weapongear'"
2786
- - sectionName: Armour
2787
- contents:
2788
- - filter: "type = 'armorgear'"
2789
- - sectionName: The Cast
2790
- contents:
2791
- - filter: "type = 'being' AND subType = 'npc'"
2792
- ```
2793
-
2794
- Three consequences follow, and each is behaviour rather than oversight:
2795
-
2796
- - **A note no clause selects is not in the book.** A project decides what its
2797
- own volume carries, so an omission is an editorial act and nothing reports it.
2798
- - **A note several clauses select appears several times**, each occurrence its
2799
- own page, outline node and anchor. Inbound wikilinks are pointed at the
2800
- first, so `[[weapongear-dagger]]` reaches one place however often it prints.
2801
- - **A filter that selects nothing _is_ reported.** A note nobody asked for is
2802
- expected; a clause matching nothing is either wrong or left over from a
2803
- structure that has moved on.
2804
-
2805
- The `WHERE` clause runs against the same content index the SQL tables read, and
2806
- the build owns the `SELECT … FROM notes` — so a filter cannot name a table, and
2807
- cannot reach another package's notes.
2808
-
2809
- ### How the book is set
2810
-
2811
- **Every entry opens a page of its own**, under a full-bleed plate carrying a
2812
- small-caps kicker above the entry's name. A reference book is consulted rather
2813
- than read through: an entry beginning halfway down a page is harder to find,
2814
- cannot carry its own running head honestly, and makes a page number in the
2815
- contents point at the middle of something else. A section opens a page of its
2816
- own too, plated to twice the depth, so a section reads as a section rather than
2817
- as the first entry beneath it.
2818
-
2819
- **The body is set in two columns**, and every other measure follows from that
2820
- one: an image with no width class is a column wide, the infobox flows in the
2821
- column measure and breaks between its sections, and a table wider than three
2822
- columns is set across the page. Two columns are print's answer and print's
2823
- alone — a scrolling page has no fixed viewport, so the website keeps one
2824
- measure with a side rail.
2825
-
2826
- An itinerary map uses a separate full-width page after its place entry. The
2827
- vector drawing fits the printable area and carries a heading naming the
2828
- viewpoint. A selected authored regional SVG map takes a page in the same way.
2829
-
2830
- A note's `description:` sets as an epigraph between short rules under the
2831
- plate. It disappears rather than leave a shell: a note with no description has
2832
- no epigraph. The running foot carries the section's name, an ornament and the
2833
- folio.
2834
-
2835
- A table of **more than three columns** is given an explicit span rather than
2836
- left to overflow the measure. Which span depends on how tall it is, and that is
2837
- measured while the page is laid out: a table that fits a page is floated across
2838
- both columns at the top of one, and a longer one is set on single-column pages
2839
- of its own — a float cannot break, and a table taller than the page placed as
2840
- one piles its rows on top of each other without a word of warning.
2841
-
2842
- ### What a section declares, its entries inherit
2843
-
2844
- A section may carry presentation alongside its `contents:`, and everything
2845
- beneath it agrees unless it says otherwise:
2846
-
2847
- ```yaml
2848
- contents:
2849
- - sectionName: Beings
2850
- footer: The Bestiary # what the running foot carries
2851
- page:
2852
- banner: assets/images/banners/bestiary.webp # the plate's picture
2853
- kicker: The Bestiary of Thalorna # the line above each entry's name
2854
- columns: 2 # the measure this section's pages are set in
2855
- contents:
2856
- - filter: "type = 'being'"
2857
- ```
2858
-
2859
- | Key | Default | What it does |
2860
- | -------------- | ---------------------- | ----------------------------------------------------- |
2861
- | `footer` | the section's own name | The name in the running foot. |
2862
- | `page.banner` | none | A file this repository ships, plated under the title. |
2863
- | `page.kicker` | the section's trail | The small-caps line above an entry's name. |
2864
- | `page.columns` | `2` | Columns, from 1 to 4. |
2865
-
2866
- `page:` is inherited whole: a subsection declaring one of its keys states the
2867
- others it wants as well.
2868
-
2869
- **A missing banner is a plate without a picture.** A section plate implies a
2870
- banner per section, and art arrives later than rendering does — so a section
2871
- that names none still gets its plate, its kicker and its title, set over the
2872
- book's ink, and nothing is reported. A banner the build _cannot read_ is a
2873
- different matter: that is a statement the tree makes and the build cannot
2874
- honour, so it is a finding.
2875
-
2876
- `header:` and `infobox:` are part of the format and read by nothing: the
2877
- running head is a foot in this design, and which infobox a note draws is
2878
- decided by the note's type.
2879
-
2880
- ### What it is fenced by
2881
-
2882
- **`publish.site` decides whether a book is built, and it is the only switch.**
2883
- `content` builds one; `homepage` does not — the same fence that stops the tree
2884
- being walked for pages stops it being walked for a book. A PDF of the content
2885
- tree is a content surface by any reading, arguably the most portable one there
2886
- is, so a package that publishes only a homepage publishes no book however its
2887
- `pdf:` block is written. The command says so and exits 0.
2888
-
2889
- That is also the opt-out: a package with no `pdf:` block, no content tree, or
2890
- `publish.site: homepage` builds nothing and fails nothing.
2891
-
2892
- ### Configuration
2893
-
2894
- ```yaml
2895
- pdf:
2896
- title: The Hârn Ensemble # required
2897
- subtitle: a roster of the ready-made
2898
- document: book.yaml # required — the document tree above
2899
- out: build/dist # default
2900
- front: # prose before the contents
2901
- - prose/colophon.md
2902
- fonts: # every key optional — the defaults below
2903
- serif: Libertinus Serif
2904
- sans: Libertinus Sans
2905
- mono: DejaVu Sans Mono
2906
- path: assets/fonts # faces of your own, searched as well as the shipped ones
2907
- iconFonts: # icon family → the font carrying its glyphs
2908
- fontawesome: assets/fonts/fa-solid-900.ttf
2909
- binary: typst # when it is not simply `typst` on PATH
2910
- ```
2911
-
2912
- Nothing here is an address or a brand: the title, the front matter and the faces
2913
- are the publishing repository's to choose, which is why they are configuration.
2914
-
2915
- ### The faces come with the build, not with the machine
2916
-
2917
- All three defaults resolve on a machine that has none of them installed. The
2918
- serif and the mono the defaults name are faces the compiler embeds; the sans is
2919
- one the toolchain ships, under `assets/fonts`, along with the superfamily's own
2920
- mono for a package that names it. The compile searches that directory, and
2921
- `pdf.fonts.path` when a package names one, and **nothing the machine has
2922
- installed** — `--font-path` and `--ignore-system-fonts` together — so the same
2923
- source sets the same book everywhere, and a machine carrying its own copy of a
2924
- named family does not quietly change what it prints.
2925
-
2926
- The default mono is the compiler's rather than the superfamily's because the
2927
- superfamily's mono carries none of the dot-below and dot-above letters this
2928
- corpus spells names with. A package whose fenced blocks stay inside what it does
2929
- carry can name it instead, and the compiler reports nothing when a glyph is
2930
- missing, so that is a claim to measure against the fences rather than assume.
2931
-
2932
- A face nothing resolves is a **finding** — the compiler says so, and a compile
2933
- that says it still exits 0 and writes a book set in the fallback, which is the
2934
- one way a wrong face reaches a reader unnoticed.
2935
-
2936
- Compiling an emitted `.typ` by hand takes the same two flags to set it the way
2937
- the build does:
2938
-
2939
- ```bash
2940
- typst compile --ignore-system-fonts \
2941
- --font-path node_modules/@heroiclands/package-build/assets/fonts \
2942
- build/dist/the-book.typ
2943
- ```
2944
-
2945
- The shipped face is licensed under the SIL Open Font License, which travels with
2946
- it.
2947
-
2948
- ### Typst is a binary, not a dependency
2949
-
2950
- The compiler is an external program, found on `PATH` or named by `pdf.binary`.
2951
- Bundling a native compiler would put a platform-specific artefact into the
2952
- dependency tree of three repositories, only one of which is mostly a book, and
2953
- it would have to resolve on every consumer's CI runner before any of them could
2954
- install the toolchain at all.
2955
-
2956
- A missing binary is a **finding**, not a failure: the `.typ` source is written
2957
- anyway, which is both the diagnostic and the thing a consumer can compile by
2958
- hand.
2959
-
2960
- ### What the book gets right, and why each matters
2961
-
2962
- | Property | How |
2963
- | ------------------------ | ------------------------------------------------------------------------------- |
2964
- | Searchable | Real text, not page images — a roster nobody can search for a name is no use. |
2965
- | Bookmark outline | Every section and entry is a heading, so a viewer's sidebar is the way in. |
2966
- | Page-numbered contents | `#outline()`, shallower than the bookmarks — 2,500 entries would be 40 pages. |
2967
- | Repeating table headers | `table.header`, so a property table spilling a page keeps its column names. |
2968
- | Internal cross-reference | A wikilink between two notes of the book becomes an internal destination. |
2969
- | External cross-reference | A cross-package link, and a note the book did not select, stay URLs. |
2970
- | Illustration | An image authored in the body is staged and set at the measure its class names. |
2971
-
2972
- An image states its own width and position once, in the note, and the book, the
2973
- website and a Foundry journal page each honour it — see
2974
- [Images](docs/content-format.md#images) for the two closed vocabularies. The book
2975
- copies each picture it prints into the output directory before the compiler
2976
- runs, because Typst reads nothing above its own root; an address naming a file
2977
- this package does not ship prints its caption alone and is reported.
2978
-
2979
- `{#anchor}` on a heading becomes an internal destination namespaced by its
2980
- entry, so `[[being-jaslyne#appearance]]` reaches the section and two notes may
2981
- both declare `{#appearance}`. A reference to a destination the book does not
2982
- carry falls back to the entry that would have held it, or to plain text, and is
2983
- reported — because Typst treats a dangling reference as fatal, and one mistyped
2984
- anchor should not take a thousand-page book down at the last step.
2985
-
2986
- ### The charset is what makes a face choosable
2987
-
2988
- A PDF embeds the faces it sets, so every character in the corpus is a claim on
2989
- the book's typeface — and Typst does not warn about a missing glyph, it falls
2990
- back and exits 0. That is why `content-build lint` holds content to a charset,
2991
- and why `:icon-…:` names an icon rather than pasting one. Both exist for this
2992
- surface. See _Prose: formatting and markdown_.
2993
-
2994
- ## Diffing published addresses
2995
-
2996
- ```bash
2997
- gh release download v0.8.2 -p system.zip -D build/baseline
2998
- npx content-build addresses diff --from build/baseline/system.zip
2999
- npx content-build addresses diff --from build/baseline/system.zip --strict
3000
- ```
3001
-
3002
- A package's `(type, shortcode)` addresses are a **published interface**. Every
3003
- satellite declaring `itemCatalog: true` assembles its beings out of them —
3004
- `attribute:str`, `skill:awar`, `weapongear:Tabri` — resolving each against the
3005
- Item packs of the release its `compatibility.verified` pins. Renaming a
3006
- shortcode is therefore a breaking change to something other repositories
3007
- consume, and it used to cost nothing and produce no signal: the check that got
3008
- made was a repository-local grep, which cannot see the other repositories and
3009
- reports the reassuring answer.
3010
-
3011
- `sohl` renamed one weapon's shortcode from `Tabri` to `Taburi` two days after
3012
- the `v0.8.2` tag, on the stated ground that "nothing referenced the old value".
3013
- True of that repository. Both satellites pin `v0.8.2` and address
3014
- `weapongear:Tabri` on their copy of the same character — five lookups that
3015
- resolve today and fail the moment either pin moves, reporting a missing item.
3016
-
3017
- **A rename is told from a removal by the document id, and that is an identity
3018
- match rather than an inference.** A note authors its `_id` in frontmatter; it is
3019
- not derived from the shortcode, so it survives a rename. An address that
3020
- disappeared while its document is still published elsewhere _is_ a rename:
3021
-
3022
- ```text
3023
- assets/content/Weapons/Melee/Taburi.md:12:1: warning: since sohl@0.8.2, weapongear:Tabri is no longer published; the same document (s5D6QJbw7ZbETxdN) is now published as weapongear:Taburi. Every package that resolves weapongear:Tabri breaks when it moves past sohl@0.8.2
3024
- ```
3025
-
3026
- Where the id is published under no address at all, that is all it says —
3027
- **withdrawn**, with no successor named. A split, a deletion and a merge are
3028
- indistinguishable at that point, and a "did you mean" guessed from string
3029
- similarity would be worse than silence, because a wrong one sends the reader to
3030
- the wrong fix.
3031
-
3032
- | Finding | What it means | Severity |
3033
- | ----------- | -------------------------------------------------------- | --------------------------------------- |
3034
- | `renamed` | Address gone; the same document publishes under another. | `warning` — legitimate, but not silent |
3035
- | `withdrawn` | Address gone; its document publishes under none. | `warning` — retiring content is allowed |
3036
-
3037
- Neither fails a build. Retiring content is legitimate, and so is renaming — the
3038
- shortcode charset rule forces some. What a rename must not do is happen without
3039
- anyone noticing. `--strict` reports both as errors and exits non-zero, for a
3040
- release workflow that wants a gate.
3041
-
3042
- **A finding is placed against the note, not the pack it was read from.** The
3043
- address space is read from compiled output because that is what actually ships;
3044
- the content tree is read only to find the note carrying the id, so the reader
3045
- lands on the `shortcode:` line they just edited. A withdrawal has no such note,
3046
- so it degrades to the baseline document — and where neither is readable the
3047
- position is dropped rather than guessed.
3048
-
3049
- **The baseline is named, never derived, and never downloaded.** `--from` takes
3050
- the artifact for the same reason `deps fetch --from` does: a command that
3051
- reaches the network on its own is not reproducible and fails strangely offline.
3052
- A baseline that yields **no** addressable item is refused rather than read as
3053
- "nothing changed" — it would report a clean result for every possible input,
3054
- which is the one failure a check like this can never catch.
3055
-
3056
- Item packs only, because that is the address space consumers resolve against: a
3057
- being's embedded items are the only cross-package resolution by
3058
- `(type, shortcode)`.
3059
-
3060
- ## Diagnostics
3061
-
3062
- Every warning or error a build reports **about a content note** is emitted in the
3063
- form every C-family compiler, `tsc` and ESLint already use, so an editor, a CI
3064
- annotator or a `grep` parses it with no knowledge of this build:
3065
-
3066
- ```text
3067
- assets/content/Regions/Capital_Nome.md:43:635: error: address [[place-kenbetpat]] resolves to no note — no package publishes it. Fix the shortcode, or declare the package that does as a dependency and run `content-build deps fetch` — in "The Capital Nome".
3068
- ```
3069
-
3070
- `file:line:column: severity: message`. The path is relative to the working
3071
- directory — during a build, the consuming repository's root.
3072
-
3073
- Two rules keep it that way, both in `engine/diagnostics.mjs`:
3074
-
3075
- - **The locator starts the line.** Diagnostics deliberately bypass `loglevel`,
3076
- whose `[timestamp] [WARN]:` prefix sits exactly where a parser reads the path
3077
- from; a greedy path pattern swallows the prefix and yields a filename nothing
3078
- can open. Progress and summary lines still go through `loglevel` — they are
3079
- not about a file and nothing needs to parse them.
3080
- - **A field is dropped, never guessed.** A diagnostic reports the position it
3081
- can establish honestly and no more: `file:line: …` when the column is
3082
- meaningless, `file: …` when only the note is known. Nothing defaults to
3083
- `1:1`, which would send a reader to the frontmatter every time.
3084
-
3085
- **A configuration error is located the same way.** Every check in
3086
- `content-config.mjs` and `config.mjs` reports through one `fail()`, naming the
3087
- offending key's dotted path — a good description and a bad locator, in a file
3088
- that runs to hundreds of lines with sibling entries flow-mapped onto one. The
3089
- path now rides on the error, and the loader that read the file resolves it
3090
- against the YAML, so all of them come out located:
3091
-
3092
- ```text
3093
- package-build.config.yaml:382:64: error: package-build config: `site.notfound.links[0].descrption` is not a recognized option (expected one of: title, url, text).
3094
- ```
3095
-
3096
- The same two rules apply. A key the file never declares — a required one that is
3097
- simply missing — has no node of its own, so the position names the **mapping it
3098
- belongs in** and no further out; a missing _top-level_ key has nothing above it
3099
- but the document, and an `.mjs` configuration has no YAML to resolve a path
3100
- against at all. Both report `package-build.config.yaml: error: …`, the file
3101
- without a line, rather than a line that would be wrong.
3102
-
3103
- Establishing a position at all takes three corrections, applied only where they
3104
- hold — see `positionInBody`. A body offset is not a file line until the
3105
- frontmatter's lines are added (`bodyLine`); the trim that strips the body can
3106
- take indentation off its first line (`bodyColumn`); and a body is scanned
3107
- _after_ its content tables expand, so an offset may land in text nobody
3108
- authored. `expandContentTables` therefore returns a `lineMap` saying which
3109
- authored line each emitted line came from — a generated row is blamed on the
3110
- directive that produced it and reports **no column**, because there is no
3111
- authored character to point at.
3112
-
3113
- ## Layout
3114
-
3115
- - **`@heroiclands/package-build/engine`** — package-agnostic machinery: the
3116
- content walk, frontmatter, tables, wikilinks, ids, folders, the content index
3117
- and the web-address rule, `BasePackCompiler`, and the generic Foundry document
3118
- compilers.
3119
- - **`@heroiclands/package-build/sohl`** — Song of Heroic Lands data-model
3120
- knowledge: item types, builders, the items and actors compilers, and default
3121
- art. Isolated behind its own entry point so an adventure module never receives
3122
- `buildWeaponGear`.
3123
-
3124
- Each module is also reachable as its own entry point —
3125
- `@heroiclands/package-build/engine/journals`,
3126
- `@heroiclands/package-build/sohl/items` — so a build that needs one thing does
3127
- not load the whole pipeline. The barrels re-export each module as a namespace
3128
- rather than flattening it, because several modules deliberately re-export a
3129
- neighbour's symbol and a flattened star export would drop every such name
3130
- silently.
3131
-
3132
- A few plain-ESM leaves are shared **with the Foundry runtime**, not just with the
3133
- build: the item default-art map, the curated region-event vocabulary, and the
3134
- affiliation standings. Each has its own entry point —
3135
- `@heroiclands/package-build/sohl/default-item-art`,
3136
- `.../engine/region-events`, `.../sohl/affiliation-standings` — so a client bundle
3137
- reaches the constant without importing a barrel that grows to hold compilers
3138
- reading the filesystem. Keeping one copy of each is the point: the build-time and
3139
- runtime values cannot disagree.
3140
-
3141
- `@heroiclands/package-build/content-config` exposes the configuration contract's own
3142
- module, so a consumer can name its types (`ContentBuildConfig`, `PackSpec`) from
3143
- JSDoc.
3144
-
3145
- ## Tests
3146
-
3147
- The package carries its own suite, so it is verifiable without any repository
3148
- that consumes it:
3149
-
3150
- ```
3151
- npm test # the whole suite
3152
- npx vitest run tests/wikilinks.test.ts # one file
3153
- ```
3154
-
3155
- **The suite is configured from a fixture, not from this repository's root.**
3156
- `vitest.config.ts` points `PACKAGE_BUILD_CONFIG` at
3157
- `tests/fixtures/repo/package-build.config.yaml`, whose adjacent `package.json`
3158
- is shaped like a consumer's. The Foundry package id and the system version are
3159
- derived from the manifest beside the configuration, and at the root that
3160
- manifest is this toolchain's own — `@heroiclands/package-build`, which is
3161
- neither a Foundry package id nor a game system version, so the derivations
3162
- would assert nothing.
3163
-
3164
- The harness is deliberately austere: no global setup, no Foundry stubs, and no
3165
- alias onto a consuming repository's source. `tests/suite-is-self-contained.test.ts`
3166
- enforces that — a test that reached for `globalThis.game`, imported `@src/…`, or
3167
- resolved a path climbing out of the package would pass in situ and fail the
3168
- moment the package was installed from npm. Before the extraction five files
3169
- resolved `"../../.."` and asserted about whatever happened to be there, which
3170
- was the system repository, because the package was vendored inside it (#1).
3171
-
3172
- `tests/dependencies-are-declared.test.ts` guards the same failure from the
3173
- shipping side. This package spent its first six changes as a workspace inside
3174
- the Song of Heroic Lands repository, where npm hoisted the root's
3175
- `devDependencies` into the workspace root: an import this package never declared
3176
- still resolved, and failed nowhere but a consumer's install. The test
3177
- walks every module named by the `files` field and holds each bare specifier to
3178
- one of three cases — a Node builtin, this package addressing itself, or a
3179
- declared `dependency` — and checks the converse: nothing shipped may import a
3180
- `devDependency`, and no declared dependency may go unimported.
3181
-
3182
- `tests/import-needs-no-config.test.ts` is the third of the same family, and the
3183
- one that keeps "resolved on first read, never at import" honest. It copies the
3184
- files the package ships into a temporary directory outside this repository and
3185
- imports each shipped module on its own, in a process whose environment has
3186
- `PACKAGE_BUILD_CONFIG` deleted. Outside is load-bearing: the config walk climbs
3187
- from the working directory and from the module's own directory, and the copy is
3188
- both — so one left inside the tree could reach a configuration above it and
3189
- prove nothing, which is why the first case asserts that none is reachable from
3190
- the copy before the rest run. A module that hoisted a configured value to import
3191
- time fails there, and only there.
3192
-
3193
- `tests/config-from-working-directory.test.ts` describes the resolution order
3194
- itself. It builds the shape no unit test can fake — a repository with the
3195
- toolchain installed under it, and a second checkout nested inside that
3196
- repository with its own configuration and no `node_modules` — and asserts which
3197
- configuration a build run in each place reads. The nested case is the one that
3198
- was wrong, and it is untestable any other way: the tell of a wrong-tree build is
3199
- normally an unexpected zero diff, and the sweeps that provoke this shape expect
3200
- zero differences, so only "which file was read" separates the outcomes.
3201
-
3202
- ## Releasing
3203
-
3204
- Releasing is not a command anyone runs. It is a consequence of merging, in two
3205
- steps, and each step is visible while it is pending.
3206
-
3207
- **A pull request declares its bump when a consumer will notice.** Run
3208
- `npx changeset` and pick major/minor/patch; the summary you write becomes the
3209
- changelog entry and the release note. Nothing a consumer meets means no
3210
- changeset at all — CI carries no gate demanding one. What CI does check is the
3211
- quality of a changeset that exists: `node bin/package-build.mjs changelog check`
3212
- lints every pending one against the rules a changeset is held to.
3213
-
3214
- **Merging to `main` opens a Version Packages pull request** carrying the version
3215
- bump and the rewritten `CHANGELOG.md`. That pull request _is_ the pending
3216
- release: as long as something is merged but unpublished, there is an open pull
3217
- request saying so. This is the whole point of the pipeline — the previous,
3218
- hand-driven process failed by leaving _nothing_ behind when the final step was
3219
- forgotten, and on 2026-08-21 it did exactly that for two versions.
3220
-
3221
- **Merging that publishes.** `changeset publish` puts the version on npm through
3222
- Trusted Publishing (OIDC — there is no `NPM_TOKEN`), tags the commit `v<version>`
3223
- and cuts the GitHub Release with the changelog section as its body. It publishes
3224
- only versions that are not already on the registry, so re-running it is a no-op;
3225
- `workflow_dispatch` on **Publish to npm** is the recovery path if a run fails
3226
- after versioning.
3227
-
3228
- Below 1.0.0, `^0.x` never crosses a minor — a consumer on `^0.15.0` will not see
3229
- `0.16.0` until it bumps the pin deliberately. Dependabot raises that as its own
3230
- pull request in each of the three consuming repositories.
3231
-
3232
- > After a successful publish, `npm view @heroiclands/package-build version` can
3233
- > report the _previous_ version for a minute or so. `dist-tags` is correct
3234
- > immediately, and is what the workflow prints.
3235
-
3236
- ## License
3237
-
3238
- GPL-3.0-or-later — see the
3239
- [SoHL repository](https://github.com/HeroicLands/Song-of-Heroic-Lands-FoundryVTT).