@heroiclands/package-build 10.0.1 → 11.1.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 (47) hide show
  1. package/CHANGELOG.md +375 -0
  2. package/CONTENT.md +234 -71
  3. package/MIGRATING.md +64 -0
  4. package/bin/content-build.mjs +79 -75
  5. package/content-config.mjs +28 -0
  6. package/docs/content-format.md +90 -67
  7. package/engine/base-compiler.mjs +7 -1
  8. package/engine/content-address.mjs +71 -18
  9. package/engine/content-format-check.mjs +1 -1
  10. package/engine/content-links.mjs +93 -112
  11. package/engine/content-lint.mjs +14 -10
  12. package/engine/content-slug.mjs +39 -105
  13. package/engine/diagnostics.mjs +16 -2
  14. package/engine/frontmatter-lint.mjs +161 -18
  15. package/engine/helpers.mjs +31 -68
  16. package/engine/homepage.mjs +131 -86
  17. package/engine/index.mjs +2 -5
  18. package/engine/manifest-emit.mjs +23 -4
  19. package/engine/note-vocabulary.mjs +58 -1
  20. package/engine/retired-fields.mjs +117 -6
  21. package/engine/site-build.mjs +182 -59
  22. package/engine/site-index.mjs +57 -102
  23. package/engine/web-wikilinks.mjs +183 -127
  24. package/engine/wikilink-syntax.mjs +174 -34
  25. package/engine/wikilinks.mjs +159 -117
  26. package/package.json +1 -1
  27. package/types/content-config.d.mts +24 -0
  28. package/types/engine/base-compiler.d.mts +1 -1
  29. package/types/engine/content-address.d.mts +46 -14
  30. package/types/engine/content-links.d.mts +13 -17
  31. package/types/engine/content-slug.d.mts +11 -48
  32. package/types/engine/diagnostics.d.mts +14 -1
  33. package/types/engine/frontmatter-lint.d.mts +27 -2
  34. package/types/engine/helpers.d.mts +4 -3
  35. package/types/engine/homepage.d.mts +96 -60
  36. package/types/engine/index.d.mts +0 -1
  37. package/types/engine/note-vocabulary.d.mts +43 -0
  38. package/types/engine/retired-fields.d.mts +78 -1
  39. package/types/engine/site-build.d.mts +70 -17
  40. package/types/engine/site-index.d.mts +19 -21
  41. package/types/engine/web-wikilinks.d.mts +29 -28
  42. package/types/engine/wikilink-syntax.d.mts +126 -40
  43. package/types/engine/wikilinks.d.mts +29 -24
  44. package/engine/abbreviations.mjs +0 -0
  45. package/engine/alias-index.mjs +0 -153
  46. package/types/engine/abbreviations.d.mts +0 -44
  47. package/types/engine/alias-index.d.mts +0 -122
