@heroiclands/package-build 25.5.4 → 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 (108) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +7 -0
  3. package/assets/styles/content-base.css +1 -1
  4. package/bin/content-commands.mjs +32 -8
  5. package/docs/api.md +81 -32
  6. package/docs/authoring/frontmatter.md +1 -1
  7. package/docs/authoring/links-and-markup.md +59 -10
  8. package/docs/commands.md +60 -17
  9. package/docs/configuration.md +3 -2
  10. package/docs/content-format.md +50 -1
  11. package/docs/diagnostics.md +17 -0
  12. package/docs/guides/module-project.md +11 -3
  13. package/docs/guides/reference-pages.md +4 -1
  14. package/docs/guides/site.md +13 -3
  15. package/docs/guides/writing-in-invented-scripts.md +5 -2
  16. package/docs/reference/format-details.md +233 -138
  17. package/docs/reference/note-types.md +165 -152
  18. package/docs/reference/theme-pages.md +1 -0
  19. package/e2e.mjs +1 -1
  20. package/engine/base-compiler.mjs +2 -0
  21. package/engine/compile-corpus.mjs +6 -2
  22. package/engine/content-charset.mjs +1 -1
  23. package/engine/content-format.yaml +9 -7
  24. package/engine/content-html.mjs +3 -2
  25. package/engine/content-index.mjs +23 -0
  26. package/engine/content-links.mjs +8 -0
  27. package/engine/content-render.mjs +14 -2
  28. package/engine/content-scripts.mjs +44 -6
  29. package/engine/content-session.mjs +11 -7
  30. package/engine/content-spans.mjs +3 -4
  31. package/engine/content-tables.mjs +12 -5
  32. package/engine/content-verse.mjs +52 -10
  33. package/engine/core-report.mjs +27 -18
  34. package/engine/culture-reach.mjs +3 -0
  35. package/engine/declared-sections.mjs +100 -0
  36. package/engine/derived-sections.mjs +36 -2
  37. package/engine/dictionary-rows.mjs +2 -1
  38. package/engine/generated-sections.mjs +2 -0
  39. package/engine/holdings.mjs +107 -3
  40. package/engine/index-records.mjs +3 -1
  41. package/engine/index.mjs +1 -1
  42. package/engine/language-data.mjs +0 -1
  43. package/engine/language-names.mjs +53 -23
  44. package/engine/language-rows.mjs +1 -3
  45. package/engine/language-terms.mjs +7 -10
  46. package/engine/map-dot.mjs +22 -0
  47. package/engine/markdown-blocks.mjs +23 -8
  48. package/engine/markdownlint-run.mjs +1 -1
  49. package/engine/markup-validation.mjs +2 -0
  50. package/engine/metadata-index.mjs +1 -0
  51. package/engine/note-addresses.mjs +16 -0
  52. package/engine/note-vocabulary.mjs +29 -13
  53. package/engine/npc-generate.mjs +52 -7
  54. package/engine/office-holders.mjs +76 -0
  55. package/engine/page-breaks.mjs +143 -0
  56. package/engine/pdf-build.mjs +12 -4
  57. package/engine/pdf-render.mjs +12 -2
  58. package/engine/place-kinds.mjs +106 -0
  59. package/engine/preview-index.mjs +4 -2
  60. package/engine/settlement-tables.mjs +410 -675
  61. package/engine/settlement-terms.mjs +118 -0
  62. package/engine/site-build.mjs +14 -7
  63. package/engine/site-preview.mjs +3 -0
  64. package/engine/site-render.mjs +18 -4
  65. package/engine/site-root.mjs +10 -1
  66. package/engine/site-serve-http.mjs +151 -0
  67. package/engine/site-serve.mjs +196 -126
  68. package/engine/sql-tables.mjs +101 -7
  69. package/engine/standing-terms.mjs +9 -5
  70. package/engine/standings.mjs +83 -31
  71. package/engine/unique-items.mjs +85 -0
  72. package/engine/verse-metre.mjs +50 -5
  73. package/engine/verse-regions.mjs +16 -0
  74. package/package.json +9 -4
  75. package/site-theme/data/banner-kinds.yaml +2 -0
  76. package/sohl/actors.mjs +4 -3
  77. package/types/e2e.d.mts +1 -1
  78. package/types/engine/content-html.d.mts +2 -1
  79. package/types/engine/content-scripts.d.mts +9 -1
  80. package/types/engine/content-verse.d.mts +11 -2
  81. package/types/engine/core-report.d.mts +6 -2
  82. package/types/engine/culture-reach.d.mts +3 -0
  83. package/types/engine/declared-sections.d.mts +30 -0
  84. package/types/engine/derived-sections.d.mts +5 -1
  85. package/types/engine/holdings.d.mts +34 -2
  86. package/types/engine/index-records.d.mts +2 -1
  87. package/types/engine/language-names.d.mts +18 -6
  88. package/types/engine/language-terms.d.mts +5 -1
  89. package/types/engine/markdown-blocks.d.mts +5 -1
  90. package/types/engine/npc-generate.d.mts +3 -0
  91. package/types/engine/office-holders.d.mts +33 -0
  92. package/types/engine/page-breaks.d.mts +38 -0
  93. package/types/engine/pdf-render.d.mts +2 -0
  94. package/types/engine/place-kinds.d.mts +18 -0
  95. package/types/engine/settlement-tables.d.mts +25 -158
  96. package/types/engine/settlement-terms.d.mts +53 -0
  97. package/types/engine/site-build.d.mts +3 -1
  98. package/types/engine/site-render.d.mts +6 -2
  99. package/types/engine/site-root.d.mts +3 -1
  100. package/types/engine/site-serve-http.d.mts +1 -0
  101. package/types/engine/site-serve.d.mts +12 -6
  102. package/types/engine/sql-tables.d.mts +35 -3
  103. package/types/engine/standing-terms.d.mts +3 -2
  104. package/types/engine/standings.d.mts +14 -2
  105. package/types/engine/unique-items.d.mts +12 -0
  106. package/types/engine/verse-metre.d.mts +2 -1
  107. package/types/engine/verse-regions.d.mts +8 -0
  108. package/vendor/markdownlint.mjs +855 -569
