@heroiclands/package-build 22.24.0 → 22.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.26.0
4
+
5
+ ### Minor Changes
6
+
7
+ Place notes can name their governing affiliation in `data.government`, or state `null` for complete anarchy. A place with positive population and no government now receives an advisory. Places with zero, null or unspecified population no longer receive the old unheld-land warning; holdings remain separate tenure facts. Governments appear in note infoboxes and persist in published metadata.
8
+
9
+ ## 22.25.0
10
+
11
+ ### Minor Changes
12
+
13
+ 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.
14
+
15
+ 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.
16
+
3
17
  ## 22.24.0
4
18
 
5
19
  ### Minor Changes
package/README.md CHANGED
@@ -46,4 +46,6 @@ The documentation lives in the Markdown files in this repository. Start with [th
46
46
  | Error locations and severities | [Diagnostics](docs/diagnostics.md) |
47
47
  | Architectural rationale | [Design](docs/design.md) |
48
48
 
49
+ Place notes may declare a governing affiliation with `data.government`, or explicit `null` for complete anarchy. Positive population with an omitted government produces an advisory. See [place governments](docs/authoring/frontmatter.md#place-governments).
50
+
49
51
  The package is GPL-3.0-or-later for code and CC-BY-SA-4.0 for original content. See [LICENSE.md](LICENSE.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` +
package/docs/api.md CHANGED
@@ -348,6 +348,8 @@ The note types the engine itself declares — vocabulary that is a fact about th
348
348
 
349
349
  ### `engine.noteVocabulary`
350
350
 
351
+ `DataFieldSpec.nullText` declares the infobox text for an explicitly authored null. The place `government` field uses “Complete anarchy”; omitted fields remain absent. Its Address targets must be affiliations.
352
+
351
353
  An addressed content note accepts exactly these top-level keys, in this order: `shortcode`, `name`, `type`, `subType`, `description`, `tags`, `data`, `dnd5e`, `hm3`, `sohl`. A key outside this list is an error at its own line. The system blocks are one per system the address vocabulary recognizes, sorted by name after the fixed keys, so a further system is a change to `SYSTEM_IDS` alone. No dnd5e-specific document types are declared. Within that shape, the closed vocabularies are the `data:` container and each type's `subType`.
352
354
 
353
355
  | Export | Signature | Returns | Use it when |
@@ -495,17 +497,18 @@ What a place is next to (`data.borders`) and reachable from (`data.routes`): the
495
497
 
496
498
  ### `engine.holdings`
497
499
 
498
- What lies within a place, who holds it, and what an affiliation holds — read off `data.parents` and `data.domains` across this package and every fetched index, inverted into the `contains`, `held_by` and `holdings` lists the site build writes — and the tenure check `package-build lint` runs on a place.
499
-
500
- | Export | Signature | Returns | Use it when |
501
- | ---------------------- | ------------------------------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
502
- | `HOLDINGS_KEYS` | `const HOLDINGS_KEYS` | `readonly string[]` | the three derived keys — `contains`, `held_by`, `holdings` — which a note cannot author |
503
- | `HELD_SUBTYPES` | `const HELD_SUBTYPES` | `readonly string[]` | the place subTypes tenure is checked on — `settlement`, `site`, `structure` |
504
- | `holdingsNode` | `holdingsNode(fm, { title, url })` | `HoldingsNode\|null` | reading a local note as a node of the derivation, or `null` for one that is neither a place nor an affiliation |
505
- | `foreignHoldingsNodes` | `foreignHoldingsNodes(foreignIndex)` | `HoldingsNode[]` | reading a fetched index's places and affiliations as nodes |
506
- | `holdingsPages` | `holdingsPages(nodes)` | `Map<string, Holdings>` | inverting `parents` and `domains` into each page's sorted lists, keyed by URL, for every page with at least one entry |
507
- | `shortcodesOf` | `shortcodesOf(value)` | `string[]` | the shortcodes a `parents` or `domains` value names — bare, addressed or wikilinked — empties dropped and repeats collapsed |
508
- | `checkHeld` | `checkHeld(note, { index })` | `object[]` | linting a place's tenure — a settlement, site or structure no affiliation's `domains` names is a warning at its `type:` line |
500
+ What lies within a place, who holds it, and what an affiliation holds — read off `data.parents` and `data.domains` across this package and every fetched index, inverted into the `contains`, `held_by` and `holdings` lists the site build writes — and the population-based government advisory `package-build lint` runs on a place.
501
+
502
+ | Export | Signature | Returns | Use it when |
503
+ | ---------------------- | ------------------------------------ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------- |
504
+ | `HOLDINGS_KEYS` | `const HOLDINGS_KEYS` | `readonly string[]` | the three derived keys — `contains`, `held_by`, `holdings` — which a note cannot author |
505
+ | `HELD_SUBTYPES` | `const HELD_SUBTYPES` | `readonly string[]` | deprecated historical tenure subtypes; government checks apply to every place subtype |
506
+ | `holdingsNode` | `holdingsNode(fm, { title, url })` | `HoldingsNode\|null` | reading a local note as a node of the derivation, or `null` for one that is neither a place nor an affiliation |
507
+ | `foreignHoldingsNodes` | `foreignHoldingsNodes(foreignIndex)` | `HoldingsNode[]` | reading a fetched index's places and affiliations as nodes |
508
+ | `holdingsPages` | `holdingsPages(nodes)` | `Map<string, Holdings>` | inverting `parents` and `domains` into each page's sorted lists, keyed by URL, for every page with at least one entry |
509
+ | `shortcodesOf` | `shortcodesOf(value)` | `string[]` | the shortcodes a `parents` or `domains` value names — bare, addressed or wikilinked — empties dropped and repeats collapsed |
510
+ | `checkGovernment` | `checkGovernment(note)` | `object[]` | warning at `data.population` for positive population with no own `data.government` key; explicit null means anarchy |
511
+ | `checkHeld` | `checkHeld(note, { index })` | `object[]` | deprecated compatibility wrapper for `checkGovernment`; holdings do not determine government |
509
512
 
510
513
  ### `engine.populations`
511
514
 
@@ -39,6 +39,27 @@ A `sohl:`, `hm3:` or `dnd5e:` block holds values for that Foundry system. Its `s
39
39
 
40
40
  One body describes the subject for both systems. A being's `{#appearance}` and `{#dossier}` sections feed SoHL Actor `system.appearance` and `system.dossier`, and HM3 Actor `system.description` and `system.biography`. An Item's `{#description}` page becomes a shared JournalEntryPage. Its resolved `@UUID[...]` pointer is stored in SoHL Item `system.docHtml` and HM3 Item `system.description`. An Item with no `{#description}` anchor uses its first page. Other H1 pages remain linkable.
41
41
 
42
+ ## Place governments
43
+
44
+ A place may state its governing affiliation in `data.government`. Use an affiliation Address, either a local shortcode or a fully qualified Address from this package or a fetched dependency:
45
+
46
+ ```yaml
47
+ data:
48
+ population: 1200
49
+ government: crown
50
+ ```
51
+
52
+ Write `government: null` for **complete anarchy**. Omitting the key leaves government unspecified. Null is retained in published metadata and displayed as “Complete anarchy” in website, Foundry and book infoboxes; an omitted key has no Government row. A reference displays a link to the governing affiliation.
53
+
54
+ | Population | Government | Governance advisory |
55
+ | --------------------- | ------------------- | -------------------------------- |
56
+ | Positive | Omitted | Warning at `data.population` |
57
+ | Missing, zero or null | Omitted | None |
58
+ | Any | Explicit `null` | None; complete anarchy |
59
+ | Any | Affiliation Address | None; the reference must resolve |
60
+
61
+ These rules apply to every place subtype, including settlements, sites, structures, features, regions and worlds. Empty strings, numbers, lists, maps, references to another note type and unresolved references are errors. They do not declare anarchy. `domains`, `parents`, containment and capitals do not supply a government. Holdings remain tenure facts; the former unconditional “unheld land” warning is retired.
62
+
42
63
  ## Publication and summaries
43
64
 
44
65
  A note infobox draws from shared `data`. A being profile shows its top-level `subType` as `Type: Character`, `Type: NPC`, or `Type: Creature`. A system infobox draws from the system's fields, and a mapped system with no document says “Not available.” The same authored body appears in Foundry, on the website, and in the book; the book places boxes after the authored body, Foundry appends one page per box, and the website places boxes in its responsive side rail. [Infobox details](../reference/format-details.md#the-infobox) describe field order and empty values.
@@ -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`.
@@ -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` |
@@ -76,3 +85,5 @@ A sheltered place where ships load and unload.
76
85
  The note's readable Address is `<package>-note-lore-harbor`. In prose, link to it with `[[lore-harbor|Harbor]]`. The package segment comes from `package-build.config.yaml`; a regular wikilink defaults to the readable `note` system. An image embed such as `![[icon-harbor|Harbor]]` defaults to `none` instead.
77
86
 
78
87
  The [first-note guide](authoring/first-note.md) shows the build commands and how to add a system document. Use the [command reference](commands.md) for exact flags and the [diagnostics guide](diagnostics.md) for located errors.
88
+
89
+ Place notes may declare a governing affiliation with `data.government`, or explicit `null` for complete anarchy. Positive population with an omitted government produces an advisory. See [place governments](authoring/frontmatter.md#place-governments).