@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.
- package/CHANGELOG.md +28 -18
- package/CONTENT.md +77 -1
- package/README.md +9 -0
- package/bin/content-build.mjs +143 -0
- package/bin/package-build.mjs +18 -4
- package/content-config.mjs +7 -0
- package/docs/api.md +84 -0
- package/docs/commands.md +182 -2
- package/docs/configuration.md +19 -1
- package/docs/content-format.md +105 -6
- package/docs/diagnostics.md +1 -0
- package/docs/project-setup.md +7 -6
- package/engine/changelog-group.mjs +27 -6
- package/engine/changelog-lint.mjs +27 -7
- package/engine/frontmatter-lint.mjs +11 -1
- package/engine/homepage.mjs +9 -1
- package/engine/index.mjs +18 -0
- package/engine/map-build.mjs +240 -0
- package/engine/map-dot.mjs +685 -0
- package/engine/map-graphviz.mjs +138 -0
- package/engine/map-layout.mjs +712 -0
- package/engine/map-places.mjs +561 -0
- package/engine/metadata-index.mjs +7 -0
- package/engine/note-vocabulary.mjs +28 -0
- package/engine/place-relations.mjs +637 -0
- package/engine/related-pages.mjs +161 -0
- package/engine/site-build.mjs +107 -9
- package/engine/site-config.mjs +1 -1
- package/engine/site-index.mjs +5 -1
- package/engine/site-root.mjs +147 -6
- package/engine/web-wikilinks.mjs +13 -2
- package/package.json +3 -2
- package/types/engine/changelog-lint.d.mts +9 -7
- package/types/engine/homepage.d.mts +8 -1
- package/types/engine/index.d.mts +6 -0
- package/types/engine/map-build.d.mts +78 -0
- package/types/engine/map-dot.d.mts +130 -0
- package/types/engine/map-graphviz.d.mts +61 -0
- package/types/engine/map-layout.d.mts +359 -0
- package/types/engine/map-places.d.mts +259 -0
- package/types/engine/note-vocabulary.d.mts +12 -0
- package/types/engine/place-relations.d.mts +95 -0
- package/types/engine/related-pages.d.mts +70 -0
- package/types/engine/site-build.d.mts +36 -3
- package/types/engine/site-config.d.mts +1 -1
- package/types/engine/site-index.d.mts +5 -1
- package/types/engine/site-root.d.mts +59 -7
- package/types/engine/web-wikilinks.d.mts +11 -2
|
@@ -0,0 +1,561 @@
|
|
|
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 places a map is drawn from, and what their `parents` say about the
|
|
16
|
+
* world's shape.
|
|
17
|
+
*
|
|
18
|
+
* **The map reads the content index, not the notes.** A place is a record of
|
|
19
|
+
* this package's index or an entry of a fetched dependency's, read through
|
|
20
|
+
* the same fields either way — name, `subType`, `parents`, `borders`,
|
|
21
|
+
* `routes`, address — so a consumer's map draws its dependencies' places
|
|
22
|
+
* beside its own without a second walk and without knowing which package a
|
|
23
|
+
* place came from. A note is opened only to locate a finding in it.
|
|
24
|
+
*
|
|
25
|
+
* The containment analysis is the author's check: which place is the world,
|
|
26
|
+
* which regions are continents, which continent each place is under, and
|
|
27
|
+
* everything `parents` can get wrong — a place with no parent, a parent no
|
|
28
|
+
* place declares, a cycle, a place with two parents, a region with no
|
|
29
|
+
* `packFolder`, a polity beside no region. Each is reported in the
|
|
30
|
+
* toolchain's finding form, located at the entry that states it.
|
|
31
|
+
*
|
|
32
|
+
* @module
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import fs from "node:fs";
|
|
36
|
+
import path from "node:path";
|
|
37
|
+
|
|
38
|
+
import { indexRecordsFor, isNoteRecord } from "./content-index.mjs";
|
|
39
|
+
import { noteFile } from "./index-records.mjs";
|
|
40
|
+
import { cachedIndexPath, loadForeignIndexes } from "./metadata-index.mjs";
|
|
41
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
42
|
+
import { resolvePackageUrl } from "./content-address.mjs";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A place as the map reads it, from either source.
|
|
46
|
+
*
|
|
47
|
+
* @typedef {object} MapPlace
|
|
48
|
+
* @property {string} shortcode - Lower case.
|
|
49
|
+
* @property {string} name - `name.full`, or the shortcode where a note has none.
|
|
50
|
+
* @property {string} type - `place`.
|
|
51
|
+
* @property {string} [subType]
|
|
52
|
+
* @property {string[]} parents - Shortcodes, lower case, empty where none.
|
|
53
|
+
* @property {Array<{to: string, bearing: string}>} borders
|
|
54
|
+
* @property {Array<{to: string, bearing: string, mode?: string, days?: number}>} routes
|
|
55
|
+
* @property {string} [address] - The canonical address.
|
|
56
|
+
* @property {string} [url] - The page, where a site base is known.
|
|
57
|
+
* @property {string} [file] - The note's path below the content root; local only.
|
|
58
|
+
* @property {string} [folder] - The note's directory below the content root; local only.
|
|
59
|
+
* @property {string} package - The package that publishes it.
|
|
60
|
+
* @property {boolean} local - Whether it is this package's own.
|
|
61
|
+
* @property {boolean|undefined} hasPackFolder - Whether the note declares
|
|
62
|
+
* `packFolder`; unknown for a fetched entry.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A polity — an `affiliation` note of `subType: polity` — drawn beside the
|
|
67
|
+
* region notes that share its directory.
|
|
68
|
+
*
|
|
69
|
+
* @typedef {object} MapPolity
|
|
70
|
+
* @property {string} shortcode
|
|
71
|
+
* @property {string} name
|
|
72
|
+
* @property {string} [address]
|
|
73
|
+
* @property {string} [url]
|
|
74
|
+
* @property {string} file
|
|
75
|
+
* @property {string} folder
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The world a map is drawn from: every place, every polity, and where the
|
|
80
|
+
* notes are for locating a finding.
|
|
81
|
+
*
|
|
82
|
+
* @typedef {object} MapWorld
|
|
83
|
+
* @property {Map<string, MapPlace>} places - By shortcode, lower case.
|
|
84
|
+
* @property {MapPolity[]} polities - In path order.
|
|
85
|
+
* @property {string} contentBase - The content root the local notes sit under.
|
|
86
|
+
* @property {object} [config] - The resolved configuration, where one was used.
|
|
87
|
+
* @property {Array<{package: string, reason: string}>} stale - Dependency
|
|
88
|
+
* indexes that could not be read.
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The shortcode a `parents` entry names, lower case: the last segment of
|
|
93
|
+
* either a bare shortcode or an address.
|
|
94
|
+
*
|
|
95
|
+
* @param {unknown} parent - One `parents` entry.
|
|
96
|
+
* @returns {string} Its shortcode.
|
|
97
|
+
*/
|
|
98
|
+
function parentShortcode(parent) {
|
|
99
|
+
return String(parent ?? "")
|
|
100
|
+
.split("-")
|
|
101
|
+
.pop()
|
|
102
|
+
.toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A `parents` value as a list of shortcodes.
|
|
107
|
+
*
|
|
108
|
+
* @param {unknown} value - The authored value, a list or a lone entry.
|
|
109
|
+
* @returns {string[]} Shortcodes, lower case, empties dropped.
|
|
110
|
+
*/
|
|
111
|
+
function parentsOf(value) {
|
|
112
|
+
const list =
|
|
113
|
+
Array.isArray(value) ? value
|
|
114
|
+
: value ? [value]
|
|
115
|
+
: [];
|
|
116
|
+
return list.map(parentShortcode).filter(Boolean);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A relation list as the map reads it: entries that are maps, `to` lower
|
|
121
|
+
* case.
|
|
122
|
+
*
|
|
123
|
+
* @param {unknown} value - A `borders` or `routes` value.
|
|
124
|
+
* @returns {Array<Record<string, any>>} The entries.
|
|
125
|
+
*/
|
|
126
|
+
function relationList(value) {
|
|
127
|
+
if (!Array.isArray(value)) return [];
|
|
128
|
+
return value
|
|
129
|
+
.filter((e) => e && typeof e === "object" && !Array.isArray(e))
|
|
130
|
+
.map((e) => ({ ...e, to: String(e.to ?? "").toLowerCase() }));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read this package's places and polities out of its index records.
|
|
135
|
+
*
|
|
136
|
+
* @param {Array<Record<string, any>>} records - Index records, as
|
|
137
|
+
* {@link module:engine/content-index.indexRecordsFor} returns them.
|
|
138
|
+
* @param {object} opts
|
|
139
|
+
* @param {string} opts.contentBase - The content root the records were read from.
|
|
140
|
+
* @param {string} [opts.base] - The site base the package's pages are served
|
|
141
|
+
* under, ending in a slash. Absent, no place carries a URL.
|
|
142
|
+
* @returns {MapWorld} The world, with no dependency's places.
|
|
143
|
+
*/
|
|
144
|
+
export function placesFromRecords(records, { contentBase, base }) {
|
|
145
|
+
/** @type {Map<string, MapPlace>} */
|
|
146
|
+
const places = new Map();
|
|
147
|
+
/** @type {MapPolity[]} */
|
|
148
|
+
const polities = [];
|
|
149
|
+
const url = (record) =>
|
|
150
|
+
base && record.address?.slug ?
|
|
151
|
+
resolvePackageUrl(`${record.address.slug}/`, base)
|
|
152
|
+
: undefined;
|
|
153
|
+
|
|
154
|
+
for (const record of records) {
|
|
155
|
+
if (!isNoteRecord(record)) continue;
|
|
156
|
+
const shortcode = String(record.shortcode ?? "").toLowerCase();
|
|
157
|
+
if (!shortcode) continue;
|
|
158
|
+
const name = String(record.name?.full ?? record.name ?? shortcode);
|
|
159
|
+
if (record.type === "place") {
|
|
160
|
+
// First writer wins, as in the index itself; a duplicate is the
|
|
161
|
+
// lint's finding.
|
|
162
|
+
if (places.has(shortcode)) continue;
|
|
163
|
+
const data = record.data && typeof record.data === "object" ? record.data : {};
|
|
164
|
+
places.set(shortcode, {
|
|
165
|
+
shortcode,
|
|
166
|
+
name,
|
|
167
|
+
type: "place",
|
|
168
|
+
subType: record.subType === undefined ? undefined : String(record.subType),
|
|
169
|
+
parents: parentsOf(data.parents),
|
|
170
|
+
borders: relationList(data.borders),
|
|
171
|
+
routes: relationList(data.routes),
|
|
172
|
+
address: record.address?.canonical,
|
|
173
|
+
url: url(record),
|
|
174
|
+
file: record.file?.path,
|
|
175
|
+
folder: record.file?.folder ?? "",
|
|
176
|
+
package: String(record.package ?? ""),
|
|
177
|
+
local: true,
|
|
178
|
+
hasPackFolder: Object.hasOwn(record, "packFolder"),
|
|
179
|
+
});
|
|
180
|
+
} else if (record.type === "affiliation" && record.subType === "polity") {
|
|
181
|
+
polities.push({
|
|
182
|
+
shortcode,
|
|
183
|
+
name,
|
|
184
|
+
address: record.address?.canonical,
|
|
185
|
+
url: url(record),
|
|
186
|
+
file: String(record.file?.path ?? ""),
|
|
187
|
+
folder: String(record.file?.folder ?? ""),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
polities.sort((a, b) => a.file.localeCompare(b.file));
|
|
192
|
+
return { places, polities, contentBase, stale: [] };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Add a fetched index's places to a world.
|
|
197
|
+
*
|
|
198
|
+
* An entry carries what the producer's record stated — name, `subType`,
|
|
199
|
+
* `parents`, `borders`, `routes` and its page URL — and nothing about the
|
|
200
|
+
* file it came from, so a finding about one is located at the cached index
|
|
201
|
+
* that supplied it. A shortcode this package already declares is left to the
|
|
202
|
+
* local place.
|
|
203
|
+
*
|
|
204
|
+
* @param {MapWorld} world - The world to add to.
|
|
205
|
+
* @param {Map<string, object>} foreignIndex - As
|
|
206
|
+
* {@link module:engine/metadata-index.loadForeignIndexes} returns it.
|
|
207
|
+
* @returns {void}
|
|
208
|
+
*/
|
|
209
|
+
function addForeignPlaces(world, foreignIndex) {
|
|
210
|
+
for (const [canonical, entry] of foreignIndex) {
|
|
211
|
+
if (entry?.type !== "place") continue;
|
|
212
|
+
const shortcode = canonical.split("-").pop()?.toLowerCase() ?? "";
|
|
213
|
+
if (!shortcode || world.places.has(shortcode)) continue;
|
|
214
|
+
world.places.set(shortcode, {
|
|
215
|
+
shortcode,
|
|
216
|
+
name: String(entry.name ?? shortcode),
|
|
217
|
+
type: "place",
|
|
218
|
+
subType: entry.subType === undefined ? undefined : String(entry.subType),
|
|
219
|
+
parents: parentsOf(entry.parents),
|
|
220
|
+
borders: relationList(entry.borders),
|
|
221
|
+
routes: relationList(entry.routes),
|
|
222
|
+
address: canonical,
|
|
223
|
+
url: entry.url,
|
|
224
|
+
package: String(entry.package ?? ""),
|
|
225
|
+
local: false,
|
|
226
|
+
hasPackFolder: undefined,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Load the world a map is drawn from: this package's index, and the indexes
|
|
233
|
+
* of every dependency it declares.
|
|
234
|
+
*
|
|
235
|
+
* @param {object} opts
|
|
236
|
+
* @param {object} opts.config - The resolved configuration.
|
|
237
|
+
* @param {string} [opts.contentBase] - The content root, defaulting to the
|
|
238
|
+
* configured one.
|
|
239
|
+
* @param {string} [opts.base] - The site base this package's pages are served
|
|
240
|
+
* under. Absent, no local place carries a URL; a dependency's places carry
|
|
241
|
+
* the URL their index resolved to regardless.
|
|
242
|
+
* @param {object[]} [opts.problems] - Collects the notes the index cannot
|
|
243
|
+
* record, as diagnostics, instead of throwing on the first.
|
|
244
|
+
* @returns {MapWorld} The world.
|
|
245
|
+
*/
|
|
246
|
+
export function loadMapWorld({ config, contentBase, base, problems }) {
|
|
247
|
+
const tree = contentBase ?? config.paths.content;
|
|
248
|
+
const records = indexRecordsFor({
|
|
249
|
+
contentBase: tree,
|
|
250
|
+
config,
|
|
251
|
+
skipDirectories: config.skipDirectories,
|
|
252
|
+
problems,
|
|
253
|
+
});
|
|
254
|
+
const world = placesFromRecords(records, { contentBase: tree, base });
|
|
255
|
+
world.config = config;
|
|
256
|
+
const foreign = loadForeignIndexes(config, [config.contentPackage]);
|
|
257
|
+
addForeignPlaces(world, foreign.index);
|
|
258
|
+
world.stale = foreign.stale;
|
|
259
|
+
return world;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* --------------------------------------------------------------------- */
|
|
263
|
+
/* Containment */
|
|
264
|
+
/* --------------------------------------------------------------------- */
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* What `parents` says about the world's shape.
|
|
268
|
+
*
|
|
269
|
+
* @typedef {object} Containment
|
|
270
|
+
* @property {string|undefined} world - The one `subType: world` place.
|
|
271
|
+
* @property {string[]} continents - Regions parented directly on the world,
|
|
272
|
+
* in name order.
|
|
273
|
+
* @property {Map<string, string>} continentOf - Each place's continent, for
|
|
274
|
+
* the places that reach one by walking `parents` upward.
|
|
275
|
+
* @property {Map<string, string[]>} children - Each place's children.
|
|
276
|
+
* @property {string[][]} cycles - Each cycle as a chain that returns to its
|
|
277
|
+
* first place.
|
|
278
|
+
* @property {Set<string>} cycleNodes - Every place on any cycle.
|
|
279
|
+
* @property {Map<string, string[]>} regionsByFolder - The local region places
|
|
280
|
+
* in each directory, for placing polities.
|
|
281
|
+
* @property {MapPolity[]} polities - The polities analysed.
|
|
282
|
+
* @property {Anomalies} anomalies
|
|
283
|
+
*/
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Everything the containment tree can get wrong.
|
|
287
|
+
*
|
|
288
|
+
* @typedef {object} Anomalies
|
|
289
|
+
* @property {string[]} noParent - Places with no parent, the world excluded.
|
|
290
|
+
* @property {Array<{place: string, parent: string, index: number}>} unresolved -
|
|
291
|
+
* `parents` entries no place declares.
|
|
292
|
+
* @property {string[][]} cycles
|
|
293
|
+
* @property {string[]} multiParent - Places with more than one parent.
|
|
294
|
+
* @property {string[]} polityNoRegion - Polity shortcodes whose directory
|
|
295
|
+
* holds no region note.
|
|
296
|
+
* @property {string[]} regionNoPackFolder - Local regions with no `packFolder`.
|
|
297
|
+
*/
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Every cycle in the child → parent graph, by depth-first search over the
|
|
301
|
+
* parents that resolve.
|
|
302
|
+
*
|
|
303
|
+
* @param {Map<string, MapPlace>} places - Every place.
|
|
304
|
+
* @returns {{cycles: string[][], cycleNodes: Set<string>}} Each cycle once,
|
|
305
|
+
* and the union of their places.
|
|
306
|
+
*/
|
|
307
|
+
function findCycles(places) {
|
|
308
|
+
const WHITE = 0;
|
|
309
|
+
const GREY = 1;
|
|
310
|
+
const BLACK = 2;
|
|
311
|
+
const colour = new Map([...places.keys()].map((sc) => [sc, WHITE]));
|
|
312
|
+
/** @type {string[]} */
|
|
313
|
+
const stack = [];
|
|
314
|
+
/** @type {string[][]} */
|
|
315
|
+
const cycles = [];
|
|
316
|
+
const cycleNodes = new Set();
|
|
317
|
+
const seen = new Set();
|
|
318
|
+
|
|
319
|
+
const visit = (sc) => {
|
|
320
|
+
colour.set(sc, GREY);
|
|
321
|
+
stack.push(sc);
|
|
322
|
+
for (const parent of places.get(sc)?.parents ?? []) {
|
|
323
|
+
if (!places.has(parent)) continue;
|
|
324
|
+
if (colour.get(parent) === WHITE) visit(parent);
|
|
325
|
+
else if (colour.get(parent) === GREY) {
|
|
326
|
+
const cycle = [...stack.slice(stack.indexOf(parent)), parent];
|
|
327
|
+
const key = [...cycle].sort().join("|");
|
|
328
|
+
if (!seen.has(key)) {
|
|
329
|
+
seen.add(key);
|
|
330
|
+
cycles.push(cycle);
|
|
331
|
+
}
|
|
332
|
+
for (const node of cycle) cycleNodes.add(node);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
stack.pop();
|
|
336
|
+
colour.set(sc, BLACK);
|
|
337
|
+
};
|
|
338
|
+
for (const sc of [...places.keys()].sort()) if (colour.get(sc) === WHITE) visit(sc);
|
|
339
|
+
return { cycles, cycleNodes };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Analyse the containment tree.
|
|
344
|
+
*
|
|
345
|
+
* @param {Map<string, MapPlace>} places - Every place.
|
|
346
|
+
* @param {MapPolity[]} [polities] - Every polity.
|
|
347
|
+
* @returns {Containment} The analysis.
|
|
348
|
+
*/
|
|
349
|
+
export function analyzeContainment(places, polities = []) {
|
|
350
|
+
const worlds = [...places.values()].filter((p) => p.subType === "world");
|
|
351
|
+
const world = worlds[0]?.shortcode;
|
|
352
|
+
|
|
353
|
+
/** @type {Map<string, string[]>} */
|
|
354
|
+
const children = new Map();
|
|
355
|
+
for (const place of places.values()) {
|
|
356
|
+
for (const parent of place.parents) {
|
|
357
|
+
if (!places.has(parent)) continue;
|
|
358
|
+
const list = children.get(parent) ?? [];
|
|
359
|
+
list.push(place.shortcode);
|
|
360
|
+
children.set(parent, list);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** @type {Map<string, string>} */
|
|
365
|
+
const continentOf = new Map();
|
|
366
|
+
const resolve = (sc, visiting) => {
|
|
367
|
+
if (continentOf.has(sc)) return continentOf.get(sc);
|
|
368
|
+
const place = places.get(sc);
|
|
369
|
+
if (!place || sc === world || visiting.has(sc)) return undefined;
|
|
370
|
+
if (place.subType === "region" && world !== undefined && place.parents.includes(world)) {
|
|
371
|
+
continentOf.set(sc, sc);
|
|
372
|
+
return sc;
|
|
373
|
+
}
|
|
374
|
+
visiting.add(sc);
|
|
375
|
+
let found;
|
|
376
|
+
for (const parent of place.parents) {
|
|
377
|
+
found = resolve(parent, visiting);
|
|
378
|
+
if (found !== undefined) break;
|
|
379
|
+
}
|
|
380
|
+
visiting.delete(sc);
|
|
381
|
+
if (found !== undefined) continentOf.set(sc, found);
|
|
382
|
+
return found;
|
|
383
|
+
};
|
|
384
|
+
for (const sc of places.keys()) resolve(sc, new Set());
|
|
385
|
+
|
|
386
|
+
const byName = (a, b) =>
|
|
387
|
+
String(places.get(a)?.name ?? a).localeCompare(String(places.get(b)?.name ?? b));
|
|
388
|
+
const continents = [...continentOf.entries()]
|
|
389
|
+
.filter(([sc, c]) => sc === c)
|
|
390
|
+
.map(([sc]) => sc)
|
|
391
|
+
.sort(byName);
|
|
392
|
+
|
|
393
|
+
/** @type {Map<string, string[]>} */
|
|
394
|
+
const regionsByFolder = new Map();
|
|
395
|
+
for (const place of places.values()) {
|
|
396
|
+
if (!place.local || place.subType !== "region") continue;
|
|
397
|
+
const list = regionsByFolder.get(place.folder ?? "") ?? [];
|
|
398
|
+
list.push(place.shortcode);
|
|
399
|
+
regionsByFolder.set(place.folder ?? "", list);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const { cycles, cycleNodes } = findCycles(places);
|
|
403
|
+
/** @type {Anomalies} */
|
|
404
|
+
const anomalies = {
|
|
405
|
+
noParent: [],
|
|
406
|
+
unresolved: [],
|
|
407
|
+
cycles,
|
|
408
|
+
multiParent: [],
|
|
409
|
+
polityNoRegion: [],
|
|
410
|
+
regionNoPackFolder: [],
|
|
411
|
+
};
|
|
412
|
+
for (const sc of [...places.keys()].sort()) {
|
|
413
|
+
const place = /** @type {MapPlace} */ (places.get(sc));
|
|
414
|
+
if (place.parents.length === 0 && sc !== world) anomalies.noParent.push(sc);
|
|
415
|
+
if (place.parents.length > 1) anomalies.multiParent.push(sc);
|
|
416
|
+
if (place.local && place.subType === "region" && place.hasPackFolder === false) {
|
|
417
|
+
anomalies.regionNoPackFolder.push(sc);
|
|
418
|
+
}
|
|
419
|
+
place.parents.forEach((parent, index) => {
|
|
420
|
+
if (!places.has(parent)) anomalies.unresolved.push({ place: sc, parent, index });
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
for (const polity of polities) {
|
|
424
|
+
if (!regionsByFolder.get(polity.folder)?.length)
|
|
425
|
+
anomalies.polityNoRegion.push(polity.shortcode);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
world,
|
|
430
|
+
continents,
|
|
431
|
+
continentOf,
|
|
432
|
+
children,
|
|
433
|
+
cycles,
|
|
434
|
+
cycleNodes,
|
|
435
|
+
regionsByFolder,
|
|
436
|
+
polities,
|
|
437
|
+
anomalies,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Every place beneath one, by following `parents` downward — the place
|
|
443
|
+
* itself included.
|
|
444
|
+
*
|
|
445
|
+
* @param {Containment} analysis - The analysis, for its children map.
|
|
446
|
+
* @param {string} root - The place at the top.
|
|
447
|
+
* @returns {Set<string>} The subtree.
|
|
448
|
+
*/
|
|
449
|
+
export function subtreeOf(analysis, root) {
|
|
450
|
+
const seen = new Set();
|
|
451
|
+
const stack = [root];
|
|
452
|
+
while (stack.length) {
|
|
453
|
+
const sc = /** @type {string} */ (stack.pop());
|
|
454
|
+
if (seen.has(sc)) continue;
|
|
455
|
+
seen.add(sc);
|
|
456
|
+
stack.push(...(analysis.children.get(sc) ?? []));
|
|
457
|
+
}
|
|
458
|
+
return seen;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The anomalies as findings, in the toolchain's form.
|
|
463
|
+
*
|
|
464
|
+
* A finding about a `parents` entry is located at that entry in the note; a
|
|
465
|
+
* finding about the note as a whole at its `data` block, or the file alone;
|
|
466
|
+
* a finding about a fetched place at the cached index that supplied it. A
|
|
467
|
+
* place with no parent, a parent no place declares, and a cycle are errors —
|
|
468
|
+
* the tree cannot reach the place, or cannot be a tree. Two parents, a region
|
|
469
|
+
* with no `packFolder` and a polity beside no region are legal and worth a
|
|
470
|
+
* look, so they are warnings.
|
|
471
|
+
*
|
|
472
|
+
* @param {Containment} analysis - The analysis.
|
|
473
|
+
* @param {Map<string, MapPlace>} places - Every place.
|
|
474
|
+
* @param {object} opts
|
|
475
|
+
* @param {string} opts.contentBase - The content root the local notes sit under.
|
|
476
|
+
* @param {object} [opts.config] - The configuration, for naming a cached index.
|
|
477
|
+
* @returns {Array<{file: string, line?: number, column?: number,
|
|
478
|
+
* severity: "error"|"warning", message: string}>} The findings.
|
|
479
|
+
*/
|
|
480
|
+
export function containmentFindings(analysis, places, { contentBase, config }) {
|
|
481
|
+
const findings = [];
|
|
482
|
+
/** @type {Map<string, string>} */
|
|
483
|
+
const rawCache = new Map();
|
|
484
|
+
const rawOf = (file) => {
|
|
485
|
+
if (!rawCache.has(file)) {
|
|
486
|
+
let text = "";
|
|
487
|
+
try {
|
|
488
|
+
text = fs.readFileSync(file, "utf8");
|
|
489
|
+
} catch {
|
|
490
|
+
// The note is gone or unreadable; the finding names the file
|
|
491
|
+
// alone rather than guessing a line.
|
|
492
|
+
}
|
|
493
|
+
rawCache.set(file, text);
|
|
494
|
+
}
|
|
495
|
+
return /** @type {string} */ (rawCache.get(file));
|
|
496
|
+
};
|
|
497
|
+
const locate = (place, keyPath) => {
|
|
498
|
+
if (place.local && place.file) {
|
|
499
|
+
const file = noteFile(contentBase, { file: { path: place.file } });
|
|
500
|
+
return { file, ...(keyPath ? positionOfFrontmatterPath(rawOf(file), keyPath) : {}) };
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
file:
|
|
504
|
+
config ?
|
|
505
|
+
cachedIndexPath(config, place.package)
|
|
506
|
+
: path.join("build", "cache", "metadata", place.package),
|
|
507
|
+
};
|
|
508
|
+
};
|
|
509
|
+
const { anomalies } = analysis;
|
|
510
|
+
|
|
511
|
+
for (const sc of anomalies.noParent) {
|
|
512
|
+
const place = /** @type {MapPlace} */ (places.get(sc));
|
|
513
|
+
findings.push({
|
|
514
|
+
...locate(place, ["data"]),
|
|
515
|
+
severity: "error",
|
|
516
|
+
message: `place "${sc}" has no parent, so the containment tree cannot reach it; state what it is within in \`data.parents\``,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
for (const { place: sc, parent, index } of anomalies.unresolved) {
|
|
520
|
+
const place = /** @type {MapPlace} */ (places.get(sc));
|
|
521
|
+
findings.push({
|
|
522
|
+
...locate(place, ["data", "parents", index]),
|
|
523
|
+
severity: "error",
|
|
524
|
+
message: `place "${sc}" names parent "${parent}", which does not resolve to any place in this package or a fetched index`,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
for (const cycle of anomalies.cycles) {
|
|
528
|
+
const first = /** @type {MapPlace} */ (places.get(cycle[0]));
|
|
529
|
+
const index = Math.max(0, first.parents.indexOf(cycle[1]));
|
|
530
|
+
findings.push({
|
|
531
|
+
...locate(first, ["data", "parents", index]),
|
|
532
|
+
severity: "error",
|
|
533
|
+
message: `\`parents\` form a cycle: ${cycle.join(" → ")}; a place is never its own ancestor`,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
for (const sc of anomalies.multiParent) {
|
|
537
|
+
const place = /** @type {MapPlace} */ (places.get(sc));
|
|
538
|
+
findings.push({
|
|
539
|
+
...locate(place, ["data", "parents"]),
|
|
540
|
+
severity: "warning",
|
|
541
|
+
message: `place "${sc}" has more than one parent (${place.parents.join(", ")}), which is legal for a sea between continents or a region split between two, and worth a look otherwise`,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
for (const sc of anomalies.regionNoPackFolder) {
|
|
545
|
+
const place = /** @type {MapPlace} */ (places.get(sc));
|
|
546
|
+
findings.push({
|
|
547
|
+
...locate(place, undefined),
|
|
548
|
+
severity: "warning",
|
|
549
|
+
message: `region "${sc}" declares no \`packFolder\`, so its documents are filed by address rather than in a folder of their own`,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
for (const sc of anomalies.polityNoRegion) {
|
|
553
|
+
const polity = analysis.polities.find((p) => p.shortcode === sc);
|
|
554
|
+
findings.push({
|
|
555
|
+
file: polity ? noteFile(contentBase, { file: { path: polity.file } }) : contentBase,
|
|
556
|
+
severity: "warning",
|
|
557
|
+
message: `polity "${sc}" sits in a directory holding no region note, so the containment tree has no region to draw it beside`,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
return findings;
|
|
561
|
+
}
|
|
@@ -386,6 +386,13 @@ 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.
|
|
393
|
+
borders: record.data?.borders ?? undefined,
|
|
394
|
+
routes: record.data?.routes ?? undefined,
|
|
395
|
+
parents: record.data?.parents ?? undefined,
|
|
389
396
|
package: pkg,
|
|
390
397
|
});
|
|
391
398
|
}
|
|
@@ -82,6 +82,9 @@ import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./address-charset.mjs
|
|
|
82
82
|
// vocabulary that answered only to the current spelling would report every key
|
|
83
83
|
// of an unswept note as unknown.
|
|
84
84
|
import { currentType } from "./ids.mjs";
|
|
85
|
+
// What a place is next to and reachable from — the two relation lists and
|
|
86
|
+
// the checks that hold them to their closed sets and to each other.
|
|
87
|
+
import { checkBorders, checkRoutes } from "./place-relations.mjs";
|
|
85
88
|
|
|
86
89
|
/**
|
|
87
90
|
* One `data:` key a note type may carry.
|
|
@@ -112,6 +115,13 @@ import { currentType } from "./ids.mjs";
|
|
|
112
115
|
* @property {"pack"} [keys] - For a `scalar-or-map` field, what its keys name.
|
|
113
116
|
* `"pack"` means each is a pack this package declares, so a key naming none
|
|
114
117
|
* is a finding of its own: it addresses a hierarchy nothing will ever read.
|
|
118
|
+
* @property {(note: object, opts: {index?: object}) => object[]} [check] - A
|
|
119
|
+
* check of the value beyond its shape, run by the frontmatter lint with the
|
|
120
|
+
* link index once the container has been checked. The lint stays
|
|
121
|
+
* vocabulary-agnostic this way: the rule lives beside the field that needs
|
|
122
|
+
* it, and the linter calls whatever it is handed. A place's `borders` and
|
|
123
|
+
* `routes` carry one, because what they state is checked against the note
|
|
124
|
+
* at the other end.
|
|
115
125
|
* @property {string} describe - One line, for the author-facing reference.
|
|
116
126
|
*/
|
|
117
127
|
|
|
@@ -997,6 +1007,24 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
997
1007
|
...NUM,
|
|
998
1008
|
describe: "Approximate population, to two significant digits.",
|
|
999
1009
|
},
|
|
1010
|
+
{
|
|
1011
|
+
name: "borders",
|
|
1012
|
+
...LIST,
|
|
1013
|
+
shape: "list of `{ to, bearing }` entries",
|
|
1014
|
+
check: checkBorders,
|
|
1015
|
+
describe:
|
|
1016
|
+
"Places sharing a frontier with this one — each the other's shortcode " +
|
|
1017
|
+
"and where it lies from here.",
|
|
1018
|
+
},
|
|
1019
|
+
{
|
|
1020
|
+
name: "routes",
|
|
1021
|
+
...LIST,
|
|
1022
|
+
shape: "list of `{ to, bearing, mode, days, terrain?, leagues? }` entries",
|
|
1023
|
+
check: checkRoutes,
|
|
1024
|
+
describe:
|
|
1025
|
+
"Journeys from this place's centre — where the destination lies, how " +
|
|
1026
|
+
"it is travelled, and about how many days it takes.",
|
|
1027
|
+
},
|
|
1000
1028
|
]),
|
|
1001
1029
|
}),
|
|
1002
1030
|
|