@heroiclands/package-build 9.0.0 → 10.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 (78) hide show
  1. package/CHANGELOG.md +694 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +437 -7
  4. package/content-config.mjs +259 -28
  5. package/docs/content-format.md +1418 -0
  6. package/engine/address-charset.mjs +62 -0
  7. package/engine/alias-index.mjs +153 -0
  8. package/engine/base-compiler.mjs +194 -4
  9. package/engine/content-address.mjs +4 -4
  10. package/engine/content-format-check.mjs +570 -0
  11. package/engine/content-format.mjs +253 -0
  12. package/engine/content-links.mjs +132 -56
  13. package/engine/content-lint.mjs +8 -1
  14. package/engine/diagnostics.mjs +33 -0
  15. package/engine/document-subtypes.mjs +440 -0
  16. package/engine/field-spec.mjs +49 -43
  17. package/engine/frontmatter-lint.mjs +343 -27
  18. package/engine/generate.mjs +32 -4
  19. package/engine/helpers.mjs +41 -29
  20. package/engine/ids.mjs +19 -1
  21. package/engine/index.mjs +15 -0
  22. package/engine/item-registry.mjs +72 -5
  23. package/engine/kb-manifest.mjs +36 -7
  24. package/engine/map-notes.mjs +34 -18
  25. package/engine/note-claims.mjs +383 -0
  26. package/engine/note-vocabulary.mjs +678 -0
  27. package/engine/pack-config.mjs +39 -22
  28. package/engine/pack-router.mjs +17 -6
  29. package/engine/prose-lint.mjs +55 -3
  30. package/engine/retired-fields.mjs +117 -3
  31. package/engine/scenes.mjs +19 -1
  32. package/engine/schema-check.mjs +347 -3
  33. package/engine/site-build.mjs +1 -1
  34. package/engine/site-index.mjs +38 -21
  35. package/engine/system-block.mjs +513 -0
  36. package/engine/web-wikilinks.mjs +112 -80
  37. package/engine/wikilink-syntax.mjs +30 -0
  38. package/engine/wikilinks.mjs +67 -51
  39. package/package.json +6 -2
  40. package/sohl/actors.mjs +249 -36
  41. package/sohl/document-subtypes.mjs +82 -0
  42. package/sohl/index.mjs +3 -0
  43. package/sohl/items.mjs +110 -14
  44. package/sohl/note-schemas.mjs +11 -7
  45. package/types/content-config.d.mts +48 -4
  46. package/types/engine/address-charset.d.mts +45 -0
  47. package/types/engine/alias-index.d.mts +122 -0
  48. package/types/engine/base-compiler.d.mts +132 -4
  49. package/types/engine/content-address.d.mts +2 -2
  50. package/types/engine/content-format-check.d.mts +163 -0
  51. package/types/engine/content-format.d.mts +101 -0
  52. package/types/engine/content-links.d.mts +16 -1
  53. package/types/engine/content-lint.d.mts +6 -0
  54. package/types/engine/diagnostics.d.mts +29 -0
  55. package/types/engine/document-subtypes.d.mts +233 -0
  56. package/types/engine/field-spec.d.mts +76 -23
  57. package/types/engine/frontmatter-lint.d.mts +47 -2
  58. package/types/engine/generate.d.mts +14 -1
  59. package/types/engine/helpers.d.mts +21 -13
  60. package/types/engine/ids.d.mts +10 -0
  61. package/types/engine/index.d.mts +5 -0
  62. package/types/engine/item-registry.d.mts +21 -2
  63. package/types/engine/kb-manifest.d.mts +35 -8
  64. package/types/engine/map-notes.d.mts +21 -11
  65. package/types/engine/note-claims.d.mts +113 -0
  66. package/types/engine/note-vocabulary.d.mts +251 -0
  67. package/types/engine/pack-config.d.mts +4 -3
  68. package/types/engine/pack-router.d.mts +4 -4
  69. package/types/engine/prose-lint.d.mts +6 -2
  70. package/types/engine/retired-fields.d.mts +73 -2
  71. package/types/engine/schema-check.d.mts +182 -0
  72. package/types/engine/system-block.d.mts +281 -0
  73. package/types/engine/web-wikilinks.d.mts +23 -12
  74. package/types/engine/wikilink-syntax.d.mts +29 -0
  75. package/types/sohl/actors.d.mts +62 -6
  76. package/types/sohl/document-subtypes.d.mts +14 -0
  77. package/types/sohl/index.d.mts +1 -0
  78. package/types/sohl/items.d.mts +21 -0
