@heroiclands/package-build 20.4.0 → 20.6.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 (78) hide show
  1. package/CHANGELOG.md +288 -0
  2. package/CONTENT.md +213 -20
  3. package/README.md +19 -1
  4. package/bin/content-build.mjs +135 -32
  5. package/bin/package-build.mjs +46 -13
  6. package/content-config.mjs +345 -101
  7. package/docs/api.md +1352 -0
  8. package/docs/commands.md +1609 -0
  9. package/docs/configuration.md +1432 -0
  10. package/docs/content-format.md +16 -6
  11. package/docs/diagnostics.md +356 -0
  12. package/docs/getting-started.md +813 -0
  13. package/docs/project-setup.md +469 -0
  14. package/engine/actor-compiler.mjs +30 -27
  15. package/engine/address-diff.mjs +45 -41
  16. package/engine/base-compiler.mjs +6 -0
  17. package/engine/bundles.mjs +9 -0
  18. package/engine/content-address.mjs +9 -9
  19. package/engine/content-index.mjs +44 -23
  20. package/engine/content-links.mjs +44 -11
  21. package/engine/content-lint.mjs +44 -10
  22. package/engine/content-tables.mjs +32 -27
  23. package/engine/folder-notes.mjs +4 -2
  24. package/engine/frontmatter-lint.mjs +35 -38
  25. package/engine/generate.mjs +5 -0
  26. package/engine/helpers.mjs +86 -32
  27. package/engine/index.mjs +12 -2
  28. package/engine/journals.mjs +9 -0
  29. package/engine/note-claims.mjs +18 -10
  30. package/engine/note-schemas.mjs +0 -5
  31. package/engine/note-vocabulary.mjs +32 -31
  32. package/engine/pack-config.mjs +26 -12
  33. package/engine/pack-router.mjs +0 -0
  34. package/engine/pdf-build.mjs +464 -0
  35. package/engine/pdf-fonts.mjs +420 -0
  36. package/engine/pdf-render.mjs +876 -0
  37. package/engine/pdf-toc.mjs +525 -0
  38. package/engine/scenes.mjs +14 -5
  39. package/engine/schema-check.mjs +1 -1
  40. package/engine/site-build.mjs +21 -3
  41. package/engine/web-wikilinks.mjs +6 -3
  42. package/engine/wikilinks.mjs +2 -4
  43. package/hm3/actors.mjs +8 -0
  44. package/hm3/items.mjs +8 -0
  45. package/package.json +1 -1
  46. package/release.mjs +63 -3
  47. package/sohl/actors.mjs +8 -0
  48. package/sohl/items.mjs +8 -0
  49. package/sohl/note-schemas.mjs +5 -5
  50. package/types/content-config.d.mts +66 -15
  51. package/types/engine/actor-compiler.d.mts +34 -30
  52. package/types/engine/address-diff.d.mts +57 -3
  53. package/types/engine/base-compiler.d.mts +10 -2
  54. package/types/engine/bundles.d.mts +9 -0
  55. package/types/engine/content-address.d.mts +9 -9
  56. package/types/engine/content-index.d.mts +57 -13
  57. package/types/engine/content-lint.d.mts +6 -4
  58. package/types/engine/content-tables.d.mts +49 -18
  59. package/types/engine/frontmatter-lint.d.mts +3 -2
  60. package/types/engine/helpers.d.mts +105 -31
  61. package/types/engine/index.d.mts +4 -0
  62. package/types/engine/journals.d.mts +9 -0
  63. package/types/engine/note-claims.d.mts +17 -10
  64. package/types/engine/note-vocabulary.d.mts +23 -196
  65. package/types/engine/pack-config.d.mts +4 -4
  66. package/types/engine/pdf-build.d.mts +42 -0
  67. package/types/engine/pdf-fonts.d.mts +30 -0
  68. package/types/engine/pdf-render.d.mts +156 -0
  69. package/types/engine/pdf-toc.d.mts +114 -0
  70. package/types/engine/scenes.d.mts +10 -1
  71. package/types/engine/schema-check.d.mts +2 -2
  72. package/types/engine/site-build.d.mts +34 -6
  73. package/types/engine/wikilinks.d.mts +2 -3
  74. package/types/hm3/actors.d.mts +8 -0
  75. package/types/hm3/items.d.mts +8 -0
  76. package/types/release.d.mts +15 -4
  77. package/types/sohl/actors.d.mts +10 -2
  78. package/types/sohl/items.d.mts +8 -0
