@heroiclands/package-build 22.4.6 → 22.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,391 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * What lies within a place, who holds it, and what an affiliation holds.
16
+ *
17
+ * Two keys the notes already carry answer all three. A place's `data.parents`
18
+ * is geography — the places it sits within — and an affiliation's
19
+ * `data.domains` is tenure — the places it holds. Read across the whole
20
+ * corpus and inverted, they give each page the lists a reader wants:
21
+ * `contains` and `held_by` on a place, `holdings` on an affiliation, each
22
+ * shaped like an entry of `related` and absent where empty.
23
+ *
24
+ * **`domains` names what an affiliation holds directly**, and is never
25
+ * expanded. A polity whose `domains` names a region holds the region; the
26
+ * settlements within it are reachable from the region's `contains`, and
27
+ * repeating them in `holdings` would make every list say the same thing at
28
+ * every level. Tenure below a lord runs through `affiliation.parents` — a
29
+ * house of its earl, an earl of the crown — and geography through
30
+ * `place.parents`, so a subinfeudated manor sits in one region by the first
31
+ * and under a lord of another polity by the second, and both pages say so.
32
+ *
33
+ * **A dependency's places and affiliations take part.** A fetched index entry
34
+ * carries the `parents` and `domains` its record stated, so a place another
35
+ * package publishes is listed within a local region, and a house another
36
+ * package publishes is named on the local manor it holds.
37
+ *
38
+ * **A place's tenure is checked.** A settlement, a site or a structure that no
39
+ * affiliation's `domains` names is land nobody holds — a gap in tenure the
40
+ * lint reports as a warning at the note's `type:` line. A region is held
41
+ * through its polity's `domains` and a feature by nobody, so both are exempt.
42
+ *
43
+ * @module
44
+ */
45
+
46
+ import { positionInFrontmatter } from "./diagnostics.mjs";
47
+
48
+ /**
49
+ * The keys this module writes, and which a note cannot author: `contains`
50
+ * and `held_by` on a place, `holdings` on an affiliation.
51
+ *
52
+ * @type {readonly string[]}
53
+ */
54
+ export const HOLDINGS_KEYS = Object.freeze(["contains", "held_by", "holdings"]);
55
+
56
+ /**
57
+ * The place subTypes tenure is checked on. A region is held through its
58
+ * polity's `domains`, a world by nobody, a feature by nobody — a river has no
59
+ * lord — so the rest are the kinds of place a body holds directly.
60
+ *
61
+ * @type {readonly string[]}
62
+ */
63
+ export const HELD_SUBTYPES = Object.freeze(["settlement", "site", "structure"]);
64
+
65
+ /**
66
+ * One entry of a holdings list — a page a reader is pointed at.
67
+ *
68
+ * @typedef {object} HoldingsEntry
69
+ * @property {string} title The page's published title.
70
+ * @property {string} url `<base><slug>/` — the page's address as every
71
+ * href this build renders composes it.
72
+ * @property {string} type The note's `type` — `place` or `affiliation`.
73
+ * @property {string} [subType] The note's `subType`, where it declares one.
74
+ */
75
+
76
+ /**
77
+ * One place or affiliation, as the derivation reads it — a local page or a
78
+ * fetched index entry through one shape.
79
+ *
80
+ * @typedef {object} HoldingsNode
81
+ * @property {string} shortcode Lower case.
82
+ * @property {"place"|"affiliation"} type
83
+ * @property {string} [subType]
84
+ * @property {string} title The page's published title.
85
+ * @property {string} [url] The page's URL; absent where the package
86
+ * publishes no page for it.
87
+ * @property {string[]} parents What a place sits within, as written.
88
+ * @property {string[]} domains What an affiliation holds, as written.
89
+ */
90
+
91
+ /**
92
+ * The lists one page carries. Every key is optional, and a key present holds
93
+ * at least one entry.
94
+ *
95
+ * @typedef {object} Holdings
96
+ * @property {HoldingsEntry[]} [contains] Places whose `parents` name this one.
97
+ * @property {HoldingsEntry[]} [held_by] Affiliations whose `domains` name this one.
98
+ * @property {HoldingsEntry[]} [holdings] Places this affiliation's `domains` name.
99
+ */
100
+
101
+ /**
102
+ * The shortcode a `parents` or `domains` entry names, lower case.
103
+ *
104
+ * An entry is written as a bare shortcode, as an address, or as a wikilink
105
+ * carrying either; the shortcode is the last segment of the address in every
106
+ * case, because a segment carries no separator.
107
+ *
108
+ * @param {unknown} value - One entry.
109
+ * @returns {string} Its shortcode, or `""` for an entry that names nothing.
110
+ */
111
+ function namedShortcode(value) {
112
+ const text = String(value ?? "")
113
+ .trim()
114
+ .replace(/^\[\[/, "")
115
+ .replace(/\]\]$/, "")
116
+ .split("|")[0]
117
+ .split("#")[0]
118
+ .trim();
119
+ return text.split("-").pop()?.toLowerCase() ?? "";
120
+ }
121
+
122
+ /**
123
+ * A `parents` or `domains` value as the shortcodes it names, in order,
124
+ * empties dropped and repeats collapsed.
125
+ *
126
+ * @param {unknown} value - The authored value — a list, or a lone entry.
127
+ * @returns {string[]} The shortcodes.
128
+ */
129
+ function shortcodesOf(value) {
130
+ const list =
131
+ Array.isArray(value) ? value
132
+ : value ? [value]
133
+ : [];
134
+ return [...new Set(list.map(namedShortcode).filter(Boolean))];
135
+ }
136
+
137
+ /**
138
+ * A stable order for `contains` and `holdings`: by subType, then title. An
139
+ * entry with no subType sorts first.
140
+ *
141
+ * @param {HoldingsEntry} a
142
+ * @param {HoldingsEntry} b
143
+ * @returns {number}
144
+ */
145
+ function bySubTypeThenTitle(a, b) {
146
+ return (
147
+ (a.subType ?? "").localeCompare(b.subType ?? "", "en") ||
148
+ a.title.localeCompare(b.title, "en")
149
+ );
150
+ }
151
+
152
+ /**
153
+ * A stable order for `held_by`: by title.
154
+ *
155
+ * @param {HoldingsEntry} a
156
+ * @param {HoldingsEntry} b
157
+ * @returns {number}
158
+ */
159
+ function byTitle(a, b) {
160
+ return a.title.localeCompare(b.title, "en");
161
+ }
162
+
163
+ /**
164
+ * The entry a node is listed as.
165
+ *
166
+ * @param {HoldingsNode & {url: string}} node - A node with a page.
167
+ * @returns {HoldingsEntry} Its entry.
168
+ */
169
+ function entryOf(node) {
170
+ return {
171
+ title: node.title,
172
+ url: node.url,
173
+ type: node.type,
174
+ ...(node.subType === undefined ? {} : { subType: node.subType }),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Read a local note into a node, or `null` for a note that takes no part.
180
+ *
181
+ * @param {object} fm - The note's frontmatter.
182
+ * @param {object} page - The page it publishes as.
183
+ * @param {string} page.title - Its published title.
184
+ * @param {string} page.url - Its URL.
185
+ * @returns {HoldingsNode|null} The node, or `null` for a note that is neither
186
+ * a place nor an affiliation, or that declares no shortcode.
187
+ */
188
+ export function holdingsNode(fm, { title, url }) {
189
+ const type = String(fm?.type ?? "");
190
+ if (type !== "place" && type !== "affiliation") return null;
191
+ const shortcode = String(fm?.shortcode ?? "").toLowerCase();
192
+ if (!shortcode) return null;
193
+ const data = fm.data && typeof fm.data === "object" && !Array.isArray(fm.data) ? fm.data : {};
194
+ return {
195
+ shortcode,
196
+ type,
197
+ subType: fm.subType == null ? undefined : String(fm.subType),
198
+ title,
199
+ url,
200
+ parents: type === "place" ? shortcodesOf(data.parents) : [],
201
+ domains: type === "affiliation" ? shortcodesOf(data.domains) : [],
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Read a fetched index's places and affiliations as nodes.
207
+ *
208
+ * An entry carries what the producer's record stated — its name, `subType`,
209
+ * `parents` and `domains` — and its page URL where the package publishes one.
210
+ *
211
+ * @param {Map<string, object>|undefined} foreignIndex - As
212
+ * {@link module:engine/metadata-index.loadForeignIndexes} returns it.
213
+ * @returns {HoldingsNode[]} The nodes, in index order.
214
+ */
215
+ export function foreignHoldingsNodes(foreignIndex) {
216
+ const out = [];
217
+ if (!foreignIndex) return out;
218
+ for (const [canonical, entry] of foreignIndex) {
219
+ const type = String(entry?.type ?? "");
220
+ if (type !== "place" && type !== "affiliation") continue;
221
+ const shortcode = canonical.split("-").pop()?.toLowerCase() ?? "";
222
+ if (!shortcode) continue;
223
+ out.push({
224
+ shortcode,
225
+ type,
226
+ subType: entry.subType == null ? undefined : String(entry.subType),
227
+ title: String(entry.name ?? shortcode),
228
+ url: entry.url,
229
+ parents: type === "place" ? shortcodesOf(entry.parents) : [],
230
+ domains: type === "affiliation" ? shortcodesOf(entry.domains) : [],
231
+ });
232
+ }
233
+ return out;
234
+ }
235
+
236
+ /**
237
+ * Invert `parents` and `domains` into each page's lists.
238
+ *
239
+ * A shortcode is declared once per type: the first node declaring it wins,
240
+ * so local nodes handed in ahead of fetched ones shadow a dependency's, the
241
+ * way the link resolver answers. A node with no URL is nobody's entry and
242
+ * carries no lists of its own, since there is no page to write them on. A
243
+ * name no node declares is dropped: a `parents` naming nowhere is the map's
244
+ * finding, a `domains` naming nowhere the reference check's.
245
+ *
246
+ * The result holds only pages with at least one entry on at least one list,
247
+ * and a page's block carries only the keys it has entries for — the theme's
248
+ * silent-disappear convention, kept at the source.
249
+ *
250
+ * @param {Iterable<HoldingsNode|null>} nodes - Every place and affiliation,
251
+ * local first. `parents` and `domains` are read as written, so a node
252
+ * built by hand may carry them as the note spells them.
253
+ * @returns {Map<string, Holdings>} URL → the page's lists, sorted.
254
+ */
255
+ export function holdingsPages(nodes) {
256
+ /** @type {Map<string, HoldingsNode>} */
257
+ const places = new Map();
258
+ /** @type {Map<string, HoldingsNode>} */
259
+ const affiliations = new Map();
260
+ for (const node of nodes) {
261
+ if (!node) continue;
262
+ const by = node.type === "place" ? places : affiliations;
263
+ const shortcode = String(node.shortcode ?? "").toLowerCase();
264
+ if (!shortcode || by.has(shortcode)) continue;
265
+ // Read through the one reader, so a caller handing in what a note
266
+ // wrote — an address, a wikilink, a repeat — is read as `holdingsNode`
267
+ // reads it.
268
+ by.set(shortcode, {
269
+ ...node,
270
+ shortcode,
271
+ parents: shortcodesOf(node.parents),
272
+ domains: shortcodesOf(node.domains),
273
+ });
274
+ }
275
+
276
+ /** @type {Map<string, {contains: HoldingsEntry[], held_by: HoldingsEntry[], holdings: HoldingsEntry[]}>} */
277
+ const lists = new Map();
278
+ const on = (url) => {
279
+ let found = lists.get(url);
280
+ if (!found) {
281
+ found = { contains: [], held_by: [], holdings: [] };
282
+ lists.set(url, found);
283
+ }
284
+ return found;
285
+ };
286
+ const listed = (node) => node.url !== undefined && node.url !== null && node.url !== "";
287
+
288
+ for (const child of places.values()) {
289
+ if (!listed(child)) continue;
290
+ for (const shortcode of child.parents) {
291
+ const parent = places.get(shortcode);
292
+ if (!parent || !listed(parent) || parent === child) continue;
293
+ on(parent.url).contains.push(entryOf(/** @type {any} */ (child)));
294
+ }
295
+ }
296
+
297
+ for (const holder of affiliations.values()) {
298
+ if (!listed(holder)) continue;
299
+ for (const shortcode of holder.domains) {
300
+ const held = places.get(shortcode);
301
+ if (!held || !listed(held)) continue;
302
+ on(holder.url).holdings.push(entryOf(/** @type {any} */ (held)));
303
+ on(held.url).held_by.push(entryOf(/** @type {any} */ (holder)));
304
+ }
305
+ }
306
+
307
+ const out = new Map();
308
+ for (const [url, { contains, held_by, holdings }] of lists) {
309
+ /** @type {Holdings} */
310
+ const block = {};
311
+ if (contains.length) block.contains = contains.sort(bySubTypeThenTitle);
312
+ if (held_by.length) block.held_by = held_by.sort(byTitle);
313
+ if (holdings.length) block.holdings = holdings.sort(bySubTypeThenTitle);
314
+ if (Object.keys(block).length) out.set(url, block);
315
+ }
316
+ return out;
317
+ }
318
+
319
+ /* --------------------------------------------------------------------- */
320
+ /* The lint */
321
+ /* --------------------------------------------------------------------- */
322
+
323
+ /**
324
+ * Every shortcode some affiliation's `domains` names, computed once per link
325
+ * index. The lint asks the question once per place note, and the answer is a
326
+ * fact about the whole corpus — local notes and every fetched entry — so it
327
+ * is read off the index the first time and kept beside it.
328
+ *
329
+ * @type {WeakMap<object, Set<string>>}
330
+ */
331
+ const HELD = new WeakMap();
332
+
333
+ /**
334
+ * The shortcodes every affiliation's `domains` names, across the link index.
335
+ *
336
+ * @param {object} index - The link index.
337
+ * @returns {Set<string>} The held shortcodes, lower case.
338
+ */
339
+ function heldShortcodes(index) {
340
+ let held = HELD.get(index);
341
+ if (held) return held;
342
+ held = new Set();
343
+ for (const note of index.notes ?? []) {
344
+ if (String(note.type ?? note.fm?.type ?? "") !== "affiliation") continue;
345
+ const data = note.fm?.data;
346
+ const domains = data && typeof data === "object" ? data.domains : undefined;
347
+ for (const shortcode of shortcodesOf(domains)) held.add(shortcode);
348
+ }
349
+ for (const entry of index.foreign?.index?.values?.() ?? []) {
350
+ if (String(entry?.type ?? "") !== "affiliation") continue;
351
+ for (const shortcode of shortcodesOf(entry.domains)) held.add(shortcode);
352
+ }
353
+ HELD.set(index, held);
354
+ return held;
355
+ }
356
+
357
+ /**
358
+ * Check a place note's tenure: a settlement, a site or a structure that no
359
+ * affiliation's `domains` names is unheld land, reported as a warning at the
360
+ * note's `type:` line.
361
+ *
362
+ * Declared on the `place` vocabulary as its type-level check, so the lint
363
+ * runs it beside the field checks with the same index. Without an index the
364
+ * question cannot be asked and nothing is reported.
365
+ *
366
+ * @param {object} note - The note, as the link index hands it over.
367
+ * @param {object} [opts]
368
+ * @param {object} [opts.index] - The link index, holding every affiliation.
369
+ * @returns {object[]} Findings.
370
+ */
371
+ export function checkHeld(note, { index } = {}) {
372
+ if (!index) return [];
373
+ const fm = note.fm ?? {};
374
+ if (String(fm.type ?? "") !== "place") return [];
375
+ const subType = String(fm.subType ?? "");
376
+ if (!HELD_SUBTYPES.includes(subType)) return [];
377
+ const shortcode = String(fm.shortcode ?? "").toLowerCase();
378
+ if (!shortcode) return [];
379
+ if (heldShortcodes(index).has(shortcode)) return [];
380
+ return [
381
+ {
382
+ file: note.file,
383
+ ...positionInFrontmatter(note.raw ?? "", "type", undefined, { topLevel: true }),
384
+ severity: "warning",
385
+ message:
386
+ `unheld land: no affiliation's \`domains\` names "${shortcode}", so nothing ` +
387
+ `says who holds this ${subType}; name it in the \`domains\` of the house, ` +
388
+ `order or polity that does`,
389
+ },
390
+ ];
391
+ }
package/engine/index.mjs CHANGED
@@ -87,6 +87,12 @@ export * as noteVocabulary from "./note-vocabulary.mjs";
87
87
  /** What a place is next to and reachable from: the closed sets, and the checks. */
88
88
  export * as placeRelations from "./place-relations.mjs";
89
89
 
90
+ /** What lies within a place, who holds it, and what an affiliation holds — the page lists, and the tenure check. */
91
+ export * as holdings from "./holdings.mjs";
92
+
93
+ /** What trade a settlement supports: the six-step market scale, and the check that holds a value to it. */
94
+ export * as marketClass from "./market-class.mjs";
95
+
90
96
  /** The places a map is drawn from, read from the content index, and what `parents` says about them. */
91
97
  export * as mapPlaces from "./map-places.mjs";
92
98
 
@@ -102,6 +108,9 @@ export * as mapGraphviz from "./map-graphviz.mjs";
102
108
  /** `content-build map`: the drawings, written under `build/map/`. */
103
109
  export * as mapBuild from "./map-build.mjs";
104
110
 
111
+ /** The map from each place, drawn for the site and written into its page. */
112
+ export * as siteMaps from "./site-maps.mjs";
113
+
105
114
  /** The closed registry of system ids, and the `none` that stands for no system. */
106
115
  export * as systems from "./systems.mjs";
107
116
 
@@ -89,9 +89,19 @@ export function findGraphviz(engine, { env = process.env, fallbacks = KNOWN_DIRE
89
89
  return undefined;
90
90
  }
91
91
 
92
+ /**
93
+ * What installs GraphViz on the two platforms a contributor is likely to be
94
+ * on, as the clause every "not installed" message ends with.
95
+ *
96
+ * @type {string}
97
+ */
98
+ export const GRAPHVIZ_INSTALL =
99
+ "install GraphViz (`brew install graphviz` on macOS, " +
100
+ "`apt-get install graphviz` on Debian or Ubuntu)";
101
+
92
102
  /**
93
103
  * What to say when an engine is not installed: the name, and what installs
94
- * it on the two platforms a contributor is likely to be on.
104
+ * it.
95
105
  *
96
106
  * @param {string} engine - The engine that was wanted.
97
107
  * @returns {string} The message.
@@ -99,8 +109,7 @@ export function findGraphviz(engine, { env = process.env, fallbacks = KNOWN_DIRE
99
109
  export function graphvizMissingMessage(engine) {
100
110
  return (
101
111
  `GraphViz's \`${engine}\` is not installed, and \`content-build map\` draws with it; ` +
102
- `install GraphViz (\`brew install graphviz\` on macOS, ` +
103
- `\`apt-get install graphviz\` on Debian or Ubuntu) and run the command again`
112
+ `${GRAPHVIZ_INSTALL} and run the command again`
104
113
  );
105
114
  }
106
115
 
@@ -251,14 +251,44 @@ export function loadMapWorld({ config, contentBase, base, problems }) {
251
251
  skipDirectories: config.skipDirectories,
252
252
  problems,
253
253
  });
254
- const world = placesFromRecords(records, { contentBase: tree, base });
255
- world.config = config;
256
254
  const foreign = loadForeignIndexes(config, [config.contentPackage]);
257
- addForeignPlaces(world, foreign.index);
255
+ const world = mapWorld({
256
+ records,
257
+ foreignIndex: foreign.index,
258
+ contentBase: tree,
259
+ base,
260
+ config,
261
+ });
258
262
  world.stale = foreign.stale;
259
263
  return world;
260
264
  }
261
265
 
266
+ /**
267
+ * The world a map is drawn from, out of records and indexes a caller already
268
+ * holds: this package's places from its index records, and every
269
+ * dependency's from the fetched indexes. The site build reads its corpus and
270
+ * its foreign indexes once for every surface it publishes, and hands them
271
+ * here rather than walking the tree a second time.
272
+ *
273
+ * @param {object} opts
274
+ * @param {Array<Record<string, any>>} opts.records - Index records, as
275
+ * {@link module:engine/content-index.indexRecordsFor} returns them.
276
+ * @param {Map<string, object>} opts.foreignIndex - The fetched indexes, as
277
+ * {@link module:engine/metadata-index.loadForeignIndexes} returns `index`.
278
+ * @param {string} opts.contentBase - The content root the records were read from.
279
+ * @param {string} [opts.base] - The site base this package's pages are served
280
+ * under. Absent, no local place carries a URL.
281
+ * @param {object} opts.config - The resolved configuration.
282
+ * @returns {MapWorld} The world, with nothing stale: the caller has already
283
+ * judged its indexes usable.
284
+ */
285
+ export function mapWorld({ records, foreignIndex, contentBase, base, config }) {
286
+ const world = placesFromRecords(records, { contentBase, base });
287
+ world.config = config;
288
+ addForeignPlaces(world, foreignIndex);
289
+ return world;
290
+ }
291
+
262
292
  /* --------------------------------------------------------------------- */
263
293
  /* Containment */
264
294
  /* --------------------------------------------------------------------- */
@@ -0,0 +1,178 @@
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 trade a settlement supports — a place note's `data.market`.
16
+ *
17
+ * `data.population` says how many people a settlement holds, which is not the
18
+ * same question as what a traveller can buy there. Two settlements of eight
19
+ * hundred are a different proposition when one has a weekly market and the
20
+ * other a chartered fair with a moneylender, and `market` is where a note says
21
+ * which.
22
+ *
23
+ * **The scale is six steps and is stated once, here.** The lint refuses a
24
+ * value against it, the vocabulary's `describe` reads its ends, and
25
+ * `docs/content-format.md` states it as a table the suite compares against
26
+ * this list. A second copy of the scale is one more list free to disagree
27
+ * about what a `4` buys.
28
+ *
29
+ * **A class is a step, not a measurement.** The numbers order the steps and
30
+ * nothing more: a `5` is not a market two fifths larger than a `3`, it is a
31
+ * city rather than a town. So a value off the scale is refused rather than
32
+ * clamped — a reader of the scale has to be able to trust that every number
33
+ * means the same thing wherever it appears.
34
+ *
35
+ * **The key is meaningful on a settlement**, and no subType condition is
36
+ * checked. `population` states none either: which places a key says something
37
+ * about is the author's judgement, and the `data:` container is closed per
38
+ * type rather than per subType.
39
+ *
40
+ * @module
41
+ */
42
+
43
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
44
+
45
+ /**
46
+ * One step of the market scale.
47
+ *
48
+ * @typedef {object} MarketClass
49
+ * @property {number} value - The integer a note writes.
50
+ * @property {string} name - What a settlement of this class is called.
51
+ * @property {string} trade - What can be had there.
52
+ */
53
+
54
+ /**
55
+ * The market scale, in order, from the settlement with no market to the one
56
+ * other markets buy from.
57
+ *
58
+ * @type {readonly MarketClass[]}
59
+ */
60
+ export const MARKET_CLASSES = Object.freeze([
61
+ Object.freeze({
62
+ value: 1,
63
+ name: "hamlet",
64
+ trade: "no market — what neighbours trade among themselves",
65
+ }),
66
+ Object.freeze({
67
+ value: 2,
68
+ name: "village",
69
+ trade: "a weekly market: staples, a smith, what a household cannot make",
70
+ }),
71
+ Object.freeze({
72
+ value: 3,
73
+ name: "town",
74
+ trade: "a regular market, most common goods, several trades working full time",
75
+ }),
76
+ Object.freeze({
77
+ value: 4,
78
+ name: "market town",
79
+ trade: "a chartered fair, goods carried from a week away, a moneylender",
80
+ }),
81
+ Object.freeze({
82
+ value: 5,
83
+ name: "city",
84
+ trade: "anything ordinary, in quantity; foreign goods; a guild structure",
85
+ }),
86
+ Object.freeze({
87
+ value: 6,
88
+ name: "great city",
89
+ trade:
90
+ "the rare and the imported as a matter of course; banking; " +
91
+ "the market from which other markets buy",
92
+ }),
93
+ ]);
94
+
95
+ /** The lowest class the scale states. @type {MarketClass} */
96
+ const LOWEST = MARKET_CLASSES[0];
97
+
98
+ /** The highest class the scale states. @type {MarketClass} */
99
+ const HIGHEST = MARKET_CLASSES[MARKET_CLASSES.length - 1];
100
+
101
+ /**
102
+ * The scale's ends, as a finding states them: `1 (hamlet) to 6 (great city)`.
103
+ *
104
+ * @returns {string} The phrase.
105
+ */
106
+ function scale() {
107
+ return `${LOWEST.value} (${LOWEST.name}) to ${HIGHEST.value} (${HIGHEST.name})`;
108
+ }
109
+
110
+ /**
111
+ * Whether a value is one of the classes the scale states.
112
+ *
113
+ * A note may write the number quoted, which YAML hands over as a string, so
114
+ * the value is read as a number before it is compared — the same reading
115
+ * `matchesKind` gives a `number` field.
116
+ *
117
+ * @param {unknown} value - The authored value.
118
+ * @returns {boolean} Whether it names a class.
119
+ */
120
+ export function isMarketClass(value) {
121
+ const n = numberOf(value);
122
+ return n !== undefined && MARKET_CLASSES.some((entry) => entry.value === n);
123
+ }
124
+
125
+ /**
126
+ * The class a value names, or `undefined` for a value that names none.
127
+ *
128
+ * @param {unknown} value - The authored value.
129
+ * @returns {MarketClass|undefined} The class.
130
+ */
131
+ export function marketClass(value) {
132
+ const n = numberOf(value);
133
+ return MARKET_CLASSES.find((entry) => entry.value === n);
134
+ }
135
+
136
+ /**
137
+ * A value as the finite number it reads as, or `undefined`.
138
+ *
139
+ * @param {unknown} value - The authored value.
140
+ * @returns {number|undefined} The number.
141
+ */
142
+ function numberOf(value) {
143
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
144
+ if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
145
+ return Number(value);
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ /**
151
+ * Check a place note's `data.market`.
152
+ *
153
+ * The value is one of the classes the scale states, or it is a finding located
154
+ * at the key's own line. A value that is not a number at all is left alone:
155
+ * the container's own shape check reports it, and saying so twice would make
156
+ * one slip read as two.
157
+ *
158
+ * @param {object} note - The note, as the link index hands it over.
159
+ * @returns {object[]} Findings.
160
+ */
161
+ export function checkMarket(note) {
162
+ const fm = note?.fm ?? {};
163
+ const data = fm.data && typeof fm.data === "object" && !Array.isArray(fm.data) ? fm.data : {};
164
+ const value = data.market;
165
+ if (value === undefined || value === null) return [];
166
+ if (numberOf(value) === undefined) return [];
167
+ if (isMarketClass(value)) return [];
168
+ return [
169
+ {
170
+ file: note.file,
171
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "market"]),
172
+ severity: "error",
173
+ message:
174
+ `\`data.market\` is a market class — a whole number from ${scale()} — ` +
175
+ `but reads ${JSON.stringify(value)}`,
176
+ },
177
+ ];
178
+ }
@@ -386,13 +386,16 @@ 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.
389
+ // What a place is next to, reachable from and within, and
390
+ // what an affiliation holds, carried so a consumer's lint
391
+ // checks a border across the package boundary from both ends,
392
+ // a consumer's map draws the dependency's places beside its
393
+ // own, and a consumer's pages list what a dependency's
394
+ // affiliation holds and what lies within its regions.
393
395
  borders: record.data?.borders ?? undefined,
394
396
  routes: record.data?.routes ?? undefined,
395
397
  parents: record.data?.parents ?? undefined,
398
+ domains: record.data?.domains ?? undefined,
396
399
  package: pkg,
397
400
  });
398
401
  }