@heroiclands/package-build 22.23.0 → 22.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -0
  3. package/assets/styles/content-base.css +45 -0
  4. package/ci/check-banners.mjs +183 -0
  5. package/ci/ci-steps.mjs +21 -11
  6. package/docs/authoring/assets.md +15 -0
  7. package/docs/authoring/links-and-markup.md +69 -2
  8. package/docs/commands.md +18 -19
  9. package/docs/configuration.md +2 -6
  10. package/docs/content-format.md +9 -0
  11. package/docs/guides/module-project.md +2 -2
  12. package/docs/guides/site.md +41 -0
  13. package/docs/project-setup.md +4 -4
  14. package/docs/reference/format-details.md +3 -3
  15. package/docs/reference/theme-pages.md +496 -0
  16. package/engine/art-slots.mjs +2 -1
  17. package/engine/content-blocks.mjs +83 -14
  18. package/engine/content-figures.mjs +18 -3
  19. package/engine/content-poetry.mjs +61 -0
  20. package/engine/foreign-catalog.mjs +2 -1
  21. package/engine/helpers.mjs +5 -3
  22. package/engine/note-vocabulary.mjs +2 -1
  23. package/engine/pdf-build.mjs +1 -1
  24. package/engine/pdf-render.mjs +32 -2
  25. package/engine/rank-ladder.mjs +32 -0
  26. package/engine/site-config.mjs +19 -24
  27. package/hugo-theme/data/banners.yaml +69 -0
  28. package/hugo-theme/layouts/404.html +85 -0
  29. package/hugo-theme/layouts/_default/baseof.html +64 -0
  30. package/hugo-theme/layouts/_default/list.html +139 -0
  31. package/hugo-theme/layouts/_default/single.html +4 -0
  32. package/hugo-theme/layouts/blog/list.html +54 -0
  33. package/hugo-theme/layouts/blog-post/single.html +36 -0
  34. package/hugo-theme/layouts/index.html +75 -0
  35. package/hugo-theme/layouts/partials/banner-url.html +117 -0
  36. package/hugo-theme/layouts/partials/cdn-url.html +36 -0
  37. package/hugo-theme/layouts/partials/draft-notice.html +41 -0
  38. package/hugo-theme/layouts/partials/draft-state.html +26 -0
  39. package/hugo-theme/layouts/partials/footer.html +29 -0
  40. package/hugo-theme/layouts/partials/header.html +35 -0
  41. package/hugo-theme/layouts/partials/hero-banner.html +86 -0
  42. package/hugo-theme/layouts/partials/holdings/entry.html +21 -0
  43. package/hugo-theme/layouts/partials/holdings/kind-groups.html +71 -0
  44. package/hugo-theme/layouts/partials/holdings.html +61 -0
  45. package/hugo-theme/layouts/partials/infobox/entry.html +15 -0
  46. package/hugo-theme/layouts/partials/infobox/items.html +26 -0
  47. package/hugo-theme/layouts/partials/infobox/section.html +56 -0
  48. package/hugo-theme/layouts/partials/infobox/value.html +43 -0
  49. package/hugo-theme/layouts/partials/infobox.html +57 -0
  50. package/hugo-theme/layouts/partials/page-body.html +154 -0
  51. package/hugo-theme/layouts/partials/place-map.html +9 -0
  52. package/hugo-theme/layouts/partials/related.html +121 -0
  53. package/hugo-theme/layouts/partials/search.html +84 -0
  54. package/hugo-theme/layouts/partials/site-url.html +32 -0
  55. package/hugo-theme/layouts/partials/tags.html +30 -0
  56. package/hugo-theme/layouts/partials/toc.html +28 -0
  57. package/hugo-theme/layouts/partials/warn-missing-banner.html +3 -0
  58. package/hugo-theme/layouts/shortcodes/cartouche.html +64 -0
  59. package/hugo-theme/layouts/shortcodes/exturl.html +12 -0
  60. package/hugo-theme/layouts/shortcodes/glyph.html +43 -0
  61. package/hugo-theme/layouts/shortcodes/pagelist.html +63 -0
  62. package/hugo-theme/static/css/sohl-icons.css +57 -0
  63. package/hugo-theme/static/css/style.css +2982 -0
  64. package/hugo-theme/static/fonts/game-icons-kb.woff2 +0 -0
  65. package/hugo-theme/static/img/banners/takheperu-hieroglyphs.svg +1 -0
  66. package/hugo-theme/static/img/cartouches/takheperu.svg +1 -0
  67. package/hugo-theme/theme.toml +11 -0
  68. package/package.json +5 -2
  69. package/types/engine/art-slots.d.mts +2 -1
  70. package/types/engine/content-figures.d.mts +1 -0
  71. package/types/engine/content-poetry.d.mts +19 -0
  72. package/types/engine/helpers.d.mts +5 -3
  73. package/types/engine/rank-ladder.d.mts +8 -0
  74. package/types/engine/site-config.d.mts +9 -9
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.25.0
4
+
5
+ ### Minor Changes
6
+
7
+ Write poems in a `:::poetry` fence to preserve verse lines, relative indentation, and stanza breaks across journals, websites, and books. A poem placed first in a `:::figure` uses the Poem counter and may carry a caption and reference.
8
+
9
+ The bundled Hugo theme's image-role and banner guards now run as part of package-build lint. Affiliation notes must declare a `data.governance.ranks` rung at level 1; a missing ordinary standing now produces a located error.
10
+
11
+ ## 22.24.0
12
+
13
+ ### Minor Changes
14
+
15
+ **Hero banners** — A page whose note names its own hero image shows that image, in place of the stock banner for its kind; a note naming none gets a band with no image.
16
+
17
+ **Figures** — A numbered figure, table, listing or map reads as one block, its number and caption set beneath it rather than running on as body text, and a passage set apart as an aside draws as a box.
18
+
19
+ **Pictures** — A portrait or an emblem draws at half the content column and a banner, plate or map at its full width, never wider than the size the picture was made at; a stated width still overrides the role's measure.
20
+
21
+ **Installing** — The shared Hugo theme arrives with this package, so a site declares this package alone and needs no separate theme dependency.
22
+
23
+ **Hero images** — The authoring guide states the three states of a note's hero image: a picture, a deliberate blank that declines one, and an absent key that takes the default for the note's type.
24
+
3
25
  ## 22.23.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -33,6 +33,7 @@ The documentation lives in the Markdown files in this repository. Start with [th
