Everything you need to take Song of Heroic Lands (SoHL) from a fresh clone to a
running Foundry instance and a published release: environment setup, every npm
script, how the build pipeline works, the layout of the build/ directory,
compendium packs from in-repo Markdown, deploying to a Foundry instance, and the
release process. Manual steps are called out explicitly with a π§ marker.
Audience: maintainers and contributors working on the SoHL system itself. For the rules of contributing, see System Development.
1. First-time setup
π§ Prerequisites: Node.js β₯ 24 (see engines in package.json) and
Git β that’s all you need to build and test. Deploying to a Foundry instance
may need extra access depending on the target (for example, SSH for a remote host).
git clone https://github.com/HeroicLands/Song-of-Heroic-Lands-FoundryVTT.git
cd Song-of-Heroic-Lands-FoundryVTT
cp .env.local.example .env.local # then edit β see below
npm ci # clean install from package-lock.json β node_modules/
npm run build # full build into build/stage/
π§ .env.local (gitignored β each developer keeps their own) holds the Foundry
paths that drive deployment. You only need it when deploying to a Foundry instance;
Β§6 Deploying to a Foundry instance lists
every variable and what it’s for.
See the file .gitignore for the specifics of which files are local only
and not stored in the repo. In particular, if you create a nogit folder,
anything inside of it will be ignored. If you use VSCode or IntelliJ, those
IDE configurations will also be ignored.
2. npm scripts
Every script in package.json, grouped by purpose. run-s runs steps in
sequence; run-p runs them in parallel.
Build
| Script | What it does |
|---|---|
build | Full production build: npm ci then build:noci. The canonical “build it all” entry. |
build:local | Same as build but npm i (allows lockfile updates) instead of npm ci. |
build:noci | The pipeline without install: lint:format β lint:docs-index β build:types β lint:dts β build:prepare β test:coverage β test:purity β build:code. |
build:prepare | In parallel: build:css, build:db, build:system. |
build:types | TypeScript type-check / compile (tsc -p tsconfig.json). No emit beyond .d.ts/checking. |
build:css | Compile scss/sohl.scss β build/stage/css/sohl.css (Sass). |
build:system | Generate build/stage/system.json from package-build.config.yaml (package-build manifest). |
build:assets | Copy templates/, lang/, assets/*, LICENSE.md, README.md into build/stage/ (package-build assets). |
build:db | build:assets then build:compiledb β stage assets, then compile packs. |
build:compiledb | Generate JSON from assets/content/ Markdown, then compile LevelDB packs in build/stage/packs/. |
build:unpackdb | The reverse: unpack the staged LevelDB packs back to JSON (for inspection). |
build:code | Bundle the system with Vite (vite build --mode release) β build/stage/sohl.js. |
build:icons | Rebuild the icon font from SVGs (utils/build-icon-font.mjs). Run by hand when icons change. |
build:icon-legend | Regenerate the user guide’s Icon Legend page from src/ + lang/en.json (utils/build-icon-legend.mjs). Verified by lint:icon-legend. |
build:kb-content | Generate the site’s Markdown: assets/content/ + kb/dev-docs/ β kb/content/kb/ (content-build site). No Hugo needed. |
build:kb | build:kb-content then render it with Hugo β build/site/sohl/. Needs Hugo and the theme submodule. |
site:assemble | Mount the TypeDoc HTML at build/site/sohl/api/ and finish the deployable tree (utils/build-site.mjs). |
build:site | The whole of /sohl/: docs:prepare β docs:html β build:kb β site:assemble. |
build:pack-release | Zip build/stage/ β build/dist/system.zip and copy system.json (package-build release). |
clean / distclean | Remove build output (distclean also clears caches/node_modules-level artifacts). |
Compendium packs
| Script | What it does |
|---|---|
build:compiledb | Generate each pack’s per-entry JSON from the assets/content/ Markdown into build/packs-json/<pack>/, then compile LevelDB from it. |
build:unpackdb | Extract a compiled LevelDB pack back to per-entry JSON under build/tmp/packs/. |
The authoritative content is the in-repo Markdown under assets/content/; the
JSON is a disposable build/ intermediate. There is no vault step β build:compiledb
reads the Markdown directly.
Scene β Level integrity
build:compiledb reads each pack back off disk after writing it and fails the
build if a Scene has lost its embedded Level.
A v14 Scene keeps its map image on a Level, and a compiled pack stores the two
under separate LevelDB keys β the Scene at !scenes!<id> holding levels as an
array of ids, each Level at !scenes.levels!<sceneId>.<levelId>. Nothing in
Foundry ties them together on read. A missing Level record only produces a
warning (N embedded levels records in Level <id> were undefined and not retrieved from the scenes.levels sublevel), after which the collection reads as
empty; the next world launch migrates that Scene and persists levels: [],
leaving initialLevel dangling. The map image is then gone for good, and the
only symptom is a blank battlemap. That is measured behaviour on both 14.359 and
14.367 β the core is not at fault, but the condition is unobservable until it is
permanent.
The check therefore runs against the compiled bytes rather than the JSON they
came from, because the gap it closes is the write path: the emitter is already
unit-tested, whereas the compendium CLI has previously mishandled Scene Levels.
An Adventure carries its scenes inline, levels and all, so that second shape is
checked too. The rule itself is a pure function (@heroiclands/package-build/engine/scene-levels)
and is unit-tested directly.
Tests, lint, format
| Script | What it does |
|---|---|
test | Run the vitest suite once. |
test:watch / test:ui | Watch mode / the vitest UI. |
test:coverage | Run with coverage. |
test:purity | The Foundry-free purity check (vitest.purity.config.ts). |
e2e:full | (on demand) The Cypress integration suite against a licensed Foundry container β not part of CI. See Testing. |
lint / lint:fix | ESLint over src/ (with --fix). |
lint:docs-index | Fail if a docs/ page is missing from its section nav or the README. |
lint:addresses | Fail on a malformed shortcode, a duplicate (type, shortcode), or a note missing its address alias (assets/content/). Runs content-build lint, so the rules are the toolchain’s. See Shortcode Integrity. |
lint:rules-vtt | Fail if a rules document under assets/content/Rules/ describes the VTT β clicks, buttons, dialogs, the chat log, or “the system”. See Authoring content notes. |
lint:content-links | Fail on a #anchor link in assets/content/ that no heading declares, or a Rules/** document unreachable from the rules root. See Authoring content notes. |
lint:doc-links | Fail on a relative link in kb/dev-docs/ whose target does not exist, or an #anchor no heading declares. The developer tree links by path, so moving a page breaks every link into it; this is what says so. |
lint:styles | stylelint over scss/. Enforces the BEM class convention and the --sohl-* token namespace, plus invalid declarations, unknown properties, and dead selectors. See What the two linters check. |
lint:markdown | markdownlint over every git-tracked .md file. A deliberately narrow structural set β heading hierarchy, duplicate sibling anchors, broken table rows, links that do not link. See What the two linters check. |
lint:icon-legend | Fail if assets/content/User_Guide/Icon_Legend.md differs from what build:icon-legend would write β the page is generated, so a hand-edit to it is lost on the next run (#1620). |
lint:expr-scopes | Fail if the generated expression-scope table in Expressions and Scripts is out of date with src/entity/expr/expression-scopes.mjs. Regenerate with npm run docs:expr-scopes. |
lint:dts | Validate the generated public type surface. |
lint:bundle-globals | Fail if system.json loads sohl.js as a classic script while the bundle declares names at global scope. Needs a built stage β runs after build:code, not inside lint. |
lint:format | Prettier --check over the repo β the same check as format:check, wired into the lint chain so drift fails the build (#1621). |
format / format:check | Prettier write / check the whole repo. |
How a linter reports a finding
Every finding a utils/check-*.mjs linter emits is a single line in the form
every C-family compiler, tsc and ESLint already use, so an editor’s error
matcher, a CI annotator or a grep resolves it without being told anything
about this repository (#1668):
assets/content/Rules/Attributes.md:28:13: error: dead address [[doc-nosuchthing]] β no document has that identity
file:line:column: severity: message, unindented, one finding per line. The
path is relative to the repository root. The layout is written in exactly one
place, and that place is not this repository β formatDiagnostic lives in
@heroiclands/package-build (engine/diagnostics), so the content build’s note
warnings and these linters’ findings cannot drift into two dialects of the same
form. Each linter imports emitDiagnostic from there directly and never formats
its own; where it needs to point at a literal in a file that is not a parsed
note β source, docs, lang files, e2e specs β it imports positionOfLiteral
from the same module.
There is no local re-export barrel in between. There was one, and it earned
nothing: ten of its thirteen consumers took a single symbol from it, so it saved
no import line, while renaming emitDiagnostic to reportDiagnostic β which
meant grepping this repository for the real name found nothing at all.
Two rules make it dependable:
- The path starts the line. A finding is never indented and never prefixed.
Leading whitespace alone puts it outside what a standard error matcher reads,
which is what the old
<file>:<line>: β¦form did. - A field is dropped, never guessed. A linter reports the position it can
establish honestly and no more β
file:line: β¦where the column is meaningless,file: β¦where only the file is known. Nothing defaults to1:1, which would send a reader to the top of the file every time.
Where a finding is about a literal the linter matched β a wikilink, a
hardcoded caption, a TODO marker, a lang key β its position is recovered with
locateInText, which also takes an occurrence number, so two identical findings
in one file are reported at their own columns rather than both at the first.
Where a finding is a property of the whole file β a stale generated page, a
missing barrel, an unreachable document β the file alone is the honest locator
and no position is invented.
Summary counts and the explanatory paragraph each linter prints after its findings are not findings, and keep their prose form.
What the two linters check
Prettier formats ~96% of the hand-written text in this repository, and formatting
is all it does. lint:styles and lint:markdown (#1622) exist for the checks it
structurally cannot make β and each is scoped to exactly that, because both
tools ship defaults that would otherwise re-format the tree to a second opinion.
stylelint.config.mjs carries the per-rule rationale for the first; the
markdown rules are shared, and their rationale lives with them in
@heroiclands/package-build (engine/prose-config.mjs), because every content
repository in the project is authored against the same set (#20). The rule of
thumb for adding a rule to either is whether it can report that something is
wrong, not that it is spelled differently.
lint:styles (stylelint + stylelint-config-standard-scss). The gate that
matters most is naming, because the stylesheets carry a public extension surface:
CSS Architecture tells module authors to build
against the --sohl-* custom properties, and Β§3 fixes SoHL’s class names to BEM.
Neither had a guard before, so a rename was an API break nobody could see.
selector-class-patternβ BEMblock__element--modifier(Β§3). The pattern also admits the Foundry-owned classes SoHL selects on, which are plain kebab-case blocks. Third-party names that are not (.ProseMirror) are disabled at the one site, with the reason, rather than widened into the pattern.custom-property-patternβ lowercase kebab-case everywhere, tightened to the--sohl-*namespace insidescss/abstracts/, where Β§4’semit-tokensmixin declares the tokens. It is deliberately looser outside that folder because the rule inspectsvar()references as well as declarations, and SoHL legitimately reads Foundry-core properties and its own template-set layout hooks (--ledger-cols,--hp-fill).- Everything else
stylelint-config-standard-scssbrings β invalid and duplicate declarations, unknown properties and units, dead selectors β stays on.
Its limit, worth knowing before trusting it: the --sohl-* tokens are
generated by interpolation (--sohl-color-#{$name}), which stylelint skips as
non-standard syntax. Renaming a key in the $color map renames a public token and
no linter here will say so.
Rules that only rewrite a value into an equivalent spelling are off β
blank-line placement, rgb() notation, currentcolor casing, longhand-vs-shorthand.
Satisfying them would mean reflowing 52 hand-written partials for byte-identical
compiled output, which is the cosmetic refactor
System Development forbids.
lint:markdown (content-build markdown). Enabled on markdownlint’s
defaults over 1,600 files it reports ~74,000 findings, essentially all of them
line length, list indentation, and blank lines β Prettier’s territory. So
default is off and nine rules are named individually:
| Rule | Catches |
|---|---|
MD001 | A skipped heading level, which breaks every outline derived from the page |
MD024 | Two identical sibling headings β two identical anchors, one reachable |
MD056 | A table row whose cell count does not match its header |
MD011 | Reversed link syntax, (text)[url], which renders as plain text |
MD034 | A bare URL, which only some of our three renderers auto-link |
MD039/MD042 | Spaces inside link text; an empty link target |
MD052/MD053 | An undefined reference link; an orphaned reference definition |
MD024 runs with siblings_only β repeating ## Notes under several parents is a
normal reference-page shape, and only a repeat within one parent is ambiguous.
gitignore: true keeps the generated trees out (kb/content/, kb/public/,
build/, nogit/); CHANGELOG.md is excluded by the shared configuration,
since changeset version regenerates it in every repository here, and the theme
submodule by .markdownlint-cli2.mjs β which is all that file holds. It spreads
the shared configuration and adds kb/themes, because a submodule that lints
itself in its own repository is knowledge about this repository’s layout and
nothing shared can know it. The same split applies to Prettier:
.prettierignore stays here, the rules do not.
Note what is not here: MD018 (#Heading with no space) reads a line starting
#1405) β¦ as a malformed heading, and this repository writes bare issue numbers
constantly. MD051 (link fragments resolve) is already covered, and covered
better β across files rather than within one β by lint:doc-links and
lint:content-links.
Docs
| Script | What it does |
|---|---|
docs | Full doc build: docs:prepare β docs:html β docs:md. |
docs:prepare | docs:catalog (generate the type catalog) + docs:expr-scopes (generate the expression-scope table). |
docs:expr-scopes | Regenerate the bound-variables table in Expressions and Scripts from the scope catalog. |
docs:html / docs:md | TypeDoc HTML / Markdown output. |
docs:coverage | Report doc-comment coverage. |
docs:serve / docs:watch | Serve build/docs-html / rebuild-and-serve on change. |
Deploy and release
| Script | What it does |
|---|---|
push:dev / push:qa / push:prod | π§ copy build/stage/ to the matching FOUNDRYVTT_*_DATA instance. |
deploy:dev / deploy:qa / deploy:prod | π§ build then the matching push:*. |
deploy:release | build then build:pack-release β produce the release zip locally. |
changeset | Create a changeset (interactive). See Writing Changesets. |
changeset:version | Apply pending changesets: bump the version and update CHANGELOG.md (normally run by CI). |
changeset:check | changeset status --since=main β fail if the branch changed something but added no changeset. |
build:sohl-types | Regenerate packages/sohl-types/index.d.ts from the SoHL source (run by that package’s prepack). |
check:sohl-types | build:sohl-types, then type-check it as a consumer would and validate the bundle. Gated in CI. |
3. The build pipeline
npm run build runs npm ci then build:noci, which is:
lint:formatβ Prettier reports no drift anywhere in the repo.lint:docs-indexβ everydocs/page is linked from its section nav and the README.lintalso runslint:markdownandlint:styleshere; see What the two linters check.build:typesβtsctype-checks the whole project.lint:dtsβ the generated public type surface is valid.check:sohl-typesβ@heroiclands/sohl-typesregenerates, type-checks from a consumer’s position, and passesutils/check-sohl-types.mjs(see The npm workspace package).build:prepare(parallel):build:cssβ Sass βbuild/stage/css/sohl.css.build:dbβ copy assets, then compile packs tobuild/stage/packs/.build:systemβ writebuild/stage/system.json.
test:coverageandtest:purityβ the suite must pass.build:codeβ Vite bundlessrc/sohl.tsβbuild/stage/sohl.js(single ES module, sourcemap, unminified, withemptyOutDir: falseso it doesn’t wipe the staged CSS/assets/packs).lint:bundle-globalsβ the manifest loads the bundle the way it was built.
The result is a complete, deployable system in build/stage/.
The bundle is an ES module β the manifest must say so
sohl.js is built as an ES module, so system.json lists it under
"esmodules". That is not a stylistic choice, and the two must never drift
apart: listing it under "scripts" makes Foundry load the same file as a
classic script, which changes where its top-level declarations live.
- In a module, every top-level
const/let/classis module-scoped β private to the bundle. - In a classic script, those declarations become global lexical bindings. One
whose name matches a non-configurable property of
windowthrowsSyntaxError: Identifier 'x' has already been declaredat parse time, before any of the system runs β so the whole system fails to load.
Bundled dependencies really do declare such names: @codemirror/view (inlined for
the SafeExpression editor) declares const chrome, and style-mod declares
const top. window.chrome is configurable: false and window.top is
[Unforgeable], so under "scripts" either one is fatal. The release build is
deliberately unminified, so these identifiers survive verbatim β Foundry’s own
CodeMirror build escapes the problem only because minification renames them.
npm run lint:bundle-globals (package-build bundle check, part of
build:noci) enforces the agreement: it parses the built bundle exactly as a
browser would and fails if sohl.js is served as a classic script while declaring
anything at global scope.
4. The build/ directory layout
build/
βββ stage/ THE DEPLOYABLE SYSTEM β what Foundry loads
β βββ sohl.js(.map) bundled system code (Vite)
β βββ system.json generated manifest (version, packs, compatibility, URLs)
β βββ css/sohl.css compiled styles
β βββ templates/ Handlebars templates (copied)
β βββ lang/ localization (copied)
β βββ assets/ icons, fonts, audio, ui, silhouette (copied)
β βββ packs/ compiled LevelDB compendium packs
β βββ docs/ generated HTML API docs (after `npm run docs`)
βββ dist/ release files β uploaded to the GitHub Release
β βββ system.zip the released system archive (a zip of build/stage/)
β βββ system.json the released manifest
βββ docs/ the Markdown documentation tree (from docs:md)
βββ docs-html/ the generated API documentation (from docs:html)
βββ site/ THE DEPLOYABLE WEBSITE β what Cloudflare Pages serves
β βββ _redirects sends the deployment's own root to /sohl/
β βββ _headers noindex on the host-assigned *.pages.dev addresses
β βββ 404.html a real 404 for a path outside /sohl/
β βββ sohl/ everything published at www.heroiclands.org/sohl/
β βββ index.html the package landing page
β βββ 404.html the 404 for every address under /sohl/
β βββ kb/ the knowledgebase (Hugo)
β βββ api/ the API documentation (TypeDoc, mounted here)
βββ tmp/ scratch (e.g. unpacked packs)
build/stage/ is the system directory. Its contents are exactly what an
installed Data/systems/sohl/ looks like β Foundry would load it as-is. Everything
downstream derives from it: the push scripts copy it verbatim into a Foundry data
directory, and build:pack-release simply zips its contents into the release
system.zip. There is no separate transform step β the staged directory is the
system, and system.zip is just an archive of it.
How system.json is assembled (package-build manifest): everything it
declares comes from package-build.config.yaml. The packageBuild.manifest
block is emitted unchanged; id, version, the four release addresses,
compatibility and packs are derived and may not be declared there; and
packageBuild.manifestFlags names utils/manifest-flags.mjs, which computes
the sohl flag namespace β the credits journal’s @UUID only exists once the
content tree has been walked.
There is no template. assets/templates/system.template.json was retired
(package-build#9): it was the last build input still hand-authored as JSON, and
it declared the pack list, the package id and the Foundry range a second time,
with nothing checking that the two agreed.
5. Compendium packs from in-repo Markdown
SoHL ships three compendium packs β items, journals, actors β declared
in the system manifest. Each has a committed JSON source tree at
assets/packs/<pack>/_source/, which build:compiledb compiles into Foundry’s
LevelDB format under build/stage/packs/<pack>/.
Design decision β Markdown in the repository, build-only JSON
The compendium content is authored in this repository, under assets/content/.
The build compiles that tree directly: build:compiledb generates each pack’s
per-entry JSON into a disposable build/packs-json/<pack>/ intermediate and
compiles the LevelDB packs from it, so the JSON is never committed.
assets/content/ was formerly a generated mirror of the HeroicLands vault, and an
edit made here was reverted by the next export without a word. That is no longer
true (#1445). The tree is this repository’s source, it is edited here, and the
export β utils/export-vault-content.mjs, utils/vault-export.mjs, and the
content:export / content:check scripts β has been removed.
Building needs nothing but this repository: npm run build, or
npm run build:compiledb for packs only.
Two guards remain, because compiling nothing is still the dangerous case β it
would ship blank compendiums with nothing in the log to say so. The pack build
and lint:addresses fail on an empty content tree; the pack build also fails on
empty output, when a pass compiles zero entries from a tree that is not
empty. That second case is what a wrong package id looks like: every note is
rejected because it declares a package this build does not compile. A pack that
genuinely ships nothing in some consuming package declares mayBeEmpty: true on
its entry in package-build.config.yaml, rather than the guard being relaxed for
everyone.
Cross-package references are resolved through published link manifests rather
than a shared tree; see @heroiclands/package-build/engine/kb-manifest and assets/manifests/.
Authoring content notes
Items, actors, and journal entries are Markdown files with YAML frontmatter
(a type:, a stable id:, and folder/embedding metadata; the content package is
not authored per note β it is the contentPackage declared once in
package-build.config.yaml, sohl here),
authored in the vault and exported anywhere under assets/content/.
Classification is frontmatter-driven, not directory-driven: a file joins a
pack because of its type (item kinds β
the items pack and, for its prose, the journals pack; type: doc β journals;
being β actors), so the
folder layout is for human organization only and can be reorganized freely. Folder
hierarchies are declared per pack in assets/content/<pack>-folders.yaml and
referenced from entries via sohl.folder: <id>.
Choosing a pack: the optional pack: field
A repository may ship more than one pack of a document type β two Item packs
grouping items editorially, say. Where it does, a note names the one it belongs
in with a top-level pack: field:
---
name:
full: Second Sight
type: skill
package: sohl
id: BBBBBBBBBBBBBBBB
pack: mysteries # optional; one of the configured Item packs
---
type: and pack: answer different questions and are not interchangeable:
type: decides what the note compiles into (and therefore which compiler
runs), pack: decides which pack of that type receives it.
- The field is optional, and omitting it is the normal case. A note that declares nothing lands in the default pack of its type. A type with exactly one pack β which is every type in this repository β is its own default, so no SoHL note declares a pack and none needs to.
- A
pack:naming no configured pack fails the build, naming the file and what it asked for. It does not quietly fall back to the default: a typo that landed content in the wrong compendium would be exactly the silent partial compilation the pack build’s other guards exist to catch. - The declaration is about the note’s own document. An item’s prose still
compiles into a JournalEntry in the journals pack;
pack:says where the item goes. - Where a type has several packs and none is marked
default: true, the field is mandatory for every note of that type.
(Why a default exists at all: every note in every consuming repository predates this field, so silence has to keep meaning “the one pack of my type”.)
# assets/content/ Markdown β build/packs-json/ (JSON) β build/stage/packs/ (LevelDB)
npm run build:compiledb
The rules never describe the VTT. Content under assets/content/Rules/ is the
specification the Foundry system implements, and must read as though no VTT
exists β no clicks, buttons, dialogs, chat log, or “the system”. A rule that says
“click Accept on the card” describes an interface, and goes silently wrong the
moment the interface changes. Automation behaviour β the consent/offer flow, what
is prompted and when β is worth documenting, but its home is
assets/content/User_Guide/, which is exempt from the rule. npm run lint:rules-vtt (part of npm run lint) enforces this over the rules tree.
The rules are a book, and its links have to land. Two link defects survive
both content builds silently, so npm run lint:content-links (also part of
npm run lint) checks for them:
- A
#anchorlink that no heading declares. The journal compiler derives a Foundry page id by hashing"<noteId>-<anchorSlug>", so a link to an anchor nothing declares compiles cleanly, emits a@UUIDenricher, and dead-ends for the reader. Declare{#the-anchor}on the heading the link means, or point the link at one that exists. - A rules document unreachable from
Rules/_Introduction.md. An unlinked note still compiles and still publishes; it is simply impossible to arrive at by reading. Link each one from the chapter that owns it. The walk resolves links exactly as the builds do βtype/shortcodefirst, then a type-scoped alias β and expands fenceddataviewtables first, so a generated row link counts. It stops at the glossary rather than walking through it: an index links to nearly everything, and following it would make the check vacuous.
build:compiledb runs the pack CLI, the content-build binary the installed
package puts on the path. The CLI owns every side effect β argv parsing, loglevel configuration, creating
build/tmp/packs/, and the process exit code β and calls the import-safe library
@heroiclands/package-build/engine/compendiums, whose compilePacks / unpackPacks / cleanPacks
take every path and pack list as an argument. That split is what lets another
repository’s build import the compiler without inheriting a build/ tree or a
reconfigured logger.
compilePacks in turn runs @heroiclands/package-build/engine/generate, which
drives one compiler per configured pack (sohl/items.mjs, sohl/actors.mjs,
engine/journals.mjs, engine/macros.mjs, engine/scenes.mjs): each walks the
content tree, selects files by frontmatter, validates folders against the pack’s
*-folders.yaml, and writes per-entry JSON β from which the LevelDB is then
compiled.
Adding a pack compiler: BasePackCompiler
That walk is written once, in @heroiclands/package-build/engine/base-compiler. Walking the
tree, rejecting notes of another content package, skipping drafts, expanding
generated tables, converting wikilinks, writing the JSON and counting what
failed are identical in every pass, so BasePackCompiler owns them and each
pass subclasses it (#1509). A pass states only what makes it that pass:
| Hook | What it decides |
|---|---|
selects(fm) | Which notes this pack claims. Required. |
buildEntry(fm, markdown) | One note β one document. Required. |
prepare() | Anything the walk needs first β an index, a prior pack’s output. |
skipNote(fm, body) | A further rejection the type filter cannot express. |
compileNote(fm, markdown) | A note that emits more than its own document. |
onCompiled(fm, doc) | Per-note tallies for the summary. |
finish(stats) | Work that needs every note compiled first. |
reportCompiled / reportDetail | The pass’s own log lines. |
selects answers which document type a pass claims, and every pack of that
type gives the same answer. Which pack of that type a claimed note lands in
is a second question, answered by the pack router from the note’s own pack:
declaration β so a subclass never has to know that its type ships in more than
one pack (see Several packs of one document
type).
Two static switches complete it: requiresId (a claimed note with no id is
fatal, or merely skipped β the journals pass is the only one that tolerates it)
and convertsWikilinks (whether the body reaching buildEntry is converted or
exactly as authored β the macros pass needs the latter, because its command is
executable source).
This is the extension point. The pack list is data and each entry names a
Foundry document type; a consumer needing a document type the toolchain does not
ship writes a subclass and registers it in generate.mjs’s COMPILERS map,
rather than copying a pass and editing it. The contract the generator relies on
stays small β construct with {contentBase, dest, companionDests, folderResolver, packName, docType, router}, await compile(), read errorCount
and compiledCount.
@heroiclands/package-build/engine/map-notes is deliberately not a subclass: it never walks
the tree. It is the pure markdownβScene translator the scenes pass calls, and
keeping it framework-free is what makes it unit-testable.
The pack pipeline is configured, not hard-coded
Everything about the pipeline that is this repository’s rather than any
consumer’s lives in one file at the repository root,
package-build.config.yaml. Nothing inside the shared package spells a
path, a package name, or a pack list of its own; each module reads the resolved
configuration through @heroiclands/package-build/engine/pack-config, which
locates the config file by walking up from itself β so it lands on the consuming
repository’s root from node_modules/ (#1508). A consuming repository β
sohl-thalorna, sohl-kethira-basic, an adventure module β ships the same
toolchain with its own copy of that file and nothing else.
The configuration is data, and it is deliberately not code. Three values a code config used to compute are derived by the loader instead, so no repository reproduces them:
| Field | Derived from |
|---|---|
rootDir | the directory the config file sits in β authoring it throws |
stats.systemVersion | version in the adjacent package.json, unless stated |
itemBuilders | a name (itemBuilders: sohl), resolved to the shipped registry |
A package-build.config.mjs still loads, for a consumer whose item-builder
registry is its own code β data cannot carry functions. Both forms end at the
same defineConfig, so both are validated and frozen identically. A directory
holding both is an error rather than a precedence question: picking one
would let a repository mid-conversion build from the file nobody is editing.
Several packs of one document type
The pack list may hold more than one entry of the same type. That is not a
stylistic nicety: a compendium UUID carries its pack name
(Compendium.<package>.<pack>.Item.<id>), so a module that ships three Item
packs and later collapses them into one invalidates every reference an existing
world holds. Foundry modules routinely split same-type documents editorially, and
the pipeline has to be able to express that.
packs:
- { name: characteristics, type: Item, default: true }
{ name: "mysteries", type: "Item" },
{ name: "journals", type: "JournalEntry" },
],
typeselects the compiler; the note’spack:selects which pack of that type receives its document. Getting those two confused is the easiest mistake to make here β they are orthogonal, and both are needed.default: truedesignates the pack of its type that receives notes declaring none. At most one per type, enforced bydefineConfig. A type with exactly one pack is its default implicitly, which is what keeps every existing one-pack-per-type configuration β including this repository’s β valid and behaving identically.- Where a type has several packs and none is marked default, every note of that type must declare one; an undeclared note fails the build rather than guessing.
- A companion pack may not be marked default and may not be named by a note: it is written by its parent’s pass, and that indirection is the only one the build has.
The routing itself is @heroiclands/package-build/engine/pack-router, one pure
function over the configured pack list. BasePackCompiler consults it for every
note its selects claims, so a pass never has to know that its document type
ships in more than one pack. Every pack of a type sees the same notes, so the
first configured pack of each type owns the error message for a note of that
type that routes nowhere β one error, named once, rather than one per pack.
Two consequences worth naming, because they are where multi-pack support actually bites:
- Links. Each note’s pack is resolved once when the content-wide link index
is built, and the resolved name is what every emitted
@UUIDcarries β so a wikilink into the second Item pack addresses that pack, not the first. - Embedded items. The actors pass resolves each being’s predefined items
against every Item pack’s generated JSON, read as one
(type, shortcode)address space. Two Item packs claiming one address is ambiguous rather than an ordering detail, and fails the build.
What it declares:
| Key | What it settles |
|---|---|
rootDir | The repository the paths below are resolved against. Absolute (import.meta.dirname), so the build reads the same files whatever directory it was launched from. |
contentPackage / foundryPackage / packageKind | Which notes are compiled, which Foundry package ships them, and whether that package is a systems/ or a modules/ install. |
stats | The identity stamped into every compiled document’s _stats β systemId, systemVersion (read from package.json, not transcribed), lastModifiedBy. |
skipDirectories | Directory names the content walk ignores (Templates/, Obsidian’s templater scaffolding β a convention of this vault, not of a content tree). |
paths | The content root, the manifest-template directory, the vendored link manifests, and the three build outputs. Each defaults to the conventional layout and is relative to rootDir. |
packs | The one pack list: name, Foundry document type, folder-hierarchy file, companions, mayBeEmpty. |
Two properties of that shape are load-bearing:
- One pack list. The directories compiled to LevelDB are derived from the
pack list as
packDirectories(each pack, then its companions), so the compile order and the compiler list cannot drift apart β they used to be two separately-maintained arrays that had to agree. - Configuration is the source, and the manifest is generated from it. That
arrow used to point the other way:
paths.packageManifestsaid wheresystem.template.jsonlived, and both the package-id drift guard and the compiled packs’_stats.coreVersionread out of it. Correct while the manifest was hand-authored β a copy in config would have stopped following a floor that moves with test evidence, which is the shape defect #1533 had.package-build manifestgenerates the manifest now, so there is nothing left to follow: the Foundry range is declared here as top-levelcompatibility, the guard is deleted (a single source needs no corroboration), andpaths.packageManifestis gone.stats.systemVersionobeys the same rule from the other side: it is a configured value, but the config reads it frompackage.jsonβ the file Changesets bumps andbuild:systemstamps into the manifest β rather than transcribing it. Transcribed, it froze at0.6.0for four releases, leaving every shipped document eligible for migrations it did not need (#1548). It stays per-repository rather than moving onto the toolchain because a module repository shipping SoHL content declares the version of the system it is content for, not its own package version.
The assets/ root a content note’s img: resolves to is derived, not written:
<packageKind>/<foundryPackage>/assets, so the same note yields
systems/sohl/assets/β¦ here and modules/<id>/assets/β¦ in a module.
Adding or removing an item type
Which type: values compile into an Item is declared once, in the registry
@heroiclands/package-build/sohl/item-builders: ITEM_BUILDERS pairs each type with the builder
that produces its system block. This repository names that registry β
itemBuilders: sohl in package-build.config.yaml, since data cannot carry
functions β the loader resolves the name to the table, and
@heroiclands/package-build/engine/item-registry reads it back out of the
resolved configuration β both the whitelist itemTypes() (its key set, which
@heroiclands/package-build/engine/item-docs re-exports and assembles
docEntryTypes() from) and the itemBuilder(type) lookup the Item compiler
dispatches through. So the whitelist of compilable types and the table of
builders are the same object and cannot drift; a type with no builder is not a
type the compiler will accept a note for.
Because both halves are read from configuration, a consuming repository supplies its own table and its notes compile with its own builders β the compiler dispatched through this package’s module-level table until #1563, so a consumer got the types it asked for and the builders it did not.
Adding a type is therefore one entry in ITEM_BUILDERS, its subtype declaration
in documentTypes.Item (packageBuild.manifest in
package-build.config.yaml), and its
default artwork in @heroiclands/package-build/sohl/default-item-art β the last
of which the unit
suite holds in exact step with the registry. Removing a type is the same three
deletions. The registry is a leaf module: it imports only the frontmatter
readers in @heroiclands/package-build/engine/frontmatter, never helpers.mjs,
and never the resolved configuration. The config file imports the registry, so a
read back out of configuration there would close a cycle around the config’s own
evaluation; the data travels into configuration and only item-registry.mjs,
which no config file imports, reads it back out.
An item’s prose compiles to a journal, not into the item
An item note’s body does not become the item’s description. It compiles into
that item’s item doc β a JournalEntry in the journals pack, in the same
folder and under the same name as the item β and system.docHtml becomes
nothing but a @UUID link to that entry’s first page. The runtime recognises a
description that is only a link as a pointer and shows what it points at β
descriptionLinkTarget() decides, and Display Description follows. See
@heroiclands/package-build/engine/item-docs. A reader of the chat card sees the prose, not a
link.
The prose therefore exists once. It used to exist once per item and again on every actor holding that item β 7.59 MB across the actors pack, of which 133 KB was distinct text, so a typo fixed in an item description left 57 stale copies on a single character. Nothing about the actors pass changed: it still embeds the item wholesale, and what it embeds is now a link.
Two passes have to agree on the link without either seeing the other’s output,
which they do by deriving both ids from the item note’s own id β the technique
anchorPageId() already uses to let a section link and its page agree:
| Items pass | Journals pass | |
|---|---|---|
| Entry id | itemDocEntryId(fm.id) | same |
| First page id | journalPageId(entryId, page, 0) | same, for every page |
| Splits the body | to name page 0 | into the pages themselves |
Both split the converted markdown, so an H1 carrying a wikilink names the same page on both sides. An item note with an empty body gets no entry and an empty description, rather than a pointer to nothing.
Linking to an item’s documentation: the doc<type> qualifier
Authoring a link rather than changing the build? Read Linking Between Content Notes instead β this section is the mechanism behind it.
Because the item and its prose are now two documents in two packs, they need two addresses:
| Wikilink | Addresses |
|---|---|
[[skill/wpnc]] | the Skill Item, in the items pack |
[[docskill/wpnc]] | that skill’s JournalEntry, in the journals pack |
[[docskill/wpnc#crafting]] | the {#crafting} page of that entry |
Every item type has a virtual doc<type> counterpart. It is formed by prefix
and never enumerated, so a type added tomorrow is addressable the day it is
authored β the same rule that keeps packForType() free of a hand-maintained
list. A real content type of the same name always wins; the virtual reading is
consulted only for a qualifier no authored note claims.
An anchor on an Item, an Actor or a Macro is a no-op and is dropped. It is
worth being clear why: a @UUID to a JournalEntry opens the journal β at its
first page, or at the page an anchor names β whereas a @UUID to an Item or an
Actor opens that document’s sheet, not its documentation. A sheet has no
sections, so there is nothing for an anchor to address. Reaching a page of an
item’s documentation is exactly what doc<type> is for; before it existed, such
a link compiled to a JournalEntryPage id under the items pack and dead-ended
(#1362).
The knowledgebase reads the same link differently, by design. There an item
note renders as one page which is its documentation, so doc<type> and
<type> are aliases for the same URL and the anchor stays an ordinary in-page
anchor. One authored link, correct in both builds.
6. Deploying to a Foundry instance
The push scripts copy the staged system into a Foundry data directory:
npm run deploy:qa # build, then push:qa (build + copy in one step)
npm run push:qa # copy build/stage/ only (no rebuild)
The Foundry paths that drive deployment live in .env.local (copy it from
.env.local.example). Use absolute paths only β $HOME and ~ are not
expanded. A *_DATA value may be either:
- a local path (e.g.
/Users/me/fvtt/data) β deployed with an intrinsic Node file copy, or - a remote SFTP target (
[user@]host:/path) β deployed over SFTP viassh2-sftp-client, a pure-JS SSH client (nosshbinary required). By default the running SSH agent is used β$SSH_AUTH_SOCKon macOS/Linux, or the OpenSSH named pipe on Windows automatically β so ifssh hostalready works no extra config is needed. No password or passphrase is read from.env.local(for an encrypted key, load it into the agent withssh-add). Per-stage overrides exist for username, port, agent endpoint (..._AGENT=pageantfor PuTTY), and a key-file path that skips the agent entirely β see the comments in.env.local.example.
Either way the push is a full mirror: the destination
Data/systems/sohl/ is cleared and rewritten so it ends up an exact copy of
build/stage/ (stale files are removed). SFTP has no delta transfer, so a
remote push re-uploads the whole staged build each time.
| Variable | Used for |
|---|---|
FOUNDRYVTT_DEV_DATA | The Foundry user-data root for the dev environment. npm run push:dev deploys build/stage/ into <root>/Data/systems/sohl/ β the Data/systems/sohl/ suffix is appended automatically. |
FOUNDRYVTT_QA_DATA | The user-data root for QA, used by npm run push:qa. |
FOUNDRYVTT_PROD_DATA | The user-data root for production, used by npm run push:prod. |
FOUNDRYVTT_DEV / FOUNDRYVTT_QA / FOUNDRYVTT_PROD | The Foundry application install path for each environment (recorded for convenience; the deploy step uses the *_DATA roots above). |
Point each *_DATA variable at the Foundry user-data directory (the one that
contains Data/), not at systems/ β the deploy appends the rest.
π§ Manual steps around a deploy:
- Stop (or at least be ready to reload) Foundry β a running server can hold file locks and won’t pick up code changes until reloaded.
- After the deploy completes, reload/restart Foundry to load the new system.
- The first time you use it in a world, select SoHL as the world’s game system.
Running a build in a container
To smoke-test a build in a real Foundry instance without maintaining a
hand-run server, container:<stage> runs Foundry in Docker against the same
FOUNDRYVTT_<STAGE>_DATA root the push scripts deploy into:
npm run build && npm run push:dev && npm run container:dev start # bring it up
npm run container:dev stop # tear it down
The commands (package-build container <stage> <command>):
| Command | Effect |
|---|---|
start | Create (or restart) sohl-foundry-<stage> and serve /data. Sweeps a stale lock first when the container is down. |
stop | Stop the container (state is kept for a fast start). |
restart | Stop, sweep a stale lock, start. Deliberately not docker restart, which leaves no window in which to sweep. |
recreate | Remove and re-create the container so changed FOUNDRY_*/CONTAINER_* env applies. |
rm | Stop and remove the container. |
status | Show the container’s docker ps -a row. |
logs | Follow the container log (watch first-run install / boot here). |
pull | Pull the latest image. |
The data-root lock. Foundry takes Config/options.json.lock while it runs
and releases it on a clean shutdown. A container that dies holding it (docker kill, a crash, an OOM) strands the lock, and every later boot then fails with
“this directory is already locked by another process” β an error naming no
owner, so it reads like corruption rather than litter. Every command here that
boots Foundry (start, restart, recreate) sweeps the lock first, which is
safe precisely because each does so while the container is stopped: with nothing
running against the data root, a lock present is by definition stale. You should
never need to delete it by hand.
The data root is bind-mounted at /data, so the system pushed to
<root>/Data/systems/sohl/ is served directly β the value must be a local
path (a remote SFTP target can’t be mounted and is rejected). Foundry itself
runs from the community felddy/foundryvtt
image, which downloads the correct build inside the container (a local Foundry
install can’t be reused across platforms β its bundled native modules are
platform-specific).
Configuration lives in .env.local (all optional):
| Variable | Default | Purpose |
|---|---|---|
FOUNDRYVTT_CONTAINER_IMAGE | felddy/foundryvtt:14 | Image tag to run (the major is derived from compatibility.minimum). |
FOUNDRYVTT_<STAGE>_VERSION | test β 14.359 | Exact build, passed to felddy as FOUNDRY_VERSION (see below). |
FOUNDRYVTT_<STAGE>_PORT | 30000 / 30001 / 30002 | Published host port (distinct per stage so all three can coexist). |
FOUNDRYVTT_CACHE | β | Host dir with a pre-downloaded Foundry zip (see cache note below). |
FOUNDRY_* / CONTAINER_* | β | Passed through to the image (licensing, cache, tuning β see below). |
π§ The test stage’s Foundry build is compatibility.minimum itself β read
from the top level of package-build.config.yaml and passed to felddy as
FOUNDRY_VERSION, so it downloads that exact build rather than the newest of the
:14 tag. That is what makes the e2e suite reproducible: without it the test
container drifts to whatever the floating tag serves, and “the suite passes”
names no particular Foundry. No other stage is pinned β dev/qa/prod are
the maintainer’s own instances.
Deriving the pin from the claim means there is one number, not two that can
drift: compatibility.minimum is the oldest Foundry the system claims to
support, and therefore the claim the suite exists to defend. Testing above the
floor would leave the promised configuration unverified, so the newest release is
covered by a periodic sweep rather than by the default:
npm run e2e:sweep -- 14.367 # full suite against the newest release
Run it roughly weekly and before shipping; it takes the build as an argument and
has no default, so it cannot rot into a second pinned version. A green sweep is
what licenses moving compatibility.verified β which names the newest build the
full suite has actually passed, never an aspiration.
FOUNDRYVTT_<STAGE>_VERSION in .env.local overrides the committed default for
any run. Raising the committed pin, by contrast, is a decision to raise the
supported floor: move the top-level compatibility.minimum in
package-build.config.yaml with it. See
Testing β Which build the suite runs on.
π§ First-run licensing. felddy needs to fetch Foundry once. Supply your
Foundry credentials (FOUNDRY_USERNAME / FOUNDRY_PASSWORD [+ FOUNDRY_LICENSE_KEY]),
a timed FOUNDRY_RELEASE_URL, or a pre-seeded cache (below) β whichever you
prefer, in .env.local. The download is cached, so subsequent starts are fast
and need no credentials. Docker must be installed and on PATH.
Env is baked in at create time. FOUNDRY_*/CONTAINER_* values are fixed
when the container is first created (docker run); start/restart do not
pick up changes to them. After editing one β e.g. FOUNDRY_WORLD=<world-dir> to
auto-launch a specific world (felddy regenerates Config/options.json from the
env on each start, so hand-editing it won’t stick), or credentials β run
npm run container:<stage> recreate to re-create the container with the current
environment.
Download cache. CONTAINER_CACHE in felddy is a path inside the
container (default /data/container_cache). Because the data root is mounted
at /data, that default is <dataRoot>/container_cache/ on your host β so the
no-config option is to drop foundryvtt-<version>.zip (e.g.
foundryvtt-14.364.zip) there. If instead the zip lives in a separate host
directory, set FOUNDRYVTT_CACHE to it: the script bind-mounts that directory
and sets CONTAINER_CACHE to the mount point for you. Do not set a raw
CONTAINER_CACHE to a host path β it names a container path and is not
forwarded (use FOUNDRYVTT_CACHE instead).
7. Cutting a release
A release is cut by merging the auto-generated “Version Packages” PR; the
Version and Release GitHub Actions workflow (.github/workflows/release.yml)
does the build, tag, GitHub Release, and asset upload. The only command you
type by hand is the docs-deploy dispatch at the end.
While developing
Every feat/bug PR carries a changeset (see
Writing Changesets):
npm run changeset # choose the version bump, write the summary
# then commit the generated .changeset/*.md with your PR
These accumulate on main as PRs merge.
To cut the release (maintainer)
Make sure every change you want in the release is merged to
main, each with its changeset.On each push to
main, the workflow opens or updates a PR titledchore(release): version packagesβ it runschangeset versionto bumppackage.jsonand rewriteCHANGELOG.md. Review it (the version and changelog are the release).π§ Merge that PR β in the GitHub UI, or from the CLI (the changesets action opens it from the
changeset-release/mainbranch):gh pr merge changeset-release/main [--admin] --squash --delete-branchMerging is the release β there’s nothing else to run locally. The workflow re-runs on the merge, sees a new untagged version, and automatically:
- runs
npm run buildthennpm run build:pack-release, - creates the
v<version>git tag and a GitHub Release, - attaches
system.zip+system.json(the manifest/download Foundry installs and updates from).
- runs
/sohl/republishes itself. The release job dispatchesdeploy-sohl.yml, which rebuilds the whole subtree β landing page, knowledgebase, and the API documentation from the newest release tag β and deploys it. Nothing to run; see Β§8. If that run fails, repeat it withgh workflow run deploy-sohl.yml(it needs no arguments β the newest release is always what it documents).
That’s the entire release. Two notes:
npm run deploy:releaseonly builds the release zip locally (intobuild/dist/) for inspection β it does not publish anything. Releasing always goes through the merge-the-PR flow above.- A push to
mainwith no pending changesets whose version is already tagged does nothing β ordinary merges never release.
The npm workspace package
packages/ holds one published npm package, hand-versioned in its own
package.json and independent of the system version:
| Package | What it is |
|---|---|
@heroiclands/sohl-types | Type declarations for authoring modules and macros against SoHL in TypeScript. |
@heroiclands/package-build used to live here too. It was extracted to its own
repository (#1589) and publishes from there; this repository now resolves it from
the registry like every other consumer (#1604).
The root package.json declares the workspace with
"workspaces": ["packages/*", "."]
π§ The trailing "." is load-bearing, not a typo. npm does not need it (it
picks the root up regardless), but Changesets discovers packages through the same
workspaces globs, and in workspace mode it excludes the root package. Without
"." every existing changeset fails with “Found changeset β¦ for package sohl
which is not in the workspace” and the release workflow stops dead. Listing the
root as a workspace keeps sohl a package Changesets can version. The one visible
side effect is a node_modules/sohl symlink back to the repository root.
It is published by the release workflow through npm Trusted Publishing
(OIDC β there is no NPM_TOKEN), in a step that is idempotent (it skips a version
already on npm) and continue-on-error (Foundry installs from the Release’s
system.zip, so an npm hiccup must not fail the release). Its prepack
regenerates index.d.ts at pack time.
β οΈ continue-on-error means the publish step cannot be the thing that tells you
the package is broken. It swallowed a failing prepack for a full release cycle,
so @heroiclands/sohl-types quietly stopped being published and nothing went red
(#1613). The generation path is therefore gated by the ordinary build instead β
build:noci runs check:sohl-types β and that is where a regression must surface.
Keep it there; do not rely on the release job to notice.
Publishing a new package needs two one-off maintainer actions that CI cannot
perform: configure a Trusted Publisher for the package name on npmjs.com (pointing
at this repository and .github/workflows/release.yml), and make the very first
publish by hand β npm cannot trust a publisher for a package that does not exist
yet.
At a glance β who does what:
| Step | Manual? | By |
|---|---|---|
| Author changesets | π§ yes | developer |
| Open the Version Packages PR | no (CI) | β |
| Merge the Version Packages PR | π§ yes | maintainer |
| Tag + GitHub Release + assets | no (CI) | β |
| Publish the API docs | no (CI) | β |
| Deploy to a Foundry instance | π§ yes | operator |
8. Publishing the /sohl/ website
Some of what this repository builds is published to the web rather than to a
Foundry instance, and all of it is one site: everything under
www.heroiclands.org/sohl/.
| Address | What | Built by | Built from |
|---|---|---|---|
/sohl/ | The package landing page | Hugo (kb/layouts/index.html) | main |
/sohl/kb/ | The knowledgebase | Hugo (build:kb) | main |
/sohl/api/ | The API documentation | TypeDoc (docs:html) | the newest release tag |
npm run build:site produces the whole thing locally, and
.github/workflows/deploy-sohl.yml produces and deploys it in CI β one build,
one deploy, one hosting project (#1470). A few things about it are worth knowing
before you change any of it.
It republishes on every push to main, and again when a release is
published. The push trigger carries no path filter: a rebuild is cheap next to
how quietly a path list goes stale, and a push that changes nothing the site
serves simply republishes the same bytes. The second trigger exists because the
API half tracks the newest release tag, not main, so a freshly published
release changes the site without any push doing so β release.yml dispatches
this workflow from the one step that knows it actually cut a release. It is
deliberately not wired to that workflow’s completion: release.yml runs on
every push to main and succeeds whether or not it released, so watching it
deployed twice per push (#1484).
Nothing is purged after a deploy, by design. /sohl/ is served through the
routing Worker straight from the Pages project, with Pages’ own
cache-control: public, max-age=0, must-revalidate and no cf-cache-status β
the zone edge holds nothing under /sohl/ to invalidate. The purge_everything
that used to follow each publish therefore evicted only the surfaces this deploy
never touched (www’s own pages, cdn). Should a Cache Rule ever cover
/sohl/, purge those URLs rather than the zone.
The deployment carries the /sohl/ prefix physically. publishDir in
kb/hugo.toml renders into build/site/sohl/, and the directory that is
uploaded is build/site/ β so a page’s /sohl/kb/β¦ link resolves against the
deployment exactly as it will against www. That is what lets the hosting
project be checked at its own *.pages.dev address before any routing points at
it, and it leaves the routing layer (#1468) a path-preserving pass-through with
nothing to rewrite.
The hosting project’s own address is noindex, the canonical path is not.
A Cloudflare Pages project answers at <project>.pages.dev (and at
<deployment>.<project>.pages.dev for every deployment) as well as under
www.heroiclands.org/sohl/. Nothing advertises it, but it serves the same
pages, so build/site/_headers marks those hostnames β and only those β
X-Robots-Tag: noindex (#1469). The rules are host-scoped rather than blanket
so the tree stays correct anywhere it is deployed: under its own domain it is
indexable. The hosting cannot tell the routing layer’s request apart from a
reader’s, since it is the same URL at the same address, so the header reaches
www too and the router (heroiclands-site, worker/) drops it there β the
one place the two addresses are distinguishable. A page that must not be indexed
at any address says so in the document (<meta name="robots">), which is
passed through untouched.
Both surfaces are rebuilt on every run, even though they track different
refs. A Cloudflare Pages deploy replaces the whole tree, so a run that published
only the half it had rebuilt would take the other half offline. The workflow
therefore builds the knowledgebase from main, checks the newest release tag out
into release/ and builds the API documentation there (with that tag’s own
lockfile β the documentation is a pure function of its tag), and
site:assemble mounts the result at build/site/sohl/api/.
Nothing ships half-built. utils/build-site.mjs refuses to finish unless
the landing page, the knowledgebase, the API documentation and the 404.html
are all present β a missing surface would publish a 404 at an address the
navigation already points at, and a missing 404.html would make Cloudflare
Pages answer unmatched paths with a soft-404 (#1416).
And nothing ships pointing at a hostname that no longer resolves. Before it
finishes, the assembler reads every rendered page and fails the build on any
href or src addressing one of the withdrawn hosts in RETIRED_HOSTS
(utils/retired-hosts.mjs). Such a link fails at DNS with no redirect to
follow, so it is a hard dead end, and nothing else in the pipeline notices β an
absolute URL is opaque to the wikilink checks. Prose that merely names a
withdrawn host is not reported; these docs explain the move, and saying so is
not a dead end.
The API documentation gets one step first, because the gate alone could never
clear it. It is rebuilt from the newest release tag, so a tag cut before a
hostname was withdrawn reproduces the dead links on every deploy however clean
main is β which is exactly what /sohl/api/ was doing (#1487). The assembler
therefore repoints those links, taking a replacement only when the page it
names is present in the tree it has just assembled: a repair is verified, never
guessed, because a wrong one would trade a dead end a reader can see for a quiet 404. Candidates come from rewriteCandidates, which knows both that the API
site dropped its version segment and that the developer docs now live under
/dev-docs/. Anything it cannot rescue falls through to the gate and fails the
build. Every repair is printed: for a current tag the count should be zero, so a
non-zero one means new src/ JSDoc β or the chrome plugin β has reintroduced a
retired address, and the fix belongs there.
No layout in this repository names an address. Every asset resolves through
the theme’s cdn-url.html against params.cdnBaseURL, and every internal link
is built from the page’s own .RelPermalink, so moving the package or its
artwork is a config edit rather than a sweep through the templates (#1464).
Worth knowing when you add one: with cdnBaseURL unset the partial falls back
to relURL, so a missing param yields /sohl/images/β¦ β a 404 against this
deploy, not a build failure. kb/hugo.toml declaring the param is the guard,
not the template.
Links inside the generated Markdown carry the prefix from the builder, not
from Hugo. Hugo prefixes what it emits itself (permalinks, assets, aliases),
but the wikilinks and cross-references written into kb/content/ are ordinary
site paths that nothing rewrites afterwards. They are composed from
site.base (defaulting to /<contentPackage>/), publish.address.prefix and
site.passOptions.apiBase in package-build.config.yaml, which is where a
relocation is edited. One consequence worth remembering: a Hugo alias is
publishDir-relative and does not get the baseURL path added, so
An authored Obsidian aliases is stripped on the way into the frontmatter: in Hugo the key means URL redirects, not names.
9. The build utility scripts
The build/deploy/doc tooling lives in utils/; the pack pipeline is the
shared package @heroiclands/package-build (#1512), developed in
its own repository and consumed
here as a devDependency from the registry β the same way every other consumer
resolves it (#1589). Each script carries a header comment describing its purpose
and how to invoke it β read the file itself for the authoritative detail. In brief:
| Script | Purpose |
|---|---|
manifest-flags.mjs | The flags(config) hook package-build manifest calls: the credits journal’s @UUID, which only exists once the content tree is walked. |
svg-theme.mjs | The transform hook package-build assets calls: recolor each staged SVG so icons follow the Foundry theme. |
build-icon-font.mjs | Build the icon font from SVGs. |
build-type-catalog.mjs | Generate docs/reference/type-catalog.md from the kind enums. |
docs-coverage.mjs | Report doc-comment coverage. |
clean.mjs | Remove build output (--distclean for a deeper clean). |
build-site.mjs | Assemble the deployable /sohl/ tree: mount the API docs, refuse a partial build, and refuse a link to a retired hostname. |
retired-hosts.mjs | The withdrawn hostnames and what replaced each β shared by the content-link check and the deploy gate. |
release.mjs | Legacy local release path; authenticate with gh auth login (CI normally cuts releases). |
packs/compendiums.mjs | Library: compilePacks / unpackPacks / cleanPacks over the Foundry CLI. No import-time side effects. |
packs/bin/build-compendiums.mjs | The pack CLI: argv, logging, directory creation, and exit codes for the library above. |
packs/export.mjs | Vault β _source/ export orchestrator. |
packs/{items,journals,actors}.mjs | Per-pack vault compilers. |
packs/helpers.mjs | Shared pack helpers (frontmatter, _key, folders). |
packs/clean-sources.mjs | Remove generated _source/ trees. |
typedoc-plugin-*.mjs | TypeDoc plugins (source categories, nested nav, Foundry links, data-field schema). |
See also
- Getting Started β the codebase tour for a new developer.
- System Development β the rules of contributing and the PR workflow.
- Writing Changesets β recording a change for the changelog and release.
- Testing β the test tooling and patterns.