@heroiclands/package-build 22.23.0 → 22.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -0
  3. package/assets/styles/content-base.css +45 -0
  4. package/ci/check-banners.mjs +183 -0
  5. package/ci/ci-steps.mjs +21 -11
  6. package/docs/authoring/assets.md +15 -0
  7. package/docs/authoring/links-and-markup.md +69 -2
  8. package/docs/commands.md +18 -19
  9. package/docs/configuration.md +2 -6
  10. package/docs/content-format.md +9 -0
  11. package/docs/guides/module-project.md +2 -2
  12. package/docs/guides/site.md +41 -0
  13. package/docs/project-setup.md +4 -4
  14. package/docs/reference/format-details.md +3 -3
  15. package/docs/reference/theme-pages.md +496 -0
  16. package/engine/art-slots.mjs +2 -1
  17. package/engine/content-blocks.mjs +83 -14
  18. package/engine/content-figures.mjs +18 -3
  19. package/engine/content-poetry.mjs +61 -0
  20. package/engine/foreign-catalog.mjs +2 -1
  21. package/engine/helpers.mjs +5 -3
  22. package/engine/note-vocabulary.mjs +2 -1
  23. package/engine/pdf-build.mjs +1 -1
  24. package/engine/pdf-render.mjs +32 -2
  25. package/engine/rank-ladder.mjs +32 -0
  26. package/engine/site-config.mjs +19 -24
  27. package/hugo-theme/data/banners.yaml +69 -0
  28. package/hugo-theme/layouts/404.html +85 -0
  29. package/hugo-theme/layouts/_default/baseof.html +64 -0
  30. package/hugo-theme/layouts/_default/list.html +139 -0
  31. package/hugo-theme/layouts/_default/single.html +4 -0
  32. package/hugo-theme/layouts/blog/list.html +54 -0
  33. package/hugo-theme/layouts/blog-post/single.html +36 -0
  34. package/hugo-theme/layouts/index.html +75 -0
  35. package/hugo-theme/layouts/partials/banner-url.html +117 -0
  36. package/hugo-theme/layouts/partials/cdn-url.html +36 -0
  37. package/hugo-theme/layouts/partials/draft-notice.html +41 -0
  38. package/hugo-theme/layouts/partials/draft-state.html +26 -0
  39. package/hugo-theme/layouts/partials/footer.html +29 -0
  40. package/hugo-theme/layouts/partials/header.html +35 -0
  41. package/hugo-theme/layouts/partials/hero-banner.html +86 -0
  42. package/hugo-theme/layouts/partials/holdings/entry.html +21 -0
  43. package/hugo-theme/layouts/partials/holdings/kind-groups.html +71 -0
  44. package/hugo-theme/layouts/partials/holdings.html +61 -0
  45. package/hugo-theme/layouts/partials/infobox/entry.html +15 -0
  46. package/hugo-theme/layouts/partials/infobox/items.html +26 -0
  47. package/hugo-theme/layouts/partials/infobox/section.html +56 -0
  48. package/hugo-theme/layouts/partials/infobox/value.html +43 -0
  49. package/hugo-theme/layouts/partials/infobox.html +57 -0
  50. package/hugo-theme/layouts/partials/page-body.html +154 -0
  51. package/hugo-theme/layouts/partials/place-map.html +9 -0
  52. package/hugo-theme/layouts/partials/related.html +121 -0
  53. package/hugo-theme/layouts/partials/search.html +84 -0
  54. package/hugo-theme/layouts/partials/site-url.html +32 -0
  55. package/hugo-theme/layouts/partials/tags.html +30 -0
  56. package/hugo-theme/layouts/partials/toc.html +28 -0
  57. package/hugo-theme/layouts/partials/warn-missing-banner.html +3 -0
  58. package/hugo-theme/layouts/shortcodes/cartouche.html +64 -0
  59. package/hugo-theme/layouts/shortcodes/exturl.html +12 -0
  60. package/hugo-theme/layouts/shortcodes/glyph.html +43 -0
  61. package/hugo-theme/layouts/shortcodes/pagelist.html +63 -0
  62. package/hugo-theme/static/css/sohl-icons.css +57 -0
  63. package/hugo-theme/static/css/style.css +2982 -0
  64. package/hugo-theme/static/fonts/game-icons-kb.woff2 +0 -0
  65. package/hugo-theme/static/img/banners/takheperu-hieroglyphs.svg +1 -0
  66. package/hugo-theme/static/img/cartouches/takheperu.svg +1 -0
  67. package/hugo-theme/theme.toml +11 -0
  68. package/package.json +5 -2
  69. package/types/engine/art-slots.d.mts +2 -1
  70. package/types/engine/content-figures.d.mts +1 -0
  71. package/types/engine/content-poetry.d.mts +19 -0
  72. package/types/engine/helpers.d.mts +5 -3
  73. package/types/engine/rank-ladder.d.mts +8 -0
  74. package/types/engine/site-config.d.mts +9 -9
