@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 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*\|\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` +
@@ -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` |
@@ -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 four counters it draws from — `Code`, `Table`, `Figure`, `Prose` — read from
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 a picture written outside any
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 body = lines
167
- .slice(opening.start + 1, i)
168
- .join("\n")
169
- .trim();
170
- if (!body) {
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
- for (let at = opening.start + 1; at < i; at++) {
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 open = OPEN.exec(line);
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 = { start: i, name, title, id, classes, attributes };
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
- parts.push(`${key}="${escapeAttribute(value)}"`);
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-${figure.kind}`, ...figure.classes];
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",
@@ -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
@@ -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 { blocks } = scanBlocks(source);
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
- renderMarkdownSegment(block, md, { ...ctx, caption: captioned }, definitions),
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(
@@ -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
- # (`utils/check-banners.mjs`, part of `npm run lint`) fetches every name below
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.24.0",
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",
@@ -87,6 +87,7 @@ export const FIGURE_NAMES: Readonly<{
87
87
  table: "Table";
88
88
  figure: "Figure";
89
89
  map: "Map";
90
+ poem: "Poem";
90
91
  prose: "Prose";
91
92
  }>;
92
93
  /**
@@ -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
  *