@heroiclands/package-build 22.14.4 → 22.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/CONTENT.md +25 -1
  3. package/bin/content-build.mjs +3 -7
  4. package/bin/package-build.mjs +25 -0
  5. package/docs/api.md +61 -34
  6. package/docs/commands.md +65 -9
  7. package/docs/configuration.md +6 -3
  8. package/docs/content-format.md +165 -121
  9. package/docs/diagnostics.md +1 -1
  10. package/docs/getting-started.md +23 -0
  11. package/engine/base-compiler.mjs +3 -1
  12. package/engine/being-age.mjs +16 -24
  13. package/engine/calendar-artifacts.mjs +153 -0
  14. package/engine/calendar-notes.mjs +64 -16
  15. package/engine/calendars.mjs +129 -0
  16. package/engine/content-index.mjs +3 -1
  17. package/engine/exported-scene.mjs +230 -0
  18. package/engine/generate.mjs +1 -0
  19. package/engine/ids.mjs +1 -1
  20. package/engine/index.mjs +6 -0
  21. package/engine/infobox.mjs +4 -0
  22. package/engine/itinerary-scenes.mjs +172 -0
  23. package/engine/map-build.mjs +10 -45
  24. package/engine/map-dot.mjs +6 -3
  25. package/engine/map-graphviz.mjs +50 -131
  26. package/engine/map-notes.mjs +26 -3
  27. package/engine/map-places.mjs +12 -6
  28. package/engine/map-raster.mjs +51 -0
  29. package/engine/note-dates.mjs +125 -85
  30. package/engine/note-vocabulary.mjs +22 -6
  31. package/engine/pdf-build.mjs +57 -1
  32. package/engine/pdf-render.mjs +18 -0
  33. package/engine/reckoning-markers.mjs +207 -0
  34. package/engine/scenes.mjs +113 -26
  35. package/engine/site-build.mjs +11 -13
  36. package/engine/site-maps.mjs +5 -35
  37. package/engine/site-preview.mjs +6 -1
  38. package/engine/social-tie-terms.mjs +3 -0
  39. package/engine/social-ties.mjs +30 -19
  40. package/package.json +3 -1
  41. package/types/engine/being-age.d.mts +2 -2
  42. package/types/engine/calendar-artifacts.d.mts +16 -0
  43. package/types/engine/calendar-notes.d.mts +1 -1
  44. package/types/engine/calendars.d.mts +65 -0
  45. package/types/engine/exported-scene.d.mts +12 -0
  46. package/types/engine/index.d.mts +2 -0
  47. package/types/engine/itinerary-scenes.d.mts +29 -0
  48. package/types/engine/map-build.d.mts +4 -13
  49. package/types/engine/map-graphviz.d.mts +17 -59
  50. package/types/engine/map-places.d.mts +5 -1
  51. package/types/engine/map-raster.d.mts +8 -0
  52. package/types/engine/note-dates.d.mts +6 -0
  53. package/types/engine/note-vocabulary.d.mts +3 -3
  54. package/types/engine/pdf-render.d.mts +7 -1
  55. package/types/engine/reckoning-markers.d.mts +27 -0
  56. package/types/engine/scenes.d.mts +9 -2
  57. package/types/engine/site-build.d.mts +1 -5
  58. package/types/engine/site-maps.d.mts +2 -7
  59. package/types/engine/social-tie-terms.d.mts +2 -0
  60. 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: ["being", "affiliation"],
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
- // One type, three subTypes: they differ only in the canvas defaults
1416
- // derived for them, which is precisely what a subType decides.
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,
@@ -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.
@@ -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
- const scene = buildScene(fm, {
428
- packageId: foundryPackageId(),
429
- // The art resolver, so the map pass turns an address into the path
430
- // each surface serves without holding an index of its own. `accepts`
431
- // is threaded through so `bgImage`, one of the four declared art
432
- // slots, is held to its own accepted set here exactly as it is
433
- // through `artPath`.
434
- art: (value, key, type, accepts) => this.artPathOf(value, key, type, accepts),
435
- name,
436
- folder,
437
- stats: this.stats,
438
- journalEntryId: entryId,
439
- // A map note's prose is a derived JournalEntry: it lands in the
440
- // default JournalEntry pack, not in whichever Scene pack the map
441
- // itself was routed to.
442
- // This compile's router, as everywhere else in this pass.
443
- journalPack: this.#packRouter.defaultOf("JournalEntry"),
444
- pageIds: hasBody ? this.#pageIds(markdown, entryId, name) : new Map(),
445
- knownActions: this.knownActions,
446
- warnings,
447
- ...this.#resolvers(this.index, this.effectsByAddress, fm.shortcode),
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.
@@ -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 present = presentAmongRecords(
200
+ const records =
200
201
  ctx.records ??
201
- indexRecordsFor({
202
- contentBase,
203
- config: ctx.config,
204
- skipDirectories: ctx.skipDirectories,
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, locate } = {}) {
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