@heroiclands/package-build 23.0.0 → 23.1.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 (157) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +1 -1
  3. package/bin/content-commands.mjs +1 -1
  4. package/ci/content-format-reference.mjs +46 -0
  5. package/docs/api.md +124 -141
  6. package/docs/authoring/assets.md +1 -1
  7. package/docs/authoring/dates-and-calendars.md +12 -6
  8. package/docs/authoring/frontmatter.md +2 -2
  9. package/docs/authoring/links-and-markup.md +145 -29
  10. package/docs/commands.md +8 -10
  11. package/docs/configuration.md +13 -11
  12. package/docs/content-format.md +2 -2
  13. package/docs/guides/book.md +59 -4
  14. package/docs/guides/icons.md +4 -5
  15. package/docs/guides/packs.md +6 -0
  16. package/docs/guides/reference-pages.md +21 -1
  17. package/docs/guides/release-and-deploy.md +39 -7
  18. package/docs/guides/site.md +17 -18
  19. package/docs/reference/format-details.md +877 -276
  20. package/docs/reference/note-types.md +278 -91
  21. package/docs/reference/theme-pages.md +58 -97
  22. package/engine/address-values.mjs +124 -4
  23. package/engine/address.mjs +44 -3
  24. package/engine/anchors.mjs +103 -3
  25. package/engine/art-slots.mjs +2 -9
  26. package/engine/base-compiler.mjs +25 -6
  27. package/engine/calendar-artifacts.mjs +4 -1
  28. package/engine/calendar-notes.mjs +166 -102
  29. package/engine/content-format-check.mjs +2 -3
  30. package/engine/content-format.mjs +44 -1
  31. package/engine/content-format.yaml +79 -16
  32. package/engine/content-images.mjs +10 -1
  33. package/engine/content-index.mjs +37 -9
  34. package/engine/content-links.mjs +24 -5
  35. package/engine/content-tables.mjs +24 -3
  36. package/engine/data-keys.mjs +347 -0
  37. package/engine/derived-sections.mjs +322 -0
  38. package/engine/diagnostics.mjs +28 -0
  39. package/engine/event-fields.mjs +187 -0
  40. package/engine/event-rows.mjs +126 -0
  41. package/engine/event-views.mjs +305 -0
  42. package/engine/exported-scene.mjs +29 -53
  43. package/engine/frontmatter-lint.mjs +103 -77
  44. package/engine/frontmatter.mjs +7 -2
  45. package/engine/generate.mjs +53 -0
  46. package/engine/generated-sections.mjs +223 -0
  47. package/engine/heading-attributes.mjs +4 -2
  48. package/engine/helpers.mjs +12 -1
  49. package/engine/holdings.mjs +12 -33
  50. package/engine/ids.mjs +17 -11
  51. package/engine/index.mjs +4 -4
  52. package/engine/infobox-render.mjs +21 -6
  53. package/engine/infobox.mjs +217 -99
  54. package/engine/journals.mjs +104 -47
  55. package/engine/literature-notes.mjs +4 -1
  56. package/engine/literature-works.mjs +21 -13
  57. package/engine/map-graphviz.mjs +4 -18
  58. package/engine/markdown-expressions.mjs +116 -11
  59. package/engine/metadata-index.mjs +26 -0
  60. package/engine/near-miss.mjs +90 -0
  61. package/engine/note-addresses.mjs +75 -19
  62. package/engine/note-dates.mjs +5 -3
  63. package/engine/note-event-terms.mjs +364 -0
  64. package/engine/note-events.mjs +823 -0
  65. package/engine/note-vocabulary.mjs +184 -88
  66. package/engine/office-holders.mjs +20 -54
  67. package/engine/packagebuild-index.mjs +16 -1
  68. package/engine/pdf-build.mjs +97 -60
  69. package/engine/pdf-index.mjs +263 -0
  70. package/engine/pdf-render.mjs +47 -5
  71. package/engine/place-relation-terms.mjs +174 -0
  72. package/engine/place-relations.mjs +72 -160
  73. package/engine/rank-ladder.mjs +14 -41
  74. package/engine/reckoning-markers.mjs +2 -0
  75. package/engine/region-events.mjs +6 -12
  76. package/engine/scenes.mjs +73 -468
  77. package/engine/site-build.mjs +75 -101
  78. package/engine/site-index.mjs +3 -1
  79. package/engine/site-maps.mjs +43 -29
  80. package/engine/site-preview.mjs +40 -31
  81. package/engine/social-ties.mjs +2 -1
  82. package/engine/sql-tables.mjs +212 -9
  83. package/engine/standing-terms.mjs +145 -6
  84. package/engine/standings.mjs +37 -49
  85. package/engine/web-wikilinks.mjs +21 -1
  86. package/engine/wikilink-syntax.mjs +34 -4
  87. package/engine/wikilinks.mjs +27 -2
  88. package/engine/yaml-lint.mjs +7 -4
  89. package/hugo-theme/layouts/partials/infobox/entry.html +4 -1
  90. package/hugo-theme/layouts/partials/page-body.html +0 -3
  91. package/hugo-theme/static/css/style.css +25 -55
  92. package/package.json +1 -2
  93. package/packagebuild-metadata.jsonl +13 -13
  94. package/release.mjs +62 -4
  95. package/sohl/note-schemas.mjs +8 -73
  96. package/types/engine/address-values.d.mts +50 -3
  97. package/types/engine/address.d.mts +21 -0
  98. package/types/engine/anchors.d.mts +49 -2
  99. package/types/engine/art-slots.d.mts +1 -1
  100. package/types/engine/base-compiler.d.mts +13 -4
  101. package/types/engine/calendar-notes.d.mts +7 -0
  102. package/types/engine/content-format.d.mts +52 -0
  103. package/types/engine/content-images.d.mts +5 -1
  104. package/types/engine/content-tables.d.mts +9 -1
  105. package/types/engine/data-keys.d.mts +145 -0
  106. package/types/engine/derived-sections.d.mts +73 -0
  107. package/types/engine/diagnostics.d.mts +15 -0
  108. package/types/engine/event-fields.d.mts +99 -0
  109. package/types/engine/event-rows.d.mts +12 -0
  110. package/types/engine/event-views.d.mts +67 -0
  111. package/types/engine/exported-scene.d.mts +5 -2
  112. package/types/engine/frontmatter-lint.d.mts +3 -3
  113. package/types/engine/generated-sections.d.mts +143 -0
  114. package/types/engine/holdings.d.mts +6 -20
  115. package/types/engine/ids.d.mts +15 -12
  116. package/types/engine/index.d.mts +1 -1
  117. package/types/engine/infobox.d.mts +13 -3
  118. package/types/engine/journals.d.mts +24 -11
  119. package/types/engine/literature-works.d.mts +1 -2
  120. package/types/engine/map-graphviz.d.mts +2 -10
  121. package/types/engine/markdown-expressions.d.mts +12 -3
  122. package/types/engine/near-miss.d.mts +42 -0
  123. package/types/engine/note-addresses.d.mts +7 -1
  124. package/types/engine/note-event-terms.d.mts +68 -0
  125. package/types/engine/note-events.d.mts +71 -0
  126. package/types/engine/note-vocabulary.d.mts +37 -8
  127. package/types/engine/office-holders.d.mts +3 -0
  128. package/types/engine/pdf-index.d.mts +35 -0
  129. package/types/engine/pdf-render.d.mts +8 -2
  130. package/types/engine/place-relation-terms.d.mts +83 -0
  131. package/types/engine/place-relations.d.mts +11 -66
  132. package/types/engine/rank-ladder.d.mts +2 -0
  133. package/types/engine/region-events.d.mts +6 -12
  134. package/types/engine/scenes.d.mts +6 -32
  135. package/types/engine/site-build.d.mts +8 -22
  136. package/types/engine/site-maps.d.mts +15 -7
  137. package/types/engine/sql-tables.d.mts +77 -2
  138. package/types/engine/standing-terms.d.mts +37 -4
  139. package/types/engine/standings.d.mts +14 -9
  140. package/types/engine/wikilink-syntax.d.mts +20 -0
  141. package/types/engine/wikilinks.d.mts +3 -2
  142. package/types/engine/yaml-lint.d.mts +5 -4
  143. package/types/release.d.mts +4 -0
  144. package/docs/guides/government-migration.md +0 -52
  145. package/engine/itinerary-scenes.mjs +0 -166
  146. package/engine/lore-events.mjs +0 -197
  147. package/engine/map-notes.mjs +0 -1309
  148. package/engine/map-raster.mjs +0 -51
  149. package/hugo-theme/layouts/partials/holdings/entry.html +0 -21
  150. package/hugo-theme/layouts/partials/holdings/kind-groups.html +0 -71
  151. package/hugo-theme/layouts/partials/holdings.html +0 -61
  152. package/hugo-theme/layouts/partials/literature-works.html +0 -29
  153. package/hugo-theme/layouts/partials/place-map.html +0 -9
  154. package/types/engine/itinerary-scenes.d.mts +0 -29
  155. package/types/engine/lore-events.d.mts +0 -14
  156. package/types/engine/map-notes.d.mts +0 -297
  157. package/types/engine/map-raster.d.mts +0 -8
