@heroiclands/package-build 22.28.0 → 23.1.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 (156) hide show
  1. package/CHANGELOG.md +72 -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 +125 -142
  6. package/docs/authoring/assets.md +1 -1
  7. package/docs/authoring/dates-and-calendars.md +12 -6
  8. package/docs/authoring/first-note.md +1 -1
  9. package/docs/authoring/frontmatter.md +2 -2
  10. package/docs/authoring/links-and-markup.md +145 -29
  11. package/docs/commands.md +8 -10
  12. package/docs/configuration.md +13 -11
  13. package/docs/content-format.md +3 -3
  14. package/docs/guides/book.md +59 -4
  15. package/docs/guides/icons.md +4 -5
  16. package/docs/guides/packs.md +6 -0
  17. package/docs/guides/reference-pages.md +21 -1
  18. package/docs/guides/release-and-deploy.md +17 -1
  19. package/docs/guides/site.md +17 -18
  20. package/docs/reference/format-details.md +904 -275
  21. package/docs/reference/note-types.md +282 -91
  22. package/docs/reference/theme-pages.md +58 -66
  23. package/engine/address-values.mjs +124 -4
  24. package/engine/address.mjs +44 -3
  25. package/engine/anchors.mjs +103 -3
  26. package/engine/art-slots.mjs +2 -9
  27. package/engine/base-compiler.mjs +25 -6
  28. package/engine/calendar-artifacts.mjs +4 -1
  29. package/engine/calendar-notes.mjs +166 -102
  30. package/engine/content-format-check.mjs +2 -3
  31. package/engine/content-format.mjs +44 -1
  32. package/engine/content-format.yaml +84 -16
  33. package/engine/content-images.mjs +10 -1
  34. package/engine/content-index.mjs +37 -9
  35. package/engine/content-links.mjs +24 -5
  36. package/engine/content-tables.mjs +24 -3
  37. package/engine/data-keys.mjs +347 -0
  38. package/engine/derived-sections.mjs +322 -0
  39. package/engine/diagnostics.mjs +28 -0
  40. package/engine/event-fields.mjs +187 -0
  41. package/engine/event-rows.mjs +126 -0
  42. package/engine/event-views.mjs +305 -0
  43. package/engine/exported-scene.mjs +29 -53
  44. package/engine/frontmatter-lint.mjs +103 -77
  45. package/engine/frontmatter.mjs +7 -2
  46. package/engine/generate.mjs +53 -0
  47. package/engine/generated-sections.mjs +223 -0
  48. package/engine/heading-attributes.mjs +4 -2
  49. package/engine/helpers.mjs +12 -1
  50. package/engine/holdings.mjs +12 -33
  51. package/engine/ids.mjs +17 -11
  52. package/engine/index.mjs +4 -4
  53. package/engine/infobox-render.mjs +21 -6
  54. package/engine/infobox.mjs +217 -99
  55. package/engine/journals.mjs +104 -47
  56. package/engine/literature-notes.mjs +136 -0
  57. package/engine/literature-works.mjs +161 -0
  58. package/engine/map-graphviz.mjs +4 -18
  59. package/engine/markdown-expressions.mjs +116 -11
  60. package/engine/metadata-index.mjs +34 -0
  61. package/engine/near-miss.mjs +90 -0
  62. package/engine/note-addresses.mjs +75 -19
  63. package/engine/note-dates.mjs +5 -3
  64. package/engine/note-event-terms.mjs +364 -0
  65. package/engine/note-events.mjs +823 -0
  66. package/engine/note-vocabulary.mjs +195 -92
  67. package/engine/office-holders.mjs +20 -54
  68. package/engine/packagebuild-index.mjs +16 -1
  69. package/engine/pdf-build.mjs +97 -60
  70. package/engine/pdf-index.mjs +263 -0
  71. package/engine/pdf-render.mjs +47 -5
  72. package/engine/place-relation-terms.mjs +174 -0
  73. package/engine/place-relations.mjs +72 -160
  74. package/engine/rank-ladder.mjs +14 -41
  75. package/engine/reckoning-markers.mjs +2 -0
  76. package/engine/region-events.mjs +6 -12
  77. package/engine/scenes.mjs +73 -468
  78. package/engine/site-build.mjs +74 -85
  79. package/engine/site-index.mjs +3 -1
  80. package/engine/site-maps.mjs +43 -29
  81. package/engine/site-preview.mjs +40 -31
  82. package/engine/social-ties.mjs +2 -1
  83. package/engine/sql-tables.mjs +212 -9
  84. package/engine/standing-terms.mjs +145 -6
  85. package/engine/standings.mjs +37 -49
  86. package/engine/stub-lint.mjs +6 -0
  87. package/engine/web-wikilinks.mjs +21 -1
  88. package/engine/wikilink-syntax.mjs +34 -4
  89. package/engine/wikilinks.mjs +27 -2
  90. package/hugo-theme/layouts/partials/infobox/entry.html +4 -1
  91. package/hugo-theme/layouts/partials/page-body.html +0 -2
  92. package/hugo-theme/static/css/style.css +25 -55
  93. package/package.json +1 -2
  94. package/packagebuild-metadata.jsonl +13 -13
  95. package/sohl/default-item-art.mjs +6 -6
  96. package/sohl/note-schemas.mjs +8 -73
  97. package/types/engine/address-values.d.mts +50 -3
  98. package/types/engine/address.d.mts +21 -0
  99. package/types/engine/anchors.d.mts +49 -2
  100. package/types/engine/art-slots.d.mts +1 -1
  101. package/types/engine/base-compiler.d.mts +13 -4
  102. package/types/engine/calendar-notes.d.mts +7 -0
  103. package/types/engine/content-format.d.mts +52 -0
  104. package/types/engine/content-images.d.mts +5 -1
  105. package/types/engine/content-tables.d.mts +9 -1
  106. package/types/engine/data-keys.d.mts +145 -0
  107. package/types/engine/derived-sections.d.mts +73 -0
  108. package/types/engine/diagnostics.d.mts +15 -0
  109. package/types/engine/event-fields.d.mts +99 -0
  110. package/types/engine/event-rows.d.mts +12 -0
  111. package/types/engine/event-views.d.mts +67 -0
  112. package/types/engine/exported-scene.d.mts +5 -2
  113. package/types/engine/frontmatter-lint.d.mts +3 -3
  114. package/types/engine/generated-sections.d.mts +143 -0
  115. package/types/engine/holdings.d.mts +6 -20
  116. package/types/engine/ids.d.mts +15 -12
  117. package/types/engine/index.d.mts +1 -1
  118. package/types/engine/infobox.d.mts +13 -3
  119. package/types/engine/journals.d.mts +24 -11
  120. package/types/engine/literature-notes.d.mts +29 -0
  121. package/types/engine/literature-works.d.mts +67 -0
  122. package/types/engine/map-graphviz.d.mts +2 -10
  123. package/types/engine/markdown-expressions.d.mts +12 -3
  124. package/types/engine/near-miss.d.mts +42 -0
  125. package/types/engine/note-addresses.d.mts +7 -1
  126. package/types/engine/note-event-terms.d.mts +68 -0
  127. package/types/engine/note-events.d.mts +71 -0
  128. package/types/engine/note-vocabulary.d.mts +37 -8
  129. package/types/engine/office-holders.d.mts +3 -0
  130. package/types/engine/pdf-index.d.mts +35 -0
  131. package/types/engine/pdf-render.d.mts +8 -2
  132. package/types/engine/place-relation-terms.d.mts +83 -0
  133. package/types/engine/place-relations.d.mts +11 -66
  134. package/types/engine/rank-ladder.d.mts +2 -0
  135. package/types/engine/region-events.d.mts +6 -12
  136. package/types/engine/scenes.d.mts +6 -32
  137. package/types/engine/site-build.d.mts +8 -22
  138. package/types/engine/site-maps.d.mts +15 -7
  139. package/types/engine/sql-tables.d.mts +77 -2
  140. package/types/engine/standing-terms.d.mts +37 -4
  141. package/types/engine/standings.d.mts +14 -9
  142. package/types/engine/wikilink-syntax.d.mts +20 -0
  143. package/types/engine/wikilinks.d.mts +3 -2
  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/place-map.html +0 -9
  153. package/types/engine/itinerary-scenes.d.mts +0 -29
  154. package/types/engine/lore-events.d.mts +0 -14
  155. package/types/engine/map-notes.d.mts +0 -297
  156. package/types/engine/map-raster.d.mts +0 -8
