@heroiclands/package-build 22.4.4 → 22.4.6

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.
@@ -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
+ }
@@ -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 `_redirects` beside it.
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