@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 +14 -0
- package/README.md +2 -0
- package/assets/styles/content-base.css +45 -0
- package/ci/check-banners.mjs +183 -0
- package/ci/ci-steps.mjs +21 -11
- package/docs/api.md +14 -11
- package/docs/authoring/frontmatter.md +21 -0
- package/docs/authoring/links-and-markup.md +69 -2
- package/docs/content-format.md +11 -0
- package/docs/reference/format-details.md +50 -35
- package/docs/reference/note-types.md +27 -26
- package/docs/reference/theme-pages.md +4 -2
- package/engine/calendar-notes.mjs +1 -1
- package/engine/content-blocks.mjs +83 -14
- package/engine/content-figures.mjs +18 -3
- package/engine/content-format.yaml +1 -0
- package/engine/content-poetry.mjs +61 -0
- package/engine/holdings.mjs +38 -66
- package/engine/index.mjs +1 -1
- package/engine/infobox.mjs +10 -0
- package/engine/metadata-index.mjs +3 -0
- package/engine/note-vocabulary.mjs +18 -5
- package/engine/pdf-build.mjs +1 -1
- package/engine/pdf-render.mjs +32 -2
- package/engine/rank-ladder.mjs +32 -0
- package/hugo-theme/data/banners.yaml +1 -1
- package/hugo-theme/static/css/style.css +29 -0
- package/package.json +4 -2
- package/types/engine/calendar-notes.d.mts +1 -1
- package/types/engine/content-figures.d.mts +1 -0
- package/types/engine/content-poetry.d.mts +19 -0
- package/types/engine/holdings.d.mts +20 -17
- package/types/engine/infobox.d.mts +1 -0
- package/types/engine/note-vocabulary.d.mts +4 -0
- package/types/engine/rank-ladder.d.mts +8 -0
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
|
|
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
|
|
174
|
+
body.push(next);
|
|
181
175
|
i += 1;
|
|
182
176
|
}
|
|
183
|
-
const
|
|
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(
|
|
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
|
|
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[]` |
|
|
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
|
-
| `
|
|
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 `:::`.
|
|
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/content-format.md
CHANGED
|
@@ -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).
|