@heroiclands/package-build 22.4.3 → 22.4.5

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 (48) hide show
  1. package/CHANGELOG.md +28 -18
  2. package/CONTENT.md +77 -1
  3. package/README.md +9 -0
  4. package/bin/content-build.mjs +143 -0
  5. package/bin/package-build.mjs +18 -4
  6. package/content-config.mjs +7 -0
  7. package/docs/api.md +84 -0
  8. package/docs/commands.md +182 -2
  9. package/docs/configuration.md +19 -1
  10. package/docs/content-format.md +105 -6
  11. package/docs/diagnostics.md +1 -0
  12. package/docs/project-setup.md +7 -6
  13. package/engine/changelog-group.mjs +27 -6
  14. package/engine/changelog-lint.mjs +27 -7
  15. package/engine/frontmatter-lint.mjs +11 -1
  16. package/engine/homepage.mjs +9 -1
  17. package/engine/index.mjs +18 -0
  18. package/engine/map-build.mjs +240 -0
  19. package/engine/map-dot.mjs +685 -0
  20. package/engine/map-graphviz.mjs +138 -0
  21. package/engine/map-layout.mjs +712 -0
  22. package/engine/map-places.mjs +561 -0
  23. package/engine/metadata-index.mjs +7 -0
  24. package/engine/note-vocabulary.mjs +28 -0
  25. package/engine/place-relations.mjs +637 -0
  26. package/engine/related-pages.mjs +161 -0
  27. package/engine/site-build.mjs +107 -9
  28. package/engine/site-config.mjs +1 -1
  29. package/engine/site-index.mjs +5 -1
  30. package/engine/site-root.mjs +147 -6
  31. package/engine/web-wikilinks.mjs +13 -2
  32. package/package.json +3 -2
  33. package/types/engine/changelog-lint.d.mts +9 -7
  34. package/types/engine/homepage.d.mts +8 -1
  35. package/types/engine/index.d.mts +6 -0
  36. package/types/engine/map-build.d.mts +78 -0
  37. package/types/engine/map-dot.d.mts +130 -0
  38. package/types/engine/map-graphviz.d.mts +61 -0
  39. package/types/engine/map-layout.d.mts +359 -0
  40. package/types/engine/map-places.d.mts +259 -0
  41. package/types/engine/note-vocabulary.d.mts +12 -0
  42. package/types/engine/place-relations.d.mts +95 -0
  43. package/types/engine/related-pages.d.mts +70 -0
  44. package/types/engine/site-build.d.mts +36 -3
  45. package/types/engine/site-config.d.mts +1 -1
  46. package/types/engine/site-index.d.mts +5 -1
  47. package/types/engine/site-root.d.mts +59 -7
  48. package/types/engine/web-wikilinks.d.mts +11 -2
