@heroiclands/package-build 25.5.5 → 25.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +25 -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/encoding-core.md +43 -5
  9. package/docs/guides/reference-pages.md +4 -1
  10. package/docs/guides/site.md +1 -1
  11. package/docs/reference/format-details.md +239 -194
  12. package/docs/reference/note-types.md +187 -174
  13. package/docs/reference/theme-pages.md +1 -0
  14. package/engine/content-format.yaml +9 -7
  15. package/engine/content-html.mjs +3 -2
  16. package/engine/content-index.mjs +23 -0
  17. package/engine/content-links.mjs +8 -0
  18. package/engine/content-render.mjs +2 -2
  19. package/engine/content-tables.mjs +12 -5
  20. package/engine/culture-reach.mjs +3 -0
  21. package/engine/declared-sections.mjs +100 -0
  22. package/engine/derived-sections.mjs +36 -2
  23. package/engine/dictionary-rows.mjs +2 -1
  24. package/engine/encoding-template.mjs +28 -0
  25. package/engine/encoding-terms.mjs +20 -12
  26. package/engine/encoding.mjs +147 -17
  27. package/engine/generated-sections.mjs +2 -0
  28. package/engine/holdings.mjs +107 -3
  29. package/engine/index-records.mjs +3 -1
  30. package/engine/index.mjs +1 -1
  31. package/engine/language-data.mjs +0 -1
  32. package/engine/language-rows.mjs +1 -3
  33. package/engine/language-terms.mjs +7 -10
  34. package/engine/map-dot.mjs +22 -0
  35. package/engine/markdown-blocks.mjs +23 -8
  36. package/engine/metadata-index.mjs +1 -0
  37. package/engine/note-addresses.mjs +16 -0
  38. package/engine/note-vocabulary.mjs +19 -8
  39. package/engine/office-holders.mjs +76 -0
  40. package/engine/page-breaks.mjs +36 -8
  41. package/engine/pdf-render.mjs +4 -3
  42. package/engine/place-kinds.mjs +106 -0
  43. package/engine/settlement-tables.mjs +410 -675
  44. package/engine/settlement-terms.mjs +118 -0
  45. package/engine/site-build.mjs +2 -4
  46. package/engine/sql-tables.mjs +101 -7
  47. package/engine/standing-terms.mjs +9 -5
  48. package/engine/standings.mjs +83 -31
  49. package/engine/unique-items.mjs +85 -0
  50. package/package.json +9 -4
  51. package/site-theme/data/banner-kinds.yaml +2 -0
  52. package/sohl/actors.mjs +4 -3
  53. package/types/engine/content-html.d.mts +2 -1
  54. package/types/engine/content-scripts.d.mts +6 -0
  55. package/types/engine/culture-reach.d.mts +3 -0
  56. package/types/engine/declared-sections.d.mts +30 -0
  57. package/types/engine/derived-sections.d.mts +5 -1
  58. package/types/engine/encoding-lexicon.d.mts +6 -0
  59. package/types/engine/encoding-template.d.mts +6 -0
  60. package/types/engine/encoding.d.mts +12 -0
  61. package/types/engine/holdings.d.mts +34 -2
  62. package/types/engine/index-records.d.mts +2 -1
  63. package/types/engine/language-terms.d.mts +5 -1
  64. package/types/engine/markdown-blocks.d.mts +5 -1
  65. package/types/engine/office-holders.d.mts +33 -0
  66. package/types/engine/page-breaks.d.mts +8 -0
  67. package/types/engine/place-kinds.d.mts +18 -0
  68. package/types/engine/settlement-tables.d.mts +25 -158
  69. package/types/engine/settlement-terms.d.mts +53 -0
  70. package/types/engine/sql-tables.d.mts +35 -3
  71. package/types/engine/standing-terms.d.mts +3 -2
  72. package/types/engine/standings.d.mts +14 -2
  73. package/types/engine/unique-items.d.mts +12 -0
  74. package/vendor/markdownlint.mjs +855 -569
@@ -218,6 +218,7 @@ something to show:
218
218
  | Within | `within` | a place | the places whose `data.parents` names it, a paragraph to each kind of place |
