@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.
- package/CHANGELOG-content-build.md +0 -8
- package/CHANGELOG.md +13 -0
- package/docs/api.md +10 -10
- package/docs/reference/format-details.md +51 -6
- package/docs/reference/note-types.md +28 -28
- package/engine/frontmatter-lint.mjs +15 -2
- package/engine/helpers.mjs +5 -0
- package/engine/infobox-render.mjs +6 -1
- package/engine/infobox.mjs +85 -0
- package/engine/note-addresses.mjs +17 -8
- package/engine/note-vocabulary.mjs +46 -7
- package/engine/reference-targets.mjs +13 -1
- 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-vocabulary.d.mts +16 -2
- 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
|
@@ -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
|
*
|
|
@@ -725,7 +758,8 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
725
758
|
ref: "lore",
|
|
726
759
|
accepts: ["lore"],
|
|
727
760
|
describe:
|
|
728
|
-
"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.",
|
|
729
763
|
},
|
|
730
764
|
{
|
|
731
765
|
name: "culture",
|
|
@@ -744,10 +778,11 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
744
778
|
},
|
|
745
779
|
{
|
|
746
780
|
name: "affiliations",
|
|
747
|
-
...
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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.",
|
|
751
786
|
},
|
|
752
787
|
{
|
|
753
788
|
name: "socialTies",
|
|
@@ -901,6 +936,10 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
901
936
|
{
|
|
902
937
|
name: "governance.offices",
|
|
903
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,
|
|
904
943
|
check: checkDatedOffices,
|
|
905
944
|
describe: "Named offices, each with a description and optional dated holders.",
|
|
906
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) {
|
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
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: GPL-3.0-or-later */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The note type a standing is held in.
|
|
5
|
+
*
|
|
6
|
+
* A standing is always held in a body: the entry is keyed by that body's
|
|
7
|
+
* Address, which is what lets the rung and the post be checked against what
|
|
8
|
+
* the body itself declares.
|
|
9
|
+
*/
|
|
10
|
+
export const STANDING_BODY_TYPES = Object.freeze(["affiliation"]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The two keys one standing carries.
|
|
14
|
+
*
|
|
15
|
+
* `rank` is the level on the body's own `governance.ranks` ladder, as a number.
|
|
16
|
+
* `office` is a key of its `governance.offices` map, as a string. Both are
|
|
17
|
+
* optional — a membership with neither is the ordinary shape.
|
|
18
|
+
*/
|
|
19
|
+
export const STANDING_KEYS = Object.freeze(["rank", "office"]);
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* **A being's standing, held in the body that confers it.**
|
|
16
|
+
*
|
|
17
|
+
* `data.affiliations` is a map keyed by the affiliation's Address, and each
|
|
18
|
+
* entry carries the standing the being holds *in that body*:
|
|
19
|
+
*
|
|
20
|
+
* ```yaml
|
|
21
|
+
* data:
|
|
22
|
+
* affiliations:
|
|
23
|
+
* vrystwldtrbs: { rank: 5, office: War Chief }
|
|
24
|
+
* greenwardens: { rank: 1 }
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* The key is what makes the pair expressible. A being in two bodies holds a
|
|
28
|
+
* standing in each, two bodies may name a rung the same word, and both facts
|
|
29
|
+
* are about one membership rather than about the being at large.
|
|
30
|
+
*
|
|
31
|
+
* **`rank` is a number, not a link.** The body declares `{level, title,
|
|
32
|
+
* description}` for every rung of its `governance.ranks`, so a number indexes
|
|
33
|
+
* into that ladder and the body stays the single source for what its level 5 is
|
|
34
|
+
* called. **`office` is a string**, matched against the keys of the body's
|
|
35
|
+
* `governance.offices` map: there are far more offices in a tree than bodies,
|
|
36
|
+
* most of them held by one body alone, and an Address would make writing the
|
|
37
|
+
* fact wait on authoring a page about the post.
|
|
38
|
+
*
|
|
39
|
+
* Both are resolved against the one affiliation the entry is keyed by, which is
|
|
40
|
+
* what lets each be checked — a rung the body does not confer and an office it
|
|
41
|
+
* does not hold are both findings naming the file, line and column.
|
|
42
|
+
*
|
|
43
|
+
* @module
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { acceptsType, isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
|
|
47
|
+
import { AddressEntries } from "./address-values.mjs";
|
|
48
|
+
import { slugify } from "./content-slug.mjs";
|
|
49
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
50
|
+
import { STANDING_BODY_TYPES, STANDING_KEYS } from "./standing-terms.mjs";
|
|
51
|
+
|
|
52
|
+
export { STANDING_BODY_TYPES, STANDING_KEYS };
|
|
53
|
+
|
|
54
|
+
/** Whether a value is a plain mapping. */
|
|
55
|
+
function mapping(value) {
|
|
56
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The standings a being authored, in one shape whichever shape it wrote.
|
|
61
|
+
*
|
|
62
|
+
* The list form names bodies and no standings; the map form names both. Three
|
|
63
|
+
* spellings reach here and every reader downstream takes the same entries, so
|
|
64
|
+
* none of them asks which it was handed:
|
|
65
|
+
*
|
|
66
|
+
* - an **{@link AddressEntries}** — the map after the note boundary decoded its
|
|
67
|
+
* keys into typed Addresses, which is the shape every compile sees;
|
|
68
|
+
* - a **plain map**, which is the shape a checker reading raw YAML sees;
|
|
69
|
+
* - a **list**, which names bodies and says nothing about standing in them.
|
|
70
|
+
*
|
|
71
|
+
* An emptied map arrives from the property editor as `[]` and means what `{}`
|
|
72
|
+
* means — this being authors no membership — so both read as no entries.
|
|
73
|
+
*
|
|
74
|
+
* Each entry carries `body` for a message and `sourceKey` for a position: the
|
|
75
|
+
* authored key is what a reader has to find in the file, and the decoded
|
|
76
|
+
* Address is what a finding should name.
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} value - The authored `data.affiliations`.
|
|
79
|
+
* @returns {{form: "absent"|"list"|"map"|"malformed",
|
|
80
|
+
* entries: Array<{body: unknown, sourceKey: string|number, standing: unknown}>}}
|
|
81
|
+
* The form it was written in, and its entries.
|
|
82
|
+
*/
|
|
83
|
+
export function readStandings(value) {
|
|
84
|
+
if (value === undefined || value === null) return { form: "absent", entries: [] };
|
|
85
|
+
if (value instanceof AddressEntries) {
|
|
86
|
+
return {
|
|
87
|
+
form: "map",
|
|
88
|
+
entries: value.entries.map((entry) => ({
|
|
89
|
+
body: entry.target,
|
|
90
|
+
sourceKey: entry.sourceKey,
|
|
91
|
+
standing: entry.value,
|
|
92
|
+
})),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
// An empty list is an emptied map, not a membership stated as a list.
|
|
97
|
+
if (value.length === 0) return { form: "map", entries: [] };
|
|
98
|
+
return {
|
|
99
|
+
form: "list",
|
|
100
|
+
entries: value.map((body, position) => ({
|
|
101
|
+
body,
|
|
102
|
+
sourceKey: position,
|
|
103
|
+
standing: undefined,
|
|
104
|
+
})),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (!mapping(value) || isAddressTuple(value)) return { form: "malformed", entries: [] };
|
|
108
|
+
return {
|
|
109
|
+
form: "map",
|
|
110
|
+
entries: Object.entries(value).map(([body, standing]) => ({
|
|
111
|
+
body,
|
|
112
|
+
sourceKey: body,
|
|
113
|
+
standing,
|
|
114
|
+
})),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The rungs an affiliation's ladder declares, as level → title.
|
|
120
|
+
*
|
|
121
|
+
* A rung states its own `level`, so the ladder is read by that number rather
|
|
122
|
+
* than by its position in the list: a body may write its rungs in any order,
|
|
123
|
+
* and several start at zero.
|
|
124
|
+
*
|
|
125
|
+
* @param {unknown} ranks - An affiliation's `data.governance.ranks`.
|
|
126
|
+
* @returns {Map<number, string>} Level → the title that level is called.
|
|
127
|
+
*/
|
|
128
|
+
export function rankLadder(ranks) {
|
|
129
|
+
const ladder = new Map();
|
|
130
|
+
if (!Array.isArray(ranks)) return ladder;
|
|
131
|
+
for (const rung of ranks) {
|
|
132
|
+
if (!mapping(rung)) continue;
|
|
133
|
+
const level = Number(rung.level);
|
|
134
|
+
if (!Number.isFinite(level)) continue;
|
|
135
|
+
if (!ladder.has(level)) ladder.set(level, typeof rung.title === "string" ? rung.title : "");
|
|
136
|
+
}
|
|
137
|
+
return ladder;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The offices an affiliation names, as key → description.
|
|
142
|
+
*
|
|
143
|
+
* An office is authored either as a bare description or as a `{description,
|
|
144
|
+
* holders}` map, and a reader of the post's name wants the same string from
|
|
145
|
+
* both.
|
|
146
|
+
*
|
|
147
|
+
* @param {unknown} offices - An affiliation's `data.governance.offices`.
|
|
148
|
+
* @returns {Map<string, string>} Office key → its description.
|
|
149
|
+
*/
|
|
150
|
+
export function officeRoster(offices) {
|
|
151
|
+
const roster = new Map();
|
|
152
|
+
if (!mapping(offices)) return roster;
|
|
153
|
+
for (const [office, value] of Object.entries(offices)) {
|
|
154
|
+
if (typeof value === "string") roster.set(office, value);
|
|
155
|
+
else if (mapping(value))
|
|
156
|
+
roster.set(office, typeof value.description === "string" ? value.description : "");
|
|
157
|
+
else roster.set(office, "");
|
|
158
|
+
}
|
|
159
|
+
return roster;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The anchor an office's row is addressed by.
|
|
164
|
+
*
|
|
165
|
+
* Declared once, because the row that carries the anchor and the link that
|
|
166
|
+
* reaches it are drawn by different passes: two spellings of the same
|
|
167
|
+
* derivation is a link into a page that offers no such destination.
|
|
168
|
+
*
|
|
169
|
+
* An office whose name reduces to nothing URL-safe has no anchor, and the
|
|
170
|
+
* caller is the one that can say what to do without one.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} office - The office's key, as the body wrote it.
|
|
173
|
+
* @returns {string} The anchor, or `""` where the name carries nothing.
|
|
174
|
+
*/
|
|
175
|
+
export function officeAnchor(office) {
|
|
176
|
+
const slug = slugify(office);
|
|
177
|
+
return slug ? `office-${slug}` : "";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* What an affiliation lends to a reference to it.
|
|
182
|
+
*
|
|
183
|
+
* A reference's value answers what was found as well as where it is — the same
|
|
184
|
+
* reason a target's `subType` travels with it. A standing is read on the
|
|
185
|
+
* *being*, and what it is called is declared on the *body*, so the ladder and
|
|
186
|
+
* the office roster ride along to the one place that needs them.
|
|
187
|
+
*
|
|
188
|
+
* Nothing rides for a note that declares neither.
|
|
189
|
+
*
|
|
190
|
+
* @param {object} fm - The affiliation note's frontmatter.
|
|
191
|
+
* @returns {{ranks?: Record<string, string>, offices?: Record<string, string>}|undefined}
|
|
192
|
+
* What a reference carries, or nothing.
|
|
193
|
+
*/
|
|
194
|
+
export function standingsDigest(fm) {
|
|
195
|
+
const governance = fm?.data?.governance;
|
|
196
|
+
if (!mapping(governance)) return undefined;
|
|
197
|
+
const ladder = rankLadder(governance.ranks);
|
|
198
|
+
const roster = officeRoster(governance.offices);
|
|
199
|
+
if (!ladder.size && !roster.size) return undefined;
|
|
200
|
+
return {
|
|
201
|
+
...(ladder.size ? { ranks: Object.fromEntries(ladder) } : {}),
|
|
202
|
+
...(roster.size ? { offices: Object.fromEntries(roster) } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* How a standing reads beside the body that confers it.
|
|
208
|
+
*
|
|
209
|
+
* The office leads, because it is the more particular of the two: a reader
|
|
210
|
+
* meeting "War Chief" has been told the rung as well. The body closes the
|
|
211
|
+
* phrase, which is the whole point of keying the entry by it — a standing with
|
|
212
|
+
* no body named is the lossy form.
|
|
213
|
+
*
|
|
214
|
+
* No article is supplied. One body's name reads with a "the" in front of it and
|
|
215
|
+
* the next does not, and inventing one is this build editing the corpus.
|
|
216
|
+
*
|
|
217
|
+
* @param {object} standing - `{rank, office}`, either or both absent.
|
|
218
|
+
* @param {string} body - What the body is called.
|
|
219
|
+
* @param {{ranks?: Record<string, string>}} [digest] - The body's ladder.
|
|
220
|
+
* @returns {string} The phrase, or the body's name where no standing is held.
|
|
221
|
+
*/
|
|
222
|
+
export function standingPhrase(standing, body, digest) {
|
|
223
|
+
const parts = [];
|
|
224
|
+
if (typeof standing?.office === "string" && standing.office.trim())
|
|
225
|
+
parts.push(standing.office.trim());
|
|
226
|
+
const level = Number(standing?.rank);
|
|
227
|
+
if (Number.isFinite(level)) {
|
|
228
|
+
const title = digest?.ranks?.[String(level)];
|
|
229
|
+
parts.push(title ? title : `Rank ${level}`);
|
|
230
|
+
}
|
|
231
|
+
return parts.length ? `${parts.join(", ")}, of ${body}` : body;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Validate a being's memberships and the standing it holds in each.
|
|
236
|
+
*
|
|
237
|
+
* Four questions, and the last two are what keying the entry by its body buys:
|
|
238
|
+
*
|
|
239
|
+
* 1. the value is a map keyed by Address (or the list this window still takes);
|
|
240
|
+
* 2. every key names an affiliation that resolves, once;
|
|
241
|
+
* 3. a `rank` is a level the named body's `governance.ranks` declares;
|
|
242
|
+
* 4. an `office` is a key of that body's `governance.offices`.
|
|
243
|
+
*
|
|
244
|
+
* Both of the last two are read from the named body's own frontmatter as the
|
|
245
|
+
* lint runs, so a renamed rung or a renamed post fails on every being pointing
|
|
246
|
+
* at it rather than on a list somebody has to remember to edit.
|
|
247
|
+
*
|
|
248
|
+
* @param {object} note - The note.
|
|
249
|
+
* @param {{index?: object}} [options] - The lint's options.
|
|
250
|
+
* @returns {object[]} Located findings.
|
|
251
|
+
*/
|
|
252
|
+
export function checkStandings(note, { index } = {}) {
|
|
253
|
+
const value = note.fm?.data?.affiliations;
|
|
254
|
+
const { form, entries } = readStandings(value);
|
|
255
|
+
if (form === "absent") return [];
|
|
256
|
+
if (form === "malformed")
|
|
257
|
+
return [
|
|
258
|
+
{
|
|
259
|
+
...position(note, ["data", "affiliations"]),
|
|
260
|
+
message:
|
|
261
|
+
"`data.affiliations` must be a map keyed by an affiliation Address, " +
|
|
262
|
+
"each entry holding that body's standing",
|
|
263
|
+
},
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
const findings = [];
|
|
267
|
+
const seen = new Map();
|
|
268
|
+
// The list form names bodies and has nowhere to put a standing. Its entries
|
|
269
|
+
// are still held to being affiliations that resolve, so a tree part-way
|
|
270
|
+
// through the sweep keeps the check it had.
|
|
271
|
+
const isKey = form === "map";
|
|
272
|
+
for (const { body, sourceKey, standing } of entries) {
|
|
273
|
+
const path = ["data", "affiliations", sourceKey];
|
|
274
|
+
const resolved = resolveBody(note, index, body);
|
|
275
|
+
findings.push(...bodyFindings(note, body, path, isKey, resolved));
|
|
276
|
+
if (resolved.address) {
|
|
277
|
+
if (seen.has(resolved.address)) {
|
|
278
|
+
findings.push({
|
|
279
|
+
...position(note, path, isKey),
|
|
280
|
+
message:
|
|
281
|
+
`\`data.affiliations\` names ${resolved.address} twice, as ` +
|
|
282
|
+
`${JSON.stringify(String(seen.get(resolved.address)))} and ` +
|
|
283
|
+
`${JSON.stringify(String(sourceKey))}; one membership per body`,
|
|
284
|
+
});
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
seen.set(resolved.address, sourceKey);
|
|
288
|
+
}
|
|
289
|
+
if (!isKey) continue;
|
|
290
|
+
if (standing === undefined || standing === null) continue;
|
|
291
|
+
if (Array.isArray(standing) && standing.length === 0) continue;
|
|
292
|
+
const named = resolved.address ?? String(body);
|
|
293
|
+
if (!mapping(standing)) {
|
|
294
|
+
findings.push({
|
|
295
|
+
...position(note, path),
|
|
296
|
+
message:
|
|
297
|
+
`\`data.affiliations\` entry for ${named} must hold that body's standing ` +
|
|
298
|
+
"as a map of `rank` and `office`, but reads " +
|
|
299
|
+
JSON.stringify(standing),
|
|
300
|
+
});
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
for (const key of Object.keys(standing)) {
|
|
304
|
+
if (STANDING_KEYS.includes(key)) continue;
|
|
305
|
+
findings.push({
|
|
306
|
+
...position(note, [...path, key], true),
|
|
307
|
+
message:
|
|
308
|
+
`\`data.affiliations\` entry for ${named} has unknown key \`${key}\`; a ` +
|
|
309
|
+
`standing holds ${STANDING_KEYS.map((name) => `\`${name}\``).join(" and ")}`,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
findings.push(...rankFindings(note, standing, named, path, resolved));
|
|
313
|
+
findings.push(...officeFindings(note, standing, named, path, resolved));
|
|
314
|
+
}
|
|
315
|
+
return findings;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Where a finding about one authored key sits.
|
|
320
|
+
*
|
|
321
|
+
* @param {object} note - The note.
|
|
322
|
+
* @param {Array<string|number>} path - The frontmatter path.
|
|
323
|
+
* @param {boolean} [key] - Whether the key itself is at fault.
|
|
324
|
+
* @returns {object} The located, error-severity half of a finding.
|
|
325
|
+
*/
|
|
326
|
+
function position(note, path, key) {
|
|
327
|
+
return {
|
|
328
|
+
file: note.file,
|
|
329
|
+
...positionOfFrontmatterPath(note.raw ?? "", path, key ? { key: true } : undefined),
|
|
330
|
+
severity: "error",
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The affiliation one key or entry names, and what that note declares.
|
|
336
|
+
*
|
|
337
|
+
* @param {object} note - The citing note.
|
|
338
|
+
* @param {object|undefined} index - The lint's address index.
|
|
339
|
+
* @param {unknown} body - The authored Address.
|
|
340
|
+
* @returns {{address?: string, type?: string, reason?: string, fm?: object, missing?: boolean}}
|
|
341
|
+
* What it names.
|
|
342
|
+
*/
|
|
343
|
+
function resolveBody(note, index, body) {
|
|
344
|
+
const tuple = parseAddress(
|
|
345
|
+
body,
|
|
346
|
+
{
|
|
347
|
+
package: index?.contentPackage ?? note.fm?.package,
|
|
348
|
+
system: "note",
|
|
349
|
+
type: "affiliation",
|
|
350
|
+
types: index?.types,
|
|
351
|
+
packages: index?.packages,
|
|
352
|
+
},
|
|
353
|
+
{ declared: true },
|
|
354
|
+
);
|
|
355
|
+
if (tuple.reason) return { reason: tuple.reason };
|
|
356
|
+
if (!acceptsType(tuple, STANDING_BODY_TYPES)) return { type: tuple.type };
|
|
357
|
+
const address = renderAddress(tuple);
|
|
358
|
+
if (!index?.addressHit) return { address };
|
|
359
|
+
const target = index.addressHit(address);
|
|
360
|
+
if (!target) return { address, missing: true };
|
|
361
|
+
return { address, fm: target.fm ?? target };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Findings about the body an entry names. */
|
|
365
|
+
function bodyFindings(note, body, path, isKey, found) {
|
|
366
|
+
const at = position(note, path, isKey);
|
|
367
|
+
const written = isAddressTuple(body) ? renderAddress(body) : String(body);
|
|
368
|
+
const label = `\`data.affiliations\`${isKey ? " key" : ""} ${JSON.stringify(written)}`;
|
|
369
|
+
if (found.reason)
|
|
370
|
+
return [{ ...at, message: `${label} is not a complete Address (${found.reason})` }];
|
|
371
|
+
if (found.type)
|
|
372
|
+
return [{ ...at, message: `\`data.affiliations\` accepts affiliation, not ${found.type}` }];
|
|
373
|
+
if (found.missing)
|
|
374
|
+
return [
|
|
375
|
+
{
|
|
376
|
+
...at,
|
|
377
|
+
message:
|
|
378
|
+
`\`data.affiliations\` names ${found.address}, which does not resolve ` +
|
|
379
|
+
"in this package or its declared dependencies",
|
|
380
|
+
},
|
|
381
|
+
];
|
|
382
|
+
return [];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Whether a `rank` is a rung the named body confers. */
|
|
386
|
+
function rankFindings(note, standing, body, path, resolved) {
|
|
387
|
+
if (standing.rank === undefined || standing.rank === null) return [];
|
|
388
|
+
const at = position(note, [...path, "rank"]);
|
|
389
|
+
const level = typeof standing.rank === "number" ? standing.rank : Number(standing.rank);
|
|
390
|
+
if (typeof standing.rank !== "number" || !Number.isInteger(level))
|
|
391
|
+
return [
|
|
392
|
+
{
|
|
393
|
+
...at,
|
|
394
|
+
message:
|
|
395
|
+
`\`data.affiliations\` entry for ${body}: \`rank\` must be the level on that body's own ` +
|
|
396
|
+
`ladder, as a whole number, but reads ${JSON.stringify(standing.rank)}`,
|
|
397
|
+
},
|
|
398
|
+
];
|
|
399
|
+
if (!resolved.fm) return [];
|
|
400
|
+
const ladder = rankLadder(resolved.fm.data?.governance?.ranks);
|
|
401
|
+
if (ladder.has(level)) return [];
|
|
402
|
+
if (!ladder.size)
|
|
403
|
+
return [
|
|
404
|
+
{
|
|
405
|
+
...at,
|
|
406
|
+
message:
|
|
407
|
+
`\`data.affiliations\` entry for ${body} states a rung, and that body ` +
|
|
408
|
+
"declares no `data.governance.ranks` ladder to hold one",
|
|
409
|
+
},
|
|
410
|
+
];
|
|
411
|
+
const levels = [...ladder.keys()].sort((a, b) => a - b);
|
|
412
|
+
return [
|
|
413
|
+
{
|
|
414
|
+
...at,
|
|
415
|
+
message:
|
|
416
|
+
`\`data.affiliations\` entry for ${body}: rank ${level} is not a level ` +
|
|
417
|
+
`${resolved.address} confers; its ladder runs ${levels.join(", ")}`,
|
|
418
|
+
},
|
|
419
|
+
];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Whether an `office` is a post the named body names. */
|
|
423
|
+
function officeFindings(note, standing, body, path, resolved) {
|
|
424
|
+
if (standing.office === undefined || standing.office === null) return [];
|
|
425
|
+
const at = position(note, [...path, "office"]);
|
|
426
|
+
if (typeof standing.office !== "string" || !standing.office.trim())
|
|
427
|
+
return [
|
|
428
|
+
{
|
|
429
|
+
...at,
|
|
430
|
+
message:
|
|
431
|
+
`\`data.affiliations\` entry for ${body}: \`office\` must name a post from its ` +
|
|
432
|
+
`\`governance.offices\`, but reads ${JSON.stringify(standing.office)}`,
|
|
433
|
+
},
|
|
434
|
+
];
|
|
435
|
+
if (!resolved.fm) return [];
|
|
436
|
+
const roster = officeRoster(resolved.fm.data?.governance?.offices);
|
|
437
|
+
if (roster.has(standing.office)) return [];
|
|
438
|
+
if (!roster.size)
|
|
439
|
+
return [
|
|
440
|
+
{
|
|
441
|
+
...at,
|
|
442
|
+
message:
|
|
443
|
+
`\`data.affiliations\` entry for ${body} names an office, and that body ` +
|
|
444
|
+
`${resolved.address} declares no \`data.governance.offices\` to hold one`,
|
|
445
|
+
},
|
|
446
|
+
];
|
|
447
|
+
return [
|
|
448
|
+
{
|
|
449
|
+
...at,
|
|
450
|
+
message:
|
|
451
|
+
`\`data.affiliations\` entry for ${body}: office ${JSON.stringify(standing.office)} is not ` +
|
|
452
|
+
`an office ${resolved.address} names; it holds ${[...roster.keys()].join(", ")}`,
|
|
453
|
+
},
|
|
454
|
+
];
|
|
455
|
+
}
|