@heroiclands/package-build 23.2.2 → 23.3.0
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 +17 -0
- package/ci/content-format-reference.mjs +28 -5
- package/docs/api.md +37 -23
- package/docs/authoring/frontmatter.md +2 -0
- package/docs/content-format.md +4 -0
- package/docs/guides/e2e.md +8 -1
- package/docs/reference/format-details.md +260 -7
- package/docs/reference/note-types.md +27 -27
- package/e2e.mjs +37 -10
- package/engine/content-format.yaml +6 -0
- package/engine/data-keys.mjs +6 -2
- package/engine/index.mjs +3 -0
- package/engine/infobox.mjs +1 -1
- package/engine/note-vocabulary.mjs +209 -17
- package/engine/settlement-tables.mjs +777 -0
- package/package.json +1 -1
- package/types/e2e.d.mts +5 -1
- package/types/engine/index.d.mts +1 -0
- package/types/engine/infobox.d.mts +1 -1
- package/types/engine/note-vocabulary.d.mts +31 -0
- package/types/engine/settlement-tables.d.mts +164 -0
|
@@ -0,0 +1,777 @@
|
|
|
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
|
+
* A region's settlements table: one authored row per settlement, stating its
|
|
16
|
+
* market, its people and who holds it, in the holding model the region
|
|
17
|
+
* declares.
|
|
18
|
+
*
|
|
19
|
+
* **A row is the whole record of a settlement with no note**, and a link to the
|
|
20
|
+
* note where one exists. A table is a second statement of whatever a linked
|
|
21
|
+
* note's frontmatter already states, so a linked row's figure and market must
|
|
22
|
+
* equal the note's, and a settlement note inside the region must have a row.
|
|
23
|
+
*
|
|
24
|
+
* **Where it is written.** A markdown table under a `## Settlements` heading on
|
|
25
|
+
* a `place` note — the region the settlements stand in. A section answered by
|
|
26
|
+
* an `sql` fence is generated from the notes themselves and is not read here.
|
|
27
|
+
* A note with no such table is not read at all, so the table is optional for
|
|
28
|
+
* a package.
|
|
29
|
+
*
|
|
30
|
+
* **The model is a tag.** A region names how its countryside is held by
|
|
31
|
+
* carrying one or more of the {@link HOLDING_MODELS} tags.
|
|
32
|
+
*
|
|
33
|
+
* **Every rule's findings are errors**, each message beginning with the rule's
|
|
34
|
+
* name from {@link SETTLEMENT_TABLE_RULES}. A wikilink that resolves nowhere is
|
|
35
|
+
* the link audit's finding and is not reported a second time here.
|
|
36
|
+
*
|
|
37
|
+
* @module
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
41
|
+
import { FENCE_LINE } from "./code-fences.mjs";
|
|
42
|
+
import { MARKET_CLASSES, marketClass } from "./market-class.mjs";
|
|
43
|
+
import { hasTag } from "./note-vocabulary.mjs";
|
|
44
|
+
import { shortcodesOf } from "./holdings.mjs";
|
|
45
|
+
import { WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
|
|
46
|
+
|
|
47
|
+
/** The heading text a settlements table stands under, at level two. */
|
|
48
|
+
export const SETTLEMENTS_HEADING = "Settlements";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How a region's countryside is held, each the tag a region carries to say so.
|
|
52
|
+
*
|
|
53
|
+
* `holder` is what the `Held by` cell names under that model. `person` says
|
|
54
|
+
* whether that holder may be a person: only where land is held by a person or
|
|
55
|
+
* a house may the cell link a `being`.
|
|
56
|
+
*
|
|
57
|
+
* **The tags stay open.** They are not a declared tag group: a group would
|
|
58
|
+
* report every tag within a typo's distance of one — a region tagged `hellad`
|
|
59
|
+
* reads as a near miss of `held` — and a misspelled model on a note with a
|
|
60
|
+
* table is already reported, because the note then names no model at all.
|
|
61
|
+
*
|
|
62
|
+
* @type {ReadonlyArray<Readonly<{tag: string, holder: string, person: boolean}>>}
|
|
63
|
+
*/
|
|
64
|
+
export const HOLDING_MODELS = Object.freeze([
|
|
65
|
+
Object.freeze({ tag: "held", holder: "a person or a house", person: true }),
|
|
66
|
+
Object.freeze({ tag: "endowed", holder: "an institution", person: false }),
|
|
67
|
+
Object.freeze({ tag: "registered", holder: "an office", person: false }),
|
|
68
|
+
Object.freeze({ tag: "kin", holder: "a kindred", person: false }),
|
|
69
|
+
Object.freeze({ tag: "polis", holder: "the city", person: false }),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The table's columns, in the order the header states them. An optional
|
|
74
|
+
* column may be left out; the header carries no other column, and keeps this
|
|
75
|
+
* order.
|
|
76
|
+
*
|
|
77
|
+
* @type {ReadonlyArray<Readonly<{name: string, required: boolean}>>}
|
|
78
|
+
*/
|
|
79
|
+
export const SETTLEMENT_COLUMNS = Object.freeze([
|
|
80
|
+
Object.freeze({ name: "Name", required: true }),
|
|
81
|
+
Object.freeze({ name: "Market", required: true }),
|
|
82
|
+
Object.freeze({ name: "People", required: true }),
|
|
83
|
+
Object.freeze({ name: "Held by", required: true }),
|
|
84
|
+
Object.freeze({ name: "For", required: false }),
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** The note types a `Held by` link may name. */
|
|
88
|
+
const HOLDER_TYPES = Object.freeze(["affiliation", "being"]);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The rules this module enforces, each `name` the prefix its findings carry.
|
|
92
|
+
*
|
|
93
|
+
* @type {ReadonlyArray<Readonly<{name: string, describe: string}>>}
|
|
94
|
+
*/
|
|
95
|
+
export const SETTLEMENT_TABLE_RULES = Object.freeze([
|
|
96
|
+
Object.freeze({
|
|
97
|
+
name: "settlements columns",
|
|
98
|
+
describe:
|
|
99
|
+
"The header is not the declared columns in order. The table's rows are not " +
|
|
100
|
+
"read until it is.",
|
|
101
|
+
}),
|
|
102
|
+
Object.freeze({
|
|
103
|
+
name: "no holding model",
|
|
104
|
+
describe: "The note carries a settlements table and no holding-model tag.",
|
|
105
|
+
}),
|
|
106
|
+
Object.freeze({
|
|
107
|
+
name: "market class",
|
|
108
|
+
describe:
|
|
109
|
+
"A Market cell is not a class on the market scale written as its number " +
|
|
110
|
+
"and its name, such as `2 village`.",
|
|
111
|
+
}),
|
|
112
|
+
Object.freeze({
|
|
113
|
+
name: "people figure",
|
|
114
|
+
describe: "A People cell is not a positive whole number.",
|
|
115
|
+
}),
|
|
116
|
+
Object.freeze({
|
|
117
|
+
name: "no holder",
|
|
118
|
+
describe: "A Held by cell is empty.",
|
|
119
|
+
}),
|
|
120
|
+
Object.freeze({
|
|
121
|
+
name: "not a holder",
|
|
122
|
+
describe: "A Held by link names a note that is neither an affiliation nor a being.",
|
|
123
|
+
}),
|
|
124
|
+
Object.freeze({
|
|
125
|
+
name: "holder out of model",
|
|
126
|
+
describe:
|
|
127
|
+
"A Held by link names a being — a person — in a region none of whose " +
|
|
128
|
+
"holding models lets a person hold land.",
|
|
129
|
+
}),
|
|
130
|
+
Object.freeze({
|
|
131
|
+
name: "not a settlement",
|
|
132
|
+
describe: "A Name link names a note that is not a place.",
|
|
133
|
+
}),
|
|
134
|
+
Object.freeze({
|
|
135
|
+
name: "figure disagrees",
|
|
136
|
+
describe:
|
|
137
|
+
"A linked row's People differs from the `data.population` its note states, " +
|
|
138
|
+
"or the note states none.",
|
|
139
|
+
}),
|
|
140
|
+
Object.freeze({
|
|
141
|
+
name: "market disagrees",
|
|
142
|
+
describe:
|
|
143
|
+
"A linked row's Market differs from the `data.market` its note states, or " +
|
|
144
|
+
"the note states none.",
|
|
145
|
+
}),
|
|
146
|
+
Object.freeze({
|
|
147
|
+
name: "unlinked settlement",
|
|
148
|
+
describe:
|
|
149
|
+
"An unlinked Name is the name or an alias of a settlement note this table " +
|
|
150
|
+
"covers. The row links the note.",
|
|
151
|
+
}),
|
|
152
|
+
Object.freeze({
|
|
153
|
+
name: "missing row",
|
|
154
|
+
describe:
|
|
155
|
+
"A settlement note this table covers has no row. A settlement is covered by " +
|
|
156
|
+
"the nearest place above it, by `parents`, that carries a settlements table.",
|
|
157
|
+
}),
|
|
158
|
+
Object.freeze({
|
|
159
|
+
name: "duplicate settlement",
|
|
160
|
+
describe:
|
|
161
|
+
"A settlement has a second row in one table, or a row in a second table. " +
|
|
162
|
+
"Its row belongs to the table covering it.",
|
|
163
|
+
}),
|
|
164
|
+
Object.freeze({
|
|
165
|
+
name: "no tail",
|
|
166
|
+
describe:
|
|
167
|
+
"No sentence follows the table to state what it leaves out before the next " +
|
|
168
|
+
"heading.",
|
|
169
|
+
}),
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
/* --------------------------------------------------------------------- */
|
|
173
|
+
/* Reading the table */
|
|
174
|
+
/* --------------------------------------------------------------------- */
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One cell, as authored.
|
|
178
|
+
*
|
|
179
|
+
* @typedef {object} Cell
|
|
180
|
+
* @property {string} text - The cell, trimmed.
|
|
181
|
+
* @property {number} column - The 1-based column its text starts at.
|
|
182
|
+
*/
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* One settlements table.
|
|
186
|
+
*
|
|
187
|
+
* @typedef {object} SettlementTable
|
|
188
|
+
* @property {number} line - The 1-based line of its header.
|
|
189
|
+
* @property {string[]} header - The header cells.
|
|
190
|
+
* @property {boolean} wellFormed - Whether the header is the declared columns.
|
|
191
|
+
* @property {Array<{line: number, cells: Record<string, Cell>}>} rows - Each
|
|
192
|
+
* row's cells keyed by column name; empty when the header is not well formed.
|
|
193
|
+
* @property {number} last - The 1-based line of its last row.
|
|
194
|
+
* @property {boolean} tailed - Whether prose follows it before the next heading.
|
|
195
|
+
*/
|
|
196
|
+
|
|
197
|
+
/** A GFM delimiter row. */
|
|
198
|
+
const DELIMITER = /^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?\s*$/;
|
|
199
|
+
|
|
200
|
+
/** An ATX heading, its level and text. */
|
|
201
|
+
const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A row's cells, split on every pipe a backslash does not escape.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} line - The row.
|
|
207
|
+
* @returns {Cell[]} The cells, in order.
|
|
208
|
+
*/
|
|
209
|
+
function cellsOf(line) {
|
|
210
|
+
const first = line.indexOf("|");
|
|
211
|
+
const cells = [];
|
|
212
|
+
let start = first + 1;
|
|
213
|
+
for (let i = start; i <= line.length; i += 1) {
|
|
214
|
+
if (i < line.length && !(line[i] === "|" && line[i - 1] !== "\\")) continue;
|
|
215
|
+
const raw = line.slice(start, i);
|
|
216
|
+
const text = raw.trim();
|
|
217
|
+
const lead = raw.length - raw.trimStart().length;
|
|
218
|
+
cells.push({ text, column: text ? start + lead + 1 : start + 1 });
|
|
219
|
+
start = i + 1;
|
|
220
|
+
}
|
|
221
|
+
if (line.trimEnd().endsWith("|") && cells.at(-1)?.text === "") cells.pop();
|
|
222
|
+
return cells;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Whether a header names the declared columns, in order, optional ones only
|
|
227
|
+
* left out.
|
|
228
|
+
*
|
|
229
|
+
* @param {string[]} header - The header cells.
|
|
230
|
+
* @returns {boolean} Whether it does.
|
|
231
|
+
*/
|
|
232
|
+
function isDeclaredHeader(header) {
|
|
233
|
+
let at = 0;
|
|
234
|
+
for (const column of SETTLEMENT_COLUMNS) {
|
|
235
|
+
if (header[at] === column.name) at += 1;
|
|
236
|
+
else if (column.required) return false;
|
|
237
|
+
}
|
|
238
|
+
return at === header.length;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The header a finding asks for.
|
|
243
|
+
*
|
|
244
|
+
* @returns {string} The declared columns as a header row.
|
|
245
|
+
*/
|
|
246
|
+
function declaredHeader() {
|
|
247
|
+
return `| ${SETTLEMENT_COLUMNS.map((c) => c.name).join(" | ")} |`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Every settlements table in a note, read from the raw file so each line
|
|
252
|
+
* number is the file's own.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} raw - The whole note, frontmatter included.
|
|
255
|
+
* @returns {{heading: number, tables: SettlementTable[]}} The `## Settlements`
|
|
256
|
+
* heading's line, 0 when there is none, and its tables.
|
|
257
|
+
*/
|
|
258
|
+
export function findSettlementTables(raw) {
|
|
259
|
+
const lines = String(raw ?? "").split("\n");
|
|
260
|
+
let i = 0;
|
|
261
|
+
if (lines[0] === "---") {
|
|
262
|
+
i = lines.indexOf("---", 1);
|
|
263
|
+
i = i === -1 ? lines.length : i + 1;
|
|
264
|
+
}
|
|
265
|
+
let heading = 0;
|
|
266
|
+
/** @type {SettlementTable[]} */
|
|
267
|
+
const tables = [];
|
|
268
|
+
let inSection = false;
|
|
269
|
+
/** @type {SettlementTable|undefined} */
|
|
270
|
+
let awaitingTail;
|
|
271
|
+
for (; i < lines.length; i += 1) {
|
|
272
|
+
const line = lines[i];
|
|
273
|
+
const fence = FENCE_LINE.exec(line);
|
|
274
|
+
if (fence) {
|
|
275
|
+
if (awaitingTail) awaitingTail = undefined;
|
|
276
|
+
const closer = new RegExp(`^[ \\t]*${fence[2][0]}{${fence[2].length},}[ \\t]*$`);
|
|
277
|
+
i += 1;
|
|
278
|
+
while (i < lines.length && !closer.test(lines[i])) i += 1;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const head = HEADING.exec(line);
|
|
282
|
+
if (head) {
|
|
283
|
+
awaitingTail = undefined;
|
|
284
|
+
if (head[1].length <= 2) {
|
|
285
|
+
inSection = head[1].length === 2 && head[2] === SETTLEMENTS_HEADING;
|
|
286
|
+
if (inSection && !heading) heading = i + 1;
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (!inSection) continue;
|
|
291
|
+
if (line.trim() === "") continue;
|
|
292
|
+
if (line.trim().startsWith("|") && DELIMITER.test(lines[i + 1] ?? "")) {
|
|
293
|
+
awaitingTail = undefined;
|
|
294
|
+
const header = cellsOf(line).map((c) => c.text);
|
|
295
|
+
const wellFormed = isDeclaredHeader(header);
|
|
296
|
+
/** @type {SettlementTable} */
|
|
297
|
+
const table = { line: i + 1, header, wellFormed, rows: [], last: i + 2, tailed: false };
|
|
298
|
+
i += 2;
|
|
299
|
+
for (; i < lines.length && lines[i].trim().startsWith("|"); i += 1) {
|
|
300
|
+
table.last = i + 1;
|
|
301
|
+
if (!wellFormed) continue;
|
|
302
|
+
const cells = cellsOf(lines[i]);
|
|
303
|
+
/** @type {Record<string, Cell>} */
|
|
304
|
+
const byName = {};
|
|
305
|
+
header.forEach((name, n) => {
|
|
306
|
+
byName[name] = cells[n] ?? { text: "", column: lines[i].length + 1 };
|
|
307
|
+
});
|
|
308
|
+
table.rows.push({ line: i + 1, cells: byName });
|
|
309
|
+
}
|
|
310
|
+
i -= 1;
|
|
311
|
+
tables.push(table);
|
|
312
|
+
awaitingTail = table;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (awaitingTail) {
|
|
316
|
+
awaitingTail.tailed = true;
|
|
317
|
+
awaitingTail = undefined;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { heading, tables };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* --------------------------------------------------------------------- */
|
|
324
|
+
/* The corpus */
|
|
325
|
+
/* --------------------------------------------------------------------- */
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* One place note, as the rules read it.
|
|
329
|
+
*
|
|
330
|
+
* @typedef {object} PlaceNode
|
|
331
|
+
* @property {object} note - The note.
|
|
332
|
+
* @property {string} shortcode - Lower case.
|
|
333
|
+
* @property {string} subType
|
|
334
|
+
* @property {string} title
|
|
335
|
+
* @property {Set<string>} names - Its name and aliases, lower case.
|
|
336
|
+
* @property {string[]} parents - Enclosing places' shortcodes.
|
|
337
|
+
* @property {{heading: number, tables: SettlementTable[]}} section
|
|
338
|
+
*/
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Everything the rules ask of the whole tree.
|
|
342
|
+
*
|
|
343
|
+
* @typedef {object} Corpus
|
|
344
|
+
* @property {Map<string, PlaceNode>} places - By shortcode.
|
|
345
|
+
* @property {Map<object, PlaceNode>} byNote - By note.
|
|
346
|
+
* @property {Map<string, PlaceNode[]>} covers - Region shortcode → the
|
|
347
|
+
* settlement notes its table covers.
|
|
348
|
+
* @property {Map<object, object[]>} rowedIn - Settlement note → the region
|
|
349
|
+
* notes whose tables link it, in path order.
|
|
350
|
+
*/
|
|
351
|
+
|
|
352
|
+
/** @type {WeakMap<object, Corpus>} */
|
|
353
|
+
const CORPUS = new WeakMap();
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* A note's name and aliases, lower case.
|
|
357
|
+
*
|
|
358
|
+
* @param {object} fm - The frontmatter.
|
|
359
|
+
* @returns {Set<string>} The names.
|
|
360
|
+
*/
|
|
361
|
+
function namesOf(fm) {
|
|
362
|
+
const name = fm?.name;
|
|
363
|
+
const out = new Set();
|
|
364
|
+
const add = (value) => {
|
|
365
|
+
if (typeof value === "string" && value.trim()) out.add(value.trim().toLowerCase());
|
|
366
|
+
};
|
|
367
|
+
if (typeof name === "string") add(name);
|
|
368
|
+
else if (name && typeof name === "object") {
|
|
369
|
+
add(name.full);
|
|
370
|
+
for (const alias of Array.isArray(name.aliases) ? name.aliases : []) add(alias);
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The note or entry a wikilink in a cell names, or `undefined`.
|
|
377
|
+
*
|
|
378
|
+
* @param {object} index - The link index.
|
|
379
|
+
* @param {string} target - The link target.
|
|
380
|
+
* @returns {object|undefined} The hit.
|
|
381
|
+
*/
|
|
382
|
+
function resolveTarget(index, target) {
|
|
383
|
+
if (!target) return undefined;
|
|
384
|
+
return index.resolve?.(target) ?? index.addressHit?.(target);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The type of what a link resolved to.
|
|
389
|
+
*
|
|
390
|
+
* @param {object} hit - A local note or a fetched entry.
|
|
391
|
+
* @returns {string} The type, lower case.
|
|
392
|
+
*/
|
|
393
|
+
function typeOf(hit) {
|
|
394
|
+
return String(hit?.fm?.type ?? hit?.type ?? "").toLowerCase();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The wikilink targets a cell carries.
|
|
399
|
+
*
|
|
400
|
+
* @param {string} text - The cell.
|
|
401
|
+
* @returns {string[]} The targets, in order.
|
|
402
|
+
*/
|
|
403
|
+
function targetsIn(text) {
|
|
404
|
+
return [...text.matchAll(new RegExp(WIKILINK.source, "g"))].map(
|
|
405
|
+
(match) => parseWikilink(match[1]).target,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The places above one, nearest first.
|
|
411
|
+
*
|
|
412
|
+
* @param {Map<string, PlaceNode>} places - By shortcode.
|
|
413
|
+
* @param {PlaceNode} node - Where to start.
|
|
414
|
+
* @returns {PlaceNode[]} The ancestors, a cycle broken.
|
|
415
|
+
*/
|
|
416
|
+
function ancestorsOf(places, node) {
|
|
417
|
+
const seen = new Set([node.shortcode]);
|
|
418
|
+
const out = [];
|
|
419
|
+
const queue = [...node.parents];
|
|
420
|
+
while (queue.length) {
|
|
421
|
+
const next = queue.shift();
|
|
422
|
+
if (!next || seen.has(next)) continue;
|
|
423
|
+
seen.add(next);
|
|
424
|
+
const above = places.get(next);
|
|
425
|
+
if (!above) continue;
|
|
426
|
+
out.push(above);
|
|
427
|
+
queue.push(...above.parents);
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The corpus a link index describes, derived once.
|
|
434
|
+
*
|
|
435
|
+
* @param {object} index - The link index.
|
|
436
|
+
* @returns {Corpus} The corpus.
|
|
437
|
+
*/
|
|
438
|
+
function corpusOf(index) {
|
|
439
|
+
let found = CORPUS.get(index);
|
|
440
|
+
if (found) return found;
|
|
441
|
+
/** @type {Map<string, PlaceNode>} */
|
|
442
|
+
const places = new Map();
|
|
443
|
+
/** @type {Map<object, PlaceNode>} */
|
|
444
|
+
const byNote = new Map();
|
|
445
|
+
for (const note of index.notes ?? []) {
|
|
446
|
+
const fm = note.fm ?? {};
|
|
447
|
+
if (String(fm.type ?? note.type ?? "") !== "place") continue;
|
|
448
|
+
const shortcode = String(fm.shortcode ?? "").toLowerCase();
|
|
449
|
+
if (!shortcode) continue;
|
|
450
|
+
const data =
|
|
451
|
+
fm.data && typeof fm.data === "object" && !Array.isArray(fm.data) ? fm.data : {};
|
|
452
|
+
const node = {
|
|
453
|
+
note,
|
|
454
|
+
shortcode,
|
|
455
|
+
subType: String(fm.subType ?? ""),
|
|
456
|
+
title: String(fm.name?.full ?? fm.name ?? shortcode),
|
|
457
|
+
names: namesOf(fm),
|
|
458
|
+
parents: shortcodesOf(data.parents),
|
|
459
|
+
section: findSettlementTables(note.raw ?? ""),
|
|
460
|
+
};
|
|
461
|
+
byNote.set(note, node);
|
|
462
|
+
// Shortcode identity: `parents` names places by shortcode, as populations reads it.
|
|
463
|
+
if (!places.has(shortcode)) places.set(shortcode, node);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** @type {Map<string, PlaceNode[]>} */
|
|
467
|
+
const covers = new Map();
|
|
468
|
+
for (const node of places.values()) {
|
|
469
|
+
if (node.subType !== "settlement") continue;
|
|
470
|
+
const owner = ancestorsOf(places, node).find((above) => above.section.tables.length);
|
|
471
|
+
if (!owner) continue;
|
|
472
|
+
const list = covers.get(owner.shortcode);
|
|
473
|
+
if (list) list.push(node);
|
|
474
|
+
else covers.set(owner.shortcode, [node]);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** @type {Map<object, object[]>} */
|
|
478
|
+
const rowedIn = new Map();
|
|
479
|
+
const regions = [...byNote.values()]
|
|
480
|
+
.filter((node) => node.section.tables.length)
|
|
481
|
+
.sort((a, b) => String(a.note.file).localeCompare(String(b.note.file), "en"));
|
|
482
|
+
for (const region of regions) {
|
|
483
|
+
for (const table of region.section.tables) {
|
|
484
|
+
for (const { cells } of table.rows) {
|
|
485
|
+
const [target] = targetsIn(cells.Name?.text ?? "");
|
|
486
|
+
const hit = resolveTarget(index, target);
|
|
487
|
+
if (!hit?.fm || typeOf(hit) !== "place") continue;
|
|
488
|
+
const list = rowedIn.get(hit) ?? [];
|
|
489
|
+
if (!list.includes(region.note)) list.push(region.note);
|
|
490
|
+
rowedIn.set(hit, list);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
found = { places, byNote, covers, rowedIn };
|
|
496
|
+
CORPUS.set(index, found);
|
|
497
|
+
return found;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/* --------------------------------------------------------------------- */
|
|
501
|
+
/* The check */
|
|
502
|
+
/* --------------------------------------------------------------------- */
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* A count as a finding spells it.
|
|
506
|
+
*
|
|
507
|
+
* @param {number} n - The figure.
|
|
508
|
+
* @returns {string} Grouped in threes.
|
|
509
|
+
*/
|
|
510
|
+
function counted(n) {
|
|
511
|
+
return n.toLocaleString("en-US");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* A People cell as a figure, or `undefined`.
|
|
516
|
+
*
|
|
517
|
+
* @param {string} text - The cell.
|
|
518
|
+
* @returns {number|undefined} The figure.
|
|
519
|
+
*/
|
|
520
|
+
function figureOf(text) {
|
|
521
|
+
if (!/^(?:\d{1,3}(?:,\d{3})+|\d+)$/.test(text)) return undefined;
|
|
522
|
+
const n = Number(text.replace(/,/g, ""));
|
|
523
|
+
return n > 0 ? n : undefined;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* A Market cell as a class, or `undefined`.
|
|
528
|
+
*
|
|
529
|
+
* @param {string} text - The cell.
|
|
530
|
+
* @returns {import("./market-class.mjs").MarketClass|undefined} The class.
|
|
531
|
+
*/
|
|
532
|
+
function classOf(text) {
|
|
533
|
+
const match = /^(\d+) (.+)$/.exec(text);
|
|
534
|
+
const entry = match ? marketClass(match[1]) : undefined;
|
|
535
|
+
return entry && entry.name === match?.[2] ? entry : undefined;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* A class as a cell writes it.
|
|
540
|
+
*
|
|
541
|
+
* @param {import("./market-class.mjs").MarketClass} entry - The class.
|
|
542
|
+
* @returns {string} The cell.
|
|
543
|
+
*/
|
|
544
|
+
function cellOf(entry) {
|
|
545
|
+
return `${entry.value} ${entry.name}`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Check a place note's settlements tables.
|
|
550
|
+
*
|
|
551
|
+
* Declared as part of the `place` vocabulary's whole-note check, so the lint
|
|
552
|
+
* runs it with the index every reference resolves through. Without an index
|
|
553
|
+
* nothing is reported.
|
|
554
|
+
*
|
|
555
|
+
* @param {object} note - The note, as the link index hands it over.
|
|
556
|
+
* @param {object} [opts]
|
|
557
|
+
* @param {object} [opts.index] - The link index, holding the whole corpus.
|
|
558
|
+
* @returns {object[]} Findings.
|
|
559
|
+
*/
|
|
560
|
+
export function checkSettlementTables(note, { index } = {}) {
|
|
561
|
+
if (!index) return [];
|
|
562
|
+
if (String(note.fm?.type ?? "") !== "place") return [];
|
|
563
|
+
const corpus = corpusOf(index);
|
|
564
|
+
const self = corpus.byNote.get(note);
|
|
565
|
+
const section = self?.section ?? findSettlementTables(note.raw ?? "");
|
|
566
|
+
if (!section.tables.length) return [];
|
|
567
|
+
|
|
568
|
+
const findings = [];
|
|
569
|
+
const report = (rule, position, message) =>
|
|
570
|
+
findings.push({
|
|
571
|
+
file: note.file,
|
|
572
|
+
...position,
|
|
573
|
+
severity: "error",
|
|
574
|
+
message: `${rule}: ${message}`,
|
|
575
|
+
});
|
|
576
|
+
const at = (line, cell) => (cell ? { line, column: cell.column } : { line });
|
|
577
|
+
|
|
578
|
+
const models = HOLDING_MODELS.filter((model) => hasTag(note.fm, model.tag));
|
|
579
|
+
if (!models.length) {
|
|
580
|
+
const tags = positionOfFrontmatterPath(note.raw ?? "", ["tags"], { key: true });
|
|
581
|
+
report(
|
|
582
|
+
"no holding model",
|
|
583
|
+
tags.line ? tags : { line: section.heading },
|
|
584
|
+
`a settlements table is written in a holding model, and this note carries none of ` +
|
|
585
|
+
`the tags ${HOLDING_MODELS.map((m) => m.tag).join(", ")}`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const covered = self ? (corpus.covers.get(self.shortcode) ?? []) : [];
|
|
590
|
+
const rowed = new Set();
|
|
591
|
+
let readable = true;
|
|
592
|
+
|
|
593
|
+
for (const table of section.tables) {
|
|
594
|
+
if (!table.tailed) {
|
|
595
|
+
report(
|
|
596
|
+
"no tail",
|
|
597
|
+
{ line: table.last },
|
|
598
|
+
"a sentence follows a settlements table, stating what the table leaves out",
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
if (!table.wellFormed) {
|
|
602
|
+
readable = false;
|
|
603
|
+
report(
|
|
604
|
+
"settlements columns",
|
|
605
|
+
{ line: table.line },
|
|
606
|
+
`the header reads \`| ${table.header.join(" | ")} |\`; a settlements table's ` +
|
|
607
|
+
`columns are \`${declaredHeader()}\`, and only ` +
|
|
608
|
+
`${SETTLEMENT_COLUMNS.filter((c) => !c.required)
|
|
609
|
+
.map((c) => c.name)
|
|
610
|
+
.join(", ")} may be left out`,
|
|
611
|
+
);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const seen = new Set();
|
|
616
|
+
for (const { line, cells } of table.rows) {
|
|
617
|
+
const name = cells.Name;
|
|
618
|
+
const [nameTarget] = targetsIn(name.text);
|
|
619
|
+
const label = name.text.replace(/\\\|/g, "|");
|
|
620
|
+
let identity = `name:${name.text.toLowerCase()}`;
|
|
621
|
+
/** @type {object|undefined} */
|
|
622
|
+
let settlement;
|
|
623
|
+
|
|
624
|
+
if (nameTarget) {
|
|
625
|
+
const hit = resolveTarget(index, nameTarget);
|
|
626
|
+
identity = `link:${nameTarget.toLowerCase()}`;
|
|
627
|
+
if (hit) {
|
|
628
|
+
identity = hit.file ? `file:${hit.file}` : identity;
|
|
629
|
+
if (typeOf(hit) !== "place") {
|
|
630
|
+
report(
|
|
631
|
+
"not a settlement",
|
|
632
|
+
at(line, name),
|
|
633
|
+
`${label} names a ${typeOf(hit) || "document"}; a row's Name ` +
|
|
634
|
+
"links the place note of the settlement it describes",
|
|
635
|
+
);
|
|
636
|
+
} else if (hit.fm) {
|
|
637
|
+
settlement = hit;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
} else if (name.text) {
|
|
641
|
+
const match = covered.find((node) => node.names.has(name.text.toLowerCase()));
|
|
642
|
+
if (match) {
|
|
643
|
+
rowed.add(match.note);
|
|
644
|
+
report(
|
|
645
|
+
"unlinked settlement",
|
|
646
|
+
at(line, name),
|
|
647
|
+
`${name.text} has a note; write the Name as ` +
|
|
648
|
+
`\`[[place-${match.shortcode}\\|${name.text}]]\``,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const repeated = seen.has(identity);
|
|
654
|
+
seen.add(identity);
|
|
655
|
+
const title = settlement ? String(settlement.fm.name?.full ?? label) : label;
|
|
656
|
+
if (repeated) {
|
|
657
|
+
report(
|
|
658
|
+
"duplicate settlement",
|
|
659
|
+
at(line, name),
|
|
660
|
+
`${label} already has a row in this table`,
|
|
661
|
+
);
|
|
662
|
+
} else if (settlement) {
|
|
663
|
+
// The row belongs to the nearest region above the settlement
|
|
664
|
+
// that rows it, or else to the first table, in path order,
|
|
665
|
+
// that does.
|
|
666
|
+
const tables = corpus.rowedIn.get(settlement) ?? [];
|
|
667
|
+
const owner = corpus.byNote.get(settlement);
|
|
668
|
+
const home =
|
|
669
|
+
(owner &&
|
|
670
|
+
ancestorsOf(corpus.places, owner)
|
|
671
|
+
.map((node) => node.note)
|
|
672
|
+
.find((region) => tables.includes(region))) ??
|
|
673
|
+
tables[0];
|
|
674
|
+
if (home && home !== note) {
|
|
675
|
+
report(
|
|
676
|
+
"duplicate settlement",
|
|
677
|
+
at(line, name),
|
|
678
|
+
`${title} already has a row in ${corpus.byNote.get(home)?.title ?? home.file}`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (settlement) rowed.add(settlement);
|
|
683
|
+
|
|
684
|
+
const market = cells.Market;
|
|
685
|
+
const marketEntry = classOf(market.text);
|
|
686
|
+
if (!marketEntry) {
|
|
687
|
+
const number = /^(\d+)\b/.exec(market.text);
|
|
688
|
+
const named = number ? marketClass(number[1]) : undefined;
|
|
689
|
+
report(
|
|
690
|
+
"market class",
|
|
691
|
+
at(line, market),
|
|
692
|
+
named ?
|
|
693
|
+
`"${market.text}" — class ${named.value} is written \`${cellOf(named)}\``
|
|
694
|
+
: `"${market.text}" is not a class on the market scale; write one of ` +
|
|
695
|
+
MARKET_CLASSES.map((c) => `\`${cellOf(c)}\``).join(", "),
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const people = cells.People;
|
|
700
|
+
const figure = figureOf(people.text);
|
|
701
|
+
if (figure === undefined) {
|
|
702
|
+
report(
|
|
703
|
+
"people figure",
|
|
704
|
+
at(line, people),
|
|
705
|
+
`"${people.text}" is not a figure; a People cell is a positive whole ` +
|
|
706
|
+
"number, such as `420` or `3,400`",
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (settlement) {
|
|
711
|
+
const data = settlement.fm.data ?? {};
|
|
712
|
+
const stated = Number(data.population);
|
|
713
|
+
const has = Number.isFinite(stated) && stated > 0;
|
|
714
|
+
if (figure !== undefined && (!has || stated !== figure)) {
|
|
715
|
+
report(
|
|
716
|
+
"figure disagrees",
|
|
717
|
+
at(line, people),
|
|
718
|
+
`${title} is carried at ${counted(figure)} here and ` +
|
|
719
|
+
(has ?
|
|
720
|
+
`${counted(stated)} on its own note`
|
|
721
|
+
: "its own note states none"),
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
const own = marketClass(data.market);
|
|
725
|
+
if (marketEntry && (!own || own.value !== marketEntry.value)) {
|
|
726
|
+
report(
|
|
727
|
+
"market disagrees",
|
|
728
|
+
at(line, market),
|
|
729
|
+
`${title} is carried as \`${cellOf(marketEntry)}\` here and ` +
|
|
730
|
+
(own ?
|
|
731
|
+
`\`${cellOf(own)}\` on its own note`
|
|
732
|
+
: "its own note states none"),
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const holder = cells["Held by"];
|
|
738
|
+
if (!holder.text) {
|
|
739
|
+
report("no holder", at(line, holder), `${label} names no holder`);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
for (const target of targetsIn(holder.text)) {
|
|
743
|
+
const hit = resolveTarget(index, target);
|
|
744
|
+
if (!hit) continue;
|
|
745
|
+
const type = typeOf(hit);
|
|
746
|
+
if (!HOLDER_TYPES.includes(type)) {
|
|
747
|
+
report(
|
|
748
|
+
"not a holder",
|
|
749
|
+
at(line, holder),
|
|
750
|
+
`${target} is a ${type || "document"}; a holder is an affiliation ` +
|
|
751
|
+
"or a being",
|
|
752
|
+
);
|
|
753
|
+
} else if (type === "being" && models.length && !models.some((m) => m.person)) {
|
|
754
|
+
report(
|
|
755
|
+
"holder out of model",
|
|
756
|
+
at(line, holder),
|
|
757
|
+
`${target} is a person, and this region is ` +
|
|
758
|
+
models.map((m) => `${m.tag}, held by ${m.holder}`).join("; "),
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (readable) {
|
|
766
|
+
for (const node of covered) {
|
|
767
|
+
if (rowed.has(node.note)) continue;
|
|
768
|
+
report(
|
|
769
|
+
"missing row",
|
|
770
|
+
{ line: section.tables[0].line },
|
|
771
|
+
`${node.title} (\`place-${node.shortcode}\`) stands in this region and has no row`,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return findings;
|
|
777
|
+
}
|