@@ -56,7 +56,7 @@
56
56
  * a side rail on wide screens and inline boxes on narrow screens.
57
57
  * 2. **It contains no image.** A picture is authored in the text with its own
58
58
  * directive, and its position governs. {@link NOTE_FIELD_PRESENTATION}
59
- * withholds the art slots and `overlay` for that reason and no other.
59
+ * withholds the art slots for that reason and no other.
60
60
  * 3. **A section is the unit that flows.** Sections are whole and unbreakable;
61
61
  * the panel breaks between them. This is what lets a long box cross a
62
62
  * column or page boundary without splitting a stat grid.
@@ -119,20 +119,16 @@ import { NOTE_VOCABULARY, dataFields } from "./note-vocabulary.mjs";
119
119
  import { readAliasedField } from "./retired-fields.mjs";
120
120
  import { subtypeRow } from "./document-subtypes.mjs";
121
121
  import { authoredKey } from "./system-block.mjs";
122
- import {
123
- formatDateInCalendar,
124
- formatNoteDate,
125
- parseNoteDate,
126
- resolvedDateFields,
127
- } from "./note-dates.mjs";
122
+ import { formatDateInCalendar, formatNoteDate, parseNoteDate } from "./note-dates.mjs";
128
123
  import { displayBeingHeight, displayBeingWeight } from "./being-measurements.mjs";
