@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +26 -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 +170 -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 +49 -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
@@ -0,0 +1,230 @@
1
+ /*
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ */
4
+
5
+ /** Preserve an exported Foundry Scene while binding marked pins to note pages. */
6
+
7
+ import { journalPageId, splitPages } from "./journals.mjs";
8
+
9
+ const EMBEDDED = Object.freeze([
10
+ "drawings",
11
+ "tokens",
12
+ "lights",
13
+ "notes",
14
+ "sounds",
15
+ "tiles",
16
+ "walls",
17
+ "regions",
18
+ "levels",
19
+ ]);
20
+
21
+ /** Parse the deliberately small path syntax used inside a Scene fixup. */
22
+ function fixupSegments(path) {
23
+ if (typeof path !== "string" || !path.startsWith(".")) {
24
+ throw new Error("a Scene fixup path must start with `.`");
25
+ }
26
+ const segments = [];
27
+ let at = 0;
28
+ while (at < path.length) {
29
+ const rest = path.slice(at);
30
+ const property = /^\.([A-Za-z_$][A-Za-z0-9_$]*)/.exec(rest);
31
+ if (property) {
32
+ segments.push({ kind: "property", key: property[1] });
33
+ at += property[0].length;
34
+ continue;
35
+ }
36
+ const bareId = /^\[([A-Za-z0-9]{16})\]/.exec(rest);
37
+ if (bareId) {
38
+ segments.push({ kind: "id", key: bareId[1] });
39
+ at += bareId[0].length;
40
+ continue;
41
+ }
42
+ const byId = /^\[_id=(?:"([A-Za-z0-9]{16})"|'([A-Za-z0-9]{16})'|([A-Za-z0-9]{16}))\]/.exec(
43
+ rest,
44
+ );
45
+ if (byId) {
46
+ segments.push({ kind: "id", key: byId[1] ?? byId[2] ?? byId[3] });
47
+ at += byId[0].length;
48
+ continue;
49
+ }
50
+ const index = /^\[(\d+)\]/.exec(rest);
51
+ if (index) {
52
+ segments.push({ kind: "index", key: Number(index[1]) });
53
+ at += index[0].length;
54
+ continue;
55
+ }
56
+ throw new Error(`invalid Scene fixup path "${path}" at "${rest}"`);
57
+ }
58
+ return segments;
59
+ }
60
+
61
+ /** Find the existing field a fixup addresses, without creating paths. */
62
+ function fixupTarget(scene, path) {
63
+ const segments = fixupSegments(path);
64
+ let current = scene;
65
+ for (const segment of segments.slice(0, -1)) {
66
+ if (segment.kind === "property") {
67
+ if (!current || typeof current !== "object" || !Object.hasOwn(current, segment.key)) {
68
+ throw new Error(`Scene fixup path "${path}" has no property "${segment.key}"`);
69
+ }
70
+ current = current[segment.key];
71
+ } else if (segment.kind === "index") {
72
+ if (!Array.isArray(current) || segment.key >= current.length) {
73
+ throw new Error(`Scene fixup path "${path}" has no array index ${segment.key}`);
74
+ }
75
+ current = current[segment.key];
76
+ } else {
77
+ if (!Array.isArray(current)) {
78
+ throw new Error(`Scene fixup path "${path}" needs an array for _id selection`);
79
+ }
80
+ const matches = current.filter((value) => value?._id === segment.key);
81
+ if (matches.length !== 1) {
82
+ throw new Error(`Scene fixup path "${path}" matches ${matches.length} documents`);
83
+ }
84
+ current = matches[0];
85
+ }
86
+ }
87
+ const last = segments.at(-1);
88
+ if (
89
+ last?.kind !== "property" ||
90
+ !current ||
91
+ typeof current !== "object" ||
92
+ !Object.hasOwn(current, last.key)
93
+ ) {
94
+ throw new Error(`Scene fixup path "${path}" must end at an existing property`);
95
+ }
96
+ return { parent: current, key: last.key };
97
+ }
98
+
99
+ /** Apply author-declared asset address replacements to an exported Scene. */
100
+ function applyFixups(scene, fixups, resolveAddress) {
101
+ if (fixups === undefined) return;
102
+ if (!Array.isArray(fixups)) throw new Error("`data.fixup` must be an array");
103
+ for (const [index, fixup] of fixups.entries()) {
104
+ if (
105
+ !fixup ||
106
+ typeof fixup !== "object" ||
107
+ Array.isArray(fixup) ||
108
+ fixup.type !== "address" ||
109
+ typeof fixup.value !== "string" ||
110
+ !fixup.value
111
+ ) {
112
+ throw new Error(
113
+ `data.fixup[${index}] needs path, type: address, and an asset address value`,
114
+ );
115
+ }
116
+ const { parent, key } = fixupTarget(scene, fixup.path);
117
+ if (typeof parent[key] !== "string" && parent[key] !== null) {
118
+ throw new Error(`Scene fixup path "${fixup.path}" must name a string asset field`);
119
+ }
120
+ if (typeof resolveAddress !== "function") {
121
+ throw new Error("Scene address fixups need an asset resolver");
122
+ }
123
+ const resolved = resolveAddress(fixup.value);
124
+ if (typeof resolved !== "string" || !resolved) {
125
+ throw new Error(`Scene fixup address "${fixup.value}" does not resolve to an asset`);
126
+ }
127
+ parent[key] = resolved;
128
+ }
129
+ }
130
+
131
+ /** Add a compendium key to one exported embedded document. */
132
+ function keyDocument(doc, collection, parentIds, sceneId) {
133
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
134
+ throw new Error(`data.scene.${collection} contains a non-document value`);
135
+ }
136
+ if (!/^[A-Za-z0-9]{16}$/.test(doc._id ?? "")) {
137
+ throw new Error(`data.scene.${collection} needs a 16-character _id on every document`);
138
+ }
139
+ doc._key = `!scenes.${collection}!${[sceneId, ...parentIds, doc._id].join(".")}`;
140
+ }
141
+
142
+ /**
143
+ * Compile a Foundry Scene export into a pack document.
144
+ *
145
+ * Only the note-derived Scene ID, compendium metadata, and pins marked
146
+ * `#anchor` change. Every other authored Scene field passes through.
147
+ *
148
+ * @param {object} fm - The map note's frontmatter.
149
+ * @param {string} markdown - Its converted Markdown body.
150
+ * @param {object} [opts] - Pack metadata, derived JournalEntry ID, and asset resolver.
151
+ * @returns {object} The keyed Scene document.
152
+ */
153
+ export function buildExportedScene(fm, markdown, { journalEntryId, stats, resolveAddress } = {}) {
154
+ if (!["battlemap", "localmap"].includes(fm.subType)) {
155
+ throw new Error("`data.scene` is for battlemap and localmap notes");
156
+ }
157
+ const source = fm.data?.scene;
158
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
159
+ throw new Error("a map note needs a Foundry Scene object at `data.scene`");
160
+ }
161
+ if (!/^[A-Za-z0-9]{16}$/.test(fm.id ?? "")) {
162
+ throw new Error("a map note needs a derived 16-character Scene ID");
163
+ }
164
+ const scene = structuredClone(source);
165
+ applyFixups(scene, fm.data?.fixup, resolveAddress);
166
+ if (
167
+ !(Number.isInteger(scene.width) && scene.width > 0) ||
168
+ !(Number.isInteger(scene.height) && scene.height > 0)
169
+ ) {
170
+ throw new Error("`data.scene` needs positive whole-number width and height");
171
+ }
172
+ if (!Array.isArray(scene.levels) || scene.levels.length === 0) {
173
+ throw new Error("`data.scene.levels` needs at least one Level");
174
+ }
175
+ scene._id = fm.id;
176
+ scene._key = `!scenes!${fm.id}`;
177
+ if (stats) scene._stats = stats;
178
+
179
+ for (const collection of EMBEDDED) {
180
+ const docs = scene[collection];
181
+ if (docs === undefined) continue;
182
+ if (!Array.isArray(docs)) {
183
+ throw new Error(`data.scene.${collection} must be an array`);
184
+ }
185
+ const ids = new Set();
186
+ for (const doc of docs) {
187
+ keyDocument(doc, collection, [], fm.id);
188
+ if (ids.has(doc._id))
189
+ throw new Error(`data.scene.${collection} repeats _id ${doc._id}`);
190
+ ids.add(doc._id);
191
+ if (collection !== "regions" || doc.behaviors === undefined) continue;
192
+ if (!Array.isArray(doc.behaviors)) {
193
+ throw new Error("data.scene.regions.behaviors must be an array");
194
+ }
195
+ const behaviorIds = new Set();
196
+ for (const behavior of doc.behaviors) {
197
+ keyDocument(behavior, "regions.behaviors", [doc._id], fm.id);
198
+ if (behaviorIds.has(behavior._id)) {
199
+ throw new Error(`data.scene.regions.behaviors repeats _id ${behavior._id}`);
200
+ }
201
+ behaviorIds.add(behavior._id);
202
+ }
203
+ }
204
+ }
205
+ if (scene.initialLevel && !scene.levels.some((level) => level._id === scene.initialLevel)) {
206
+ throw new Error(`data.scene.initialLevel "${scene.initialLevel}" names no Level`);
207
+ }
208
+
209
+ const pages = new Map();
210
+ for (const page of splitPages(markdown, fm.name?.full ?? scene.name)) {
211
+ if (!page.anchorSlug) continue;
212
+ if (pages.has(page.anchorSlug)) {
213
+ throw new Error(`the map note repeats anchor "${page.anchorSlug}"`);
214
+ }
215
+ pages.set(page.anchorSlug, page);
216
+ }
217
+ for (const pin of scene.notes ?? []) {
218
+ const match = /^#([A-Za-z0-9][A-Za-z0-9_-]*)$/.exec(String(pin.text ?? ""));
219
+ if (!match) continue;
220
+ const page = pages.get(match[1]);
221
+ if (!page) throw new Error(`Scene pin "${pin.text}" names no heading anchor in this note`);
222
+ if (!journalEntryId) {
223
+ throw new Error(`Scene pin "${pin.text}" needs a JournalEntry from this note`);
224
+ }
225
+ pin.entryId = journalEntryId;
226
+ pin.pageId = journalPageId(journalEntryId, page);
227
+ pin.text = page.name;
228
+ }
229
+ return scene;
230
+ }
@@ -584,6 +584,7 @@ async function generatePack(
584
584
  // for the same reason `itemsSourceDirs` is, and scoped to this
585
585
  // pack's system so a bundle holds the catalogue it is compiled for.
586
586
  bundleSourceDirs: bundleSourceJsonDirs(config, system ?? null),
587
+ config,
587
588
  folderResolver: resolver,
588
589
  // One answer to "which files are the corpus?", from the configuration
589
590
  // this build resolved rather than from the working directory.
package/engine/ids.mjs CHANGED
@@ -62,7 +62,7 @@ export const MAP_TYPES = Object.freeze(new Set(["map"]));
62
62
  *
63
63
  * @type {readonly string[]}
64
64
  */
65
- export const MAP_SUBTYPES = Object.freeze(["battlemap", "localmap", "regionalmap"]);
65
+ export const MAP_SUBTYPES = Object.freeze(["battlemap", "localmap", "regionalmap", "totm"]);
66
66
 
67
67
  /**
68
68
  * Content types whose whole document **is** a JournalEntry.
package/engine/index.mjs CHANGED
@@ -130,9 +130,15 @@ export * as calendars from "./calendars.mjs";
130
130
  /** A note's dates: one authored string, one grammar, one record shape. */
131
131
  export * as noteDates from "./note-dates.mjs";
132
132
 
133
+ /** Era markers resolved from calendar notes in a corpus. */
134
+ export * as reckoningMarkers from "./reckoning-markers.mjs";
135
+
133
136
  /** Calendars as notes: what one may declare, and the definition emitted from it. */
134
137
  export * as calendarNotes from "./calendar-notes.mjs";
135
138
 
139
+ /** Compiled calendar definitions and import files from the content index. */
140
+ export * as calendarArtifacts from "./calendar-artifacts.mjs";
141
+
136
142
  /** A being's `age`, absent beside a dated `born`: the age the date computes to. */
137
143
  export * as beingAge from "./being-age.mjs";
138
144
 
@@ -575,6 +575,7 @@ function structuredRows(field, raw, resolve, label, fm) {
575
575
  }
576
576
  if (isMapping(raw) && !isAddressTuple(raw)) {
577
577
  const groups = new Map();
578
+ const seen = new Set();
578
579
  for (const [target, relation] of Object.entries(raw)) {
579
580
  if (!hasValue(target) || !hasValue(relation) || isMapping(relation)) continue;
580
581
  const term = String(relation);
@@ -591,6 +592,9 @@ function structuredRows(field, raw, resolve, label, fm) {
591
592
  );
592
593
  if (address.reason || (field.accepts && !field.accepts.includes(address.type)))
593
594
  continue;
595
+ const canonical = renderAddress(address);
596
+ if (seen.has(canonical)) continue;
597
+ seen.add(canonical);
594
598
  }
595
599
  const links = groups.get(term) ?? [];
596
600
  links.push(linkValue(target, resolve, { type: field.ref }));
@@ -0,0 +1,170 @@
1
+ /*
2
+ * SPDX-License-Identifier: GPL-3.0-or-later
3
+ */
4
+
5
+ /** Gridless Foundry Scenes drawn from place relations. */
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ import { buildMaps, relatedPlaces } from "./map-build.mjs";
11
+ import { renderDot } from "./map-graphviz.mjs";
12
+ import { mapWorld } from "./map-places.mjs";
13
+ import { loadForeignIndexes } from "./metadata-index.mjs";
14
+ import { authoredFrontmatter, isNoteRecord } from "./index-records.mjs";
15
+ import { buildScene } from "./map-notes.mjs";
16
+ import { buildJournalEntry } from "./journals.mjs";
17
+ import { makeId } from "./ids.mjs";
18
+ import { stripAdventureKeys } from "./bundle-notes.mjs";
19
+
20
+ /** The PNG's dimensions, from its fixed header. */
21
+ function pngDimensions(file) {
22
+ const bytes = fs.readFileSync(file);
23
+ if (bytes.toString("hex", 0, 8) !== "89504e470d0a1a0a") {
24
+ throw new Error(`${file} is not a PNG`);
25
+ }
26
+ return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
27
+ }
28
+
29
+ /** GraphViz's plain coordinates, converted to pixel positions on its PNG. */
30
+ export function pinPositions(plain, width, height) {
31
+ const graph = /^graph\s+\S+\s+(\S+)\s+(\S+)/m.exec(plain);
32
+ if (!graph) throw new Error("GraphViz emitted no graph size for itinerary pins");
33
+ const graphWidth = Number(graph[1]);
34
+ const graphHeight = Number(graph[2]);
35
+ if (!(graphWidth > 0 && graphHeight > 0)) {
36
+ throw new Error("GraphViz emitted an invalid graph size for itinerary pins");
37
+ }
38
+ const positions = new Map();
39
+ for (const line of plain.split("\n")) {
40
+ const match = /^node\s+(?:"(p_[^"]+)"|(p_\S+))\s+(\S+)\s+(\S+)/.exec(line);
41
+ if (!match) continue;
42
+ const shortcode = (match[1] ?? match[2]).slice(2);
43
+ positions.set(shortcode, {
44
+ x: Math.round((Number(match[3]) / graphWidth) * width),
45
+ y: Math.round((1 - Number(match[4]) / graphHeight) * height),
46
+ });
47
+ }
48
+ return positions;
49
+ }
50
+
51
+ /** The compiled local journals a generated Adventure can import with its Scenes. */
52
+ function localJournals(dirs) {
53
+ const byId = new Map();
54
+ for (const dir of dirs) {
55
+ if (!fs.existsSync(dir)) continue;
56
+ for (const file of fs.readdirSync(dir)) {
57
+ if (!file.endsWith(".json")) continue;
58
+ const doc = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
59
+ if (String(doc._key ?? "").startsWith("!journal!")) byId.set(doc._id, doc);
60
+ }
61
+ }
62
+ return byId;
63
+ }
64
+
65
+ /**
66
+ * Emit backgrounds into the staged package and return Scenes and the journals
67
+ * their pins need. The caller writes them into its Scene and Adventure packs.
68
+ */
69
+ export function buildItineraryScenes({
70
+ config,
71
+ records,
72
+ contentBase,
73
+ journalSourceDirs = [],
74
+ stats,
75
+ foreign = loadForeignIndexes(config, [config.contentPackage]),
76
+ }) {
77
+ const world = mapWorld({ records, foreignIndex: foreign.index, contentBase, config });
78
+ const centres = relatedPlaces(world.places).filter((sc) => world.places.get(sc)?.local);
79
+ const findings = foreign.stale.map((stale) => ({
80
+ file: contentBase,
81
+ severity: "warning",
82
+ message: `the ${stale.package} content index is unavailable, so itinerary maps omit its places: ${stale.reason}`,
83
+ }));
84
+ const assetDir = path.resolve(
85
+ config.rootDir,
86
+ config.packageBuild.stageDir,
87
+ "assets/maps/generated-itineraries",
88
+ );
89
+ fs.rmSync(assetDir, { recursive: true, force: true });
90
+ if (!centres.length) return { scenes: [], journal: [], findings };
91
+
92
+ const outDir = path.resolve(config.rootDir, "build/map/foundry");
93
+ fs.rmSync(outDir, { recursive: true, force: true });
94
+ const drawn = buildMaps({ world, outDir, from: centres });
95
+ findings.push(...drawn.findings);
96
+ fs.mkdirSync(assetDir, { recursive: true });
97
+
98
+ const localRecords = new Map();
99
+ for (const record of records) {
100
+ if (!isNoteRecord(record) || record.type !== "place") continue;
101
+ localRecords.set(String(record.shortcode).toLowerCase(), authoredFrontmatter(record));
102
+ }
103
+ const compiledJournals = localJournals(journalSourceDirs);
104
+ const imported = new Map();
105
+ const scenes = [];
106
+ for (const centre of centres) {
107
+ const dot = path.join(outDir, `from-${centre}.dot`);
108
+ const png = path.join(assetDir, `from-${centre}.png`);
109
+ const plain = path.join(outDir, `from-${centre}.plain`);
110
+ renderDot(dot, png, { engine: "neato", format: "png", nop: 2, dpi: 150 });
111
+ renderDot(dot, plain, { engine: "neato", format: "plain", nop: 2 });
112
+ const [width, height] = pngDimensions(png);
113
+ const positions = pinPositions(fs.readFileSync(plain, "utf8"), width, height);
114
+ const sceneId = makeId("itinerary-scene", `${config.contentPackage}:${centre}`);
115
+ const src = `${config.packageKind}/${config.foundryPackage.id}/assets/maps/generated-itineraries/from-${centre}.png`;
116
+ const scene = buildScene(
117
+ {
118
+ id: sceneId,
119
+ name: { full: `From ${world.places.get(centre).name}` },
120
+ subType: "regionalmap",
121
+ sohl: { dimensions: [width, height], pxPerGrid: 100 },
122
+ data: { bgImage: src },
123
+ },
124
+ { art: () => src, stats },
125
+ );
126
+ scene.grid.distance = 1;
127
+ scene.grid.units = "";
128
+ for (const [shortcode, at] of positions) {
129
+ const place = world.places.get(shortcode);
130
+ if (!place) continue;
131
+ let journal;
132
+ if (place.local) {
133
+ journal = compiledJournals.get(localRecords.get(shortcode)?.id);
134
+ } else {
135
+ const ref = foreign.index.get(place.address);
136
+ if (ref?.uuid && /\.JournalEntry\./.test(ref.uuid)) {
137
+ const id = makeId("itinerary-foreign-journal", place.address);
138
+ journal = buildJournalEntry({
139
+ id,
140
+ name: place.name,
141
+ markdown: `@UUID[${ref.uuid}]{Open ${place.name}}`,
142
+ stats,
143
+ });
144
+ }
145
+ }
146
+ if (!journal) {
147
+ findings.push({
148
+ file: place.file ? path.join(contentBase, place.file) : contentBase,
149
+ severity: "warning",
150
+ message: `place "${shortcode}" has no addressable JournalEntry, so its itinerary label has no Foundry pin`,
151
+ });
152
+ continue;
153
+ }
154
+ imported.set(journal._id, stripAdventureKeys(journal));
155
+ const id = makeId("itinerary-note", `${sceneId}:${shortcode}`);
156
+ scene.notes.push({
157
+ _id: id,
158
+ entryId: journal._id,
159
+ pageId: null,
160
+ x: at.x,
161
+ y: at.y,
162
+ text: place.name,
163
+ iconSize: 32,
164
+ _key: `!scenes.notes!${sceneId}.${id}`,
165
+ });
166
+ }
167
+ scenes.push(scene);
168
+ }
169
+ return { scenes, journal: [...imported.values()], findings };
170
+ }
@@ -29,10 +29,7 @@
29
29
  * - **`travel`** — the whole route graph, `travel.svg`.
30
30
  *
31
31
  * Every rendering is written beside the `.dot` it was drawn from, and the
32
- * `.dot` is written first: with GraphViz absent the command either stops with
33
- * an error naming what to install, or — where the caller asked it to — writes
34
- * the `.dot` files, returns one warning, and skips the rendering, which is
35
- * what a site build asking for maps does rather than fail.
32
+ * `.dot` is written first.
36
33
  *
37
34
  * A build never mutates its inputs: nothing here reads a note except to
38
35
  * locate a finding, and everything lands under the output directory.
@@ -44,7 +41,7 @@ import fs from "node:fs";
44
41
  import path from "node:path";
45
42
 
46
43
  import { fromDot, travelDot, treeDot } from "./map-dot.mjs";
47
- import { findGraphviz, graphvizMissingMessage, renderDot } from "./map-graphviz.mjs";
44
+ import { renderDot } from "./map-graphviz.mjs";
48
45
  import { CHART_HORIZON_DAYS, layoutChart, layoutFrom, travelGraph } from "./map-layout.mjs";
49
46
  import { TRAVEL_DAYS } from "./place-relations.mjs";
50
47
  import { analyzeContainment, containmentFindings } from "./map-places.mjs";
@@ -106,17 +103,11 @@ export function relatedPlaces(places) {
106
103
  * @param {number} [opts.ranksep] - The tree's `ranksep`, inches.
107
104
  * @param {number} [opts.scale=1] - Multiplies every node's size and font.
108
105
  * @param {boolean} [opts.polities=true] - Whether the tree draws polities.
109
- * @param {(engine: string) => string|undefined} [opts.locate] - How a GraphViz
110
- * engine's binary is found; the real lookup by default.
111
- * @param {boolean} [opts.requireGraphviz=true] - Whether a missing engine is
112
- * an error. False, the `.dot` files are written, the renderings skipped,
113
- * and one warning returned.
114
106
  * @returns {{written: string[], findings: Array<{file: string, line?: number,
115
- * column?: number, severity: "error"|"warning", message: string}>,
116
- * skipped: boolean}} What was written, what was found, and whether the
117
- * rendering was skipped for want of GraphViz.
107
+ * column?: number, severity: "error"|"warning", message: string}>}} What was
108
+ * written and what was found.
118
109
  * @throws {Error} When no mode is asked for, a named place is not one, the
