@heroiclands/package-build 22.4.5 → 22.4.7

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.
@@ -0,0 +1,178 @@
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
+ * What trade a settlement supports — a place note's `data.market`.
16
+ *
17
+ * `data.population` says how many people a settlement holds, which is not the
18
+ * same question as what a traveller can buy there. Two settlements of eight
19
+ * hundred are a different proposition when one has a weekly market and the
20
+ * other a chartered fair with a moneylender, and `market` is where a note says
21
+ * which.
22
+ *
23
+ * **The scale is six steps and is stated once, here.** The lint refuses a
24
+ * value against it, the vocabulary's `describe` reads its ends, and
25
+ * `docs/content-format.md` states it as a table the suite compares against
26
+ * this list. A second copy of the scale is one more list free to disagree
27
+ * about what a `4` buys.
28
+ *
29
+ * **A class is a step, not a measurement.** The numbers order the steps and
30
+ * nothing more: a `5` is not a market two fifths larger than a `3`, it is a
31
+ * city rather than a town. So a value off the scale is refused rather than
32
+ * clamped — a reader of the scale has to be able to trust that every number
33
+ * means the same thing wherever it appears.
34
+ *
35
+ * **The key is meaningful on a settlement**, and no subType condition is
36
+ * checked. `population` states none either: which places a key says something
37
+ * about is the author's judgement, and the `data:` container is closed per
38
+ * type rather than per subType.
39
+ *
40
+ * @module
41
+ */
42
+
43
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
44
+
45
+ /**
46
+ * One step of the market scale.
47
+ *
48
+ * @typedef {object} MarketClass
49
+ * @property {number} value - The integer a note writes.
50
+ * @property {string} name - What a settlement of this class is called.
51
+ * @property {string} trade - What can be had there.
52
+ */
53
+
54
+ /**
55
+ * The market scale, in order, from the settlement with no market to the one
56
+ * other markets buy from.
57
+ *
58
+ * @type {readonly MarketClass[]}
59
+ */
60
+ export const MARKET_CLASSES = Object.freeze([
61
+ Object.freeze({
62
+ value: 1,
63
+ name: "hamlet",
64
+ trade: "no market — what neighbours trade among themselves",
65
+ }),
66
+ Object.freeze({
67
+ value: 2,
68
+ name: "village",
69
+ trade: "a weekly market: staples, a smith, what a household cannot make",
70
+ }),
71
+ Object.freeze({
72
+ value: 3,
73
+ name: "town",
74
+ trade: "a regular market, most common goods, several trades working full time",
75
+ }),
76
+ Object.freeze({
77
+ value: 4,
78
+ name: "market town",
79
+ trade: "a chartered fair, goods carried from a week away, a moneylender",
80
+ }),
81
+ Object.freeze({
82
+ value: 5,
83
+ name: "city",
84
+ trade: "anything ordinary, in quantity; foreign goods; a guild structure",
85
+ }),
86
+ Object.freeze({
87
+ value: 6,
88
+ name: "great city",
89
+ trade:
90
+ "the rare and the imported as a matter of course; banking; " +
91
+ "the market from which other markets buy",
92
+ }),
93
+ ]);
94
+
95
+ /** The lowest class the scale states. @type {MarketClass} */
96
+ const LOWEST = MARKET_CLASSES[0];
97
+
98
+ /** The highest class the scale states. @type {MarketClass} */
99
+ const HIGHEST = MARKET_CLASSES[MARKET_CLASSES.length - 1];
100
+
101
+ /**
102
+ * The scale's ends, as a finding states them: `1 (hamlet) to 6 (great city)`.
103
+ *
104
+ * @returns {string} The phrase.
105
+ */
106
+ function scale() {
107
+ return `${LOWEST.value} (${LOWEST.name}) to ${HIGHEST.value} (${HIGHEST.name})`;
108
+ }
109
+
110
+ /**
111
+ * Whether a value is one of the classes the scale states.
112
+ *
113
+ * A note may write the number quoted, which YAML hands over as a string, so
114
+ * the value is read as a number before it is compared — the same reading
115
+ * `matchesKind` gives a `number` field.
116
+ *
117
+ * @param {unknown} value - The authored value.
118
+ * @returns {boolean} Whether it names a class.
119
+ */
120
+ export function isMarketClass(value) {
121
+ const n = numberOf(value);
122
+ return n !== undefined && MARKET_CLASSES.some((entry) => entry.value === n);
123
+ }
124
+
125
+ /**
126
+ * The class a value names, or `undefined` for a value that names none.
127
+ *
128
+ * @param {unknown} value - The authored value.
129
+ * @returns {MarketClass|undefined} The class.
130
+ */
131
+ export function marketClass(value) {
132
+ const n = numberOf(value);
133
+ return MARKET_CLASSES.find((entry) => entry.value === n);
134
+ }
135
+
136
+ /**
137
+ * A value as the finite number it reads as, or `undefined`.
138
+ *
139
+ * @param {unknown} value - The authored value.
140
+ * @returns {number|undefined} The number.
141
+ */
142
+ function numberOf(value) {
143
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
144
+ if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
145
+ return Number(value);
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ /**
151
+ * Check a place note's `data.market`.
152
+ *
153
+ * The value is one of the classes the scale states, or it is a finding located
154
+ * at the key's own line. A value that is not a number at all is left alone:
155
+ * the container's own shape check reports it, and saying so twice would make
156
+ * one slip read as two.
157
+ *
158
+ * @param {object} note - The note, as the link index hands it over.
159
+ * @returns {object[]} Findings.
160
+ */
161
+ export function checkMarket(note) {
162
+ const fm = note?.fm ?? {};
163
+ const data = fm.data && typeof fm.data === "object" && !Array.isArray(fm.data) ? fm.data : {};
164
+ const value = data.market;
165
+ if (value === undefined || value === null) return [];
166
+ if (numberOf(value) === undefined) return [];
167
+ if (isMarketClass(value)) return [];
168
+ return [
169
+ {
170
+ file: note.file,
171
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "market"]),
172
+ severity: "error",
173
+ message:
174
+ `\`data.market\` is a market class — a whole number from ${scale()} — ` +
175
+ `but reads ${JSON.stringify(value)}`,
176
+ },
177
+ ];
178
+ }
@@ -386,13 +386,16 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
386
386
  // root onto it and resolves in one step — which is the point of
