@heroiclands/package-build 22.20.0 → 22.22.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 +204 -0
- package/assets/styles/content-base.css +162 -0
- package/bin/content-commands.mjs +26 -3
- package/bin/package-build.mjs +15 -2
- package/ci/content-format-reference.mjs +68 -1
- package/config.mjs +26 -0
- package/content-config.mjs +73 -4
- package/docs/api.md +46 -28
- package/docs/authoring/dates-and-calendars.md +4 -2
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +237 -28
- package/docs/configuration.md +27 -1
- package/docs/content-format.md +3 -1
- package/docs/guides/reference-pages.md +15 -0
- package/docs/reference/format-details.md +265 -42
- package/docs/reference/note-types.md +45 -28
- package/engine/actor-compiler.mjs +23 -0
- package/engine/anchored-sections.mjs +35 -4
- package/engine/anchors.mjs +10 -20
- package/engine/base-compiler.mjs +86 -21
- package/engine/base-styles.mjs +149 -0
- package/engine/bundles.mjs +0 -1
- package/engine/compendiums.mjs +8 -1
- package/engine/content-blocks.mjs +410 -0
- package/engine/content-captions.mjs +57 -8
- package/engine/content-footnotes.mjs +189 -0
- package/engine/content-format-check.mjs +15 -12
- package/engine/content-index.mjs +2 -2
- package/engine/content-links.mjs +14 -6
- package/engine/content-tables.mjs +45 -4
- package/engine/content-tasklists.mjs +189 -0
- package/engine/docs-checks.mjs +4 -4
- package/engine/extension-attributes.mjs +48 -2
- package/engine/frontmatter-lint.mjs +62 -66
- package/engine/frontmatter.mjs +3 -3
- package/engine/heading-attributes.mjs +308 -0
- package/engine/helpers.mjs +19 -13
- package/engine/homepage.mjs +8 -8
- package/engine/index.mjs +3 -0
- package/engine/infobox-render.mjs +21 -4
- package/engine/infobox.mjs +167 -7
- package/engine/journals.mjs +36 -17
- package/engine/macros.mjs +1 -1
- package/engine/markdown-expressions.mjs +178 -53
- package/engine/note-addresses.mjs +17 -8
- package/engine/note-dates.mjs +49 -0
- package/engine/note-frontmatter.mjs +7 -16
- package/engine/note-vocabulary.mjs +216 -42
- package/engine/page-lists.mjs +358 -0
- package/engine/pdf-build.mjs +76 -42
- package/engine/pdf-render.mjs +422 -49
- package/engine/rank-ladder.mjs +198 -0
- package/engine/reckoning-markers.mjs +1 -1
- package/engine/reference-targets.mjs +13 -1
- package/engine/retired-fields.mjs +8 -13
- package/engine/scenes.mjs +10 -3
- package/engine/site-build.mjs +41 -12
- package/engine/site-config.mjs +10 -1
- package/engine/site-index.mjs +39 -4
- package/engine/site-preview.mjs +18 -7
- package/engine/social-ties.mjs +52 -14
- package/engine/sql-tables.mjs +66 -9
- package/engine/standing-terms.mjs +19 -0
- package/engine/standings.mjs +538 -0
- package/engine/svg-theme.mjs +15 -4
- package/engine/system-block.mjs +12 -14
- package/engine/web-wikilinks.mjs +48 -1
- package/engine/wikilinks.mjs +3 -1
- package/hm3/actors.mjs +6 -3
- package/manifest.mjs +13 -0
- package/package.json +1 -1
- package/sohl/actors.mjs +82 -103
- package/sohl/item-fields.mjs +4 -5
- package/sohl/items.mjs +1 -1
- package/sohl/note-schemas.mjs +11 -21
- package/types/config.d.mts +9 -0
- package/types/engine/actor-compiler.d.mts +11 -0
- package/types/engine/anchored-sections.d.mts +24 -0
- package/types/engine/base-compiler.d.mts +19 -0
- package/types/engine/base-styles.d.mts +93 -0
- package/types/engine/content-blocks.d.mts +87 -0
- package/types/engine/content-captions.d.mts +14 -1
- package/types/engine/content-footnotes.d.mts +73 -0
- package/types/engine/content-format-check.d.mts +8 -6
- package/types/engine/content-tables.d.mts +12 -4
- package/types/engine/content-tasklists.d.mts +72 -0
- package/types/engine/extension-attributes.d.mts +22 -1
- package/types/engine/frontmatter.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +174 -0
- package/types/engine/helpers.d.mts +7 -4
- package/types/engine/homepage.d.mts +7 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/infobox-render.d.mts +6 -1
- package/types/engine/journals.d.mts +14 -7
- package/types/engine/markdown-expressions.d.mts +23 -0
- package/types/engine/note-frontmatter.d.mts +1 -4
- package/types/engine/note-vocabulary.d.mts +98 -8
- package/types/engine/page-lists.d.mts +126 -0
- package/types/engine/pdf-render.d.mts +72 -2
- package/types/engine/rank-ladder.d.mts +32 -0
- package/types/engine/retired-fields.d.mts +4 -6
- package/types/engine/site-build.d.mts +13 -1
- package/types/engine/site-config.d.mts +9 -0
- package/types/engine/site-index.d.mts +13 -1
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +31 -7
- package/types/engine/standing-terms.d.mts +16 -0
- package/types/engine/standings.d.mts +147 -0
- package/types/engine/system-block.d.mts +11 -12
- package/types/engine/web-wikilinks.d.mts +3 -1
- package/types/engine/wikilinks.d.mts +2 -1
- package/types/hm3/actors.d.mts +3 -3
- package/types/sohl/actors.d.mts +25 -7
- package/engine/content-admonitions.mjs +0 -114
- package/engine/content-secrets.mjs +0 -112
- package/types/engine/content-admonitions.d.mts +0 -24
- package/types/engine/content-secrets.d.mts +0 -17
|
@@ -0,0 +1,538 @@
|
|
|
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
|
+
* **An entry states a rung, and `rank` is required.** Belonging to a body is
|
|
44
|
+
* holding some standing in it, so `1` is the ordinary member and `0` is the rung
|
|
45
|
+
* for someone cast out — between them they cover every standing a member can
|
|
46
|
+
* hold, which is what makes the requirement writable. `office` is the optional
|
|
47
|
+
* half, and an entry carrying one and no rung is the same fault rather than a
|
|
48
|
+
* second: an office distinguishes a person within a standing rather than
|
|
49
|
+
* standing in for one.
|
|
50
|
+
*
|
|
51
|
+
* @module
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { acceptsType, isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
|
|
55
|
+
import { AddressEntries } from "./address-values.mjs";
|
|
56
|
+
import { slugify } from "./content-slug.mjs";
|
|
57
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
58
|
+
import { STANDING_BODY_TYPES, STANDING_KEYS } from "./standing-terms.mjs";
|
|
59
|
+
|
|
60
|
+
export { STANDING_BODY_TYPES, STANDING_KEYS };
|
|
61
|
+
|
|
62
|
+
/** Whether a value is a plain mapping. */
|
|
63
|
+
function mapping(value) {
|
|
64
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The standings a being authored, in one shape whichever shape it wrote.
|
|
69
|
+
*
|
|
70
|
+
* The list form names bodies and no standings; the map form names both. Three
|
|
71
|
+
* spellings reach here and every reader downstream takes the same entries, so
|
|
72
|
+
* none of them asks which it was handed:
|
|
73
|
+
*
|
|
74
|
+
* - an **{@link AddressEntries}** — the map after the note boundary decoded its
|
|
75
|
+
* keys into typed Addresses, which is the shape every compile sees;
|
|
76
|
+
* - a **plain map**, which is the shape a checker reading raw YAML sees;
|
|
77
|
+
* - a **list**, which names bodies and says nothing about standing in them.
|
|
78
|
+
*
|
|
79
|
+
* An emptied map arrives from the property editor as `[]` and means what `{}`
|
|
80
|
+
* means — this being authors no membership — so both read as no entries.
|
|
81
|
+
*
|
|
82
|
+
* Each entry carries `body` for a message and `sourceKey` for a position: the
|
|
83
|
+
* authored key is what a reader has to find in the file, and the decoded
|
|
84
|
+
* Address is what a finding should name.
|
|
85
|
+
*
|
|
86
|
+
* @param {unknown} value - The authored `data.affiliations`.
|
|
87
|
+
* @returns {{form: "absent"|"list"|"map"|"malformed",
|
|
88
|
+
* entries: Array<{body: unknown, sourceKey: string|number, standing: unknown}>}}
|
|
89
|
+
* The form it was written in, and its entries.
|
|
90
|
+
*/
|
|
91
|
+
export function readStandings(value) {
|
|
92
|
+
if (value === undefined || value === null) return { form: "absent", entries: [] };
|
|
93
|
+
if (value instanceof AddressEntries) {
|
|
94
|
+
return {
|
|
95
|
+
form: "map",
|
|
96
|
+
entries: value.entries.map((entry) => ({
|
|
97
|
+
body: entry.target,
|
|
98
|
+
sourceKey: entry.sourceKey,
|
|
99
|
+
standing: entry.value,
|
|
100
|
+
})),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (Array.isArray(value)) {
|
|
104
|
+
// An empty list is an emptied map, not a membership stated as a list.
|
|
105
|
+
if (value.length === 0) return { form: "map", entries: [] };
|
|
106
|
+
return {
|
|
107
|
+
form: "list",
|
|
108
|
+
entries: value.map((body, position) => ({
|
|
109
|
+
body,
|
|
110
|
+
sourceKey: position,
|
|
111
|
+
standing: undefined,
|
|
112
|
+
})),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (!mapping(value) || isAddressTuple(value)) return { form: "malformed", entries: [] };
|
|
116
|
+
return {
|
|
117
|
+
form: "map",
|
|
118
|
+
entries: Object.entries(value).map(([body, standing]) => ({
|
|
119
|
+
body,
|
|
120
|
+
sourceKey: body,
|
|
121
|
+
standing,
|
|
122
|
+
})),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The rungs an affiliation's ladder declares, as level → title.
|
|
128
|
+
*
|
|
129
|
+
* A rung states its own `level`, so the ladder is read by that number rather
|
|
130
|
+
* than by its position in the list: a body may write its rungs in any order,
|
|
131
|
+
* and several start at zero.
|
|
132
|
+
*
|
|
133
|
+
* One title per level, which is what the rung check holds a ladder to: two
|
|
134
|
+
* rungs claiming one level is a finding on the second, so a ladder that reaches
|
|
135
|
+
* here carries at most one title for any number a member's `rank` can hold.
|
|
136
|
+
*
|
|
137
|
+
* @param {unknown} ranks - An affiliation's `data.governance.ranks`.
|
|
138
|
+
* @returns {Map<number, string>} Level → the title that level is called.
|
|
139
|
+
*/
|
|
140
|
+
export function rankLadder(ranks) {
|
|
141
|
+
const ladder = new Map();
|
|
142
|
+
if (!Array.isArray(ranks)) return ladder;
|
|
143
|
+
for (const rung of ranks) {
|
|
144
|
+
if (!mapping(rung)) continue;
|
|
145
|
+
const level = Number(rung.level);
|
|
146
|
+
if (!Number.isFinite(level)) continue;
|
|
147
|
+
if (!ladder.has(level)) ladder.set(level, typeof rung.title === "string" ? rung.title : "");
|
|
148
|
+
}
|
|
149
|
+
return ladder;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The offices an affiliation names, as key → description.
|
|
154
|
+
*
|
|
155
|
+
* An office is authored either as a bare description or as a `{description,
|
|
156
|
+
* holders}` map, and a reader of the post's name wants the same string from
|
|
157
|
+
* both.
|
|
158
|
+
*
|
|
159
|
+
* @param {unknown} offices - An affiliation's `data.governance.offices`.
|
|
160
|
+
* @returns {Map<string, string>} Office key → its description.
|
|
161
|
+
*/
|
|
162
|
+
export function officeRoster(offices) {
|
|
163
|
+
const roster = new Map();
|
|
164
|
+
if (!mapping(offices)) return roster;
|
|
165
|
+
for (const [office, value] of Object.entries(offices)) {
|
|
166
|
+
if (typeof value === "string") roster.set(office, value);
|
|
167
|
+
else if (mapping(value))
|
|
168
|
+
roster.set(office, typeof value.description === "string" ? value.description : "");
|
|
169
|
+
else roster.set(office, "");
|
|
170
|
+
}
|
|
171
|
+
return roster;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The anchor an office's row is addressed by.
|
|
176
|
+
*
|
|
177
|
+
* Declared once, because the row that carries the anchor and the link that
|
|
178
|
+
* reaches it are drawn by different passes: two spellings of the same
|
|
179
|
+
* derivation is a link into a page that offers no such destination.
|
|
180
|
+
*
|
|
181
|
+
* An office whose name reduces to nothing URL-safe has no anchor, and the
|
|
182
|
+
* caller is the one that can say what to do without one.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} office - The office's key, as the body wrote it.
|
|
185
|
+
* @returns {string} The anchor, or `""` where the name carries nothing.
|
|
186
|
+
*/
|
|
187
|
+
export function officeAnchor(office) {
|
|
188
|
+
const slug = slugify(office);
|
|
189
|
+
return slug ? `office-${slug}` : "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The anchor a rung's row is addressed by.
|
|
194
|
+
*
|
|
195
|
+
* The ladder's parallel to {@link officeAnchor}, prefixed apart from it so a
|
|
196
|
+
* body naming an office the same word as one of its ranks still gets two
|
|
197
|
+
* destinations rather than one row answering for both.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} title - The rung's title, as the body wrote it.
|
|
200
|
+
* @returns {string} The anchor, or `""` where the title carries nothing
|
|
201
|
+
* URL-safe.
|
|
202
|
+
*/
|
|
203
|
+
export function rankAnchor(title) {
|
|
204
|
+
const slug = slugify(title);
|
|
205
|
+
return slug ? `rank-${slug}` : "";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What an affiliation lends to a reference to it.
|
|
210
|
+
*
|
|
211
|
+
* A reference's value answers what was found as well as where it is — the same
|
|
212
|
+
* reason a target's `subType` travels with it. A standing is read on the
|
|
213
|
+
* *being*, and what it is called is declared on the *body*, so the ladder and
|
|
214
|
+
* the office roster ride along to the one place that needs them.
|
|
215
|
+
*
|
|
216
|
+
* Nothing rides for a note that declares neither.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} fm - The affiliation note's frontmatter.
|
|
219
|
+
* @returns {{ranks?: Record<string, string>, offices?: Record<string, string>}|undefined}
|
|
220
|
+
* What a reference carries, or nothing.
|
|
221
|
+
*/
|
|
222
|
+
export function standingsDigest(fm) {
|
|
223
|
+
const governance = fm?.data?.governance;
|
|
224
|
+
if (!mapping(governance)) return undefined;
|
|
225
|
+
const ladder = rankLadder(governance.ranks);
|
|
226
|
+
const roster = officeRoster(governance.offices);
|
|
227
|
+
if (!ladder.size && !roster.size) return undefined;
|
|
228
|
+
return {
|
|
229
|
+
...(ladder.size ? { ranks: Object.fromEntries(ladder) } : {}),
|
|
230
|
+
...(roster.size ? { offices: Object.fromEntries(roster) } : {}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* How a standing reads beside the body that confers it.
|
|
236
|
+
*
|
|
237
|
+
* The office leads, because it is the more particular of the two: a reader
|
|
238
|
+
* meeting "War Chief" has been told the rung as well. The body closes the
|
|
239
|
+
* phrase, which is the whole point of keying the entry by it — a standing with
|
|
240
|
+
* no body named is the lossy form.
|
|
241
|
+
*
|
|
242
|
+
* No article is supplied. One body's name reads with a "the" in front of it and
|
|
243
|
+
* the next does not, and inventing one is this build editing the corpus.
|
|
244
|
+
*
|
|
245
|
+
* @param {object} standing - `{rank, office}`, either or both absent.
|
|
246
|
+
* @param {string} body - What the body is called.
|
|
247
|
+
* @param {{ranks?: Record<string, string>}} [digest] - The body's ladder.
|
|
248
|
+
* @returns {string} The phrase, or the body's name where no standing is held.
|
|
249
|
+
*/
|
|
250
|
+
export function standingPhrase(standing, body, digest) {
|
|
251
|
+
const parts = [];
|
|
252
|
+
if (typeof standing?.office === "string" && standing.office.trim())
|
|
253
|
+
parts.push(standing.office.trim());
|
|
254
|
+
const level = Number(standing?.rank);
|
|
255
|
+
if (Number.isFinite(level)) {
|
|
256
|
+
const title = digest?.ranks?.[String(level)];
|
|
257
|
+
parts.push(title ? title : `Rank ${level}`);
|
|
258
|
+
}
|
|
259
|
+
return parts.length ? `${parts.join(", ")}, of ${body}` : body;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Validate a being's memberships and the standing it holds in each.
|
|
264
|
+
*
|
|
265
|
+
* Five questions, and the last three are what keying the entry by its body
|
|
266
|
+
* buys:
|
|
267
|
+
*
|
|
268
|
+
* 1. the value is a map keyed by Address (or the list this window still takes);
|
|
269
|
+
* 2. every key names an affiliation that resolves, once;
|
|
270
|
+
* 3. the entry states a `rank`;
|
|
271
|
+
* 4. a `rank` is a level the named body's `governance.ranks` declares;
|
|
272
|
+
* 5. an `office` is a key of that body's `governance.offices`.
|
|
273
|
+
*
|
|
274
|
+
* Both of the last two are read from the named body's own frontmatter as the
|
|
275
|
+
* lint runs, so a renamed rung or a renamed post fails on every being pointing
|
|
276
|
+
* at it rather than on a list somebody has to remember to edit.
|
|
277
|
+
*
|
|
278
|
+
* @param {object} note - The note.
|
|
279
|
+
* @param {{index?: object}} [options] - The lint's options.
|
|
280
|
+
* @returns {object[]} Located findings.
|
|
281
|
+
*/
|
|
282
|
+
export function checkStandings(note, { index } = {}) {
|
|
283
|
+
const value = note.fm?.data?.affiliations;
|
|
284
|
+
const { form, entries } = readStandings(value);
|
|
285
|
+
if (form === "absent") return [];
|
|
286
|
+
if (form === "malformed")
|
|
287
|
+
return [
|
|
288
|
+
{
|
|
289
|
+
...position(note, ["data", "affiliations"]),
|
|
290
|
+
message:
|
|
291
|
+
"`data.affiliations` must be a map keyed by an affiliation Address, " +
|
|
292
|
+
"each entry holding that body's standing",
|
|
293
|
+
},
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
const findings = [];
|
|
297
|
+
const seen = new Map();
|
|
298
|
+
// The list form names bodies and has nowhere to put a standing. Its entries
|
|
299
|
+
// are still held to being affiliations that resolve, so a tree part-way
|
|
300
|
+
// through the sweep keeps the check it had.
|
|
301
|
+
const isKey = form === "map";
|
|
302
|
+
for (const { body, sourceKey, standing } of entries) {
|
|
303
|
+
const path = ["data", "affiliations", sourceKey];
|
|
304
|
+
const resolved = resolveBody(note, index, body);
|
|
305
|
+
findings.push(...bodyFindings(note, body, path, isKey, resolved));
|
|
306
|
+
if (resolved.address) {
|
|
307
|
+
if (seen.has(resolved.address)) {
|
|
308
|
+
findings.push({
|
|
309
|
+
...position(note, path, isKey),
|
|
310
|
+
message:
|
|
311
|
+
`\`data.affiliations\` names ${resolved.address} twice, as ` +
|
|
312
|
+
`${JSON.stringify(String(seen.get(resolved.address)))} and ` +
|
|
313
|
+
`${JSON.stringify(String(sourceKey))}; one membership per body`,
|
|
314
|
+
});
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
seen.set(resolved.address, sourceKey);
|
|
318
|
+
}
|
|
319
|
+
if (!isKey) continue;
|
|
320
|
+
const named = resolved.address ?? String(body);
|
|
321
|
+
// An entry with nothing after its key, and an emptied map arriving from
|
|
322
|
+
// the property editor as `[]`, say exactly what an authored `{}` says:
|
|
323
|
+
// this being belongs to this body, and no standing in it is stated.
|
|
324
|
+
if (
|
|
325
|
+
standing === undefined ||
|
|
326
|
+
standing === null ||
|
|
327
|
+
(Array.isArray(standing) && standing.length === 0)
|
|
328
|
+
) {
|
|
329
|
+
findings.push(...absentRankFindings(note, {}, named, path));
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (!mapping(standing)) {
|
|
333
|
+
findings.push({
|
|
334
|
+
...position(note, path),
|
|
335
|
+
message:
|
|
336
|
+
`\`data.affiliations\` entry for ${named} must hold that body's standing ` +
|
|
337
|
+
"as a map of `rank` and `office`, but reads " +
|
|
338
|
+
JSON.stringify(standing),
|
|
339
|
+
});
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
for (const key of Object.keys(standing)) {
|
|
343
|
+
if (STANDING_KEYS.includes(key)) continue;
|
|
344
|
+
findings.push({
|
|
345
|
+
...position(note, [...path, key], true),
|
|
346
|
+
message:
|
|
347
|
+
`\`data.affiliations\` entry for ${named} has unknown key \`${key}\`; a ` +
|
|
348
|
+
`standing holds ${STANDING_KEYS.map((name) => `\`${name}\``).join(" and ")}`,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
findings.push(...absentRankFindings(note, standing, named, path));
|
|
352
|
+
findings.push(...rankFindings(note, standing, named, path, resolved));
|
|
353
|
+
findings.push(...officeFindings(note, standing, named, path, resolved));
|
|
354
|
+
}
|
|
355
|
+
return findings;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Where a finding about one authored key sits.
|
|
360
|
+
*
|
|
361
|
+
* @param {object} note - The note.
|
|
362
|
+
* @param {Array<string|number>} path - The frontmatter path.
|
|
363
|
+
* @param {boolean} [key] - Whether the key itself is at fault.
|
|
364
|
+
* @returns {object} The located, error-severity half of a finding.
|
|
365
|
+
*/
|
|
366
|
+
function position(note, path, key) {
|
|
367
|
+
return {
|
|
368
|
+
file: note.file,
|
|
369
|
+
...positionOfFrontmatterPath(note.raw ?? "", path, key ? { key: true } : undefined),
|
|
370
|
+
severity: "error",
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The affiliation one key or entry names, and what that note declares.
|
|
376
|
+
*
|
|
377
|
+
* @param {object} note - The citing note.
|
|
378
|
+
* @param {object|undefined} index - The lint's address index.
|
|
379
|
+
* @param {unknown} body - The authored Address.
|
|
380
|
+
* @returns {{address?: string, type?: string, reason?: string, fm?: object, missing?: boolean}}
|
|
381
|
+
* What it names.
|
|
382
|
+
*/
|
|
383
|
+
function resolveBody(note, index, body) {
|
|
384
|
+
const tuple = parseAddress(
|
|
385
|
+
body,
|
|
386
|
+
{
|
|
387
|
+
package: index?.contentPackage,
|
|
388
|
+
system: "note",
|
|
389
|
+
type: "affiliation",
|
|
390
|
+
types: index?.types,
|
|
391
|
+
packages: index?.packages,
|
|
392
|
+
},
|
|
393
|
+
{ declared: true },
|
|
394
|
+
);
|
|
395
|
+
if (tuple.reason) return { reason: tuple.reason };
|
|
396
|
+
if (!acceptsType(tuple, STANDING_BODY_TYPES)) return { type: tuple.type };
|
|
397
|
+
const address = renderAddress(tuple);
|
|
398
|
+
if (!index?.addressHit) return { address };
|
|
399
|
+
const target = index.addressHit(address);
|
|
400
|
+
if (!target) return { address, missing: true };
|
|
401
|
+
return { address, fm: target.fm ?? target };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Findings about the body an entry names. */
|
|
405
|
+
function bodyFindings(note, body, path, isKey, found) {
|
|
406
|
+
const at = position(note, path, isKey);
|
|
407
|
+
const written = isAddressTuple(body) ? renderAddress(body) : String(body);
|
|
408
|
+
const label = `\`data.affiliations\`${isKey ? " key" : ""} ${JSON.stringify(written)}`;
|
|
409
|
+
if (found.reason)
|
|
410
|
+
return [{ ...at, message: `${label} is not a complete Address (${found.reason})` }];
|
|
411
|
+
if (found.type)
|
|
412
|
+
return [{ ...at, message: `\`data.affiliations\` accepts affiliation, not ${found.type}` }];
|
|
413
|
+
if (found.missing)
|
|
414
|
+
return [
|
|
415
|
+
{
|
|
416
|
+
...at,
|
|
417
|
+
message:
|
|
418
|
+
`\`data.affiliations\` names ${found.address}, which does not resolve ` +
|
|
419
|
+
"in this package or its declared dependencies",
|
|
420
|
+
},
|
|
421
|
+
];
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* What an entry states no `rank` earns.
|
|
427
|
+
*
|
|
428
|
+
* Every member of a body holds some standing in it, and the ladders say so at
|
|
429
|
+
* both ends: `1` is the ordinary member, and `0` is a real rung rather than a
|
|
430
|
+
* default — `0 Níding`, `0 Outlaw`, `0 Vrystrith`, `0 Struck from the Roll` all
|
|
431
|
+
* name someone cast out, who still has a standing. So an entry naming a body
|
|
432
|
+
* and no rung is incomplete, and it is an **error**.
|
|
433
|
+
*
|
|
434
|
+
* **The message carries the rule, not just the gap.** "States no `rank`" sends a
|
|
435
|
+
* reader to find out which number to write; naming `1` and `0` is the whole
|
|
436
|
+
* answer, and the two ends are what makes the requirement writable — there is no
|
|
437
|
+
* standing a member can hold that one of them does not cover.
|
|
438
|
+
*
|
|
439
|
+
* **One finding per entry.** An entry carrying an office and no rung is the same
|
|
440
|
+
* fault and not a second one: an office distinguishes a person *within* a
|
|
441
|
+
* standing rather than standing in for one, so the office is named here as the
|
|
442
|
+
* reason the entry looked complete, rather than reported on its own line as well.
|
|
443
|
+
*
|
|
444
|
+
* @param {object} note - The note.
|
|
445
|
+
* @param {object} standing - The authored entry.
|
|
446
|
+
* @param {string} body - How the body is named in a message.
|
|
447
|
+
* @param {Array<string|number>} path - The entry's frontmatter path.
|
|
448
|
+
* @returns {object[]} The finding, or nothing.
|
|
449
|
+
*/
|
|
450
|
+
function absentRankFindings(note, standing, body, path) {
|
|
451
|
+
if (standing.rank !== undefined && standing.rank !== null) return [];
|
|
452
|
+
const office = typeof standing.office === "string" ? standing.office.trim() : "";
|
|
453
|
+
return [
|
|
454
|
+
{
|
|
455
|
+
...position(note, path, true),
|
|
456
|
+
message:
|
|
457
|
+
`\`data.affiliations\` entry for ${body} states no \`rank\`` +
|
|
458
|
+
(office ?
|
|
459
|
+
`, and the office ${JSON.stringify(office)} is not one — an office ` +
|
|
460
|
+
`distinguishes a person within a standing rather than standing in ` +
|
|
461
|
+
`for one`
|
|
462
|
+
: " — a being that belongs to a body holds some standing in it") +
|
|
463
|
+
". An ordinary member is `1`, and `0` is the rung for someone cast out",
|
|
464
|
+
},
|
|
465
|
+
];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Whether a `rank` is a rung the named body confers. */
|
|
469
|
+
function rankFindings(note, standing, body, path, resolved) {
|
|
470
|
+
if (standing.rank === undefined || standing.rank === null) return [];
|
|
471
|
+
const at = position(note, [...path, "rank"]);
|
|
472
|
+
const level = typeof standing.rank === "number" ? standing.rank : Number(standing.rank);
|
|
473
|
+
if (typeof standing.rank !== "number" || !Number.isInteger(level))
|
|
474
|
+
return [
|
|
475
|
+
{
|
|
476
|
+
...at,
|
|
477
|
+
message:
|
|
478
|
+
`\`data.affiliations\` entry for ${body}: \`rank\` must be the level on that body's own ` +
|
|
479
|
+
`ladder, as a whole number, but reads ${JSON.stringify(standing.rank)}`,
|
|
480
|
+
},
|
|
481
|
+
];
|
|
482
|
+
if (!resolved.fm) return [];
|
|
483
|
+
const ladder = rankLadder(resolved.fm.data?.governance?.ranks);
|
|
484
|
+
if (ladder.has(level)) return [];
|
|
485
|
+
if (!ladder.size)
|
|
486
|
+
return [
|
|
487
|
+
{
|
|
488
|
+
...at,
|
|
489
|
+
message:
|
|
490
|
+
`\`data.affiliations\` entry for ${body} states a rung, and that body ` +
|
|
491
|
+
"declares no `data.governance.ranks` ladder to hold one",
|
|
492
|
+
},
|
|
493
|
+
];
|
|
494
|
+
const levels = [...ladder.keys()].sort((a, b) => a - b);
|
|
495
|
+
return [
|
|
496
|
+
{
|
|
497
|
+
...at,
|
|
498
|
+
message:
|
|
499
|
+
`\`data.affiliations\` entry for ${body}: rank ${level} is not a level ` +
|
|
500
|
+
`${resolved.address} confers; its ladder runs ${levels.join(", ")}`,
|
|
501
|
+
},
|
|
502
|
+
];
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Whether an `office` is a post the named body names. */
|
|
506
|
+
function officeFindings(note, standing, body, path, resolved) {
|
|
507
|
+
if (standing.office === undefined || standing.office === null) return [];
|
|
508
|
+
const at = position(note, [...path, "office"]);
|
|
509
|
+
if (typeof standing.office !== "string" || !standing.office.trim())
|
|
510
|
+
return [
|
|
511
|
+
{
|
|
512
|
+
...at,
|
|
513
|
+
message:
|
|
514
|
+
`\`data.affiliations\` entry for ${body}: \`office\` must name a post from its ` +
|
|
515
|
+
`\`governance.offices\`, but reads ${JSON.stringify(standing.office)}`,
|
|
516
|
+
},
|
|
517
|
+
];
|
|
518
|
+
if (!resolved.fm) return [];
|
|
519
|
+
const roster = officeRoster(resolved.fm.data?.governance?.offices);
|
|
520
|
+
if (roster.has(standing.office)) return [];
|
|
521
|
+
if (!roster.size)
|
|
522
|
+
return [
|
|
523
|
+
{
|
|
524
|
+
...at,
|
|
525
|
+
message:
|
|
526
|
+
`\`data.affiliations\` entry for ${body} names an office, and that body ` +
|
|
527
|
+
`${resolved.address} declares no \`data.governance.offices\` to hold one`,
|
|
528
|
+
},
|
|
529
|
+
];
|
|
530
|
+
return [
|
|
531
|
+
{
|
|
532
|
+
...at,
|
|
533
|
+
message:
|
|
534
|
+
`\`data.affiliations\` entry for ${body}: office ${JSON.stringify(standing.office)} is not ` +
|
|
535
|
+
`an office ${resolved.address} names; it holds ${[...roster.keys()].join(", ")}`,
|
|
536
|
+
},
|
|
537
|
+
];
|
|
538
|
+
}
|
package/engine/svg-theme.mjs
CHANGED
|
@@ -45,7 +45,7 @@ const INK_LIGHT = "#211d16";
|
|
|
45
45
|
const INK_DARK = "#ece3cf";
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* The shapes a
|
|
48
|
+
* The shapes a fill rule may repaint.
|
|
49
49
|
*
|
|
50
50
|
* Explicitly black, or black by default for want of a `fill`. A shape painted
|
|
51
51
|
* some other colour is deliberate — a white highlight in a two-tone badge — and
|
|
@@ -53,7 +53,7 @@ const INK_DARK = "#ece3cf";
|
|
|
53
53
|
*
|
|
54
54
|
* @type {string}
|
|
55
55
|
*/
|
|
56
|
-
const
|
|
56
|
+
const FILL_SELECTOR = [
|
|
57
57
|
'[fill="#000"]',
|
|
58
58
|
'[fill="#000000"]',
|
|
59
59
|
'[fill="black"]',
|
|
@@ -67,10 +67,21 @@ const SELECTOR = [
|
|
|
67
67
|
"g:not([fill])",
|
|
68
68
|
].join(",");
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The shapes a stroke rule may repaint.
|
|
72
|
+
*
|
|
73
|
+
* Explicitly black only — SVG's default stroke is `none`, so there is no
|
|
74
|
+
* want-of-a-`stroke` case to opt in the way a want-of-a-`fill` case is. A
|
|
75
|
+
* broad match would paint an outline onto a shape that has none.
|
|
76
|
+
*
|
|
77
|
+
* @type {string}
|
|
78
|
+
*/
|
|
79
|
+
const STROKE_SELECTOR = ['[stroke="#000"]', '[stroke="#000000"]', '[stroke="black"]'].join(",");
|
|
80
|
+
|
|
70
81
|
/** @type {string} */
|
|
71
82
|
const STYLE =
|
|
72
|
-
`<style>${
|
|
73
|
-
`@media(prefers-color-scheme:dark){${
|
|
83
|
+
`<style>${FILL_SELECTOR}{fill:${INK_LIGHT}}${STROKE_SELECTOR}{stroke:${INK_LIGHT}}` +
|
|
84
|
+
`@media(prefers-color-scheme:dark){${FILL_SELECTOR}{fill:${INK_DARK}}${STROKE_SELECTOR}{stroke:${INK_DARK}}}</style>`;
|
|
74
85
|
|
|
75
86
|
/**
|
|
76
87
|
* Every `style="…"` attribute, capturing its declarations.
|
package/engine/system-block.mjs
CHANGED
|
@@ -339,17 +339,18 @@ export function sharedProperty(fm, source, defaultValue = undefined) {
|
|
|
339
339
|
}
|
|
340
340
|
|
|
341
341
|
/**
|
|
342
|
-
* A property a system block
|
|
342
|
+
* A property a system block declares, else the default.
|
|
343
343
|
*
|
|
344
|
-
* This is what gives `
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
* in
|
|
344
|
+
* This is what gives `effects` and `flags` their per-system form without
|
|
345
|
+
* inventing a mechanism for each: a note states them inside the block whose
|
|
346
|
+
* document carries them, which is the only position the format accepts for
|
|
347
|
+
* either. A note needing one value for both systems writes it in both blocks —
|
|
348
|
+
* they are properties of a document, and there are two documents.
|
|
348
349
|
*
|
|
349
350
|
* @param {object} fm - The note's frontmatter.
|
|
350
351
|
* @param {string} block - The block key.
|
|
351
352
|
* @param {string} key - The property.
|
|
352
|
-
* @param {any} [defaultValue] - Returned when
|
|
353
|
+
* @param {any} [defaultValue] - Returned when the block does not declare it.
|
|
353
354
|
* @returns {any} The value.
|
|
354
355
|
*/
|
|
355
356
|
export function blockProperty(fm, block, key, defaultValue = undefined) {
|
|
@@ -357,8 +358,7 @@ export function blockProperty(fm, block, key, defaultValue = undefined) {
|
|
|
357
358
|
if (declared && declared[key] !== undefined && declared[key] !== null) {
|
|
358
359
|
return declared[key];
|
|
359
360
|
}
|
|
360
|
-
|
|
361
|
-
return shared === undefined || shared === null ? defaultValue : shared;
|
|
361
|
+
return defaultValue;
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
/**
|
|
@@ -411,12 +411,10 @@ const DATA_PREFIX = "data.";
|
|
|
411
411
|
/**
|
|
412
412
|
* The bare top-level key a `data:`-sourced field is being swept off — step 3b.
|
|
413
413
|
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
* every other key that move relocated. Deriving it is what keeps the two
|
|
419
|
-
* spellings of one field from disagreeing the way two declarations would.
|
|
414
|
+
* The retiring spelling of `data.portrait` is not a second declaration anyone
|
|
415
|
+
* has to write — it is `portrait`, mechanically, and the same holds for every
|
|
416
|
+
* other key the container gathered. Deriving it is what keeps the two spellings
|
|
417
|
+
* of one field from disagreeing the way two declarations would.
|
|
420
418
|
*
|
|
421
419
|
* **Only a `data.` source has one.** `protection.blunt` and `impact.die` are
|
|
422
420
|
* paths into containers a note has always written at the top level; they were
|