219
219
  | Governed by | `governedby` | a place | the affiliation its `data.government` names, with that affiliation's kind |
220
220
  | Governed places | `governedplaces` | an affiliation | the places whose `data.government` names it, a paragraph to each kind of place |
221
+ | Office holders | `officeholders` | an affiliation | each office a living being holds, one line each, with its holders |
221
222
  | Chronology | `chronology` | a place | the [event views](format-details.md#event-views) |
222
223
  | Events | `events` | a being, an affiliation or a people | the event views |
223
224
  | Accounts | `accounts` | an affiliation, a place or a people | the event views |
@@ -96,6 +96,7 @@ types:
96
96
  - durability
97
97
  - made
98
98
  - lost
99
+ - unique
99
100
  subTypes: []
100
101
  armorlocation:
101
102
  data:
@@ -114,6 +115,7 @@ types:
114
115
  - durability
115
116
  - made
116
117
  - lost
118
+ - unique
117
119
  - quantity
118
120
  - potency
119
121
  - strength
@@ -130,6 +132,7 @@ types:
130
132
  - durability
131
133
  - made
132
134
  - lost
135
+ - unique
133
136
  - capacity
134
137
  subTypes: []
135
138
  miscgear:
@@ -141,6 +144,7 @@ types:
141
144
  - durability
142
145
  - made
143
146
  - lost
147
+ - unique
144
148
  - quantity
145
149
  subTypes: []
146
150
  mystery:
@@ -189,6 +193,7 @@ types:
189
193
  - durability
190
194
  - made
191
195
  - lost
196
+ - unique
192
197
  - quantity
193
198
  subTypes:
194
199
  - none
@@ -268,6 +273,7 @@ types:
268
273
  - durability
269
274
  - made
270
275
  - lost
276
+ - unique
271
277
  subTypes: []
272
278
  lore:
273
279
  data:
@@ -323,6 +329,7 @@ types:
323
329
  - culture
324
330
  - population
325
331
  - government
332
+ - settlements
326
333
  - market
327
334
  - borders
328
335
  - routes
@@ -343,10 +350,12 @@ types:
343
350
  subTypes:
344
351
  - world
345
352
  - region
353
+ - district
346
354
  - settlement
347
355
  - site
348
356
  - structure
349
357
  - feature
358
+ - route
350
359
  - celestial
351
360
  scenario:
352
361
  data:
@@ -355,7 +364,6 @@ types:
355
364
  - cast
356
365
  - factions
357
366
  - follows
358
- - status
359
367
  - party.size
360
368
  - party.archetypes
361
369
  subTypes:
@@ -740,12 +748,6 @@ vocabularies:
740
748
  - "4"
741
749
  - "5"
742
750
  - "6"
743
- holdingModel:
744
- - held
745
- - endowed
746
- - registered
747
- - kin
748
- - polis
749
751
  bearing:
750
752
  - N
751
753
  - NE
@@ -64,7 +64,8 @@ import { positionInBody, splitFrontmatter } from "./diagnostics.mjs";
64
64
  * markdown's own angle brackets out of it: an autolink is `<https://…>`, and
65
65
  * `https` is followed by `:` rather than whitespace or `>`, so the pattern
66
66
  * stops. A comparison written in prose — `a < b` — has no name after the
67
- * bracket at all.
67
+ * bracket at all. A doubled `<<` opens a quotation, so its second bracket
68
+ * never starts an HTML tag.
68
69
  *
69
70
  * Attributes are consumed as "anything but a bracket", deliberately loosely: a
70
71
  * finding names the tag, and a pattern that tried to parse attribute syntax
@@ -73,7 +74,7 @@ import { positionInBody, splitFrontmatter } from "./diagnostics.mjs";
73
74
  *
74
75
  * @type {RegExp}
75
76
  */
76
- export const HTML_TAG = /<\/?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>]*)?\/?>/g;
77
+ export const HTML_TAG = /(?<!<)<\/?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>]*)?\/?>/g;
77
78
 