package/CHANGELOG.md CHANGED
@@ -1,5 +1,380 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 11.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8906d7a: **A `README` landing's `subType` is checked against the sections the repository declares, not the genre list.**
8
+
9
+ Two vocabularies were spelled with one key. For every note, `subType` is a
10
+ sub-kind of its type, checked against a closed list. For a `README` under
11
+ `publish.address.landing: readme` it is additionally the **URL section** —
12
+ `sectionOf` reads a `doc`'s `subType` as the segment the landing addresses — and
13
+ that is an open set the consuming repository names in `site.sections` /
14
+ `site.readmeSections`. `engine/frontmatter-lint.mjs` applied the closed reading
15
+ while `engine/content-address.mjs` applied the open one, so the two halves of the
16
+ toolchain disagreed about the same note: `content-build site` addressed and
17
+ published `Weapons/README.md` at `weapongear/`, and `content-build lint` refused
18
+ it because `doc` declares only `rules`, `user-guide` and `reference`. An item
19
+ section's landing was not expressible.
20
+
21
+ The check now widens **for a landing only**, and only under `landing: readme`: a
22
+ `README` whose `subType` is its section may name any section the repository
23
+ declares, as well as any of its type's own genres — a genre is a section a `doc`
24
+ tree publishes under whether or not the repository describes it, so narrowing to
25
+ the configured set alone would refuse a correct `README`. An ordinary note's
26
+ `subType` stays closed to its type's genres, unchanged.
27
+
28
+ **The guard survives**, checked against the set that actually decides where the
29
+ page goes:
30
+
31
+ ```text
32
+ Weapons/README.md:4:1: error: `subType` "weapongeer" is the section this README
33
+ lands at, and nothing declares it: it is neither a section this repository
34
+ configures under `site.sections` / `site.readmeSections` (rules, user-guide,
35
+ weapongear, …) nor one of the subtypes doc declares (rules, user-guide,
36
+ reference). Did you mean "weapongear"?
37
+ ```
38
+
39
+ Whether the value is an address is asked of `sectionOf` rather than by naming a
40
+ type, so the linter still knows no type names of its own; the section list is
41
+ read from the resolved configuration the site build renders those landings from,
42
+ through a new `declaredSections(config)`, so neither can name a section the other
43
+ does not.
44
+
45
+ _Minor rather than patch_: `lintNote` and `lintFrontmatter` take two new
46
+ options (`landing`, `sections`) and `content-config.mjs` exports
47
+ `declaredSections`. Both options default to today's behaviour, so a caller that
48
+ passes neither — and a repository that declares no sections — is unaffected.
49
+
50
+ Closes #197
51
+
52
+ ### Patch Changes
53
+
54
+ - 68910b6: **A `README` landing may name any section that exists, not only a configured one.**
55
+
56
+ #197 widened a landing's `subType` check by one term and it was the wrong one:
57
+ the sections the repository configures in `site.sections` /
58
+ `site.readmeSections`. That keys on configuration a consumer may legitimately not
59
+ have. `sohl-thalorna` has **no `site:` block at all** — it renders its site
60
+ through a local fork of the emitter — so `declaredSections` answered `[]`, the
61
+ accepted set collapsed back to the three `doc` genres, and five of its landings
62
+ were still refused for naming their own content type:
63
+
64
+ ```text
65
+ assets/content/Characters/README.md:7:1: error: `subType` "being" is not one of the subtypes doc declares (rules, user-guide, reference)
66
+ ```
67
+
68
+ A landing's `subType` is an **address**, so it is now checked against the
69
+ addresses that exist — the range of `sectionOf` over every note the format
70
+ permits, plus whatever the repository names:
71
+
72
+ 1. **Every content type the specification declares.** `sectionOf` returns
73
+ `fm.type` for a non-`doc` note, so `being`, `lore`, `scenario` and
74
+ `weapongear` are sections _by construction_, configured or not.
75
+ 2. **The type's own subtypes** — `rules`, `user-guide`, `reference`.
76
+ 3. **The configured sections**, which may name one that is neither: `sohl` has
77
+ `credits` and `dev-docs`.
78
+
79
+ **The guard survives.** A misspelling is in none of the three, so it still fails,
80
+ and the near miss is drawn from the whole union. What is deliberately no longer
81
+ caught is a landing for a section that exists but is currently empty — which is
82
+ legitimate, and is why the set is the types the format _declares_ rather than
83
+ those _present in the tree_: `sohl` ships two such landings above tables that
84
+ stay empty until the first note of each type does.
85
+
86
+ **The type list is the specification's, not the schema map's.** `NOTE_SCHEMAS`
87
+ declares 18 types and `docs/content-format.md` declares 23; `lore`, `place`,
88
+ `scenario`, `vehicle` and `armorlocation` are in the second only. Checking an
89
+ address against the schema map would refuse `Lore/README.md` for a reason that
90
+ is not about addresses, and would report one gap twice in two vocabularies.
91
+ Whether a note's fields can be checked is a different question, answered
92
+ elsewhere and reported on the notes themselves.
93
+
94
+ `lintNote` / `lintFrontmatter` take a `types` option beside `sections`; both
95
+ default to empty, so a caller that passes neither is unaffected.
96
+
97
+ Closes #200
98
+
99
+ ## 11.0.0
100
+
101
+ ### Major Changes
102
+
103
+ - 51f9b4f: **The package homepage becomes an ordinary addressed note** (#182).
104
+
105
+ A homepage declares a `shortcode` — conventionally `root` — and publishes at
106
+ its address, `/<package>/homepage-root/`, written by the same rule as every
107
+ other page. `[[homepage-root|Read the introduction]]` is an ordinary wikilink
108
+ now, resolving to the page the build actually writes.
109
+
110
+ **Why it was the exception, and why it is not one any more.** A page's URL used
111
+ to derive from `name.full` while a homepage's destination was fixed at
112
+ `_index.md`, so a `shortcode` on one put the note in the address index and
113
+ `[[homepage-<shortcode>]]` resolved _green_ to a page nothing wrote. That is
114
+ what `HOMEPAGE_REFUSED_FIELDS` refused, and the reason was entirely an artifact
115
+ of name-derived URLs. #181 removed the premise: the address a `shortcode`
116
+ computes is the address the build publishes.
117
+
118
+ **Breaking, two ways.**
119
+
120
+ - **Every homepage note must now declare a `shortcode`.** Without one the note
121
+ has no address, and both `content-build lint` and `content-build site` refuse
122
+ it, located at the `type:` value that makes it necessary. All six trees need
123
+ the same one-line edit: `shortcode: root`.
124
+ - **The landing moves** from `/<package>/` to `/<package>/homepage-root/`.
125
+ `/<package>/` becomes a `301` the package authors in its own `_redirects`,
126
+ with a pinned `Cache-Control: max-age=3600` in `_headers` — Cloudflare Pages
127
+ sets no `Cache-Control` on a redirect it generates, and an unpinned 301 is
128
+ cacheable indefinitely under RFC 9111. See `CONTENT.md`.
129
+
130
+ | Field | Was | Now |
131
+ | ----------- | ------- | -------------------------------------------- |
132
+ | `shortcode` | refused | **required** |
133
+ | `name` | refused | permitted, like any other note's |
134
+ | `id` | refused | refused — a homepage compiles to no document |
135
+
136
+ `id` is unaffected because its reason is: a homepage appears in no pack, and it
137
+ stays out of the **link manifest** for the same reason, now that a shortcode
138
+ alone would put it in.
139
+
140
+ **What is deleted.** `HOMEPAGE_DESTINATION`, and the comment describing the
141
+ homepage as "the one page for which neither addressing rule holds" — it no
142
+ longer is. `homepageDestination(fm)` replaces the constant, and
143
+ `homepageFrontmatter` takes a `base` so the emitted page can state its `url`.
144
+
145
+ **Singularity is now stated as a cardinality rule.** It used to rest on the
146
+ fixed destination every homepage shared — the second silently overwrote the
147
+ first — so the address rule enforced it as a side effect. Two homepages publish
148
+ two pages now and collide over nothing, so `checkHomepageCount` says what it
149
+ means: a package has one front page, and nothing here can decide which of two
150
+ `/<package>/` should redirect to.
151
+ - 6291dec: **A published page's URL is its address, not its display name** (#181).
152
+
153
+ Every content page now serves at `/<package>/<type>-<shortcode>/`. `(type,
154
+ shortcode)` names one note within a package — the rule `content-build lint`
155
+ already enforces — so the URL is unique _by construction_: there is no
156
+ collision check behind it, and renaming a note moves no URL, because no part of
157
+ the address comes from a display string.
158
+
159
+ **Every published URL moves**, which is what makes this a major. `sohl`'s
160
+ `/sohl/kb/rules/shock/` becomes `/sohl/doc-shock/`; `thalorna`'s
161
+ `/thalorna/affiliation/the-aerarium-imperii/` becomes
162
+ `/thalorna/affiliation-aerarium/`. Measured across the three live trees: 1,595
163
+ `sohl` pages, 1,848 `thalorna`, 370 `kethira` — every one of them unique, and no
164
+ content edit required in any repository.
165
+
166
+ **What it replaces.** The URL derived from `name.full`, which made a display
167
+ name load-bearing three ways at once: a rename silently 404'd every inbound
168
+ link, two notes in one section could derive one URL so a uniqueness gate had to
169
+ run, and long names were shortened through a table of 200 abbreviations. The
170
+ module doing it justified the cost by promising redirects — _"every change
171
+ appends to the legacy-URL map"_ — and no such map was ever written, here or in
172
+ any consumer. An address needs none of it.
173
+
174
+ **Sections stay, as directories.** Hugo derives a page's section from where its
175
+ file is written, not from its URL, and that section is what supplies the section
176
+ landing pages, `.CurrentSection` and per-section layout lookup. So a page is
177
+ still written into `<section>/` and now carries a front-matter `url:` publishing
178
+ it at its address. A **landing page** is the one exception: it _is_ its section,
179
+ so it still addresses the section under the configured `publish.address.prefix`
180
+ (`kb/rules/`), which is why an entry's `path` is still written to the manifest
181
+ rather than left for a consumer to compute.
182
+
183
+ **The `type-` prefix is deliberate.** Flattening to `<shortcode>` would put
184
+ content in the same namespace as a package's fixed mounts — `/<package>/` for
185
+ the landing page, `/<package>/api/` for generated API docs — neither of which
186
+ contains a hyphen or names a type. With it the namespace is provably disjoint.
187
+
188
+ **Removed**
189
+
190
+ | Gone | Why |
191
+ | ----------------------------------------------- | -------------------------------------------- |
192
+ | `contentSlug` | Nothing derives a URL from a name. |
193
+ | `findSlugCollisions`, and the site build's gate | Two addresses cannot collide. |
194
+ | `ABBREVIATIONS` / `abbreviateTokens` | They only ever shortened a name-derived URL. |
195
+
196
+ `slugify` stays, unchanged and un-abbreviated, in the two places it was always
197
+ right for: heading anchors and pack filenames.
198
+
199
+ **Breaking, for a consumer calling the engine directly**
200
+
201
+ - `packageAddress(fm, name, options)` → `packageAddress(fm, options)`, and
202
+ `contentAddress(fm, name, isReadme)` → `contentAddress(fm, isReadme)`. The
203
+ name was the input the address no longer has.
204
+ - `addressSlug(fm)` is new: the `type-shortcode` segment, lowercased, so it is
205
+ exactly the tail of the canonical key.
206
+ - The site build's gate result renames `slugErrors` to `addressErrors` and drops
207
+ `collisions` entirely. A note with no `shortcode`, or no section to be filed
208
+ under, is reported there rather than published.
209
+ - `engine/abbreviations.mjs` is deleted.
210
+ - a9fb0fc: **Retire the bare `[[Alias]]` wikilink form and the alias index** (#180, resolving #179).
211
+
212
+ Every wikilink is now an **address**, and every wikilink carries a **label**. The
213
+ pipe no longer selects between two namespaces — there is only one — so a link
214
+ written without one addresses nothing and is a finding wherever it is met:
215
+ `content-build links` fails on it, the pack compilers fail the note, and the site
216
+ resolver reports it. The correction is always the same, and the message says so:
217
+ write `[[type-shortcode|Text]]`. `[[#slug|Text]]` still resolves; the link part
218
+ may be an anchor, it is the label that is required.
219
+
220
+ **Why.** The alias namespace was empty in practice. Across 8,305 wikilinks in the
221
+ three content trees, **not one** bare `[[Alias]]` resolved to a note. What the
222
+ index behind it did do was fold every note's `name.full` into itself, so two
223
+ notes of one type could not share a display name — five `doc` notes in the `sohl`
224
+ tree collided, and every available fix moved a published URL (#179). The rule had
225
+ never prevented a broken link; it had only ever forbidden a name.
226
+
227
+ **The top-level `aliases:` is a retired field.** It fed nothing else, so it is
228
+ now **refused** naming the file and the line, the same way `draft:` and
229
+ `package:` are, and reported by the frontmatter lint as well as at compile.
230
+
231
+ **`name.aliases` is kept, and is read by nothing.** It fed the same index and
232
+ lost the same reader, but it is **reserved** — held for a use that does not
233
+ exist yet — so it is deliberately neither retired nor consulted. No index folds
234
+ it in, no rule validates it, nothing derives a name, address or URL from it, and
235
+ the refusal above never mentions it. A note carrying one compiles, resolves and
236
+ addresses exactly as the same note without it, and `tests/name-aliases-reserved.test.ts`
237
+ pins that equivalence across the pack compile, both wikilink resolvers, the link
238
+ manifest and the site index, so a reader cannot be reintroduced unnoticed.
239
+
240
+ **Removed.** `engine/alias-index.mjs` in its entirety — `aliasesOf`, `aliasKey`,
241
+ `indexAliases`, and the `engine.aliasIndex` namespace export — along with
242
+ `resolvesAsAddress` from `engine/wikilink-syntax.mjs`, replaced by
243
+ `unlabelledLinkMessage`, which is the one place that states the rule for both
244
+ builds.
245
+
246
+ **API changes** for anything importing the engine directly:
247
+
248
+ | was | now |
249
+ | ------------------------------------------------------ | ------------------------------ |
250
+ | `index.resolve(note, target, labelled)` | `index.resolve(target)` |
251
+ | `index.resolveAlias`, `aliasClaims`, `aliasCollisions` | gone |
252
+ | `auditLinks().deadAliases` / `.aliasCollisions` | `auditLinks().unlabelledLinks` |
253
+ | `buildWikilinkIndex().byAlias` / `.aliasClaims` | gone |
254
+ | `buildSiteIndex().typeAlias` / `.typeCollide` | gone |
255
+ | `wikiContext()`'s `typeAlias` / `typeCollide` | gone |
256
+
257
+ `buildSiteIndex` no longer indexes a page by its **name**, filename or bare slug
258
+ — those were the collision-aware fallbacks the bare form was looked up in, and
259
+ nothing consults them now. `ambiguous` / `collide` becomes the set of short
260
+ `type/shortcode` addresses two **foreign packages** both publish, which the web
261
+ resolver now reports as ambiguous rather than as merely broken.
262
+
263
+ **Consumer impact, measured.** Every tree needs a mechanical frontmatter sweep
264
+ of the **top-level** field, which is authored almost everywhere: 1,609 notes in
265
+ `sohl`, 1,850 in `thalorna`, 370 in `kethira`. A `name.aliases:` is left exactly
266
+ where it is — the sweep must delete the top-level list only. Links are cheaper — `sohl` has **0** unlabelled
267
+ links and `kethira` has none at all; `thalorna` carries 70 (68 bare links and 2
268
+ pipe-less anchors), which is content work in its own repository. The five `sohl`
269
+ alias collisions and `thalorna`'s thirteen cease to exist with no note renamed
270
+ and no published URL moved.
271
+ - ffb1b04: **An address that resolves to no note fails every build** (#184).
272
+
273
+ A wikilink whose address names no document was a **warning** in `content-build
274
+ links`, a **failure** in the pack compilers, and — in the site build — _nothing
275
+ at all_ while any linkable package had no vendored manifest. One authored link,
276
+ three verdicts. This makes it an error everywhere, and makes the three resolvers
277
+ name and word every class of link failure identically.
278
+
279
+ **Why the tolerance is spent.** It existed because a bare `[[Sunless Vault]]`
280
+ might be a worldbuilding placeholder for a note nobody had written. That was a
281
+ property of the bare form, which #180 retired, and the intent behind it now has
282
+ a real spelling: a note tagged `draft` exists, resolves, compiles, publishes,
283
+ and renders its inbound links marked (#183). An address naming no note is a typo
284
+ or an omission, and both want fixing.
285
+
286
+ **One vocabulary, one message.** `engine/wikilink-syntax.mjs` — which already
287
+ held the syntax the three resolvers share — now also holds the closed set of
288
+ failure classes (`LINK_FINDING_REASONS`) and the message each reports through
289
+ (`linkFindingMessage`, `unresolvedAddressMessage`, `ambiguousAddressMessage`).
290
+ An author meets whichever build ran first, and a consumer switching on a
291
+ `reason` should not be switching on which build produced it.
292
+
293
+ | finding | checker | pack build | site build |
294
+ | ---------------- | ------- | ---------- | ---------- |
295
+ | `unlabelled` | error | error | error |
296
+ | `not-an-address` | error | error | error |
297
+ | `unknown-type` | error | error | error |
298
+ | `unresolved` | error | error | **error** |
299
+ | `ambiguous` | error | error | error |
300
+
301
+ **Breaking changes.**
302
+
303
+ - _The site build fails an unresolved address unconditionally._
304
+ `wikiContext()` no longer takes `manifestsComplete`, and `resolveWebWikilinks`
305
+ ignores one on the context. A missing manifest is now advice inside the
306
+ message rather than a reason to let the link through.
307
+ - _Two reason strings were renamed into the shared vocabulary._ The pack
308
+ build's `"unknown"` and the site build's `"broken type/shortcode"` are both
309
+ `"unresolved"`. A slash-qualified target naming no known type reports as
310
+ `"unknown-type"` on the site build too, matching the checker.
311
+ - _`ambiguous` is a class in all three._ An address more than one package
312
+ publishes was reported by the checker and the pack build as though nothing
313
+ published it. The finding carries the claiming `packages`, and the message
314
+ names them.
315
+ - _Site-build wikilink findings are compiler-parseable._ They were
316
+ `log.error("bad wikilink [[x]]: reason (file)")` — a `loglevel` timestamp
317
+ sitting exactly where a parser reads the path from. They are now
318
+ `file:line:column: error: message`, located by the authored link's own
319
+ position in the source note, like every other diagnostic.
320
+ - _Pack-build failure messages are reworded_ by the shared table, and name the
321
+ address rather than the whole authored link.
322
+
323
+ **Consumer impact: none measured.** Across the three content trees — `sohl`
324
+ (1,607 notes / 3,618 links), `thalorna` (1,849 / 7,913) and `kethira` (371 / 0)
325
+ — the promotion produces **zero** new findings. `thalorna`'s 66 dead addresses
326
+ are all `not-an-address`, which was already an error, and its 70 unlabelled
327
+ links are #180's. No tree carries an address that resolves nowhere.
328
+
329
+ ### Minor Changes
330
+
331
+ - ba51273: Mark a link whose target is tagged `draft` (#183).
332
+
333
+ A note that exists only so a link is not dead is now visibly distinct from one
334
+ that is written. Both builds wrap such a link in
335
+ `<span class="sohl-draft-link" title="Draft — not yet written">…</span>` —
336
+ byte-identically, as `unresolvedLink` already does, so one authored link carries
337
+ the same cue in a compiled journal and on the website. The link inside is
338
+ untouched: Foundry enriches inside HTML and Goldmark parses markdown inside an
339
+ inline span, so it is still a live link either way.
340
+
341
+ **The note stays in the graph.** This marks at presentation and nothing else.
342
+ Resolution, validation, pack compilation and manifest membership are unchanged
343
+ for a draft note, which is what separates the tag from the retired `draft:`
344
+ field — that field moved a note from _published_ to unresolvable without saying
345
+ so, and suppressed the build failures the note carried. Nothing here reinstates
346
+ any of it, and declaring the field is still refused by name.
347
+
348
+ **Read from the tag vocabulary, not respelt.** `draft` is declared in
349
+ `DECLARED_TAGS.state` (#172) and exported as `DRAFT_TAG`, with `isDraftNote()`
350
+ as its one reader — so the declared tag and the thing that acts on it cannot
351
+ drift apart. This is the first thing in either build to read `tags`.
352
+
353
+ **For consumers.** The class carries no appearance of its own. A Foundry system
354
+ supplies it in an SCSS partial beside `_unresolved-link.scss`; a site supplies
355
+ it in its theme. Until one does, a draft link renders exactly as it did before.
356
+
357
+ ### Patch Changes
358
+
359
+ - 743b303: **`collectContentPages` refuses a missing `base` instead of publishing to `undefined/`.**
360
+
361
+ A page's URL is built as `` `${ctx.base}${slug}/` `` since _a page URL is its
362
+ address_ (#181). When `base` was absent the template still ran, so every
363
+ non-landing page in that build published at `undefineddoc-<shortcode>/` with no
364
+ diagnostic. The function already guards the section immediately below, on the
365
+ stated grounds that a note with none "is reported rather than written to
366
+ `undefined/`" — the same reasoning applies to `base`, which affects _every_ page
367
+ rather than one.
368
+
369
+ It is a caller contract rather than a note defect, so it throws rather than being
370
+ collected as a finding.
371
+
372
+ Fixes the two in-repo callers that still supplied the pre-#181 options and had
373
+ gone unnoticed: the end-to-end draft-link case (#183) and a homepage case, whose
374
+ combination with #181 left `main` red — neither pull request failed alone.
375
+
376
+ Closes #195
377
+
3
378
  ## 10.0.1
4
379
 
5
380
  ### Patch Changes