@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
@@ -470,7 +470,11 @@ function checkGroupCase(enc, groups, text, at, bound) {
470
470
  * @param {string} text
471
471
  * @param {{font?: string, verse?: boolean, start?: number, end?: number}} [options]
472
472
  */
473
- function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text.length } = {}) {
473
+ function encodeRawPieces(
474
+ enc,
475
+ text,
476
+ { font, verse = false, start = 0, end = text.length, stops = [] } = {},
477
+ ) {
474
478
  const groups = tiers(enc, font);
475
479
  if (typeof text !== "string") throw new EncodingError("Text must be a string");
476
480
  if (
@@ -485,9 +489,13 @@ function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text
485
489
  });
486
490
  const bound = end;
487
491
  const out = [];
488
- let at = start;
492
+ let at = start,
493
+ stop = 0;
489
494
  while (at < bound) {
490
495
  const first = firstAt(text, at);
496
+ while (stop < stops.length && stops[stop] <= at) stop++;
497
+ // A row never spans the start of a diaeresis vowel written by its plain vowel.
498
+ const limit = Math.min(bound, stops[stop] ?? bound);
491
499
  if (first === OPEN) {
492
500
  const close = span(text.slice(0, bound), at, OPEN, CLOSE);
493
501
  out.push(
@@ -545,7 +553,7 @@ function encodeRawPieces(enc, text, { font, verse = false, start = 0, end = text
545
553
  continue;
546
554
  }
547
555
  if (!enc.template) checkGroupCase(enc, groups, text, at, bound);
548
- const row = enc.accepted.has(first) ? rowAt(enc, groups, text, at, bound) : null;
556
+ const row = enc.accepted.has(first) ? rowAt(enc, groups, text, at, limit) : null;
549
557
  if (!row)
550
558
  refused(
551
559
  enc,
@@ -688,7 +696,13 @@ function sourceDetails(enc, text, options) {
688
696
  pointSlice(normalized.slice(0, bound), at, enc.template.maxVowelGroup)
689
697
  : "";
690
698
  const lower = lookahead.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
691
- const group = enc.template?.vowelGroups.find((value) => lower.startsWith(value));
699
+ const group = enc.template?.vowelGroups.find(
700
+ (value) =>
701
+ lower.startsWith(value) &&
702
+ [...lookahead]
703
+ .slice(1, [...value].length)
704
+ .every((letter) => !/[\u0308\u0300]/u.test(letter.normalize("NFD"))),
705
+ );
692
706
  if (group) {
693
707
  let cursor = at;
694
708
  for (let i = 0; i < [...group].length; i++) {
@@ -713,7 +727,16 @@ function sourceDetails(enc, text, options) {
713
727
  });
714
728
  throw error;
715
729
  }
716
- return projection;
730
+ const letters = enc.template?.apart?.letters;
731
+ if (!letters) return { ...projection, folded: normalized, stops: [] };
732
+ const stops = [];
733
+ let folded = "";
734
+ for (let at = 0; at < normalized.length; at++) {
735
+ const plain = at >= begin && at < bound ? letters[normalized[at]] : undefined;
736
+ if (plain) stops.push(at);
737
+ folded += plain ?? normalized[at];
738
+ }
739
+ return { ...projection, folded, stops };
717
740
  }
718
741
 
719
742
  /**
@@ -736,16 +759,49 @@ export function validateSource(enc, text, options = {}) {
736
759
  */
737
760
  export function encodePieces(enc, text, options = {}) {
738
761
  if (!enc?.template) return encodeRawPieces(enc, text, options);
739
- const { normalized, sourceAt, begin, bound } = sourceDetails(enc, text, options);
762
+ const { normalized, folded, stops, sourceAt, begin, bound } = sourceDetails(enc, text, options);
740
763
  try {
741
- return encodeRawPieces(enc, normalized, { ...options, start: begin, end: bound }).map(
742
- (one) => ({
743
- ...one,
744
- from: text.slice(sourceAt[one.start], sourceAt[one.end]),
745
- start: sourceAt[one.start],
746
- end: sourceAt[one.end],
747
- }),
748
- );
764
+ const pieces = encodeRawPieces(enc, folded, {
765
+ ...options,
766
+ start: begin,
767
+ end: bound,
768
+ stops,
769
+ });
770
+ if (enc.roundTrip === "exact" && stops.length) {
771
+ const native = pieces.map((one) => one.to).join("");
772
+ const decoded = decodeTemplatePieces(enc, native, options);
773
+ let nativeAt = 0;
774
+ for (const one of pieces) {
775
+ const expected = normalized.slice(one.start, one.end);
776
+ const marked = stops.find(
777
+ (at) =>
778
+ at >= one.start &&
779
+ at < one.end &&
780
+ splitsDeclaredGroup(enc, normalized, at, begin, bound),
781
+ );
782
+ if (
783
+ marked !== undefined &&
784
+ !decoded.some(
785
+ (step) =>
786
+ step.start === nativeAt &&
787
+ step.end === nativeAt + one.to.length &&
788
+ step.from === expected,
789
+ )
790
+ )
791
+ textError(
792
+ folded,
793
+ marked,
794
+ "The encoding cannot distinguish this diaeresis split in an exact round trip",
795
+ );
796
+ nativeAt += one.to.length;
797
+ }
798
+ }
799
+ return pieces.map((one) => ({
800
+ ...one,
801
+ from: text.slice(sourceAt[one.start], sourceAt[one.end]),
802
+ start: sourceAt[one.start],
803
+ end: sourceAt[one.end],
804
+ }));
749
805
  } catch (error) {
750
806
  if (error instanceof EncodingError && error.offset !== undefined)
751
807
  throw new EncodingError(error.message, {
@@ -845,6 +901,12 @@ function decodeRawText(enc, text, { font } = {}) {
845
901
  */
846
902
  export function decodeText(enc, text, options = {}) {
847
903
  if (!enc?.template) return decodeRawText(enc, text, options);
904
+ return decodeTemplatePieces(enc, text, options)
905
+ .map((one) => one.from)
906
+ .join("");
907
+ }
908
+
909
+ function decodeTemplatePieces(enc, text, options = {}) {
848
910
  const readings = tiers(enc, options.font)[0].readings;
849
911
  if (typeof text !== "string") throw new EncodingError("Text must be a string");
850
912
  if (!templateReaders.has(enc)) {
@@ -905,7 +967,7 @@ export function decodeText(enc, text, options = {}) {
905
967
  failedContext = Math.min(failedContext, at);
906
968
  continue;
907
969
  }
908
- paths.set(at, { from: row.from, next });
970
+ paths.set(at, { from: row.from, row, next });
909
971
  }
910
972
  }
911
973
  if (!paths.has(0)) {
@@ -950,8 +1012,76 @@ export function decodeText(enc, text, options = {}) {
950
1012
  const out = [];
951
1013
  for (let at = 0; at < text.length;) {
952
1014
  const step = paths.get(at);
953
- out.push(step.from);
1015
+ out.push({ from: step.from, row: step.row, start: at, end: step.next });
954
1016
  at = step.next;
955
1017
  }
956
- return out.join("");
1018
+ const apart = enc.template.apart;
1019
+ if (!apart) return out;
1020
+ return restoreDiaeresis(enc, out, options);
1021
+ }
1022
+
1023
+ /** Restore only joins contradicted by the selected native row boundaries. */
1024
+ function restoreDiaeresis(enc, pieces, options) {
1025
+ const text = pieces.map((one) => one.from).join("");
1026
+ const letters = [];
1027
+ let offset = 0;
1028
+ for (const [part, piece] of pieces.entries()) {
1029
+ for (const char of piece.from) {
1030
+ letters.push({ char, part, offset, literal: !piece.row });
1031
+ offset += char.length;
1032
+ }
1033
+ }
1034
+ const groups = tiers(enc, options.font);
1035
+ for (let i = 0; i < letters.length; i++) {
1036
+ if (letters[i].literal) continue;
1037
+ const group = enc.template.apart.groups.find((value) => {
1038
+ const span = [...value];
1039
+ if (
1040
+ !span.every((char, k) => {
1041
+ const letter = letters[i + k];
1042
+ return (
1043
+ letter &&
1044
+ !letter.literal &&
1045
+ letter.char.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase() ===
1046
+ char &&
1047
+ (k === 0 || letter.char.normalize("NFD").length === 1)
1048
+ );
1049
+ })
1050
+ )
1051
+ return false;
1052
+ const row = rowAt(enc, groups, text, letters[i].offset);
1053
+ return row && row.from.toLowerCase() === value;
1054
+ });
1055
+ if (!group) continue;
1056
+ const end = i + [...group].length;
1057
+ let boundary = i + 1;
1058
+ while (boundary < end && letters[boundary].part === letters[i].part) boundary++;
1059
+ if (boundary === end) {
1060
+ i = end - 1;
1061
+ continue;
1062
+ }
1063
+ const letter = letters[boundary];
1064
+ const marked = (letter.char + "\u0308").normalize("NFC");
1065
+ if (enc.template.apart.letters[marked]) {
1066
+ letter.char = marked;
1067
+ i = boundary - 1;
1068
+ }
1069
+ }
1070
+ const output = pieces.map((piece) => ({ ...piece, from: "" }));
1071
+ for (const letter of letters) output[letter.part].from += letter.char;
1072
+ return output;
1073
+ }
1074
+
1075
+ /** Whether a derived mark actually splits one of this note's declared groups. */
1076
+ function splitsDeclaredGroup(enc, text, at, begin, bound) {
1077
+ for (const group of enc.template.apart.groups) {
1078
+ const count = [...group].length;
1079
+ // The supported diaeresis letters are one NFC UTF-16 unit each.
1080
+ for (let start = Math.max(begin, at - count + 1); start < at; start++) {
1081
+ const span = pointSlice(text.slice(0, bound), start, count);
1082
+ if (start + span.length <= at) continue;
1083
+ if (span.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase() === group) return true;
1084
+ }
1085
+ }
1086
+ return false;
957
1087
  }
@@ -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, `quality` changes
158
+ * which vowel is said, and `tone` marks a rising or falling pitch.
159
+ */
160
+ export const MARK_KINDS = Object.freeze(["stress", "length", "apart", "quality", "tone"]);
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
  }
@@ -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"],