387
387
  // the record naming a path at all.
388
388
  asset: record.asset ?? undefined,
389
- // What a place is next to, reachable from and within, carried
390
- // so a consumer's lint checks a border across the package
391
- // boundary from both ends, and a consumer's map draws the
392
- // dependency's places beside its own.
389
+ // What a place is next to, reachable from and within, and
390
+ // what an affiliation holds, carried so a consumer's lint
391
+ // checks a border across the package boundary from both ends,
392
+ // a consumer's map draws the dependency's places beside its
393
+ // own, and a consumer's pages list what a dependency's
394
+ // affiliation holds and what lies within its regions.
393
395
  borders: record.data?.borders ?? undefined,
394
396
  routes: record.data?.routes ?? undefined,
395
397
  parents: record.data?.parents ?? undefined,
398
+ domains: record.data?.domains ?? undefined,
396
399
  package: pkg,
397
400
  });
398
401
  }
@@ -85,6 +85,11 @@ import { currentType } from "./ids.mjs";
85
85
  // What a place is next to and reachable from — the two relation lists and
86
86
  // the checks that hold them to their closed sets and to each other.
87
87
  import { checkBorders, checkRoutes } from "./place-relations.mjs";
88
+ import { checkHeld } from "./holdings.mjs";
89
+ // What trade a settlement supports — the scale and the check that holds a
90
+ // value to it, read rather than restated, so the reference below and the
91
+ // finding an author meets state one list.
92
+ import { MARKET_CLASSES, checkMarket } from "./market-class.mjs";
88
93
 
