@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.
- package/CHANGELOG.md +22 -0
- package/README.md +1 -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/assets.md +15 -0
- package/docs/authoring/links-and-markup.md +69 -2
- package/docs/commands.md +18 -19
- package/docs/configuration.md +2 -6
- package/docs/content-format.md +9 -0
- package/docs/guides/module-project.md +2 -2
- package/docs/guides/site.md +41 -0
- package/docs/project-setup.md +4 -4
- package/docs/reference/format-details.md +3 -3
- package/docs/reference/theme-pages.md +496 -0
- package/engine/art-slots.mjs +2 -1
- package/engine/content-blocks.mjs +83 -14
- package/engine/content-figures.mjs +18 -3
- package/engine/content-poetry.mjs +61 -0
- package/engine/foreign-catalog.mjs +2 -1
- package/engine/helpers.mjs +5 -3
- 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/engine/site-config.mjs +19 -24
- package/hugo-theme/data/banners.yaml +69 -0
- package/hugo-theme/layouts/404.html +85 -0
- package/hugo-theme/layouts/_default/baseof.html +64 -0
- package/hugo-theme/layouts/_default/list.html +139 -0
- package/hugo-theme/layouts/_default/single.html +4 -0
- package/hugo-theme/layouts/blog/list.html +54 -0
- package/hugo-theme/layouts/blog-post/single.html +36 -0
- package/hugo-theme/layouts/index.html +75 -0
- package/hugo-theme/layouts/partials/banner-url.html +117 -0
- package/hugo-theme/layouts/partials/cdn-url.html +36 -0
- package/hugo-theme/layouts/partials/draft-notice.html +41 -0
- package/hugo-theme/layouts/partials/draft-state.html +26 -0
- package/hugo-theme/layouts/partials/footer.html +29 -0
- package/hugo-theme/layouts/partials/header.html +35 -0
- package/hugo-theme/layouts/partials/hero-banner.html +86 -0
- package/hugo-theme/layouts/partials/holdings/entry.html +21 -0
- package/hugo-theme/layouts/partials/holdings/kind-groups.html +71 -0
- package/hugo-theme/layouts/partials/holdings.html +61 -0
- package/hugo-theme/layouts/partials/infobox/entry.html +15 -0
- package/hugo-theme/layouts/partials/infobox/items.html +26 -0
- package/hugo-theme/layouts/partials/infobox/section.html +56 -0
- package/hugo-theme/layouts/partials/infobox/value.html +43 -0
- package/hugo-theme/layouts/partials/infobox.html +57 -0
- package/hugo-theme/layouts/partials/page-body.html +154 -0
- package/hugo-theme/layouts/partials/place-map.html +9 -0
- package/hugo-theme/layouts/partials/related.html +121 -0
- package/hugo-theme/layouts/partials/search.html +84 -0
- package/hugo-theme/layouts/partials/site-url.html +32 -0
- package/hugo-theme/layouts/partials/tags.html +30 -0
- package/hugo-theme/layouts/partials/toc.html +28 -0
- package/hugo-theme/layouts/partials/warn-missing-banner.html +3 -0
- package/hugo-theme/layouts/shortcodes/cartouche.html +64 -0
- package/hugo-theme/layouts/shortcodes/exturl.html +12 -0
- package/hugo-theme/layouts/shortcodes/glyph.html +43 -0
- package/hugo-theme/layouts/shortcodes/pagelist.html +63 -0
- package/hugo-theme/static/css/sohl-icons.css +57 -0
- package/hugo-theme/static/css/style.css +2982 -0
- package/hugo-theme/static/fonts/game-icons-kb.woff2 +0 -0
- package/hugo-theme/static/img/banners/takheperu-hieroglyphs.svg +1 -0
- package/hugo-theme/static/img/cartouches/takheperu.svg +1 -0
- package/hugo-theme/theme.toml +11 -0
- package/package.json +5 -2
- package/types/engine/art-slots.d.mts +2 -1
- package/types/engine/content-figures.d.mts +1 -0
- package/types/engine/content-poetry.d.mts +19 -0
- package/types/engine/helpers.d.mts +5 -3
- package/types/engine/rank-ladder.d.mts +8 -0
- 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
|
|
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
|
+
}
|
|
@@ -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) {
|
package/engine/helpers.mjs
CHANGED
|
@@ -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
|
|
621
|
-
*
|
|
622
|
-
*
|
|
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",
|
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
|
*
|
package/engine/site-config.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
65
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
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
|
-
|
|
324
|
-
|
|
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
|