@heroiclands/package-build 22.14.4 → 22.15.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 +26 -0
- package/CONTENT.md +25 -1
- package/bin/content-build.mjs +3 -7
- package/bin/package-build.mjs +25 -0
- package/docs/api.md +61 -34
- package/docs/commands.md +65 -9
- package/docs/configuration.md +6 -3
- package/docs/content-format.md +165 -121
- package/docs/diagnostics.md +1 -1
- package/docs/getting-started.md +23 -0
- package/engine/base-compiler.mjs +3 -1
- package/engine/being-age.mjs +16 -24
- package/engine/calendar-artifacts.mjs +153 -0
- package/engine/calendar-notes.mjs +64 -16
- package/engine/calendars.mjs +129 -0
- package/engine/content-index.mjs +3 -1
- package/engine/exported-scene.mjs +230 -0
- package/engine/generate.mjs +1 -0
- package/engine/ids.mjs +1 -1
- package/engine/index.mjs +6 -0
- package/engine/infobox.mjs +4 -0
- package/engine/itinerary-scenes.mjs +170 -0
- package/engine/map-build.mjs +10 -45
- package/engine/map-dot.mjs +6 -3
- package/engine/map-graphviz.mjs +50 -131
- package/engine/map-notes.mjs +26 -3
- package/engine/map-places.mjs +12 -6
- package/engine/map-raster.mjs +49 -0
- package/engine/note-dates.mjs +125 -85
- package/engine/note-vocabulary.mjs +22 -6
- package/engine/pdf-build.mjs +57 -1
- package/engine/pdf-render.mjs +18 -0
- package/engine/reckoning-markers.mjs +207 -0
- package/engine/scenes.mjs +113 -26
- package/engine/site-build.mjs +11 -13
- package/engine/site-maps.mjs +5 -35
- package/engine/site-preview.mjs +6 -1
- package/engine/social-tie-terms.mjs +3 -0
- package/engine/social-ties.mjs +30 -19
- package/package.json +3 -1
- package/types/engine/being-age.d.mts +2 -2
- package/types/engine/calendar-artifacts.d.mts +16 -0
- package/types/engine/calendar-notes.d.mts +1 -1
- package/types/engine/calendars.d.mts +65 -0
- package/types/engine/exported-scene.d.mts +12 -0
- package/types/engine/index.d.mts +2 -0
- package/types/engine/itinerary-scenes.d.mts +29 -0
- package/types/engine/map-build.d.mts +4 -13
- package/types/engine/map-graphviz.d.mts +17 -59
- package/types/engine/map-places.d.mts +5 -1
- package/types/engine/map-raster.d.mts +8 -0
- package/types/engine/note-dates.d.mts +6 -0
- package/types/engine/note-vocabulary.d.mts +3 -3
- package/types/engine/pdf-render.d.mts +7 -1
- package/types/engine/reckoning-markers.d.mts +27 -0
- package/types/engine/scenes.d.mts +9 -2
- package/types/engine/site-build.d.mts +1 -5
- package/types/engine/site-maps.d.mts +2 -7
- package/types/engine/social-tie-terms.d.mts +2 -0
- package/types/engine/social-ties.d.mts +2 -1
|
@@ -97,8 +97,9 @@ import {
|
|
|
97
97
|
// question, asked the way `checkPlace` and `checkCalendarNote` are.
|
|
98
98
|
import { checkBeingAge } from "./being-age.mjs";
|
|
99
99
|
import { checkSocialTies } from "./social-ties.mjs";
|
|
100
|
-
import { SOCIAL_TIES } from "./social-tie-terms.mjs";
|
|
100
|
+
import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
|
|
101
101
|
import { parseNoteDate } from "./note-dates.mjs";
|
|
102
|
+
import { reckoningContext } from "./reckoning-markers.mjs";
|
|
102
103
|
import { checkHeld } from "./holdings.mjs";
|
|
103
104
|
import { checkCitedPopulations, checkPopulation } from "./populations.mjs";
|
|
104
105
|
// What trade a settlement supports — the scale and the check that holds a
|
|
@@ -371,8 +372,9 @@ function checkPlace(note, opts) {
|
|
|
371
372
|
|
|
372
373
|
/** Validate a being's authored date using the shared note-date grammar. */
|
|
373
374
|
function checkBeingDate(key) {
|
|
374
|
-
return (note) =>
|
|
375
|
+
return (note, { index } = {}) =>
|
|
375
376
|
parseNoteDate(note.fm?.data?.[key], {
|
|
377
|
+
...reckoningContext(index),
|
|
376
378
|
field: `data.${key}`,
|
|
377
379
|
file: note.file,
|
|
378
380
|
raw: note.raw,
|
|
@@ -719,7 +721,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
719
721
|
name: "socialTies",
|
|
720
722
|
kind: "map",
|
|
721
723
|
keyKind: "address",
|
|
722
|
-
accepts:
|
|
724
|
+
accepts: SOCIAL_TIE_TARGET_TYPES,
|
|
723
725
|
terms: SOCIAL_TIES,
|
|
724
726
|
shape: "a map keyed by Address",
|
|
725
727
|
check: checkSocialTies,
|
|
@@ -1412,10 +1414,19 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
1412
1414
|
|
|
1413
1415
|
map: Object.freeze({
|
|
1414
1416
|
stubbable: true,
|
|
1415
|
-
//
|
|
1416
|
-
|
|
1417
|
-
subTypes: Object.freeze(["battlemap", "localmap", "regionalmap"]),
|
|
1417
|
+
// The subType classifies the map; `data.scene` carries its canvas.
|
|
1418
|
+
subTypes: Object.freeze(["battlemap", "localmap", "regionalmap", "totm"]),
|
|
1418
1419
|
data: Object.freeze([
|
|
1420
|
+
{
|
|
1421
|
+
name: "scene",
|
|
1422
|
+
...ANY,
|
|
1423
|
+
describe: "A Foundry Scene export with all its authored fields.",
|
|
1424
|
+
},
|
|
1425
|
+
{
|
|
1426
|
+
name: "fixup",
|
|
1427
|
+
...LIST,
|
|
1428
|
+
describe: "Asset address replacements in an exported Scene.",
|
|
1429
|
+
},
|
|
1419
1430
|
// A Scene has no `img`, so the shared art key reaches nothing here:
|
|
1420
1431
|
// a map's background is its own slot, and an `image` address.
|
|
1421
1432
|
{
|
|
@@ -1425,6 +1436,11 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
1425
1436
|
accepts: ART_SLOTS.find((slot) => slot.key === "bgImage").accepts,
|
|
1426
1437
|
describe: "The map's background art — an `image` address.",
|
|
1427
1438
|
},
|
|
1439
|
+
{
|
|
1440
|
+
name: "scale",
|
|
1441
|
+
...ANY,
|
|
1442
|
+
describe: "Regional map distance per grid unit: {distance, unit}.",
|
|
1443
|
+
},
|
|
1428
1444
|
{
|
|
1429
1445
|
name: "dimensions",
|
|
1430
1446
|
...LIST,
|
package/engine/pdf-build.mjs
CHANGED
|
@@ -100,7 +100,7 @@ import { collectContentPages, siteGates, tableUniverse, gatesFailed } from "./si
|
|
|
100
100
|
import { resolveInfoboxRef, wikiContext } from "./site-index.mjs";
|
|
101
101
|
import { resolveWebWikilinks } from "./web-wikilinks.mjs";
|
|
102
102
|
import { linkFindingMessage } from "./wikilink-syntax.mjs";
|
|
103
|
-
import { assetAddressIndex } from "./art-fields.mjs";
|
|
103
|
+
import { artPathname, assetAddressIndex } from "./art-fields.mjs";
|
|
104
104
|
import { expandContentTables } from "./content-tables.mjs";
|
|
105
105
|
import { renderSecretBlocks } from "./content-secrets.mjs";
|
|
106
106
|
import { protectCode } from "./code-fences.mjs";
|
|
@@ -118,6 +118,8 @@ import { isDraftNote } from "./note-vocabulary.mjs";
|
|
|
118
118
|
import { infoboxTypstPreamble, infoboxesToTypst, linkToTypst } from "./infobox-render.mjs";
|
|
119
119
|
import { noteInfoboxes } from "./infobox-registry.mjs";
|
|
120
120
|
import { resolveIconGlyphs } from "./pdf-fonts.mjs";
|
|
121
|
+
import { buildMaps, relatedPlaces } from "./map-build.mjs";
|
|
122
|
+
import { mapWorld } from "./map-places.mjs";
|
|
121
123
|
|
|
122
124
|
/**
|
|
123
125
|
* The file on disk an authored image pathname names, or `null`.
|
|
@@ -637,6 +639,59 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
|
|
|
637
639
|
|
|
638
640
|
const banners = stageBanners(plan.entries, resolved, outDir, findings);
|
|
639
641
|
|
|
642
|
+
/** @type {Map<string, {path: string, title: string}>} Entry anchor → map page. */
|
|
643
|
+
const maps = new Map();
|
|
644
|
+
const world = mapWorld({
|
|
645
|
+
records,
|
|
646
|
+
foreignIndex: gates.foreign.index,
|
|
647
|
+
contentBase,
|
|
648
|
+
config: resolved,
|
|
649
|
+
});
|
|
650
|
+
const related = new Set(relatedPlaces(world.places));
|
|
651
|
+
const mapDir = path.join(outDir, "maps");
|
|
652
|
+
fs.rmSync(mapDir, { recursive: true, force: true });
|
|
653
|
+
const mappedEntries = plan.entries.filter(
|
|
654
|
+
(entry) =>
|
|
655
|
+
entry.kind === "note" &&
|
|
656
|
+
entry.record?.type === "place" &&
|
|
657
|
+
related.has(String(entry.record.shortcode ?? "").toLowerCase()),
|
|
658
|
+
);
|
|
659
|
+
if (mappedEntries.length) {
|
|
660
|
+
const centres = [...new Set(mappedEntries.map((entry) => entry.record.shortcode))];
|
|
661
|
+
const drawn = buildMaps({ world, outDir: mapDir, from: centres });
|
|
662
|
+
findings.push(...drawn.findings);
|
|
663
|
+
for (const entry of mappedEntries) {
|
|
664
|
+
maps.set(entry.anchor, {
|
|
665
|
+
path: `maps/from-${entry.record.shortcode}.svg`,
|
|
666
|
+
title: `From here: ${entry.record.name?.full ?? entry.record.shortcode}`,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
for (const entry of plan.entries) {
|
|
671
|
+
if (entry.kind !== "note" || entry.record?.type !== "map") continue;
|
|
672
|
+
if (entry.record.subType !== "regionalmap") continue;
|
|
673
|
+
const image = entry.record.data?.bgImage;
|
|
674
|
+
if (!image) continue;
|
|
675
|
+
const found = artPathname(assets, image, "image", ["image", "icon"]);
|
|
676
|
+
if (!found.pathname) {
|
|
677
|
+
findings.push({
|
|
678
|
+
file: noteFile(contentBase, entry.record),
|
|
679
|
+
severity: "warning",
|
|
680
|
+
message: `the regional map's background "${image}" cannot be resolved for the book`,
|
|
681
|
+
});
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const staged = stagedImagePath(found.pathname, resolved);
|
|
685
|
+
if (!staged || !staged.from.toLowerCase().endsWith(".svg")) continue;
|
|
686
|
+
const target = path.join(outDir, staged.to);
|
|
687
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
688
|
+
fs.copyFileSync(staged.from, target);
|
|
689
|
+
maps.set(entry.anchor, {
|
|
690
|
+
path: staged.to,
|
|
691
|
+
title: entry.record.name?.full ?? entry.record.shortcode,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
640
695
|
const assembled = renderBook({
|
|
641
696
|
plan,
|
|
642
697
|
bodies,
|
|
@@ -647,6 +702,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
|
|
|
647
702
|
version,
|
|
648
703
|
preamble: infoboxTypstPreamble(),
|
|
649
704
|
banners,
|
|
705
|
+
maps,
|
|
650
706
|
});
|
|
651
707
|
// Last, over the whole document: a reference can only be checked once every
|
|
652
708
|
// declaration is in one string, and Typst treats a dangling one as fatal.
|
package/engine/pdf-render.mjs
CHANGED
|
@@ -963,6 +963,14 @@ export function bookTypstPreamble() {
|
|
|
963
963
|
' place(top, float: true, scope: "parent", clearance: 0.7em)[#body]\n' +
|
|
964
964
|
" }\n" +
|
|
965
965
|
"}",
|
|
966
|
+
"#let book-place-map(title, path) = {\n" +
|
|
967
|
+
" page(columns: 1)[\n" +
|
|
968
|
+
" #set par(justify: false, first-line-indent: 0em)\n" +
|
|
969
|
+
' #text(size: 18pt, weight: "bold", fill: book-head)[#title]\n' +
|
|
970
|
+
" #v(0.4cm)\n" +
|
|
971
|
+
' #image(path, width: 100%, height: book-text-height - 2cm, fit: "contain")\n' +
|
|
972
|
+
" ]\n" +
|
|
973
|
+
"}",
|
|
966
974
|
// Wide content spans the page, and how it spans depends on how tall it
|
|
967
975
|
// is: a float is unbreakable and silently overflows, so anything taller
|
|
968
976
|
// than a page takes pages of its own instead.
|
|
@@ -1077,6 +1085,8 @@ export function bookTypstPreamble() {
|
|
|
1077
1085
|
* declared it → the staged file's path, relative to the `.typ`. A declared
|
|
1078
1086
|
* banner this map does not carry has no file the compiler can open, so the
|
|
1079
1087
|
* plate draws without a picture.
|
|
1088
|
+
* @param {Map<string, {path: string, title: string}>} [opts.maps] - An entry
|
|
1089
|
+
* anchor to its SVG path and page heading. Each map takes a page after its entry.
|
|
1080
1090
|
* @returns {string} A complete `.typ` document.
|
|
1081
1091
|
*/
|
|
1082
1092
|
export function renderBook({
|
|
@@ -1089,6 +1099,7 @@ export function renderBook({
|
|
|
1089
1099
|
version = "",
|
|
1090
1100
|
preamble = "",
|
|
1091
1101
|
banners = new Map(),
|
|
1102
|
+
maps = new Map(),
|
|
1092
1103
|
} = {}) {
|
|
1093
1104
|
// The two halves of one superfamily, chosen together: matched metrics are
|
|
1094
1105
|
// most of why the sans can carry every heading over a serif body without
|
|
@@ -1236,6 +1247,13 @@ export function renderBook({
|
|
|
1236
1247
|
out.push(body);
|
|
1237
1248
|
out.push("");
|
|
1238
1249
|
}
|
|
1250
|
+
const map = maps.get(entry.anchor);
|
|
1251
|
+
if (map) {
|
|
1252
|
+
out.push(
|
|
1253
|
+
`#book-place-map([${escapeTypst(map.title)}], "${escapeTypstString(map.path)}")`,
|
|
1254
|
+
);
|
|
1255
|
+
out.push("");
|
|
1256
|
+
}
|
|
1239
1257
|
}
|
|
1240
1258
|
|
|
1241
1259
|
return `${out.join("\n")}\n`;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
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
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
12
|
+
import { canonicalDayOffset, daysInMonth } from "./calendars.mjs";
|
|
13
|
+
import { parseNoteDate } from "./note-dates.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve every declared reckoning marker against its calendar and era start.
|
|
17
|
+
* The marker names one era, while the calendar supplies the month layout.
|
|
18
|
+
*
|
|
19
|
+
* @param {{notes?: readonly object[]}} index - The corpus link index.
|
|
20
|
+
* @param {number} daysPerYear - The world's year length.
|
|
21
|
+
* @returns {{markers: Map<string, object>, findings: object[]}} Resolved markers and errors.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveReckoningMarkers(index, daysPerYear) {
|
|
24
|
+
const declared = new Map();
|
|
25
|
+
const markers = new Map();
|
|
26
|
+
const findings = [];
|
|
27
|
+
|
|
28
|
+
for (const note of index?.notes ?? []) {
|
|
29
|
+
const fm = note?.fm ?? note?.frontmatter ?? note;
|
|
30
|
+
if (fm?.type !== "lore" || fm?.subType !== "calendar") continue;
|
|
31
|
+
const eras = fm.data?.eras;
|
|
32
|
+
if (!Array.isArray(eras)) continue;
|
|
33
|
+
eras.forEach((row, position) => {
|
|
34
|
+
if (row?.marker === undefined) return;
|
|
35
|
+
const marker = row.marker;
|
|
36
|
+
const at = {
|
|
37
|
+
...(note.file === undefined ? {} : { file: note.file }),
|
|
38
|
+
...(note.raw === undefined ?
|
|
39
|
+
{}
|
|
40
|
+
: positionOfFrontmatterPath(note.raw, ["data", "eras", position, "marker"])),
|
|
41
|
+
};
|
|
42
|
+
if (typeof marker !== "string" || !/^[A-Z][A-Z0-9]*$/.test(marker)) {
|
|
43
|
+
findings.push({
|
|
44
|
+
...at,
|
|
45
|
+
severity: "error",
|
|
46
|
+
message:
|
|
47
|
+
"an era marker is uppercase letters and digits, starting with a letter",
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (declared.has(marker)) {
|
|
52
|
+
findings.push({
|
|
53
|
+
...at,
|
|
54
|
+
severity: "error",
|
|
55
|
+
message: `reckoning marker ${marker} is declared more than once`,
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
declared.set(marker, {
|
|
60
|
+
marker,
|
|
61
|
+
era: `${fm.shortcode}.${row.shortcode}`,
|
|
62
|
+
calendar: fm.data,
|
|
63
|
+
start: row.start,
|
|
64
|
+
end: row.end,
|
|
65
|
+
note,
|
|
66
|
+
position,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const visiting = new Set();
|
|
72
|
+
const failed = new Set();
|
|
73
|
+
function resolve(marker) {
|
|
74
|
+
if (markers.has(marker)) return markers.get(marker);
|
|
75
|
+
if (failed.has(marker)) return null;
|
|
76
|
+
const row = declared.get(marker);
|
|
77
|
+
if (!row) return null;
|
|
78
|
+
if (visiting.has(marker)) {
|
|
79
|
+
findings.push({
|
|
80
|
+
...(row.note.file === undefined ? {} : { file: row.note.file }),
|
|
81
|
+
severity: "error",
|
|
82
|
+
message: `reckoning marker cycle includes ${[...visiting, marker].join(" → ")}`,
|
|
83
|
+
});
|
|
84
|
+
failed.add(marker);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
visiting.add(marker);
|
|
88
|
+
if (row.start === undefined || row.start === null) {
|
|
89
|
+
findings.push({
|
|
90
|
+
...(row.note.file === undefined ? {} : { file: row.note.file }),
|
|
91
|
+
severity: "error",
|
|
92
|
+
message: `reckoning marker ${marker} needs an era start`,
|
|
93
|
+
});
|
|
94
|
+
failed.add(marker);
|
|
95
|
+
}
|
|
96
|
+
const dependency =
|
|
97
|
+
typeof row.start === "string" ? /^([A-Z][A-Z0-9]*)\(/.exec(row.start)?.[1] : null;
|
|
98
|
+
if (dependency && !failed.has(marker)) resolve(dependency);
|
|
99
|
+
if (!failed.has(marker)) {
|
|
100
|
+
const parsed = parseNoteDate(row.start, {
|
|
101
|
+
markers,
|
|
102
|
+
daysPerYear,
|
|
103
|
+
field: "data.eras.start",
|
|
104
|
+
allowUnknown: false,
|
|
105
|
+
file: row.note.file,
|
|
106
|
+
raw: row.note.raw,
|
|
107
|
+
keyPath: ["data", "eras", row.position, "start"],
|
|
108
|
+
});
|
|
109
|
+
findings.push(...parsed.findings);
|
|
110
|
+
if (
|
|
111
|
+
parsed.date?.known &&
|
|
112
|
+
Number.isSafeInteger(parsed.date.canonicalYear) &&
|
|
113
|
+
parsed.findings.every((f) => f.severity !== "error")
|
|
114
|
+
) {
|
|
115
|
+
const entry = {
|
|
116
|
+
era: row.era,
|
|
117
|
+
calendar: row.calendar,
|
|
118
|
+
epochYear: parsed.date.canonicalYear,
|
|
119
|
+
epochDay: parsed.date.canonicalDay ?? 1,
|
|
120
|
+
};
|
|
121
|
+
markers.set(marker, entry);
|
|
122
|
+
} else {
|
|
123
|
+
if (parsed.date && parsed.findings.length === 0)
|
|
124
|
+
findings.push({
|
|
125
|
+
...(row.note.file === undefined ? {} : { file: row.note.file }),
|
|
126
|
+
severity: "error",
|
|
127
|
+
message: `reckoning marker ${marker} needs a canonically resolved era start`,
|
|
128
|
+
});
|
|
129
|
+
failed.add(marker);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
visiting.delete(marker);
|
|
133
|
+
return markers.get(marker) ?? null;
|
|
134
|
+
}
|
|
135
|
+
for (const marker of declared.keys()) resolve(marker);
|
|
136
|
+
for (const [marker, row] of declared) {
|
|
137
|
+
const entry = markers.get(marker);
|
|
138
|
+
if (!entry || row.end === undefined || row.end === null) continue;
|
|
139
|
+
const parsed = parseNoteDate(row.end, {
|
|
140
|
+
markers,
|
|
141
|
+
daysPerYear,
|
|
142
|
+
field: "data.eras.end",
|
|
143
|
+
allowUnknown: false,
|
|
144
|
+
file: row.note.file,
|
|
145
|
+
raw: row.note.raw,
|
|
146
|
+
keyPath: ["data", "eras", row.position, "end"],
|
|
147
|
+
});
|
|
148
|
+
findings.push(...parsed.findings);
|
|
149
|
+
if (!Number.isSafeInteger(parsed.date?.canonicalYear)) {
|
|
150
|
+
if (parsed.date && parsed.findings.length === 0)
|
|
151
|
+
findings.push({
|
|
152
|
+
...(row.note.file === undefined ? {} : { file: row.note.file }),
|
|
153
|
+
severity: "error",
|
|
154
|
+
message: `reckoning marker ${marker} needs a canonically resolved era end`,
|
|
155
|
+
});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const firstEndDay = canonicalDayOffset(
|
|
159
|
+
parsed.date.canonicalYear,
|
|
160
|
+
parsed.date.canonicalDay ?? 1,
|
|
161
|
+
1,
|
|
162
|
+
daysPerYear,
|
|
163
|
+
);
|
|
164
|
+
const extraDays =
|
|
165
|
+
parsed.date.precision === "year" ? daysPerYear - 1
|
|
166
|
+
: parsed.date.precision === "month" ?
|
|
167
|
+
(daysInMonth(markers.get(parsed.date.marker)?.calendar, parsed.date.month) ?? 1) - 1
|
|
168
|
+
: 0;
|
|
169
|
+
const endOffset = firstEndDay + extraDays;
|
|
170
|
+
const startOffset = canonicalDayOffset(entry.epochYear, entry.epochDay, 1, daysPerYear);
|
|
171
|
+
if (endOffset < startOffset) {
|
|
172
|
+
findings.push({
|
|
173
|
+
...(row.note.file === undefined ? {} : { file: row.note.file }),
|
|
174
|
+
severity: "error",
|
|
175
|
+
message: `reckoning marker ${marker} ends before its era starts`,
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
entry.endOffset = endOffset;
|
|
180
|
+
}
|
|
181
|
+
return { markers, findings };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** One resolved date context per corpus index, shared by its note checks. */
|
|
185
|
+
const contexts = new WeakMap();
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Read the world's year length and its era markers from one corpus index.
|
|
189
|
+
*
|
|
190
|
+
* @param {{notes?: readonly object[]}|undefined} index - The corpus index.
|
|
191
|
+
* @returns {{markers: Map<string, object>, daysPerYear: number|undefined, findings: object[]}}
|
|
192
|
+
*/
|
|
193
|
+
export function reckoningContext(index) {
|
|
194
|
+
if (!index || typeof index !== "object")
|
|
195
|
+
return { markers: new Map(), daysPerYear: undefined, findings: [] };
|
|
196
|
+
const cached = contexts.get(index);
|
|
197
|
+
if (cached) return cached;
|
|
198
|
+
const world = (index.notes ?? []).find((note) => {
|
|
199
|
+
const fm = note?.fm ?? note?.frontmatter ?? note;
|
|
200
|
+
return fm?.type === "place" && fm?.data?.year?.days;
|
|
201
|
+
});
|
|
202
|
+
const fm = world?.fm ?? world?.frontmatter ?? world;
|
|
203
|
+
const daysPerYear = fm?.data?.year?.days;
|
|
204
|
+
const context = { ...resolveReckoningMarkers(index, daysPerYear), daysPerYear };
|
|
205
|
+
contexts.set(index, context);
|
|
206
|
+
return context;
|
|
207
|
+
}
|
package/engine/scenes.mjs
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
slugify,
|
|
58
58
|
defaultStats,
|
|
59
59
|
folderField,
|
|
60
|
+
resolveImg,
|
|
60
61
|
} from "./helpers.mjs";
|
|
61
62
|
import { BasePackCompiler } from "./base-compiler.mjs";
|
|
62
63
|
// What an Adventure member may carry is one rule, and the module that owns the
|
|
@@ -72,6 +73,11 @@ import { packRouter } from "./pack-router.mjs";
|
|
|
72
73
|
import { foundryPackageId } from "./content-package.mjs";
|
|
73
74
|
import { itemDocEntryId } from "./item-docs.mjs";
|
|
74
75
|
import { behaviorDocId, buildScene, isMapType, regionDocId } from "./map-notes.mjs";
|
|
76
|
+
import { buildItineraryScenes } from "./itinerary-scenes.mjs";
|
|
77
|
+
import { emitDiagnostic } from "./diagnostics.mjs";
|
|
78
|
+
import { rasterizeMapSvg } from "./map-raster.mjs";
|
|
79
|
+
import { artPathname, artSlot } from "./art-fields.mjs";
|
|
80
|
+
import { buildExportedScene } from "./exported-scene.mjs";
|
|
75
81
|
|
|
76
82
|
/**
|
|
77
83
|
* Every SoHL action name this build knows about, for the `action:` warning on a
|
|
@@ -127,6 +133,7 @@ export function collectKnownActionNames(repoRoot) {
|
|
|
127
133
|
export class Scenes extends BasePackCompiler {
|
|
128
134
|
static id = "scenes";
|
|
129
135
|
static label = "map";
|
|
136
|
+
static readsPackOutputOf = Object.freeze(["JournalEntry"]);
|
|
130
137
|
|
|
131
138
|
/**
|
|
132
139
|
* A map note's `bgImage` is its background art, and it is **required**: the
|
|
@@ -157,8 +164,12 @@ export class Scenes extends BasePackCompiler {
|
|
|
157
164
|
companionDests = {},
|
|
158
165
|
folderResolver = () => null,
|
|
159
166
|
repoRoot = process.cwd(),
|
|
167
|
+
config,
|
|
168
|
+
packName,
|
|
169
|
+
bundleSourceDirs = {},
|
|
170
|
+
corpus,
|
|
160
171
|
}) {
|
|
161
|
-
super({ contentBase, assetsBase, dest, folderResolver, skipDirectories });
|
|
172
|
+
super({ contentBase, assetsBase, dest, folderResolver, skipDirectories, packName, corpus });
|
|
162
173
|
if (!companionDests.adventures) {
|
|
163
174
|
throw new Error("Scenes compiler requires an `adventures` companion destination");
|
|
164
175
|
}
|
|
@@ -170,6 +181,9 @@ export class Scenes extends BasePackCompiler {
|
|
|
170
181
|
value: repoRoot,
|
|
171
182
|
writable: false,
|
|
172
183
|
});
|
|
184
|
+
this.mapConfig = config;
|
|
185
|
+
this.packName = packName;
|
|
186
|
+
this.journalSourceDirs = bundleSourceDirs.JournalEntry ?? [];
|
|
173
187
|
}
|
|
174
188
|
|
|
175
189
|
/**
|
|
@@ -424,28 +438,73 @@ export class Scenes extends BasePackCompiler {
|
|
|
424
438
|
const { value: authoredFolder } = folderField(fm);
|
|
425
439
|
const folder = this.folderResolver(authoredFolder, { isAddress: true });
|
|
426
440
|
const warnings = [];
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
441
|
+
if (fm.data?.scene && fm.subType === "regionalmap") {
|
|
442
|
+
throw new Error("`data.scene` is for battlemap and localmap notes");
|
|
443
|
+
}
|
|
444
|
+
if (fm.data?.fixup !== undefined && !fm.data?.scene) {
|
|
445
|
+
throw new Error("`data.fixup` needs an exported Scene at `data.scene`");
|
|
446
|
+
}
|
|
447
|
+
const bgImage =
|
|
448
|
+
fm.data?.scene ?
|
|
449
|
+
null
|
|
450
|
+
: this.artPathOf(fm.data?.bgImage, "bgImage", "image", artSlot("bgImage")?.accepts);
|
|
451
|
+
if (
|
|
452
|
+
bgImage?.toLowerCase().endsWith(".svg") &&
|
|
453
|
+
(!["regionalmap", "totm"].includes(fm.subType) ||
|
|
454
|
+
(fm.subType === "regionalmap" && !fm.data?.scale))
|
|
455
|
+
) {
|
|
456
|
+
throw new Error("an SVG map needs `subType: regionalmap` and `data.scale`");
|
|
457
|
+
}
|
|
458
|
+
const raster =
|
|
459
|
+
bgImage?.toLowerCase().endsWith(".svg") ?
|
|
460
|
+
rasterizeMapSvg({
|
|
461
|
+
foundryPath: bgImage,
|
|
462
|
+
width: fm.sohl?.dimensions?.[0],
|
|
463
|
+
height: fm.sohl?.dimensions?.[1],
|
|
464
|
+
sceneId: fm.id,
|
|
465
|
+
config: this.mapConfig,
|
|
466
|
+
})
|
|
467
|
+
: null;
|
|
468
|
+
const scene =
|
|
469
|
+
fm.data?.scene ?
|
|
470
|
+
buildExportedScene(fm, markdown, {
|
|
471
|
+
journalEntryId: entryId,
|
|
472
|
+
stats: this.stats,
|
|
473
|
+
resolveAddress: (address) => {
|
|
474
|
+
const result = artPathname(this.linkIndex, address, "image");
|
|
475
|
+
if (!result.resolved || !result.pathname) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
`Scene fixup address "${address}" cannot resolve to an asset: ${result.reason ?? "no pathname"}`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return resolveImg(result.pathname);
|
|
481
|
+
},
|
|
482
|
+
})
|
|
483
|
+
: buildScene(fm, {
|
|
484
|
+
packageId: foundryPackageId(),
|
|
485
|
+
// The art resolver, so the map pass turns an address into the path
|
|
486
|
+
// each surface serves without holding an index of its own. `accepts`
|
|
487
|
+
// is threaded through so `bgImage`, one of the four declared art
|
|
488
|
+
// slots, is held to its own accepted set here exactly as it is
|
|
489
|
+
// through `artPath`.
|
|
490
|
+
art: (value, key, type, accepts) =>
|
|
491
|
+
key === "bgImage" && raster ?
|
|
492
|
+
raster
|
|
493
|
+
: this.artPathOf(value, key, type, accepts),
|
|
494
|
+
name,
|
|
495
|
+
folder,
|
|
496
|
+
stats: this.stats,
|
|
497
|
+
journalEntryId: entryId,
|
|
498
|
+
// A map note's prose is a derived JournalEntry: it lands in the
|
|
499
|
+
// default JournalEntry pack, not in whichever Scene pack the map
|
|
500
|
+
// itself was routed to.
|
|
501
|
+
// This compile's router, as everywhere else in this pass.
|
|
502
|
+
journalPack: this.#packRouter.defaultOf("JournalEntry"),
|
|
503
|
+
pageIds: hasBody ? this.#pageIds(markdown, entryId, name) : new Map(),
|
|
504
|
+
knownActions: this.knownActions,
|
|
505
|
+
warnings,
|
|
506
|
+
...this.#resolvers(this.index, this.effectsByAddress, fm.shortcode),
|
|
507
|
+
});
|
|
449
508
|
for (const message of warnings) {
|
|
450
509
|
// Named by file, like every other note diagnostic. A map
|
|
451
510
|
// warning is about the note's frontmatter, which carries no
|
|
@@ -479,7 +538,7 @@ export class Scenes extends BasePackCompiler {
|
|
|
479
538
|
this.places.set(placeKey, {
|
|
480
539
|
key: placeKey,
|
|
481
540
|
name: sohlField(fm, "placeName", null) || name,
|
|
482
|
-
img: this.artPath(fm, "bgImage"),
|
|
541
|
+
img: fm.data?.scene ? null : this.artPath(fm, "bgImage"),
|
|
483
542
|
pinned: false,
|
|
484
543
|
scenes: [],
|
|
485
544
|
journal: [],
|
|
@@ -488,7 +547,7 @@ export class Scenes extends BasePackCompiler {
|
|
|
488
547
|
const place = this.places.get(placeKey);
|
|
489
548
|
place.scenes.push(stripAdventureKeys(scene));
|
|
490
549
|
if (journal) place.journal.push(stripAdventureKeys(journal));
|
|
491
|
-
if (Object.keys(fm.sohl?.locations ?? {}).length) {
|
|
550
|
+
if (scene.notes?.length || Object.keys(fm.sohl?.locations ?? {}).length) {
|
|
492
551
|
place.pinned = true;
|
|
493
552
|
}
|
|
494
553
|
return scene;
|
|
@@ -500,8 +559,36 @@ export class Scenes extends BasePackCompiler {
|
|
|
500
559
|
*
|
|
501
560
|
* @returns {Promise<void>}
|
|
502
561
|
*/
|
|
503
|
-
async finish() {
|
|
562
|
+
async finish(stats) {
|
|
504
563
|
this.adventureCount = 0;
|
|
564
|
+
const firstScenePack = this.mapConfig?.packs.find((pack) => pack.type === "Scene")?.name;
|
|
565
|
+
if (this.mapConfig && this.packName === firstScenePack) {
|
|
566
|
+
const generated = buildItineraryScenes({
|
|
567
|
+
config: this.mapConfig,
|
|
568
|
+
records: this.corpus.records,
|
|
569
|
+
contentBase: this.contentBase,
|
|
570
|
+
journalSourceDirs: this.journalSourceDirs,
|
|
571
|
+
stats: this.stats,
|
|
572
|
+
});
|
|
573
|
+
for (const finding of generated.findings) emitDiagnostic(finding);
|
|
574
|
+
if (generated.scenes.length) {
|
|
575
|
+
const place = {
|
|
576
|
+
key: "generated-itineraries",
|
|
577
|
+
name: "Itinerary Maps",
|
|
578
|
+
img: null,
|
|
579
|
+
pinned: true,
|
|
580
|
+
scenes: [],
|
|
581
|
+
journal: generated.journal,
|
|
582
|
+
};
|
|
583
|
+
for (const scene of generated.scenes) {
|
|
584
|
+
this.writeEntry(scene);
|
|
585
|
+
place.scenes.push(stripAdventureKeys(scene));
|
|
586
|
+
}
|
|
587
|
+
this.places.set(place.key, place);
|
|
588
|
+
stats.compiled += generated.scenes.length;
|
|
589
|
+
this.compiledCount = stats.compiled;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
505
592
|
for (const place of this.places.values()) {
|
|
506
593
|
// A scene that references nothing ships fine as a plain `scenes`
|
|
507
594
|
// entry; only pins need the id-preserving import an Adventure gives.
|
package/engine/site-build.mjs
CHANGED
|
@@ -78,6 +78,7 @@ import { indexRecordsFor } from "./content-index.mjs";
|
|
|
78
78
|
// A being's computed `age` — see `engine/being-age.mjs`. `pdf-build.mjs`
|
|
79
79
|
// reuses `collectContentPages`'s pages, so wiring it here reaches the book too.
|
|
80
80
|
import { applyComputedBeingAge, presentAmongRecords } from "./being-age.mjs";
|
|
81
|
+
import { reckoningContext } from "./reckoning-markers.mjs";
|
|
81
82
|
import { isNoteRecord, noteFile } from "./index-records.mjs";
|
|
82
83
|
// The one statement of what an empty body means, shared with the index.
|
|
83
84
|
import { isStubNote } from "./note-state.mjs";
|
|
@@ -196,19 +197,20 @@ export function collectContentPages(contentBase, ctx) {
|
|
|
196
197
|
// The package's declared present, read once from the same corpus the site
|
|
197
198
|
// and the PDF share — see `engine/being-age.mjs`. `pdf-build.mjs` reaches
|
|
198
199
|
// this through the pages this function returns, so one call covers both.
|
|
199
|
-
const
|
|
200
|
+
const records =
|
|
200
201
|
ctx.records ??
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
);
|
|
202
|
+
indexRecordsFor({
|
|
203
|
+
contentBase,
|
|
204
|
+
config: ctx.config,
|
|
205
|
+
skipDirectories: ctx.skipDirectories,
|
|
206
|
+
});
|
|
207
|
+
const present = presentAmongRecords(records);
|
|
208
|
+
const dates = reckoningContext({ notes: records });
|
|
207
209
|
|
|
208
210
|
for (const file of siteCorpusFiles(contentBase, ctx)) {
|
|
209
211
|
const note = readNote(file, ctx);
|
|
210
212
|
if (!note) continue;
|
|
211
|
-
applyComputedBeingAge(note.fm, present);
|
|
213
|
+
applyComputedBeingAge(note.fm, present, dates);
|
|
212
214
|
const { fm, body, bodyLine } = note;
|
|
213
215
|
// The configuration's, never a note's: `package:` is retired, so every
|
|
214
216
|
// note in the tree belongs to the package this repository compiles.
|
|
@@ -987,15 +989,12 @@ export function sitePageDecorator(config, index) {
|
|
|
987
989
|
* {@link module:engine/sql-tables.prepareSqlTables}. A page authoring an
|
|
988
990
|
* `sql` directive with none prepared is a table error: nothing here runs a
|
|
989
991
|
* query.
|
|
990
|
-
* @param {(engine: string) => string|undefined} [options.locate] - How a
|
|
991
|
-
* GraphViz engine's binary is found, for the maps; the real lookup by
|
|
992
|
-
* default.
|
|
993
992
|
* @returns {{gates: object, stats: object|null, tableErrors: object[],
|
|
994
993
|
* wikiErrors: object[], imageErrors: object[], mapFindings: object[],
|
|
995
994
|
* manifests: object|null}} `mapFindings` is what drawing the maps found —
|
|
996
995
|
* warnings, never a reason to fail the build.
|
|
997
996
|
*/
|
|
998
|
-
export function buildSite({ config, sqlTables
|
|
997
|
+
export function buildSite({ config, sqlTables } = {}) {
|
|
999
998
|
const resolved = config ?? loadPackConfig();
|
|
1000
999
|
const site = resolved.site;
|
|
1001
1000
|
const scheme = resolved.publish.address;
|
|
@@ -1179,7 +1178,6 @@ export function buildSite({ config, sqlTables, locate } = {}) {
|
|
|
1179
1178
|
foreignIndex: gates.foreign.index,
|
|
1180
1179
|
config: resolved,
|
|
1181
1180
|
base,
|
|
1182
|
-
...(locate ? { locate } : {}),
|
|
1183
1181
|
})
|
|
1184
1182
|
: { maps: new Map(), findings: [] };
|
|
1185
1183
|
|