@heroiclands/package-build 19.0.0 → 20.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 (65) hide show
  1. package/CHANGELOG.md +624 -0
  2. package/CONTENT.md +79 -8
  3. package/bin/content-build.mjs +7 -1
  4. package/content-config.mjs +10 -1
  5. package/docs/content-format.md +394 -72
  6. package/engine/actor-compiler.mjs +197 -7
  7. package/engine/address-charset.mjs +23 -5
  8. package/engine/base-compiler.mjs +63 -2
  9. package/engine/bundles.mjs +9 -0
  10. package/engine/content-address.mjs +92 -1
  11. package/engine/content-format.mjs +102 -0
  12. package/engine/content-index.mjs +11 -8
  13. package/engine/content-links.mjs +37 -21
  14. package/engine/field-reference.mjs +57 -5
  15. package/engine/field-spec.mjs +214 -7
  16. package/engine/folder-notes.mjs +24 -1
  17. package/engine/foreign-catalog.mjs +4 -1
  18. package/engine/foundry-entries.mjs +14 -0
  19. package/engine/frontmatter-lint.mjs +186 -27
  20. package/engine/frontmatter.mjs +11 -11
  21. package/engine/generate.mjs +67 -10
  22. package/engine/helpers.mjs +86 -9
  23. package/engine/index.mjs +3 -0
  24. package/engine/item-compiler.mjs +37 -0
  25. package/engine/journals.mjs +21 -4
  26. package/engine/macros.mjs +8 -0
  27. package/engine/map-notes.mjs +7 -7
  28. package/engine/note-ids.mjs +25 -1
  29. package/engine/note-vocabulary.mjs +76 -9
  30. package/engine/retired-fields.mjs +57 -16
  31. package/engine/runtime-only-fields.mjs +204 -0
  32. package/engine/scenes.mjs +12 -19
  33. package/engine/schema-check.mjs +23 -1
  34. package/engine/site-index.mjs +17 -0
  35. package/engine/subtype-registry.mjs +30 -0
  36. package/engine/system-block.mjs +81 -3
  37. package/engine/web-wikilinks.mjs +33 -27
  38. package/engine/wikilink-syntax.mjs +7 -0
  39. package/engine/wikilinks.mjs +74 -16
  40. package/hm3/actors.mjs +63 -13
  41. package/package.json +2 -2
  42. package/sohl/actors.mjs +106 -7
  43. package/sohl/item-fields.mjs +203 -0
  44. package/sohl/note-schemas.mjs +6 -3
  45. package/types/engine/actor-compiler.d.mts +83 -3
  46. package/types/engine/address-charset.d.mts +22 -4
  47. package/types/engine/base-compiler.d.mts +54 -3
  48. package/types/engine/content-address.d.mts +64 -0
  49. package/types/engine/content-format.d.mts +9 -0
  50. package/types/engine/field-spec.d.mts +271 -3
  51. package/types/engine/folder-notes.d.mts +20 -0
  52. package/types/engine/foundry-entries.d.mts +6 -0
  53. package/types/engine/frontmatter-lint.d.mts +18 -2
  54. package/types/engine/frontmatter.d.mts +11 -11
  55. package/types/engine/generate.d.mts +27 -0
  56. package/types/engine/helpers.d.mts +37 -9
  57. package/types/engine/index.d.mts +1 -0
  58. package/types/engine/map-notes.d.mts +2 -2
  59. package/types/engine/note-ids.d.mts +14 -0
  60. package/types/engine/retired-fields.d.mts +29 -13
  61. package/types/engine/runtime-only-fields.d.mts +102 -0
  62. package/types/engine/schema-check.d.mts +10 -1
  63. package/types/engine/subtype-registry.d.mts +21 -0
  64. package/types/engine/system-block.d.mts +28 -2
  65. package/types/sohl/actors.d.mts +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,629 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 20.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 1670506: **An omitted address segment now defaults from where the link is written** (#336),
8
+ instead of being wildcarded or searched for. Three resolvers each carried their
9
+ own reading of a partial address and disagreed; there is one rule now, in
10
+ `expandAddress` beside `canonicalKey`.
11
+
12
+ - **package** omitted → the current package.
13
+ - **system** omitted → the **system block the link sits under**: anywhere under
14
+ `sohl:` is `sohl`, anywhere under `hm3:` is `hm3`, and anywhere else —
15
+ top-level frontmatter, `data:`, body prose — is `none`. The enclosing block
16
+ decides at any depth; the field has no say.
17
+
18
+ Every short form therefore expands to exactly one canonical address before
19
+ lookup. Resolution is a `Map.get`, with no candidate set and no single-hit rule.
20
+
21
+ **Under `none`, a system-bearing type addresses its documentation.** A note's
22
+ `none` address _is_ its `doc<type>` journal, so a prose `[[affiliation-x|…]]`
23
+ names the page — what a prose link almost always means. A prose link that means
24
+ the Item states the system: `[[sohl-affiliation-x|…]]`. A `macro` and the map
25
+ types are **not** redirected: their own documents are core ones already at
26
+ `none`, so `macro-x` still names the Macro.
27
+
28
+ **Breaking, in three ways a consumer will notice**
29
+
30
+ | | before | after |
31
+ | ----------------------------------- | -------------------------------------------- | ------------------------------------------------- |
32
+ | a bare prose link to an item type | the Item's UUID | the documentation journal's |
33
+ | a bare address naming no local note | fell through to any dependency publishing it | `unresolved`; qualify it to reach another package |
34
+ | `[[Skill-Climb\|…]]` | resolved, case folded | `not-lowercase` |
35
+
36
+ The first rewrites every such link in every pack. The second is the point of the
37
+ issue: a link resolved into another package only because no local note claimed
38
+ the address, and would have retargeted silently the day one did.
39
+
40
+ **Two defects go with it.** The index's system-blind `type/shortcode` key is
41
+ gone — it was set with a plain `Map.set`, so two notes in one package sharing a
42
+ `(type, shortcode)` across systems silently overwrote each other while both
43
+ canonical keys sat correctly beside it. And cross-package `ambiguous` is now
44
+ unreachable: one expanded address names one package, so a lookup returns one
45
+ entry or none. The finding is retained for older vendored manifests.
46
+
47
+ **An address capitalises nothing but its shortcode.** Package, system and type
48
+ are closed vocabularies with one spelling each; a shortcode is case-sensitive and
49
+ routinely mixed (`Clb`, `LtShoe`), so it keeps its case. Neither tree carried a
50
+ violation — 10,538 authored targets checked. Requiring the shortcode to be
51
+ lowercase too is tracked as #340.
52
+ - c906d54: **A shortcode must now match `^[a-z0-9]+$`** — lowercase letters and digits only
53
+ (#340). So must every other address segment; the charset is one rule with no
54
+ exceptions left in it.
55
+
56
+ `Dgr` beside `dgr` is a distinction nobody can say out loud and can only see by
57
+ looking twice. It was also a silent identity collapse: `canonicalKey` lowercases
58
+ the address it builds, so a note declaring `Clb` published
59
+ `sohl-sohl-weapongear-clb` and derived its `_id` from that — and two notes
60
+ differing only in case shared one address, one `_id` and one URL with nothing to
61
+ report it, because the shortcode check compared shortcodes (genuinely distinct)
62
+ and the address check saw one address.
63
+
64
+ It forced two exceptions elsewhere, and both go: #336 had to exempt the shortcode
65
+ from the lowercase rule it pinned on every other segment, and #346 had to fold
66
+ the shortcode's case in the item catalogue because an address is lowercased when
67
+ it is read.
68
+
69
+ **A consumer with a capital in a shortcode will fail to build**, citing the
70
+ note's file, line and column. The sweep is mechanical: every violation in every
71
+ tree is a capital letter — no underscores, hyphens or other characters occur —
72
+ and **nothing collides when folded**, checked per `(type, shortcode)` in every
73
+ tree.
74
+
75
+ | tree | shortcodes to change |
76
+ | --------------------------------- | --------------------: |
77
+ | `Song-of-Heroic-Lands-FoundryVTT` | 438 |
78
+ | `sohl-thalorna` | 96 |
79
+ | every other tree | 0 — already compliant |
80
+
81
+ **No document changes identity and no URL moves**, because an address and a
82
+ document `_id` already derive from the lowercased form. References need no edit
83
+ either: no authored wikilink target carries a capital (10,538 checked), and
84
+ `model:` addresses are already lowercase.
85
+
86
+ **The emitted packs do change, in three narrow ways**, measured on `sohl`'s 438:
87
+
88
+ | change | count |
89
+ | ----------------------------------------------------- | ----: |
90
+ | `system.shortcode` on an Item document, `Clb` → `clb` | 438 |
91
+ | embedded item `_id` / `_key` on one being | 6 |
92
+ | journal pages showing a shortcode in a content table | 5 |
93
+ | **top-level document `_id`** | **0** |
94
+ | **addresses and published URLs** | **0** |
95
+
96
+ The first is the point: the emitted field now matches the address built from it.
97
+ The six embedded ids move because an embedded id derives from the item's own
98
+ `system.shortcode`, which #346 deliberately does **not** case-fold — folding
99
+ there would re-identify documents rather than look them up.
100
+ - 4890864: **A map note's `image:` is no longer read** (#149). Its art is `img:`, as every
101
+ other note type's is.
102
+
103
+ This is the third and last step of the rename #142 began. Through the retirement
104
+ window both spellings were read, `img` won where a note carried both, and a note
105
+ still writing `image` got a located warning — it compiled to the byte-identical
106
+ document, so failing a build over it would have redded a tree that had done
107
+ nothing wrong. The trees have since been swept, so the alias has nothing left to
108
+ honour and is gone.
109
+
110
+ **No shipped tree is affected.** Every content tree was checked — `sohl`,
111
+ `sohl-thalorna`, `sohl-kethira-basic`, `harn-ensemble` and `harn-adventures` —
112
+ and none writes the retired spelling. The window did its job; this only closes
113
+ it.
114
+
115
+ **What an unswept note now sees.** Two errors rather than one warning, and it
116
+ stops compiling: `image` in a `sohl:` block is reported as a key the type does
117
+ not have, and the `img` the note therefore never declared is reported as
118
+ missing. The fix is the rename, and moving the key to the note's top level while
119
+ you are there — art is not system-specific, so it belongs beside every other
120
+ note's `img` rather than inside a system block.
121
+
122
+ **A tile's `image:` is untouched.** `sohl.tiles.<key>.image` is a nested
123
+ placeable's texture, not the note's own artwork, and was never the retired field:
124
+ the check reads the `sohl:` block's own keys and never descends into one.
125
+
126
+ **Nothing was added to refuse it.** The two findings above are the ordinary
127
+ unknown-key and required-field checks, which is the point of a rename's third
128
+ step — one that had to add a standing refusal would be one whose replacement
129
+ never arrived. No tombstone entry is kept: the absence of an alias is the record.
130
+ - a1c21e8: **A being's items entry names the item it copies with `model:`, an address**
131
+ (#334). The top-level `shortcode:` it replaces is retired.
132
+
133
+ ```yaml
134
+ sohl:
135
+ items:
136
+ - { model: skill-wpnc, system: { masteryLevelBase: 52 } } # this package
137
+ - { model: sohl-sohl-weapongear-dgr } # another one
138
+ ```
139
+
140
+ The old key was doing two jobs badly. It **selected a template**, while the
141
+ `system.shortcode` beside it **was** the compiled item's identity — one word for
142
+ two things, which the compiler's own error messages had to keep explaining. And
143
+ it could not say **which package** the template came from: `loadItemsMap`
144
+ flattened every local Item pack and every dependency catalogue into one
145
+ `subType:shortcode` space where a local definition silently shadowed a foreign
146
+ one. In `sohl-thalorna`, 25,485 of 26,251 model references reach into `sohl` and
147
+ none of them said so; the day that repository ships its own `weapongear-dgr`,
148
+ every entry citing `dgr` would have retargeted with a green build and no
149
+ diagnostic.
150
+
151
+ **What changes for an author**
152
+
153
+ | | before | after |
154
+ | ------------------------ | ---------------------------------- | ---------------------------------------------- |
155
+ | naming a template | `{ shortcode: wpnc, type: skill }` | `{ model: skill-wpnc }` |
156
+ | reaching another package | impossible | `{ model: sohl-sohl-skill-wpnc }` |
157
+ | `type:` beside it | required | refused — the address names the type |
158
+ | a custom item | `name` + `type` + `system` | unchanged, and `system.shortcode` now required |
159
+
160
+ A `model` is read by the same grammar every wikilink is (#336), so it is written
161
+ at whatever length says what it means and the system segment defaults from the
162
+ block the entry sits in — which is why the short form names an **Item** here
163
+ while the same string in body prose names a page.
164
+
165
+ **The catalogue is package-aware.** Every item is keyed under its own package as
166
+ well as unqualified, so a `model` that states a package resolves to that
167
+ package's item and nothing local can shadow it, while a `model` that states none
168
+ still resolves locally-first exactly as before. `foreignItemCatalogDirs` returns
169
+ `{ dir, package }` rather than a bare path.
170
+
171
+ **Consumer sweeps**: HeroicLands/Song-of-Heroic-Lands-FoundryVTT#1875 (95 beings,
172
+ 1,557 entries) and HeroicLands/sohl-thalorna#176 (645 beings, 26,251 entries plus
173
+ 938 shortcodes derived for custom gear).
174
+
175
+ ### Minor Changes
176
+
177
+ - ccf35a1: **An actor note now publishes documentation, like every other note that compiles
178
+ into a system-bearing document** (#337).
179
+
180
+ A being used to produce its Actor and nothing else. That left it the one
181
+ system-bearing note with no address at `none` — its only address named the
182
+ Actor — so a prose link written `[[being-<shortcode>|Text]]` had no page to land
183
+ on. `sohl-thalorna` alone carries 772 such links.
184
+
185
+ `docEntryTypes` is now `itemTypes` plus the actor types the shipped subtype maps
186
+ declare (derived from those maps, not listed again), plus `macro` and the map
187
+ types. Only `doc` stays outside it, for the reason that actually applies to it:
188
+ its single document _is_ the prose.
189
+
190
+ **What a consumer sees**
191
+
192
+ | Before | After |
193
+ | ------------------------------------- | -------------------------------------------------------------------- |
194
+ | `<pkg>-<system>-being-<shortcode>` | that, **and** `<pkg>-none-docbeing-<shortcode>` |
195
+ | a being's prose reachable only inline | also a JournalEntry, and a page the site publishes |
196
+ | `[[docbeing-x\|Text]]` unresolved | resolves, in the link checker, the pack compilers and the site build |
197
+
198
+ Packs gain one JournalEntry per being **carrying prose**; a being with an empty
199
+ body compiles no entry, exactly as an item with an empty body does. No existing
200
+ document changes.
201
+
202
+ **A being keeps its prose inline as well**, and the asymmetry with items is
203
+ deliberate. `system.appearance` and `system.dossier` stay as rendered text, where
204
+ an item's description is an `@UUID` pointer into its journal. One item is
205
+ embedded across hundreds of beings, so baking its description into every copy
206
+ bloats the compendium by the length of the prose times the number of carriers,
207
+ and the pointer buys that back. An actor is singular, so the same indirection
208
+ would cost a reader a click and save nothing.
209
+ - afb3650: **An asset path's first segment says which package owns it, and the rule is now
210
+ stated and tested** (#331).
211
+
212
+ `img:` and `portrait:` have always answered "which package holds this file?" in
213
+ their first segment, but nothing wrote the rule down and nothing asserted it —
214
+ the only way to confirm it was to compile a package and read `build/packs-json`.
215
+
216
+ | Authored path starts with | Owner | Emitted |
217
+ | ------------------------- | --------------------- | -------------------- |
218
+ | `systems/` | a separate **system** | unchanged |
219
+ | `modules/` | a separate **module** | unchanged |
220
+ | anything else | **this package** | `<assetRoot>/<path>` |
221
+
222
+ **The third row is now true.** The translator prefixed `icons/…` and `images/…`
223
+ and passed everything else through — the same answer for every path any tree
224
+ authors today, and the wrong one for the next directory a package ships.
225
+ `sohl-kethira-basic` keeps art under `assets/artwork/`, so an authored
226
+ `artwork/deity.webp` would have shipped unprefixed: a 404 in Foundry, reported
227
+ by nothing. Ownership is the rule; the directory names inside a package's
228
+ `assets/` tree are that package's business.
229
+
230
+ An address naming no package — an absolute URL, a `data:` URI, a `/`-rooted
231
+ path — passes through, on the same rule rather than as an exception. `worlds/`
232
+ is deliberately not exempt: a package may not ship art out of a world.
233
+
234
+ **No compiled document changes.** Compiling `sohl-thalorna` and this system's
235
+ own tree before and after gives byte-identical `packs-json`; every path either
236
+ tree authors is `icons/`, `images/` or already `systems/`-rooted.
237
+
238
+ **`banner:` is a path that does not follow this rule, and is documented as
239
+ deliberate rather than reconciled.** It reaches no compiled document: it is a
240
+ top-level key the Hugo theme reads, and the theme prefixes a relative value with
241
+ `images/` and joins it onto `params.cdnBaseURL`. The two address different
242
+ places — `img:` a file Foundry serves, `banner:` a file the CDN serves.
243
+ - e3d300c: **An `img:` authored on a type that emits none is now reported instead of
244
+ dropped in silence** (#349).
245
+
246
+ `img` is a shared top-level field — it maps onto `document.img`, so it is legal
247
+ on every note whatever the type — but not every document has one. `doc`, `place`,
248
+ `lore` and `scenario` compile into a JournalEntry, `folder` into a Foundry
249
+ `Folder`, and neither carries artwork; a `homepage` compiles into no compendium
250
+ document at all. On any of those the authored path went nowhere, the note
251
+ validated, the tree compiled clean, and nothing said so.
252
+
253
+ The frontmatter lint now warns, naming the note, the key and what the type
254
+ compiles into. `portrait:` is checked the same way, so an item authoring one is
255
+ told it has nowhere to put a sheet portrait.
256
+
257
+ **What it does not report.** `img: null` — that is the blessed way to say "this
258
+ note names no art", and on a type with no art it is a true and harmless thing to
259
+ say. A warning rather than an error, too: the note still compiles correctly, and
260
+ a note's top level is the generated page's front matter as well, so a site
261
+ template may read there what no document carries.
262
+
263
+ **Which types those are is derived, not listed.** Each pass declares the art it
264
+ writes (`BasePackCompiler.emitsArt`), and `emittedArtFor` walks type → document →
265
+ pass to answer. A second table of iconless types would be free to drift from what
266
+ is actually emitted, which is the defect rather than the check.
267
+
268
+ **What a consumer sees.** New advisory findings on notes already in this state;
269
+ warnings do not fail a build. `sohl-thalorna` has 57 — the `Lore/Totems/` and
270
+ `Lore/Deities/Kemetian/` clusters, which carried an `img:` from when they
271
+ compiled to affiliation items. The other shipped trees are clean.
272
+ - bd5e6cb: **`lore` declares `gathering`, a genre for a scheduled public occasion** (#333).
273
+
274
+ A tournament or martial games, a great market or fair, a religious festival, a
275
+ ceremony or rite: something that happens at a place and a time, on a cycle, and
276
+ that people travel to. The genre had no value, and neither neighbour fitted.
277
+
278
+ | genre | what it covers |
279
+ | ----------- | ---------------------------------------------------------------- |
280
+ | `calendar` | the _reckoning_ — the cycle, the seasons, the dating system |
281
+ | `culture` | a social grouping of people |
282
+ | `gathering` | the occasion itself — who attends, what is contested or observed |
283
+
284
+ A festival's **date** is `calendar`; the festival is not. A tournament is not a
285
+ matter of time-reckoning at all, and a great market is not a grouping of people.
286
+
287
+ **Why it matters beyond labelling.** `site.sections` narrows a section with
288
+ `listSubType`, so a subType is what makes a genre browsable. Without one, a
289
+ consumer declaring a Gatherings section would sweep in the castes, Marriage and
290
+ Personal Names alongside the games — the notes could not be listed as what they
291
+ are.
292
+
293
+ **On the name.** `festival` is too narrow: a tournament is not a festival, a
294
+ great market is a fair, and a rite is not a celebration. `event` is avoided
295
+ because it already names something else in SoHL — the event queue and
296
+ `system.scheduledActions`, where an event is a timed thing that fires in play.
297
+ `gathering` covers the whole set, and matches how the other `lore` genres are
298
+ named: a single lowercase noun for a kind of thing.
299
+
300
+ Nothing existing changes. A note already filed under `lore/culture` keeps
301
+ compiling until its author moves it; `subType` is not an address, so nothing
302
+ resolves through it.
303
+ - 5630e7c: **A note can no longer author a field the document writes in play** (#330).
304
+
305
+ A data model declares everything a document stores, and part of that is runtime
306
+ state — an affliction's `onsetDate` is the world time its onset fired at.
307
+ Writing `sohl.system.onsetDate` reached it as directly as any other field: the
308
+ block is a verbatim passthrough, no declared field claimed the path, and the
309
+ schema check's fatal direction is _undeclared_, which a field the schema really
310
+ does declare satisfies. So a compiled pack could ship one world's play state to
311
+ every world that installed it, with the build reporting success.
312
+
313
+ A field declaration may now say `runtimeOnly`, whose value is the reason — what
314
+ the field holds — the way `topLevelMeans` already works. It states both halves
315
+ of one fact:
316
+
317
+ | declaration | authored | absent |
318
+ | ------------- | ----------- | --------------- |
319
+ | ordinary | emitted | default written |
320
+ | `runtimeOnly` | **refused** | key omitted |
321
+
322
+ The refusal names the note, the line, the whole key and the field's own reason,
323
+ and says that deleting it is the fix — there is no value that makes writing one
324
+ right. Omitting the key rather than emitting `null` is what leaves the data
325
+ model's own `initial` standing.
326
+
327
+ It is a property of the declaration, not a list of names, so it holds for any
328
+ runtime-only field any system adds later. Such an entry declares a `to` and no
329
+ `name`, which keeps it out of the authored vocabulary — the generated field
330
+ reference lists it under **Never authored** with its reason instead of as a row
331
+ an author might fill in — while still claiming the path for the passthrough.
332
+
333
+ **What a consumer sees**
334
+
335
+ - SoHL's six timed-phase dates are declared: `contractDate`, `onsetDate`,
336
+ `treatmentDate` and `resolutionDate` on `affliction`; `contractDate` and
337
+ `treatmentDate` on `trauma`. No tree authors one today, so no compiled
338
+ document changes.
339
+ - The refusal covers both positions a note can reach them from: a note's own
340
+ `<system>.system` block, and an actor's `items:` entry `system:` overlay,
341
+ which merges verbatim and so passed no field declaration at all.
342
+ - A runtime-only path is no longer reported as a field the builder forgot to
343
+ emit — "every compiled document will carry the field's initial value" is what
344
+ the declaration is _for_, so the warning could never be cleared.
345
+ - Regenerate the item frontmatter reference to pick up the new section.
346
+ - cf20488: **The specification's `subType` lists are compared to the declared vocabulary,
347
+ for every type** (#345).
348
+
349
+ `tests/content-format-agreement.test.ts` made `docs/content-format.md`
350
+ executable for the `data` property tables only. The other half of the same
351
+ vocabulary entry — a type's genres, which an author picks from and which a
352
+ note's `subType` is closed against — was prose that nothing read, free to
353
+ disagree with `note-vocabulary.mjs` in either direction. That is the drift #231
354
+ and #232 were filed about, on the half they did not reach. `gathering` (#333)
355
+ guarded `lore` alone, deliberately scoped to the type it changed.
356
+
357
+ **The five spellings converged on one first.** The document stated a type's
358
+ values as `subType`, `subType:`, `**subType**`, `**subType**:` and
359
+ `**subTypes**:`, and a reader that accepted every one of them would accept the
360
+ sixth by reading that section as declaring nothing — the exact failure the
361
+ comparison exists to catch. One shape is now stated in the specification and
362
+ enforced by the parser: `**subType**:` on its own line, then `- <value>` or
363
+ `- <value>: <definition>`, one bullet per value. A type with no `subType`, or
364
+ one whose values are not enumerated yet, writes no marker.
365
+
366
+ | Written | Read as |
367
+ | -------------------------------------------- | ----------------------------------------------------------- |
368
+ | `**subType**:` + a bullet per value | that type's closed value list, in document order |
369
+ | no marker | the type enumerates none — the ordinary case for nine types |
370
+ | any other spelling, or a marker with no list | a build error naming the line |
371
+
372
+ **What the comparison asks**, of every type rather than of `lore`: the values
373
+ the specification lists equal the values `NOTE_VOCABULARY` declares, in the same
374
+ order. `subTypes` stays three-valued — a list is closed, `null` is a `subType`
375
+ whose values are not enumerated, an absent key is a type with no `subType` at
376
+ all — and each reading is compared to what the document states. The `lore`-only
377
+ assertion is folded in.
378
+
379
+ Nothing that compiles changes: the values were already equal everywhere, in both
380
+ shapes, so this is about keeping them that way. `parseContentFormat` gains a
381
+ `subTypes` array per type, and throws on a marker it does not recognise —
382
+ reachable only through `content-format --spec <a copy of the document>`.
383
+ - 312379e: **Afflictions and traumas can declare their timed phases** (#329).
384
+
385
+ SoHL stores each timed phase as `{…DurationFormula, …DurationBase, …Date}`, and
386
+ the two authored thirds were declared by nothing. They were reachable only
387
+ through the raw `system:` passthrough — undocumented, uncoerced, and absent from
388
+ the field list every author-facing surface is built from — so no note in any
389
+ tree wrote one. Every shipped affliction carried `null`, and
390
+ `AfflictionLogic.rollDuration()` opens `if (!formula) return 0`: the timed-phase
391
+ machinery existed, and the content that would drive it could not be written.
392
+
393
+ They could not simply be declared either. `buildFromFields` wrote every declared
394
+ field unconditionally, so a declaration would have stamped `null` onto every
395
+ document — the same outcome, minus the ability to tell "unset" from "authored as
396
+ empty".
397
+
398
+ **`omitWhenAbsent`** is the missing capability: a field declaring it is emitted
399
+ when the note carries one and has its **key left out entirely** when it does
400
+ not, so the DataModel's own `initial` stands. It completes the table
401
+ `runtimeOnly` (#330) opened:
402
+
403
+ | declaration | authored | absent |
404
+ | ---------------- | ----------- | --------------- |
405
+ | ordinary | emitted | default written |
406
+ | `omitWhenAbsent` | emitted | key omitted |
407
+ | `runtimeOnly` | **refused** | key omitted |
408
+
409
+ The decision is made on the **position** a value came from, never on the value:
410
+ a declared `default: null` and an authored `null` are the same value and
411
+ opposite facts. `readFieldEntry` reports the source beside the value so the
412
+ position is resolved once rather than twice.
413
+
414
+ **Twelve fields are now declared vocabulary** — `onset`, `healingCheck` and
415
+ `resolution` on `affliction`; `healingCheck`, `bloodLossAdvance` and `course` on
416
+ `trauma` — each as both a `…DurationFormula` and a `…DurationBase`, in the
417
+ `sohl:` block and in the closed `data:` container alike. Intervals are in
418
+ seconds, and a bare number is a valid formula.
419
+
420
+ **What a consumer sees**
421
+
422
+ - The twelve appear in the generated item-frontmatter reference, with `_omitted_`
423
+ in the Default column rather than a value. Regenerate the page.
424
+ - The `unemitted` warnings these raised against a pinned schema clear.
425
+ - No compiled document changes: no tree authors one yet, and a note that writes
426
+ nothing emits nothing where it previously emitted nothing.
427
+ - `omitWhenAbsent` may not be combined with `default` (contradictory), with
428
+ `required`, or with `runtimeOnly`; the shipped declarations are checked for all
429
+ three.
430
+
431
+ ### Patch Changes
432
+
433
+ - b5004f0: **A `model:` now resolves an item whose shortcode carries a capital** (#346). The
434
+ catalogue was keyed on the compiled document's `system.shortcode` exactly, while
435
+ an address is lower-cased when it is read — so `model: weapongear-clb` looked for
436
+ `weapongear:clb` while the document sat under `weapongear:Clb`, and every being
437
+ referencing one of the six mixed-case gear shortcodes in `sohl` failed to
438
+ compile.
439
+
440
+ The catalogue key folds the shortcode's case. The **id-bearing** address does
441
+ not: `itemAddress` seeds `embeddedItemId`, so folding there would change the
442
+ `_id` of every embedded item whose identity carries a capital — silently
443
+ re-identifying documents nothing about which had changed. A catalogue is a lookup
444
+ table; an id is a promise.
445
+
446
+ Verified by compiling both swept trees: `sohl` emits actors, items, macros,
447
+ scenes and adventures byte-identical to its pre-sweep baseline, and
448
+ `sohl-thalorna` differs only by the 938 embedded ids its sweep predicts.
449
+ - 4c57a5a: **A being's `data.portrait` reaches the actor** (#332). It never had: the
450
+ emitters read `blockProperty(fm, "portrait")`, which knows a system block and
451
+ the note's top level and never splits a dotted path, so the position the content
452
+ format names was invisible to them — and the `?? defaultImg` beside it turned
453
+ every miss into the subtype's icon rather than into a complaint. 646
454
+ `sohl-thalorna` beings authored a portrait, 341 of them pointing at art that
455
+ exists on disk, and every one compiled the generic person icon. Nothing warned.
456
+
457
+ `portrait` now resolves through the same declaration `data.species` does, in
458
+ both the `sohl` and `hm3` actor passes.
459
+
460
+ **A `data:` source has a retiring top-level spelling, and step 3b reads it.**
461
+ `data:` did not invent the facts it holds — it gathered them out of the note's
462
+ open top level, where `portrait:` sat beside `img:` — so the pre-`data:`
463
+ spelling of `data.<key>` is `<key>`, and until now nothing read it. That is why
464
+ this is a resolution-order fix and not a one-line emitter fix: `data.portrait`
465
+ had to start working _without_ breaking the top-level `portrait:` that `sohl`'s
466
+ own bestiary writes on every note.
467
+
468
+ The spelling is **derived**, not declared — a second declaration would be a
469
+ second place for one fact to live — and only a `data.` source has one, so
470
+ `protection.blunt` and `impact.die` resolve exactly as they did.
471
+
472
+ **Nothing is dropped in silence any more.** A field read from the retiring
473
+ top-level key emits a warning naming the line, the counterpart to the existing
474
+ in-block report; the note compiles to the identical document either way. The
475
+ frontmatter lint's `portrait: ""` check reads the `data:` position too, which it
476
+ could not see before.
477
+ - 59469d7: **The dependency check read English prose in a comment as an import** (#355).
478
+
479
+ `tests/dependencies-are-declared.test.ts` finds a shipped file's imports with a
480
+ regex over the raw file text. It already reasoned about one false positive — the
481
+ lookbehind stops `["from", "to"]` reading as an import of `", "` — but not about
482
+ comments, where `from` is an ordinary word and the quotes are ordinary quotes.
483
+ Any explanatory comment containing the word `from`, `import` or `require`
484
+ followed by a quoted phrase was reported as an undeclared dependency:
485
+
486
+ ```text
487
+ FAIL sohl/item-fields.mjs imports only builtins, itself, or a declared dependency
488
+ + [ "sohl/item-fields.mjs:455 → this note does not set the phase" ]
489
+ ```
490
+
491
+ The message names a real file and a real line and says a dependency is missing,
492
+ so the first reading is that one genuinely is. Nothing in it suggests the culprit
493
+ is a sentence, and the fix — reword the comment — is unrelated to anything the
494
+ message describes. It cost a debugging cycle in #329, and the workaround left the
495
+ trap armed for whoever wrote the next comment.
496
+
497
+ **Comments are now blanked before the regex runs.** They are located by parsing
498
+ the file, not by a second regex, so `//` inside a string literal is still a string
499
+ literal. Each comment's characters are replaced one-for-one with spaces and its
500
+ newlines are left alone, so every offset survives and a finding still points at
501
+ the line a reader opens. The `sohl/item-fields.mjs` comment that provoked this
502
+ reads naturally again, and the suite carries it verbatim.
503
+ - fec6c80: **The generated item-frontmatter examples no longer author an `id:`** (#314).
504
+
505
+ `content-build docs item-fields` emitted `id: <16-character id>` in the worked
506
+ example for every item type — thirteen of them in the `sohl` tree. Since #270
507
+ and #277 a note's document `_id` derives from its canonical address, and the
508
+ authored field is the escape hatch for keeping a document's identity across a
509
+ shortcode rename, not part of the envelope every note carries.
510
+
511
+ The example is the block an author copies as a template, and the page is the
512
+ per-type reference they read while writing the note, so the one place the field
513
+ survived a tree's sweep was the document teaching them to write it. It is now
514
+ omitted, as every other optional envelope field already was; the `type` and
515
+ `shortcode` the derivation reads are unchanged.
516
+
517
+ Consumers should regenerate their item frontmatter reference to drop the line.
518
+ - a48802f: **A folder note's published `id` is now the id its Foundry documents carry**
519
+ (#310).
520
+
521
+ The content index derived every note's id under the `document` namespace. A
522
+ `Folder` is hashed under the `folder` namespace, so the index published one
523
+ value and the packs addressed another — `sohl-none-folder-cookware` was
524
+ `f5d3dc635b7e799c` in the index and `b92b28b7d06638ed` in every pack.
525
+
526
+ `noteDocId` now asks the folder pass for a folder's id instead of deriving a
527
+ second one, so the two cannot disagree.
528
+
529
+ **Why nothing caught it.** Every one of `sohl`'s 79 folder notes pins an `id`,
530
+ and a pin wins in both paths — so all 65 emitted folder documents agreed by
531
+ coincidence. It is also invisible from inside a build: no pass reads a folder's
532
+ id off the index. The published artifact was the only place the wrong value
533
+ surfaced, and a reader outside the build could neither recompute the right one
534
+ nor notice the wrong one.
535
+
536
+ **The general rule this settles:** for every entry the content index gives an
537
+ identity to, it publishes both the `id` and the `uuid`, each computed once by
538
+ whatever owns that entry's derivation. A documentation journal's record
539
+ accordingly gains its own `id` — it carried the UUID that id ends in, but not
540
+ the id — so a consumer reads it rather than parsing it back out of the UUID's
541
+ last segment.
542
+
543
+ _No emitted document changes; this corrects what the index says about them._
544
+ - ebd3a80: **A note-level frontmatter check no longer answers from a system field that
545
+ merely shares the name** (#312).
546
+
547
+ `topLevelMeans` exists because one spelling can name two unrelated quantities. An
548
+ `affiliation` item's `system.title` is the style of address an office carries —
549
+ "Ajaw", "Warden"; a note's top-level `title` is the heading its page publishes
550
+ under. The field declares the collision, and `resolveFieldValue` honours it by
551
+ refusing to read the top level for that field.
552
+
553
+ The frontmatter lint did not. `authoredValue` resolved every check through the
554
+ `sohl:` block first, so the blank-heading check — whose emitter is `fm.title ??
555
+ name`, the note's top level and nothing else — read `sohl.title` and found the
556
+ office's style of address. An office with no style of address is ordinary, and
557
+ each one was reported as a page published with no heading, sorting to the front
558
+ of its section.
559
+
560
+ **The statement is symmetric, and is now read that way.** If two positions hold
561
+ unrelated quantities then the in-block position is not the note-level field
562
+ either, so a note-level check reads past a block key the note's own type claims
563
+ for something else. The exemption is still the field's own declaration rather
564
+ than a name the linter knows: `collidingBlockKeys` asks the schemas the caller
565
+ supplies, so the linter and the resolver cannot disagree about which field
566
+ declares one.
567
+
568
+ **The art fields are checked the same way.** `img` and `portrait` keep resolving
569
+ through the block, because that is what their emitter does — `blockProperty`
570
+ reads `sohl.img` first, so a `sohl.img: ""` really does ship a document with no
571
+ art and is still reported. What changes is that a future system field of either
572
+ name cannot quietly answer for the note's own art; a map's was `sohl.image`
573
+ until #142.
574
+
575
+ **What a consumer sees.** Twenty-eight fewer warnings on an unswept
576
+ `sohl-kethira-basic` — every affiliation writing `sohl.title: ""`. All were false
577
+ positives; their pages took `name.full` throughout. A tree already swept onto
578
+ `sohl.system.title` was unaffected either way, which is why the findings
579
+ disappearing looked like a lint regression in that sweep rather than the
580
+ false positives going away.
581
+ - f6a8a05: **The specification and four engine docblocks stated the retired folder model**
582
+ (#358).
583
+
584
+ The folder epic replaced a model wholesale — `folder:` named a Foundry id
585
+ resolved against a per-pack `*-folders.yaml`, and `packFolder` named a path.
586
+ None of that exists: `folder:` is refused, the YAML is gone, and `packFolder`
587
+ is a folder note's address. Six passages still described the old shape as the
588
+ live one.
589
+
590
+ **`docs/content-format.md` contradicted itself twice.** The shared-mappings
591
+ table — the one place eight rows common to all sixteen type tables are stated —
592
+ offered `` `packFolder` / `folder` ``, so a reader was told to write a value the
593
+ build rejects, 330 lines before the same document says it is retired. And the
594
+ argument for deriving a document id cited "`packFolder: <path>` above", where
595
+ above says address.
596
+
597
+ **Four docblocks described the retired resolution path**, and they publish:
598
+
599
+ | site | said |
600
+ | -------------------------- | ----------------------------------------------------------------------------------- |
601
+ | `engine/generate.mjs` | folder files "referenced from entry frontmatter via `sohl.folder: <id>`" |
602
+ | `engine/journals.mjs` | the target folder's id "from folders.yaml", resolved against a folders.yaml list |
603
+ | `engine/base-compiler.mjs` | `folderResolver` "resolves a `sohl.folder` id against this pack's folder hierarchy" |
604
+ | `engine/frontmatter.mjs` | `folderField` reads "two spellings", `packFolder` winning "where both are present" |
605
+
606
+ The last two were the sharpest. `folderField` reads `packFolder` and nothing
607
+ else, so its docstring described a resolution the function cannot perform and
608
+ deferred to an issue that had closed. `generate.mjs` disagreed with itself
609
+ across one file: the module header named `sohl.folder`, while its `resolver`
610
+ states the rule correctly — "There is one spelling."
611
+
612
+ **Prose is the defect the epic was about.** Its argument against
613
+ `*-folders.yaml` was that a second, unchecked statement of one fact drifts from
614
+ the first, and nothing compares the two. These six passages were exactly that,
615
+ and nothing caught them: `lint:content-format` makes a claim only for a
616
+ `system.*` target, so a row mapping to core Foundry's `folder` yields none, and
617
+ the source side of a shared row is checked by nothing at all.
618
+
619
+ Two assertions now hold the specification to it — the shared-mappings sources
620
+ name no retired field, and the document never presents `packFolder` as holding
621
+ a path. The docblocks are held to review instead: a sentence describing
622
+ `folder:` as _retired_ is correct and must survive, and no assertion separates
623
+ that from one describing it as live without reading the prose.
624
+
625
+ No behaviour changes; the fix is what the documents say.
626
+
3
627
  ## 19.0.0
4
628
 
5
629
  ### Major Changes