@heroiclands/package-build 19.0.0 → 20.2.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 (85) hide show
  1. package/CHANGELOG.md +1100 -0
  2. package/CONTENT.md +264 -33
  3. package/README.md +43 -4
  4. package/bin/content-build.mjs +94 -3
  5. package/config.mjs +9 -1
  6. package/content-config.mjs +99 -19
  7. package/docs/content-format.md +394 -72
  8. package/e2e.mjs +297 -3
  9. package/engine/actor-compiler.mjs +197 -7
  10. package/engine/address-charset.mjs +23 -5
  11. package/engine/base-compiler.mjs +63 -2
  12. package/engine/bundles.mjs +9 -0
  13. package/engine/content-address.mjs +92 -1
  14. package/engine/content-charset.mjs +434 -0
  15. package/engine/content-format.mjs +102 -0
  16. package/engine/content-icons.mjs +388 -0
  17. package/engine/content-index.mjs +11 -8
  18. package/engine/content-links.mjs +37 -21
  19. package/engine/field-reference.mjs +57 -5
  20. package/engine/field-spec.mjs +214 -7
  21. package/engine/folder-notes.mjs +24 -1
  22. package/engine/foreign-catalog.mjs +112 -7
  23. package/engine/foundry-entries.mjs +14 -0
  24. package/engine/frontmatter-lint.mjs +377 -56
  25. package/engine/frontmatter.mjs +11 -11
  26. package/engine/generate.mjs +72 -12
  27. package/engine/helpers.mjs +96 -10
  28. package/engine/index.mjs +9 -0
  29. package/engine/item-compiler.mjs +37 -0
  30. package/engine/journals.mjs +21 -4
  31. package/engine/macros.mjs +8 -0
  32. package/engine/map-notes.mjs +7 -7
  33. package/engine/note-claims.mjs +208 -5
  34. package/engine/note-ids.mjs +25 -1
  35. package/engine/note-vocabulary.mjs +76 -9
  36. package/engine/pack-config.mjs +102 -12
  37. package/engine/pack-router.mjs +0 -0
  38. package/engine/prose-config.mjs +42 -0
  39. package/engine/prose-lint.mjs +126 -0
  40. package/engine/retired-fields.mjs +57 -16
  41. package/engine/runtime-only-fields.mjs +204 -0
  42. package/engine/scenes.mjs +12 -19
  43. package/engine/schema-check.mjs +23 -1
  44. package/engine/schema-extract.mjs +13 -0
  45. package/engine/site-index.mjs +17 -0
  46. package/engine/subtype-registry.mjs +30 -0
  47. package/engine/system-block.mjs +81 -3
  48. package/engine/web-wikilinks.mjs +33 -27
  49. package/engine/wikilink-syntax.mjs +7 -0
  50. package/engine/wikilinks.mjs +74 -16
  51. package/hm3/actors.mjs +63 -13
  52. package/package.json +2 -2
  53. package/sohl/actors.mjs +106 -7
  54. package/sohl/item-fields.mjs +203 -0
  55. package/sohl/note-schemas.mjs +6 -3
  56. package/types/config.d.mts +7 -0
  57. package/types/e2e.d.mts +130 -3
  58. package/types/engine/actor-compiler.d.mts +83 -3
  59. package/types/engine/address-charset.d.mts +22 -4
  60. package/types/engine/base-compiler.d.mts +54 -3
  61. package/types/engine/content-address.d.mts +64 -0
  62. package/types/engine/content-charset.d.mts +127 -0
  63. package/types/engine/content-format.d.mts +9 -0
  64. package/types/engine/content-icons.d.mts +151 -0
  65. package/types/engine/field-spec.d.mts +271 -3
  66. package/types/engine/folder-notes.d.mts +20 -0
  67. package/types/engine/foreign-catalog.d.mts +38 -2
  68. package/types/engine/foundry-entries.d.mts +6 -0
  69. package/types/engine/frontmatter-lint.d.mts +164 -30
  70. package/types/engine/frontmatter.d.mts +11 -11
  71. package/types/engine/generate.d.mts +27 -0
  72. package/types/engine/helpers.d.mts +45 -9
  73. package/types/engine/index.d.mts +3 -0
  74. package/types/engine/map-notes.d.mts +2 -2
  75. package/types/engine/note-claims.d.mts +67 -0
  76. package/types/engine/note-ids.d.mts +14 -0
  77. package/types/engine/pack-config.d.mts +35 -0
  78. package/types/engine/prose-config.d.mts +41 -0
  79. package/types/engine/prose-lint.d.mts +36 -0
  80. package/types/engine/retired-fields.d.mts +29 -13
  81. package/types/engine/runtime-only-fields.d.mts +102 -0
  82. package/types/engine/schema-check.d.mts +10 -1
  83. package/types/engine/subtype-registry.d.mts +21 -0
  84. package/types/engine/system-block.d.mts +28 -2
  85. package/types/sohl/actors.d.mts +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1105 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 20.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 85fffd6: **The frontmatter lint checks the system blocks a package ships for, instead of