119
- * horizon is not a marker of the scale, or GraphViz is required and absent.
110
+ * horizon is not a marker of the scale.
120
111
  */
121
112
  export function buildMaps({
122
113
  world,
@@ -133,8 +124,6 @@ export function buildMaps({
133
124
  ranksep,
134
125
  scale = 1,
135
126
  polities = true,
136
- locate = findGraphviz,
137
- requireGraphviz = true,
138
127
  }) {
139
128
  if (!tree && from.length === 0 && chart.length === 0 && !travel) {
140
129
  throw new Error(
@@ -148,39 +137,15 @@ export function buildMaps({
148
137
  }
149
138
  const { places } = world;
150
139
 
151
- // Every binary this run needs, found once and before anything is drawn,
152
- // so a missing one is reported before a directory is touched.
153
- const engines = new Set();
154
- if (tree) engines.add(engine);
155
- if (from.length || chart.length || travel) engines.add("neato");
156
- /** @type {Map<string, string>} */
157
- const binaries = new Map();
158
140
  const findings = [];
159
- let skipped = false;
160
- for (const name of engines) {
161
- const binary = locate(name);
162
- if (binary) binaries.set(name, binary);
163
- else if (requireGraphviz) throw new Error(graphvizMissingMessage(name));
164
- else if (!skipped) {
165
- skipped = true;
166
- findings.push({
167
- file: outDir,
168
- severity: "warning",
169
- message: `${graphvizMissingMessage(name)}; the .dot files are written and the renderings skipped`,
170
- });
171
- }
172
- }
173
-
174
141
  fs.mkdirSync(outDir, { recursive: true });
175
142
  const written = [];
176
- const emit = (name, dot, engineName, args = []) => {
143
+ const emit = (name, dot, engineName, nop) => {
177
144
  const dotPath = path.join(outDir, `${name}.dot`);
178
145
  fs.writeFileSync(dotPath, dot);
179
146
  written.push(dotPath);
180
- const binary = binaries.get(engineName);
181
- if (!binary) return;
182
147
  const svgPath = path.join(outDir, `${name}.svg`);
183
- const { warnings } = renderDot(dotPath, svgPath, { binary, args });
148
+ const { warnings } = renderDot(dotPath, svgPath, { engine: engineName, nop });
184
149
  written.push(svgPath);
185
150
  if (warnings) {
186
151
  findings.push({ file: dotPath, severity: "warning", message: warnings });
@@ -226,7 +191,7 @@ export function buildMaps({
226
191
  message: `place "${place}" names "${to}" in a border or a route, and no place declares that shortcode; the map centred on "${centre}" leaves it out`,
227
192
  });
228
193
  }
229
- emit(`from-${centre}`, fromDot(layout, places, { scale }), "neato", ["-n2"]);
194
+ emit(`from-${centre}`, fromDot(layout, places, { scale }), "neato", 2);
230
195
  }
231
196
 
232
197
  const viewpoints = chart.includes(FROM_ALL) ? relatedPlaces(places) : chart;
@@ -239,7 +204,7 @@ export function buildMaps({
239
204
  message: `place "${place}" names "${to}" in a border or a route, and no place declares that shortcode; the chart centred on "${centre}" leaves it out`,
240
205
  });
241
206
  }
242
- emit(`chart-${centre}`, fromDot(layout, places, { scale }), "neato", ["-n2"]);
207
+ emit(`chart-${centre}`, fromDot(layout, places, { scale }), "neato", 2);
243
208
  }
244
209
 
245
210
  if (travel) {
@@ -254,7 +219,7 @@ export function buildMaps({
254
219
  emit("travel", travelDot(graph, places, { scale }), "neato");
255
220
  }
256
221
 
257
- return { written, findings, skipped };
222
+ return { written, findings };
258
223
  }
259
224
 
260
225
  /**
@@ -330,7 +330,7 @@ function emitLegend(lines, scale) {
330
330
  */
331
331
  function placeAttrs(place, analysis, scale) {
332
332
  const onCycle = analysis.cycleNodes.has(place.shortcode);
333
- const noParent = place.parents.length === 0 && place.shortcode !== analysis.world;
333
+ const noParent = place.parents.length === 0 && !analysis.worlds.includes(place.shortcode);
334
334
  return styleAttrs(place.subType ?? "settlement", {
335
335
  scale,
336
336
  label: place.name,
@@ -399,7 +399,10 @@ export function treeDot({
399
399
  scale = 1,
400
400
  drawPolities = true,
401
401
  }) {
402
- const world = analysis.world;
402
+ const world =
403
+ root === undefined ? analysis.world
404
+ : analysis.worlds.includes(root) ? root
405
+ : places.get(root)?.parents.find((parent) => analysis.worlds.includes(parent));
403
406
  let included;
404
407
  let scopeLabel;
405
408
  let worldIsContext = false;
@@ -439,7 +442,7 @@ export function treeDot({
439
442
  lines.push(' edge [fontname="Helvetica", fontsize=8];');
440
443
  const heading =
441
444
  title ??
442
- `${scopeLabel ?? (world ? places.get(world)?.name : "The world")} — containment tree`;
445
+ `${scopeLabel ?? (world ? places.get(world)?.name : "The worlds")} — containment tree`;
443
446
  lines.push(` label=${dotString(heading)};`);
444
447
  lines.push(' labelloc=t; fontsize=18; fontname="Helvetica-Bold";');
445
448
  lines.push("");