33
33
  | Detailed format rules and examples | [Format details](docs/reference/format-details.md) |
34
34
  | Compendium routing and document builds | [Packs](docs/guides/packs.md) |
35
35
  | Website build and publication | [Site](docs/guides/site.md) |
36
+ | What the theme draws on a page | [Theme pages](docs/reference/theme-pages.md) |
36
37
  | PDF book build | [Book](docs/guides/book.md) |
37
38
  | Icon generation and use | [Icons](docs/guides/icons.md) |
38
39
  | Reference pages and content indexes | [Reference pages](docs/guides/reference-pages.md) |
@@ -106,6 +106,51 @@
106
106
  }
107
107
  }
108
108
 
109
+ /* Each verse line breaks in the markup; each paragraph is a stanza. */
110
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) .poetry {
111
+ margin: 1.5rem 0;
112
+ line-height: 1.7;
113
+ }
114
+
115
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) .poetry p {
116
+ margin: 0;
117
+ }
118
+
119
+ :is(.journal-entry-page, .journal-entry-content, .editor-content, .prosemirror) .poetry p + p {
120
+ margin-top: 1em;
121
+ }
122
+
123
+ .poetry .poetry-line {
124
+ display: inline-block;
125
+ margin-inline-start: var(--poetry-indent, 0em);
126
+ white-space: pre-wrap;
127
+ }
128
+
129
+ .poetry .poetry-line.i1 {
130
+ --poetry-indent: 1.25em;
131
+ }
132
+ .poetry .poetry-line.i2 {
133
+ --poetry-indent: 2.5em;
134
+ }
135
+ .poetry .poetry-line.i3 {
136
+ --poetry-indent: 3.75em;
137
+ }
138
+ .poetry .poetry-line.i4 {
139
+ --poetry-indent: 5em;
140
+ }
141
+ .poetry .poetry-line.i5 {
142
+ --poetry-indent: 6.25em;
143
+ }
144
+ .poetry .poetry-line.i6 {
145
+ --poetry-indent: 7.5em;
146
+ }
147
+ .poetry .poetry-line.i7 {
148
+ --poetry-indent: 8.75em;
149
+ }
150
+ .poetry .poetry-line.i8 {
151
+ --poetry-indent: 10em;
152
+ }
153
+
109
154
  /*
110
155
  * Named body blocks. `:::info` and `:::warn` emit a `<section>` carrying the
111
156
  * block's name as its class, so the appearance belongs here rather than in an
@@ -0,0 +1,183 @@
1
+ /*
2
+ * This file is part of the Heroic Lands shared Hugo theme.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * CI guard: every banner `data/banners.yaml` declares must actually be published.
16
+ *
17
+ * `partials/hero-banner.html` needs to know which banner images exist, and Hugo
18
+ * cannot ask a remote asset host that question at build time — so the answer is
19
+ * declared in `data/banners.yaml` instead. A declaration nothing
20
+ * checks is kept in step by hope, and the hole it was written to close reopens
21
+ * the first time it drifts. It already had: when this check was written, four
22
+ * names present in a local working copy of the artwork repository were not
23
+ * published, and three that were published appeared in no local copy at all.
24
+ *
25
+ * So this fetches every declared name and fails on any non-200. It is the test
26
+ * the template cannot make, moved to the one place that can make it.
27
+ *
28
+ * Usage:
29
+ * node utils/check-banners.mjs [--base <url>] [--concurrency <n>]
30
+ *
31
+ * The host defaults to the Heroic Lands CDN and can be pointed anywhere with
32
+ * `--base` or `BANNER_BASE_URL`, so a consumer publishing its own artwork set
33
+ * can run the same check against its own `data/banners.yaml`.
34
+ *
35
+ * A network failure is reported as a failure rather than skipped: a check that
36
+ * passes when it could not reach the host is worse than no check at all.
37
+ */
38
+
39
+ import { readFileSync } from "node:fs";
40
+ import { fileURLToPath } from "node:url";
41
+ import { dirname, resolve } from "node:path";
42
+ import { parse } from "yaml";
43
+
44
+ const HERE = dirname(fileURLToPath(import.meta.url));
45
+ const DATA_FILE = resolve(HERE, "..", "hugo-theme", "data", "banners.yaml");
46
+ const DEFAULT_BASE = "https://cdn.heroiclands.org";
47
+
48
+ function parseArgs(argv) {
49
+ const opts = {
50
+ base: process.env.BANNER_BASE_URL || DEFAULT_BASE,
51
+ concurrency: 8,
52
+ };
53
+ for (let i = 0; i < argv.length; i += 1) {
54
+ const arg = argv[i];
55
+ if (arg === "--base") opts.base = argv[(i += 1)];
56
+ else if (arg === "--concurrency") opts.concurrency = Number(argv[(i += 1)]);
57
+ else {
58
+ console.error(`check-banners: unrecognised argument ${arg}`);
59
+ process.exit(2);
60
+ }
61
+ }
62
+ if (!opts.base) {
63
+ console.error("check-banners: no asset host; pass --base or set BANNER_BASE_URL");
64
+ process.exit(2);
65
+ }
66
+ if (!Number.isInteger(opts.concurrency) || opts.concurrency < 1) {
67
+ console.error("check-banners: --concurrency must be a positive integer");
68
+ process.exit(2);
69
+ }
70
+ return opts;
71
+ }
72
+
73
+ /** Diagnostics carry the fields they can know and drop the ones they cannot. */
74
+ function diag(line, message) {
75
+ const where = line ? `${DATA_FILE}:${line}` : DATA_FILE;
76
+ console.error(`${where}: error: ${message}`);
77
+ }
78
+
79
+ function fail(message, line) {
80
+ diag(line, message);
81
+ process.exit(1);
82
+ }
83
+
84
+ /** Line number of a declared name, so an editor can jump straight to it. */
85
+ function lineOf(source, name) {
86
+ const index = source
87
+ .split("\n")
88
+ .findIndex((l) => l.trim() === `- ${name}` || l.trim() === `- "${name}"`);
89
+ return index === -1 ? 0 : index + 1;
90
+ }
91
+
92
+ /** Read and validate the inventory, so a malformed file fails loudly. */
93
+ function readInventory() {
94
+ const source = readFileSync(DATA_FILE, "utf8");
95
+ const data = parse(source);
96
+ const dir = data?.dir;
97
+ const fallback = data?.fallback;
98
+ const available = data?.available;
99
+
100
+ if (typeof dir !== "string" || !dir) fail("`dir` must be a non-empty string");
101
+ if (typeof fallback !== "string" || !fallback) fail("`fallback` must be a non-empty string");
102
+ if (!Array.isArray(available) || available.length === 0)
103
+ fail("`available` must be a non-empty list");
104
+ if (available.some((n) => typeof n !== "string" || !n))
105
+ fail("`available` must hold only non-empty strings");
106
+
107
+ const dupes = [...new Set(available.filter((n, i) => available.indexOf(n) !== i))];
108
+ if (dupes.length) fail(`duplicate names: ${dupes.join(", ")}`, lineOf(source, dupes[0]));
109
+
110
+ const unsorted = available.findIndex((n, i) => i > 0 && available[i - 1] > n);
111
+ if (unsorted !== -1)
112
+ fail(
113
+ "`available` must be sorted, so a diff shows what changed",
114
+ lineOf(source, available[unsorted]),
115
+ );
116
+
117
+ // The fallback is what every unlisted name resolves to, so its absence would
118
+ // turn one dead URL into every dead URL.
119
+ const fallbackName = fallback.replace(/\.[^./]+$/, "");
120
+ if (!available.includes(fallbackName))
121
+ fail(`the fallback ${fallback} is not itself listed in \`available\``);
122
+
123
+ return { source, dir, available };
124
+ }
125
+
126
+ async function probe(url) {
127
+ try {
128
+ // HEAD first — it is what the question actually is. Some object stores
129
+ // answer 405 to it, so fall back to a ranged GET rather than reporting a
130
+ // published file as missing.
131
+ let res = await fetch(url, { method: "HEAD", redirect: "follow" });
132
+ if (res.status === 405 || res.status === 501) {
133
+ res = await fetch(url, {
134
+ headers: { Range: "bytes=0-0" },
135
+ redirect: "follow",
136
+ });
137
+ }
138
+ return {
139
+ ok: res.status === 200 || res.status === 206,
140
+ status: String(res.status),
141
+ };
142
+ } catch (err) {
143
+ return { ok: false, status: `network error: ${err.message}` };
144
+ }
145
+ }
146
+
147
+ async function main() {
148
+ const { base, concurrency } = parseArgs(process.argv.slice(2));
149
+ const { source, dir, available } = readInventory();
150
+ const root = `${base.replace(/\/$/, "")}/${dir.replace(/^\//, "")}`;
151
+
152
+ const failures = [];
153
+ const queue = [...available];
154
+ const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
155
+ for (let name = queue.shift(); name !== undefined; name = queue.shift()) {
156
+ const url = `${root}/${name}.webp`;
157
+ const { ok, status } = await probe(url);
158
+ if (!ok) failures.push({ name, url, status });
159
+ }
160
+ });
161
+ await Promise.all(workers);
162
+
163
+ if (failures.length) {
164
+ failures.sort((a, b) => a.name.localeCompare(b.name));
165
+ for (const { name, url, status } of failures) {
166
+ diag(
167
+ lineOf(source, name),
168
+ `declared banner ${name} is not published (${status}): ${url}`,
169
+ );
170
+ }
171
+ console.error(
172
+ `check-banners: ${failures.length} of ${available.length} declared banners are missing from ${base}.`,
173
+ );
174
+ console.error(
175
+ "Publish the artwork, or drop the name — an undrawn name here is a 404 on every page that resolves to it.",
176
+ );
177
+ process.exit(1);
178
+ }
179
+
180
+ console.log(`check-banners: all ${available.length} declared banners resolve at ${base}`);
181
+ }
182
+
183
+ await main();
package/ci/ci-steps.mjs CHANGED
@@ -139,7 +139,7 @@ function prTriggeredWorkflows() {
139
139
  * @param {string} file - The workflow.
140
140
  * @returns {{run: Array<{name: string, run: string}>, skipped: string[]}} Its steps.
141
141
  */
142
- function stepsIn(file) {
142
+ export function stepsIn(file) {
143
143
  const lines = fs.readFileSync(file, "utf8").split("\n");
144
144
  const run = [];
145
145
  const skipped = [];
@@ -164,25 +164,32 @@ function stepsIn(file) {
164
164
  continue;
165
165
  }
166
166
 
167
- const inline = /^-?\s*run:\s*(?!\|)(.+)$/.exec(text);
168
- if (inline) {
169
- run.push({ name: name ?? inline[1], run: inline[1].trim() });
170
- name = null;
171
- continue;
172
- }
173
167
  // A `run: |` block scalar: every following line indented deeper.
174
- if (/^-?\s*run:\s*\|\s*$/.test(text)) {
168
+ if (/^-?\s*run:\s*\|[+-]?\s*$/.test(text)) {
175
169
  const base = indentOf(line);
176
170
  const body = [];
177
171
  while (i + 1 < lines.length) {
178
172
  const next = lines[i + 1];
179
173
  if (next.trim() !== "" && indentOf(next) <= base) break;
180
- body.push(next.trim());
174
+ body.push(next);
181
175
  i += 1;
182
176
  }
183
- const command = body.filter(Boolean).join(" && ");
177
+ const margin = Math.min(
178
+ ...body.filter((line) => line.trim()).map((line) => indentOf(line)),
179
+ );
180
+ const command = body
181
+ .map((line) => line.slice(margin))
182
+ .join("\n")
183
+ .trimEnd();
184
184
  if (command) run.push({ name: name ?? command, run: command });
185
185
  name = null;
186
+ continue;
187
+ }
188
+ const inline = /^-?\s*run:\s*(.+)$/.exec(text);
189
+ if (inline) {
190
+ run.push({ name: name ?? inline[1], run: inline[1].trim() });
191
+ name = null;
192
+ continue;
186
193
  }
187
194
  }
188
195
  return { run, skipped };
@@ -223,7 +230,10 @@ function main() {
223
230
 
224
231
  for (const [i, step] of run.entries()) {
225
232
  console.log(`\nci-steps: [${i + 1}/${run.length}] ${step.name}\n $ ${step.run}`);
226
- const result = spawnSync(step.run, { cwd: ROOT, shell: true, stdio: "inherit" });
233
+ const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", step.run], {
234
+ cwd: ROOT,
235
+ stdio: "inherit",
236
+ });
227
237
  if (result.status !== 0) {
228
238
  console.error(
229
239
  `\nci-steps: FAILED at "${step.name}" — this is what GitHub would report.\n` +
@@ -38,6 +38,21 @@ role: portrait
38
38
 
39
39
  **A hero image is 1792×768.** `data.banner` fills one fixed strip wherever it is drawn, so every banner is cut to that size and a picture of any other size is a finding naming both sizes — resize the picture to match. That is the one size any art field requires. A map's `bgImage` sets its own scene's dimensions, so that asks nothing of a picture's dimensions either.
40
40
 
41
+ `data.banner` reads in one of three states:
42
+
43
+ | A note writes | The page draws |
44
+ | ------------------ | -------------------------------------------------- |
45
+ | an asset Address | that picture |
46
+ | a blank | no picture, on purpose, and no default replaces it |
47
+ | nothing, or `null` | a default picture chosen by the note's `type` |
48
+
49
+ Omitting the key does not mean "no hero image" — it means artwork chosen for
50
+ the author, which inverts the ordinary reading of an absent key. **A note
51
+ declines with a blank, not with `none`.** `none` parses as an ordinary asset
52
+ Address — a shortcode, not a keyword — and since nothing in the corpus
53
+ answers to it, the build drops the key outright and the page falls through to
54
+ its type's default: the opposite of a decline.
55
+
41
56
  `role` is one of `portrait`, `emblem`, `banner`, `plate` or `map`, and belongs to the `image` type only — declaring it in a `provenance.yaml` under `assets/icons` is a finding, since an icon carries one nominal size per medium whatever the file holds. Leave the key out for an ordinary picture; there is no default role name, only its absence.
42
57
 
43
58
  **A role decides how large a picture written in prose draws, when nothing else states a size.** Each medium maps a role to a maximum, and the drawn size is the smaller of that slot and what the file's own pixels support — see [what a role draws at](../reference/format-details.md#what-a-role-draws-at). A named `size=` beside the embed still overrides it outright; an icon draws at the medium's nominal icon size whatever role it might otherwise have had, because `role` never reaches an icon address at all.
@@ -93,7 +93,7 @@ The fence displays **Table 1: Regional trade routes**, and `{{ref "#trade"}}` di
93
93
 
94
94
  **`.border` draws the figure and its label inside a border.** It is the only class the construct accepts — any other class, such as `.wide`, is an error naming the class. `key="value"` is refused outright: a figure takes an id and classes, nothing else.
95
95
 
96
- **The counter is derived from the fence's contents, never authored.** A table is a table; an `sql` fence is a table, because its rendered output is one; any other fence or code block is code; pictures alone, one or several, are a figure; everything else is prose. A fence around prose with `.border` is a numbered, referable boxed aside — the `Prose` counter's whole purpose. The counters are `Code`, `Table`, `Figure`, `Map`, and `Prose`. The website and a Foundry journal number each counter within the note; a book numbers each counter across the book in reading order.
96
+ **The counter is derived from the fence's contents, never authored.** A table is a table; an `sql` fence is a table, because its rendered output is one; any other fence or code block is code; pictures alone, one or several, are a figure; a `:::poetry` fence written first makes it a poem; everything else is prose. A fence around prose with `.border` is a numbered, referable boxed aside — the `Prose` counter's whole purpose. The counters are `Code`, `Table`, `Figure`, `Map`, `Poem`, and `Prose`. The website and a Foundry journal number each counter within the note; a book numbers each counter across the book in reading order.
97
97
 
98
98
  **A picture counts as `Map` instead of `Figure` when the asset it draws declares `role: map`** in its provenance — see [the asset provenance reference](../reference/format-details.md). The fence's own markup cannot say so; a picture of a map looks like any other picture, so the fact rides entirely on the asset's own record. A fence grouping several pictures counts as `Map` only when every one of them declares the role; one picture without it keeps the whole fence a `Figure`.
99
99
 
@@ -172,7 +172,7 @@ The bridge closes during the spring flood.
172
172
  ```
173
173
 
174
174
  Each heads itself with its own name — **Secret**, **Info**, **Warn** — and takes
175
- a closing `:::`. A name that is not one of the three is reported.
175
+ a closing `:::`. Poetry has its own fence below; other names are reported.
176
176
 
177
177
  **A GM-only section holds a box.** A `:::secret` is a container for whatever the
178
178
  GM reads, boxes included, and a box written inside one stays inside it. A box
@@ -232,6 +232,73 @@ would tear the block or the figure in two, and a reader could open the second
232
232
  half with none of the box around it. A lower heading with no anchor is
233
233
  unaffected and is the ordinary way to structure a long box.
234
234
 
235
+ ### Poetry
236
+
237
+ Use `:::poetry` to preserve verse lines and stanzas. Every nonblank source line
238
+ is one verse line; blank lines separate stanzas. Inline emphasis, links and
239
+ footnotes work as they do in prose. The fence itself adds no title, caption or
240
+ number.
241
+
242
+ Leading spaces indent individual verse lines. The least-indented nonblank line
243
+ sets level zero for the whole poem, so a common indentation on every line has no
244
+ visual effect. Each additional two spaces adds one level, up to `i8`; an odd
245
+ extra space rounds down. Each medium sets the actual width of a level, with
246
+ every higher level wider than the preceding one. Tabs anywhere in the poem are
247
+ an error. Interior spaces are preserved on a best-effort basis.
248
+
249
+ | Extra leading spaces | Indent class |
250
+ | -------------------- | ------------ |
251
+ | 0–1 | none |
252
+ | 2–3 | `i1` |
253
+ | 4–5 | `i2` |
254
+ | 6–7 | `i3` |
255
+ | 8–9 | `i4` |
256
+ | 10–11 | `i5` |
257
+ | 12–13 | `i6` |
258
+ | 14–15 | `i7` |
259
+ | 16 or more | `i8` |
260
+
261
+ For example, a line with four leading spaces and a line with six leading spaces
262
+ render at levels zero and `i1` respectively. This also works when the entire
263
+ fence is indented inside a list item. Poetry is upright by default; use normal
264
+ Markdown emphasis when italics are part of the poem.
265
+
266
+ ```markdown
267
+ :::poetry {form=ballad meter="common meter" rhyme=ABCB syllables="8,6,8,6" lang=en}
268
+ The lantern burns beside the gate,
269
+ The harbor sleeps below.
270
+
271
+ The keeper guards the road till dawn,
272
+ And keeps a light aglow.
273
+ :::
274
+ ```
275
+
276
+ The optional `form`, `meter` and `rhyme` attributes record the author's intent;
277
+ the build does not infer stress, rhyme or pronunciation. `syllables` gives a
278
+ positive count for each verse line in order, separated by commas, with blank
279
+ stanza lines omitted. The build checks the number of counts, not the syllables
280
+ spoken. `lang` is a language tag for the rendered HTML. An optional `#id`
281
+ addresses the poem itself. There are no `title` or `lines` attributes, and no
282
+ special punctuation for stress, pauses or refrains.
283
+
284
+ To number and caption a poem, put it first inside a `:::figure`:
285
+
286
+ ```markdown
287
+ :::figure {#watch-song}
288
+ :::poetry {form=ballad}
289
+ The watch has gone to sea,
290
+ The lantern marks the shore.
291
+ :::
292
+ ///
293
+ A song sung by the harbor watch.
294
+ :::
295
+ ```
296
+
297
+ The inner `:::` closes the poem; the outer one closes the figure. That figure
298
+ uses the **Poem** counter, so `{{ref "#watch-song"}}` reads **Poem 1**. The
299
+ caption after `///` belongs to the figure. A poem outside a figure is
300
+ unnumbered. Prose written before the poem makes the figure a **Prose** figure.
301
+
235
302
  ## Footnotes and definition lists
236
303
 
237
304
  Write a footnote reference as `[^id]` and its definition as `[^id]: text`.
package/docs/commands.md CHANGED
@@ -885,8 +885,8 @@ Install it with `npm ci`, which resolves from the lockfile this just moved.
885
885
  ```
886
886
 
887
887
  ```
888
- $ package-build bump @heroiclands/hugo-theme --check
889
- @heroiclands/hugo-theme 0.5.0 → 0.6.0
888
+ $ package-build bump @heroiclands/package-build --check
889
+ @heroiclands/package-build 22.6.0 → 22.7.0
890
890
 
891
891
  Run without --check to take it.
892
892
  ```
@@ -2310,9 +2310,9 @@ Three things are written, and nothing outside `build/`:
2310
2310
  - `build/hugo/hugo.toml`, generated on every run from `package.json`
2311
2311
  (`homepage`, `description`, `author`), `package-build.config.yaml`
2312
2312
  (`packageBuild.manifest.title`, `site.assets`, `site.notfound`,
2313
- `site.hugo`), the organisation's constants, the installed
2314
- `@heroiclands/hugo-theme`'s location, and the navigation `deps fetch`
2315
- cached. Every value's source is listed under
2313
+ `site.hugo`), the organisation's constants, the shared theme's own
2314
+ location, and the navigation `deps fetch` cached. Every value's source is
2315
+ listed under
2316
2316
  [the generated Hugo configuration](configuration.md#the-generated-hugo-configuration).
2317
2317
  - `build/hugo/content/`, the content mount — the homepage as its `_index.md`,
2318
2318
  and the content tree's pages flat below `publish.address.prefix`. Wiped on
@@ -2321,13 +2321,13 @@ Three things are written, and nothing outside `build/`:
2321
2321
  deployment root `package-build site-root` writes beside. Nothing Hugo
2322
2322
  reads lands in what is published.
2323
2323
 
2324
- The configuration's sources are read before the output tree is touched, so
2325
- a missing `homepage`, a cold navigation cache or an uninstalled theme fails
2326
- with the previous site intact. Every gate is then checked and reported, and
2327
- the run stops at the first that fires, ordered so the report names the cause
2328
- rather than its symptoms — an unusable dependency manifest, reported after
2329
- the links that failed because of it, would otherwise read as a pile of
2330
- broken notes. Reads the content tree named by `paths.content`.
2324
+ The configuration's sources are read before the output tree is touched, so a
2325
+ missing `homepage` or a cold navigation cache fails with the previous site
2326
+ intact. Every gate is then checked and reported, and the run stops at the
2327
+ first that fires, ordered so the report names the cause rather than its
2328
+ symptoms — an unusable dependency manifest, reported after the links that
2329
+ failed because of it, would otherwise read as a pile of broken notes. Reads
2330
+ the content tree named by `paths.content`.
2331
2331
 
2332
2332
  **OPTIONS**
2333
2333
 
@@ -2336,13 +2336,12 @@ None.
2336
2336
  **EXIT STATUS**
2337
2337
 
2338
2338
  1 if `package.json` declares no `homepage`, or one that does not end
2339
- `/<contentPackage>/`; if `packageBuild.manifest.title` is undeclared; if the
2340
- navigation has not been fetched (`package-build deps fetch` fills the cache
2341
- and is named in the message); or if `@heroiclands/hugo-theme` is not
2342
- installed. 1 if any gate fires — no homepage or two competing for it, a
2343
- frontmatter wikilink, an address that cannot be derived, a stale or
2344
- unaddressable dependency manifest, an address published twice, a table that
2345
- failed to expand, or a dead wikilink. Otherwise 0.
2339
+ `/<contentPackage>/`; if `packageBuild.manifest.title` is undeclared; or if
2340
+ the navigation has not been fetched (`package-build deps fetch` fills the
2341
+ cache and is named in the message). 1 if any gate fires — no homepage or two
2342
+ competing for it, a frontmatter wikilink, an address that cannot be derived,
2343
+ a stale or unaddressable dependency manifest, an address published twice, a
2344
+ table that failed to expand, or a dead wikilink. Otherwise 0.
2346
2345
 
2347
2346
  **EXAMPLES**
2348
2347
 
@@ -1031,8 +1031,8 @@ in it has one source, and that source is where it is edited:
1031
1031
  | `locale` | the organisation's locale, `en-us`, in `engine/site-config.mjs` |
1032
1032
  | `publishDir` | `contentPackage`, under the deployment root `build/site` — written relative to `build/hugo/`, so `../site/<contentPackage>` |
1033
1033
  | `contentDir` | the fixed content mount, `build/hugo/content` — written as `content` |
1034
- | `themesDir` | where `@heroiclands/hugo-theme` is installed, resolved the way Node resolves a package and written relative to `build/hugo/` |
1035
- | `theme` | the installed `@heroiclands/hugo-theme`, so `hugo-theme` |
1034
+ | `themesDir` | this package's own root, which holds the theme it ships, written relative to `build/hugo/` |
1035
+ | `theme` | the theme's directory inside this package, `hugo-theme` |
1036
1036
  | `disableKinds` | the toolchain, which renders a site as its homepage and its pages: `["section", "taxonomy", "term", "RSS"]` on every site, whatever its notes carry |
1037
1037
  | `taxonomies` | the toolchain — never written, because `taxonomy` and `term` are disabled kinds |
1038
1038
  | `outputs` | the toolchain — never written, because every listing kind is disabled |
@@ -1049,10 +1049,6 @@ error naming the command that fills it:
1049
1049
 
1050
1050
  > `the site navigation has not been fetched. Run `package-build deps fetch` first.`
1051
1051
 
1052
- A missing theme names the package to install:
1053
-
1054
- > `@heroiclands/hugo-theme is not installed anywhere above <rootDir> — add it to `devDependencies`and run`npm ci``
1055
-
1056
1052
  The site needs a title from `site.title` or the package manifest:
1057
1053
 
1058
1054
  > ``package-build config: declare `site.title` or `packageBuild.manifest.title` for the site's title.``
@@ -27,6 +27,8 @@ A nonempty note body creates one system-agnostic Foundry JournalEntry. Content b
27
27
 
28
28
  A `:::figure {#anchor}` fence numbers whatever it holds — prose, code, a table, or an image. Its anchor makes the fence a target `{{ref "#anchor"}}` can point to, displaying the generated kind and number, such as **Figure 12**. Foundry gives the fence its own JournalEntryPage. See [figures and numbered references](authoring/links-and-markup.md#figures-and-numbered-references) for the syntax.
29
29
 
30
+ A `:::poetry` fence keeps each authored line as a verse line and blank lines as stanza breaks. Inside a `:::figure`, a poem written first gives that figure the **Poem** counter and an optional caption. See [poetry](authoring/links-and-markup.md#poetry) for attributes and examples.
31
+
30
32
  Body extensions use braces with space-separated `key=value` attributes:
31
33
  `![[icon-harbor|Harbor]]{float=top-left size=medium}` and an SQL fence with
32
34
  `{allow-empty=true}` are examples. Inline font glyphs use the package's icon
@@ -49,6 +51,13 @@ explains routing for documents and their prose journals.
49
51
 
50
52
  A note can also produce a system document: an Actor, Item, Scene, or Macro according to its type and system blocks. The `sohl:` and `hm3:` blocks supply game-specific mechanics and overrides; they do not create separate prose. The JournalEntry is shared between systems.
51
53
 
54
+ An `affiliation` note declares `data.governance.ranks` with at least one rung at
55
+ `level: 1`. Each rung states `level`, `title`, and `description`. A being's
56
+ `data.affiliations` entry names a rank on that body's ladder; level 1 is the
57
+ ordinary standing when no higher rank applies. A single level-1 rung is enough
58
+ when the body expresses its hierarchy through `governance.offices` instead.
59
+ Missing ranks, an empty list, or a ladder without level 1 are build errors.
60
+
52
61
  | Authored section | SoHL Actor | HM3 Actor |
53
62
  | ---------------- | ------------------- | -------------------- |
54
63
  | `{#appearance}` | `system.appearance` | `system.description` |
@@ -863,8 +863,8 @@ None of them is needed to build a package.
863
863
  **A website.** Every package publishes one — at the least, the homepage note
864
864
  from step 5 — at `https://www.heroiclands.org/<contentPackage>/`. Add that
865
865
  address to `package.json` as `homepage`, with a `description` and an `author`
866
- beside it; add `@heroiclands/hugo-theme` under `devDependencies`; and add a
867
- `packageBuild.manifest.title`. Then `package-build deps fetch` caches the
866
+ beside it, and add a `packageBuild.manifest.title`. The theme the site renders
867
+ through ships with this package, so nothing else is installed. Then `package-build deps fetch` caches the
868
868
  organisation's navigation, and `package-build site` writes the whole Hugo
869
869
  source tree under `build/hugo/` — the configuration generated from those
870
870
  values, and the content mount — for `hugo --source build/hugo` to render
@@ -132,6 +132,47 @@ fenced example remains literal. `site.passOptions` supplies that named pass's
132
132
  options. This is a package-specific extension point; keep the general note
133
133
  format and link behavior in the [content format](../content-format.md).
134
134
 
135
+ ## What an emitted page carries
136
+
137
+ A theme reads the front matter `package-build site` writes: the note's own
138
+ front matter, plus the values the build derives. Which is which matters,
139
+ because an authored value under a derived key is discarded.
140
+
141
+ The build always writes `title`, `slug`, `url`, `kbfolder`, `package` and
142
+ `infoboxes`. `title` is the note's name, `url` is the page's address, and
143
+ `slug` is that address's last segment. `package` names the package shipping the
144
+ note. `infoboxes` is assembled from the note's own fields, so no note authors
145
+ it and nothing in the note format accepts it.
146
+
147
+ The build writes `resolvedDates`, `related`, `contains`, `held_by`, `holdings`
148
+ and `map` only when it has something to write. A page with no links either way
149
+ carries no `related`, and a place with no drawing carries no `map`.
150
+
151
+ An authored `aliases`, `related`, `contains`, `held_by`, `holdings` or `map` is
152
+ dropped. Each states a fact about the whole tree rather than about one note, so
153
+ the build replaces whatever a note wrote.
154
+
155
+ Artwork addresses are rewritten in place under `data`. A page's `data.icon`,
156
+ `data.bgImage` and `data.banner` carry the URLs the site serves rather than the
157
+ addresses the note authored, and there are four cases to tell apart:
158
+
159
+ | The note writes | The page carries |
160
+ | ---------------------------- | ---------------- |
161
+ | an address something answers | the resolved URL |
162
+ | an address nothing answers | no key at all |
163
+ | `null` | `null` |
164
+ | `""` | `""` |
165
+
166
+ An address nothing answers is deleted rather than written through, so an absent
167
+ key is the ordinary case for a theme to render nothing against, and no page
168
+ reaches a reader carrying a broken image source. The two empties are distinct
169
+ and both survive: `null` is a note naming no art, where a default may apply,
170
+ and `""` is a note refusing art, where no default may replace it.
171
+
172
+ The build writes these keys; [what the theme draws](../reference/theme-pages.md)
173
+ describes the panel each one feeds — the infobox, the hero band, the holdings
174
+ and related cards, the place map, and search.
175
+
135
176
  ## Build and serve
136
177
 
137
178
  ```bash
@@ -309,10 +309,10 @@ the rendered pages for search into `build/site/<contentPackage>/pagefind/`;
309
309
  and `serve:site` does the first and then `hugo server` for a live preview.
310
310
  The repository carries no Hugo configuration of its own: `hugo.toml` is
311
311
  generated on every run from `package.json`, `package-build.config.yaml`, the
312
- installed `@heroiclands/hugo-theme` and the navigation `deps fetch` cached,
313
- and the only file to add is `@heroiclands/hugo-theme` under
314
- `devDependencies`. Hugo itself is a separate install — the extended edition,
315
- on the developer's `PATH` and the runner's.
312
+ shared theme that ships with `@heroiclands/package-build`, and the navigation
313
+ `deps fetch` cached — a site declares this one package and needs nothing
314
+ further added for its theme. Hugo itself is a separate install — the
315
+ extended edition, on the developer's `PATH` and the runner's.
316
316
 
317
317
  The site build reads the cached navigation, so `build:site` in a package with
318
318
  no other dependency still runs `deps fetch` first: