@heroiclands/package-build 22.20.0 → 22.21.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.
@@ -62,7 +62,6 @@
62
62
  pass, which reads them exactly as it reads a local pack.
63
63
 
64
64
  Three things it does deliberately:
65
-
66
65
  - **Pins, rather than following `latest`.** A published
67
66
  `releases/latest/download/…` URL is rewritten to the declared
68
67
  `compatibility.verified` version, so a build names one particular dependency
@@ -177,7 +176,6 @@
177
176
 
178
177
  `content-build lint` now checks frontmatter as well as addresses. Five classes,
179
178
  each previously reported somewhere other than where it was made, or not at all:
180
-
181
179
  - **Unknown or retired type**, told what replaced it.
182
180
  - **Missing required property** — `dimensions` on a map, `subType` on a skill.
183
181
  - **Wrong value shape** — `weight: heavy` where a number belongs.
@@ -215,7 +213,6 @@
215
213
  configures (#69).
216
214
 
217
215
  Two new commands:
218
-
219
216
  - `content-build format [paths..] [--write]` — Prettier, with the shared
220
217
  configuration.
221
218
  - `content-build markdown [paths..] [--fix]` — markdownlint, with a narrow,
@@ -310,7 +307,6 @@ version` regenerates it in every repository here.
310
307
  entries) exactly as the scripts it replaces emit them, on the same toolchain.
311
308
 
312
309
  Also new:
313
-
314
310
  - `paths.manifestOut` (default `build/manifests`) — where the manifest is
315
311
  written. Deliberately not `paths.manifests`, which is the _inbound_ directory
316
312
  of vendored foreign manifests that `links` consumes.
@@ -377,7 +373,6 @@ compile`. Publishing the _same tree_ as a website was a script each consumer
377
373
  exotic, so neither is left to care.
378
374
 
379
375
  Also new:
380
-
381
376
  - `engine/site-build.mjs` exports each stage (`collectContentPages`,
382
377
  `collectTreePages`, `siteGates`, `renderPages`, `writeSectionLandings`) for a
383
378
  consumer that needs a step rather than the whole command.
@@ -422,7 +417,6 @@ compile`. Publishing the _same tree_ as a website was a script each consumer
422
417
  stale by `--check` on every clean checkout — the two undoing each other forever.
423
418
  Three things were making that happen, and all three are fixed at the source
424
419
  rather than by adding a formatting pass:
425
-
426
420
  - Table columns are padded to their widest cell, which is what Prettier's
427
421
  alignment comes to for this content.
428
422
  - The worked example's fence said `yaml`, but the block is a whole note —
@@ -632,7 +626,6 @@ compile`. Publishing the _same tree_ as a website was a script each consumer
632
626
  hide the next drift instead of surfacing it.
633
627
 
634
628
  Two deliberate differences from the code it replaces:
635
-
636
629
  - **The `corpus` derivation is dropped.** `corpus` is not a registered item
637
630
  type, so nothing can compile to one and the branch matched nothing — the same
638
631
  class of dead code as the gate that caused the bug. It was in SoHL's copy and
@@ -654,7 +647,6 @@ compile`. Publishing the _same tree_ as a website was a script each consumer
654
647
  `gh release create`, because cutting the Release is what published. Nothing
655
648
  enforced the last step, so on 2026-08-21 `main` carried 0.5.1 while npm served
656
649
  0.4.0: two versions merged and never published, with no check red.
657
-
658
650
  - Every pull request now declares its bump as a `.changeset/*.md` file, and CI's
659
651
  **Changeset declared** job fails one that does not. `npx changeset add --empty`
660
652
  is how a change says it needs no release — explicitly, rather than by omission.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.21.0
4
+
5
+ ### Minor Changes
6
+
7
+ **Standing in a body** — A being's `affiliations` is now a map keyed by the body, and each entry says what the being is there: `rank`, a level on that body's own ladder, and `office`, one of the posts it names. A page prints the standing closing on the body it belongs to — "War Chief, Hárár, of Vrystwald Tribes" — and an office links to the description the body already carries. In SoHL a being now arrives carrying one affiliation item per body it belongs to, standing included.
8
+
9
+ **Offices read the right way round** — An affiliation's offices each take their own row, with the post's name as the label and its description beside it, and each row can be linked to directly.
10
+
11
+ ### Patch Changes
12
+
13
+ - Icons drawn with outlines follow the colour scheme instead of keeping a black rim in dark mode.
14
+ - An explicitly black outline also moves to ink in light mode, matching the icon's filled shapes.
15
+
3
16
  ## 22.20.0
4
17
 
5
18
  ### Minor Changes
package/docs/api.md CHANGED
@@ -1288,15 +1288,15 @@ The content tree, built into a book. The I/O half of the PDF surface: it reads t
1288
1288
 
1289
1289
  Journals pack compiler — produces JSON pack files for the "journals" Foundry compendium from markdown notes in the `assets/content/` tree. The content root (`contentBase`) is walked recursively; any `.md` file whose frontmatter declares either `type: doc` or a **doc-carrying type** ({@link sohl.utils.packs.docEntryTypes} — every item type, plus `macro`) is compiled into one JournalEntry document. Each note's body is split on top-level H1 headings; the optional content before the first H1 becomes a lead page, and each subsequent H1 starts a new page named after its heading text. All page bodies are rendered to HTML.
1290
1290
 
1291
- | Export | Signature | Returns | Use it when |
1292
- | --------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
1293
- | `splitPages` | `function splitPages(body, leadName` | — | Splits a markdown body into pages by top-level H1 headings. |
1294
- | `assertUniquePages` | `function assertUniquePages(rawPages, noteName)` | — | Two pages in one note that would derive the same id, which the LevelDB packer reports only as an opaque duplicate-key collision. |
1295
- | `assertUniqueAnchors` | `function assertUniqueAnchors(rawPages, noteName)` | — | The anchor half of {@link assertUniquePages}, under its former name. |
1296
- | `journalPageId` | `function journalPageId(entryId, page)` | {string} A 16-character Foundry id. | The id of one page within its entry. |
1297
- | `buildPages` | `function buildPages(rawPages, entryId, noteName)` | {Array<{_id: string, name: string, type: string, title: {show: boolean, level: number}, text: {format: number, content: string}, _key: string}>} The page documents, in order. | Compile split pages into JournalEntryPage documents. |
1298
- | `buildJournalEntry` | `buildJournalEntry({ id, name, markdown, leadName, folder, flags, stats })` | {object} The JournalEntry document, keyed for the pack. | Assemble one JournalEntry document from a note's converted markdown. |
1299
- | `Journals` | `class Journals extends BasePackCompiler` | — | not called directly — imported and driven by `engine/generate.mjs` as the "journals" pack compiler |
1291
+ | Export | Signature | Returns | Use it when |
1292
+ | --------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
1293
+ | `splitPages` | `function splitPages(body, leadName` | — | Splits a markdown body into pages by top-level H1 headings. |
1294
+ | `assertUniquePages` | `function assertUniquePages(rawPages, noteName)` | — | Two pages in one note that would derive the same id, which the LevelDB packer reports only as an opaque duplicate-key collision. |
1295
+ | `assertUniqueAnchors` | `function assertUniqueAnchors(rawPages, noteName)` | — | The anchor half of {@link assertUniquePages}, under its former name. |
1296
+ | `journalPageId` | `function journalPageId(entryId, page)` | {string} A 16-character Foundry id. | The id of one page within its entry. |
1297
+ | `buildPages` | `function buildPages(rawPages, entryId, noteName)` | {Array<{\_id: string, name: string, type: string, title: {show: boolean, level: number}, text: {format: number, content: string}, \_key: string}>} The page documents, in order. | Compile split pages into JournalEntryPage documents. |
1298
+ | `buildJournalEntry` | `buildJournalEntry({ id, name, markdown, leadName, folder, flags, stats })` | {object} The JournalEntry document, keyed for the pack. | Assemble one JournalEntry document from a note's converted markdown. |
1299
+ | `Journals` | `class Journals extends BasePackCompiler` | — | not called directly — imported and driven by `engine/generate.mjs` as the "journals" pack compiler |
1300
1300
 
1301
1301
  ### `engine.macros`
1302
1302
 
@@ -1361,7 +1361,7 @@ Bundles, as notes — the Foundry `Adventure` a `type: bundle` note compiles int
1361
1361
  | `BUNDLE_TYPE` | `const BUNDLE_TYPE` | — | The note type a bundle is authored as. |
1362
1362
  | `ADVENTURE_CONTENT_FIELD` | `const ADVENTURE_CONTENT_FIELD` | — | Foundry's `Adventure.contentFields`, keyed by the document class each holds. |
1363
1363
  | `CONTENTS_FIELD` | `const CONTENTS_FIELD` | — | The `data:` key a bundle lists its members under. |
1364
- | `stripAdventureKeys` | `function stripAdventureKeys(value)` | {*} The same shape with every `_key` removed. | Strip the LevelDB keys from a document tree. |
1364
+ | `stripAdventureKeys` | `function stripAdventureKeys(value)` | {\*} The same shape with every `_key` removed. | Strip the LevelDB keys from a document tree. |
1365
1365
  | `bareAddress` | `function bareAddress(value)` | {string\|null} The bare address, or `null` for a blank entry. | An authored address with any wikilink brackets and label stripped. |
1366
1366
  | `bundleContents` | `function bundleContents(fm)` | {string[]} The bare addresses. | The addresses a bundle note names, in the order it names them. |
1367
1367
  | `missingMemberVerdict` | `function missingMemberVerdict(packSystem)` | {"omit"\|"fail"} What to do about a member the sources lack. | What a pass should do about a member its sources do not hold. |
@@ -2487,7 +2487,7 @@ Generates a living (or undead, or spirit) being.
2487
2487
  | `lore` | `Address[]` | Other lore concerning this being, such as social standing or law |
2488
2488
  | `culture` | `Address` | Primary culture, naming a lore note with `subType: culture` |
2489
2489
  | `homes` | `Address[]` | Place the being calls home |
2490
- | `affiliations` | `Address[]` | Affilliations (e.g., arcane/divine traditions, polities, etc) |
2490
+ | `affiliations` | `Map<Address, Membership>` | Bodies the being belongs to, keyed by Address, each entry the standing it holds there |
2491
2491
  | `socialTies` | `Map<Address, Tie>` | Defining support and opposition, directed from this being to others |
2492
2492
  | `gender` | `male \| female \| other` | Gender of the character |
2493
2493
  | `species` | `Address` | Being's species (lore) |
@@ -2517,15 +2517,60 @@ describe a being's body. Item gear weight remains a numeric `data.weight`.
2517
2517
  `data.culture` names the culture in which a character belongs. It is one
2518
2518
  Address to a `type: lore`, `subType: culture` note. A named character with a
2519
2519
  known culture states it here; generic beings and templates can omit it.
2520
- `data.lore` holds other lore concerning the being, such as social standing or
2521
- law. Record the primary culture in `data.culture` only.
2520
+ `data.lore` holds the law the being lives under, the customs it is subject to
2521
+ and the traditions it was raised in. Record the primary culture in
2522
+ `data.culture` only, and a standing in `data.affiliations`.
2522
2523
 
2523
2524
  ```yaml
2524
2525
  data:
2525
2526
  culture: lore-vedyariclt
2526
- lore: [lore-commonerrnk]
2527
+ lore: [lore-marchlaw]
2527
2528
  ```
2528
2529
 
2530
+ #### The bodies a being belongs to, and its standing in each
2531
+
2532
+ **Membership**: `{rank: number, office: string}`, both optional.
2533
+
2534
+ `data.affiliations` is a **map keyed by the body's Address**, and each entry is
2535
+ the standing the being holds in that body. The key is what makes the pair
2536
+ expressible: a being in two bodies holds a standing in each, and two bodies may
2537
+ call a rung the same word.
2538
+
2539
+ An entry carries two optional keys and nothing else:
2540
+
2541
+ - **`rank`** — a **number**, the level on that body's own `governance.ranks`
2542
+ ladder. The body declares `{level, title, description}` for every rung, so a
2543
+ number indexes into it and the body stays the single source for what its
2544
+ level 5 is called.
2545
+ - **`office`** — a **string**, matched against the keys of that body's
2546
+ `governance.offices` map.
2547
+
2548
+ An entry with neither is valid and is the ordinary shape: it says the being
2549
+ belongs to the body and nothing more.
2550
+
2551
+ ```yaml
2552
+ data:
2553
+ affiliations:
2554
+ affiliation-vrystwldtrbs: { rank: 5, office: War Chief }
2555
+ affiliation-greenwardens: { rank: 1 }
2556
+ affiliation-silverguild: {}
2557
+ ```
2558
+
2559
+ Both values are checked against the body the entry is keyed by. A `rank` that
2560
+ is not a level the body confers and an `office` that is not one of its keys are
2561
+ each an error naming the file, line and column — so a renamed rung or a renamed
2562
+ post fails on every being pointing at it. Naming one body twice, by its short
2563
+ and full Address, still names one body and is an error.
2564
+
2565
+ The infobox prints the standing closing on its body — `War Chief, Hárár, of
2566
+ Vrystwald Tribes` — and links an office to the description the body itself
2567
+ declares, so recording the post needs no note about the post.
2568
+
2569
+ For SoHL, a being embeds one affiliation item per entry: `rank` **is** that
2570
+ item's `system.level` and `office` its `system.office`. A standing is a fact
2571
+ about the setting rather than about one system, so the `sohl:` block states
2572
+ none of it.
2573
+
2529
2574
  #### The people who matter to a being
2530
2575
 
2531
2576
  `data.socialTies` records relationships that define the subject's dossier, not
@@ -2558,8 +2603,8 @@ an error.
2558
2603
  Each tie is one-sided. The target need not name the subject back, even when
2559
2604
  `patron` and `dependent` would describe opposite ends of the same support.
2560
2605
  Kinship, household membership, organizational membership, rank, and office are
2561
- distinct facts. Their relevant effects may appear as ties, while their details
2562
- belong in prose, `data.affiliations`, or governance fields. A tie has no
2606
+ distinct facts. Their relevant effects may appear as ties, while a membership
2607
+ and the standing held in it belong in `data.affiliations`. A tie has no
2563
2608
  mechanical modifier.
2564
2609
 
2565
2610
  #### When a being was born, when it died, and how old it is
@@ -30,34 +30,34 @@ These fields are accepted by every note type. A field's value can still be irrel
30
30
 
31
31
  **Subtypes:** `npc`, `character`, `creature`.
32
32
 
33
- | Field | Shape | Meaning |
34
- | -------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
- | `data.social` | map | The being's social profile. |
36
- | `data.tokenIcon` | an Address | What a token on the canvas wears — an `icon` address; unset, it follows `icon`. |
37
- | `data.templatePriority` | number | Template priority; unset means the note is not a template. |
38
- | `data.archetypes` | list | Archetypal behaviours the being fits. |
39
- | `data.occupation` | string | What the being does for a living. |
40
- | `data.stations` | list of Addresses | Stations the being holds. |
41
- | `data.lore` | list of Addresses | Lore concerning this being, such as the standing it holds or the law it lives under. |
42
- | `data.culture` | an Address | The being's primary culture, as a culture lore note. |
43
- | `data.homes` | list of Addresses | Places the being calls home. |
44
- | `data.affiliations` | list of Addresses | Affiliations the being belongs to — traditions, polities, and the rest. |
45
- | `data.socialTies` | a map keyed by Address | Defining ties directed from this being to others: `patron` (Supports the subject from greater power, wealth, or status.); `friend` (Supports the subject out of goodwill.); `dependent` (Relies on the subject's support or protection.); `acquaintance` (Knows the subject without a strong disposition.); `rival` (Opposes the subject without implacable hostility.); `nemesis` (Opposes the subject personally and implacably.) |
46
- | `data.gender` | string | `male`, `female` or `other`. |
47
- | `data.species` | an Address | The being's species, as a lore note. |
48
- | `data.born` | string | When the being was born — a date, or `unknown` where the birth is unrecorded. Absent, the being was never born. |
49
- | `data.calendar` | an Address | Calendar used to display this being's dates. |
50
- | `data.died` | string | When the being died — a date, or `unknown` where the death is unrecorded. Absent, the being is alive. |
51
- | `data.age` | string | Age in years, stated only to override what `born` says — `34`, or `~34` for an estimate. Unstated beside a dated `born` it is computed; unstated beside an unknown or absent `born` the age is unknown. |
52
- | `data.ageYears` | number | Written by the compiler beside an `age` estimate — the `~` stripped, the magnitude alone. A note never authors this. |
53
- | `data.height` | metres or feet and inches | Height in metres or feet and inches. |
54
- | `data.weight` | kilograms or pounds | Body weight in kilograms or pounds. |
55
- | `data.frame` | string | Relative frame — `scant`, `light`, `medium`, `large` or `massive`. |
56
- | `data.appearance.eye_color` | string | Eye colour. |
57
- | `data.appearance.hair_color` | string | Hair colour. |
58
- | `data.appearance.skin_color` | string | Skin colour. |
59
- | `data.appearance.complexion` | string | Complexion. |
60
- | `data.appearance.extra_features` | list | Anything else a stranger would notice. |
33
+ | Field | Shape | Meaning |
34
+ | -------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
+ | `data.social` | map | The being's social profile. |
36
+ | `data.tokenIcon` | an Address | What a token on the canvas wears — an `icon` address; unset, it follows `icon`. |
37
+ | `data.templatePriority` | number | Template priority; unset means the note is not a template. |
38
+ | `data.archetypes` | list | Archetypal behaviours the being fits. |
39
+ | `data.occupation` | string | What the being does for a living. |
40
+ | `data.stations` | list of Addresses | Stations the being holds. |
41
+ | `data.lore` | list of Addresses | Lore concerning this being — the law it lives under, the customs it is subject to, the traditions it was raised in. |
42
+ | `data.culture` | an Address | The being's primary culture, as a culture lore note. |
43
+ | `data.homes` | list of Addresses | Places the being calls home. |
44
+ | `data.affiliations` | a standing map keyed by Address, or a list of Addresses | Bodies the being belongs to, keyed by Address, each entry holding the standing it holds there — `rank`, a level on that body's own ladder, and `office`, a post that body names. |
45
+ | `data.socialTies` | a map keyed by Address | Defining ties directed from this being to others: `patron` (Supports the subject from greater power, wealth, or status.); `friend` (Supports the subject out of goodwill.); `dependent` (Relies on the subject's support or protection.); `acquaintance` (Knows the subject without a strong disposition.); `rival` (Opposes the subject without implacable hostility.); `nemesis` (Opposes the subject personally and implacably.) |
46
+ | `data.gender` | string | `male`, `female` or `other`. |
47
+ | `data.species` | an Address | The being's species, as a lore note. |
48
+ | `data.born` | string | When the being was born — a date, or `unknown` where the birth is unrecorded. Absent, the being was never born. |
49
+ | `data.calendar` | an Address | Calendar used to display this being's dates. |
50
+ | `data.died` | string | When the being died — a date, or `unknown` where the death is unrecorded. Absent, the being is alive. |
51
+ | `data.age` | string | Age in years, stated only to override what `born` says — `34`, or `~34` for an estimate. Unstated beside a dated `born` it is computed; unstated beside an unknown or absent `born` the age is unknown. |
52
+ | `data.ageYears` | number | Written by the compiler beside an `age` estimate — the `~` stripped, the magnitude alone. A note never authors this. |
53
+ | `data.height` | metres or feet and inches | Height in metres or feet and inches. |
54
+ | `data.weight` | kilograms or pounds | Body weight in kilograms or pounds. |
55
+ | `data.frame` | string | Relative frame — `scant`, `light`, `medium`, `large` or `massive`. |
56
+ | `data.appearance.eye_color` | string | Eye colour. |
57
+ | `data.appearance.hair_color` | string | Hair colour. |
58
+ | `data.appearance.skin_color` | string | Skin colour. |
59
+ | `data.appearance.complexion` | string | Complexion. |
60
+ | `data.appearance.extra_features` | list | Anything else a stranger would notice. |
61
61
 
62
62
  **System mappings**
63
63
 
@@ -357,6 +357,13 @@ export function matchesKind(value, kind, context = {}) {
357
357
  // this answers is whether the value has one of the two shapes the
358
358
  // field admits. A list has neither.
359
359
  return matchesKind(value, "string") || matchesKind(value, "map");
360
+ case "list-or-map":
361
+ // A list, or a map. The two carry different facts rather than
362
+ // being two spellings of one — a map keyed by Address says what is
363
+ // true *of* each key, which a list has nowhere to put — so the
364
+ // field's own check is what reads the entries, and all this
365
+ // answers is whether the value has one of the two shapes.
366
+ return Array.isArray(value) || matchesKind(value, "map");
360
367
  default:
361
368
  return true;
362
369
  }
@@ -539,7 +546,13 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
539
546
  function checkDataReferences(note, field, value, segments, context, index) {
540
547
  const checks = [];
541
548
  if (field.kind === "address") checks.push({ value, path: [], kind: "address" });
542
- if (field.entryKind) {
549
+ // A `list-or-map` field declares both halves, and each describes one shape:
550
+ // `entryKind` the list's entries, `keyKind` the map's keys. Reading both of
551
+ // one value would check a map's standings as if they were Addresses and a
552
+ // list's indices as if they were its keys.
553
+ const dual = field.kind === "list-or-map";
554
+ const written = Array.isArray(value) ? "list" : "map";
555
+ if (field.entryKind && !(dual && written === "map")) {
543
556
  if (Array.isArray(value)) {
544
557
  value.forEach((entry, i) =>
545
558
  checks.push({ value: entry, path: [i], kind: field.entryKind }),
@@ -551,7 +564,7 @@ function checkDataReferences(note, field, value, segments, context, index) {
551
564
  });
552
565
  } else checks.push({ value, path: [], kind: field.entryKind });
553
566
  }
554
- if (field.keyKind) {
567
+ if (field.keyKind && !(dual && written === "list")) {
555
568
  if (value instanceof AddressEntries) {
556
569
  for (const entry of value.entries)
557
570
  checks.push({
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { buildReferenceTargets } from "./reference-targets.mjs";
29
+ import { standingsDigest } from "./standings.mjs";
29
30
 
30
31
  import { decodeNoteAddresses } from "./note-addresses.mjs";
31
32
  import { positionOfYamlPath } from "./diagnostics.mjs";
@@ -834,6 +835,10 @@ export function buildContentLinkIndex(
834
835
  // can group by the family its target declares rather than only by
835
836
  // where the target lives.
836
837
  subType: fm.subType ?? null,
838
+ // The rungs and posts this body confers, carried for the same
839
+ // reason: a being holds a rank as a number, and the body is where
840
+ // what that number is called is declared.
841
+ standings: standingsDigest(fm) ?? null,
837
842
  name: fm.name?.full ?? base,
838
843
  // Whether the note is tagged `draft`. Read from the tag
839
844
  // vocabulary that declares it, and used for one thing: a link
@@ -139,7 +139,12 @@ function sectionToHtml(section, link) {
139
139
  if (section.layout === "rows") {
140
140
  out.push('<dl class="infobox-rows">');
141
141
  for (const row of section.rows ?? []) {
142
- out.push(`<dt>${escapeHtml(row.label)}</dt><dd>${valueToHtml(row, link)}</dd>`);
142
+ // A row that declares an anchor carries it on the `<dt>`, so the
143
+ // label is the destination a link to that row arrives at. A row
144
+ // that declares none emits none: an id nobody wrote would be
145
+ // invented once per renderer, and the two would not agree.
146
+ const id = row.id ? ` id="${escapeHtml(row.id)}"` : "";
147
+ out.push(`<dt${id}>${escapeHtml(row.label)}</dt><dd>${valueToHtml(row, link)}</dd>`);
143
148
  }
144
149
  out.push("</dl>");
145
150
  } else if (section.layout === "grid") {
@@ -121,6 +121,7 @@ import { subtypeRow } from "./document-subtypes.mjs";
121
121
  import { authoredKey } from "./system-block.mjs";
122
122
  import { formatDateInCalendar, formatNoteDate, parseNoteDate } from "./note-dates.mjs";
123
123
  import { displayBeingHeight, displayBeingWeight } from "./being-measurements.mjs";
124
+ import { officeAnchor, officeRoster, readStandings, standingPhrase } from "./standings.mjs";
124
125
 
125
126
  /**
126
127
  * How a section arranges what it holds.
@@ -553,8 +554,92 @@ function rowValue(kind, raw, resolve, hint) {
553
554
  return presentValue(raw);
554
555
  }
555
556
 
557
+ /**
558
+ * A being's memberships, each with the standing it holds there.
559
+ *
560
+ * One row, one entry per body, and the standing reads inside the entry rather
561
+ * than beside it — "War Chief, of Vrystwald Tribes" rather than a rank link
562
+ * with nothing saying which body confers it.
563
+ *
564
+ * **The office carries the link where the body's page anchors it.** An office's
565
+ * description is already declared in the body's own `governance.offices`, and
566
+ * the row that prints it is anchored by the same derivation, so the string a
567
+ * being wrote reaches the description without anyone authoring a note about the
568
+ * post. In a medium with no page to reach — a compendium journal links by
569
+ * document UUID — the entry keeps the body's own document, so the reader still
570
+ * lands somewhere.
571
+ *
572
+ * @param {object} field - The declaration.
573
+ * @param {unknown} raw - The authored value, in either form.
574
+ * @param {(ref: unknown, hint?: object) => object|undefined} resolve - The
575
+ * medium's resolver.
576
+ * @param {string} label - The row's label.
577
+ * @returns {object[]} The row, or none where nothing is held.
578
+ */
579
+ function standingRows(field, raw, resolve, label) {
580
+ const { entries } = readStandings(raw);
581
+ const value = [];
582
+ for (const { body, standing } of entries) {
583
+ if (!hasValue(body)) continue;
584
+ const link = linkValue(body, resolve, { type: field.ref });
585
+ const digest = resolve?.(body, { type: field.ref })?.standings;
586
+ const text = standingPhrase(standing, link.text, digest);
587
+ const anchor =
588
+ (
589
+ typeof standing?.office === "string" &&
590
+ digest?.offices?.[standing.office] !== undefined
591
+ ) ?
592
+ officeAnchor(standing.office)
593
+ : "";
594
+ value.push({
595
+ ...link,
596
+ text,
597
+ ...(anchor && link.url ? { url: `${link.url}#${anchor}` } : {}),
598
+ });
599
+ }
600
+ return value.length ? [{ label, kind: "links", value }] : [];
601
+ }
602
+
603
+ /**
604
+ * A map of named posts, one row each, anchored by its name.
605
+ *
606
+ * The **name is the label and its description is the value**, which is the way
607
+ * round a reader needs: a field name belongs in the label column, and a whole
608
+ * sentence there leaves the post itself standing where a value goes.
609
+ *
610
+ * Each row carries the anchor its name derives, so the description is a
611
+ * destination rather than merely text on a page — which is what lets a being's
612
+ * `office` string link to the post it names without anyone authoring a note
613
+ * about the post.
614
+ *
615
+ * @param {unknown} raw - The authored map.
616
+ * @returns {object[]|null} One row per post, in the order the body declared
617
+ * them, or `null` where the value is not a map of them — a note writing
618
+ * something else keeps its row and states what it wrote.
619
+ */
620
+ function rosterRows(raw) {
621
+ if (!isMapping(raw) || isAddressTuple(raw)) return null;
622
+ const rows = [];
623
+ for (const [post, description] of officeRoster(raw)) {
624
+ if (!hasValue(post)) continue;
625
+ const anchor = officeAnchor(post);
626
+ rows.push({
627
+ ...(anchor ? { id: anchor } : {}),
628
+ label: String(post),
629
+ kind: "text",
630
+ value: hasValue(description) ? String(description) : String(post),
631
+ });
632
+ }
633
+ return rows;
634
+ }
635
+
556
636
  /** Expand structured references using the existing linked-row shape. */
557
637
  function structuredRows(field, raw, resolve, label, fm) {
638
+ if (field.standings) return standingRows(field, raw, resolve, label);
639
+ if (field.roster) {
640
+ const roster = rosterRows(raw);
641
+ if (roster) return roster;
642
+ }
558
643
  if (Array.isArray(raw) && raw.some(isMapping)) {
559
644
  const links = raw
560
645
  .filter((entry) => isMapping(entry) && hasValue(entry.to))
@@ -62,8 +62,12 @@ export const STRUCTURED_ADDRESSES = Object.freeze([
62
62
  export function addressPositions(fm, context = {}) {
63
63
  const out = [];
64
64
  for (const field of dataFields(fm.type) ?? []) {
65
+ // A field that admits both a list and a map declares both halves, and
66
+ // which one a note wrote is not knowable from the declaration — so the
67
+ // position says "either" and the conversion reads the value's shape.
65
68
  const shape =
66
69
  field.kind === "address" ? "value"
70
+ : field.kind === "list-or-map" ? "keys-or-list"
67
71
  : field.keyKind === "address" ? "keys"
68
72
  : field.entryKind === "address" ? field.kind
69
73
  : undefined;
@@ -197,7 +201,7 @@ export function decodeNoteAddresses(fm, context) {
197
201
  `\`${path.join(".")}\` ${description}: ${JSON.stringify(value)}`,
198
202
  );
199
203
  error.keyPath = path;
200
- error.addressKey = position.shape === "keys";
204
+ error.addressKey = position.shape === "keys" || position.shape === "keys-or-list";
201
205
  throw error;
202
206
  };
203
207
  const read = (value, path) => {
@@ -217,7 +221,16 @@ export function decodeNoteAddresses(fm, context) {
217
221
  };
218
222
  const convert = (value, path) => {
219
223
  if (value == null || value === "") return value;
220
- if (position.shape === "keys") {
224
+ // Whichever of the two shapes the note wrote, read as that one. An
225
+ // empty list is the property editor's emptied map and converts to
226
+ // nothing either way.
227
+ const shape =
228
+ position.shape === "keys-or-list" ?
229
+ Array.isArray(value) && value.length ?
230
+ "list"
231
+ : "keys"
232
+ : position.shape;
233
+ if (shape === "keys") {
221
234
  if (value instanceof AddressEntries) return value;
222
235
  if (Array.isArray(value) && value.length === 0) return value;
223
236
  if (typeof value !== "object" || Array.isArray(value))
@@ -230,15 +243,11 @@ export function decodeNoteAddresses(fm, context) {
230
243
  })),
231
244
  );
232
245
  }
233
- if (position.shape === "list") {
246
+ if (shape === "list") {
234
247
  if (!Array.isArray(value)) fail(value, path, "must be a list of Addresses");
235
248
  return value.map((v, i) => read(v, [...path, i]));
236
249
  }
237
- if (
238
- position.shape === "scalar-or-map" &&
239
- typeof value === "object" &&
240
- !isAddressTuple(value)
241
- ) {
250
+ if (shape === "scalar-or-map" && typeof value === "object" && !isAddressTuple(value)) {
242
251
  if (Array.isArray(value)) {
243
252
  if (!value.length) return value;
244
253
  fail(value, path, "must be an Address or pack map");