78
79
  /**
79
80
  * What a note carrying raw HTML is told.
@@ -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);
@@ -21,6 +21,12 @@ const scalar = (value) =>
21
21
  !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
22
22
  const base = (value) => value.normalize("NFD").replace(/\p{M}/gu, "");
23
23
  const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
24
+ /** Each diaeresis vowel and the plain vowel whose mapping writes it. */
25
+ const DIAERESIS_VOWELS = Object.freeze(
26
+ Object.fromEntries(
27
+ [..."aeiouAEIOU"].map((plain) => [(plain + "\u0308").normalize("NFC"), plain]),
28
+ ),
29
+ );
24
30
  const codeName = /^[A-Za-z0-9_]+$/;
25
31
 
26
32
  /**
@@ -339,12 +345,27 @@ export function templateProgram(data, options, fault) {
339
345
  ]),
340
346
  ];
341
347
  if (!Array.isArray(accepts)) fault("accepts", "accepts must be a list of characters or ranges");
348
+ // A diaeresis vowel no source names is written with its plain vowel's rows,
349
+ // and decoding restores it by rule.
350
+ const named = [...mappings.map(([from]) => from), ...contextual.map((r) => r.from)];
351
+ const derived =
352
+ rules.diaeresis?.allowed === true ?
353
+ Object.fromEntries(
354
+ Object.entries(DIAERESIS_VOWELS).filter(
355
+ ([letter, plain]) =>
356
+ named.includes(plain) &&
357
+ !named.some((from) => from.includes(letter)) &&
358
+ !separators.some((row) => row.from.includes(letter)),
359
+ ),
360
+ )
361
+ : undefined;
342
362
  return {
343
363
  definition: {
344
364
  name: "encoding-note",
345
365
  fonts: [data.font],
346
366
  accepts: [
347
367
  ...accepts,
368
+ ...Object.keys(derived ?? {}),
348
369
  ...STANDARD_ROWS.flatMap((r) => [...(Array.isArray(r) ? r[0] : r.from)]),
349
370
  ],
350
371
  separator: " ",
@@ -366,6 +387,13 @@ export function templateProgram(data, options, fault) {
366
387
  maxVowelGroup: Math.max(0, ...vowelGroups.map((group) => [...group].length)),
367
388
  stress,
368
389
  diaeresis: rules.diaeresis?.allowed === false,
390
+ apart:
391
+ derived ?
392
+ Object.freeze({
393
+ letters: Object.freeze(derived),
394
+ groups: Object.freeze(vowelGroups),
395
+ })
396
+ : undefined,
369
397
  grave: rules["grave-accent"]?.allowed === false,
370
398
  }),
371
399
  },
@@ -89,18 +89,26 @@ export const ENCODING_RULE_FIELDS = Object.freeze([
89
89
  describe: "The complete authored group inventory.",
90
90
  },
91
91
  ]),
92
- ...["diaeresis", "grave-accent"].map((name) =>
93
- block(name, `Whether ${name} mappings are allowed.`, [
94
- {
95
- name: "allowed",
96
- kind: "boolean",
97
- shape: "true or false",
98
- required: true,
99
- describe:
100
- "False forbids the accent; true permits explicitly mapped and accepted characters.",
101
- },
102
- ]),
103
- ),
92
+ block("diaeresis", "Whether diaeresis vowels are allowed, and how they are written.", [
93
+ {
94
+ name: "allowed",
95
+ kind: "boolean",
96
+ shape: "true or false",
97
+ required: true,
98
+ describe:
99
+ "False forbids the diaeresis; true derives plain-vowel mappings and restores splits where a declared group's native mapping distinguishes joined and separate vowels. Exact conversion refuses an unrecoverable derived group split; canonical conversion keeps its conventional reading.",
100
+ },
101
+ ]),
102
+ block("grave-accent", "Whether grave-accent mappings are allowed.", [
103
+ {
104
+ name: "allowed",
105
+ kind: "boolean",
106
+ shape: "true or false",
107
+ required: true,
108
+ describe:
109
+ "False forbids the accent; true permits explicitly mapped and accepted characters.",
110
+ },
111
+ ]),
104
112
  {
105
113
  name: "contextual-mappings",
106
114
  kind: "list",