@@ -0,0 +1,678 @@
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 **closed** half of a note's frontmatter: the `data:` container, and the
16
+ * `subType` each note type declares (#128).
17
+ *
18
+ * A note's frontmatter has three regions, and only one of them is open. The
19
+ * **top level** describes the note as a published artefact, every key of it is
20
+ * copied into the generated web page, and an unrecognised key there is a Hugo
21
+ * or theme parameter this build has no standing to refuse. The **system
22
+ * blocks** describe the subject as one system's documents. Between them sits
23
+ * `data:` — the type-specific facts about the subject itself, system-agnostic,
24
+ * and closed.
25
+ *
26
+ * Closed is the whole point. Those facts previously sat at the top level, where
27
+ * the pass-through rule applied to them too, so a misspelled `wieght` became a
28
+ * theme parameter rather than a finding — indistinguishable, from the outside,
29
+ * from a weapon that simply weighs nothing. Under `data:` the same misspelling
30
+ * is an error naming the note and the key it was probably meant to be.
31
+ *
32
+ * **`subType` rides along, and stays at the top level.** It is not a `data:`
33
+ * key: a note's `(type, subType)` is what each system's map reads to derive a
34
+ * document type, so it describes the note rather than the subject. But it is
35
+ * the other per-type vocabulary the format closes, it is enumerated in the same
36
+ * `### type:` section of the specification that enumerates the `data:` keys,
37
+ * and keeping the two together means one entry per type rather than two
38
+ * registries free to disagree about which types exist.
39
+ *
40
+ * **This is note-format knowledge, so it lives in `engine/`.** `data:` holds
41
+ * what is true of the *thing* — a weapon's weight, an affliction's
42
+ * transmission — and is true of it whichever system is reading. What each
43
+ * system makes of that value is declared elsewhere, in that system's own half.
44
+ *
45
+ * **The type names here are today's**, which for four of them is not the name
46
+ * the specification uses: `armor`, `weapon`, `projectile` and `concoction` are
47
+ * still spelled `armorgear`, `weapongear`, `projectilegear` and
48
+ * `concoctiongear`, and a `map` is still one of `battlemap` / `localmap` /
49
+ * `regionalmap`. Those renames are a later slice (#78, #79), and declaring the
50
+ * vocabulary under a name no note may yet carry would make it unreachable. The
51
+ * specification's types with no name here at all — `place`, `scenario`,
52
+ * `lore`, `vehicle`, `armorlocation` — are likewise deferred: a note carrying
53
+ * one is already reported as a type no schema declares, which is the finding it
54
+ * deserves until the type exists.
55
+ *
56
+ * @module
57
+ */
58
+
59
+ /**
60
+ * One `data:` key a note type may carry.
61
+ *
62
+ * A deliberate subset of {@link import("./field-spec.mjs").FieldSpec}: no `to`,
63
+ * because nothing here builds anything yet. Reading `data.*` through into a
64
+ * document's `system` block is the passthrough slice (#126), and claiming an
65
+ * emitted path this does not produce would be a lie in the one place a reader
66
+ * would trust it.
67
+ *
68
+ * @typedef {object} DataFieldSpec
69
+ * @property {string} name - The key under `data:`, dotted for a nested one
70
+ * (`charges.value`).
71
+ * @property {"string"|"number"|"boolean"|"list"|"map"} [kind] - The value's
72
+ * shape, for the lint. Absent means no claim is made about the value — which
73
+ * is the honest answer wherever the specification's stated shape and the
74
+ * shape notes are authored in today disagree.
75
+ * @property {string} [shape] - Human-readable shape, for a finding and for
76
+ * documentation.
77
+ * @property {string} describe - One line, for the author-facing reference.
78
+ */
79
+
80
+ /**
81
+ * What one note type declares.
82
+ *
83
+ * `subTypes` is three-valued, and the difference matters:
84
+ *
85
+ * - **omitted** — the type has no `subType` at all, and a note carrying one is
86
+ * a finding. A `weapon` is the deliberate case: SoHL distinguishes a
87
+ * weapon's uses by strike mode rather than by kind.
88
+ * - **`null`** — the type has a `subType` whose values the specification does
89
+ * not yet enumerate. Presence is permitted and the value is unchecked.
90
+ * - **a list** — the closed set of values, and anything else is a finding.
91
+ *
92
+ * @typedef {object} TypeVocabulary
93
+ * @property {readonly DataFieldSpec[]} data - The `data:` keys, closed.
94
+ * @property {readonly string[]|null} [subTypes] - The top-level `subType`
95
+ * values, as above.
96
+ */
97
+
98
+ /* --------------------------------------------------------------------- */
99
+ /* Shapes */
100
+ /* --------------------------------------------------------------------- */
101
+
102
+ /** A number. */
103
+ const NUM = Object.freeze({ shape: "number", kind: "number" });
104
+
105
+ /** A string, including the enumerated-value fields the format spells as one. */
106
+ const TEXT = Object.freeze({ shape: "string", kind: "string" });
107
+
108
+ /** A list. */
109
+ const LIST = Object.freeze({ shape: "list", kind: "list" });
110
+
111
+ /** A single wikilink, which is a string until it is resolved. */
112
+ const LINK = Object.freeze({ shape: "a wikilink", kind: "string" });
113
+
114
+ /** A list of wikilinks. */
115
+ const LINKS = Object.freeze({ shape: "list of wikilinks", kind: "list" });
116
+
117
+ /** Whatever the author wrote — declared, but with no claim about its shape. */
118
+ const ANY = Object.freeze({ shape: "as authored" });
119
+
120
+ /**
121
+ * The template-priority key every document-producing type carries.
122
+ *
123
+ * Declared once and shared, rather than retyped in twenty tables where the one
124
+ * that was mistyped would be the one nobody noticed.
125
+ *
126
+ * @type {DataFieldSpec}
127
+ */
128
+ const TEMPLATE_PRIORITY = Object.freeze({
129
+ name: "templatePriority",
130
+ ...NUM,
131
+ describe: "Template priority; unset means the note is not a template.",
132
+ });
133
+
134
+ /**
135
+ * The four gear values every carried thing declares.
136
+ *
137
+ * @type {readonly DataFieldSpec[]}
138
+ */
139
+ const GEAR = Object.freeze([
140
+ { name: "weight", ...NUM, describe: "What the thing weighs." },
141
+ { name: "value", ...NUM, describe: "What the thing is worth." },
142
+ { name: "quality", ...NUM, describe: "How well it is made." },
143
+ { name: "durability", ...NUM, describe: "How much wear it takes before it fails." },
144
+ ]);
145
+
146
+ /** Gear that is counted rather than carried singly. */
147
+ const QUANTITY = Object.freeze({
148
+ name: "quantity",
149
+ ...NUM,
150
+ describe: "How many of the thing there are; one when unstated.",
151
+ });
152
+
153
+ /**
154
+ * The charges a mystery or mystical ability holds.
155
+ *
156
+ * @type {readonly DataFieldSpec[]}
157
+ */
158
+ const CHARGES = Object.freeze([
159
+ {
160
+ name: "charges.value",
161
+ ...NUM,
162
+ describe: "Charges available now; unset means charges are not used.",
163
+ },
164
+ { name: "charges.max", ...NUM, describe: "Most charges it can hold; unset means no maximum." },
165
+ ]);
166
+
167
+ /* --------------------------------------------------------------------- */
168
+ /* The vocabulary */
169
+ /* --------------------------------------------------------------------- */
170
+
171
+ /**
172
+ * Every note type this toolchain compiles, and the closed vocabulary it
173
+ * declares.
174
+ *
175
+ * Taken from the content-format specification, one `### type:` section per
176
+ * entry. Where the specification and the shape notes are authored in today
177
+ * disagree, the specification wins on the **name** — that is what a `data:`
178
+ * key will be called — and the disagreement is recorded on the field rather
179
+ * than resolved silently.
180
+ *
181
+ * @type {Readonly<Record<string, TypeVocabulary>>}
182
+ */
183
+ /**
184
+ * The tags that **classify** a note, grouped by what they classify (#172).
185
+ *
186
+ * `tags:` lives at the open top level and most tags belong there: a theme, a
187
+ * region, a working state is the author's own and this build has no opinion
188
+ * about it. A classifying tag is different, because something queries it — a
189
+ * settlement tagged `village` appears in the list of villages and an untagged
190
+ * one does not, so `vilage` does not merely look wrong, it removes the note from
191
+ * an index while the index still renders a table that looks complete.
192
+ *
193
+ * **This list is not a closed set.** An unrecognised tag is legal, because the
194
+ * region is open; what is reported is a **near miss** — a tag close enough to a
195
+ * declared one to be a typo of it.
196
+ *
197
+ * **Each group names the types it applies to**, and that scope is what makes the
198
+ * check sound rather than noisy. A place's kinds are only a place's: `azravan`
199
+ * on a faith, `barter` on an economy note and `secret` on three lore notes all
200
+ * sit within a typo's distance of `caravan`, `border` and `sacred`, and not one
201
+ * is a mistake. Checked against every group at once the rule was wrong on every
202
+ * note it touched; scoped to the type it is wrong on none. `types: null` is a
203
+ * group any note may carry.
204
+ *
205
+ * Kind and character are separate groups because one slot could not hold both: a
206
+ * fishing village is a `village` that is `fishing`, and the single-valued field
207
+ * this replaced had to spell it `Fishing Village` as a value of its own.
208
+ */
209
+ export const DECLARED_TAGS = Object.freeze({
210
+ /** What a place *is*. */
211
+ placeKind: Object.freeze({
212
+ types: ["place"],
213
+ tags: Object.freeze([
214
+ "city",
215
+ "city-state",
216
+ "town",
217
+ "village",
218
+ "settlement",
219
+ "port",
220
+ "fortress",
221
+ "citadel",
222
+ "castle",
223
+ "stronghold",
224
+ "garrison",
225
+ "camp",
226
+ "oasis",
227
+ "waypoint",
228
+ "post",
229
+ "precinct",
230
+ "district",
231
+ "necropolis",
232
+ "hall",
233
+ "capital",
234
+ ]),
235
+ }),
236
+ /** What a place is known for. */
237
+ placeCharacter: Object.freeze({
238
+ types: ["place"],
239
+ tags: Object.freeze([
240
+ "fortified",
241
+ "temple",
242
+ "market",
243
+ "trading",
244
+ "merchant",
245
+ "mining",
246
+ "fishing",
247
+ "naval",
248
+ "military",
249
+ "imperial",
250
+ "provincial",
251
+ "coastal",
252
+ "river",
253
+ "lakeside",
254
+ "hill",
255
+ "mountain",
256
+ "valley",
257
+ "forest",
258
+ "woodland",
259
+ "inland",
260
+ "island",
261
+ "frontier",
262
+ "border",
263
+ "craft",
264
+ "caravan",
265
+ "pilgrimage",
266
+ "holy",
267
+ "sacred",
268
+ "free",
269
+ ]),
270
+ }),
271
+ /** A place's scale, where the subtype does not distinguish it. */
272
+ placeScale: Object.freeze({ types: ["place"], tags: Object.freeze(["continent"]) }),
273
+ /** Which kind of body a being belongs to — a station rather than a rank. */
274
+ beingStation: Object.freeze({
275
+ types: ["being"],
276
+ tags: Object.freeze([
277
+ "tradesfolk",
278
+ "common-folk",
279
+ "soldiery",
280
+ "administration",
281
+ "clergy",
282
+ "mages",
283
+ "underworld",
284
+ "dependents",
285
+ "guilded",
286
+ "unguilded",
287
+ ]),
288
+ }),
289
+ /** A note's working state, which any note may carry. */
290
+ state: Object.freeze({ types: null, tags: Object.freeze(["draft"]) }),
291
+ });
292
+
293
+ /**
294
+ * The declared tags a note of this type may carry, flattened.
295
+ *
296
+ * @param {string} type - The note's type.
297
+ * @param {object} [groups] - The grouped declaration.
298
+ * @returns {readonly string[]} The tags, in declaration order.
299
+ */
300
+ export function declaredTags(type, groups = DECLARED_TAGS) {
301
+ const applies = Object.values(groups).filter((g) => !g.types || g.types.includes(type));
302
+ return Object.freeze(applies.flatMap((g) => g.tags));
303
+ }
304
+
305
+ export const NOTE_VOCABULARY = Object.freeze({
306
+ /* ----- actors --------------------------------------------------- */
307
+
308
+ being: Object.freeze({
309
+ // Derived from the note's `(type, subType)` by each system's map, which
310
+ // lands with #79. Declared open until it does, because inventing the
311
+ // values here would put a second, weaker answer beside the real one.
312
+ subTypes: null,
313
+ data: Object.freeze([
314
+ { name: "portrait", ...TEXT, describe: "Path to the portrait image." },
315
+ TEMPLATE_PRIORITY,
316
+ { name: "archetypes", ...LIST, describe: "Archetypal behaviours the being fits." },
317
+ { name: "occupation", ...TEXT, describe: "What the being does for a living." },
318
+ { name: "stations", ...LINKS, describe: "Stations the being holds." },
319
+ { name: "peoples", ...LINKS, describe: "Peoples the being belongs to." },
320
+ { name: "homes", ...LINKS, describe: "Places the being calls home." },
321
+ {
322
+ name: "affiliations",
323
+ ...LINKS,
324
+ describe: "Affiliations the being belongs to — traditions, polities, and the rest.",
325
+ },
326
+ { name: "gender", ...TEXT, describe: "`male`, `female` or `other`." },
327
+ { name: "species", ...LINK, describe: "The being's species, as a lore note." },
328
+ { name: "age", ...NUM, describe: "Age in years." },
329
+ { name: "birthday", ...TEXT, describe: "Date of birth, `YYYY/MM/DD`." },
330
+ { name: "height", ...NUM, describe: "Height in metres." },
331
+ { name: "weight", ...NUM, describe: "Weight in kilograms." },
332
+ {
333
+ name: "frame",
334
+ ...TEXT,
335
+ describe: "Relative frame — `scant`, `light`, `medium`, `large` or `massive`.",
336
+ },
337
+ { name: "appearance.eye_color", ...TEXT, describe: "Eye colour." },
338
+ { name: "appearance.hair_color", ...TEXT, describe: "Hair colour." },
339
+ { name: "appearance.skin_color", ...TEXT, describe: "Skin colour." },
340
+ { name: "appearance.complexion", ...TEXT, describe: "Complexion." },
341
+ {
342
+ name: "appearance.extra_features",
343
+ ...LIST,
344
+ describe: "Anything else a stranger would notice.",
345
+ },
346
+ ]),
347
+ }),
348
+
349
+ /* ----- items ---------------------------------------------------- */
350
+
351
+ affiliation: Object.freeze({
352
+ subTypes: Object.freeze([
353
+ "guild",
354
+ "order",
355
+ "polity",
356
+ "faithtradition",
357
+ "arcanetradition",
358
+ "spirittradition",
359
+ "lineage",
360
+ "venture",
361
+ "criminal",
362
+ "governmental",
363
+ "fellowship",
364
+ ]),
365
+ data: Object.freeze([
366
+ TEMPLATE_PRIORITY,
367
+ {
368
+ name: "demonym",
369
+ ...TEXT,
370
+ describe: "What one member is called — a Vylarian.",
371
+ },
372
+ {
373
+ name: "governance.model",
374
+ ...TEXT,
375
+ describe: "How the affiliation is governed, where it is.",
376
+ },
377
+ {
378
+ name: "governance.summary",
379
+ ...TEXT,
380
+ describe: "A sentence on how the governance actually works.",
381
+ },
382
+ {
383
+ name: "governance.ranks",
384
+ ...LIST,
385
+ describe: "The ladder of ranks the body confers — level, title, description.",
386
+ },
387
+ {
388
+ name: "governance.offices",
389
+ ...ANY,
390
+ describe: "The named offices it appoints, each with what the office does.",
391
+ },
392
+ {
393
+ name: "commonSkills",
394
+ ...LINKS,
395
+ describe: "Skills common among members — languages first among them.",
396
+ },
397
+ { name: "seat", ...LINK, describe: "Where the affiliation's authority sits." },
398
+ { name: "domains", ...LINKS, describe: "Places over which it holds sway." },
399
+ { name: "population", ...NUM, describe: "How many people it counts." },
400
+ {
401
+ name: "economy",
402
+ ...LINKS,
403
+ describe: "What its economic life runs on — currencies, banking bodies, goods.",
404
+ },
405
+ { name: "peoples", ...LINKS, describe: "Peoples associated with it." },
406
+ { name: "parents", ...LINKS, describe: "Affiliations it is subordinate to." },
407
+ {
408
+ name: "relations",
409
+ ...ANY,
410
+ describe: "Standing with other affiliations — aligned, unaligned, rival, nemesis.",
411
+ },
412
+ ]),
413
+ }),
414
+
415
+ affliction: Object.freeze({
416
+ subTypes: Object.freeze(["disease", "poisontoxin", "maladiction"]),
417
+ data: Object.freeze([
418
+ TEMPLATE_PRIORITY,
419
+ { name: "transmission", ...TEXT, describe: "How it passes from one host to another." },
420
+ {
421
+ name: "outcome",
422
+ ...TEXT,
423
+ describe: "Where it ends once it has run its course — `death` or `cured`.",
424
+ },
425
+ { name: "healingRate", ...NUM, describe: "How readily a healing test goes well." },
426
+ { name: "contagionIndex", ...NUM, describe: "How contagious it is." },
427
+ {
428
+ name: "outcomeTraumas",
429
+ ...TEXT,
430
+ describe: "Expression returning the traumas recovery leaves behind.",
431
+ },
432
+ {
433
+ name: "onsetDurationFormula",
434
+ ...TEXT,
435
+ describe: "Roll formula for the delay between contraction and onset.",
436
+ },
437
+ {
438
+ name: "healingCheckDurationFormula",
439
+ ...TEXT,
440
+ describe: "Roll formula for the interval between healing checks.",
441
+ },
442
+ {
443
+ name: "resolutionDurationFormula",
444
+ ...TEXT,
445
+ describe: "Roll formula for the time from onset to resolution.",
446
+ },
447
+ ]),
448
+ }),
449
+
450
+ armorgear: Object.freeze({
451
+ // Quantity is always one, so the specification refuses the key rather
452
+ // than defaulting it.
453
+ data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR]),
454
+ }),
455
+
456
+ attribute: Object.freeze({
457
+ data: Object.freeze([TEMPLATE_PRIORITY]),
458
+ }),
459
+
460
+ concoctiongear: Object.freeze({
461
+ subTypes: Object.freeze(["mundane", "exotic", "elixir"]),
462
+ data: Object.freeze([
463
+ TEMPLATE_PRIORITY,
464
+ ...GEAR,
465
+ QUANTITY,
466
+ {
467
+ name: "potency",
468
+ ...TEXT,
469
+ describe: "Potency — `na`, `mild`, `strong` or `great`.",
470
+ },
471
+ { name: "strength", ...NUM, describe: "Strength; the higher, the stronger." },
472
+ ]),
473
+ }),
474
+
475
+ containergear: Object.freeze({
476
+ data: Object.freeze([
477
+ TEMPLATE_PRIORITY,
478
+ ...GEAR,
479
+ { name: "capacity", ...NUM, describe: "How much it holds." },
480
+ ]),
481
+ }),
482
+
483
+ miscgear: Object.freeze({
484
+ data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR, QUANTITY]),
485
+ }),
486
+
487
+ mystery: Object.freeze({
488
+ subTypes: Object.freeze(["boon", "boost", "fate", "grace", "birthsign", "other", "piety"]),
489
+ data: Object.freeze([
490
+ TEMPLATE_PRIORITY,
491
+ { name: "assocSkill", ...LINK, describe: "The skill it is associated with." },
492
+ {
493
+ name: "assocAffiliation",
494
+ ...LINK,
495
+ describe: "The affiliation it is associated with.",
496
+ },
497
+ {
498
+ name: "skillAptitudes",
499
+ ...ANY,
500
+ describe:
501
+ "Bonuses and penalties, each naming a skill or a `subType:<skill-subtype>`.",
502
+ },
503
+ { name: "level", ...NUM, describe: "The magnitude of the mystery." },
504
+ ...CHARGES,
505
+ ]),
506
+ }),
507
+
508
+ mysticalability: Object.freeze({
509
+ subTypes: Object.freeze([
510
+ "spiritrite",
511
+ "spiritaction",
512
+ "spiritpower",
513
+ "ritualaction",
514
+ "divineincantation",
515
+ "arcaneincantation",
516
+ "arcanetalent",
517
+ "spirittalent",
518
+ "alchemy",
519
+ "divination",
520
+ ]),
521
+ data: Object.freeze([
522
+ TEMPLATE_PRIORITY,
523
+ { name: "assocSkill", ...LINK, describe: "The skill it is associated with." },
524
+ {
525
+ name: "assocAffiliation",
526
+ ...LINK,
527
+ describe: "The affiliation it is associated with.",
528
+ },
529
+ { name: "masteryLevel", ...NUM, describe: "Mastery before any modifier." },
530
+ { name: "level", ...NUM, describe: "The magnitude of the ability." },
531
+ ...CHARGES,
532
+ ]),
533
+ }),
534
+
535
+ projectilegear: Object.freeze({
536
+ subTypes: Object.freeze(["none", "arrow", "bolt", "bullet", "dart", "other"]),
537
+ data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR, QUANTITY]),
538
+ }),
539
+
540
+ skill: Object.freeze({
541
+ subTypes: Object.freeze([
542
+ "social",
543
+ "nature",
544
+ "craft",
545
+ "lore",
546
+ "language",
547
+ "script",
548
+ "mystical",
549
+ "physical",
550
+ "combat",
551
+ "combattechnique",
552
+ ]),
553
+ data: Object.freeze([
554
+ TEMPLATE_PRIORITY,
555
+ { name: "masteryLevel", ...NUM, describe: "Mastery before any modifier." },
556
+ { name: "parentSkill", ...LINK, describe: "The skill this one specialises." },
557
+ ]),
558
+ }),
559
+
560
+ trauma: Object.freeze({
561
+ subTypes: Object.freeze([
562
+ "injury",
563
+ "fear",
564
+ "morale",
565
+ "pall",
566
+ "psycond",
567
+ "physcond",
568
+ "auralshock",
569
+ "fatigue",
570
+ "infection",
571
+ "shock",
572
+ "coma",
573
+ ]),
574
+ data: Object.freeze([TEMPLATE_PRIORITY]),
575
+ }),
576
+
577
+ weapongear: Object.freeze({
578
+ // No `subTypes`, deliberately: SoHL distinguishes a weapon's uses with
579
+ // strike modes rather than by kind, and HM3's document type follows
580
+ // from which of those a note describes.
581
+ data: Object.freeze([TEMPLATE_PRIORITY, ...GEAR]),
582
+ }),
583
+
584
+ /* ----- core documents ------------------------------------------- */
585
+
586
+ doc: Object.freeze({
587
+ subTypes: Object.freeze(["rules", "user-guide", "reference"]),
588
+ data: Object.freeze([]),
589
+ }),
590
+
591
+ macro: Object.freeze({ data: Object.freeze([]) }),
592
+
593
+ homepage: Object.freeze({ data: Object.freeze([]) }),
594
+
595
+ map: Object.freeze({
596
+ // One type, three subTypes: they differ only in the canvas defaults
597
+ // derived for them, which is precisely what a subType decides (#174).
598
+ subTypes: Object.freeze(["battlemap", "localmap", "regionalmap"]),
599
+ data: Object.freeze([
600
+ // The specification spells this `img`, matching every other
601
+ // note type, while the map compiler reads `image` from
602
+ // `sohl:` today. It says outright that one of the two has
603
+ // to move; the container takes the specification's name,
604
+ // and moving the authored key is the migration's business.
605
+ { name: "img", ...TEXT, describe: "Path to the map art." },
606
+ {
607
+ name: "dimensions",
608
+ ...LIST,
609
+ describe: "`[width, height]` in whole pixels — the art's own size.",
610
+ },
611
+ {
612
+ name: "pxPerGrid",
613
+ ...NUM,
614
+ describe: "Whole pixels per grid square; must match the art.",
615
+ },
616
+ { name: "navName", ...TEXT, describe: "Short name for the navigation bar." },
617
+ { name: "levelName", ...TEXT, describe: "Name of the embedded level." },
618
+ {
619
+ name: "backgroundColor",
620
+ ...TEXT,
621
+ describe: "Colour shown where the art does not reach.",
622
+ },
623
+ { name: "overlay", ...TEXT, describe: "Path to the foreground art." },
624
+ // Geometry carries no `kind`. The specification lists each
625
+ // as a sequence while the notes authoring them today write
626
+ // a map keyed by name, and a lint has no business picking
627
+ // the winner of a disagreement the format has not settled.
628
+ { name: "walls", ...ANY, describe: "Wall segments." },
629
+ { name: "doors", ...ANY, describe: "Doors." },
630
+ { name: "lights", ...ANY, describe: "Light sources." },
631
+ { name: "tiles", ...ANY, describe: "Tiles." },
632
+ { name: "sounds", ...ANY, describe: "Ambient sounds." },
633
+ { name: "regions", ...ANY, describe: "Regions and their behaviours." },
634
+ {
635
+ name: "notes",
636
+ ...ANY,
637
+ describe: "Map pins, each a grid location and an anchor in this note's own body.",
638
+ },
639
+ {
640
+ name: "place",
641
+ ...TEXT,
642
+ describe:
643
+ "The place this map depicts. Named here and not on the place, " +
644
+ "because a place has several maps and a map depicts one place.",
645
+ },
646
+ ]),
647
+ }),
648
+ });
649
+
650
+ /**
651
+ * The `data:` keys a note type may carry.
652
+ *
653
+ * @param {string} type - The note's `type`.
654
+ * @param {Readonly<Record<string, TypeVocabulary>>} [vocabulary] - The registry
655
+ * to read, defaulting to {@link NOTE_VOCABULARY}.
656
+ * @returns {readonly DataFieldSpec[]|undefined} The declaration, or `undefined`
657
+ * when the type declares none — which is not the same as declaring an empty
658
+ * one, and is why the lint makes no claim rather than refusing every key.
659
+ */
660
+ export function dataFields(type, vocabulary = NOTE_VOCABULARY) {
661
+ return vocabulary?.[type]?.data;
662
+ }
663
+
664
+ /**
665
+ * The `subType` values a note type declares.
666
+ *
667
+ * @param {string} type - The note's `type`.
668
+ * @param {Readonly<Record<string, TypeVocabulary>>} [vocabulary] - The registry
669
+ * to read, defaulting to {@link NOTE_VOCABULARY}.
670
+ * @returns {readonly string[]|null|undefined} The closed set; `null` when the
671
+ * type has a `subType` whose values are not yet enumerated; `undefined` when
672
+ * it has no `subType` at all — see {@link TypeVocabulary}.
673
+ */
674
+ export function subTypes(type, vocabulary = NOTE_VOCABULARY) {
675
+ const entry = vocabulary?.[type];
676
+ if (!entry || !Object.hasOwn(entry, "subTypes")) return undefined;
677
+ return entry.subTypes;
678
+ }