@@ -79,7 +79,7 @@ function idsOf(word) {
79
79
  * @param {Iterable<Record<string, any>>} records - Index records.
80
80
  * @param {{language?: string}} [options] - A language to report alone, by its
81
81
  * name, shortcode or reference slug whether or not it states `data.core`.
82
- * @returns {{concepts: number, languages: Array<{name: string, language: string, core: boolean, total: number, covered: number, missing: Array<{group: string, count: number, total: number, concepts: Array<{id: string, gloss: string}>}>}>}|{problem: string, about: string}}
82
+ * @returns {{concepts: number, notCovered: string[], languages: Array<{name: string, language: string, core: boolean, total: number, covered: number, missing: Array<{group: string, count: number, total: number, concepts: Array<{id: string, gloss: string}>}>}>}|{problem: string, about: string}}
83
83
  * The report, or the problem when there is no concept list or the language
84
84
  * asked for is none.
85
85
  */
@@ -113,6 +113,13 @@ export function coreReport(records, { language } = {}) {
113
113
  const groups = [...new Set(concepts.map((concept) => concept.group))];
114
114
  return {
115
115
  concepts: concepts.length,
116
+ notCovered:
117
+ language === undefined ?
118
+ languages
119
+ .filter((record) => record.data?.core !== true)
120
+ .map(nameOf)
121
+ .sort((a, b) => a.localeCompare(b))
122
+ : [],
116
123
  languages: chosen.map((record) => {
117
124
  const have = new Set();
118
125
  const forms = new Set();
@@ -154,24 +161,26 @@ export function coreReport(records, { language } = {}) {
154
161
  * A core report as text: one block per language, its missing concepts under
155
162
  * their groups with counts, each concept's id and gloss tab-separated.
156
163
  *
157
- * @param {{concepts: number, languages: Array<object>}} report - A report from {@link coreReport}.
164
+ * The default report ends with the names of languages it does not measure.
165
+ *
166
+ * @param {{concepts: number, notCovered: string[], languages: Array<object>}} report - A report from {@link coreReport}.
158
167
  * @returns {string} The text, ending in a newline.
159
168
  */
160
169
  export function coreReportText(report) {
161
- if (!report.languages.length) return "No language states `data.core: true`.\n";
162
- return report.languages
163
- .map((one) => {
164
- if (!one.missing.length)
165
- return `${one.name} (${one.language}): all ${one.total} core concepts have a word\n`;
166
- const lines = [
167
- `${one.name} (${one.language}): ${one.total - one.covered} of ${one.total} core concepts have no word`,
168
- ];
169
- for (const group of one.missing) {
170
- lines.push(` ${group.group}: ${group.count} of ${group.total}`);
171
- for (const concept of group.concepts)
172
- lines.push(` ${concept.id}\t${concept.gloss}`);
173
- }
174
- return `${lines.join("\n")}\n`;
175
- })
176
- .join("\n");
170
+ const blocks = report.languages.map((one) => {
171
+ if (!one.missing.length)
172
+ return `${one.name} (${one.language}): all ${one.total} core concepts have a word\n`;
173
+ const lines = [
174
+ `${one.name} (${one.language}): ${one.total - one.covered} of ${one.total} core concepts have no word`,
175
+ ];
176
+ for (const group of one.missing) {
177
+ lines.push(` ${group.group}: ${group.count} of ${group.total}`);
178
+ for (const concept of group.concepts) lines.push(` ${concept.id}\t${concept.gloss}`);
179
+ }
180
+ return `${lines.join("\n")}\n`;
181
+ });
182
+ if (!blocks.length) blocks.push("No language states `data.core: true`.\n");
183
+ if (report.notCovered.length)
184
+ blocks.push(`Not covered (no data.core): ${report.notCovered.join(", ")}\n`);
185
+ return blocks.join("\n");
177
186
  }
@@ -345,6 +345,9 @@ export function noteTerms(index, note, entry) {
345
345
  * in a tree that describes peoples. Its ranks and offices then have no
346
346
  * culture associations for term queries.
347
347
  *
348
+ * The note's `data.culture` is the culture the content index resolved for it:
349
+ * its own, else the one its `data.parents` chain agrees on.
350
+ *
348
351
  * @param {object} note - An `affiliation` note.
349
352
  * @param {{index?: object}} [context]
350
353
  * @returns {object[]} One warning, at the note's `type`, or none.
@@ -0,0 +1,100 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+ /** Place declared content in its authored section, or create that section. */
14
+ import { scanHeadingAttributes } from "./heading-attributes.mjs";
15
+ import { scanBlocks } from "./content-blocks.mjs";
16
+
17
+ /** Authored headings, excluding literal examples and named containers. */
18
+ export function declaredHeadings(markdown, syntax) {
19
+ const blocks = (syntax ? syntax(markdown).blocks : scanBlocks(markdown)).blocks;
20
+ return scanHeadingAttributes(markdown).headings.filter(
21
+ (heading) =>
22
+ !blocks.some(
23
+ (block) => heading.line - 1 >= block.start && heading.line - 1 <= block.end,
24
+ ),
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Insert indexed content at its heading while preserving subsequent source lines.
30
+ * @param {string} markdown - Expanded body.
31
+ * @param {Array<{slug:string,heading:string,markdown:string}>} [sections] - Declared content.
32
+ * @param {{authored?:string,lineMap?:object[],generatedFrom?:number,syntax?:Function}} [context] - Captured source mappings.
33
+ * @returns {{markdown:string,lineMap:object[],generatedFrom?:number}} Expanded body and locations.
34
+ */
35
+ export function insertDeclaredSections(
36
+ markdown,
37
+ sections = [],
38
+ { authored = markdown, lineMap, generatedFrom, syntax } = {},
39
+ ) {
40
+ const lines = String(markdown ?? "").split("\n");
41
+ const locations = lineMap ? [...lineMap] : lines.map((_, line) => ({ line, generated: false }));
42
+ if (!sections.length)
43
+ return {
44
+ markdown: lines.join("\n"),
45
+ lineMap: locations,
46
+ ...(generatedFrom === undefined ? {} : { generatedFrom }),
47
+ };
48
+ const headings = declaredHeadings(authored, syntax)
49
+ .map((heading) => ({
50
+ ...heading,
51
+ sourceLine: heading.line - 1,
52
+ line: locations.findIndex(
53
+ (location, at) =>
54
+ location.line === heading.line - 1 &&
55
+ !location.generated &&
56
+ (generatedFrom === undefined || at < generatedFrom),
57
+ ),
58
+ }))
59
+ .filter((h) => h.line >= 0);
60
+ const edits = [];
61
+ for (const section of sections) {
62
+ const own = headings.find((h) => h.id === section.slug || h.text === section.heading);
63
+ const next = own && headings.find((h) => h.line > own.line && h.level <= own.level);
64
+ const at = next?.line ?? generatedFrom ?? lines.length;
65
+ if (own && !own.id) {
66
+ const line = lines[own.line].replace(/\s*#*\s*$/, "");
67
+ lines[own.line] =
68
+ /\{[^{}]*\}$/.test(line) ?
69
+ line.replace(/\}$/, ` #${section.slug}}`)
70
+ : line + ` {#${section.slug}}`;
71
+ }
72
+ edits.push({
73
+ at,
74
+ source: own?.sourceLine ?? Math.max(0, String(authored).split("\n").length - 1),
75
+ lines: [
76
+ "",
77
+ ...(own ?
78
+ section.markdown
79
+ : `# ${section.heading} {#${section.slug}}\n\n${section.markdown}`
80
+ ).split("\n"),
81
+ "",
82
+ ],
83
+ });
84
+ }
85
+ for (const edit of edits.sort((a, b) => b.at - a.at)) {
86
+ lines.splice(edit.at, 0, ...edit.lines);
87
+ locations.splice(
88
+ edit.at,
89
+ 0,
90
+ ...edit.lines.map(() => ({ line: edit.source, generated: true })),
91
+ );
92
+ if (generatedFrom !== undefined && edit.at <= generatedFrom)
93
+ generatedFrom += edit.lines.length;
94
+ }
95
+ return {
96
+ markdown: lines.join("\n"),
97
+ lineMap: locations,
98
+ ...(generatedFrom === undefined ? {} : { generatedFrom }),
99
+ };
100
+ }
@@ -21,10 +21,12 @@
21
21
  * | Within | `within` | a place | the places whose `parents` names it, grouped by kind |
22
22
  * | Governed by | `governedby` | a place | the affiliation its `data.government` names |
23
23
  * | Governed places | `governedplaces` | an affiliation | the places naming it as government, grouped by kind |
24
+ * | Office holders | `officeholders` | an affiliation | each declared office's living holders, from the beings' own `office` |
24
25
  * | In song and story | `insongandstory` | any note | the works naming it, or one of its events, in `subjects` |
25
26
  * | From here | `fromhere` | a place on a border or a route | the map from it, as an image |
26
27
  *
27
- * The lists are the derivations {@link module:engine/holdings} and
28
+ * The lists are the derivations {@link module:engine/holdings},
29
+ * {@link module:engine/office-holders} and
28
30
  * {@link module:engine/literature-works} make, over this package's records and
29
31
  * every fetched index. An entry naming a note that publishes a page is a
30
32
  * wikilink with an empty label, so each surface resolves it its own way and
@@ -39,6 +41,7 @@
39
41
  */
40
42
 
41
43
  import fs from "node:fs";
44
+ import { assertSettlementRecords, settlementMarkdown } from "./settlement-tables.mjs";
42
45
  import path from "node:path";
43
46
 
44
47
  import {
@@ -52,6 +55,8 @@ import { isNoteRecord } from "./index-records.mjs";
52
55
  import { foreignWorksNodes, worksNode, worksPages } from "./literature-works.mjs";
53
56
  import { buildMaps, relatedPlaces } from "./map-build.mjs";
54
57
  import { mapWorld } from "./map-places.mjs";
58
+ import { officeBodyKey, officeHolders } from "./office-holders.mjs";
59
+ import { renderAddress } from "./address.mjs";
55
60
  import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
56
61
 
57
62
  /**
@@ -65,10 +70,12 @@ import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
65
70
  export const PLACE_KIND_LABELS = Object.freeze({
66
71
  world: "Worlds",
67
72
  region: "Regions",
73
+ district: "Districts",
68
74
  settlement: "Settlements",
69
75
  site: "Sites",
70
76
  structure: "Structures",
71
77
  feature: "Features",
78
+ route: "Routes",
72
79
  celestial: "Celestial places",
73
80
  });
74
81
 
@@ -166,6 +173,22 @@ function section(slug, content) {
166
173
  return `${sectionHeading(slug)}\n\n${content}\n`;
167
174
  }
168
175
 
176
+ /**
177
+ * A body's offices, one line each, with the beings holding each.
178
+ *
179
+ * @param {Map<string, object[]>} held - Office → its holders' records.
180
+ * @returns {string} Markdown.
181
+ */
182
+ function officeLines(held) {
183
+ const holder = (record) =>
184
+ record.address ?
185
+ `[[${renderAddress({ ...record.address.canonical, system: "note" })}|]]`
186
+ : plain(titleOf(record));
187
+ return [...held]
188
+ .map(([office, holders]) => `- **${plain(office)}:** ${holders.map(holder).join(", ")}`)
189
+ .join("\n");
190
+ }
191
+
169
192
  /**
170
193
  * The note's name, as the derivations title an entry.
171
194
  *
@@ -257,12 +280,18 @@ export function drawMapsFrom(records, { outDir, foreignIndex = new Map(), config
257
280
  * prepared results are keyed.
258
281
  * @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes.
259
282
  * @param {object} [opts.config] - The resolved configuration.
283
+ * @param {(file:string)=>string|undefined} [opts.rawOf] - Captured source locators.
284
+ * @param {object[]} [opts.all=records] - Complete records for settlement validation.
260
285
  * @param {string} [opts.only] - One note file to give sections to; absent,
261
286
  * every note.
262
287
  * @returns {Map<string, Map<string, string>>} Note file to its sections, each
263
288
  * keyed by its slug, for every note given at least one.
264
289
  */
265
- export function derivedSections(records, { fileOf, foreignIndex = new Map(), config, only }) {
290
+ export function derivedSections(
291
+ records,
292
+ { fileOf, foreignIndex = new Map(), config, only, rawOf, all = records },
293
+ ) {
294
+ assertSettlementRecords(all, { fileOf, foreignIndex, rawOf });
266
295
  const notes = records.filter((record) => isNoteRecord(record) && record.shortcode);
267
296
  const pages = notes.filter((record) => record.address);
268
297
  const nodeOf = (record) => ({
@@ -286,6 +315,7 @@ export function derivedSections(records, { fileOf, foreignIndex = new Map(), con
286
315
  { types: new Set(Object.keys(NOTE_VOCABULARY)) },
287
316
  );
288
317
  const mapped = new Set(placesWithMaps(notes, { foreignIndex, config }));
318
+ const offices = officeHolders(notes);
289
319
 
290
320
  const out = new Map();
291
321
  for (const record of pages) {
@@ -297,6 +327,8 @@ export function derivedSections(records, { fileOf, foreignIndex = new Map(), con
297
327
  if (content && !authoredSection(record, slug))
298
328
  sections.set(slug, section(slug, content));
299
329
  };
330
+ const settlements = settlementMarkdown(record);
331
+ if (settlements) sections.set("settlements", section("settlements", settlements));
300
332
  if (lists.contains?.length) add("within", groupedPlaces(lists.contains));
301
333
  if (lists.governed_by?.length)
302
334
  add(
@@ -305,6 +337,8 @@ export function derivedSections(records, { fileOf, foreignIndex = new Map(), con
305
337
  );
306
338
  if (lists.governed_places?.length)
307
339
  add("governedplaces", groupedPlaces(lists.governed_places));
340
+ const held = record.type === "affiliation" && offices.get(officeBodyKey(record));
341
+ if (held) add("officeholders", officeLines(held));
308
342
  const written = works.get(key)?.works ?? [];
309
343
  if (written.length)
310
344
  add(
@@ -49,6 +49,7 @@
49
49
  */
50
50
 
51
51
  import { pluralOf, pluralByRule } from "./word-inflections.mjs";
52
+ import { standingOffices } from "./standings.mjs";
52
53
  export { pluralOf, pluralByRule } from "./word-inflections.mjs";
53
54
  import { createHash } from "node:crypto";
54
55
  import { parseAddress, renderAddress } from "./address.mjs";
@@ -393,7 +394,7 @@ export function dictionaryAndTermRows(records, all = records) {
393
394
  : [];
394
395
  add("office", title, isMap(office) ? office.description : office, [
395
396
  ...holders,
396
- ...membersOf(body, (standing) => standing.office === title),
397
+ ...membersOf(body, (standing) => standingOffices(standing).includes(title)),
397
398
  ]);
398
399
  }
399
400
  governance.set(body, rows);
@@ -57,9 +57,11 @@ export const CHRONOLOGY_SLUG = "chronology";
57
57
  */
58
58
  export const GENERATED_SECTIONS = Object.freeze(
59
59
  [
60
+ { slug: "settlements", heading: "Settlements" },
60
61
  { slug: "within", heading: "Within" },
61
62
  { slug: "governedby", heading: "Governed by" },
62
63
  { slug: "governedplaces", heading: "Governed places" },
64
+ { slug: "officeholders", heading: "Office holders" },
63
65
  { slug: CHRONOLOGY_SLUG, heading: "Chronology" },
64
66
  { slug: "events", heading: "Events" },
65
67
  { slug: "accounts", heading: "Accounts" },
@@ -14,7 +14,9 @@
14
14
  /**
15
15
  * Derive containment and government lists from place facts. Geography comes
16
16
  * only from `data.parents`; government comes only from `data.government`.
17
- * Neither graph expands through geographic or affiliation ancestors. Government identity preserves the Address package, type and shortcode.
17
+ * Neither graph expands through affiliation ancestors, and government expands
18
+ * through geographic ones only for a district stating none, which the content
19
+ * index resolves before these lists read it. Government identity preserves the Address package, type and shortcode.
18
20
  * Its system normalizes to `note` because Item and journal documents share one page.
19
21
  * The lists become a note's generated **Within**, **Governed by** and
20
22
  * **Governed places** sections — see {@link module:engine/derived-sections}.
@@ -255,6 +257,106 @@ export function holdingsPages(nodes, { governmentNodes = [] } = {}) {
255
257
  return lists;
256
258
  }
257
259
 
260
+ /* --------------------------------------------------------------------- */
261
+ /* A district's government */
262
+ /* --------------------------------------------------------------------- */
263
+
264
+ /** Districts whose government the index supplied from a parent. */
265
+ const inheritedGovernment = new WeakSet();
266
+
267
+ /**
268
+ * Whether a record's `data.government` came from a parent rather than its own
269
+ * note.
270
+ *
271
+ * @param {object} record - An index record.
272
+ * @returns {boolean} True for a district that inherited its government.
273
+ */
274
+ export function governmentInherited(record) {
275
+ return inheritedGovernment.has(record);
276
+ }
277
+
278
+ /**
279
+ * Give each district that states no government its parent's.
280
+ *
281
+ * A `place/district` falls under the government of the place it stands
282
+ * within. One stating no `data.government` key takes the value of its nearest
283
+ * parent that states the key, walking `data.parents` breadth first, in the
284
+ * order written, through any parent that states none, and never visiting a
285
+ * place twice. An explicit value on the district, `null` (complete anarchy)
286
+ * included, is its own and is kept; a parent's explicit `null` is inherited as
287
+ * anarchy. Every reader of the index — the Governed by and Governed places
288
+ * sections, the "missing government" advisory, a consumer of the published
289
+ * index — then reads the effective government at `data.government`.
290
+ *
291
+ * @param {object[]} records - Content-index records, edited in place.
292
+ * @param {object} [opts]
293
+ * @param {(key: string) => object|undefined} [opts.foreignRecord] - A fetched
294
+ * index's record by canonical Address, for a parent in a dependency.
295
+ * @returns {void}
296
+ */
297
+ export function inheritDistrictGovernments(records, { foreignRecord } = {}) {
298
+ const keyOf = (pkg, shortcode) =>
299
+ renderAddress({
300
+ package: String(pkg ?? "local"),
301
+ system: "note",
302
+ type: "place",
303
+ shortcode: String(shortcode).toLowerCase(),
304
+ });
305
+ const places = new Map();
306
+ for (const record of records) {
307
+ if (record?.type !== "place" || record.documents || !record.shortcode) continue;
308
+ const key = keyOf(record.package, record.shortcode);
309
+ if (!places.has(key)) places.set(key, record);
310
+ }
311
+ const parentKeys = (record) => {
312
+ const parents = record?.data?.parents ?? record?.parents;
313
+ const list =
314
+ Array.isArray(parents) ? parents
315
+ : parents == null ? []
316
+ : [parents];
317
+ return list
318
+ .map((value) => {
319
+ if (isAddressTuple(value)) return keyOf(value.package, value.shortcode);
320
+ const tuple =
321
+ readCanonicalKey(String(value ?? "")) ??
322
+ parseAddress(value, {
323
+ package: record.package ?? "local",
324
+ system: "note",
325
+ type: "place",
326
+ types: new Set(["place"]),
327
+ });
328
+ return isAddressTuple(tuple) ? keyOf(tuple.package, tuple.shortcode) : "";
329
+ })
330
+ .filter(Boolean);
331
+ };
332
+ const stated = (record) => {
333
+ const data = record?.data && typeof record.data === "object" ? record.data : record;
334
+ return data && Object.hasOwn(data, "government") && !inheritedGovernment.has(record) ?
335
+ { value: data.government }
336
+ : undefined;
337
+ };
338
+ for (const district of places.values()) {
339
+ if (district.subType !== "district") continue;
340
+ const data = district.data && typeof district.data === "object" ? district.data : {};
341
+ if (Object.hasOwn(data, "government")) continue;
342
+ const seen = new Set([keyOf(district.package, district.shortcode)]);
343
+ const queue = parentKeys(district);
344
+ let found;
345
+ while (queue.length && !found) {
346
+ const key = queue.shift();
347
+ if (seen.has(key)) continue;
348
+ seen.add(key);
349
+ const parent = places.get(key) ?? foreignRecord?.(key);
350
+ if (!parent) continue;
351
+ found = stated(parent);
352
+ if (!found) queue.push(...parentKeys(parent));
353
+ }
354
+ if (!found) continue;
355
+ district.data = { ...data, government: found.value };
356
+ inheritedGovernment.add(district);
357
+ }
358
+ }
359
+
258
360
  /* --------------------------------------------------------------------- */
259
361
  /* The lint */
260
362
  /* --------------------------------------------------------------------- */
@@ -264,8 +366,10 @@ export function holdingsPages(nodes, { governmentNodes = [] } = {}) {
264
366
  * Missing, null or zero population establishes no inhabited population.
265
367
  * Property presence distinguishes omitted government from explicit null,
266
368
  * which means complete anarchy. Non-null values are validated by the declared
267
- * affiliation reference field. Neither geographic containment nor tenure
268
- * supplies this fact, and the rule is independent of place subtype.
369
+ * affiliation reference field. Tenure never supplies this fact, and
370
+ * containment supplies it only to a district, which the content index gives
371
+ * its nearest parent's government (see {@link inheritDistrictGovernments}), so
372
+ * a district under a governed place is never advised.
269
373
  *
270
374
  * @param {object} note - The note with parsed frontmatter and raw source.
271
375
  * @returns {object[]} Located warning findings.
@@ -42,7 +42,8 @@ import { isAddressTuple } from "./address.mjs";
42
42
  * since a note declaring its own is a hard error — and it matches what
43
43
  * the content-table expander puts on the same field, so a query reads the same
44
44
  * value from either. `file` namespaces the note's place in the tree, again
45
- * matching the expander's `file.*`.
45
+ * matching the expander's `file.*`. `officeHolders` lists, on a body, the
46
+ * living beings whose own standing names each of its offices.
46
47
  *
47
48
  * Both are checked rather than assumed: `folder` is real frontmatter on most
48
49
  * notes, so the neighbouring names are close enough to a real key that a silent
@@ -68,6 +69,7 @@ export const DERIVED_KEYS = Object.freeze([
68
69
  "citations",
69
70
  "names",
70
71
  "runs",
72
+ "officeHolders",
71
73
  ]);
72
74
 
73
75
  /**
package/engine/index.mjs CHANGED
@@ -103,7 +103,7 @@ export * as populations from "./populations.mjs";
103
103
  /** What trade a settlement supports: the six-step market scale, and the check that holds a value to it. */
104
104
  export * as marketClass from "./market-class.mjs";
105
105
 
106
- /** A region's settlements table: the holding models, the columns, and the check that holds a table to them. */
106
+ /** A region's settlements table: its rows, the columns, and the check that holds a table to them. */
107
107
  export * as settlementTables from "./settlement-tables.mjs";
108
108
 
109
109
  /** The places a map is drawn from, read from the content index, and what `parents` says about them. */
@@ -1071,7 +1071,6 @@ export function checkLanguageData(note, { index, types } = {}) {
1071
1071
  for (const kind of SCOPE_KINDS) {
1072
1072
  listOf(scope[kind]).forEach((value, i) => {
1073
1073
  const path = ["data", "scope", kind, i];
1074
- if (kind === "paths") return;
1075
1074
  if (kind === "types") {
1076
1075
  if (types && typeof value === "string" && !types.includes(value))
1077
1076
  findings.push(finding(path, "error", `\`${value}\` is not a note type`));
@@ -29,11 +29,11 @@
29
29
  * tongue whose forms differ by gender states a map of gender words to a
30
30
  * pattern or a list of them. A gender with no pattern of its own takes every
31
31
  * pattern stated.
32
- * - `given` — given names, one list per gender word of the closed gender
33
- * vocabulary. `female` and `male` are required. `{given}` draws from the
34
- * person's gender's list, or from every given list together where that gender
35
- * has none; `{given.<gender>}` draws from that gender's list whatever the
36
- * person's gender, as a patronymic draws a father's name.
32
+ * - `given` — one list of names for everyone, or a map of gender words to
33
+ * lists. The map requires `female` and `male`. `{given}` draws from the shared
34
+ * list, the person's gender's list, or every given list together where that
35
+ * gender has none. `{given.<gender>}` draws from the shared list or that
36
+ * gender's list whatever the person's gender, as a patronymic draws a father's name.
37
37
  * - `en` — English names, in groups of lists: `en.epithet` is a list a pattern
38
38
  * draws from as `{en.epithet}`, as `"{given} the {en.epithet}"` does. English
39
39
  * names are never encoded in the tongue's script or checked against its
@@ -73,7 +73,7 @@ const LIST_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
73
73
  const PLACEHOLDER = /^([A-Za-z][A-Za-z0-9_]*)(?:\.([A-Za-z][A-Za-z0-9_]*))?$/;
74
74
 
75
75
  /**
76
- * The genders every `data.names.given` states a list for.
76
+ * The genders a mapped `data.names.given` states a list for.
77
77
  *
78
78
  * A function rather than a `const`: `note-vocabulary.mjs` reads it while the
79
79
  * module graph is still evaluating.
@@ -317,8 +317,12 @@ export function namesProblems(names, lookVocabulary) {
317
317
 
318
318
  const given = names[GIVEN_LIST];
319
319
  if (given === undefined || given === null) shape([], "`data.names` must state `given`", true);
320
+ else if (Array.isArray(given)) checkList([GIVEN_LIST]);
320
321
  else if (!isPlainMap(given))
321
- shape([GIVEN_LIST], `\`${label([GIVEN_LIST])}\` should be a map of genders to lists`);
322
+ shape(
323
+ [GIVEN_LIST],
324
+ `\`${label([GIVEN_LIST])}\` should be a list of names or a map of genders to lists`,
325
+ );
322
326
  else {
323
327
  const genders = genderWords(lookVocabulary);
324
328
  for (const gender of requiredGivenGenders()) {
@@ -428,7 +432,18 @@ export function namesProblems(names, lookVocabulary) {
428
432
  }
429
433
  named.add(part.list);
430
434
  if (part.gender !== undefined) {
431
- if (givenMap[part.gender] === undefined || givenMap[part.gender] === null)
435
+ if (Array.isArray(given)) {
436
+ if (!genders.includes(part.gender)) {
437
+ const guess = nearest(part.gender, genders);
438
+ problem(
439
+ path,
440
+ `${JSON.stringify(part.gender)} is not a gender; ` +
441
+ `\`{${placeholderText(part)}}\` in \`${label(path)}\` takes ` +
442
+ genders.join(", ") +
443
+ (guess ? `. Did you mean "${guess}"?` : ""),
444
+ );
445
+ }
446
+ } else if (givenMap[part.gender] === undefined || givenMap[part.gender] === null)
432
447
  problem(
433
448
  path,
434
449
  `\`{${placeholderText(part)}}\` in \`${label(path)}\` names no list: ` +
@@ -476,7 +491,7 @@ export function namesProblems(names, lookVocabulary) {
476
491
  * keys cannot: a pattern that does not parse, a placeholder naming no list, a
477
492
  * list no pattern names, a list name a placeholder cannot spell, an empty list
478
493
  * of patterns or of names, an empty name, a pattern or given list keyed by no
479
- * gender, and a missing `female` or `male` list. Each is located at the pattern, list, entry or key at fault.
494
+ * gender, and a missing `female` or `male` list in a gender map. Each is located at the pattern, list, entry or key at fault.
480
495
  *
481
496
  * @param {object} note - The note: `file`, `raw` and parsed `fm`.
482
497
  * @param {Readonly<Record<string, readonly {value: string}[]>>} lookVocabulary -
@@ -534,8 +549,8 @@ export function namesFindings(note, lookVocabulary) {
534
549
  }
535
550
 
536
551
  /**
537
- * The given names a gender draws from: its own list, or every given list
538
- * together where it has none.
552
+ * The given names a gender draws from: the shared list, its own list, or
553
+ * every given list together where it has none.
539
554
  *
540
555
  * @param {object} names - Checked `data.names`.
541
556
  * @param {string} gender - A gender word.
@@ -543,6 +558,7 @@ export function namesFindings(note, lookVocabulary) {
543
558
  */
544
559
  export function givenNames(names, gender) {
545
560
  const given = names[GIVEN_LIST];
561
+ if (Array.isArray(given)) return given;
546
562
  if (Array.isArray(given[gender])) return given[gender];
547
563
  return [...new Set(Object.values(given).filter(Array.isArray).flat())];
548
564
  }
@@ -551,29 +567,42 @@ export function givenNames(names, gender) {
551
567
  function listFor(names, part, gender) {
552
568
  if (part.sub !== undefined) return names[part.list][part.sub];
553
569
  if (part.list !== GIVEN_LIST) return names[part.list];
554
- if (part.gender !== undefined) return names[GIVEN_LIST][part.gender];
570
+ if (part.gender !== undefined) return givenNames(names, part.gender);
555
571
  return givenNames(names, gender);
556
572
  }
557
573
 
558
574
  /**
559
- * A name drawn from a language's lists: one of the gender's patterns, where it
560
- * has more than one, then each placeholder an even draw from its list.
575
+ * A name's selected literal and placeholder parts. One of the gender's
576
+ * patterns is selected first, then each placeholder is an even draw from its
577
+ * source list.
561
578
  *
562
579
  * @param {object} names - Checked `data.names`.
563
580
  * @param {string} gender - A gender word.
564
581
  * @param {() => number} next - A stream of numbers in `[0, 1)`.
565
- * @returns {string} The name.
582
+ * @returns {(PatternPart & {text: string})[]} The selected parts in pattern order.
566
583
  */
567
- export function drawName(names, gender, next) {
584
+ export function drawNameParts(names, gender, next) {
568
585
  const patterns = patternsFor(names, gender);
569
586
  const pattern =
570
587
  patterns.length > 1 ? patterns[Math.floor(next() * patterns.length)] : patterns[0];
571
- return parsePattern(pattern)
572
- .parts.map((part) => {
573
- if ("literal" in part) return part.literal;
574
- const list = listFor(names, part, gender);
575
- return list[Math.floor(next() * list.length)];
576
- })
588
+ return parsePattern(pattern).parts.map((part) => {
589
+ if ("literal" in part) return { ...part, text: part.literal };
590
+ const list = listFor(names, part, gender);
591
+ return { ...part, text: list[Math.floor(next() * list.length)] };
592
+ });
593
+ }
594
+
595
+ /**
596
+ * A name drawn from a language's lists.
597
+ *
598
+ * @param {object} names - Checked `data.names`.
599
+ * @param {string} gender - A gender word.
600
+ * @param {() => number} next - A stream of numbers in `[0, 1)`.
601
+ * @returns {string} The name.
602
+ */
603
+ export function drawName(names, gender, next) {
604
+ return drawNameParts(names, gender, next)
605
+ .map((part) => part.text)
577
606
  .join("");
578
607
  }
579
608
 
@@ -640,7 +669,8 @@ export function nameMisfit(full, names, gender) {
640
669
  const text = nfc(full);
641
670
  const given = names[GIVEN_LIST];
642
671
  const own = givenNames(names, gender).map(nfc);
643
- const every = Object.values(given).filter(Array.isArray).flat().map(nfc);
672
+ const every = (
673
+ Array.isArray(given) ? given : Object.values(given).filter(Array.isArray).flat()).map(nfc);
644
674
  const lookup = (forGiven) => (part) =>
645
675
  part.list === GIVEN_LIST && part.gender === undefined ?
646
676
  forGiven
@@ -413,14 +413,12 @@ export function inScope(record, address, scope) {
413
413
  const wanted = values(kind);
414
414
  return strings(list).some((one) => wanted.has(one));
415
415
  };
416
- const file = typeof record.file?.path === "string" ? record.file.path : "";
417
416
  return (
418
417
  values("addresses").has(address) ||
419
418
  any("folders", data.packFolder) ||
420
419
  any("cultures", data.culture) ||
421
420
  any("lore", data.lore) ||
422
- any("parents", data.parents) ||
423
- strings(scope.paths).some((prefix) => file.startsWith(prefix))
421
+ any("parents", data.parents)
424
422
  );
425
423
  }
426
424