@@ -0,0 +1,240 @@
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
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * `content-build map`: the drawings, written under `build/map/`.
16
+ *
17
+ * Three modes, each a function of the world the content index describes:
18
+ *
19
+ * - **`tree`** — the containment tree from `parents`, the author's check.
20
+ * `tree.svg` for the whole world and `tree-<continent>.svg` per continent,
21
+ * or `tree-<root>.svg` for the subtree beneath one place. The anomalies
22
+ * are returned as findings.
23
+ * - **`from`** — the map from a place, `from-<shortcode>.svg`, for each
24
+ * place named, or for every place that takes part in a border or a route
25
+ * with `all`.
26
+ * - **`travel`** — the whole route graph, `travel.svg`.
27
+ *
28
+ * Every rendering is written beside the `.dot` it was drawn from, and the
29
+ * `.dot` is written first: with GraphViz absent the command either stops with
30
+ * an error naming what to install, or — where the caller asked it to — writes
31
+ * the `.dot` files, returns one warning, and skips the rendering, which is
32
+ * what a site build asking for maps does rather than fail.
33
+ *
34
+ * A build never mutates its inputs: nothing here reads a note except to
35
+ * locate a finding, and everything lands under the output directory.
36
+ *
37
+ * @module
38
+ */
39
+
40
+ import fs from "node:fs";
41
+ import path from "node:path";
42
+
43
+ import { fromDot, travelDot, treeDot } from "./map-dot.mjs";
44
+ import { findGraphviz, graphvizMissingMessage, renderDot } from "./map-graphviz.mjs";
45
+ import { layoutFrom, travelGraph } from "./map-layout.mjs";
46
+ import { analyzeContainment, containmentFindings } from "./map-places.mjs";
47
+
48
+ /**
49
+ * The directory the maps land in, below a package's root.
50
+ *
51
+ * @type {string}
52
+ */
53
+ export const MAP_DIR = "build/map";
54
+
55
+ /**
56
+ * The value of `--from` that means every place with a relation.
57
+ *
58
+ * @type {string}
59
+ */
60
+ export const FROM_ALL = "all";
61
+
62
+ /**
63
+ * Every place that states, or is named in, a border or a route — the places
64
+ * `--from all` draws.
65
+ *
66
+ * @param {Map<string, import("./map-places.mjs").MapPlace>} places - Every place.
67
+ * @returns {string[]} Shortcodes, sorted.
68
+ */
69
+ export function relatedPlaces(places) {
70
+ const related = new Set();
71
+ for (const place of places.values()) {
72
+ for (const entry of [...place.borders, ...place.routes]) {
73
+ if (!places.has(entry.to)) continue;
74
+ related.add(place.shortcode);
75
+ related.add(entry.to);
76
+ }
77
+ }
78
+ return [...related].sort();
79
+ }
80
+
81
+ /**
82
+ * Build the maps.
83
+ *
84
+ * @param {object} opts
85
+ * @param {import("./map-places.mjs").MapWorld} opts.world - The places, as
86
+ * {@link module:engine/map-places.loadMapWorld} returns them.
87
+ * @param {string} opts.outDir - Where to write; created if absent.
88
+ * @param {boolean} [opts.tree] - Draw the containment tree.
89
+ * @param {string} [opts.root] - With `tree`, the subtree beneath this place.
90
+ * @param {string[]} [opts.from] - Draw the map from each of these places, or
91
+ * from every related place for {@link FROM_ALL}.
92
+ * @param {boolean} [opts.travel] - Draw the whole route graph.
93
+ * @param {string} [opts.engine="dot"] - The tree's engine: `dot`, `twopi` or
94
+ * `neato`. The map from a place is always `neato` with every node pinned,
95
+ * and the route graph always `neato`.
96
+ * @param {string} [opts.rankdir="TB"] - The tree's rank direction, `dot` only.
97
+ * @param {number} [opts.nodesep] - The tree's `nodesep`, inches.
98
+ * @param {number} [opts.ranksep] - The tree's `ranksep`, inches.
99
+ * @param {number} [opts.scale=1] - Multiplies every node's size and font.
100
+ * @param {boolean} [opts.polities=true] - Whether the tree draws polities.
101
+ * @param {(engine: string) => string|undefined} [opts.locate] - How a GraphViz
102
+ * engine's binary is found; the real lookup by default.
103
+ * @param {boolean} [opts.requireGraphviz=true] - Whether a missing engine is
104
+ * an error. False, the `.dot` files are written, the renderings skipped,
105
+ * and one warning returned.
106
+ * @returns {{written: string[], findings: Array<{file: string, line?: number,
107
+ * column?: number, severity: "error"|"warning", message: string}>,
108
+ * skipped: boolean}} What was written, what was found, and whether the
109
+ * rendering was skipped for want of GraphViz.
110
+ * @throws {Error} When no mode is asked for, a named place is not one, or
111
+ * GraphViz is required and absent.
112
+ */
113
+ export function buildMaps({
114
+ world,
115
+ outDir,
116
+ tree = false,
117
+ root,
118
+ from = [],
119
+ travel = false,
120
+ engine = "dot",
121
+ rankdir = "TB",
122
+ nodesep,
123
+ ranksep,
124
+ scale = 1,
125
+ polities = true,
126
+ locate = findGraphviz,
127
+ requireGraphviz = true,
128
+ }) {
129
+ if (!tree && from.length === 0 && !travel) {
130
+ throw new Error("name a drawing: --tree, --from <shortcode> or --travel");
131
+ }
132
+ const { places } = world;
133
+
134
+ // Every binary this run needs, found once and before anything is drawn,
135
+ // so a missing one is reported before a directory is touched.
136
+ const engines = new Set();
137
+ if (tree) engines.add(engine);
138
+ if (from.length || travel) engines.add("neato");
139
+ /** @type {Map<string, string>} */
140
+ const binaries = new Map();
141
+ const findings = [];
142
+ let skipped = false;
143
+ for (const name of engines) {
144
+ const binary = locate(name);
145
+ if (binary) binaries.set(name, binary);
146
+ else if (requireGraphviz) throw new Error(graphvizMissingMessage(name));
147
+ else if (!skipped) {
148
+ skipped = true;
149
+ findings.push({
150
+ file: outDir,
151
+ severity: "warning",
152
+ message: `${graphvizMissingMessage(name)}; the .dot files are written and the renderings skipped`,
153
+ });
154
+ }
155
+ }
156
+
157
+ fs.mkdirSync(outDir, { recursive: true });
158
+ const written = [];
159
+ const emit = (name, dot, engineName, args = []) => {
160
+ const dotPath = path.join(outDir, `${name}.dot`);
161
+ fs.writeFileSync(dotPath, dot);
162
+ written.push(dotPath);
163
+ const binary = binaries.get(engineName);
164
+ if (!binary) return;
165
+ const svgPath = path.join(outDir, `${name}.svg`);
166
+ const { warnings } = renderDot(dotPath, svgPath, { binary, args });
167
+ written.push(svgPath);
168
+ if (warnings) {
169
+ findings.push({ file: dotPath, severity: "warning", message: warnings });
170
+ }
171
+ };
172
+
173
+ if (tree) {
174
+ const analysis = analyzeContainment(places, world.polities);
175
+ const common = {
176
+ places,
177
+ polities: world.polities,
178
+ analysis,
179
+ engine,
180
+ rankdir,
181
+ nodesep,
182
+ ranksep,
183
+ scale,
184
+ drawPolities: polities,
185
+ };
186
+ if (root !== undefined) {
187
+ emit(`tree-${root}`, treeDot({ ...common, root }), engine);
188
+ } else {
189
+ emit("tree", treeDot(common), engine);
190
+ for (const continent of analysis.continents) {
191
+ emit(`tree-${continent}`, treeDot({ ...common, root: continent }), engine);
192
+ }
193
+ findings.push(
194
+ ...containmentFindings(analysis, places, {
195
+ contentBase: world.contentBase,
196
+ config: world.config,
197
+ }),
198
+ );
199
+ }
200
+ }
201
+
202
+ const centres = from.includes(FROM_ALL) ? relatedPlaces(places) : from;
203
+ for (const centre of centres) {
204
+ const layout = layoutFrom(places, centre);
205
+ for (const { from: place, to } of layout.unresolved) {
206
+ findings.push({
207
+ file: unresolvedFile(world, place),
208
+ severity: "warning",
209
+ 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`,
210
+ });
211
+ }
212
+ emit(`from-${centre}`, fromDot(layout, places, { scale }), "neato", ["-n2"]);
213
+ }
214
+
215
+ if (travel) {
216
+ const graph = travelGraph(places);
217
+ for (const { from: place, to } of graph.unresolved) {
218
+ findings.push({
219
+ file: unresolvedFile(world, place),
220
+ severity: "warning",
221
+ message: `place "${place}" names "${to}" in a border or a route, and no place declares that shortcode; the route graph leaves it out`,
222
+ });
223
+ }
224
+ emit("travel", travelDot(graph, places, { scale }), "neato");
225
+ }
226
+
227
+ return { written, findings, skipped };
228
+ }
229
+
230
+ /**
231
+ * The file a finding about a place's relation is reported against.
232
+ *
233
+ * @param {import("./map-places.mjs").MapWorld} world - The world.
234
+ * @param {string} shortcode - The place.
235
+ * @returns {string} The note, or the content root for a place with no file.
236
+ */
237
+ function unresolvedFile(world, shortcode) {
238
+ const place = world.places.get(shortcode);
239
+ return place?.file ? path.join(world.contentBase, ...place.file.split("/")) : world.contentBase;
240
+ }