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