@@ -28,7 +28,10 @@
28
28
 
29
29
  import { parseAddress, renderAddress, isAddressTuple } from "./address.mjs";
30
30
  import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
31
- import { encodeAddresses } from "./address-values.mjs";
31
+ import { encodeAddresses, flattenPublishedAddresses } from "./address-values.mjs";
32
+ import { EVENT_COLUMNS, EVENT_SORT_COLUMNS, eventRows } from "./event-rows.mjs";
33
+ import { eventViewSections } from "./event-views.mjs";
34
+ import { appendGeneratedSections, joinSections } from "./generated-sections.mjs";
32
35
  import fs from "node:fs";
33
36
  import os from "node:os";
34
37
  import path from "node:path";
@@ -211,6 +214,13 @@ export async function openNotesDatabase(
211
214
  const connection = await instance.connect();
212
215
  await connection.run("SET threads=1");
213
216
  await createRelations(connection, jsonl, "", audience);
217
+ await createEventRelation(
218
+ connection,
219
+ path.join(base, "events.jsonl"),
220
+ records.map((record) => encodeAddresses(record)),
221
+ "",
222
+ audience,
223
+ );
214
224
 
215
225
  // One schema per declared dependency, so `FROM sohl.notes` reads the notes
216
226
  // that package published. Quoted, because a package id may carry a hyphen
@@ -220,7 +230,26 @@ export async function openNotesDatabase(
220
230
  if (!dep?.id || !dep?.file || !fs.existsSync(dep.file)) continue;
221
231
  const schema = `"${String(dep.id).replace(/"/g, '""')}"`;
222
232
  await connection.run(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
223
- await createRelations(connection, dep.file, `${schema}.`, audience);
233
+ // A published index writes each Address as an object; a fence compares
234
+ // the string an author writes, so each is flattened to it on load.
235
+ const flat = path.join(
236
+ base,
237
+ `dependency-${String(dep.id).replace(/[^a-z0-9]/gi, "_")}.jsonl`,
238
+ );
239
+ const depRecords = fs
240
+ .readFileSync(dep.file, "utf8")
241
+ .split("\n")
242
+ .filter((line) => line.trim())
243
+ .map((line) => flattenPublishedAddresses(JSON.parse(line)));
244
+ fs.writeFileSync(flat, depRecords.map((record) => JSON.stringify(record)).join("\n"));
245
+ await createRelations(connection, flat, `${schema}.`, audience);
246
+ await createEventRelation(
247
+ connection,
248
+ flat.replace(/\.jsonl$/, "-events.jsonl"),
249
+ depRecords,
250
+ `${schema}.`,
251
+ audience,
252
+ );
224
253
  }
225
254
 
226
255
  // The market scale as a relation, so a table prints `village` beside the
@@ -245,6 +274,7 @@ export async function openNotesDatabase(
245
274
  // {@link prepareSqlTables}.
246
275
  await connection.run(`CREATE SCHEMA IF NOT EXISTS ${WITH_STUBS_SCHEMA}`);
247
276
  await connection.run(`CREATE VIEW ${WITH_STUBS_SCHEMA}.notes AS SELECT * FROM entries`);
277
+ await connection.run(`CREATE VIEW ${WITH_STUBS_SCHEMA}.events AS SELECT * FROM main.events`);
248
278
 
249
279
  // Whether this index holds a stub at all, asked once. A corpus with none
250
280
  // pays nothing for the comparison above, which is every package that has
@@ -394,6 +424,42 @@ async function createRelations(connection, file, prefix = "", audience = "all")
394
424
  );
395
425
  }
396
426
 
427
+ /**
428
+ * Create the `events` relation: one row per `data.events` entry, across every
429
+ * note type that carries events, with the columns
430
+ * {@link module:engine/event-rows.EVENT_COLUMNS} names.
431
+ *
432
+ * Its rows are derived from the same records `entries` reads, stubs included —
433
+ * an event a stub states is still an event — and a GM note's events are left
434
+ * out on a public surface exactly as the note is. A corpus with no event at all
435
+ * still has the relation, empty, so a fence over it reports no rows rather than
436
+ * a missing table.
437
+ *
438
+ * @param {object} connection - An open DuckDB connection.
439
+ * @param {string} file - Where to write the rows.
440
+ * @param {object[]} records - Index records, every Address a string.
441
+ * @param {string} prefix - A schema to qualify the view name with.
442
+ * @param {"all"|"public"} audience - Whether to leave out GM notes.
443
+ * @returns {Promise<void>}
444
+ */
445
+ async function createEventRelation(connection, file, records, prefix, audience) {
446
+ const rows = eventRows(
447
+ audience === "public" ? records.filter((record) => !isGmNote(record)) : records,
448
+ );
449
+ if (!rows.length) {
450
+ const columns = EVENT_COLUMNS.map((column) => {
451
+ const type = EVENT_SORT_COLUMNS.includes(column) ? "DOUBLE" : "VARCHAR";
452
+ return `NULL::${type} AS "${column}"`;
453
+ });
454
+ await connection.run(
455
+ `CREATE VIEW ${prefix}events AS SELECT ${columns.join(", ")} WHERE false`,
456
+ );
457
+ return;
458
+ }
459
+ fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join("\n"));
460
+ await connection.run(`CREATE VIEW ${prefix}events AS ${readJsonAuto(file)}`);
461
+ }
462
+
397
463
  /**
398
464
  * The `read_json_auto` clause both the own-notes view and a dependency's use.
399
465
  *
@@ -681,6 +747,106 @@ export async function prepareInlineSqlExpressions(db, sources) {
681
747
  return byNote;
682
748
  }
683
749
 
750
+ /**
751
+ * Each note's generated sections — the derived lists and the event views — see
752
+ * {@link module:engine/generated-sections}.
753
+ *
754
+ * @param {object|undefined} db - From {@link openNotesDatabase}, over
755
+ * `records`; absent, no event view is asked for.
756
+ * @param {object[]} records - Content-index records, of the audience the
757
+ * surface publishes to.
758
+ * @param {string} contentBase - Root of the content tree.
759
+ * @param {object} [opts]
760
+ * @param {string} [opts.only] - One note file to give sections to; absent,
761
+ * every note.
762
+ * @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes, whose
763
+ * places, affiliations and works the lists take in.
764
+ * @param {object} [opts.config] - The resolved configuration.
765
+ * @param {Map<string, Map<string, string>>} [opts.derived] - The derived
766
+ * lists, from an earlier call over the same records, which are not derived
767
+ * again.
768
+ * @returns {Promise<{sections: Map<string, string>, views: Set<string>,
769
+ * derived: Map<string, Map<string, string>>}>} Note file to its sections,
770
+ * joined in order; the files whose sections hold an event view's fence; and
771
+ * the derived lists alone.
772
+ */
773
+ export async function generatedSectionsFor(
774
+ db,
775
+ records,
776
+ contentBase,
777
+ { only, foreignIndex, config, derived: given } = {},
778
+ ) {
779
+ const notes = records.filter((record) => isNoteRecord(record));
780
+ const fileOf = (record) => noteFile(contentBase, record);
781
+ // Imported here for the cycle reason the index is: the derivations read
782
+ // the map world, which reaches the content index.
783
+ const derived =
784
+ given ??
785
+ (await import("./derived-sections.mjs")).derivedSections(notes, {
786
+ fileOf,
787
+ foreignIndex,
788
+ config,
789
+ only,
790
+ });
791
+ const views = db ? await eventViewSections(db, notes, fileOf, only) : new Map();
792
+ const sections = new Map();
793
+ for (const file of new Set([...derived.keys(), ...views.keys()]))
794
+ sections.set(file, joinSections([derived.get(file), views.get(file)]));
795
+ return { sections, views: new Set(views.keys()), derived };
796
+ }
797
+
798
+ /**
799
+ * Join each note's generated sections to its body among the sources a
800
+ * preparation answers, so the fences the event views hold are answered with
801
+ * the author's own, in order after them. A note whose sections hold a fence
802
+ * and whose body holds no directive of its own joins the sources, its body
803
+ * read as {@link prepareTreeSqlTables} reads one.
804
+ *
805
+ * The expansion every surface runs appends the same text again, from the
806
+ * `generated` {@link attachGeneratedSections} sets on the note's results —
807
+ * see {@link module:engine/content-tables.expandContentTables}.
808
+ *
809
+ * @param {{sections: Map<string, string>, views: Set<string>}} generated -
810
+ * From {@link generatedSectionsFor}.
811
+ * @param {Array<{source: string, markdown: string, frontmatter?: object}>} sources -
812
+ * The bodies to answer, extended in place.
813
+ * @returns {void}
814
+ */
815
+ export function joinGeneratedSections(generated, sources) {
816
+ for (const [absPath, text] of generated.sections) {
817
+ const source = sources.find((one) => one.source === absPath);
818
+ if (source) {
819
+ source.markdown = appendGeneratedSections(source.markdown, text);
820
+ continue;
821
+ }
822
+ if (!generated.views.has(absPath)) continue;
823
+ const { body, frontmatter } = parseMarkdownFile(absPath);
824
+ sources.push({
825
+ source: absPath,
826
+ markdown: appendGeneratedSections(body, text),
827
+ frontmatter,
828
+ });
829
+ }
830
+ }
831
+
832
+ /**
833
+ * Record each note's generated sections on its prepared results, where the
834
+ * table expansion reads them. A note with no results of its own is given an
835
+ * empty set carrying them.
836
+ *
837
+ * @param {Map<string, object[]>} prepared - From {@link prepareSqlTables}.
838
+ * @param {{sections: Map<string, string>}} generated - From
839
+ * {@link generatedSectionsFor}.
840
+ * @returns {void}
841
+ */
842
+ export function attachGeneratedSections(prepared, generated) {
843
+ for (const [absPath, text] of generated.sections) {
844
+ let results = prepared.get(absPath);
845
+ if (!results) prepared.set(absPath, (results = []));
846
+ results.generated = text;
847
+ }
848
+ }
849
+
684
850
  /**
685
851
  * Answer every `sql` directive in a content tree.
686
852
  *
@@ -706,13 +872,16 @@ export async function prepareInlineSqlExpressions(db, sources) {
706
872
  * A command that also builds a link index holds them already, and deriving
707
873
  * them twice is the duplicated-corpus failure this closes.
708
874
  * @param {"all"|"public"} [opts.audience] - Whether to exclude GM notes.
875
+ * @param {Map<string, object>} [opts.foreignIndex] - The fetched indexes the
876
+ * generated sections take in, where the caller holds them already; absent,
877
+ * they are read from the cache.
709
878
  * @returns {Promise<Map<string, object[]>|undefined>} Results by note path,
710
879
  * carrying `inline` and `pageLists` beside them, or nothing when the tree
711
- * holds no such directive.
880
+ * holds no such directive and no note is given a generated section.
712
881
  */
