@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,391 @@
|
|
|
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 lies within a place, who holds it, and what an affiliation holds.
|
|
16
|
+
*
|
|
17
|
+
* Two keys the notes already carry answer all three. A place's `data.parents`
|
|
18
|
+
* is geography — the places it sits within — and an affiliation's
|
|
19
|
+
* `data.domains` is tenure — the places it holds. Read across the whole
|
|
20
|
+
* corpus and inverted, they give each page the lists a reader wants:
|
|
21
|
+
* `contains` and `held_by` on a place, `holdings` on an affiliation, each
|
|
22
|
+
* shaped like an entry of `related` and absent where empty.
|
|
23
|
+
*
|
|
24
|
+
* **`domains` names what an affiliation holds directly**, and is never
|
|
25
|
+
* expanded. A polity whose `domains` names a region holds the region; the
|
|
26
|
+
* settlements within it are reachable from the region's `contains`, and
|
|
27
|
+
* repeating them in `holdings` would make every list say the same thing at
|
|
28
|
+
* every level. Tenure below a lord runs through `affiliation.parents` — a
|
|
29
|
+
* house of its earl, an earl of the crown — and geography through
|
|
30
|
+
* `place.parents`, so a subinfeudated manor sits in one region by the first
|
|
31
|
+
* and under a lord of another polity by the second, and both pages say so.
|
|
32
|
+
*
|
|
33
|
+
* **A dependency's places and affiliations take part.** A fetched index entry
|
|
34
|
+
* carries the `parents` and `domains` its record stated, so a place another
|
|
35
|
+
* package publishes is listed within a local region, and a house another
|
|
36
|
+
* package publishes is named on the local manor it holds.
|
|
37
|
+
*
|
|
38
|
+
* **A place's tenure is checked.** A settlement, a site or a structure that no
|
|
39
|
+
* affiliation's `domains` names is land nobody holds — a gap in tenure the
|
|
40
|
+
* lint reports as a warning at the note's `type:` line. A region is held
|
|
41
|
+
* through its polity's `domains` and a feature by nobody, so both are exempt.
|
|
42
|
+
*
|
|
43
|
+
* @module
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { positionInFrontmatter } from "./diagnostics.mjs";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The keys this module writes, and which a note cannot author: `contains`
|
|
50
|
+
* and `held_by` on a place, `holdings` on an affiliation.
|
|
51
|
+
*
|
|
52
|
+
* @type {readonly string[]}
|
|
53
|
+
*/
|
|
54
|
+
export const HOLDINGS_KEYS = Object.freeze(["contains", "held_by", "holdings"]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The place subTypes tenure is checked on. A region is held through its
|
|
58
|
+
* polity's `domains`, a world by nobody, a feature by nobody — a river has no
|
|
59
|
+
* lord — so the rest are the kinds of place a body holds directly.
|
|
60
|
+
*
|
|
61
|
+
* @type {readonly string[]}
|
|
62
|
+
*/
|
|
63
|
+
export const HELD_SUBTYPES = Object.freeze(["settlement", "site", "structure"]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One entry of a holdings list — a page a reader is pointed at.
|
|
67
|
+
*
|
|
68
|
+
* @typedef {object} HoldingsEntry
|
|
69
|
+
* @property {string} title The page's published title.
|
|
70
|
+
* @property {string} url `<base><slug>/` — the page's address as every
|
|
71
|
+
* href this build renders composes it.
|
|
72
|
+
* @property {string} type The note's `type` — `place` or `affiliation`.
|
|
73
|
+
* @property {string} [subType] The note's `subType`, where it declares one.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One place or affiliation, as the derivation reads it — a local page or a
|
|
78
|
+
* fetched index entry through one shape.
|
|
79
|
+
*
|
|
80
|
+
* @typedef {object} HoldingsNode
|
|
81
|
+
* @property {string} shortcode Lower case.
|
|
82
|
+
* @property {"place"|"affiliation"} type
|
|
83
|
+
* @property {string} [subType]
|
|
84
|
+
* @property {string} title The page's published title.
|
|
85
|
+
* @property {string} [url] The page's URL; absent where the package
|
|
86
|
+
* publishes no page for it.
|
|
87
|
+
* @property {string[]} parents What a place sits within, as written.
|
|
88
|
+
* @property {string[]} domains What an affiliation holds, as written.
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The lists one page carries. Every key is optional, and a key present holds
|
|
93
|
+
* at least one entry.
|
|
94
|
+
*
|
|
95
|
+
* @typedef {object} Holdings
|
|
96
|
+
* @property {HoldingsEntry[]} [contains] Places whose `parents` name this one.
|
|
97
|
+
* @property {HoldingsEntry[]} [held_by] Affiliations whose `domains` name this one.
|
|
98
|
+
* @property {HoldingsEntry[]} [holdings] Places this affiliation's `domains` name.
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The shortcode a `parents` or `domains` entry names, lower case.
|
|
103
|
+
*
|
|
104
|
+
* An entry is written as a bare shortcode, as an address, or as a wikilink
|
|
105
|
+
* carrying either; the shortcode is the last segment of the address in every
|
|
106
|
+
* case, because a segment carries no separator.
|
|
107
|
+
*
|
|
108
|
+
* @param {unknown} value - One entry.
|
|
109
|
+
* @returns {string} Its shortcode, or `""` for an entry that names nothing.
|
|
110
|
+
*/
|
|
111
|
+
function namedShortcode(value) {
|
|
112
|
+
const text = String(value ?? "")
|
|
113
|
+
.trim()
|
|
114
|
+
.replace(/^\[\[/, "")
|
|
115
|
+
.replace(/\]\]$/, "")
|
|
116
|
+
.split("|")[0]
|
|
117
|
+
.split("#")[0]
|
|
118
|
+
.trim();
|
|
119
|
+
return text.split("-").pop()?.toLowerCase() ?? "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A `parents` or `domains` value as the shortcodes it names, in order,
|
|
124
|
+
* empties dropped and repeats collapsed.
|
|
125
|
+
*
|
|
126
|
+
* @param {unknown} value - The authored value — a list, or a lone entry.
|
|
127
|
+
* @returns {string[]} The shortcodes.
|
|
128
|
+
*/
|
|
129
|
+
function shortcodesOf(value) {
|
|
130
|
+
const list =
|
|
131
|
+
Array.isArray(value) ? value
|
|
132
|
+
: value ? [value]
|
|
133
|
+
: [];
|
|
134
|
+
return [...new Set(list.map(namedShortcode).filter(Boolean))];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A stable order for `contains` and `holdings`: by subType, then title. An
|
|
139
|
+
* entry with no subType sorts first.
|
|
140
|
+
*
|
|
141
|
+
* @param {HoldingsEntry} a
|
|
142
|
+
* @param {HoldingsEntry} b
|
|
143
|
+
* @returns {number}
|
|
144
|
+
*/
|
|
145
|
+
function bySubTypeThenTitle(a, b) {
|
|
146
|
+
return (
|
|
147
|
+
(a.subType ?? "").localeCompare(b.subType ?? "", "en") ||
|
|
148
|
+
a.title.localeCompare(b.title, "en")
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A stable order for `held_by`: by title.
|
|
154
|
+
*
|
|
155
|
+
* @param {HoldingsEntry} a
|
|
156
|
+
* @param {HoldingsEntry} b
|
|
157
|
+
* @returns {number}
|
|
158
|
+
*/
|
|
159
|
+
function byTitle(a, b) {
|
|
160
|
+
return a.title.localeCompare(b.title, "en");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The entry a node is listed as.
|
|
165
|
+
*
|
|
166
|
+
* @param {HoldingsNode & {url: string}} node - A node with a page.
|
|
167
|
+
* @returns {HoldingsEntry} Its entry.
|
|
168
|
+
*/
|
|
169
|
+
function entryOf(node) {
|
|
170
|
+
return {
|
|
171
|
+
title: node.title,
|
|
172
|
+
url: node.url,
|
|
173
|
+
type: node.type,
|
|
174
|
+
...(node.subType === undefined ? {} : { subType: node.subType }),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Read a local note into a node, or `null` for a note that takes no part.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} fm - The note's frontmatter.
|
|
182
|
+
* @param {object} page - The page it publishes as.
|
|
183
|
+
* @param {string} page.title - Its published title.
|
|
184
|
+
* @param {string} page.url - Its URL.
|
|
185
|
+
* @returns {HoldingsNode|null} The node, or `null` for a note that is neither
|
|
186
|
+
* a place nor an affiliation, or that declares no shortcode.
|
|
187
|
+
*/
|
|
188
|
+
export function holdingsNode(fm, { title, url }) {
|
|
189
|
+
const type = String(fm?.type ?? "");
|
|
190
|
+
if (type !== "place" && type !== "affiliation") return null;
|
|
191
|
+
const shortcode = String(fm?.shortcode ?? "").toLowerCase();
|
|
192
|
+
if (!shortcode) return null;
|
|
193
|
+
const data = fm.data && typeof fm.data === "object" && !Array.isArray(fm.data) ? fm.data : {};
|
|
194
|
+
return {
|
|
195
|
+
shortcode,
|
|
196
|
+
type,
|
|
197
|
+
subType: fm.subType == null ? undefined : String(fm.subType),
|
|
198
|
+
title,
|
|
199
|
+
url,
|
|
200
|
+
parents: type === "place" ? shortcodesOf(data.parents) : [],
|
|
201
|
+
domains: type === "affiliation" ? shortcodesOf(data.domains) : [],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Read a fetched index's places and affiliations as nodes.
|
|
207
|
+
*
|
|
208
|
+
* An entry carries what the producer's record stated — its name, `subType`,
|
|
209
|
+
* `parents` and `domains` — and its page URL where the package publishes one.
|
|
210
|
+
*
|
|
211
|
+
* @param {Map<string, object>|undefined} foreignIndex - As
|
|
212
|
+
* {@link module:engine/metadata-index.loadForeignIndexes} returns it.
|
|
213
|
+
* @returns {HoldingsNode[]} The nodes, in index order.
|
|
214
|
+
*/
|
|
215
|
+
export function foreignHoldingsNodes(foreignIndex) {
|
|
216
|
+
const out = [];
|
|
217
|
+
if (!foreignIndex) return out;
|
|
218
|
+
for (const [canonical, entry] of foreignIndex) {
|
|
219
|
+
const type = String(entry?.type ?? "");
|
|
220
|
+
if (type !== "place" && type !== "affiliation") continue;
|
|
221
|
+
const shortcode = canonical.split("-").pop()?.toLowerCase() ?? "";
|
|
222
|
+
if (!shortcode) continue;
|
|
223
|
+
out.push({
|
|
224
|
+
shortcode,
|
|
225
|
+
type,
|
|
226
|
+
subType: entry.subType == null ? undefined : String(entry.subType),
|
|
227
|
+
title: String(entry.name ?? shortcode),
|
|
228
|
+
url: entry.url,
|
|
229
|
+
parents: type === "place" ? shortcodesOf(entry.parents) : [],
|
|
230
|
+
domains: type === "affiliation" ? shortcodesOf(entry.domains) : [],
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Invert `parents` and `domains` into each page's lists.
|
|
238
|
+
*
|
|
239
|
+
* A shortcode is declared once per type: the first node declaring it wins,
|
|
240
|
+
* so local nodes handed in ahead of fetched ones shadow a dependency's, the
|
|
241
|
+
* way the link resolver answers. A node with no URL is nobody's entry and
|
|
242
|
+
* carries no lists of its own, since there is no page to write them on. A
|
|
243
|
+
* name no node declares is dropped: a `parents` naming nowhere is the map's
|
|
244
|
+
* finding, a `domains` naming nowhere the reference check's.
|
|
245
|
+
*
|
|
246
|
+
* The result holds only pages with at least one entry on at least one list,
|
|
247
|
+
* and a page's block carries only the keys it has entries for — the theme's
|
|
248
|
+
* silent-disappear convention, kept at the source.
|
|
249
|
+
*
|
|
250
|
+
* @param {Iterable<HoldingsNode|null>} nodes - Every place and affiliation,
|
|
251
|
+
* local first. `parents` and `domains` are read as written, so a node
|
|
252
|
+
* built by hand may carry them as the note spells them.
|
|
253
|
+
* @returns {Map<string, Holdings>} URL → the page's lists, sorted.
|
|
254
|
+
*/
|
|
255
|
+
export function holdingsPages(nodes) {
|
|
256
|
+
/** @type {Map<string, HoldingsNode>} */
|
|
257
|
+
const places = new Map();
|
|
258
|
+
/** @type {Map<string, HoldingsNode>} */
|
|
259
|
+
const affiliations = new Map();
|
|
260
|
+
for (const node of nodes) {
|
|
261
|
+
if (!node) continue;
|
|
262
|
+
const by = node.type === "place" ? places : affiliations;
|
|
263
|
+
const shortcode = String(node.shortcode ?? "").toLowerCase();
|
|
264
|
+
if (!shortcode || by.has(shortcode)) continue;
|
|
265
|
+
// Read through the one reader, so a caller handing in what a note
|
|
266
|
+
// wrote — an address, a wikilink, a repeat — is read as `holdingsNode`
|
|
267
|
+
// reads it.
|
|
268
|
+
by.set(shortcode, {
|
|
269
|
+
...node,
|
|
270
|
+
shortcode,
|
|
271
|
+
parents: shortcodesOf(node.parents),
|
|
272
|
+
domains: shortcodesOf(node.domains),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @type {Map<string, {contains: HoldingsEntry[], held_by: HoldingsEntry[], holdings: HoldingsEntry[]}>} */
|
|
277
|
+
const lists = new Map();
|
|
278
|
+
const on = (url) => {
|
|
279
|
+
let found = lists.get(url);
|
|
280
|
+
if (!found) {
|
|
281
|
+
found = { contains: [], held_by: [], holdings: [] };
|
|
282
|
+
lists.set(url, found);
|
|
283
|
+
}
|
|
284
|
+
return found;
|
|
285
|
+
};
|
|
286
|
+
const listed = (node) => node.url !== undefined && node.url !== null && node.url !== "";
|
|
287
|
+
|
|
288
|
+
for (const child of places.values()) {
|
|
289
|
+
if (!listed(child)) continue;
|
|
290
|
+
for (const shortcode of child.parents) {
|
|
291
|
+
const parent = places.get(shortcode);
|
|
292
|
+
if (!parent || !listed(parent) || parent === child) continue;
|
|
293
|
+
on(parent.url).contains.push(entryOf(/** @type {any} */ (child)));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (const holder of affiliations.values()) {
|
|
298
|
+
if (!listed(holder)) continue;
|
|
299
|
+
for (const shortcode of holder.domains) {
|
|
300
|
+
const held = places.get(shortcode);
|
|
301
|
+
if (!held || !listed(held)) continue;
|
|
302
|
+
on(holder.url).holdings.push(entryOf(/** @type {any} */ (held)));
|
|
303
|
+
on(held.url).held_by.push(entryOf(/** @type {any} */ (holder)));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const out = new Map();
|
|
308
|
+
for (const [url, { contains, held_by, holdings }] of lists) {
|
|
309
|
+
/** @type {Holdings} */
|
|
310
|
+
const block = {};
|
|
311
|
+
if (contains.length) block.contains = contains.sort(bySubTypeThenTitle);
|
|
312
|
+
if (held_by.length) block.held_by = held_by.sort(byTitle);
|
|
313
|
+
if (holdings.length) block.holdings = holdings.sort(bySubTypeThenTitle);
|
|
314
|
+
if (Object.keys(block).length) out.set(url, block);
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/* --------------------------------------------------------------------- */
|
|
320
|
+
/* The lint */
|
|
321
|
+
/* --------------------------------------------------------------------- */
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Every shortcode some affiliation's `domains` names, computed once per link
|
|
325
|
+
* index. The lint asks the question once per place note, and the answer is a
|
|
326
|
+
* fact about the whole corpus — local notes and every fetched entry — so it
|
|
327
|
+
* is read off the index the first time and kept beside it.
|
|
328
|
+
*
|
|
329
|
+
* @type {WeakMap<object, Set<string>>}
|
|
330
|
+
*/
|
|
331
|
+
const HELD = new WeakMap();
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The shortcodes every affiliation's `domains` names, across the link index.
|
|
335
|
+
*
|
|
336
|
+
* @param {object} index - The link index.
|
|
337
|
+
* @returns {Set<string>} The held shortcodes, lower case.
|
|
338
|
+
*/
|
|
339
|
+
function heldShortcodes(index) {
|
|
340
|
+
let held = HELD.get(index);
|
|
341
|
+
if (held) return held;
|
|
342
|
+
held = new Set();
|
|
343
|
+
for (const note of index.notes ?? []) {
|
|
344
|
+
if (String(note.type ?? note.fm?.type ?? "") !== "affiliation") continue;
|
|
345
|
+
const data = note.fm?.data;
|
|
346
|
+
const domains = data && typeof data === "object" ? data.domains : undefined;
|
|
347
|
+
for (const shortcode of shortcodesOf(domains)) held.add(shortcode);
|
|
348
|
+
}
|
|
349
|
+
for (const entry of index.foreign?.index?.values?.() ?? []) {
|
|
350
|
+
if (String(entry?.type ?? "") !== "affiliation") continue;
|
|
351
|
+
for (const shortcode of shortcodesOf(entry.domains)) held.add(shortcode);
|
|
352
|
+
}
|
|
353
|
+
HELD.set(index, held);
|
|
354
|
+
return held;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Check a place note's tenure: a settlement, a site or a structure that no
|
|
359
|
+
* affiliation's `domains` names is unheld land, reported as a warning at the
|
|
360
|
+
* note's `type:` line.
|
|
361
|
+
*
|
|
362
|
+
* Declared on the `place` vocabulary as its type-level check, so the lint
|
|
363
|
+
* runs it beside the field checks with the same index. Without an index the
|
|
364
|
+
* question cannot be asked and nothing is reported.
|
|
365
|
+
*
|
|
366
|
+
* @param {object} note - The note, as the link index hands it over.
|
|
367
|
+
* @param {object} [opts]
|
|
368
|
+
* @param {object} [opts.index] - The link index, holding every affiliation.
|
|
369
|
+
* @returns {object[]} Findings.
|
|
370
|
+
*/
|
|
371
|
+
export function checkHeld(note, { index } = {}) {
|
|
372
|
+
if (!index) return [];
|
|
373
|
+
const fm = note.fm ?? {};
|
|
374
|
+
if (String(fm.type ?? "") !== "place") return [];
|
|
375
|
+
const subType = String(fm.subType ?? "");
|
|
376
|
+
if (!HELD_SUBTYPES.includes(subType)) return [];
|
|
377
|
+
const shortcode = String(fm.shortcode ?? "").toLowerCase();
|
|
378
|
+
if (!shortcode) return [];
|
|
379
|
+
if (heldShortcodes(index).has(shortcode)) return [];
|
|
380
|
+
return [
|
|
381
|
+
{
|
|
382
|
+
file: note.file,
|
|
383
|
+
...positionInFrontmatter(note.raw ?? "", "type", undefined, { topLevel: true }),
|
|
384
|
+
severity: "warning",
|
|
385
|
+
message:
|
|
386
|
+
`unheld land: no affiliation's \`domains\` names "${shortcode}", so nothing ` +
|
|
387
|
+
`says who holds this ${subType}; name it in the \`domains\` of the house, ` +
|
|
388
|
+
`order or polity that does`,
|
|
389
|
+
},
|
|
390
|
+
];
|
|
391
|
+
}
|
package/engine/index.mjs
CHANGED
|
@@ -87,6 +87,12 @@ export * as noteVocabulary from "./note-vocabulary.mjs";
|
|
|
87
87
|
/** What a place is next to and reachable from: the closed sets, and the checks. */
|
|
88
88
|
export * as placeRelations from "./place-relations.mjs";
|
|
89
89
|
|
|
90
|
+
/** What lies within a place, who holds it, and what an affiliation holds — the page lists, and the tenure check. */
|
|
91
|
+
export * as holdings from "./holdings.mjs";
|
|
92
|
+
|
|
93
|
+
/** What trade a settlement supports: the six-step market scale, and the check that holds a value to it. */
|
|
94
|
+
export * as marketClass from "./market-class.mjs";
|
|
95
|
+
|
|
90
96
|
/** The places a map is drawn from, read from the content index, and what `parents` says about them. */
|
|
91
97
|
export * as mapPlaces from "./map-places.mjs";
|
|
92
98
|
|
|
@@ -102,6 +108,9 @@ export * as mapGraphviz from "./map-graphviz.mjs";
|
|
|
102
108
|
/** `content-build map`: the drawings, written under `build/map/`. */
|
|
103
109
|
export * as mapBuild from "./map-build.mjs";
|
|
104
110
|
|
|
111
|
+
/** The map from each place, drawn for the site and written into its page. */
|
|
112
|
+
export * as siteMaps from "./site-maps.mjs";
|
|
113
|
+
|
|
105
114
|
/** The closed registry of system ids, and the `none` that stands for no system. */
|
|
106
115
|
export * as systems from "./systems.mjs";
|
|
107
116
|
|
package/engine/map-build.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
/**
|
|
15
15
|
* `content-build map`: the drawings, written under `build/map/`.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
17
|
+
* Four modes, each a function of the world the content index describes:
|
|
18
18
|
*
|
|
19
19
|
* - **`tree`** — the containment tree from `parents`, the author's check.
|
|
20
20
|
* `tree.svg` for the whole world and `tree-<continent>.svg` per continent,
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
* - **`from`** — the map from a place, `from-<shortcode>.svg`, for each
|
|
24
24
|
* place named, or for every place that takes part in a border or a route
|
|
25
25
|
* with `all`.
|
|
26
|
+
* - **`chart`** — the chart from a place, `chart-<shortcode>.svg`: the same
|
|
27
|
+
* drawing at a larger horizon, the hops chaining on as long as none turns
|
|
28
|
+
* back. `all` as for `from`.
|
|
26
29
|
* - **`travel`** — the whole route graph, `travel.svg`.
|
|
27
30
|
*
|
|
28
31
|
* Every rendering is written beside the `.dot` it was drawn from, and the
|
|
@@ -42,7 +45,8 @@ import path from "node:path";
|
|
|
42
45
|
|
|
43
46
|
import { fromDot, travelDot, treeDot } from "./map-dot.mjs";
|
|
44
47
|
import { findGraphviz, graphvizMissingMessage, renderDot } from "./map-graphviz.mjs";
|
|
45
|
-
import { layoutFrom, travelGraph } from "./map-layout.mjs";
|
|
48
|
+
import { CHART_HORIZON_DAYS, layoutChart, layoutFrom, travelGraph } from "./map-layout.mjs";
|
|
49
|
+
import { TRAVEL_DAYS } from "./place-relations.mjs";
|
|
46
50
|
import { analyzeContainment, containmentFindings } from "./map-places.mjs";
|
|
47
51
|
|
|
48
52
|
/**
|
|
@@ -53,7 +57,7 @@ import { analyzeContainment, containmentFindings } from "./map-places.mjs";
|
|
|
53
57
|
export const MAP_DIR = "build/map";
|
|
54
58
|
|
|
55
59
|
/**
|
|
56
|
-
* The value of `--from` that means every place with a relation.
|
|
60
|
+
* The value of `--from` or `--chart` that means every place with a relation.
|
|
57
61
|
*
|
|
58
62
|
* @type {string}
|
|
59
63
|
*/
|
|
@@ -61,7 +65,7 @@ export const FROM_ALL = "all";
|
|
|
61
65
|
|
|
62
66
|
/**
|
|
63
67
|
* Every place that states, or is named in, a border or a route — the places
|
|
64
|
-
* `--from all`
|
|
68
|
+
* `--from all` and `--chart all` draw.
|
|
65
69
|
*
|
|
66
70
|
* @param {Map<string, import("./map-places.mjs").MapPlace>} places - Every place.
|
|
67
71
|
* @returns {string[]} Shortcodes, sorted.
|
|
@@ -89,10 +93,14 @@ export function relatedPlaces(places) {
|
|
|
89
93
|
* @param {string} [opts.root] - With `tree`, the subtree beneath this place.
|
|
90
94
|
* @param {string[]} [opts.from] - Draw the map from each of these places, or
|
|
91
95
|
* from every related place for {@link FROM_ALL}.
|
|
96
|
+
* @param {string[]} [opts.chart] - Draw the chart from each of these places,
|
|
97
|
+
* or from every related place for {@link FROM_ALL}.
|
|
98
|
+
* @param {number} [opts.horizon] - The charts' horizon in days, a marker of
|
|
99
|
+
* the scale; the end of the scale by default.
|
|
92
100
|
* @param {boolean} [opts.travel] - Draw the whole route graph.
|
|
93
101
|
* @param {string} [opts.engine="dot"] - The tree's engine: `dot`, `twopi` or
|
|
94
|
-
* `neato`. The map from a place
|
|
95
|
-
* and the route graph always `neato`.
|
|
102
|
+
* `neato`. The map and the chart from a place are always `neato` with
|
|
103
|
+
* every node pinned, and the route graph always `neato`.
|
|
96
104
|
* @param {string} [opts.rankdir="TB"] - The tree's rank direction, `dot` only.
|
|
97
105
|
* @param {number} [opts.nodesep] - The tree's `nodesep`, inches.
|
|
98
106
|
* @param {number} [opts.ranksep] - The tree's `ranksep`, inches.
|
|
@@ -107,8 +115,8 @@ export function relatedPlaces(places) {
|
|
|
107
115
|
* column?: number, severity: "error"|"warning", message: string}>,
|
|
108
116
|
* skipped: boolean}} What was written, what was found, and whether the
|
|
109
117
|
* rendering was skipped for want of GraphViz.
|
|
110
|
-
* @throws {Error} When no mode is asked for, a named place is not one,
|
|
111
|
-
* GraphViz is required and absent.
|
|
118
|
+
* @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.
|
|
112
120
|
*/
|
|
113
121
|
export function buildMaps({
|
|
114
122
|
world,
|
|
@@ -116,6 +124,8 @@ export function buildMaps({
|
|
|
116
124
|
tree = false,
|
|
117
125
|
root,
|
|
118
126
|
from = [],
|
|
127
|
+
chart = [],
|
|
128
|
+
horizon = CHART_HORIZON_DAYS,
|
|
119
129
|
travel = false,
|
|
120
130
|
engine = "dot",
|
|
121
131
|
rankdir = "TB",
|
|
@@ -126,8 +136,15 @@ export function buildMaps({
|
|
|
126
136
|
locate = findGraphviz,
|
|
127
137
|
requireGraphviz = true,
|
|
128
138
|
}) {
|
|
129
|
-
if (!tree && from.length === 0 && !travel) {
|
|
130
|
-
throw new Error(
|
|
139
|
+
if (!tree && from.length === 0 && chart.length === 0 && !travel) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
"name a drawing: --tree, --from <shortcode>, --chart <shortcode> or --travel",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (!TRAVEL_DAYS.includes(horizon)) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`--horizon must be a marker of the days scale — one of ${TRAVEL_DAYS.join(", ")} — and ${horizon} is not`,
|
|
147
|
+
);
|
|
131
148
|
}
|
|
132
149
|
const { places } = world;
|
|
133
150
|
|
|
@@ -135,7 +152,7 @@ export function buildMaps({
|
|
|
135
152
|
// so a missing one is reported before a directory is touched.
|
|
136
153
|
const engines = new Set();
|
|
137
154
|
if (tree) engines.add(engine);
|
|
138
|
-
if (from.length || travel) engines.add("neato");
|
|
155
|
+
if (from.length || chart.length || travel) engines.add("neato");
|
|
139
156
|
/** @type {Map<string, string>} */
|
|
140
157
|
const binaries = new Map();
|
|
141
158
|
const findings = [];
|
|
@@ -212,6 +229,19 @@ export function buildMaps({
|
|
|
212
229
|
emit(`from-${centre}`, fromDot(layout, places, { scale }), "neato", ["-n2"]);
|
|
213
230
|
}
|
|
214
231
|
|
|
232
|
+
const viewpoints = chart.includes(FROM_ALL) ? relatedPlaces(places) : chart;
|
|
233
|
+
for (const centre of viewpoints) {
|
|
234
|
+
const layout = layoutChart(places, centre, { horizon });
|
|
235
|
+
for (const { from: place, to } of layout.unresolved) {
|
|
236
|
+
findings.push({
|
|
237
|
+
file: unresolvedFile(world, place),
|
|
238
|
+
severity: "warning",
|
|
239
|
+
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
|
+
});
|
|
241
|
+
}
|
|
242
|
+
emit(`chart-${centre}`, fromDot(layout, places, { scale }), "neato", ["-n2"]);
|
|
243
|
+
}
|
|
244
|
+
|
|
215
245
|
if (travel) {
|
|
216
246
|
const graph = travelGraph(places);
|
|
217
247
|
for (const { from: place, to } of graph.unresolved) {
|