@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
@@ -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
 
@@ -25,35 +25,50 @@
25
25
  */
26
26
 
27
27
  import MarkdownIt from "markdown-it";
28
+ import footnotePlugin from "markdown-it-footnote";
28
29
  import { verseDivPlugin } from "./verse-regions.mjs";
29
30
 
30
31
  /** The renderer's block grammar, with inline parsing and its passes switched off. */
31
32
  const parser = new MarkdownIt({ html: true }).use(verseDivPlugin);
32
- parser.core.ruler.disable(["inline", "linkify", "replacements", "smartquotes", "text_join"]);
33
+ const footnoteParser = new MarkdownIt({ html: true }).use(footnotePlugin).use(verseDivPlugin);
34
+ for (const projection of [parser, footnoteParser])
35
+ projection.core.ruler.disable([
36
+ "inline",
37
+ "linkify",
38
+ "replacements",
39
+ "smartquotes",
40
+ "text_join",
41
+ ]);
42
+ // Keep definition containers and their original maps in place for scanners.
43
+ footnoteParser.core.ruler.disable("footnote_tail");
33
44
 
34
45
  /** How many recent sources keep their tokens. */
35
46
  const RECENT = 16;
36
47
 
37
48
  /** @type {Map<string, import("markdown-it").Token[]>} */
38
49
  const recent = new Map();
50
+ const recentFootnotes = new Map();
39
51
 
40
52
  /**
41
53
  * The block tokens of a Markdown source. The array is shared between callers
42
54
  * and must not be modified; an `inline` token carries no `children`.
43
55
  *
44
56
  * @param {unknown} source - Markdown.
57
+ * @param {object} [options] - Optional block projection.
58
+ * @param {boolean} [options.footnotes=false] - Include footnote definition containers.
45
59
  * @returns {readonly import("markdown-it").Token[]} The block tokens, in order.
46
60
  */