713
882
  export async function prepareTreeSqlTables(
714
883
  contentBase,
715
- { config, skipDirectories, records, audience = "all" } = {},
884
+ { config, skipDirectories, records, audience = "all", foreignIndex } = {},
716
885
  ) {
717
886
  // Imported here rather than at module scope: the index reaches the pack
718
887
  // compilers through `manifest-emit` → `journals`, so a static import from a
@@ -745,20 +914,49 @@ export async function prepareTreeSqlTables(
745
914
  )
746
915
  sources.push({ source: absPath, markdown: body, frontmatter });
747
916
  }
748
- if (!sources.length) return undefined;
917
+ // A note's event views are fences too, so a tree whose notes carry events
918
+ // asks a query even where no author wrote one.
919
+ const hasEvents = indexRecords.some(
920
+ (record) =>
921
+ isNoteRecord(record) &&
922
+ Array.isArray(record.data?.events) &&
923
+ record.data.events.length > 0,
924
+ );
925
+ // The derived lists read no database, so they are found before one is
926
+ // opened, and a tree whose notes are given only those opens none.
927
+ let foreign = foreignIndex;
928
+ if (!foreign) {
929
+ try {
930
+ const { loadForeignIndexes } = await import("./metadata-index.mjs");
931
+ foreign =
932
+ config ? loadForeignIndexes(config, [config.contentPackage]).index : new Map();
933
+ } catch {
934
+ foreign = new Map();
935
+ }
936
+ }
937
+ const derivedOnly = await generatedSectionsFor(undefined, indexRecords, contentBase, {
938
+ foreignIndex: foreign,
939
+ config,
940
+ });
941
+ if (!sources.length && !hasEvents && !derivedOnly.sections.size) return undefined;
749
942
 
