@heroiclands/package-build 22.15.0 → 22.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +103 -0
- package/README.md +39 -678
- package/bin/{content-build.mjs → content-commands.mjs} +197 -118
- package/bin/package-build.mjs +303 -42
- package/bin/report.mjs +1 -1
- package/ci/ci-docker.mjs +6 -3
- package/ci/ci-steps.mjs +6 -4
- package/ci/content-format-reference.mjs +106 -0
- package/config.mjs +22 -2
- package/content-config.mjs +74 -134
- package/docs/api.md +294 -234
- package/docs/authoring/assets.md +31 -0
- package/docs/authoring/dates-and-calendars.md +33 -0
- package/docs/authoring/first-note.md +48 -0
- package/docs/authoring/frontmatter.md +40 -0
- package/docs/authoring/links-and-markup.md +137 -0
- package/docs/commands.md +631 -162
- package/docs/configuration.md +176 -140
- package/docs/content-format.md +50 -4229
- package/docs/design.md +32 -0
- package/docs/diagnostics.md +29 -22
- package/docs/getting-started.md +21 -877
- package/docs/guides/book.md +153 -0
- package/docs/guides/e2e.md +143 -0
- package/docs/guides/icons.md +126 -0
- package/docs/guides/localization.md +120 -0
- package/docs/guides/module-project.md +915 -0
- package/docs/guides/packs.md +167 -0
- package/docs/guides/reference-pages.md +148 -0
- package/docs/guides/release-and-deploy.md +114 -0
- package/docs/guides/site.md +152 -0
- package/docs/guides/system-project.md +45 -0
- package/docs/index.md +16 -0
- package/docs/project-setup.md +54 -35
- package/docs/reference/format-details.md +4420 -0
- package/docs/reference/note-types.md +531 -0
- package/engine/address-render.mjs +26 -0
- package/engine/address.mjs +3 -20
- package/engine/anchors.mjs +15 -6
- package/engine/asset-bindings.mjs +6 -2
- package/engine/base-compiler.mjs +47 -11
- package/engine/being-measurements.mjs +70 -0
- package/engine/calendar-artifacts.mjs +1 -1
- package/engine/calendar-choice.mjs +32 -0
- package/engine/calendar-human.mjs +71 -0
- package/engine/calendar-notes.mjs +41 -3
- package/engine/calendars.mjs +8 -1
- package/engine/changelog-lint.mjs +2 -2
- package/engine/code-fences.mjs +23 -97
- package/engine/compile-corpus.mjs +6 -7
- package/engine/content-admonitions.mjs +114 -0
- package/engine/content-captions.mjs +181 -0
- package/engine/content-embeds.mjs +2 -2
- package/engine/content-footnotes.mjs +34 -0
- package/engine/content-format-check.mjs +2 -2
- package/engine/content-format.mjs +127 -73
- package/engine/content-format.yaml +745 -0
- package/engine/content-html.mjs +1 -1
- package/engine/content-icons.mjs +45 -92
- package/engine/content-images.mjs +22 -74
- package/engine/content-index.mjs +12 -3
- package/engine/content-links.mjs +21 -26
- package/engine/content-lint.mjs +1 -5
- package/engine/content-secrets.mjs +3 -1
- package/engine/content-tables.mjs +47 -1257
- package/engine/culture-choice.mjs +32 -0
- package/engine/date-conversion.mjs +47 -0
- package/engine/declaration-check.mjs +87 -0
- package/engine/docs-checks.mjs +140 -0
- package/engine/documentation-links.mjs +31 -0
- package/engine/exported-scene.mjs +1 -1
- package/engine/extension-attributes.mjs +74 -0
- package/engine/field-reference.mjs +1 -1
- package/engine/field-spec.mjs +4 -0
- package/engine/foreign-catalog.mjs +5 -5
- package/engine/format-generated.mjs +15 -0
- package/engine/foundry-entries.mjs +3 -1
- package/engine/frontmatter-lint.mjs +5 -1
- package/engine/frontmatter.mjs +10 -0
- package/engine/generate.mjs +2 -2
- package/engine/helpers.mjs +71 -117
- package/engine/homepage.mjs +2 -3
- package/engine/index-records.mjs +1 -0
- package/engine/index.mjs +18 -3
- package/engine/infobox-registry.mjs +3 -1
- package/engine/infobox.mjs +38 -20
- package/engine/item-compiler.mjs +10 -8
- package/engine/item-docs.mjs +7 -6
- package/engine/itinerary-scenes.mjs +4 -2
- package/engine/journals.mjs +51 -10
- package/engine/macros.mjs +1 -1
- package/engine/map-build.mjs +1 -1
- package/engine/map-dot.mjs +1 -1
- package/engine/map-raster.mjs +4 -2
- package/engine/markdown-expressions.mjs +117 -0
- package/engine/metadata-index.mjs +7 -3
- package/engine/note-addresses.mjs +1 -0
- package/engine/note-dates.mjs +283 -61
- package/engine/note-package.mjs +5 -25
- package/engine/note-state.mjs +6 -2
- package/engine/note-vocabulary.mjs +74 -19
- package/engine/number-words.mjs +75 -0
- package/engine/office-holders.mjs +188 -0
- package/engine/pack-config.mjs +2 -15
- package/engine/pack-router.mjs +24 -5
- package/engine/packages.mjs +3 -2
- package/engine/pathnames.mjs +21 -24
- package/engine/pdf-build.mjs +217 -55
- package/engine/pdf-fonts.mjs +2 -2
- package/engine/pdf-images.mjs +82 -0
- package/engine/pdf-render.mjs +174 -22
- package/engine/populations.mjs +3 -2
- package/engine/project-init.mjs +499 -0
- package/engine/prose-config.mjs +1 -1
- package/engine/prose-lint.mjs +7 -6
- package/engine/readability-lint.mjs +86 -0
- package/engine/reckoning-markers.mjs +205 -93
- package/engine/scenes.mjs +5 -2
- package/engine/schema-check.mjs +3 -3
- package/engine/site-build.mjs +87 -37
- package/engine/site-config.mjs +8 -8
- package/engine/site-index.mjs +4 -0
- package/engine/site-maps.mjs +1 -1
- package/engine/site-preview.mjs +19 -8
- package/engine/social-ties.mjs +5 -1
- package/engine/sql-tables.mjs +92 -22
- package/engine/web-wikilinks.mjs +15 -31
- package/engine/wikilink-syntax.mjs +4 -1
- package/engine/wikilinks.mjs +12 -14
- package/hm3/item-fields.mjs +1 -1
- package/hm3/items.mjs +12 -45
- package/index.mjs +2 -14
- package/manifest.mjs +31 -4
- package/markdownlint-config.mjs +1 -1
- package/package.json +32 -11
- package/prettier-config.mjs +1 -1
- package/sohl/being-info.mjs +3 -145
- package/sohl/index.mjs +1 -1
- package/sohl/infobox.mjs +2 -1
- package/sohl/item-builders.mjs +3 -27
- package/sohl/item-fields.mjs +61 -22
- package/sohl/kb-passes.mjs +2 -2
- package/sohl/note-schemas.mjs +0 -8
- package/stage.mjs +19 -0
- package/types/config.d.mts +12 -3
- package/types/content-config.d.mts +15 -81
- package/types/engine/address-render.d.mts +13 -0
- package/types/engine/address.d.mts +2 -11
- package/types/engine/anchors.d.mts +3 -4
- package/types/engine/asset-bindings.d.mts +9 -2
- package/types/engine/base-compiler.d.mts +1 -2
- package/types/engine/being-measurements.d.mts +16 -0
- package/types/engine/calendar-choice.d.mts +14 -0
- package/types/engine/calendar-human.d.mts +4 -0
- package/types/engine/calendar-notes.d.mts +2 -0
- package/types/engine/calendars.d.mts +5 -3
- package/types/engine/code-fences.d.mts +6 -72
- package/types/engine/compile-corpus.d.mts +1 -3
- package/types/engine/content-admonitions.d.mts +24 -0
- package/types/engine/content-captions.d.mts +45 -0
- package/types/engine/content-footnotes.d.mts +10 -0
- package/types/engine/content-format-check.d.mts +17 -4
- package/types/engine/content-format.d.mts +24 -11
- package/types/engine/content-icons.d.mts +14 -23
- package/types/engine/content-images.d.mts +2 -2
- package/types/engine/content-index.d.mts +9 -2
- package/types/engine/content-links.d.mts +3 -5
- package/types/engine/content-tables.d.mts +11 -140
- package/types/engine/culture-choice.d.mts +14 -0
- package/types/engine/date-conversion.d.mts +4 -0
- package/types/engine/declaration-check.d.mts +24 -0
- package/types/engine/docs-checks.d.mts +28 -0
- package/types/engine/documentation-links.d.mts +7 -0
- package/types/engine/exported-scene.d.mts +6 -2
- package/types/engine/extension-attributes.d.mts +14 -0
- package/types/engine/field-spec.d.mts +9 -0
- package/types/engine/format-generated.d.mts +7 -0
- package/types/engine/frontmatter.d.mts +14 -4
- package/types/engine/generate.d.mts +1 -1
- package/types/engine/helpers.d.mts +37 -92
- package/types/engine/homepage.d.mts +2 -3
- package/types/engine/index.d.mts +5 -0
- package/types/engine/infobox-registry.d.mts +3 -1
- package/types/engine/infobox.d.mts +17 -6
- package/types/engine/item-compiler.d.mts +4 -4
- package/types/engine/item-docs.d.mts +4 -3
- package/types/engine/journals.d.mts +10 -7
- package/types/engine/map-graphviz.d.mts +1 -1
- package/types/engine/markdown-expressions.d.mts +17 -0
- package/types/engine/note-dates.d.mts +47 -1
- package/types/engine/note-package.d.mts +3 -19
- package/types/engine/note-state.d.mts +2 -1
- package/types/engine/note-vocabulary.d.mts +9 -0
- package/types/engine/number-words.d.mts +4 -0
- package/types/engine/office-holders.d.mts +14 -0
- package/types/engine/pack-config.d.mts +1 -1
- package/types/engine/packages.d.mts +3 -2
- package/types/engine/pdf-images.d.mts +38 -0
- package/types/engine/populations.d.mts +2 -2
- package/types/engine/project-init.d.mts +17 -0
- package/types/engine/prose-config.d.mts +1 -1
- package/types/engine/prose-lint.d.mts +3 -2
- package/types/engine/readability-lint.d.mts +22 -0
- package/types/engine/reckoning-markers.d.mts +4 -2
- package/types/engine/schema-check.d.mts +3 -3
- package/types/engine/site-build.d.mts +38 -28
- package/types/engine/site-config.d.mts +2 -2
- package/types/engine/site-index.d.mts +4 -0
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/social-ties.d.mts +8 -2
- package/types/engine/sql-tables.d.mts +13 -2
- package/types/engine/web-wikilinks.d.mts +2 -1
- package/types/engine/wikilinks.d.mts +7 -1
- package/types/hm3/items.d.mts +5 -6
- package/types/labels.d.mts +40 -0
- package/types/manifest.d.mts +3 -3
- package/types/sohl/actors.d.mts +2 -2
- package/types/sohl/being-info.d.mts +3 -43
- package/types/sohl/index.d.mts +1 -1
- package/types/sohl/infobox.d.mts +5 -2
- package/types/sohl/item-fields.d.mts +4 -13
- package/types/stage.d.mts +9 -0
- package/CONTENT.md +0 -3239
package/CONTENT.md
DELETED
|
@@ -1,3239 +0,0 @@
|
|
|
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
|
-
## Content references
|
|
20
|
-
|
|
21
|
-
Notes may abbreviate Addresses according to the defaults of their field and
|
|
22
|
-
system block. Builds preserve the authored files and write full Addresses to
|
|
23
|
-
generated content under `build/`. A reference under `data:` or in body prose
|
|
24
|
-
uses `none` as its system; a system-bearing target at `none` names its
|
|
25
|
-
documentation. A reference to an Item or Actor states its system explicitly.
|
|
26
|
-
|
|
27
|
-
Published SQL records use these complete identities too. Compare Address fields
|
|
28
|
-
with full Addresses, not a target's bare Shortcode. A stub's identity remains
|
|
29
|
-
available from its package, type and Shortcode even though it has no page address.
|
|
30
|
-
See the [content format](docs/content-format.md#addresses) for defaults, accepted
|
|
31
|
-
forms and examples.
|
|
32
|
-
|
|
33
|
-
Shared `data.species` names species lore. HârnMaster's native `hm3.system.species`
|
|
34
|
-
is independent character-sheet text; a shared lore reference does not populate it.
|
|
35
|
-
|
|
36
|
-
## Configure
|
|
37
|
-
|
|
38
|
-
A consuming repository declares one `package-build.config.yaml` at its root:
|
|
39
|
-
|
|
40
|
-
```yaml
|
|
41
|
-
# The package this repository's content is published as: the first segment of
|
|
42
|
-
# every canonical address, the name of the content index it emits, and the
|
|
43
|
-
# package a cross-package wikilink writes to reach one of its notes.
|
|
44
|
-
contentPackage: thalorna
|
|
45
|
-
# What kind of package this is. "systems" and "modules" are where Foundry
|
|
46
|
-
# installs it, and each decides the served asset root a note's `img:` resolves
|
|
47
|
-
# to — `modules/sohl-thalorna/assets/…`. "documentation" is a package Foundry
|
|
48
|
-
# never installs; see "A package that compiles nothing" below.
|
|
49
|
-
packageKind: modules
|
|
50
|
-
|
|
51
|
-
# The Foundry core range this package supports. `minimum` is stamped into every
|
|
52
|
-
# compiled document as `_stats.coreVersion`; `verified` names the newest build
|
|
53
|
-
# the full suite has actually passed on — never an aspiration.
|
|
54
|
-
compatibility:
|
|
55
|
-
minimum: "14.359"
|
|
56
|
-
verified: "14.364"
|
|
57
|
-
|
|
58
|
-
# What this package declares about others, in Foundry's own shape. A module's
|
|
59
|
-
# `_stats.systemVersion` comes from the `verified` version of the system it
|
|
60
|
-
# targets — note that this `compatibility` is the *system's* range, not
|
|
61
|
-
# Foundry's. Same key, different subject.
|
|
62
|
-
relationships:
|
|
63
|
-
systems:
|
|
64
|
-
- id: sohl
|
|
65
|
-
type: system
|
|
66
|
-
manifest: https://github.com/HeroicLands/Song-of-Heroic-Lands-FoundryVTT/releases/latest/download/system.json
|
|
67
|
-
compatibility:
|
|
68
|
-
minimum: "0.4.0"
|
|
69
|
-
verified: "0.4.3"
|
|
70
|
-
|
|
71
|
-
# Stamped into every compiled document's `_stats`. `coreVersion` and
|
|
72
|
-
# `systemVersion` are both absent on purpose — see the derived table below.
|
|
73
|
-
stats:
|
|
74
|
-
systemId: sohl
|
|
75
|
-
lastModifiedBy: thalornabuild000
|
|
76
|
-
|
|
77
|
-
# Which content types compile into Items, and what builds each one's `system`
|
|
78
|
-
# block — named, because the registry is code. The registry's keys are the
|
|
79
|
-
# accepted item types, so a type cannot be whitelisted without a builder behind
|
|
80
|
-
# it. A module that ships no items omits this key. See "An item type's default
|
|
81
|
-
# art" below, and "A registry of your own" for the `.mjs` form.
|
|
82
|
-
itemBuilders: sohl
|
|
83
|
-
|
|
84
|
-
# Directory names the content walk ignores wherever they appear.
|
|
85
|
-
skipDirectories: [Templates]
|
|
86
|
-
|
|
87
|
-
# Optional. The icon fonts this package ships and the names its notes draw from
|
|
88
|
-
# them. Nothing is supplied by default: an entry is a promise that a glyph will
|
|
89
|
-
# render, and only the package shipping the font can keep it.
|
|
90
|
-
#
|
|
91
|
-
# Either the registry itself, or a path to a file holding it — the file form is
|
|
92
|
-
# what a real package wants, because a registry is generated from what the
|
|
93
|
-
# interface draws and a generated document inlined here conflicts on every
|
|
94
|
-
# regeneration.
|
|
95
|
-
icons: assets/icon-registry.yaml
|
|
96
|
-
#
|
|
97
|
-
# The same, written inline:
|
|
98
|
-
#
|
|
99
|
-
# icons:
|
|
100
|
-
# families:
|
|
101
|
-
# # `class` is the stylesheet prefix; `styles` are the weights the font
|
|
102
|
-
# # ships, and `[]` means it has none.
|
|
103
|
-
# fontawesome: { class: fa, styles: [solid, regular, brands], describe: Font Awesome Free }
|
|
104
|
-
# game-icons: { class: ginf, styles: [], describe: the Game-Icons webfont }
|
|
105
|
-
# # Which family an entry with no `family` belongs to. Optional where exactly
|
|
106
|
-
# # one is declared, since then there is nothing to choose between.
|
|
107
|
-
# defaultFamily: fontawesome
|
|
108
|
-
# icons:
|
|
109
|
-
# # A name is what a note writes between the colons, in the charset an
|
|
110
|
-
# # address segment uses. `fixedWidth` asks for a full advance, which a
|
|
111
|
-
# # glyph like an ellipsis needs to sit in a column of controls.
|
|
112
|
-
# being: { style: solid, icon: user, label: being }
|
|
113
|
-
# context-menu: { style: solid, icon: ellipsis-vertical, fixedWidth: true, label: context menu }
|
|
114
|
-
# vehicle: { family: game-icons, icon: old-wagon, label: vehicle }
|
|
115
|
-
|
|
116
|
-
# Optional; each path is relative to this file's directory and defaults to the
|
|
117
|
-
# conventional layout shown here.
|
|
118
|
-
paths:
|
|
119
|
-
content: assets/content
|
|
120
|
-
# The asset roots' parent: `icons/`, `images/` and `audio/` sit directly under
|
|
121
|
-
# it, and the content tree beside them.
|
|
122
|
-
assets: assets
|
|
123
|
-
# Vendored foreign manifests, read by `links`. Inbound.
|
|
124
|
-
manifests: assets/manifests
|
|
125
|
-
# Where `manifest` writes this package's own. Outbound, and a build artifact.
|
|
126
|
-
manifestOut: build/manifests
|
|
127
|
-
# Where `content-index` writes this package's note index. Derived and
|
|
128
|
-
# disposable — never a source, and never inside `stage`.
|
|
129
|
-
contentIndex: build/content-index
|
|
130
|
-
packJson: build/packs-json
|
|
131
|
-
stage: build/stage/packs
|
|
132
|
-
unpack: build/tmp/packs
|
|
133
|
-
|
|
134
|
-
# The one pack list. `packDirectories` and the manifest's `packs` array are both
|
|
135
|
-
# derived from it, so order it for a reader browsing compendiums — the compile
|
|
136
|
-
# order is worked out separately, from what each pass reads (see "Declaration
|
|
137
|
-
# order is presentation" below).
|
|
138
|
-
packs:
|
|
139
|
-
- { name: items, type: Item, label: Items }
|
|
140
|
-
- { name: journals, type: JournalEntry, label: Journals }
|
|
141
|
-
# A companion is written by its parent's pass rather than one of its own.
|
|
142
|
-
- name: scenes
|
|
143
|
-
type: Scene
|
|
144
|
-
companions:
|
|
145
|
-
- { name: adventures, type: Adventure }
|
|
146
|
-
|
|
147
|
-
# A pack whose per-document JSON is already built — checked in rather than
|
|
148
|
-
# generated. `prebuilt` names where it lives, generation is skipped for it,
|
|
149
|
-
# and `cleanPackEntry` and the Scene/Level integrity check still run. It may
|
|
150
|
-
# not carry `companions` or `default`, and may not be a companion:
|
|
151
|
-
# each of those describes a generation pass a prebuilt pack does not have.
|
|
152
|
-
# When every configured pack is prebuilt the content walk is skipped
|
|
153
|
-
# entirely, so a package with no `assets/content` builds.
|
|
154
|
-
- name: adventures
|
|
155
|
-
type: Adventure
|
|
156
|
-
prebuilt: assets/packs/adventure
|
|
157
|
-
# Foundry requires `system` on ActiveEffect, Actor and Item packs and on no
|
|
158
|
-
# others, so it is declared per pack. Unset falls back to `stats.systemId`
|
|
159
|
-
# — itself optional — and with neither the manifest omits the key. An
|
|
160
|
-
# Adventure or Scene pack that names a system is hidden from every other
|
|
161
|
-
# one, which is rarely what a package that declined to name one meant.
|
|
162
|
-
system: null
|
|
163
|
-
|
|
164
|
-
# How this repository frames the pages `content-build docs` generates. The
|
|
165
|
-
# tables come from the itemBuilders registry and are the same everywhere; the
|
|
166
|
-
# heading, the filing and what a reader is told first are this repository's.
|
|
167
|
-
docs:
|
|
168
|
-
itemFields:
|
|
169
|
-
title: Item Note Frontmatter
|
|
170
|
-
out: kb/dev-docs/content-creator/item-frontmatter.md
|
|
171
|
-
preamble:
|
|
172
|
-
- "See also: [The Authoring Workflow](authoring-workflow.md)"
|
|
173
|
-
- ""
|
|
174
|
-
- Every item note carries the frontmatter envelope described there. This
|
|
175
|
-
page covers what each **type** adds to it.
|
|
176
|
-
|
|
177
|
-
# Reserved for @heroiclands/package-build, which validates what is inside it.
|
|
178
|
-
# One repository describes itself in one file; the two build packages split by
|
|
179
|
-
# input, and neither learns the other's schema. Values package-build needs that
|
|
180
|
-
# already live at the top level — `packageKind`, `foundryPackage` — it reads
|
|
181
|
-
# from there rather than restating them here.
|
|
182
|
-
packageBuild:
|
|
183
|
-
assets:
|
|
184
|
-
- { from: assets/icons, to: assets/icons }
|
|
185
|
-
|
|
186
|
-
# How much of the package reaches the web, plus the address scheme both
|
|
187
|
-
# `manifest` and `site` derive addresses under. `site` is a mode, not a
|
|
188
|
-
# boolean: `homepage` (the default) publishes the authored homepage and no
|
|
189
|
-
# other page; `content` publishes it plus every page the tree compiles to, and
|
|
190
|
-
# is also what builds the book.
|
|
191
|
-
publish:
|
|
192
|
-
site: content
|
|
193
|
-
address:
|
|
194
|
-
prefix: kb/
|
|
195
|
-
|
|
196
|
-
# How this repository frames the website `content-build site` publishes.
|
|
197
|
-
# Framing only: addresses come from `publish.address` above.
|
|
198
|
-
site:
|
|
199
|
-
assets: https://cdn.heroiclands.org
|
|
200
|
-
description: The rules, the setting, and the reference material.
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
The loader validates the document, resolves every path against the directory
|
|
204
|
-
the file sits in, fills the optional halves with their defaults
|
|
205
|
-
(`skipDirectories: []`, `packageBuild: {}`, the conventional `paths` and
|
|
206
|
-
`publish.site` at its `homepage` floor),
|
|
207
|
-
derives `assetRoot`, `packDirectories`, `itemTypes` and `docEntryTypes`, and
|
|
208
|
-
freezes the result. A malformed configuration throws a `TypeError` naming the
|
|
209
|
-
offending field — and the line and column it was written on, in the
|
|
210
|
-
[located form](#diagnostics) — so it fails at load rather than as an empty pack
|
|
211
|
-
much later.
|
|
212
|
-
|
|
213
|
-
**Four values are derived rather than authored**, because each is something a
|
|
214
|
-
file can be asked for rather than told:
|
|
215
|
-
|
|
216
|
-
| Field | Derived from |
|
|
217
|
-
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
218
|
-
| `rootDir` | the directory the configuration file sits in |
|
|
219
|
-
| `foundryPackage` | the `name` of the adjacent `package.json`, verbatim |
|
|
220
|
-
| `stats.systemVersion` | a **system**: that `package.json`'s `version`. A **module**: the `verified` version of the system it declares a relationship with |
|
|
221
|
-
| `itemBuilders` | the named registry (`sohl`) — or a list of names — required lazily so importing costs nothing |
|
|
222
|
-
|
|
223
|
-
**Authoring any of the first three is an error**, not an override. Each was
|
|
224
|
-
previously transcribed from a file that already stated it, and a transcription
|
|
225
|
-
is free to disagree with what it copies — `stats.systemVersion` froze at
|
|
226
|
-
`0.6.0` for four releases before anyone noticed, and was still frozen there in
|
|
227
|
-
two repositories afterwards.
|
|
228
|
-
|
|
229
|
-
A module does **not** take its system version from its own `package.json`: that
|
|
230
|
-
is the _module's_ version, and stamping it would claim a system version that
|
|
231
|
-
never existed. A module declaring no usable system relationship fails the build
|
|
232
|
-
rather than guessing — a wrong `_stats.systemVersion` is invisible until
|
|
233
|
-
something migrates on it.
|
|
234
|
-
|
|
235
|
-
### A package that compiles nothing
|
|
236
|
-
|
|
237
|
-
`packageKind: documentation` is the answer "not a Foundry package at all". It
|
|
238
|
-
publishes a website and the book built from the same notes, and compiles no
|
|
239
|
-
compendium: nothing is installed into a Foundry data directory, so there is no
|
|
240
|
-
manifest, no pack and no document.
|
|
241
|
-
|
|
242
|
-
```yaml
|
|
243
|
-
contentPackage: toolkit
|
|
244
|
-
packageKind: documentation
|
|
245
|
-
|
|
246
|
-
# Required, and `content`: publishing the tree is the whole of what this kind
|
|
247
|
-
# does. `homepage` would leave one authored page, no book and nothing compiled.
|
|
248
|
-
publish:
|
|
249
|
-
site: content
|
|
250
|
-
address: { prefix: guide/ }
|
|
251
|
-
|
|
252
|
-
packageBuild:
|
|
253
|
-
manifest:
|
|
254
|
-
title: The Toolkit
|
|
255
|
-
|
|
256
|
-
pdf:
|
|
257
|
-
title: The Toolkit
|
|
258
|
-
document: book.yaml
|
|
259
|
-
fonts: { serif: Libertinus Serif }
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
`contentPackage`, `paths`, `skipDirectories`, `icons`, `site`, `pdf` and
|
|
263
|
-
`packageBuild` mean exactly what they mean anywhere else. **Every key that
|
|
264
|
-
describes a Foundry package is refused**, by name, with the line and column it
|
|
265
|
-
was written on — `packs`, `itemBuilders`, `docs`, `compatibility`,
|
|
266
|
-
`relationships`, `systems`, `requiresSystem`, `stats` and `foundryPackage`:
|
|
267
|
-
|
|
268
|
-
```text
|
|
269
|
-
package-build.config.yaml:7:1: error: package-build config: `packs` is refused in a `documentation` package, which compiles no compendium, so there are no packs to declare.
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
A key that cannot mean anything in this shape fails at load rather than being
|
|
273
|
-
read and ignored, which is the difference between a configuration that is wrong
|
|
274
|
-
and a build that is quietly wrong.
|
|
275
|
-
|
|
276
|
-
What follows from the kind:
|
|
277
|
-
|
|
278
|
-
- **`foundryPackage` is not derived.** For every other kind the loader reads it
|
|
279
|
-
from the adjacent `package.json`; there is no Foundry package here to carry an
|
|
280
|
-
id, so `foundryPackage` and `assetRoot` are both `null`. A note's `img:`
|
|
281
|
-
therefore names a `/`-rooted path or a URL — a pathname this package would
|
|
282
|
-
have to serve itself is refused, because Foundry serves no files for a
|
|
283
|
-
package it does not install.
|
|
284
|
-
- **`package-build manifest` refuses**, rather than emitting a `module.json`
|
|
285
|
-
advertising an installable package with no id, no packs and no compatibility
|
|
286
|
-
range. So does `content-build package compile`, which would otherwise exit 0
|
|
287
|
-
having compiled nothing.
|
|
288
|
-
- **Notes need no `id:`.** An id is derived from a note's address, and a
|
|
289
|
-
hand-assigned one is only ever a pin for a document that already shipped —
|
|
290
|
-
which needs a compendium, and there is none.
|
|
291
|
-
- **The note vocabulary is `doc` and `homepage`.** Every other type exists to
|
|
292
|
-
become a Foundry document, so a note carrying one has no destination;
|
|
293
|
-
`content-build lint` reports it at its `type:` line.
|
|
294
|
-
- **A content index is still published.** `content-build content-index` emits
|
|
295
|
-
`<contentPackage>-metadata.jsonl` exactly as it does elsewhere, so another
|
|
296
|
-
package can resolve an address into this one and a wikilink from its notes can
|
|
297
|
-
reach a page here.
|
|
298
|
-
|
|
299
|
-
### A note's package is the repository's, not the note's
|
|
300
|
-
|
|
301
|
-
`contentPackage` is the **address namespace** every note in the tree is
|
|
302
|
-
published under: the first segment of every canonical key, the name of the link
|
|
303
|
-
manifest this build emits (`sohl.json`), and the package a cross-package
|
|
304
|
-
wikilink writes to reach one of these notes. It is the repository's identity in
|
|
305
|
-
the address space — not a filter — and a note does not restate it.
|
|
306
|
-
|
|
307
|
-
A canonical key has four segments, read by position:
|
|
308
|
-
|
|
309
|
-
```text
|
|
310
|
-
<package>-<system>-<type>-<shortcode>
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
`<system>` is a game system this toolchain compiles for — `sohl` or `hm3` — or
|
|
314
|
-
the literal `none` for a document no game system defines: a JournalEntry, a
|
|
315
|
-
Macro, a Scene, and an item's documentation journal, which is `none` however
|
|
316
|
-
many system blocks the item itself carries. The registry of permitted values is
|
|
317
|
-
`engine/systems.mjs`, and `none` is a **word** on purpose: `any` would read as a
|
|
318
|
-
wildcard, which is the opposite of what it says, and a YAML null (`null`, `~`,
|
|
319
|
-
an empty value) parses to an absent value and drops the segment altogether. So
|
|
320
|
-
`sohl-none-doc-gear`, `sohl-sohl-skill-clmb` — the package and the system are
|
|
321
|
-
independent slots that may hold the same word, because Foundry requires a system
|
|
322
|
-
package's id to _be_ its system id.
|
|
323
|
-
|
|
324
|
-
Because it is a segment of an address, the value is **validated** rather than
|
|
325
|
-
taken as written, and a violation fails the build naming the line it is on:
|
|
326
|
-
|
|
327
|
-
- **Lowercase alphanumeric** (`^[a-z0-9]+$`). An address is read by counting
|
|
328
|
-
hyphen-separated segments, so the hyphen has to be purely a separator — which
|
|
329
|
-
is why `harn-adventures` is configured as `harnadventures`. This is the same
|
|
330
|
-
rule `shortcode` is already held to, and the two are one constant.
|
|
331
|
-
- **Not a note type.** A written address is a _partial_ one — the shorter forms
|
|
332
|
-
drop segments from the left, so `skill-clmb` and `sohl-skill-clmb` are both
|
|
333
|
-
addresses — and position alone therefore no longer says which vocabulary a
|
|
334
|
-
leading segment is drawn from. The reader settles that by asking whether the
|
|
335
|
-
name is a known package, so a name belonging to both vocabularies makes one
|
|
336
|
-
target readable two ways with no defensible pick. The package and the type are
|
|
337
|
-
no longer _adjacent_ segments now that the system sits between them, and that
|
|
338
|
-
changes nothing: the hazard was never adjacency, it is that a short form omits
|
|
339
|
-
the slots in between. `doc`, `being`, every map type, and every item type this
|
|
340
|
-
repository declares — with its `doc`-prefixed documentation form — are
|
|
341
|
-
refused.
|
|
342
|
-
|
|
343
|
-
```text
|
|
344
|
-
package-build.config.yaml:1:1: error: package-build config: `contentPackage` is `harn-adventures`, which is not lowercase alphanumeric (^[a-z0-9]+$). It is the first segment of every address this package publishes (`harn-adventures-<system>-<type>-<shortcode>`), and an address is read by counting hyphen-separated segments — so anything outside that here makes those addresses unreadable rather than merely ugly. `harn-adventures` became `harnadventures`.
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
**`package:` in a note's frontmatter is retired, and declaring it fails the
|
|
348
|
-
build**, naming the file, whatever the value says. An agreeing declaration is
|
|
349
|
-
refused exactly as a disagreeing one is: there is no value that makes writing
|
|
350
|
-
the field correct. The diagnostic says so, and says where the value comes from
|
|
351
|
-
instead:
|
|
352
|
-
|
|
353
|
-
```text
|
|
354
|
-
assets/content/Gear/Axe.md:12:1: error: `package: sohl` is a retired frontmatter field — delete it. A note's package is this repository's configured `contentPackage` ("sohl", in package-build.config.yaml), and every note in the tree belongs to it.
|
|
355
|
-
```
|
|
356
|
-
|
|
357
|
-
`content-build lint` reports every such note in one pass; `content-build
|
|
358
|
-
package compile` and `content-build content-index` refuse the tree.
|
|
359
|
-
|
|
360
|
-
A generated table that scopes itself with `WHERE … and package = "<pkg>"` keeps
|
|
361
|
-
working: the package is **synthesised** into what the table search sees,
|
|
362
|
-
supplied from `contentPackage` rather than read off the note. It is a search
|
|
363
|
-
value, never an authored one.
|
|
364
|
-
|
|
365
|
-
It used to **select**: a note compiled when its `package:` matched and was
|
|
366
|
-
skipped when it did not. Every content tree is single-package — each is
|
|
367
|
-
single-sourced in the repository that ships it — so the field restated one
|
|
368
|
-
constant thousands of times, while a tree whose notes named a package no
|
|
369
|
-
configuration answered to compiled **zero notes and exited 0**. Deleting the
|
|
370
|
-
field from a note is the fix; deleting the _configured_ value is not, since
|
|
371
|
-
every address derives from it.
|
|
372
|
-
|
|
373
|
-
Sweeping a tree is mechanical — the field is a whole line, and nothing else
|
|
374
|
-
reads it:
|
|
375
|
-
|
|
376
|
-
```bash
|
|
377
|
-
find assets/content -name '*.md' -print0 | xargs -0 sed -i '' '/^package: /d'
|
|
378
|
-
```
|
|
379
|
-
|
|
380
|
-
### A registry of your own
|
|
381
|
-
|
|
382
|
-
`itemBuilders` is the one part of the contract that is code — a table of
|
|
383
|
-
functions building each type's `system` block — so data can only _name_ one of
|
|
384
|
-
the registries this package ships. A consumer supplying its own writes
|
|
385
|
-
`package-build.config.mjs` instead, which is loaded in place of the YAML:
|
|
386
|
-
|
|
387
|
-
```js
|
|
388
|
-
import { defineConfig } from "@heroiclands/package-build/content-config";
|
|
389
|
-
import { ITEM_BUILDERS } from "./build/item-builders.mjs";
|
|
390
|
-
|
|
391
|
-
export default defineConfig({
|
|
392
|
-
// Stated, since a code configuration derives nothing: it is code, and can
|
|
393
|
-
// read whatever it likes for itself.
|
|
394
|
-
rootDir: import.meta.dirname,
|
|
395
|
-
contentPackage: "kethira",
|
|
396
|
-
foundryPackage: "sohl-kethira-basic",
|
|
397
|
-
packageKind: "modules",
|
|
398
|
-
compatibility: { minimum: "14.359", verified: "14.364" },
|
|
399
|
-
stats: { lastModifiedBy: "…" },
|
|
400
|
-
itemBuilders: ITEM_BUILDERS,
|
|
401
|
-
packs: [{ name: "items", type: "Item" }],
|
|
402
|
-
});
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
The two forms end at the same `defineConfig`, so they are validated and frozen
|
|
406
|
-
identically; a code config simply states the three fields above itself, which it
|
|
407
|
-
can, because it is code. **Import `defineConfig` from
|
|
408
|
-
`@heroiclands/package-build/content-config`, never from the package root** — the root
|
|
409
|
-
barrel pulls in the compilers, the compilers read the resolved configuration,
|
|
410
|
-
and resolving it loads this file, so importing the barrel here closes a cycle
|
|
411
|
-
around the file's own evaluation. The `/config` entry point imports nothing but
|
|
412
|
-
`node:path` and the id helpers, so it cannot.
|
|
413
|
-
|
|
414
|
-
**One directory, one configuration.** A directory holding both a `.yaml` and an
|
|
415
|
-
`.mjs` is an error, not a precedence question: picking one would let a
|
|
416
|
-
repository mid-conversion build from the file its author is no longer editing,
|
|
417
|
-
and look entirely healthy doing it.
|
|
418
|
-
|
|
419
|
-
### Several packs of one document type
|
|
420
|
-
|
|
421
|
-
A repository may declare more than one pack of the same `type`, and route notes
|
|
422
|
-
between them. Editorial grouping of same-type documents into separate
|
|
423
|
-
compendiums is ordinary Foundry practice — "Core Spells" and "Expanded Spells"
|
|
424
|
-
are two Item packs — and it matters beyond taste: a compendium UUID carries its
|
|
425
|
-
pack name (`Compendium.<package>.<pack>.Item.<id>`), so collapsing several packs
|
|
426
|
-
into one invalidates every reference an existing world holds.
|
|
427
|
-
|
|
428
|
-
Two axes, deliberately orthogonal:
|
|
429
|
-
|
|
430
|
-
- a pack's **`type`** selects the _compiler_ that fills it;
|
|
431
|
-
- a note's **`pack:`** frontmatter selects _which pack of that type_ receives its
|
|
432
|
-
document.
|
|
433
|
-
|
|
434
|
-
```yaml
|
|
435
|
-
packs:
|
|
436
|
-
- { name: characteristics, type: Item, default: true }
|
|
437
|
-
- { name: mysteries, type: Item }
|
|
438
|
-
- { name: journals, type: JournalEntry }
|
|
439
|
-
```
|
|
440
|
-
|
|
441
|
-
```yaml
|
|
442
|
-
# A note that says nothing lands in `characteristics`, the default Item pack.
|
|
443
|
-
---
|
|
444
|
-
name:
|
|
445
|
-
full: Climbing
|
|
446
|
-
type: skill
|
|
447
|
-
id: ...
|
|
448
|
-
---
|
|
449
|
-
# A note that names one lands there instead.
|
|
450
|
-
---
|
|
451
|
-
name:
|
|
452
|
-
full: Second Sight
|
|
453
|
-
type: skill
|
|
454
|
-
id: ...
|
|
455
|
-
pack: mysteries
|
|
456
|
-
---
|
|
457
|
-
```
|
|
458
|
-
|
|
459
|
-
- **`pack:` is optional, and silence means the default.** Every note written
|
|
460
|
-
before this existed declares nothing, so an undeclared note must keep
|
|
461
|
-
compiling exactly where it always did. A type with exactly **one** pack is
|
|
462
|
-
that type's default implicitly; a type with several designates one with
|
|
463
|
-
`default: true`. Where several exist and none is marked, a declaration is
|
|
464
|
-
**mandatory** and an undeclared note fails the build.
|
|
465
|
-
- **A default is per system, not merely per type**. The rule above counts
|
|
466
|
-
every pack of a type together, so a tree shipping one Actor pack per system has
|
|
467
|
-
two and would need a flag — except that asked _per system_ the layout is
|
|
468
|
-
unambiguous, one pack each, for the same reason a single-pack type needs no
|
|
469
|
-
flag. And a system is never answered with another system's pack: a type-wide
|
|
470
|
-
default declaring `system: sohl` does not route the HM3 document, which
|
|
471
|
-
otherwise had that pass see a pack name that was not its own and skip every
|
|
472
|
-
note in the tree without a word.
|
|
473
|
-
- **A `pack:` naming another system's pack** is refused where the _block_
|
|
474
|
-
declares it — `hm3.pack` says where the HM3 document goes, so naming a SoHL
|
|
475
|
-
pack is a contradiction, reported naming the note and the pack. At the top
|
|
476
|
-
level it is no contradiction: the shared position is the value a note states
|
|
477
|
-
once for every system, and a system-specific pack cannot be that value, so it
|
|
478
|
-
simply does not answer for the other system, which falls through to its own
|
|
479
|
-
default.
|
|
480
|
-
- **A `pack:` naming no configured pack is a build error**, not a fall-through to
|
|
481
|
-
the default. A typo'd name that quietly landed content in the wrong compendium
|
|
482
|
-
would be silent partial compilation — the failure mode this toolchain's guards
|
|
483
|
-
exist to eliminate. The same applies to a name that belongs to a pack of
|
|
484
|
-
another document type, or to a companion (no note is ever routed into one).
|
|
485
|
-
- **A note's `pack:` names where its _own_ document goes.** Anything derived from
|
|
486
|
-
it — an item's or a macro's prose, which compiles into a JournalEntry of its
|
|
487
|
-
own — lands in the default pack of _that_ type.
|
|
488
|
-
- **Every document a note produces needs a pack, and one that has none is a
|
|
489
|
-
finding**. A note produces more than one document as a matter of
|
|
490
|
-
course: an item note an Item and the JournalEntry its prose becomes, a map
|
|
491
|
-
note a Scene and a JournalEntry, an actor note an Actor and a JournalEntry
|
|
492
|
-
too. Where the configuration declares no pack for one of them, that
|
|
493
|
-
document would be dropped while the rest of the note compiled into a pack
|
|
494
|
-
that does exist — a build that succeeds and ships half of what was written.
|
|
495
|
-
The finding names the note, the class with no pack, and the class that did
|
|
496
|
-
compile, which is what distinguishes it from a note **nothing** claims: that
|
|
497
|
-
one is a `type:` to correct, this one a pack to declare.
|
|
498
|
-
|
|
499
|
-
It is asked **per note, not per type**, because documentation is: `Journals`
|
|
500
|
-
declines a doc-carrying note whose body is empty — an item with no prose gets
|
|
501
|
-
no doc — so a tree of deliberately description-less items loses nothing by
|
|
502
|
-
having no JournalEntry pack, and is told nothing. And it names no system, so a
|
|
503
|
-
type one system maps and another does not stays silent for the system that
|
|
504
|
-
declines it.
|
|
505
|
-
|
|
506
|
-
- **`pack: none` compiles the note into no document, on purpose.** The note is
|
|
507
|
-
walked, published as a page, present in the content index with an address
|
|
508
|
-
and no UUID, and addressable by wikilink; every pass passes over it without
|
|
509
|
-
a finding, and the finding above says nothing about it. It is accepted only
|
|
510
|
-
on a type whose sole document is the JournalEntry its prose becomes — a
|
|
511
|
-
`doc`, say — and refused by name on a type that compiles an Item, an Actor,
|
|
512
|
-
a Macro, a Scene or an Adventure, where it would drop that document. A
|
|
513
|
-
configured pack may not be called `none`. This is how a page of developer
|
|
514
|
-
documentation lives in the content tree beside everything else: it is a
|
|
515
|
-
note like any other, and it reaches no compendium.
|
|
516
|
-
|
|
517
|
-
**The configuration is found by walking up from the working directory, and from
|
|
518
|
-
the installed package only if that finds nothing.** `engine/pack-config.mjs`
|
|
519
|
-
climbs from `process.cwd()` first, so a build reads the tree it was run in —
|
|
520
|
-
from the repository root, from `packages/`, from anywhere below, since the walk
|
|
521
|
-
climbs. Climbing from the module itself is the fallback, for an invocation from
|
|
522
|
-
outside any repository.
|
|
523
|
-
|
|
524
|
-
The order matters in one shape: a git worktree nested under its parent checkout
|
|
525
|
-
with **no `node_modules` of its own** resolves `@heroiclands/package-build` out
|
|
526
|
-
of the parent's, because Node's resolution walks parent directories. Climbing
|
|
527
|
-
from the module then landed on the _parent's_ configuration, and the build
|
|
528
|
-
compiled the parent's content tree into the parent's `build/` and exited 0
|
|
529
|
-
. When both walks find a configuration and they disagree, the working
|
|
530
|
-
directory's is read and the ignored one is named in a warning — that
|
|
531
|
-
disagreement is also the cheapest signal that this tree is building on another
|
|
532
|
-
checkout's `node_modules`. Run `npm ci` in the worktree to give it its own.
|
|
533
|
-
|
|
534
|
-
Set `PACKAGE_BUILD_CONFIG` to point at the file explicitly if a consumer keeps
|
|
535
|
-
it somewhere else; it skips both walks.
|
|
536
|
-
|
|
537
|
-
**The configuration is resolved on first read, never at import.** Every module
|
|
538
|
-
here can be imported — and `content-build --version` and `--help` answered — in a
|
|
539
|
-
directory with no `package-build.config.yaml` and no Foundry package manifest, so
|
|
540
|
-
a consumer can reach for one pure helper (`engine/content-address`,
|
|
541
|
-
`engine/wikilinks`) without standing up a pack build. Anything derived from
|
|
542
|
-
configuration is therefore an accessor rather than a hoisted constant —
|
|
543
|
-
`loadPackConfig()`, `contentPackage()`, `foundryPackageId()`, `itemTypes()`,
|
|
544
|
-
`docEntryTypes()`, `packRouter()`, `defaultTemplateDir()` — and each throws, with
|
|
545
|
-
the same explicit message as before, the moment a build actually needs a value it
|
|
546
|
-
cannot find. Absence is still a hard failure; only the moment it is reported
|
|
547
|
-
moved (#2).
|
|
548
|
-
|
|
549
|
-
The file is read synchronously — an `.mjs` one with `require` — so that reading
|
|
550
|
-
a configured value stays an ordinary expression instead of making every module
|
|
551
|
-
downstream of it an async one. The one shape that cannot be loaded is an `.mjs`
|
|
552
|
-
config whose own module graph uses top-level `await`, which is reported as such.
|
|
553
|
-
|
|
554
|
-
**`itemBuilders` is how the engine learns a consumer's item types without
|
|
555
|
-
holding its data model.** `itemTypes` is its key set, and `docEntryTypes` — every
|
|
556
|
-
type whose prose compiles into a JournalEntry of its own — is composed from it
|
|
557
|
-
exactly once, here, and read through `loadPackConfig()` everywhere. There is one
|
|
558
|
-
resolved set at runtime; the compilers and the link-manifest emitter cannot come
|
|
559
|
-
to disagree about which notes carry documentation.
|
|
560
|
-
|
|
561
|
-
**Every note compiling into a system-bearing document carries documentation, and
|
|
562
|
-
that includes actors**. `docEntryTypes` is `itemTypes` plus the actor
|
|
563
|
-
types the shipped subtype maps declare (`ACTOR_TYPES`, derived from them rather
|
|
564
|
-
than listed again), plus `macro` and the map types. Only `doc` is outside it, for
|
|
565
|
-
the reason that actually applies to it: its single document _is_ the prose.
|
|
566
|
-
|
|
567
|
-
A being therefore has the same two addresses an item has — `<pkg>-<system>-being-<shortcode>`
|
|
568
|
-
for the Actor and `<pkg>-none-docbeing-<shortcode>` for the page — where before
|
|
569
|
-
it had only the first, and was the one system-bearing note a prose link could not
|
|
570
|
-
name.
|
|
571
|
-
|
|
572
|
-
**An actor keeps its prose inline as well, and that asymmetry is deliberate.** An
|
|
573
|
-
item's description is an `@UUID` pointer into its journal, because one item is
|
|
574
|
-
embedded across hundreds of beings and baking long prose into every copy bloats
|
|
575
|
-
the compendium by the length of the text times the number of carriers. An actor
|
|
576
|
-
is singular, so the same indirection costs a reader a click and saves nothing:
|
|
577
|
-
`system.appearance` and `system.dossier` stay as rendered prose.
|
|
578
|
-
|
|
579
|
-
The Item compiler **dispatches through that same resolved table**, via
|
|
580
|
-
`engine/item-registry.mjs` (`itemTypes()` and `itemBuilder(type)`), so the types a
|
|
581
|
-
consumer's notes are accepted for and the builders they compile with are one
|
|
582
|
-
object. Supplying `itemBuilders` is therefore all a consumer does to define an
|
|
583
|
-
item type of its own; a table this package ships is one possible value, not the
|
|
584
|
-
one the compiler holds.
|
|
585
|
-
|
|
586
|
-
**A tree feeding two systems declares a set of registries.** One registry is
|
|
587
|
-
also a ceiling: the accepted vocabulary is its keys, so a type only the _other_
|
|
588
|
-
system knows — `spell` and `invocation` are HM3's, `mysticalability` is SoHL's —
|
|
589
|
-
cannot be accepted at all. So `itemBuilders` takes either form:
|
|
590
|
-
|
|
591
|
-
```yaml
|
|
592
|
-
itemBuilders: sohl # one registry, and what every existing configuration says
|
|
593
|
-
itemBuilders: [sohl, hm3] # a set; the vocabulary is their union
|
|
594
|
-
```
|
|
595
|
-
|
|
596
|
-
A registry's **name is the system it belongs to**, which is what lets a data
|
|
597
|
-
configuration declare a set without naming each system twice. In an `.mjs`
|
|
598
|
-
configuration the same set is written out:
|
|
599
|
-
|
|
600
|
-
```js
|
|
601
|
-
itemBuilders: [
|
|
602
|
-
{ system: "sohl", builders: SOHL_ITEM_BUILDERS },
|
|
603
|
-
{ system: "hm3", builders: HM3_ITEM_BUILDERS },
|
|
604
|
-
],
|
|
605
|
-
```
|
|
606
|
-
|
|
607
|
-
A type **both** registries declare — `skill` is one name over two data models —
|
|
608
|
-
keeps a builder on each side. `itemBuilder(type, system)` and `itemArt(type,
|
|
609
|
-
system)` take the system that is asking; asking without one, for a type more
|
|
610
|
-
than one registry declares, **throws** rather than answering with whichever was
|
|
611
|
-
declared first.
|
|
612
|
-
|
|
613
|
-
**`hm3` is a real registry, and the rest of the pipeline follows the same
|
|
614
|
-
field.** This package ships two system halves, `sohl/` and `hm3/`,
|
|
615
|
-
each with its own builders, its own default art and its own note-type →
|
|
616
|
-
document-subtype map; they share the engine between them and import nothing from
|
|
617
|
-
each other. A pack's `system:` is what selects among them — the Item and Actor
|
|
618
|
-
compilers, the item catalogue a being resolves against, the published
|
|
619
|
-
`schema.json` its emissions are checked against, and the `_stats` stamp all read
|
|
620
|
-
that one field — so declaring one Item pack and one Actor pack per system is the
|
|
621
|
-
whole of the configuration a dual-system tree needs:
|
|
622
|
-
|
|
623
|
-
```yaml
|
|
624
|
-
itemBuilders: [sohl, hm3]
|
|
625
|
-
systems:
|
|
626
|
-
sohl: { compatibility: { verified: "0.9.0" } }
|
|
627
|
-
hm3: { compatibility: { verified: "1.6.3" } }
|
|
628
|
-
packs:
|
|
629
|
-
- { name: items-sohl, type: Item, system: sohl }
|
|
630
|
-
- { name: items-hm3, type: Item, system: hm3 }
|
|
631
|
-
- { name: actors-sohl, type: Actor, system: sohl }
|
|
632
|
-
- { name: actors-hm3, type: Actor, system: hm3 }
|
|
633
|
-
```
|
|
634
|
-
|
|
635
|
-
**The `systems:` block is required here, not decorative.** A pack's `system:` is
|
|
636
|
-
what its documents are stamped `_stats.systemId` and `systemVersion` from, and
|
|
637
|
-
the version can only come from that block — or, for a package whose packs are
|
|
638
|
-
all for its own system, from the package-wide stats. A pack naming a system that
|
|
639
|
-
resolves to neither is refused at configuration time, naming the pack and the
|
|
640
|
-
entry to add. Falling through and stamping `null` for both is a plausible lie:
|
|
641
|
-
a pack whose configuration says `system: sohl` on the line above ships every
|
|
642
|
-
compiled actor with no system at all.
|
|
643
|
-
|
|
644
|
-
No `default: true` anywhere, because each system has exactly one pack of each
|
|
645
|
-
type and a default is resolved per system. Marking one is still allowed and
|
|
646
|
-
still means what it says — it designates that _system's_ default where a system
|
|
647
|
-
has several packs of a type.
|
|
648
|
-
|
|
649
|
-
A note carrying both a `sohl:` and an `hm3:` block then compiles **one document
|
|
650
|
-
in each system**, each shaped by its own builders and stamped with its own
|
|
651
|
-
system version. A note carrying only one block compiles only that system's
|
|
652
|
-
document: the other system's pass passes over it, rather than failing it for a
|
|
653
|
-
block it was never going to have.
|
|
654
|
-
|
|
655
|
-
Four of HM3's rows are **one-to-many** — `mysticalability` becomes a `psionic`,
|
|
656
|
-
a `spell` or an `invocation`; `trauma` an `injury` or a `trait`; `weapongear` a
|
|
657
|
-
`weapongear` or a `missilegear`; `being` a `character` or a `creature` — and the
|
|
658
|
-
note says which by writing `hm3.type`. Nothing is inferred from the note's own
|
|
659
|
-
`subType`, and a note that says nothing is an error naming the note and listing
|
|
660
|
-
the permitted values.
|
|
661
|
-
|
|
662
|
-
**Configuration is the source, and the manifest is generated from it.** The
|
|
663
|
-
floor is the top-level `compatibility.minimum`, the id is derived from
|
|
664
|
-
`package.json` `name`, and `@heroiclands/package-build` writes the manifest from
|
|
665
|
-
this file. There is no `paths.packageManifest` pointing at a hand-authored
|
|
666
|
-
`system.template.json` for a package-id guard or a `_stats.coreVersion` stamp to
|
|
667
|
-
read out of.
|
|
668
|
-
|
|
669
|
-
### Declaration order is presentation, not compile order
|
|
670
|
-
|
|
671
|
-
`packs:` is the manifest's `packs` array as well, so a consumer orders it for a
|
|
672
|
-
reader browsing compendiums. It is **not** the order the passes run in, and it
|
|
673
|
-
does not have to be: the compile order is derived from what each pass reads.
|
|
674
|
-
|
|
675
|
-
One pass reads another's output today. The actors pass resolves each being's
|
|
676
|
-
embedded items against the JSON the item passes wrote — a being names an item by
|
|
677
|
-
`(type, shortcode)` and never by the pack it ships in, so **every** Item pack has
|
|
678
|
-
to be compiled before the Actor pass, not merely the first. A compiler states
|
|
679
|
-
that on itself:
|
|
680
|
-
|
|
681
|
-
```js
|
|
682
|
-
export class Actors extends BasePackCompiler {
|
|
683
|
-
static readsPackOutputOf = Object.freeze(["Item"]);
|
|
684
|
-
}
|
|
685
|
-
```
|
|
686
|
-
|
|
687
|
-
The generator schedules each pass after the packs of every type it names, and
|
|
688
|
-
does so with the **smallest** reordering that works — the earliest declared pass
|
|
689
|
-
whose dependencies have all run goes next. A list already in a workable order is
|
|
690
|
-
therefore compiled exactly as declared, and one that is not moves only the
|
|
691
|
-
passes that had to move. When the two orders differ the build says so:
|
|
692
|
-
|
|
693
|
-
```text
|
|
694
|
-
[INFO]: Pass order: characteristics, mysteries, characters — a pass that reads
|
|
695
|
-
another's output compiles after it, whatever order `packs:` declares.
|
|
696
|
-
```
|
|
697
|
-
|
|
698
|
-
This is not the author's problem, and it would be a nasty one: an Actor pack declaring
|
|
699
|
-
first compiled only where an earlier run had already left `build/packs-json`
|
|
700
|
-
populated. `build/` is gitignored, so it was green on every local tree that had
|
|
701
|
-
built once and exit 1 on every fresh checkout and CI runner, over a message that
|
|
702
|
-
named a missing directory rather than the ordering that caused it. A
|
|
703
|
-
consumer registering a compiler of its own declares its dependencies the same
|
|
704
|
-
way; a type no pack of which is declared is simply not waited for.
|
|
705
|
-
|
|
706
|
-
**Compiling one pack by name is the case ordering cannot answer.**
|
|
707
|
-
`content-build package compile <name>` runs the pass you asked for and no other,
|
|
708
|
-
so a dependency that is neither in the run nor already on disk is reported
|
|
709
|
-
rather than ordered around:
|
|
710
|
-
|
|
711
|
-
```text
|
|
712
|
-
error: pack "characters" (Actor) reads the compiled output of the Item pack
|
|
713
|
-
"characteristics", which this run does not compile and which
|
|
714
|
-
build/packs-json/characteristics does not hold — compile the whole
|
|
715
|
-
package, or compile "characteristics" first
|
|
716
|
-
```
|
|
717
|
-
|
|
718
|
-
### An item type's default art
|
|
719
|
-
|
|
720
|
-
A note that names no art gets its type's **default art**, and a type declares
|
|
721
|
-
that art in the same place it declares its builder. An `itemBuilders` entry may
|
|
722
|
-
be written two ways:
|
|
723
|
-
|
|
724
|
-
```js
|
|
725
|
-
itemBuilders: {
|
|
726
|
-
// A bare builder. Every note of this type must carry its own `img:`.
|
|
727
|
-
charm: buildCharm,
|
|
728
|
-
// The same builder, paired with the art a note of this type gets when it
|
|
729
|
-
// sets no `img:` of its own.
|
|
730
|
-
relic: { system: buildRelic, img: "icons/relic.svg" },
|
|
731
|
-
}
|
|
732
|
-
```
|
|
733
|
-
|
|
734
|
-
Both spellings are equal; the difference is only whether the type brings art.
|
|
735
|
-
`itemTypes` is still the key set either way, so a type is still impossible to
|
|
736
|
-
whitelist without a builder behind it.
|
|
737
|
-
|
|
738
|
-
**The path is spelled the way a note spells it.** Registry art goes through the
|
|
739
|
-
same `resolveImg` rule as a note's `img:`, so one spelling means one thing
|
|
740
|
-
wherever it is written.
|
|
741
|
-
|
|
742
|
-
#### A pathname names the package that owns the file
|
|
743
|
-
|
|
744
|
-
Every authored pathname — a registry `img:`, a note's `img:`, an actor's
|
|
745
|
-
`data.portrait:`, a map's background, the address of an image in a body —
|
|
746
|
-
answers "which package holds this file?" in its **first segment**, when an
|
|
747
|
-
`assets/` follows it. Everything after that `assets/` is the _suffix_, and a
|
|
748
|
-
pathname that does not open `<package>/assets/` belongs to the package being
|
|
749
|
-
compiled, whole.
|
|
750
|
-
|
|
751
|
-
Four surfaces derive an address from that one statement. For a `thalorna` note
|
|
752
|
-
— the package Foundry installs as the module `sohl-thalorna` — writing
|
|
753
|
-
`images/map.webp`:
|
|
754
|
-
|
|
755
|
-
| Surface | Address |
|
|
756
|
-
| ----------- | -------------------------------------------------------- |
|
|
757
|
-
| **Foundry** | `modules/sohl-thalorna/assets/images/map.webp` |
|
|
758
|
-
| **Local** | `assets/images/map.webp` |
|
|
759
|
-
| **Web** | `https://cdn.heroiclands.org/thalorna/images/map.webp` |
|
|
760
|
-
| **Book** | `assets/images/map.webp`, staged beside the Typst source |
|
|
761
|
-
|
|
762
|
-
`sohl/assets/icons/relic.svg` names the `sohl` package's file wherever it is
|
|
763
|
-
written, which is what lets a module pair a SoHL default with one of its own
|
|
764
|
-
types. The package's name is not its Foundry id: `thalorna` is the content and
|
|
765
|
-
`sohl-thalorna` the install, and only the Foundry form carries the second.
|
|
766
|
-
|
|
767
|
-
The web host is `site.assets`, and a package-owned image on a page with none
|
|
768
|
-
set is an error naming that key.
|
|
769
|
-
|
|
770
|
-
**The rule is about ownership, not a list of directories.** A package owns its
|
|
771
|
-
whole `assets/` tree, so a directory this toolchain has never heard of is still
|
|
772
|
-
that package's: art under `assets/artwork/` is addressed `artwork/deity.webp`
|
|
773
|
-
and resolved exactly as `icons/…` and `images/…` are. An address naming no
|
|
774
|
-
package at all — an absolute URL, a `data:` URI, a `/`-rooted path — passes
|
|
775
|
-
through on every surface, on the same rule rather than as an exception.
|
|
776
|
-
|
|
777
|
-
**A `systems/…` or `modules/…` pathname is refused**, with a finding naming the
|
|
778
|
-
replacement: it resolves for Foundry and for neither of the other surfaces, and
|
|
779
|
-
deriving anything from it would put a wrong address on two of the three.
|
|
780
|
-
`worlds/` is not such a root — a package may not ship files out of a world, so
|
|
781
|
-
that path gets the ordinary reading and produces a plainly broken one rather
|
|
782
|
-
than a plausible one that 404s unreported.
|
|
783
|
-
|
|
784
|
-
A `documentation` package installs nothing, so it has no Foundry form to derive
|
|
785
|
-
and addresses another package's file by a `/`-rooted path or a URL.
|
|
786
|
-
|
|
787
|
-
**`banner:` is a path that does not follow this rule.** It reaches no compiled
|
|
788
|
-
document and no book; it is a top-level key the Hugo theme reads, and the theme
|
|
789
|
-
prefixes a relative value with `images/` and joins it onto
|
|
790
|
-
`params.cdnBaseURL`. The two address different places — a pathname a file a
|
|
791
|
-
package ships, `banner:` a hero image on the site's own asset host — so they are
|
|
792
|
-
stated apart rather than reconciled. See the
|
|
793
|
-
[content format specification](docs/content-format.md#banner-addresses-the-cdn-not-the-foundry-install).
|
|
794
|
-
|
|
795
|
-
#### "Names no art" and "wants no art" are different
|
|
796
|
-
|
|
797
|
-
A note has two ways to leave `img:` empty, and they mean opposite things:
|
|
798
|
-
|
|
799
|
-
| a note writes | it means | it compiles with |
|
|
800
|
-
| -------------- | ------------------------------------ | ---------------- |
|
|
801
|
-
| nothing at all | _unset_ — name me no art | the type default |
|
|
802
|
-
| `img: null` | the same thing, said out loud | the type default |
|
|
803
|
-
| `img: ""` | _blank on purpose_ — I want no image | no image |
|
|
804
|
-
| `img: <path>` | this art | that path |
|
|
805
|
-
|
|
806
|
-
`resolveImg` returns `null` for the first two and `""` for the third, and every
|
|
807
|
-
caller pairs its default with **nullish** coalescing — `resolveImg(fm.img) ?? itemArt(type)`.
|
|
808
|
-
Never `||`: that collapses a deliberate blank back into the default and takes the
|
|
809
|
-
distinction away again.
|
|
810
|
-
|
|
811
|
-
**`portrait` is the same field twice over.** A being carries `img` (its token
|
|
812
|
-
art) and `portrait` (its sheet portrait) independently, and both resolve through
|
|
813
|
-
`resolveImg`, so the rule above is the rule for both.
|
|
814
|
-
|
|
815
|
-
This is the convention the project already holds for an optional "not specified"
|
|
816
|
-
DataModel string — `nullable, initial: null`, so "unset" is one honest value
|
|
817
|
-
rather than two.
|
|
818
|
-
|
|
819
|
-
> **The rule is `img`'s, and does not extend to `title`.** `title` is not art
|
|
820
|
-
> and never reaches `resolveImg`, so nothing here applies to it.
|
|
821
|
-
>
|
|
822
|
-
> The field declares `topLevelMeans`, so the top-level key is not a source for
|
|
823
|
-
> an `affiliation` item's `system.title`. Were it one, a single authored key
|
|
824
|
-
> would feed two unrelated destinations that disagree about what empty means,
|
|
825
|
-
> and `title: null` would stringify into the compiled document as the literal
|
|
826
|
-
> `"null"`.
|
|
827
|
-
>
|
|
828
|
-
> So `title: null` is a note declining to state a heading, and the site emitter's
|
|
829
|
-
> `fm.title ?? name` falls back to `name.full`. `title: ""` publishes a
|
|
830
|
-
> deliberately blank heading and is warned about on its own account — as the
|
|
831
|
-
> page's heading, not as an art path.
|
|
832
|
-
>
|
|
833
|
-
> **That warning reads the note's top level only.** The two spellings still name
|
|
834
|
-
> unrelated quantities, so a `sohl.title` — the office's style of address — must
|
|
835
|
-
> not answer for the page's heading. Twenty-eight `sohl-kethira-basic`
|
|
836
|
-
> affiliations write `sohl.title: ""`, meaning an office with no style of
|
|
837
|
-
> address, and every one of them was reported as publishing a blank heading
|
|
838
|
-
> until the check honoured the declaration.
|
|
839
|
-
|
|
840
|
-
Because `""` reads as "unset" elsewhere, a note carrying that spelling has
|
|
841
|
-
quietly changed meaning, and the frontmatter lint says so — for either art
|
|
842
|
-
field:
|
|
843
|
-
|
|
844
|
-
```
|
|
845
|
-
Note.md:9:1: warning: `img: ""` means "ship no art at all" — it no longer falls
|
|
846
|
-
back to this type's default. Write `img: null` for a note that simply names
|
|
847
|
-
none; keep `""` only where the document is meant to have no image
|
|
848
|
-
```
|
|
849
|
-
|
|
850
|
-
A warning, not an error: the note still compiles, to a document that is merely
|
|
851
|
-
iconless.
|
|
852
|
-
|
|
853
|
-
**A type with neither is a build error, deliberately.** When a note names no
|
|
854
|
-
art and its type pairs none, the pack build aborts rather than shipping an
|
|
855
|
-
item with a mismatched icon:
|
|
856
|
-
|
|
857
|
-
```
|
|
858
|
-
No default art for item type "relic" — the note carries no `img:`, and the
|
|
859
|
-
`itemBuilders` entry for "relic" in this repository's configuration
|
|
860
|
-
pairs none with its builder.
|
|
861
|
-
```
|
|
862
|
-
|
|
863
|
-
#### Why art travels with the builder (#7)
|
|
864
|
-
|
|
865
|
-
It did not always. The item **type** whitelist was derived from a consumer's
|
|
866
|
-
`itemBuilders` keys, while the **art** for those same types was looked up in
|
|
867
|
-
`sohl/default-item-art.mjs` — a table this package ships for the `sohl` package
|
|
868
|
-
and which a consumer cannot add to. A type was therefore configurable while its
|
|
869
|
-
default art was not, and a second consumer's own item type compiled only if
|
|
870
|
-
every one of its notes carried an explicit `img:`; the first note that omitted
|
|
871
|
-
one failed the build with an error naming a module in someone else's package.
|
|
872
|
-
|
|
873
|
-
Widening that map was not the fix. It is deliberately SoHL data, shared with the
|
|
874
|
-
runtime's `SohlItem.getDefaultArtwork` so that the build-time and runtime
|
|
875
|
-
defaults are one list and cannot drift. Pairing art with the
|
|
876
|
-
builder instead moves it onto the seam a type is _already_ declared through, and
|
|
877
|
-
costs the `sohl` package nothing: `ITEM_BUILDERS` reads each entry's image out of
|
|
878
|
-
that same map, so there is still exactly one map — and the drift a test used to
|
|
879
|
-
watch for is now unrepresentable, because building the registry throws if a type
|
|
880
|
-
has no art.
|
|
881
|
-
|
|
882
|
-
## The per-system block
|
|
883
|
-
|
|
884
|
-
A note is **system-agnostic**. The only system-specific things it carries are
|
|
885
|
-
the properties named after a system, and one note may carry more than one — a
|
|
886
|
-
`being` in `harn-ensemble` compiles into a SoHL `being` _and_ an HM3
|
|
887
|
-
`character`.
|
|
888
|
-
|
|
889
|
-
**The block is what makes a game document.** A note carrying none produces no
|
|
890
|
-
Actor and no Item at all, in any package; its prose still compiles into a page,
|
|
891
|
-
a PDF page and a JournalEntry, so a person can be written without being statted.
|
|
892
|
-
Within a system's block:
|
|
893
|
-
|
|
894
|
-
| property | maps to |
|
|
895
|
-
| ------------------ | ------------------------------------------------------------- |
|
|
896
|
-
| `<system>.system` | `document.system` — the DataModel schema, verbatim paths |
|
|
897
|
-
| `<system>.type` | `document.type` — the subtype the note compiles into |
|
|
898
|
-
| `<system>.img` | `document.img` |
|
|
899
|
-
| `<system>.items` | `document.items` — actors only |
|
|
900
|
-
| `<system>.effects` | `document.effects` |
|
|
901
|
-
| `<system>.flags` | `document.flags` |
|
|
902
|
-
| `<system>.pack` | _nothing on the document_ — a build directive naming the pack |
|
|
903
|
-
|
|
904
|
-
Everything else a system declares sits directly under the block. `kbcat` is
|
|
905
|
-
toolchain vocabulary, and the _generators_ `items` and `attributes` expand into
|
|
906
|
-
embedded documents rather than mapping anywhere, so neither has a `system` path
|
|
907
|
-
to be written at.
|
|
908
|
-
|
|
909
|
-
`templatePriority` is the one key of this shape that _is_ a field, and it is
|
|
910
|
-
**shared rather than per-system**: its home is `data.templatePriority`, and it
|
|
911
|
-
reaches `sohl.system.templatePriority` and `hm3.flags.hm3.templatePriority` —
|
|
912
|
-
exactly as `portrait` reaches two differently-named fields from one shared
|
|
913
|
-
property. A number is a template at that priority, `null` is not a template, and
|
|
914
|
-
absent is an authoring error. The legacy in-block and top-level
|
|
915
|
-
positions are still read, in that order after `data:`, so a tree sweeps on its
|
|
916
|
-
own schedule; `archetype` is the retiring spelling of the same field, still read
|
|
917
|
-
last but **refused by the frontmatter linter** — a priority and the `archetypes`
|
|
918
|
-
a being fits are different things, and one letter is not enough to tell them
|
|
919
|
-
apart.
|
|
920
|
-
|
|
921
|
-
```yaml
|
|
922
|
-
type: being # the content type — system-agnostic
|
|
923
|
-
pack: actors # shared: unless a block says otherwise
|
|
924
|
-
portrait: kaldor.webp # shared: reaches both systems' fields, differently named
|
|
925
|
-
data:
|
|
926
|
-
templatePriority: 1 # shared: → sohl `system.`, hm3 `flags.hm3.`
|
|
927
|
-
hm3:
|
|
928
|
-
type: character # this system's document subtype
|
|
929
|
-
pack: actors-hm3 # overrides the shared one, for HM3 only
|
|
930
|
-
system: # → document.system, verbatim
|
|
931
|
-
species: human
|
|
932
|
-
sunsign: ulandus
|
|
933
|
-
attributes: { str: 10, sta: 14 } # a generator, not a system field
|
|
934
|
-
sohl:
|
|
935
|
-
type: being
|
|
936
|
-
system:
|
|
937
|
-
currentMoveMedium: walk
|
|
938
|
-
```
|
|
939
|
-
|
|
940
|
-
**The shared fallback is declared, not name-matched.** `sohl.system.portrait`
|
|
941
|
-
and `hm3.system.bioImage` both default from one shared property, and they are
|
|
942
|
-
two real fields with different names — SoHL's `Actor.being` and HM3's
|
|
943
|
-
`Actor.character` share **no** field name at all, so a rule matching on spelling
|
|
944
|
-
would never fire. Each field declares its source instead, and resolution for a
|
|
945
|
-
system `S` is:
|
|
946
|
-
|
|
947
|
-
1. `S.system.<to>` — authored directly, wins outright;
|
|
948
|
-
2. `S.<name>` — the legacy in-block position, until the corpus moves off it;
|
|
949
|
-
3. the shared top-level property the field declares as its source, which may be
|
|
950
|
-
a **dotted path** (`data.portrait`) rather than a sibling key;
|
|
951
|
-
4. the field's own default.
|
|
952
|
-
|
|
953
|
-
`FieldSpec.name` is that declared source. Read as "frontmatter key under
|
|
954
|
-
`sohl:`", which is the degenerate case where source and destination happen to
|
|
955
|
-
share a name.
|
|
956
|
-
|
|
957
|
-
**A spelling that means two different things skips step 3.** Because a field's
|
|
958
|
-
`name` doubles as its identity and as the shared property it draws from, the two
|
|
959
|
-
coincide only while the note vocabulary and the system vocabulary agree about
|
|
960
|
-
what a spelling means. `title` is where they do not. A note's top-level `title`
|
|
961
|
-
is _the title of the note_ — the heading its page publishes under, which the
|
|
962
|
-
site emitter reads. An `affiliation` item's `system.title` is _the style of
|
|
963
|
-
address the office carries_ — Ajaw, Warden, a person's style within the body.
|
|
964
|
-
They are unrelated quantities, and step 3 used to feed the second from the first
|
|
965
|
-
.
|
|
966
|
-
|
|
967
|
-
That was not merely untidy, because **step 3 answers without applying
|
|
968
|
-
`field.default`** — only step 2 does — so an authored `title: null` reached the
|
|
969
|
-
field's `String()` coercion unguarded and shipped as the literal string `"null"`.
|
|
970
|
-
|
|
971
|
-
So a field may declare `topLevelMeans`: what the top-level key of that name means
|
|
972
|
-
_instead_. Declaring it removes step 3 for that field, and the value is the
|
|
973
|
-
reason rather than a bare flag, so the collision is legible where the field is
|
|
974
|
-
declared and the generated field reference can print it. It is a per-field
|
|
975
|
-
opt-out, not a change to the order — step 3 is right wherever the two levels
|
|
976
|
-
state the same quantity, which is nearly everywhere: `subType` is the other
|
|
977
|
-
declared item field spelled like a note-level key, and there the two agree by
|
|
978
|
-
design.
|
|
979
|
-
|
|
980
|
-
**The statement is symmetric, and the frontmatter linter reads it from the other
|
|
981
|
-
side too.** If the two positions hold unrelated quantities, then the _in-block_
|
|
982
|
-
position is not the note-level field either — so a check about a note-level field
|
|
983
|
-
(the page's heading, an art path) reads past a block key the note's own type
|
|
984
|
-
claims for something else. Declaring `topLevelMeans` settles both directions at
|
|
985
|
-
once. Reading it for the emitted field alone is how an
|
|
986
|
-
affiliation's office style came to answer for its page heading.
|
|
987
|
-
|
|
988
|
-
**An exempted field is still authorable**, at the two positions that describe the
|
|
989
|
-
document rather than the note:
|
|
990
|
-
|
|
991
|
-
```yaml
|
|
992
|
-
title: The Order of the Silver Hand # the note's own heading — reaches the page
|
|
993
|
-
type: affiliation
|
|
994
|
-
subType: order
|
|
995
|
-
sohl:
|
|
996
|
-
system:
|
|
997
|
-
title: Warden # → document.system.title, the style of address
|
|
998
|
-
```
|
|
999
|
-
|
|
1000
|
-
`sohl.title`, the legacy in-block position, works the same way. A membership — a
|
|
1001
|
-
`title` a particular being holds — is authored on the entry in that being's
|
|
1002
|
-
`sohl.items`, whose `system` is overlaid on the catalogue document directly.
|
|
1003
|
-
`data.title` is neither position: `title` is not a `data:` property any note type
|
|
1004
|
-
declares, so the frontmatter lint refuses it.
|
|
1005
|
-
|
|
1006
|
-
**`<system>.system` is written through verbatim**, at the DataModel's own paths,
|
|
1007
|
-
with no renaming layer. A key the system's published `schema.json` does not
|
|
1008
|
-
declare for the subtype the note compiles into is an **error naming the note**,
|
|
1009
|
-
not a silent drop: Foundry discards an unknown `system` key at construction
|
|
1010
|
-
without a word, so the alternative is a field the author wrote and nobody will
|
|
1011
|
-
ever see. A path a declared field already writes is left to that field, so the
|
|
1012
|
-
value goes through one coercion rather than two.
|
|
1013
|
-
|
|
1014
|
-
**What the compiler writes on its own is checked too.** A compiled document
|
|
1015
|
-
carries keys no field declaration and no note ever names — `shortcode`,
|
|
1016
|
-
`templatePriority`, `actionDefs`, `notes`, `docHtml` — because the pass writes them
|
|
1017
|
-
itself, and they were compared against nothing: the declaration-derived check
|
|
1018
|
-
reads `itemBuilders`, the note-side check reads `<system>.system`, and these are
|
|
1019
|
-
in neither. A compile now reads the `system` block each pass **assembled** and
|
|
1020
|
-
checks its keys against the same published `schema.json`, so the emitted set is
|
|
1021
|
-
observed rather than listed and a compiler that grows a key is covered without
|
|
1022
|
-
anyone remembering to add it. A key the schema does not declare is an error,
|
|
1023
|
-
reported once per subtype rather than once per document, and the message says
|
|
1024
|
-
which of two things wrote it: a `fields:` entry, which the repository can change,
|
|
1025
|
-
or the compiler, which it cannot — that one means the build is running ahead of
|
|
1026
|
-
the system it compiles for, and the fixes are to declare the field there or to
|
|
1027
|
-
hold this package at a build that does not write it. A subtree the schema
|
|
1028
|
-
declares but describes no further — a discriminated `TypedSchemaField`, stored
|
|
1029
|
-
flat — is left alone rather than reported wholesale.
|
|
1030
|
-
|
|
1031
|
-
**An Actor or Item pass takes only notes carrying the block it reads.** A note
|
|
1032
|
-
that says nothing about a system has no system data, and compiling it would emit
|
|
1033
|
-
a hollow document — a subtype, and none of the fields the subtype exists for. So
|
|
1034
|
-
a note carrying no block for any system produces no Actor and no Item anywhere,
|
|
1035
|
-
and is passed over in silence; its prose still compiles into a page, a PDF page
|
|
1036
|
-
and a JournalEntry. The question is asked of the pass's own system rather than
|
|
1037
|
-
of the pack's `system:`: a pack declaring none is compiled by the fallback pass,
|
|
1038
|
-
which reads one block like any other. A pass whose document is not system data
|
|
1039
|
-
at all — journals, macros, scenes — is not subject to the rule.
|
|
1040
|
-
|
|
1041
|
-
**A block for a system no pack of the class compiles is an error**, reported
|
|
1042
|
-
once per note per system with the position of the block key, because the
|
|
1043
|
-
authored data is discarded either way.
|
|
1044
|
-
|
|
1045
|
-
**`(type, shortcode)` resolves inside one system's catalogue.** A being names its
|
|
1046
|
-
embedded items by address and never by pack, so the Item packs are read as one
|
|
1047
|
-
address space; with two systems in the tree that space stops being one, because
|
|
1048
|
-
`skill:sword` exists under both names. An Actor pass reads the Item packs of its
|
|
1049
|
-
**own** system plus the system-neutral ones.
|
|
1050
|
-
|
|
1051
|
-
## Command line
|
|
1052
|
-
|
|
1053
|
-
```
|
|
1054
|
-
npx content-build package <compile|unpack|clean> [pack] [entry]
|
|
1055
|
-
npx content-build docs item-fields [--out <path>] [--title <title>]
|
|
1056
|
-
npx content-build lint [root] [--no-references]
|
|
1057
|
-
npx content-build content-format schema --schema <system>=<path>
|
|
1058
|
-
npx content-build content-format notes [root] [--strict]
|
|
1059
|
-
npx content-build links [root] [--manifests <dir>]
|
|
1060
|
-
npx content-build format [paths..] [--write]
|
|
1061
|
-
npx content-build markdown [paths..] [--fix]
|
|
1062
|
-
npx content-build content-index [root] [--out <dir>]
|
|
1063
|
-
npx content-build site
|
|
1064
|
-
npx content-build reachability <dir> [file] [--index <shortcode>]
|
|
1065
|
-
npx content-build addresses diff --from <zip|dir> [--strict]
|
|
1066
|
-
```
|
|
1067
|
-
|
|
1068
|
-
| Command | What it does |
|
|
1069
|
-
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1070
|
-
| `package` | Compile the content tree into LevelDB packs, unpack a shipped pack back to JSON, or clean one. See [Install](#install). |
|
|
1071
|
-
| `docs` | Render a generated reference from the configured registries. `item-fields` is the item-frontmatter page. |
|
|
1072
|
-
| `lint` | Check a content tree's addresses and its frontmatter. See [Linting a content tree](#linting-a-content-tree). |
|
|
1073
|
-
| `content-format` | Check the content format specification itself. See [The content format specification](#the-content-format-specification). |
|
|
1074
|
-
| `links` | Check that every link in the tree lands: dead anchors, dead qualified addresses, wikilinks in frontmatter, drifted manifests, and the package homepage's own addresses. |
|
|
1075
|
-
| `format` | Prettier, with the shared configuration. See [Prose: formatting and markdown](#prose-formatting-and-markdown). |
|
|
1076
|
-
| `markdown` | markdownlint, with the shared rule set — the structure Prettier is indifferent to. |
|
|
1077
|
-
| `content-index` | Emit this package's note index as JSON Lines. See [Publishing a content index](#publishing-a-content-index). |
|
|
1078
|
-
| `site` | Publish the content tree as a website. See [Publishing a website](#publishing-a-website). |
|
|
1079
|
-
| `reachability` | Walk outward from an index note and report what no path reaches, for a tree meant to be navigable from one entry point. |
|
|
1080
|
-
| `addresses` | Report every published item address this build has stopped publishing. See [Diffing published addresses](#diffing-published-addresses). |
|
|
1081
|
-
|
|
1082
|
-
Every path, pack name and root it needs comes from the consuming repository's
|
|
1083
|
-
`package-build.config.yaml`, so the usual invocation takes no arguments beyond
|
|
1084
|
-
the command itself. What may be named on the command line overrides that.
|
|
1085
|
-
|
|
1086
|
-
**Every invocation it accepts is one it performs.** A missing command, an
|
|
1087
|
-
unknown command, a missing or unknown action, and an unknown option are each an
|
|
1088
|
-
error that names what was wrong and exits non-zero — never a silent success. A
|
|
1089
|
-
build chain can therefore treat a zero exit as "the work happened". `--version`
|
|
1090
|
-
and `--help` still answer in a directory with no configuration at all.
|
|
1091
|
-
|
|
1092
|
-
## Linting a content tree
|
|
1093
|
-
|
|
1094
|
-
```bash
|
|
1095
|
-
npx content-build lint # the configured `paths.content`
|
|
1096
|
-
npx content-build lint some/tree # or a tree named outright
|
|
1097
|
-
```
|
|
1098
|
-
|
|
1099
|
-
Checks the three rules every note's **identity** is authored against, and reports
|
|
1100
|
-
each finding in the located form below:
|
|
1101
|
-
|
|
1102
|
-
- **Shape** — a `shortcode` is strictly lowercase ASCII-alphanumeric. It is
|
|
1103
|
-
the identity key referenced from saved world data, and half of the
|
|
1104
|
-
`type-shortcode` address, whose parse needs the separating hyphen to be the
|
|
1105
|
-
only hyphen.
|
|
1106
|
-
- **Uniqueness** — `(type, shortcode)` names one note. A document is addressed
|
|
1107
|
-
across _every_ pack of its document type, so routing two same-address notes to
|
|
1108
|
-
different packs with `pack:` does not separate them.
|
|
1109
|
-
- **One front page** — exactly one note declares `type: homepage`. See
|
|
1110
|
-
[Exactly one homepage](#exactly-one-homepage) below.
|
|
1111
|
-
|
|
1112
|
-
It compiles nothing, opens no LevelDB and needs no Foundry manifest, so it runs
|
|
1113
|
-
in about a second and can gate a commit. An empty or untyped tree **fails**
|
|
1114
|
-
rather than passing: "every one of nothing is unique" is a vacuous pass, and it
|
|
1115
|
-
is exactly what a tree that failed to check out produces.
|
|
1116
|
-
|
|
1117
|
-
What that guard reports is an **empty walk**, not an empty set of addresses. A
|
|
1118
|
-
note may be keyless — a folder document carries no `shortcode` — so a tree of
|
|
1119
|
-
them is populated, correct and unkeyed. That tree passes; a tree holding no
|
|
1120
|
-
notes at all still fails. (The homepage is not the headline example, though it was once
|
|
1121
|
-
it was addressed by the package rather than by a slug. It carries an address
|
|
1122
|
-
like every other note now; the guard is unchanged, because what it reads
|
|
1123
|
-
was never the key count.)
|
|
1124
|
-
|
|
1125
|
-
### An empty body is a stub, on purpose
|
|
1126
|
-
|
|
1127
|
-
A note whose body is empty is a **stub**: it carries its frontmatter into the
|
|
1128
|
-
content index and is queryable there, it keeps its `id`, and it publishes no
|
|
1129
|
-
page, no address and no link target. A note with a body and the `draft` tag is a
|
|
1130
|
-
**draft**, and one with a body and no tag is **full**.
|
|
1131
|
-
`docs/content-format.md` states the rule; this is what the lint does with it.
|
|
1132
|
-
|
|
1133
|
-
A draft says so on every surface it reaches — its journal entry, the Actor a
|
|
1134
|
-
being compiles into, its page in the book — in one sentence at the head of the
|
|
1135
|
-
entry, above the infobox and the prose. A stub says nothing anywhere, because it
|
|
1136
|
-
publishes nothing to say it on.
|
|
1137
|
-
|
|
1138
|
-
**A document that would be empty is not created**, and the index then names no
|
|
1139
|
-
artefact — `uuid`, `path` and `address` are `null`. An unwritten note still
|
|
1140
|
-
compiles every document derived from `data:`, so an arcane talent is still its
|
|
1141
|
-
Item; what it compiles no journal for is its prose, because there is none. An
|
|
1142
|
-
infobox does not count as content: the panel renders `data:` the index already
|
|
1143
|
-
carries.
|
|
1144
|
-
|
|
1145
|
-
Four findings and two reports:
|
|
1146
|
-
|
|
1147
|
-
- **A stub carries a `description`.** With nothing in the body it is the only
|
|
1148
|
-
sentence a reader gets, and it is what every table listing the note prints. An
|
|
1149
|
-
**error**, at the note's `type:` line.
|
|
1150
|
-
- **A stub is not tagged `draft`.** A thing not started is not a thing in
|
|
1151
|
-
progress. An **error**: remove the tag, or write a body.
|
|
1152
|
-
- **A body that reduces to nothing is an abandoned draft, not a stub.** A body
|
|
1153
|
-
that is only headings, rules, comments and placeholder phrases — `TBD`, `To be
|
|
1154
|
-
written`, `Coming soon`, `Placeholder`, `N/A` — publishes a page that tells a
|
|
1155
|
-
reader nothing. An **error** naming the phrase and where it sits: empty the
|
|
1156
|
-
body to make it a stub, or write it.
|
|
1157
|
-
- **A short unmarked body may be a draft that forgot its marker.** Under
|
|
1158
|
-
twenty-five words with no `draft` tag is a **warning, never an error**. A note
|
|
1159
|
-
reading `See [[affiliation-meivor|Mëivōr]]` says everything it has to say, and
|
|
1160
|
-
a corpus holds many of them.
|
|
1161
|
-
|
|
1162
|
-
And two things the rules cannot decide, so they are reported as prose rather
|
|
1163
|
-
than flagged:
|
|
1164
|
-
|
|
1165
|
-
- **The oldest drafts are listed** by last modification. A long-standing draft
|
|
1166
|
-
is unfinished, which the tag already says; a warning nobody can clear is how a
|
|
1167
|
-
report teaches its reader to stop looking at it.
|
|
1168
|
-
- **The counts are reported**, per package and per type:
|
|
1169
|
-
|
|
1170
|
-
```text
|
|
1171
|
-
thalorna: 2572 notes — 2089 full, 464 draft, 19 stub
|
|
1172
|
-
```
|
|
1173
|
-
|
|
1174
|
-
`content-build content-index` prints the same line, so the ratio of written to
|
|
1175
|
-
unwritten is visible on every build rather than discovered in a year.
|
|
1176
|
-
|
|
1177
|
-
#### Adopting it
|
|
1178
|
-
|
|
1179
|
-
A package with no empty-bodied note is unaffected. A package that has them:
|
|
1180
|
-
|
|
1181
|
-
1. Take the release and run `content-build lint`. Act on the stub findings — a
|
|
1182
|
-
`description` on every stub, and no stub tagged `draft`.
|
|
1183
|
-
2. Change the `sql` fences that **should** see stubs from `FROM notes` to
|
|
1184
|
-
`FROM entries`. The ones that would gain rows are warned about at the fence,
|
|
1185
|
-
so the list comes to you.
|
|
1186
|
-
3. Read the site's page count. A stub publishes no page, so a tree with stubs
|
|
1187
|
-
publishes fewer pages than it did — which is the point, and worth confirming
|
|
1188
|
-
against any `min-pages` gate the deploy declares.
|
|
1189
|
-
|
|
1190
|
-
### Exactly one homepage
|
|
1191
|
-
|
|
1192
|
-
A content tree declares **exactly one** `type: homepage` note. Zero is an
|
|
1193
|
-
error and two is an error, at the same severity, because they are one defect: a
|
|
1194
|
-
package whose front page is not the page a person chose.
|
|
1195
|
-
|
|
1196
|
-
- _Zero_ and the package serves nothing at `/<package>/` — the failure the
|
|
1197
|
-
authored homepage exists to prevent, and a silent one: the site build reports
|
|
1198
|
-
`wrote 0 homepage(s)` and exits 0.
|
|
1199
|
-
- _Two_ and it serves a page nobody chose. **This is a cardinality rule, and
|
|
1200
|
-
only that.** Both are written to the mount's `_index.md`, so the second
|
|
1201
|
-
silently overwrites the first: the duplicate-address check catches only the
|
|
1202
|
-
pair that happen to share a shortcode, and says nothing at all about a
|
|
1203
|
-
`homepage-root` beside a `homepage-front`. Which of the two should be the
|
|
1204
|
-
front page is a question nothing here can answer.
|
|
1205
|
-
|
|
1206
|
-
Neither has a safe default, so neither is a warning: a build that proceeded past
|
|
1207
|
-
either would publish the wrong front page while reporting success, which is
|
|
1208
|
-
exactly what a warning tolerates.
|
|
1209
|
-
|
|
1210
|
-
**Where it fires: `lint` _and_ `site`.** No single command reaches every
|
|
1211
|
-
package — `HarnMaster-3-FoundryVTT` runs `content-build site` and no
|
|
1212
|
-
`content-build lint`; `sohl-thalorna` runs `content-build lint` and its own site
|
|
1213
|
-
builder — so a rule in one of them is a rule two of the six packages do not
|
|
1214
|
-
have. Both call the same function, so there is one rule and two call sites
|
|
1215
|
-
rather than two rules. In the site build it runs **before the output tree is
|
|
1216
|
-
cleared**, so a failing gate cannot destroy a good site to report a bad tree.
|
|
1217
|
-
|
|
1218
|
-
**It does not vary by `publish.site`.** That setting chooses whether the
|
|
1219
|
-
_content_ surfaces are published; the homepage is the floor beneath both modes.
|
|
1220
|
-
The lint call site reads no `site:` block at all, and so could not vary by mode
|
|
1221
|
-
even if the rule wanted to.
|
|
1222
|
-
|
|
1223
|
-
Zero has no file to name, so the locator is the **content root** — a real path,
|
|
1224
|
-
and the directory the note has to be added to. No line or column is invented for
|
|
1225
|
-
it. Two is reported once per offending note, located at its own `type:` value and
|
|
1226
|
-
naming the other, because each note is a place an author has to open and edit:
|
|
1227
|
-
|
|
1228
|
-
```text
|
|
1229
|
-
assets/content: error: holds no `type: homepage` note, so package "sohl" publishes nothing at its own address /sohl/ — a package's front page is one authored note in this tree, routed by `type:` rather than by filename
|
|
1230
|
-
assets/content/homepage.md:3:7: error: duplicate `type: homepage` note, also declared by assets/content/Landing.md; a package has one front page, at /sohl/, and nothing here can say which of these it should be. Keep one, and make the rest ordinary notes
|
|
1231
|
-
```
|
|
1232
|
-
|
|
1233
|
-
### Frontmatter, against the schema its type declares
|
|
1234
|
-
|
|
1235
|
-
The same command also checks that each note's `sohl:` block is what its **type**
|
|
1236
|
-
allows. Five classes, all of them mistakes that were previously reported
|
|
1237
|
-
somewhere other than where they were made, or not at all:
|
|
1238
|
-
|
|
1239
|
-
- **Unknown or retired type** — a note on a retired spelling is told what
|
|
1240
|
-
replaced it.
|
|
1241
|
-
- **Missing required property** — `dimensions` on a map, `subType` on a skill.
|
|
1242
|
-
- **Wrong value shape** — `weight: heavy` where a number belongs.
|
|
1243
|
-
- **Unknown property** — _the allow-list made loud_. The builders discard a
|
|
1244
|
-
`sohl:` key no field declares, with no warning and no effect on the exit code,
|
|
1245
|
-
which is how 204 kethira mystical abilities shipped with no affiliation (#3).
|
|
1246
|
-
A near miss is named: `Did you mean "masteryLevelBase"?`
|
|
1247
|
-
- **Dead shortcode reference** — `assocSkillCode` naming a skill nothing
|
|
1248
|
-
declares. Resolved through the same resolver `links` uses, so a cross-package
|
|
1249
|
-
reference answered by a vendored manifest lands exactly as it would in a
|
|
1250
|
-
wikilink. `--no-references` turns this one off for a tree whose cross-package
|
|
1251
|
-
references it cannot see.
|
|
1252
|
-
- **Dead Address reference** — an Address-valued `data:` field naming no local
|
|
1253
|
-
note, asset or entry in a declared dependency index. The finding names the
|
|
1254
|
-
field and the complete Address it names. A short form means this package;
|
|
1255
|
-
another package requires the full Address. `--no-references` also turns off
|
|
1256
|
-
this target check. Art slots report unresolved art through their own fallback
|
|
1257
|
-
warning.
|
|
1258
|
-
|
|
1259
|
-
**A schema says what a note may _write_, not what the compiler emits.** Those
|
|
1260
|
-
are different, and the difference is the whole calibration of the check: a note
|
|
1261
|
-
also feeds a knowledgebase and a website, and those read classification the pack
|
|
1262
|
-
build never compiles — `kbcat` alone appears 51 times in SoHL's knowledgebase
|
|
1263
|
-
layouts. Equating the vocabulary with the builder's allow-list reported 4,241
|
|
1264
|
-
unknown properties against SoHL's own tree, every one correctly authored.
|
|
1265
|
-
|
|
1266
|
-
Item types need no separate declaration: their field list already _is_ the
|
|
1267
|
-
builder, so schema and compiler cannot disagree. The hand-written compilers —
|
|
1268
|
-
`being`, `macro`, `doc` and the three map types — declare theirs in
|
|
1269
|
-
`sohl/note-schemas.mjs`.
|
|
1270
|
-
|
|
1271
|
-
**A place's `borders` and `routes` are checked from both ends.** A `place`
|
|
1272
|
-
note states what it is next to and what it is reachable from as two `data:`
|
|
1273
|
-
lists — `borders: [{ to, bearing }]` and
|
|
1274
|
-
`routes: [{ to, bearing, mode, days, terrain?, leagues? }]` — and every value
|
|
1275
|
-
is from a closed set: the eight bearings, `land | boat | ship`, a days scale of
|
|
1276
|
-
`1 2 3 5 10 20 30 45 60 90 180 360`, and a terrain registry in which each
|
|
1277
|
-
terrain names the modes that cross it. Every `to` must be the shortcode of a
|
|
1278
|
-
place, here or in a fetched index. The other end must state the pair back —
|
|
1279
|
-
at the opposite bearing, and for a route by the same mode in the same days.
|
|
1280
|
-
A settlement states a border only when it shares a frontier; most settlements
|
|
1281
|
-
use `parents` for location and `routes` for journeys. The terrain registry is
|
|
1282
|
-
shared across packages. A neighbour stating nothing is a warning naming both notes; one stating a
|
|
1283
|
-
different bearing, mode or days is an error, as is a border to the note's own
|
|
1284
|
-
parent or child, or a pair listed twice by one mode. Each finding lands on the
|
|
1285
|
-
entry that states it. The keys, the sets and the days scale are specified under
|
|
1286
|
-
`type: place` in `docs/content-format.md`.
|
|
1287
|
-
|
|
1288
|
-
**A settlement is held by someone.** An affiliation's `domains` names the
|
|
1289
|
-
places it holds, and a `place` of subType `settlement`, `site` or `structure`
|
|
1290
|
-
that no affiliation's `domains` names — in this tree or in a fetched index —
|
|
1291
|
-
is a warning, `unheld land`, at the note's `type:` line, so a gap in tenure
|
|
1292
|
-
shows. A region is held through its polity's `domains` and a feature by
|
|
1293
|
-
nobody, so neither is checked.
|
|
1294
|
-
|
|
1295
|
-
**Population figures agree from the region down.** A place states
|
|
1296
|
-
`data.population` and an affiliation states how many people it counts, and four
|
|
1297
|
-
warnings read those figures against each other. The polities whose `domains`
|
|
1298
|
-
name a place must not count more people than the place states — `over-held
|
|
1299
|
-
land` — with a polity subordinate to another holding the same place counted
|
|
1300
|
-
once and a settlement exempt, its holder's figure counting a hinterland the
|
|
1301
|
-
settlement does not. The regions whose `parents` name a place must not count
|
|
1302
|
-
more than it states either — `over-full region` — a region naming both its
|
|
1303
|
-
parent and its grandparent counting under the nearer. A settlement must not
|
|
1304
|
-
hold more than any place containing it states, which is `oversized settlement`.
|
|
1305
|
-
And a `doc` note carrying a figure beside a wikilink to the place or
|
|
1306
|
-
affiliation it belongs to must state that note's figure, which is `disputed
|
|
1307
|
-
figure`, located at the number it wrote. Figures are authored to two
|
|
1308
|
-
significant digits, so a parent rounded up to five per cent below what it
|
|
1309
|
-
contains is not a finding, and a place or affiliation stating no figure is
|
|
1310
|
-
neither compared nor counted. A fetched index carries no figure, so a
|
|
1311
|
-
dependency's people are counted nowhere. There is deliberately **no urban-share
|
|
1312
|
-
rule**: a share computed from the named settlements against a region's total
|
|
1313
|
-
measures how much of the region has been written, not how much of it is urban.
|
|
1314
|
-
|
|
1315
|
-
**A settlement's market class is one of six.** `data.market` states what trade
|
|
1316
|
-
a settlement supports — `1` a hamlet, with no market beyond what neighbours
|
|
1317
|
-
trade among themselves; `2` a village and its weekly market; `3` a town with
|
|
1318
|
-
several trades working full time; `4` a market town, its chartered fair and its
|
|
1319
|
-
moneylender; `5` a city, where anything ordinary can be had in quantity; `6` a
|
|
1320
|
-
great city that banks, imports as a matter of course, and is the market from
|
|
1321
|
-
which the other markets buy. The numbers order the steps and do nothing else, so a value off the
|
|
1322
|
-
scale is an error at the value that states it rather than a number rounded onto
|
|
1323
|
-
a class the author did not write. The key is optional and the lint checks no
|
|
1324
|
-
`subType` condition, as it checks none for `population`. The scale is stated
|
|
1325
|
-
under `type: place` in `docs/content-format.md`.
|
|
1326
|
-
|
|
1327
|
-
Nothing here writes. A check reports and an author fixes.
|
|
1328
|
-
|
|
1329
|
-
**`content-build map` draws what those keys state.** `--tree` is the author's
|
|
1330
|
-
check of `parents` — the containment tree, clustered by continent, with a
|
|
1331
|
-
place no parent reaches, a parent no place declares and a cycle flagged red
|
|
1332
|
-
and reported as findings. `--from <shortcode>` is the reader's map: the place
|
|
1333
|
-
at the centre, north up, each neighbour at the angle of its bearing and on a
|
|
1334
|
-
log-spaced ring of its days, a second hop drawn dimmer where the two hops
|
|
1335
|
-
agree; `--from all` draws one for every place with a relation. `--travel` is
|
|
1336
|
-
the whole route graph. Every drawing reads the content index, so a
|
|
1337
|
-
dependency's places take part, and lands under `build/map/` as a `.dot`
|
|
1338
|
-
beside its `.svg`; GraphViz draws, and is needed by this command alone. The
|
|
1339
|
-
command and its rings are documented in `docs/commands.md`.
|
|
1340
|
-
|
|
1341
|
-
### The `data:` container is closed; the top level is not
|
|
1342
|
-
|
|
1343
|
-
A note's frontmatter has three regions, and only one of them is open:
|
|
1344
|
-
|
|
1345
|
-
| region | describes | an unknown key is |
|
|
1346
|
-
| ---------------- | -------------------------------------------- | ------------------ |
|
|
1347
|
-
| top level | the note as a published artefact | passed to the page |
|
|
1348
|
-
| `data:` | the subject itself, whatever system reads it | an **error** |
|
|
1349
|
-
| `sohl:` / `hm3:` | the subject as one system's documents | an **error** |
|
|
1350
|
-
|
|
1351
|
-
**Top level is deliberately open**, for the reason the homepage rule above gives
|
|
1352
|
-
at length: every key of it is copied into the generated page, so an
|
|
1353
|
-
unrecognised one is a Hugo or theme parameter this build has no standing to
|
|
1354
|
-
refuse. `description` is the everyday case — not a document field at all, but
|
|
1355
|
-
the page's description.
|
|
1356
|
-
|
|
1357
|
-
**`data:` is deliberately closed**, and that is the point of having it. The
|
|
1358
|
-
type-specific facts about a subject — a weapon's weight, an affliction's
|
|
1359
|
-
transmission, a being's species — do not sit at the top level, where the
|
|
1360
|
-
pass-through rule applied to them too. So a misspelled `wieght` became a theme
|
|
1361
|
-
parameter rather than a finding, indistinguishable from a weapon that weighs
|
|
1362
|
-
nothing. Under `data:` the same key is reported where it was written, with the
|
|
1363
|
-
key it was probably meant to be, drawn from that type's own vocabulary:
|
|
1364
|
-
|
|
1365
|
-
```text
|
|
1366
|
-
assets/content/Gear/Axe.md:14:5: error: "wieght" is not a `data:` property of a weapongear; the container is closed, so unlike a top-level key it is not passed through to the page. Did you mean "weight"?
|
|
1367
|
-
```
|
|
1368
|
-
|
|
1369
|
-
**A system block is closed too, and which blocks exist is the configuration's
|
|
1370
|
-
answer**. A package is held to the blocks named after the systems it
|
|
1371
|
-
declares it ships for, read from the three places that already declare them:
|
|
1372
|
-
`systems:`, a pack's own `system:`, and `stats.systemId` where neither is
|
|
1373
|
-
written. So a package shipping for HM3 has its `hm3:` block checked and a
|
|
1374
|
-
package shipping for SoHL its `sohl:`. Held constant at `sohl`, an `hm3:` block
|
|
1375
|
-
would never be read at all: every key in it would be discarded at compile
|
|
1376
|
-
without a word, while the block that _was_ checked would be named after a system
|
|
1377
|
-
the package does not ship for.
|
|
1378
|
-
|
|
1379
|
-
A pack's `system:` counts because it is already authoritative at compile — a
|
|
1380
|
-
note routed to a pack declaring one and carrying no such block fails the build —
|
|
1381
|
-
so a lint blind to it would refuse a note for want of a block it never checked.
|
|
1382
|
-
`harn-ensemble` declares its two systems that way and no other.
|
|
1383
|
-
|
|
1384
|
-
**Each block is checked against its own system's vocabulary**, and that has two
|
|
1385
|
-
sources. A system's **`itemBuilders` registry** covers its item types: `skill` is
|
|
1386
|
-
one name over two data models, so a key SoHL's `skill` declares is not thereby a
|
|
1387
|
-
key HM3's declares, and a block that borrowed its neighbour's field names would
|
|
1388
|
-
accept the one mistake this check exists to report. The **note schemas** cover
|
|
1389
|
-
the rest — `being` above all, which is an actor type and sits in no item
|
|
1390
|
-
registry — and they belong to one system, SoHL, because that is the vocabulary
|
|
1391
|
-
`content-build` is built with.
|
|
1392
|
-
|
|
1393
|
-
A type neither source names — `mysticalability` is SoHL's, `invocation` is
|
|
1394
|
-
HM3's — is a type that system says nothing about, and its block is left alone on
|
|
1395
|
-
such a note rather than reported wholesale. A package that names no system at all
|
|
1396
|
-
is system-agnostic on purpose: its packs are core document types carrying no
|
|
1397
|
-
system data, so it has no system block and none is invented for it.
|
|
1398
|
-
|
|
1399
|
-
**A block whose vocabulary nothing states is said out loud.** A package
|
|
1400
|
-
declaring a system other than SoHL and no `itemBuilders` registry for it has
|
|
1401
|
-
nothing that can say what that block may carry, so the block goes unchecked — and
|
|
1402
|
-
`content-build lint` reports that once, naming the system and the registry to
|
|
1403
|
-
declare, because a check that quietly does nothing is indistinguishable from one
|
|
1404
|
-
that passed. `harn-ensemble` is the tree that gets it today: its `sohl:` block is
|
|
1405
|
-
checked, and its `hm3:` waits on `itemBuilders: [hm3, sohl]`.
|
|
1406
|
-
|
|
1407
|
-
**`subType` stays at the top level**, and is closed in its own way: a type
|
|
1408
|
-
either declares a `subType` or does not, and a type that does declares its
|
|
1409
|
-
values. A `weapon` declares none — SoHL distinguishes a weapon's uses by strike
|
|
1410
|
-
mode rather than by kind — so `subType` on one is a finding; a `skill` declares
|
|
1411
|
-
ten, so `subType: crafte` is a finding naming `craft`.
|
|
1412
|
-
|
|
1413
|
-
**A `type` and a `subType` are both held to `^[a-z0-9]+$`** — the same
|
|
1414
|
-
constant a `shortcode` is held to, read rather than restated. A type is a
|
|
1415
|
-
segment of every address — the first of the short form an author writes, the
|
|
1416
|
-
third of the canonical `package-system-type-shortcode` — so a hyphen in one is
|
|
1417
|
-
read back as a segment boundary nobody wrote. A `subType` reaches no address
|
|
1418
|
-
— sections are retired — and keeps the rule anyway: it is a vocabulary term the toolchain keys
|
|
1419
|
-
on, and one charset that holds for every term is a rule an author can state. The
|
|
1420
|
-
rule is checked ahead of the closed-set check, which is what makes it reach a
|
|
1421
|
-
type whose values are declared but not yet enumerated:
|
|
1422
|
-
|
|
1423
|
-
```text
|
|
1424
|
-
assets/content/Beings/Folk.md:3:1: error: `subType` "common-folk" is not a well-formed subType — a subType is letters and digits only (^[a-z0-9]+$), the same charset a type, a shortcode and a contentPackage are held to. …
|
|
1425
|
-
```
|
|
1426
|
-
|
|
1427
|
-
One declared value broke that rule: a `doc`'s `user-guide`, now **`userguide`**.
|
|
1428
|
-
The old spelling was accepted for one transitional release, as a warning naming
|
|
1429
|
-
the replacement, because an error would have redded every tree that took the
|
|
1430
|
-
release before it had a chance to sweep. Every tree has swept, so the acceptance
|
|
1431
|
-
is gone: `user-guide` is refused by the charset check like any other
|
|
1432
|
-
hyphenated value, and nothing retirement-specific was left to remove.
|
|
1433
|
-
|
|
1434
|
-
The vocabulary lives in `engine/note-vocabulary.mjs`, one entry per note type,
|
|
1435
|
-
taken from the content-format specification. It is note-format knowledge rather
|
|
1436
|
-
than any system's: `data:` holds what is true of the thing, and what a system
|
|
1437
|
-
makes of that value is declared in that system's own half.
|
|
1438
|
-
|
|
1439
|
-
An Address field declares `kind: "address"`; an Address list declares
|
|
1440
|
-
`kind: "list"` and `entryKind: "address"`. A keyed map keeps `kind: "map"`
|
|
1441
|
-
and declares `keyKind`: `"address"` for affiliation relations, `"shortcode"`
|
|
1442
|
-
for skill aptitudes. Aptitude keys also accept `subType:<skill-subtype>`.
|
|
1443
|
-
`ref` supplies an omitted type and `accepts` states the permitted types;
|
|
1444
|
-
these are independent declarations. Heterogeneous fields such as `economy`
|
|
1445
|
-
and bundle `contents` require an explicit type. `economy` accepts only
|
|
1446
|
-
`affiliation` and `lore` Addresses.
|
|
1447
|
-
|
|
1448
|
-
The frontmatter lint checks these values and locates malformed entries without
|
|
1449
|
-
requiring their targets to exist. `parseAddress(value, defaults, { declared:
|
|
1450
|
-
true })` applies the shared grammar to a declared Address, validates every
|
|
1451
|
-
segment, and returns a complete tuple or a reason. This mode leaves package
|
|
1452
|
-
membership to resolution. The lint receives the builder's package separately
|
|
1453
|
-
from target lookup, including when reference resolution is disabled.
|
|
1454
|
-
|
|
1455
|
-
An affiliation authors native SoHL skill references at
|
|
1456
|
-
`sohl.system.commonSkills`:
|
|
1457
|
-
|
|
1458
|
-
```yaml
|
|
1459
|
-
sohl:
|
|
1460
|
-
system:
|
|
1461
|
-
commonSkills:
|
|
1462
|
-
- herb
|
|
1463
|
-
- sohl-sohl-skill-law
|
|
1464
|
-
```
|
|
1465
|
-
|
|
1466
|
-
The first Address names this content package's SoHL skill; the second names a
|
|
1467
|
-
skill in the declared SoHL dependency. The content index retains their complete
|
|
1468
|
-
Addresses. Foundry compilation resolves each exact native skill Item in the
|
|
1469
|
-
local or declared dependency index and writes its published Item UUID to the
|
|
1470
|
-
affiliation's `system.commonSkills` array. The producer's module ID and pack
|
|
1471
|
-
come from that index. An unresolved target or a target without a native Item
|
|
1472
|
-
UUID is a located error. The shared `data:` container has no `commonSkills`
|
|
1473
|
-
property. Infoboxes use a skill's published documentation journal when it has
|
|
1474
|
-
one and show its known name as plain text otherwise.
|
|
1475
|
-
|
|
1476
|
-
**There is deliberately no third rule** requiring every note to repeat its own
|
|
1477
|
-
`type-shortcode` address in `aliases:`. It would serve one reader — Obsidian, so
|
|
1478
|
-
`[[type-shortcode]]` resolves in the editor — and no build reads it: both
|
|
1479
|
-
resolvers parse the hyphen qualifier themselves. The field is retired.
|
|
1480
|
-
|
|
1481
|
-
**And the top-level field itself is now retired.** `aliases:` fed the
|
|
1482
|
-
alias index, which is what a bare `[[Alias]]` was looked up in. That form
|
|
1483
|
-
resolved to nothing anywhere in the corpus, while the collision rule guarding it
|
|
1484
|
-
folded in every note's `name.full` and so decided what a note could be named
|
|
1485
|
-
. Both are gone: every wikilink is an address, written
|
|
1486
|
-
`[[type-shortcode|Text]]`, and declaring `aliases:` is refused naming the file
|
|
1487
|
-
and the line.
|
|
1488
|
-
|
|
1489
|
-
### Writing a link: the address grammar
|
|
1490
|
-
|
|
1491
|
-
Every wikilink is an address, and omission runs **strictly left to right**:
|
|
1492
|
-
|
|
1493
|
-
```text
|
|
1494
|
-
[[[[<package>-]<system>-]<type>-]<shortcode>]
|
|
1495
|
-
```
|
|
1496
|
-
|
|
1497
|
-
So the written forms are exactly the suffixes of the canonical address:
|
|
1498
|
-
|
|
1499
|
-
| Form | Example | Means |
|
|
1500
|
-
| ------------------------------- | ------------------------------------ | --------------------------------- |
|
|
1501
|
-
| `type-shortcode` | `[[skill-clmb\|Climbing]]` | This package, any system. |
|
|
1502
|
-
| `system-type-shortcode` | `[[sohl-skill-clmb\|Climbing]]` | This package, the `sohl` system. |
|
|
1503
|
-
| `package-system-type-shortcode` | `[[thalorna-sohl-being-grod\|Grod]]` | Another package, fully qualified. |
|
|
1504
|
-
|
|
1505
|
-
**There is no `package-type-shortcode`.** Naming a package means naming the
|
|
1506
|
-
system before the type, because the segments are positional rather than tagged.
|
|
1507
|
-
A link into another package must therefore be fully qualified — which is the
|
|
1508
|
-
price of a grammar that needs no vocabulary to parse.
|
|
1509
|
-
|
|
1510
|
-
**An omitted system is a wildcard; an omitted package is a default.** Most links
|
|
1511
|
-
target items, which belong to a system, so a target naming none matches a note
|
|
1512
|
-
under any of them and exactly one hit is required — two claimants is an
|
|
1513
|
-
_ambiguity_, a different finding with a different fix from resolving nowhere. A
|
|
1514
|
-
target naming no package means the citing note's own, so an unqualified link
|
|
1515
|
-
resolves locally and only locally.
|
|
1516
|
-
|
|
1517
|
-
**`sohl` is both a package and a system**, and positional counting is what makes
|
|
1518
|
-
that harmless: three segments name a _system_ whatever the first segment could
|
|
1519
|
-
also have meant, and four is the full form.
|
|
1520
|
-
|
|
1521
|
-
**Parsing is plain segment counting**, the same rule the canonical key follows,
|
|
1522
|
-
and it is sound because every segment is `^[a-z0-9]+$` — so a hyphen is
|
|
1523
|
-
purely a separator. A target with five segments is not a hyphenated shortcode;
|
|
1524
|
-
it is a name, and not an address.
|
|
1525
|
-
|
|
1526
|
-
**`name.aliases` is kept, and is read by nothing.** It fed the same index and
|
|
1527
|
-
lost the same reader, but unlike the top-level list it is **reserved** — held
|
|
1528
|
-
for a use that does not exist yet. So it is the one field in the format that is
|
|
1529
|
-
neither retired nor consulted: no index folds it in, no rule validates it,
|
|
1530
|
-
nothing derives a name, address or URL from it, and no build branches on it. A
|
|
1531
|
-
note carrying one compiles, resolves and addresses exactly as the same note
|
|
1532
|
-
without it; it rides through into a page's emitted front matter untouched,
|
|
1533
|
-
because the emitter spreads a note's frontmatter wholesale and stripping it
|
|
1534
|
-
there would mean referencing it. Write it if you have a use for it later —
|
|
1535
|
-
nothing today will read it.
|
|
1536
|
-
|
|
1537
|
-
### The homepage is the package root, addressed like every other note
|
|
1538
|
-
|
|
1539
|
-
A homepage declares a `shortcode` — conventionally `root` — because that is
|
|
1540
|
-
what a link is written with: `[[homepage-root|Read the introduction]]` is an
|
|
1541
|
-
ordinary wikilink. It resolves to `/<package>/`, because the homepage _is_ the
|
|
1542
|
-
package root: the site build writes it as the mount's `_index.md`, and Hugo
|
|
1543
|
-
renders that as the `home` kind at `baseURL`. The shortcode names the page in
|
|
1544
|
-
links; the address is the package root, and `[[thalorna-homepage-root]]` from
|
|
1545
|
-
another package lands on `/thalorna/` for the same reason.
|
|
1546
|
-
|
|
1547
|
-
A homepage that declares no `shortcode` is refused, located at the `type:`
|
|
1548
|
-
value that makes it necessary:
|
|
1549
|
-
|
|
1550
|
-
```text
|
|
1551
|
-
assets/content/homepage.md:3:7: error: a `type: homepage` note declares a `shortcode`, like every other note: it is addressed as `homepage-<shortcode>`, which is what `[[homepage-<shortcode>|Text]]` is written with to reach the package's front page at `/<package>/`. Write `shortcode: root` — the front page is `homepage-root` in every package
|
|
1552
|
-
```
|
|
1553
|
-
|
|
1554
|
-
`root` is a **convention, not a rule**: the address only has to be unique within
|
|
1555
|
-
the package, which `(type, shortcode)` already guarantees, and nothing here
|
|
1556
|
-
knows better than an author what their front page is called. What the
|
|
1557
|
-
convention buys is one spelling shared by every tree, so `[[homepage-root|…]]`
|
|
1558
|
-
is the same link in every package.
|
|
1559
|
-
|
|
1560
|
-
**Not a bare `[[homepage]]`.** With no hyphen it does not parse as an address,
|
|
1561
|
-
so it would need a hardcoded single-token exception in the grammar.
|
|
1562
|
-
|
|
1563
|
-
#### `id` and `landing` are refused
|
|
1564
|
-
|
|
1565
|
-
Two top-level keys are refused, because neither decides anything on a page.
|
|
1566
|
-
`id` is the Foundry document id a compendium UUID is built from, and a
|
|
1567
|
-
homepage compiles into **no document**:
|
|
1568
|
-
|
|
1569
|
-
```text
|
|
1570
|
-
assets/content/homepage.md:4:1: error: `id` decides nothing on a `type: homepage` note: it is the Foundry document id a compendium UUID is built from, and a homepage compiles into no document — it appears in no pack and in no link manifest. Delete it
|
|
1571
|
-
```
|
|
1572
|
-
|
|
1573
|
-
`landing` is a card block. The homepage is a page with a body, rendered as
|
|
1574
|
-
one, and no card block is read off it — an index of what the package
|
|
1575
|
-
publishes is a `doc` note carrying a content table, linked from the homepage
|
|
1576
|
-
like any other page:
|
|
1577
|
-
|
|
1578
|
-
```text
|
|
1579
|
-
assets/content/homepage.md:5:1: error: `landing` decides nothing on a `type: homepage` note: the homepage is a page with a body, rendered as one, and no card block is read off it. Write the page's links in its body, and author an index of what the package publishes as a `doc` note carrying a content table. Delete it
|
|
1580
|
-
```
|
|
1581
|
-
|
|
1582
|
-
That is also why a homepage states **no Foundry address**. A manifest entry is
|
|
1583
|
-
how another package resolves a _document_; a cross-package link to a package's
|
|
1584
|
-
front page is its bare `/<package>/` address, which needs no index.
|
|
1585
|
-
|
|
1586
|
-
**A named class, not an allow-list.** The documented envelope is `type` and
|
|
1587
|
-
`shortcode`, with `name`, `title`, `description` and `banner` legitimate beside
|
|
1588
|
-
them — but an unknown top-level key is **not** refused, and that boundary is
|
|
1589
|
-
the decision rather than an omission. A homepage's frontmatter is emitted into
|
|
1590
|
-
the published page, so an unrecognised key is a Hugo or theme parameter this
|
|
1591
|
-
build has never heard of and has no standing to reject; a closed list would
|
|
1592
|
-
make every new theme parameter wait on a package-build release. `aliases` is
|
|
1593
|
-
not in the class either — it is a retired field, refused on every note
|
|
1594
|
-
whatever its type.
|
|
1595
|
-
|
|
1596
|
-
**Where it fires: `content-build lint` only.** Unlike a rule about the shape of
|
|
1597
|
-
the _tree_, which the site build has its own reason to gate on, this is a
|
|
1598
|
-
_frontmatter-schema_ rule and `content-build site` runs none of them — wiring in
|
|
1599
|
-
one type's field rule would have the site build refuse `id` on a homepage while
|
|
1600
|
-
accepting `weight: heavy` on a weapon. The site build does refuse a homepage it
|
|
1601
|
-
cannot address, because a link to it could not resolve otherwise, and it
|
|
1602
|
-
reports that beside the count so the finding reaches `publish.site: homepage`
|
|
1603
|
-
mode as well. The remaining gap is `HarnMaster-3-FoundryVTT`, which runs no
|
|
1604
|
-
`content-build lint` at all and so receives no frontmatter finding of any kind;
|
|
1605
|
-
that is a missing script in that repository, not a rule to duplicate one at a
|
|
1606
|
-
time.
|
|
1607
|
-
|
|
1608
|
-
### `/<package>/` is the homepage, and nothing redirects
|
|
1609
|
-
|
|
1610
|
-
The package's own address serves the homepage directly: Hugo renders the
|
|
1611
|
-
mount's `_index.md` as the `home` kind at `baseURL`, which is
|
|
1612
|
-
`https://www.heroiclands.org/<package>/`. `package-build site-root` writes no
|
|
1613
|
-
`_redirects` beside the site — and removes one an earlier build left there,
|
|
1614
|
-
since Cloudflare Pages would apply it. Its `_headers` suppress indexing on
|
|
1615
|
-
every host-assigned address and nothing else: no `Cache-Control` is pinned on
|
|
1616
|
-
the prefix root, because a lifetime on the homepage would hold a stale copy at
|
|
1617
|
-
the most-linked address after a deploy. The same command then indexes the
|
|
1618
|
-
rendered pages for search into `<package>/pagefind/`, served with the rest of
|
|
1619
|
-
the site; `site.search: false` skips it.
|
|
1620
|
-
|
|
1621
|
-
### The homepage's own links
|
|
1622
|
-
|
|
1623
|
-
The homepage is the page a reader arrives at, and it is the one page no
|
|
1624
|
-
wikilink resolver reaches: a homepage is published verbatim in every
|
|
1625
|
-
publishing mode, so its links are markdown links in its body, and `links`
|
|
1626
|
-
audits them. A body link is emitted as written and resolved by the browser
|
|
1627
|
-
against the homepage's own address, which _is_ the package root, so a
|
|
1628
|
-
package-relative one (`kb/rules/`) lands where a reader expects.
|
|
1629
|
-
|
|
1630
|
-
Three findings, and each one names the form to write instead:
|
|
1631
|
-
|
|
1632
|
-
| Finding | Why |
|
|
1633
|
-
| ---------------------------- | --------------------------------------------------------------------------------- |
|
|
1634
|
-
| A **retired content type** | `kb/creature/` when `creature` became `being`. The engine knows what was retired. |
|
|
1635
|
-
| A **hardcoded absolute URL** | Into this package's own prefix, or into one a fetched index names. |
|
|
1636
|
-
| A **wikilink** | Nothing resolves one here: a homepage is published verbatim in every mode. |
|
|
1637
|
-
|
|
1638
|
-
That last one is why a homepage does **not** get the wikilink resolution every
|
|
1639
|
-
other note body gets. In `homepage` mode the content tree is never walked, so
|
|
1640
|
-
there is no index for a wikilink to resolve against — and giving the page one
|
|
1641
|
-
would make the mode depend on exactly the machinery its licensing fence exists
|
|
1642
|
-
to not build. So a homepage addresses the web the way the web does, and a
|
|
1643
|
-
wikilink on one is reported rather than resolved.
|
|
1644
|
-
|
|
1645
|
-
**What is checkable, and what is not.** Only an address into this site is, and
|
|
1646
|
-
only against facts the build already holds — the retired-type table and the
|
|
1647
|
-
package prefixes a fetched index names. Two things are deliberately not
|
|
1648
|
-
attempted:
|
|
1649
|
-
|
|
1650
|
-
- **Whether an external URL answers.** There is no network at build time, and a
|
|
1651
|
-
build must not go red because a third party is down.
|
|
1652
|
-
- **Whether a live in-site address names a page that exists.** Several surfaces
|
|
1653
|
-
a homepage routes to are produced by other tools entirely — generated API
|
|
1654
|
-
documentation, say — so this build does not hold the set of published pages
|
|
1655
|
-
and would report a working link as dead. A bare `/<package>/` names another
|
|
1656
|
-
package's front page and needs no index: it is left alone because it cannot
|
|
1657
|
-
be improved.
|
|
1658
|
-
|
|
1659
|
-
## The content format specification
|
|
1660
|
-
|
|
1661
|
-
`docs/content-format.md` is the contract this package honours: how a note
|
|
1662
|
-
becomes a Foundry document and a web page. Three frontmatter regions, a note
|
|
1663
|
-
vocabulary with its own `type` and `subType`, a declared map onto each system's
|
|
1664
|
-
document fields, the precedence between a shared source and a system's override,
|
|
1665
|
-
and the wikilink address grammar.
|
|
1666
|
-
|
|
1667
|
-
**It does not define the `sohl:` or `hm3:` schemas.** Each system defines its
|
|
1668
|
-
own, and its published `schema.json` is the authoritative statement of it. The
|
|
1669
|
-
format says which shared source feeds which system field; what fields exist, and
|
|
1670
|
-
what they mean, belongs to the system.
|
|
1671
|
-
|
|
1672
|
-
That makes a mapping row **checkable rather than declarative**, and two commands
|
|
1673
|
-
check it. Both read the document's own tables, so editing the specification
|
|
1674
|
-
changes what they assert — a transcribed copy would be free to drift from the
|
|
1675
|
-
prose the moment either was edited, which is the failure they exist to prevent.
|
|
1676
|
-
|
|
1677
|
-
### `content-format schema` — the specification against a published schema
|
|
1678
|
-
|
|
1679
|
-
```bash
|
|
1680
|
-
npx content-build content-format schema --schema sohl=./schema.json
|
|
1681
|
-
```
|
|
1682
|
-
|
|
1683
|
-
Every `system.*` target the document names must appear in the naming system's
|
|
1684
|
-
published `schema.json`, in the `version: 1` shape `package-build schema` emits.
|
|
1685
|
-
A target no schema declares is an error naming the note type, the field, the
|
|
1686
|
-
system and the version it was checked at — because a field may be perfectly well
|
|
1687
|
-
defined on the system's `main` and simply unreleased, and that is the difference
|
|
1688
|
-
between "the specification is wrong" and "the schema has not caught up".
|
|
1689
|
-
|
|
1690
|
-
A target is resolved against the **union** of the system's document subtypes.
|
|
1691
|
-
The mapping tables say which field a shared source reaches; _which subtype
|
|
1692
|
-
receives it_ is the note-type → subtype map, which does not exist yet — so the
|
|
1693
|
-
question asked is the one that can be answered honestly today, and it narrows to
|
|
1694
|
-
the subtype when that map lands.
|
|
1695
|
-
|
|
1696
|
-
`--schema` is repeatable and takes `<system>=<path>`, because a consumer holds
|
|
1697
|
-
its system's artifact and this repository holds a committed fixture, and neither
|
|
1698
|
-
arrangement should have to pretend to be the other. A system the document maps
|
|
1699
|
-
onto but no schema was supplied for is reported as **unchecked**, with a count:
|
|
1700
|
-
HM3 publishes no artifact today, so that is the ordinary case for a fifth of the
|
|
1701
|
-
claims, and a check that skipped them in silence would read as one that passed.
|
|
1702
|
-
|
|
1703
|
-
### `content-format notes` — a content tree against the declared vocabulary
|
|
1704
|
-
|
|
1705
|
-
```bash
|
|
1706
|
-
npx content-build content-format notes # the configured `paths.content`
|
|
1707
|
-
npx content-build content-format notes --strict # and fail on what it finds
|
|
1708
|
-
```
|
|
1709
|
-
|
|
1710
|
-
Measures every authored note against the per-type `data` tables, and counts the
|
|
1711
|
-
findings by class:
|
|
1712
|
-
|
|
1713
|
-
| class | what it means |
|
|
1714
|
-
| ----------------------- | --------------------------------------------------------------------- |
|
|
1715
|
-
| `unknown-type` | the format declares no `### type:` section for this note's `type` |
|
|
1716
|
-
| `unknown-data-key` | a key in `data:`, which is closed, that the type does not declare |
|
|
1717
|
-
| `top-level-data-key` | a declared `data` property written at top level instead |
|
|
1718
|
-
| `system-block-data-key` | a declared shared source written straight into a `sohl:`/`hm3:` block |
|
|
1719
|
-
|
|
1720
|
-
**It reports; it does not fail.** Every authored note predates the format, so a
|
|
1721
|
-
failing check would be red in every repository on the day it lands and would
|
|
1722
|
-
stay red for the length of the migration — which is a check nobody can act on
|
|
1723
|
-
and everybody learns to skip. The counts are the migration's progress bar
|
|
1724
|
-
instead, and each class is promoted to fatal, by turning `--strict` on, as it
|
|
1725
|
-
reaches zero.
|
|
1726
|
-
|
|
1727
|
-
What it deliberately leaves alone is a key inside a system block that the format
|
|
1728
|
-
says nothing about. Those regions are closed against _the system's_ schema, not
|
|
1729
|
-
against this document, and `content-build lint` already checks them against the
|
|
1730
|
-
declared fields.
|
|
1731
|
-
|
|
1732
|
-
## Prose: formatting and markdown
|
|
1733
|
-
|
|
1734
|
-
```bash
|
|
1735
|
-
npx content-build format # check the whole repository
|
|
1736
|
-
npx content-build format --write # rewrite what is not formatted
|
|
1737
|
-
npx content-build markdown # lint every markdown file
|
|
1738
|
-
npx content-build markdown --fix # apply the fixes markdownlint can make
|
|
1739
|
-
```
|
|
1740
|
-
|
|
1741
|
-
Two conventions every content repository writes to, declared once here so a note
|
|
1742
|
-
formatted in one is formatted the same way in the next:
|
|
1743
|
-
|
|
1744
|
-
- **`format`** runs Prettier. Same values SoHL has always used, so a module or a
|
|
1745
|
-
note moving between repositories does not reformat on arrival.
|
|
1746
|
-
- **`markdown`** runs markdownlint — the structural checks Prettier cannot make:
|
|
1747
|
-
a heading level that skips, two sibling headings claiming one anchor, a
|
|
1748
|
-
reversed `(text)[url]`, a bare URL, an empty link, a table row with the wrong
|
|
1749
|
-
cell count, and the emphasis markers (`_emphasis_`, `**strong**`) these
|
|
1750
|
-
repositories write.
|
|
1751
|
-
|
|
1752
|
-
The rule set is **deliberately narrow**. markdownlint's defaults over a content
|
|
1753
|
-
tree produce tens of thousands of findings, almost all of them line length, list
|
|
1754
|
-
indentation and blank lines — Prettier's territory. So `default` is off and each
|
|
1755
|
-
rule is enabled by name, with the reason it earns its place; add one only if it
|
|
1756
|
-
can report that a page is _wrong_.
|
|
1757
|
-
|
|
1758
|
-
Both run over the **repository**, not the content tree, and neither reads the
|
|
1759
|
-
pack configuration — a repository's formatting covers everything it holds, and
|
|
1760
|
-
one that has not configured this package at all can still format itself.
|
|
1761
|
-
|
|
1762
|
-
**What ships here is a default, not an override.** A consumer's own Prettier
|
|
1763
|
-
config or `.markdownlint-cli2.jsonc` wins wherever it has one. Which paths to
|
|
1764
|
-
skip is knowledge about a repository's layout and stays with that repository, in
|
|
1765
|
-
its own `.prettierignore` and `.gitignore` — both honoured, as Prettier and
|
|
1766
|
-
markdownlint honour them natively. The one exception is `CHANGELOG.md`, which
|
|
1767
|
-
`changeset version` regenerates in every repository here: linting it reports on
|
|
1768
|
-
the generator, so it is skipped by default.
|
|
1769
|
-
|
|
1770
|
-
**A default that says when it is not in force.** Because a local config wins
|
|
1771
|
-
wholesale rather than merging, the conventions above otherwise hold by convention
|
|
1772
|
-
alone: a `prettier.config.mjs` that spreads `PRETTIER_BASE` without the `**/*.md`
|
|
1773
|
-
override reindents every note at 4, and a partial `.prettierrc` such as
|
|
1774
|
-
`{"tabWidth": 2}` silently discards `printWidth: 100`, `trailingComma` and the
|
|
1775
|
-
rest. So every `format` run first names, as warnings, each shared convention this
|
|
1776
|
-
repository resolves differently:
|
|
1777
|
-
|
|
1778
|
-
```text
|
|
1779
|
-
prettier.config.mjs: warning: markdown `tabWidth` is 4 here; the shared configuration says 2
|
|
1780
|
-
.prettierrc: warning: `printWidth` is not set here, so Prettier's own default applies; the shared configuration says 100
|
|
1781
|
-
```
|
|
1782
|
-
|
|
1783
|
-
A repository with **no** Prettier config is warned too, and it is the sharper
|
|
1784
|
-
case: the shared conventions then reach this command and nothing else, so an
|
|
1785
|
-
editor's format-on-save and a bare `npx prettier --check .` apply Prettier's own
|
|
1786
|
-
defaults to the same tree and the two take turns rewriting the same lines. The
|
|
1787
|
-
fix is the one-line re-export below.
|
|
1788
|
-
|
|
1789
|
-
None of this fails a run. A deliberate local choice still wins — it just stops
|
|
1790
|
-
being silent.
|
|
1791
|
-
|
|
1792
|
-
Neither tool's file discovery is reimplemented, so `content-build format --check`
|
|
1793
|
-
and a bare `prettier --check .` report the same thing. A file Prettier cannot
|
|
1794
|
-
parse is a **finding**, with its position — not a crash that costs the report on
|
|
1795
|
-
every other file.
|
|
1796
|
-
|
|
1797
|
-
To make an editor agree with the lint chain, point its config at the same rules:
|
|
1798
|
-
|
|
1799
|
-
```js
|
|
1800
|
-
// prettier.config.mjs
|
|
1801
|
-
export { default } from "@heroiclands/package-build/prettier";
|
|
1802
|
-
```
|
|
1803
|
-
|
|
1804
|
-
```js
|
|
1805
|
-
// .markdownlint-cli2.mjs — extending rather than replacing
|
|
1806
|
-
import shared from "@heroiclands/package-build/markdownlint";
|
|
1807
|
-
export default { ...shared, config: { ...shared.config, MD013: true } };
|
|
1808
|
-
```
|
|
1809
|
-
|
|
1810
|
-
## YAML: frontmatter, and every YAML file
|
|
1811
|
-
|
|
1812
|
-
```bash
|
|
1813
|
-
npx package-build yaml # every YAML file git would consider
|
|
1814
|
-
npx package-build yaml assets/content # or just these paths
|
|
1815
|
-
```
|
|
1816
|
-
|
|
1817
|
-
Frontmatter carries a note's type, its shortcode, its address and the system
|
|
1818
|
-
blocks a document is compiled from — and until this command existed nothing
|
|
1819
|
-
checked it _as YAML_.
|
|
1820
|
-
|
|
1821
|
-
**Worse than unchecked: a parse failure unmade the note.** `parseMarkdownFile`
|
|
1822
|
-
caught the error, logged it at `warn`, and returned `{frontmatter: null}` — which
|
|
1823
|
-
is not a note with bad frontmatter but, to every pass downstream, _a file with no
|
|
1824
|
-
frontmatter_. It was skipped by the compiler, the linter, the link checker and
|
|
1825
|
-
the index, and the build reported success. A duplicate key did not fail anything;
|
|
1826
|
-
it removed a note from the corpus. The parser had detected it all along.
|
|
1827
|
-
|
|
1828
|
-
**Frontmatter is linted through an ESLint processor** — the mechanism
|
|
1829
|
-
`eslint-plugin-markdown` uses for fenced code blocks. Frontmatter is its easy
|
|
1830
|
-
case: the block is always at the top of the file, so a finding maps back to the
|
|
1831
|
-
line it came from with a constant `+1` for the opening `---`, and nothing after
|
|
1832
|
-
the closing `---` is read as YAML.
|
|
1833
|
-
|
|
1834
|
-
The rule set is **deliberately narrow**, for the same reason `markdown`'s is.
|
|
1835
|
-
Prettier already owns YAML's whitespace, quoting and line breaks — including
|
|
1836
|
-
inside a frontmatter fence — so a rule about any of those would duplicate the
|
|
1837
|
-
formatter or fight it. What is left is the class a formatter cannot see: text
|
|
1838
|
-
that parses to something other than what it looks like.
|
|
1839
|
-
|
|
1840
|
-
| Reported | Why it is not a matter of taste |
|
|
1841
|
-
| ------------------------------------- | -------------------------------------------------------------- |
|
|
1842
|
-
| a parse error | A duplicate key, a tab indent, mis-aligned mapping items. |
|
|
1843
|
-
| `yml/no-empty-mapping-value` | `folder:` and `folder: null` are one value and two statements. |
|
|
1844
|
-
| `yml/no-irregular-whitespace` | A non-breaking space is invisible and part of the value. |
|
|
1845
|
-
| `yml/no-empty-key`, `-empty-document` | A fence or a file that parses to nothing at all. |
|
|
1846
|
-
|
|
1847
|
-
`folder:` and `folder: null` read as opposites — a decision, or a key somebody
|
|
1848
|
-
began and did not finish — so the distinction is drawn where the text still
|
|
1849
|
-
exists. **A key with a block under it is not empty**: `name:` followed by an
|
|
1850
|
-
indented mapping, or by a sequence at its own indent, is an ordinary container.
|
|
1851
|
-
|
|
1852
|
-
**GitHub workflows are exempt from the empty-value rule.** `on:`, `push:` and
|
|
1853
|
-
`workflow_dispatch:` carry their meaning by being present, and writing
|
|
1854
|
-
`push: null` to satisfy a linter would be worse YAML, not better. A real error in
|
|
1855
|
-
a workflow is still reported.
|
|
1856
|
-
|
|
1857
|
-
**The files are the ones git would consider** — `--cached --others
|
|
1858
|
-
--exclude-standard`, tracked plus untracked-and-not-ignored, the same set
|
|
1859
|
-
`gitignore: true` gives the markdown linter. Untracked is included so a note is
|
|
1860
|
-
linted while it is being written rather than only once it has been staged.
|
|
1861
|
-
|
|
1862
|
-
**A consumer needs no ESLint.** This ships as a command, not as a configuration
|
|
1863
|
-
to adopt: there is no `eslint` dependency to add, no `eslint.config.js` and no
|
|
1864
|
-
rules to declare. A repository that _has_ an ESLint of its own keeps it untouched
|
|
1865
|
-
and unconsulted — the run sets `overrideConfigFile: true`, so no config file is
|
|
1866
|
-
looked for at all.
|
|
1867
|
-
|
|
1868
|
-
## Publishing the content index
|
|
1869
|
-
|
|
1870
|
-
Every package emits one file naming every note it publishes:
|
|
1871
|
-
|
|
1872
|
-
```bash
|
|
1873
|
-
npx content-build content-index # the configured tree and output directory
|
|
1874
|
-
```
|
|
1875
|
-
|
|
1876
|
-
`<contentPackage>-metadata.jsonl` holds one JSON record per line, keyed by the
|
|
1877
|
-
canonical `package-system-type-shortcode` address and carrying every address
|
|
1878
|
-
that note has: its `address.slug` on the web, a `foundry.<system>.uuid` in
|
|
1879
|
-
Foundry, the `anchors` its named sections compiled to, and a `documentation`
|
|
1880
|
-
pointer where an item's prose compiles into a JournalEntry of its own.
|
|
1881
|
-
|
|
1882
|
-
**This is the artifact other packages resolve your addresses through.** It is
|
|
1883
|
-
advertised in the emitted `system.json` / `module.json` as
|
|
1884
|
-
`flags.metadataUrl` — derived, version-pinned, and not something you write down
|
|
1885
|
-
— and published as a release asset beside the manifest and the `.zip`.
|
|
1886
|
-
|
|
1887
|
-
| Setting | What it decides |
|
|
1888
|
-
| -------------------- | --------------------------------------------- |
|
|
1889
|
-
| `contentPackage` | The package emitted, and the file's name. |
|
|
1890
|
-
| `foundryPackage` | The package every emitted `uuid` names. |
|
|
1891
|
-
| `paths.content` | The tree walked. |
|
|
1892
|
-
| `paths.contentIndex` | Where the file lands (`build/content-index`). |
|
|
1893
|
-
|
|
1894
|
-
**Both addresses are optional, independently.** A note that compiles into no
|
|
1895
|
-
document states no `foundry` block, and a package that ships compendiums and
|
|
1896
|
-
publishes only a homepage serves no page at its addresses. Neither is an error,
|
|
1897
|
-
and neither is guessed: inventing the missing one asserts a target that does not
|
|
1898
|
-
exist, which is the silent dead link this whole mechanism exists to prevent.
|
|
1899
|
-
|
|
1900
|
-
## Resolving another package's addresses
|
|
1901
|
-
|
|
1902
|
-
Declare what you depend on, and fetch it:
|
|
1903
|
-
|
|
1904
|
-
```bash
|
|
1905
|
-
npx content-build deps fetch # every declared dependency
|
|
1906
|
-
npx content-build deps fetch --from ../sohl # from a local build, unreleased
|
|
1907
|
-
```
|
|
1908
|
-
|
|
1909
|
-
```yaml
|
|
1910
|
-
relationships:
|
|
1911
|
-
systems:
|
|
1912
|
-
- id: sohl
|
|
1913
|
-
type: system
|
|
1914
|
-
manifest: https://github.com/HeroicLands/Song-of-Heroic-Lands-FoundryVTT/releases/latest/download/system.json
|
|
1915
|
-
compatibility: { minimum: "0.8.0", verified: "0.8.2" }
|
|
1916
|
-
```
|
|
1917
|
-
|
|
1918
|
-
`deps fetch` reads that manifest, takes the `flags.metadataUrl` it advertises,
|
|
1919
|
-
and pulls the index into `build/cache/metadata`. The whole chain is declared, so
|
|
1920
|
-
nothing here holds an address of its own and a dependency that moves its release
|
|
1921
|
-
assets does not break its consumers.
|
|
1922
|
-
|
|
1923
|
-
**You may cite what you depend on, and nothing else.** The set is every entry in
|
|
1924
|
-
`relationships.systems` and `relationships.requires`; `recommends` and
|
|
1925
|
-
`conflicts` are declarations _about_ other packages rather than dependencies on
|
|
1926
|
-
them, so a link into one is a defect in the citing note. An address into a
|
|
1927
|
-
package you have not declared resolves nowhere and fails the build.
|
|
1928
|
-
|
|
1929
|
-
**A build never reaches the network.** A declared dependency whose index has not
|
|
1930
|
-
been fetched is an error naming `deps fetch`, rather than a download nobody
|
|
1931
|
-
asked for — a build that downloads silently is not reproducible and fails
|
|
1932
|
-
strangely offline. The cache is keyed by version, so changing the pinned version
|
|
1933
|
-
is a miss rather than a silent overwrite.
|
|
1934
|
-
|
|
1935
|
-
### A relationship may be a Foundry dependency only
|
|
1936
|
-
|
|
1937
|
-
`requires` and `systems` install with Foundry whether or not the tree cites
|
|
1938
|
-
them, and a package may need the one without the other — thalornaaltart
|
|
1939
|
-
`requires` Thalorna so Foundry installs the base module, and its one homepage
|
|
1940
|
-
note links nowhere. Declare `contentIndex: false` on that entry to say so:
|
|
1941
|
-
|
|
1942
|
-
```yaml
|
|
1943
|
-
relationships:
|
|
1944
|
-
requires:
|
|
1945
|
-
- id: thalorna
|
|
1946
|
-
type: module
|
|
1947
|
-
manifest: https://github.com/HeroicLands/thalorna/releases/latest/download/module.json
|
|
1948
|
-
contentIndex: false
|
|
1949
|
-
```
|
|
1950
|
-
|
|
1951
|
-
`deps fetch` fetches nothing for it — no cache directory, nothing to go stale —
|
|
1952
|
-
and a wikilink into it fails at the link, naming `contentIndex`, rather than
|
|
1953
|
-
resolving against a stale declaration or an index nobody fetched. It cannot be
|
|
1954
|
-
combined with `itemCatalog: true`, which extracts items from the same index
|
|
1955
|
-
this declares there is none of.
|
|
1956
|
-
|
|
1957
|
-
### `packagebuild` needs no declaration
|
|
1958
|
-
|
|
1959
|
-
package-build ships a set of images of its own — section banners chiefly — and a
|
|
1960
|
-
note reaches one without declaring anything:
|
|
1961
|
-
|
|
1962
|
-
```yaml
|
|
1963
|
-
data:
|
|
1964
|
-
banner: packagebuild-none-image-skillbnr
|
|
1965
|
-
```
|
|
1966
|
-
|
|
1967
|
-
**There is nothing to declare and nothing to fetch.** package-build is an npm
|
|
1968
|
-
dependency of every consumer rather than a Foundry package, so there is no
|
|
1969
|
-
release archive behind the name and no reason to fetch one — the tree is already
|
|
1970
|
-
on disk under `node_modules/@heroiclands/package-build/assets/`. The records are
|
|
1971
|
-
walked from it on every load and join the index like any other package's, so a
|
|
1972
|
-
cold cache is not a failure mode here and every lookup stays one path.
|
|
1973
|
-
|
|
1974
|
-
`packagebuild` is a **reserved** name: no repository may configure it as its
|
|
1975
|
-
`contentPackage`, and a configuration that tries is refused.
|
|
1976
|
-
|
|
1977
|
-
**These addresses have no Foundry form.** Foundry installs no package for this
|
|
1978
|
-
one, so a resolver returns "no Foundry address" deliberately rather than
|
|
1979
|
-
deriving `modules/packagebuild/…`, which installs nowhere. That is not a
|
|
1980
|
-
limitation in practice: the only slot that names them is `banner`, which reaches
|
|
1981
|
-
no compiled document at all and is read by the website and the book.
|
|
1982
|
-
|
|
1983
|
-
**A fetched catalogue is read one system at a time.** A dependency may ship a
|
|
1984
|
-
pack per system, and the two hold the same `(type, shortcode)` addresses with
|
|
1985
|
-
different data models — `skill:awar` is a real address in both vocabularies and
|
|
1986
|
-
means two different documents. So a pack declaring `system: hm3` resolves its
|
|
1987
|
-
embedded items against the dependency's `hm3` packs and its system-neutral ones,
|
|
1988
|
-
never against another system's, exactly as it already does for this repository's
|
|
1989
|
-
own packs. What each cached pack is comes from the dependency's manifest at
|
|
1990
|
-
fetch time, so a cache filled before this rule existed is treated as incomplete
|
|
1991
|
-
and `deps fetch` refills it — the alternative is a lookup that answers with the
|
|
1992
|
-
wrong system's document and reports nothing.
|
|
1993
|
-
|
|
1994
|
-
**Within one document, the shortcode itself comes from `system.shortcode`
|
|
1995
|
-
where the data model declares such a field, and otherwise from
|
|
1996
|
-
`flags.<systemId>.shortcode` in that same system's own flag namespace** — a
|
|
1997
|
-
system writes its per-document handle into its own flags and never another
|
|
1998
|
-
system's, so a document carrying its shortcode under a different system's flag
|
|
1999
|
-
namespace is not resolved by it.
|
|
2000
|
-
|
|
2001
|
-
**`--from` is for two packages changing together.** It fills the cache from a
|
|
2002
|
-
locally built artifact — a package zip or the directory it was built from — so a
|
|
2003
|
-
consumer can be built against a dependency that has not shipped. Without it,
|
|
2004
|
-
testing a dependency change against its consumers would cost a release
|
|
2005
|
-
round-trip.
|
|
2006
|
-
|
|
2007
|
-
> **This replaced a vendored link manifest**, which every repository committed a
|
|
2008
|
-
> copy of every other repository's file into. A copy went stale silently and one
|
|
2009
|
-
> did — 2,101 entries in `Song-of-Heroic-Lands-FoundryVTT` pointed at URLs the
|
|
2010
|
-
> `thalorna` site had stopped publishing, at the current format version, so the
|
|
2011
|
-
> version gate saw nothing. Mutual vendoring also deadlocked: each package had to
|
|
2012
|
-
> read the other's file before it could publish its own. A fetched artifact
|
|
2013
|
-
> cannot drift from its producer, and a consumer reads one the producer has
|
|
2014
|
-
> already shipped.
|
|
2015
|
-
|
|
2016
|
-
### A page's URL is its address
|
|
2017
|
-
|
|
2018
|
-
```text
|
|
2019
|
-
/<package>/<type>-<shortcode>/
|
|
2020
|
-
```
|
|
2021
|
-
|
|
2022
|
-
`(type, shortcode)` names one note within a package — that is the rule
|
|
2023
|
-
`content-build lint` enforces — so the URL is **unique by construction**. There
|
|
2024
|
-
is no collision check behind it, there never can be one to fail, and renaming a
|
|
2025
|
-
note changes nothing: no part of the address comes from a display string.
|
|
2026
|
-
|
|
2027
|
-
**A URL carries no `<system>` segment**, though the canonical address does. That
|
|
2028
|
-
is deliberate rather than an omission: a note publishes one page however many
|
|
2029
|
-
systems' documents it compiles into, so the segment would have nothing to
|
|
2030
|
-
distinguish and would only split one page's URL in two. The canonical address
|
|
2031
|
-
names a _document_; a URL names a _page_. So a consumer deriving a page address
|
|
2032
|
-
from a manifest key drops the package **and** the system, not the package
|
|
2033
|
-
alone.
|
|
2034
|
-
|
|
2035
|
-
It does not come from `name.full`. That would make a display name load-bearing three
|
|
2036
|
-
ways at once — a rename silently 404'd every inbound link, two notes in one
|
|
2037
|
-
section could derive the same URL so a uniqueness gate had to run, and long names
|
|
2038
|
-
had to be shortened through a table of 200 abbreviations. The header of the
|
|
2039
|
-
module doing it justified the cost by promising redirects "every change appends
|
|
2040
|
-
to the legacy-URL map" — and no such map was ever written, here or in any
|
|
2041
|
-
consumer. All of it is gone.
|
|
2042
|
-
|
|
2043
|
-
The `type-` half earns its place: it keeps every content address clear of the
|
|
2044
|
-
package's fixed mounts (`/<package>/` for the homepage, `/<package>/api/` for
|
|
2045
|
-
generated API docs), neither of which contains a hyphen or names a type. So the
|
|
2046
|
-
namespace is provably disjoint rather than conventionally so.
|
|
2047
|
-
|
|
2048
|
-
**A page is written flat, named by its address**, not filed into a
|
|
2049
|
-
directory, because Hugo derives a page's section from where the file is
|
|
2050
|
-
written, and a section appears in no address. The file is
|
|
2051
|
-
`<mount>/<type>-<shortcode>.md` and the front-matter `url:` publishes it at
|
|
2052
|
-
the package root, one level above; the `section` kind is disabled on every
|
|
2053
|
-
site, so no directory would answer even if one were written.
|
|
2054
|
-
|
|
2055
|
-
**A page states its address without the package base; everything pointing _at_
|
|
2056
|
-
it composes one**. They read as one quantity and are two:
|
|
2057
|
-
|
|
2058
|
-
| Written | Form | Because |
|
|
2059
|
-
| ------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------- |
|
|
2060
|
-
| A page's own `url:` front matter | `/<type>-<shortcode>/` | Hugo resolves it against `baseURL`, whose path already _is_ where the package is served |
|
|
2061
|
-
| Every `href` this build renders into a body | `<base><type>-<shortcode>/` | A browser resolves it against nothing |
|
|
2062
|
-
| A link-manifest `path` | `<type>-<shortcode>/` | Measured against `site.base` and stripped; a consumer prefixes its own |
|
|
2063
|
-
|
|
2064
|
-
`site.base` is the second and third of those and reaches the first not at all.
|
|
2065
|
-
Writing it into the `url:` as well makes every consumer's Hugo prefix
|
|
2066
|
-
its own base to a value that already carried one and published every content
|
|
2067
|
-
page a segment too deep — `/sohl/sohl/doc-rulesintro/`, 404 at the address the
|
|
2068
|
-
manifest, the sitemap and every inbound link named.
|
|
2069
|
-
|
|
2070
|
-
**There is no landing page and no section.** A page that introduces the notes
|
|
2071
|
-
of a type is an ordinary note addressed `doc-<type>`, with no build path of its
|
|
2072
|
-
own, carrying a content table over what it introduces. A site is its homepage
|
|
2073
|
-
and its pages, and nothing is generated between them — see
|
|
2074
|
-
[What the site publishes](#what-the-site-publishes).
|
|
2075
|
-
|
|
2076
|
-
### The address scheme
|
|
2077
|
-
|
|
2078
|
-
Where the content tree mounts _inside the package_ differs between repositories
|
|
2079
|
-
and is load-bearing. It is one setting, read by this command **and** by the page
|
|
2080
|
-
emitter, so the address a manifest publishes is the address a page is emitted at
|
|
2081
|
-
— stating it twice is how a manifest comes to assert a URL that resolves at build
|
|
2082
|
-
time and 404s for the reader.
|
|
2083
|
-
|
|
2084
|
-
```yaml
|
|
2085
|
-
publish:
|
|
2086
|
-
site: content
|
|
2087
|
-
manifests: { publish: true, consume: true }
|
|
2088
|
-
address:
|
|
2089
|
-
prefix: kb/ # default: "" — the package root
|
|
2090
|
-
```
|
|
2091
|
-
|
|
2092
|
-
- **`prefix`** — the content tree's mount within the package: the Hugo directory
|
|
2093
|
-
its pages are written under. `sohl` publishes a knowledgebase alongside
|
|
2094
|
-
generated API docs, so its tree sits under `kb/` while its pages address the
|
|
2095
|
-
package root (`affliction-aconite/`); `thalorna`'s site is nothing but its
|
|
2096
|
-
content, so it has no prefix. It must end in a slash and must not begin with
|
|
2097
|
-
one — where the _package_ is mounted is the consuming build's knowledge and is
|
|
2098
|
-
never recorded here.
|
|
2099
|
-
|
|
2100
|
-
`prefix` is the whole scheme. The `collection` subtype and the top-level
|
|
2101
|
-
`section:` key are refused by name.
|
|
2102
|
-
|
|
2103
|
-
A note's `subType` is checked against the values its type declares, and only
|
|
2104
|
-
those.
|
|
2105
|
-
|
|
2106
|
-
The only note the scheme yields no address for is one carrying no `shortcode`.
|
|
2107
|
-
It is **reported and omitted**, never guessed: the command prints one located
|
|
2108
|
-
diagnostic per note and still writes the file, because a note with no address is
|
|
2109
|
-
ordinary while a manifest entry pointing at a page that does not exist is not.
|
|
2110
|
-
|
|
2111
|
-
## Publishing a content index
|
|
2112
|
-
|
|
2113
|
-
Every content build walks the whole note tree and parses every note's
|
|
2114
|
-
frontmatter — the pack compilers, the site build, and the content-table expander
|
|
2115
|
-
each do it — and every one of them throws the result away. So nothing outside a
|
|
2116
|
-
build can ask a question about the content. "Which beings carry no `kbcat`?",
|
|
2117
|
-
"what does this table actually select?", "did that type rename leave anything
|
|
2118
|
-
behind?" have no answer short of writing a throwaway script that re-walks the
|
|
2119
|
-
tree, which is how eight dead Bestiary tables came to ship for weeks unnoticed.
|
|
2120
|
-
|
|
2121
|
-
`content-index` publishes the walk:
|
|
2122
|
-
|
|
2123
|
-
```bash
|
|
2124
|
-
npx content-build content-index
|
|
2125
|
-
# sohl → build/content-index/sohl.jsonl (1606 notes, 1578 KiB)
|
|
2126
|
-
```
|
|
2127
|
-
|
|
2128
|
-
One line of [JSON Lines](https://jsonlines.org/) per note, holding the note's
|
|
2129
|
-
whole frontmatter plus where it sits in the tree:
|
|
2130
|
-
|
|
2131
|
-
```json
|
|
2132
|
-
{
|
|
2133
|
-
"type": "being",
|
|
2134
|
-
"shortcode": "aurochs",
|
|
2135
|
-
"package": "sohl",
|
|
2136
|
-
"file": { "path": "Bestiary/Animal/Aurochs.md", "folder": "Bestiary/Animal", "name": "Aurochs" },
|
|
2137
|
-
"sohl": { "kbcat": "animal", "body": { "weight": { "base": 1500 } } }
|
|
2138
|
-
}
|
|
2139
|
-
```
|
|
2140
|
-
|
|
2141
|
-
so a question is one line of `jq`:
|
|
2142
|
-
|
|
2143
|
-
```bash
|
|
2144
|
-
jq -r 'select(.type == "being" and .sohl.kbcat == "animal") | .shortcode' \
|
|
2145
|
-
build/content-index/sohl.jsonl
|
|
2146
|
-
```
|
|
2147
|
-
|
|
2148
|
-
### The record is the note, not a projection of it
|
|
2149
|
-
|
|
2150
|
-
Nothing is selected, flattened, or renamed. A reader addresses
|
|
2151
|
-
`sohl.body.weight.base` because that is what the note says — which is also,
|
|
2152
|
-
not by accident, exactly what a `dataview` content-table query writes.
|
|
2153
|
-
|
|
2154
|
-
That is a deliberate refusal to impose a schema, and the tree is why. In `sohl`,
|
|
2155
|
-
frontmatter spreads **242 distinct leaf paths** unevenly over **15 types**, from
|
|
2156
|
-
9 on a `macro` to 72 on a `being`, and adding a field to one type is ordinary
|
|
2157
|
-
authoring. A format with a fixed column set would turn that authoring into a
|
|
2158
|
-
schema migration; a document format has no such problem.
|
|
2159
|
-
|
|
2160
|
-
Three keys are **derived** rather than authored, and a note carrying one is an
|
|
2161
|
-
error rather than a silent overwrite:
|
|
2162
|
-
|
|
2163
|
-
| Key | What it holds |
|
|
2164
|
-
| --------- | -------------------------------------------------------------------------------------------------- |
|
|
2165
|
-
| `package` | The configured `contentPackage`. A note may not declare its own, and the expander reads the same. |
|
|
2166
|
-
| `file` | `path`, `folder` and `name` below the content root — the same `file.*` a content-table query uses. |
|
|
2167
|
-
| `asset` | The file an asset record addresses. Present on an asset's record and on no note's. |
|
|
2168
|
-
|
|
2169
|
-
The location is namespaced under `file` precisely because `folder` is real
|
|
2170
|
-
frontmatter on most notes; a record states both, and they mean different things.
|
|
2171
|
-
|
|
2172
|
-
### A file is a record too
|
|
2173
|
-
|
|
2174
|
-
The same pass walks the package's **asset roots** — `assets/icons`,
|
|
2175
|
-
`assets/images` and `assets/audio` — and emits one record per addressable file
|
|
2176
|
-
into the same index. There is no second artifact and no asset-specific emitter:
|
|
2177
|
-
a package whose tree holds only pictures publishes an ordinary content index
|
|
2178
|
-
that happens to hold only asset records.
|
|
2179
|
-
|
|
2180
|
-
The filename is the shortcode, the root supplies the type, and the layout in
|
|
2181
|
-
between is the package's own business:
|
|
2182
|
-
|
|
2183
|
-
```json
|
|
2184
|
-
{
|
|
2185
|
-
"type": "icon",
|
|
2186
|
-
"shortcode": "anvil",
|
|
2187
|
-
"package": "sohl",
|
|
2188
|
-
"address": { "canonical": "sohl-none-icon-anvil" },
|
|
2189
|
-
"asset": {
|
|
2190
|
-
"path": "icons/game-icons/lorc/anvil.svg",
|
|
2191
|
-
"attribution": "Lorc",
|
|
2192
|
-
"source": "http://lorcblog.blogspot.com",
|
|
2193
|
-
"license": "CC-BY-3.0",
|
|
2194
|
-
"notes": "From game-icons.net"
|
|
2195
|
-
}
|
|
2196
|
-
}
|
|
2197
|
-
```
|
|
2198
|
-
|
|
2199
|
-
An asset record carries no frontmatter, no anchors and no `foundry` block, and
|
|
2200
|
-
its `address` holds the canonical key and no page slug — a file declares nothing
|
|
2201
|
-
about itself, compiles into no document, and publishes no page. The `asset`
|
|
2202
|
-
block is what a reader tells the two shapes apart by.
|
|
2203
|
-
|
|
2204
|
-
`path` is relative to the emitting package's own asset directory, so each
|
|
2205
|
-
consumer joins its own root onto it and resolves in one step. Provenance comes
|
|
2206
|
-
from a sibling `<filename.ext>.yaml` where one exists, and otherwise from the
|
|
2207
|
-
nearest `provenance.yaml` above the file, searching no higher than the type
|
|
2208
|
-
root. `docs/content-format.md` states the whole rule.
|
|
2209
|
-
|
|
2210
|
-
Two files under one root sharing a basename are two claims on one address, and
|
|
2211
|
-
the build fails naming both.
|
|
2212
|
-
|
|
2213
|
-
### Every note's address, and every anchor it defines
|
|
2214
|
-
|
|
2215
|
-
A record states the address a wikilink writes to reach the note, and every
|
|
2216
|
-
`{#slug}` anchor its body declares — unless the note is a **stub**, which
|
|
2217
|
-
carries both as `null`: it publishes no page, so it holds no address and offers
|
|
2218
|
-
no anchor to reach. Everything else it declares is kept, its `id` and `foundry`
|
|
2219
|
-
block included, because a document is derived from `data:` rather than from
|
|
2220
|
-
prose.
|
|
2221
|
-
|
|
2222
|
-
The SQL views read that absence as the state: `entries` is every row plus a
|
|
2223
|
-
derived `state` column, and `notes` is `entries` without the stubs. Nothing
|
|
2224
|
-
about the state is stored, and no note may author it.
|
|
2225
|
-
|
|
2226
|
-
```json
|
|
2227
|
-
{
|
|
2228
|
-
"address": { "slug": "being-aurochs", "canonical": "sohl-sohl-being-aurochs" },
|
|
2229
|
-
"file": { "path": "Bestiary/Animal/Aurochs.md", "folder": "Bestiary/Animal", "name": "Aurochs" },
|
|
2230
|
-
"anchors": [
|
|
2231
|
-
{
|
|
2232
|
-
"slug": "appearance",
|
|
2233
|
-
"name": "Appearance",
|
|
2234
|
-
"level": 1,
|
|
2235
|
-
"line": 348,
|
|
2236
|
-
"link": "being-aurochs#appearance"
|
|
2237
|
-
},
|
|
2238
|
-
{
|
|
2239
|
-
"slug": "dossier",
|
|
2240
|
-
"name": "Dossier",
|
|
2241
|
-
"level": 1,
|
|
2242
|
-
"line": 352,
|
|
2243
|
-
"link": "being-aurochs#dossier"
|
|
2244
|
-
}
|
|
2245
|
-
]
|
|
2246
|
-
}
|
|
2247
|
-
```
|
|
2248
|
-
|
|
2249
|
-
A record also states `nameAscii`, the note's `name.full` reduced to printable
|
|
2250
|
-
7-bit ASCII:
|
|
2251
|
-
|
|
2252
|
-
| `name.full` | `nameAscii` |
|
|
2253
|
-
| --------------- | ----------------- |
|
|
2254
|
-
| `Kûrbúl ¾-Helm` | `Kurbul 3/4-Helm` |
|
|
2255
|
-
| `Kèthîra` | `Kethira` |
|
|
2256
|
-
| `Ærling` | `AErling` |
|
|
2257
|
-
| `Þorn` | `Thorn` |
|
|
2258
|
-
| `Ðunhold` | `Dunhold` |
|
|
2259
|
-
| `Straße` | `Strasse` |
|
|
2260
|
-
|
|
2261
|
-
Names carry the setting's orthography and nobody types them, so anything
|
|
2262
|
-
searching or completing over the index needs a form a keyboard produces. Stating
|
|
2263
|
-
one means every consumer matches the same way, rather than each inventing a
|
|
2264
|
-
slightly different fold and two searches over the same data disagreeing.
|
|
2265
|
-
|
|
2266
|
-
It **transliterates rather than strips**, through the same `unidecode` table
|
|
2267
|
-
`slugify` already runs — so an ASCII name and a slug can never disagree about a
|
|
2268
|
-
character. Diacritics fold, ligatures expand, the runic letters spell out, and a
|
|
2269
|
-
vulgar fraction becomes readable. Deleting the marks instead would reduce
|
|
2270
|
-
`Kûrbúl` to `Krbl`, which is worse than the original for anyone trying to
|
|
2271
|
-
recognise it. Whatever is still outside printable ASCII afterwards becomes a
|
|
2272
|
-
space and runs of whitespace collapse — a space rather than nothing, so a
|
|
2273
|
-
character that transliterates away cannot weld two words together.
|
|
2274
|
-
|
|
2275
|
-
The value is emitted even when it equals the name, so a consumer matching on it
|
|
2276
|
-
never has to branch on whether the name happened to be ASCII already; it is
|
|
2277
|
-
`null` only when the note has no name at all. On the `sohl` tree, 25 of 1,606
|
|
2278
|
-
notes differ from their `name.full`.
|
|
2279
|
-
|
|
2280
|
-
`aliasesAscii` does the same for `name.aliases`, in the authored order. An alias
|
|
2281
|
-
is the name a reader is at least as likely to reach for as the canonical one —
|
|
2282
|
-
`Killer Whale` for an orca, `Ice Bear` for a polar bear, `Ix'balam` for a
|
|
2283
|
-
jaguar — so anything searching the index has to match them too. It is an **empty
|
|
2284
|
-
array**, never null, when a note has no aliases: an empty set of names is a fact
|
|
2285
|
-
rather than a missing value, and a consumer iterating it should not have to check
|
|
2286
|
-
first. An entry that is not a non-empty string is dropped rather than left as a
|
|
2287
|
-
hole, since the array is a set of names to match and a null is not one.
|
|
2288
|
-
|
|
2289
|
-
`address.slug` is what goes inside `[[…]]` within the package; `address.canonical`
|
|
2290
|
-
is the fully qualified key the content index files the note under, carrying the
|
|
2291
|
-
package and the system as well. The slug is the canonical key's **last two
|
|
2292
|
-
segments**, not its whole tail: a page has no system to name, so the two forms
|
|
2293
|
-
diverge by that segment rather than one trailing the other. Both are `null` for a
|
|
2294
|
-
note with no type or no shortcode, which has no address at all — the record says
|
|
2295
|
-
so rather than leaving each reader to rediscover the rule.
|
|
2296
|
-
|
|
2297
|
-
**Neither is new information** — the slug derives from `type` and `shortcode`,
|
|
2298
|
-
which every record already carries, and the canonical key adds only the system
|
|
2299
|
-
those two already imply. What the fields add is the _rule_: the lowercasing
|
|
2300
|
-
and the hyphen join live in one place, derived by the same `addressSlug` and
|
|
2301
|
-
`canonicalKey` the manifest and the site build use, so an index cannot disagree
|
|
2302
|
-
with either about where a note lives. A consumer that reimplements the join
|
|
2303
|
-
slightly differently gets a lookup matching nothing and no explanation — which is
|
|
2304
|
-
exactly how a resolver keyed on a bare `type/shortcode` silently misses every
|
|
2305
|
-
canonical `pkg-system-type-shortcode` entry.
|
|
2306
|
-
|
|
2307
|
-
**Anchors make a link checkable without a build.** Because the index states every
|
|
2308
|
-
anchor a note defines, `[[being-aurochs#dossier]]` can be confirmed — or shown
|
|
2309
|
-
dead — by a lookup, rather than by re-parsing the tree. Each anchor also carries
|
|
2310
|
-
its **line in the file**, so an editor jumps straight to the heading instead of
|
|
2311
|
-
searching for it, and a diagnostic about a section can name a real position.
|
|
2312
|
-
|
|
2313
|
-
Only headings carrying an explicit `{#slug}` are listed. A bare `#` heading also
|
|
2314
|
-
starts a journal page, but declares no slug, so nothing can address it with `#…`
|
|
2315
|
-
and listing it would offer a link that cannot be written. What counts as an anchor
|
|
2316
|
-
is kept identical to what `splitPages` matches — that pass decides which sections
|
|
2317
|
-
become addressable journal pages — and a test asserts the two agree, so drift
|
|
2318
|
-
fails the suite rather than advertising a link that resolves nowhere.
|
|
2319
|
-
|
|
2320
|
-
**The path stays relative.** `file.path` is below the content root and is
|
|
2321
|
-
deliberately never absolute: an absolute path is a fact about the machine that
|
|
2322
|
-
built the index rather than about the content, so it would differ between two
|
|
2323
|
-
checkouts of the same tree — costing the byte-stability the artifact depends on —
|
|
2324
|
-
and a published copy would carry someone's home directory and be wrong for every
|
|
2325
|
-
reader. Anyone holding the index knows the root it was built from, and
|
|
2326
|
-
`root + file.path` is the absolute form whenever it is wanted.
|
|
2327
|
-
|
|
2328
|
-
### Why JSON Lines, and not a database
|
|
2329
|
-
|
|
2330
|
-
The artifact has to survive the build that made it and be usable by anything — a
|
|
2331
|
-
person with `jq`, an editor, a CI check, another package's build. A
|
|
2332
|
-
line-per-note text file needs no server, no driver, and no schema; it is
|
|
2333
|
-
readable by every language without an install; and it **diffs**, so a migration
|
|
2334
|
-
that quietly empties a category shows up as a reviewable change rather than as a
|
|
2335
|
-
silently different binary.
|
|
2336
|
-
|
|
2337
|
-
Choosing it forfeits no SQL: DuckDB reads JSON Lines directly, with nested
|
|
2338
|
-
access, so `FROM read_json_auto('build/content-index/sohl.jsonl')` is a query
|
|
2339
|
-
away. A stored schema would forfeit the open shape, which is the asymmetry that
|
|
2340
|
-
decides it.
|
|
2341
|
-
|
|
2342
|
-
### It is derived, disposable, and byte-stable
|
|
2343
|
-
|
|
2344
|
-
The index is written under `build/`, gitignored with the rest of it, and
|
|
2345
|
-
**nothing may be authored against it**. It is deliberately not in `paths.stage`:
|
|
2346
|
-
that tree is mirrored destructively into a Foundry data root, so anything left
|
|
2347
|
-
there ships inside the installed system to every player.
|
|
2348
|
-
|
|
2349
|
-
Regenerating costs a frontmatter parse rather than a build, so the intended way
|
|
2350
|
-
to use it is to rebuild it whenever it looks stale — which is why it is a
|
|
2351
|
-
command of its own and not only a build step, and why it need never be
|
|
2352
|
-
committed.
|
|
2353
|
-
|
|
2354
|
-
That only holds if a rebuild is a no-op when nothing changed, so the output is
|
|
2355
|
-
**byte-stable**: records are ordered by content path with the note id breaking
|
|
2356
|
-
any tie, and every object's keys are sorted at every depth. A walk order is a
|
|
2357
|
-
directory-read order, and directory-read order is not a fact about the content.
|
|
2358
|
-
|
|
2359
|
-
An empty tree is an **error**, not an empty index. A reader takes the file as
|
|
2360
|
-
authoritative, and an index stating that a package has no content is
|
|
2361
|
-
indistinguishable from one built against a mis-pointed tree.
|
|
2362
|
-
|
|
2363
|
-
## Publishing a website
|
|
2364
|
-
|
|
2365
|
-
```bash
|
|
2366
|
-
npx content-build site # the configured tree, under build/hugo/
|
|
2367
|
-
```
|
|
2368
|
-
|
|
2369
|
-
The sibling of `package compile`: the same content tree, rendered as pages
|
|
2370
|
-
instead of compiled into packs. It does the walk, the frontmatter read, the
|
|
2371
|
-
address derivation, the address index, table expansion, wikilink resolution,
|
|
2372
|
-
code-fence protection, the foreign-manifest merge and the page emission. A
|
|
2373
|
-
site is its homepage and its pages — see
|
|
2374
|
-
[What the site publishes](#what-the-site-publishes).
|
|
2375
|
-
|
|
2376
|
-
### The homepage, and how much else is published
|
|
2377
|
-
|
|
2378
|
-
Every package is reachable at `https://www.heroiclands.org/<contentPackage>/`,
|
|
2379
|
-
and what a reader finds there is a note in the content tree — one markdown file,
|
|
2380
|
-
written by a person:
|
|
2381
|
-
|
|
2382
|
-
```markdown
|
|
2383
|
-
---
|
|
2384
|
-
type: homepage
|
|
2385
|
-
shortcode: root
|
|
2386
|
-
title: HârnMaster Kethira Basic # optional; defaults to packageBuild.manifest.title
|
|
2387
|
-
---
|
|
2388
|
-
|
|
2389
|
-
What the module is, which system it needs, how to install it.
|
|
2390
|
-
```
|
|
2391
|
-
|
|
2392
|
-
A package declares **exactly one** of these, and both `content-build lint` and
|
|
2393
|
-
`content-build site` require it — see
|
|
2394
|
-
[Exactly one homepage](#exactly-one-homepage).
|
|
2395
|
-
|
|
2396
|
-
That is the whole envelope. A homepage **compiles into no compendium
|
|
2397
|
-
document**, and so appears in no pack and in no link manifest — which is why it
|
|
2398
|
-
refuses `id`. It is the package root: written as the mount's `_index.md`,
|
|
2399
|
-
rendered at `/<contentPackage>/`, and cited as `[[homepage-root|Text]]` by
|
|
2400
|
-
the `shortcode` it declares (see
|
|
2401
|
-
[The homepage is the package root](#the-homepage-is-the-package-root-addressed-like-every-other-note)).
|
|
2402
|
-
It is dispatched on `type` like every other note, not on a filename — nothing
|
|
2403
|
-
in this format is decided by a file's name, which is why `sohl-thalorna` can
|
|
2404
|
-
keep a `README.md` in its content tree as a developer explainer about the
|
|
2405
|
-
source tree.
|
|
2406
|
-
|
|
2407
|
-
`type: homepage` is declared by the **engine**, not by the `sohl` item registry,
|
|
2408
|
-
so a package that configures no `itemBuilders` at all — `HarnMaster-3-FoundryVTT`
|
|
2409
|
-
and every HM3 module — can author one. The `engine/` ÷ `sohl/` line is
|
|
2410
|
-
note-format knowledge against game-system knowledge, and a homepage carries no
|
|
2411
|
-
`system` block.
|
|
2412
|
-
|
|
2413
|
-
`publish.site` then says how much _else_ is published:
|
|
2414
|
-
|
|
2415
|
-
| Mode | What is published |
|
|
2416
|
-
| ---------- | ------------------------------------------------------------------------- |
|
|
2417
|
-
| `homepage` | The authored homepage, and no other page. **The default, and the floor.** |
|
|
2418
|
-
| `content` | The homepage plus every page the content tree compiles to. |
|
|
2419
|
-
|
|
2420
|
-
There is no value meaning "no web presence": every package publishes its
|
|
2421
|
-
homepage. A boolean is refused, with a message naming the mode to write
|
|
2422
|
-
instead.
|
|
2423
|
-
|
|
2424
|
-
**Homepage-only is a first-class mode, not an accommodation.**
|
|
2425
|
-
`sohl-kethira-basic` (unofficial Hârn fan material under Keléstia Productions'
|
|
2426
|
-
Fan Material Guidelines) and `harn-adventures` (HârnFanon under Lythia's terms)
|
|
2427
|
-
must each publish a homepage and nothing beneath it. The boundary is _published
|
|
2428
|
-
content_ — journal text, artwork, item descriptions, compiled notes — and a page
|
|
2429
|
-
announcing the module discloses none of it. Because the failure mode is silent,
|
|
2430
|
-
the mode **fences the content surfaces off** rather than trusting a
|
|
2431
|
-
configuration to stay empty: in `homepage` mode the tree is never walked for
|
|
2432
|
-
pages, whatever else the `site:` block declares.
|
|
2433
|
-
|
|
2434
|
-
That is separate from the **dependency** edge, which such a module also
|
|
2435
|
-
declines: being cited by another package is what would stop it being
|
|
2436
|
-
withdrawable, and a homepage is one row in a routing table rather than an
|
|
2437
|
-
address anyone links to. A package publishes its content index regardless — the
|
|
2438
|
-
licensing constraint is against publishing _pages_, not against the artifact
|
|
2439
|
-
existing — but nothing may declare it as a dependency.
|
|
2440
|
-
|
|
2441
|
-
The homepage's file is the mount's `_index.md`, written at the root of the
|
|
2442
|
-
content tree, `build/hugo/content/` — the package's own site root, one level
|
|
2443
|
-
above the content mount, which is where `publish.address.prefix` puts
|
|
2444
|
-
everything else. Hugo renders it as the `home` kind at `baseURL`, so it states
|
|
2445
|
-
no `url` of its own.
|
|
2446
|
-
|
|
2447
|
-
**What it does not do is decide addresses.** Those come from `publish.address`,
|
|
2448
|
-
the same setting the content index reads, so a page and its index record cannot
|
|
2449
|
-
disagree about where the page is. Everything under `site:` is _framing_ —
|
|
2450
|
-
the repository's own body rewrites, and the residue of the generated Hugo
|
|
2451
|
-
configuration that is this repository's own. What the site publishes is the
|
|
2452
|
-
homepage and the content tree and nothing beside them: a page of
|
|
2453
|
-
documentation is a note (`type: doc`, with `pack: none` where it compiles into
|
|
2454
|
-
no document), so there is no second mechanism for mounting a directory of
|
|
2455
|
-
markdown, and a `trees:` or `readmeSections:` key is refused with a message
|
|
2456
|
-
saying so.
|
|
2457
|
-
|
|
2458
|
-
```yaml
|
|
2459
|
-
site:
|
|
2460
|
-
base: /sohl/ # default: /<contentPackage>/ — hrefs only, never a page's `url:`
|
|
2461
|
-
packages: [sohl, thalorna] # default: just contentPackage
|
|
2462
|
-
pass: sohlKb
|
|
2463
|
-
passOptions:
|
|
2464
|
-
apiBase: /sohl/api/
|
|
2465
|
-
symbolMap: kb/data/api-symbols.json
|
|
2466
|
-
notfound:
|
|
2467
|
-
tagline: Song of Heroic Lands has no page at
|
|
2468
|
-
sitenoun: site
|
|
2469
|
-
links:
|
|
2470
|
-
- { title: Rules, url: doc-rulesintro/, text: Success and opposed tests, injury, healing. }
|
|
2471
|
-
```
|
|
2472
|
-
|
|
2473
|
-
| Key | What it decides |
|
|
2474
|
-
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2475
|
-
| `base` | Where the package is served: the prefix on every rendered `href`, and what a manifest `path` is measured against. It reaches no page's own `url:` — see [A page's URL is its address](#a-pages-url-is-its-address). Defaults to `/<contentPackage>/`. |
|
|
2476
|
-
| `packages` | Which content packages this site renders. Defaults to its own. |
|
|
2477
|
-
| `pass` | A named bundle of this repository's own body rewrites. |
|
|
2478
|
-
| `passOptions` | That bundle's options. |
|
|
2479
|
-
| `assets` | The host every package's imagery is served from; the generated `params.cdnBaseURL`. |
|
|
2480
|
-
| `notfound` | The wording of the "page not found" page; the generated `params.notfound`. |
|
|
2481
|
-
| `hugo` | A mapping deep-merged over the generated Hugo configuration, last. Every key the generator writes is refused here — see `docs/configuration.md`. |
|
|
2482
|
-
|
|
2483
|
-
Where the tree is written is not among them. `content-build site` writes the
|
|
2484
|
-
whole Hugo source tree under `build/hugo/` — the generated `hugo.toml`, the
|
|
2485
|
-
content mount at `build/hugo/content/`, Hugo's cache — and the consumer's
|
|
2486
|
-
script runs `hugo --source build/hugo` over it. The generated file's every
|
|
2487
|
-
value has a source the repository already states; `docs/configuration.md`
|
|
2488
|
-
lists them.
|
|
2489
|
-
|
|
2490
|
-
### What the site publishes
|
|
2491
|
-
|
|
2492
|
-
**A site is its homepage and its pages, and nothing is generated between
|
|
2493
|
-
them.** The `type: homepage` note is `/<package>/`; every other note is one
|
|
2494
|
-
page at `/<package>/<type>-<shortcode>/`. No section directory is written, no
|
|
2495
|
-
listing of a type, no tag page: the generated Hugo configuration disables the
|
|
2496
|
-
`section`, `taxonomy`, `term` and `RSS` kinds on every site, so `home` and
|
|
2497
|
-
`page` are the only kinds that render, and a request for `/<package>/being/`
|
|
2498
|
-
is a 404.
|
|
2499
|
-
|
|
2500
|
-
Every structure above the pages — which notes belong together, in what order,
|
|
2501
|
-
under which headings, with which columns — is **authored**, as a `doc` note
|
|
2502
|
-
carrying a content table over the content index:
|
|
2503
|
-
|
|
2504
|
-
````markdown
|
|
2505
|
-
---
|
|
2506
|
-
type: doc
|
|
2507
|
-
subType: reference
|
|
2508
|
-
shortcode: beings
|
|
2509
|
-
pack: none
|
|
2510
|
-
name:
|
|
2511
|
-
full: Beings
|
|
2512
|
-
---
|
|
2513
|
-
|
|
2514
|
-
Every person and creature of the setting, by realm.
|
|
2515
|
-
|
|
2516
|
-
```dataview
|
|
2517
|
-
TABLE name.full AS Name, subType AS Kind, data.realm AS Realm
|
|
2518
|
-
FROM type = "being"
|
|
2519
|
-
SORT data.realm, name.full
|
|
2520
|
-
```
|
|
2521
|
-
````
|
|
2522
|
-
|
|
2523
|
-
````
|
|
2524
|
-
|
|
2525
|
-
That page is `doc-beings`, published at `/<package>/doc-beings/`, and it is the
|
|
2526
|
-
index — linked from the homepage like any other page, and choosing its own
|
|
2527
|
-
membership, order, headings and columns. A tag is a field such a table
|
|
2528
|
-
filters on, not a page of its own.
|
|
2529
|
-
|
|
2530
|
-
**One page per note, whatever it compiles into.** A system-bearing note — an
|
|
2531
|
-
`affiliation`, a `being`, every item type — compiles into two Foundry
|
|
2532
|
-
documents, the Item and the JournalEntry carrying its prose, addressed
|
|
2533
|
-
`doc<type>-<shortcode>`. On the web it renders as one page, which is its own
|
|
2534
|
-
documentation: the site index keys that page under both `<type>/<shortcode>`
|
|
2535
|
-
and `doc<type>/<shortcode>`, so a link written either way lands on it, and a
|
|
2536
|
-
content table that lists a type surfaces each note once.
|
|
2537
|
-
|
|
2538
|
-
A configuration that asks the build to generate an index — `site.sections`
|
|
2539
|
-
(with the `listType` / `listSubType` an entry carried), `site.landing`,
|
|
2540
|
-
`site.backfillSections`, and `site.list`, which said how such a listing
|
|
2541
|
-
renders — is refused by name, with one message:
|
|
2542
|
-
|
|
2543
|
-
```text
|
|
2544
|
-
package-build config: `site.sections` is retired — a site is its homepage and
|
|
2545
|
-
its pages, and any index between them is a `doc` note: write one with
|
|
2546
|
-
`type: doc`, a `shortcode` and `pack: none`, carrying a content table over the
|
|
2547
|
-
notes it lists, and link it from the homepage. Nothing is generated between
|
|
2548
|
-
the homepage and the pages, so delete the key.
|
|
2549
|
-
````
|
|
2550
|
-
|
|
2551
|
-
### Every page carries what links to it, and what it links to
|
|
2552
|
-
|
|
2553
|
-
The build resolves every wikilink on every page through the address index, so
|
|
2554
|
-
at the moment it writes a page it holds the whole link graph. It writes the
|
|
2555
|
-
part that concerns each page into that page's front matter:
|
|
2556
|
-
|
|
2557
|
-
```yaml
|
|
2558
|
-
related:
|
|
2559
|
-
backlinks: # pages that link to this one
|
|
2560
|
-
- title: Afzandah Parnâzar
|
|
2561
|
-
url: /thalorna/being-afzndhprnzr/
|
|
2562
|
-
type: being
|
|
2563
|
-
mentions: # pages this one links to
|
|
2564
|
-
- title: Kethramír
|
|
2565
|
-
url: /thalorna/place-kthrmr/
|
|
2566
|
-
type: place
|
|
2567
|
-
```
|
|
2568
|
-
|
|
2569
|
-
The theme's `related.html` partial reads both lists and renders them as one
|
|
2570
|
-
"Related" card, grouped by `type`. What the build guarantees about them:
|
|
2571
|
-
|
|
2572
|
-
- **A page appears once per list**, however many times it is linked, and
|
|
2573
|
-
whichever address the link was written to: `docbeing-grod` and `being-grod`
|
|
2574
|
-
are one page, and a page that cites it under both lists it once. A link from
|
|
2575
|
-
a page to itself is not a connection and is dropped.
|
|
2576
|
-
- **Each list is sorted by `type`, then `title`**, so the card's grouping is
|
|
2577
|
-
the same on every build whatever order the notes were walked in.
|
|
2578
|
-
- **Both lists are present whenever either has an entry**, so a theme reads one
|
|
2579
|
-
shape; a page with no links either way carries **no `related` key at all**,
|
|
2580
|
-
which is the partial's silent-disappear convention kept at the source.
|
|
2581
|
-
- **`url` composes `<base><slug>/`**, like every other href this build renders
|
|
2582
|
-
— see [A page's URL is its address](#a-pages-url-is-its-address). The page
|
|
2583
|
-
states its own address as `/<slug>/`; everything pointing _at_ it, this
|
|
2584
|
-
included, carries the base.
|
|
2585
|
-
- **The homepage is a page like any other on both sides.** A content page
|
|
2586
|
-
reaches it through `[[homepage-root|Text]]` and is listed in its backlinks;
|
|
2587
|
-
its own body links are ordinary markdown, resolved against the package root
|
|
2588
|
-
the way a browser resolves them, so `being-grod/`, `/thalorna/being-grod/`
|
|
2589
|
-
and `./being-grod/#top` each count as a mention of that page. A link that
|
|
2590
|
-
names a host leaves the site and counts as nothing.
|
|
2591
|
-
- **Only a page of this site is an endpoint.** A link into another package
|
|
2592
|
-
resolves to an href the build renders but no page it writes, so it is listed
|
|
2593
|
-
on neither side; a backlink can be counted only on a page this build emits.
|
|
2594
|
-
|
|
2595
|
-
`related` is derived, never authored: what links to a page is a fact about
|
|
2596
|
-
every other page in the tree. A note that writes one has it replaced.
|
|
2597
|
-
|
|
2598
|
-
### A place page says what lies within it and who holds it; an affiliation page says what it holds
|
|
2599
|
-
|
|
2600
|
-
Two keys the notes already carry answer three questions a reader asks of a
|
|
2601
|
-
page. A place's `data.parents` is geography — what it sits within — and an
|
|
2602
|
-
affiliation's `data.domains` is tenure — what it holds. The build reads both
|
|
2603
|
-
across the tree and every fetched index, inverts them, and writes each page
|
|
2604
|
-
the lists that concern it, shaped like `related`:
|
|
2605
|
-
|
|
2606
|
-
```yaml
|
|
2607
|
-
# on a place
|
|
2608
|
-
contains: # every place whose `parents` names this one
|
|
2609
|
-
- title: Khaset-Mehtet
|
|
2610
|
-
url: /thalorna/place-khasetmehtet/
|
|
2611
|
-
type: place
|
|
2612
|
-
subType: settlement
|
|
2613
|
-
held_by: # every affiliation whose `domains` names this one
|
|
2614
|
-
- title: The Nome of Ankhsetun
|
|
2615
|
-
url: /thalorna/affiliation-nomenkhstn/
|
|
2616
|
-
type: affiliation
|
|
2617
|
-
subType: polity
|
|
2618
|
-
|
|
2619
|
-
# on an affiliation
|
|
2620
|
-
holdings: # every place its `domains` names
|
|
2621
|
-
- title: Ankhsetun
|
|
2622
|
-
url: /thalorna/place-ankhsetunnome/
|
|
2623
|
-
type: place
|
|
2624
|
-
subType: region
|
|
2625
|
-
```
|
|
2626
|
-
|
|
2627
|
-
- **`contains` and `holdings` are sorted by `subType`, then `title`**;
|
|
2628
|
-
`held_by` by `title`. An entry carries `subType` where the note declares
|
|
2629
|
-
one.
|
|
2630
|
-
- **A key is present only when it has an entry.** A place nobody holds and
|
|
2631
|
-
nothing sits within carries neither key; an affiliation whose `domains` is
|
|
2632
|
-
empty carries no `holdings`. The theme's silent-disappear convention, kept
|
|
2633
|
-
at the source.
|
|
2634
|
-
- **`domains` names what an affiliation holds directly, and is never
|
|
2635
|
-
expanded.** A polity whose `domains` names a region holds the region; the
|
|
2636
|
-
settlements within it are reached through the region's `contains`, and are
|
|
2637
|
-
not repeated in the polity's `holdings`. Tenure runs through
|
|
2638
|
-
`affiliation.parents` — a house of its earl, an earl of the crown — and
|
|
2639
|
-
geography through `place.parents`. A subinfeudated manor sits in one region
|
|
2640
|
-
by the second and under a lord of another polity by the first, and both
|
|
2641
|
-
pages say so.
|
|
2642
|
-
- **A dependency's places and affiliations take part.** A fetched index entry
|
|
2643
|
-
carries the `parents` and `domains` its record stated, so a place another
|
|
2644
|
-
package publishes is listed within a local region, and a house another
|
|
2645
|
-
package publishes is named on the local manor it holds. A local note
|
|
2646
|
-
declaring a shortcode shadows a fetched entry declaring the same one.
|
|
2647
|
-
- **`url` composes `<base><slug>/`**, as every `related` entry does.
|
|
2648
|
-
|
|
2649
|
-
All three are derived, never authored: a note that writes one has it
|
|
2650
|
-
replaced. The theme renders each list as a table on the page.
|
|
2651
|
-
|
|
2652
|
-
### A place page carries the map from that place
|
|
2653
|
-
|
|
2654
|
-
A place that states a border or a route, or is named in one, has a map from
|
|
2655
|
-
it — the drawing `content-build map --from` makes, with the place at the
|
|
2656
|
-
centre and each neighbour at its bearing. The site build draws that map for
|
|
2657
|
-
every such place and writes it with the page: the page is a leaf bundle,
|
|
2658
|
-
`place-<shortcode>/index.md`, the drawing sits beside it as
|
|
2659
|
-
`from-<shortcode>.svg`, and the front matter names it:
|
|
2660
|
-
|
|
2661
|
-
```yaml
|
|
2662
|
-
map: from-kthrmr.svg
|
|
2663
|
-
```
|
|
2664
|
-
|
|
2665
|
-
The theme's "From here" panel reads the key, inlines the file as a page
|
|
2666
|
-
resource, and so every place name on the map is a link to that place's page.
|
|
2667
|
-
What the build guarantees:
|
|
2668
|
-
|
|
2669
|
-
- **The address does not move.** A bundled page states the same `url` a flat
|
|
2670
|
-
one does, `/<slug>/`, so `place-kthrmr/index.md` publishes exactly where
|
|
2671
|
-
`place-kthrmr.md` would.
|
|
2672
|
-
- **Every name links through the site base.** The map is drawn with the base
|
|
2673
|
-
the pages are served under, so each `href` composes `<base><slug>/` the way
|
|
2674
|
-
every other href the build renders does.
|
|
2675
|
-
- **It is written for inlining.** No XML prologue, no DOCTYPE, no comment, no
|
|
2676
|
-
reference to anything outside the file; the root carries a `viewBox` and no
|
|
2677
|
-
fixed width or height, so the theme sizes it to the column.
|
|
2678
|
-
- **A place with no relation carries nothing**: no file beside it, no `map`
|
|
2679
|
-
key, and the page stays a flat file. So does every page that is not a place.
|
|
2680
|
-
- **A site never fails for want of a map.** GraphViz draws; when it is not
|
|
2681
|
-
installed the build says so once, as a warning naming what to install, and
|
|
2682
|
-
every page is written as it would be without maps. `site.maps: false` in
|
|
2683
|
-
`package-build.config.yaml` draws nothing and asks nothing of GraphViz.
|
|
2684
|
-
|
|
2685
|
-
`map` is derived, never authored: a note that writes one has it replaced when
|
|
2686
|
-
the build draws a map, and dropped when there is none to draw.
|
|
2687
|
-
|
|
2688
|
-
The same relations supply a PDF book's full-page vector map immediately after
|
|
2689
|
-
each selected place entry. When a Scene pack is configured, they also supply
|
|
2690
|
-
gridless Foundry Scenes with raster backgrounds and pins to journals in an
|
|
2691
|
-
Adventure. A place from a dependency receives a pin through an importable
|
|
2692
|
-
journal link when its published index includes a JournalEntry UUID. An
|
|
2693
|
-
authored `regionalmap` with SVG art keeps the SVG for site and book while the
|
|
2694
|
-
Foundry build stages a PNG. Its `data.scale` states the distance and unit per
|
|
2695
|
-
grid unit.
|
|
2696
|
-
|
|
2697
|
-
Battle and local maps can carry a Foundry Scene export in `data.scene`. The
|
|
2698
|
-
Scene's canvas and embedded documents stay as authored. A pin whose text is
|
|
2699
|
-
`#anchor` opens the JournalEntry page with that heading in the map note and
|
|
2700
|
-
displays the heading text. A `totm` map uses a background image and dimensions
|
|
2701
|
-
to create a gridless Scene for theatre-of-the-mind play.
|
|
2702
|
-
An exported Scene can declare `data.fixup` entries that resolve asset Addresses
|
|
2703
|
-
into specific Scene fields. A fixup path can select an embedded document by its
|
|
2704
|
-
stable `_id`, so reordering the export does not redirect the replacement.
|
|
2705
|
-
|
|
2706
|
-
### Why the output location is fixed
|
|
2707
|
-
|
|
2708
|
-
The content mount is a build artifact and is **deleted on every run**, so that a
|
|
2709
|
-
page whose note was renamed cannot linger and keep publishing. A configurable
|
|
2710
|
-
location is a location that can be unset — and an unset one resolves to the
|
|
2711
|
-
repository root, where the wipe deletes the working tree. A fixed one under
|
|
2712
|
-
`build/` can point nowhere else, so `site.out` is refused by name and the wipe
|
|
2713
|
-
needs no guard.
|
|
2714
|
-
|
|
2715
|
-
### Consumer passes are named, not imported
|
|
2716
|
-
|
|
2717
|
-
A repository's own body rewrites are code, and a configuration is data, so a
|
|
2718
|
-
configuration **names** a bundle and the toolchain resolves it — exactly as
|
|
2719
|
-
`itemBuilders` names an item registry. `sohlKb` is the bundle for the `sohl`
|
|
2720
|
-
knowledgebase: it resolves `{@link}` tags against a TypeDoc symbol map and
|
|
2721
|
-
rewrites repository-relative links in the developer docs to their published or
|
|
2722
|
-
GitHub addresses. Neither rewrite can fail a build; an unknown `{@link}` degrades
|
|
2723
|
-
to a code span.
|
|
2724
|
-
|
|
2725
|
-
`symbolMap` is resolved **against the repository root**, not the process cwd, so
|
|
2726
|
-
`content-build site` reads the same map whatever directory it was invoked from.
|
|
2727
|
-
Leaving it unset is the legitimate empty case — every `{@link}` degrades, and
|
|
2728
|
-
nothing is reported. Setting it to a path that cannot be read, cannot be parsed,
|
|
2729
|
-
or does not hold a name → page object **fails the build**, naming the file and
|
|
2730
|
-
the reason. Left unreported they are indistinguishable from "no symbols", and
|
|
2731
|
-
a site publishes dead `{@link}` tags at exit 0. A map that is read reports
|
|
2732
|
-
its symbol count at info level, which is the only way to tell a map that loaded
|
|
2733
|
-
from one that loaded empty without reading the emitted HTML.
|
|
2734
|
-
|
|
2735
|
-
A bundle supplies one hook, `beforeLinks`, which runs on every page before
|
|
2736
|
-
wikilinks resolve — a `{@link}` tag may sit in prose a wikilink also touches.
|
|
2737
|
-
It runs inside code-fence protection, so it cannot rewrite a fenced example.
|
|
2738
|
-
|
|
2739
|
-
### The gates
|
|
2740
|
-
|
|
2741
|
-
Every integrity check reports and the run stops at the first that fires, so the
|
|
2742
|
-
output names the cause rather than its symptoms — an unusable manifest reported
|
|
2743
|
-
after the links that failed because of it reads as a pile of broken notes.
|
|
2744
|
-
|
|
2745
|
-
| Gate | What it catches |
|
|
2746
|
-
| ---------------------- | ---------------------------------------------------------------------------------- |
|
|
2747
|
-
| Frontmatter wikilinks | A link in frontmatter, which is copied verbatim and reaches the reader as `[[…]]`. |
|
|
2748
|
-
| Addresses | A note with no shortcode to be addressed by, or no section to be filed under. |
|
|
2749
|
-
| Unusable manifest | A vendored manifest this build cannot read. |
|
|
2750
|
-
| Unaddressable manifest | One it can read but cannot look anything up in. |
|
|
2751
|
-
| Tables and wikilinks | A table directive that cannot be honoured, or a link that lands nowhere. |
|
|
2752
|
-
|
|
2753
|
-
None of them exits the process from inside the library; the command decides. That
|
|
2754
|
-
is what makes them testable, which the consumer scripts' inline `process.exit`
|
|
2755
|
-
calls were not.
|
|
2756
|
-
|
|
2757
|
-
## Publishing a book
|
|
2758
|
-
|
|
2759
|
-
The third surface the content tree publishes, beside the compendium packs and
|
|
2760
|
-
the website: one PDF, built by `content-build pdf` and by `package-build release`.
|
|
2761
|
-
|
|
2762
|
-
```bash
|
|
2763
|
-
npx content-build pdf # build it where `pdf.out` says
|
|
2764
|
-
npx content-build pdf --out build/book # somewhere else
|
|
2765
|
-
npx content-build pdf --no-compile # emit the Typst source and stop
|
|
2766
|
-
npx content-build pdf --version 1.4.0 # stamp a version on the title page
|
|
2767
|
-
```
|
|
2768
|
-
|
|
2769
|
-
### A book is a selection, not a rendering of everything
|
|
2770
|
-
|
|
2771
|
-
The packs and the website publish the _whole_ tree: every note becomes a
|
|
2772
|
-
document and a page, and the three surfaces agreeing about what the content is
|
|
2773
|
-
is the point. **A book is not that.** It is a declared structure whose leaves
|
|
2774
|
-
pick notes out of the corpus, interleaved with prose that need not live in the
|
|
2775
|
-
content tree at all.
|
|
2776
|
-
|
|
2777
|
-
`pdf.document` names that structure — a YAML file of nested sections, each
|
|
2778
|
-
holding some mixture of prose files and `filter:` clauses:
|
|
2779
|
-
|
|
2780
|
-
```yaml
|
|
2781
|
-
contents:
|
|
2782
|
-
- sectionName: Gear
|
|
2783
|
-
contents:
|
|
2784
|
-
- file: prose/gear-preamble.md
|
|
2785
|
-
- filter: "type = 'weapongear'"
|
|
2786
|
-
- sectionName: Armour
|
|
2787
|
-
contents:
|
|
2788
|
-
- filter: "type = 'armorgear'"
|
|
2789
|
-
- sectionName: The Cast
|
|
2790
|
-
contents:
|
|
2791
|
-
- filter: "type = 'being' AND subType = 'npc'"
|
|
2792
|
-
```
|
|
2793
|
-
|
|
2794
|
-
Three consequences follow, and each is behaviour rather than oversight:
|
|
2795
|
-
|
|
2796
|
-
- **A note no clause selects is not in the book.** A project decides what its
|
|
2797
|
-
own volume carries, so an omission is an editorial act and nothing reports it.
|
|
2798
|
-
- **A note several clauses select appears several times**, each occurrence its
|
|
2799
|
-
own page, outline node and anchor. Inbound wikilinks are pointed at the
|
|
2800
|
-
first, so `[[weapongear-dagger]]` reaches one place however often it prints.
|
|
2801
|
-
- **A filter that selects nothing _is_ reported.** A note nobody asked for is
|
|
2802
|
-
expected; a clause matching nothing is either wrong or left over from a
|
|
2803
|
-
structure that has moved on.
|
|
2804
|
-
|
|
2805
|
-
The `WHERE` clause runs against the same content index the SQL tables read, and
|
|
2806
|
-
the build owns the `SELECT … FROM notes` — so a filter cannot name a table, and
|
|
2807
|
-
cannot reach another package's notes.
|
|
2808
|
-
|
|
2809
|
-
### How the book is set
|
|
2810
|
-
|
|
2811
|
-
**Every entry opens a page of its own**, under a full-bleed plate carrying a
|
|
2812
|
-
small-caps kicker above the entry's name. A reference book is consulted rather
|
|
2813
|
-
than read through: an entry beginning halfway down a page is harder to find,
|
|
2814
|
-
cannot carry its own running head honestly, and makes a page number in the
|
|
2815
|
-
contents point at the middle of something else. A section opens a page of its
|
|
2816
|
-
own too, plated to twice the depth, so a section reads as a section rather than
|
|
2817
|
-
as the first entry beneath it.
|
|
2818
|
-
|
|
2819
|
-
**The body is set in two columns**, and every other measure follows from that
|
|
2820
|
-
one: an image with no width class is a column wide, the infobox flows in the
|
|
2821
|
-
column measure and breaks between its sections, and a table wider than three
|
|
2822
|
-
columns is set across the page. Two columns are print's answer and print's
|
|
2823
|
-
alone — a scrolling page has no fixed viewport, so the website keeps one
|
|
2824
|
-
measure with a side rail.
|
|
2825
|
-
|
|
2826
|
-
An itinerary map uses a separate full-width page after its place entry. The
|
|
2827
|
-
vector drawing fits the printable area and carries a heading naming the
|
|
2828
|
-
viewpoint. A selected authored regional SVG map takes a page in the same way.
|
|
2829
|
-
|
|
2830
|
-
A note's `description:` sets as an epigraph between short rules under the
|
|
2831
|
-
plate. It disappears rather than leave a shell: a note with no description has
|
|
2832
|
-
no epigraph. The running foot carries the section's name, an ornament and the
|
|
2833
|
-
folio.
|
|
2834
|
-
|
|
2835
|
-
A table of **more than three columns** is given an explicit span rather than
|
|
2836
|
-
left to overflow the measure. Which span depends on how tall it is, and that is
|
|
2837
|
-
measured while the page is laid out: a table that fits a page is floated across
|
|
2838
|
-
both columns at the top of one, and a longer one is set on single-column pages
|
|
2839
|
-
of its own — a float cannot break, and a table taller than the page placed as
|
|
2840
|
-
one piles its rows on top of each other without a word of warning.
|
|
2841
|
-
|
|
2842
|
-
### What a section declares, its entries inherit
|
|
2843
|
-
|
|
2844
|
-
A section may carry presentation alongside its `contents:`, and everything
|
|
2845
|
-
beneath it agrees unless it says otherwise:
|
|
2846
|
-
|
|
2847
|
-
```yaml
|
|
2848
|
-
contents:
|
|
2849
|
-
- sectionName: Beings
|
|
2850
|
-
footer: The Bestiary # what the running foot carries
|
|
2851
|
-
page:
|
|
2852
|
-
banner: assets/images/banners/bestiary.webp # the plate's picture
|
|
2853
|
-
kicker: The Bestiary of Thalorna # the line above each entry's name
|
|
2854
|
-
columns: 2 # the measure this section's pages are set in
|
|
2855
|
-
contents:
|
|
2856
|
-
- filter: "type = 'being'"
|
|
2857
|
-
```
|
|
2858
|
-
|
|
2859
|
-
| Key | Default | What it does |
|
|
2860
|
-
| -------------- | ---------------------- | ----------------------------------------------------- |
|
|
2861
|
-
| `footer` | the section's own name | The name in the running foot. |
|
|
2862
|
-
| `page.banner` | none | A file this repository ships, plated under the title. |
|
|
2863
|
-
| `page.kicker` | the section's trail | The small-caps line above an entry's name. |
|
|
2864
|
-
| `page.columns` | `2` | Columns, from 1 to 4. |
|
|
2865
|
-
|
|
2866
|
-
`page:` is inherited whole: a subsection declaring one of its keys states the
|
|
2867
|
-
others it wants as well.
|
|
2868
|
-
|
|
2869
|
-
**A missing banner is a plate without a picture.** A section plate implies a
|
|
2870
|
-
banner per section, and art arrives later than rendering does — so a section
|
|
2871
|
-
that names none still gets its plate, its kicker and its title, set over the
|
|
2872
|
-
book's ink, and nothing is reported. A banner the build _cannot read_ is a
|
|
2873
|
-
different matter: that is a statement the tree makes and the build cannot
|
|
2874
|
-
honour, so it is a finding.
|
|
2875
|
-
|
|
2876
|
-
`header:` and `infobox:` are part of the format and read by nothing: the
|
|
2877
|
-
running head is a foot in this design, and which infobox a note draws is
|
|
2878
|
-
decided by the note's type.
|
|
2879
|
-
|
|
2880
|
-
### What it is fenced by
|
|
2881
|
-
|
|
2882
|
-
**`publish.site` decides whether a book is built, and it is the only switch.**
|
|
2883
|
-
`content` builds one; `homepage` does not — the same fence that stops the tree
|
|
2884
|
-
being walked for pages stops it being walked for a book. A PDF of the content
|
|
2885
|
-
tree is a content surface by any reading, arguably the most portable one there
|
|
2886
|
-
is, so a package that publishes only a homepage publishes no book however its
|
|
2887
|
-
`pdf:` block is written. The command says so and exits 0.
|
|
2888
|
-
|
|
2889
|
-
That is also the opt-out: a package with no `pdf:` block, no content tree, or
|
|
2890
|
-
`publish.site: homepage` builds nothing and fails nothing.
|
|
2891
|
-
|
|
2892
|
-
### Configuration
|
|
2893
|
-
|
|
2894
|
-
```yaml
|
|
2895
|
-
pdf:
|
|
2896
|
-
title: The Hârn Ensemble # required
|
|
2897
|
-
subtitle: a roster of the ready-made
|
|
2898
|
-
document: book.yaml # required — the document tree above
|
|
2899
|
-
out: build/dist # default
|
|
2900
|
-
front: # prose before the contents
|
|
2901
|
-
- prose/colophon.md
|
|
2902
|
-
fonts: # every key optional — the defaults below
|
|
2903
|
-
serif: Libertinus Serif
|
|
2904
|
-
sans: Libertinus Sans
|
|
2905
|
-
mono: DejaVu Sans Mono
|
|
2906
|
-
path: assets/fonts # faces of your own, searched as well as the shipped ones
|
|
2907
|
-
iconFonts: # icon family → the font carrying its glyphs
|
|
2908
|
-
fontawesome: assets/fonts/fa-solid-900.ttf
|
|
2909
|
-
binary: typst # when it is not simply `typst` on PATH
|
|
2910
|
-
```
|
|
2911
|
-
|
|
2912
|
-
Nothing here is an address or a brand: the title, the front matter and the faces
|
|
2913
|
-
are the publishing repository's to choose, which is why they are configuration.
|
|
2914
|
-
|
|
2915
|
-
### The faces come with the build, not with the machine
|
|
2916
|
-
|
|
2917
|
-
All three defaults resolve on a machine that has none of them installed. The
|
|
2918
|
-
serif and the mono the defaults name are faces the compiler embeds; the sans is
|
|
2919
|
-
one the toolchain ships, under `assets/fonts`, along with the superfamily's own
|
|
2920
|
-
mono for a package that names it. The compile searches that directory, and
|
|
2921
|
-
`pdf.fonts.path` when a package names one, and **nothing the machine has
|
|
2922
|
-
installed** — `--font-path` and `--ignore-system-fonts` together — so the same
|
|
2923
|
-
source sets the same book everywhere, and a machine carrying its own copy of a
|
|
2924
|
-
named family does not quietly change what it prints.
|
|
2925
|
-
|
|
2926
|
-
The default mono is the compiler's rather than the superfamily's because the
|
|
2927
|
-
superfamily's mono carries none of the dot-below and dot-above letters this
|
|
2928
|
-
corpus spells names with. A package whose fenced blocks stay inside what it does
|
|
2929
|
-
carry can name it instead, and the compiler reports nothing when a glyph is
|
|
2930
|
-
missing, so that is a claim to measure against the fences rather than assume.
|
|
2931
|
-
|
|
2932
|
-
A face nothing resolves is a **finding** — the compiler says so, and a compile
|
|
2933
|
-
that says it still exits 0 and writes a book set in the fallback, which is the
|
|
2934
|
-
one way a wrong face reaches a reader unnoticed.
|
|
2935
|
-
|
|
2936
|
-
Compiling an emitted `.typ` by hand takes the same two flags to set it the way
|
|
2937
|
-
the build does:
|
|
2938
|
-
|
|
2939
|
-
```bash
|
|
2940
|
-
typst compile --ignore-system-fonts \
|
|
2941
|
-
--font-path node_modules/@heroiclands/package-build/assets/fonts \
|
|
2942
|
-
build/dist/the-book.typ
|
|
2943
|
-
```
|
|
2944
|
-
|
|
2945
|
-
The shipped face is licensed under the SIL Open Font License, which travels with
|
|
2946
|
-
it.
|
|
2947
|
-
|
|
2948
|
-
### Typst is a binary, not a dependency
|
|
2949
|
-
|
|
2950
|
-
The compiler is an external program, found on `PATH` or named by `pdf.binary`.
|
|
2951
|
-
Bundling a native compiler would put a platform-specific artefact into the
|
|
2952
|
-
dependency tree of three repositories, only one of which is mostly a book, and
|
|
2953
|
-
it would have to resolve on every consumer's CI runner before any of them could
|
|
2954
|
-
install the toolchain at all.
|
|
2955
|
-
|
|
2956
|
-
A missing binary is a **finding**, not a failure: the `.typ` source is written
|
|
2957
|
-
anyway, which is both the diagnostic and the thing a consumer can compile by
|
|
2958
|
-
hand.
|
|
2959
|
-
|
|
2960
|
-
### What the book gets right, and why each matters
|
|
2961
|
-
|
|
2962
|
-
| Property | How |
|
|
2963
|
-
| ------------------------ | ------------------------------------------------------------------------------- |
|
|
2964
|
-
| Searchable | Real text, not page images — a roster nobody can search for a name is no use. |
|
|
2965
|
-
| Bookmark outline | Every section and entry is a heading, so a viewer's sidebar is the way in. |
|
|
2966
|
-
| Page-numbered contents | `#outline()`, shallower than the bookmarks — 2,500 entries would be 40 pages. |
|
|
2967
|
-
| Repeating table headers | `table.header`, so a property table spilling a page keeps its column names. |
|
|
2968
|
-
| Internal cross-reference | A wikilink between two notes of the book becomes an internal destination. |
|
|
2969
|
-
| External cross-reference | A cross-package link, and a note the book did not select, stay URLs. |
|
|
2970
|
-
| Illustration | An image authored in the body is staged and set at the measure its class names. |
|
|
2971
|
-
|
|
2972
|
-
An image states its own width and position once, in the note, and the book, the
|
|
2973
|
-
website and a Foundry journal page each honour it — see
|
|
2974
|
-
[Images](docs/content-format.md#images) for the two closed vocabularies. The book
|
|
2975
|
-
copies each picture it prints into the output directory before the compiler
|
|
2976
|
-
runs, because Typst reads nothing above its own root; an address naming a file
|
|
2977
|
-
this package does not ship prints its caption alone and is reported.
|
|
2978
|
-
|
|
2979
|
-
`{#anchor}` on a heading becomes an internal destination namespaced by its
|
|
2980
|
-
entry, so `[[being-jaslyne#appearance]]` reaches the section and two notes may
|
|
2981
|
-
both declare `{#appearance}`. A reference to a destination the book does not
|
|
2982
|
-
carry falls back to the entry that would have held it, or to plain text, and is
|
|
2983
|
-
reported — because Typst treats a dangling reference as fatal, and one mistyped
|
|
2984
|
-
anchor should not take a thousand-page book down at the last step.
|
|
2985
|
-
|
|
2986
|
-
### The charset is what makes a face choosable
|
|
2987
|
-
|
|
2988
|
-
A PDF embeds the faces it sets, so every character in the corpus is a claim on
|
|
2989
|
-
the book's typeface — and Typst does not warn about a missing glyph, it falls
|
|
2990
|
-
back and exits 0. That is why `content-build lint` holds content to a charset,
|
|
2991
|
-
and why `:icon-…:` names an icon rather than pasting one. Both exist for this
|
|
2992
|
-
surface. See _Prose: formatting and markdown_.
|
|
2993
|
-
|
|
2994
|
-
## Diffing published addresses
|
|
2995
|
-
|
|
2996
|
-
```bash
|
|
2997
|
-
gh release download v0.8.2 -p system.zip -D build/baseline
|
|
2998
|
-
npx content-build addresses diff --from build/baseline/system.zip
|
|
2999
|
-
npx content-build addresses diff --from build/baseline/system.zip --strict
|
|
3000
|
-
```
|
|
3001
|
-
|
|
3002
|
-
A package's `(type, shortcode)` addresses are a **published interface**. Every
|
|
3003
|
-
satellite declaring `itemCatalog: true` assembles its beings out of them —
|
|
3004
|
-
`attribute:str`, `skill:awar`, `weapongear:Tabri` — resolving each against the
|
|
3005
|
-
Item packs of the release its `compatibility.verified` pins. Renaming a
|
|
3006
|
-
shortcode is therefore a breaking change to something other repositories
|
|
3007
|
-
consume, and it used to cost nothing and produce no signal: the check that got
|
|
3008
|
-
made was a repository-local grep, which cannot see the other repositories and
|
|
3009
|
-
reports the reassuring answer.
|
|
3010
|
-
|
|
3011
|
-
`sohl` renamed one weapon's shortcode from `Tabri` to `Taburi` two days after
|
|
3012
|
-
the `v0.8.2` tag, on the stated ground that "nothing referenced the old value".
|
|
3013
|
-
True of that repository. Both satellites pin `v0.8.2` and address
|
|
3014
|
-
`weapongear:Tabri` on their copy of the same character — five lookups that
|
|
3015
|
-
resolve today and fail the moment either pin moves, reporting a missing item.
|
|
3016
|
-
|
|
3017
|
-
**A rename is told from a removal by the document id, and that is an identity
|
|
3018
|
-
match rather than an inference.** A note authors its `_id` in frontmatter; it is
|
|
3019
|
-
not derived from the shortcode, so it survives a rename. An address that
|
|
3020
|
-
disappeared while its document is still published elsewhere _is_ a rename:
|
|
3021
|
-
|
|
3022
|
-
```text
|
|
3023
|
-
assets/content/Weapons/Melee/Taburi.md:12:1: warning: since sohl@0.8.2, weapongear:Tabri is no longer published; the same document (s5D6QJbw7ZbETxdN) is now published as weapongear:Taburi. Every package that resolves weapongear:Tabri breaks when it moves past sohl@0.8.2
|
|
3024
|
-
```
|
|
3025
|
-
|
|
3026
|
-
Where the id is published under no address at all, that is all it says —
|
|
3027
|
-
**withdrawn**, with no successor named. A split, a deletion and a merge are
|
|
3028
|
-
indistinguishable at that point, and a "did you mean" guessed from string
|
|
3029
|
-
similarity would be worse than silence, because a wrong one sends the reader to
|
|
3030
|
-
the wrong fix.
|
|
3031
|
-
|
|
3032
|
-
| Finding | What it means | Severity |
|
|
3033
|
-
| ----------- | -------------------------------------------------------- | --------------------------------------- |
|
|
3034
|
-
| `renamed` | Address gone; the same document publishes under another. | `warning` — legitimate, but not silent |
|
|
3035
|
-
| `withdrawn` | Address gone; its document publishes under none. | `warning` — retiring content is allowed |
|
|
3036
|
-
|
|
3037
|
-
Neither fails a build. Retiring content is legitimate, and so is renaming — the
|
|
3038
|
-
shortcode charset rule forces some. What a rename must not do is happen without
|
|
3039
|
-
anyone noticing. `--strict` reports both as errors and exits non-zero, for a
|
|
3040
|
-
release workflow that wants a gate.
|
|
3041
|
-
|
|
3042
|
-
**A finding is placed against the note, not the pack it was read from.** The
|
|
3043
|
-
address space is read from compiled output because that is what actually ships;
|
|
3044
|
-
the content tree is read only to find the note carrying the id, so the reader
|
|
3045
|
-
lands on the `shortcode:` line they just edited. A withdrawal has no such note,
|
|
3046
|
-
so it degrades to the baseline document — and where neither is readable the
|
|
3047
|
-
position is dropped rather than guessed.
|
|
3048
|
-
|
|
3049
|
-
**The baseline is named, never derived, and never downloaded.** `--from` takes
|
|
3050
|
-
the artifact for the same reason `deps fetch --from` does: a command that
|
|
3051
|
-
reaches the network on its own is not reproducible and fails strangely offline.
|
|
3052
|
-
A baseline that yields **no** addressable item is refused rather than read as
|
|
3053
|
-
"nothing changed" — it would report a clean result for every possible input,
|
|
3054
|
-
which is the one failure a check like this can never catch.
|
|
3055
|
-
|
|
3056
|
-
Item packs only, because that is the address space consumers resolve against: a
|
|
3057
|
-
being's embedded items are the only cross-package resolution by
|
|
3058
|
-
`(type, shortcode)`.
|
|
3059
|
-
|
|
3060
|
-
## Diagnostics
|
|
3061
|
-
|
|
3062
|
-
Every warning or error a build reports **about a content note** is emitted in the
|
|
3063
|
-
form every C-family compiler, `tsc` and ESLint already use, so an editor, a CI
|
|
3064
|
-
annotator or a `grep` parses it with no knowledge of this build:
|
|
3065
|
-
|
|
3066
|
-
```text
|
|
3067
|
-
assets/content/Regions/Capital_Nome.md:43:635: error: address [[place-kenbetpat]] resolves to no note — no package publishes it. Fix the shortcode, or declare the package that does as a dependency and run `content-build deps fetch` — in "The Capital Nome".
|
|
3068
|
-
```
|
|
3069
|
-
|
|
3070
|
-
`file:line:column: severity: message`. The path is relative to the working
|
|
3071
|
-
directory — during a build, the consuming repository's root.
|
|
3072
|
-
|
|
3073
|
-
Two rules keep it that way, both in `engine/diagnostics.mjs`:
|
|
3074
|
-
|
|
3075
|
-
- **The locator starts the line.** Diagnostics deliberately bypass `loglevel`,
|
|
3076
|
-
whose `[timestamp] [WARN]:` prefix sits exactly where a parser reads the path
|
|
3077
|
-
from; a greedy path pattern swallows the prefix and yields a filename nothing
|
|
3078
|
-
can open. Progress and summary lines still go through `loglevel` — they are
|
|
3079
|
-
not about a file and nothing needs to parse them.
|
|
3080
|
-
- **A field is dropped, never guessed.** A diagnostic reports the position it
|
|
3081
|
-
can establish honestly and no more: `file:line: …` when the column is
|
|
3082
|
-
meaningless, `file: …` when only the note is known. Nothing defaults to
|
|
3083
|
-
`1:1`, which would send a reader to the frontmatter every time.
|
|
3084
|
-
|
|
3085
|
-
**A configuration error is located the same way.** Every check in
|
|
3086
|
-
`content-config.mjs` and `config.mjs` reports through one `fail()`, naming the
|
|
3087
|
-
offending key's dotted path — a good description and a bad locator, in a file
|
|
3088
|
-
that runs to hundreds of lines with sibling entries flow-mapped onto one. The
|
|
3089
|
-
path now rides on the error, and the loader that read the file resolves it
|
|
3090
|
-
against the YAML, so all of them come out located:
|
|
3091
|
-
|
|
3092
|
-
```text
|
|
3093
|
-
package-build.config.yaml:382:64: error: package-build config: `site.notfound.links[0].descrption` is not a recognized option (expected one of: title, url, text).
|
|
3094
|
-
```
|
|
3095
|
-
|
|
3096
|
-
The same two rules apply. A key the file never declares — a required one that is
|
|
3097
|
-
simply missing — has no node of its own, so the position names the **mapping it
|
|
3098
|
-
belongs in** and no further out; a missing _top-level_ key has nothing above it
|
|
3099
|
-
but the document, and an `.mjs` configuration has no YAML to resolve a path
|
|
3100
|
-
against at all. Both report `package-build.config.yaml: error: …`, the file
|
|
3101
|
-
without a line, rather than a line that would be wrong.
|
|
3102
|
-
|
|
3103
|
-
Establishing a position at all takes three corrections, applied only where they
|
|
3104
|
-
hold — see `positionInBody`. A body offset is not a file line until the
|
|
3105
|
-
frontmatter's lines are added (`bodyLine`); the trim that strips the body can
|
|
3106
|
-
take indentation off its first line (`bodyColumn`); and a body is scanned
|
|
3107
|
-
_after_ its content tables expand, so an offset may land in text nobody
|
|
3108
|
-
authored. `expandContentTables` therefore returns a `lineMap` saying which
|
|
3109
|
-
authored line each emitted line came from — a generated row is blamed on the
|
|
3110
|
-
directive that produced it and reports **no column**, because there is no
|
|
3111
|
-
authored character to point at.
|
|
3112
|
-
|
|
3113
|
-
## Layout
|
|
3114
|
-
|
|
3115
|
-
- **`@heroiclands/package-build/engine`** — package-agnostic machinery: the
|
|
3116
|
-
content walk, frontmatter, tables, wikilinks, ids, folders, the content index
|
|
3117
|
-
and the web-address rule, `BasePackCompiler`, and the generic Foundry document
|
|
3118
|
-
compilers.
|
|
3119
|
-
- **`@heroiclands/package-build/sohl`** — Song of Heroic Lands data-model
|
|
3120
|
-
knowledge: item types, builders, the items and actors compilers, and default
|
|
3121
|
-
art. Isolated behind its own entry point so an adventure module never receives
|
|
3122
|
-
`buildWeaponGear`.
|
|
3123
|
-
|
|
3124
|
-
Each module is also reachable as its own entry point —
|
|
3125
|
-
`@heroiclands/package-build/engine/journals`,
|
|
3126
|
-
`@heroiclands/package-build/sohl/items` — so a build that needs one thing does
|
|
3127
|
-
not load the whole pipeline. The barrels re-export each module as a namespace
|
|
3128
|
-
rather than flattening it, because several modules deliberately re-export a
|
|
3129
|
-
neighbour's symbol and a flattened star export would drop every such name
|
|
3130
|
-
silently.
|
|
3131
|
-
|
|
3132
|
-
A few plain-ESM leaves are shared **with the Foundry runtime**, not just with the
|
|
3133
|
-
build: the item default-art map, the curated region-event vocabulary, and the
|
|
3134
|
-
affiliation standings. Each has its own entry point —
|
|
3135
|
-
`@heroiclands/package-build/sohl/default-item-art`,
|
|
3136
|
-
`.../engine/region-events`, `.../sohl/affiliation-standings` — so a client bundle
|
|
3137
|
-
reaches the constant without importing a barrel that grows to hold compilers
|
|
3138
|
-
reading the filesystem. Keeping one copy of each is the point: the build-time and
|
|
3139
|
-
runtime values cannot disagree.
|
|
3140
|
-
|
|
3141
|
-
`@heroiclands/package-build/content-config` exposes the configuration contract's own
|
|
3142
|
-
module, so a consumer can name its types (`ContentBuildConfig`, `PackSpec`) from
|
|
3143
|
-
JSDoc.
|
|
3144
|
-
|
|
3145
|
-
## Tests
|
|
3146
|
-
|
|
3147
|
-
The package carries its own suite, so it is verifiable without any repository
|
|
3148
|
-
that consumes it:
|
|
3149
|
-
|
|
3150
|
-
```
|
|
3151
|
-
npm test # the whole suite
|
|
3152
|
-
npx vitest run tests/wikilinks.test.ts # one file
|
|
3153
|
-
```
|
|
3154
|
-
|
|
3155
|
-
**The suite is configured from a fixture, not from this repository's root.**
|
|
3156
|
-
`vitest.config.ts` points `PACKAGE_BUILD_CONFIG` at
|
|
3157
|
-
`tests/fixtures/repo/package-build.config.yaml`, whose adjacent `package.json`
|
|
3158
|
-
is shaped like a consumer's. The Foundry package id and the system version are
|
|
3159
|
-
derived from the manifest beside the configuration, and at the root that
|
|
3160
|
-
manifest is this toolchain's own — `@heroiclands/package-build`, which is
|
|
3161
|
-
neither a Foundry package id nor a game system version, so the derivations
|
|
3162
|
-
would assert nothing.
|
|
3163
|
-
|
|
3164
|
-
The harness is deliberately austere: no global setup, no Foundry stubs, and no
|
|
3165
|
-
alias onto a consuming repository's source. `tests/suite-is-self-contained.test.ts`
|
|
3166
|
-
enforces that — a test that reached for `globalThis.game`, imported `@src/…`, or
|
|
3167
|
-
resolved a path climbing out of the package would pass in situ and fail the
|
|
3168
|
-
moment the package was installed from npm. Before the extraction five files
|
|
3169
|
-
resolved `"../../.."` and asserted about whatever happened to be there, which
|
|
3170
|
-
was the system repository, because the package was vendored inside it (#1).
|
|
3171
|
-
|
|
3172
|
-
`tests/dependencies-are-declared.test.ts` guards the same failure from the
|
|
3173
|
-
shipping side. This package spent its first six changes as a workspace inside
|
|
3174
|
-
the Song of Heroic Lands repository, where npm hoisted the root's
|
|
3175
|
-
`devDependencies` into the workspace root: an import this package never declared
|
|
3176
|
-
still resolved, and failed nowhere but a consumer's install. The test
|
|
3177
|
-
walks every module named by the `files` field and holds each bare specifier to
|
|
3178
|
-
one of three cases — a Node builtin, this package addressing itself, or a
|
|
3179
|
-
declared `dependency` — and checks the converse: nothing shipped may import a
|
|
3180
|
-
`devDependency`, and no declared dependency may go unimported.
|
|
3181
|
-
|
|
3182
|
-
`tests/import-needs-no-config.test.ts` is the third of the same family, and the
|
|
3183
|
-
one that keeps "resolved on first read, never at import" honest. It copies the
|
|
3184
|
-
files the package ships into a temporary directory outside this repository and
|
|
3185
|
-
imports each shipped module on its own, in a process whose environment has
|
|
3186
|
-
`PACKAGE_BUILD_CONFIG` deleted. Outside is load-bearing: the config walk climbs
|
|
3187
|
-
from the working directory and from the module's own directory, and the copy is
|
|
3188
|
-
both — so one left inside the tree could reach a configuration above it and
|
|
3189
|
-
prove nothing, which is why the first case asserts that none is reachable from
|
|
3190
|
-
the copy before the rest run. A module that hoisted a configured value to import
|
|
3191
|
-
time fails there, and only there.
|
|
3192
|
-
|
|
3193
|
-
`tests/config-from-working-directory.test.ts` describes the resolution order
|
|
3194
|
-
itself. It builds the shape no unit test can fake — a repository with the
|
|
3195
|
-
toolchain installed under it, and a second checkout nested inside that
|
|
3196
|
-
repository with its own configuration and no `node_modules` — and asserts which
|
|
3197
|
-
configuration a build run in each place reads. The nested case is the one that
|
|
3198
|
-
was wrong, and it is untestable any other way: the tell of a wrong-tree build is
|
|
3199
|
-
normally an unexpected zero diff, and the sweeps that provoke this shape expect
|
|
3200
|
-
zero differences, so only "which file was read" separates the outcomes.
|
|
3201
|
-
|
|
3202
|
-
## Releasing
|
|
3203
|
-
|
|
3204
|
-
Releasing is not a command anyone runs. It is a consequence of merging, in two
|
|
3205
|
-
steps, and each step is visible while it is pending.
|
|
3206
|
-
|
|
3207
|
-
**A pull request declares its bump when a consumer will notice.** Run
|
|
3208
|
-
`npx changeset` and pick major/minor/patch; the summary you write becomes the
|
|
3209
|
-
changelog entry and the release note. Nothing a consumer meets means no
|
|
3210
|
-
changeset at all — CI carries no gate demanding one. What CI does check is the
|
|
3211
|
-
quality of a changeset that exists: `node bin/package-build.mjs changelog check`
|
|
3212
|
-
lints every pending one against the rules a changeset is held to.
|
|
3213
|
-
|
|
3214
|
-
**Merging to `main` opens a Version Packages pull request** carrying the version
|
|
3215
|
-
bump and the rewritten `CHANGELOG.md`. That pull request _is_ the pending
|
|
3216
|
-
release: as long as something is merged but unpublished, there is an open pull
|
|
3217
|
-
request saying so. This is the whole point of the pipeline — the previous,
|
|
3218
|
-
hand-driven process failed by leaving _nothing_ behind when the final step was
|
|
3219
|
-
forgotten, and on 2026-08-21 it did exactly that for two versions.
|
|
3220
|
-
|
|
3221
|
-
**Merging that publishes.** `changeset publish` puts the version on npm through
|
|
3222
|
-
Trusted Publishing (OIDC — there is no `NPM_TOKEN`), tags the commit `v<version>`
|
|
3223
|
-
and cuts the GitHub Release with the changelog section as its body. It publishes
|
|
3224
|
-
only versions that are not already on the registry, so re-running it is a no-op;
|
|
3225
|
-
`workflow_dispatch` on **Publish to npm** is the recovery path if a run fails
|
|
3226
|
-
after versioning.
|
|
3227
|
-
|
|
3228
|
-
Below 1.0.0, `^0.x` never crosses a minor — a consumer on `^0.15.0` will not see
|
|
3229
|
-
`0.16.0` until it bumps the pin deliberately. Dependabot raises that as its own
|
|
3230
|
-
pull request in each of the three consuming repositories.
|
|
3231
|
-
|
|
3232
|
-
> After a successful publish, `npm view @heroiclands/package-build version` can
|
|
3233
|
-
> report the _previous_ version for a minute or so. `dist-tags` is correct
|
|
3234
|
-
> immediately, and is what the workflow prints.
|
|
3235
|
-
|
|
3236
|
-
## License
|
|
3237
|
-
|
|
3238
|
-
GPL-3.0-or-later — see the
|
|
3239
|
-
[SoHL repository](https://github.com/HeroicLands/Song-of-Heroic-Lands-FoundryVTT).
|