@heroiclands/package-build 25.5.5 → 25.6.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/assets/styles/content-base.css +1 -1
  3. package/docs/api.md +24 -16
  4. package/docs/authoring/frontmatter.md +1 -1
  5. package/docs/authoring/links-and-markup.md +10 -9
  6. package/docs/content-format.md +40 -1
  7. package/docs/diagnostics.md +2 -2
  8. package/docs/guides/reference-pages.md +4 -1
  9. package/docs/guides/site.md +1 -1
  10. package/docs/reference/format-details.md +174 -129
  11. package/docs/reference/note-types.md +134 -121
  12. package/docs/reference/theme-pages.md +1 -0
  13. package/engine/content-format.yaml +9 -7
  14. package/engine/content-html.mjs +3 -2
  15. package/engine/content-index.mjs +23 -0
  16. package/engine/content-links.mjs +8 -0
  17. package/engine/content-render.mjs +2 -2
  18. package/engine/content-tables.mjs +12 -5
  19. package/engine/culture-reach.mjs +3 -0
  20. package/engine/declared-sections.mjs +100 -0
  21. package/engine/derived-sections.mjs +36 -2
  22. package/engine/dictionary-rows.mjs +2 -1
  23. package/engine/generated-sections.mjs +2 -0
  24. package/engine/holdings.mjs +107 -3
  25. package/engine/index-records.mjs +3 -1
  26. package/engine/index.mjs +1 -1
  27. package/engine/language-data.mjs +0 -1
  28. package/engine/language-rows.mjs +1 -3
  29. package/engine/language-terms.mjs +7 -10
  30. package/engine/map-dot.mjs +22 -0
  31. package/engine/markdown-blocks.mjs +23 -8
  32. package/engine/metadata-index.mjs +1 -0
  33. package/engine/note-addresses.mjs +16 -0
  34. package/engine/note-vocabulary.mjs +19 -8
  35. package/engine/office-holders.mjs +76 -0
  36. package/engine/page-breaks.mjs +36 -8
  37. package/engine/pdf-render.mjs +4 -3
  38. package/engine/place-kinds.mjs +106 -0
  39. package/engine/settlement-tables.mjs +410 -675
  40. package/engine/settlement-terms.mjs +118 -0
  41. package/engine/site-build.mjs +2 -4
  42. package/engine/sql-tables.mjs +101 -7
  43. package/engine/standing-terms.mjs +9 -5
  44. package/engine/standings.mjs +83 -31
  45. package/engine/unique-items.mjs +85 -0
  46. package/package.json +9 -4
  47. package/site-theme/data/banner-kinds.yaml +2 -0
  48. package/sohl/actors.mjs +4 -3
  49. package/types/engine/content-html.d.mts +2 -1
  50. package/types/engine/culture-reach.d.mts +3 -0
  51. package/types/engine/declared-sections.d.mts +30 -0
  52. package/types/engine/derived-sections.d.mts +5 -1
  53. package/types/engine/holdings.d.mts +34 -2
  54. package/types/engine/index-records.d.mts +2 -1
  55. package/types/engine/language-terms.d.mts +5 -1
  56. package/types/engine/markdown-blocks.d.mts +5 -1
  57. package/types/engine/office-holders.d.mts +33 -0
  58. package/types/engine/page-breaks.d.mts +8 -0
  59. package/types/engine/place-kinds.d.mts +18 -0
  60. package/types/engine/settlement-tables.d.mts +25 -158
  61. package/types/engine/settlement-terms.d.mts +53 -0
  62. package/types/engine/sql-tables.d.mts +35 -3
  63. package/types/engine/standing-terms.d.mts +3 -2
  64. package/types/engine/standings.d.mts +14 -2
  65. package/types/engine/unique-items.d.mts +12 -0
  66. package/vendor/markdownlint.mjs +855 -569
@@ -12,767 +12,502 @@
12
12
  */
13
13
 
