@heroiclands/package-build 8.1.0 → 10.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 (122) hide show
  1. package/CHANGELOG.md +748 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +479 -123
  4. package/bin/package-build.mjs +27 -69
  5. package/bin/report.mjs +1 -2
  6. package/bundle.mjs +2 -10
  7. package/config.mjs +31 -106
  8. package/container.mjs +13 -57
  9. package/content-config.mjs +300 -188
  10. package/coverage.mjs +14 -55
  11. package/deploy.mjs +4 -13
  12. package/docs/content-format.md +1418 -0
  13. package/e2e.mjs +16 -55
  14. package/engine/address-charset.mjs +62 -0
  15. package/engine/address-diff.mjs +1 -4
  16. package/engine/alias-index.mjs +153 -0
  17. package/engine/base-compiler.mjs +203 -31
  18. package/engine/code-fences.mjs +4 -13
  19. package/engine/compendiums.mjs +13 -37
  20. package/engine/content-address.mjs +6 -10
  21. package/engine/content-format-check.mjs +570 -0
  22. package/engine/content-format.mjs +253 -0
  23. package/engine/content-links.mjs +144 -99
  24. package/engine/content-lint.mjs +12 -16
  25. package/engine/content-slug.mjs +2 -6
  26. package/engine/content-tables.mjs +26 -79
  27. package/engine/diagnostics.mjs +37 -15
  28. package/engine/document-subtypes.mjs +440 -0
  29. package/engine/field-reference.mjs +6 -20
  30. package/engine/field-spec.mjs +49 -45
  31. package/engine/foreign-catalog.mjs +7 -22
  32. package/engine/foreign-manifests.mjs +1 -4
  33. package/engine/frontmatter-lint.mjs +347 -43
  34. package/engine/frontmatter.mjs +3 -8
  35. package/engine/generate.mjs +36 -20
  36. package/engine/helpers.mjs +54 -81
  37. package/engine/homepage.mjs +4 -15
  38. package/engine/ids.mjs +21 -12
  39. package/engine/index.mjs +15 -0
  40. package/engine/item-registry.mjs +72 -9
  41. package/engine/journals.mjs +4 -14
  42. package/engine/kb-manifest.mjs +41 -24
  43. package/engine/macros.mjs +2 -10
  44. package/engine/manifest-emit.mjs +6 -17
  45. package/engine/map-notes.mjs +53 -87
  46. package/engine/note-claims.mjs +383 -0
  47. package/engine/note-package.mjs +1 -4
  48. package/engine/note-vocabulary.mjs +678 -0
  49. package/engine/pack-config.mjs +56 -60
  50. package/engine/pack-router.mjs +18 -8
  51. package/engine/prose-config.mjs +20 -4
  52. package/engine/prose-lint.mjs +61 -17
  53. package/engine/region-events.mjs +1 -3
  54. package/engine/retired-fields.mjs +117 -3
  55. package/engine/scene-levels.mjs +8 -22
  56. package/engine/scenes.mjs +31 -47
  57. package/engine/schema-check.mjs +348 -7
  58. package/engine/schema-extract.mjs +11 -39
  59. package/engine/site-build.mjs +13 -38
  60. package/engine/site-index.mjs +40 -35
  61. package/engine/system-block.mjs +513 -0
  62. package/engine/web-wikilinks.mjs +115 -92
  63. package/engine/wikilink-syntax.mjs +30 -0
  64. package/engine/wikilinks.mjs +147 -183
  65. package/index.mjs +1 -5
  66. package/lang.mjs +1 -3
  67. package/manifest.mjs +10 -37
  68. package/markdownlint-config.mjs +1 -5
  69. package/package.json +6 -2
  70. package/sohl/actors.mjs +251 -68
  71. package/sohl/being-info.mjs +3 -6
  72. package/sohl/document-subtypes.mjs +82 -0
  73. package/sohl/index.mjs +4 -6
  74. package/sohl/item-builders.mjs +1 -3
  75. package/sohl/item-fields.mjs +16 -34
  76. package/sohl/items.mjs +111 -17
  77. package/sohl/kb-passes.mjs +29 -39
  78. package/sohl/note-schemas.mjs +11 -7
  79. package/sohl/skill-base.mjs +7 -23
  80. package/stage.mjs +3 -13
  81. package/templates.mjs +4 -15
  82. package/types/bundle.d.mts +1 -1
  83. package/types/container.d.mts +2 -2
  84. package/types/content-config.d.mts +48 -4
  85. package/types/coverage.d.mts +1 -1
  86. package/types/e2e.d.mts +4 -4
  87. package/types/engine/address-charset.d.mts +45 -0
  88. package/types/engine/alias-index.d.mts +122 -0
  89. package/types/engine/base-compiler.d.mts +132 -4
  90. package/types/engine/content-address.d.mts +2 -2
  91. package/types/engine/content-format-check.d.mts +163 -0
  92. package/types/engine/content-format.d.mts +101 -0
  93. package/types/engine/content-links.d.mts +16 -1
  94. package/types/engine/content-lint.d.mts +6 -0
  95. package/types/engine/diagnostics.d.mts +29 -0
  96. package/types/engine/document-subtypes.d.mts +233 -0
  97. package/types/engine/field-spec.d.mts +76 -23
  98. package/types/engine/frontmatter-lint.d.mts +47 -2
  99. package/types/engine/generate.d.mts +15 -2
  100. package/types/engine/helpers.d.mts +22 -14
  101. package/types/engine/ids.d.mts +10 -0
  102. package/types/engine/index.d.mts +5 -0
  103. package/types/engine/item-registry.d.mts +21 -2
  104. package/types/engine/kb-manifest.d.mts +35 -8
  105. package/types/engine/map-notes.d.mts +21 -11
  106. package/types/engine/note-claims.d.mts +113 -0
  107. package/types/engine/note-vocabulary.d.mts +251 -0
  108. package/types/engine/pack-config.d.mts +4 -3
  109. package/types/engine/pack-router.d.mts +4 -4
  110. package/types/engine/prose-lint.d.mts +6 -2
  111. package/types/engine/retired-fields.d.mts +73 -2
  112. package/types/engine/schema-check.d.mts +182 -0
  113. package/types/engine/schema-extract.d.mts +1 -1
  114. package/types/engine/site-index.d.mts +1 -1
  115. package/types/engine/system-block.d.mts +281 -0
  116. package/types/engine/web-wikilinks.d.mts +23 -12
  117. package/types/engine/wikilink-syntax.d.mts +29 -0
  118. package/types/manifest.d.mts +1 -1
  119. package/types/sohl/actors.d.mts +62 -6
  120. package/types/sohl/document-subtypes.d.mts +14 -0
  121. package/types/sohl/index.d.mts +1 -0
  122. package/types/sohl/items.d.mts +21 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,753 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 10.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 7f5c14b: **The pipe decides how a wikilink resolves** (#131). `[[x]]` is an **alias**;
8
+ `[[x|…]]` is an **address**. Neither falls back to the other, so a target is
9
+ read by the punctuation the author wrote rather than by whether its shape
10
+ happens to look like an address.
11
+
12
+ Both resolvers previously tried the address grammar and fell through to the
13
+ alias index — or the reverse — so one authored link had two chances to land and
14
+ the author could not say which they meant. A note whose _name_ looked like an
15
+ address (`Grukar-ahk`) was read as one, and a genuine address that resolved
16
+ nowhere silently became a name lookup and reported nothing.
17
+
18
+ **What changes for a content tree**
19
+
20
+ | Written | Was | Now |
21
+ | -------------------------- | -------------------------------- | ---------------------------------------- |
22
+ | `[[type-shortcode\|Text]]` | address | address — _unchanged_ |
23
+ | `[[type-shortcode\|]]` | address, shows the target's name | _unchanged_ |
24
+ | `[[Some Name]]` | alias | alias — _unchanged_ |
25
+ | `[[type-shortcode]]` | address | **alias lookup**, so it must gain a `\|` |
26
+ | `[[Some Name\|Text]]` | alias | **address**, so the name must become one |
27
+
28
+ An empty label stays writable and now carries its full weight: `[[x|]]` is the
29
+ one way to write an address that renders the target's _current_ name, so a
30
+ rename shows at every citation with no link edited.
31
+
32
+ **The alias index no longer carries the filename.** Its sources are the
33
+ authored ones — `aliases`, `name.aliases`, `name.full`. `basename(file, ".md")`
34
+ with underscores turned to spaces admitted keys nobody could cite: thirteen
35
+ `_Introduction.md` notes all claimed `" introduction"`, leading space included.
36
+ Measured across five content trees, not one link that resolves today resolves
37
+ through the filename alone.
38
+
39
+ **A same-type alias collision is now a finding, naming every claimant.** It was
40
+ silently deleted, so the pair resolved to nothing and nobody was told. The
41
+ finding is reported at each claiming note, never at the note that merely cites
42
+ the alias — whoever added the second claimant broke every existing citation.
43
+
44
+ **The two failure modes read differently.** A piped target that resolves
45
+ nowhere, or does not parse as an address at all, is an **error**: the pipe says
46
+ the author meant an address. An unpiped target naming no note of the source's
47
+ type is a **warning**, since a bare `[[Name]]` may be a worldbuilding
48
+ placeholder for a note not yet written.
49
+
50
+ Resolution is stated once for both builds: `resolvesAsAddress` in
51
+ `engine/wikilink-syntax.mjs`, and the new `engine/alias-index.mjs` for what may
52
+ be claimed and how a claim is keyed.
53
+
54
+ ### Minor Changes
55
+
56
+ - 3e6f3a4: Give an affiliation the ranks and offices it confers, settle the
57
+ `government`/`governance` split on one root, and stop the mapping table claiming
58
+ system fields for things that reach none (#160).
59
+
60
+ **An affiliation now publishes its own social structure.** `governance.ranks` is
61
+ the ladder the body confers — `level`, `title`, `description` per rung — and
62
+ `governance.offices` is a map of named post to what that post does. Level 0 is
63
+ reserved for the excluded: outlawed, expelled, excommunicated is a standing an
64
+ organisation still recognises, so it still has to define it.
65
+
66
+ The reason to put them here rather than on a membership is that a bare `level: 4`
67
+ says nothing on its own. It means _Knight_ only because the polity declared that
68
+ rung, so the ladder is authored once, on the body that confers it, and a member's
69
+ rank is an index into it. Offices are not ranked at all — a Chancellor and a
70
+ Marshal are both great officers and neither is above the other — so an office is a
71
+ key with a description rather than a rung.
72
+
73
+ **The membership fields leave `data:`.** `society`, `office`, `title` and `level`
74
+ described a being's standing in a body, not the body, and all 199 authored
75
+ affiliation notes left them null. They are filled on the affiliation _as embedded
76
+ on a being_, which is where they always belonged.
77
+
78
+ **One root: `governance`.** The specification mixed `governance.model` with
79
+ `government.summary` and `engine/note-vocabulary.mjs` declared exactly that pair,
80
+ with a comment saying the two had to be reconciled somewhere else. They are
81
+ reconciled here, on `governance` — the root that names the concept rather than
82
+ the institution, and the one the new `ranks` and `offices` read naturally under.
83
+
84
+ **`Republic` joins `GovernanceModel`, and the three civic models are given a
85
+ test.** A Roman-shaped republic was none of the existing values exactly: not an
86
+ `Oligarchy`, whose closed group holds authority with no election and no term; not
87
+ a `Democracy`, where any member may hold office. Ask who fills the offices and on
88
+ what terms and the three separate cleanly, which the enum now says. Naming it
89
+ `Republic` rather than `Senatorial Oligarchy` keeps the enum a partition of
90
+ one-word answers to _where does authority rest_ — "senatorial" is a culture's word
91
+ for its ruling order, and culture words belong in a body's rank titles, not in the
92
+ shared vocabulary every culture is described with.
93
+
94
+ **Two renames and a new field.** `domain` becomes `domains`, which is what a list
95
+ of places wants to be called; `languages` becomes `commonSkills`, since what
96
+ members share is not only speech; and `economy` is new — the currencies, banking
97
+ bodies and goods an affiliation's economic life runs on, as wikilinks rather than
98
+ prose.
99
+
100
+ **The mapping table stops overclaiming.** `governance` reaches no `system.*`
101
+ field: SoHL's affiliation item has nowhere to put ranks or offices, and inventing
102
+ a mapping for a field no schema declares is the drift these tables exist to
103
+ catch. The four membership rows go with the fields, leaving 84 mapping claims and
104
+ 66 checked against SoHL's published schema.
105
+
106
+ **`mystery` accepts `birthsign`**, which the specification declares and the
107
+ vocabulary did not, and the four gear headings are spelled as the vocabulary
108
+ enforces them — `armorgear`, `concoctiongear`, `projectilegear`, `weapongear` —
109
+ rather than as #78 will rename them.
110
+ - 1c4517f: Let an affiliation cite any lore, and declare its epithet and its symbol (#166).
111
+
112
+ **`peoples` becomes `lore`**, as it did on `place` in #164 and for the same
113
+ reason: peoples are one kind of lore among many, and the target's own subType
114
+ already distinguishes a `folk` from a `law` or a `calendar`.
115
+
116
+ **The case that made it urgent is the deity.** A `faithtradition` is a religion —
117
+ a practice, which can outlive belief in its god, and one god may be venerated by
118
+ several religions that agree on nothing else. The god is `lore` of subType
119
+ `deity`. With only `peoples` available a faith had nowhere to name the god it
120
+ venerates, and the authored corpus worked around it with an undeclared top-level
121
+ `deity` string on 75 notes: a name nothing could follow.
122
+
123
+ **`epithet` and `symbol` are declared.** Seventy-seven affiliations carried them
124
+ at the top level, where nothing checked them and nothing compiled them. Neither
125
+ is a faith's alone — they are what the members call the thing and what they carve,
126
+ so a guild has them as much as a cult does. What a god _is_ belongs on the deity
127
+ note, where every religion that venerates it can point at one account.
128
+
129
+ **A rank names the standing it is.** `Rank` gains `lore`, a link to a `lore` note
130
+ of subType `law`, and the note is **shared**: a Normen kingdom calls it `Thrall`
131
+ and a Vylarian province calls it `Slave`, and they mean one thing — owned
132
+ outright, with no standing at law except through an owner. The title is what this
133
+ body calls it and the description is how this body puts it, but the obligations
134
+ and rights belong to the standing, so they are written once and cited by every
135
+ ladder that confers them. In the authored corpus 237 distinct titles across 2,602
136
+ rank entries resolve onto 43 standings, which is also what makes a rank
137
+ answerable across bodies: asking what a `Naukrátissa` may do no longer means
138
+ reading the Bethûan fleet's ladder.
139
+
140
+ **A being cites lore too.** Its `peoples` becomes `lore` for the same reason
141
+ `place` and `affiliation` did: the people a character is of, the standing they
142
+ hold and the law they live under are all lore, and the target's own subType tells
143
+ a `culture` from a `law`. A being that names its culture and its rank in one list
144
+ is saying two things of one kind, which is what the field is for.
145
+ - 65a4ba1: **Affiliation subTypes: `spirittradition` added, `pantheon` removed.**
146
+
147
+ A totemic or ancestor cult had nowhere to go. `faithtradition` is defined as
148
+ concerning _the divine_, and `sohl-thalorna` carries 47 affiliations that are
149
+ not — 44 animal totems plus `Nyaluba_Spirits`, `The_Kindred` and `Astrokyklos`.
150
+ Folding them into `faithtradition` would also have collapsed the partition
151
+ `MYSTICALABILITY_SUBTYPE` distinguishes the spirit families by, and a picker
152
+ filter is only as useful as the partition it filters on. `spirittradition` is
153
+ worded symmetrically with its two siblings.
154
+
155
+ `pantheon` is gone because it answered a different question from every other
156
+ value. The rest state _what kind of body this is_; `pantheon` stated _where it
157
+ sits in a hierarchy_. A pantheon is a `faithtradition` carrying subordinate
158
+ faith traditions, and that hierarchy is already authored — 77 divine
159
+ affiliations carry a `pantheon:` key holding an affiliation shortcode which
160
+ resolves on all 77, while `parents` is set on none of them.
161
+
162
+ **No content changes.** None of the eleven values is authored anywhere yet, so
163
+ removing one and adding another costs no note an edit.
164
+
165
+ Closes #157.
166
+ - 20e51a7: Write a document's archetype to `system.archetype` instead of `flags.sohl.docArchetype` (part of #126, part of #127).
167
+
168
+ **Requires an unreleased SoHL.** This must not ship before HeroicLands/Song-of-Heroic-Lands-FoundryVTT#1780 declares `system.archetype` on SoHL's shared data schema and that release is out. Foundry discards an undeclared `system` key at construction without a word, so a package built with this against an older system carries an archetype nothing can read. The order is: the field, then this, then rebuild.
169
+
170
+ **What changed.** `sohl/items.mjs` and `sohl/actors.mjs` stop calling `withArchetypeFlag` and write the value into `system` — a number for an archetype at that priority, `null` for a document that is not one. `flags` becomes a plain passthrough of what the note authors, defaulting to `{}` exactly as before. `withArchetypeFlag` is deleted; `resolveArchetype` stays, and so does its required-ness, so an absent `archetype` is still an authoring error rather than a silent "not an archetype".
171
+
172
+ `engine/helpers.mjs` gains `systemArchetype`, which is where `resolveArchetype`'s `undefined` becomes the field's `null`. That conversion has to be somewhere: an emitted `undefined` is dropped by `JSON.stringify`, which would leave the compiled document with no `archetype` at all and a tri-state readable as two.
173
+
174
+ **The falsy trap, held by tests.** `0` means "is an archetype, at priority 0" — the priority SoHL's own archetypes ship at — while `null` means "is not one". `resolveArchetype(fm) || null` passes every other case and turns 1,470 SoHL documents from archetypes into non-archetypes, so the suite asserts `0` through the builder and through a `JSON` round trip.
175
+
176
+ **Compiled output moves, and this is the one change in this stack where it should.** Characterised document by document across three trees, every difference is a `flags.sohl.docArchetype` disappearing and a `system.archetype` appearing with the identical value, plus the now-empty `flags: {}` that the removed flag leaves behind. No `_id` and no `_key` moves anywhere.
177
+
178
+ Counts, with the last four columns the value each **top-level** document carries (embedded items carry it too, as they carried the flag):
179
+
180
+ | tree | compiled | changed | `0` | `1` | `100` | `null` |
181
+ | -------------------- | -------- | ------- | ----- | --- | ----- | ------ |
182
+ | `sohl` | 3,126 | 1,474 | 1,470 | 1 | — | 3 |
183
+ | `sohl-thalorna` | 2,561 | 1,273 | 157 | — | — | 1,116 |
184
+ | `sohl-kethira-basic` | 385 | 363 | 343 | 10 | 10 | — |
185
+
186
+ **One diagnostic moves.** Seven `sohl-thalorna` affiliation notes that already fail to compile now report the missing `archetype` rather than a folder id or a missing `subType`, because the requirement is checked earlier in `buildEntry` than the flag it replaces. Same files, same count, same severity; every other finding in all three trees is unchanged, message for message.
187
+
188
+ **Neither schema check has anything to say about it**, before or after the field is declared. `compareFields` derives what a builder emits from its `itemBuilders` field declarations, and `archetype` is written by the compiler itself — as `shortcode`, `actionDefs`, `notes` and `docHtml` already are — while the note-side check reads only what a note authors under `<system>.system`, and `archetype` is authored at the block's top level. So the ordering constraint above binds at Foundry's silent discard, not at a check that would catch it; the suite records that, so nobody reads the quiet as coverage.
189
+ - 6f59add: Declare `lore` subType `bestiary` and `doc` subType `collection` (#162).
190
+
191
+ **A creature that is not a people had nowhere to go.** `folk` is _related sapient
192
+ beings — kindreds, ancestries_, and `spirit` is the non-divine numinous; a beast
193
+ is neither. `bestiary` is what `folk` is for the sapient, applied to everything
194
+ else — including the made things that were never born.
195
+
196
+ **A note that indexes other notes had nowhere either.** A roster, a table of
197
+ settlements, an index of languages is not `rules`, not `user-guide`, and not
198
+ `reference` — and the distinction is where the content comes from. A reference
199
+ **states** facts of its own; a collection **derives** them from the tree, almost
200
+ always through a query. The author's test is whether the page would still say
201
+ anything if every other note vanished. `reference`'s own description drops
202
+ "indexes" accordingly.
203
+
204
+ **The value earns its keep by making a silent failure nameable.** A collection
205
+ whose query matches nothing renders a header and no rows, which looks exactly
206
+ like a full table until it is read — and `sohl-thalorna` has thirteen in that
207
+ state right now, having filtered on a frontmatter key its content format no
208
+ longer has. Nothing failed and nothing reported it. A checker can only say so
209
+ about a note class it can name.
210
+ - 103c307: Check the `system` keys a compiler writes itself against the receiving DataModel (closes #155, part of #127).
211
+
212
+ **What a consumer sees.** A compile can now fail with a line naming one of its
213
+ notes:
214
+
215
+ ```
216
+ assets/content/Skills/Social/Charm.md: error: the compiler writes `system.archetype` into every Item of subtype "skill", and no field declaration names it — Item subtype "skill" does not define it at 0.8.2, and Foundry discards an unknown `system` key when the document is constructed, without a warning, so the value is lost at load while the build reports success. No `itemBuilders` change fixes this: declare the field in the receiving system, or hold this package at a build that does not write it
217
+ ```
218
+
219
+ It means the build is running **ahead of the system it compiles for**. Nothing
220
+ in the repository's own configuration writes the key, so nothing there can stop
221
+ it; the two real fixes are the ones the message names. For `archetype`
222
+ specifically that is HeroicLands/Song-of-Heroic-Lands-FoundryVTT#1785 — SoHL
223
+ checks against its **own** committed `schema.json`, so merging it is enough and
224
+ no release is needed; a module checks against the cached schema of the SoHL
225
+ release it pins, so it needs that release.
226
+
227
+ **The gap.** The emitted-versus-declared check (#60) derives what a build emits
228
+ from the `itemBuilders` **field declarations**, so a key a compiler writes on its
229
+ own initiative is in neither set it compares and was never compared at all.
230
+ That is not a residue: it is `shortcode`, `actionDefs`, `notes`, `docHtml`, and
231
+ since #126 `archetype`. #145's authored-`system` check does not reach them
232
+ either — it reads `<system>.system`, and these are written rather than authored.
233
+ So #126's ordering constraint, stated in the issue and real, was enforced by
234
+ nothing: get the order wrong and every compiled document silently loses
235
+ `system.archetype`, no check fires, the build is green, and the Create dialog
236
+ simply stops finding archetypes.
237
+
238
+ **Derived by observation, not by a list.** The compilers assemble a `system`
239
+ object, so `compareEmittedSystem` reads the keys off what they produced — after
240
+ the JSON round trip the pack file actually receives, which is why a key whose
241
+ value is `undefined` is correctly not a finding. A compiler that grows a key is
242
+ covered on the next build without anyone remembering to add it anywhere, and a
243
+ `system` block that is checked is the one that was written.
244
+
245
+ **Two conditions, because the fixes differ.** Both name the version, as the
246
+ existing message already does. A key a `fields:` entry declares is the
247
+ consumer's own — change the field's `to`, or get the system to declare it. A key
248
+ the compiler writes has no declaration to correct, and the message says so
249
+ rather than sending a reader looking through `itemBuilders` for something that
250
+ is not there.
251
+
252
+ **An error, deliberately.** The failure is Foundry's silent discard either way,
253
+ and the sibling condition has been an error since #60; making the _less_ fixable
254
+ half the quieter one would invert the point. Measured read-only against five
255
+ consuming trees, the whole cost is 11 findings in
256
+ `Song-of-Heroic-Lands-FoundryVTT` — one `archetype` per subtype, exactly what
257
+ #1785 declares. The satellites are unaffected today: sohl 0.8.2 published no
258
+ `schema.json`, so there is nothing for them to check against and the check stays
259
+ silent, and by the time they pin a release that publishes one the field is in
260
+ it.
261
+
262
+ **A subtree the schema describes no further is not checked.** SoHL's
263
+ `strikeMode` is a discriminated `TypedSchemaField`: published as one path,
264
+ stored flat as `{ type, name, … }`. Walking into it against a schema that
265
+ enumerates nothing beneath it reported all ten of a combat technique's stored
266
+ keys — ten findings, every one wrong, about a document that is correct. What the
267
+ artifact does not describe is left alone, the same stance the check already takes
268
+ on a subtype the artifact does not name.
269
+
270
+ **Compiled output does not move.** This adds a check, not an emission: the SoHL
271
+ tree compiles to the same 3,126 `build/packs-json` files, byte for byte.
272
+ - 808ef55: Add the closed `data:` container, and close `subType` (#128, part of #127).
273
+
274
+ **What it is.** A note's frontmatter has three regions and only one of them is
275
+ open. The top level describes the note as a published artefact and every key of
276
+ it is copied into the generated web page, so an unrecognised key there is a Hugo
277
+ or theme parameter this build has no standing to refuse. `data:` holds the
278
+ type-specific facts about the _subject_ — a weapon's weight, an affliction's
279
+ transmission, a being's species — and every note type declares which keys it may
280
+ carry.
281
+
282
+ **Why it earns its place.** Those facts previously sat at the top level, where
283
+ the pass-through rule applied to them too, so a misspelled `wieght` became a
284
+ theme parameter rather than a finding — indistinguishable, from the outside,
285
+ from a weapon that weighs nothing. Under `data:` the same key is reported where
286
+ it was written, with the key it was probably meant to be, drawn from that type's
287
+ own vocabulary and using the capped edit distance the `sohl:` check already
288
+ applies:
289
+
290
+ ```text
291
+ assets/content/Gear/Axe.md:14:5: error: "wieght" is not a `data:` property declared by weapongear; the container is closed, so unlike a top-level key it is not passed through to the page. Did you mean "weight"?
292
+ ```
293
+
294
+ **`subType` stays at the top level**, and is closed in its own way: a type either
295
+ declares a `subType` or does not, and a type that does declares its values. A
296
+ `weapon` declares none — SoHL distinguishes a weapon's uses by strike mode rather
297
+ than by kind — so `subType` on one is a finding; a `skill` declares ten, so
298
+ `subType: crafte` is a finding naming `craft`.
299
+
300
+ **Additive.** Nothing reads `data:` into a document's `system` block yet — that
301
+ is the passthrough slice — and no note in any tree authors one today, so no
302
+ compiled output changes. `engine/note-vocabulary.mjs` carries the declaration,
303
+ one entry per note type, taken from the content-format specification;
304
+ `lintNote` and `lintFrontmatter` take it as a `vocabulary` option, so the linter
305
+ stays a checker of whatever it is handed rather than gaining type names of its
306
+ own, and a caller that supplies none is checked exactly as before.
307
+ - 3542a73: Commit the content format specification as `docs/content-format.md`, and add
308
+ `content-build content-format` to check it.
309
+
310
+ The specification — how a note becomes a Foundry document and a web page: three
311
+ frontmatter regions, a note vocabulary with its own `type` and `subType`, the
312
+ declared map onto each system's document fields, the precedence between a shared
313
+ source and a system's override, and the wikilink address grammar — lived in a
314
+ gitignored draft. Nothing could link to it, it had no history, and it was
315
+ invisible to everyone but its author while four other issues were being
316
+ implemented against it. It is this package's contract: `content-build` is what
317
+ reads notes and writes documents, so the format belongs beside the code that
318
+ honours it.
319
+
320
+ **The document is checkable, and two throwaway scripts written while drafting it
321
+ are now real commands.**
322
+
323
+ `content-build content-format schema --schema <system>=<path>` resolves every
324
+ `system.*` target the document names against the naming system's published
325
+ `schema.json`, in the `version: 1` shape `package-build schema` emits. This is
326
+ the emitted-versus-declared idea pointed at prose rather than at code: the format
327
+ does not define the `sohl:` or `hm3:` schemas — each system does, and its
328
+ artifact is the authority — so a mapping row is a claim, and a claim naming a
329
+ field no schema declares means the two disagree. There are 88 such claims today.
330
+
331
+ `content-build content-format notes` measures a content tree against the
332
+ per-type `data` tables, counting findings in four classes: a note type the
333
+ document declares no section for, an unknown key in the closed `data:` region, a
334
+ declared shared source written at top level instead, and one written straight
335
+ into a system block.
336
+
337
+ **Both read the document's own tables.** A transcribed list of targets and
338
+ vocabularies would be a second copy of the specification, free to drift from the
339
+ first the moment either was edited — the exact failure these checks exist to
340
+ prevent, moved one level up. No type name, field name or system name is written
341
+ in the code; editing the specification changes what the checks assert.
342
+
343
+ **A target resolves against the union of a system's subtypes.** The mapping
344
+ tables say which field a shared source reaches; _which document subtype receives
345
+ it_ is the note-type → subtype map, which is not built yet. Resolving per subtype
346
+ before that map exists would mean inferring it from the prose around each table,
347
+ which is the transcription the whole design avoids. It narrows when that map
348
+ lands.
349
+
350
+ **A system with no schema supplied is counted unchecked, not passed.** HM3
351
+ publishes no artifact today, so that is the ordinary case for 18 of the 88
352
+ claims, and a check skipping them in silence would read exactly like one that
353
+ passed.
354
+
355
+ **The corpus meter is a report, and `--strict` is the opt-in.** All ~6,210
356
+ authored notes predate the format, so a failing check would be red in every
357
+ repository on the day it landed and would stay red for the length of the
358
+ migration — a check nobody can act on and everybody learns to skip. The counts
359
+ are the migration's progress bar instead, and each class is promoted to fatal, by
360
+ turning the flag on, as it reaches zero.
361
+
362
+ Part of HeroicLands/package-build#127; closes HeroicLands/package-build#130.
363
+
364
+ **Bump**
365
+
366
+ _Minor._ A new command group, a document added to the published files, and no
367
+ change to any existing behaviour.
368
+ - 1120e3f: Close the gaps in the content format specification that the compiler can answer,
369
+ and add `content-build content-format fields` so the hand-written per-type tables
370
+ cannot drift from the declarations that compile them.
371
+
372
+ **A `### type: macro` section, written from what the compiler does.** `macro` was
373
+ named in the note vocabulary with no section of its own, so
374
+ `content-build content-format notes` reported every authored macro note as an
375
+ unknown type — a false finding caused by the document being incomplete rather
376
+ than by the note being wrong. The section states the `{#script}` anchor and its
377
+ three fence rules, why the executable copy is read from the raw markdown, why
378
+ `macroType: chat` is an error, and the two fields (`macroType`, `macroScope`) the
379
+ compiler reads from `sohl:` today and that belong in `data:` for the same reason
380
+ the map fields do. Measured against SoHL's tree, the `unknown-type` count falls
381
+ from 435 to 434 and no new finding appears.
382
+
383
+ **`government.model`, not `governance.model`.** The `affiliation` table carried
384
+ two roots for one concept — `governance.model` beside `government.summary`.
385
+ `government` is the established spelling: `engine/web-wikilinks.mjs` documents
386
+ `government.summary` as its example key path, `tests/web-wikilinks.test.ts`
387
+ fixtures it, and 79 authored notes in `sohl-thalorna` write `government:` while
388
+ none writes `governance:`. Only the odd root moved; `GovernanceModel` remains the
389
+ name of the value's vocabulary.
390
+
391
+ **A map note's art is `image:`, and the document now says so.** The table said
392
+ `img` and a standing note admitted the compiler disagreed. It does:
393
+ `map-notes.mjs` and `scenes.mjs` both read `image`, and all three authored map
394
+ notes write it. Which of the two spellings survives is a decision rather than a
395
+ documentation fix, so the document states what the build reads and points at the
396
+ issue that will settle it.
397
+
398
+ **Two stale counts corrected.** Twenty-seven fields take a `WikiLink`, not
399
+ "roughly forty"; thirteen of an `affiliation`'s properties describe the
400
+ organisation, not four. The "sixteen tables" the shared-mapping section speaks of
401
+ is exact — there are sixteen per-type mapping tables — and is left alone.
402
+
403
+ **The drift guard.** The specification hand-writes a `data` table under most of
404
+ its type sections, which is ground `engine/field-reference.mjs` already generates
405
+ from the `fields` on each `itemBuilders` entry. Generating the document is not
406
+ available: its vocabulary spans note types that produce Scenes, Macros and
407
+ JournalEntries, which no item registry covers. So the two are **checked** where
408
+ they both speak — a mapping row saying `data.weight` reaches `system.weightBase`
409
+ and a declaration writing `weight` to `weightBase` are one statement made twice,
410
+ and a rename that moves only one of them now fails, positioned at the cell in the
411
+ specification that makes the claim.
412
+
413
+ Everything else is reported rather than asserted, because the two vocabularies
414
+ differ by design until the corpus migration lands: fields only one side names come
415
+ back as coverage, and types only one side describes are **named** as out of reach
416
+ rather than skipped in silence. Against the shipped SoHL declarations it compares
417
+ 9 types and 26 field pairs, names the 14 the format declares that no
418
+ `itemBuilders` entry covers and the 4 declared types the format has no section
419
+ for, and finds no disagreement. Wired into `npm run lint:content-format`.
420
+ - 7cf58f3: `contentPackage` is validated as an address segment, and `readCanonicalKey` counts segments explicitly.
421
+
422
+ A canonical address (`sohl-skill-clmb`) is read by counting hyphen-separated segments, which is sound only while the hyphen is _purely_ a separator — no segment may contain one. #59 names three charset guarantees behind that and asks for each to be **enforced rather than assumed**. Shortcodes already were; `contentPackage` was not, and its absence had a live cost: `harn-adventures` produced four-segment keys that failed as a `null` return rather than as an error saying what was wrong.
423
+
424
+ **`contentPackage` is now checked twice.** It must be alphanumeric (`^[A-Za-z0-9]+$`), and it must not equal a note type — `doc`, `being`, the map types, and every declared item type with its `doc`-prefixed documentation form. A violation is a build error in the usual `file:line:column: severity: message` form, naming the key's own line in the configuration file (#95). Every package in use today passes: `sohl`, `hm3`, `thalorna`, `kethira`, `harnensemble`, `harnadventures`.
425
+
426
+ **The shortcode rule and the package rule are one constant.** `SHORTCODE_PATTERN` now _is_ `ADDRESS_SEGMENT_PATTERN`, from the new `engine/address-charset.mjs` leaf, rather than a second copy of the same regex free to drift from it.
427
+
428
+ **`readCanonicalKey` states its premise instead of assuming it.** It counts against a named `CANONICAL_KEY_SEGMENTS`, and its documentation says the charset rule is what makes counting sound — rather than restating "nothing contains a hyphen" as a fact about the data that nothing checked. It also distinguishes its two failures: a string that cannot be a key still yields `null`, while an absent or blank input yields `undefined`. Both are falsy and all four call sites test only for truthiness, so no behaviour changes.
429
+
430
+ Deliberately **not** in this change, because they depend on decisions still open in #59: the system segment, `none`, the manifest format-version bump, partial-address resolution, and the single-hit rule. The key format is unchanged — three segments, `<package>-<type>-<shortcode>`.
431
+
432
+ Part of #127. Part of #59.
433
+ - 00706df: Declare the tags that classify, and say that every other tag stays open (#172).
434
+
435
+ `tags:` lives at the open top level, and most tags belong there: a theme, a
436
+ region, a working state is the author's own. **A tag that classifies the subject
437
+ is different, because something queries it.** A settlement tagged `village`
438
+ appears in the list of villages and an untagged one does not, so `vilage` does
439
+ not merely look wrong — it removes the note from an index, silently, while the
440
+ index still renders a table that looks complete. That is the failure the closed
441
+ `data:` container was introduced to end, in a region that is still open.
442
+
443
+ Four groups are declared: a place's **kind** (`city`, `town`, `village`, `port`,
444
+ `fortress`, `hall`, …), its **character** (`fortified`, `temple`, `market`,
445
+ `fishing`, `coastal`, …), its **scale** (`continent`), and a note's **state**
446
+ (`draft`).
447
+
448
+ **Kind and character are separate because one slot could not hold both.** The
449
+ single-valued field these replaced ran to 101 values over 196 notes, 72% of them
450
+ used exactly once, because `Fishing Village` and `Market Town / Seat of Local
451
+ Nobility` each had to be a value of its own — and a query for villages found two
452
+ of the eleven that existed.
453
+
454
+ **A continent is a region carrying a tag, not a subtype**, because structurally it
455
+ is a region: the same fields, the same parent chain, everything but scale.
456
+
457
+ **And the declaration is checked.** `lintFrontmatter` reports a tag that is a near
458
+ miss for a declared one — `vilage` for `village` — while leaving every other tag
459
+ alone, because the region is open and a theme or a region is the author's own.
460
+ The group's **scope** is what makes that sound rather than noisy: distance alone
461
+ was wrong on all eight notes it touched in `sohl-thalorna`, since `azravan`,
462
+ `barter` and `secret` each sit a typo's distance from a place tag while sitting
463
+ on a faith, an economy note and three lore notes. Scoped to the types each group
464
+ applies to, both authored trees report nothing.
465
+
466
+ **A being's station is declared too**, and it is not a rank: which kind of body a
467
+ person belongs to — the clergy, the soldiery, the tradesfolk — is a different
468
+ axis from where they stand inside one, which `data.lore` carries by naming the
469
+ rank. A tag holds the first, because a person may be several at once and because
470
+ nothing ranks `clergy` against `mages`.
471
+ - 215ba23: Key a being's embedded-item references and the predefined-items map on the same vocabulary, and report a reference that resolves to nothing (closes #140, part of #127).
472
+
473
+ **The defect.** Two vocabularies met in embedded-item resolution and disagreed about which one they were speaking. `Actors.loadItemsMap` keyed each predefined item by the **compiled document's** subtype (`doc.type`), while a being's frontmatter addresses its embedded items by the **note's** `type`. They are the same string in every SoHL row today, so the lookup succeeded by coincidence; the note-type → document-subtype map (#79) made the coincidence visible without creating it.
474
+
475
+ The first non-identity row breaks it. #78 introduces exactly that — `armorgear` → `armor` and its two siblings — at which point a being's `armor` reference is looked up in a map keyed `armorgear`, finds nothing, and the item is missing from the compiled actor. `harn-ensemble` alone carries 30,741 such references.
476
+
477
+ **Which vocabulary, and why that one.** The addresses stay keyed on the **document subtype**, and each authored reference is translated forward through the system's map before the lookup. The map is a function from note type to subtype by construction; the reverse is not — two note types may compile into one subtype — and a compiled document records nothing about the note that produced it, so there is no honest way to key the addresses the other way round. The translation lives in one place, `Actors#embeddedSubtype`, over a new `referencedSubtype` in `engine/document-subtypes.mjs`, and both are documented as saying which side translates rather than leaving it implied.
478
+
479
+ | a reference naming… | before | after |
480
+ | --------------------------------------------- | ----------------------------- | -------------------------------------- |
481
+ | a mapped type | looked up verbatim | looked up as the subtype the row names |
482
+ | a type the system does not map | looked up verbatim | unchanged — the consumer's own type |
483
+ | a type the system compiles into another class | resolved to nothing, silently | a finding naming the note |
484
+ | a one-to-many row | resolved to nothing, silently | a finding listing the candidates |
485
+ | a retired spelling | resolved to the old name | a finding naming the replacement |
486
+
487
+ **A stand-alone entry moved too.** An entry carrying no shortcode is built from the reference alone, so the note type became the document's subtype outright — a document of a subtype the system does not define, with nothing said. It now carries the mapped subtype, and the embedded `_id` seed is the subtype as well, so a later note-type rename leaves every embedded id exactly where it was.
488
+
489
+ **Every finding is located.** An unresolved reference is now reported at the line the reference sits on rather than at the note, in the usual `path:line:column: severity: message` form.
490
+
491
+ **Nothing compiled changes, and no tree gains a finding.** SoHL's map is the identity throughout: its 3,126 compiled pack files are byte-identical across the change. Compiled read-only against `harn-ensemble`, `sohl-thalorna`, `sohl-kethira-basic`, `harn-adventures` and `sohl`, the diagnostic output is the same finding for finding — 64 pre-existing `sohl-thalorna` findings gain a line and column, and nothing else moves.
492
+ - 3ea6f64: **A map is one type, and the three spellings are its subTypes.**
493
+
494
+ `docs/content-format.md` has always described a map that way, and said why:
495
+ _the three differ only in the canvas defaults derived for them, which is why
496
+ they are subTypes of one type rather than three types._ The implementation
497
+ declared the opposite, so a map note written to the specification was refused
498
+ with `no schema is declared for content type "map"`.
499
+
500
+ The three names cost three entries in the pack router, three in the claims set,
501
+ three in `NOTE_SCHEMAS`, and three in every consumer's `sections` config — for
502
+ one idea. `mapProfile()` now keys the derived canvas off `subType`, which is
503
+ the one thing the spellings ever decided, and `MAP_SUBTYPES` names them.
504
+
505
+ `battlemap`, `localmap` and `regionalmap` join `RETIRED_TYPES`, so a note or a
506
+ link still writing one is **told what to write instead** rather than routed
507
+ silently to the items pack — the treatment `character` and `creature` got.
508
+
509
+ **`data.place` is declared**, closing a second gap in the same table: the link
510
+ from a map to the place it depicts was specified and not declared, so authoring
511
+ it was an error. It is named on the map and not on the place, because a place
512
+ has several maps and a map depicts one place.
513
+
514
+ Closes #174
515
+ - 6d3ffa1: Stop inferring a Foundry document's subtype from the markdown note's `type`, and look it up in a map each system declares (part of #79, slice 3 of #127).
516
+
517
+ **The defect.** The two vocabularies were the same identifier for one reason: a builder wrote the same string twice. `sohl/actors.mjs` declared `ACTOR_VAULT_TYPE = "being"` and emitted `type: "being"` several hundred lines below it, under a comment reading _"One content type, named for the Foundry actor it produces."_ Nothing related them, so changing one and not the other produced a wrongly-typed document in silence — a wrong-output risk with **one** system, not only with two.
518
+
519
+ **The mechanism** is `engine/document-subtypes.mjs` and the declaration is the system's, which is the `engine/` ÷ `sohl/` line this package draws everywhere else: note-format knowledge in the engine, game-system knowledge in the system half. `sohl/document-subtypes.mjs` declares SoHL's own map — _identity rows included_. `skill` → `skill` is written out rather than derived from the item registry's keys, because deriving it is exactly the coincidence the map exists to remove.
520
+
521
+ | behaviour | before | after |
522
+ | -------------------------------------------------- | ----------------------------------- | ------------------------------------------- |
523
+ | an item's emitted subtype | `fm.type`, verbatim | the row the system declares |
524
+ | an actor's emitted subtype | the literal `"being"` | the row the system declares |
525
+ | which notes the actors pass claims | `fm.type === "being"` | every note type the map sends to an `Actor` |
526
+ | a type the system maps onto another document class | claimed by whichever pass got there | claimed by neither |
527
+
528
+ A markdown type with **no** row for a given system produces no document for that system — silently and correctly, exactly as the thousands of notes belonging to another pass already are. A one-to-many row is resolved by the note, which supplies the discriminator in that system's own block; an absent one is an error that names the note and lists the permitted values, never a default. SoHL has no one-to-many row, so that path is exercised against a fixture system in the suite rather than by inventing one.
529
+
530
+ **Nothing compiled changes.** Every SoHL row is the identity today, so the lookup returns what the inference returned: `sohl`'s 3,126 compiled pack files and the build's whole diagnostic output are byte-identical across the change. The renames the content format calls for (`armorgear` → `armor` and its three siblings) are #78 and stay deferred — when one lands it edits one row here and the notes that address it, which is a data change rather than a mechanism change.
531
+
532
+ Additive throughout: `itemBuilders`, the pack list and every other configured surface are untouched, and a consumer shipping an item type this system does not map keeps compiling it exactly as before.
533
+ - b292fba: Reduce a `place`'s `data` properties to what is true of ground, and let a map name
534
+ the place it depicts (#164).
535
+
536
+ Eight properties become four. Measured against the 246 authored place notes,
537
+ three of the eight were used by **no note at all**, one was declared as the wrong
538
+ type, and two were the wrong end of a relation.
539
+
540
+ **`languages` is a fact about a polity.** A place's languages change when its
541
+ ruler changes, which is what makes them the ruler's property; `commonSkills` on
542
+ the affiliation already holds them. The corpus agrees — of 206 places carrying
543
+ `languages`, 190 were settlements and 16 were regions, and not one was a site, a
544
+ structure or a feature, because a ruin has no language.
545
+
546
+ **`peoples` widens to `lore`.** It was the only lore-pointing property a place
547
+ had, so a place with a calendar, a body of law or a local history had nowhere to
548
+ cite it. The target's own subType already distinguishes a `folk` from a `law`,
549
+ which is the same reason `affiliation` carries no `pantheons`.
550
+
551
+ **`demonym` is a `string`**, which is what all 24 uses are and what
552
+ `affiliation.demonym` has always been.
553
+
554
+ **`summary` duplicated the top-level `description`** — no note carried it.
555
+
556
+ **`affiliations` and `maps` were authored from the wrong end.** `affiliations` is
557
+ the inverse of `affiliation.domains`, which 91 polities populate and no place
558
+ does; a relation authored from both ends drifts the moment one is edited. `maps`
559
+ moves onto the map, which gains a `place` property — optional, because an
560
+ encounter map depicts no named place, but that is the exception the map section
561
+ already describes. A place's maps are now derived: every map whose `place` is
562
+ this one.
563
+ - caea6e3: Map a note's `<system>.system` block onto the document's `system` property, and let one note carry a block per system (part of #58, slice 2 of #127).
564
+
565
+ **The rule.** A note is system-agnostic; the only system-specific things it carries are the properties named after a system. Within one, `<system>.system` maps straight onto `document.system` — the DataModel's own paths, verbatim, with no renaming layer — while `type`, `img`, `items`, `effects` and `flags` map onto their document properties and `pack` is a build directive that maps onto nothing. `archetype`, `kbcat` and the generators `items` and `attributes` are not `system` fields in any system, so they stay directly under the block.
566
+
567
+ **The shared fallback is declared, not name-matched, and that is the load-bearing part.** `sohl.system.portrait` and `hm3.system.bioImage` both default from one shared property — two real fields with different names. SoHL's `Actor.being` and HM3's `Actor.character` share **no field name at all**, so a rule matching on spelling is not a rule with exceptions; it is a rule that never fires. Each field declares its source instead, and a source may be a dotted path (`data.portrait`) as `to` already may on the destination side. Resolution for a system `S`: `S.system.<to>`, else `S.<name>` (the legacy in-block position, kept until the corpus moves off it in #126), else the declared shared property, else the field's default.
568
+
569
+ `FieldSpec.name` is reinterpreted accordingly — it is **the shared property this field draws from**, not "the frontmatter key under `sohl:`", which is the degenerate case where source and destination happen to share a name. `sohlField()` stops being the general rule; `blockField()` generalizes it to any block.
570
+
571
+ **What is checked, that was not.** A key under `<system>.system` that the system's published `schema.json` does not declare for the subtype the note compiles into is an **error naming the note**, located at the offending line — Foundry discards an unknown `system` key at construction without a word, so the alternative is a field the author wrote and nobody ever sees. Unrecognised keys under a system block are reported against **that system's** vocabulary rather than only SoHL's, and a second system's block is checked once the build declares it.
572
+
573
+ **`itemBuilders` becomes a set.** One registry is a ceiling as well as a vocabulary: the accepted types are its keys, so a type only the other system knows — `spell` and `invocation` are HM3's, `mysticalability` is SoHL's — cannot be accepted at all. A configuration may now name several (`itemBuilders: [sohl, hm3]`, or `[{ system, builders }, …]` in code) and the vocabulary is their **union**. A type both declare keeps a builder per system; `itemBuilder(type, system)` and `itemArt(type, system)` take the system that is asking, and asking without one for a contested type throws rather than answering with whichever registry was declared first. **The scalar form is unchanged** and still resolves to exactly the flat registry it always did.
574
+
575
+ **Pack eligibility.** A pack declaring a `system:` compiles only notes carrying that system's block; one that declares none constrains nothing, and a pass whose document is not system data — journals, macros, scenes — is not subject to the rule at all. A violation fails naming the note and the pack, rather than emitting a hollow document with a subtype and none of the fields that subtype exists for. `pack:` itself needed no new mechanism: the block-override rule gives it, `effects` and `flags` their per-system form for free.
576
+
577
+ **`(type, shortcode)` resolves inside one system's catalogue.** A being addresses its embedded items by that pair and never by pack, so the Item packs are read as one address space — which stops being one address space the moment two systems are in the tree, since `skill:sword` exists under both names over different data models. An Actor pass now reads the Item packs of its own system plus the system-neutral ones. The references themselves were never ambiguous; the resolver simply did not know which catalogue it was searching.
578
+
579
+ **Nothing compiled changes.** No consumer authors a `<system>.system` block yet, and no consumer's packs declare a system that its notes do not carry, so this is purely additive to real output: `sohl`, `sohl-thalorna` and `sohl-kethira-basic` all compile byte-identically, and `content-build lint` reports the same findings in the same order. A note carrying no system block at all still compiles its system-neutral documents.
580
+
581
+ The corpus migration that exercises all of this is #126, in the content repositories.
582
+ - b45c0f9: Report a note whose `type:` no configured pack claims, instead of compiling it into nothing in silence (#146, part of #127).
583
+
584
+ **The defect.** Every compile pass answers one question about a note — _is this mine?_ — and a note every pass answers "no" to is skipped as quietly as the thousands that legitimately belong to another pass. Where **no** pass would ever have said yes, that quiet was the whole of the report. `harn-ensemble` declares no `itemBuilders`, so its five `affiliation` notes were a type nothing selected: the journals pass rejected them, the Actor passes rejected them, and no Item pack existed to claim them. They vanished from the build with no error, no warning and no census line — while its 2,512 `being` notes each produced a routing error, which is the correct behaviour. The two cases differed only in whether some pass got far enough to complain, and the quieter one had no owner.
585
+
586
+ **The finding.** A note whose type no pack in the resolved configuration claims now fails the build, named and located at its `type:` key in the project's diagnostic form:
587
+
588
+ ```text
589
+ assets/content/Affiliations/fff-901-pentacle.md:6:7: error: no configured pack claims a note of type "affiliation", so it compiles into nothing. The "sohl" system compiles it into an Item, but `packs:` declares no Item pack and no `itemBuilders` registry declares "affiliation" — declare both in package-build.config.yaml, or stop authoring the type.
590
+ ```
591
+
592
+ **Two conditions, two fixes.** The **vocabulary** — what this toolchain and the systems it ships know a note type to be — is deliberately wider than any one repository's configuration. `affiliation` is a SoHL Item however a given repository is configured, so a tree of `affiliation` notes with no Item pack behind them is a repository that has not finished configuring itself, and the message says which piece is missing. A type in **no** vocabulary is the other finding — nothing anywhere compiles it, so the fix is the note's `type:`, not the configuration. Collapsing the two would have sent `harn-ensemble` to correct five perfectly good notes.
593
+
594
+ **#79's silence is preserved, and is why the question is asked once.** A markdown type with no mapping in a given system produces no document _for that system_, silently and correctly. A per-pass check would report every such type against every system that does not map it, which is exactly the noise that rule forbids — so the question is put once, to the whole configured pack list, and "no system claims it at all" is the only statement made. A type one system maps and another does not stays silent as long as some pack claims it.
595
+
596
+ `engine/note-claims.mjs` holds the claim table, which restates each pass's `selects` in the only form that can be asked of a pack the configuration does **not** declare; the suite compares the two for every type in the vocabulary, so they cannot drift apart. `homepage` is exempt by name: it compiles into a page rather than a compendium document, and its absence from every pack is the intended state.
597
+
598
+ **Measured before it became an error.** Against every content tree in the org, this adds findings to exactly one repository and exactly the notes it was filed for: `harn-ensemble` 2514 → 2519 errors, the five `affiliation` notes; `sohl-thalorna` 150 → 150; `sohl-kethira-basic` 0 → 0; `harn-adventures` 2 → 2; `Song-of-Heroic-Lands-FoundryVTT` 0 → 0, with its 3,126 compiled pack files and its whole diagnostic output byte-identical across the change.
599
+
600
+ **Bump**
601
+
602
+ _Minor._ No consumer that builds green today has to change anything to upgrade — the one repository that gains findings is already red for an unrelated reason, and its five findings are the defect this exists to surface rather than a new demand on it. A repository that was silently shipping nothing for a type will now be told so, which is the correction.
603
+ - ad0cfd8: A map note's background art is `img:`, and `image:` is retired (#142).
604
+
605
+ Every note type names its artwork `img` and carries it at the note's **top
606
+ level**. A map alone named it `image` and read it out of the `sohl:` block, so
607
+ one idea had two spellings with nothing to reconcile them — and the content
608
+ format specification had to hedge rather than state a rule.
609
+
610
+ **`img` is now the name, at the top level.** `buildScene` and the place index
611
+ read `img` through `sohlField`, so it resolves the way every other note's art
612
+ does: the `sohl:` block first, then the note's own top level, which is where it
613
+ belongs. Art is not system-specific — a Scene is a core Foundry document and a
614
+ second system would want the identical one — so the field has no business inside
615
+ a system block. `docs/content-format.md` states that rule now, in place of the
616
+ callout that recorded the disagreement and pointed at this issue.
617
+
618
+ **`image:` still compiles, and is reported.** This is the first of the three
619
+ steps `package:` took (#56): both spellings are read, `img` wins where a note
620
+ carries both, and a note still writing `image` gets a finding naming the file,
621
+ the line and the replacement. The sweep of the authored notes and the eventual
622
+ refusal are separate, later work — nothing has to be renamed to take this
623
+ release.
624
+
625
+ **The finding is a warning, not an error.** A note writing `image` compiles to
626
+ the byte-identical document, so failing a build over it would red a tree that
627
+ has done nothing wrong on a key that still works. It is emitted on both paths an
628
+ author meets — the **compile**, which every consumer runs, and `content-build
629
+ lint` — so it is not a lint-only notice a project might never see.
630
+
631
+ Two consequences worth knowing:
632
+
633
+ | what | before | after |
634
+ | ------------------------ | ---------------------- | ------------------------------------------------------ |
635
+ | `content-build lint` | any finding set exit 1 | only an **error** does; warnings are reported and pass |
636
+ | a map with no art at all | "needs an `image`" | "needs an `img`" |
637
+
638
+ The exit-code change is `reportFindings`' existing rule applied to the lint
639
+ command rather than a second copy of it. Every finding was an error until now,
640
+ so it changed nothing the day it landed.
641
+
642
+ Verified against the `sohl` content tree, whose three map notes still write
643
+ `image`: 3,126 compiled documents, byte-identical before and after, plus the
644
+ three warnings.
645
+
646
+ ### Patch Changes
647
+
648
+ - 291f5b3: Declare `section` on a `doc` of subType `collection` (#170).
649
+
650
+ The address engine already reads it — a collection is a section's landing page,
651
+ and `section` is the URL segment that page occupies — but the specification never
652
+ declared it, so nothing could check it and an author had no way to learn it
653
+ existed. Fifteen notes in `sohl-thalorna` carry it.
654
+
655
+ Two things the declaration says that the code alone did not. **Two collections may
656
+ not claim the same segment**, so a collection listing a _subset_ of a section
657
+ names none and falls back to its own slug: five of that tree's collections list
658
+ places and three list affiliations, and they cannot all be `/place/`. And it is
659
+ **authored rather than derived** because a note's title is presentation — a
660
+ collection called "Creatures" heads the `being` section, and slugging the title
661
+ would put it somewhere else.
662
+ - d6a6b0b: Route a `doc` note by its subtype, not by the retired `category` key (#168).
663
+
664
+ `sectionOf()` read `fm.category`, and a `doc` is the one type that routes by its
665
+ subtype label rather than by its type — so when the content format retired
666
+ `category`, every `doc` note began answering `undefined` and, as the function's
667
+ own documentation says, _a `doc` with no section has no address and is not
668
+ published_. `sohl-thalorna` has 24 such notes and `sohl` has 128. The site build
669
+ emitted fewer pages and exited 0.
670
+
671
+ Two further call sites read the same key: `landingOf()` tested
672
+ `category === "collection"`, so a collection stopped being a landing page and its
673
+ authored `section` — which is its URL segment — was ignored; and `site-build`
674
+ fell back to the tree's section rather than the note's.
675
+
676
+ Four tests now cover a `doc` of each subtype, and one asserts that a note
677
+ carrying the retired key gets no section at all — the regression was visible only
678
+ by reading the source, which is what a test is for.
679
+ - 42c03b6: **`content-build format --write` now formats to a fixpoint** (#125)
680
+
681
+ `--write` formatted each file exactly once and reported success. Prettier's
682
+ `format` is _assumed_ idempotent and is not guaranteed to be, so a single pass
683
+ could leave text the next pass would still change — and the run would call such
684
+ a file formatted while `prettier --check` still rejected it. Each file is now
685
+ formatted repeatedly until it stops changing, capped at three passes, so what
686
+ lands on disk is what a second run would have produced.
687
+
688
+ **A file that will not converge is reported, not written.** At the cap the file
689
+ is left exactly as it was and a diagnostic names it, because a formatting the
690
+ command cannot reproduce would otherwise churn the file on every run.
691
+
692
+ **`--write` now surfaces its findings and fails.** It collected them and threw
693
+ them away, so a run that could not parse a file still printed
694
+ `Formatted N of M file(s).` and exited 0. It now emits each diagnostic and exits
695
+ 1, which is the same channel the non-convergence report uses.
696
+
697
+ ## 9.0.0
698
+
699
+ ### Major Changes
700
+
701
+ - 8e48f7e: Raise the shared `printWidth` from 80 to 100.
702
+
703
+ **Breaking for every consumer**, in the one way this configuration always is: a
704
+ repository that updates and runs `lint:format` will find every file reported
705
+ until it reformats. Nothing about a built package, an emitted document or a
706
+ manifest changes — this is hygiene, and it reaches users not at all.
707
+
708
+ **80 was inherited, not chosen.** The usual argument for it is reading measure,
709
+ and that argument does not apply here: `proseWrap` is left at Prettier's default
710
+ of `preserve`, so authored prose is **never reflowed**. Measured across the
711
+ content trees, prose lines run to a p90 of 378 characters and a maximum of
712
+ 5,531, entirely untouched by this number. What `printWidth` actually governs is
713
+ TypeScript and the YAML of a note's frontmatter.
714
+
715
+ **Both were measurably cramped.** Reformatting a third of the SoHL source at
716
+ each width:
717
+
718
+ | width | total lines | vs 80 | lines still over width |
719
+ | ----- | ----------- | ----- | ---------------------- |
720
+ | 80 | 31,198 | — | **1,399** |
721
+ | 90 | 30,328 | −2.8% | 193 |
722
+ | 100 | 29,672 | −4.9% | 75 |
723
+ | 120 | 28,850 | −7.5% | 25 |
724
+
725
+ The last column is the argument. At 80, Prettier _cannot_ honour the limit on
726
+ 1,399 lines — long string literals, `@src/…` specifiers, generic signatures — so
727
+ those lines are over-width regardless and their surroundings were broken up for
728
+ nothing. At 100 that falls to 75.
729
+
730
+ The same knee appears in content. An item entry written in flow style —
731
+ `{ shortcode: X, type: skill, name: …, system: { … } }` — typically lands in the
732
+ low 90s, so of 318,030 entries across the two largest trees, 90.8% fit on one
733
+ line at 80 and **95.3% at 100**, with almost nothing gained in between. 120 buys
734
+ another 0.8 points and is not worth a second reformat.
735
+
736
+ **This package's own config stops restating the values.** `prettier.config.js`
737
+ carried its own copy of all twelve options with a comment saying they were
738
+ "matched to" the SoHL repository — already the wrong authority once
739
+ `PRETTIER_BASE` existed here, and a copy is a copy: raising the width would have
740
+ left the package that _defines_ the shared style as the one repository not
741
+ written in it. It now re-exports `PRETTIER_CONFIG`, so there is nothing left to
742
+ drift.
743
+
744
+ This release reformats this repository: 147 files, 2,677 lines shorter.
745
+
746
+ **Bump**
747
+
748
+ _Major._ Consumers' `lint:format` fails until they reformat, which is the whole
749
+ of the breakage and is a one-command fix.
750
+
3
751
  ## 8.1.0
4
752
 
5
753
  ### Minor Changes