@heroiclands/package-build 22.4.3 → 22.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +28 -18
  2. package/CONTENT.md +77 -1
  3. package/README.md +9 -0
  4. package/bin/content-build.mjs +143 -0
  5. package/bin/package-build.mjs +18 -4
  6. package/content-config.mjs +7 -0
  7. package/docs/api.md +84 -0
  8. package/docs/commands.md +182 -2
  9. package/docs/configuration.md +19 -1
  10. package/docs/content-format.md +105 -6
  11. package/docs/diagnostics.md +1 -0
  12. package/docs/project-setup.md +7 -6
  13. package/engine/changelog-group.mjs +27 -6
  14. package/engine/changelog-lint.mjs +27 -7
  15. package/engine/frontmatter-lint.mjs +11 -1
  16. package/engine/homepage.mjs +9 -1
  17. package/engine/index.mjs +18 -0
  18. package/engine/map-build.mjs +240 -0
  19. package/engine/map-dot.mjs +685 -0
  20. package/engine/map-graphviz.mjs +138 -0
  21. package/engine/map-layout.mjs +712 -0
  22. package/engine/map-places.mjs +561 -0
  23. package/engine/metadata-index.mjs +7 -0
  24. package/engine/note-vocabulary.mjs +28 -0
  25. package/engine/place-relations.mjs +637 -0
  26. package/engine/related-pages.mjs +161 -0
  27. package/engine/site-build.mjs +107 -9
  28. package/engine/site-config.mjs +1 -1
  29. package/engine/site-index.mjs +5 -1
  30. package/engine/site-root.mjs +147 -6
  31. package/engine/web-wikilinks.mjs +13 -2
  32. package/package.json +3 -2
  33. package/types/engine/changelog-lint.d.mts +9 -7
  34. package/types/engine/homepage.d.mts +8 -1
  35. package/types/engine/index.d.mts +6 -0
  36. package/types/engine/map-build.d.mts +78 -0
  37. package/types/engine/map-dot.d.mts +130 -0
  38. package/types/engine/map-graphviz.d.mts +61 -0
  39. package/types/engine/map-layout.d.mts +359 -0
  40. package/types/engine/map-places.d.mts +259 -0
  41. package/types/engine/note-vocabulary.d.mts +12 -0
  42. package/types/engine/place-relations.d.mts +95 -0
  43. package/types/engine/related-pages.d.mts +70 -0
  44. package/types/engine/site-build.d.mts +36 -3
  45. package/types/engine/site-config.d.mts +1 -1
  46. package/types/engine/site-index.d.mts +5 -1
  47. package/types/engine/site-root.d.mts +59 -7
  48. package/types/engine/web-wikilinks.d.mts +11 -2
@@ -0,0 +1,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