@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.
- package/CHANGELOG.md +16 -0
- package/CONTENT.md +109 -0
- package/bin/content-build.mjs +36 -8
- package/content-config.mjs +8 -3
- package/docs/api.md +86 -46
- package/docs/commands.md +64 -24
- package/docs/configuration.md +20 -1
- package/docs/content-format.md +71 -0
- package/engine/frontmatter-lint.mjs +2 -0
- package/engine/holdings.mjs +391 -0
- package/engine/index.mjs +9 -0
- package/engine/map-build.mjs +41 -11
- package/engine/map-dot.mjs +84 -24
- package/engine/map-graphviz.mjs +12 -3
- package/engine/map-layout.mjs +283 -101
- package/engine/map-places.mjs +33 -3
- package/engine/market-class.mjs +178 -0
- package/engine/metadata-index.mjs +7 -4
- package/engine/note-vocabulary.mjs +19 -0
- package/engine/site-build.mjs +88 -10
- package/engine/site-maps.mjs +165 -0
- package/package.json +1 -1
- package/types/engine/holdings.d.mts +146 -0
- package/types/engine/index.d.mts +3 -0
- package/types/engine/map-build.d.mts +13 -7
- package/types/engine/map-dot.d.mts +22 -9
- package/types/engine/map-graphviz.d.mts +8 -1
- package/types/engine/map-layout.d.mts +117 -49
- package/types/engine/map-places.d.mts +26 -0
- package/types/engine/market-class.d.mts +62 -0
- package/types/engine/note-vocabulary.d.mts +10 -0
- package/types/engine/site-build.d.mts +37 -6
- package/types/engine/site-maps.d.mts +81 -0
|
@@ -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,
|
|
390
|
-
// so a consumer's lint
|
|
391
|
-
//
|
|
392
|
-
// dependency's places beside its
|
|
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,
|
package/engine/site-build.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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[],
|
|
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.
|
|
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",
|