@@ -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
+ }
@@ -62,7 +62,6 @@ import os from "node:os";
62
62
  import path from "node:path";
63
63
 
64
64
  import { unzipSync } from "fflate";
65
- import { extractPack } from "@foundryvtt/foundryvtt-cli";
66
65
 
67
66
  import log from "loglevel";
68
67
 
@@ -326,6 +325,8 @@ async function extractItemPacks(id, version, manifest, root, dir) {
326
325
  `cannot supply an item catalogue`,
327
326
  );
328
327
  }
328
+ // Loaded here so importing the content index does not load the extractor.
329
+ const { extractPack } = await import("@foundryvtt/foundryvtt-cli");
329
330
  for (const pack of itemPacks) {
330
331
  const src = resolvePackPath(root, pack.path);
331
332
  if (!src) {
@@ -617,9 +617,11 @@ export function makeFilename(name, id) {
617
617
  * apply to.
618
618
  *
619
619
  * **`banner:` does not follow it either, deliberately.** It is not a file
620
- * inside a Foundry install: it reaches no compiled document and no book, its
621
- * only consumer is the Hugo theme, and the theme resolves it against the site's
622
- * own asset root. See `docs/content-format.md`.
620
+ * inside a Foundry install: it reaches no compiled document, and the site build
621
+ * resolves it through the website's own resolver before a page is written, so
622
+ * the theme receives a URL rather than an address. The book's section plates
623
+ * read a banner of their own, from the book plan's `presentation.page.banner`
624
+ * rather than from a note. See `docs/guides/site.md`.
623
625
  *
624
626
  * For items, the default is the art paired with the type's builder, reached
625
627
  * through `itemArt()`, which runs the pathname back through this function so a
@@ -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
  *
@@ -42,6 +42,7 @@
42
42
 
43
43
  import fs from "node:fs";
44
44
  import path from "node:path";
45
+ import { fileURLToPath } from "node:url";
45
46
  import { stringify as stringifyToml } from "smol-toml";
46
47
 
47
48
  import { checkHomepage, fail } from "../config.mjs";
@@ -61,10 +62,15 @@ export const HUGO_CONTENT = `${HUGO_SOURCE}/content`;
61
62
  */
62
63
  export const DEPLOY_ROOT = "build/site";
63
64
 
64
- /** The npm package the shared theme arrives as. */
65
- export const THEME_PACKAGE = "@heroiclands/hugo-theme";
65
+ /**
66
+ * This package's own root, which is the directory Hugo reads themes from.
67
+ *
68
+ * The theme ships inside this package, so the directory holding it is found
69
+ * from this module's own location rather than searched for.
70
+ */
71
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
66
72
 
67
- /** The theme's name under `themesDir`, which is the package's unscoped name. */
73
+ /** The theme's directory name under `themesDir`. */
68
74
  export const THEME = "hugo-theme";
69
75
 
70
76
  /** The locale every site renders in. */
@@ -308,33 +314,22 @@ export async function fetchNavigation(
308
314
 
309
315
  /**
310
316
  * The `themesDir` for a repository, as the path from `build/hugo/` to the
311
- * directory holding the installed theme.
317
+ * directory holding the theme.
318
+ *
319
+ * The theme ships inside this package at `hugo-theme/`, so the directory Hugo
320
+ * reads themes from is {@link PACKAGE_ROOT}. `theme` stays {@link THEME}, so
321
+ * Hugo opens `<themesDir>/hugo-theme/layouts`.
312
322
  *
313
- * Resolved the way Node resolves a package — `node_modules/` in the
314
- * repository root, then in each parent — and written as a path rather than
315
- * assumed, so a worktree that resolves its parent's install says so in the
316
- * generated file.
323
+ * Written as a path rather than assumed: `build/hugo/` and the installed
324
+ * package sit at whatever distance the install put them, and a worktree
325
+ * resolving its parent's install says so in the generated file.
317
326
  *
318
327
  * @param {string} rootDir - The repository root.
319
328
  * @returns {string} The relative path, POSIX-separated.
320
- * @throws {Error} When the theme is installed nowhere above the root.
321
329
  */
322
330
  export function resolveThemesDir(rootDir) {
323
- let dir = path.resolve(rootDir);
324
- for (;;) {
325
- const scope = path.join(dir, "node_modules", path.dirname(THEME_PACKAGE));
326
- if (fs.existsSync(path.join(scope, path.basename(THEME_PACKAGE), "theme.toml"))) {
327
- const rel = path.relative(path.resolve(rootDir, HUGO_SOURCE), scope);
328
- return rel.split(path.sep).join("/");
329
- }
330
- const parent = path.dirname(dir);
331
- if (parent === dir) break;
332
- dir = parent;
333
- }
334
- throw new Error(
335
- `${THEME_PACKAGE} is not installed anywhere above ${rootDir} — add it to ` +
336
- "`devDependencies` and run `npm ci`",
337
- );
331
+ const from = path.resolve(rootDir, HUGO_SOURCE);
332
+ return path.relative(from, PACKAGE_ROOT).split(path.sep).join("/");
338
333
  }
339
334
 
340
335
  /**
@@ -0,0 +1,69 @@
1
+ # Which banner images exist in the shared artwork set.
2
+ #
3
+ # `partials/hero-banner.html` resolves a page's hero image to a path under
4
+ # `images/banners/`, and that path is emitted into a `background-image` with no
5
+ # way to test it: the artwork is served from the consuming site's
6
+ # `params.cdnBaseURL`, and Hugo cannot ask a remote host whether a URL exists.
7
+ # So a banner that was never drawn used to 404 in silence — the band rendered
8
+ # with its title, palette and gradient intact and a dead image URL behind it,
9
+ # and nothing failed: not the build, not Hugo, not the deploy guard
10
+ # (issue #36).
11
+ #
12
+ # This file is the declaration that replaces that missing test. A name listed
13
+ # here is asserted to exist; a resolved banner whose name is absent falls back
14
+ # to `default.webp` and Hugo warns once for it during the consumer's build.
15
+ #
16
+ # It speaks for the names this theme resolves itself — a hand-authored
17
+ # `banner:` and the subtype defaults below. A note's own `data.banner` arrives
18
+ # already resolved from an asset Address, against a real asset record, so it is
19
+ # used as given and never looked for here.
20
+ #
21
+ # Keeping it in step is not left to hope: `npm run lint:banners`
22
+ # (`ci/check-banners.mjs`, part of `npm run lint`) fetches every name below
23
+ # from a real asset host and fails on any non-200. Add a name here only after
24
+ # the file is published, or the check will say so.
25
+ #
26
+ # A consumer publishing its own artwork set overrides the whole list by
27
+ # shipping its own `data/banners.yaml` — Hugo reads the site's data directory
28
+ # in preference to the theme's.
29
+
30
+ # The asset root every name below is resolved under, and the name used when a
31
+ # resolved banner is not in `available`. Both are relative to the consuming
32
+ # site's `params.cdnBaseURL`, like every other image path in this theme.
33
+ dir: images/banners
34
+ fallback: default.webp
35
+
36
+ # Verified against https://cdn.heroiclands.org on 2026-08-29.
37
+ available:
38
+ - affliction
39
+ - armor-clothing
40
+ - character
41
+ - company
42
+ - containers
43
+ - continent
44
+ - creature
45
+ - default
46
+ - dev-docs
47
+ - faith
48
+ - fvtt-add-on-modules
49
+ - fvtt-cc-anvil
50
+ - misc-gear
51
+ - module-dnload
52
+ - mystical-abilities
53
+ - orators-chance
54
+ - organization
55
+ - pantheon
56
+ - people
57
+ - projectiles
58
+ - region
59
+ - rules
60
+ - settlement
61
+ - site
62
+ - skills-attributes
63
+ - sohl-icon-3d-1920x1080
64
+ - ta-kheperu-banner
65
+ - tamzir-banner
66
+ - tapestry-of-dreams
67
+ - trauma
68
+ - user-guide
69
+ - weapons