@heroiclands/package-build 22.4.6 → 22.5.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 +33 -0
- package/CONTENT.md +192 -1
- package/bin/content-build.mjs +26 -4
- package/content-config.mjs +8 -3
- package/docs/api.md +99 -26
- package/docs/configuration.md +20 -1
- package/docs/content-format.md +238 -0
- package/docs/diagnostics.md +44 -0
- package/engine/content-index.mjs +63 -21
- package/engine/content-links.mjs +60 -6
- package/engine/content-tables.mjs +29 -2
- package/engine/frontmatter-lint.mjs +2 -0
- package/engine/holdings.mjs +416 -0
- package/engine/index-records.mjs +25 -0
- package/engine/index.mjs +16 -0
- package/engine/map-graphviz.mjs +12 -3
- package/engine/map-places.mjs +33 -3
- package/engine/market-class.mjs +178 -0
- package/engine/metadata-index.mjs +7 -4
- package/engine/note-state.mjs +213 -0
- package/engine/note-vocabulary.mjs +59 -2
- package/engine/pdf-build.mjs +4 -2
- package/engine/populations.mjs +557 -0
- package/engine/site-build.mjs +96 -10
- package/engine/site-maps.mjs +165 -0
- package/engine/sql-tables.mjs +157 -4
- package/engine/stub-lint.mjs +237 -0
- package/engine/wikilink-syntax.mjs +27 -1
- package/package.json +1 -1
- package/types/engine/content-index.d.mts +19 -5
- package/types/engine/content-links.d.mts +3 -3
- package/types/engine/content-tables.d.mts +2 -1
- package/types/engine/holdings.d.mts +157 -0
- package/types/engine/index-records.d.mts +22 -0
- package/types/engine/index.d.mts +6 -0
- package/types/engine/map-graphviz.d.mts +8 -1
- package/types/engine/map-places.d.mts +26 -0
- package/types/engine/market-class.d.mts +62 -0
- package/types/engine/note-state.d.mts +110 -0
- package/types/engine/note-vocabulary.d.mts +25 -0
- package/types/engine/populations.d.mts +121 -0
- package/types/engine/site-build.d.mts +37 -6
- package/types/engine/site-maps.d.mts +81 -0
- package/types/engine/stub-lint.d.mts +51 -0
- package/types/engine/wikilink-syntax.d.mts +19 -1
|
@@ -0,0 +1,557 @@
|
|
|
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
|
+
* Whether a world's population figures agree from the region down.
|
|
16
|
+
*
|
|
17
|
+
* A place states `data.population` and an affiliation states how many people
|
|
18
|
+
* it counts. Read across the whole corpus those figures make claims about each
|
|
19
|
+
* other, and four of those claims can be checked: the bodies holding a region
|
|
20
|
+
* fit inside it, the regions inside it fit inside it, a settlement fits inside
|
|
21
|
+
* every place that contains it, and a page citing a figure states the figure
|
|
22
|
+
* that note states.
|
|
23
|
+
*
|
|
24
|
+
* This is {@link module:engine/holdings}'s sibling and reads the same two
|
|
25
|
+
* keys — a place's `data.parents` for geography, an affiliation's
|
|
26
|
+
* `data.domains` for tenure. Where that module asks whether anybody holds a
|
|
27
|
+
* place, this one asks whether the numbers add up.
|
|
28
|
+
*
|
|
29
|
+
* **Every finding is a warning.** A figure out of step with its neighbours is
|
|
30
|
+
* a thing an author wants to see while the note is open, and the same gap can
|
|
31
|
+
* be deliberate for a while: a region whose settlements are half written will
|
|
32
|
+
* disagree with itself until the rest are. Nothing here fails a build.
|
|
33
|
+
*
|
|
34
|
+
* **Figures are authored to two significant digits**, so a parent rounded
|
|
35
|
+
* below the true sum of its children is not a finding.
|
|
36
|
+
* {@link POPULATION_TOLERANCE} is the widest half-unit error such a figure can
|
|
37
|
+
* carry.
|
|
38
|
+
*
|
|
39
|
+
* **A stated figure is compared; an absent one is not.** A place or an
|
|
40
|
+
* affiliation with no `population` contributes nothing to a sum and is never
|
|
41
|
+
* the subject of a finding, so a half-written corpus is quiet rather than
|
|
42
|
+
* noisy. A dependency's places and affiliations take part in the *structure* —
|
|
43
|
+
* a fetched entry carries the `parents` and `domains` its record stated — and
|
|
44
|
+
* a fetched index carries no figure, so a dependency's people are counted
|
|
45
|
+
* nowhere.
|
|
46
|
+
*
|
|
47
|
+
* **There is deliberately no urban-share rule.** A share computed from the
|
|
48
|
+
* named settlements against a region's total measures nothing: the settlement
|
|
49
|
+
* layer names the notable places and is complete nowhere, so the ratio says
|
|
50
|
+
* how much of a region has been written rather than how much of it is urban.
|
|
51
|
+
* As a gate it moved figures rather than finding faults.
|
|
52
|
+
*
|
|
53
|
+
* @module
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { positionOfFrontmatterPath, positionOfLiteral } from "./diagnostics.mjs";
|
|
57
|
+
import { shortcodesOf } from "./holdings.mjs";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* How far a parent's figure may fall below the sum of what it contains before
|
|
61
|
+
* that is a finding.
|
|
62
|
+
*
|
|
63
|
+
* A figure written to two significant digits carries a half-unit of error in
|
|
64
|
+
* the second digit, and that error is widest at the bottom of a decade — half
|
|
65
|
+
* a unit on `1.0` is five per cent, against half a unit on `9.9` at a twentieth
|
|
66
|
+
* of that. The widest case is the one a tolerance has to admit, so the whole
|
|
67
|
+
* scale is held to five per cent.
|
|
68
|
+
*
|
|
69
|
+
* @type {number}
|
|
70
|
+
*/
|
|
71
|
+
export const POPULATION_TOLERANCE = 1.05;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The rules this module enforces, each `name` the prefix its findings carry.
|
|
75
|
+
*
|
|
76
|
+
* The list is the module's own statement of what it checks: a reader learns
|
|
77
|
+
* the whole surface from it, and the suite derives a case per entry from it, so
|
|
78
|
+
* a rule cannot be added without evidence that it fires.
|
|
79
|
+
*
|
|
80
|
+
* @type {ReadonlyArray<{name: string, describe: string}>}
|
|
81
|
+
*/
|
|
82
|
+
export const POPULATION_RULES = Object.freeze([
|
|
83
|
+
Object.freeze({
|
|
84
|
+
name: "over-held land",
|
|
85
|
+
describe:
|
|
86
|
+
"The polities whose `domains` name a place count more people than the place " +
|
|
87
|
+
"states. A polity subordinate to another holding the same place is already " +
|
|
88
|
+
"counted in that polity's figure, and a settlement is exempt, its holder's " +
|
|
89
|
+
"figure counting a hinterland the settlement does not.",
|
|
90
|
+
}),
|
|
91
|
+
Object.freeze({
|
|
92
|
+
name: "over-full region",
|
|
93
|
+
describe:
|
|
94
|
+
"The regions whose `parents` name a place count more people than the place " +
|
|
95
|
+
"states. A region naming both its parent and its grandparent counts under the " +
|
|
96
|
+
"nearer of the two.",
|
|
97
|
+
}),
|
|
98
|
+
Object.freeze({
|
|
99
|
+
name: "oversized settlement",
|
|
100
|
+
describe:
|
|
101
|
+
"A settlement counts more people than a place containing it states — its own " +
|
|
102
|
+
"region, or any place above that.",
|
|
103
|
+
}),
|
|
104
|
+
Object.freeze({
|
|
105
|
+
name: "disputed figure",
|
|
106
|
+
describe:
|
|
107
|
+
"A `doc` note carries a population beside a wikilink to the place or " +
|
|
108
|
+
"affiliation it belongs to, and the two disagree. A figure is read as a " +
|
|
109
|
+
"population only where it carries the `~` approximation marker.",
|
|
110
|
+
}),
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The corpus one link index describes, derived once and kept beside it.
|
|
115
|
+
*
|
|
116
|
+
* Every rule asks a question about the whole tree, and the answer does not
|
|
117
|
+
* change between notes, so the walk happens on the first note linted and the
|
|
118
|
+
* rest read it.
|
|
119
|
+
*
|
|
120
|
+
* @type {WeakMap<object, Corpus>}
|
|
121
|
+
*/
|
|
122
|
+
const CORPUS = new WeakMap();
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One place or affiliation as the rules read it.
|
|
126
|
+
*
|
|
127
|
+
* @typedef {object} PopulationNode
|
|
128
|
+
* @property {string} shortcode Lower case.
|
|
129
|
+
* @property {"place"|"affiliation"} type
|
|
130
|
+
* @property {string} subType
|
|
131
|
+
* @property {string} title What a finding calls it.
|
|
132
|
+
* @property {number} [population] The stated figure, absent where unstated.
|
|
133
|
+
* @property {string[]} parents A place's enclosing places, an affiliation's
|
|
134
|
+
* superiors.
|
|
135
|
+
* @property {string[]} domains What an affiliation holds.
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The whole tree, indexed the three ways the rules read it.
|
|
140
|
+
*
|
|
141
|
+
* @typedef {object} Corpus
|
|
142
|
+
* @property {Map<string, PopulationNode>} places By shortcode.
|
|
143
|
+
* @property {Map<string, PopulationNode>} affiliations By shortcode.
|
|
144
|
+
* @property {Map<string, string[]>} childrenOf Place shortcode → the places
|
|
145
|
+
* whose `parents` name it.
|
|
146
|
+
* @property {Map<string, string[]>} holdersOf Place shortcode → the polities
|
|
147
|
+
* whose `domains` name it.
|
|
148
|
+
* @property {Map<string, PopulationNode>} byAddress `type-shortcode` → the node,
|
|
149
|
+
* for the citation rule.
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A `data.population` value as a number, or `undefined`.
|
|
154
|
+
*
|
|
155
|
+
* A figure is a positive count. Anything else — a blank, a string of prose, a
|
|
156
|
+
* zero — states nothing this module can compare, and is read as unstated
|
|
157
|
+
* rather than as a figure of zero, which would make every sum a finding.
|
|
158
|
+
*
|
|
159
|
+
* @param {unknown} value - The authored value.
|
|
160
|
+
* @returns {number|undefined} The figure, or `undefined`.
|
|
161
|
+
*/
|
|
162
|
+
function figureOf(value) {
|
|
163
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
164
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A node's `data` block, or an empty one.
|
|
169
|
+
*
|
|
170
|
+
* @param {object|undefined} fm - The frontmatter.
|
|
171
|
+
* @returns {object} The block.
|
|
172
|
+
*/
|
|
173
|
+
function dataOf(fm) {
|
|
174
|
+
const data = fm?.data;
|
|
175
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A count as a finding spells it.
|
|
180
|
+
*
|
|
181
|
+
* @param {number} n - The figure.
|
|
182
|
+
* @returns {string} Grouped in threes.
|
|
183
|
+
*/
|
|
184
|
+
function counted(n) {
|
|
185
|
+
return n.toLocaleString("en-US");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read one local note into a node, or `null` where it takes no part.
|
|
190
|
+
*
|
|
191
|
+
* @param {object} note - A note from the link index.
|
|
192
|
+
* @returns {PopulationNode|null} The node.
|
|
193
|
+
*/
|
|
194
|
+
function localNode(note) {
|
|
195
|
+
const fm = note.fm ?? {};
|
|
196
|
+
const type = String(fm.type ?? note.type ?? "");
|
|
197
|
+
if (type !== "place" && type !== "affiliation") return null;
|
|
198
|
+
const shortcode = String(fm.shortcode ?? "").toLowerCase();
|
|
199
|
+
if (!shortcode) return null;
|
|
200
|
+
const data = dataOf(fm);
|
|
201
|
+
return {
|
|
202
|
+
shortcode,
|
|
203
|
+
type,
|
|
204
|
+
subType: String(fm.subType ?? ""),
|
|
205
|
+
title: String(fm.name?.full ?? shortcode),
|
|
206
|
+
population: figureOf(data.population),
|
|
207
|
+
parents: shortcodesOf(data.parents),
|
|
208
|
+
domains: type === "affiliation" ? shortcodesOf(data.domains) : [],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Read a fetched index entry into a node, or `null` where it takes no part.
|
|
214
|
+
*
|
|
215
|
+
* A fetched entry carries the structure its record stated and no figure, so
|
|
216
|
+
* a dependency's place sits in the geography and counts nobody.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} canonical - The entry's address.
|
|
219
|
+
* @param {object} entry - The entry.
|
|
220
|
+
* @returns {PopulationNode|null} The node.
|
|
221
|
+
*/
|
|
222
|
+
function foreignNode(canonical, entry) {
|
|
223
|
+
const type = String(entry?.type ?? "");
|
|
224
|
+
if (type !== "place" && type !== "affiliation") return null;
|
|
225
|
+
const shortcode = canonical.split("-").pop()?.toLowerCase() ?? "";
|
|
226
|
+
if (!shortcode) return null;
|
|
227
|
+
return {
|
|
228
|
+
shortcode,
|
|
229
|
+
type,
|
|
230
|
+
subType: String(entry.subType ?? ""),
|
|
231
|
+
title: String(entry.name ?? shortcode),
|
|
232
|
+
parents: shortcodesOf(entry.parents),
|
|
233
|
+
domains: type === "affiliation" ? shortcodesOf(entry.domains) : [],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The corpus a link index describes.
|
|
239
|
+
*
|
|
240
|
+
* A shortcode is declared once per type and the first declaration wins, so a
|
|
241
|
+
* local note shadows a dependency's the way the link resolver answers.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} index - The link index.
|
|
244
|
+
* @returns {Corpus} The corpus.
|
|
245
|
+
*/
|
|
246
|
+
function corpusOf(index) {
|
|
247
|
+
let found = CORPUS.get(index);
|
|
248
|
+
if (found) return found;
|
|
249
|
+
|
|
250
|
+
/** @type {Map<string, PopulationNode>} */
|
|
251
|
+
const places = new Map();
|
|
252
|
+
/** @type {Map<string, PopulationNode>} */
|
|
253
|
+
const affiliations = new Map();
|
|
254
|
+
/** @type {Map<string, PopulationNode>} */
|
|
255
|
+
const byAddress = new Map();
|
|
256
|
+
|
|
257
|
+
const take = (node) => {
|
|
258
|
+
if (!node) return;
|
|
259
|
+
const by = node.type === "place" ? places : affiliations;
|
|
260
|
+
if (by.has(node.shortcode)) return;
|
|
261
|
+
by.set(node.shortcode, node);
|
|
262
|
+
const address = `${node.type}-${node.shortcode}`;
|
|
263
|
+
if (!byAddress.has(address)) byAddress.set(address, node);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
for (const note of index.notes ?? []) take(localNode(note));
|
|
267
|
+
for (const [canonical, entry] of index.foreign?.index ?? [])
|
|
268
|
+
take(foreignNode(canonical, entry));
|
|
269
|
+
|
|
270
|
+
/** @type {Map<string, string[]>} */
|
|
271
|
+
const childrenOf = new Map();
|
|
272
|
+
for (const node of places.values()) {
|
|
273
|
+
for (const parent of node.parents) {
|
|
274
|
+
const list = childrenOf.get(parent);
|
|
275
|
+
if (list) list.push(node.shortcode);
|
|
276
|
+
else childrenOf.set(parent, [node.shortcode]);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** @type {Map<string, string[]>} */
|
|
281
|
+
const holdersOf = new Map();
|
|
282
|
+
for (const node of affiliations.values()) {
|
|
283
|
+
if (node.subType !== "polity") continue;
|
|
284
|
+
for (const domain of node.domains) {
|
|
285
|
+
const list = holdersOf.get(domain);
|
|
286
|
+
if (list) list.push(node.shortcode);
|
|
287
|
+
else holdersOf.set(domain, [node.shortcode]);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
found = { places, affiliations, childrenOf, holdersOf, byAddress };
|
|
292
|
+
CORPUS.set(index, found);
|
|
293
|
+
return found;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Every place below one, by `parents`.
|
|
298
|
+
*
|
|
299
|
+
* @param {Corpus} corpus - The corpus.
|
|
300
|
+
* @param {string} shortcode - Where to start.
|
|
301
|
+
* @returns {Set<string>} The descendants, the starting place excluded.
|
|
302
|
+
*/
|
|
303
|
+
function descendants(corpus, shortcode) {
|
|
304
|
+
const seen = new Set();
|
|
305
|
+
const queue = [...(corpus.childrenOf.get(shortcode) ?? [])];
|
|
306
|
+
while (queue.length) {
|
|
307
|
+
const next = queue.pop();
|
|
308
|
+
if (!next || seen.has(next)) continue;
|
|
309
|
+
seen.add(next);
|
|
310
|
+
queue.push(...(corpus.childrenOf.get(next) ?? []));
|
|
311
|
+
}
|
|
312
|
+
return seen;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Every place above one, by `parents`.
|
|
317
|
+
*
|
|
318
|
+
* @param {Corpus} corpus - The corpus.
|
|
319
|
+
* @param {string} shortcode - Where to start.
|
|
320
|
+
* @returns {string[]} The ancestors, nearest first, the starting place
|
|
321
|
+
* excluded and a cycle broken.
|
|
322
|
+
*/
|
|
323
|
+
function ancestors(corpus, shortcode) {
|
|
324
|
+
const seen = new Set();
|
|
325
|
+
const out = [];
|
|
326
|
+
const queue = [...(corpus.places.get(shortcode)?.parents ?? [])];
|
|
327
|
+
while (queue.length) {
|
|
328
|
+
const next = queue.shift();
|
|
329
|
+
if (!next || next === shortcode || seen.has(next)) continue;
|
|
330
|
+
const node = corpus.places.get(next);
|
|
331
|
+
if (!node) continue;
|
|
332
|
+
seen.add(next);
|
|
333
|
+
out.push(next);
|
|
334
|
+
queue.push(...node.parents);
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The polities holding a place, each counted once.
|
|
341
|
+
*
|
|
342
|
+
* A polity subordinate to another polity that holds the same place is already
|
|
343
|
+
* inside that polity's figure, so only the topmost of a chain counts.
|
|
344
|
+
*
|
|
345
|
+
* @param {Corpus} corpus - The corpus.
|
|
346
|
+
* @param {string} shortcode - The place.
|
|
347
|
+
* @returns {PopulationNode[]} The holders.
|
|
348
|
+
*/
|
|
349
|
+
function topHolders(corpus, shortcode) {
|
|
350
|
+
const holders = corpus.holdersOf.get(shortcode) ?? [];
|
|
351
|
+
const held = new Set(holders);
|
|
352
|
+
return holders
|
|
353
|
+
.map((name) => corpus.affiliations.get(name))
|
|
354
|
+
.filter(
|
|
355
|
+
/** @returns {node is PopulationNode} */
|
|
356
|
+
(node) => Boolean(node) && !(node?.parents ?? []).some((p) => held.has(p)),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The regions immediately inside a place.
|
|
362
|
+
*
|
|
363
|
+
* A place may name both its region and that region's continent as parents.
|
|
364
|
+
* It is counted under the nearer, so a region naming a second parent that is
|
|
365
|
+
* itself inside this place is left to that parent.
|
|
366
|
+
*
|
|
367
|
+
* @param {Corpus} corpus - The corpus.
|
|
368
|
+
* @param {string} shortcode - The enclosing place.
|
|
369
|
+
* @returns {PopulationNode[]} The regions.
|
|
370
|
+
*/
|
|
371
|
+
function nearestRegions(corpus, shortcode) {
|
|
372
|
+
const inside = descendants(corpus, shortcode);
|
|
373
|
+
const out = [];
|
|
374
|
+
for (const child of corpus.childrenOf.get(shortcode) ?? []) {
|
|
375
|
+
const node = corpus.places.get(child);
|
|
376
|
+
if (!node || node.subType !== "region") continue;
|
|
377
|
+
if (node.parents.some((p) => p !== shortcode && inside.has(p))) continue;
|
|
378
|
+
out.push(node);
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* A finding on the note's own `data.population`.
|
|
385
|
+
*
|
|
386
|
+
* @param {object} note - The note.
|
|
387
|
+
* @param {string} rule - The rule's name.
|
|
388
|
+
* @param {string} message - What it found, after the name.
|
|
389
|
+
* @returns {object} The finding.
|
|
390
|
+
*/
|
|
391
|
+
function atFigure(note, rule, message) {
|
|
392
|
+
return {
|
|
393
|
+
file: note.file,
|
|
394
|
+
...positionOfFrontmatterPath(note.raw ?? "", ["data", "population"]),
|
|
395
|
+
severity: "warning",
|
|
396
|
+
message: `${rule}: ${message}`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Check a place's population against the corpus around it.
|
|
402
|
+
*
|
|
403
|
+
* Declared on the `place` vocabulary beside `data.population`, so the lint
|
|
404
|
+
* runs it with the index every reference check resolves through. Without an
|
|
405
|
+
* index the questions cannot be asked and nothing is reported.
|
|
406
|
+
*
|
|
407
|
+
* Three rules, each reported on the note being linted: `over-held land` and
|
|
408
|
+
* `over-full region` on the place whose figure is exceeded, and
|
|
409
|
+
* `oversized settlement` on the settlement that exceeds one.
|
|
410
|
+
*
|
|
411
|
+
* @param {object} note - The note, as the link index hands it over.
|
|
412
|
+
* @param {object} [opts]
|
|
413
|
+
* @param {object} [opts.index] - The link index, holding the whole corpus.
|
|
414
|
+
* @returns {object[]} Findings.
|
|
415
|
+
*/
|
|
416
|
+
export function checkPopulation(note, { index } = {}) {
|
|
417
|
+
if (!index) return [];
|
|
418
|
+
const fm = note.fm ?? {};
|
|
419
|
+
if (String(fm.type ?? "") !== "place") return [];
|
|
420
|
+
const shortcode = String(fm.shortcode ?? "").toLowerCase();
|
|
421
|
+
if (!shortcode) return [];
|
|
422
|
+
const stated = figureOf(dataOf(fm).population);
|
|
423
|
+
if (stated === undefined) return [];
|
|
424
|
+
|
|
425
|
+
const corpus = corpusOf(index);
|
|
426
|
+
const self = corpus.places.get(shortcode);
|
|
427
|
+
const title = self?.title ?? shortcode;
|
|
428
|
+
const subType = self?.subType ?? String(fm.subType ?? "");
|
|
429
|
+
const allowed = stated * POPULATION_TOLERANCE;
|
|
430
|
+
const findings = [];
|
|
431
|
+
|
|
432
|
+
// A settlement is exempt: the polity holding a city-state counts the
|
|
433
|
+
// hinterland the city does not, so its figure is properly the larger.
|
|
434
|
+
if (subType !== "settlement") {
|
|
435
|
+
const holders = topHolders(corpus, shortcode);
|
|
436
|
+
const sum = holders.reduce((total, node) => total + (node.population ?? 0), 0);
|
|
437
|
+
if (sum > allowed) {
|
|
438
|
+
findings.push(
|
|
439
|
+
atFigure(
|
|
440
|
+
note,
|
|
441
|
+
"over-held land",
|
|
442
|
+
`the polities holding ${title} count ${counted(sum)} people against its ` +
|
|
443
|
+
`stated ${counted(stated)}`,
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const regions = nearestRegions(corpus, shortcode);
|
|
450
|
+
const inside = regions.reduce((total, node) => total + (node.population ?? 0), 0);
|
|
451
|
+
if (inside > allowed) {
|
|
452
|
+
findings.push(
|
|
453
|
+
atFigure(
|
|
454
|
+
note,
|
|
455
|
+
"over-full region",
|
|
456
|
+
`the regions inside ${title} count ${counted(inside)} people against its ` +
|
|
457
|
+
`stated ${counted(stated)}`,
|
|
458
|
+
),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (subType === "settlement") {
|
|
463
|
+
for (const name of ancestors(corpus, shortcode)) {
|
|
464
|
+
const above = corpus.places.get(name);
|
|
465
|
+
const room = above?.population;
|
|
466
|
+
if (room === undefined || stated <= room * POPULATION_TOLERANCE) continue;
|
|
467
|
+
findings.push(
|
|
468
|
+
atFigure(
|
|
469
|
+
note,
|
|
470
|
+
"oversized settlement",
|
|
471
|
+
`${title} holds ${counted(stated)}, more than the ${counted(room)} ` +
|
|
472
|
+
`stated by ${above?.title}`,
|
|
473
|
+
),
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return findings;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* A wikilink to a place or an affiliation followed by an approximate figure.
|
|
483
|
+
*
|
|
484
|
+
* The figure must carry `~`, which is how a note marks a number as an
|
|
485
|
+
* estimate. That is what separates a population from every other number a
|
|
486
|
+
* table puts beside a place — a distance, a stage count, a tally of days — and
|
|
487
|
+
* without it a routes table would be read as a census.
|
|
488
|
+
*
|
|
489
|
+
* The span between the link and the figure crosses neither a newline nor
|
|
490
|
+
* another wikilink, so a row's figure belongs to that row's link.
|
|
491
|
+
*
|
|
492
|
+
* **The figure is read whole or not at all.** A number carrying a magnitude
|
|
493
|
+
* letter, a decimal, a percent sign or a dash into a second number is prose
|
|
494
|
+
* about a figure rather than the figure — `~1M`, `~1.5M`, `~5%`, `~12–14M` —
|
|
495
|
+
* and reading its leading digits would compare `1` against a million. Those
|
|
496
|
+
* are left alone, so an aside stays an aside and the rule holds the tables
|
|
497
|
+
* that state a count exactly.
|
|
498
|
+
*
|
|
499
|
+
* @type {RegExp}
|
|
500
|
+
*/
|
|
501
|
+
const CITED =
|
|
502
|
+
/\[\[((?:place|affiliation)-[a-z0-9.]+)(?:\\?\|[^\]]*)?\]\]([^[\n]{0,80}?)~([0-9](?:[0-9,]*[0-9])?)(?![\w%]|[.,]\d|[-–—])/g;
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Check the populations a `doc` note cites against the notes they belong to.
|
|
506
|
+
*
|
|
507
|
+
* A reference page restates figures the place and affiliation notes own, and
|
|
508
|
+
* restated figures drift. Each is located at the figure it wrote, counting
|
|
509
|
+
* repeats so two rows carrying one number land on their own lines.
|
|
510
|
+
*
|
|
511
|
+
* A citation naming a note this package does not hold is skipped: a fetched
|
|
512
|
+
* index carries no figure, so there is nothing to disagree with.
|
|
513
|
+
*
|
|
514
|
+
* Declared as the `doc` vocabulary's type-level check.
|
|
515
|
+
*
|
|
516
|
+
* @param {object} note - The note, as the link index hands it over.
|
|
517
|
+
* @param {object} [opts]
|
|
518
|
+
* @param {object} [opts.index] - The link index, holding the whole corpus.
|
|
519
|
+
* @returns {object[]} Findings.
|
|
520
|
+
*/
|
|
521
|
+
export function checkCitedPopulations(note, { index } = {}) {
|
|
522
|
+
if (!index) return [];
|
|
523
|
+
if (String(note.fm?.type ?? "") !== "doc") return [];
|
|
524
|
+
const raw = String(note.raw ?? "");
|
|
525
|
+
const body = String(note.body ?? "");
|
|
526
|
+
if (!body) return [];
|
|
527
|
+
|
|
528
|
+
const corpus = corpusOf(index);
|
|
529
|
+
const offset = raw.length - body.length;
|
|
530
|
+
const findings = [];
|
|
531
|
+
|
|
532
|
+
CITED.lastIndex = 0;
|
|
533
|
+
for (const match of body.matchAll(CITED)) {
|
|
534
|
+
const [, address, , figure] = match;
|
|
535
|
+
const target = corpus.byAddress.get(address.toLowerCase());
|
|
536
|
+
if (!target || target.population === undefined) continue;
|
|
537
|
+
const claimed = Number(figure.replace(/,/g, ""));
|
|
538
|
+
if (!Number.isFinite(claimed) || claimed === target.population) continue;
|
|
539
|
+
|
|
540
|
+
// Which `~figure` in the file this is, so a number written twice is
|
|
541
|
+
// located twice rather than both findings landing on the first.
|
|
542
|
+
const needle = `~${figure}`;
|
|
543
|
+
const at = offset + (match.index ?? 0) + match[0].length - needle.length;
|
|
544
|
+
const occurrence = raw.slice(0, at).split(needle).length;
|
|
545
|
+
|
|
546
|
+
findings.push({
|
|
547
|
+
file: note.file,
|
|
548
|
+
...positionOfLiteral(raw, needle, occurrence),
|
|
549
|
+
severity: "warning",
|
|
550
|
+
message:
|
|
551
|
+
`disputed figure: ${target.title} is carried at ${counted(claimed)} here and ` +
|
|
552
|
+
`${counted(target.population)} on its own note`,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return findings;
|
|
557
|
+
}
|