@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/CONTENT.md +134 -44
  3. package/bin/content-build.mjs +37 -5
  4. package/bin/package-build.mjs +77 -0
  5. package/content-config.mjs +59 -1
  6. package/docs/api.md +149 -19
  7. package/docs/commands.md +75 -0
  8. package/docs/configuration.md +37 -10
  9. package/docs/content-format.md +450 -49
  10. package/engine/content-format.mjs +52 -3
  11. package/engine/content-images.mjs +699 -0
  12. package/engine/dependency-bump.mjs +218 -0
  13. package/engine/frontmatter-lint.mjs +89 -2
  14. package/engine/helpers.mjs +81 -142
  15. package/engine/index.mjs +15 -0
  16. package/engine/infobox-registry.mjs +81 -0
  17. package/engine/infobox-render.mjs +381 -0
  18. package/engine/infobox.mjs +963 -0
  19. package/engine/item-registry.mjs +5 -5
  20. package/engine/journals.mjs +22 -1
  21. package/engine/map-notes.mjs +11 -5
  22. package/engine/metadata-index.mjs +5 -0
  23. package/engine/note-vocabulary.mjs +57 -2
  24. package/engine/pathnames.mjs +374 -0
  25. package/engine/pdf-build.mjs +208 -9
  26. package/engine/pdf-render.mjs +461 -21
  27. package/engine/pdf-toc.mjs +77 -5
  28. package/engine/scenes.mjs +2 -1
  29. package/engine/site-build.mjs +115 -8
  30. package/engine/site-index.mjs +93 -4
  31. package/engine/wikilinks.mjs +93 -0
  32. package/hm3/default-item-art.mjs +14 -15
  33. package/hm3/index.mjs +3 -0
  34. package/hm3/infobox.mjs +64 -0
  35. package/package.json +3 -2
  36. package/sohl/being-info.mjs +9 -3
  37. package/sohl/default-item-art.mjs +18 -16
  38. package/sohl/index.mjs +3 -0
  39. package/sohl/infobox.mjs +499 -0
  40. package/types/content-config.d.mts +7 -0
  41. package/types/engine/content-format.d.mts +36 -0
  42. package/types/engine/content-images.d.mts +281 -0
  43. package/types/engine/dependency-bump.d.mts +89 -0
  44. package/types/engine/frontmatter-lint.d.mts +23 -0
  45. package/types/engine/helpers.d.mts +30 -72
  46. package/types/engine/index.d.mts +5 -0
  47. package/types/engine/infobox-registry.d.mts +36 -0
  48. package/types/engine/infobox-render.d.mts +87 -0
  49. package/types/engine/infobox.d.mts +443 -0
  50. package/types/engine/item-registry.d.mts +5 -5
  51. package/types/engine/journals.d.mts +9 -1
  52. package/types/engine/note-vocabulary.d.mts +51 -0
  53. package/types/engine/pathnames.d.mts +189 -0
  54. package/types/engine/pdf-build.d.mts +46 -0
  55. package/types/engine/pdf-render.d.mts +99 -1
  56. package/types/engine/pdf-toc.d.mts +10 -5
  57. package/types/engine/site-build.d.mts +19 -3
  58. package/types/engine/site-index.d.mts +35 -3
  59. package/types/engine/wikilinks.d.mts +22 -0
  60. package/types/hm3/default-item-art.d.mts +5 -6
  61. package/types/hm3/index.d.mts +1 -0
  62. package/types/hm3/infobox.d.mts +22 -0
  63. package/types/sohl/being-info.d.mts +4 -3
  64. package/types/sohl/index.d.mts +1 -0
  65. 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
+ }