@heroiclands/package-build 14.0.0 → 16.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,332 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 16.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - d734ac9: **Two embedded items on one actor may no longer share `(type, shortcode)`.**
8
+ `content-build lint` reports each collision as an error, at the later entry.
9
+
10
+ SoHL treats `(type, shortcode)` as a **logical identity** rather than a lookup
11
+ convenience: two documents of one type bearing one shortcode denote _the same
12
+ entity_, whatever their `_id`s or field values. It is unique within four scopes,
13
+ one of which is an actor's own embedded items — and the invariant exists to keep
14
+ that identity well-defined. Two colliding entries make "the same thing"
15
+ ambiguous, and every match that resolves by it — compendium↔world
16
+ reconciliation, archetype shadowing, `fvttFindItemByShortcode`, cohort
17
+ membership, expression and effect references — becomes unsound.
18
+
19
+ Nothing caught it. The compiler resolves each entry independently and
20
+ distinguishes the two only when seeding `_id`, so a collision compiled to two
21
+ documents with distinct ids and shipped unremarked:
22
+
23
+ ```yaml
24
+ items:
25
+ - { shortcode: swim, type: skill, system: { masteryLevelBase: 30 } }
26
+ - { shortcode: swim, type: skill, initSkillMult: 1 }
27
+ ```
28
+
29
+ Neither entry overrides `system.shortcode`, so both inherit `swim` from the
30
+ template and compile to two `skill` items keyed `swim` on one actor.
31
+
32
+ **The rule is decidable from frontmatter alone**, which is why it is a lint and
33
+ not a compile step. An entry's effective key is `system.shortcode ?? shortcode`:
34
+ a top-level `shortcode` merely selects the template the entry is written from
35
+ and never reaches the document, while a template's own `system.shortcode` is its
36
+ address by construction. Neither the catalogue nor a compile is needed to know
37
+ what an entry will carry.
38
+
39
+ Two entries written from one template are still fine when the second says which
40
+ entity it is — `{ shortcode: Dgr, type: weapongear, name: "Dagger 2", system: { shortcode: Dgr2 } }`.
41
+ The key is the _pair_, so two different types may share a shortcode, and an
42
+ entry naming no key at all is left to the compiler, which already reports it.
43
+
44
+ **Major**, because a tree carrying a collision goes red on adoption. Known at
45
+ the time of writing: `Song-of-Heroic-Lands-FoundryVTT` 1 actor,
46
+ `sohl-thalorna` 15, `sohl-kethira-basic` 0.
47
+ - 2e0d32e: **A note's address has one name: `packageAddress`.** `engine/content-address.mjs`
48
+ exported it twice — as `contentAddress` and as `packageAddress` — and the two
49
+ bodies had become byte-identical:
50
+
51
+ ```js
52
+ // before // after
53
+ contentAddress(fm); // "doc-gear/" packageAddress(fm); // "doc-gear/"
54
+ packageAddress(fm); // "doc-gear/"
55
+ ```
56
+
57
+ `contentAddress` is removed. Import `packageAddress` from
58
+ `@heroiclands/package-build/engine/content-address` instead; the two returned
59
+ the same string, so nothing else changes.
60
+
61
+ The names once meant different things — a note's address _within the content
62
+ tree_, and its address _relative to the package_. They could differ while a
63
+ `README.md` addressed its section rather than itself and while a page's URL was
64
+ derived from `name.full`; the first went with the landing rules (#204, #208) and
65
+ the second when a page's URL became its address (#181). What was left was two
66
+ exported names for one notion, on a public subpath, with `contentAddress`'s
67
+ documentation still describing an address "below the knowledgebase mount" that
68
+ it no longer returned.
69
+
70
+ `packageAddress` is the name that survives because it still says something true
71
+ and load-bearing: the string is measured **from the package**, so a caller
72
+ composing a URL or a manifest `path` prepends where the package is served. That
73
+ is the one qualification a reader of the call site needs, and it is exactly the
74
+ distinction the `url:` change in 15.0.0 turned on. Closes #226.
75
+
76
+ ### Minor Changes
77
+
78
+ - e131f7b: Publish the note tree as a queryable index, instead of throwing every build's walk away.
79
+
80
+ Every content build parses every note's frontmatter — the pack compilers, the site
81
+ build, and the content-table expander each do it — and every one of them discards
82
+ the result. Nothing outside a build could therefore ask a question about the
83
+ content: _which beings carry no `kbcat`_, _what does this table actually select_,
84
+ _did that type rename leave anything behind_ each needed a throwaway script that
85
+ re-walked the tree. Eight dead tables shipped for weeks behind exactly that gap.
86
+
87
+ `content-build content-index` emits one JSON Lines record per note — the whole
88
+ frontmatter, plus a derived `file` (`path`, `folder`, `name`) and the configured
89
+ `package` — so a question is one line of `jq`, and so an editor, a CI check, or
90
+ another package's build can read the content without re-deriving it.
91
+
92
+ **The record is the note, not a projection of it.** Nothing is selected, flattened,
93
+ or renamed; a reader addresses `sohl.body.weight.base` because that is what the note
94
+ says, which is also exactly what a `dataview` query writes. The refusal to impose a
95
+ schema is deliberate: `sohl`'s frontmatter spreads 242 distinct leaf paths unevenly
96
+ over 15 types, from 9 on a `macro` to 72 on a `being`, so a fixed column set would
97
+ turn ordinary authoring into a schema migration. `package` and `file` are the two
98
+ derived keys, and a note carrying either is an error rather than a silent overwrite.
99
+
100
+ **Derived, disposable, byte-stable.** It writes to the new `paths.contentIndex`
101
+ (`build/content-index/<package>.jsonl`) — never `paths.stage`, which is mirrored
102
+ into a Foundry data root — so nothing may be authored against it and it need never
103
+ be committed. Regenerating costs a frontmatter parse rather than a build, which is
104
+ why it is a command of its own; and because rebuilding is the intended use, records
105
+ are ordered by content path with the note id breaking ties and every object's keys
106
+ are sorted at every depth, so a rebuild over an unchanged tree is a no-op. A tree
107
+ that yields no note is an error, not an empty index: a reader takes the file as
108
+ authoritative, and "this package has no content" is indistinguishable from a
109
+ mis-pointed tree.
110
+
111
+ **Every note's address, and every anchor it defines.** A record states
112
+ `address.slug` (what goes inside `[[…]]` locally) and `address.canonical` (the
113
+ package-qualified key the manifest files it under), plus every `{#slug}` anchor the
114
+ body declares — each with its heading name, level, **line in the file**, and the
115
+ `slug#anchor` link that reaches it. Neither address is new information, since both
116
+ derive from `type` and `shortcode`; what the fields add is the rule, derived through
117
+ the same `addressSlug` and `canonicalKey` the manifest and site build use, so an
118
+ index cannot disagree with either about where a note lives. The payoff is that a
119
+ wikilink — anchor and all — becomes checkable by lookup rather than by re-parsing
120
+ the tree, and an editor can jump to a section instead of searching for it. What
121
+ counts as an anchor is kept identical to what `splitPages` matches, and a test
122
+ asserts the two agree.
123
+
124
+ **A keyboard-typeable form of every name.** `nameAscii` states `name.full`
125
+ reduced to printable 7-bit ASCII — `Kûrbúl ¾-Helm` → `Kurbul 3/4-Helm`, `Kèthîra`
126
+ → `Kethira`, `Ærling` → `AErling`, `Þorn` → `Thorn`, `Straße` → `Strasse`. Names
127
+ carry the setting's orthography and nobody types them, so anything searching or
128
+ completing over the index needs a form a keyboard produces; stating one means
129
+ every consumer folds the same way instead of each inventing its own and two
130
+ searches over the same data disagreeing. It transliterates rather than strips,
131
+ through the same `unidecode` table `slugify` already runs — so an ASCII name and
132
+ a slug cannot disagree about a character, and `Kûrbúl` does not become `Krbl`.
133
+ Emitted even when it equals the name, so a consumer never branches on whether a
134
+ name happened to be ASCII; `null` only when the note has no name. `aliasesAscii`
135
+ does the same for `name.aliases` in the authored order — an alias is the name a
136
+ reader is at least as likely to reach for (`Killer Whale` for an orca), so a
137
+ search has to match it too — and is an empty array rather than null when a note
138
+ has none.
139
+
140
+ `file.path` stays relative to the content root and is deliberately never absolute:
141
+ an absolute path is a fact about the machine that built the index, so it would break
142
+ byte-stability between checkouts and publish someone's home directory.
143
+
144
+ Closes #224.
145
+
146
+ ## 15.0.0
147
+
148
+ ### Major Changes
149
+
150
+ - 5c16c02: Let a note's art path say "unset" and "blank on purpose" with two different values.
151
+
152
+ `resolveImg` opened with `if (!raw) return ""`, and every caller then applied its
153
+ own default to the result with `||` — `resolveImg(fm.img) || itemArt(type)`. So
154
+ `""`, `null` and an absent key were one case: all three compiled to the type's
155
+ default art, and a note had no way to say _ship no image_ at all.
156
+
157
+ They are now three values with two meanings, the convention the project already
158
+ holds for an optional "not specified" DataModel string (`nullable, initial: null`,
159
+ so "unset" is one honest value rather than two):
160
+
161
+ | a note writes | it means | it compiles with |
162
+ | -------------- | ------------------------------------ | ---------------- |
163
+ | nothing at all | _unset_ — name me no art | the type default |
164
+ | `img: null` | the same thing, said out loud | the type default |
165
+ | `img: ""` | _blank on purpose_ — I want no image | no image |
166
+
167
+ **`resolveImg` returns `string | null`.** `null` for an unset path, `""` for a
168
+ deliberate blank, the translation half unchanged. Every caller pairs its default
169
+ with **nullish** coalescing: `sohl/items.mjs`, three in `sohl/actors.mjs` (`img`,
170
+ `portrait`, and the prototype token's `texture.src`), and `engine/macros.mjs`.
171
+ Not `||` — that collapses a deliberate blank back into the default and takes the
172
+ distinction away again, and it does so silently, because `""` is falsy.
173
+ `itemArt()` is unaffected: a registry entry with no art throws before the
174
+ translation, so its result is never the unset case.
175
+
176
+ **The default-art seam is a documented extension point, so this is the substance
177
+ of the change, not a detail.** A consuming repository that pairs art with its own
178
+ `itemBuilders` entry, or calls `resolveImg` from a builder of its own, gets the
179
+ new reading of `""` whether or not it asked for it — which is why the sweeps have
180
+ to come first. `sohl-thalorna` swept forty-five `img: ""` notes ahead of this
181
+ release (HeroicLands/sohl-thalorna#134) and `sohl-kethira-basic` eleven
182
+ `portrait: ""` beings (HeroicLands/sohl-kethira-basic#81); `sohl` authors neither,
183
+ and its 3,125 compiled documents are byte-identical across the change.
184
+
185
+ **Both art fields, because both go through `resolveImg`.** A being carries `img`
186
+ (its token art) and `portrait` (its sheet portrait) independently; `portrait` is
187
+ not a variant spelling of `img`, and the rule belongs to the translator rather
188
+ than to one of the keys reaching it. This is not theoretical: `sohl-kethira-basic`
189
+ writes `portrait: ""` on eleven beings and `img: ""` on none, so a change — or a
190
+ guard — keyed on `img` alone would have called that tree clean and dropped every
191
+ one of those portraits.
192
+
193
+ **A warning for the old spelling.** The frontmatter lint reports `img: ""` and
194
+ `portrait: ""` — in either authoring position — as the meaning-change they are, on
195
+ the pattern the `package:` and retired-alias sweeps set: a warning, because the
196
+ note still compiles, to a document that is merely iconless.
197
+
198
+ **`title` is deliberately not on this rule.** It reads as a general rule about
199
+ optional strings and it is not: on a `type: affiliation` note `title` is
200
+ _simultaneously_ a declared item field whose default is `""`
201
+ (`sohl/item-fields.mjs`), resolved from the very same shared top-level key the
202
+ site emitter reads as the page title. `title: null` therefore does not fall back —
203
+ it stringifies, and the compiled document ships the literal `"null"`. A `title` a
204
+ note does not want is written by omitting the key; the emitter's `fm.title ?? name`
205
+ is already correct and is untouched. The lint guard is `img`'s alone for the same
206
+ reason.
207
+
208
+ Delivers the `img` half of #218. The `title` half — the collision above — stays
209
+ open.
210
+ - 63dfcae: **A note's own `title` no longer fills an affiliation's `system.title`** (#218).
211
+
212
+ The two were never the same quantity. A note's top-level `title` is _the title
213
+ of the note_ — the heading its page is published under, which the site emitter
214
+ reads. An `affiliation` item's `system.title` is _the style of address the office
215
+ carries_ — Ajaw, Warden, a person's style within the body. They collided only in
216
+ spelling, and field resolution's third step, the shared top-level property, fed
217
+ the second from the first.
218
+
219
+ That step also answers **without** applying the field's default — only the
220
+ in-block step does — so an authored `title: null` reached the `String()` coercion
221
+ unguarded and compiled to the literal string `"null"`. Fifteen `sohl-thalorna`
222
+ notes shipped `"system": { "title": "null" }` that way.
223
+
224
+ **Nothing in any content tree relied on the fallback.** Across all three
225
+ consumers — 295 `affiliation` notes in `sohl-thalorna`, 28 in
226
+ `sohl-kethira-basic`, none in `sohl` — not one carries a non-empty top-level
227
+ `title`, and `content-build package compile` emits byte-identical
228
+ `build/packs-json` for all three. It is a major because the rule that decides a
229
+ consumer's compiled documents changed with no configuration to restore it, and
230
+ because the generated item-field reference moves (below).
231
+
232
+ **The field is still authorable**, at the two positions that describe the
233
+ document rather than the note: `sohl.system.title`, and the legacy in-block
234
+ `sohl.title` that most trees already write. A membership's title belongs on the
235
+ entry in a being's `sohl.items`, as its `system.title`. `data.title` is neither —
236
+ `title` is not a `data:` property any note type declares, so `content-build lint`
237
+ refuses it.
238
+
239
+ **Declaring the exemption:** a field in an `itemBuilders` `fields:` declaration
240
+ may now carry `topLevelMeans`, whose value is _what the note's top-level key of
241
+ that name means instead_. Declaring it removes the shared top-level position from
242
+ that field's resolution order, and the generated item-field reference prints the
243
+ reason beneath the type's table, so an author reading it learns that the
244
+ top-level key will not fill the field. A repository that commits that page should
245
+ regenerate it.
246
+
247
+ The value is the reason rather than a bare flag deliberately: a boolean would
248
+ record the decision and lose the case for it, and the next person adding a field
249
+ needs to know the question exists.
250
+ - a41b066: **A page states its address relative to the site root.** `site.base` is no
251
+ longer written into a page's Hugo `url:` front matter — only into the `href`s
252
+ this build renders and the base a link-manifest `path` is measured against:
253
+
254
+ ```yaml
255
+ # emitted page
256
+ url: /doc-rulesintro/ # was: /sohl/doc-rulesintro/
257
+ ```
258
+
259
+ `site.base` was two quantities wearing one name. It is correctly _where the
260
+ package is served_ — the prefix on every rendered `href`, and the base a
261
+ manifest `path` is stripped against — and `/<contentPackage>/` is the right
262
+ default for that. It was also written verbatim into each page's `url:`, and that
263
+ is a different quantity: Hugo resolves `url` against `baseURL`, whose path for
264
+ every consumer that exists **already is** the package base. So the prefix was
265
+ written twice, and every content page — plus the homepage — published one
266
+ package segment too deep:
267
+
268
+ ```text
269
+ /sohl/doc-rulesintro/ 404 /sohl/sohl/doc-rulesintro/ 200
270
+ /thalorna/being-afzndhprnzr/ 404 /thalorna/thalorna/being-afzndhprnzr/ 200
271
+ ```
272
+
273
+ Not one address a link manifest advertised resolved: **0 of `sohl`'s 2,988
274
+ entries**, and 0 of `thalorna`'s 2,585.
275
+
276
+ **One value fed two readers that need opposite framings**, which is why no
277
+ setting could fix it from a consumer: `site.base: "/"` bought the addresses and
278
+ short-changed the hrefs, and the default did the reverse. They are now separate,
279
+ and each reader gets the form it needs:
280
+
281
+ | Reader | Gets | Because |
282
+ | --------------------------------------------------- | --------------------------- | ------------------------------------------------------------------- |
283
+ | A page's own `url:` front matter | `/<type>-<shortcode>/` | Hugo prefixes the site's `baseURL` path to it |
284
+ | The address index a `[[wikilink]]` resolves through | `<base><type>-<shortcode>/` | A browser resolves the rendered `href` against nothing |
285
+ | A link-manifest `path` | `<type>-<shortcode>/` | Measured against `site.base` and stripped; unchanged, byte for byte |
286
+
287
+ The homepage moves with them: `homepageFrontmatter` states `/homepage-root/`,
288
+ and no longer takes a `base` (nor does `writeHomepages`' fourth argument, which
289
+ existed only to supply it). `trees` pages and section landings never stated a
290
+ `url:` and are untouched — they take their address from their path.
291
+
292
+ **Take this release and drop your `site.base`.** Both publishing consumers set
293
+ `site.base: "/"` as a stopgap
294
+ (HeroicLands/Song-of-Heroic-Lands-FoundryVTT#1813, HeroicLands/sohl-thalorna#131),
295
+ which bought the correct addresses at the cost of same-package body links
296
+ rendering `/doc-x/` rather than `/sohl/doc-x/` — dead either way, so nothing
297
+ regressed. With this release the stopgap is no longer needed and no longer
298
+ harmless: keeping it leaves those hrefs short. Delete the `base:` line and the
299
+ default is right for both halves.
300
+
301
+ **Verified against pristine `git archive origin/main` extractions of all three
302
+ consumers**, in both configurations, through `content-build site` **and** Hugo
303
+ 0.165:
304
+
305
+ | Consumer | `site.base` | Page `url:` | Rendered path | Manifest entries resolving |
306
+ | ---------- | ----------- | --------------------- | ------------------------------ | -------------------------- |
307
+ | `sohl` | unset | `/doc-rulesintro/` | `/sohl/doc-rulesintro/` | 2,988 of 2,988 (was 0) |
308
+ | `sohl` | `"/"` | `/doc-rulesintro/` | `/sohl/doc-rulesintro/` | 2,988 of 2,988 |
309
+ | `thalorna` | unset | `/being-afzndhprnzr/` | `/thalorna/being-afzndhprnzr/` | 2,585 of 2,585 (was 0) |
310
+ | `thalorna` | `"/"` | `/being-afzndhprnzr/` | `/thalorna/being-afzndhprnzr/` | 2,585 of 2,585 |
311
+
312
+ No `/sohl/sohl/` or `/thalorna/thalorna/` path exists in either built tree, the
313
+ section landings, the content mount and the `dev-docs` tree pages all still
314
+ resolve, and with the stopgap dropped a same-package body link renders
315
+ `<a href=/sohl/doc-skills/>` again. `lint` and `links` are identical line for
316
+ line in every combination — `sohl` green, `sohl-thalorna` exactly as red as its
317
+ own content gap leaves it (1,983 lint findings, 122 link findings).
318
+
319
+ **Exactly one line per page changes, and nothing else does.** Of `sohl`'s 1,671
320
+ emitted files, 1,606 differ — the 1,605 content pages and the homepage — each by
321
+ its `url:` alone; the 46 tree pages and 19 landings are byte-identical, and
322
+ `build/manifests/sohl.json` is byte-identical. `sohl-kethira-basic` publishes a
323
+ homepage and nothing else (`publish.site: homepage`) and declares no
324
+ `site.base`: its console output is identical, its one emitted page moves from
325
+ `/kethira/kethira/homepage-root/` to `/kethira/homepage-root/`, and its landing
326
+ at `/kethira/` is unaffected.
327
+
328
+ Closes #217
329
+
3
330
  ## 14.0.0
4
331
 
5
332
  ### Major Changes