@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
@@ -141,6 +141,8 @@ import { EXPRESSION, renderMarkdownExpressions } from "./markdown-expressions.mj
141
141
  import { scanFigures } from "./content-figures.mjs";
142
142
  import { eventNoteIndex } from "./event-fields.mjs";
143
143
  import { derivedSections } from "./derived-sections.mjs";
144
+ import { officeBodyKey, officeHolders } from "./office-holders.mjs";
145
+ import { inheritDistrictGovernments } from "./holdings.mjs";
144
146
  // One reader for a note's anchors, shared with the link checker and with the
145
147
  // builds that emit a link. Re-exported because this is where callers
146
148
  // have always addressed it.
@@ -1311,6 +1313,8 @@ export function collectContentIndex(
1311
1313
  parsed,
1312
1314
  });
1313
1315
  resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems, parsed });
1316
+ // A district stating no government falls under its parent's.
1317
+ inheritDistrictGovernments(records, { foreignRecord });
1314
1318
  const cultureNotes = records
1315
1319
  .filter((record) => isNoteRecord(record) && record.type === "lore")
1316
1320
  .map((record) => ({
@@ -1333,6 +1337,24 @@ export function collectContentIndex(
1333
1337
  for (const [note, inherited] of inheritance.values)
1334
1338
  if (Object.keys(inherited).length) note.fm.data = { ...note.fm.data, ...inherited };
1335
1339
 
1340
+ // An office's holders are the living beings naming it, read once every
1341
+ // being is recorded. A public body lists only public beings, as its page
1342
+ // does.
1343
+ phase("index office holders");
1344
+ const publicHolders = officeHolders(records.filter((record) => !isGmNote(record)));
1345
+ const allHolders = officeHolders(records);
1346
+ for (const record of records) {
1347
+ if (!isNoteRecord(record) || record.type !== "affiliation") continue;
1348
+ const held = (isGmNote(record) ? allHolders : publicHolders).get(officeBodyKey(record));
1349
+ if (!held) continue;
1350
+ record.officeHolders = [...held].map(([office, holders]) => ({
1351
+ office,
1352
+ holders: holders.map((being) =>
1353
+ readCanonicalKey(canonicalKey(being.package, "note", "being", being.shortcode)),
1354
+ ),
1355
+ }));
1356
+ }
1357
+
1336
1358
  // Automatic lexical sections require only the recorded frontmatter. Their
1337
1359
  // published anchors and journal targets therefore use the same pure body
1338
1360
  // projection as the surfaces, without making the index emitter async.
@@ -1924,6 +1946,7 @@ export function emitContentIndex({ contentBase, outDir, config, publication } =
1924
1946
  record.data?.events?.length,
1925
1947
  ) ||
1926
1948
  derivedSections(notes, {
1949
+ rawOf: (file) => parsed.get(file)?.raw,
1927
1950
  fileOf: (record) => noteFile(tree, record),
1928
1951
  config: resolved,
1929
1952
  foreignIndex: loadForeignIndexes(resolved, [contentPackage]).index,
@@ -56,6 +56,7 @@ import { readWikilink } from "./wikilink-syntax.mjs";
56
56
  import { encodeAddresses } from "./address-values.mjs";
57
57
  import fs from "node:fs";
58
58
  import { cultureRecordProblems, cultureInheritedFields } from "./culture-parents.mjs";
59
+ import { governmentInherited } from "./holdings.mjs";
59
60
  import path from "node:path";
60
61
 
61
62
  import { matchAllOutsideCode } from "./code-fences.mjs";
@@ -248,6 +249,13 @@ export function buildLinkIndex(
248
249
  if (typeof record.languageCode === "string") note.languageCode = record.languageCode;
249
250
  const hierarchyProblems = cultureRecordProblems(record);
250
251
  note.inheritedCultureFields = cultureInheritedFields(record);
252
+ // A district's government taken from its parent is the parent's
253
+ // authored value, checked where it is written, not here.
254
+ if (governmentInherited(record))
255
+ note.inheritedCultureFields = new Set([
256
+ ...(note.inheritedCultureFields ?? []),
257
+ "government",
258
+ ]);
251
259
  if (hierarchyProblems)
252
260
  note.reportedCultureParents = new Set(
253
261
  hierarchyProblems
@@ -40,11 +40,11 @@
40
40
  *
41
41
  * A note reaches {@link Renderer#render} with its index-dependent constructs
42
42
  * already resolved by {@link module:engine/content-expand.expandContent}.
43
+ * Valid authored page breaks are omitted before these stages.
44
+ *
43
45
  * {@link Renderer#prepare} then writes the constructs markdown-it has no
44
46
  * syntax for as HTML between blank lines, in this order:
45
47
  *
46
- * Valid authored page breaks are omitted before these stages.
47
- *
48
48
  * 1. **Marked text** — a run's language, face and mapping, from the
49
49
  * configuration's script notes.
50
50
  * 2. **Captioned items** — each figure's wrapper and label. Its number, kind
@@ -14,6 +14,7 @@
14
14
  import { FENCE_LINE } from "./code-fences.mjs";
15
15
  import { PAGE_LIST_LANGUAGE } from "./page-lists.mjs";
16
16
  import { appendGeneratedSections } from "./generated-sections.mjs";
17
+ import { insertDeclaredSections } from "./declared-sections.mjs";
17
18
  import { insertLexicalSections } from "./lexical-sections.mjs";
18
19
 
19
20
  /**
@@ -129,8 +130,8 @@ export function expandContentTables(markdown, { source = "", sqlTables, pageList
129
130
  source,
130
131
  directive: block.join("\n"),
131
132
  reason: failure,
132
- line: i,
133
- column: indent.length + 1,
133
+ line: prepared?.line ?? i,
134
+ column: prepared?.column ?? indent.length + 1,
134
135
  });
135
136
  block.forEach((value, offset) => emit(value, i + offset));
136
137
  i = close;
@@ -149,11 +150,17 @@ export function expandContentTables(markdown, { source = "", sqlTables, pageList
149
150
  if (close + 1 < lines.length && lines[close + 1].trim()) emit("", i, true);
150
151
  i = close;
151
152
  }
153
+ const declared = insertDeclaredSections(out.join("\n"), sqlTables?.declared, {
154
+ authored,
155
+ lineMap,
156
+ generatedFrom,
157
+ syntax: sqlTables?.syntax,
158
+ });
152
159
  return {
153
- ...insertLexicalSections(out.join("\n"), sqlTables?.lexical, {
160
+ ...insertLexicalSections(declared.markdown, sqlTables?.lexical, {
154
161
  authored,
155
- lineMap,
156
- generatedFrom,
162
+ lineMap: declared.lineMap,
163
+ generatedFrom: declared.generatedFrom,
157
164
  syntax: sqlTables?.syntax,
158
165
  }),
159
166
  errors,
@@ -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`));
@@ -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
 
@@ -152,8 +152,12 @@ export const JOIN_MEETINGS = Object.freeze(["C+C", "V+C", "V+V"]);
152
152
  */
153
153
  export const JOIN_RULES = Object.freeze(["merge", "insert", "drop", "double"]);
154
154
 
155
- /** What a written mark says of its letter. */
156
- export const MARK_KINDS = Object.freeze(["stress", "length", "apart"]);
155
+ /**
156
+ * What a written mark says of its letter: `stress` falls on it, `length` holds
157
+ * it longer, `apart` says it apart from its neighbour, and `quality` changes
158
+ * which vowel is said.
159
+ */
160
+ export const MARK_KINDS = Object.freeze(["stress", "length", "apart", "quality"]);
157
161
 
158
162
  /** How many of a mark a word may carry: `one` at most, or `any` number. */
159
163
  export const MARK_LIMITS = Object.freeze(["one", "any"]);
@@ -320,13 +324,6 @@ export const SCOPE_FIELDS = Object.freeze([
320
324
  entries: Object.freeze({ kind: "address", shape: "an Address" }),
321
325
  describe: "These notes themselves.",
322
326
  }),
323
- Object.freeze({
324
- name: "paths",
325
- kind: "list",
326
- shape: "a list of content paths",
327
- entries: Object.freeze({ kind: "string", shape: "a path such as `Regions/Aelwyth/`" }),
328
- describe: "Every note whose file sits below one of these paths of the content tree.",
329
- }),
330
327
  Object.freeze({
331
328
  name: "types",
332
329
  kind: "list",
@@ -1926,7 +1923,7 @@ export const LANGUAGE_DATA_FIELDS = Object.freeze([
1926
1923
  Object.freeze({
1927
1924
  name: "scope",
1928
1925
  kind: "map",
1929
- shape: "a map — `{ folders?, cultures?, lore?, parents?, addresses?, paths?, types?, skip? }`",
1926
+ shape: "a map — `{ folders?, cultures?, lore?, parents?, addresses?, types?, skip? }`",
1930
1927
  fields: SCOPE_FIELDS,
1931
1928
  describe: "The notes the register covers. A language skill note only.",
1932
1929
  }),
@@ -131,6 +131,26 @@ export const STYLE = Object.freeze({
131
131
  penwidth: 2.0,
132
132
  extraStyle: "rounded",
133
133
  },
134
+ district: {
135
+ shape: "box",
136
+ fill: "#C5E1A5",
137
+ border: DEFAULT_BORDER,
138
+ fontsize: 9,
139
+ width: 0.8,
140
+ height: 0.4,
141
+ penwidth: 1.0,
142
+ extraStyle: "rounded,dashed",
143
+ },
144
+ route: {
145
+ shape: "parallelogram",
146
+ fill: "#FFE082",
147
+ border: DEFAULT_BORDER,
148
+ fontsize: 9,
149
+ width: 0.9,
150
+ height: 0.4,
151
+ penwidth: 1.0,
152
+ extraStyle: "",
153
+ },
134
154
  settlement: {
135
155
  shape: "ellipse",
136
156
  fill: "#A5D6A7",
@@ -187,10 +207,12 @@ export const STYLE = Object.freeze({
187
207
  const LEGEND = Object.freeze([
188
208
  ["world", "the root note"],
189
209
  ["region", "continent, region, sub-region"],
210
+ ["district", "quarter, ward, shire"],
190
211
  ["settlement", "city, town, village"],
191
212
  ["site", "ruin, landmark, dungeon, wonder"],
192
213
  ["feature", "river, mountain range, sea"],
193
214
  ["structure", "a single building"],
215
+ ["route", "road, pass, sea lane"],
194
216
  ["polity", "affiliation, dashed edge to its region"],
195
217
  ]);
196
218