Build, Deployment, and Release

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

ScriptWhat it does
buildFull production build: npm ci then build:noci. The canonical “build it all” entry.
build:localSame as build but npm i (allows lockfile updates) instead of npm ci.
build:nociThe pipeline without install: lint:format β†’ lint:docs-index β†’ build:types β†’ lint:dts β†’ build:prepare β†’ test:coverage β†’ test:purity β†’ build:code.
build:prepareIn parallel: build:css, build:db, build:system.
build:typesTypeScript type-check / compile (tsc -p tsconfig.json). No emit beyond .d.ts/checking.
build:cssCompile scss/sohl.scss β†’ build/stage/css/sohl.css (Sass).
build:systemGenerate build/stage/system.json from package-build.config.yaml (package-build manifest).
build:assetsCopy templates/, lang/, assets/*, LICENSE.md, README.md into build/stage/ (package-build assets).
build:dbbuild:assets then build:compiledb β€” stage assets, then compile packs.
build:compiledbGenerate JSON from assets/content/ Markdown, then compile LevelDB packs in build/stage/packs/.
build:unpackdbThe reverse: unpack the staged LevelDB packs back to JSON (for inspection).
build:codeBundle the system with Vite (vite build --mode release) β†’ build/stage/sohl.js.
build:iconsRebuild the icon font from SVGs (utils/build-icon-font.mjs). Run by hand when icons change.
build:icon-legendRegenerate the user guide’s Icon Legend page from src/ + lang/en.json (utils/build-icon-legend.mjs). Verified by lint:icon-legend.
build:kb-contentGenerate the site’s Markdown: assets/content/ + kb/dev-docs/ β†’ kb/content/kb/ (content-build site). No Hugo needed.
build:kbbuild:kb-content then render it with Hugo β†’ build/site/sohl/. Needs Hugo and the theme submodule.
site:assembleMount the TypeDoc HTML at build/site/sohl/api/ and finish the deployable tree (utils/build-site.mjs).
build:siteThe whole of /sohl/: docs:prepare β†’ docs:html β†’ build:kb β†’ site:assemble.
build:pack-releaseZip build/stage/ β†’ build/dist/system.zip and copy system.json (package-build release).
clean / distcleanRemove build output (distclean also clears caches/node_modules-level artifacts).

Compendium packs

ScriptWhat it does
build:compiledbGenerate each pack’s per-entry JSON from the assets/content/ Markdown into build/packs-json/<pack>/, then compile LevelDB from it.
build:unpackdbExtract 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

ScriptWhat it does
testRun the vitest suite once.
test:watch / test:uiWatch mode / the vitest UI.
test:coverageRun with coverage.
test:purityThe 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:fixESLint over src/ (with --fix).
lint:docs-indexFail if a docs/ page is missing from its section nav or the README.
lint:addressesFail 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-vttFail 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-linksFail 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-linksFail 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:stylesstylelint 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:markdownmarkdownlint 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-legendFail 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-scopesFail 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:dtsValidate the generated public type surface.
lint:bundle-globalsFail 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:formatPrettier --check over the repo β€” the same check as format:check, wired into the lint chain so drift fails the build (#1621).
format / format:checkPrettier 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 to 1: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 β€” BEM block__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 inside scss/abstracts/, where Β§4’s emit-tokens mixin declares the tokens. It is deliberately looser outside that folder because the rule inspects var() 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-scss brings β€” 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:

RuleCatches
MD001A skipped heading level, which breaks every outline derived from the page
MD024Two identical sibling headings β€” two identical anchors, one reachable
MD056A table row whose cell count does not match its header
MD011Reversed link syntax, (text)[url], which renders as plain text
MD034A bare URL, which only some of our three renderers auto-link
MD039/MD042Spaces inside link text; an empty link target
MD052/MD053An 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

ScriptWhat it does
docsFull doc build: docs:prepare β†’ docs:html β†’ docs:md.
docs:preparedocs:catalog (generate the type catalog) + docs:expr-scopes (generate the expression-scope table).
docs:expr-scopesRegenerate the bound-variables table in Expressions and Scripts from the scope catalog.
docs:html / docs:mdTypeDoc HTML / Markdown output.
docs:coverageReport doc-comment coverage.
docs:serve / docs:watchServe build/docs-html / rebuild-and-serve on change.

Deploy and release

ScriptWhat 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:releasebuild then build:pack-release β€” produce the release zip locally.
changesetCreate a changeset (interactive). See Writing Changesets.
changeset:versionApply pending changesets: bump the version and update CHANGELOG.md (normally run by CI).
changeset:checkchangeset status --since=main β€” fail if the branch changed something but added no changeset.
build:sohl-typesRegenerate packages/sohl-types/index.d.ts from the SoHL source (run by that package’s prepack).
check:sohl-typesbuild: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:

  1. lint:format β€” Prettier reports no drift anywhere in the repo.
  2. lint:docs-index β€” every docs/ page is linked from its section nav and the README. lint also runs lint:markdown and lint:styles here; see What the two linters check.
  3. build:types β€” tsc type-checks the whole project.
  4. lint:dts β€” the generated public type surface is valid.
  5. check:sohl-types β€” @heroiclands/sohl-types regenerates, type-checks from a consumer’s position, and passes utils/check-sohl-types.mjs (see The npm workspace package).
  6. build:prepare (parallel):
    • build:css β€” Sass β†’ build/stage/css/sohl.css.
    • build:db β€” copy assets, then compile packs to build/stage/packs/.
    • build:system β€” write build/stage/system.json.
  7. test:coverage and test:purity β€” the suite must pass.
  8. build:code β€” Vite bundles src/sohl.ts β†’ build/stage/sohl.js (single ES module, sourcemap, unminified, with emptyOutDir: false so it doesn’t wipe the staged CSS/assets/packs).
  9. 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/class is 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 window throws SyntaxError: Identifier 'x' has already been declared at 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 #anchor link 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 @UUID enricher, 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/shortcode first, then a type-scoped alias β€” and expands fenced dataview tables 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:

HookWhat 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 / reportDetailThe 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:

FieldDerived from
rootDirthe directory the config file sits in β€” authoring it throws
stats.systemVersionversion in the adjacent package.json, unless stated
itemBuildersa 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" },
],
  • type selects the compiler; the note’s pack: 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: true designates the pack of its type that receives notes declaring none. At most one per type, enforced by defineConfig. 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 @UUID carries β€” 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:

KeyWhat it settles
rootDirThe 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 / packageKindWhich notes are compiled, which Foundry package ships them, and whether that package is a systems/ or a modules/ install.
statsThe identity stamped into every compiled document’s _stats β€” systemId, systemVersion (read from package.json, not transcribed), lastModifiedBy.
skipDirectoriesDirectory names the content walk ignores (Templates/, Obsidian’s templater scaffolding β€” a convention of this vault, not of a content tree).
pathsThe 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.
packsThe 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.packageManifest said where system.template.json lived, and both the package-id drift guard and the compiled packs’ _stats.coreVersion read 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 manifest generates the manifest now, so there is nothing left to follow: the Foundry range is declared here as top-level compatibility, the guard is deleted (a single source needs no corroboration), and paths.packageManifest is gone. stats.systemVersion obeys the same rule from the other side: it is a configured value, but the config reads it from package.json β€” the file Changesets bumps and build:system stamps into the manifest β€” rather than transcribing it. Transcribed, it froze at 0.6.0 for 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 passJournals pass
Entry iditemDocEntryId(fm.id)same
First page idjournalPageId(entryId, page, 0)same, for every page
Splits the bodyto name page 0into 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:

WikilinkAddresses
[[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 via ssh2-sftp-client, a pure-JS SSH client (no ssh binary required). By default the running SSH agent is used β€” $SSH_AUTH_SOCK on macOS/Linux, or the OpenSSH named pipe on Windows automatically β€” so if ssh host already works no extra config is needed. No password or passphrase is read from .env.local (for an encrypted key, load it into the agent with ssh-add). Per-stage overrides exist for username, port, agent endpoint (..._AGENT=pageant for 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.

VariableUsed for
FOUNDRYVTT_DEV_DATAThe 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_DATAThe user-data root for QA, used by npm run push:qa.
FOUNDRYVTT_PROD_DATAThe user-data root for production, used by npm run push:prod.
FOUNDRYVTT_DEV / FOUNDRYVTT_QA / FOUNDRYVTT_PRODThe 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>):

CommandEffect
startCreate (or restart) sohl-foundry-<stage> and serve /data. Sweeps a stale lock first when the container is down.
stopStop the container (state is kept for a fast start).
restartStop, sweep a stale lock, start. Deliberately not docker restart, which leaves no window in which to sweep.
recreateRemove and re-create the container so changed FOUNDRY_*/CONTAINER_* env applies.
rmStop and remove the container.
statusShow the container’s docker ps -a row.
logsFollow the container log (watch first-run install / boot here).
pullPull 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):

