@heroiclands/package-build 22.24.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.
- package/CHANGELOG.md +8 -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/authoring/links-and-markup.md +69 -2
- package/docs/content-format.md +9 -0
- package/docs/reference/format-details.md +3 -3
- package/docs/reference/theme-pages.md +2 -2
- package/engine/content-blocks.mjs +83 -14
- package/engine/content-figures.mjs +18 -3
- package/engine/content-poetry.mjs +61 -0
- package/engine/note-vocabulary.mjs +2 -1
- 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/content-figures.d.mts +1 -0
- package/types/engine/content-poetry.d.mts +19 -0
- package/types/engine/rank-ladder.d.mts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 22.24.0
|
|
4
12
|
|
|
5
13
|
### Minor Changes
|
|
@@ -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` +
|
|
@@ -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` |
|
|
@@ -2648,7 +2648,7 @@ displays **Table 1** as a link.
|
|
|
2648
2648
|
optionally followed by a braced attribute block in the grammar a heading and
|
|
2649
2649
|
a named block take too. A line of `:::` alone closes it. Between the
|
|
2650
2650
|
two, the first `///` written alone on a line, read at the top level — outside
|
|
2651
|
-
any nested code fence — divides what the fence holds from its caption; a `///`
|
|
2651
|
+
any nested code or poetry fence — divides what the fence holds from its caption; a `///`
|
|
2652
2652
|
written inside a nested fence is content, not a divider. A second top-level
|
|
2653
2653
|
`///` is an error, naming the line: a figure holds one caption, so it carries
|
|
2654
2654
|
one `///` line.
|
|
@@ -2681,11 +2681,11 @@ note and is `a figure needs a closing :::`, also at the opening line, and
|
|
|
2681
2681
|
nothing after it is scanned as a second fence.
|
|
2682
2682
|
|
|
2683
2683
|
**The counter is derived, never authored.** The fence's contents decide which
|
|
2684
|
-
of
|
|
2684
|
+
of six counters it draws from — `Code`, `Table`, `Figure`, `Map`, `Poem`, `Prose` — read from
|
|
2685
2685
|
the first top-level block inside the fence: a table is `Table`; a fenced `sql`
|
|
2686
2686
|
block is `Table`, because its rendered output is one; any other fenced or
|
|
2687
2687
|
indented code block is `Code`; one or more pictures and nothing else is
|
|
2688
|
-
`Figure`; everything else is `Prose`. A table or
|
|
2688
|
+
`Figure`; a `:::poetry` fence as the first nonblank content is `Poem`; everything else is `Prose`. A poem, table or picture written outside any
|
|
2689
2689
|
`:::figure` fence draws no label and is counted nowhere. The website and a
|
|
2690
2690
|
Foundry journal number each counter within the note; the book numbers each
|
|
2691
2691
|
counter across the book in reading order.
|
|
@@ -301,8 +301,8 @@ its label beneath whatever it holds:
|
|
|
301
301
|
</div>
|
|
302
302
|
```
|
|
303
303
|
|
|
304
|
-
The kind is `code`, `table`, `figure`, `map` or `prose`, labelled **Code**,
|
|
305
|
-
**Table**, **Figure**, **Map** and **Prose**. Every kind is drawn alike — the
|
|
304
|
+
The kind is `code`, `table`, `figure`, `map`, `poem` or `prose`, labelled **Code**,
|
|
305
|
+
**Table**, **Figure**, **Map**, **Poem** and **Prose**. Every kind is drawn alike — the
|
|
306
306
|
block carries the spacing, its contents add none of their own at the edges,
|
|
307
307
|
and the label reads as a caption rather than as another paragraph of the body
|
|
308
308
|
— so a kind added to the vocabulary arrives styled. A fence carrying an `id`
|
|
@@ -50,6 +50,7 @@ import MarkdownIt from "markdown-it";
|
|
|
50
50
|
import footnotePlugin from "markdown-it-footnote";
|
|
51
51
|
import deflistPlugin from "markdown-it-deflist";
|
|
52
52
|
import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
|
|
53
|
+
import { poetryMarkdown, verseLines } from "./content-poetry.mjs";
|
|
53
54
|
import { WITHHELD_CLASS, parseHeadingLine, withheldSections } from "./heading-attributes.mjs";
|
|
54
55
|
|
|
55
56
|
const parser = new MarkdownIt({ html: true }).use(footnotePlugin).use(deflistPlugin);
|
|
@@ -63,6 +64,8 @@ const titleParser = new MarkdownIt({ html: false });
|
|
|
63
64
|
|
|
64
65
|
const OPEN = /^:::([A-Za-z][A-Za-z0-9-]*)(?:[ \t]+(.*?))?[ \t]*$/;
|
|
65
66
|
const CLOSE = /^:::[ \t]*$/;
|
|
67
|
+
const INDENTED_POETRY_OPEN = /^ +:::poetry(?:[ \t]+(.*?))?[ \t]*$/;
|
|
68
|
+
const INDENTED_CLOSE = /^ *:::[ \t]*$/;
|
|
66
69
|
|
|
67
70
|
/** The blocks an author may open, with the heading each takes by default. */
|
|
68
71
|
export const BLOCK_NAMES = Object.freeze({
|
|
@@ -98,7 +101,7 @@ const FOREIGN = Object.freeze(["figure"]);
|
|
|
98
101
|
/** `title` is the heading. Everything else an author writes becomes an attribute. */
|
|
99
102
|
const TITLE = "title";
|
|
100
103
|
|
|
101
|
-
const names = () => Object.keys(BLOCK_NAMES).join(", ");
|
|
104
|
+
const names = () => [...Object.keys(BLOCK_NAMES), "poetry"].join(", ");
|
|
102
105
|
|
|
103
106
|
/** Text safe inside a double-quoted attribute. */
|
|
104
107
|
function escapeAttribute(value) {
|
|
@@ -132,7 +135,7 @@ export function scanBlocks(source) {
|
|
|
132
135
|
|
|
133
136
|
for (let i = 0; i < lines.length; i++) {
|
|
134
137
|
const line = lines[i];
|
|
135
|
-
const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
138
|
+
const fence = opening?.name === "poetry" ? null : /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
136
139
|
if (codeFence) {
|
|
137
140
|
if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
|
|
138
141
|
codeFence = null;
|
|
@@ -144,7 +147,7 @@ export function scanBlocks(source) {
|
|
|
144
147
|
continue;
|
|
145
148
|
}
|
|
146
149
|
|
|
147
|
-
if (CLOSE.test(line)) {
|
|
150
|
+
if (CLOSE.test(line) || (opening?.name === "poetry" && INDENTED_CLOSE.test(line))) {
|
|
148
151
|
// A closer belongs to the innermost block still open, whichever
|
|
149
152
|
// pass owns it.
|
|
150
153
|
if (opening && innerOpen > 0) {
|
|
@@ -163,11 +166,21 @@ export function scanBlocks(source) {
|
|
|
163
166
|
opening = null;
|
|
164
167
|
continue;
|
|
165
168
|
}
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
const bodyLines = lines.slice(opening.start + 1, i);
|
|
170
|
+
const rawBody = bodyLines.join("\n");
|
|
171
|
+
const body = opening.name === "poetry" ? rawBody : rawBody.trim();
|
|
172
|
+
if (opening.name === "poetry") {
|
|
173
|
+
for (let offset = 0; offset < bodyLines.length; offset++) {
|
|
174
|
+
const column = bodyLines[offset].indexOf("\t");
|
|
175
|
+
if (column >= 0)
|
|
176
|
+
errors.push({
|
|
177
|
+
line: opening.start + offset + 2,
|
|
178
|
+
column: column + 1,
|
|
179
|
+
message: "tabs are not allowed in poetry",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!body.trim()) {
|
|
171
184
|
errors.push({
|
|
172
185
|
line: opening.start + 1,
|
|
173
186
|
column: 1,
|
|
@@ -176,12 +189,23 @@ export function scanBlocks(source) {
|
|
|
176
189
|
opening = null;
|
|
177
190
|
continue;
|
|
178
191
|
}
|
|
192
|
+
if (opening.name === "poetry" && opening.attributes.syllables) {
|
|
193
|
+
const expected = opening.attributes.syllables.split(",").length;
|
|
194
|
+
const actual = verseLines(body).length;
|
|
195
|
+
if (expected !== actual)
|
|
196
|
+
errors.push({
|
|
197
|
+
line: opening.start + 1,
|
|
198
|
+
column: 1,
|
|
199
|
+
message: `poetry syllables= gives ${expected} counts for ${actual} verse lines`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
179
202
|
// An H1, or an anchored heading at any level, starts a Foundry
|
|
180
203
|
// journal page — splitting the block's own page in two and
|
|
181
204
|
// publishing the rest with no wrapper around it, a GM-only
|
|
182
205
|
// section included. Refused rather than split: a lower heading
|
|
183
206
|
// with no anchor is still the ordinary way to structure a box.
|
|
184
|
-
|
|
207
|
+
// Poetry treats those same characters as verse text.
|
|
208
|
+
for (let at = opening.start + 1; opening.name !== "poetry" && at < i; at++) {
|
|
185
209
|
if (!parseHeadingLine(lines[at])?.startsPage) continue;
|
|
186
210
|
const article = /^[aeiou]/i.test(opening.name) ? "an" : "a";
|
|
187
211
|
errors.push({
|
|
@@ -198,7 +222,8 @@ export function scanBlocks(source) {
|
|
|
198
222
|
continue;
|
|
199
223
|
}
|
|
200
224
|
|
|
201
|
-
const
|
|
225
|
+
const indentedPoetry = INDENTED_POETRY_OPEN.exec(line);
|
|
226
|
+
const open = OPEN.exec(line) ?? (indentedPoetry && [line, "poetry", indentedPoetry[1]]);
|
|
202
227
|
if (!open) continue;
|
|
203
228
|
const [, name, raw = ""] = open;
|
|
204
229
|
const at = { line: i + 1, column: 1 };
|
|
@@ -207,7 +232,7 @@ export function scanBlocks(source) {
|
|
|
207
232
|
else foreignOutside += 1;
|
|
208
233
|
continue;
|
|
209
234
|
}
|
|
210
|
-
if (!Object.hasOwn(BLOCK_NAMES, name)) {
|
|
235
|
+
if (!Object.hasOwn(BLOCK_NAMES, name) && name !== "poetry") {
|
|
211
236
|
errors.push({ ...at, message: `there is no ${name} block; the blocks are ${names()}` });
|
|
212
237
|
opening = { start: i, rejected: true };
|
|
213
238
|
continue;
|
|
@@ -254,11 +279,30 @@ export function scanBlocks(source) {
|
|
|
254
279
|
continue;
|
|
255
280
|
}
|
|
256
281
|
for (const [key, value] of Object.entries(parsed.values)) {
|
|
282
|
+
if (name === "poetry") {
|
|
283
|
+
if (!["form", "meter", "rhyme", "syllables", "lang"].includes(key))
|
|
284
|
+
errors.push({ ...at, message: `${key}= is not a poetry attribute` });
|
|
285
|
+
else if (key === "syllables" && !/^[1-9]\d*(?:,[1-9]\d*)*$/.test(value))
|
|
286
|
+
errors.push({
|
|
287
|
+
...at,
|
|
288
|
+
message: "poetry syllables= needs positive comma-separated counts",
|
|
289
|
+
});
|
|
290
|
+
else if (key === "lang" && !/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(value))
|
|
291
|
+
errors.push({ ...at, message: "poetry lang= needs a language tag" });
|
|
292
|
+
}
|
|
257
293
|
if (key === TITLE) title = value;
|
|
258
294
|
else attributes[key] = value;
|
|
259
295
|
}
|
|
260
296
|
}
|
|
261
|
-
opening = {
|
|
297
|
+
opening = {
|
|
298
|
+
start: i,
|
|
299
|
+
name,
|
|
300
|
+
title,
|
|
301
|
+
id,
|
|
302
|
+
classes,
|
|
303
|
+
attributes,
|
|
304
|
+
indent: name === "poetry" ? /^ */.exec(line)[0] : "",
|
|
305
|
+
};
|
|
262
306
|
}
|
|
263
307
|
|
|
264
308
|
if (opening && !opening.rejected) {
|
|
@@ -295,11 +339,26 @@ function attributeText(block, target, classes) {
|
|
|
295
339
|
const parts = [`class="${escapeAttribute(classes.join(" "))}"`];
|
|
296
340
|
if (id) parts.push(`id="${escapeAttribute(id)}"`);
|
|
297
341
|
for (const [key, value] of Object.entries(block.attributes)) {
|
|
298
|
-
|
|
342
|
+
const attribute = block.name === "poetry" && key !== "lang" ? `data-${key}` : key;
|
|
343
|
+
parts.push(`${attribute}="${escapeAttribute(value)}"`);
|
|
299
344
|
}
|
|
300
345
|
return parts.join(" ");
|
|
301
346
|
}
|
|
302
347
|
|
|
348
|
+
/** Keep a poem in its list, without turning standalone readable indentation into code. */
|
|
349
|
+
function poetryWrapperIndent(lines, block) {
|
|
350
|
+
const indent = block.indent;
|
|
351
|
+
if (indent.length < 4) return indent;
|
|
352
|
+
for (let at = block.start - 1; at >= 0; at--) {
|
|
353
|
+
const line = lines[at];
|
|
354
|
+
if (!line.trim()) continue;
|
|
355
|
+
const leading = /^ */.exec(line)[0].length;
|
|
356
|
+
if (/^ *(?:[-+*]|\d+[.)]) +/.test(line) && leading < indent.length) return indent;
|
|
357
|
+
if (leading < indent.length) break;
|
|
358
|
+
}
|
|
359
|
+
return "";
|
|
360
|
+
}
|
|
361
|
+
|
|
303
362
|
/**
|
|
304
363
|
* Render named blocks for one publishing surface.
|
|
305
364
|
*
|
|
@@ -327,7 +386,6 @@ export function renderBlocks(source, target) {
|
|
|
327
386
|
output.push(...lines.slice(cursor, block.start));
|
|
328
387
|
const classes = [block.name, ...block.classes];
|
|
329
388
|
const attributes = attributeText(block, target, classes);
|
|
330
|
-
const title = titleParser.renderInline(block.title);
|
|
331
389
|
// A container's body may hold a block of its own, rendered first so
|
|
332
390
|
// its own wrapper is already written when the outer one wraps it in
|
|
333
391
|
// turn — nesting composes because each level is markdown-with-raw-HTML
|
|
@@ -336,6 +394,17 @@ export function renderBlocks(source, target) {
|
|
|
336
394
|
BLOCK_CONTAINERS.includes(block.name) ?
|
|
337
395
|
renderBlocks(block.body, target).markdown
|
|
338
396
|
: block.body;
|
|
397
|
+
if (block.name === "poetry") {
|
|
398
|
+
const indent = poetryWrapperIndent(lines, block);
|
|
399
|
+
const verses = poetryMarkdown(held)
|
|
400
|
+
.split("\n")
|
|
401
|
+
.map((line) => (line ? `${indent}${line}` : line))
|
|
402
|
+
.join("\n");
|
|
403
|
+
output.push("", `${indent}<div ${attributes}>`, "", verses, "", `${indent}</div>`, "");
|
|
404
|
+
cursor = block.end + 1;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const title = titleParser.renderInline(block.title);
|
|
339
408
|
if (target === "foundry") {
|
|
340
409
|
output.push(
|
|
341
410
|
"",
|
|
@@ -107,6 +107,7 @@ export const FIGURE_NAMES = Object.freeze({
|
|
|
107
107
|
table: "Table",
|
|
108
108
|
figure: "Figure",
|
|
109
109
|
map: "Map",
|
|
110
|
+
poem: "Poem",
|
|
110
111
|
prose: "Prose",
|
|
111
112
|
});
|
|
112
113
|
|
|
@@ -204,9 +205,10 @@ function picturesAreMap(contents, resolveRole) {
|
|
|
204
205
|
* @param {string} contents - The contents, trimmed.
|
|
205
206
|
* @param {(address: string) => string|undefined} resolveRole - The caller's
|
|
206
207
|
* lookup from a picture's address to the role its asset declares.
|
|
207
|
-
* @returns {"code"|"table"|"figure"|"map"|"prose"} The kind.
|
|
208
|
+
* @returns {"code"|"table"|"figure"|"map"|"poem"|"prose"} The kind.
|
|
208
209
|
*/
|
|
209
210
|
function figureKind(first, contents, resolveRole) {
|
|
211
|
+
if (/^(?: *:::poetry(?:\s|$)|<div\b[^>]*\bclass="poetry(?:\s|"))/.test(contents)) return "poem";
|
|
210
212
|
if (first?.type === "table_open") return "table";
|
|
211
213
|
if (first?.type === "fence" && /^\s*sql\b/i.test(first.info ?? "")) return "table";
|
|
212
214
|
if (first?.type === "fence" || first?.type === "code_block") return "code";
|
|
@@ -240,7 +242,7 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
|
|
|
240
242
|
const lines = String(source ?? "").split("\n");
|
|
241
243
|
const figures = [];
|
|
242
244
|
const errors = [];
|
|
243
|
-
const counts = { code: 0, table: 0, figure: 0, map: 0, prose: 0 };
|
|
245
|
+
const counts = { code: 0, table: 0, figure: 0, map: 0, poem: 0, prose: 0 };
|
|
244
246
|
const ids = new Set();
|
|
245
247
|
const headingIds = [];
|
|
246
248
|
let codeFence = null;
|
|
@@ -292,10 +294,17 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
|
|
|
292
294
|
let close = start + 1;
|
|
293
295
|
let split = -1;
|
|
294
296
|
let nested = null;
|
|
297
|
+
let namedDepth = 0;
|
|
295
298
|
const fenced = new Set();
|
|
296
299
|
while (close < lines.length) {
|
|
297
300
|
const body = lines[close];
|
|
298
301
|
const bodyFence = FENCE.exec(body);
|
|
302
|
+
if (namedDepth) {
|
|
303
|
+
fenced.add(close);
|
|
304
|
+
if (/^ *:::[ \t]*$/.test(body)) namedDepth--;
|
|
305
|
+
close++;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
299
308
|
if (nested) {
|
|
300
309
|
fenced.add(close);
|
|
301
310
|
if (
|
|
@@ -313,6 +322,12 @@ export function scanFigures(source, { resolveRole = () => undefined } = {}) {
|
|
|
313
322
|
close++;
|
|
314
323
|
continue;
|
|
315
324
|
}
|
|
325
|
+
if (/^ *:::poetry(?:\s|$)/.test(body)) {
|
|
326
|
+
namedDepth++;
|
|
327
|
+
fenced.add(close);
|
|
328
|
+
close++;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
316
331
|
if (CLOSE.test(body)) break;
|
|
317
332
|
if (SPLIT.test(body)) {
|
|
318
333
|
if (split < 0) split = close;
|
|
@@ -446,7 +461,7 @@ export function renderFigureBlocks(
|
|
|
446
461
|
output.push(...lines.slice(cursor, figure.line - 1));
|
|
447
462
|
const numbered = (figure.id ? byId.get(figure.id) : null) ?? figure;
|
|
448
463
|
const contents = lines.slice(figure.bodyStart, figure.bodyEnd).join("\n");
|
|
449
|
-
const classes = ["content-figure", `content-figure-${
|
|
464
|
+
const classes = ["content-figure", `content-figure-${numbered.kind}`, ...figure.classes];
|
|
450
465
|
const id = figure.slug ? ` id="${escapeHtml(figure.slug)}"` : "";
|
|
451
466
|
output.push(`<div${id} class="${escapeHtml(classes.join(" "))}">`);
|
|
452
467
|
if (figure.kind === "figure" || figure.kind === "map") {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
|
2
|
+
|
|
3
|
+
/** The authored verse lines, excluding stanza separators. */
|
|
4
|
+
export function verseLines(body) {
|
|
5
|
+
return String(body ?? "")
|
|
6
|
+
.split("\n")
|
|
7
|
+
.filter((line) => line.trim());
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The least-indented verse is level zero. Every two additional leading spaces
|
|
12
|
+
* add one level; a leftover space is discarded and the level stops at eight.
|
|
13
|
+
* Blank lines divide stanzas rather than contributing to the baseline.
|
|
14
|
+
*/
|
|
15
|
+
export function poetryStanzas(body) {
|
|
16
|
+
const lines = String(body ?? "").split("\n");
|
|
17
|
+
const verses = verseLines(body);
|
|
18
|
+
if (!verses.length) return [];
|
|
19
|
+
const baseline = Math.min(...verses.map((line) => /^ */.exec(line)[0].length));
|
|
20
|
+
const stanzas = [];
|
|
21
|
+
let stanza = [];
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
if (!line.trim()) {
|
|
24
|
+
if (stanza.length) stanzas.push(stanza);
|
|
25
|
+
stanza = [];
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const spaces = /^ */.exec(line)[0].length;
|
|
29
|
+
stanza.push({
|
|
30
|
+
text: line.slice(spaces).trimEnd(),
|
|
31
|
+
level: Math.min(8, Math.floor((spaces - baseline) / 2)),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (stanza.length) stanzas.push(stanza);
|
|
35
|
+
return stanzas;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A verse is inline Markdown even when it starts with block syntax. */
|
|
39
|
+
export function inlineVerse(text) {
|
|
40
|
+
return String(text)
|
|
41
|
+
.replace(/^(#{1,6}\s|>\s|[-+*]\s|:{1,2}\s|`{3,}|~{3,}|-{3,}\s*$)/, "\\$1")
|
|
42
|
+
.replace(/^(\d+)([.)])\s/, "$1\\$2 ");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Keep stanzas as Markdown paragraphs and make every verse line a hard break.
|
|
47
|
+
* The surrounding renderer still sees the entire note, so links and footnote
|
|
48
|
+
* definitions remain in scope. The span carries the indent for each line.
|
|
49
|
+
*/
|
|
50
|
+
export function poetryMarkdown(body) {
|
|
51
|
+
return poetryStanzas(body)
|
|
52
|
+
.map((stanza) =>
|
|
53
|
+
stanza
|
|
54
|
+
.map(
|
|
55
|
+
({ text, level }) =>
|
|
56
|
+
`<span class="poetry-line${level ? ` i${level}` : ""}">${inlineVerse(text)}</span>`,
|
|
57
|
+
)
|
|
58
|
+
.join(" \n"),
|
|
59
|
+
)
|
|
60
|
+
.join("\n\n");
|
|
61
|
+
}
|
|
@@ -105,7 +105,7 @@ import { checkSocialTies } from "./social-ties.mjs";
|
|
|
105
105
|
import { checkStandings } from "./standings.mjs";
|
|
106
106
|
import { STANDING_BODY_TYPES } from "./standing-terms.mjs";
|
|
107
107
|
import { checkDatedOffices } from "./office-holders.mjs";
|
|
108
|
-
import { checkRankLadder } from "./rank-ladder.mjs";
|
|
108
|
+
import { checkAffiliationRankFloor, checkRankLadder } from "./rank-ladder.mjs";
|
|
109
109
|
import { checkCalendarChoice } from "./calendar-choice.mjs";
|
|
110
110
|
import { checkLoreEvents } from "./lore-events.mjs";
|
|
111
111
|
import { checkCultureChoice } from "./culture-choice.mjs";
|
|
@@ -1014,6 +1014,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
1014
1014
|
|
|
1015
1015
|
affiliation: Object.freeze({
|
|
1016
1016
|
stubbable: true,
|
|
1017
|
+
check: checkAffiliationRankFloor,
|
|
1017
1018
|
subTypes: Object.freeze([
|
|
1018
1019
|
"guild",
|
|
1019
1020
|
"order",
|
package/engine/pdf-build.mjs
CHANGED
|
@@ -744,7 +744,7 @@ export async function buildPdf({
|
|
|
744
744
|
* @param {number} columns - The entry's page columns.
|
|
745
745
|
* @returns {string} Typst markup.
|
|
746
746
|
*/
|
|
747
|
-
const figureCounts = { code: 0, table: 0, figure: 0, map: 0, prose: 0 };
|
|
747
|
+
const figureCounts = { code: 0, table: 0, figure: 0, map: 0, poem: 0, prose: 0 };
|
|
748
748
|
// The role lookup a `:::figure` fence's `map` counter reaches through —
|
|
749
749
|
// see `engine/content-figures.mjs`'s `resolveRole`. Addressed form, for a
|
|
750
750
|
// page's own scan below, which runs before its embeds are rewritten into
|
package/engine/pdf-render.mjs
CHANGED
|
@@ -88,6 +88,7 @@ export const BOOK_IMAGE_WIDTHS = Object.freeze({
|
|
|
88
88
|
import { slugify } from "./content-slug.mjs";
|
|
89
89
|
import { scanFigures } from "./content-figures.mjs";
|
|
90
90
|
import { BLOCK_CONTAINERS, scanBlocks, BLOCK_NAMES } from "./content-blocks.mjs";
|
|
91
|
+
import { inlineVerse, poetryStanzas } from "./content-poetry.mjs";
|
|
91
92
|
import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
|
|
92
93
|
import { HTML_TAG, htmlMessage } from "./content-html.mjs";
|
|
93
94
|
import { EXPRESSION } from "./markdown-expressions.mjs";
|
|
@@ -526,7 +527,16 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
526
527
|
}
|
|
527
528
|
}
|
|
528
529
|
if (!opts.insideAdmonition) {
|
|
529
|
-
const
|
|
530
|
+
const figuresHoldingPoetry = scanFigures(source).figures;
|
|
531
|
+
const blocks = scanBlocks(source).blocks.filter(
|
|
532
|
+
(block) =>
|
|
533
|
+
!figuresHoldingPoetry.some(
|
|
534
|
+
(figure) =>
|
|
535
|
+
block.name === "poetry" &&
|
|
536
|
+
block.start >= figure.bodyStart &&
|
|
537
|
+
block.end < figure.bodyEnd,
|
|
538
|
+
),
|
|
539
|
+
);
|
|
530
540
|
if (blocks.length) {
|
|
531
541
|
const lines = source.split("\n");
|
|
532
542
|
const output = [];
|
|
@@ -538,6 +548,24 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
538
548
|
insideAdmonition: true,
|
|
539
549
|
}),
|
|
540
550
|
);
|
|
551
|
+
if (block.name === "poetry") {
|
|
552
|
+
const content = poetryStanzas(block.body)
|
|
553
|
+
.map((stanza) =>
|
|
554
|
+
stanza
|
|
555
|
+
.map(({ text, level }) => {
|
|
556
|
+
const verse = markdownToTypst(inlineVerse(text), {
|
|
557
|
+
...sharedOptions,
|
|
558
|
+
insideAdmonition: true,
|
|
559
|
+
});
|
|
560
|
+
return `${level ? `#h(${level * 1.25}em)` : ""}${verse}`;
|
|
561
|
+
})
|
|
562
|
+
.join(" \\\n"),
|
|
563
|
+
)
|
|
564
|
+
.join("\n\n");
|
|
565
|
+
output.push(`\n#block(width: 100%)[${content}]\n`);
|
|
566
|
+
cursor = block.end + 1;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
541
569
|
const { color, background, symbol } = BLOCK_PRINT[block.name];
|
|
542
570
|
// The title is inline markdown, rendered the way every other
|
|
543
571
|
// phrase in the book is. Set as it was written it would be read
|
|
@@ -601,7 +629,9 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
601
629
|
{ ...caption, imagesRemaining: { n: countImages(block) } }
|
|
602
630
|
: caption;
|
|
603
631
|
const segments = [
|
|
604
|
-
|
|
632
|
+
caption.kind === "poem" ?
|
|
633
|
+
markdownToTypst(block, { ...sharedOptions, captions: undefined })
|
|
634
|
+
: renderMarkdownSegment(block, md, { ...ctx, caption: captioned }, definitions),
|
|
605
635
|
];
|
|
606
636
|
if (caption.kind !== "table" && caption.kind !== "figure" && caption.kind !== "map") {
|
|
607
637
|
segments.push(
|
package/engine/rank-ladder.mjs
CHANGED
|
@@ -50,6 +50,38 @@ const RUNG_KEYS = Object.freeze(["level", "title", "description", "lore"]);
|
|
|
50
50
|
/** The keys a rung must carry a value for. */
|
|
51
51
|
const REQUIRED_TEXT = Object.freeze(["title", "description"]);
|
|
52
52
|
|
|
53
|
+
/** Every affiliation has an ordinary standing that a being can name as rank 1. */
|
|
54
|
+
export function checkAffiliationRankFloor(note) {
|
|
55
|
+
if (note.fm?.type !== "affiliation") return [];
|
|
56
|
+
const governance = note.fm?.data?.governance;
|
|
57
|
+
const ranks = governance?.ranks;
|
|
58
|
+
// A non-list already has a shape finding. Do not repeat it here.
|
|
59
|
+
if (ranks !== undefined && ranks !== null && !Array.isArray(ranks)) return [];
|
|
60
|
+
if (
|
|
61
|
+
Array.isArray(ranks) &&
|
|
62
|
+
ranks.some((rung) => integer(rung?.level) && Number(rung.level) === 1)
|
|
63
|
+
)
|
|
64
|
+
return [];
|
|
65
|
+
|
|
66
|
+
const path = Array.isArray(ranks) ? ["data", "governance", "ranks"] : ["data", "governance"];
|
|
67
|
+
const atRankOrGovernance = positionOfFrontmatterPath(note.raw ?? "", path, { key: true });
|
|
68
|
+
const position =
|
|
69
|
+
atRankOrGovernance.line !== undefined ?
|
|
70
|
+
atRankOrGovernance
|
|
71
|
+
: positionOfFrontmatterPath(note.raw ?? "", ["data"], { key: true });
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
file: note.file,
|
|
75
|
+
...position,
|
|
76
|
+
severity: "error",
|
|
77
|
+
message:
|
|
78
|
+
!Array.isArray(ranks) || ranks.length === 0 ?
|
|
79
|
+
"affiliation needs data.governance.ranks with a level 1 standing"
|
|
80
|
+
: "affiliation data.governance.ranks needs a level 1 standing",
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
53
85
|
/**
|
|
54
86
|
* Whether a value is a YAML mapping rather than a list or a scalar.
|
|
55
87
|
*
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
# used as given and never looked for here.
|
|
20
20
|
#
|
|
21
21
|
# Keeping it in step is not left to hope: `npm run lint:banners`
|
|
22
|
-
# (`
|
|
22
|
+
# (`ci/check-banners.mjs`, part of `npm run lint`) fetches every name below
|
|
23
23
|
# from a real asset host and fails on any non-200. Add a name here only after
|
|
24
24
|
# the file is published, or the check will say so.
|
|
25
25
|
#
|
|
@@ -1373,6 +1373,35 @@ a.tag:hover {
|
|
|
1373
1373
|
background: var(--bg-card);
|
|
1374
1374
|
}
|
|
1375
1375
|
|
|
1376
|
+
/* Verse lines are hard breaks in the markup; paragraphs mark stanzas. */
|
|
1377
|
+
.single-content .poetry {
|
|
1378
|
+
margin: 1.5rem 0;
|
|
1379
|
+
line-height: 1.7;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
.single-content .poetry p {
|
|
1383
|
+
margin: 0;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
.single-content .poetry p + p {
|
|
1387
|
+
margin-top: 1em;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
.single-content .poetry .poetry-line {
|
|
1391
|
+
display: inline-block;
|
|
1392
|
+
margin-inline-start: var(--poetry-indent, 0em);
|
|
1393
|
+
white-space: pre-wrap;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
.single-content .poetry .poetry-line.i1 { --poetry-indent: 1.25em; }
|
|
1397
|
+
.single-content .poetry .poetry-line.i2 { --poetry-indent: 2.5em; }
|
|
1398
|
+
.single-content .poetry .poetry-line.i3 { --poetry-indent: 3.75em; }
|
|
1399
|
+
.single-content .poetry .poetry-line.i4 { --poetry-indent: 5em; }
|
|
1400
|
+
.single-content .poetry .poetry-line.i5 { --poetry-indent: 6.25em; }
|
|
1401
|
+
.single-content .poetry .poetry-line.i6 { --poetry-indent: 7.5em; }
|
|
1402
|
+
.single-content .poetry .poetry-line.i7 { --poetry-indent: 8.75em; }
|
|
1403
|
+
.single-content .poetry .poetry-line.i8 { --poetry-indent: 10em; }
|
|
1404
|
+
|
|
1376
1405
|
/* Ornamental horizontal rule: thin line flanking a centered gold
|
|
1377
1406
|
diamond. Uses gradients on ::before/::after to keep the markup
|
|
1378
1407
|
clean (authors just write `---` in markdown). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.25.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -187,10 +187,12 @@
|
|
|
187
187
|
"build:asset-index": "node ci/emit-asset-index.mjs",
|
|
188
188
|
"format": "prettier --write .",
|
|
189
189
|
"format:check": "prettier --check .",
|
|
190
|
-
"lint": "run-p --aggregate-output -c --max-parallel 4 format:check lint:markdown lint:yaml lint:labels lint:content-format",
|
|
190
|
+
"lint": "run-p --aggregate-output -c --max-parallel 4 format:check lint:markdown lint:yaml lint:labels lint:image-roles lint:banners lint:content-format",
|
|
191
191
|
"lint:markdown": "node bin/package-build.mjs markdown",
|
|
192
192
|
"lint:yaml": "node bin/package-build.mjs yaml",
|
|
193
193
|
"lint:labels": "node bin/package-build.mjs labels check",
|
|
194
|
+
"lint:image-roles": "vitest run tests/theme-image-roles.test.ts",
|
|
195
|
+
"lint:banners": "node ci/check-banners.mjs",
|
|
194
196
|
"lint:markdown:fix": "node bin/package-build.mjs markdown --fix",
|
|
195
197
|
"lint:content-format": "npm run lint:content-format:schema && npm run lint:content-format:fields && node ci/content-format-reference.mjs --check",
|
|
196
198
|
"lint:content-format:schema": "node bin/package-build.mjs content-format schema --schema sohl=tests/fixtures/content-format/schema-sohl.json --schema hm3=tests/fixtures/content-format/schema-hm3.json",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** The authored verse lines, excluding stanza separators. */
|
|
2
|
+
export function verseLines(body: any): string[];
|
|
3
|
+
/**
|
|
4
|
+
* The least-indented verse is level zero. Every two additional leading spaces
|
|
5
|
+
* add one level; a leftover space is discarded and the level stops at eight.
|
|
6
|
+
* Blank lines divide stanzas rather than contributing to the baseline.
|
|
7
|
+
*/
|
|
8
|
+
export function poetryStanzas(body: any): {
|
|
9
|
+
text: string;
|
|
10
|
+
level: number;
|
|
11
|
+
}[][];
|
|
12
|
+
/** A verse is inline Markdown even when it starts with block syntax. */
|
|
13
|
+
export function inlineVerse(text: any): string;
|
|
14
|
+
/**
|
|
15
|
+
* Keep stanzas as Markdown paragraphs and make every verse line a hard break.
|
|
16
|
+
* The surrounding renderer still sees the entire note, so links and footnote
|
|
17
|
+
* definitions remain in scope. The span carries the indent for each line.
|
|
18
|
+
*/
|
|
19
|
+
export function poetryMarkdown(body: any): string;
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/** Every affiliation has an ordinary standing that a being can name as rank 1. */
|
|
2
|
+
export function checkAffiliationRankFloor(note: any): {
|
|
3
|
+
severity: string;
|
|
4
|
+
message: string;
|
|
5
|
+
line?: number;
|
|
6
|
+
column?: number;
|
|
7
|
+
file: any;
|
|
8
|
+
}[];
|
|
1
9
|
/**
|
|
2
10
|
* Check one affiliation's `data.governance.ranks` against the rung contract.
|
|
3
11
|
*
|