@heroiclands/package-build 0.6.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +95 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -34
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. package/types/sohl/note-schemas.d.mts +7 -0
@@ -0,0 +1,965 @@
1
+ # @heroiclands/content-build
2
+
3
+ ## 1.8.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 1fb6b96: Move the release workflow to `changesets/action@v2`.
8
+
9
+ v2 renamed four of the inputs this workflow passes — `version` →
10
+ `version-script`, `publish` → `publish-script`, `commit` → `commit-message`,
11
+ `title` → `pr-title` — and **rejects the old names outright** rather than
12
+ warning and carrying on. So the bump and the rename have to land in the same
13
+ commit.
14
+
15
+ Taken deliberately rather than waiting for Dependabot, because Dependabot bumps
16
+ the pin without touching the inputs, and that combination has already broken the
17
+ release pipeline in three sibling repositories:
18
+ Song-of-Heroic-Lands-FoundryVTT#1729, sohl-thalorna#71 and
19
+ sohl-kethira-basic#42. In the first of those it went unnoticed for over two
20
+ weeks — a failing release job looks exactly like a repository nobody has
21
+ released lately.
22
+
23
+ Nothing else had to move: `version-script` already calls an npm script (#74), and
24
+ one command is what v2's tokenized, never-shelled input requires.
25
+
26
+ ## 1.8.1
27
+
28
+ ### Patch Changes
29
+
30
+ - 9cbf2c4: Actually assign the foreign item catalogue onto the actors compiler.
31
+
32
+ 1.8.0 shipped `itemCatalog: true` and `deps fetch` in a state where they did
33
+ nothing. The compiler destructured `foreignSourceDirs` and never assigned it, so
34
+ `this.foreignSourceDirs` was always `undefined` and `loadItemsMap` fell back to
35
+ its empty default. The catalogue was downloaded, extracted, cached — and
36
+ silently dropped.
37
+
38
+ The symptom was a compile that had changed in no way at all: `sohl-thalorna`
39
+ with the feature fully switched on still reported all 26,220 unresolved items,
40
+ and logged `Loaded 630 predefined items` — its own count, with none of sohl's
41
+ 1,224.
42
+
43
+ Every test passed throughout, because they exercised the catalogue module in
44
+ isolation and nothing exercised the wiring. A feature wired up wrong looks
45
+ exactly like one switched off, so the regression test asserts the compiler keeps
46
+ the directories it was constructed with, and was checked by mutation.
47
+
48
+ ## 1.8.0
49
+
50
+ ### Minor Changes
51
+
52
+ - 0bf5334: Resolve a being's embedded items against a dependency's shipped item catalogue.
53
+
54
+ A repository that authors beings without holding the items they are assembled
55
+ from could not compile an actors pack at all: the pass resolved against local
56
+ Item packs and nothing else, so `sohl-thalorna` turning its actors pack on
57
+ produced 26,220 unresolved-item errors.
58
+
59
+ A declared relationship may now opt in with `itemCatalog: true`. Fetching
60
+ downloads that package's release, extracts its Item packs with
61
+ `@foundryvtt/foundryvtt-cli`, and hands the resulting directories to the actors
62
+ pass, which reads them exactly as it reads a local pack.
63
+
64
+ Three things it does deliberately:
65
+
66
+ - **Pins, rather than following `latest`.** A published
67
+ `releases/latest/download/…` URL is rewritten to the declared
68
+ `compatibility.verified` version, so a build names one particular dependency
69
+ and stays reproducible. Where the URL cannot be rewritten, the version that
70
+ comes back is checked against `verified` instead — floating silently is not
71
+ on offer.
72
+ - **Never reaches the network during a compile.** Fetching is its own command,
73
+ `content-build deps fetch`, and a compile with a cold cache fails naming it.
74
+ The cache is version-keyed, so a second run costs nothing.
75
+ - **Lets a local item shadow a foreign one.** Two local packs claiming one
76
+ address still collide, because that is ambiguous; a repository's own
77
+ `skill:awar` standing in front of the system's is not.
78
+
79
+ New `paths.foreignCache` (`build/cache/foreign`) names where catalogues land.
80
+
81
+ ## 1.7.0
82
+
83
+ ### Minor Changes
84
+
85
+ - 189b795: Retire the address-alias rule (#79).
86
+
87
+ `lint` required every note to repeat its own `type-shortcode` address in the
88
+ top-level `aliases:` list. That served exactly one reader — **Obsidian**, so
89
+ `[[type-shortcode]]` resolved in the editor — and no build ever read it: both
90
+ resolvers parse the hyphen qualifier themselves, and the alias list feeds only
91
+ the bare-alias fallback index.
92
+
93
+ The project no longer authors in Obsidian, so the rule required a line of
94
+ frontmatter per note for a reader that does not exist.
95
+
96
+ `sohl-thalorna` had already dropped its aliases, which left its `lint` reporting
97
+ **1,738 findings — one per note**, none of them a defect, burying the 120 that
98
+ were real. With the rule retired that tree reports **0** address findings, and
99
+ `sohl` (1,457 notes) and `sohl-kethira-basic` (363 notes) — which still carry
100
+ their aliases — report 0 as well. An alias that is still there is simply an
101
+ ordinary alias now.
102
+
103
+ `isAddressAlias` and `auditNoteAliases` are removed with it. Neither was
104
+ imported by anything but this package's own tests.
105
+
106
+ _Verified output-neutral before the aliases were dropped:_ across 1,735 stripped
107
+ notes, `package compile` produced byte-identical `build/packs-json` and the site
108
+ build byte-identical `site/content`. Only `lint` ever disagreed.
109
+
110
+ The two rules that remain are the ones about identity rather than tooling: a
111
+ `shortcode` is ASCII-alphanumeric, and `(type, shortcode)` names one note.
112
+
113
+ ## 1.6.0
114
+
115
+ ### Minor Changes
116
+
117
+ - be6667e: Apply the shared markdown indentation in a repository that declares no Prettier
118
+ config of its own (#76).
119
+
120
+ `content-build format` fell back to the shared configuration object and passed
121
+ it inline to Prettier. **Prettier applies an `overrides` block only while
122
+ resolving a config file**, never to options handed to it directly, so the
123
+ `**/*.md → tabWidth: 2` override was silently dropped and markdown was formatted
124
+ at the global `tabWidth: 4`.
125
+
126
+ Pointing `resolveConfig` at the shipped config file does not fix it either:
127
+ Prettier matches an override's glob relative to **the config file's own
128
+ directory**, and that file lives inside `node_modules`.
129
+
130
+ **Only a consumer with no Prettier config was affected** — which is every
131
+ repository this command was added for. One with a config resolves it from its
132
+ own root and was always correct, which is why this repository and
133
+ `Song-of-Heroic-Lands-FoundryVTT` both reported clean.
134
+
135
+ Caught while adopting the command in `sohl-thalorna`: it proposed rewriting
136
+ **1,738 content notes**, converting YAML frontmatter from 2-space to 4-space.
137
+ SoHL's notes are 2-space, so that is precisely backwards for a configuration
138
+ whose purpose is that a note formatted in one repository is formatted the same
139
+ way in the next. Verified after the fix: a real thalorna note is now returned
140
+ byte-identical, and the tree reports zero markdown findings.
141
+
142
+ The values are now declared once as `PRETTIER_BASE` + `PRETTIER_MARKDOWN`, with
143
+ `PRETTIER_CONFIG` composing them into the shape a config file wants and the new
144
+ `sharedPrettierOptionsFor(file)` giving the runner the same values as flat
145
+ options. One source, two presentations, and nothing passes `overrides` inline
146
+ again.
147
+
148
+ ## 1.5.0
149
+
150
+ ### Minor Changes
151
+
152
+ - 4a86493: Carry a mystical ability's and a mystery's granting affiliation through to the
153
+ compiled document (#3).
154
+
155
+ `assocAffiliationCode` is a real field on both `MysticalAbilityDataModel` and
156
+ `MysteryDataModel`, and neither type's declaration named it — so the builders,
157
+ which are an allow-list, discarded it. In `sohl-kethira-basic` **204 of 224
158
+ mysticalability notes set it to a real value**, and every one of them compiled
159
+ without it: no mystical ability in that shipped pack was linked to the
160
+ affiliation that grants it. `mystery` was missing `assocSkillCode` for the same
161
+ reason.
162
+
163
+ Both are declared `nullable: true, blank: false, initial: null` on their
164
+ DataModels, so the new fields read blank as `null` rather than `""` — "unset" is
165
+ one value, not two.
166
+
167
+ **This changes emitted documents**, so a consumer whose notes set either field
168
+ wants a rebuild rather than a silent upgrade.
169
+
170
+ _Not fixed here:_ the silence itself. A `sohl:` key no declaration names is
171
+ still dropped with no warning and no effect on the exit code, which is what made
172
+ this cost 204 documents before anyone noticed — that is #19's unknown-property
173
+ check. The inverse case, an emitted field no DataModel declares, is #70.
174
+
175
+ - d9ea689: Check a note's frontmatter against the schema its type declares (#19), and make
176
+ the builders' allow-list loud (#3).
177
+
178
+ `content-build lint` now checks frontmatter as well as addresses. Five classes,
179
+ each previously reported somewhere other than where it was made, or not at all:
180
+
181
+ - **Unknown or retired type**, told what replaced it.
182
+ - **Missing required property** — `dimensions` on a map, `subType` on a skill.
183
+ - **Wrong value shape** — `weight: heavy` where a number belongs.
184
+ - **Unknown property**, with a near-miss suggestion. This is #3's second half:
185
+ the builders discard a `sohl:` key no field declares, with no warning and no
186
+ effect on the exit code, which is how 204 kethira mystical abilities shipped
187
+ with no affiliation. An author could not tell a builder that forgot a field
188
+ apart from a field that does not belong on the type at all.
189
+ - **Dead shortcode reference**, resolved through the same resolver `links` uses,
190
+ so a cross-package reference answered by a vendored manifest lands exactly as
191
+ it would in a wikilink. `--no-references` turns it off for a tree whose
192
+ cross-package references it cannot see.
193
+
194
+ **A schema says what a note may _write_, not what the compiler emits.** That
195
+ distinction is the calibration: a note also feeds a knowledgebase and a website,
196
+ which read classification the pack build never compiles. Equating the vocabulary
197
+ with the builder's allow-list reported 4,241 unknown properties against SoHL's
198
+ own tree, every one correctly authored; declared properly, the same tree reports
199
+ **nothing** across 1,457 notes.
200
+
201
+ What that calibration then finds elsewhere is real: 120 findings in
202
+ `sohl-thalorna` — including 44 mysteries still carrying the retired `trait`, a
203
+ being on the retired `birthsign`, and a skill with no `subType` — and 270 in
204
+ `sohl-kethira-basic`.
205
+
206
+ **Expect a previously green tree to go red.** That is the point of the issue,
207
+ not a regression: the findings were always there and nothing reported them.
208
+
209
+ Two additions to a field declaration make this checkable: `kind`, a
210
+ machine-readable value shape distinct from the prose `shape` (a field may
211
+ declare one without changing a byte of what it emits), and `ref`, the content
212
+ type a shortcode addresses.
213
+
214
+ - 97fcb9b: Own prose formatting and markdown linting, so a consumer invokes rather than
215
+ configures (#69).
216
+
217
+ Two new commands:
218
+
219
+ - `content-build format [paths..] [--write]` — Prettier, with the shared
220
+ configuration.
221
+ - `content-build markdown [paths..] [--fix]` — markdownlint, with a narrow,
222
+ individually justified rule set covering the structure Prettier is indifferent
223
+ to: a skipped heading level, two sibling headings claiming one anchor, a
224
+ reversed `(text)[url]`, a bare URL, an empty link, a table row with the wrong
225
+ cell count, and the emphasis markers these repositories write.
226
+
227
+ **Why here.** Nothing checked the _shape_ of the markdown this package compiles
228
+ — `lint` checks addresses, `links` checks that links land. Each consumer wired
229
+ prose checking itself, so coverage was lopsided: SoHL ran both tools, thalorna
230
+ had Prettier but never from `lint`, and kethira had neither, leaving the package
231
+ least likely to have been proofread checked for addresses and nothing else. This
232
+ package is the only one all three consume.
233
+
234
+ **Both are defaults, not overrides.** A consumer's own Prettier config or
235
+ `.markdownlint-cli2.jsonc` wins. Repository-layout knowledge — which paths to
236
+ skip — stays in that repository's `.prettierignore` and `.gitignore`, both
237
+ honoured natively. `CHANGELOG.md` is skipped by default, since `changeset
238
+ version` regenerates it in every repository here.
239
+
240
+ Neither tool's file discovery is reimplemented, so `content-build format` and a
241
+ bare `prettier --check .` report the same thing — verified against SoHL's tree
242
+ (2,470 files, identical result). A file Prettier cannot parse is reported as a
243
+ located finding rather than taking the run down, which is how
244
+ `sohl-kethira-basic`'s invalid `lang/en.json` was found
245
+ (HeroicLands/sohl-kethira-basic#34).
246
+
247
+ The shared rules are also exported for editors, so format-on-save agrees with
248
+ the lint chain: `@heroiclands/content-build/prettier` and
249
+ `@heroiclands/content-build/markdownlint`.
250
+
251
+ _New runtime dependencies:_ `markdownlint-cli2`, and `prettier` moves from a dev
252
+ dependency to a real one — the commands run them in process.
253
+
254
+ ## 1.4.0
255
+
256
+ ### Minor Changes
257
+
258
+ - 04577c7: **`content-build manifest` emits a package's link manifest, so no consumer
259
+ writes the walk itself (#58).**
260
+
261
+ `writeManifests` could always write a manifest; nothing could _derive_ one. So a
262
+ repository that publishes one wrote the walk, the address derivation, the anchor
263
+ pass and the entry assembly for itself — 285 lines in `sohl`, 300 in
264
+ `sohl-thalorna` — and the two drifted in ways nobody chose. One routed its UUIDs
265
+ through the pack router and one did not, so a repository shipping several packs
266
+ of a type published UUIDs naming the wrong one.
267
+
268
+ ```bash
269
+ npx content-build manifest # the configured tree and output directory
270
+ npx content-build manifest --out tmp/ # or somewhere else
271
+ ```
272
+
273
+ It takes no paths. The content tree, the output directory, the two package
274
+ identities and the address scheme all come from configuration; `[root]` and
275
+ `--out` exist to point the same derivation at a scratch tree.
276
+
277
+ **The base a manifest records against is gone from the interface, because it was
278
+ never an input.** Both scripts built a site-absolute URL and handed
279
+ `buildManifest` the base it was built from, whose first act is to strip that same
280
+ prefix back off — the value provably never reached the file. Addresses are now
281
+ derived package-relative from the start. What survives is the two-state
282
+ distinction `publish.site` already carries: a build that publishes no pages emits
283
+ entries with no `path`, exactly as a note that compiles into no document emits
284
+ none with no `uuid`.
285
+
286
+ **What genuinely differed between the two consumers is now one setting, shared
287
+ with the page build.** Where the content tree mounts inside the package, and
288
+ which note addresses a whole section rather than a page within one, are both
289
+ load-bearing — `sohl` records `kb/affliction/aconite/` and `thalorna` records
290
+ `affiliation/the-aerarium-imperii/` — and reading them in one place is what stops
291
+ a manifest asserting an address the site does not publish:
292
+
293
+ ```yaml
294
+ publish:
295
+ site: true
296
+ manifests: { publish: true, consume: true }
297
+ address:
298
+ prefix: kb/ # default "" — the content tree mounts at the package root
299
+ landing: readme # readme | collection
300
+ ```
301
+
302
+ `landing` names which note is a section's landing page: `readme` (a `README.md`
303
+ addresses its section) or `collection` (a `doc` note whose `category` is
304
+ `collection` addresses the section it introduces, named by its authored
305
+ `section`). The two are alternatives, not a pair that could both apply — each
306
+ live content tree holds notes the other rule would move.
307
+
308
+ **Verified byte-for-byte against both consumers**: the command reproduces
309
+ `sohl`'s manifest (2,691 entries from 1,457 notes) and `sohl-thalorna`'s (2,367
310
+ entries) exactly as the scripts it replaces emit them, on the same toolchain.
311
+
312
+ Also new:
313
+
314
+ - `paths.manifestOut` (default `build/manifests`) — where the manifest is
315
+ written. Deliberately not `paths.manifests`, which is the _inbound_ directory
316
+ of vendored foreign manifests that `links` consumes.
317
+ - `publish.manifests.publish` is enforced as a declaration rather than a
318
+ preference: with it off, emitting fails instead of writing a file other
319
+ repositories would vendor and read as authoritative. The check lives in the
320
+ library, so a caller that bypasses the command cannot bypass the declaration.
321
+ - A note the scheme yields no address for is reported as a located diagnostic and
322
+ omitted, never guessed — the old scripts printed a loose list.
323
+ - `engine/manifest-emit.mjs` exports the pass (`collectManifestEntries`,
324
+ `entriesForNote`, `anchorsOf`, `emitLinkManifest`) for a consumer that needs a
325
+ step of it rather than the whole command.
326
+
327
+ - 7ce0349: **`content-build site` publishes a content tree as a website, so no consumer
328
+ writes the pipeline itself (#63).**
329
+
330
+ Compiling a content tree into compendium packs was `content-build package
331
+ compile`. Publishing the _same tree_ as a website was a script each consumer
332
+ wrote for itself — 473 code lines in `sohl`, 462 in `sohl-thalorna`, 87 of them
333
+ identical — and the copies drifted where nobody could see it. `sohl-thalorna`
334
+ reimplemented four things this package already exported, not because it needed
335
+ different behaviour but because its script predates the extraction.
336
+
337
+ ```bash
338
+ npx content-build site # the configured tree and output
339
+ npx content-build site --out tmp/kb # or somewhere else
340
+ ```
341
+
342
+ The command does the walk, the frontmatter read, the address derivation, the
343
+ address index, table expansion, wikilink resolution, code-fence protection, the
344
+ foreign-manifest merge, page emission, and the section-landing backfill.
345
+
346
+ **Addresses are not part of the new `site:` section.** They come from
347
+ `publish.address`, the same setting `manifest` reads, so a page and its manifest
348
+ entry cannot disagree about where the page is. `site:` is framing only — the
349
+ output root, the base, which packages are rendered, what a section is called,
350
+ which extra trees are published beside the content, and which named pass bundle
351
+ supplies the repository's own body rewrites.
352
+
353
+ **Consumer passes are named, not imported.** A repository's own rewrites are
354
+ code and a configuration is data, so a configuration names a bundle and the
355
+ toolchain resolves it, exactly as `itemBuilders` names an item registry.
356
+ `sohlKb` is the bundle for the `sohl` knowledgebase — `{@link}` against a
357
+ TypeDoc symbol map, and repository-relative links in developer docs. A bundle
358
+ supplies `beforeLinks` (every page, before wikilinks resolve) and `afterLinks`
359
+ (extra-tree pages only), both inside code-fence protection.
360
+
361
+ **Every gate reports; none exits.** The seven integrity checks — a wikilink in
362
+ frontmatter, a name yielding no slug, two notes claiming one URL, an unusable or
363
+ unaddressable vendored manifest, an address two packages both claim, a bad table
364
+ or dead link — were inline `process.exit` calls in both scripts, with no test
365
+ between them. They now return findings and the command decides, which is the
366
+ only reason they can be tested at all.
367
+
368
+ **Verified byte-for-byte**: the command reproduces `sohl`'s entire published tree
369
+ — 1,520 files, 5,479,528 bytes — exactly as the script it replaces emits it.
370
+
371
+ **A safety note worth stating plainly.** The output tree is wiped on every run so
372
+ a renamed note's page cannot linger. An unset `site.out` resolves to the
373
+ repository root, and the wipe then deletes the working tree — which happened
374
+ while this command was being written, on a configuration that had no `site`
375
+ section yet. `site.out` is now required, and refused again unless it resolves
376
+ strictly inside the repository root. Both failing shapes are ordinary rather than
377
+ exotic, so neither is left to care.
378
+
379
+ Also new:
380
+
381
+ - `engine/site-build.mjs` exports each stage (`collectContentPages`,
382
+ `collectTreePages`, `siteGates`, `renderPages`, `writeSectionLandings`) for a
383
+ consumer that needs a step rather than the whole command.
384
+ - `sohl/kb-passes.mjs` exports the two `sohl` rewrites directly.
385
+ - `gray-matter` is a dependency. It is the authority on the exact bytes of a
386
+ page's frontmatter, and matching it is what makes the byte-identical claim
387
+ above true rather than approximately true.
388
+
389
+ ## 1.3.0
390
+
391
+ ### Minor Changes
392
+
393
+ - 1a36dfa: **`docs item-fields` renders the page a consumer publishes, not just the
394
+ tables.**
395
+
396
+ The command existed but no consumer could use it: it emitted the generated
397
+ tables and nothing else, so a repository that wanted a page — with a heading, a
398
+ "See also" line, and a paragraph telling the reader what they are looking at —
399
+ wrapped the renderer in a script of its own. That script was the thing the
400
+ command line exists to remove.
401
+
402
+ A new top-level `docs:` section says what is the consumer's:
403
+
404
+ ```yaml
405
+ docs:
406
+ itemFields:
407
+ title: Item Note Frontmatter
408
+ out: kb/dev-docs/content-creator/item-frontmatter.md
409
+ preamble:
410
+ - "See also: [The Authoring Workflow](authoring-workflow.md)"
411
+ - ""
412
+ - Every item note carries the envelope described there.
413
+ ```
414
+
415
+ `--check` compares against the file already there and writes nothing, so a
416
+ repository can gate on the page being current without a temporary file or a
417
+ second implementation of the comparison.
418
+
419
+ **The page is now what Prettier would write.** A consumer commits it and formats
420
+ its repository, so a generator that disagreed with the formatter by one
421
+ character would have its output rewritten on the next format run and then called
422
+ stale by `--check` on every clean checkout — the two undoing each other forever.
423
+ Three things were making that happen, and all three are fixed at the source
424
+ rather than by adding a formatting pass:
425
+
426
+ - Table columns are padded to their widest cell, which is what Prettier's
427
+ alignment comes to for this content.
428
+ - The worked example's fence said `yaml`, but the block is a whole note —
429
+ frontmatter _and_ the prose beneath it. Prettier formats a fenced block in the
430
+ language it declares, so labelling it YAML both misdescribed it and dropped the
431
+ blank line after the frontmatter. It is `markdown`.
432
+ - One field description used `*emphasis*`; Prettier normalises to `_emphasis_`.
433
+ Fixed where it is written rather than by rewriting markers on the way out.
434
+
435
+ `tests/field-reference.test.ts` asserts the rendered page survives Prettier
436
+ unchanged, so if its markdown printer changes — or a field description starts
437
+ using a construct it normalises — that fails here, in the package that generates
438
+ the page, rather than in the repository that publishes it.
439
+
440
+ Also clears prose left behind when the manifest template was retired: the
441
+ `paths.packageManifest` typedefs, and a `config.mjs` example still showing a
442
+ `foundryPackage` that is now rejected.
443
+
444
+ ## 1.2.0
445
+
446
+ ### Minor Changes
447
+
448
+ - c5360c5: **Every invocation the command line accepts is now one it performs.**
449
+
450
+ Four invocations were accepted, performed nothing, and exited 0. From a `run-s`
451
+ build chain each read as a step that had done its work:
452
+
453
+ | Invocation | Was | Now |
454
+ | -------------------------- | -------------------------------------------- | ------------------------------ |
455
+ | `content-build` | exit 0, no output | usage, exit non-zero |
456
+ | `content-build bogus` | exit 0, silently ignored | rejected by name |
457
+ | `content-build package` | exit 0, compiled nothing | names `compile\|unpack\|clean` |
458
+ | `content-build docs` | rendered `item-fields` whatever it was asked | names the documents |
459
+ | `content-build lint --xyz` | exit 0, option ignored | rejected |
460
+
461
+ The CLI is built on yargs but had opted into none of its guarantees — no
462
+ `.demandCommand()`, no `.strict()`, and both multi-action commands declared
463
+ their action optional (`package [action]`) rather than required. `docs` went
464
+ further: it declared an `action` positional with `choices` and never read
465
+ `argv.action`, so the positional constrained what could be typed and selected
466
+ nothing. With one document that was latent; a second would have rendered the
467
+ wrong one and exited 0. The action is now dispatched on.
468
+
469
+ The sibling toolchain `@heroiclands/package-build` already opts into the same
470
+ two guards, so the two command lines now agree about what an error is.
471
+
472
+ **On the bump.** Marked _minor_ rather than _major_ although exit codes change
473
+ for inputs that were previously accepted: no invocation that did any work
474
+ behaves differently, and every invocation that changes was one doing nothing at
475
+ all. A consumer whose build starts failing was not compiling, linting or
476
+ rendering anything at that step. Treating it as a breaking change would strand
477
+ every `^1.0.0` consumer for a fix whose entire effect is to make a silent
478
+ no-op loud.
479
+
480
+ `--version` and `--help` still answer in a directory with no configuration.
481
+
482
+ Closes #57
483
+
484
+ ## 1.1.0
485
+
486
+ ### Minor Changes
487
+
488
+ - a032fd7: **The package-id guard is deleted, and with it every read of the shipped
489
+ manifest.** A single source needs no corroboration.
490
+
491
+ `assertPackageIdMatchesManifest` existed because the package id was declared
492
+ twice — once in configuration, once in a hand-authored manifest template — and
493
+ guarded the pair against drift (#1503). content-build 1.0.0 derived the
494
+ configured half from `package.json`; package-build 0.3.0 generates the manifest
495
+ from that same configuration. The guard was left in place through both, since
496
+ deleting it before the second declaration was actually gone would have removed a
497
+ check that still checked something. Both are gone now, and it compares a derived
498
+ value against itself.
499
+
500
+ Removed rather than repaired, along with everything that only existed to serve
501
+ it: `engine/package-manifest.mjs` entire — `resolvePackageManifestPath`,
502
+ `readPackageManifest`, `readManifestPackageId`,
503
+ `assertPackageIdMatchesManifestFile` — its barrel export, and the
504
+ `paths.packageManifest` key.
505
+
506
+ **`content-build package unpack` reads the configured pack list.** It took the
507
+ list out of the shipped manifest, which was the same second declaration one
508
+ level along. Nothing in the toolchain opens a manifest template now, so a
509
+ repository that has deleted `assets/templates/` compiles, unpacks and stamps
510
+ exactly as before.
511
+
512
+ **Breaking for any configuration still declaring `paths.packageManifest`** — the
513
+ key is refused, naming it. Every consumer drops it in the same change that
514
+ deletes its template.
515
+
516
+ ## 1.0.0
517
+
518
+ ### Major Changes
519
+
520
+ - 29857ed: **Four keys change hands: two stop being authored, two start.** All four were
521
+ wrong in the same way — a fact either transcribed into the configuration from a
522
+ file that already stated it, or read back _out of_ the manifest because the
523
+ configuration could not state it.
524
+
525
+ | Key | Was | Is |
526
+ | --------------------- | --------------------------------- | --------------------------------- |
527
+ | `foundryPackage` | transcribed `package.json` `name` | derived; authoring it is an error |
528
+ | `stats.systemVersion` | declarable | derived; authoring it is an error |
529
+ | `compatibility` | read out of the manifest | declared, top level |
530
+ | `relationships` | hand-authored in the manifest | declared, top level |
531
+
532
+ **Breaking.** Every consumer configuration must drop `foundryPackage` and
533
+ `stats.systemVersion` and gain `compatibility`, moving the values out of its
534
+ manifest template rather than retyping them.
535
+
536
+ **A module's system version is not its own version.** For a system,
537
+ `package.json` `version` _is_ the system version. For a module it is the
538
+ _module's_ — `sohl-thalorna` sits at `0.0.1` — so deriving from it would stamp a
539
+ SoHL version that has never existed, which is worse than the frozen `0.6.0` both
540
+ modules carry today, since that at least was once true. It comes instead from
541
+ the `compatibility.verified` of the system the module declares a relationship
542
+ with: `_stats.systemVersion` records what the packs were built against, not the
543
+ floor they tolerate. A module declaring no usable system relationship fails the
544
+ build rather than guessing.
545
+
546
+ **This reverses a rule.** Configuration used to be forbidden from holding the
547
+ Foundry floor — it named the manifest and the value was read from there, because
548
+ the manifest was hand-authored and moved with test evidence. Now that
549
+ package-build generates the manifest _from_ the configuration, reading it back
550
+ would be a round trip through an artifact that need not exist yet: `build:db`
551
+ can run before the manifest is written. `supportedCoreVersion` takes the
552
+ resolved configuration instead of a manifest directory, and no longer reads the
553
+ filesystem at all. The loud failure survives the reversal: an undeclared floor
554
+ throws rather than defaulting, which is what `coreVersion: "14"` taught (#1533).
555
+
556
+ `relationships` is **top level**, not in `packageBuild:`, because this package
557
+ must read the system relationship to derive a module's version — and the
558
+ dependency runs one way, so content-build must never read package-build's
559
+ section.
560
+
561
+ Mind the collision: top-level `compatibility` is the **Foundry core** range;
562
+ `relationships.systems[].compatibility` is the **game system's**. Same key,
563
+ different subject. `minimum` is required of the former, since it is stamped into
564
+ every document, and optional inside a relationship, where `verified` is what is
565
+ load-bearing.
566
+
567
+ The package-id drift guard is deliberately left in place. It compares this
568
+ configuration's id against the shipped template's, and the template still
569
+ declares one; it becomes vacuous only once package-build generates the manifest,
570
+ and should be deleted then rather than repaired.
571
+
572
+ ## 0.17.0
573
+
574
+ ### Minor Changes
575
+
576
+ - da18007: **The address index a site build resolves its wikilinks against moves here.**
577
+
578
+ `engine/site-index.mjs` exports `buildSiteIndex` and `wikiContext`: given pages
579
+ that already know their own URLs, it builds every key space a wikilink resolver
580
+ reads — `section/slug`, `type/shortcode`, the canonical
581
+ `package-type-shortcode`, collision-aware bare fallbacks, and type-scoped
582
+ aliases — merges the foreign packages in, and reports what more than one package
583
+ claims.
584
+
585
+ Every consumer publishing a content tree as a website answers the same question
586
+ — given `[[Something]]`, which page? — and each answered it with its own copy.
587
+ `sohl` and `sohl-thalorna` still share **147 identical lines** of that answer,
588
+ comments and indentation aside.
589
+
590
+ **What deliberately stays with the consumer:** how a page gets its address. The
591
+ URL scheme, the section a note is filed under, whether developer docs are part
592
+ of the site at all — the two builds differ on all three, and those differences
593
+ are real rather than drift.
594
+
595
+ The ordering of the foreign merge is now pinned by a test and explained where it
596
+ happens: foreign entries merge _before_ local canonical addresses are written,
597
+ so a local page always ends up owning its own `package-type-shortcode` even if a
598
+ stale vendored manifest claims it.
599
+
600
+ Additive — nothing here consumes it yet. Verified against SoHL's real tree:
601
+ 1,457 notes and a 2,101-entry foreign index produce an index identical to the
602
+ one its build constructs today, across all six key spaces (12,663 addresses, 12
603
+ ambiguous keys, 3,402 type-scoped aliases, 3 poisoned aliases, 34 content types,
604
+ 0 conflicts).
605
+
606
+ ## 0.16.0
607
+
608
+ ### Minor Changes
609
+
610
+ - db89a4d: **A being's info-block derivation moves here, from the two repositories that
611
+ each had a copy.**
612
+
613
+ `sohl/being-info.mjs` exports `deriveBeingInfo`, `isBeing`, `BEING_TYPE` and
614
+ `GEAR_TYPE_TO_KEY`: the translation between the flat `sohl.items[]` a being note
615
+ authors and the resolved shapes the shared theme's sidebar reads — a `skills`
616
+ map, `gear` grouped by kind, and `spells`/`talents` split out of the mystical
617
+ abilities. It is SoHL data-model knowledge (which item type is a skill, where a
618
+ mastery level lives, what separates a spell from a talent), so it belongs in
619
+ this package's `sohl` half rather than in each site build.
620
+
621
+ It lived in `Song-of-Heroic-Lands-FoundryVTT` and `sohl-thalorna` at once, and
622
+ the copies drifted. SoHL's caller still gated the derivation on `character` and
623
+ `creature` — the types #1580 merged into `being` — so it had matched nothing
624
+ since the merge, and all 95 of its being pages published with empty sidebar
625
+ sections (SoHL#1696). thalorna's copy checked `being` and was correct. Nothing
626
+ failed in either repository.
627
+
628
+ `isBeing` exists because of that: the defect was never in the derivation, it was
629
+ in each caller's idea of what a being _is_, written out per repository where it
630
+ could rot independently. The retired names are deliberately not accepted as
631
+ aliases — they throw elsewhere in the system, and tolerating them here would
632
+ hide the next drift instead of surfacing it.
633
+
634
+ Two deliberate differences from the code it replaces:
635
+
636
+ - **The `corpus` derivation is dropped.** `corpus` is not a registered item
637
+ type, so nothing can compile to one and the branch matched nothing — the same
638
+ class of dead code as the gate that caused the bug. It was in SoHL's copy and
639
+ never in thalorna's.
640
+ - The mystical-ability branch keeps its lack of a shortcode fallback, unlike
641
+ gear, and now says why: these render as prose names, so a row reading like a
642
+ shortcode is worse than no row.
643
+
644
+ Additive — nothing in this package consumes it yet. Verified against every real
645
+ being note: `deriveBeingInfo` and the copy it replaces produce byte-identical
646
+ output for all 95, deriving a skills map for 95 and gear for 2.
647
+
648
+ ### Patch Changes
649
+
650
+ - a89a065: **Release from merged changesets instead of a remembered command**
651
+
652
+ Fixes [#15](https://github.com/HeroicLands/content-build/issues/15). Releasing was
653
+ hand-driven — bump `package.json` on a branch, merge, then remember
654
+ `gh release create`, because cutting the Release is what published. Nothing
655
+ enforced the last step, so on 2026-08-21 `main` carried 0.5.1 while npm served
656
+ 0.4.0: two versions merged and never published, with no check red.
657
+
658
+ - Every pull request now declares its bump as a `.changeset/*.md` file, and CI's
659
+ **Changeset declared** job fails one that does not. `npx changeset add --empty`
660
+ is how a change says it needs no release — explicitly, rather than by omission.
661
+ - Merging to `main` opens a **Version Packages** pull request carrying the bump
662
+ and the rewritten `CHANGELOG.md`. An unreleased state is now a pull request
663
+ waiting in the queue rather than nothing at all.
664
+ - Merging that runs `changeset publish`: npm publish, the `v<version>` tag, and
665
+ the GitHub Release with the changelog section as its body. The OIDC Trusted
666
+ Publishing step is unchanged and still last; there is still no `NPM_TOKEN`, and
667
+ re-running on a published version is a no-op.
668
+ - `CHANGELOG.md` is seeded from the eleven hand-cut Releases so far and now ships
669
+ with the package.
670
+
671
+ <!-- Sections at 0.16.0 and above are generated by `changeset version` from
672
+ the changesets merged into `main`. Sections at 0.15.0 and below predate
673
+ that pipeline and are the hand-written GitHub Release notes, kept verbatim
674
+ (headings demoted one level to sit under their version) so no history was
675
+ lost in adopting it. -->
676
+
677
+ ## 0.15.0
678
+
679
+ _2026-08-22 — one config file, two build packages_
680
+
681
+ **One repository describes itself in one file — and now that file serves both build packages.**
682
+
683
+ #### A reserved `packageBuild:` section
684
+
685
+ `defineConfig` accepts a `packageBuild:` mapping, validates that it _is_ a mapping, and hands it back frozen and **uninterpreted**. `@heroiclands/package-build` validates everything inside it.
686
+
687
+ ```yaml
688
+ packageKind: systems # read from the top level, never restated below
689
+ foundryPackage: sohl
690
+
691
+ packageBuild:
692
+ assets:
693
+ - { from: lang, to: lang }
694
+ deploy:
695
+ envPrefix: SOHL
696
+ ```
697
+
698
+ The two packages split by **input** — content-build reads the content tree, package-build reads `lang/`, `styles/`, `src/`, the assets and the manifest template — so neither should learn the other's schema. A section rather than a scatter of top-level keys, because that keeps the unknown-key guard intact for everything around it: that guard is what catches a typo'd `packs` before it becomes an empty compendium.
699
+
700
+ The alternative was a second config file, and it would have restated `packageKind` and `foundryPackage` — two places for one fact. That is exactly what every consumer's `push-stage.mjs` did, hard-coding `packageKind: "systems"` and `packageId: "sohl"` beside a configuration that already declared both.
701
+
702
+ #### `assets` is retired
703
+
704
+ The key had been part of the contract since the pack-config hoist — validated and frozen on every load, and read by **nothing**. The job it describes is `stageAssets`, which belongs to package-build, and each consumer did it from a local table instead. It now lives at `packageBuild.assets`.
705
+
706
+ No configuration anywhere declared it — not SoHL's, not this package's own — so the removal costs nobody a migration.
707
+
708
+ #### Upgrading
709
+
710
+ `^0.x` never crosses a minor, so no consumer moves until it bumps deliberately. For most, this release changes nothing: adopt it when you adopt `@heroiclands/package-build`'s command line.
711
+
712
+ **Full changelog:** https://github.com/HeroicLands/content-build/compare/v0.14.0...v0.15.0
713
+
714
+ ## 0.14.0
715
+
716
+ _2026-08-22_
717
+
718
+ Released with no notes — see [v0.14.0](https://github.com/HeroicLands/content-build/releases/tag/v0.14.0).
719
+
720
+ ## 0.13.0
721
+
722
+ _2026-08-22 — links and reachability as commands_
723
+
724
+ Two things a consumer should not have to write a script for.
725
+
726
+ ### `content-build links [root]`
727
+
728
+ Checks that every link in a content tree lands, reporting:
729
+
730
+ - a **dead `#anchor`** — a page id is derived by hashing the note id and the anchor slug, and nothing else checks that a heading declaring it exists;
731
+ - a **dead qualified address** — a `type-shortcode` target resolving to no note is a typo (a bare `[[Name]]` that finds nothing is a worldbuilding placeholder, and is left alone);
732
+ - a **wikilink authored in frontmatter** — both builds copy frontmatter through verbatim, so it publishes as literal `[[…]]` text;
733
+ - a **vendored manifest that has drifted out of reach** — readable is not the same as addressable, and a key shape the lookup cannot parse makes every cross-package link miss while each page still reads correctly.
734
+
735
+ All of it is package-agnostic, so a consumer needs no script of its own.
736
+
737
+ ### `content-build reachability <dir> [file] [--index <shortcode>]`
738
+
739
+ A documentation set is a **book, not a pile of notes**: it has a page one, and everything in it should follow from that page by reading. A note with no inbound link still compiles and still publishes — it is simply impossible to arrive at, and nothing else notices, because every other check asks whether a link _lands_, never whether a document is _reached_.
740
+
741
+ The corpus is named on the command line because it never changes for a repository:
742
+
743
+ ```json
744
+ "lint:reachability:rules": "content-build reachability Rules --index glossary",
745
+ "lint:reachability:guide": "content-build reachability User_Guide --index glossary"
746
+ ```
747
+
748
+ `--index` marks a page walked **to** but not **through** — an index links to nearly everything it covers, so traversing one makes the check vacuous. A corpus whose entry page is missing exits 1 rather than reporting every page as an orphan.
749
+
750
+ `walkReachability` is exported too, for a caller that wants the graph rather than a report.
751
+
752
+ ### Also
753
+
754
+ - `engine/foreign-manifests.mjs` — the addressability guard, beside the key format it guards rather than in whichever consumer loads a manifest.
755
+ - `positionOfLiteral` in `engine/diagnostics.mjs` — for a finding about a literal in a file that is neither a note body nor frontmatter.
756
+
757
+ ### Verified
758
+
759
+ Against the SoHL content tree, matching what its own scripts report: 1457 notes, every anchor landing, every qualified address resolving, 21 cross-package references via manifest, and 73/73 rules plus 43/43 user guide documents reachable. 854 tests.
760
+
761
+ Purely additive — but a `^0.12` pin will not cross to 0.13.0, so each consumer bumps its pin **and lockfile** deliberately.
762
+
763
+ ## 0.12.0
764
+
765
+ _2026-08-22 — link resolution and the link audit_
766
+
767
+ Three link defects survive both content builds silently, so neither the pack compilers nor a site build catches them:
768
+
769
+ - **a dead `#anchor`** — a page id is derived by hashing the note id and the anchor slug, and nothing checks that a heading declaring it exists;
770
+ - **a dead qualified address** — a `type-shortcode` target resolving to no note is a typo (a bare `[[Name]]` that finds nothing is not: that is a worldbuilding placeholder, and is left alone);
771
+ - **a wikilink authored in frontmatter** — both builds copy frontmatter through verbatim, so it publishes as literal `[[…]]` text.
772
+
773
+ The checks for all three lived in the SoHL repository, inspecting only its own tree. `engine/content-links.mjs` builds the resolution index both builds construct — the type-scoped alias map, the `type/shortcode` and `doc<type>/shortcode` addresses, the vendored foreign manifests — and reports what lands nowhere.
774
+
775
+ **It parses links the way the builds do now.** It carried its own copy of the wikilink pattern: the _third_ in this codebase, and the same drifted one that let an unclosed bracket swallow a document. The checker was parsing more loosely than the compilers it was checking.
776
+
777
+ **Corpus reachability and retired hostnames are deliberately absent.** Both are statements about what one package publishes rather than about the note format, and both are served by the link graph the module returns (`notes`, `linksOf`, `resolve`) — so a consumer keeps those checks without keeping its own resolver.
778
+
779
+ Verified against the SoHL content tree: 1457 notes, 0 dead anchors, 0 dead addresses, 0 frontmatter wikilinks, and the same 21 cross-package references answered by manifest that its own script reports. 848 tests.
780
+
781
+ Purely additive — but a `^0.11` pin will not cross to 0.12.0, so each consumer bumps its pin **and lockfile** deliberately.
782
+
783
+ ## 0.11.0
784
+
785
+ _2026-08-22 — arms-and-armour abbreviations_
786
+
787
+ Four words this content names constantly gain abbreviations:
788
+
789
+ | word | short |
790
+ | -------- | ------ |
791
+ | `sword` | `swd` |
792
+ | `shield` | `shld` |
793
+ | `round` | `rnd` |
794
+ | `battle` | `btl` |
795
+
796
+ The table had none of them, so `Round Shield` addressed a page at `round-shield` where the convention is `rnd-shld` — and the vowel reduction a shortcode falls back to produced `roundshild`, a shortening nobody would have chosen by hand.
797
+
798
+ Whole-word matching handles the near-misses with no special casing: `Broadsword` is one word, so `sword`'s rule does not reach inside it and the name stays whole.
799
+
800
+ **Derived addresses change, which is why this is a minor.** A page whose name contains one of these words now publishes at a different URL, and a shortcode suggested from such a name differs too. A `^0.10` pin will not cross to 0.11.0 — each consumer bumps deliberately, and regenerates any copy of the table it keeps.
801
+
802
+ Verified against the SoHL content tree: 1457 notes still yield 1457 distinct URLs, and the compiled packs remain byte-identical across all 2,828 documents. 829 tests.
803
+
804
+ ## 0.10.0
805
+
806
+ _2026-08-22 — one wikilink syntax, one slug rule_
807
+
808
+ An authored `[[…]]` compiles to two addresses — a Foundry `@UUID` for the packs, a URL for the web — and those destinations are the only thing that legitimately differs. The syntax was written twice and **had already drifted**: the web side's pattern omitted `\n`, so an unclosed bracket swallowed everything up to the next `]]` anywhere in the document. `engine/wikilink-syntax.mjs` now owns the pattern and the parse, and both resolvers consume it.
809
+
810
+ **Breaking renames.** The names now say which address space each resolves into, since that is the whole of the difference:
811
+
812
+ - `engine/kb-wikilinks.mjs` → `engine/web-wikilinks.mjs`
813
+ - `resolveKbWikilinks` → `resolveWebWikilinks`
814
+ - barrel namespace `kbWikilinks` → `webWikilinks`
815
+
816
+ "kb" named one consumer's site section; the resolver already served any site. "html" would be wrong too — it emits Markdown.
817
+
818
+ **One slug rule.** Four slug-shaped transforms had drifted, and three dropped non-ASCII letters instead of transliterating them: `Kûrbúl Helm` published at `kurbul-helm` while its pack file was `k-rb-l-helm` and a link to a heading of that name pointed at `#k-rb-l-helm`. Twenty-two notes in the SoHL tree were affected. `engine/content-slug.mjs` owns the rule; `helpers`, `web-wikilinks` and `compendiums` consume it. `compendiums` was the worst — `.replace("'", "")` with a _string_ argument stripped only the first straight apostrophe and never a curly one.
819
+
820
+ **`engine/abbreviations.mjs`** — the conventional shortenings for this setting's vocabulary (ranks, offices, materials, units), matched greedily longest-first, whole words only. Applied to **document addresses only**: an anchor key is written by hand, and abbreviating a heading broke a real map pin (`locations.stair-foot` against a heading that became `stair-ft`).
821
+
822
+ **`protectCode`** joins `codeRegions` and `replaceOutsideCode` in `engine/code-fences.mjs`.
823
+
824
+ Verified against the SoHL content tree: 1,457 notes yield 1,457 distinct URLs, and the compiled packs are byte-identical across all 2,828 documents. 827 tests.
825
+
826
+ ## 0.9.0
827
+
828
+ _2026-08-22 — lint a content tree's addresses_
829
+
830
+ The three rules a content note's **identity** is authored against move into this package, where every consumer gets them, instead of living in the SoHL repository where they only ever inspected SoHL's own tree (#20).
831
+
832
+ - **Shape** — a `shortcode` is strictly ASCII-alphanumeric. It is the identity key referenced from saved world data, and half of the `type-shortcode` address, whose parse needs the separating hyphen to be the only hyphen.
833
+ - **Uniqueness** — `(type, shortcode)` names one note.
834
+ - **Alias** — the note physically carries its own address in `aliases`, exactly once. Obsidian resolves a wikilink against the files on disk, so without the alias the address form resolves in the build and is dead in the editor.
835
+
836
+ ```bash
837
+ npx content-build lint # the configured `paths.content`
838
+ npx content-build lint some/tree # or a tree named outright
839
+ ```
840
+
841
+ It compiles nothing, opens no LevelDB and needs no Foundry manifest, so it takes about a second and can gate a commit. An empty or untyped tree **fails** rather than passing: "every one of nothing is unique" is a vacuous pass, and it is exactly what a tree that failed to check out produces.
842
+
843
+ **Why this mattered.** Pointed at the three real trees, two of which nothing had ever checked: `sohl` 1457 notes / 0 findings (matching its own guards exactly), `thalorna` 1738 notes / 4 findings, `kethira` 363 notes / **363 findings** — not one note there carries its address, so the address form of a wikilink has never resolved in that vault.
844
+
845
+ **Also fixes SoHL#1678.** The uniqueness rule now states what the pipeline actually enforces — a document is addressed by `(type, shortcode)` across _every_ pack of its document type — rather than the per-pack scope that #1566 made false once a note could declare `pack:`. Duplicates are reported once per offending note, each naming the others.
846
+
847
+ **New API:** `engine/content-lint` (`lintContentTree`, `auditNoteAliases`, `isAddressAlias`, `isValidShortcode`, `SHORTCODE_PATTERN`), the `contentLint` barrel export, and `positionInFrontmatter` in `engine/diagnostics`.
848
+
849
+ Purely additive — but a `^0.8` pin will not cross to 0.9.0, so each consumer bumps its pin **and lockfile** deliberately.
850
+
851
+ ## 0.8.0
852
+
853
+ _2026-08-21 — declarative item fields_
854
+
855
+ **Item builders now declare the frontmatter they consume.**
856
+
857
+ The mapping from a note's `sohl:` frontmatter to the emitted `system` block
858
+ lived inside each builder's function body, so nothing could read it — not a
859
+ documentation generator, not a validator, not a person (#22).
860
+
861
+ The declaration is now the only statement of that mapping, and the builder is
862
+ generated from it:
863
+
864
+ - `engine/field-spec.mjs` — the declaration primitives, the coercions, and
865
+ `buildFromFields`, which turns a field list into the builder that runs.
866
+ - `sohl/item-fields.mjs` — all thirteen SoHL item types, each field with its
867
+ name, target, shape, requiredness, default and a one-line description.
868
+ - `engine/field-reference.mjs` and `content-build docs item-fields` — the
869
+ authoring reference, rendered from whatever the resolved configuration
870
+ declares.
871
+
872
+ **New in the configuration contract:** an `itemBuilders` entry may carry
873
+ `fields` alongside `system` and `img`, so a consuming repository declares — and
874
+ documents — its own item types the same way. The key is optional; a type that
875
+ omits it compiles exactly as before and is simply undocumented.
876
+
877
+ **No behaviour change.** Compiling the SoHL content tree before and after
878
+ produces 2,828 byte-for-byte identical pack documents, 1,230 of them items.
879
+
880
+ Consumers pick this up with a pin bump; nothing breaks on the old one.
881
+
882
+ ## 0.7.0
883
+
884
+ _2026-08-21 — parseable, located diagnostics_
885
+
886
+ **Diagnostics about a content note now name the file, line and column.**
887
+
888
+ A warning used to name the note by `name.full`, which is not an address — four
889
+ identical warnings on one note were indistinguishable, and each had to be hunted
890
+ for in a file the build had already read. Every diagnostic is now emitted in the
891
+ form every C-family compiler, `tsc` and ESLint already use, so an editor's error
892
+ matcher or a CI annotator resolves it with no knowledge of this build:
893
+
894
+ ```text
895
+ assets/content/Regions/Capital_Nome.md:43:635: warning: unresolved wikilink [[Kenbet_Pat|Kenbet'Pat]] (unknown) in "The Capital Nome"
896
+ ```
897
+
898
+ Two rules keep it parseable: the locator starts the line (diagnostics bypass
899
+ `loglevel`, whose `[timestamp] [WARN]:` prefix sits where a parser reads the
900
+ path from), and a field is dropped rather than guessed — nothing defaults to
901
+ `1:1`.
902
+
903
+ #### Breaking
904
+
905
+ - **`expandNoteTables` returns `{ markdown, lineMap }`**, not the markdown
906
+ string. `engine/*` is a public export, so a direct importer must be updated.
907
+ A `^0.6` pin will not cross to 0.7.0; each consumer bumps deliberately.
908
+
909
+ #### Also in this release
910
+
911
+ - `parseMarkdownFile` additionally returns `bodyLine` / `bodyColumn`.
912
+ - `expandContentTables` additionally returns `lineMap`; its `errors` entries
913
+ carry the failing directive's `line`.
914
+ - `convertWikilinks`' `unresolved` entries carry `offset`.
915
+ - `convertNoteWikilinks` accepts `file` / `bodyLine` / `bodyColumn` / `lineMap`;
916
+ its thrown errors carry `file` and `position`.
917
+ - `BasePackCompiler` publishes the note being compiled as `currentNote` and
918
+ exposes `noteWarn` / `noteError`, so a pass reports a position without every
919
+ method being handed one. The map warnings and the actor-compiler errors go
920
+ through it too — both previously named a note and no file.
921
+ - A link a `dataview` table generated is blamed on the directive that produced
922
+ it and reports **no** column, since there is no authored character to point at.
923
+
924
+ Progress and summary lines are unchanged.
925
+
926
+ Closes #17.
927
+
928
+ ## 0.6.0
929
+
930
+ _2026-08-21 — default art by builder, ambiguous links now fail_
931
+
932
+ **Breaking**
933
+
934
+ - An item type's **default art now travels with its builder** (#11). An
935
+ `itemBuilders` entry may be `type: buildFn` as before, or
936
+ `type: { system: buildFn, img: "…" }`. A consuming repository can finally
937
+ declare art for its own item types; previously art was looked up in a table
938
+ this package ships and a consumer could not add to, so a consumer's own type
939
+ compiled only while every one of its notes carried an explicit `img:`.
940
+ - An **ambiguous wikilink now fails the compile** instead of warning (#13), and
941
+ the message names the notes that collided rather than the note that cites
942
+ them. An ambiguous address matched real content twice; there is no defensible
943
+ way to pick one, and the fix is mechanical — write the qualified form. The
944
+ knowledgebase build has always treated this as fatal, so the two builds now
945
+ agree. Verified against every consumer: 0 links would fail in
946
+ Song-of-Heroic-Lands-FoundryVTT, sohl-thalorna or sohl-kethira-basic.
947
+
948
+ **Fixed**
949
+
950
+ - A Scene `levels` entry is described by the shape it actually has (#12).
951
+ - An empty `relation` / `skillAptitudes` list reads as an empty map (#10).
952
+
953
+ **Upgrading**
954
+
955
+ Consumers pin `^0.4.0` = `>=0.4.0 <0.5.0`, so this release does not reach anyone
956
+ on its own — both the manifest and the **lockfile** must move, since `npm ci`
957
+ installs what is locked. Dependabot is configured in all three consumers with
958
+ this package as its own single-package group and will open one pull request each
959
+ now that a version exists to bump to.
960
+
961
+ ## 0.4.0
962
+
963
+ _2026-08-20 — retire the character/creature content types_
964
+
965
+ Retires `character` and `creature` in favour of a single `being`, reported rather than silently routed to the items pack. Also carries the lazy-config change from #4. See #5, #6.