VariableDefaultPurpose
FOUNDRYVTT_CONTAINER_IMAGEfelddy/foundryvtt:14Image tag to run (the major is derived from compatibility.minimum).
FOUNDRYVTT_<STAGE>_VERSIONtest β†’ 14.359Exact build, passed to felddy as FOUNDRY_VERSION (see below).
FOUNDRYVTT_<STAGE>_PORT30000 / 30001 / 30002Published 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)

  1. Make sure every change you want in the release is merged to main, each with its changeset.

  2. On each push to main, the workflow opens or updates a PR titled chore(release): version packages β€” it runs changeset version to bump package.json and rewrite CHANGELOG.md. Review it (the version and changelog are the release).

  3. πŸ”§ Merge that PR β€” in the GitHub UI, or from the CLI (the changesets action opens it from the changeset-release/main branch):

    gh pr merge changeset-release/main [--admin] --squash --delete-branch
    

    Merging 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 build then npm 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).
  4. /sohl/ republishes itself. The release job dispatches deploy-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 with gh 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:release only builds the release zip locally (into build/dist/) for inspection β€” it does not publish anything. Releasing always goes through the merge-the-PR flow above.
  • A push to main with 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:

PackageWhat it is
@heroiclands/sohl-typesType 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:

StepManual?By
Author changesetsπŸ”§ yesdeveloper
Open the Version Packages PRno (CI)β€”
Merge the Version Packages PRπŸ”§ yesmaintainer
Tag + GitHub Release + assetsno (CI)β€”
Publish the API docsno (CI)β€”
Deploy to a Foundry instanceπŸ”§ yesoperator

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/.

