@heroiclands/package-build 22.14.3 → 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 +32 -0
- package/CONTENT.md +25 -1
- package/bin/content-build.mjs +3 -7
- package/bin/package-build.mjs +25 -0
- package/docs/api.md +64 -36
- package/docs/commands.md +65 -9
- package/docs/configuration.md +6 -3
- package/docs/content-format.md +187 -140
- package/docs/diagnostics.md +1 -1
- package/docs/getting-started.md +27 -3
- 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-images.mjs +20 -10
- 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 +48 -7
- 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/content-images.d.mts +7 -4
- 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 +16 -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
|
@@ -66,6 +66,16 @@ import MarkdownIt from "markdown-it";
|
|
|
66
66
|
import { bookDraftNoticePreamble } from "./draft-notice.mjs";
|
|
67
67
|
import { iconPlugin, ICON_PATTERN } from "./content-icons.mjs";
|
|
68
68
|
import { IMAGE_CLASSES, IMAGE_FLOATS, imagePlugin } from "./content-images.mjs";
|
|
69
|
+
|
|
70
|
+
/** Requested print width for every named image size. */
|
|
71
|
+
export const BOOK_IMAGE_WIDTHS = Object.freeze({
|
|
72
|
+
auto: "auto",
|
|
73
|
+
small: "1.6cm",
|
|
74
|
+
medium: "3.2cm",
|
|
75
|
+
large: "5.6cm",
|
|
76
|
+
xlarge: "8cm",
|
|
77
|
+
"full-width": '"full-width"',
|
|
78
|
+
});
|
|
69
79
|
import { slugify } from "./content-slug.mjs";
|
|
70
80
|
|
|
71
81
|
/**
|
|
@@ -706,17 +716,19 @@ function renderLink(href, inner, ctx) {
|
|
|
706
716
|
*/
|
|
707
717
|
function renderImage(token, ctx) {
|
|
708
718
|
const alt = token.content || token.attrGet?.("alt") || "";
|
|
709
|
-
const caption =
|
|
710
|
-
alt ? `\n #text(size: 7.6pt, style: "italic", fill: luma(45%))[${escapeTypst(alt)}]` : "";
|
|
719
|
+
const caption = alt ? `[${escapeTypst(alt)}]` : "none";
|
|
711
720
|
const staged = ctx.images.get(token.attrGet?.("src") ?? "");
|
|
712
|
-
if (!staged)
|
|
721
|
+
if (!staged)
|
|
722
|
+
return alt ?
|
|
723
|
+
`\n#block(below: 0.6em)[#text(size: 7.6pt, style: "italic", fill: luma(45%))${caption}]\n\n`
|
|
724
|
+
: "";
|
|
713
725
|
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
` #image("${escapeTypstString(staged)}", width: 100%)${caption}\n]`;
|
|
726
|
+
const size = BOOK_IMAGE_WIDTHS[token.meta?.size] ?? BOOK_IMAGE_WIDTHS.auto;
|
|
727
|
+
const figure = `#book-image("${escapeTypstString(staged)}", requested: ${size}, caption: ${caption})`;
|
|
717
728
|
|
|
718
729
|
const width = token.meta?.classes?.[0];
|
|
719
|
-
const scope =
|
|
730
|
+
const scope =
|
|
731
|
+
token.meta?.size === "full-width" ? "parent" : (IMAGE_CLASSES[width]?.scope ?? "column");
|
|
720
732
|
const float = IMAGE_FLOATS[token.meta?.float];
|
|
721
733
|
// In the flow where it was written: no class asking for the page, and no
|
|
722
734
|
// position asking for the top or the bottom of the column.
|
|
@@ -804,6 +816,17 @@ export function bookTypstPreamble() {
|
|
|
804
816
|
"#let book-page-height = 11in",
|
|
805
817
|
"#let book-text-width = book-page-width - 2 * book-margin",
|
|
806
818
|
"#let book-text-height = book-page-height - 2 * book-margin",
|
|
819
|
+
// Use the file's natural measure for auto. Every requested width is
|
|
820
|
+
// bounded by the available measure and by the height of a page.
|
|
821
|
+
"#let book-image(path, requested: auto, caption: none) = layout(size => {\n" +
|
|
822
|
+
" let natural = measure(image(path))\n" +
|
|
823
|
+
' let target = if requested == auto { natural.width } else if requested == "full-width" { size.width } else { requested }\n' +
|
|
824
|
+
" let width = calc.min(target, size.width, book-text-height * 0.8 * (natural.width / natural.height))\n" +
|
|
825
|
+
" block(width: width, below: 0.6em)[\n" +
|
|
826
|
+
' #image(path, width: 100%, fit: "contain")\n' +
|
|
827
|
+
' #if caption != none { text(size: 7.6pt, style: "italic", fill: luma(45%))[#caption] }\n' +
|
|
828
|
+
" ]\n" +
|
|
829
|
+
"})",
|
|
807
830
|
// The title sets short of the measure, so a plate's last line does not
|
|
808
831
|
// run to the trimmed edge of the paper.
|
|
809
832
|
"#let book-title-measure = book-text-width - 2.5cm",
|
|
@@ -940,6 +963,14 @@ export function bookTypstPreamble() {
|
|
|
940
963
|
' place(top, float: true, scope: "parent", clearance: 0.7em)[#body]\n' +
|
|
941
964
|
" }\n" +
|
|
942
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
|
+
"}",
|
|
943
974
|
// Wide content spans the page, and how it spans depends on how tall it
|
|
944
975
|
// is: a float is unbreakable and silently overflows, so anything taller
|
|
945
976
|
// than a page takes pages of its own instead.
|
|
@@ -1054,6 +1085,8 @@ export function bookTypstPreamble() {
|
|
|
1054
1085
|
* declared it → the staged file's path, relative to the `.typ`. A declared
|
|
1055
1086
|
* banner this map does not carry has no file the compiler can open, so the
|
|
1056
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.
|
|
1057
1090
|
* @returns {string} A complete `.typ` document.
|
|
1058
1091
|
*/
|
|
1059
1092
|
export function renderBook({
|
|
@@ -1066,6 +1099,7 @@ export function renderBook({
|
|
|
1066
1099
|
version = "",
|
|
1067
1100
|
preamble = "",
|
|
1068
1101
|
banners = new Map(),
|
|
1102
|
+
maps = new Map(),
|
|
1069
1103
|
} = {}) {
|
|
1070
1104
|
// The two halves of one superfamily, chosen together: matched metrics are
|
|
1071
1105
|
// most of why the sans can carry every heading over a serif body without
|
|
@@ -1213,6 +1247,13 @@ export function renderBook({
|
|
|
1213
1247
|
out.push(body);
|
|
1214
1248
|
out.push("");
|
|
1215
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
|
+
}
|
|
1216
1257
|
}
|
|
1217
1258
|
|
|
1218
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.
|