89
94
  /**
90
95
  * One `data:` key a note type may carry.
@@ -141,6 +146,11 @@ import { checkBorders, checkRoutes } from "./place-relations.mjs";
141
146
  * @property {readonly DataFieldSpec[]} data - The `data:` keys, closed.
142
147
  * @property {readonly string[]|null} [subTypes] - The top-level `subType`
143
148
  * values, as above.
149
+ * @property {(note: object, opts: {index?: object}) => object[]} [check] - A
150
+ * check of the whole note, run by the frontmatter lint beside the field
151
+ * checks with the same index. For a rule that is about the note rather than
152
+ * one of its fields — a place's tenure is a fact about every affiliation's
153
+ * `domains`, and is located at the note's `type:` line.
144
154
  */
145
155
 
146
156
  /* --------------------------------------------------------------------- */
@@ -984,6 +994,7 @@ export const NOTE_VOCABULARY = Object.freeze({
984
994
 
985
995
  place: Object.freeze({
986
996
  subTypes: Object.freeze(["world", "region", "settlement", "site", "structure", "feature"]),
997
+ check: checkHeld,
987
998
  data: Object.freeze([
988
999
  {
989
1000
  name: "demonym",
@@ -1007,6 +1018,14 @@ export const NOTE_VOCABULARY = Object.freeze({
1007
1018
  ...NUM,
1008
1019
  describe: "Approximate population, to two significant digits.",
1009
1020
  },
1021
+ {
1022
+ name: "market",
1023
+ ...NUM,
1024
+ check: checkMarket,
1025
+ describe:
1026
+ "What trade the settlement supports, on a scale of six: " +
1027
+ `${MARKET_CLASSES.map((c) => `${c.value} ${c.name}`).join(", ")}.`,
1028
+ },
1010
1029
  {
1011
1030
  name: "borders",
1012
1031
  ...LIST,
@@ -36,7 +36,9 @@
36
36
  * `/<package>/<type>-<shortcode>/`. Nothing is generated between them — no
37
37
  * section directory, no listing, no `_index.md` but the root's. An index of
38
38
  * what the package publishes is a `doc` note carrying a content table, and it
39
- * is authored where every other page is.
39
+ * is authored where every other page is. The one file a page carries beside
40
+ * itself is the map from a place — see `engine/site-maps.mjs` — and a page
41
+ * that carries one is a leaf bundle at the same address.
40
42
  *
41
43
  * **Every gate reports; none exits.** The integrity checks a site build needs —
42
44
  * a wikilink authored in frontmatter, a name that yields no slug, an unusable
@@ -82,6 +84,8 @@ import {
82
84
  import { publishesContentPages } from "../content-config.mjs";
83
85
  import { HUGO_CONTENT } from "./site-config.mjs";
84
86
  import { homepageLinkTargets, relatedPages } from "./related-pages.mjs";
87
+ import { HOLDINGS_KEYS, foreignHoldingsNodes, holdingsNode, holdingsPages } from "./holdings.mjs";
88
+ import { drawSiteMaps } from "./site-maps.mjs";
85
89
 
86
90
  const require = createRequire(import.meta.url);
87
91
 
@@ -489,7 +493,10 @@ export function tableUniverse(pages) {
489
493
  * about every other page, known only once the whole tree has resolved — see
490
494
  * {@link module:engine/related-pages} — so an authored value is dropped the
491
495
  * way `aliases` is, and {@link renderPages} writes the derived block once it
492
- * holds the graph.
496
+ * holds the graph. `contains`, `held_by` and `holdings` are dropped for the
497
+ * same reason — see {@link module:engine/holdings}. **So is `map`**: the
498
+ * file a place page names is the one the build drew beside it — see
499
+ * {@link module:engine/site-maps} — and a page with no drawing names none.
493
500
  *
494
501
  * @param {object} page - The page.
495
502
  * @param {object} options
@@ -525,6 +532,8 @@ export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
525
532
  if (decorate) decorate(data, page);
526
533
  delete data.aliases;
527
534
  delete data.related;
535
+ for (const key of HOLDINGS_KEYS) delete data[key];
536
+ delete data.map;
528
537
  if (webSrc && artSrc) resolveArtFields(data, webSrc, artSrc);
529
538
  return data;
530
539
  }
@@ -598,11 +607,20 @@ function isPlainObject(value) {
598
607
  * `doc` note's `subType` may be spelled the same as another note's `type`, and
599
608
  * `doc-gear.md` and `weapongear-gear.md` are distinct.
600
609
  *
610
+ * **A page that carries a file beside itself is a leaf bundle**, `<slug>/index.md`,
611
+ * because that is the one shape under which Hugo hands a page its own
612
+ * resources — the map from a place, inlined by the theme through
613
+ * `.Resources.Get`. The directory is the page, not a section: it holds no
614
+ * `_index.md`, and the page's stated `url` keeps its address exactly where the
615
+ * flat file's was.
616
+ *
601
617
  * @param {object} page - The page.
618
+ * @param {object} [opts]
619
+ * @param {boolean} [opts.bundle=false] - Whether the page carries a resource.
602
620
  * @returns {string} The file, relative to the mount.
603
621
  */
604
- export function pageDestination(page) {
605
- return `${page.slug}.md`;
622
+ export function pageDestination(page, { bundle = false } = {}) {
623
+ return bundle ? `${page.slug}/index.md` : `${page.slug}.md`;
606
624
  }
607
625
 
608
626
  /**
@@ -627,16 +645,25 @@ export function pageDestination(page) {
627
645
  * point the resolver answers, and inverted once between them. Nothing is
628
646
  * resolved twice.
629
647
  *
648
+ * **A map is written with its page.** The drawing from a place is made before
649
+ * this render — see {@link module:engine/site-maps} — and handed in keyed by
650
+ * page URL; the page that owns one is written as a leaf bundle, the file is
651
+ * copied beside it, and the front matter names it as `map`. Every other page
652
+ * is written flat, and carries no such key.
653
+ *
630
654
  * @param {object[]} pages - Every page.
631
655
  * @param {object} options - Everything the render needs. `homepages` is the
632
656
  * homepage as the index knows it — `{ fm, body, url, title }` — which takes
633
657
  * no part in the render and every part in the graph: a content page links
634
- * it by wikilink, and its own markdown links name content pages.
658
+ * it by wikilink, and its own markdown links name content pages. `maps` is
659
+ * each drawing by the URL of the page that carries it.
635
660
  * @returns {{written: number, byKind: Record<string, number>, tableErrors: object[],
636
661
  * wikiErrors: object[], imageErrors: object[],
637
- * related: Map<string, import("./related-pages.mjs").Related>}} `related`
662
+ * related: Map<string, import("./related-pages.mjs").Related>,
663
+ * maps: number}} `related`
638
664
  * is keyed by page URL, and holds the homepage's block beside every content
639
665
  * page's, so the caller can write it on the page this render does not.
666
+ * `maps` counts the pages written with one.
640
667
  */
641
668
  export function renderPages(pages, options) {
642
669
  const {
@@ -651,6 +678,7 @@ export function renderPages(pages, options) {
651
678
  config,
652
679
  records = [],
653
680
  homepages = [],
681
+ maps = new Map(),
654
682
  } = options;
655
683
 
656
684
  // The address space the art slots and the body's embeds resolve against:
@@ -783,17 +811,40 @@ export function renderPages(pages, options) {
783
811
  }
784
812
 
785
813
  const related = relatedPages(edges, entries);
814
+ // What lies within a place, who holds it, and what an affiliation holds
815
+ // — read off `parents` and `domains` across this package and every
816
+ // fetched index, local notes first so they shadow a dependency's.
817
+ const holdings = holdingsPages([
818
+ ...pages.map((page) => holdingsNode(page.fm, { title: pageTitle(page), url: page.url })),
819
+ ...foreignHoldingsNodes(foreign?.index),
820
+ ]);
786
821
 
822
+ let withMap = 0;
787
823
  for (const { page, body, data } of rendered) {
788
824
  const block = related.get(page.url);
789
825
  if (block) data.related = block;
790
- const dest = path.join(outRoot, pageDestination(page));
826
+ Object.assign(data, holdings.get(page.url));
827
+ const map = maps.get(page.url);
828
+ const dest = path.join(outRoot, pageDestination(page, { bundle: Boolean(map) }));
791
829
  fs.mkdirSync(path.dirname(dest), { recursive: true });
830
+ if (map) {
831
+ fs.copyFileSync(map.file, path.join(path.dirname(dest), map.name));
832
+ data.map = map.name;
833
+ withMap += 1;
834
+ }
792
835
  fs.writeFileSync(dest, matter.stringify(body, data));
793
836
  byKind[page.kind] = (byKind[page.kind] ?? 0) + 1;
794
837
  }
795
838
 
796
- return { written: pages.length, byKind, tableErrors, wikiErrors, imageErrors, related };
839
+ return {
840
+ written: pages.length,
841
+ byKind,
842
+ tableErrors,
843
+ wikiErrors,
844
+ imageErrors,
845
+ related,
846
+ maps: withMap,
847
+ };
797
848
  }
798
849
 
799
850
  /**
@@ -846,10 +897,15 @@ export function resolveSitePass(name, options) {
846
897
  * {@link module:engine/sql-tables.prepareSqlTables}. A page authoring an
847
898
  * `sql` directive with none prepared is a table error: nothing here runs a
848
899
  * query.
900
+ * @param {(engine: string) => string|undefined} [options.locate] - How a
901
+ * GraphViz engine's binary is found, for the maps; the real lookup by
902
+ * default.
849
903
  * @returns {{gates: object, stats: object|null, tableErrors: object[],
850
- * wikiErrors: object[], imageErrors: object[], manifests: object|null}}
904
+ * wikiErrors: object[], imageErrors: object[], mapFindings: object[],
905
+ * manifests: object|null}} `mapFindings` is what drawing the maps found —
906
+ * warnings, never a reason to fail the build.
851
907
  */
852
- export function buildSite({ config, sqlTables } = {}) {
908
+ export function buildSite({ config, sqlTables, locate } = {}) {
853
909
  const resolved = config ?? loadPackConfig();
854
910
  const site = resolved.site;
855
911
  const scheme = resolved.publish.address;
@@ -945,6 +1001,7 @@ export function buildSite({ config, sqlTables } = {}) {
945
1001
  tableErrors: [],
946
1002
  wikiErrors: [],
947
1003
  imageErrors: [],
1004
+ mapFindings: [],
948
1005
  stats: null,
949
1006
  };
950
1007
  }
@@ -963,6 +1020,7 @@ export function buildSite({ config, sqlTables } = {}) {
963
1020
  tableErrors: [],
964
1021
  wikiErrors: [],
965
1022
  imageErrors: [],
1023
+ mapFindings: [],
966
1024
  stats: {
967
1025
  homepages: writeHomepages(homeRoot, homepages, resolved),
968
1026
  out: homeRoot,
@@ -1004,6 +1062,7 @@ export function buildSite({ config, sqlTables } = {}) {
1004
1062
  tableErrors: [],
1005
1063
  wikiErrors: [],
1006
1064
  imageErrors: [],
1065
+ mapFindings: [],
1007
1066
  };
1008
1067
  }
1009
1068
 
@@ -1012,6 +1071,22 @@ export function buildSite({ config, sqlTables } = {}) {
1012
1071
  repoRoot: resolved.rootDir,
1013
1072
  });
1014
1073
 
1074
+ // The map from each related place, drawn now that every page is known to
1075
+ // be addressable and before any is written, so the page writer lays each
1076
+ // drawing beside its page. Drawn with `base`, which is what every name
1077
+ // in a drawing links through — the same `<base><slug>/` every other href
1078
+ // composes. Off, nothing is drawn and nothing is asked of GraphViz.
1079
+ const drawn =
1080
+ site.maps ?
1081
+ drawSiteMaps({
1082
+ records: ctx.records,
1083
+ foreignIndex: gates.foreign.index,
1084
+ config: resolved,
1085
+ base,
1086
+ ...(locate ? { locate } : {}),
1087
+ })
1088
+ : { maps: new Map(), findings: [] };
1089
+
1015
1090
  const rendered = renderPages(pages, {
1016
1091
  outRoot: out,
1017
1092
  sqlTables,
@@ -1021,6 +1096,7 @@ export function buildSite({ config, sqlTables } = {}) {
1021
1096
  foreign: gates.foreign,
1022
1097
  universe: tableUniverse(pages),
1023
1098
  homepages: homepageEntries,
1099
+ maps: drawn.maps,
1024
1100
  pass,
1025
1101
  // What counts as a being is the toolchain's to say, not a consumer's.
1026
1102
  // Asking in a consumer's script is how one came to still be checking
@@ -1053,9 +1129,11 @@ export function buildSite({ config, sqlTables } = {}) {
1053
1129
  tableErrors: rendered.tableErrors,
1054
1130
  wikiErrors: rendered.wikiErrors,
1055
1131
  imageErrors: rendered.imageErrors,
1132
+ mapFindings: drawn.findings,
1056
1133
  stats: {
1057
1134
  ...rendered.byKind,
1058
1135
  homepages: homepagesWritten,
1136
+ maps: rendered.maps,
1059
1137
  out,
1060
1138
  },
1061
1139
  };
@@ -0,0 +1,165 @@
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
+ * The maps a site build writes into its place pages.
16
+ *
17
+ * A place that states, or is named in, a border or a route has a map from it
18
+ * — the drawing `content-build map --from` makes — and the site build draws
19
+ * that map for every such place of this package and hands each one to the
20
+ * page writer, which lays it beside the page as `from-<shortcode>.svg` and
21
+ * names it in the front matter as `map`. The theme inlines it, so its place
22
+ * names are links to their pages: every drawing is made with the site's base,
23
+ * and every `href` in it composes `<base><slug>/` the way every other href
24
+ * the build renders does.
25
+ *
26
+ * GraphViz draws, and a site never fails for want of a map: when the engine is
27
+ * absent the build says so once, as a warning, and writes every page as it
28
+ * would without maps. `site.maps: false` says the site carries none and asks
29
+ * nothing of GraphViz.
30
+ *
31
+ * The drawings land under `build/map/site/` — beside the author's own
32
+ * drawings under `build/map/`, and apart from them, because the site's
33
+ * carry links and the author's may not — and are copied from there into the
34
+ * content mount. A build never mutates its inputs.
35
+ *
36
+ * @module
37
+ */
38
+
39
+ import fs from "node:fs";
40
+ import path from "node:path";
41
+
42
+ import { buildMaps, relatedPlaces } from "./map-build.mjs";
43
+ import { GRAPHVIZ_INSTALL, findGraphviz } from "./map-graphviz.mjs";
44
+ import { mapWorld } from "./map-places.mjs";
45
+
46
+ /**
47
+ * Where the site build draws, below the package root.
48
+ *
49
+ * @type {string}
50
+ */
51
+ export const SITE_MAP_DIR = "build/map/site";
52
+
53
+ /**
54
+ * The engine the map from a place is drawn with.
55
+ *
56
+ * @type {string}
57
+ */
58
+ const ENGINE = "neato";
59
+
60
+ /**
61
+ * A drawing as the page writer receives it.
62
+ *
63
+ * @typedef {object} SiteMap
64
+ * @property {string} name - The file's name in the page's bundle, and the
65
+ * value of the page's `map` key: `from-<shortcode>.svg`.
66
+ * @property {string} file - Where the drawing is, absolute.
67
+ */
68
+
69
+ /**
70
+ * A GraphViz SVG rewritten for inlining in an HTML page.
71
+ *
72
+ * GraphViz writes a standalone document: an XML declaration, a DOCTYPE naming
73
+ * the SVG 1.1 DTD by URL, generator comments, a root sized in points beside
74
+ * its `viewBox`, and links through the `xlink` namespace. Inline in HTML the
75
+ * prologue is invalid, the DTD reference is the one external reference the
76
+ * drawing would carry, the fixed size fights the column it sits in, and
77
+ * `xlink:` is the pre-SVG-2 spelling. So the prologue and the comments go,
78
+ * the root keeps its `viewBox` and loses `width` and `height`, and
79
+ * `xlink:href` and `xlink:title` become `href` and `title`, with the
80
+ * namespace declaration they needed.
81
+ *
82
+ * @param {string} svg - The SVG as GraphViz wrote it.
83
+ * @returns {string} The SVG to inline, starting at `<svg`.
84
+ */
85
+ export function inlineSvg(svg) {
86
+ const start = svg.indexOf("<svg");
87
+ let out = start < 0 ? svg : svg.slice(start);
88
+ out = out.replace(/<!--[\s\S]*?-->\n?/g, "");
89
+ out = out.replace(/^<svg[^>]*>/, (root) =>
90
+ root
91
+ .replace(/\s+(width|height)="[^"]*"/g, "")
92
+ .replace(/\s+xmlns:xlink="[^"]*"/, "")
93
+ .replace(/^<svg\s+/, "<svg "),
94
+ );
95
+ return out.replace(/\bxlink:(href|title)=/g, "$1=");
96
+ }
97
+
98
+ /**
99
+ * Draw the map from every related place of this package.
100
+ *
101
+ * @param {object} opts
102
+ * @param {Array<Record<string, any>>} opts.records - The package's index
103
+ * records, as the site build read them.
104
+ * @param {Map<string, object>} opts.foreignIndex - The fetched indexes the
105
+ * site build resolved links against.
106
+ * @param {object} opts.config - The resolved configuration.
107
+ * @param {string} opts.base - The site base every page is served under,
108
+ * ending in a slash: what every name in a drawing links through.
109
+ * @param {(engine: string) => string|undefined} [opts.locate] - How the
110
+ * engine's binary is found; the real lookup by default.
111
+ * @returns {{maps: Map<string, SiteMap>, findings: Array<{file: string,
112
+ * line?: number, column?: number, severity: "error"|"warning",
113
+ * message: string}>}} The drawings, keyed by the URL of the page each
114
+ * belongs to, and what the drawing found — one warning naming what to
115
+ * install when GraphViz is absent, and a warning for each relation that
116
+ * names no place.
117
+ */
118
+ export function drawSiteMaps({ records, foreignIndex, config, base, locate = findGraphviz }) {
119
+ /** @type {Map<string, SiteMap>} */
120
+ const maps = new Map();
121
+ const outDir = path.join(config.rootDir, ...SITE_MAP_DIR.split("/"));
122
+ // Every run draws afresh: a place that lost its relations must not keep
123
+ // the map an earlier run drew.
124
+ fs.rmSync(outDir, { recursive: true, force: true });
125
+
126
+ if (!locate(ENGINE)) {
127
+ return {
128
+ maps,
129
+ findings: [
130
+ {
131
+ file: config.rootDir,
132
+ severity: "warning",
133
+ message:
134
+ `GraphViz's \`${ENGINE}\` is not installed, so no place page carries ` +
135
+ `the map from that place; ${GRAPHVIZ_INSTALL} to draw them, or set ` +
136
+ "`site.maps: false` to say the site carries none",
137
+ },
138
+ ],
139
+ };
140
+ }
141
+
142
+ const world = mapWorld({
143
+ records,
144
+ foreignIndex,
145
+ contentBase: config.paths.content,
146
+ base,
147
+ config,
148
+ });
149
+ // This package's related places, which are the ones with a page here to
150
+ // carry a map. A dependency's place is drawn on those maps and gets none
151
+ // of its own from this build.
152
+ const centres = relatedPlaces(world.places).filter((s) => world.places.get(s)?.local);
153
+ if (centres.length === 0) return { maps, findings: [] };
154
+
155
+ const { findings } = buildMaps({ world, outDir, from: centres, locate });
156
+ for (const centre of centres) {
157
+ const place = world.places.get(centre);
158
+ if (!place?.url) continue;
159
+ const name = `from-${centre}.svg`;
160
+ const file = path.join(outDir, name);
161
+ fs.writeFileSync(file, inlineSvg(fs.readFileSync(file, "utf8")));
162
+ maps.set(place.url, { name, file });
163
+ }
164
+ return { maps, findings };
165
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.4.5",
3
+ "version": "22.4.7",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",