@heroiclands/package-build 20.6.0 → 21.0.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.md +181 -0
- package/CONTENT.md +134 -44
- package/bin/content-build.mjs +37 -5
- package/bin/package-build.mjs +77 -0
- package/content-config.mjs +59 -1
- package/docs/api.md +149 -19
- package/docs/commands.md +75 -0
- package/docs/configuration.md +37 -10
- package/docs/content-format.md +450 -49
- package/engine/content-format.mjs +52 -3
- package/engine/content-images.mjs +699 -0
- package/engine/dependency-bump.mjs +218 -0
- package/engine/frontmatter-lint.mjs +89 -2
- package/engine/helpers.mjs +81 -142
- package/engine/index.mjs +15 -0
- package/engine/infobox-registry.mjs +81 -0
- package/engine/infobox-render.mjs +381 -0
- package/engine/infobox.mjs +963 -0
- package/engine/item-registry.mjs +5 -5
- package/engine/journals.mjs +22 -1
- package/engine/map-notes.mjs +11 -5
- package/engine/metadata-index.mjs +5 -0
- package/engine/note-vocabulary.mjs +57 -2
- package/engine/pathnames.mjs +374 -0
- package/engine/pdf-build.mjs +208 -9
- package/engine/pdf-render.mjs +461 -21
- package/engine/pdf-toc.mjs +77 -5
- package/engine/scenes.mjs +2 -1
- package/engine/site-build.mjs +115 -8
- package/engine/site-index.mjs +93 -4
- package/engine/wikilinks.mjs +93 -0
- package/hm3/default-item-art.mjs +14 -15
- package/hm3/index.mjs +3 -0
- package/hm3/infobox.mjs +64 -0
- package/package.json +3 -2
- package/sohl/being-info.mjs +9 -3
- package/sohl/default-item-art.mjs +18 -16
- package/sohl/index.mjs +3 -0
- package/sohl/infobox.mjs +499 -0
- package/types/content-config.d.mts +7 -0
- package/types/engine/content-format.d.mts +36 -0
- package/types/engine/content-images.d.mts +281 -0
- package/types/engine/dependency-bump.d.mts +89 -0
- package/types/engine/frontmatter-lint.d.mts +23 -0
- package/types/engine/helpers.d.mts +30 -72
- package/types/engine/index.d.mts +5 -0
- package/types/engine/infobox-registry.d.mts +36 -0
- package/types/engine/infobox-render.d.mts +87 -0
- package/types/engine/infobox.d.mts +443 -0
- package/types/engine/item-registry.d.mts +5 -5
- package/types/engine/journals.d.mts +9 -1
- package/types/engine/note-vocabulary.d.mts +51 -0
- package/types/engine/pathnames.d.mts +189 -0
- package/types/engine/pdf-build.d.mts +46 -0
- package/types/engine/pdf-render.d.mts +99 -1
- package/types/engine/pdf-toc.d.mts +10 -5
- package/types/engine/site-build.d.mts +19 -3
- package/types/engine/site-index.d.mts +35 -3
- package/types/engine/wikilinks.d.mts +22 -0
- package/types/hm3/default-item-art.d.mts +5 -6
- package/types/hm3/index.d.mts +1 -0
- package/types/hm3/infobox.d.mts +22 -0
- package/types/sohl/being-info.d.mts +4 -3
- package/types/sohl/index.d.mts +1 -0
- package/types/sohl/infobox.d.mts +145 -0
|
@@ -0,0 +1,963 @@
|
|
|
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
|
+
* **The infobox as a declared structure** — what a note's summary panel holds,
|
|
16
|
+
* decided once here and rendered by each medium.
|
|
17
|
+
*
|
|
18
|
+
* A note carries an ordered list of boxes, and each medium lays that list out
|
|
19
|
+
* its own way: the book flows it in the column measure, the website puts it in
|
|
20
|
+
* a side rail, a Foundry Journal Page inlines it. None of them decides the
|
|
21
|
+
* *content*, which is what stops the same panel being defined once per
|
|
22
|
+
* renderer.
|
|
23
|
+
*
|
|
24
|
+
* **Two kinds of box**, exactly as `docs/content-format.md` states them:
|
|
25
|
+
*
|
|
26
|
+
* - A **note infobox** summarises the subject from `data:` — system-agnostic
|
|
27
|
+
* facts about the thing itself. Every type has one, because every note has a
|
|
28
|
+
* name.
|
|
29
|
+
* - A **system infobox** summarises what one system makes of the note, from
|
|
30
|
+
* that system's block. There is one per system the note's type maps to, and
|
|
31
|
+
* a box whose system this note produces no document for reads
|
|
32
|
+
* {@link NOT_AVAILABLE}. A system that does not map the type at all draws
|
|
33
|
+
* no box —
|
|
34
|
+
* HM3 has no affiliations, mysteries or attributes, and a box reading "Not
|
|
35
|
+
* available" on those pages would suggest a gap in the note when the truth
|
|
36
|
+
* is about the system's scope.
|
|
37
|
+
*
|
|
38
|
+
* **Which boxes appear is derived, never declared a second time.** The note
|
|
39
|
+
* box is the type's `data:` vocabulary; the system boxes are the note-type →
|
|
40
|
+
* document-subtype map. That is what makes _every page carries exactly the
|
|
41
|
+
* boxes its type maps to_ an assertion the build can make —
|
|
42
|
+
* {@link assertInfoboxSet} — rather than a property nobody checks.
|
|
43
|
+
*
|
|
44
|
+
* **What a field is called and where it sits is the only thing declared**, in
|
|
45
|
+
* {@link NOTE_FIELD_PRESENTATION} for the note box and in each system's own
|
|
46
|
+
* `presentation` overlay for its box. The field *set* comes from
|
|
47
|
+
* {@link NOTE_VOCABULARY} and from the system's field declaration, in their
|
|
48
|
+
* declared order, so a key added to a type appears on every surface with no
|
|
49
|
+
* second edit. A field an overlay does not mention still gets a row,
|
|
50
|
+
* humanised from its own name.
|
|
51
|
+
*
|
|
52
|
+
* ## Six rules that hold in every medium
|
|
53
|
+
*
|
|
54
|
+
* 1. **The infobox is generated content in document order** — prepended,
|
|
55
|
+
* before the prose. Not a floating sidebar. An image authored before it
|
|
56
|
+
* appears before it.
|
|
57
|
+
* 2. **It contains no image.** A picture is authored in the text with its own
|
|
58
|
+
* directive, and its position governs. {@link NOTE_FIELD_PRESENTATION}
|
|
59
|
+
* withholds `portrait`, `img` and `overlay` for that reason and no other.
|
|
60
|
+
* 3. **A section is the unit that flows.** Sections are whole and unbreakable;
|
|
61
|
+
* the panel breaks between them. This is what lets a long box cross a
|
|
62
|
+
* column or page boundary without splitting a stat grid.
|
|
63
|
+
* 4. **An absent field is absent, not empty.** A row with no value is not
|
|
64
|
+
* emitted, and neither is a section with no rows — a creature carrying no
|
|
65
|
+
* equipment gets no equipment section rather than an empty one. An em-dash
|
|
66
|
+
* placeholder asserts a fact that is not there, and an empty heading asserts
|
|
67
|
+
* that something was expected and is missing.
|
|
68
|
+
* 5. **A system box is never empty; it says which silence it is.** Rule 4
|
|
69
|
+
* governs rows, and a box is not a row. A mapped system that produced no
|
|
70
|
+
* document says {@link NOT_AVAILABLE}; one that produced a document holding
|
|
71
|
+
* nothing a reader has not already been shown says
|
|
72
|
+
* {@link NOTHING_BEYOND_PROFILE}. Both travel as the box's `statement`, so
|
|
73
|
+
* a medium draws one thing and decides neither.
|
|
74
|
+
* 6. **Order is the toolchain's.** A medium renders boxes, sections and rows
|
|
75
|
+
* in the order given.
|
|
76
|
+
*
|
|
77
|
+
* ## The shape
|
|
78
|
+
*
|
|
79
|
+
* ```yaml
|
|
80
|
+
* infoboxes:
|
|
81
|
+
* - id: note
|
|
82
|
+
* kind: note
|
|
83
|
+
* title: Profile
|
|
84
|
+
* sections:
|
|
85
|
+
* - id: profile
|
|
86
|
+
* layout: rows
|
|
87
|
+
* rows:
|
|
88
|
+
* - label: Name
|
|
89
|
+
* kind: text
|
|
90
|
+
* value: Brànwâal Dôrgaar
|
|
91
|
+
* - label: Affiliations
|
|
92
|
+
* kind: links
|
|
93
|
+
* value:
|
|
94
|
+
* - text: The Silent Talon Company
|
|
95
|
+
* url: /thalorna/affiliation-slntlncmpny/
|
|
96
|
+
* - id: sohl
|
|
97
|
+
* kind: system
|
|
98
|
+
* system: sohl
|
|
99
|
+
* title: SoHL
|
|
100
|
+
* available: true
|
|
101
|
+
* sections:
|
|
102
|
+
* - id: attributes
|
|
103
|
+
* label: Attributes
|
|
104
|
+
* layout: grid
|
|
105
|
+
* cells: [{ label: STR, value: 14 }]
|
|
106
|
+
* ```
|
|
107
|
+
*
|
|
108
|
+
* Four layouts, a closed set, each one the book prototype actually uses; five
|
|
109
|
+
* value kinds. **A renderer switches on `layout` and `kind`, never on a field
|
|
110
|
+
* name** — that is what stops the field list leaking back into a template.
|
|
111
|
+
*
|
|
112
|
+
* @module
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
import { getFrontmatter } from "./frontmatter.mjs";
|
|
116
|
+
import { currentType } from "./ids.mjs";
|
|
117
|
+
import { NOTE_VOCABULARY, dataFields } from "./note-vocabulary.mjs";
|
|
118
|
+
import { subtypeRow } from "./document-subtypes.mjs";
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* How a section arranges what it holds.
|
|
122
|
+
*
|
|
123
|
+
* Closed, and keyed by the property the section carries its content in — a
|
|
124
|
+
* renderer reads one of four keys and needs no other knowledge of the data.
|
|
125
|
+
*
|
|
126
|
+
* @type {Readonly<Record<string, string>>}
|
|
127
|
+
*/
|
|
128
|
+
export const INFOBOX_LAYOUTS = Object.freeze({
|
|
129
|
+
/** Label/value pairs, one per line. A profile. */
|
|
130
|
+
rows: "rows",
|
|
131
|
+
/** Short label/value cells packed into a grid. An attribute block. */
|
|
132
|
+
grid: "cells",
|
|
133
|
+
/** Groups of comma-joined entries, each run in after its label. */
|
|
134
|
+
runin: "groups",
|
|
135
|
+
/** One entry per line. */
|
|
136
|
+
list: "entries",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* What a row's `value` is.
|
|
141
|
+
*
|
|
142
|
+
* Closed. `link` and `links` carry `{text, url?, uuid?, address?}` — the three
|
|
143
|
+
* addresses a medium may need, whichever of them its own index could supply.
|
|
144
|
+
*
|
|
145
|
+
* @type {readonly string[]}
|
|
146
|
+
*/
|
|
147
|
+
export const INFOBOX_VALUE_KINDS = Object.freeze(["text", "number", "link", "links", "list"]);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What a mapped system that produced no document for this note says.
|
|
151
|
+
*
|
|
152
|
+
* Stated rather than inferred from an empty block, because an absence is a
|
|
153
|
+
* poor signal: noticing that something is missing requires already knowing it
|
|
154
|
+
* should have been there, and a reader meeting one page has no way to know.
|
|
155
|
+
*
|
|
156
|
+
* @type {string}
|
|
157
|
+
*/
|
|
158
|
+
export const NOT_AVAILABLE = "Not available";
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* What a system that does produce a document, and holds nothing a reader has
|
|
162
|
+
* not already been shown, says.
|
|
163
|
+
*
|
|
164
|
+
* The third state of a system box, and the reason it is stated rather than
|
|
165
|
+
* drawn as an empty panel: a heading over nothing asserts that something
|
|
166
|
+
* should have been there. It is not {@link NOT_AVAILABLE} — the system
|
|
167
|
+
* compiles this note, and a reader told otherwise would go looking for a
|
|
168
|
+
* document that exists. It is that everything this system holds about the note
|
|
169
|
+
* is either the subject's own fact, carried by the note box above, or a value
|
|
170
|
+
* the compiler would have supplied anyway.
|
|
171
|
+
*
|
|
172
|
+
* @type {string}
|
|
173
|
+
*/
|
|
174
|
+
export const NOTHING_BEYOND_PROFILE = "Nothing beyond the profile";
|
|
175
|
+
|
|
176
|
+
/** The note infobox's id, which is not a system id. @type {string} */
|
|
177
|
+
export const NOTE_BOX_ID = "note";
|
|
178
|
+
|
|
179
|
+
/** The note infobox's title. @type {string} */
|
|
180
|
+
export const NOTE_BOX_TITLE = "Profile";
|
|
181
|
+
|
|
182
|
+
/** The note infobox's single section id. @type {string} */
|
|
183
|
+
export const NOTE_SECTION_ID = "profile";
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* What a **duration pair** is called.
|
|
187
|
+
*
|
|
188
|
+
* A note states an interval twice over — once as a roll formula and once as a
|
|
189
|
+
* flat number of seconds — and the declaration names the two by what they hold:
|
|
190
|
+
* `courseDurationFormula` and `courseDurationBase`. A page wants the thing
|
|
191
|
+
* being timed and which of the two it is reading.
|
|
192
|
+
*
|
|
193
|
+
* Shared by the note box's overlay and by a system's, because the two
|
|
194
|
+
* declarations spell these fields identically and a reader meeting an
|
|
195
|
+
* affliction written one way and one written the other should meet one word.
|
|
196
|
+
*
|
|
197
|
+
* @type {Readonly<Record<string, {label: string}>>}
|
|
198
|
+
*/
|
|
199
|
+
export const DURATION_LABELS = Object.freeze({
|
|
200
|
+
onsetDurationFormula: Object.freeze({ label: "Onset roll" }),
|
|
201
|
+
onsetDurationBase: Object.freeze({ label: "Onset" }),
|
|
202
|
+
healingCheckDurationFormula: Object.freeze({ label: "Healing check roll" }),
|
|
203
|
+
healingCheckDurationBase: Object.freeze({ label: "Healing check" }),
|
|
204
|
+
resolutionDurationFormula: Object.freeze({ label: "Resolution roll" }),
|
|
205
|
+
resolutionDurationBase: Object.freeze({ label: "Resolution" }),
|
|
206
|
+
bloodLossAdvanceDurationFormula: Object.freeze({ label: "Blood loss roll" }),
|
|
207
|
+
bloodLossAdvanceDurationBase: Object.freeze({ label: "Blood loss" }),
|
|
208
|
+
courseDurationFormula: Object.freeze({ label: "Course roll" }),
|
|
209
|
+
courseDurationBase: Object.freeze({ label: "Course" }),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* What a gear item's two measured quantities are called, and measured in.
|
|
214
|
+
*
|
|
215
|
+
* Price is in pence and weight in pounds throughout the corpus, and the same
|
|
216
|
+
* fact is authored under `data:` on one note and at its destination path on
|
|
217
|
+
* another — so both overlays read the one declaration and a reader meets one
|
|
218
|
+
* word and one unit whichever box the row landed in.
|
|
219
|
+
*
|
|
220
|
+
* @type {Readonly<Record<string, {label?: string, unit: string}>>}
|
|
221
|
+
*/
|
|
222
|
+
export const GEAR_UNITS = Object.freeze({
|
|
223
|
+
value: Object.freeze({ label: "Price", unit: "d" }),
|
|
224
|
+
weight: Object.freeze({ unit: " lbs" }),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The presentation overlay: what a `data:` field is called, and whether it
|
|
229
|
+
* belongs in the box at all.
|
|
230
|
+
*
|
|
231
|
+
* **This is not a second field list.** The fields come from
|
|
232
|
+
* {@link NOTE_VOCABULARY}; this says only what the vocabulary cannot — the
|
|
233
|
+
* label where humanising the key is wrong, the group a field composes into,
|
|
234
|
+
* and the handful of keys that are not reader-facing.
|
|
235
|
+
*
|
|
236
|
+
* Three properties, all optional:
|
|
237
|
+
*
|
|
238
|
+
* - `label` — what the row is called. Absent means the key, humanised.
|
|
239
|
+
* - `withheld` — why the field carries no row. Two reasons only: it is
|
|
240
|
+
* machinery — something that steers a build or an interface rather than
|
|
241
|
+
* describing the subject — or it is an image, which rule 2 keeps out of the
|
|
242
|
+
* box.
|
|
243
|
+
* - `unit` — what the quantity is measured in, appended to the value verbatim.
|
|
244
|
+
* See {@link applyUnit}.
|
|
245
|
+
* - `group` / `phrase` — the field composes into one row with its group mates
|
|
246
|
+
* rather than taking a row of its own. `phrase` turns the value into its
|
|
247
|
+
* clause; without one the value stands alone, so a field added to a group
|
|
248
|
+
* still appears.
|
|
249
|
+
*
|
|
250
|
+
* A key is either a field name or `<type>.<field name>`, and the qualified one
|
|
251
|
+
* wins. One spelling is not one quantity across the vocabulary: `data.weight`
|
|
252
|
+
* is a being's body weight, which reads as a clause of its appearance, and a
|
|
253
|
+
* gear item's mass, which is a row. An overlay keyed on the bare word would
|
|
254
|
+
* compose a coin's weight into its appearance.
|
|
255
|
+
*
|
|
256
|
+
* @type {Readonly<Record<string, {label?: string, withheld?: string,
|
|
257
|
+
* unit?: string, group?: string, phrase?: (value: any) => string}>>}
|
|
258
|
+
*/
|
|
259
|
+
export const NOTE_FIELD_PRESENTATION = Object.freeze({
|
|
260
|
+
...DURATION_LABELS,
|
|
261
|
+
...GEAR_UNITS,
|
|
262
|
+
|
|
263
|
+
templatePriority: Object.freeze({
|
|
264
|
+
withheld: "template machinery, not a fact about the subject",
|
|
265
|
+
}),
|
|
266
|
+
color: Object.freeze({
|
|
267
|
+
withheld: "sidebar machinery, not a fact about the subject",
|
|
268
|
+
}),
|
|
269
|
+
portrait: Object.freeze({ withheld: "an image, which the box never carries" }),
|
|
270
|
+
img: Object.freeze({ withheld: "an image, which the box never carries" }),
|
|
271
|
+
overlay: Object.freeze({ withheld: "an image, which the box never carries" }),
|
|
272
|
+
|
|
273
|
+
birthday: Object.freeze({ label: "Born" }),
|
|
274
|
+
assocSkill: Object.freeze({ label: "Skill" }),
|
|
275
|
+
assocAffiliation: Object.freeze({ label: "Affiliation" }),
|
|
276
|
+
parentSkill: Object.freeze({ label: "Specialises" }),
|
|
277
|
+
"governance.model": Object.freeze({ label: "Government" }),
|
|
278
|
+
"governance.summary": Object.freeze({ label: "How it governs" }),
|
|
279
|
+
"governance.ranks": Object.freeze({ label: "Ranks" }),
|
|
280
|
+
"governance.offices": Object.freeze({ label: "Offices" }),
|
|
281
|
+
"party.size": Object.freeze({ label: "Party size" }),
|
|
282
|
+
"party.archetypes": Object.freeze({ label: "Written for" }),
|
|
283
|
+
seat: Object.freeze({ label: "Seat" }),
|
|
284
|
+
parents: Object.freeze({ label: "Within" }),
|
|
285
|
+
lore: Object.freeze({ label: "Lore" }),
|
|
286
|
+
homes: Object.freeze({ label: "Home" }),
|
|
287
|
+
|
|
288
|
+
// The appearance clause: a being's measurements read as a sentence rather
|
|
289
|
+
// than as six rows of numbers, exactly as the book prototype sets them.
|
|
290
|
+
// Qualified by type, because `weight` is also what a coin weighs.
|
|
291
|
+
"being.age": Object.freeze({ group: "appearance", phrase: (v) => `Age ${v}` }),
|
|
292
|
+
"being.height": Object.freeze({ group: "appearance", phrase: heightPhrase }),
|
|
293
|
+
"being.weight": Object.freeze({ group: "appearance", phrase: weightPhrase }),
|
|
294
|
+
"being.frame": Object.freeze({ group: "appearance", phrase: (v) => `${v} frame` }),
|
|
295
|
+
"being.appearance.eye_color": Object.freeze({
|
|
296
|
+
group: "appearance",
|
|
297
|
+
phrase: (v) => `${humanizeValue(v)} eyes`,
|
|
298
|
+
}),
|
|
299
|
+
"being.appearance.hair_color": Object.freeze({
|
|
300
|
+
group: "appearance",
|
|
301
|
+
phrase: (v) => `${humanizeValue(v)} hair`,
|
|
302
|
+
}),
|
|
303
|
+
"being.appearance.skin_color": Object.freeze({
|
|
304
|
+
group: "appearance",
|
|
305
|
+
phrase: (v) => `${humanizeValue(v)} skin`,
|
|
306
|
+
}),
|
|
307
|
+
"being.appearance.complexion": Object.freeze({
|
|
308
|
+
group: "appearance",
|
|
309
|
+
phrase: (v) => `${humanizeValue(v)} complexion`,
|
|
310
|
+
}),
|
|
311
|
+
"being.appearance.extra_features": Object.freeze({ group: "appearance" }),
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* One field's overlay entry, qualified by type where the declaration qualifies
|
|
316
|
+
* it.
|
|
317
|
+
*
|
|
318
|
+
* `<type>.<field>` wins over the bare name, so a spelling two types use for two
|
|
319
|
+
* quantities can be said differently on each without splitting the overlay.
|
|
320
|
+
*
|
|
321
|
+
* @param {Readonly<Record<string, object>>} presentation - The overlay.
|
|
322
|
+
* @param {string|undefined} type - The note's type.
|
|
323
|
+
* @param {string} name - The field's declared name.
|
|
324
|
+
* @returns {object} The entry, or an empty one.
|
|
325
|
+
*/
|
|
326
|
+
export function overlayFor(presentation, type, name) {
|
|
327
|
+
return presentation?.[`${currentType(type)}.${name}`] ?? presentation?.[name] ?? {};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** What a composed group's row is called. @type {Readonly<Record<string, string>>} */
|
|
331
|
+
const GROUP_LABELS = Object.freeze({ appearance: "Appearance" });
|
|
332
|
+
|
|
333
|
+
/** Metres as feet and inches, which is how the corpus reads a height. */
|
|
334
|
+
function heightPhrase(metres) {
|
|
335
|
+
const inches = Math.round(Number(metres) * 39.3701);
|
|
336
|
+
const feet = Math.floor(inches / 12);
|
|
337
|
+
return `${feet}′ ${inches - feet * 12}″`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Kilograms as pounds, which is how the corpus reads a weight. */
|
|
341
|
+
function weightPhrase(kilograms) {
|
|
342
|
+
return `${Math.round(Number(kilograms) * 2.20462)} lbs`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Whether a value is a plain mapping. */
|
|
346
|
+
function isMapping(value) {
|
|
347
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
/**
|
|
352
|
+
* The words a note writes when it means "there is nothing here".
|
|
353
|
+
*
|
|
354
|
+
* A corpus states an absence three ways, and only two of them are the absence
|
|
355
|
+
* of a value: the key is omitted, or it holds the field's own default. The
|
|
356
|
+
* third is a **sentinel** — a token standing in for the unset state, spelled
|
|
357
|
+
* however the note happened to spell it. `potency: na` and `category: none`
|
|
358
|
+
* are not a sodium potion and a category called None; they are two authors
|
|
359
|
+
* writing "not applicable" in the space a value would go.
|
|
360
|
+
*
|
|
361
|
+
* Compared after {@link normalizeToken} strips everything but letters, so
|
|
362
|
+
* `n/a`, `N/A` and `not applicable` are one word and `none of the above` is
|
|
363
|
+
* not one of them.
|
|
364
|
+
*
|
|
365
|
+
* @type {readonly string[]}
|
|
366
|
+
*/
|
|
367
|
+
export const UNSET_VALUES = Object.freeze([
|
|
368
|
+
"na",
|
|
369
|
+
"none",
|
|
370
|
+
"notapplicable",
|
|
371
|
+
"unset",
|
|
372
|
+
"null",
|
|
373
|
+
"undefined",
|
|
374
|
+
]);
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* A value reduced to its letters, lowercased.
|
|
378
|
+
*
|
|
379
|
+
* @param {unknown} value - The authored value.
|
|
380
|
+
* @returns {string} The token.
|
|
381
|
+
*/
|
|
382
|
+
function normalizeToken(value) {
|
|
383
|
+
return String(value ?? "")
|
|
384
|
+
.toLowerCase()
|
|
385
|
+
.replace(/[^a-z]/g, "");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Whether a value is a word meaning "nothing here" rather than a value.
|
|
390
|
+
*
|
|
391
|
+
* @param {unknown} value - The authored value.
|
|
392
|
+
* @returns {boolean} Whether it is one of {@link UNSET_VALUES}.
|
|
393
|
+
*/
|
|
394
|
+
export function isUnsetSentinel(value) {
|
|
395
|
+
if (typeof value !== "string") return false;
|
|
396
|
+
return UNSET_VALUES.includes(normalizeToken(value));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Whether a value is worth a row.
|
|
401
|
+
*
|
|
402
|
+
* Rule 4: an absent field is absent. `null`, `""` and `[]` are how the corpus
|
|
403
|
+
* writes "nobody filled this in" — a note that declares every key of its type
|
|
404
|
+
* and leaves most of them empty is the ordinary shape, not the exception — and
|
|
405
|
+
* so is a sentinel, which is the same absence written as a word. Judged here
|
|
406
|
+
* rather than per field, because a sentinel that reaches a page reaches it the
|
|
407
|
+
* same way whichever box was building the row.
|
|
408
|
+
*
|
|
409
|
+
* @param {unknown} value - The authored value.
|
|
410
|
+
* @returns {boolean} Whether to emit it.
|
|
411
|
+
*/
|
|
412
|
+
export function hasValue(value) {
|
|
413
|
+
if (value == null) return false;
|
|
414
|
+
if (typeof value === "string") return value.trim() !== "" && !isUnsetSentinel(value);
|
|
415
|
+
if (Array.isArray(value)) return value.some((entry) => hasValue(entry));
|
|
416
|
+
if (isMapping(value)) return false;
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* A key as a reader sees it: `healingRate` → `Healing rate`.
|
|
422
|
+
*
|
|
423
|
+
* The last dotted segment only — a nested key's container is already said by
|
|
424
|
+
* the section it sits in, and "Appearance eye color" says it twice.
|
|
425
|
+
*
|
|
426
|
+
* @param {string} name - The declared key.
|
|
427
|
+
* @returns {string} The label.
|
|
428
|
+
*/
|
|
429
|
+
export function humanizeFieldName(name) {
|
|
430
|
+
const leaf =
|
|
431
|
+
String(name ?? "")
|
|
432
|
+
.split(".")
|
|
433
|
+
.pop() ?? "";
|
|
434
|
+
const words = leaf
|
|
435
|
+
.replace(/[_-]+/g, " ")
|
|
436
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
437
|
+
.trim()
|
|
438
|
+
.toLowerCase();
|
|
439
|
+
return words ? words[0].toUpperCase() + words.slice(1) : "";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* An enumerated value as a reader sees it: `graying_brown` → `graying brown`.
|
|
444
|
+
*
|
|
445
|
+
* @param {unknown} value - The authored value.
|
|
446
|
+
* @returns {string} The text.
|
|
447
|
+
*/
|
|
448
|
+
export function humanizeValue(value) {
|
|
449
|
+
return String(value ?? "")
|
|
450
|
+
.replace(/[_-]+/g, " ")
|
|
451
|
+
.trim();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* A value as a row shows it.
|
|
456
|
+
*
|
|
457
|
+
* A **single lowercase token** is an enumerated value — `male`, `medium`,
|
|
458
|
+
* `city-state` — and a row reading "male" beside a name reads as a fault
|
|
459
|
+
* rather than as a value, so its words are capitalised. Anything else is prose
|
|
460
|
+
* the author wrote and is shown as written: title-casing "a dispossessed
|
|
461
|
+
* noble" would be this build editing the corpus.
|
|
462
|
+
*
|
|
463
|
+
* @param {unknown} value - The authored value.
|
|
464
|
+
* @returns {string} The text.
|
|
465
|
+
*/
|
|
466
|
+
export function presentValue(value) {
|
|
467
|
+
const text = humanizeValue(value);
|
|
468
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(String(value ?? ""))) return text;
|
|
469
|
+
return text.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Whether a built value is worth a row.
|
|
474
|
+
*
|
|
475
|
+
* Separate from {@link hasValue}, which judges what an author wrote: a `link`
|
|
476
|
+
* value is a mapping and a `links` value is a list of them, and both are
|
|
477
|
+
* exactly what the authored form is not.
|
|
478
|
+
*
|
|
479
|
+
* @param {string} kind - One of {@link INFOBOX_VALUE_KINDS}.
|
|
480
|
+
* @param {unknown} value - The built value.
|
|
481
|
+
* @returns {boolean} Whether to emit the row.
|
|
482
|
+
*/
|
|
483
|
+
export function hasRenderableValue(kind, value) {
|
|
484
|
+
if (kind === "link") return Boolean(value && value.text);
|
|
485
|
+
if (kind === "links" || kind === "list") return Array.isArray(value) && value.length > 0;
|
|
486
|
+
return hasValue(value);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The value kind a `data:` declaration implies.
|
|
491
|
+
*
|
|
492
|
+
* Read from the declaration's own shape rather than from the value, so two
|
|
493
|
+
* notes of one type describe the same field the same way — a one-entry list
|
|
494
|
+
* and a two-entry list are both `links`.
|
|
495
|
+
*
|
|
496
|
+
* @param {object} field - A `DataFieldSpec`.
|
|
497
|
+
* @param {unknown} value - The authored value, for the shapes the declaration
|
|
498
|
+
* makes no claim about.
|
|
499
|
+
* @returns {string} One of {@link INFOBOX_VALUE_KINDS}.
|
|
500
|
+
*/
|
|
501
|
+
export function valueKindOf(field, value) {
|
|
502
|
+
if (field?.shape === "a wikilink") return "link";
|
|
503
|
+
if (field?.shape === "list of wikilinks") return "links";
|
|
504
|
+
if (field?.kind === "number") return "number";
|
|
505
|
+
if (field?.kind === "list") return "list";
|
|
506
|
+
if (field?.kind === "string") return "text";
|
|
507
|
+
return Array.isArray(value) ? "list" : "text";
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Build one row's value, resolving whatever the kind says is a reference.
|
|
512
|
+
*
|
|
513
|
+
* @param {string} kind - One of {@link INFOBOX_VALUE_KINDS}.
|
|
514
|
+
* @param {unknown} raw - The authored value.
|
|
515
|
+
* @param {(ref: unknown, hint?: object) => object|undefined} resolve - The
|
|
516
|
+
* medium's resolver.
|
|
517
|
+
* @param {object} [hint] - What the reference is expected to name.
|
|
518
|
+
* @returns {unknown} The row value, shaped for the kind.
|
|
519
|
+
*/
|
|
520
|
+
function rowValue(kind, raw, resolve, hint) {
|
|
521
|
+
if (kind === "link") return linkValue(raw, resolve, hint);
|
|
522
|
+
if (kind === "links") {
|
|
523
|
+
return (Array.isArray(raw) ? raw : [raw])
|
|
524
|
+
.filter(hasValue)
|
|
525
|
+
.map((r) => linkValue(r, resolve, hint));
|
|
526
|
+
}
|
|
527
|
+
if (kind === "list") {
|
|
528
|
+
return (Array.isArray(raw) ? raw : [raw]).filter(hasValue).map((v) => presentValue(v));
|
|
529
|
+
}
|
|
530
|
+
if (kind === "number") return raw;
|
|
531
|
+
return presentValue(raw);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* One row, with its unit on it.
|
|
536
|
+
*
|
|
537
|
+
* **The unit goes on the value, not on the label**, because it belongs to the
|
|
538
|
+
* quantity rather than to the name of the quantity. A price is 160d and a
|
|
539
|
+
* weight is 1.1 lbs; splitting that across two cells — `Price (d)` beside
|
|
540
|
+
* `160` — makes a reader reassemble one fact from two places, and reads worst
|
|
541
|
+
* in the book, whose label column is a narrow small-caps rule.
|
|
542
|
+
*
|
|
543
|
+
* A medium cannot supply it. Appending `d` to a price means knowing which row
|
|
544
|
+
* is the price, which is the one thing a generic renderer must never know — so
|
|
545
|
+
* the unit is declared here and travels as part of the value.
|
|
546
|
+
*
|
|
547
|
+
* The declared string is appended **verbatim**, which is what lets a symbol
|
|
548
|
+
* hug its number (`160d`) and a word stand off it (`1.1 lbs`). The row's kind
|
|
549
|
+
* becomes `text`: a number with a unit on it is no longer a number, and saying
|
|
550
|
+
* otherwise would invite a medium to format it as one.
|
|
551
|
+
*
|
|
552
|
+
* @param {string} kind - The row's kind.
|
|
553
|
+
* @param {unknown} value - The built value.
|
|
554
|
+
* @param {string} [unit] - The declared unit, or nothing.
|
|
555
|
+
* @returns {{kind: string, value: unknown}} The row's kind and value.
|
|
556
|
+
*/
|
|
557
|
+
export function applyUnit(kind, value, unit) {
|
|
558
|
+
if (!unit || (kind !== "number" && kind !== "text")) return { kind, value };
|
|
559
|
+
return { kind: "text", value: `${value}${unit}` };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* One reference, resolved as far as the medium's index reaches.
|
|
564
|
+
*
|
|
565
|
+
* An unresolved reference keeps its own text rather than being dropped: a page
|
|
566
|
+
* that names something the index has not heard of is better than a page
|
|
567
|
+
* silently missing a row.
|
|
568
|
+
*
|
|
569
|
+
* @param {unknown} ref - The authored reference — a shortcode or an address.
|
|
570
|
+
* @param {(ref: unknown, hint?: object) => object|undefined} resolve - The
|
|
571
|
+
* medium's resolver.
|
|
572
|
+
* @param {object} [hint] - What the reference is expected to name, where the
|
|
573
|
+
* declaration says: `{type}`. A shortcode is unique within a type and not
|
|
574
|
+
* across a tree, so a hint is what stops a region resolving to a polity.
|
|
575
|
+
* @returns {{text: string, url?: string, uuid?: string, address?: string}} The value.
|
|
576
|
+
*/
|
|
577
|
+
export function linkValue(ref, resolve, hint) {
|
|
578
|
+
const found = resolve?.(ref, hint);
|
|
579
|
+
const value = { text: found?.name || humanizeValue(ref) };
|
|
580
|
+
if (found?.url) value.url = found.url;
|
|
581
|
+
if (found?.uuid) value.uuid = found.uuid;
|
|
582
|
+
if (found?.address) value.address = found.address;
|
|
583
|
+
return value;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The note infobox: the subject from `data:`, plus the name every note has.
|
|
588
|
+
*
|
|
589
|
+
* The rows are {@link NOTE_VOCABULARY}'s declaration for the type, in its
|
|
590
|
+
* order, minus what {@link NOTE_FIELD_PRESENTATION} withholds and minus every
|
|
591
|
+
* field the note left empty. A type that declares no `data:` fields still gets
|
|
592
|
+
* the box, because `Name` is a fact about every note.
|
|
593
|
+
*
|
|
594
|
+
* @param {object} fm - The note's frontmatter.
|
|
595
|
+
* @param {object} [options] - Options.
|
|
596
|
+
* @param {(ref: unknown) => object|undefined} [options.resolve] - Resolves a
|
|
597
|
+
* reference to `{name, url?, uuid?, address?}`.
|
|
598
|
+
* @param {object} [options.vocabulary] - The note vocabulary to read.
|
|
599
|
+
* @param {object} [options.presentation] - The overlay to read.
|
|
600
|
+
* @returns {object} The box.
|
|
601
|
+
*/
|
|
602
|
+
export function noteInfobox(fm, options = {}) {
|
|
603
|
+
return noteBox(fm, options).box;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* The note infobox, and the names of the fields it actually shows.
|
|
608
|
+
*
|
|
609
|
+
* The second half is what a system box needs: a field is the note's to state
|
|
610
|
+
* only where the note box **states** it, which is not the same as the
|
|
611
|
+
* vocabulary declaring it. A gear item's weight is declared under `data:` and
|
|
612
|
+
* authored at `sohl.system.weightBase`, so the note box shows nothing for it,
|
|
613
|
+
* and a system box that stood down on the strength of the declaration alone
|
|
614
|
+
* left the fact on no surface at all.
|
|
615
|
+
*
|
|
616
|
+
* @param {object} fm - The note's frontmatter.
|
|
617
|
+
* @param {object} [options] - As {@link noteInfobox}.
|
|
618
|
+
* @returns {{box: object, shown: Set<string>}} The box, and the field names it
|
|
619
|
+
* put on the page.
|
|
620
|
+
*/
|
|
621
|
+
function noteBox(
|
|
622
|
+
fm,
|
|
623
|
+
{ resolve, vocabulary = NOTE_VOCABULARY, presentation = NOTE_FIELD_PRESENTATION } = {},
|
|
624
|
+
) {
|
|
625
|
+
const rows = [];
|
|
626
|
+
/** @type {Set<string>} */
|
|
627
|
+
const shown = new Set();
|
|
628
|
+
const name = fm?.name?.full ?? fm?.title;
|
|
629
|
+
if (hasValue(name)) rows.push({ label: "Name", kind: "text", value: String(name) });
|
|
630
|
+
|
|
631
|
+
const data = isMapping(fm?.data) ? fm.data : {};
|
|
632
|
+
/** @type {Map<string, {label: string, entries: string[]}>} */
|
|
633
|
+
const groups = new Map();
|
|
634
|
+
|
|
635
|
+
for (const field of dataFields(fm?.type, vocabulary) ?? []) {
|
|
636
|
+
const overlay = overlayFor(presentation, fm?.type, field.name);
|
|
637
|
+
if (overlay.withheld) continue;
|
|
638
|
+
const raw = getFrontmatter(data, field.name, undefined);
|
|
639
|
+
if (!hasValue(raw)) continue;
|
|
640
|
+
|
|
641
|
+
if (overlay.group) {
|
|
642
|
+
const group = groups.get(overlay.group) ?? {
|
|
643
|
+
label: GROUP_LABELS[overlay.group] ?? humanizeFieldName(overlay.group),
|
|
644
|
+
entries: [],
|
|
645
|
+
};
|
|
646
|
+
const parts = Array.isArray(raw) ? raw.filter(hasValue) : [raw];
|
|
647
|
+
for (const part of parts) {
|
|
648
|
+
group.entries.push(overlay.phrase ? overlay.phrase(part) : humanizeValue(part));
|
|
649
|
+
}
|
|
650
|
+
if (!groups.has(overlay.group)) {
|
|
651
|
+
groups.set(overlay.group, group);
|
|
652
|
+
// A composed row takes the place of its group's first field, so
|
|
653
|
+
// the group appears where the vocabulary put it.
|
|
654
|
+
rows.push({ label: group.label, kind: "list", value: group.entries });
|
|
655
|
+
}
|
|
656
|
+
shown.add(field.name);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const declaredKind = valueKindOf(field, raw);
|
|
661
|
+
const built = rowValue(declaredKind, raw, resolve);
|
|
662
|
+
if (!hasRenderableValue(declaredKind, built)) continue;
|
|
663
|
+
const { kind, value } = applyUnit(declaredKind, built, overlay.unit);
|
|
664
|
+
rows.push({ label: overlay.label ?? humanizeFieldName(field.name), kind, value });
|
|
665
|
+
shown.add(field.name);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
box: {
|
|
670
|
+
id: NOTE_BOX_ID,
|
|
671
|
+
kind: "note",
|
|
672
|
+
title: NOTE_BOX_TITLE,
|
|
673
|
+
sections: [{ id: NOTE_SECTION_ID, layout: "rows", rows }],
|
|
674
|
+
},
|
|
675
|
+
shown,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Declare one system's half of the infobox.
|
|
681
|
+
*
|
|
682
|
+
* The **mechanism** is here and the **declaration** is the system's, which is
|
|
683
|
+
* the `engine/` ÷ `sohl/` line everywhere else in this package: what a box
|
|
684
|
+
* looks like is note-format knowledge, and which of a system's fields are
|
|
685
|
+
* worth a row is that system's.
|
|
686
|
+
*
|
|
687
|
+
* A declaration is refused at module evaluation rather than when some note
|
|
688
|
+
* reaches it: it is a small piece of authored data loaded once per build, so a
|
|
689
|
+
* mistake in it should stop the build immediately and name the fault.
|
|
690
|
+
*
|
|
691
|
+
* @param {object} declaration - The system's declaration.
|
|
692
|
+
* @param {string} declaration.system - The system id.
|
|
693
|
+
* @param {string} declaration.title - What the box is called.
|
|
694
|
+
* @param {Readonly<Record<string, readonly object[]>>} [declaration.fields] -
|
|
695
|
+
* Note type → that system's declared field list, read for the generic rows
|
|
696
|
+
* section. The same list the compiler obeys, never a copy of it.
|
|
697
|
+
* @param {Readonly<Record<string, {label?: string, withheld?: string}>>} [declaration.presentation] -
|
|
698
|
+
* The presentation overlay: what one of this system's fields is called where
|
|
699
|
+
* humanising its key is wrong, and the fields that carry no row at all.
|
|
700
|
+
* **Not a second field list** — the fields come from `fields`, and a field
|
|
701
|
+
* the overlay does not mention still gets a row, so a field added to the
|
|
702
|
+
* system reaches the box with no edit here.
|
|
703
|
+
* @param {Record<string, (fm: object, ctx: object) => object[]>} [declaration.sections] -
|
|
704
|
+
* Note type → a builder returning that type's sections, for a type whose box
|
|
705
|
+
* is derived rather than read field by field.
|
|
706
|
+
* @returns {object} The frozen declaration.
|
|
707
|
+
* @throws {Error} When it names no system or no title.
|
|
708
|
+
*/
|
|
709
|
+
export function defineInfobox(
|
|
710
|
+
{ system, title, fields, presentation, sections } = /** @type {never} */ ({}),
|
|
711
|
+
) {
|
|
712
|
+
if (typeof system !== "string" || system === "") {
|
|
713
|
+
throw new Error(
|
|
714
|
+
"An infobox declaration must name the `system` it belongs to — a box " +
|
|
715
|
+
"is built per system, and its rows are only meaningful against one.",
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
if (typeof title !== "string" || title === "") {
|
|
719
|
+
throw new Error(
|
|
720
|
+
`The "${system}" infobox declares no \`title\` — the box carries a ` +
|
|
721
|
+
"heading on every surface, and a heading nobody wrote would be " +
|
|
722
|
+
"invented once per renderer.",
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
return Object.freeze({
|
|
726
|
+
system,
|
|
727
|
+
title,
|
|
728
|
+
fields: Object.freeze({ ...(fields ?? {}) }),
|
|
729
|
+
presentation: Object.freeze({ ...(presentation ?? {}) }),
|
|
730
|
+
sections: Object.freeze({ ...(sections ?? {}) }),
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Whether two authored values say the same thing.
|
|
736
|
+
*
|
|
737
|
+
* Structural, because a declared default is as often `[]` or `{value: null}`
|
|
738
|
+
* as it is a number.
|
|
739
|
+
*
|
|
740
|
+
* @param {unknown} a - One value.
|
|
741
|
+
* @param {unknown} b - The other.
|
|
742
|
+
* @returns {boolean} Whether they agree.
|
|
743
|
+
*/
|
|
744
|
+
function sameValue(a, b) {
|
|
745
|
+
if (a === b) return true;
|
|
746
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
747
|
+
return a.length === b.length && a.every((entry, at) => sameValue(entry, b[at]));
|
|
748
|
+
}
|
|
749
|
+
if (isMapping(a) && isMapping(b)) {
|
|
750
|
+
const keys = Object.keys(a);
|
|
751
|
+
if (keys.length !== Object.keys(b).length) return false;
|
|
752
|
+
return keys.every((key) => key in b && sameValue(a[key], b[key]));
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Whether a field's value is the one its own declaration would have supplied.
|
|
759
|
+
*
|
|
760
|
+
* _A field answered by its default is a fact about the compiler, not about the
|
|
761
|
+
* note_ — and that is true of the **value**, not of where it was written. A
|
|
762
|
+
* corpus writes its defaults out: `improveFlag: false`, `combatCategory: none`
|
|
763
|
+
* and `initSkillMult: 0` are typed into hundreds of notes that mean nothing by
|
|
764
|
+
* them, and a row for each says the compiler's word back to a reader who came
|
|
765
|
+
* for the note's.
|
|
766
|
+
*
|
|
767
|
+
* A field declaring no default has nothing to be equal to, so every value it
|
|
768
|
+
* holds is the note's.
|
|
769
|
+
*
|
|
770
|
+
* @param {object} field - The declaration.
|
|
771
|
+
* @param {unknown} raw - The resolved value.
|
|
772
|
+
* @returns {boolean} Whether the value is the declaration's own.
|
|
773
|
+
*/
|
|
774
|
+
export function isDeclaredDefault(field, raw) {
|
|
775
|
+
return "default" in (field ?? {}) && sameValue(raw, field.default);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* The generic rows section: what this note authors in one system's block.
|
|
780
|
+
*
|
|
781
|
+
* Read through the system's own field declaration, in its order, and only
|
|
782
|
+
* where the note said something the declaration does not already say — a value
|
|
783
|
+
* equal to the field's own default is the compiler's answer wherever it was
|
|
784
|
+
* typed.
|
|
785
|
+
*
|
|
786
|
+
* A field the **note box** already put on the page is skipped, so the panel
|
|
787
|
+
* does not say one fact twice. It is what the note box *shows* rather than
|
|
788
|
+
* what its vocabulary declares: a gear item's weight is declared under `data:`
|
|
789
|
+
* and authored at `sohl.system.weightBase`, and standing down on the
|
|
790
|
+
* declaration alone left the fact on no surface at all.
|
|
791
|
+
*
|
|
792
|
+
* @param {object} fm - The note's frontmatter.
|
|
793
|
+
* @param {readonly object[]} fields - The system's field declaration.
|
|
794
|
+
* @param {object} ctx - `{ block, resolve, resolveField, taken, presentation }`.
|
|
795
|
+
* @returns {object[]} Zero or one section.
|
|
796
|
+
*/
|
|
797
|
+
export function systemRowsSection(
|
|
798
|
+
fm,
|
|
799
|
+
fields,
|
|
800
|
+
{ block, resolve, resolveField, taken = new Set(), presentation = {} },
|
|
801
|
+
) {
|
|
802
|
+
const rows = [];
|
|
803
|
+
for (const field of fields ?? []) {
|
|
804
|
+
if (!field?.name || taken.has(field.name)) continue;
|
|
805
|
+
const overlay = overlayFor(presentation, fm?.type, field.name);
|
|
806
|
+
if (overlay.withheld) continue;
|
|
807
|
+
const { value: raw, from } = resolveField(field, fm, { block });
|
|
808
|
+
if (from === "default" || from === "value") continue;
|
|
809
|
+
if (isDeclaredDefault(field, raw)) continue;
|
|
810
|
+
if (!hasValue(raw)) continue;
|
|
811
|
+
const declaredKind = field.ref ? "link" : valueKindOf(field, raw);
|
|
812
|
+
const built = rowValue(
|
|
813
|
+
declaredKind,
|
|
814
|
+
raw,
|
|
815
|
+
resolve,
|
|
816
|
+
field.ref ? { type: field.ref } : undefined,
|
|
817
|
+
);
|
|
818
|
+
if (!hasRenderableValue(declaredKind, built)) continue;
|
|
819
|
+
const { kind, value } = applyUnit(declaredKind, built, overlay.unit);
|
|
820
|
+
rows.push({ label: overlay.label ?? humanizeFieldName(field.name), kind, value });
|
|
821
|
+
}
|
|
822
|
+
return rows.length ? [{ id: "profile", layout: "rows", rows }] : [];
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Every box a note carries, in the order every medium renders them.
|
|
827
|
+
*
|
|
828
|
+
* The note box first — it is the subject itself — then one box per system the
|
|
829
|
+
* note's type maps to, in the order the maps are declared.
|
|
830
|
+
*
|
|
831
|
+
* @param {object} fm - The note's frontmatter.
|
|
832
|
+
* @param {object} options - Options.
|
|
833
|
+
* @param {readonly object[]} options.maps - The document-subtype maps this
|
|
834
|
+
* build ships, which decide the box set.
|
|
835
|
+
* @param {readonly object[]} [options.providers] - The systems' infobox
|
|
836
|
+
* declarations, keyed by `system`.
|
|
837
|
+
* @param {(fm: object, block: string) => boolean} options.carriesBlock -
|
|
838
|
+
* Whether the note says anything about a system, which decides
|
|
839
|
+
* {@link NOT_AVAILABLE}.
|
|
840
|
+
* @param {(field: object, fm: object, opts: object) => object} options.resolveField -
|
|
841
|
+
* Resolves one declared field against the note.
|
|
842
|
+
* @param {(ref: unknown) => object|undefined} [options.resolve] - Resolves a
|
|
843
|
+
* reference to `{name, url?, uuid?, address?}`.
|
|
844
|
+
* @param {object} [options.vocabulary] - The note vocabulary to read.
|
|
845
|
+
* @returns {object[]} The boxes.
|
|
846
|
+
*/
|
|
847
|
+
export function buildInfoboxes(fm, options) {
|
|
848
|
+
const {
|
|
849
|
+
maps,
|
|
850
|
+
providers = [],
|
|
851
|
+
carriesBlock,
|
|
852
|
+
resolveField,
|
|
853
|
+
resolve,
|
|
854
|
+
vocabulary = NOTE_VOCABULARY,
|
|
855
|
+
} = options;
|
|
856
|
+
|
|
857
|
+
const { box: note, shown: taken } = noteBox(fm, { resolve, vocabulary });
|
|
858
|
+
const boxes = [note];
|
|
859
|
+
|
|
860
|
+
for (const map of maps ?? []) {
|
|
861
|
+
if (!subtypeRow(map, fm?.type)) continue;
|
|
862
|
+
const provider = providers.find((entry) => entry.system === map.system);
|
|
863
|
+
const available = Boolean(carriesBlock(fm, map.block));
|
|
864
|
+
const box = {
|
|
865
|
+
id: map.system,
|
|
866
|
+
kind: "system",
|
|
867
|
+
system: map.system,
|
|
868
|
+
title: provider?.title ?? map.system,
|
|
869
|
+
available,
|
|
870
|
+
sections: [],
|
|
871
|
+
};
|
|
872
|
+
if (available && provider) {
|
|
873
|
+
const type = currentType(fm.type);
|
|
874
|
+
const ctx = {
|
|
875
|
+
block: map.block,
|
|
876
|
+
resolve,
|
|
877
|
+
resolveField,
|
|
878
|
+
taken,
|
|
879
|
+
presentation: provider.presentation ?? {},
|
|
880
|
+
};
|
|
881
|
+
const build = provider.sections[type];
|
|
882
|
+
box.sections =
|
|
883
|
+
build ? build(fm, ctx) : systemRowsSection(fm, provider.fields[type], ctx);
|
|
884
|
+
}
|
|
885
|
+
// A system box is never an empty panel. It either holds something, or
|
|
886
|
+
// it says which of the two silences this is.
|
|
887
|
+
if (!available) box.statement = NOT_AVAILABLE;
|
|
888
|
+
else if (!box.sections.some(sectionHolds)) box.statement = NOTHING_BEYOND_PROFILE;
|
|
889
|
+
boxes.push(box);
|
|
890
|
+
}
|
|
891
|
+
return boxes;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Whether a section holds anything a medium would draw.
|
|
896
|
+
*
|
|
897
|
+
* Keyed by {@link INFOBOX_LAYOUTS}, so a layout added there is understood here
|
|
898
|
+
* without a second edit.
|
|
899
|
+
*
|
|
900
|
+
* @param {object} section - The section.
|
|
901
|
+
* @returns {boolean} Whether it holds anything.
|
|
902
|
+
*/
|
|
903
|
+
export function sectionHolds(section) {
|
|
904
|
+
const key = INFOBOX_LAYOUTS[section?.layout];
|
|
905
|
+
return Boolean(key && Array.isArray(section[key]) && section[key].length);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* The box ids a note's type requires, in order.
|
|
910
|
+
*
|
|
911
|
+
* Derived from the same two declarations {@link buildInfoboxes} reads, so the
|
|
912
|
+
* assertion below compares an emitter's output against the format rather than
|
|
913
|
+
* against a restatement of it.
|
|
914
|
+
*
|
|
915
|
+
* @param {object} fm - The note's frontmatter.
|
|
916
|
+
* @param {object} options - `{ maps }`.
|
|
917
|
+
* @returns {string[]} The ids.
|
|
918
|
+
*/
|
|
919
|
+
export function requiredInfoboxIds(fm, { maps }) {
|
|
920
|
+
const ids = [NOTE_BOX_ID];
|
|
921
|
+
for (const map of maps ?? []) {
|
|
922
|
+
if (subtypeRow(map, fm?.type)) ids.push(map.system);
|
|
923
|
+
}
|
|
924
|
+
return ids;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Refuse a page carrying anything but the boxes its type maps to.
|
|
929
|
+
*
|
|
930
|
+
* _Every page carries exactly the boxes its type maps to_ is the property the
|
|
931
|
+
* format states and the reason the box set is derived rather than declared.
|
|
932
|
+
* Asserted where the boxes are emitted, in every medium, so a missing infobox
|
|
933
|
+
* is a failure rather than something nobody notices — and so is a box for a
|
|
934
|
+
* system that will compile no document, which is a note claiming a system it
|
|
935
|
+
* does not reach.
|
|
936
|
+
*
|
|
937
|
+
* @param {readonly object[]} boxes - What was built.
|
|
938
|
+
* @param {object} fm - The note's frontmatter.
|
|
939
|
+
* @param {object} options - `{ maps, where }`.
|
|
940
|
+
* @returns {readonly object[]} The boxes, unchanged, so a caller may assert
|
|
941
|
+
* inline.
|
|
942
|
+
* @throws {Error} Naming the note, what is missing and what is extra.
|
|
943
|
+
*/
|
|
944
|
+
export function assertInfoboxSet(boxes, fm, { maps, where = "" } = {}) {
|
|
945
|
+
const required = requiredInfoboxIds(fm, { maps });
|
|
946
|
+
const got = (boxes ?? []).map((box) => box.id);
|
|
947
|
+
const missing = required.filter((id) => !got.includes(id));
|
|
948
|
+
const extra = got.filter((id) => !required.includes(id));
|
|
949
|
+
if (!missing.length && !extra.length) return boxes;
|
|
950
|
+
|
|
951
|
+
const subject = fm?.name?.full ?? fm?.shortcode ?? "this note";
|
|
952
|
+
const parts = [];
|
|
953
|
+
if (missing.length) parts.push(`is missing ${missing.map((id) => `\`${id}\``).join(", ")}`);
|
|
954
|
+
if (extra.length) parts.push(`carries ${extra.map((id) => `\`${id}\``).join(", ")} as well`);
|
|
955
|
+
throw new Error(
|
|
956
|
+
`"${subject}" is a "${fm?.type}", whose type maps to the infoboxes ` +
|
|
957
|
+
`${required.map((id) => `\`${id}\``).join(", ")}, and the page ${parts.join(" and ")}` +
|
|
958
|
+
`${where ? ` — ${where}` : ""}. Which boxes a page carries is decided by ` +
|
|
959
|
+
`the note-type → document-subtype map, so a set that disagrees with it ` +
|
|
960
|
+
`means either a system block nothing will compile or a panel a reader ` +
|
|
961
|
+
`is silently not shown.`,
|
|
962
|
+
);
|
|
963
|
+
}
|