@heroiclands/package-build 22.19.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.
@@ -55,7 +55,12 @@
55
55
  */
56
56
 
57
57
  import { isAddressTuple } from "./address.mjs";
58
- import { authoredNoteKeys, NOTE_TOP_LEVEL_KEY_SET } from "./note-frontmatter.mjs";
58
+ import {
59
+ authoredNoteKeys,
60
+ CHARACTER_NAME_KEYS,
61
+ NOTE_NAME_KEYS,
62
+ NOTE_TOP_LEVEL_KEY_SET,
63
+ } from "./note-frontmatter.mjs";
59
64
  import { AddressEntries } from "./address-values.mjs";
60
65
  import { addressPositions } from "./note-addresses.mjs";
61
66
  import { authoredFields, readsLegacyKey } from "./field-spec.mjs";
@@ -78,6 +83,7 @@ import { isAddressSegment } from "./address-charset.mjs";
78
83
  // reads is exactly the disagreement to avoid.
79
84
  import { DEFAULT_PARENT } from "./folder-notes.mjs";
80
85
  import {
86
+ BEING_ARCHETYPES,
81
87
  dataFields,
82
88
  declaredTags,
83
89
  subTypeCharsetMessage,
@@ -351,6 +357,13 @@ export function matchesKind(value, kind, context = {}) {
351
357
  // this answers is whether the value has one of the two shapes the
352
358
  // field admits. A list has neither.
353
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");
354
367
  default:
355
368
  return true;
356
369
  }
@@ -533,7 +546,13 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
533
546
  function checkDataReferences(note, field, value, segments, context, index) {
534
547
  const checks = [];
535
548
  if (field.kind === "address") checks.push({ value, path: [], kind: "address" });
536
- 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")) {
537
556
  if (Array.isArray(value)) {
538
557
  value.forEach((entry, i) =>
539
558
  checks.push({ value: entry, path: [i], kind: field.entryKind }),
@@ -545,7 +564,7 @@ function checkDataReferences(note, field, value, segments, context, index) {
545
564
  });
546
565
  } else checks.push({ value, path: [], kind: field.entryKind });
547
566
  }
548
- if (field.keyKind) {
567
+ if (field.keyKind && !(dual && written === "list")) {
549
568
  if (value instanceof AddressEntries) {
550
569
  for (const entry of value.entries)
551
570
  checks.push({
@@ -1173,18 +1192,101 @@ export function lintNote(
1173
1192
  const type = String(fm.type ?? "");
1174
1193
  const raw = () => note.raw ?? "";
1175
1194
  const at = (key, literal) => positionInFrontmatter(raw(), key, literal ?? undefined);
1176
- if (
1177
- type === "being" &&
1178
- Array.isArray(fm.data?.archetypes) &&
1179
- fm.data.archetypes.includes("commoner") &&
1180
- fm.data.archetypes.length > 1
1181
- ) {
1182
- findings.push({
1183
- file: note.file,
1184
- ...at("archetypes", "commoner"),
1185
- severity: "error",
1186
- message: "`data.archetypes` may contain `commoner` only by itself",
1187
- });
1195
+ if (type === "being") {
1196
+ const archetypes = fm.data?.archetypes;
1197
+ if (
1198
+ ["character", "npc"].includes(fm.subType) &&
1199
+ (archetypes == null || (Array.isArray(archetypes) && archetypes.length === 0))
1200
+ ) {
1201
+ findings.push({
1202
+ file: note.file,
1203
+ ...positionOfFrontmatterPath(
1204
+ raw(),
1205
+ archetypes == null ? ["subType"] : ["data", "archetypes"],
1206
+ ),
1207
+ severity: "error",
1208
+ message: "A character or npc requires at least one `data.archetypes` value",
1209
+ });
1210
+ }
1211
+ if (Array.isArray(archetypes)) {
1212
+ archetypes.forEach((value, i) => {
1213
+ if (!Object.hasOwn(BEING_ARCHETYPES, value)) {
1214
+ findings.push({
1215
+ file: note.file,
1216
+ ...positionOfFrontmatterPath(raw(), ["data", "archetypes", i]),
1217
+ severity: "error",
1218
+ message: `Unknown archetype \`${String(value)}\`; use a case-sensitive archetype from the reference`,
1219
+ });
1220
+ }
1221
+ });
1222
+ const commoner = archetypes.indexOf("commoner");
1223
+ if (commoner !== -1 && archetypes.length > 1) {
1224
+ findings.push({
1225
+ file: note.file,
1226
+ ...positionOfFrontmatterPath(raw(), ["data", "archetypes", commoner]),
1227
+ severity: "error",
1228
+ message: "`data.archetypes` may contain `commoner` only by itself",
1229
+ });
1230
+ }
1231
+ }
1232
+ }
1233
+ if (Object.hasOwn(fm, "name")) {
1234
+ const name = fm.name;
1235
+ const nameAt = (path) => positionOfFrontmatterPath(raw(), ["name", ...path], { key: true });
1236
+ if (!name || typeof name !== "object" || Array.isArray(name)) {
1237
+ findings.push({
1238
+ file: note.file,
1239
+ ...positionOfFrontmatterPath(raw(), ["name"], { key: true }),
1240
+ severity: "error",
1241
+ message: "`name` must be a map with a nonempty `full` string",
1242
+ });
1243
+ } else {
1244
+ const allowed = new Set(NOTE_NAME_KEYS);
1245
+ if (type === "being" && fm.subType === "character") {
1246
+ for (const key of CHARACTER_NAME_KEYS) allowed.add(key);
1247
+ }
1248
+ for (const key of Object.keys(name)) {
1249
+ if (!allowed.has(key)) {
1250
+ findings.push({
1251
+ file: note.file,
1252
+ ...nameAt([key]),
1253
+ severity: "error",
1254
+ message: `\`name.${key}\` is not valid for this note`,
1255
+ });
1256
+ }
1257
+ }
1258
+ if (typeof name.full !== "string" || !name.full.trim()) {
1259
+ findings.push({
1260
+ file: note.file,
1261
+ ...(Object.hasOwn(name, "full") ? nameAt(["full"]) : nameAt([])),
1262
+ severity: "error",
1263
+ message: "`name.full` must be a nonempty string",
1264
+ });
1265
+ }
1266
+ if (Object.hasOwn(name, "aliases")) {
1267
+ if (
1268
+ !Array.isArray(name.aliases) ||
1269
+ name.aliases.some((alias) => typeof alias !== "string" || !alias.trim())
1270
+ ) {
1271
+ findings.push({
1272
+ file: note.file,
1273
+ ...nameAt(["aliases"]),
1274
+ severity: "error",
1275
+ message: "`name.aliases` must be a list of nonempty strings",
1276
+ });
1277
+ }
1278
+ }
1279
+ for (const key of CHARACTER_NAME_KEYS) {
1280
+ if (!Object.hasOwn(name, key) || !allowed.has(key)) continue;
1281
+ if (typeof name[key] === "string" && name[key].trim()) continue;
1282
+ findings.push({
1283
+ file: note.file,
1284
+ ...nameAt([key]),
1285
+ severity: "error",
1286
+ message: `\`name.${key}\` must be a nonempty string`,
1287
+ });
1288
+ }
1289
+ }
1188
1290
  }
1189
1291
  /**
1190
1292
  * The in-block keys this note's own type claims for something other than
@@ -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))
@@ -706,6 +791,9 @@ function noteBox(
706
791
  const shown = new Set();
707
792
  const name = fm?.name?.full ?? fm?.title;
708
793
  if (hasValue(name)) rows.push({ label: "Name", kind: "text", value: String(name) });
794
+ if (Array.isArray(fm?.name?.aliases) && fm.name.aliases.length) {
795
+ rows.push({ label: "Aliases", kind: "list", value: fm.name.aliases });
796
+ }
709
797
  if (fm?.type === "being" && Object.hasOwn(BEING_SUBTYPE_LABELS, fm.subType)) {
710
798
  rows.push({ label: "Type", kind: "text", value: BEING_SUBTYPE_LABELS[fm.subType] });
711
799
  }
@@ -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");
@@ -24,6 +24,12 @@ export const NOTE_TOP_LEVEL_KEYS = Object.freeze([
24
24
  /** Membership test for the top-level vocabulary. */
25
25
  export const NOTE_TOP_LEVEL_KEY_SET = new Set(NOTE_TOP_LEVEL_KEYS);
26
26
 
27
+ /** The name properties every note accepts, in display order. */
28
+ export const NOTE_NAME_KEYS = Object.freeze(["full", "aliases"]);
29
+
30
+ /** Additional name components accepted by an individual character. */
31
+ export const CHARACTER_NAME_KEYS = Object.freeze(["given", "clan"]);
32
+
27
33
  /** Return authored keys from an opening YAML frontmatter map. */
28
34
  export function authoredNoteKeys(markdown) {
29
35
  const source = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
@@ -97,6 +97,8 @@ import {
97
97
  // question, asked the way `checkPlace` and `checkCalendarNote` are.
98
98
  import { checkBeingAge } from "./being-age.mjs";
99
99
  import { checkSocialTies } from "./social-ties.mjs";
100
+ import { checkStandings } from "./standings.mjs";
101
+ import { STANDING_BODY_TYPES } from "./standing-terms.mjs";
100
102
  import { checkDatedOffices } from "./office-holders.mjs";
101
103
  import { checkCalendarChoice } from "./calendar-choice.mjs";
102
104
  import { checkCultureChoice } from "./culture-choice.mjs";
@@ -128,12 +130,20 @@ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
128
130
  * @typedef {object} DataFieldSpec
129
131
  * @property {string} name - The key under `data:`, dotted for a nested one
130
132
  * (`charges.value`).
131
- * @property {"string"|"number"|"boolean"|"list"|"map"|"scalar-or-map"|"address"|"shortcode"} [kind] -
133
+ * @property {"string"|"number"|"boolean"|"list"|"map"|"list-or-map"|"scalar-or-map"|"address"|"shortcode"} [kind] -
132
134
  * The value's shape, for the lint. Absent means no claim is made about the
133
135
  * value — which is the honest answer wherever the specification's stated
134
136
  * shape and the shape notes are authored in today disagree.
135
137
  * @property {"address"|"shortcode"} [entryKind] - Type of each list or pack-map value.
136
- * @property {"address"|"shortcode"} [keyKind] - Type of each map key.
138
+ * @property {"address"|"shortcode"} [keyKind] - Type of each map key. On a
139
+ * `list-or-map` field it describes the map form alone, and `entryKind` the
140
+ * list form alone: reading both of one value would check a map's entries as
141
+ * if they were its keys.
142
+ * @property {boolean} [standings] - The map's entries are standings held in the
143
+ * body each is keyed by, so a row names the two together — see
144
+ * {@link module:engine/standings}.
145
+ * @property {boolean} [roster] - The map names posts, each with a description,
146
+ * so each takes a row labelled and anchored by the post's own name.
137
147
  * @property {"subType"} [keySelector] - An alternative `subType:<skill-subtype>` key.
138
148
  * @property {readonly string[]} [accepts] - Allowed Address types, separate from `ref`.
139
149
  * @property {string} [shape] - Human-readable shape, for a finding and for
@@ -214,6 +224,29 @@ const LINK = Object.freeze({ shape: "an Address", kind: "address" });
214
224
  /** A list of Addresses. */
215
225
  const LINKS = Object.freeze({ shape: "list of Addresses", kind: "list", entryKind: "address" });
216
226
 
227
+ /**
228
+ * A being's memberships, and the standing it holds in each.
229
+ *
230
+ * A map keyed by the body's Address, whose entry is that membership's `rank`
231
+ * and `office` — the pair a flat list of bodies cannot state, because it has
232
+ * nowhere to say which body a standing is held in.
233
+ *
234
+ * **`list-or-map` is the sweep's shape**, and it says which half each
235
+ * declaration describes: `entryKind` the list's entries, `keyKind` the map's
236
+ * keys. A tree still writing the list form keeps the check it had, and the map
237
+ * form earns the two the body can answer — see {@link module:engine/standings}.
238
+ */
239
+ const STANDINGS = Object.freeze({
240
+ shape: "a standing map keyed by Address, or a list of Addresses",
241
+ kind: "list-or-map",
242
+ entryKind: "address",
243
+ keyKind: "address",
244
+ standings: true,
245
+ ref: "affiliation",
246
+ accepts: STANDING_BODY_TYPES,
247
+ check: checkStandings,
248
+ });
249
+
217
250
  /**
218
251
  * A single Address, or one per pack.
219
252
  *
@@ -666,6 +699,25 @@ export function isGmNote(fm) {
666
699
  return hasTag(fm, GM_TAG);
667
700
  }
668
701
 
702
+ /** The case-sensitive archetypes and their author-facing meanings. */
703
+ export const BEING_ARCHETYPES = Object.freeze({
704
+ warrior: "Can hold a line and win a fight.",
705
+ skirmisher: "Fights light — ambush, missile, mobility.",
706
+ infiltrator: "Gets in unseen — locks, stealth, disguise.",
707
+ mage: "Commands arcane practice.",
708
+ cleric: "Commands religious practice and standing.",
709
+ healer: "Treats wounds and illness.",
710
+ scholar: "Reads, researches, and knows things.",
711
+ courtier: "Navigates rank, negotiation, and intrigue.",
712
+ woodsman: "Travels and survives wild country.",
713
+ mariner: "Handles boats and blue water.",
714
+ artisan: "Builds, repairs, and appraises craft work.",
715
+ trader: "Moves goods, values them, and knows markets.",
716
+ commoner: "Fits no more specific archetype; handles ordinary work and daily life.",
717
+ entertainer: "Performs for an audience through acting, music, comedy, or similar arts.",
718
+ guildsperson: "Has professional training, standing, or connections that open doors.",
719
+ });
720
+
669
721
  /**
670
722
  * Every note type this toolchain compiles, and the closed vocabulary it
671
723
  * declares.
@@ -706,7 +758,8 @@ export const NOTE_VOCABULARY = Object.freeze({
706
758
  ref: "lore",
707
759
  accepts: ["lore"],
708
760
  describe:
709
- "Lore concerning this being, such as the standing it holds or the law it lives under.",
761
+ "Lore concerning this being — the law it lives under, the customs it is " +
762
+ "subject to, the traditions it was raised in.",
710
763
  },
711
764
  {
712
765
  name: "culture",
@@ -725,10 +778,11 @@ export const NOTE_VOCABULARY = Object.freeze({
725
778
  },
726
779
  {
727
780
  name: "affiliations",
728
- ...LINKS,
729
- ref: "affiliation",
730
- accepts: ["affiliation"],
731
- describe: "Affiliations the being belongs to — traditions, polities, and the rest.",
781
+ ...STANDINGS,
782
+ describe:
783
+ "Bodies the being belongs to, keyed by Address, each entry holding the " +
784
+ "standing it holds there — `rank`, a level on that body's own ladder, " +
785
+ "and `office`, a post that body names.",
732
786
  },
733
787
  {
734
788
  name: "socialTies",
@@ -882,6 +936,10 @@ export const NOTE_VOCABULARY = Object.freeze({
882
936
  {
883
937
  name: "governance.offices",
884
938
  ...ANY,
939
+ // A map of named posts: each takes a row of its own, labelled
940
+ // by the post and anchored by it, so the description a body
941
+ // declares is a destination a being's `office` can link to.
942
+ roster: true,
885
943
  check: checkDatedOffices,
886
944
  describe: "Named offices, each with a description and optional dated holders.",
887
945
  },
@@ -8,6 +8,7 @@ import { completeAddress, ownDocumentSystem, renderAddress } from "./address.mjs
8
8
  import { NOTE_SYSTEM } from "./systems.mjs";
9
9
  import { JOURNAL_TYPES } from "./ids.mjs";
10
10
  import { hasDocEntry } from "./item-docs.mjs";
11
+ import { standingsDigest } from "./standings.mjs";
11
12
 
12
13
  /**
13
14
  * Names and published destinations for reference presentation.
@@ -21,7 +22,18 @@ export function buildReferenceTargets(records, published = new Map()) {
21
22
  const targets = new Map();
22
23
  for (const record of records) {
23
24
  if (!record.package || !record.type || !record.shortcode) continue;
24
- const value = { name: record.name?.full ?? record.name, subType: record.subType };
25
+ // `subType` and the standings a body confers both ride along for the
26
+ // same reason: a caller resolving a reference often needs to know what
27
+ // it found, not only where it is. A rank is a number on the *being*
28
+ // and what that number is called is declared on the *body*, so the
29
+ // ladder travels with the reference rather than being looked up a
30
+ // second way.
31
+ const standings = standingsDigest(record);
32
+ const value = {
33
+ name: record.name?.full ?? record.name,
34
+ subType: record.subType,
35
+ ...(standings ? { standings } : {}),
36
+ };
25
37
  const systems = new Set([ownDocumentSystem(record.type)]);
26
38
  if (JOURNAL_TYPES.has(record.type) || hasDocEntry(record.type)) systems.add(NOTE_SYSTEM);
27
39
  for (const system of systems) {
@@ -48,13 +48,8 @@
48
48
  * nothing checks unrecognized top-level keys, so left in place it would be
49
49
  * silently ignored rather than reported.
50
50
  *
51
- * **`name.aliases` fed the same index and is nonetheless kept.** It is
52
- * **reserved** — held for a use that does not exist yet — so it is the one
53
- * field here that is neither retired nor read. Nothing consults it: no index,
54
- * no resolver, no lint rule, no emitter. A note carrying one compiles,
55
- * resolves and emits exactly as if it were absent, and `tests/name-aliases-
56
- * reserved.test.ts` pins that equivalence so a future reader cannot be added
57
- * by accident.
51
+ * **`name.aliases` lists alternate names.** The note infobox shows them in
52
+ * authored order. They do not create addresses or change link resolution.
58
53
  *
59
54
  * **What `traits:` did.** It held a being's own description — gender,
60
55
  * species, age, birth date, height, weight, frame and `appearance.*` — at the
@@ -199,10 +194,8 @@ export function assertNoDraftField(fm, { file, absPath } = {}) {
199
194
  * unambiguous folded in every note's `name.full` and so dictated what a note
200
195
  * could be named. The form is retired, so the list has no reader.
201
196
  *
202
- * **`name.aliases` is a different field and is not retired.** It fed the same
203
- * index, but unlike the top-level list it is being kept — reserved, unread,
204
- * and deliberately unmentioned by this message, which would otherwise tell an
205
- * author to delete a field they are allowed to write. See
197
+ * **`name.aliases` is a different field.** It lists alternate names in the
198
+ * infobox and is deliberately unmentioned by this message. See
206
199
  * {@link assertNoAliasesField}.
207
200
  *
208
201
  * @param {string} [file] - The note's path, named in the message. Omit it where
@@ -227,13 +220,9 @@ export function aliasesRetiredMessage(file) {
227
220
  * it reads as "this note claims no other names", a statement about a namespace
228
221
  * that no longer exists.
229
222
  *
230
- * **The nested `name.aliases` is deliberately not refused.** Both spellings fed
231
- * the retired alias index, and both lost their reader with it, but only the
232
- * top-level one is retired: `name.aliases` is **reserved**, held for a use that
233
- * does not exist yet. So it is neither refused nor read — no index consults it,
234
- * no rule validates its contents, nothing derives from it, and nothing emits
235
- * it. It rides in the note as inert data, and a note carrying one compiles,
236
- * resolves and emits exactly as if it were absent.
223
+ * **The nested `name.aliases` is deliberately not refused.** It is the note's
224
+ * ordered alternate names and appears in the note infobox. It does not create
225
+ * addresses or change how authored links resolve.
237
226
  *
238
227
  * That distinction is the reason this checks `Object.hasOwn(fm, "aliases")`
239
228
  * rather than resolving a dotted key: the top-level field is the whole subject,
@@ -50,6 +50,7 @@
50
50
  */
51
51
 
52
52
  import { buildReferenceTargets } from "./reference-targets.mjs";
53
+ import { standingsDigest } from "./standings.mjs";
53
54
 
54
55
  import { resolveShortcodeReference } from "./shortcode-references.mjs";
55
56
 
@@ -218,11 +219,16 @@ export function buildSiteIndex(
218
219
  // needs to know what it found, not only where it is: an infobox groups
219
220
  // a being's skills by the family each skill note declares, and that
220
221
  // fact lives on the target rather than on the reference.
222
+ // The rungs and posts a body confers, carried for the same reason
223
+ // `subType` is: a being states a rank as a number, and what that
224
+ // number is called is declared on the body.
225
+ const standings = standingsDigest(e.fm);
221
226
  const value = {
222
227
  url: e.url,
223
228
  name: e.name,
224
229
  draft: isDraftNote(e.fm),
225
230
  ...(e.fm.subType ? { subType: e.fm.subType } : {}),
231
+ ...(standings ? { standings } : {}),
226
232
  };
227
233
 
228
234
  const shortcode = e.fm.shortcode;
@@ -343,7 +349,13 @@ export function resolveInfoboxRef(siteIndex, ref, hint) {
343
349
  hint,
344
350
  );
345
351
  return found ?
346
- { name: found.name, url: found.url, subType: found.subType, address: found.address }
352
+ {
353
+ name: found.name,
354
+ url: found.url,
355
+ subType: found.subType,
356
+ address: found.address,
357
+ ...(found.standings ? { standings: found.standings } : {}),
358
+ }
347
359
  : undefined;
348
360
  }
349
361
  const context = {
@@ -357,6 +369,13 @@ export function resolveInfoboxRef(siteIndex, ref, hint) {
357
369
  const found =
358
370
  siteIndex.referenceTargets?.get(renderAddress(tuple)) ??
359
371
  siteIndex.index?.get(renderAddress(tuple));
360
- if (found) return { name: found.name, url: found.url, subType: found.subType, address: tuple };
372
+ if (found)
373
+ return {
374
+ name: found.name,
375
+ url: found.url,
376
+ subType: found.subType,
377
+ address: tuple,
378
+ ...(found.standings ? { standings: found.standings } : {}),
379
+ };
361
380
  return undefined;
362
381
  }