@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.
- package/CHANGELOG-content-build.md +0 -8
- package/CHANGELOG.md +20 -0
- package/ci/content-format-reference.mjs +31 -1
- package/docs/api.md +11 -10
- package/docs/authoring/frontmatter.md +3 -1
- package/docs/reference/format-details.md +93 -34
- package/docs/reference/note-types.md +28 -28
- package/engine/base-compiler.mjs +2 -2
- package/engine/frontmatter-lint.mjs +117 -15
- package/engine/helpers.mjs +5 -0
- package/engine/infobox-render.mjs +6 -1
- package/engine/infobox.mjs +88 -0
- package/engine/note-addresses.mjs +17 -8
- package/engine/note-frontmatter.mjs +6 -0
- package/engine/note-vocabulary.mjs +65 -7
- package/engine/reference-targets.mjs +13 -1
- package/engine/retired-fields.mjs +7 -18
- package/engine/site-index.mjs +21 -2
- package/engine/standing-terms.mjs +19 -0
- package/engine/standings.mjs +455 -0
- package/engine/svg-theme.mjs +15 -4
- package/engine/wikilinks.mjs +3 -1
- package/package.json +1 -1
- package/sohl/actors.mjs +61 -5
- package/types/engine/note-frontmatter.d.mts +4 -0
- package/types/engine/note-vocabulary.d.mts +34 -2
- package/types/engine/retired-fields.d.mts +5 -11
- package/types/engine/standing-terms.d.mts +16 -0
- package/types/engine/standings.d.mts +129 -0
- package/types/engine/wikilinks.d.mts +2 -1
- package/types/sohl/actors.d.mts +19 -0
|
@@ -55,7 +55,12 @@
|
|
|
55
55
|
*/
|
|
56
56
|
|
|
57
57
|
import { isAddressTuple } from "./address.mjs";
|
|
58
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
package/engine/helpers.mjs
CHANGED
|
@@ -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
|
-
|
|
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") {
|
package/engine/infobox.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
...
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
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`
|
|
52
|
-
*
|
|
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
|
|
203
|
-
*
|
|
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.**
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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,
|
package/engine/site-index.mjs
CHANGED
|
@@ -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
|
-
{
|
|
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)
|
|
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
|
}
|