129
124
  import {
130
125
  officeAnchor,
131
126
  officeRoster,
132
127
  rankAnchor,
133
128
  readStandings,
134
- standingPhrase,
129
+ standingLead,
135
130
  } from "./standings.mjs";
131
+ import { AddressEntries } from "./address-values.mjs";
136
132
 
137
133
  /**
138
134
  * How a section arranges what it holds.
@@ -260,10 +256,11 @@ export const GEAR_UNITS = Object.freeze({
260
256
  * Three properties, all optional:
261
257
  *
262
258
  * - `label` — what the row is called. Absent means the key, humanised.
263
- * - `withheld` — why the field carries no row. Two reasons only: it is
259
+ * - `withheld` — why the field carries no row. Three reasons only: it is
264
260
  * machinery — something that steers a build or an interface rather than
265
- * describing the subject — or it is an image, which rule 2 keeps out of the
266
- * box.
261
+ * describing the subject, which includes a calendar's definition and a map's
262
+ * canvas — it is an image, which rule 2 keeps out of the box, or it is a
263
+ * source system's own detail, which no surface's box shows.
267
264
  * - `unit` — what the quantity is measured in, appended to the value verbatim.
268
265
  * See {@link applyUnit}.
269
266
  * - `group` / `phrase` — the field composes into one row with its group mates
@@ -287,15 +284,39 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
287
284
  templatePriority: Object.freeze({
288
285
  withheld: "template machinery, not a fact about the subject",
289
286
  }),
287
+ id: Object.freeze({ withheld: "document identity machinery, not a fact about the subject" }),
288
+ pack: Object.freeze({ withheld: "compendium routing machinery, not a fact about the subject" }),
289
+ packFolder: Object.freeze({
290
+ withheld: "compendium routing machinery, not a fact about the subject",
291
+ }),
292
+ harnworld: Object.freeze({
293
+ withheld: "HârnWorld source detail, which no surface's box shows",
294
+ }),
290
295
  color: Object.freeze({
291
296
  withheld: "sidebar machinery, not a fact about the subject",
292
297
  }),
293
298
  icon: Object.freeze({ withheld: "an image, which the box never carries" }),
294
299
  tokenIcon: Object.freeze({ withheld: "an image, which the box never carries" }),
295
- bgImage: Object.freeze({ withheld: "an image, which the box never carries" }),
296
300
  banner: Object.freeze({ withheld: "an image, which the box never carries" }),
297
- overlay: Object.freeze({ withheld: "an image, which the box never carries" }),
298
- "lore.events": Object.freeze({ withheld: "chronology machinery, not a summary row" }),
301
+ events: Object.freeze({ withheld: "chronology machinery, which no infobox row draws on" }),
302
+ made: Object.freeze({
303
+ withheld: "chronology machinery — an event, which no infobox row draws on",
304
+ }),
305
+ lost: Object.freeze({
306
+ withheld: "chronology machinery — an event, which no infobox row draws on",
307
+ }),
308
+ // A calendar's definition is what dates are read through, not a fact a
309
+ // summary row can carry: a list of months or eras is the calendar itself.
310
+ "lore.months": Object.freeze({ withheld: "calendar machinery, not a summary row" }),
311
+ "lore.weekdays": Object.freeze({ withheld: "calendar machinery, not a summary row" }),
312
+ "lore.seasons": Object.freeze({ withheld: "calendar machinery, not a summary row" }),
313
+ "lore.namedDays": Object.freeze({ withheld: "calendar machinery, not a summary row" }),
314
+ "lore.eras": Object.freeze({ withheld: "calendar machinery, not a summary row" }),
315
+ "lore.formats": Object.freeze({ withheld: "calendar machinery, not a summary row" }),
316
+ // A map's canvas — the exported Scene and its asset fixups — is the
317
+ // Scene itself, not a fact a summary row can carry.
318
+ "map.scene": Object.freeze({ withheld: "canvas machinery, not a summary row" }),
319
+ "map.fixup": Object.freeze({ withheld: "canvas machinery, not a summary row" }),
299
320
 
300
321
  assocSkill: Object.freeze({ label: "Skill" }),
301
322
  assocAffiliation: Object.freeze({ label: "Affiliation" }),
@@ -569,66 +590,63 @@ export function valueKindOf(field, value) {
569
590
  * @param {(ref: unknown, hint?: object) => object|undefined} resolve - The
570
591
  * medium's resolver.
571
592
  * @param {object} [hint] - What the reference is expected to name.
593
+ * @param {boolean} [verbatim] - The value is a symbol, shown exactly as written.
572
594
  * @returns {unknown} The row value, shaped for the kind.
573
595
  */