750
943
  // A page list is a filter over the records, so it needs no database. A tree
751
944
  // whose only corpus-reading directive is one therefore opens none — which
752
945
  // is the same bargain the early return above makes for a tree with no
753
946
  // directive at all.
754
- const needsDatabase = sources.some(
755
- ({ markdown, frontmatter }) =>
756
- findSqlBlocks(markdown).length || sqlQueriesInMarkdown(markdown, frontmatter).length,
757
- );
947
+ const needsDatabase =
948
+ hasEvents ||
949
+ sources.some(
950
+ ({ markdown, frontmatter }) =>
951
+ findSqlBlocks(markdown).length ||
952
+ sqlQueriesInMarkdown(markdown, frontmatter).length,
953
+ );
758
954
  if (!needsDatabase) {
759
955
  const pageOnly = new Map();
956
+ joinGeneratedSections(derivedOnly, sources);
760
957
  pageOnly.inline = new Map();
761
958
  pageOnly.pageLists = preparePageLists(indexRecords, sources);
959
+ attachGeneratedSections(pageOnly, derivedOnly);
762
960
  return pageOnly;
763
961
  }
764
962
  // A cell links only where the address it would emit resolves, so a table
@@ -790,11 +988,16 @@ export async function prepareTreeSqlTables(
790
988
  }