8
+ a block named `sohl`.**
9
+
10
+ A system block is a closed region: a key the system's vocabulary does not
11
+ declare is an error, because the compiler's builders are an allow-list and drop
12
+ it without a word. That held for exactly one block, `sohl:`, and it held whatever
13
+ system the package shipped for — the linter takes the blocks its caller names,
14
+ and the only caller named none, so every tree fell back to the same constant.
15
+
16
+ Both directions of that are wrong once a second system exists, and the second is
17
+ the costlier:
18
+
19
+ | | before | now |
20
+ | ----------------------------- | --------------------------------------------------------- | ------------------------------------ |
21
+ | a package shipping for `sohl` | `sohl:` checked | unchanged |
22
+ | a package shipping for `hm3` | `sohl:` checked — a block it does not carry | `hm3:` checked |
23
+ | an `hm3:` block | **never read**, every key discarded at compile in silence | checked against HM3's own vocabulary |
24
+ | a tree feeding both | one of two blocks checked | each block against its own system |
25
+
26
+ **Which systems a package ships for is already declared**, so this reads that
27
+ rather than asking for it again — in all three places it is written:
28
+
29
+ - `systems:`, which declares them without requiring one;
30
+ - a **pack's** `system:`, which is the same statement per pack and the only one
31
+ some trees make. It is already authoritative at compile, where a note routed
32
+ to such a pack and carrying no such block fails the build, so a lint blind to
33
+ it would refuse a note for want of a block it never checked;
34
+ - `stats.systemId` where neither is written, which has already absorbed every
35
+ remaining spelling: a system package is its own system, and a module takes
36
+ `requiresSystem`, its lone `systems:` entry, or its lone system relationship.
37
+
38
+ **Each block is held to its own system's vocabulary**, and that has two sources.
39
+ A system's `itemBuilders` registry covers its item types — `skill` is one name
40
+ over two data models, so a key SoHL's `skill` declares is not thereby a key
41
+ HM3's. The note schemas cover the rest, `being` above all, which is an actor type
42
+ sitting in no item registry; they are SoHL's, because that is the vocabulary
43
+ `content-build` is built with.
44
+
45
+ A type neither source names is a type that system says nothing about, and its
46
+ block is left alone on such a note rather than reported wholesale. A package
47
+ naming no system anywhere is system-agnostic on purpose — its packs are core
48
+ document types carrying no system data — so it has no system block, and none is
49
+ invented for it.
50
+
51
+ **A block whose vocabulary nothing states is said out loud.** A package
52
+ declaring a system other than SoHL and no `itemBuilders` registry for it has
53
+ nothing that can say what that block may carry, so the block goes unchecked and
54
+ `content-build lint` reports that once, naming the system and the registry to
55
+ declare. A check that quietly does nothing is indistinguishable from one that
56
+ passed, which is the whole subject here.
57
+
58
+ For `harn-ensemble` — the tree this issue is about, declaring both systems
59
+ through its packs — that means its `sohl:` block is checked exactly as before,
60
+ its 2,512 `being` notes included, and its `hm3:` block waits on
61
+ `itemBuilders: [hm3, sohl]`, which the lint now asks for by name.
62
+
63
+ **Nothing changes for a package shipping for SoHL**, which is every consumer
64
+ today: one system, one registry, and the derivation is the identity on it.
65
+ - e084547: The e2e harness no longer reports a run that never started as green.
66
+
67
+ Observed against a licensed container: a concurrent `npm ci` removed
68
+ `node_modules` out from under a run in progress, Cypress died with
69
+ `Cannot find package '.../cypress/index.js'`, and `package-build e2e run`
70
+ **exited 0**. The concurrency was an operator's mistake; the exit code was not.
71
+ A scripted caller, or anyone reading the tail of a log, would have recorded the
72
+ suite as passing when nothing was executed — and the suite is what moves
73
+ `compatibility.verified`, so an exit code that says green when nothing ran makes
74
+ that evidence unfalsifiable in the one direction that matters.
75
+
76
+ The suite is now bracketed rather than trusted on its exit status:
77
+
78
+ | When | Check | What it catches |
79
+ | ------ | ---------------------------------- | ---------------------------------------------------------------------------------------- |
80
+ | Before | Every executable the command names | The runner is not installed — an error naming it, before a container and a world. |
81
+ | Before | The tool behind a package runner | `npx cypress run` resolves **`cypress`**; `npx` is never missing, so it answers nothing. |
82
+ | After | Those executables again | The runner disappeared mid-run, which is the failure reported above. |
83
+ | After | Results written since the spawn | The suite started and produced nothing. Needs the new `results` key. |
84
+
85
+ **New: `packageBuild.e2e.results`.** One path, or a list of them, relative to
86
+ the repository root, naming where the suite writes its results:
87
+
88
+ ```yaml
89
+ e2e:
90
+ suite:
91
+ run: [npx, cypress, run]
92
+ results: [cypress/results]
93
+ ```
94
+
95
+ Existence is not the test — a directory the _previous_ run left behind exists,
96
+ and reading that as evidence would make the check agree with exactly the thing
97
+ it was built to catch. What counts is a file modified since the suite was
98
+ spawned. Declaring nothing keeps the previous contract, in which the exit status
99
+ is taken at its word; declaring a path is what buys the distinction between _the
100
+ suite ran and passed_ and _the suite did not run_.
101
+
102
+ **What a consumer may notice.** A `run`, `fast` or `sweep` whose suite is not
103
+ installed now fails immediately with a diagnostic naming the missing program,
104
+ where before it stood a container up and failed later — or, in the reported
105
+ case, did not fail at all. The check only ever makes a verdict _worse_: a suite
106
+ that failed keeps its own exit status, so there is no new way for the harness to
107
+ report a result that did not happen. `open` is untouched, because a person
108
+ decides what to execute there and a session that ran no specs is not a fault.
109
+
110
+ Also exported from `@heroiclands/package-build/e2e`, for a repository that wants
111
+ the same rules elsewhere: `suiteExecutables`, `findExecutable`,
112
+ `missingExecutables`, `freshResults` and the pure `suiteVerdict`.
113
+ - d72c4b2: A note can now name an interface icon instead of drawing one. `:icon-star:`
114
+ renders as the same Font Awesome element the system's own sheets emit, and an
115
+ undeclared name is reported rather than published as literal text.
116
+
117
+ The user guide described Foundry's interface by pasting Unicode lookalikes of
118
+ icons the sheets actually draw — `☆` for the improve flag, `✎` for the formula
119
+ editor — so the note and the screen it described were drifting apart. Those
120
+ characters are also the worst in the corpus to typeset: of eight candidate book
121
+ faces, none carries them.
122
+
123
+ A registry maps a writer's name to a style and a Font Awesome icon, because the
124
+ three surfaces need different artefacts from one name: the journals and the
125
+ website want an `<i class="fa-solid fa-star">`, and a PDF wants a font file and
126
+ a glyph. It also means an icon renamed between Font Awesome major versions costs
127
+ one line rather than a sweep of the corpus.
128
+
129
+ Codepoints are deliberately absent: a renderer embedding the font has to read it
130
+ to subset it, and the font's own `cmap` is the only trustworthy source for which
131
+ glyph a name resolves to.
132
+
133
+ Part of #378.
134
+ - 15fb41f: **A pack's `system:` must resolve to the version its documents are stamped with,
135
+ and a configuration where it resolves to nothing is now refused.**
136
+
137
+ Every document in a pack carries `_stats.systemId` and `_stats.systemVersion`,
138
+ and for a pack declaring `system:` those come from one of exactly two places:
139
+ the `systems:` entry for that system, which carries the verified version, or the
140
+ package-wide stats, which answer for a package whose packs are all for its own
141
+ system.
142
+
143
+ A pack naming a system that resolves to **neither** used to fall through to the
144
+ package-wide value — and a module that declares no system does not have one, so
145
+ both fields were stamped `null`. That is the plausible lie #43 was about,
146
+ reached by the one path the guard did not cover:
147
+
148
+ ```
149
+ _stats: { systemId: null, systemVersion: null, … }
150
+ ```
151
+
152
+ on 2,513 compiled actors in a pack whose configuration says `system: sohl` on
153
+ the line above.
154
+
155
+ **The check existed; it was skipped in exactly this case.** `packs.<n>.system`
156
+ was validated against `systems:` only when that block was non-empty — the guard
157
+ read `declaredSystems.size && …` — so an absent block meant no check at all. Its
158
+ sibling ten lines up refuses the same thing for `requiresSystem` and says "the
159
+ `systems:` block is empty or absent" in as many words, and the comment above
160
+ both already described this failure. The suite was green throughout because its
161
+ `harn-ensemble`-shaped fixture declares the `systems:` block the repository does
162
+ not: the fixture was more complete than the configuration it stood for.
163
+
164
+ **What a consumer sees.** A configuration in this shape now fails with the pack
165
+ named and the entry to add:
166
+
167
+ > `packs.actors-hm3.system` names `hm3`, which `systems:` does not declare — the
168
+ > `systems:` block is empty or absent, and this package has no package-wide
169
+ > system either. Every document in the pack is stamped `_stats.systemId` and
170
+ > `systemVersion` from one of those two, so with neither it would be stamped
171
+ > null. Add `systems:` naming `hm3` with a `compatibility.verified` version.
172
+
173
+ **Nothing changes for a package whose packs name no system**, or whose packs name
174
+ its own system — the package-wide stats answer for those exactly as before,
175
+ which is every single-system tree. The package-wide derivation itself is now a
176
+ named function read by both the stamp and the check, so the value validated
177
+ against and the value stamped cannot come to disagree about the case that has no
178
+ answer.
179
+ - c9e7a5a: **A pack default is resolved per system, so one note compiles into one pack per
180
+ system without declaring anything.**
181
+
182
+ This is the routing half of #58, and until now it made the documented
183
+ two-system layout impossible to build. A default was computed per document
184
+ _type_: a type with exactly one pack is that type's default implicitly, and a
185
+ type with several designates one with `default: true`. A tree shipping one Actor
186
+ pack per system has two, so it had neither — and a note feeding both systems
187
+ declares no `pack:` by design, since a block's `pack:` exists to say where one
188
+ system's document goes only when that _differs_.
189
+
190
+ So every note routed nowhere. On `harn-ensemble` that was all 2,519 of them, the
191
+ build failing on each in turn with a message saying the configuration was wrong
192
+ when it was the question being asked that was.
193
+
194
+ | | before | now |
195
+ | -------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
196
+ | one Actor pack per system, no flag | every note routes nowhere; build fails | each system's document routes to its own pack |
197
+ | a type-wide `default: true` on `actors-sohl` | returned to the HM3 pass too, which saw a name that was not its own and **skipped every note in silence** | the HM3 pass gets `actors-hm3` |
198
+ | `hm3.pack:` naming a SoHL pack | routed there, and the HM3 document was lost without a word | refused, naming the note and the pack |
199
+ | a shared `pack:` naming a SoHL pack | the HM3 document was lost without a word | does not answer for HM3, which falls through to its own default |
200
+
201
+ **A system is never answered with another system's pack.** That is the rule the
202
+ four rows share, and the second is the one worth stating twice: it failed
203
+ silently. The pack compiled zero entries, which a build reports only because a
204
+ pack that compiles nothing from a non-empty tree is itself an error.
205
+
206
+ **Marking a default still means what it says** — it designates that _system's_
207
+ default where a system has several packs of one type — and every single-system
208
+ configuration is untouched, since a pack declaring no system belongs to all of
209
+ them and the type-wide default answers exactly as before.
210
+
211
+ On `harn-ensemble` this takes `actors-sohl` from 0 compiled actors to 2,497, and
212
+ `actors-hm3` from routing nothing to claiming every note and reporting what each
213
+ still needs: `hm3.type`, which `being` requires because it is one-to-many into
214
+ `character` and `creature`.
215
+ - 0418fa8: `content-build lint` now holds a content tree to a character allowlist, so a
216
+ book can choose its typeface without discovering at print time that no font
217
+ carries what the notes are written in.
218
+
219
+ Typst does not warn when a glyph is missing — it falls back to whatever system
220
+ font has one and exits 0, so a rules table can set in three unrelated faces and
221
+ the build still reports success. The check moves that failure back to where the
222
+ character is written.
223
+
224
+ The tiers are measured rather than chosen: eight candidate book faces were
225
+ probed over every non-ASCII character in the five content trees, and what is
226
+ admitted is what enough of them carry. Letters and typography are universal;
227
+ Latin Extended Additional is carried by seven of eight; IPA was considered and
228
+ refused at five of eight, because requiring it would cost font freedom rather
229
+ than buy it.
230
+
231
+ Two rules ride along that an allowlist cannot express. Content must be NFC — a
232
+ decomposed letter is a different string to every byte comparison, including
233
+ DuckDB's `=`, so a filter typed one way silently misses a note stored the other.
234
+ And box-drawing, geometric and arrow characters are permitted inside a fenced
235
+ code block only, where the mono face sets them.
236
+
237
+ Part of #377.
238
+ - 571d5bc: **`content-build format` now says which shared Prettier conventions your
239
+ repository is not using.**
240
+
241
+ A consumer's own Prettier config wins **wholesale** — that is Prettier's own
242
+ behaviour and it is not changing — so the conventions this package publishes held
243
+ by convention alone, and lapsed silently in two opposite directions (#133):
244
+
245
+ | what a repository declares | what it actually formatted to |
246
+ | --------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
247
+ | `export { default } from "@heroiclands/package-build/prettier"` | the shared conventions |
248
+ | `{ ...PRETTIER_BASE }`, without the `**/*.md` override | markdown at `tabWidth: 4` — every note reindenting away from the form it was written in |
249
+ | a partial `.prettierrc`, e.g. `{"tabWidth": 2}` | Prettier's defaults for `printWidth`, `trailingComma`, `experimentalTernaries`, … |
250
+ | nothing at all | the shared conventions here, Prettier's own in your editor and in `npx prettier` |
251
+
252
+ Every `format` run now reports each disagreement by name, before the per-file
253
+ report:
254
+
255
+ ```text
256
+ prettier.config.mjs: warning: markdown `tabWidth` is 4 here; the shared configuration says 2
257
+ .prettierrc: warning: `printWidth` is not set here, so Prettier's own default applies; the shared configuration says 100
258
+ ```
259
+
260
+ A repository with no Prettier config is warned too, with the one line that fixes
261
+ it — that case is the sharper one, because the shared conventions then reach this
262
+ command and nothing else, so a bare `npx prettier --check .` and the lint chain
263
+ take turns rewriting the same lines.
264
+
265
+ **Nothing here fails a build.** Every finding is a `warning`, the exit code is
266
+ untouched, and a deliberate local override keeps working exactly as before — it
267
+ just stops being silent.
268
+
269
+ New export: `checkPrettierConventions(root)` from
270
+ `@heroiclands/package-build/engine/prose-lint`, and the pure comparison behind it,
271
+ `sharedPrettierDivergence(resolved, file)` from
272
+ `@heroiclands/package-build/engine/prose-config`.
273
+ - 9f638fa: **A note whose secondary document has no pack is now a finding, instead of
274
+ losing that document in silence.**
275
+
276
+ A note produces more than one document as a matter of course: an item note an
277
+ Item and the JournalEntry its prose becomes, a map note a Scene and a
278
+ JournalEntry, an actor note an Actor and a JournalEntry since #337. Where the
279
+ configuration declares no pack for one of them, that document was dropped while
280
+ the rest of the note compiled into a pack that does exist. The build succeeded,
281
+ the compendium shipped, and the missing half was discoverable only by noticing
282
+ it was not there.
283
+
284
+ #146 already reports a note **nothing** claims, and could not see this: it asks
285
+ one question of the whole configuration — does any pack claim this type — and a
286
+ note that compiles its Item into an Item pack answers yes.
287
+
288
+ > a note of type "being" compiles into a JournalEntry as well as an Actor, and
289
+ > `packs:` declares no JournalEntry pack — so the JournalEntry is dropped with
290
+ > no error while the rest of the note compiles. Declare a JournalEntry pack in
291
+ > package-build.config.yaml, or accept the loss deliberately by not authoring
292
+ > what it would have carried.
293
+
294
+ The message names the note, the class with no pack, and the class that did
295
+ compile — the last because it is what tells the two findings apart at a glance:
296
+ one is a `type:` to correct, this one a pack to declare.
297
+
298
+ **Asked per note, not per type**, which is the difference between a useful
299
+ finding and a useless one. `Journals` declines a doc-carrying note whose body is
300
+ empty — an item with no prose gets no doc — so whether an item note produces a
301
+ JournalEntry is decided by the note. `sohl-kethira-basic` declares no
302
+ JournalEntry pack and ships 393 notes whose descriptions are _deliberately_
303
+ empty under the Fan Material Guidelines its configuration explains at length; a
304
+ type-level answer would report every one of them for losing a document none of
305
+ them produces. It reports none.
306
+
307
+ **It names no system**, so a type one system maps and another does not stays
308
+ silent for the system that declines it, per #79. That holds by construction: the
309
+ `Item` and `Actor` rows fold the systems' maps together before this sees them,
310
+ so a type appears once or not at all and no system is ever named.
311
+
312
+ **What it finds today.** `Song-of-Heroic-Lands-FoundryVTT` and `sohl-thalorna`
313
+ report nothing — every document their notes produce already has a pack.
314
+ `harn-ensemble` reports 2,512: it declares two Actor packs and no JournalEntry
315
+ pack, so every one of its beings has been losing the `{#appearance}` and
316
+ `{#dossier}` prose it carries. When the issue was filed no tree authored the
317
+ affected combination; one does now.
318
+
319
+ ### Patch Changes
320
+
321
+ - 6499109: **A build now reads the configuration of the tree it was run in.**
322
+
323
+ The configuration was located by walking up from the installed package's own
324
+ directory. That is the same file as the working directory's in every ordinary
325
+ install — and a different one in a git worktree nested under its parent checkout
326
+ with no `node_modules` of its own. Node's resolution walks parent directories,
327
+ so such a worktree resolves `@heroiclands/package-build` out of the _parent's_
328
+ `node_modules`; the walk started inside the parent and landed on the parent's
329
+ `package-build.config.yaml`. The build then compiled the parent's content tree
330
+ into the parent's `build/`, said so only in absolute paths that are easy to read
331
+ past, and exited 0.
332
+
333
+ Resolution now starts at `process.cwd()` and falls back to the installed
334
+ package's directory only when that finds nothing.
335
+
336
+ | running `content-build package compile` in | before | after |
337
+ | -------------------------------------------- | ----------------------- | ---------------- |
338
+ | a repository, or any directory below it | that repository | unchanged |
339
+ | a nested worktree that has had `npm ci` run | the worktree | unchanged |
340
+ | a nested worktree with **no `node_modules`** | _the parent checkout_ | **the worktree** |
341
+ | a directory outside any repository | the installed package's | unchanged |
342
+ | anywhere, with `PACKAGE_BUILD_CONFIG` set | the file it names | unchanged |
343
+
344
+ Nothing about "a build reads one tree however it was launched" changes: the walk
345
+ climbs, so every directory inside a repository still resolves that repository's
346
+ single configuration.
347
+
348
+ When both walks find a configuration and they disagree, the working directory's
349
+ is read and the ignored one is named in a warning on stderr. The disagreement is
350
+ worth hearing on its own — it is the cheapest signal that this tree is building
351
+ on another checkout's `node_modules`, which is also a masked missing dependency.
352
+ `npm ci` in the worktree silences it properly.
353
+
354
+ **Why this was worth a fix rather than a note.** A silent wrong-tree build does
355
+ not merely fail to prove what was wanted, it produces confident evidence for the
356
+ wrong tree — and on an output-preserving sweep there is no observation that
357
+ distinguishes success from it. The usual tell is a zero diff where a change was
358
+ expected; a sweep that expects zero differences has no tell at all.
359
+
360
+ `resolveConfigFile()` is exported from
361
+ `@heroiclands/package-build/engine/pack-config`, reporting the chosen file and
362
+ each walk's own answer, so a caller can ask which tree it is about to compile
363
+ without re-deriving the resolution and risking disagreement with the loader.
364
+ - c229f2b: Stop shipping a `node_modules` symlink, which had broken every release for a
365
+ day.
366
+
367
+ A worktree's `node_modules` symlink — a 120000 blob holding one developer's
368
+ absolute path — was committed on 2026-09-11. `.gitignore` said
369
+ `/node_modules/`, and a trailing slash matches a directory rather than a
370
+ symlink, so nothing refused it.
371
+
372
+ The release job installs, runs the tests, and then hands over to the changesets
373
+ action, which does `git reset --hard` before versioning. That reset restored the
374
+ symlink over the top of the install, pointing at a path no runner has, so every
375
+ module became unresolvable and the release died on `changeset: not found`. The
376
+ tests had already passed, because they run before the reset.
377
+
378
+ The symlink is untracked, the ignore rule now matches a symlink at any depth,
379
+ and CI refuses a tracked `node_modules` path outright — the release is the only
380
+ thing this breaks, and no pull request check would otherwise notice.
381
+ - 80f40b2: Unblock releasing. Every run of the release workflow had failed at
382
+ `changeset version` with `sh: 1: changeset: not found` since 2026-09-11, so
383
+ nothing reached the registry past 20.0.0 while `main` went on believing itself
384
+ released.
385
+
386
+ The workflow installed `npm@latest` before publishing, to clear an OIDC floor of
387
+ 11.5. That was written when Node 24.0–24.4 bundled npm 11.3–11.4; since 24.5 the
388
+ bundled npm has cleared the floor on its own, and the step became a no-op that
389
+ nobody removed. On 2026-09-11 `latest` became npm 12, which stopped putting
390
+ `node_modules/.bin` on the PATH of a run-script's shell, and a release path
391
+ nobody had touched broke.
392
+
393
+ The install is gone rather than pinned: the npm that publishes is now the one
394
+ Node brings, so its version follows `node-version` instead of a number kept in
395
+ step by hand. A check in its place asserts the floor and fails loudly if a
396
+ future Node pin ever drops below it — an assertion cannot go quietly stale the
397
+ way the comment it replaces did. The version script also resolves its binary
398
+ through `npx`, as the publish script already did.
399
+ - 12061da: Release again. Since 2026-09-11 every run of the release workflow had died at
400
+ `changeset version` with `sh: 1: changeset: not found`, so nothing reached the
401
+ registry past 20.0.0.
402
+
403
+ The workflow set `version-script`, which replaces the action's own invocation of
404
+ the changesets CLI with a shell command run through its exec — and under that
405
+ exec a bare `changeset` does not resolve on a runner. Left unset, the action
406
+ resolves the installed package with `require.resolve` and runs it with `node`,
407
+ depending on no PATH at all. The override is removed.
408
+
409
+ The fault was never in this repository's install. A diagnostic run confirmed
410
+ that after `npm ci` a runner has the package, has the bin linked, puts
411
+ `node_modules/.bin` first on a run-script's PATH, and resolves the bare name
412
+ through `npm run` — all in the same job that then failed.
413
+
414
+ The one thing the override bought, refreshing `package-lock.json`'s root
415
+ `version`, is now #385 rather than a reason to keep a step that does not run.
416
+ - 9572517: Finish unblocking the release. Removing the stale `npm install -g npm@latest`
417
+ fixed the npm-12 half, but the same change also swapped the version script's
418
+ bare `changeset` for `npx changeset`, and that turned the failure into `npm
419
+ error could not determine executable to run`.
420
+
421
+ The npx form was belt-and-braces and it was wrong. `npm run` already puts
422
+ `node_modules/.bin` on the PATH — the same mechanism `npm test` uses to reach
423
+ `vitest` earlier in the same job — so the bare name resolves the pinned local
424
+ copy with no lookup. npx instead consults the registry, which this job
425
+ configures for OIDC publishing rather than for reads.
426
+
427
+ The script is back to the bare binary, and the reasoning is recorded beside it
428
+ so the asymmetry with `publish-script` is not mistaken for an oversight again.
429
+
430
+ ## 20.1.0
431
+
432
+ ### Minor Changes
433
+
434
+ - f6d2a8f: **A fetched item catalogue is read one system at a time** (#58).
435
+
436
+ A pack declaring `system: hm3` already read only this repository's `hm3` and
437
+ system-neutral Item packs. The other half of the same lookup — the catalogue
438
+ fetched from a dependency that declares `itemCatalog: true` — was unscoped, and
439
+ both halves are merged into one address space keyed by `subType:shortcode`. So
440
+ an address that exists in both vocabularies resolved against whichever document
441
+ the dependency's other system happened to supply, and said nothing: `skill:awar`
442
+ is a real address under SoHL and under HM3 and means two different documents.
443
+
444
+ `deps fetch` now records what each extracted pack is, from the dependency's own
445
+ manifest, and `foreignItemCatalogDirs(config, system)` reads only the packs that
446
+ system may see plus the ones declaring no system at all.
447
+
448
+ **What a consumer sees**
449
+
450
+ | | Before | After |
451
+ | ------------------------------------ | ----------------------- | -------------------------------- |
452
+ | a pack with `system: hm3` | reads every cached pack | reads the `hm3` and neutral ones |
453
+ | a single-system build | reads every cached pack | unchanged |
454
+ | a cache filled by an earlier version | used as-is | treated as incomplete |
455
+
456
+ **Refill the cache once.** A cache written before this holds the items but not
457
+ what they are, and neither way of proceeding without that is honest: reading
458
+ every pack is the wrong-document failure above, and reading none fails a build
459
+ that worked. So it is incomplete, and `content-build deps fetch` refills it —
460
+ the command the cold-cache error already names.
461
+
462
+ ### Patch Changes
463
+
464
+ - 2b1157e: **The license header is on every shipped module, and CI refuses a `TODO`.**
465
+
466
+ `engine/foreign-catalog.mjs` and `engine/schema-extract.mjs` shipped without the
467
+ GPL-3.0 header every other module carries — 109 of 111 had one, which is the
468
+ state a rule reaches when nothing checks it.
469
+
470
+ The forbidden-marker check now runs here too, through the org-wide
471
+ `HeroicLands/.github/actions/todos` action the other repositories already call.
472
+ It scans the whole checkout rather than a named list of directories: this
473
+ package's modules sit at its root as well as under `bin/`, `ci/`, `engine/`,
474
+ `hm3/` and `sohl/`, so a list would name sixteen root files today and quietly
475
+ stop covering the seventeenth.
476
+
477
+ Nothing a consumer imports changes.
478
+
479
+ ## 20.0.0
480
+
481
+ ### Major Changes
482
+
483
+ - 1670506: **An omitted address segment now defaults from where the link is written** (#336),
484
+ instead of being wildcarded or searched for. Three resolvers each carried their
485
+ own reading of a partial address and disagreed; there is one rule now, in
486
+ `expandAddress` beside `canonicalKey`.
487
+
488
+ - **package** omitted → the current package.
489
+ - **system** omitted → the **system block the link sits under**: anywhere under
490
+ `sohl:` is `sohl`, anywhere under `hm3:` is `hm3`, and anywhere else —
491
+ top-level frontmatter, `data:`, body prose — is `none`. The enclosing block
492
+ decides at any depth; the field has no say.
493
+
494
+ Every short form therefore expands to exactly one canonical address before
495
+ lookup. Resolution is a `Map.get`, with no candidate set and no single-hit rule.
496
+
497
+ **Under `none`, a system-bearing type addresses its documentation.** A note's
498
+ `none` address _is_ its `doc<type>` journal, so a prose `[[affiliation-x|…]]`
499
+ names the page — what a prose link almost always means. A prose link that means
500
+ the Item states the system: `[[sohl-affiliation-x|…]]`. A `macro` and the map
501
+ types are **not** redirected: their own documents are core ones already at
502
+ `none`, so `macro-x` still names the Macro.
503
+
504
+ **Breaking, in three ways a consumer will notice**
505
+
506
+ | | before | after |
507
+ | ----------------------------------- | -------------------------------------------- | ------------------------------------------------- |
508
+ | a bare prose link to an item type | the Item's UUID | the documentation journal's |
509
+ | a bare address naming no local note | fell through to any dependency publishing it | `unresolved`; qualify it to reach another package |
510
+ | `[[Skill-Climb\|…]]` | resolved, case folded | `not-lowercase` |
511
+
512
+ The first rewrites every such link in every pack. The second is the point of the
513
+ issue: a link resolved into another package only because no local note claimed
514
+ the address, and would have retargeted silently the day one did.
515
+
516
+ **Two defects go with it.** The index's system-blind `type/shortcode` key is
517
+ gone — it was set with a plain `Map.set`, so two notes in one package sharing a
518
+ `(type, shortcode)` across systems silently overwrote each other while both
519
+ canonical keys sat correctly beside it. And cross-package `ambiguous` is now
520
+ unreachable: one expanded address names one package, so a lookup returns one
521
+ entry or none. The finding is retained for older vendored manifests.
522
+
523
+ **An address capitalises nothing but its shortcode.** Package, system and type
524
+ are closed vocabularies with one spelling each; a shortcode is case-sensitive and
525
+ routinely mixed (`Clb`, `LtShoe`), so it keeps its case. Neither tree carried a
526
+ violation — 10,538 authored targets checked. Requiring the shortcode to be
527
+ lowercase too is tracked as #340.
528
+ - c906d54: **A shortcode must now match `^[a-z0-9]+$`** — lowercase letters and digits only
529
+ (#340). So must every other address segment; the charset is one rule with no
530
+ exceptions left in it.
531
+
532
+ `Dgr` beside `dgr` is a distinction nobody can say out loud and can only see by
533
+ looking twice. It was also a silent identity collapse: `canonicalKey` lowercases
534
+ the address it builds, so a note declaring `Clb` published
535
+ `sohl-sohl-weapongear-clb` and derived its `_id` from that — and two notes
536
+ differing only in case shared one address, one `_id` and one URL with nothing to
537
+ report it, because the shortcode check compared shortcodes (genuinely distinct)
538
+ and the address check saw one address.
539
+
540
+ It forced two exceptions elsewhere, and both go: #336 had to exempt the shortcode
541
+ from the lowercase rule it pinned on every other segment, and #346 had to fold
542
+ the shortcode's case in the item catalogue because an address is lowercased when
543
+ it is read.
544
+
545
+ **A consumer with a capital in a shortcode will fail to build**, citing the
546
+ note's file, line and column. The sweep is mechanical: every violation in every
547
+ tree is a capital letter — no underscores, hyphens or other characters occur —
548
+ and **nothing collides when folded**, checked per `(type, shortcode)` in every
549
+ tree.
550
+
551
+ | tree | shortcodes to change |
552
+ | --------------------------------- | --------------------: |
553
+ | `Song-of-Heroic-Lands-FoundryVTT` | 438 |
554
+ | `sohl-thalorna` | 96 |
555
+ | every other tree | 0 — already compliant |
556
+
557
+ **No document changes identity and no URL moves**, because an address and a
558
+ document `_id` already derive from the lowercased form. References need no edit
559
+ either: no authored wikilink target carries a capital (10,538 checked), and
560
+ `model:` addresses are already lowercase.
561
+
562
+ **The emitted packs do change, in three narrow ways**, measured on `sohl`'s 438:
563
+
564
+ | change | count |
565
+ | ----------------------------------------------------- | ----: |
566
+ | `system.shortcode` on an Item document, `Clb` → `clb` | 438 |
567
+ | embedded item `_id` / `_key` on one being | 6 |
568
+ | journal pages showing a shortcode in a content table | 5 |
569
+ | **top-level document `_id`** | **0** |
570
+ | **addresses and published URLs** | **0** |
571
+
572
+ The first is the point: the emitted field now matches the address built from it.
573
+ The six embedded ids move because an embedded id derives from the item's own
574
+ `system.shortcode`, which #346 deliberately does **not** case-fold — folding
575
+ there would re-identify documents rather than look them up.
576
+ - 4890864: **A map note's `image:` is no longer read** (#149). Its art is `img:`, as every
577
+ other note type's is.
578
+
579
+ This is the third and last step of the rename #142 began. Through the retirement
580
+ window both spellings were read, `img` won where a note carried both, and a note
581
+ still writing `image` got a located warning — it compiled to the byte-identical
582
+ document, so failing a build over it would have redded a tree that had done
583
+ nothing wrong. The trees have since been swept, so the alias has nothing left to
584
+ honour and is gone.
585
+
586
+ **No shipped tree is affected.** Every content tree was checked — `sohl`,
587
+ `sohl-thalorna`, `sohl-kethira-basic`, `harn-ensemble` and `harn-adventures` —
588
+ and none writes the retired spelling. The window did its job; this only closes
589
+ it.
590
+
591
+ **What an unswept note now sees.** Two errors rather than one warning, and it
592
+ stops compiling: `image` in a `sohl:` block is reported as a key the type does
593
+ not have, and the `img` the note therefore never declared is reported as
594
+ missing. The fix is the rename, and moving the key to the note's top level while
595
+ you are there — art is not system-specific, so it belongs beside every other
596
+ note's `img` rather than inside a system block.
597
+
598
+ **A tile's `image:` is untouched.** `sohl.tiles.<key>.image` is a nested
599
+ placeable's texture, not the note's own artwork, and was never the retired field:
600
+ the check reads the `sohl:` block's own keys and never descends into one.
601
+
602
+ **Nothing was added to refuse it.** The two findings above are the ordinary
603
+ unknown-key and required-field checks, which is the point of a rename's third
604
+ step — one that had to add a standing refusal would be one whose replacement
605
+ never arrived. No tombstone entry is kept: the absence of an alias is the record.
606
+ - a1c21e8: **A being's items entry names the item it copies with `model:`, an address**
607
+ (#334). The top-level `shortcode:` it replaces is retired.
608
+
609
+ ```yaml
610
+ sohl:
611
+ items:
612
+ - { model: skill-wpnc, system: { masteryLevelBase: 52 } } # this package
613
+ - { model: sohl-sohl-weapongear-dgr } # another one
614
+ ```
615
+
616
+ The old key was doing two jobs badly. It **selected a template**, while the
617
+ `system.shortcode` beside it **was** the compiled item's identity — one word for
618
+ two things, which the compiler's own error messages had to keep explaining. And
619
+ it could not say **which package** the template came from: `loadItemsMap`
620
+ flattened every local Item pack and every dependency catalogue into one
621
+ `subType:shortcode` space where a local definition silently shadowed a foreign
622
+ one. In `sohl-thalorna`, 25,485 of 26,251 model references reach into `sohl` and
623
+ none of them said so; the day that repository ships its own `weapongear-dgr`,
624
+ every entry citing `dgr` would have retargeted with a green build and no
625
+ diagnostic.
626
+
627
+ **What changes for an author**
628
+
629
+ | | before | after |
630
+ | ------------------------ | ---------------------------------- | ---------------------------------------------- |
631
+ | naming a template | `{ shortcode: wpnc, type: skill }` | `{ model: skill-wpnc }` |
632
+ | reaching another package | impossible | `{ model: sohl-sohl-skill-wpnc }` |
633
+ | `type:` beside it | required | refused — the address names the type |
634
+ | a custom item | `name` + `type` + `system` | unchanged, and `system.shortcode` now required |
635
+
636
+ A `model` is read by the same grammar every wikilink is (#336), so it is written
637
+ at whatever length says what it means and the system segment defaults from the
638
+ block the entry sits in — which is why the short form names an **Item** here
639
+ while the same string in body prose names a page.
640
+
641
+ **The catalogue is package-aware.** Every item is keyed under its own package as
642
+ well as unqualified, so a `model` that states a package resolves to that
643
+ package's item and nothing local can shadow it, while a `model` that states none
644
+ still resolves locally-first exactly as before. `foreignItemCatalogDirs` returns
645
+ `{ dir, package }` rather than a bare path.
646
+
647
+ **Consumer sweeps**: HeroicLands/Song-of-Heroic-Lands-FoundryVTT#1875 (95 beings,
648
+ 1,557 entries) and HeroicLands/sohl-thalorna#176 (645 beings, 26,251 entries plus
649
+ 938 shortcodes derived for custom gear).
650
+
651
+ ### Minor Changes
652
+
653
+ - ccf35a1: **An actor note now publishes documentation, like every other note that compiles
654
+ into a system-bearing document** (#337).
655
+
656
+ A being used to produce its Actor and nothing else. That left it the one
657
+ system-bearing note with no address at `none` — its only address named the
658
+ Actor — so a prose link written `[[being-<shortcode>|Text]]` had no page to land
659
+ on. `sohl-thalorna` alone carries 772 such links.
660
+
661
+ `docEntryTypes` is now `itemTypes` plus the actor types the shipped subtype maps
662
+ declare (derived from those maps, not listed again), plus `macro` and the map
663
+ types. Only `doc` stays outside it, for the reason that actually applies to it:
664
+ its single document _is_ the prose.
665
+
666
+ **What a consumer sees**
667
+
668
+ | Before | After |
669
+ | ------------------------------------- | -------------------------------------------------------------------- |
670
+ | `<pkg>-<system>-being-<shortcode>` | that, **and** `<pkg>-none-docbeing-<shortcode>` |
671
+ | a being's prose reachable only inline | also a JournalEntry, and a page the site publishes |
672
+ | `[[docbeing-x\|Text]]` unresolved | resolves, in the link checker, the pack compilers and the site build |
673
+
674
+ Packs gain one JournalEntry per being **carrying prose**; a being with an empty
675
+ body compiles no entry, exactly as an item with an empty body does. No existing
676
+ document changes.
677
+
678
+ **A being keeps its prose inline as well**, and the asymmetry with items is
679
+ deliberate. `system.appearance` and `system.dossier` stay as rendered text, where
680
+ an item's description is an `@UUID` pointer into its journal. One item is
681
+ embedded across hundreds of beings, so baking its description into every copy
682
+ bloats the compendium by the length of the prose times the number of carriers,
683
+ and the pointer buys that back. An actor is singular, so the same indirection
684
+ would cost a reader a click and save nothing.
685
+ - afb3650: **An asset path's first segment says which package owns it, and the rule is now
686
+ stated and tested** (#331).
687
+
688
+ `img:` and `portrait:` have always answered "which package holds this file?" in
689
+ their first segment, but nothing wrote the rule down and nothing asserted it —
690
+ the only way to confirm it was to compile a package and read `build/packs-json`.
691
+
692
+ | Authored path starts with | Owner | Emitted |
693
+ | ------------------------- | --------------------- | -------------------- |
694
+ | `systems/` | a separate **system** | unchanged |
695
+ | `modules/` | a separate **module** | unchanged |
696
+ | anything else | **this package** | `<assetRoot>/<path>` |
697
+
698
+ **The third row is now true.** The translator prefixed `icons/…` and `images/…`
699
+ and passed everything else through — the same answer for every path any tree
700
+ authors today, and the wrong one for the next directory a package ships.
701
+ `sohl-kethira-basic` keeps art under `assets/artwork/`, so an authored
702
+ `artwork/deity.webp` would have shipped unprefixed: a 404 in Foundry, reported
703
+ by nothing. Ownership is the rule; the directory names inside a package's
704
+ `assets/` tree are that package's business.
705
+
706
+ An address naming no package — an absolute URL, a `data:` URI, a `/`-rooted
707
+ path — passes through, on the same rule rather than as an exception. `worlds/`
708
+ is deliberately not exempt: a package may not ship art out of a world.
709
+
710
+ **No compiled document changes.** Compiling `sohl-thalorna` and this system's
711
+ own tree before and after gives byte-identical `packs-json`; every path either
712
+ tree authors is `icons/`, `images/` or already `systems/`-rooted.
713
+
714
+ **`banner:` is a path that does not follow this rule, and is documented as
715
+ deliberate rather than reconciled.** It reaches no compiled document: it is a
716
+ top-level key the Hugo theme reads, and the theme prefixes a relative value with
717
+ `images/` and joins it onto `params.cdnBaseURL`. The two address different
718
+ places — `img:` a file Foundry serves, `banner:` a file the CDN serves.
719
+ - e3d300c: **An `img:` authored on a type that emits none is now reported instead of
720
+ dropped in silence** (#349).
721
+
722
+ `img` is a shared top-level field — it maps onto `document.img`, so it is legal
723
+ on every note whatever the type — but not every document has one. `doc`, `place`,
724
+ `lore` and `scenario` compile into a JournalEntry, `folder` into a Foundry
725
+ `Folder`, and neither carries artwork; a `homepage` compiles into no compendium
726
+ document at all. On any of those the authored path went nowhere, the note
727
+ validated, the tree compiled clean, and nothing said so.
728
+
729
+ The frontmatter lint now warns, naming the note, the key and what the type
730
+ compiles into. `portrait:` is checked the same way, so an item authoring one is
731
+ told it has nowhere to put a sheet portrait.
732
+
733
+ **What it does not report.** `img: null` — that is the blessed way to say "this
734
+ note names no art", and on a type with no art it is a true and harmless thing to
735
+ say. A warning rather than an error, too: the note still compiles correctly, and
736
+ a note's top level is the generated page's front matter as well, so a site
737
+ template may read there what no document carries.
738
+
739
+ **Which types those are is derived, not listed.** Each pass declares the art it
740
+ writes (`BasePackCompiler.emitsArt`), and `emittedArtFor` walks type → document →
741
+ pass to answer. A second table of iconless types would be free to drift from what
742
+ is actually emitted, which is the defect rather than the check.
743
+
744
+ **What a consumer sees.** New advisory findings on notes already in this state;
745
+ warnings do not fail a build. `sohl-thalorna` has 57 — the `Lore/Totems/` and
746
+ `Lore/Deities/Kemetian/` clusters, which carried an `img:` from when they
747
+ compiled to affiliation items. The other shipped trees are clean.
748
+ - bd5e6cb: **`lore` declares `gathering`, a genre for a scheduled public occasion** (#333).
749
+
750
+ A tournament or martial games, a great market or fair, a religious festival, a
751
+ ceremony or rite: something that happens at a place and a time, on a cycle, and
752
+ that people travel to. The genre had no value, and neither neighbour fitted.
753
+
754
+ | genre | what it covers |
755
+ | ----------- | ---------------------------------------------------------------- |
756
+ | `calendar` | the _reckoning_ — the cycle, the seasons, the dating system |
757
+ | `culture` | a social grouping of people |
758
+ | `gathering` | the occasion itself — who attends, what is contested or observed |
759
+
760
+ A festival's **date** is `calendar`; the festival is not. A tournament is not a
761
+ matter of time-reckoning at all, and a great market is not a grouping of people.
762
+
763
+ **Why it matters beyond labelling.** `site.sections` narrows a section with
764
+ `listSubType`, so a subType is what makes a genre browsable. Without one, a
765
+ consumer declaring a Gatherings section would sweep in the castes, Marriage and
766
+ Personal Names alongside the games — the notes could not be listed as what they
767
+ are.
768
+
769
+ **On the name.** `festival` is too narrow: a tournament is not a festival, a
770
+ great market is a fair, and a rite is not a celebration. `event` is avoided
771
+ because it already names something else in SoHL — the event queue and
772
+ `system.scheduledActions`, where an event is a timed thing that fires in play.
773
+ `gathering` covers the whole set, and matches how the other `lore` genres are
774
+ named: a single lowercase noun for a kind of thing.
775
+
776
+ Nothing existing changes. A note already filed under `lore/culture` keeps
777
+ compiling until its author moves it; `subType` is not an address, so nothing
778
+ resolves through it.
779
+ - 5630e7c: **A note can no longer author a field the document writes in play** (#330).
780
+
781
+ A data model declares everything a document stores, and part of that is runtime
782
+ state — an affliction's `onsetDate` is the world time its onset fired at.
783
+ Writing `sohl.system.onsetDate` reached it as directly as any other field: the
784
+ block is a verbatim passthrough, no declared field claimed the path, and the
785
+ schema check's fatal direction is _undeclared_, which a field the schema really
786
+ does declare satisfies. So a compiled pack could ship one world's play state to
787
+ every world that installed it, with the build reporting success.
788
+
789
+ A field declaration may now say `runtimeOnly`, whose value is the reason — what
790
+ the field holds — the way `topLevelMeans` already works. It states both halves
791
+ of one fact:
792
+
793
+ | declaration | authored | absent |
794
+ | ------------- | ----------- | --------------- |
795
+ | ordinary | emitted | default written |
796
+ | `runtimeOnly` | **refused** | key omitted |
797
+
798
+ The refusal names the note, the line, the whole key and the field's own reason,
799
+ and says that deleting it is the fix — there is no value that makes writing one
800
+ right. Omitting the key rather than emitting `null` is what leaves the data
801
+ model's own `initial` standing.
802
+
803
+ It is a property of the declaration, not a list of names, so it holds for any
804
+ runtime-only field any system adds later. Such an entry declares a `to` and no
805
+ `name`, which keeps it out of the authored vocabulary — the generated field
806
+ reference lists it under **Never authored** with its reason instead of as a row
807
+ an author might fill in — while still claiming the path for the passthrough.
808
+
809
+ **What a consumer sees**
810
+
811
+ - SoHL's six timed-phase dates are declared: `contractDate`, `onsetDate`,
812
+ `treatmentDate` and `resolutionDate` on `affliction`; `contractDate` and
813
+ `treatmentDate` on `trauma`. No tree authors one today, so no compiled
814
+ document changes.
815
+ - The refusal covers both positions a note can reach them from: a note's own
816
+ `<system>.system` block, and an actor's `items:` entry `system:` overlay,
817
+ which merges verbatim and so passed no field declaration at all.
818
+ - A runtime-only path is no longer reported as a field the builder forgot to
819
+ emit — "every compiled document will carry the field's initial value" is what
820
+ the declaration is _for_, so the warning could never be cleared.
821
+ - Regenerate the item frontmatter reference to pick up the new section.
822
+ - cf20488: **The specification's `subType` lists are compared to the declared vocabulary,
823
+ for every type** (#345).
824
+
825
+ `tests/content-format-agreement.test.ts` made `docs/content-format.md`
826
+ executable for the `data` property tables only. The other half of the same
827
+ vocabulary entry — a type's genres, which an author picks from and which a
828
+ note's `subType` is closed against — was prose that nothing read, free to
829
+ disagree with `note-vocabulary.mjs` in either direction. That is the drift #231
830
+ and #232 were filed about, on the half they did not reach. `gathering` (#333)
831
+ guarded `lore` alone, deliberately scoped to the type it changed.
832
+
833
+ **The five spellings converged on one first.** The document stated a type's
834
+ values as `subType`, `subType:`, `**subType**`, `**subType**:` and
835
+ `**subTypes**:`, and a reader that accepted every one of them would accept the
836
+ sixth by reading that section as declaring nothing — the exact failure the
837
+ comparison exists to catch. One shape is now stated in the specification and
838
+ enforced by the parser: `**subType**:` on its own line, then `- <value>` or
839
+ `- <value>: <definition>`, one bullet per value. A type with no `subType`, or
840
+ one whose values are not enumerated yet, writes no marker.
841
+
842
+ | Written | Read as |
843
+ | -------------------------------------------- | ----------------------------------------------------------- |
844
+ | `**subType**:` + a bullet per value | that type's closed value list, in document order |
845
+ | no marker | the type enumerates none — the ordinary case for nine types |
846
+ | any other spelling, or a marker with no list | a build error naming the line |
847
+
848
+ **What the comparison asks**, of every type rather than of `lore`: the values
849
+ the specification lists equal the values `NOTE_VOCABULARY` declares, in the same
850
+ order. `subTypes` stays three-valued — a list is closed, `null` is a `subType`
851
+ whose values are not enumerated, an absent key is a type with no `subType` at
852
+ all — and each reading is compared to what the document states. The `lore`-only
853
+ assertion is folded in.
854
+
855
+ Nothing that compiles changes: the values were already equal everywhere, in both
856
+ shapes, so this is about keeping them that way. `parseContentFormat` gains a
857
+ `subTypes` array per type, and throws on a marker it does not recognise —
858
+ reachable only through `content-format --spec <a copy of the document>`.
859
+ - 312379e: **Afflictions and traumas can declare their timed phases** (#329).
860
+
861
+ SoHL stores each timed phase as `{…DurationFormula, …DurationBase, …Date}`, and
862
+ the two authored thirds were declared by nothing. They were reachable only
863
+ through the raw `system:` passthrough — undocumented, uncoerced, and absent from
864
+ the field list every author-facing surface is built from — so no note in any
865
+ tree wrote one. Every shipped affliction carried `null`, and
866
+ `AfflictionLogic.rollDuration()` opens `if (!formula) return 0`: the timed-phase
867
+ machinery existed, and the content that would drive it could not be written.
868
+
869
+ They could not simply be declared either. `buildFromFields` wrote every declared
870
+ field unconditionally, so a declaration would have stamped `null` onto every
871
+ document — the same outcome, minus the ability to tell "unset" from "authored as
872
+ empty".
873
+
874
+ **`omitWhenAbsent`** is the missing capability: a field declaring it is emitted
875
+ when the note carries one and has its **key left out entirely** when it does
876
+ not, so the DataModel's own `initial` stands. It completes the table
877
+ `runtimeOnly` (#330) opened:
878
+
879
+ | declaration | authored | absent |
880
+ | ---------------- | ----------- | --------------- |
881
+ | ordinary | emitted | default written |
882
+ | `omitWhenAbsent` | emitted | key omitted |
883
+ | `runtimeOnly` | **refused** | key omitted |
884
+
885
+ The decision is made on the **position** a value came from, never on the value:
886
+ a declared `default: null` and an authored `null` are the same value and
887
+ opposite facts. `readFieldEntry` reports the source beside the value so the
888
+ position is resolved once rather than twice.
889
+
890
+ **Twelve fields are now declared vocabulary** — `onset`, `healingCheck` and
891
+ `resolution` on `affliction`; `healingCheck`, `bloodLossAdvance` and `course` on
892
+ `trauma` — each as both a `…DurationFormula` and a `…DurationBase`, in the
893
+ `sohl:` block and in the closed `data:` container alike. Intervals are in
894
+ seconds, and a bare number is a valid formula.
895
+
896
+ **What a consumer sees**
897
+
898
+ - The twelve appear in the generated item-frontmatter reference, with `_omitted_`
899
+ in the Default column rather than a value. Regenerate the page.
900
+ - The `unemitted` warnings these raised against a pinned schema clear.
901
+ - No compiled document changes: no tree authors one yet, and a note that writes
902
+ nothing emits nothing where it previously emitted nothing.
903
+ - `omitWhenAbsent` may not be combined with `default` (contradictory), with
904
+ `required`, or with `runtimeOnly`; the shipped declarations are checked for all
905
+ three.
906
+
907
+ ### Patch Changes
908
+
909
+ - b5004f0: **A `model:` now resolves an item whose shortcode carries a capital** (#346). The
910
+ catalogue was keyed on the compiled document's `system.shortcode` exactly, while
911
+ an address is lower-cased when it is read — so `model: weapongear-clb` looked for
912
+ `weapongear:clb` while the document sat under `weapongear:Clb`, and every being
913
+ referencing one of the six mixed-case gear shortcodes in `sohl` failed to
914
+ compile.
915
+
916
+ The catalogue key folds the shortcode's case. The **id-bearing** address does
917
+ not: `itemAddress` seeds `embeddedItemId`, so folding there would change the
918
+ `_id` of every embedded item whose identity carries a capital — silently
919
+ re-identifying documents nothing about which had changed. A catalogue is a lookup
920
+ table; an id is a promise.
921
+
922
+ Verified by compiling both swept trees: `sohl` emits actors, items, macros,
923
+ scenes and adventures byte-identical to its pre-sweep baseline, and
924
+ `sohl-thalorna` differs only by the 938 embedded ids its sweep predicts.
925
+ - 4c57a5a: **A being's `data.portrait` reaches the actor** (#332). It never had: the
926
+ emitters read `blockProperty(fm, "portrait")`, which knows a system block and
927
+ the note's top level and never splits a dotted path, so the position the content
928
+ format names was invisible to them — and the `?? defaultImg` beside it turned
929
+ every miss into the subtype's icon rather than into a complaint. 646
930
+ `sohl-thalorna` beings authored a portrait, 341 of them pointing at art that
931
+ exists on disk, and every one compiled the generic person icon. Nothing warned.
932
+
933
+ `portrait` now resolves through the same declaration `data.species` does, in
934
+ both the `sohl` and `hm3` actor passes.
935
+
936
+ **A `data:` source has a retiring top-level spelling, and step 3b reads it.**
937
+ `data:` did not invent the facts it holds — it gathered them out of the note's
938
+ open top level, where `portrait:` sat beside `img:` — so the pre-`data:`
939
+ spelling of `data.<key>` is `<key>`, and until now nothing read it. That is why
940
+ this is a resolution-order fix and not a one-line emitter fix: `data.portrait`
941
+ had to start working _without_ breaking the top-level `portrait:` that `sohl`'s
942
+ own bestiary writes on every note.
943
+
944
+ The spelling is **derived**, not declared — a second declaration would be a
945
+ second place for one fact to live — and only a `data.` source has one, so
946
+ `protection.blunt` and `impact.die` resolve exactly as they did.
947
+
948
+ **Nothing is dropped in silence any more.** A field read from the retiring
949
+ top-level key emits a warning naming the line, the counterpart to the existing
950
+ in-block report; the note compiles to the identical document either way. The
951
+ frontmatter lint's `portrait: ""` check reads the `data:` position too, which it
952
+ could not see before.
953
+ - 59469d7: **The dependency check read English prose in a comment as an import** (#355).
954
+
955
+ `tests/dependencies-are-declared.test.ts` finds a shipped file's imports with a
956
+ regex over the raw file text. It already reasoned about one false positive — the
957
+ lookbehind stops `["from", "to"]` reading as an import of `", "` — but not about
958
+ comments, where `from` is an ordinary word and the quotes are ordinary quotes.
959
+ Any explanatory comment containing the word `from`, `import` or `require`
960
+ followed by a quoted phrase was reported as an undeclared dependency:
961
+
962
+ ```text
963
+ FAIL sohl/item-fields.mjs imports only builtins, itself, or a declared dependency
964
+ + [ "sohl/item-fields.mjs:455 → this note does not set the phase" ]
965
+ ```
966
+
967
+ The message names a real file and a real line and says a dependency is missing,
968
+ so the first reading is that one genuinely is. Nothing in it suggests the culprit
969
+ is a sentence, and the fix — reword the comment — is unrelated to anything the
970
+ message describes. It cost a debugging cycle in #329, and the workaround left the
971
+ trap armed for whoever wrote the next comment.
972
+
973
+ **Comments are now blanked before the regex runs.** They are located by parsing
974
+ the file, not by a second regex, so `//` inside a string literal is still a string
975
+ literal. Each comment's characters are replaced one-for-one with spaces and its
976
+ newlines are left alone, so every offset survives and a finding still points at
977
+ the line a reader opens. The `sohl/item-fields.mjs` comment that provoked this
978
+ reads naturally again, and the suite carries it verbatim.
979
+ - fec6c80: **The generated item-frontmatter examples no longer author an `id:`** (#314).
980
+
981
+ `content-build docs item-fields` emitted `id: <16-character id>` in the worked
982
+ example for every item type — thirteen of them in the `sohl` tree. Since #270
983
+ and #277 a note's document `_id` derives from its canonical address, and the
984
+ authored field is the escape hatch for keeping a document's identity across a
985
+ shortcode rename, not part of the envelope every note carries.
986
+
987
+ The example is the block an author copies as a template, and the page is the
988
+ per-type reference they read while writing the note, so the one place the field
989
+ survived a tree's sweep was the document teaching them to write it. It is now
990
+ omitted, as every other optional envelope field already was; the `type` and
991
+ `shortcode` the derivation reads are unchanged.
992
+
993
+ Consumers should regenerate their item frontmatter reference to drop the line.
994
+ - a48802f: **A folder note's published `id` is now the id its Foundry documents carry**
995
+ (#310).
996
+
997
+ The content index derived every note's id under the `document` namespace. A
998
+ `Folder` is hashed under the `folder` namespace, so the index published one
999
+ value and the packs addressed another — `sohl-none-folder-cookware` was
1000
+ `f5d3dc635b7e799c` in the index and `b92b28b7d06638ed` in every pack.
1001
+
1002
+ `noteDocId` now asks the folder pass for a folder's id instead of deriving a
1003
+ second one, so the two cannot disagree.
1004
+
1005
+ **Why nothing caught it.** Every one of `sohl`'s 79 folder notes pins an `id`,
1006
+ and a pin wins in both paths — so all 65 emitted folder documents agreed by
1007
+ coincidence. It is also invisible from inside a build: no pass reads a folder's
1008
+ id off the index. The published artifact was the only place the wrong value
1009
+ surfaced, and a reader outside the build could neither recompute the right one
1010
+ nor notice the wrong one.
1011
+
1012
+ **The general rule this settles:** for every entry the content index gives an
1013
+ identity to, it publishes both the `id` and the `uuid`, each computed once by
1014
+ whatever owns that entry's derivation. A documentation journal's record
1015
+ accordingly gains its own `id` — it carried the UUID that id ends in, but not
1016
+ the id — so a consumer reads it rather than parsing it back out of the UUID's
1017
+ last segment.
1018
+
1019
+ _No emitted document changes; this corrects what the index says about them._
1020
+ - ebd3a80: **A note-level frontmatter check no longer answers from a system field that
1021
+ merely shares the name** (#312).
1022
+
1023
+ `topLevelMeans` exists because one spelling can name two unrelated quantities. An
1024
+ `affiliation` item's `system.title` is the style of address an office carries —
1025
+ "Ajaw", "Warden"; a note's top-level `title` is the heading its page publishes
1026
+ under. The field declares the collision, and `resolveFieldValue` honours it by
1027
+ refusing to read the top level for that field.
1028
+
1029
+ The frontmatter lint did not. `authoredValue` resolved every check through the
1030
+ `sohl:` block first, so the blank-heading check — whose emitter is `fm.title ??
1031
+ name`, the note's top level and nothing else — read `sohl.title` and found the
1032
+ office's style of address. An office with no style of address is ordinary, and
1033
+ each one was reported as a page published with no heading, sorting to the front
1034
+ of its section.
1035
+
1036
+ **The statement is symmetric, and is now read that way.** If two positions hold
1037
+ unrelated quantities then the in-block position is not the note-level field
1038
+ either, so a note-level check reads past a block key the note's own type claims
1039
+ for something else. The exemption is still the field's own declaration rather
1040
+ than a name the linter knows: `collidingBlockKeys` asks the schemas the caller
1041
+ supplies, so the linter and the resolver cannot disagree about which field
1042
+ declares one.
1043
+
1044
+ **The art fields are checked the same way.** `img` and `portrait` keep resolving
1045
+ through the block, because that is what their emitter does — `blockProperty`
1046
+ reads `sohl.img` first, so a `sohl.img: ""` really does ship a document with no
1047
+ art and is still reported. What changes is that a future system field of either
1048
+ name cannot quietly answer for the note's own art; a map's was `sohl.image`
1049
+ until #142.
1050
+
1051
+ **What a consumer sees.** Twenty-eight fewer warnings on an unswept
1052
+ `sohl-kethira-basic` — every affiliation writing `sohl.title: ""`. All were false
1053
+ positives; their pages took `name.full` throughout. A tree already swept onto
1054
+ `sohl.system.title` was unaffected either way, which is why the findings
1055
+ disappearing looked like a lint regression in that sweep rather than the
1056
+ false positives going away.
1057
+ - f6a8a05: **The specification and four engine docblocks stated the retired folder model**
1058
+ (#358).
1059
+
1060
+ The folder epic replaced a model wholesale — `folder:` named a Foundry id
1061
+ resolved against a per-pack `*-folders.yaml`, and `packFolder` named a path.
1062
+ None of that exists: `folder:` is refused, the YAML is gone, and `packFolder`
1063
+ is a folder note's address. Six passages still described the old shape as the
1064
+ live one.
1065
+
1066
+ **`docs/content-format.md` contradicted itself twice.** The shared-mappings
1067
+ table — the one place eight rows common to all sixteen type tables are stated —
1068
+ offered `` `packFolder` / `folder` ``, so a reader was told to write a value the
1069
+ build rejects, 330 lines before the same document says it is retired. And the
1070
+ argument for deriving a document id cited "`packFolder: <path>` above", where
1071
+ above says address.
1072
+
1073
+ **Four docblocks described the retired resolution path**, and they publish:
1074
+
1075
+ | site | said |
1076
+ | -------------------------- | ----------------------------------------------------------------------------------- |
1077
+ | `engine/generate.mjs` | folder files "referenced from entry frontmatter via `sohl.folder: <id>`" |
1078
+ | `engine/journals.mjs` | the target folder's id "from folders.yaml", resolved against a folders.yaml list |
1079
+ | `engine/base-compiler.mjs` | `folderResolver` "resolves a `sohl.folder` id against this pack's folder hierarchy" |
1080
+ | `engine/frontmatter.mjs` | `folderField` reads "two spellings", `packFolder` winning "where both are present" |
1081
+
1082
+ The last two were the sharpest. `folderField` reads `packFolder` and nothing
1083
+ else, so its docstring described a resolution the function cannot perform and
1084
+ deferred to an issue that had closed. `generate.mjs` disagreed with itself
1085
+ across one file: the module header named `sohl.folder`, while its `resolver`
1086
+ states the rule correctly — "There is one spelling."
1087
+
1088
+ **Prose is the defect the epic was about.** Its argument against
1089
+ `*-folders.yaml` was that a second, unchecked statement of one fact drifts from
1090
+ the first, and nothing compares the two. These six passages were exactly that,
1091
+ and nothing caught them: `lint:content-format` makes a claim only for a
1092
+ `system.*` target, so a row mapping to core Foundry's `folder` yields none, and
1093
+ the source side of a shared row is checked by nothing at all.
1094
+
1095
+ Two assertions now hold the specification to it — the shared-mappings sources
1096
+ name no retired field, and the document never presents `packFolder` as holding
1097
+ a path. The docblocks are held to review instead: a sentence describing
1098
+ `folder:` as _retired_ is correct and must survive, and no assertion separates
1099
+ that from one describing it as live without reading the prose.
1100
+
1101
+ No behaviour changes; the fix is what the documents say.
1102
+
3
1103
  ## 19.0.0
4
1104
 
5
1105
  ### Major Changes