574
- function rowValue(kind, raw, resolve, hint) {
596
+ function rowValue(kind, raw, resolve, hint, verbatim = false) {
575
597
  if (kind === "link") return linkValue(raw, resolve, hint);
576
598
  if (kind === "links") {
577
599
  return (Array.isArray(raw) ? raw : [raw])
578
600
  .filter(hasValue)
579
601
  .map((r) => linkValue(r, resolve, hint));
580
602
  }
603
+ // A symbol such as a unit is shown as written, never recased.
604
+ const shown = (value) => (verbatim ? String(value) : presentValue(value));
581
605
  if (kind === "list") {
582
- return (Array.isArray(raw) ? raw : [raw]).filter(hasValue).map((v) => presentValue(v));
606
+ return (Array.isArray(raw) ? raw : [raw]).filter(hasValue).map((v) => shown(v));
583
607
  }
584
608
  if (kind === "number") return raw;
585
- return presentValue(raw);
609
+ return shown(raw);
586
610
  }
587
611
 
588
612
  /**
589
- * A being's memberships, each with the standing it holds there.
613
+ * The section id and title a being's memberships are drawn under.
614
+ *
615
+ * @type {Readonly<{id: string, label: string}>}
616
+ */
617
+ export const MEMBERSHIPS_SECTION = Object.freeze({ id: "affiliations", label: "Affiliations" });
618
+
619
+ /**
620
+ * A being's memberships, one entry each, in the order the note writes them.
590
621
  *
591
- * One row, one entry per body, and the standing reads inside the entry rather
592
- * than beside it — "War Chief, of Vrystwald Tribes" rather than a rank link
593
- * with nothing saying which body confers it.
622
+ * Each entry is the line a reader meets — `War Chief, Hárár (5), of Vrystwald
623
+ * Tribes` — with the body's name as the link and the standing before it as the
624
+ * entry's `lead`, drawn as plain text. The rung's title is read from the body's
625
+ * own ladder, carried on the reference as its `standings` digest, so a renamed
626
+ * rung renames every line pointing at it. A body that publishes no page keeps
627
+ * its name as plain text, and a membership in the list form is the name alone.
594
628
  *
595
- * **The office carries the link where the body's page anchors it.** An office's
596
- * description is already declared in the body's own `governance.offices`, and
597
- * the row that prints it is anchored by the same derivation, so the string a
598
- * being wrote reaches the description without anyone authoring a note about the
599
- * post. In a medium with no page to reach — a compendium journal links by
600
- * document UUID — the entry keeps the body's own document, so the reader still
601
- * lands somewhere.
629
+ * The entries are a `list` section of their own rather than one `links` row,
630
+ * because a line already holds commas and a `links` row joins its values with
631
+ * them: one line per membership is what keeps two memberships apart.
602
632
  *
603
633
  * @param {object} field - The declaration.
604
634
  * @param {unknown} raw - The authored value, in either form.
605
635
  * @param {(ref: unknown, hint?: object) => object|undefined} resolve - The
606
636
  * medium's resolver.
607
- * @param {string} label - The row's label.
608
- * @returns {object[]} The row, or none where nothing is held.
637
+ * @returns {object[]} One entry per membership.
609
638
  */
610
- function standingRows(field, raw, resolve, label) {
639
+ function membershipEntries(field, raw, resolve) {
611
640
  const { entries } = readStandings(raw);
612
- const value = [];
641
+ const out = [];
613
642
  for (const { body, standing } of entries) {
614
643
  if (!hasValue(body)) continue;
615
644
  const link = linkValue(body, resolve, { type: field.ref });
616
645
  const digest = resolve?.(body, { type: field.ref })?.standings;
617
- const text = standingPhrase(standing, link.text, digest);
618
- const anchor =
619
- (
620
- typeof standing?.office === "string" &&
621
- digest?.offices?.[standing.office] !== undefined
622
- ) ?
623
- officeAnchor(standing.office)
624
- : "";
625
- value.push({
626
- ...link,
627
- text,
628
- ...(anchor && link.url ? { url: `${link.url}#${anchor}` } : {}),
629
- });
646
+ const lead = standingLead(isMapping(standing) ? standing : {}, digest);
647
+ out.push(lead ? { lead, ...link } : link);
630
648
  }
631
- return value.length ? [{ label, kind: "links", value }] : [];
649
+ return out;
632
650
  }
633
651
 
634
652
  /**
@@ -710,7 +728,8 @@ function rankRows(raw, resolve) {
710
728
  * short Address with none handed to it skips rather than guesses.
711
729
  */
712
730
  function structuredRows(field, raw, resolve, label, contentPackage) {
713
- if (field.standings) return standingRows(field, raw, resolve, label);
731
+ // Drawn as a section of their own; see `membershipEntries`.
732
+ if (field.standings) return [];
714
733
  if (field.roster) {
715
734
  const roster = rosterRows(raw);
716
735
  if (roster) return roster;
@@ -741,43 +760,145 @@ function structuredRows(field, raw, resolve, label, contentPackage) {
741
760
  });
742
761
  return links.length ? [{ label, kind: "links", value: links }] : [];
743
762
  }
744
- if (isMapping(raw) && !isAddressTuple(raw)) {
745
- const groups = new Map();
746
- const seen = new Set();
747
- for (const [target, relation] of Object.entries(raw)) {
748
- if (!hasValue(target) || !hasValue(relation) || isMapping(relation)) continue;
749
- const term = String(relation);
750
- if (field.terms && !field.terms.some((entry) => entry.term === term)) continue;
751
- if (field.terms && field.keyKind === "address") {
752
- const address = parseAddress(
753
- target,
754
- {
755
- package: contentPackage,
756
- system: "note",
757
- types: new Set(field.accepts),
758
- },
759
- { declared: true },
763
+ if (!isMapping(raw) || isAddressTuple(raw)) return null;
764
+ // A map keyed by Address relates this note to others, and its value is
765
+ // the term the relation goes by: the term labels the row and the targets
766
+ // are its links.
767
+ if (field.keyKind === "address") return relationRows(field, raw, resolve, contentPackage);
768
+ // A map keyed by Shortcode weighs each named thing: one row, each target
769
+ // linked and its weight beside it.
770
+ if (field.keyKind === "shortcode") return weightedRows(field, raw, resolve, label);
771
+ // A map with declared keys: each key is a row's label and its value the
772
+ // row's value.
773
+ if (field.fields) return declaredKeyRows(field.fields, raw);
774
+ // Any other map has no summary shape, and a map is never drawn as text.
775
+ return [];
776
+ }
777
+
778
+ /**
779
+ * The authored entries of a keyed map, with each key's Address where the note
780
+ * boundary has typed it.
781
+ *
782
+ * @param {unknown} raw - The authored map.
783
+ * @returns {Array<[unknown, unknown]>} `[key, value]` pairs, in authored order.
784
+ */
785
+ function keyedEntries(raw) {
786
+ if (raw instanceof AddressEntries)
787
+ return raw.entries.map((entry) => [entry.target, entry.value]);
788
+ return Object.entries(raw);
789
+ }
790
+
791
+ /**
792
+ * A map keyed by Address, one row per relation term, each target linked.
793
+ *
794
+ * @param {object} field - The declaration.
795
+ * @param {unknown} raw - The authored map.
796
+ * @param {(ref: unknown, hint?: object) => object|undefined} resolve - The
797
+ * medium's resolver.
798
+ * @param {string} [contentPackage] - This build's content package.
799
+ * @returns {object[]} The rows, in the declared term order where one is
800
+ * declared and in authored order otherwise.
801
+ */
802
+ function relationRows(field, raw, resolve, contentPackage) {
803
+ const groups = new Map();
804
+ const seen = new Set();
805
+ for (const [target, relation] of keyedEntries(raw)) {
806
+ if (!hasValue(target) || !hasValue(relation) || isMapping(relation)) continue;
807
+ const term = String(relation);
808
+ if (field.terms && !field.terms.some((entry) => entry.term === term)) continue;
809
+ if (field.terms) {
810
+ const address =
811
+ isAddressTuple(target) ? target : (
812
+ parseAddress(
813
+ target,
814
+ { package: contentPackage, system: "note", types: new Set(field.accepts) },
815
+ { declared: true },
816
+ )
760
817
  );
761
- if (address.reason || (field.accepts && !field.accepts.includes(address.type)))
762
- continue;
763
- const canonical = renderAddress(address);
764
- if (seen.has(canonical)) continue;
765
- seen.add(canonical);
766
- }
767
- const links = groups.get(term) ?? [];
768
- links.push(linkValue(target, resolve, { type: field.ref }));
769
- groups.set(term, links);
818
+ if (address.reason || (field.accepts && !field.accepts.includes(address.type)))
819
+ continue;
820
+ const canonical = renderAddress(address);
821
+ if (seen.has(canonical)) continue;
822
+ seen.add(canonical);
823
+ }
824
+ const links = groups.get(term) ?? [];
825
+ links.push(linkValue(target, resolve, { type: field.ref }));
826
+ groups.set(term, links);
827
+ }
828
+ const terms = field.terms ? field.terms.map(({ term }) => term) : [...groups.keys()];
829
+ return terms
830
+ .filter((term) => groups.has(term))
831
+ .map((term) => ({ label: presentValue(term), kind: "links", value: groups.get(term) }));
832
+ }
833
+
834
+ /**
835
+ * A map keyed by Shortcode, one row: each key linked, its value beside it.
836
+ *
837
+ * A `subType:<subtype>` key names every skill of that kind rather than one
838
+ * note, so it reads as the kind and links nowhere.
839
+ *
840
+ * @param {object} field - The declaration.
841
+ * @param {unknown} raw - The authored map.
842
+ * @param {(ref: unknown, hint?: object) => object|undefined} resolve - The
843
+ * medium's resolver.
844
+ * @param {string} label - The row's label.
845
+ * @returns {object[]} The row, or none where nothing is stated.
846
+ */
847
+ function weightedRows(field, raw, resolve, label) {
848
+ const value = [];
849
+ for (const [key, weight] of keyedEntries(raw)) {
850
+ if (!hasValue(key) || !hasValue(weight) || isMapping(weight)) continue;
851
+ const selector =
852
+ field.keySelector === "subType" &&
853
+ typeof key === "string" &&
854
+ key.startsWith("subType:");
855
+ const link =
856
+ selector ?
857
+ { text: `${humanizeFieldName(key.slice("subType:".length))} skills` }
858
+ : linkValue(key, resolve, { type: field.ref });
859
+ const amount = Number(weight);
860
+ const shown = Number.isFinite(amount) && amount > 0 ? `+${amount}` : presentValue(weight);
861
+ value.push({ ...link, text: `${link.text} (${shown})` });
862
+ }
863
+ return value.length ? [{ label, kind: "links", value }] : [];
864
+ }
865
+
866
+ /**
867
+ * A map whose keys are declared: one row per key that holds a value, in
868
+ * declared order, labelled by the key and carrying its value.
869
+ *
870
+ * A list of words is one row of them; a key holding a map has no summary
871
+ * shape and takes no row; a key holding nothing takes none either.
872
+ *
873
+ * @param {readonly object[]} fields - The declared keys.
874
+ * @param {Record<string, unknown>} raw - The authored map.
875
+ * @returns {object[]} The rows.
876
+ */
877
+ function declaredKeyRows(fields, raw) {
878
+ const rows = [];
879
+ for (const inner of fields) {
880
+ const value = raw[inner.name];
881
+ if (isMapping(value) && !isAddressTuple(value)) continue;
882
+ if (!hasValue(value)) continue;
883
+ const label = inner.label ?? humanizeFieldName(inner.name);
884
+ // A symbol such as a unit is shown as written: `km` recased is a
885
+ // different unit, or none.
886
+ const shown = (entry) => (inner.verbatim ? String(entry) : presentValue(entry));
887
+ if (Array.isArray(value)) {
888
+ const words = value
889
+ .filter((entry) => hasValue(entry) && !isMapping(entry))
890
+ .map((entry) => shown(entry));
891
+ if (words.length) rows.push({ label, kind: "list", value: words });
892
+ } else if (
893
+ (inner.kind === "number" || inner.kind === "integer") &&
894
+ Number.isFinite(Number(value))
895
+ ) {
896
+ rows.push({ label, kind: "number", value: Number(value) });
897
+ } else {
898
+ rows.push({ label, kind: "text", value: shown(value) });
770
899
  }
771
- const terms = field.terms ? field.terms.map(({ term }) => term) : [...groups.keys()];
772
- return terms
773
- .filter((term) => groups.has(term))
774
- .map((term) => ({
775
- label: presentValue(term),
776
- kind: "links",
777
- value: groups.get(term),
778
- }));
779
900
  }
780
- return null;
901
+ return rows;
781
902
  }
782
903
 
783
904
  /**
@@ -895,6 +1016,8 @@ function noteBox(
895
1016
  const data = isMapping(fm?.data) ? fm.data : {};
896
1017
  /** @type {Map<string, {label: string, entries: string[]}>} */
897
1018
  const groups = new Map();
1019
+ /** One entry per membership, drawn as a section after the profile. */
1020
+ const memberships = [];
898
1021
 
899
1022
  for (const field of dataFields(fm?.type, vocabulary) ?? []) {
900
1023
  const overlay = overlayFor(presentation, fm?.type, field.name);
@@ -932,6 +1055,12 @@ function noteBox(
932
1055
  printable ??= formatNoteDate(parsed, era, dates.daysPerYear);
933
1056
  if (printable) raw = printable.prose ?? printable.text;
934
1057
  }
1058
+ if (field.standings) {
1059
+ const entries = membershipEntries(field, raw, resolve);
1060
+ memberships.push(...entries);
1061
+ if (entries.length) shown.add(field.name);
1062
+ continue;
1063
+ }
935
1064
  const structured = structuredRows(
936
1065
  field,
937
1066
  raw,
@@ -977,35 +1106,24 @@ function noteBox(
977
1106
  }
978
1107
 
979
1108
  const declaredKind = valueKindOf(field, raw);
980
- const built = rowValue(declaredKind, raw, resolve);
1109
+ const built = rowValue(declaredKind, raw, resolve, undefined, field.verbatim);
981
1110
  if (!hasRenderableValue(declaredKind, built)) continue;
982
1111
  const { kind, value } = applyUnit(declaredKind, built, overlay.unit);
983
1112
  rows.push({ label: overlay.label ?? humanizeFieldName(field.name), kind, value });
984
1113
  shown.add(field.name);
985
1114
  }
986
1115
 
987
- // `events` itself stays withheld — the family's shape is chronology
988
- // machinery, not a summary row — but a reader meets each occurrence's
989
- // own next date, the way a being's computed `age` reaches the box.
990
- if (fm?.type === "lore" && Array.isArray(data.events) && data.events.length && dates) {
991
- const resolved = resolvedDateFields(fm, dates);
992
- for (const eventEntry of resolved.events ?? []) {
993
- if (!eventEntry.next) continue;
994
- rows.push({
995
- label: "Next occurrence",
996
- kind: "text",
997
- value: eventEntry.next.prose ?? eventEntry.next.text,
998
- });
999
- shown.add("events");
1000
- }
1001
- }
1002
-
1003
1116
  return {
1004
1117
  box: {
1005
1118
  id: NOTE_BOX_ID,
1006
1119
  kind: "note",
1007
1120
  title: NOTE_BOX_TITLE,
1008
- sections: [{ id: NOTE_SECTION_ID, layout: "rows", rows }],
1121
+ sections: [
1122
+ { id: NOTE_SECTION_ID, layout: "rows", rows },
1123
+ ...(memberships.length ?
1124
+ [{ ...MEMBERSHIPS_SECTION, layout: "list", entries: memberships }]
1125
+ : []),
1126
+ ],
1009
1127
  },
1010
1128
  shown,
1011
1129
  };