@@ -0,0 +1,813 @@
1
+ # Getting started
2
+
3
+ This is the path from an empty directory to a HeroicLands package that builds:
4
+ a repository whose notes compile into Foundry compendium packs, whose manifest
5
+ Foundry can install, and whose release archive is ready to attach to a tag.
6
+
7
+ It assumes you know the constellation's conventions — `~/dev/HeroicLands`, the
8
+ branch and pull request rules, the shared Prettier configuration, the git hooks
9
+ — and assumes nothing about this toolchain. Everything specific to
10
+ `@heroiclands/package-build` is stated here or linked.
11
+
12
+ The worked example builds a **module content package**: a Foundry module whose
13
+ whole substance is a content tree. That is the common case, and the shortest
14
+ path that exercises every stage of the build — the note format, the pack
15
+ compilers, the content index, the manifest and the release archive. A system
16
+ package (`packageKind: systems`) differs only in what its manifest is called and
17
+ where its version is derived from; a documentation package is a different shape
18
+ and is covered at the end.
19
+
20
+ You will need Node 24 or newer and npm. Nothing else: no Foundry install, no
21
+ credentials, no container.
22
+
23
+ ## Where the reference material is
24
+
25
+ This document is about **order and motivation** — what to do first, and why the
26
+ next step needs the one before it. The detail lives in the references beside it,
27
+ and this tutorial links to them rather than repeating them:
28
+
29
+ | Document | What it answers |
30
+ | ---------------------------------------- | ------------------------------------------------------------------------------- |
31
+ | [`commands.md`](commands.md) | What each command does, its options, its exit codes. |
32
+ | [`configuration.md`](configuration.md) | Every key of `package-build.config.yaml`, its type, its default, its refusals. |
33
+ | [`content-format.md`](content-format.md) | What a note may declare, per note type, and what it compiles into. |
34
+ | [`diagnostics.md`](diagnostics.md) | How to read the `file:line:column: severity: message` output. |
35
+ | [`project-setup.md`](project-setup.md) | The files a repository carries beyond the configuration, and the script wiring. |
36
+ | [`api.md`](api.md) | The programmatic surface, for a repository with a build script of its own. |
37
+
38
+ ## The shape of what you are building
39
+
40
+ Nine files, and a directory the build writes:
41
+
42
+ ```text
43
+ acme-bestiary/
44
+ ├── package.json # identity: the Foundry package id and the release addresses
45
+ ├── package-build.config.yaml # the build: what this package is, and what it compiles
46
+ ├── prettier.config.js # the shared formatting options
47
+ ├── .gitignore # what is generated, so the checks skip it
48
+ ├── README.md # shipped into the package
49
+ ├── LICENSE.md # shipped into the package
50
+ ├── lang/en.json # shipped into the package
51
+ ├── assets/content/ # the notes — the only hand-authored content
52
+ │ ├── homepage.md
53
+ │ └── Bestiary/Marsh_Drake.md
54
+ └── build/ # everything the build writes; never committed
55
+ ```
56
+
57
+ Two facts govern the whole arrangement, and both are worth holding on to before
58
+ the first command.
59
+
60
+ **`package.json` is the package's identity.** The Foundry package id is its
61
+ `name`, verbatim; the manifest, download and bug addresses are derived from its
62
+ `repository.url`; a system's stamped version is its `version`. None of those is
63
+ transcribed into the build configuration, because a transcribed copy is free to
64
+ drift from what it copied.
65
+
66
+ **`package-build.config.yaml` is the build's single source.** The pack list, the
67
+ compatibility range, the manifest and the site layout are all declared there
68
+ once, and the generated `module.json` is derived from it. There is no
69
+ hand-authored manifest to keep in step.
70
+
71
+ **One thing about the examples.** Every block below is a real file or a real
72
+ transcript. Fenced examples render at the two-space indentation markdown itself
73
+ uses, which is not the four the shared Prettier configuration gives a `.json` or
74
+ `.yaml` file — so copy the content and let `content-build format --write` settle
75
+ the whitespace, rather than transcribing it. Step 6 is where that becomes part
76
+ of the routine.
77
+
78
+ ## Step 1 — `package.json`
79
+
80
+ Create the directory and write `package.json` first, because the toolchain reads
81
+ it from the moment it is installed.
82
+
83
+ ```bash
84
+ mkdir acme-bestiary
85
+ cd acme-bestiary
86
+ ```
87
+
88
+ ```json
89
+ {
90
+ "name": "acme-bestiary",
91
+ "private": true,
92
+ "version": "0.1.0",
93
+ "type": "module",
94
+ "license": "GPL-3.0-or-later AND CC-BY-SA-4.0",
95
+ "repository": {
96
+ "type": "git",
97
+ "url": "https://github.com/HeroicLands/acme-bestiary"
98
+ },
99
+ "engines": {
100
+ "node": ">=24.0.0"
101
+ },
102
+ "scripts": {}
103
+ }
104
+ ```
105
+
106
+ Five of those keys are load-bearing:
107
+
108
+ - **`name`** becomes the Foundry package id, read verbatim. It is what appears
109
+ as `id` in the generated `module.json`, what a compendium UUID is addressed
110
+ through (`Compendium.acme-bestiary.journals.…`), and what the served asset
111
+ root is built from. Choose it once; renaming it later invalidates every UUID
112
+ anything has stored.
113
+ - **`version`** is the release version. It appears in the manifest and in the
114
+ pinned `download` address.
115
+ - **`repository.url`** is what the manifest's `url`, `bugs`, `manifest` and
116
+ `download` addresses are derived from. Without it, `package-build manifest`
117
+ refuses to write anything.
118
+ - **`type: "module"`** lets `prettier.config.js` be written as an ES module,
119
+ which is how the shared configuration is re-exported.
120
+ - **`private: true`** because a package built by this toolchain ships as a
121
+ Foundry release archive, not to npm.
122
+
123
+ Let `content-build format --write` set the indentation once this file exists:
124
+ npm matches `package.json`'s existing indentation when it writes
125
+ `package-lock.json`, so a formatted `package.json` produces a formatted
126
+ lockfile, and neither needs a `.prettierignore` entry for the life of the
127
+ repository.
128
+
129
+ ## Step 2 — install the toolchain
130
+
131
+ ```bash
132
+ npm install --save-dev @heroiclands/package-build
133
+ ```
134
+
135
+ That installs two binaries into `node_modules/.bin`: `package-build`, the
136
+ packaging half, and `content-build`, the content half. Every command below is
137
+ one of those two. Run them through `npx`, or wire them into npm scripts as
138
+ [`project-setup.md`](project-setup.md) describes.
139
+
140
+ **How it worked:** `npx package-build --version` prints the installed version.
141
+ `--version` and `--help` are the only invocations that do not read a
142
+ configuration file. Every other one resolves `package-build.config.yaml` first,
143
+ by walking up from the working directory, and fails loudly when it finds none:
144
+
145
+ ```console
146
+ $ npx content-build package compile
147
+ [ERROR]: package-build: no package-build.config.yaml or package-build.config.yml or package-build.config.mjs found at or above /path/to/acme-bestiary, nor at or above /path/to/acme-bestiary/node_modules/@heroiclands/package-build/engine. A consuming repository declares its build in one file at its root; set PACKAGE_BUILD_CONFIG to name it elsewhere.
148
+ ```
149
+
150
+ That is the next step.
151
+
152
+ ## Step 3 — `.gitignore` and `prettier.config.js`
153
+
154
+ These come before the first check rather than after it, because the checks read
155
+ them.
156
+
157
+ `.gitignore` is what keeps generated trees out of the prose checks: both
158
+ `content-build format` and `content-build markdown` consult a repository's
159
+ ignore files, so a `build/` that is not named here is a build whose own output
160
+ gets reported as unformatted.
161
+
162
+ ```text
163
+ node_modules
164
+ /build/
165
+ /nogit/
166
+ /.env.local
167
+ ```
168
+
169
+ `node_modules` carries neither a leading nor a trailing slash, for a reason
170
+ [`project-setup.md`](project-setup.md) gives in full: a trailing slash matches a
171
+ directory only, and a worktree whose `node_modules` is a symlink then goes
172
+ unignored.
173
+
174
+ `prettier.config.js` re-exports the shared options:
175
+
176
+ ```js
177
+ /**
178
+ * The shared HeroicLands Prettier configuration.
179
+ *
180
+ * Every route to Prettier — the toolchain, an editor integration, a bare
181
+ * `npx prettier` — resolves a config file, so re-exporting the shared options
182
+ * here is what keeps those three from formatting the same tree three ways.
183
+ *
184
+ * @type {import("prettier").Config}
185
+ */
186
+ export { default } from "@heroiclands/package-build/prettier";
187
+ ```
188
+
189
+ `content-build format` applies the shared options whether or not this file
190
+ exists, so the lint chain is correct without it. Nothing else is: an editor's
191
+ format-on-save and a bare `npx prettier --write .` resolve a _config file_, and
192
+ finding none they fall back to Prettier's own defaults — a different print width
193
+ against the same tree, so the editor and the lint chain take turns rewriting
194
+ each other's work. The toolchain says so when the file is missing:
195
+
196
+ ```console
197
+ $ npx content-build format
198
+ warning: this repository declares no Prettier configuration, so `content-build format` applies the shared conventions while an editor and a bare `npx prettier` apply Prettier's own to the same tree; declare them in a prettier.config.mjs — export { default } from "@heroiclands/package-build/prettier";
199
+ ```
200
+
201
+ Any filename Prettier resolves works, `prettier.config.js` included; this is the
202
+ one every repository in the family carries.
203
+
204
+ ## Step 4 — `package-build.config.yaml`
205
+
206
+ The build's declaration. This is the smallest one that compiles a module content
207
+ package:
208
+
209
+ ```yaml
210
+ contentPackage: bestiary
211
+ packageKind: modules
212
+
213
+ compatibility:
214
+ minimum: "14.359"
215
+ verified: "14.364"
216
+
217
+ stats:
218
+ lastModifiedBy: acmebuilder00000
219
+
220
+ packs:
221
+ - { name: journals, label: Journals, type: JournalEntry }
222
+ ```
223
+
224
+ Five keys, and each answers a question the build cannot answer for itself:
225
+
226
+ - **`contentPackage`** names the content package this repository single-sources
227
+ — `bestiary` here, as `thalorna` and `kethira` name theirs. It is what a note's
228
+ canonical address is scoped by and what the published content index is named
229
+ for. **A note never declares its own package**; it belongs to this one, and a
230
+ `package:` key in a note is a hard error.
231
+ - **`packageKind`** is `modules`, `systems` or `documentation`. It decides
232
+ whether the manifest is written as `module.json` or `system.json`, where the
233
+ served asset root is rooted, and where the stamped system version comes from.
234
+ - **`compatibility.minimum`** is the Foundry core version every compiled
235
+ document is stamped with, and `verified` is the version the package is tested
236
+ against. There is no default: a guessed floor is stamped into every document
237
+ in the pack and stays invisible until something migrates on it.
238
+ - **`stats.lastModifiedBy`** is the sixteen-character Foundry user id stamped
239
+ into every document's `_stats` block.
240
+ - **`packs`** is the compendium list, and it is the _only_ place packs are
241
+ declared — the manifest's `packs` array is derived from it, so the two cannot
242
+ disagree.
243
+
244
+ Everything omitted has a default, and the defaults are the conventional
245
+ HeroicLands layout: the content tree at `assets/content`, the compiled packs at
246
+ `build/stage/packs`, the intermediates and caches elsewhere under `build/`.
247
+ [`configuration.md`](configuration.md) documents all eighteen top-level keys,
248
+ what each refusal message means, and the five values that are derived rather
249
+ than authored.
250
+
251
+ **How it worked:** run a content command and watch the complaint move from the
252
+ configuration to the content tree.
253
+
254
+ ```console
255
+ $ npx content-build package compile
256
+ [ERROR]: Content tree not found at /path/to/acme-bestiary/assets/content.
257
+ [ERROR]: Pack JSON generation reported 1 error(s); refusing to compile packs from incomplete output.
258
+ ```
259
+
260
+ The configuration validated. There is simply nothing to compile yet.
261
+
262
+ ## Step 5 — the content tree
263
+
264
+ Two notes: the package homepage, and one piece of content.
265
+
266
+ **Every package's tree holds exactly one `type: homepage` note.** It is the
267
+ package's front page, authored rather than generated, and `content-build lint`
268
+ requires it. It compiles to a page and to no Foundry document.
269
+
270
+ `assets/content/homepage.md`:
271
+
272
+ ```markdown
273
+ ---
274
+ type: homepage
275
+ shortcode: root
276
+ title: The Acme Bestiary
277
+ description: Creatures of the reed flats, their habits and their hides.
278
+ ---
279
+
280
+ # The Acme Bestiary
281
+
282
+ What lives in the reed flats, what it eats, and what it is worth to the people
283
+ who hunt it.
284
+ ```
285
+
286
+ `assets/content/Bestiary/Marsh_Drake.md`:
287
+
288
+ ```markdown
289
+ ---
290
+ type: lore
291
+ subType: bestiary
292
+ name:
293
+ full: Marsh Drake
294
+ shortcode: marshdrake
295
+ description: A wingless drake of the reed flats, hunted for its hide and feared for its patience.
296
+ ---
297
+
298
+ # Marsh Drake
299
+
300
+ The marsh drake is a wingless reptile of the reed flats, grown to the length of
301
+ a river barge. It hunts by stillness: it lies half-submerged for a day at a
302
+ time and takes whatever wades within reach.
303
+
304
+ ## Habits
305
+
306
+ Drakes hold a stretch of water and defend it against their own kind. A stretch
307
+ that falls vacant is claimed within a season.
308
+ ```
309
+
310
+ Four things about that frontmatter are the note format in miniature, and they
311
+ are worth reading closely because everything else in the tree is a variation on
312
+ them.
313
+
314
+ **`type:` routes the note, not its location.** `Bestiary/` is a folder for a
315
+ human's benefit. What makes this note a journal is `type: lore`; a note's
316
+ directory has no bearing on which pack it lands in. `subType:` narrows it —
317
+ `bestiary` is "a kind of creature that is not a people" — and the pair
318
+ `(type, subType)` is what each game system maps onto its own document type.
319
+
320
+ **`shortcode:` is the note's address.** Cross-references are written
321
+ `[[lore-marshdrake]]` — the type and the shortcode — and resolve to whatever
322
+ that note compiles into, in whatever system is being compiled. A shortcode is
323
+ identity: renaming one breaks every link into it.
324
+
325
+ **`name.full` is the document name and the published URL**, derived by one
326
+ shared rule. There is no authored slug anywhere in this toolchain.
327
+
328
+ **Frontmatter has three regions and only one of them is open.** Top-level keys
329
+ (`type`, `shortcode`, `description`, `tags`, and anything else) are copied into
330
+ the generated web page, so an unrecognised one is a theme parameter rather than
331
+ an error. A `data:` block and a `sohl:` / `hm3:` block are closed: a misspelled
332
+ key there is a finding that names the key you meant.
333
+ [`content-format.md`](content-format.md) is the specification.
334
+
335
+ ## Step 6 — check the tree
336
+
337
+ Four checks, and they are quick enough to run continuously while authoring.
338
+
339
+ ```bash
340
+ npx content-build lint # addresses and frontmatter
341
+ npx content-build links # every wikilink resolves
342
+ npx content-build format # the shared Prettier options
343
+ npx content-build markdown # the shared markdownlint rule set
344
+ ```
345
+
346
+ **How it worked:**
347
+
348
+ ```console
349
+ $ npx content-build lint
350
+ [INFO]: Addresses and frontmatter are well-formed (2 address(es) across 2 note(s)).
351
+
352
+ $ npx content-build links
353
+ [INFO]: 2 notes: every link is a labelled address, every anchor link lands and every address resolves (0 cross-package reference(s) via manifest), no wikilink in frontmatter, every homepage address resolvable.
354
+
355
+ $ npx content-build markdown
356
+ [INFO]: Markdown is clean.
357
+ ```
358
+
359
+ `content-build format` reports rather than fixes, which is what a CI gate wants;
360
+ `--write` is the fixing form. Transcribe a file's whitespace by hand and the
361
+ reporting form names it:
362
+
363
+ ```console
364
+ $ npx content-build format
365
+ package-build.config.yaml: error: is not formatted; run `content-build format --write` to fix it
366
+ [ERROR]: 1 of 6 file(s) are not formatted.
367
+
368
+ $ npx content-build format --write
369
+ [INFO]: Formatted 1 of 6 file(s).
370
+
371
+ $ npx content-build format
372
+ [INFO]: Formatting is clean (6 file(s)).
373
+ ```
374
+
375
+ Both forms walk the whole repository, not only the content tree — the
376
+ configuration, the scripts and the prose are all held to the same options.
377
+
378
+ Every finding these commands emit starts with the path of the file that is
379
+ wrong, followed by a line, a column, a severity and a message — the contract
380
+ [`diagnostics.md`](diagnostics.md) describes. A field that cannot be known is
381
+ dropped rather than guessed, so a finding about a whole file names only the file.
382
+
383
+ Forget the homepage and `content-build lint` says so by name:
384
+
385
+ ```console
386
+ $ npx content-build lint
387
+ assets/content: error: holds no `type: homepage` note, so package "bestiary" publishes nothing at its own address /bestiary/ — a package's front page is one authored note in this tree, routed by `type:` rather than by filename
388
+ [ERROR]: 1 finding(s) across 1 note(s).
389
+ ```
390
+
391
+ ## Step 7 — compile the packs
392
+
393
+ ```bash
394
+ npx content-build package compile
395
+ ```
396
+
397
+ ```console
398
+ [INFO]: Content tree: 2 note(s) at /path/to/acme-bestiary/assets/content
399
+ [INFO]: Pack journals: /path/to/acme-bestiary/assets/content → /path/to/acme-bestiary/build/packs-json/journals
400
+ [INFO]: Compiled 1 journal entry (0 documentation entries)
401
+ [INFO]: Pack journals: compiling to LevelDB at /path/to/acme-bestiary/build/stage/packs/journals
402
+ [INFO]: Pack compilation complete.
403
+ ```
404
+
405
+ Two notes, one journal entry: the homepage compiles to a page and to no Foundry
406
+ document, which is why the counts differ.
407
+
408
+ The compile runs in two passes, and the intermediate is worth knowing about
409
+ because it is where you look when a document is not what you expected.
410
+ `build/packs-json/journals/Marsh_Drake_72d497c6a8e57f57.json` is the compiled
411
+ document as JSON, before it is written into the LevelDB pack — shown here with
412
+ the page's HTML elided, and otherwise entire:
413
+
414
+ ```json
415
+ {
416
+ "name": "Marsh Drake",
417
+ "pages": [
418
+ {
419
+ "_id": "2ecce29fd1c5578f",
420
+ "name": "Marsh Drake",
421
+ "type": "text",
422
+ "title": { "show": true, "level": 1 },
423
+ "text": { "format": 1, "content": "…" },
424
+ "_key": "!journal.pages!72d497c6a8e57f57.2ecce29fd1c5578f"
425
+ }
426
+ ],
427
+ "folder": null,
428
+ "sort": 0,
429
+ "ownership": { "default": 0 },
430
+ "flags": {},
431
+ "_id": "72d497c6a8e57f57",
432
+ "_stats": {
433
+ "systemId": null,
434
+ "systemVersion": null,
435
+ "coreVersion": "14.359",
436
+ "createdTime": 0,
437
+ "modifiedTime": 0,
438
+ "lastModifiedBy": "acmebuilder00000"
439
+ },
440
+ "_key": "!journal!72d497c6a8e57f57"
441
+ }
442
+ ```
443
+
444
+ The body has become one text page, because a journal's pages are cut at its `#`
445
+ headings: this note has one, so the whole body is one page named for it. A
446
+ second `#` would produce a second page; anything before the first becomes a
447
+ leading page named "Introduction". A heading at any level carrying an
448
+ `{#anchor}` suffix also starts a page, because a Foundry UUID can address a page
449
+ and nothing smaller — which is how a link to a section inside a note resolves at
450
+ all. `_key` is the LevelDB key the entry is stored under, which is why the
451
+ intermediate carries it.
452
+
453
+ `coreVersion` is the `compatibility.minimum` from step 4 and `lastModifiedBy` is
454
+ the `stats.lastModifiedBy`. `systemId` and `systemVersion` are `null` because
455
+ this module declares no game system: it ships journals, which every system can
456
+ read. A module shipping Actors or Items declares the system it ships for, and
457
+ those two fields are stamped from it — see
458
+ [`systems`](configuration.md#systems).
459
+
460
+ `_id` is derived from the note's canonical address, so it is stable across
461
+ builds: recompiling does not renumber anything, and a world that imported
462
+ yesterday's pack still resolves against today's.
463
+
464
+ **A build that exits 0 and produces the wrong output is a bug.** The compile is
465
+ the stage where that matters most, so read the counts: "2 note(s)" and "Compiled
466
+ 1 journal entry" are the two numbers that say what the tree held and what came
467
+ out of it.
468
+
469
+ ### Cross-references, and what they compile into
470
+
471
+ Add a second note and link to it. `assets/content/Bestiary/Fen_Adder.md`:
472
+
473
+ ```markdown
474
+ ---
475
+ type: lore
476
+ subType: bestiary
477
+ name:
478
+ full: Fen Adder
479
+ shortcode: fenadder
480
+ description: A small venomous snake of the standing water, more feared than the drake.
481
+ ---
482
+
483
+ # Fen Adder
484
+
485
+ A hand-long snake that lies under the surface scum. Its venom kills slowly and
486
+ reliably, which is why the reed-cutters fear it more than the drake.
487
+ ```
488
+
489
+ Then, in `Marsh_Drake.md`:
490
+
491
+ ```markdown
492
+ Drakes hold a stretch of water and defend it against their own kind. A stretch
493
+ that falls vacant is claimed within a season. Reed-cutters working a drake's
494
+ water fear the [[lore-fenadder|fen adder]] more.
495
+ ```
496
+
497
+ ```console
498
+ $ npx content-build links
499
+ [INFO]: 3 notes: every link is a labelled address, every anchor link lands and every address resolves (0 cross-package reference(s) via manifest), no wikilink in frontmatter, every homepage address resolvable.
500
+
501
+ $ npx content-build package compile
502
+ [INFO]: Compiled 2 journal entries (0 documentation entries)
503
+ ```
504
+
505
+ And in the compiled journal, the wikilink has become a Foundry reference:
506
+
507
+ ```text
508
+ @UUID[Compendium.acme-bestiary.journals.JournalEntry.c7c3488c3e2a9282]{fen adder}
509
+ ```
510
+
511
+ That is the whole point of addressing notes by shortcode rather than by
512
+ filename or by id: one authored link, resolved at build time into the reference
513
+ each surface needs — a `@UUID` in a compendium journal, a relative URL on the
514
+ website. A link that resolves to nothing is an error naming the note, never a
515
+ blank.
516
+
517
+ ## Step 8 — what the package ships
518
+
519
+ The packs are compiled but the package is not yet assembled. A Foundry package
520
+ is a staged directory — a manifest, the packs, and whatever files the manifest
521
+ points at — and the configuration declares both halves.
522
+
523
+ Write the three files the manifest will name:
524
+
525
+ `README.md`:
526
+
527
+ ```markdown
528
+ # Acme Bestiary
529
+
530
+ Creatures of the reed flats, as compendium journals for Foundry VTT.
531
+ ```
532
+
533
+ `LICENSE.md`:
534
+
535
+ ```markdown
536
+ # License
537
+
538
+ Code: GPL-3.0-or-later. Content: CC-BY-SA-4.0.
539
+ ```
540
+
541
+ `lang/en.json`:
542
+
543
+ ```json
544
+ {
545
+ "ACMEBESTIARY": {
546
+ "Title": "Acme Bestiary"
547
+ }
548
+ }
549
+ ```
550
+
551
+ Then add the `packageBuild:` section to `package-build.config.yaml`. It is the
552
+ packaging half's reserved section — validated separately from everything above
553
+ it, and documented under
554
+ [the `packageBuild` section](configuration.md#the-packagebuild-section):
555
+
556
+ ```yaml
557
+ packageBuild:
558
+ assets:
559
+ - { from: README.md, to: README.md }
560
+ - { from: LICENSE.md, to: LICENSE.md }
561
+ - { from: lang, to: lang }
562
+
563
+ manifest:
564
+ title: Acme Bestiary
565
+ description: Creatures of the reed flats, as compendium journals.
566
+ authors:
567
+ - name: Acme
568
+ license: LICENSE.md
569
+ readme: README.md
570
+ languages:
571
+ - lang: en
572
+ name: English
573
+ path: lang/en.json
574
+ packFolders:
575
+ - name: Acme Bestiary
576
+ sorting: m
577
+ packs:
578
+ - journals
579
+ ```
580
+
581
+ `assets:` is a copy list, from the repository into the stage. `manifest:` is
582
+ everything about the manifest that cannot be derived from `package.json` or from
583
+ the keys above — the human-facing identity, and the files Foundry links to.
584
+ `title` is required by Foundry and has no derivation; supply it.
585
+
586
+ `packFolders` may name only packs this package ships, and the build compares the
587
+ two lists rather than trusting them to agree:
588
+
589
+ ```console
590
+ $ npx package-build manifest
591
+ package-build.config.yaml:36:21: error: packFolders: folder "Acme Bestiary" names pack "items", which this package does not ship (packs: journals)
592
+ package-build: packFolders names 1 pack this package does not ship (reported above). Foundry skips a name it cannot resolve, so the folder would ship missing those packs — correct `packageBuild.manifest.packFolders`.
593
+ ```
594
+
595
+ That is the shape of the checks throughout: Foundry's own behaviour on bad input
596
+ is to skip it silently, so the build refuses to emit input Foundry would skip.
597
+
598
+ Stage the files:
599
+
600
+ ```console
601
+ $ npx package-build assets
602
+ ✅ Static assets staged (3 entries, 3 files).
603
+ ```
604
+
605
+ Name a path that is not there and it says so before copying anything:
606
+
607
+ ```console
608
+ $ npx package-build assets
609
+ package-build: Cannot stage assets — these paths do not exist:
610
+ README.md
611
+ LICENSE.md
612
+ lang
613
+ ```
614
+
615
+ ## Step 9 — generate the manifest
616
+
617
+ ```console
618
+ $ npx package-build manifest
619
+ ✅ Wrote build/stage/module.json (16 keys, 1 packs).
620
+ ```
621
+
622
+ `build/stage/module.json` is the file Foundry installs:
623
+
624
+ ```json
625
+ {
626
+ "id": "acme-bestiary",
627
+ "title": "Acme Bestiary",
628
+ "description": "Creatures of the reed flats, as compendium journals.",
629
+ "version": "0.1.0",
630
+ "authors": [{ "name": "Acme" }],
631
+ "license": "LICENSE.md",
632
+ "readme": "README.md",
633
+ "flags": {
634
+ "metadataUrl": "https://github.com/HeroicLands/acme-bestiary/releases/download/v0.1.0/bestiary-metadata.jsonl"
635
+ },
636
+ "compatibility": { "minimum": "14.359", "verified": "14.364" },
637
+ "languages": [{ "lang": "en", "name": "English", "path": "lang/en.json" }],
638
+ "packFolders": [{ "name": "Acme Bestiary", "sorting": "m", "packs": ["journals"] }],
639
+ "packs": [
640
+ {
641
+ "label": "Journals",
642
+ "type": "JournalEntry",
643
+ "name": "journals",
644
+ "path": "packs/journals",
645
+ "private": false
646
+ }
647
+ ],
648
+ "url": "https://github.com/HeroicLands/acme-bestiary",
649
+ "bugs": "https://github.com/HeroicLands/acme-bestiary/issues",
650
+ "manifest": "https://github.com/HeroicLands/acme-bestiary/releases/latest/download/module.json",
651
+ "download": "https://github.com/HeroicLands/acme-bestiary/releases/download/v0.1.0/module.zip"
652
+ }
653
+ ```
654
+
655
+ Read it against what you authored, because the derivations are the part worth
656
+ checking:
657
+
658
+ - `id` is `package.json`'s `name`, verbatim.
659
+ - `version` and the pinned `download` address are its `version`.
660
+ - `url`, `bugs`, `manifest` and `download` are all built from its
661
+ `repository.url`. Omit that and the command refuses rather than writing an
662
+ unpublishable manifest:
663
+
664
+ ```console
665
+ $ npx package-build manifest
666
+ package-build: package.json declares no `repository.url`, so the manifest has no release addresses to advertise. Add it.
667
+ ```
668
+
669
+ - `packs` is the `packs:` list from step 4, with `path` and `private` filled in.
670
+ It is never declared twice.
671
+ - `manifest` points at `releases/latest`, while `download` and `flags.metadataUrl`
672
+ are pinned to this version. That asymmetry is deliberate: the manifest address
673
+ must keep resolving as new releases land, and the download must not move under
674
+ a world that installed it.
675
+
676
+ ## Step 10 — publish the content index
677
+
678
+ ```console
679
+ $ npx content-build content-index
680
+ [INFO]: bestiary → build/content-index/bestiary-metadata.jsonl (3 notes, 1 KiB)
681
+ ```
682
+
683
+ This is the file `flags.metadataUrl` advertises, and it is how packages address
684
+ each other. One JSON object per note, carrying the note's canonical address, its
685
+ name, its description and the Foundry UUID it compiles to:
686
+
687
+ ```text
688
+ {"address":{"canonical":"bestiary-none-lore-marshdrake","slug":"lore-marshdrake"},"aliasesAscii":[],"anchors":[],"description":"A wingless drake of the reed flats, hunted for its hide and feared for its patience.","documentation":null,"file":{"folder":"Bestiary","name":"Marsh_Drake","path":"Bestiary/Marsh_Drake.md"},"foundry":{"none":{"uuid":"Compendium.acme-bestiary.journals.JournalEntry.72d497c6a8e57f57"}},"id":"72d497c6a8e57f57","name":{"full":"Marsh Drake"},"nameAscii":"Marsh Drake","package":"bestiary","shortcode":"marshdrake","subType":"bestiary","type":"lore"}
689
+ ```
690
+
691
+ A downstream package that declares a dependency on this one fetches this file
692
+ for the release it pins, and `[[lore-marshdrake]]` written in _its_ tree resolves
693
+ through it to that UUID. The homepage's entry carries `"foundry": null` — it
694
+ compiles to a page, so there is no document to address.
695
+
696
+ The index is derived and disposable; it is written under `build/` and rebuilt
697
+ from the tree every time.
698
+
699
+ ## Step 11 — the release archive
700
+
701
+ ```console
702
+ $ npx package-build release
703
+ ✅ Packaged 0.1.0 for release: build/dist/module.zip (0.0 MB)
704
+ ```
705
+
706
+ `build/dist/` then holds the three files a GitHub release needs, and the archive
707
+ is named from `packageKind` — a module ships `module.zip`, a system
708
+ `system.zip` — so no repository states it a second time:
709
+
710
+ ```console
711
+ $ ls build/dist
712
+ bestiary-metadata.jsonl
713
+ module.json
714
+ module.zip
715
+ ```
716
+
717
+ The manifest and the content index sit beside the archive rather than only
718
+ inside it, because that is where the addresses in the manifest point: a
719
+ consumer reads `module.json` from the release without downloading the zip, and
720
+ then fetches the index it names.
721
+
722
+ The archive itself is `build/stage/` verbatim — the manifest at the root, the
723
+ LevelDB packs under `packs/`, and the files the assets step put there. `unzip
724
+ -l build/dist/module.zip` is worth reading once, because it is the only view of
725
+ what a player actually installs.
726
+
727
+ That is the whole build. From an empty directory: identity, configuration,
728
+ notes, checks, packs, stage, manifest, index, archive.
729
+
730
+ ## Putting it in order
731
+
732
+ The eleven steps above are the order you need them the _first_ time. Thereafter
733
+ the build is one chain, and this is the order it runs in:
734
+
735
+ ```bash
736
+ npx content-build format # the prose checks
737
+ npx content-build markdown
738
+ npx content-build lint # the content checks
739
+ npx content-build links
740
+ npx content-build content-index # the index the manifest advertises
741
+ npx package-build assets # the stage: the shipped files
742
+ npx content-build package compile
743
+ npx package-build manifest # the stage: the manifest
744
+ npx package-build release # the archive
745
+ ```
746
+
747
+ Only one of those orderings is enforced, and it is worth knowing which.
748
+ `package-build release` refuses to pack an archive whose manifest advertises a
749
+ content index the tree never wrote:
750
+
751
+ ```console
752
+ $ npx package-build release
753
+ package-build: the manifest advertises bestiary-metadata.jsonl as `flags.metadataUrl` but no such file exists — looked in …/build/stage/bestiary-metadata.jsonl and …/build/content-index/bestiary-metadata.jsonl. Build the content index before packing the release.
754
+ ```
755
+
756
+ The rest of the chain is order-independent: `assets`, `compile` and `manifest`
757
+ each write their own part of `build/stage/` and none clobbers another's. They
758
+ are written in this order because it is the order that reads as an assembly —
759
+ the files, then the packs, then the manifest describing both — and because
760
+ everything that reads the tree runs before everything that writes the stage.
761
+
762
+ [`project-setup.md`](project-setup.md) turns this into npm scripts, and covers
763
+ everything a repository carries beyond the build itself: the git hooks, the
764
+ changeset directory, the label registry, and what each script in the chain is
765
+ for.
766
+
767
+ ## What comes next
768
+
769
+ Four capabilities are configuration away, and each has its own guide material.
770
+ None of them is needed to build a package.
771
+
772
+ **A website.** Set `publish.site: content` and add a `site:` block naming the
773
+ sections, and `content-build site` writes a Hugo content tree from the same
774
+ notes. The site renders through `@heroiclands/hugo-theme`, and the package
775
+ publishes at `https://www.heroiclands.org/<contentPackage>/`.
776
+
777
+ **Another package's content.** Declare a dependency under `relationships`, and
778
+ `content-build deps fetch` caches that release's published content index so
779
+ `[[…]]` links into it resolve. A relationship marked `itemCatalog: true` also
780
+ caches the release's Item packs, which is what lets a being embed items by
781
+ `(type, shortcode)`. Fetching never happens during a compile: a cold cache
782
+ fails naming `deps fetch` rather than reaching the network.
783
+
784
+ **A game system's documents.** A module shipping Actors or Items names its
785
+ `itemBuilders` registry — `sohl` or `hm3` — and declares the system under
786
+ `systems:`. That is what maps a note's `(type, subType)` onto a system's own
787
+ document type, and what supplies the `_stats.systemVersion` every document is
788
+ stamped with.
789
+
790
+ **Deploying and testing.** `package-build deploy <stage>` pushes a staged
791
+ package to a Foundry data directory or a remote host; `package-build container`
792
+ runs a licensed Foundry in Docker; `package-build e2e` drives the Cypress suite
793
+ against it. All three need a Foundry install, credentials, or both — see
794
+ [`commands.md`](commands.md).
795
+
796
+ ## A documentation package
797
+
798
+ `packageKind: documentation` is the third kind, and it is a different shape
799
+ rather than a smaller one: a package that publishes a content tree as a website
800
+ and a book, and compiles no Foundry documents at all.
801
+
802
+ Everything in steps 4 and 8 that exists to describe a Foundry package is
803
+ **refused** there rather than ignored, each with a message saying why:
804
+ `foundryPackage`, `stats`, `packs`, `itemBuilders`, `compatibility`,
805
+ `relationships`, `systems`, `requiresSystem` and `docs`. In exchange, `publish`
806
+ becomes required, with `site: content` — publishing the tree is the whole of
807
+ what the package does.
808
+
809
+ So a documentation package's configuration is steps 1 through 6 with a different
810
+ `packageKind`, plus a `site:` block, and then `content-build site` in place of
811
+ steps 7 through 11. [`configuration.md`](configuration.md) carries the refusal
812
+ message for every key; [`commands.md`](commands.md) covers the two commands such
813
+ a package lives on, `content-build site` and `content-build pdf`.