@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
@@ -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";
@@ -1251,8 +1254,8 @@ const EVENT_ADDRESS = Object.freeze({
1251
1254
  });
1252
1255
 
1253
1256
  /**
1254
- * The values every carried thing declares: the four a game reads, and the two
1255
- * 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.
1256
1259
  *
1257
1260
  * @type {readonly DataFieldSpec[]}
1258
1261
  */
@@ -1275,6 +1278,15 @@ const GEAR = Object.freeze([
1275
1278
  "The event in which the thing was lost — `lore-flood`, or one event of a note " +
1276
1279
  "as `place-ironfells#sack`.",
1277
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
+ },
1278
1290
  ]);
1279
1291
 
1280
1292
  /** Gear that is counted rather than carried singly. */
@@ -1304,7 +1316,7 @@ const CHARGES = Object.freeze([
1304
1316
  * Three questions about one subject, and they are independent: who governs
1305
1317
  * this inhabited place, what the body states about itself where the note is a
1306
1318
  * body rather than somewhere within one, and whether a region's settlements
1307
- * 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
1308
1320
  * one whole-note check, so the three are composed here rather than any being
1309
1321
  * folded into another.
1310
1322
  *
@@ -1317,6 +1329,7 @@ function checkPlace(note, opts) {
1317
1329
  ...checkGovernment(note),
1318
1330
  ...checkWorldFacts(note, opts),
1319
1331
  ...checkSettlementTables(note, opts),
1332
+ ...checkPlaceKind(note, opts),
1320
1333
  ];
1321
1334
  }
1322
1335
 
@@ -2497,10 +2510,12 @@ export const NOTE_VOCABULARY = Object.freeze({
2497
2510
  subTypes: Object.freeze([
2498
2511
  "world",
2499
2512
  "region",
2513
+ "district",
2500
2514
  "settlement",
2501
2515
  "site",
2502
2516
  "structure",
2503
2517
  "feature",
2518
+ "route",
2504
2519
  "celestial",
2505
2520
  ]),
2506
2521
  check: checkPlace,
@@ -2556,6 +2571,7 @@ export const NOTE_VOCABULARY = Object.freeze({
2556
2571
  "Explicit null means complete anarchy. " +
2557
2572
  "A positive population with no government key produces an advisory.",
2558
2573
  },
2574
+ SETTLEMENTS_FIELD,
2559
2575
  {
2560
2576
  name: "market",
2561
2577
  ...NUM,
@@ -2638,11 +2654,6 @@ export const NOTE_VOCABULARY = Object.freeze({
2638
2654
  accepts: ["scenario"],
2639
2655
  describe: "Scenarios that should be played before this one.",
2640
2656
  },
2641
- {
2642
- name: "status",
2643
- ...TEXT,
2644
- describe: "`draft`, `playtested` or `published`.",
2645
- },
2646
2657
  {
2647
2658
  name: "party.size",
2648
2659
  ...TEXT,
@@ -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
  /**
@@ -19,7 +19,7 @@ import { parseExtensionAttributes, refusedAttributes } from "./extension-attribu
19
19
 
20
20
  /** A standalone attributed break, including Markdown container prefixes. */
21
21
  const BREAK_LINE =
22
- /^(?:[ \t]*(?:>[ \t]?|(?:[-+*]|\d{1,9}[.)])[ \t]+))*[ \t]*(---|\*\*\*|___)[ \t]+(\{[^\r\n]*\})[ \t]*$/;
22
+ /^(?<prefix>(?:[ \t]*(?:>[ \t]?|(?:[-+*]|\d{1,9}[.)])[ \t]+|\[\^[^\]\s]+\]:[ \t]*))*[ \t]*)(?<marker>---|\*\*\*|___)[ \t]+(?<attributes>\{[^\r\n]*\})[ \t]*$/;
23
23
  const PLACEMENT = "a page break stands between blocks at the top level of a note";
24
24
 
25
25
  /**
@@ -35,8 +35,13 @@ export function scanPageBreaks(source) {
35
35
  if (!lines.some((line) => BREAK_LINE.test(line))) return { breaks: [], errors: [] };
36
36
  const literal = new Set();
37
37
  const nested = new Set();
38
- for (const token of blockTokens(text)) {
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--;
39
42
  if (!token.map) continue;
43
+ if (footnoteDepth)
44
+ for (let line = token.map[0]; line < token.map[1]; line++) nested.add(line);
40
45
  const target =
41
46
  ["fence", "code_block", "html_block", "verse_div"].includes(token.type) ? literal
42
47
  : ["blockquote_open", "list_item_open"].includes(token.type) ? nested
@@ -51,10 +56,11 @@ export function scanPageBreaks(source) {
51
56
  if (literal.has(i)) continue;
52
57
  const match = BREAK_LINE.exec(lines[i]);
53
58
  if (!match) continue;
54
- const parsed = parseExtensionAttributes(match[2].slice(1, -1));
55
- if (match[1] !== "---" && !parsed.classes.includes("page-break")) continue;
56
- const column = lines[i].indexOf(match[1]) + 1;
57
- const attributeColumn = lines[i].indexOf("{") + 1;
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;
58
64
  const at = (message) => ({ line: i + 1, column: attributeColumn, message });
59
65
  const problems = [...parsed.problems, ...refusedAttributes(parsed.values)];
60
66
  if (
@@ -68,7 +74,7 @@ export function scanPageBreaks(source) {
68
74
  if (parsed.id) problems.push("a page break does not accept an id");
69
75
  if (Object.keys(parsed.values).length)
70
76
  problems.push("a page break does not accept attributes");
71
- if (match[1] !== "---")
77
+ if (marker !== "---")
72
78
  errors.push({ line: i + 1, column, message: "only --- accepts {.page-break}" });
73
79
  if (
74
80
  nested.has(i) ||
@@ -77,7 +83,7 @@ export function scanPageBreaks(source) {
77
83
  )
78
84
  errors.push({ line: i + 1, column, message: PLACEMENT });
79
85
  errors.push(...problems.map(at));
80
- if (match[1] === "---" && !problems.length && !errors.some((error) => error.line === i + 1))
86
+ if (marker === "---" && !problems.length && !errors.some((error) => error.line === i + 1))
81
87
  breaks.push({ line: i + 1, column });
82
88
  }
83
89
  return { breaks, errors };
@@ -94,6 +100,28 @@ export function omitPageBreaks(source) {
94
100
  return lines.join("\n");
95
101
  }
96
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
+
97
125
  /**
98
126
  * Produce the book's page-break token, leaving ordinary thematic breaks alone.
99
127
  * @param {import("markdown-it")} md - The book parser.
@@ -62,7 +62,7 @@
62
62
  * @module
63
63
  */
64
64
 
65
- import { scanPageBreaks } from "./page-breaks.mjs";
65
+ import { escapeInvalidPageBreaks, scanPageBreaks } from "./page-breaks.mjs";
66
66
  import { createRenderer } from "./content-render.mjs";
67
67
  import { bookDraftNoticePreamble } from "./draft-notice.mjs";
68
68
  import { ICON_PATTERN, ICON_SIZES } from "./content-icons.mjs";
@@ -360,7 +360,7 @@ function reportUnrenderable(source, definitions, opts, original) {
360
360
  }
361
361
  }
362
362
  for (const error of [
363
- ...scanPageBreaks(source).errors,
363
+ ...scanPageBreaks(original).errors,
364
364
  ...scanSpans(source).errors,
365
365
  ...scanRuns(source, opts.runs).errors,
366
366
  ...markupAnchorFindings(source),
@@ -522,7 +522,8 @@ export function markdownToTypst(markdown, opts = {}) {
522
522
  renderRuns(markingSource, opts.runs, undefined, { target: "book" })
523
523
  );
524
524
  const markedFootnotes = marked && definitions ? separateFootnotes(marked.markdown) : undefined;
525
- const source = markedFootnotes?.markdown ?? marked?.markdown ?? authored;
525
+ const markedSource = markedFootnotes?.markdown ?? marked?.markdown ?? authored;
526
+ const source = opts.nested ? markedSource : escapeInvalidPageBreaks(markedSource);
526
527
  if (markedFootnotes) definitions = markedFootnotes.definitions;
527
528
  if (marked) {
528
529
  sharedOptions.footnoteDefinitions = definitions;
@@ -0,0 +1,106 @@
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
+ /**
15
+ * **What a district and a route require of their notes.**
16
+ *
17
+ * A `place/district` is a recognised division of a larger place, usually
18
+ * administrative: a city's quarter, a town's ward, a kingdom's shire. It always
19
+ * stands within a `place/settlement` or a `place/region`, named in
20
+ * `data.parents`. It may name its own governing affiliation in
21
+ * `data.government`; without one it falls under its parent's government.
22
+ *
23
+ * A `place/route` is a road, a pass or a sea lane, and the journeys it carries
24
+ * are its `data.routes`.
25
+ *
26
+ * @module
27
+ */
28
+
29
+ import { isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
30
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
31
+
32
+ /** The place kinds a district may stand within. */
33
+ export const DISTRICT_PARENT_KINDS = Object.freeze(["settlement", "region"]);
34
+
35
+ /**
36
+ * The place one `parents` entry names, as the lint's index holds it.
37
+ *
38
+ * @param {unknown} value - The authored entry.
39
+ * @param {object} [index] - The lint's address index.
40
+ * @returns {{resolved: boolean, fm?: object}} Whether it resolves, and its
41
+ * frontmatter where it does.
42
+ */
43
+ function parentOf(value, index) {
44
+ const tuple =
45
+ isAddressTuple(value) ? value : (
46
+ parseAddress(value, {
47
+ package: index?.contentPackage,
48
+ system: "note",
49
+ type: "place",
50
+ types: index?.types,
51
+ packages: index?.packages,
52
+ })
53
+ );
54
+ if (!isAddressTuple(tuple) || !index?.addressHit) return { resolved: false };
55
+ const hit = index.addressHit(renderAddress({ ...tuple, system: "note" }));
56
+ return hit ? { resolved: true, fm: hit.fm ?? hit } : { resolved: false };
57
+ }
58
+
59
+ /**
60
+ * Check a district's parent and a route's journeys.
61
+ *
62
+ * A district naming no parent is reported at its `subType`; one whose parents
63
+ * all resolve and none is a settlement or a region is reported at `parents`.
64
+ * A parent that does not resolve is the Address check's finding, so a district
65
+ * whose only parents are unresolved earns nothing more here. A route stating no
66
+ * `data.routes` entry is reported at its `subType`.
67
+ *
68
+ * @param {object} note - The note: `file`, `raw` and parsed `fm`.
69
+ * @param {{index?: object}} [options] - The lint's options.
70
+ * @returns {object[]} Located findings.
71
+ */
72
+ export function checkPlaceKind(note, { index } = {}) {
73
+ const subType = note.fm?.subType;
74
+ const at = (path, key) => ({
75
+ file: note.file,
76
+ ...positionOfFrontmatterPath(note.raw ?? "", path, key ? { key: true } : undefined),
77
+ severity: "error",
78
+ });
79
+ if (subType === "district") {
80
+ const parents = note.fm?.data?.parents;
81
+ const list =
82
+ Array.isArray(parents) ? parents
83
+ : parents == null ? []
84
+ : [parents];
85
+ const message =
86
+ "a district stands within a settlement or a region; name one in `data.parents`";
87
+ if (!list.length) return [{ ...at(["subType"]), message }];
88
+ const found = list.map((value) => parentOf(value, index));
89
+ if (found.some((one) => !one.resolved)) return [];
90
+ if (found.some((one) => DISTRICT_PARENT_KINDS.includes(one.fm?.subType))) return [];
91
+ return [{ ...at(["data", "parents"], true), message }];
92
+ }
93
+ if (subType === "route") {
94
+ const routes = note.fm?.data?.routes;
95
+ if (Array.isArray(routes) && routes.length) return [];
96
+ return [
97
+ {
98
+ ...at(["subType"]),
99
+ message:
100
+ "a route carries its journeys in `data.routes`; state at least one, " +
101
+ "each `{ to, bearing, mode, days }`",
102
+ },
103
+ ];
104
+ }
105
+ return [];
106
+ }