47
- export function blockTokens(source) {
61
+ export function blockTokens(source, { footnotes = false } = {}) {
48
62
  const text = String(source ?? "");
49
- const hit = recent.get(text);
63
+ const cache = footnotes ? recentFootnotes : recent;
64
+ const hit = cache.get(text);
50
65
  if (hit) {
51
- recent.delete(text);
52
- recent.set(text, hit);
66
+ cache.delete(text);
67
+ cache.set(text, hit);
53
68
  return hit;
54
69
  }
55
- const tokens = parser.parse(text, { verseSource: text });
56
- recent.set(text, tokens);
57
- if (recent.size > RECENT) recent.delete(recent.keys().next().value);
70
+ const tokens = (footnotes ? footnoteParser : parser).parse(text, { verseSource: text });
71
+ cache.set(text, tokens);
72
+ if (cache.size > RECENT) cache.delete(cache.keys().next().value);
58
73
  return tokens;
59
74
  }
@@ -669,7 +669,7 @@ function protectLintVerse(source) {
669
669
  };
670
670
  for (const region of verseRegions(syntax)) {
671
671
  if (!region.closed) continue;
672
- referencesInVerse(region.body, parseVerse(region.body, { footnotes }));
672
+ referencesInVerse(region.logicalBody, parseVerse(region.logicalBody, { footnotes }));
673
673
  ranges.push([region.bodyStart, starts[region.end]]);
674
674
  for (let line = region.start + 1; line < region.end; line++) {
675
675
  bodyLines.add(line + 1);
@@ -11,6 +11,7 @@
11
11
  * SPDX-License-Identifier: GPL-3.0-or-later
12
12
  */
13
13
 
14
+ import { scanPageBreaks } from "./page-breaks.mjs";
14
15
  import { scanBlocks } from "./content-blocks.mjs";
15
16
  import { scanAlerts } from "./content-alerts.mjs";
16
17
  import { scanRuns } from "./content-scripts.mjs";
@@ -74,6 +75,7 @@ export function markupFindings(source, file, syntax, runs) {
74
75
  const errors = [
75
76
  ...(scanned?.blocks ?? scanBlocks(body)).errors,
76
77
  ...(scanned?.alerts ?? scanAlerts(body)).errors,
78
+ ...scanPageBreaks(body).errors,
77
79
  ...verseErrors,
78
80
  ...(marked?.errors ?? []),
79
81
  ...shortcodeErrors(body),
@@ -507,6 +507,7 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
507
507
  }
508
508
  : {}),
509
509
  population: record.data?.population ?? undefined,
510
+ market: record.data?.market ?? undefined,
510
511
  ...(Object.hasOwn(record.data ?? {}, "government") ?
511
512
  { government: record.data.government }
512
513
  : {}),
@@ -43,6 +43,22 @@ export {
43
43
 
44
44
  /** Structured positions whose Address is nested inside a declared container. */
45
45
  export const STRUCTURED_ADDRESSES = Object.freeze([
46
+ {
47
+ noteType: "place",
48
+ path: ["data", "settlements", "rows", "*", "place"],
49
+ shape: "value",
50
+ type: "place",
51
+ accepts: ["place"],
52
+ lenient: true,
53
+ },
54
+ {
55
+ noteType: "place",
56
+ path: ["data", "settlements", "rows", "*", "heldBy"],
57
+ shape: "value",
58
+ type: "affiliation",
59
+ accepts: ["affiliation", "being"],
60
+ lenient: true,
61
+ },
46
62
  { path: ["data", "seeAlso", "*", "address"], shape: "value", lenient: true },
47
63
  {
48
64
  path: ["packFolder"],
@@ -104,6 +104,8 @@ import {
104
104
  STANDING_FIELDS,
105
105
  } from "./standing-terms.mjs";
106
106
  import { checkDatedOffices } from "./office-holders.mjs";
107
+ import { checkUniqueItem } from "./unique-items.mjs";
108
+ import { checkPlaceKind } from "./place-kinds.mjs";
107
109
  import { checkAffiliationRankFloor, checkRankLadder } from "./rank-ladder.mjs";
108
110
  import { checkCalendarChoice } from "./calendar-choice.mjs";
109
111
  import { checkNoteEvents } from "./note-events.mjs";
@@ -160,6 +162,7 @@ import { checkCitedPopulations, checkPopulation } from "./populations.mjs";
160
162
  import { MARKET_CLASSES, checkMarket } from "./market-class.mjs";
161
163
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
162
164
  import { checkSettlementTables } from "./settlement-tables.mjs";
165
+ import { SETTLEMENTS_FIELD } from "./settlement-terms.mjs";
163
166
  import { checkConceptList, checkCoreFlag } from "./core-vocabulary.mjs";
164
167
  import { METRE_KINDS, checkMetresField } from "./metre-declaration.mjs";
165
168
  import { checkLanguageEmphasis, checkScriptField } from "./script-emphasis.mjs";
@@ -771,7 +774,7 @@ const NAMES_FIELD = Object.freeze({
771
774
  describe:
772
775
  "How this language's speakers are named, for `package-build npc new`: `pattern` " +
773
776
  'builds a name from placeholders such as "{given} {clan}" and literal text, `given` ' +
774
- "maps a gender to a list of given names, " +
777
+ "is one list of given names for everyone or a map by gender with " +
775
778
  `${wordList([...requiredGivenGenders()]).replace(" or ", " and ")} required, and ` +
776
779
  "`en` holds groups of English names, and every other key is a list of names a " +
777
780
  "placeholder of the same name draws from. A " +
@@ -807,12 +810,17 @@ const NAMES_FIELD = Object.freeze({
807
810
  }),
808
811
  Object.freeze({
809
812
  name: "given",
810
- kind: "map",
813
+ kind: "list-or-map",
811
814
  required: true,
812
- shape: "a map of genders to lists of names",
815
+ shape: "a list of names or a map of genders to lists of names",
813
816
  describe:
814
- "Given names by gender word. A gender with no list draws from every given " +
815
- "list together.",
817
+ "One shared list supplies every gender. A gender map requires female and male; " +
818
+ "a gender with no list draws from every given list together.",
819
+ entries: Object.freeze({
820
+ kind: "string",
821
+ shape: "a name",
822
+ describe: "One name, as written.",
823
+ }),
816
824
  values: Object.freeze({
817
825
  kind: "list",
818
826
  shape: "a list of names",
@@ -1246,8 +1254,8 @@ const EVENT_ADDRESS = Object.freeze({
1246
1254
  });
1247
1255
 
1248
1256
  /**
1249
- * The values every carried thing declares: the four a game reads, and the two
1250
- * events of its history.
1257
+ * The values every carried thing declares: the four a game reads, the two
1258
+ * events of its history, and whether it is the only one of its kind.
1251
1259
  *
1252
1260
  * @type {readonly DataFieldSpec[]}
1253
1261
  */
@@ -1270,6 +1278,15 @@ const GEAR = Object.freeze([
1270
1278
  "The event in which the thing was lost — `lore-flood`, or one event of a note " +
1271
1279
  "as `place-ironfells#sack`.",
1272
1280
  },
1281
+ {
1282
+ name: "unique",
1283
+ kind: "boolean",
1284
+ shape: "`true` or `false`",
1285
+ check: checkUniqueItem,
1286
+ describe:
1287
+ "`true` for a one-of-a-kind item, such as a named weapon; its note links the " +
1288
+ "places and beings involved. Absent, the item is common.",
1289
+ },
1273
1290
  ]);
1274
1291
 
1275
1292
  /** Gear that is counted rather than carried singly. */
@@ -1299,7 +1316,7 @@ const CHARGES = Object.freeze([
1299
1316
  * Three questions about one subject, and they are independent: who governs
1300
1317
  * this inhabited place, what the body states about itself where the note is a
1301
1318
  * body rather than somewhere within one, and whether a region's settlements
1302
- * table agrees with its holding model and the notes it links. A type declares
1319
+ * table agrees with the notes it links. A type declares
1303
1320
  * one whole-note check, so the three are composed here rather than any being
1304
1321
  * folded into another.
1305
1322
  *
@@ -1312,6 +1329,7 @@ function checkPlace(note, opts) {
1312
1329
  ...checkGovernment(note),
1313
1330
  ...checkWorldFacts(note, opts),
1314
1331
  ...checkSettlementTables(note, opts),
1332
+ ...checkPlaceKind(note, opts),
1315
1333
  ];
1316
1334
  }
1317
1335
 
@@ -2492,10 +2510,12 @@ export const NOTE_VOCABULARY = Object.freeze({
2492
2510
  subTypes: Object.freeze([
2493
2511
  "world",
2494
2512
  "region",
2513
+ "district",
2495
2514
  "settlement",
2496
2515
  "site",
2497
2516
  "structure",
2498
2517
  "feature",
2518
+ "route",
2499
2519
  "celestial",
2500
2520
  ]),
2501
2521
  check: checkPlace,
@@ -2551,6 +2571,7 @@ export const NOTE_VOCABULARY = Object.freeze({
2551
2571
  "Explicit null means complete anarchy. " +
2552
2572
  "A positive population with no government key produces an advisory.",
2553
2573
  },
2574
+ SETTLEMENTS_FIELD,
2554
2575
  {
2555
2576
  name: "market",
2556
2577
  ...NUM,
@@ -2633,11 +2654,6 @@ export const NOTE_VOCABULARY = Object.freeze({
2633
2654
  accepts: ["scenario"],
2634
2655
  describe: "Scenarios that should be played before this one.",
2635
2656
  },
2636
- {
2637
- name: "status",
2638
- ...TEXT,
2639
- describe: "`draft`, `playtested` or `published`.",
2640
- },
2641
2657
  {
2642
2658
  name: "party.size",
2643
2659
  ...TEXT,
@@ -32,9 +32,11 @@
32
32
  *
33
33
  * - `name.full` — drawn from the culture's language's name lists by their
34
34
  * pattern, the given name from the list for `data.gender` (see
35
- * `language-names.mjs`). A name the spec states is kept, and must fit those
36
- * lists: every placeholder filled by a name of its list, the given name from
37
- * the gender's.
35
+ * `language-names.mjs`). Each drawn name word starts with a capital in the
36
+ * English display form; the pattern's literal connectors keep their case.
37
+ * A different source form with no English list part is retained under the
38
+ * language code. A name the spec states is kept, and must fit the source or
39
+ * display-form lists.
38
40
  * - `shortcode` — the name transliterated to lowercase ASCII letters and
39
41
  * digits, cut to twelve characters, with a counter replacing its tail where a
40
42
  * being in the package already holds it.
@@ -70,7 +72,7 @@ import { parseNoteDate } from "./note-dates.mjs";
70
72
  import { eraYear } from "./calendars.mjs";
71
73
  import { parseAddress, renderAddress } from "./address.mjs";
72
74
  import { isPlainMap } from "./data-keys.mjs";
73
- import { drawName, nameMisfit } from "./language-names.mjs";
75
+ import { drawNameParts, nameMisfit } from "./language-names.mjs";
74
76
 
75
77
  /** The youngest and oldest age, in whole years, a generated person is born to. */
76
78
  export const ADULT_AGE = Object.freeze({ min: 18, max: 60 });
@@ -152,6 +154,27 @@ export function canonicalSpec(value) {
152
154
  /** Whether a spec value counts as given — any value but `undefined`. */
153
155
  const given = (value) => value !== undefined;
154
156
 
157
+ /** Each name word starts with a capital and keeps its remaining letters. */
158
+ const displayNameWords = (name) =>
159
+ name.replace(/\p{L}[\p{L}\p{M}'’]*/gu, (word) =>
160
+ word.replace(/^\p{L}/u, (letter) => letter.toUpperCase()),
161
+ );
162
+
163
+ /** Name lists in English display case; literal pattern text keeps its case. */
164
+ function displayNameLists(names) {
165
+ const display = (value) =>
166
+ Array.isArray(value) ? value.map(displayNameWords)
167
+ : isPlainMap(value) ?
168
+ Object.fromEntries(Object.entries(value).map(([key, list]) => [key, display(list)]))
169
+ : value;
170
+ return Object.fromEntries(
171
+ Object.entries(names).map(([key, value]) => [
172
+ key,
173
+ key === "pattern" ? value : display(value),
174
+ ]),
175
+ );
176
+ }
177
+
155
178
  /** The value at a dotted path, or `undefined`. */
156
179
  function at(object, path) {
157
180
  let value = object;
@@ -433,6 +456,8 @@ function frontmatterYaml(frontmatter) {
433
456
  * by `namesProblems`.
434
457
  * @param {string} [context.language] - The language's Address, for an error
435
458
  * about a name that does not fit.
459
+ * @param {string} [context.languageCode] - The language code under which a
460
+ * generated name's distinct source form is retained.
436
461
  * @param {string} [context.seed] - Text joined to the spec's own seed, to draw a
437
462
  * different person from the same spec.
438
463
  * @param {unknown} [context.currentDate] - The setting's neutral current date, defaulting to "720.1".
@@ -460,7 +485,9 @@ export function generateNpc(spec, context) {
460
485
  const gender = data.gender;
461
486
  const statedName = spec.name?.full;
462
487
  if (given(statedName)) {
463
- const misfit = nameMisfit(statedName, names, gender);
488
+ const displayNames = displayNameLists(names);
489
+ const misfit =
490
+ nameMisfit(statedName, names, gender) && nameMisfit(statedName, displayNames, gender);
464
491
  if (misfit)
465
492
  throw Object.assign(
466
493
  new Error(
@@ -470,7 +497,25 @@ export function generateNpc(spec, context) {
470
497
  { keyPath: ["name", "full"] },
471
498
  );
472
499
  }
473
- const name = given(statedName) ? statedName : drawName(names, gender, draw("name.full"));
500
+ const nameParts = given(statedName) ? [] : drawNameParts(names, gender, draw("name.full"));
501
+ const sourceName = given(statedName) ? statedName : nameParts.map((part) => part.text).join("");
502
+ const name =
503
+ given(statedName) ? statedName : (
504
+ nameParts
505
+ .map((part) => ("literal" in part ? part.text : displayNameWords(part.text)))
506
+ .join("")
507
+ );
508
+ const languageCode = context.languageCode;
509
+ const sourceAlias =
510
+ (
511
+ !given(statedName) &&
512
+ languageCode &&
513
+ languageCode !== "en" &&
514
+ sourceName !== name &&
515
+ !nameParts.some((part) => "list" in part && part.list === "en")
516
+ ) ?
517
+ { [languageCode]: [sourceName] }
518
+ : {};
474
519
 
475
520
  const height =
476
521
  given(data.height) ?
@@ -537,7 +582,7 @@ export function generateNpc(spec, context) {
537
582
  subType: "npc",
538
583
  tags: ["generated", "draft"],
539
584
  ...frontmatterSpec,
540
- name: { full: name, ...(isPlainMap(spec.name) ? spec.name : {}) },
585
+ name: { full: name, ...sourceAlias, ...(isPlainMap(spec.name) ? spec.name : {}) },
541
586
  data: fullData,
542
587
  },
543
588
  TOP_ORDER,
@@ -17,9 +17,85 @@ import { parseNoteDate } from "./note-dates.mjs";
17
17
  import { reckoningContext } from "./reckoning-markers.mjs";
18
18
 
19
19
  import { HOLDER_FIELDS, OFFICE_FIELDS } from "./standing-terms.mjs";
20
+ import { isNoteRecord } from "./index-records.mjs";
21
+ import { isAddressTuple } from "./address.mjs";
22
+ import { officeRoster, readStandings, standingOffices } from "./standings.mjs";
20
23
 
21
24
  export { HOLDER_FIELDS, OFFICE_FIELDS };
22
25
 
26
+ /**
27
+ * A note's identity across its systems: `package-type-shortcode`, lowercased.
28
+ *
29
+ * A body's Item and its journal carry one page, and a being names the body by
30
+ * its `note` Address, so the system is left out.
31
+ *
32
+ * @param {{package?: string, type?: string, shortcode?: string}} tuple
33
+ * @returns {string} The key, or `""` where a part is missing.
34
+ */
35
+ export function officeBodyKey(tuple) {
36
+ const parts = [tuple?.package, tuple?.type, tuple?.shortcode].map((part) =>
37
+ String(part ?? "").toLowerCase(),
38
+ );
39
+ return parts.every(Boolean) ? parts.join("-") : "";
40
+ }
41
+
42
+ /**
43
+ * Each body's offices with the living beings that hold them.
44
+ *
45
+ * Nothing is authored on the office for this: a being states the post it holds
46
+ * in its own `data.affiliations.<body>.office`, and this collects, for every
47
+ * office in a body's `governance.offices`, each being naming it. A being whose
48
+ * `data.died` is set holds nothing, so a former holder drops out and a vacant
49
+ * office lists no one. A post the body does not declare is the standing check's
50
+ * finding and is listed nowhere.
51
+ *
52
+ * Offices keep the body's declared order; holders are ordered by name.
53
+ *
54
+ * @param {object[]} records - Content-index records; only note records are
55
+ * read.
56
+ * @returns {Map<string, Map<string, object[]>>} Body identity
57
+ * (`package-type-shortcode`) → office → the holding beings' records, for every
58
+ * body with at least one held office.
59
+ */
60
+ export function officeHolders(records) {
61
+ const notes = records.filter((record) => isNoteRecord(record) && record.shortcode);
62
+ /** Body identity → office → holding beings. */
63
+ const named = new Map();
64
+ for (const record of notes) {
65
+ if (record.type !== "being" || record.data?.died != null) continue;
66
+ for (const { body, standing } of readStandings(record.data?.affiliations).entries) {
67
+ const key = isAddressTuple(body) ? officeBodyKey(body) : "";
68
+ if (!key || !mapping(standing)) continue;
69
+ const offices = named.get(key) ?? new Map();
70
+ for (const office of standingOffices(standing)) {
71
+ const holders = offices.get(office) ?? [];
72
+ if (!holders.includes(record)) holders.push(record);
73
+ offices.set(office, holders);
74
+ }
75
+ named.set(key, offices);
76
+ }
77
+ }
78
+ const nameOf = (record) => String(record.name?.full ?? record.shortcode);
79
+ const out = new Map();
80
+ for (const record of notes) {
81
+ if (record.type !== "affiliation") continue;
82
+ const key = officeBodyKey(record);
83
+ const claims = named.get(key);
84
+ if (!claims) continue;
85
+ const held = new Map();
86
+ for (const office of officeRoster(record.data?.governance?.offices).keys()) {
87
+ const holders = claims.get(office);
88
+ if (holders?.length)
89
+ held.set(
90
+ office,
91
+ holders.toSorted((a, b) => nameOf(a).localeCompare(nameOf(b), "en")),
92
+ );
93
+ }
94
+ if (held.size) out.set(key, held);
95
+ }
96
+ return out;
97
+ }
98
+
23
99
  const mapping = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
24
100
 
25
101
  /**
@@ -0,0 +1,143 @@
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
+
14
+ /** Authored book page breaks and their positioned syntax findings. @module */
15
+
16
+ import { blockTokens } from "./markdown-blocks.mjs";
17
+ import { scanBlocks } from "./content-blocks.mjs";
18
+ import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
19
+
20
+ /** A standalone attributed break, including Markdown container prefixes. */
21
+ const BREAK_LINE =
22
+ /^(?<prefix>(?:[ \t]*(?:>[ \t]?|(?:[-+*]|\d{1,9}[.)])[ \t]+|\[\^[^\]\s]+\]:[ \t]*))*[ \t]*)(?<marker>---|\*\*\*|___)[ \t]+(?<attributes>\{[^\r\n]*\})[ \t]*$/;
23
+ const PLACEMENT = "a page break stands between blocks at the top level of a note";
24
+
25
+ /**
26
+ * Read page-break markers before any surface rewrites their source.
27
+ * Code, HTML examples and entire verse divs remain literal. Parser ranges
28
+ * retain list and quote membership, including lazy continuation lines.
29
+ * @param {string} source - Authored Markdown body.
30
+ * @returns {{breaks: Array<{line: number, column: number}>, errors: Array<{line: number, column: number, message: string}>}}
31
+ */
32
+ export function scanPageBreaks(source) {
33
+ const text = String(source ?? "");
34
+ const lines = text.split(/\r?\n/);
35
+ if (!lines.some((line) => BREAK_LINE.test(line))) return { breaks: [], errors: [] };
36
+ const literal = new Set();
37
+ const nested = new Set();
38
+ let footnoteDepth = 0;
39
+ for (const token of blockTokens(text, { footnotes: true })) {
40
+ if (token.type === "footnote_reference_open") footnoteDepth++;
41
+ if (token.type === "footnote_reference_close") footnoteDepth--;
42
+ if (!token.map) continue;
43
+ if (footnoteDepth)
44
+ for (let line = token.map[0]; line < token.map[1]; line++) nested.add(line);
45
+ const target =
46
+ ["fence", "code_block", "html_block", "verse_div"].includes(token.type) ? literal
47
+ : ["blockquote_open", "list_item_open"].includes(token.type) ? nested
48
+ : undefined;
49
+ if (target) for (let line = token.map[0]; line < token.map[1]; line++) target.add(line);
50
+ }
51
+ for (const block of scanBlocks(text).blocks)
52
+ for (let line = block.start + 1; line < block.end; line++) nested.add(line);
53
+ const breaks = [],
54
+ errors = [];
55
+ for (let i = 0; i < lines.length; i++) {
56
+ if (literal.has(i)) continue;
57
+ const match = BREAK_LINE.exec(lines[i]);
58
+ if (!match) continue;
59
+ const { prefix, marker, attributes } = match.groups;
60
+ const parsed = parseExtensionAttributes(attributes.slice(1, -1));
61
+ if (marker !== "---" && !parsed.classes.includes("page-break")) continue;
62
+ const column = prefix.length + 1;
63
+ const attributeColumn = lines[i].lastIndexOf(attributes) + 1;
64
+ const at = (message) => ({ line: i + 1, column: attributeColumn, message });
65
+ const problems = [...parsed.problems, ...refusedAttributes(parsed.values)];
66
+ if (
67
+ parsed.classes.some((name) => name !== "page-break") ||
68
+ (!parsed.classes.length &&
69
+ !parsed.id &&
70
+ !Object.keys(parsed.values).length &&
71
+ !problems.length)
72
+ )
73
+ problems.push("a page break accepts only .page-break");
74
+ if (parsed.id) problems.push("a page break does not accept an id");
75
+ if (Object.keys(parsed.values).length)
76
+ problems.push("a page break does not accept attributes");
77
+ if (marker !== "---")
78
+ errors.push({ line: i + 1, column, message: "only --- accepts {.page-break}" });
79
+ if (
80
+ nested.has(i) ||
81
+ (i > 0 && lines[i - 1].trim()) ||
82
+ (i + 1 < lines.length && lines[i + 1].trim())
83
+ )
84
+ errors.push({ line: i + 1, column, message: PLACEMENT });
85
+ errors.push(...problems.map(at));
86
+ if (marker === "---" && !problems.length && !errors.some((error) => error.line === i + 1))
87
+ breaks.push({ line: i + 1, column });
88
+ }
89
+ return { breaks, errors };
90
+ }
91
+
92
+ /**
93
+ * Omit valid page breaks on a screen, preserving the surrounding block boundaries.
94
+ * @param {string} source - Markdown body.
95
+ * @returns {string} Markdown without valid page-break lines.
96
+ */
97
+ export function omitPageBreaks(source) {
98
+ const lines = String(source ?? "").split("\n");
99
+ for (const marker of scanPageBreaks(source).breaks) lines[marker.line - 1] = "";
100
+ return lines.join("\n");
101
+ }
102
+
103
+ /**
104
+ * Preserve refused markers as literal Markdown before the book slices blocks.
105
+ * A slice must not make a marker eligible by discarding its original neighbour.
106
+ * Findings are collected from the original source before this rewrite.
107
+ * @param {string} source - Complete Markdown body, before splitting containers.
108
+ * @returns {string} Markdown with invalid markers escaped for literal rendering.
109
+ */
110
+ export function escapeInvalidPageBreaks(source) {
111
+ const text = String(source ?? "");
112
+ const refused = new Set(scanPageBreaks(text).errors.map((error) => error.line));
113
+ if (!refused.size) return text;
114
+ return text
115
+ .split("\n")
116
+ .map((line, i) => {
117
+ if (!refused.has(i + 1)) return line;
118
+ const match = BREAK_LINE.exec(line.replace(/\r$/, ""));
119
+ const prefix = match.groups.prefix;
120
+ return `${prefix}\\${line.slice(prefix.length)}`;
121
+ })
122
+ .join("\n");
123
+ }
124
+
125
+ /**
126
+ * Produce the book's page-break token, leaving ordinary thematic breaks alone.
127
+ * @param {import("markdown-it")} md - The book parser.
128
+ * @returns {void}
129
+ */
130
+ export function pageBreakPlugin(md) {
131
+ md.block.ruler.before("hr", "page_break", (state, startLine, _endLine, silent) => {
132
+ if (state.env.pageBreaks === false) return false;
133
+ const line = state.src.slice(state.bMarks[startLine], state.eMarks[startLine]);
134
+ if (!BREAK_LINE.test(line)) return false;
135
+ if (!scanPageBreaks(state.src).breaks.some((marker) => marker.line === startLine + 1))
136
+ return false;
137
+ if (silent) return true;
138
+ const token = state.push("page_break", "", 0);
139
+ token.map = [startLine, startLine + 1];
140
+ state.line = startLine + 1;
141
+ return true;
142
+ });
143
+ }