AddressWhatBuilt byBuilt from
/sohl/The package landing pageHugo (kb/layouts/index.html)main
/sohl/kb/The knowledgebaseHugo (build:kb)main
/sohl/api/The API documentationTypeDoc (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:

ScriptPurpose
manifest-flags.mjsThe flags(config) hook package-build manifest calls: the credits journal’s @UUID, which only exists once the content tree is walked.
svg-theme.mjsThe transform hook package-build assets calls: recolor each staged SVG so icons follow the Foundry theme.
build-icon-font.mjsBuild the icon font from SVGs.
build-type-catalog.mjsGenerate docs/reference/type-catalog.md from the kind enums.
docs-coverage.mjsReport doc-comment coverage.
clean.mjsRemove build output (--distclean for a deeper clean).
build-site.mjsAssemble the deployable /sohl/ tree: mount the API docs, refuse a partial build, and refuse a link to a retired hostname.
retired-hosts.mjsThe withdrawn hostnames and what replaced each β€” shared by the content-link check and the deploy gate.
release.mjsLegacy local release path; authenticate with gh auth login (CI normally cuts releases).
packs/compendiums.mjsLibrary: compilePacks / unpackPacks / cleanPacks over the Foundry CLI. No import-time side effects.
packs/bin/build-compendiums.mjsThe pack CLI: argv, logging, directory creation, and exit codes for the library above.
packs/export.mjsVault β†’ _source/ export orchestrator.
packs/{items,journals,actors}.mjsPer-pack vault compilers.
packs/helpers.mjsShared pack helpers (frontmatter, _key, folders).
packs/clean-sources.mjsRemove generated _source/ trees.
typedoc-plugin-*.mjsTypeDoc plugins (source categories, nested nav, Foundry links, data-field schema).

See also