791
989
  const db = await openNotesDatabase(indexRecords, { dependencies, audience });
792
990
  try {
991
+ const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
992
+ derived: derivedOnly.derived,
993
+ });
994
+ joinGeneratedSections(generated, sources);
793
995
  const prepared = await prepareSqlTables(db, sources, {
794
996
  linkable: (ref) => addresses.has(renderAddress(ref)),
795
997
  records: indexRecords,
796
998
  });
797
999
  prepared.inline = await prepareInlineSqlExpressions(db, sources);
1000
+ attachGeneratedSections(prepared, generated);
798
1001
  return prepared;
799
1002
  } finally {
800
1003
  await db.close();
@@ -1,4 +1,26 @@
1
- /* SPDX-License-Identifier: GPL-3.0-or-later */
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
+ * The terms of a body's standing — what a membership carries, and the rungs
16
+ * and posts a body confers — as inner-key declarations.
17
+ *
18
+ * A leaf module, importing nothing: the note vocabulary reads these
19
+ * declarations as it loads, and the checks that judge what the values mean
20
+ * reach the vocabulary through the Address reader.
21
+ *
22
+ * @module
23
+ */
2
24
 
3
25
  /**
4
26
  * The note type a standing is held in.
@@ -10,10 +32,127 @@
10
32
  export const STANDING_BODY_TYPES = Object.freeze(["affiliation"]);
11
33
 
12
34
  /**
13
- * The two keys one standing carries.
35
+ * The keys one standing carries, as inner-key declarations.
36
+ *
37
+ * `rank` is the level on the body's own `governance.ranks` ladder, and every
38
+ * membership states one. `office` is a key of its `governance.offices` map, and
39
+ * is stated only where the being holds a post.
40
+ *
41
+ * @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
42
+ */
43
+ export const STANDING_FIELDS = Object.freeze([
44
+ Object.freeze({
45
+ name: "rank",
46
+ kind: "integer",
47
+ required: true,
48
+ shape:
49
+ "the level on that body's own ladder, as a whole number — an ordinary " +
50
+ "member is `1`, and `0` is the rung for someone cast out",
51
+ describe: "The level on the body's own `governance.ranks` ladder.",
52
+ }),
53
+ Object.freeze({
54
+ name: "office",
55
+ kind: "string",
56
+ shape: "a post from that body's `governance.offices`",
57
+ describe: "A post the body names in its `governance.offices`, where one is held.",
58
+ }),
59
+ ]);
60
+
61
+ /** The keys of {@link STANDING_FIELDS}, in declared order. */
62
+ export const STANDING_KEYS = Object.freeze(STANDING_FIELDS.map((field) => field.name));
63
+
64
+ /**
65
+ * The keys a rung carries, as inner-key declarations.
66
+ *
67
+ * @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
68
+ */
69
+ export const RUNG_FIELDS = Object.freeze([
70
+ Object.freeze({
71
+ name: "level",
72
+ kind: "integer",
73
+ required: true,
74
+ shape: "a whole number — the rung's position on this body's own ladder",
75
+ describe: "The rung's position on the ladder; a member's `rank` names it.",
76
+ }),
77
+ Object.freeze({
78
+ name: "title",
79
+ kind: "string",
80
+ required: true,
81
+ shape: "what the standing is called",
82
+ describe: "What the standing is called.",
83
+ }),
84
+ Object.freeze({
85
+ name: "description",
86
+ kind: "string",
87
+ required: true,
88
+ shape: "what the standing is",
89
+ describe: "What the standing is.",
90
+ }),
91
+ Object.freeze({
92
+ name: "lore",
93
+ kind: "address",
94
+ shape: "a lore Address",
95
+ describe: "Lore saying more about the standing than a description holds.",
96
+ }),
97
+ ]);
98
+
99
+ /**
100
+ * The keys one row of an office's `holders` carries, as inner-key declarations.
101
+ *
102
+ * @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
103
+ */
104
+ export const HOLDER_FIELDS = Object.freeze([
105
+ Object.freeze({
106
+ name: "being",
107
+ kind: "address",
108
+ required: true,
109
+ shape: "a being Address",
110
+ describe: "Who held the office.",
111
+ }),
112
+ Object.freeze({
113
+ name: "start",
114
+ kind: "date",
115
+ shape: "a date",
116
+ describe: "When the term began; unstated, it began before anything recorded.",
117
+ }),
118
+ Object.freeze({
119
+ name: "end",
120
+ kind: "date",
121
+ shape: "a date",
122
+ describe: "When the term ended; unstated, the being holds the office now.",
123
+ }),
124
+ Object.freeze({
125
+ name: "contested",
126
+ kind: "boolean",
127
+ shape: "`true` or `false`",
128
+ describe: "The term may overlap another holder's.",
129
+ }),
130
+ ]);
131
+
132
+ /**
133
+ * The keys an office written as a map carries. An office may instead be its
134
+ * description alone, as a string.
14
135
  *
15
- * `rank` is the level on the body's own `governance.ranks` ladder, as a number.
16
- * `office` is a key of its `governance.offices` map, as a string. Both are
17
- * optional — a membership with neither is the ordinary shape.
136
+ * @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
18
137
  */
19
- export const STANDING_KEYS = Object.freeze(["rank", "office"]);
138
+ export const OFFICE_FIELDS = Object.freeze([
139
+ Object.freeze({
140
+ name: "description",
141
+ kind: "string",
142
+ required: true,
143
+ shape: "a string",
144
+ describe: "What the office is.",
145
+ }),
146
+ Object.freeze({
147
+ name: "holders",
148
+ kind: "list",
149
+ required: true,
150
+ shape: "a list of `{ being, start?, end?, contested? }`",
151
+ entries: Object.freeze({
152
+ kind: "map",
153
+ shape: "a map — `{ being, start?, end?, contested? }`",
154
+ fields: HOLDER_FIELDS,
155
+ }),
156
+ describe: "Who has held it, each with the dates of the term.",
157
+ }),
158
+ ]);
@@ -55,9 +55,10 @@ import { acceptsType, isAddressTuple, parseAddress, renderAddress } from "./addr
55
55
  import { AddressEntries } from "./address-values.mjs";
56
56
  import { slugify } from "./content-slug.mjs";
57
57
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
58
- import { STANDING_BODY_TYPES, STANDING_KEYS } from "./standing-terms.mjs";
58
+ import { fitsKind } from "./data-keys.mjs";
59
+ import { STANDING_BODY_TYPES, STANDING_FIELDS, STANDING_KEYS } from "./standing-terms.mjs";
59
60
 
60
- export { STANDING_BODY_TYPES, STANDING_KEYS };
61
+ export { STANDING_BODY_TYPES, STANDING_FIELDS, STANDING_KEYS };
61
62
 
62
63
  /** Whether a value is a plain mapping. */
63
64
  function mapping(value) {
@@ -232,31 +233,35 @@ export function standingsDigest(fm) {
232
233
  }
233
234
 
234
235
  /**
235
- * How a standing reads beside the body that confers it.
236
+ * What a standing says before the body that confers it — `War Chief, Hárár
237
+ * (5), of ` — or `""` where no standing is held.
236
238
  *
237
239
  * The office leads, because it is the more particular of the two: a reader
238
- * meeting "War Chief" has been told the rung as well. The body closes the
239
- * phrase, which is the whole point of keying the entry by it — a standing with
240
- * no body named is the lossy form.
240
+ * meeting "War Chief" has been told the rung as well. The rung follows as its
241
+ * title with its level in parentheses, read from the body's own ladder; where
242
+ * the ladder names no rung at that level, or none is available, it reads as
243
+ * `Rank 5` and the number is not repeated. The body closes the phrase, which is
244
+ * the whole point of keying the entry by it — a standing with no body named is
245
+ * the lossy form.
241
246
  *
242
247
  * No article is supplied. One body's name reads with a "the" in front of it and
243
248
  * the next does not, and inventing one is this build editing the corpus.
244
249
  *
245
250
  * @param {object} standing - `{rank, office}`, either or both absent.
246
- * @param {string} body - What the body is called.
247
251
  * @param {{ranks?: Record<string, string>}} [digest] - The body's ladder.
248
- * @returns {string} The phrase, or the body's name where no standing is held.
252
+ * @returns {string} The words before the body's name, ending `of `.
249
253
  */
250
- export function standingPhrase(standing, body, digest) {
254
+ export function standingLead(standing, digest) {
251
255
  const parts = [];
252
256
  if (typeof standing?.office === "string" && standing.office.trim())
253
257
  parts.push(standing.office.trim());
254
- const level = Number(standing?.rank);
255
- if (Number.isFinite(level)) {
258
+ const rank = standing?.rank;
259
+ const level = typeof rank === "number" || typeof rank === "string" ? Number(rank) : NaN;
260
+ if (rank !== "" && Number.isFinite(level)) {
256
261
  const title = digest?.ranks?.[String(level)];
257
- parts.push(title ? title : `Rank ${level}`);
262
+ parts.push(title ? `${title} (${level})` : `Rank ${level}`);
258
263
  }
259
- return parts.length ? `${parts.join(", ")}, of ${body}` : body;
264
+ return parts.length ? `${parts.join(", ")}, of ` : "";
260
265
  }
261
266
 
262
267
  /**
@@ -267,7 +272,8 @@ export function standingPhrase(standing, body, digest) {
267
272
  *
268
273
  * 1. the value is a map keyed by Address (or the list this window still takes);
269
274
  * 2. every key names an affiliation that resolves, once;
270
- * 3. the entry states a `rank`;
275
+ * 3. the entry states a standing at all — what a stated standing holds is the
276
+ * inner-key check's, read from {@link STANDING_FIELDS};
271
277
  * 4. a `rank` is a level the named body's `governance.ranks` declares;
272
278
  * 5. an `office` is a key of that body's `governance.offices`.
273
279
  *
@@ -320,35 +326,20 @@ export function checkStandings(note, { index } = {}) {
320
326
  const named = resolved.address ?? String(body);
321
327
  // An entry with nothing after its key, and an emptied map arriving from
322
328
  // the property editor as `[]`, say exactly what an authored `{}` says:
323
- // this being belongs to this body, and no standing in it is stated.
329
+ // this being belongs to this body, and no standing in it is stated. The
330
+ // inner-key check reads a map and has nothing to walk in either, so the
331
+ // absence is reported here.
324
332
  if (
325
333
  standing === undefined ||
326
334
  standing === null ||
327
335
  (Array.isArray(standing) && standing.length === 0)
328
336
  ) {
329
- findings.push(...absentRankFindings(note, {}, named, path));
337
+ findings.push(...absentRankFindings(note, named, path));
330
338
  continue;
331
339
  }
332
- if (!mapping(standing)) {
333
- findings.push({
334
- ...position(note, path),
335
- message:
336
- `\`data.affiliations\` entry for ${named} must hold that body's standing ` +
337
- "as a map of `rank` and `office`, but reads " +
338
- JSON.stringify(standing),
339
- });
340
- continue;
341
- }
342
- for (const key of Object.keys(standing)) {
343
- if (STANDING_KEYS.includes(key)) continue;
344
- findings.push({
345
- ...position(note, [...path, key], true),
346
- message:
347
- `\`data.affiliations\` entry for ${named} has unknown key \`${key}\`; a ` +
348
- `standing holds ${STANDING_KEYS.map((name) => `\`${name}\``).join(" and ")}`,
349
- });
350
- }
351
- findings.push(...absentRankFindings(note, standing, named, path));
340
+ // A standing of the wrong shape, an undeclared key and a missing `rank`
341
+ // are the inner-key check's findings, made from `STANDING_FIELDS`.
342
+ if (!mapping(standing)) continue;
352
343
  findings.push(...rankFindings(note, standing, named, path, resolved));
353
344
  findings.push(...officeFindings(note, standing, named, path, resolved));
354
345
  }
@@ -447,27 +438,22 @@ function bodyFindings(note, body, path, isKey, found) {
447
438
  * @param {Array<string|number>} path - The entry's frontmatter path.
448
439
  * @returns {object[]} The finding, or nothing.
449
440
  */
450
- function absentRankFindings(note, standing, body, path) {
451
- if (standing.rank !== undefined && standing.rank !== null) return [];
452
- const office = typeof standing.office === "string" ? standing.office.trim() : "";
441
+ function absentRankFindings(note, body, path) {
453
442
  return [
454
443
  {
455
444
  ...position(note, path, true),
456
445
  message:
457
- `\`data.affiliations\` entry for ${body} states no \`rank\`` +
458
- (office ?
459
- `, and the office ${JSON.stringify(office)} is not one — an office ` +
460
- `distinguishes a person within a standing rather than standing in ` +
461
- `for one`
462
- : " — a being that belongs to a body holds some standing in it") +
463
- ". An ordinary member is `1`, and `0` is the rung for someone cast out",
446
+ `\`data.affiliations\` entry for ${body} states no \`rank\` — a being ` +
447
+ "that belongs to a body holds some standing in it. An ordinary member " +
448
+ "is `1`, and `0` is the rung for someone cast out",
464
449
  },
465
450
  ];
466
451
  }
467
452
 
468
453
  /** Whether a `rank` is a rung the named body confers. */
469
454
  function rankFindings(note, standing, body, path, resolved) {
470
- if (standing.rank === undefined || standing.rank === null) return [];
455
+ // Absent, or not a whole number: the inner-key check's finding.
456
+ if (!fitsKind(standing.rank ?? null, "integer")) return [];
471
457
  const at = position(note, [...path, "rank"]);
472
458
  const level = typeof standing.rank === "number" ? standing.rank : Number(standing.rank);
473
459
  if (typeof standing.rank !== "number" || !Number.isInteger(level))
@@ -504,9 +490,11 @@ function rankFindings(note, standing, body, path, resolved) {
504
490
 
505
491
  /** Whether an `office` is a post the named body names. */
506
492
  function officeFindings(note, standing, body, path, resolved) {
507
- if (standing.office === undefined || standing.office === null) return [];
493
+ // Absent is a membership with no post; a value that is not text is the
494
+ // inner-key check's finding.
495
+ if (typeof standing.office !== "string") return [];
508
496
  const at = position(note, [...path, "office"]);
509
- if (typeof standing.office !== "string" || !standing.office.trim())
497
+ if (!standing.office.trim())
510
498
  return [
511
499
  {
512
500
  ...at,
@@ -61,7 +61,13 @@ import { slugify } from "./content-slug.mjs";
61
61
  // resolution: the same rule that names a page also names an anchor within it.
62
62
  /** KB heading/anchor slug: lowercase, non-alphanumerics to single hyphens. */
63
63
  export { slugify };
64
- import { authoredLabel, WIKILINK, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
64
+ import {
65
+ authoredLabel,
66
+ WIKILINK,
67
+ isEventAnchor,
68
+ isSamePage,
69
+ parseWikilink,
70
+ } from "./wikilink-syntax.mjs";
65
71
  import { resolveEmbeds } from "./content-embeds.mjs";
66
72
 
67
73
  /**
@@ -424,6 +430,13 @@ export function resolveWebWikilinks(body, ctx) {
424
430
  // Checked against this page's own anchors, exactly as a link into
425
431
  // another page is: a self-link naming a section the page does not
426
432
  // declare is as dead as one naming a section nowhere does.
433
+ if (isEventAnchor(ctx, anchor)) {
434
+ return report(
435
+ all,
436
+ { target, reason: "event-anchor", anchor, addressed: true },
437
+ label ?? anchor,
438
+ );
439
+ }
427
440
  if (!hasAnchor(ctx.anchors, anchor)) {
428
441
  return report(
429
442
  all,
@@ -459,6 +472,13 @@ export function resolveWebWikilinks(body, ctx) {
459
472
  // hash any slug into something that resolves, so an undeclared
460
473
  // one otherwise joins onto the URL unchecked and dead-ends for
461
474
  // the reader.
475
+ if (anchor && isEventAnchor(hit, anchor)) {
476
+ return report(
477
+ all,
478
+ { target, reason: "event-anchor", anchor, addressed: true },
479
+ label ?? hit.name ?? target,
480
+ );
481
+ }
462
482
  if (anchor && hit.url && !hasAnchor(hit.anchors, anchor)) {
463
483
  return report(
464
484
  all,