@heroiclands/package-build 22.4.4 → 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 +18 -0
- package/CONTENT.md +30 -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/frontmatter-lint.mjs +11 -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/site-config.mjs +1 -1
- package/engine/site-root.mjs +147 -6
- package/package.json +3 -2
- 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/site-config.d.mts +1 -1
- package/types/engine/site-root.d.mts +59 -7
|
@@ -0,0 +1,637 @@
|
|
|
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 a place is **next to** and what it is **reachable from**.
|
|
16
|
+
*
|
|
17
|
+
* A place note states what it is *within* through `data.parents`. Its other
|
|
18
|
+
* two structural relations are stated here: `data.borders`, the places it
|
|
19
|
+
* shares a frontier with, and `data.routes`, the journeys from its centre to
|
|
20
|
+
* another place. Both are lists of entries naming the other place by
|
|
21
|
+
* shortcode, and both are stated **from each end**: A at `NE` of B is the same
|
|
22
|
+
* fact as B at `SW` of A, so a note stating one side of it and a neighbour
|
|
23
|
+
* stating a different side is a contradiction the lint reports rather than a
|
|
24
|
+
* discovery made while writing a third note.
|
|
25
|
+
*
|
|
26
|
+
* **The value sets are closed and stated once.** The eight bearings, the three
|
|
27
|
+
* travel modes, the days markers and the terrain registry are the constants
|
|
28
|
+
* below, read by the lint, compared against the specification's tables by the
|
|
29
|
+
* test suite, and exported for a consumer drawing a map. A second copy of any
|
|
30
|
+
* of them is one more list free to disagree.
|
|
31
|
+
*
|
|
32
|
+
* **Days are markers, not measurements.** A route's `days` means "about this,
|
|
33
|
+
* under normal conditions" and takes one of twelve values: `10`, `20` and `30`
|
|
34
|
+
* are one, two and three ten-day weeks; `180` is many months; `360` is "who
|
|
35
|
+
* knows". A value off the scale is refused rather than rounded, because a
|
|
36
|
+
* reader of the scale has to be able to trust that every marker means the
|
|
37
|
+
* same thing wherever it appears.
|
|
38
|
+
*
|
|
39
|
+
* **Terrain and mode agree.** Each terrain names the modes that cross it: open
|
|
40
|
+
* sea is crossed by ship, a river or a lake by boat, a road by land, and a
|
|
41
|
+
* coast by any of the three. A water terrain on a land route — or the
|
|
42
|
+
* reverse — is refused.
|
|
43
|
+
*
|
|
44
|
+
* The checks take the link index the lint already holds, so a target resolves
|
|
45
|
+
* through the same resolver a wikilink does and a dependency's places take
|
|
46
|
+
* part: a fetched index entry carries the relation the record stated, and a
|
|
47
|
+
* border across a package boundary is checked from both ends like any other.
|
|
48
|
+
*
|
|
49
|
+
* @module
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
53
|
+
import { isAddressSegment } from "./address-charset.mjs";
|
|
54
|
+
|
|
55
|
+
/* --------------------------------------------------------------------- */
|
|
56
|
+
/* The closed sets */
|
|
57
|
+
/* --------------------------------------------------------------------- */
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The eight compass bearings, clockwise from north.
|
|
61
|
+
*
|
|
62
|
+
* Where a neighbour or a destination lies from the place stating it. The
|
|
63
|
+
* order is load-bearing: {@link oppositeBearing} reads four steps around it.
|
|
64
|
+
*
|
|
65
|
+
* @type {readonly string[]}
|
|
66
|
+
*/
|
|
67
|
+
export const BEARINGS = Object.freeze(["N", "NE", "E", "SE", "S", "SW", "W", "NW"]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The modes a route is travelled by: `land`, `boat` on a river or a lake,
|
|
71
|
+
* `ship` on open sea.
|
|
72
|
+
*
|
|
73
|
+
* @type {readonly string[]}
|
|
74
|
+
*/
|
|
75
|
+
export const ROUTE_MODES = Object.freeze(["land", "boat", "ship"]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The days markers a route may state.
|
|
79
|
+
*
|
|
80
|
+
* Each is "about this, under normal conditions": `10`, `20` and `30` are one,
|
|
81
|
+
* two and three ten-day weeks, `180` is many months, `360` is "who knows".
|
|
82
|
+
*
|
|
83
|
+
* @type {readonly number[]}
|
|
84
|
+
*/
|
|
85
|
+
export const TRAVEL_DAYS = Object.freeze([1, 2, 3, 5, 10, 20, 30, 45, 60, 90, 180, 360]);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The terrain registry, each terrain naming the modes that cross it.
|
|
89
|
+
*
|
|
90
|
+
* A land terrain is crossed by land alone; open sea by ship; a river or a lake
|
|
91
|
+
* by boat; a coast by all three, because a coast road and a coasting voyage
|
|
92
|
+
* are both journeys along it.
|
|
93
|
+
*
|
|
94
|
+
* @type {Readonly<Record<string, readonly string[]>>}
|
|
95
|
+
*/
|
|
96
|
+
export const TERRAIN_MODES = Object.freeze({
|
|
97
|
+
road: Object.freeze(["land"]),
|
|
98
|
+
track: Object.freeze(["land"]),
|
|
99
|
+
plain: Object.freeze(["land"]),
|
|
100
|
+
steppe: Object.freeze(["land"]),
|
|
101
|
+
hills: Object.freeze(["land"]),
|
|
102
|
+
mountains: Object.freeze(["land"]),
|
|
103
|
+
forest: Object.freeze(["land"]),
|
|
104
|
+
jungle: Object.freeze(["land"]),
|
|
105
|
+
marsh: Object.freeze(["land"]),
|
|
106
|
+
dunes: Object.freeze(["land"]),
|
|
107
|
+
desert: Object.freeze(["land"]),
|
|
108
|
+
ice: Object.freeze(["land"]),
|
|
109
|
+
coast: Object.freeze(["land", "boat", "ship"]),
|
|
110
|
+
"open-sea": Object.freeze(["ship"]),
|
|
111
|
+
river: Object.freeze(["boat"]),
|
|
112
|
+
lake: Object.freeze(["boat"]),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The terrain names, in registry order — the closed set a `terrain` list
|
|
117
|
+
* draws from.
|
|
118
|
+
*
|
|
119
|
+
* @type {readonly string[]}
|
|
120
|
+
*/
|
|
121
|
+
export const TERRAINS = Object.freeze(Object.keys(TERRAIN_MODES));
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The keys a `borders` entry may carry. Both are required.
|
|
125
|
+
*
|
|
126
|
+
* @type {readonly string[]}
|
|
127
|
+
*/
|
|
128
|
+
export const BORDER_KEYS = Object.freeze(["to", "bearing"]);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The keys a `routes` entry may carry. `to`, `bearing`, `mode` and `days` are
|
|
132
|
+
* required; `terrain` and `leagues` are optional.
|
|
133
|
+
*
|
|
134
|
+
* @type {readonly string[]}
|
|
135
|
+
*/
|
|
136
|
+
export const ROUTE_KEYS = Object.freeze(["to", "bearing", "mode", "days", "terrain", "leagues"]);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The bearing the other end of a relation states, or `undefined` for a value
|
|
140
|
+
* that is not a bearing.
|
|
141
|
+
*
|
|
142
|
+
* @param {unknown} bearing - A bearing.
|
|
143
|
+
* @returns {string|undefined} The one four steps around the compass.
|
|
144
|
+
*/
|
|
145
|
+
export function oppositeBearing(bearing) {
|
|
146
|
+
const i = BEARINGS.indexOf(/** @type {string} */ (bearing));
|
|
147
|
+
return i === -1 ? undefined : BEARINGS[(i + 4) % BEARINGS.length];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* --------------------------------------------------------------------- */
|
|
151
|
+
/* Reading the other end */
|
|
152
|
+
/* --------------------------------------------------------------------- */
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A resolved target, read the same way whether it is a local note or a
|
|
156
|
+
* fetched index entry.
|
|
157
|
+
*
|
|
158
|
+
* A local note arrives with its parsed frontmatter, so the relation is
|
|
159
|
+
* `fm.data.*`; a fetched entry carries `borders`, `routes` and `parents`
|
|
160
|
+
* directly, copied from the record when the index was loaded.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} hit - What the index resolved.
|
|
163
|
+
* @returns {{type: string, borders: unknown, routes: unknown, parents: unknown, name: string}}
|
|
164
|
+
* The relation it states.
|
|
165
|
+
*/
|
|
166
|
+
function relationsOf(hit) {
|
|
167
|
+
if (hit?.fm) {
|
|
168
|
+
const data = hit.fm.data && typeof hit.fm.data === "object" ? hit.fm.data : {};
|
|
169
|
+
return {
|
|
170
|
+
type: String(hit.type ?? hit.fm.type ?? ""),
|
|
171
|
+
borders: data.borders,
|
|
172
|
+
routes: data.routes,
|
|
173
|
+
parents: data.parents,
|
|
174
|
+
name: String(hit.fm.shortcode ?? ""),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
type: String(hit?.type ?? ""),
|
|
179
|
+
borders: hit?.borders,
|
|
180
|
+
routes: hit?.routes,
|
|
181
|
+
parents: hit?.parents,
|
|
182
|
+
name: "",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The shortcode a `parents` entry names.
|
|
188
|
+
*
|
|
189
|
+
* A parent is written as a bare shortcode, and may be written as an address;
|
|
190
|
+
* a shortcode is the last segment either way, because a segment carries no
|
|
191
|
+
* separator.
|
|
192
|
+
*
|
|
193
|
+
* @param {unknown} parent - One `parents` entry.
|
|
194
|
+
* @returns {string} Its shortcode.
|
|
195
|
+
*/
|
|
196
|
+
function parentShortcode(parent) {
|
|
197
|
+
return String(parent ?? "")
|
|
198
|
+
.split("-")
|
|
199
|
+
.pop()
|
|
200
|
+
.toLowerCase();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Whether one place's `parents` names another.
|
|
205
|
+
*
|
|
206
|
+
* @param {unknown} parents - The `parents` list.
|
|
207
|
+
* @param {string} shortcode - The place asked about.
|
|
208
|
+
* @returns {boolean} Whether it is named.
|
|
209
|
+
*/
|
|
210
|
+
function hasParent(parents, shortcode) {
|
|
211
|
+
return Array.isArray(parents) && parents.some((p) => parentShortcode(p) === shortcode);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The entries of a relation list that name one place.
|
|
216
|
+
*
|
|
217
|
+
* @param {unknown} list - A `borders` or `routes` value.
|
|
218
|
+
* @param {string} shortcode - The place they name.
|
|
219
|
+
* @returns {Record<string, unknown>[]} The entries.
|
|
220
|
+
*/
|
|
221
|
+
function entriesTo(list, shortcode) {
|
|
222
|
+
if (!Array.isArray(list)) return [];
|
|
223
|
+
return list.filter(
|
|
224
|
+
(e) =>
|
|
225
|
+
e &&
|
|
226
|
+
typeof e === "object" &&
|
|
227
|
+
!Array.isArray(e) &&
|
|
228
|
+
String(e.to ?? "").toLowerCase() === shortcode,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* --------------------------------------------------------------------- */
|
|
233
|
+
/* The checks */
|
|
234
|
+
/* --------------------------------------------------------------------- */
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Format a closed set for a message: `N, NE, … or NW`.
|
|
238
|
+
*
|
|
239
|
+
* @param {readonly unknown[]} values - The set.
|
|
240
|
+
* @returns {string} The values, joined.
|
|
241
|
+
*/
|
|
242
|
+
function oneOf(values) {
|
|
243
|
+
const all = values.map(String);
|
|
244
|
+
return all.length < 2 ?
|
|
245
|
+
all.join("")
|
|
246
|
+
: `${all.slice(0, -1).join(", ")} or ${all[all.length - 1]}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A days value as a number, or `undefined` where it is not one. A quoted
|
|
251
|
+
* scalar is a number here for the reason it is everywhere else in the lint:
|
|
252
|
+
* that is how a number arrives from many editors.
|
|
253
|
+
*
|
|
254
|
+
* @param {unknown} value - The authored value.
|
|
255
|
+
* @returns {number|undefined} The number.
|
|
256
|
+
*/
|
|
257
|
+
function daysOf(value) {
|
|
258
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
259
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
|
|
260
|
+
return Number(value);
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Check one relation list — `borders` or `routes` — on one place note.
|
|
267
|
+
*
|
|
268
|
+
* Shared by the two exported checks, which differ only in which keys an entry
|
|
269
|
+
* carries, whether a route's `mode` and `days` take part, and what the
|
|
270
|
+
* reciprocal check compares. Every finding is located at the entry that states
|
|
271
|
+
* the fault, or at the value inside it where the fault is one value.
|
|
272
|
+
*
|
|
273
|
+
* @param {object} note - The note, as the link index hands it over.
|
|
274
|
+
* @param {object} opts
|
|
275
|
+
* @param {"borders"|"routes"} opts.field - Which list.
|
|
276
|
+
* @param {object} [opts.index] - The link index, for resolving `to`. Absent,
|
|
277
|
+
* every check that needs the other end is skipped and only the values are
|
|
278
|
+
* checked.
|
|
279
|
+
* @returns {object[]} Findings.
|
|
280
|
+
*/
|
|
281
|
+
function checkRelation(note, { field, index }) {
|
|
282
|
+
const findings = [];
|
|
283
|
+
const fm = note.fm ?? {};
|
|
284
|
+
const data = fm.data && typeof fm.data === "object" && !Array.isArray(fm.data) ? fm.data : {};
|
|
285
|
+
const list = data[field];
|
|
286
|
+
// Absent, or the wrong shape: the container check reports the shape, so
|
|
287
|
+
// nothing here has anything to add.
|
|
288
|
+
if (!Array.isArray(list)) return findings;
|
|
289
|
+
|
|
290
|
+
const raw = note.raw ?? "";
|
|
291
|
+
const self = String(fm.shortcode ?? "").toLowerCase();
|
|
292
|
+
const isRoute = field === "routes";
|
|
293
|
+
const keys = isRoute ? ROUTE_KEYS : BORDER_KEYS;
|
|
294
|
+
const at = (i, key) =>
|
|
295
|
+
positionOfFrontmatterPath(raw, ["data", field, i, ...(key ? [key] : [])]);
|
|
296
|
+
const label = (i, key) => `\`data.${field}[${i}]${key ? `.${key}` : ""}\``;
|
|
297
|
+
const finding = (i, key, severity, message) => ({
|
|
298
|
+
file: note.file,
|
|
299
|
+
...at(i, key),
|
|
300
|
+
severity,
|
|
301
|
+
message,
|
|
302
|
+
});
|
|
303
|
+
const error = (i, key, message) => findings.push(finding(i, key, "error", message));
|
|
304
|
+
|
|
305
|
+
/** `to` → the modes it has been listed with, for the once-per-pair rule. */
|
|
306
|
+
const seen = new Map();
|
|
307
|
+
|
|
308
|
+
list.forEach((entry, i) => {
|
|
309
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
310
|
+
error(
|
|
311
|
+
i,
|
|
312
|
+
undefined,
|
|
313
|
+
`${label(i)} must be a map — \`{ to: <shortcode>, bearing: <bearing>` +
|
|
314
|
+
`${isRoute ? ", mode: <mode>, days: <days>" : ""} }\` — but reads ` +
|
|
315
|
+
JSON.stringify(entry),
|
|
316
|
+
);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for (const key of Object.keys(entry)) {
|
|
321
|
+
if (keys.includes(key)) continue;
|
|
322
|
+
error(
|
|
323
|
+
i,
|
|
324
|
+
key,
|
|
325
|
+
`"${key}" is not a key of a ${field} entry; an entry carries ` +
|
|
326
|
+
`${oneOf(keys.map((k) => `\`${k}\``))} and nothing else`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// `to`: a shortcode, and one a place declares.
|
|
331
|
+
const to = entry.to;
|
|
332
|
+
let target;
|
|
333
|
+
let toIsValid = false;
|
|
334
|
+
if (to === undefined || to === null || to === "") {
|
|
335
|
+
error(i, undefined, `${label(i)} must name the other place in \`to\``);
|
|
336
|
+
} else if (typeof to !== "string" || !isAddressSegment(to)) {
|
|
337
|
+
error(
|
|
338
|
+
i,
|
|
339
|
+
"to",
|
|
340
|
+
`${label(i, "to")} must be a shortcode — the other place's ` +
|
|
341
|
+
`\`shortcode\`, not an address — but reads ${JSON.stringify(to)}`,
|
|
342
|
+
);
|
|
343
|
+
} else if (index) {
|
|
344
|
+
const hit = index.referenceHit(`place-${to}`);
|
|
345
|
+
if (hit) {
|
|
346
|
+
target = relationsOf(hit);
|
|
347
|
+
toIsValid = true;
|
|
348
|
+
} else {
|
|
349
|
+
// The same shortcode on a note of another type is the likely
|
|
350
|
+
// slip, and naming it says what was found rather than only
|
|
351
|
+
// what was not.
|
|
352
|
+
const other = (index.notes ?? []).find(
|
|
353
|
+
(n) => String(n.fm?.shortcode ?? "").toLowerCase() === to.toLowerCase(),
|
|
354
|
+
);
|
|
355
|
+
error(
|
|
356
|
+
i,
|
|
357
|
+
"to",
|
|
358
|
+
`${label(i, "to")} names "${to}", and no place in this package or a ` +
|
|
359
|
+
`fetched index declares that shortcode` +
|
|
360
|
+
(other ? `; the ${other.type} note "${to}" is not a place` : ""),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
toIsValid = true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// `bearing`.
|
|
368
|
+
const bearing = entry.bearing;
|
|
369
|
+
let bearingIsValid = false;
|
|
370
|
+
if (bearing === undefined || bearing === null || bearing === "") {
|
|
371
|
+
error(i, undefined, `${label(i)} must state a \`bearing\` — ${oneOf(BEARINGS)}`);
|
|
372
|
+
} else if (!BEARINGS.includes(String(bearing))) {
|
|
373
|
+
error(
|
|
374
|
+
i,
|
|
375
|
+
"bearing",
|
|
376
|
+
`${label(i, "bearing")} must be one of ${oneOf(BEARINGS)}, but reads ` +
|
|
377
|
+
JSON.stringify(bearing),
|
|
378
|
+
);
|
|
379
|
+
} else {
|
|
380
|
+
bearingIsValid = true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// `mode` and `days`, on a route.
|
|
384
|
+
let mode;
|
|
385
|
+
let days;
|
|
386
|
+
if (isRoute) {
|
|
387
|
+
if (entry.mode === undefined || entry.mode === null || entry.mode === "") {
|
|
388
|
+
error(i, undefined, `${label(i)} must state a \`mode\` — ${oneOf(ROUTE_MODES)}`);
|
|
389
|
+
} else if (!ROUTE_MODES.includes(String(entry.mode))) {
|
|
390
|
+
error(
|
|
391
|
+
i,
|
|
392
|
+
"mode",
|
|
393
|
+
`${label(i, "mode")} must be one of ${oneOf(ROUTE_MODES)}, but reads ` +
|
|
394
|
+
JSON.stringify(entry.mode),
|
|
395
|
+
);
|
|
396
|
+
} else {
|
|
397
|
+
mode = String(entry.mode);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (entry.days === undefined || entry.days === null || entry.days === "") {
|
|
401
|
+
error(i, undefined, `${label(i)} must state \`days\` — ${oneOf(TRAVEL_DAYS)}`);
|
|
402
|
+
} else {
|
|
403
|
+
const n = daysOf(entry.days);
|
|
404
|
+
if (n === undefined || !TRAVEL_DAYS.includes(n)) {
|
|
405
|
+
error(
|
|
406
|
+
i,
|
|
407
|
+
"days",
|
|
408
|
+
`${label(i, "days")} must be one of ${oneOf(TRAVEL_DAYS)} — a marker ` +
|
|
409
|
+
`meaning "about this, under normal conditions" — but reads ` +
|
|
410
|
+
JSON.stringify(entry.days),
|
|
411
|
+
);
|
|
412
|
+
} else {
|
|
413
|
+
days = n;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (entry.terrain !== undefined && entry.terrain !== null) {
|
|
418
|
+
if (!Array.isArray(entry.terrain)) {
|
|
419
|
+
error(
|
|
420
|
+
i,
|
|
421
|
+
"terrain",
|
|
422
|
+
`${label(i, "terrain")} must be a list of terrains in travel order, ` +
|
|
423
|
+
`but reads ${JSON.stringify(entry.terrain)}`,
|
|
424
|
+
);
|
|
425
|
+
} else {
|
|
426
|
+
entry.terrain.forEach((terrain, j) => {
|
|
427
|
+
const name = String(terrain);
|
|
428
|
+
const modes = TERRAIN_MODES[name];
|
|
429
|
+
if (!modes) {
|
|
430
|
+
findings.push({
|
|
431
|
+
file: note.file,
|
|
432
|
+
...positionOfFrontmatterPath(raw, ["data", field, i, "terrain", j]),
|
|
433
|
+
severity: "error",
|
|
434
|
+
message:
|
|
435
|
+
`${label(i, "terrain")} names "${name}", which is not a ` +
|
|
436
|
+
`terrain; the registry is ${oneOf(TERRAINS)}`,
|
|
437
|
+
});
|
|
438
|
+
} else if (mode && !modes.includes(mode)) {
|
|
439
|
+
findings.push({
|
|
440
|
+
file: note.file,
|
|
441
|
+
...positionOfFrontmatterPath(raw, ["data", field, i, "terrain", j]),
|
|
442
|
+
severity: "error",
|
|
443
|
+
message:
|
|
444
|
+
`${label(i, "terrain")} names "${name}", which is crossed ` +
|
|
445
|
+
`by ${oneOf(modes)}, but the route is by ${mode}`,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (entry.leagues !== undefined && entry.leagues !== null) {
|
|
453
|
+
const n = daysOf(entry.leagues);
|
|
454
|
+
if (n === undefined || n <= 0) {
|
|
455
|
+
error(
|
|
456
|
+
i,
|
|
457
|
+
"leagues",
|
|
458
|
+
`${label(i, "leagues")} must be a distance in leagues, but reads ` +
|
|
459
|
+
JSON.stringify(entry.leagues),
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (!toIsValid) return;
|
|
466
|
+
const toKey = String(to).toLowerCase();
|
|
467
|
+
|
|
468
|
+
// Once per pair — per mode, on a route.
|
|
469
|
+
const modes = seen.get(toKey) ?? new Set();
|
|
470
|
+
const modeKey = isRoute ? (mode ?? "") : "";
|
|
471
|
+
if (modes.has(modeKey)) {
|
|
472
|
+
error(
|
|
473
|
+
i,
|
|
474
|
+
undefined,
|
|
475
|
+
isRoute ?
|
|
476
|
+
`${label(i)} lists "${to}" by ${mode} twice; a pair appears in \`routes\` ` +
|
|
477
|
+
`once per mode`
|
|
478
|
+
: `${label(i)} lists "${to}" twice; a pair appears in \`borders\` once`,
|
|
479
|
+
);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
modes.add(modeKey);
|
|
483
|
+
seen.set(toKey, modes);
|
|
484
|
+
|
|
485
|
+
if (!target) return;
|
|
486
|
+
|
|
487
|
+
if (target.type !== "place") {
|
|
488
|
+
error(
|
|
489
|
+
i,
|
|
490
|
+
"to",
|
|
491
|
+
`${label(i, "to")} names "${to}", which is a ${target.type} note; a ` +
|
|
492
|
+
`${field === "borders" ? "border" : "route"} is between places`,
|
|
493
|
+
);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Containment is `parents`; a border to a parent or a child restates
|
|
498
|
+
// it as adjacency, and the two cannot both be true.
|
|
499
|
+
if (!isRoute) {
|
|
500
|
+
if (hasParent(data.parents, toKey)) {
|
|
501
|
+
error(
|
|
502
|
+
i,
|
|
503
|
+
undefined,
|
|
504
|
+
`${label(i)} borders "${to}", which is this place's parent; ` +
|
|
505
|
+
`containment is \`parents\`, and a place never borders its parent`,
|
|
506
|
+
);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (hasParent(target.parents, self)) {
|
|
510
|
+
error(
|
|
511
|
+
i,
|
|
512
|
+
undefined,
|
|
513
|
+
`${label(i)} borders "${to}", which is this place's child; ` +
|
|
514
|
+
`containment is \`parents\`, and a place never borders its child`,
|
|
515
|
+
);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (!bearingIsValid) return;
|
|
521
|
+
const back = entriesTo(target[field], self);
|
|
522
|
+
const expected = oppositeBearing(bearing);
|
|
523
|
+
|
|
524
|
+
if (!isRoute) {
|
|
525
|
+
if (back.length === 0) {
|
|
526
|
+
findings.push(
|
|
527
|
+
finding(
|
|
528
|
+
i,
|
|
529
|
+
undefined,
|
|
530
|
+
"warning",
|
|
531
|
+
`"${self}" borders "${to}" at ${bearing}, but "${to}" states no ` +
|
|
532
|
+
`border to "${self}"; it should carry ` +
|
|
533
|
+
`\`{ to: ${self}, bearing: ${expected} }\``,
|
|
534
|
+
),
|
|
535
|
+
);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const theirs = String(back[0].bearing ?? "");
|
|
539
|
+
if (BEARINGS.includes(theirs) && theirs !== expected) {
|
|
540
|
+
error(
|
|
541
|
+
i,
|
|
542
|
+
"bearing",
|
|
543
|
+
`"${self}" borders "${to}" at ${bearing}, so "${to}" should state the ` +
|
|
544
|
+
`border back at bearing ${expected}, but states ${theirs}`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (mode === undefined || days === undefined) return;
|
|
551
|
+
if (back.length === 0) {
|
|
552
|
+
findings.push(
|
|
553
|
+
finding(
|
|
554
|
+
i,
|
|
555
|
+
undefined,
|
|
556
|
+
"warning",
|
|
557
|
+
`"${self}" reaches "${to}" by ${mode} in ${days} days, but "${to}" ` +
|
|
558
|
+
`states no route to "${self}"; it should carry ` +
|
|
559
|
+
`\`{ to: ${self}, bearing: ${expected}, mode: ${mode}, days: ${days} }\``,
|
|
560
|
+
),
|
|
561
|
+
);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const sameMode = back.find((e) => String(e.mode ?? "") === mode);
|
|
565
|
+
if (!sameMode) {
|
|
566
|
+
// Only the modes the other end states validly: an invalid one is
|
|
567
|
+
// that note's own finding, and reading it as a mismatch here would
|
|
568
|
+
// report one slip twice.
|
|
569
|
+
const theirs = [...new Set(back.map((e) => String(e.mode ?? "")))].filter((m) =>
|
|
570
|
+
ROUTE_MODES.includes(m),
|
|
571
|
+
);
|
|
572
|
+
if (theirs.length === 0) return;
|
|
573
|
+
error(
|
|
574
|
+
i,
|
|
575
|
+
"mode",
|
|
576
|
+
`"${self}" reaches "${to}" by ${mode}, but "${to}" states the route back ` +
|
|
577
|
+
`by ${oneOf(theirs)} only; a pair is stated by the same mode at ` +
|
|
578
|
+
`both ends`,
|
|
579
|
+
);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const theirBearing = String(sameMode.bearing ?? "");
|
|
583
|
+
if (BEARINGS.includes(theirBearing) && theirBearing !== expected) {
|
|
584
|
+
error(
|
|
585
|
+
i,
|
|
586
|
+
"bearing",
|
|
587
|
+
`"${self}" reaches "${to}" at ${bearing}, so "${to}" should state the route ` +
|
|
588
|
+
`back at bearing ${expected}, but states ${theirBearing}`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
const theirDays = daysOf(sameMode.days);
|
|
592
|
+
if (theirDays !== undefined && theirDays !== days) {
|
|
593
|
+
error(
|
|
594
|
+
i,
|
|
595
|
+
"days",
|
|
596
|
+
`"${self}" reaches "${to}" by ${mode} in ${days} days, but "${to}" states ` +
|
|
597
|
+
`the route back as ${theirDays}; both ends state the same days`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
return findings;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Check a place note's `data.borders`.
|
|
607
|
+
*
|
|
608
|
+
* Every `to` is a shortcode a place declares, every `bearing` is one of the
|
|
609
|
+
* eight, no pair is listed twice, no target is the note's own parent or child,
|
|
610
|
+
* and the other end states the pair back at the opposite bearing — a missing
|
|
611
|
+
* reciprocal is a warning naming both notes, a wrong one an error.
|
|
612
|
+
*
|
|
613
|
+
* @param {object} note - The note, as the link index hands it over.
|
|
614
|
+
* @param {object} [opts]
|
|
615
|
+
* @param {object} [opts.index] - The link index, for resolving `to`.
|
|
616
|
+
* @returns {object[]} Findings.
|
|
617
|
+
*/
|
|
618
|
+
export function checkBorders(note, { index } = {}) {
|
|
619
|
+
return checkRelation(note, { field: "borders", index });
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Check a place note's `data.routes`.
|
|
624
|
+
*
|
|
625
|
+
* As {@link checkBorders}, plus: `mode` and `days` are required and from
|
|
626
|
+
* their sets, `terrain` is from the registry and crossable by the mode, a pair
|
|
627
|
+
* appears once per mode, and the other end states the pair back by the same
|
|
628
|
+
* mode, at the opposite bearing, with the same days.
|
|
629
|
+
*
|
|
630
|
+
* @param {object} note - The note, as the link index hands it over.
|
|
631
|
+
* @param {object} [opts]
|
|
632
|
+
* @param {object} [opts.index] - The link index, for resolving `to`.
|
|
633
|
+
* @returns {object[]} Findings.
|
|
634
|
+
*/
|
|
635
|
+
export function checkRoutes(note, { index } = {}) {
|
|
636
|
+
return checkRelation(note, { field: "routes", index });
|
|
637
|
+
}
|
package/engine/site-config.mjs
CHANGED
|
@@ -57,7 +57,7 @@ export const HUGO_CONTENT = `${HUGO_SOURCE}/content`;
|
|
|
57
57
|
* The directory that is deployed, relative to the repository root.
|
|
58
58
|
*
|
|
59
59
|
* Hugo renders into `<DEPLOY_ROOT>/<contentPackage>/`; `package-build
|
|
60
|
-
* site-root` writes `_headers` and
|
|
60
|
+
* site-root` writes `_headers` beside it and the search index inside it.
|
|
61
61
|
*/
|
|
62
62
|
export const DEPLOY_ROOT = "build/site";
|
|
63
63
|
|