@heroiclands/package-build 0.6.1 → 3.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 (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +58 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -35
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. package/types/sohl/note-schemas.d.mts +7 -0
package/CONTENT.md ADDED
@@ -0,0 +1,824 @@
1
+ # @heroiclands/package-build
2
+
3
+ The shared toolchain that compiles a **HeroicLands content tree** — a folder of
4
+ Markdown notes with YAML frontmatter — into **Foundry VTT compendium packs**.
5
+
6
+ Every HeroicLands content module (`sohl`, `thalorna`, `kethira`, and the
7
+ adventure modules) builds its packs from this one implementation, rather than
8
+ from a copied `utils/packs/` tree.
9
+
10
+ It ships a command line as well as a library — see
11
+ [Command line](#command-line) for the whole surface.
12
+
13
+ ## Install
14
+
15
+ ```
16
+ npm install -D @heroiclands/package-build
17
+ ```
18
+
19
+ ## Configure
20
+
21
+ A consuming repository declares one `package-build.config.yaml` at its root:
22
+
23
+ ```yaml
24
+ # The value each content note carries in its `package:` frontmatter.
25
+ contentPackage: thalorna
26
+ # Where Foundry installs it: "systems" or "modules". Also decides the served
27
+ # asset root a note's `img:` resolves to — `modules/sohl-thalorna/assets/…`.
28
+ packageKind: modules
29
+
30
+ # The Foundry core range this package supports. `minimum` is stamped into every
31
+ # compiled document as `_stats.coreVersion`; `verified` names the newest build
32
+ # the full suite has actually passed on — never an aspiration.
33
+ compatibility:
34
+ minimum: "14.359"
35
+ verified: "14.364"
36
+
37
+ # What this package declares about others, in Foundry's own shape. A module's
38
+ # `_stats.systemVersion` comes from the `verified` version of the system it
39
+ # targets — note that this `compatibility` is the *system's* range, not
40
+ # Foundry's. Same key, different subject.
41
+ relationships:
42
+ systems:
43
+ - id: sohl
44
+ type: system
45
+ manifest: https://github.com/HeroicLands/Song-of-Heroic-Lands-FoundryVTT/releases/latest/download/system.json
46
+ compatibility:
47
+ minimum: "0.4.0"
48
+ verified: "0.4.3"
49
+
50
+ # Stamped into every compiled document's `_stats`. `coreVersion` and
51
+ # `systemVersion` are both absent on purpose — see the derived table below.
52
+ stats:
53
+ systemId: sohl
54
+ lastModifiedBy: thalornabuild000
55
+
56
+ # Which content types compile into Items, and what builds each one's `system`
57
+ # block — named, because the registry is code. The registry's keys are the
58
+ # accepted item types, so a type cannot be whitelisted without a builder behind
59
+ # it. A module that ships no items omits this key. See "An item type's default
60
+ # art" below, and "A registry of your own" for the `.mjs` form.
61
+ itemBuilders: sohl
62
+
63
+ # Directory names the content walk ignores wherever they appear.
64
+ skipDirectories: [Templates]
65
+
66
+ # Optional; each path is relative to this file's directory and defaults to the
67
+ # conventional layout shown here.
68
+ paths:
69
+ content: assets/content
70
+ # Vendored foreign manifests, read by `links`. Inbound.
71
+ manifests: assets/manifests
72
+ # Where `manifest` writes this package's own. Outbound, and a build artifact.
73
+ manifestOut: build/manifests
74
+ packJson: build/packs-json
75
+ stage: build/stage/packs
76
+ unpack: build/tmp/packs
77
+
78
+ # The one pack list. Order is load-bearing where one pass reads another's
79
+ # output, and `packDirectories` is derived from it.
80
+ packs:
81
+ - { name: items, type: Item, label: Items, folders: item-folders.yaml }
82
+ - { name: journals, type: JournalEntry, label: Journals }
83
+ # A companion is written by its parent's pass rather than one of its own.
84
+ - name: scenes
85
+ type: Scene
86
+ companions:
87
+ - { name: adventures, type: Adventure }
88
+
89
+ # How this repository frames the pages `content-build docs` generates. The
90
+ # tables come from the itemBuilders registry and are the same everywhere; the
91
+ # heading, the filing and what a reader is told first are this repository's.
92
+ docs:
93
+ itemFields:
94
+ title: Item Note Frontmatter
95
+ out: kb/dev-docs/content-creator/item-frontmatter.md
96
+ preamble:
97
+ - "See also: [The Authoring Workflow](authoring-workflow.md)"
98
+ - ""
99
+ - Every item note carries the frontmatter envelope described there. This
100
+ page covers what each **type** adds to it.
101
+
102
+ # Reserved for @heroiclands/package-build, which validates what is inside it.
103
+ # One repository describes itself in one file; the two build packages split by
104
+ # input, and neither learns the other's schema. Values package-build needs that
105
+ # already live at the top level — `packageKind`, `foundryPackage` — it reads
106
+ # from there rather than restating them here.
107
+ packageBuild:
108
+ assets:
109
+ - { from: assets/icons, to: assets/icons }
110
+
111
+ # Three independent switches — every combination is real — plus the address
112
+ # scheme both `manifest` and `site` derive addresses under.
113
+ publish:
114
+ site: true
115
+ manifests: { publish: true, consume: true }
116
+ address:
117
+ prefix: kb/
118
+ landing: readme
119
+
120
+ # How this repository frames the website `content-build site` publishes.
121
+ # Framing only: addresses come from `publish.address` above.
122
+ site:
123
+ out: kb/content
124
+ landing: { title: Knowledgebase, type: knowledgebase }
125
+ sections:
126
+ being: { title: Beings, banner: banners/creature.webp }
127
+ ```
128
+
129
+ The loader validates the document, resolves every path against the directory
130
+ the file sits in, fills the optional halves with their defaults
131
+ (`skipDirectories: []`, `packageBuild: {}`, the conventional `paths`, every
132
+ publishing switch off),
133
+ derives `assetRoot`, `packDirectories`, `itemTypes` and `docEntryTypes`, and
134
+ freezes the result. A malformed configuration throws a `TypeError` naming the
135
+ offending field, so it fails at load rather than as an empty pack much later.
136
+
137
+ **Four values are derived rather than authored**, because each is something a
138
+ file can be asked for rather than told:
139
+
140
+ | Field | Derived from |
141
+ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
142
+ | `rootDir` | the directory the configuration file sits in |
143
+ | `foundryPackage` | the `name` of the adjacent `package.json`, verbatim |
144
+ | `stats.systemVersion` | a **system**: that `package.json`'s `version`. A **module**: the `verified` version of the system it declares a relationship with |
145
+ | `itemBuilders` | the named registry (`sohl`), required lazily so importing costs nothing |
146
+
147
+ **Authoring any of the first three is an error**, not an override. Each was
148
+ previously transcribed from a file that already stated it, and a transcription
149
+ is free to disagree with what it copies — `stats.systemVersion` froze at
150
+ `0.6.0` for four releases before anyone noticed, and was still frozen there in
151
+ two repositories afterwards.
152
+
153
+ A module does **not** take its system version from its own `package.json`: that
154
+ is the _module's_ version, and stamping it would claim a system version that
155
+ never existed. A module declaring no usable system relationship fails the build
156
+ rather than guessing — a wrong `_stats.systemVersion` is invisible until
157
+ something migrates on it.
158
+
159
+ ### A registry of your own
160
+
161
+ `itemBuilders` is the one part of the contract that is code — a table of
162
+ functions building each type's `system` block — so data can only _name_ one of
163
+ the registries this package ships. A consumer supplying its own writes
164
+ `package-build.config.mjs` instead, which is loaded in place of the YAML:
165
+
166
+ ```js
167
+ import { defineConfig } from "@heroiclands/package-build/content-config";
168
+ import { ITEM_BUILDERS } from "./build/item-builders.mjs";
169
+
170
+ export default defineConfig({
171
+ // Stated, since a code configuration derives nothing: it is code, and can
172
+ // read whatever it likes for itself.
173
+ rootDir: import.meta.dirname,
174
+ contentPackage: "kethira",
175
+ foundryPackage: "sohl-kethira-basic",
176
+ packageKind: "modules",
177
+ compatibility: { minimum: "14.359", verified: "14.364" },
178
+ stats: { systemId: "sohl", systemVersion: "0.4.3", lastModifiedBy: "…" },
179
+ itemBuilders: ITEM_BUILDERS,
180
+ packs: [{ name: "items", type: "Item" }],
181
+ });
182
+ ```
183
+
184
+ The two forms end at the same `defineConfig`, so they are validated and frozen
185
+ identically; a code config simply states the three fields above itself, which it
186
+ can, because it is code. **Import `defineConfig` from
187
+ `@heroiclands/package-build/content-config`, never from the package root** — the root
188
+ barrel pulls in the compilers, the compilers read the resolved configuration,
189
+ and resolving it loads this file, so importing the barrel here closes a cycle
190
+ around the file's own evaluation. The `/config` entry point imports nothing but
191
+ `node:path` and the id helpers, so it cannot.
192
+
193
+ **One directory, one configuration.** A directory holding both a `.yaml` and an
194
+ `.mjs` is an error, not a precedence question: picking one would let a
195
+ repository mid-conversion build from the file its author is no longer editing,
196
+ and look entirely healthy doing it.
197
+
198
+ ### Several packs of one document type
199
+
200
+ A repository may declare more than one pack of the same `type`, and route notes
201
+ between them. Editorial grouping of same-type documents into separate
202
+ compendiums is ordinary Foundry practice — "Core Spells" and "Expanded Spells"
203
+ are two Item packs — and it matters beyond taste: a compendium UUID carries its
204
+ pack name (`Compendium.<package>.<pack>.Item.<id>`), so collapsing several packs
205
+ into one invalidates every reference an existing world holds.
206
+
207
+ Two axes, deliberately orthogonal:
208
+
209
+ - a pack's **`type`** selects the _compiler_ that fills it;
210
+ - a note's **`pack:`** frontmatter selects _which pack of that type_ receives its
211
+ document.
212
+
213
+ ```yaml
214
+ packs:
215
+ - { name: characteristics, type: Item, default: true }
216
+ - { name: mysteries, type: Item }
217
+ - { name: journals, type: JournalEntry }
218
+ ```
219
+
220
+ ```yaml
221
+ # A note that says nothing lands in `characteristics`, the default Item pack.
222
+ ---
223
+ name:
224
+ full: Climbing
225
+ type: skill
226
+ package: kethira
227
+ id: ...
228
+ ---
229
+ # A note that names one lands there instead.
230
+ ---
231
+ name:
232
+ full: Second Sight
233
+ type: skill
234
+ package: kethira
235
+ id: ...
236
+ pack: mysteries
237
+ ---
238
+ ```
239
+
240
+ - **`pack:` is optional, and silence means the default.** Every note written
241
+ before this existed declares nothing, so an undeclared note must keep
242
+ compiling exactly where it always did. A type with exactly **one** pack is
243
+ that type's default implicitly; a type with several designates one with
244
+ `default: true`. Where several exist and none is marked, a declaration is
245
+ **mandatory** and an undeclared note fails the build.
246
+ - **A `pack:` naming no configured pack is a build error**, not a fall-through to
247
+ the default. A typo'd name that quietly landed content in the wrong compendium
248
+ would be silent partial compilation — the failure mode this toolchain's guards
249
+ exist to eliminate. The same applies to a name that belongs to a pack of
250
+ another document type, or to a companion (no note is ever routed into one).
251
+ - **A note's `pack:` names where its _own_ document goes.** Anything derived from
252
+ it — an item's or a macro's prose, which compiles into a JournalEntry of its
253
+ own — lands in the default pack of _that_ type.
254
+
255
+ **The configuration is found by walking up, not from the working directory.**
256
+ `engine/pack-config.mjs` climbs from itself — so it works from `packages/` and
257
+ from `node_modules/` alike, and does not depend on the directory the build was
258
+ launched from. Set `PACKAGE_BUILD_CONFIG` to point at the file explicitly if a
259
+ consumer keeps it somewhere else.
260
+
261
+ **The configuration is resolved on first read, never at import.** Every module
262
+ here can be imported — and `content-build --version` and `--help` answered — in a
263
+ directory with no `package-build.config.yaml` and no Foundry package manifest, so
264
+ a consumer can reach for one pure helper (`engine/content-slug`,
265
+ `engine/wikilinks`) without standing up a pack build. Anything derived from
266
+ configuration is therefore an accessor rather than a hoisted constant —
267
+ `loadPackConfig()`, `contentPackage()`, `foundryPackageId()`, `itemTypes()`,
268
+ `docEntryTypes()`, `packRouter()`, `defaultTemplateDir()` — and each throws, with
269
+ the same explicit message as before, the moment a build actually needs a value it
270
+ cannot find. Absence is still a hard failure; only the moment it is reported
271
+ moved (#2).
272
+
273
+ The file is read synchronously — an `.mjs` one with `require` — so that reading
274
+ a configured value stays an ordinary expression instead of making every module
275
+ downstream of it an async one. The one shape that cannot be loaded is an `.mjs`
276
+ config whose own module graph uses top-level `await`, which is reported as such.
277
+
278
+ **`itemBuilders` is how the engine learns a consumer's item types without
279
+ holding its data model.** `itemTypes` is its key set, and `docEntryTypes` — every
280
+ type whose prose compiles into a JournalEntry of its own — is composed from it
281
+ exactly once, here, and read through `loadPackConfig()` everywhere. There is one
282
+ resolved set at runtime; the compilers and the link-manifest emitter cannot come
283
+ to disagree about which notes carry documentation.
284
+
285
+ The Item compiler **dispatches through that same resolved table**, via
286
+ `engine/item-registry.mjs` (`itemTypes()` and `itemBuilder(type)`), so the types a
287
+ consumer's notes are accepted for and the builders they compile with are one
288
+ object. Supplying `itemBuilders` is therefore all a consumer does to define an
289
+ item type of its own; a table this package ships is one possible value, not the
290
+ one the compiler holds.
291
+
292
+ **Configuration is the source, and the manifest is generated from it.** That
293
+ arrow used to point the other way: `paths.packageManifest` said where a
294
+ hand-authored `system.template.json` lived, and the package-id guard and the
295
+ `_stats.coreVersion` stamp both read out of it. Both are gone — the floor is the
296
+ top-level `compatibility.minimum`, the id is derived from `package.json`
297
+ `name`, and `@heroiclands/package-build` writes the manifest from this file.
298
+
299
+ ### An item type's default art
300
+
301
+ A note that carries no `img:` gets its type's **default art**, and a type
302
+ declares that art in the same place it declares its builder. An `itemBuilders`
303
+ entry may be written two ways:
304
+
305
+ ```js
306
+ itemBuilders: {
307
+ // A bare builder. Every note of this type must carry its own `img:`.
308
+ charm: buildCharm,
309
+ // The same builder, paired with the art a note of this type gets when it
310
+ // sets no `img:` of its own.
311
+ relic: { system: buildRelic, img: "icons/relic.svg" },
312
+ }
313
+ ```
314
+
315
+ Both spellings are equal; the difference is only whether the type brings art.
316
+ `itemTypes` is still the key set either way, so a type is still impossible to
317
+ whitelist without a builder behind it.
318
+
319
+ **The path is spelled the way a note spells it.** Registry art goes through the
320
+ same `resolveImg` rule as a note's `img:`, so `icons/relic.svg` means _this_
321
+ repository's asset root — `modules/sohl-relics/assets/icons/relic.svg` — and an
322
+ already-served path (`systems/sohl/assets/icons/…`) passes through untouched.
323
+
324
+ **A type with neither is a build error, deliberately.** When a note sets no
325
+ `img:` and its type pairs none, the pack build aborts rather than shipping an
326
+ item with a mismatched icon:
327
+
328
+ ```
329
+ No default art for item type "relic" — the note carries no `img:`, and the
330
+ `itemBuilders` entry for "relic" in this repository's configuration
331
+ pairs none with its builder.
332
+ ```
333
+
334
+ #### Why art travels with the builder (#7)
335
+
336
+ It did not always. The item **type** whitelist was derived from a consumer's
337
+ `itemBuilders` keys, while the **art** for those same types was looked up in
338
+ `sohl/default-item-art.mjs` — a table this package ships for the `sohl` package
339
+ and which a consumer cannot add to. A type was therefore configurable while its
340
+ default art was not, and a second consumer's own item type compiled only if
341
+ every one of its notes carried an explicit `img:`; the first note that omitted
342
+ one failed the build with an error naming a module in someone else's package.
343
+
344
+ Widening that map was not the fix. It is deliberately SoHL data, shared with the
345
+ runtime's `SohlItem.getDefaultArtwork` so that the build-time and runtime
346
+ defaults are one list and cannot drift (SoHL#932/#1510). Pairing art with the
347
+ builder instead moves it onto the seam a type is _already_ declared through, and
348
+ costs the `sohl` package nothing: `ITEM_BUILDERS` reads each entry's image out of
349
+ that same map, so there is still exactly one map — and the drift a test used to
350
+ watch for is now unrepresentable, because building the registry throws if a type
351
+ has no art.
352
+
353
+ ## Command line
354
+
355
+ ```
356
+ npx content-build package <compile|unpack|clean> [pack] [entry]
357
+ npx content-build docs item-fields [--out <path>] [--title <title>]
358
+ npx content-build lint [root] [--no-references]
359
+ npx content-build links [root] [--manifests <dir>]
360
+ npx content-build format [paths..] [--write]
361
+ npx content-build markdown [paths..] [--fix]
362
+ npx content-build manifest [root] [--out <dir>]
363
+ npx content-build site [--out <dir>]
364
+ npx content-build reachability <dir> [file] [--index <shortcode>]
365
+ ```
366
+
367
+ | Command | What it does |
368
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------- |
369
+ | `package` | Compile the content tree into LevelDB packs, unpack a shipped pack back to JSON, or clean one. See [Install](#install). |
370
+ | `docs` | Render a generated reference from the configured registries. `item-fields` is the item-frontmatter page. |
371
+ | `lint` | Check a content tree's addresses and its frontmatter. See [Linting a content tree](#linting-a-content-tree). |
372
+ | `links` | Check that every link in the tree lands: dead anchors, dead qualified addresses, wikilinks in frontmatter, drifted manifests. |
373
+ | `format` | Prettier, with the shared configuration. See [Prose: formatting and markdown](#prose-formatting-and-markdown). |
374
+ | `markdown` | markdownlint, with the shared rule set — the structure Prettier is indifferent to. |
375
+ | `manifest` | Emit this package's cross-package link manifest. See [Publishing a link manifest](#publishing-a-link-manifest). |
376
+ | `site` | Publish the content tree as a website. See [Publishing a website](#publishing-a-website). |
377
+ | `reachability` | Walk outward from an index note and report what no path reaches, for a tree meant to be navigable from one entry point. |
378
+
379
+ Every path, pack name and root it needs comes from the consuming repository's
380
+ `package-build.config.yaml`, so the usual invocation takes no arguments beyond
381
+ the command itself. What may be named on the command line overrides that.
382
+
383
+ **Every invocation it accepts is one it performs.** A missing command, an
384
+ unknown command, a missing or unknown action, and an unknown option are each an
385
+ error that names what was wrong and exits non-zero — never a silent success. A
386
+ build chain can therefore treat a zero exit as "the work happened". `--version`
387
+ and `--help` still answer in a directory with no configuration at all.
388
+
389
+ ## Linting a content tree
390
+
391
+ ```bash
392
+ npx content-build lint # the configured `paths.content`
393
+ npx content-build lint some/tree # or a tree named outright
394
+ ```
395
+
396
+ Checks the two rules every note's **identity** is authored against, and reports
397
+ each finding in the located form below:
398
+
399
+ - **Shape** — a `shortcode` is strictly ASCII-alphanumeric. It is the identity
400
+ key referenced from saved world data, and half of the `type-shortcode`
401
+ address, whose parse needs the separating hyphen to be the only hyphen.
402
+ - **Uniqueness** — `(type, shortcode)` names one note. A document is addressed
403
+ across _every_ pack of its document type, so routing two same-address notes to
404
+ different packs with `pack:` does not separate them.
405
+
406
+ It compiles nothing, opens no LevelDB and needs no Foundry manifest, so it runs
407
+ in about a second and can gate a commit. An empty or untyped tree **fails**
408
+ rather than passing: "every one of nothing is unique" is a vacuous pass, and it
409
+ is exactly what a tree that failed to check out produces.
410
+
411
+ ### Frontmatter, against the schema its type declares
412
+
413
+ The same command also checks that each note's `sohl:` block is what its **type**
414
+ allows (#19). Five classes, all of them mistakes that were previously reported
415
+ somewhere other than where they were made, or not at all:
416
+
417
+ - **Unknown or retired type** — a note on a retired spelling is told what
418
+ replaced it.
419
+ - **Missing required property** — `dimensions` on a map, `subType` on a skill.
420
+ - **Wrong value shape** — `weight: heavy` where a number belongs.
421
+ - **Unknown property** — _the allow-list made loud_. The builders discard a
422
+ `sohl:` key no field declares, with no warning and no effect on the exit code,
423
+ which is how 204 kethira mystical abilities shipped with no affiliation (#3).
424
+ A near miss is named: `Did you mean "masteryLevelBase"?`
425
+ - **Dead shortcode reference** — `assocSkillCode` naming a skill nothing
426
+ declares. Resolved through the same resolver `links` uses, so a cross-package
427
+ reference answered by a vendored manifest lands exactly as it would in a
428
+ wikilink. `--no-references` turns this one off for a tree whose cross-package
429
+ references it cannot see.
430
+
431
+ **A schema says what a note may _write_, not what the compiler emits.** Those
432
+ are different, and the difference is the whole calibration of the check: a note
433
+ also feeds a knowledgebase and a website, and those read classification the pack
434
+ build never compiles — `kbcat` alone appears 51 times in SoHL's knowledgebase
435
+ layouts. Equating the vocabulary with the builder's allow-list reported 4,241
436
+ unknown properties against SoHL's own tree, every one correctly authored.
437
+
438
+ Item types need no separate declaration: their field list already _is_ the
439
+ builder, so schema and compiler cannot disagree. The hand-written compilers —
440
+ `being`, `macro`, `doc` and the three map types — declare theirs in
441
+ `sohl/note-schemas.mjs`.
442
+
443
+ Nothing here writes. A check reports and an author fixes.
444
+
445
+ **A third rule was retired (#79).** Every note used to be required to repeat its
446
+ own `type-shortcode` address in `aliases:`. That served one reader — Obsidian,
447
+ so `[[type-shortcode]]` resolved in the editor — and no build ever read it: both
448
+ resolvers parse the hyphen qualifier themselves. The project no longer authors
449
+ in Obsidian, so the rule cost a line of frontmatter per note for a reader that
450
+ does not exist. Removing it was verified output-neutral first: across 1,735
451
+ stripped notes, `package compile` produced byte-identical `build/packs-json` and
452
+ the site build byte-identical `site/content`.
453
+
454
+ ## Prose: formatting and markdown
455
+
456
+ ```bash
457
+ npx content-build format # check the whole repository
458
+ npx content-build format --write # rewrite what is not formatted
459
+ npx content-build markdown # lint every markdown file
460
+ npx content-build markdown --fix # apply the fixes markdownlint can make
461
+ ```
462
+
463
+ Two conventions every content repository writes to, declared once here so a note
464
+ formatted in one is formatted the same way in the next (#69):
465
+
466
+ - **`format`** runs Prettier. Same values SoHL has always used, so a module or a
467
+ note moving between repositories does not reformat on arrival.
468
+ - **`markdown`** runs markdownlint — the structural checks Prettier cannot make:
469
+ a heading level that skips, two sibling headings claiming one anchor, a
470
+ reversed `(text)[url]`, a bare URL, an empty link, a table row with the wrong
471
+ cell count, and the emphasis markers (`_emphasis_`, `**strong**`) these
472
+ repositories write.
473
+
474
+ The rule set is **deliberately narrow**. markdownlint's defaults over a content
475
+ tree produce tens of thousands of findings, almost all of them line length, list
476
+ indentation and blank lines — Prettier's territory. So `default` is off and each
477
+ rule is enabled by name, with the reason it earns its place; add one only if it
478
+ can report that a page is _wrong_.
479
+
480
+ Both run over the **repository**, not the content tree, and neither reads the
481
+ pack configuration — a repository's formatting covers everything it holds, and
482
+ one that has not configured this package at all can still format itself.
483
+
484
+ **What ships here is a default, not an override.** A consumer's own Prettier
485
+ config or `.markdownlint-cli2.jsonc` wins wherever it has one. Which paths to
486
+ skip is knowledge about a repository's layout and stays with that repository, in
487
+ its own `.prettierignore` and `.gitignore` — both honoured, as Prettier and
488
+ markdownlint honour them natively. The one exception is `CHANGELOG.md`, which
489
+ `changeset version` regenerates in every repository here: linting it reports on
490
+ the generator, so it is skipped by default.
491
+
492
+ Neither tool's file discovery is reimplemented, so `content-build format --check`
493
+ and a bare `prettier --check .` report the same thing. A file Prettier cannot
494
+ parse is a **finding**, with its position — not a crash that costs the report on
495
+ every other file.
496
+
497
+ To make an editor agree with the lint chain, point its config at the same rules:
498
+
499
+ ```js
500
+ // prettier.config.mjs
501
+ export { default } from "@heroiclands/package-build/prettier";
502
+ ```
503
+
504
+ ```js
505
+ // .markdownlint-cli2.mjs — extending rather than replacing
506
+ import shared from "@heroiclands/package-build/markdownlint";
507
+ export default { ...shared, config: { ...shared.config, MD013: true } };
508
+ ```
509
+
510
+ ## Publishing a link manifest
511
+
512
+ ```bash
513
+ npx content-build manifest # the configured tree and output directory
514
+ npx content-build manifest --out tmp/ # or somewhere else
515
+ ```
516
+
517
+ Writes `<contentPackage>.json` naming every note this package publishes, keyed by
518
+ the canonical `package-type-shortcode` address and valued with every address that
519
+ note has: a `path` on the web, a `uuid` in Foundry, the `anchors` its named
520
+ sections compiled to, and a `doc` pointer where an item's prose compiles into a
521
+ JournalEntry of its own. A consuming build vendors the file into its own
522
+ `paths.manifests` and resolves cross-package links through it — the counterpart
523
+ of `links`, which consumes what this emits.
524
+
525
+ It reads its whole input from configuration and takes nothing else:
526
+
527
+ | Setting | What it decides |
528
+ | --------------------------- | ------------------------------------------------------------- |
529
+ | `contentPackage` | The package emitted, and which notes belong to it. |
530
+ | `foundryPackage` | The package every emitted `uuid` names. |
531
+ | `paths.content` | The tree walked. |
532
+ | `paths.manifestOut` | Where the file lands (`build/manifests` by default). |
533
+ | `publish.manifests.publish` | Whether this repository publishes one at all. |
534
+ | `publish.site` | Whether entries carry a `path` — see below. |
535
+ | `publish.address` | The address scheme those paths are derived under — see below. |
536
+
537
+ **Both addresses are optional, independently.** A note that compiles into no
538
+ document has no `uuid`, and a package that ships compendiums and publishes no
539
+ site (`publish.site: false`) has no `path` on any entry. Neither is an error, and
540
+ neither is guessed: inventing the missing one asserts a target that does not
541
+ exist, which is the silent dead link the manifest exists to prevent.
542
+
543
+ **`publish.manifests.publish` is a declaration, not a preference.** The file is
544
+ vendored by other repositories and read as authoritative, so emitting one is a
545
+ statement about this package. With the switch off the command fails rather than
546
+ writing.
547
+
548
+ ### The address scheme
549
+
550
+ Where the content tree mounts _inside the package_, and which note addresses a
551
+ whole section rather than a page within one, differ between repositories and are
552
+ both load-bearing. They are one setting, read by this command **and** by the page
553
+ emitter, so the address a manifest publishes is the address a page is emitted at
554
+ — stating it twice is how a manifest comes to assert a URL that resolves at build
555
+ time and 404s for the reader.
556
+
557
+ ```yaml
558
+ publish:
559
+ site: true
560
+ manifests: { publish: true, consume: true }
561
+ address:
562
+ prefix: kb/ # default: "" — the package root
563
+ landing: readme # default: readme
564
+ ```
565
+
566
+ - **`prefix`** — the content tree's mount within the package. `sohl` publishes a
567
+ knowledgebase alongside generated API docs, so its notes sit under `kb/`
568
+ (`kb/affliction/aconite/`); `thalorna`'s site is nothing but its content, so it
569
+ has no prefix (`affiliation/the-aerarium-imperii/`). It must end in a slash and
570
+ must not begin with one — where the _package_ is mounted is the consuming
571
+ build's knowledge and is never recorded here.
572
+ - **`landing`** — which note is a section's landing page, and so has no slug of
573
+ its own:
574
+ - `readme` — a `README.md` addresses its section. A `doc` note then routes by
575
+ its `category` like any other, so a `category: collection` note publishes
576
+ under a literal `collection/` section.
577
+ - `collection` — a `doc` note whose `category` is `collection` addresses the
578
+ section it introduces, named by its authored `section`.
579
+
580
+ The two are alternatives rather than a pair that could both apply: each live
581
+ content tree holds notes the other rule would move.
582
+
583
+ A note the scheme yields no address for — a `doc` with no category, a collection
584
+ note naming no section — is **reported and omitted**, never guessed. The command
585
+ prints one located diagnostic per note and still writes the file, because a note
586
+ with no address is ordinary while a manifest entry pointing at a page that does
587
+ not exist is not.
588
+
589
+ ## Publishing a website
590
+
591
+ ```bash
592
+ npx content-build site # the configured tree and output
593
+ npx content-build site --out tmp/kb # or somewhere else
594
+ ```
595
+
596
+ The sibling of `package compile`: the same content tree, rendered as pages
597
+ instead of compiled into packs. It does the walk, the frontmatter read, the
598
+ address derivation, the address index, table expansion, wikilink resolution,
599
+ code-fence protection, the foreign-manifest merge, the page emission and the
600
+ section-landing backfill.
601
+
602
+ **What it does not do is decide addresses.** Those come from `publish.address`,
603
+ the same setting the link manifest reads, so a page and its manifest entry cannot
604
+ disagree about where the page is. Everything under `site:` is _framing_ —
605
+ where the tree is written, what a section is called, which extra trees are
606
+ published beside the content:
607
+
608
+ ```yaml
609
+ site:
610
+ out: kb/content # required; wiped on every run
611
+ base: /sohl/ # default: /<contentPackage>/
612
+ packages: [sohl, thalorna] # default: just contentPackage
613
+ backfillSections: true
614
+ landing: { title: Knowledgebase, type: knowledgebase }
615
+ pass: sohlKb
616
+ passOptions:
617
+ apiBase: /sohl/api/
618
+ symbolMap: kb/data/api-symbols.json
619
+ blob: https://github.com/HeroicLands/…/blob/main/
620
+ trees:
621
+ - { from: kb/dev-docs, section: dev-docs }
622
+ sections:
623
+ being: { title: Beings, banner: banners/creature.webp }
624
+ readmeSections:
625
+ dev-docs: { title: Developer Documentation, banner: banners/dev-docs.webp }
626
+ ```
627
+
628
+ | Key | What it decides |
629
+ | ------------------ | ------------------------------------------------------------------------------------------------ |
630
+ | `out` | The Hugo content root. **Required**, and wiped on every run — see the safety note below. |
631
+ | `base` | Where the package is served. Defaults to `/<contentPackage>/`. |
632
+ | `packages` | Which content packages this site renders. Defaults to its own. |
633
+ | `sections` | Landing title and hero per section, so a landing matches the card that links to it. |
634
+ | `readmeSections` | The same, for a section whose landing comes from a `README`. |
635
+ | `landing` | Frontmatter for the mount's own `_index.md`. Passed through — the vocabulary is the theme's. |
636
+ | `backfillSections` | Write a bare `_index.md` for any other section directly under the mount. |
637
+ | `trees` | Extra source trees published beside the content, preserving their source layout below a section. |
638
+ | `pass` | A named bundle of this repository's own body rewrites. |
639
+ | `passOptions` | That bundle's options. |
640
+
641
+ ### Why `out` is required
642
+
643
+ The output tree is a build artifact and is **deleted on every run**, so that a
644
+ page whose note was renamed cannot linger and keep publishing. An unset `out`
645
+ resolves to the repository root, and the wipe then deletes the working tree.
646
+ That is not hypothetical — it happened while this command was being written, on
647
+ a configuration that simply had no `site` section yet. So `out` is refused when
648
+ unset, and refused again when it resolves anywhere that is not strictly inside
649
+ the repository root.
650
+
651
+ ### Consumer passes are named, not imported
652
+
653
+ A repository's own body rewrites are code, and a configuration is data, so a
654
+ configuration **names** a bundle and the toolchain resolves it — exactly as
655
+ `itemBuilders` names an item registry. `sohlKb` is the bundle for the `sohl`
656
+ knowledgebase: it resolves `{@link}` tags against a TypeDoc symbol map and
657
+ rewrites repository-relative links in the developer docs to their published or
658
+ GitHub addresses. Neither rewrite can fail a build; an unknown `{@link}` degrades
659
+ to a code span.
660
+
661
+ A bundle supplies up to two hooks, and their order around the shared work is the
662
+ point:
663
+
664
+ 1. `beforeLinks`, on every page, before wikilinks resolve — a `{@link}` tag may
665
+ sit in prose a wikilink also touches.
666
+ 2. `afterLinks`, on pages from an extra tree only — repository-relative links are
667
+ a property of how those pages are authored, not of content notes.
668
+
669
+ Both run inside code-fence protection, so neither can rewrite a fenced example.
670
+
671
+ ### The gates
672
+
673
+ Every integrity check reports and the run stops at the first that fires, so the
674
+ output names the cause rather than its symptoms — an unusable manifest reported
675
+ after the links that failed because of it reads as a pile of broken notes.
676
+
677
+ | Gate | What it catches |
678
+ | ---------------------- | ---------------------------------------------------------------------------------- |
679
+ | Frontmatter wikilinks | A link in frontmatter, which is copied verbatim and reaches the reader as `[[…]]`. |
680
+ | Slugs | A name that yields no URL. |
681
+ | Collisions | Two notes claiming one page URL. |
682
+ | Unusable manifest | A vendored manifest this build cannot read. |
683
+ | Unaddressable manifest | One it can read but cannot look anything up in. |
684
+ | Package conflicts | One address claimed by two packages. |
685
+ | Tables and wikilinks | A table directive that cannot be honoured, or a link that lands nowhere. |
686
+
687
+ None of them exits the process from inside the library; the command decides. That
688
+ is what makes them testable, which the consumer scripts' inline `process.exit`
689
+ calls were not.
690
+
691
+ ## Diagnostics
692
+
693
+ Every warning or error a build reports **about a content note** is emitted in the
694
+ form every C-family compiler, `tsc` and ESLint already use, so an editor, a CI
695
+ annotator or a `grep` parses it with no knowledge of this build:
696
+
697
+ ```text
698
+ assets/content/Regions/Capital_Nome.md:43:635: warning: unresolved wikilink [[Kenbet_Pat|Kenbet'Pat]] (unknown) in "The Capital Nome"
699
+ ```
700
+
701
+ `file:line:column: severity: message`. The path is relative to the working
702
+ directory — during a build, the consuming repository's root.
703
+
704
+ Two rules keep it that way, both in `engine/diagnostics.mjs`:
705
+
706
+ - **The locator starts the line.** Diagnostics deliberately bypass `loglevel`,
707
+ whose `[timestamp] [WARN]:` prefix sits exactly where a parser reads the path
708
+ from; a greedy path pattern swallows the prefix and yields a filename nothing
709
+ can open. Progress and summary lines still go through `loglevel` — they are
710
+ not about a file and nothing needs to parse them.
711
+ - **A field is dropped, never guessed.** A diagnostic reports the position it
712
+ can establish honestly and no more: `file:line: …` when the column is
713
+ meaningless, `file: …` when only the note is known. Nothing defaults to
714
+ `1:1`, which would send a reader to the frontmatter every time.
715
+
716
+ Establishing a position at all takes three corrections, applied only where they
717
+ hold — see `positionInBody`. A body offset is not a file line until the
718
+ frontmatter's lines are added (`bodyLine`); the trim that strips the body can
719
+ take indentation off its first line (`bodyColumn`); and a body is scanned
720
+ _after_ its content tables expand, so an offset may land in text nobody
721
+ authored. `expandContentTables` therefore returns a `lineMap` saying which
722
+ authored line each emitted line came from — a generated row is blamed on the
723
+ directive that produced it and reports **no column**, because there is no
724
+ authored character to point at.
725
+
726
+ ## Layout
727
+
728
+ - **`@heroiclands/package-build/engine`** — package-agnostic machinery: the
729
+ content walk, frontmatter, tables, wikilinks, ids, folders, the link manifest
730
+ and the web-address rule, `BasePackCompiler`, and the generic Foundry document
731
+ compilers.
732
+ - **`@heroiclands/package-build/sohl`** — Song of Heroic Lands data-model
733
+ knowledge: item types, builders, the items and actors compilers, and default
734
+ art. Isolated behind its own entry point so an adventure module never receives
735
+ `buildWeaponGear`.
736
+
737
+ Each module is also reachable as its own entry point —
738
+ `@heroiclands/package-build/engine/journals`,
739
+ `@heroiclands/package-build/sohl/items` — so a build that needs one thing does
740
+ not load the whole pipeline. The barrels re-export each module as a namespace
741
+ rather than flattening it, because several modules deliberately re-export a
742
+ neighbour's symbol and a flattened star export would drop every such name
743
+ silently.
744
+
745
+ A few plain-ESM leaves are shared **with the Foundry runtime**, not just with the
746
+ build: the item default-art map, the curated region-event vocabulary, and the
747
+ affiliation standings. Each has its own entry point —
748
+ `@heroiclands/package-build/sohl/default-item-art`,
749
+ `.../engine/region-events`, `.../sohl/affiliation-standings` — so a client bundle
750
+ reaches the constant without importing a barrel that grows to hold compilers
751
+ reading the filesystem. Keeping one copy of each is the point: the build-time and
752
+ runtime values cannot disagree, which is the drift that produced #932.
753
+
754
+ `@heroiclands/package-build/content-config` exposes the configuration contract's own
755
+ module, so a consumer can name its types (`ContentBuildConfig`, `PackSpec`) from
756
+ JSDoc.
757
+
758
+ ## Tests
759
+
760
+ The package carries its own suite and its own vitest project, so it is
761
+ verifiable without the repository that happens to host it:
762
+
763
+ ```
764
+ npm test -w @heroiclands/package-build # from the SoHL repository root
765
+ npm test # from packages/content-build/
766
+ ```
767
+
768
+ The SoHL repository's root `npm run test` names the very same project config, so
769
+ one command still gates everything CI runs and neither entry point can drift
770
+ into a different suite.
771
+
772
+ The harness is deliberately austere: no global setup, no Foundry stubs, and no
773
+ alias onto a consuming repository's source. `tests/suite-is-self-contained.test.ts`
774
+ enforces that — a test in this suite that reached for `globalThis.game` or `@src`
775
+ would pass in situ and fail the moment the package was installed from npm.
776
+
777
+ `tests/dependencies-are-declared.test.ts` guards the same failure from the
778
+ shipping side. Because this package is a workspace, npm hoists the root
779
+ repository's `devDependencies` into the workspace root, so an import this
780
+ package never declared still resolves here and fails nowhere but a consumer's
781
+ install (#1557). The test walks every module named by the `files` field and
782
+ holds each bare specifier to one of three cases — a Node builtin, this package
783
+ addressing itself, or a declared `dependency` — and checks the converse: nothing
784
+ shipped may import a `devDependency`, and no declared dependency may go
785
+ unimported.
786
+
787
+ ## Releasing
788
+
789
+ Releasing is not a command anyone runs. It is a consequence of merging, in two
790
+ steps, and each step is visible while it is pending.
791
+
792
+ **Every pull request declares its bump.** Run `npx changeset` and pick
793
+ major/minor/patch; the summary you write becomes the changelog entry and the
794
+ release note. If the change ships nothing a consumer can see, say so explicitly
795
+ with `npx changeset add --empty`. CI's **Changeset declared** job fails a pull
796
+ request that declares neither — `npm run changeset:check` is the same check,
797
+ locally.
798
+
799
+ **Merging to `main` opens a Version Packages pull request** carrying the version
800
+ bump and the rewritten `CHANGELOG.md`. That pull request _is_ the pending
801
+ release: as long as something is merged but unpublished, there is an open pull
802
+ request saying so. This is the whole point of the pipeline — the previous,
803
+ hand-driven process failed by leaving _nothing_ behind when the final step was
804
+ forgotten, and on 2026-08-21 it did exactly that for two versions (#15).
805
+
806
+ **Merging that publishes.** `changeset publish` puts the version on npm through
807
+ Trusted Publishing (OIDC — there is no `NPM_TOKEN`), tags the commit `v<version>`
808
+ and cuts the GitHub Release with the changelog section as its body. It publishes
809
+ only versions that are not already on the registry, so re-running it is a no-op;
810
+ `workflow_dispatch` on **Publish to npm** is the recovery path if a run fails
811
+ after versioning.
812
+
813
+ Below 1.0.0, `^0.x` never crosses a minor — a consumer on `^0.15.0` will not see
814
+ `0.16.0` until it bumps the pin deliberately. Dependabot raises that as its own
815
+ pull request in each of the three consuming repositories.
816
+
817
+ > After a successful publish, `npm view @heroiclands/package-build version` can
818
+ > report the _previous_ version for a minute or so. `dist-tags` is correct
819
+ > immediately, and is what the workflow prints.
820
+
821
+ ## License
822
+
823
+ GPL-3.0-or-later — see the
824
+ [SoHL repository](https://github.com/HeroicLands/Song-of-Heroic-Lands-FoundryVTT).