14
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
- *
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 { positionOfFrontmatterPath } from "./diagnostics.mjs";
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
- /** The heading text a settlements table stands under, at level two. */
49
- export const SETTLEMENTS_HEADING = "Settlements";
50
-
51
- /**
52
- * How a region's countryside is held, each the tag a region carries to say so.
53
- *
54
- * `holder` is what the `Held by` cell names under that model. `person` says
55
- * whether that holder may be a person: only where land is held by a person or
56
- * a house may the cell link a `being`.
57
- *
58
- * **The tags stay open.** They are not a declared tag group: a group would
59
- * report every tag within a typo's distance of one — a region tagged `hellad`
60
- * reads as a near miss of `held` — and a misspelled model on a note with a
61
- * table is already reported, because the note then names no model at all.
62
- *
63
- * @type {ReadonlyArray<Readonly<{tag: string, holder: string, person: boolean}>>}
64
- */
65
- export const HOLDING_MODELS = Object.freeze([
66
- Object.freeze({ tag: "held", holder: "a person or a house", person: true }),
67
- Object.freeze({ tag: "endowed", holder: "an institution", person: false }),
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
- if (lines[0] === "---") {
263
- i = lines.indexOf("---", 1);
264
- i = i === -1 ? lines.length : i + 1;
265
- }
266
- let heading = 0;
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
- if (awaitingTail) awaitingTail = undefined;
277
- const closer = new RegExp(`^[ \\t]*${fence[2][0]}{${fence[2].length},}[ \\t]*$`);
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 head = HEADING.exec(line);
283
- if (head) {
284
- awaitingTail = undefined;
285
- if (head[1].length <= 2) {
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 (!inSection) continue;
292
- if (line.trim() === "") continue;
293
- if (line.trim().startsWith("|") && DELIMITER.test(lines[i + 1] ?? "")) {
294
- awaitingTail = undefined;
295
- const header = cellsOf(line).map((c) => c.text);
296
- const wellFormed = isDeclaredHeader(header);
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 { heading, tables };
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 = (value) => {
366
- if (typeof value === "string" && value.trim()) out.add(value.trim().toLowerCase());
91
+ const add = (v) => {
92
+ if (text(v)) out.add(text(v).toLowerCase());
367
93
  };
368
- if (typeof name === "string") add(name);
369
- else if (name && typeof name === "object") {
370
- add(name.full);
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 next = queue.shift();
423
- if (!next || seen.has(next)) continue;
424
- seen.add(next);
425
- const above = places.get(next);
426
- if (!above) continue;
427
- out.push(above);
428
- queue.push(...above.parents);
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
- * The corpus a link index describes, derived once.
435
- *
436
- * @param {object} index - The link index.
437
- * @returns {Corpus} The corpus.
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 (String(fm.type ?? note.type ?? "") !== "place") continue;
145
+ if (typeOf(note) !== "place") continue;
449
146
  const shortcode = String(fm.shortcode ?? "").toLowerCase();
450
147
  if (!shortcode) continue;
451
- const data =
452
- fm.data && typeof fm.data === "object" && !Array.isArray(fm.data) ? fm.data : {};
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
- shortcode,
456
- subType: String(fm.subType ?? ""),
457
- title: String(fm.name?.full ?? fm.name ?? shortcode),
159
+ key,
160
+ code: shortcode,
161
+ kind: fm.subType,
162
+ title: fm.name?.full ?? fm.name ?? shortcode,
458
163
  names: namesOf(fm),
459
- parents: shortcodesOf(data.parents),
460
- section: findSettlementTables(note.raw ?? ""),
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.subType !== "settlement") continue;
471
- const owner = ancestorsOf(places, node).find((above) => above.section.tables.length);
472
- if (!owner) continue;
473
- const list = covers.get(owner.shortcode);
474
- if (list) list.push(node);
475
- else covers.set(owner.shortcode, [node]);
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 regions = [...byNote.values()]
481
- .filter((node) => node.section.tables.length)
482
- .sort((a, b) => String(a.note.file).localeCompare(String(b.note.file), "en"));
483
- for (const region of regions) {
484
- for (const table of region.section.tables) {
485
- for (const { cells } of table.rows) {
486
- const [target] = targetsIn(cells.Name?.text ?? "");
487
- const hit = resolveTarget(index, target);
488
- if (!hit?.fm || typeOf(hit) !== "place") continue;
489
- const list = rowedIn.get(hit) ?? [];
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
- * A Market cell as a class, or `undefined`.
529
- *
530
- * @param {string} text - The cell.
531
- * @returns {import("./market-class.mjs").MarketClass|undefined} The class.
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 report = (rule, position, message) =>
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
- ...position,
221
+ ...location(path),
574
222
  severity: "error",
575
223
  message: `${rule}: ${message}`,
576
224
  });
577
- const at = (line, cell) => (cell ? { line, column: cell.column } : { line });
578
-
579
- const models = HOLDING_MODELS.filter((model) => hasTag(note.fm, model.tag));
580
- if (!models.length) {
581
- const tags = positionOfFrontmatterPath(note.raw ?? "", ["tags"], { key: true });
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
- "no holding model",
584
- tags.line ? tags : { line: section.heading },
585
- `a settlements table is written in a holding model, and this note carries none of ` +
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
- const covered = self ? (corpus.covers.get(self.shortcode) ?? []) : [];
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
- let readable = true;
593
-
594
- for (const table of section.tables) {
595
- if (!table.tailed) {
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
- "no tail",
598
- { line: table.last },
599
- "a sentence follows a settlements table, stating what the table leaves out",
283
+ "settlements shape",
284
+ at,
285
+ "a row carries only place, name, market, population, heldBy and for",
600
286
  );
601
- }
602
- if (!table.wellFormed) {
603
- readable = false;
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 columns",
606
- { line: table.line },
607
- `the header reads \`| ${table.header.join(" | ")} |\`; a settlements table's ` +
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
- const seen = new Set();
617
- for (const { line, cells } of table.rows) {
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
- "duplicate settlement",
660
- at(line, name),
661
- `${label} already has a row in this table`,
303
+ "unresolved settlement reference",
304
+ [...at, "place"],
305
+ `${ref(row.place)} does not resolve`,
662
306
  );
663
- } else if (settlement) {
664
- // The row belongs to the nearest region above the settlement
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
- "duplicate settlement",
678
- at(line, name),
679
- `${title} already has a row in ${corpus.byNote.get(home)?.title ?? home.file}`,
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
- if (settlement) rowed.add(settlement);
684
-
685
- const market = cells.Market;
686
- const marketEntry = classOf(market.text);
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
- "market class",
692
- at(line, market),
693
- named ?
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
- const people = cells.People;
701
- const figure = figureOf(people.text);
702
- if (figure === undefined) {
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
- "people figure",
705
- at(line, people),
706
- `"${people.text}" is not a figure; a People cell is a positive whole ` +
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
- if (settlement) {
712
- const data = settlement.fm.data ?? {};
713
- const stated = Number(data.population);
714
- const has = Number.isFinite(stated) && stated > 0;
715
- if (figure !== undefined && (!has || stated !== figure)) {
716
- report(
717
- "figure disagrees",
718
- at(line, people),
719
- `${title} is carried at ${counted(figure)} here and ` +
720
- (has ?
721
- `${counted(stated)} on its own note`
722
- : "its own note states none"),
723
- );
724
- }
725
- const own = marketClass(data.market);
726
- if (marketEntry && (!own || own.value !== marketEntry.value)) {
727
- report(
728
- "market disagrees",
729
- at(line, market),
730
- `${title} is carried as \`${cellOf(marketEntry)}\` here and ` +
731
- (own ?
732
- `\`${cellOf(own)}\` on its own note`
733
- : "its own note states none"),
734
- );
735
- }
736
- }
737
-
738
- const holder = cells["Held by"];
739
- if (!holder.text) {
740
- report("no holder", at(line, holder), `${label} names no holder`);
741
- continue;
742
- }
743
- for (const target of targetsIn(holder.text)) {
744
- const hit = resolveTarget(index, target);
745
- if (!hit) continue;
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 (!HOLDER_TYPES.includes(type)) {
399
+ if (!["affiliation", "being"].includes(type))
748
400
  report(
749
401
  "not a holder",
750
- at(line, holder),
751
- `${target} is a ${type || "document"}; a holder is an affiliation ` +
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
- if (readable) {
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
- { line: section.tables[0].line },
772
- `${node.title} (\`place-${node.shortcode}\`) stands in this region and has no row`,
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
- return findings;
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
  }