@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,64 @@
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
+ * **HM3's half of the infobox** — what this system's summary panel carries.
16
+ *
17
+ * Read straight off {@link HM3_ITEM_FIELDS}, the same list the item builders
18
+ * obey, so a field added to a type reaches the box with no second edit. HM3
19
+ * describes a weapon, a skill and a piece of gear with its own values, which
20
+ * is the whole reason a system box exists per system rather than once: the two
21
+ * systems disagree about impact, heft, reach and draw, so neither box can
22
+ * stand for the other.
23
+ *
24
+ * A note that says nothing about HM3 gets a box reading _Not available_, and a
25
+ * note type HM3 has no concept of — an affiliation, a mystery, an attribute —
26
+ * gets no box at all. Both answers come from the note-type → document-subtype
27
+ * map, so neither is stated here.
28
+ *
29
+ * @module
30
+ */
31
+
32
+ import { GEAR_UNITS, defineInfobox } from "../engine/infobox.mjs";
33
+ import { HM3_ITEM_FIELDS } from "./item-fields.mjs";
34
+
35
+ /** What this system's box is called. @type {string} */
36
+ export const HM3_INFOBOX_TITLE = "HM3";
37
+
38
+ /**
39
+ * HM3's presentation overlay: what one of this system's fields is called where
40
+ * humanising its key gives the wrong word.
41
+ *
42
+ * **Not a second field list.** A field it does not mention still gets a row
43
+ * under its own humanised name, so a field added to {@link HM3_ITEM_FIELDS}
44
+ * reaches the box with no edit here.
45
+ *
46
+ * @type {Readonly<Record<string, {label?: string, withheld?: string}>>}
47
+ */
48
+ export const HM3_FIELD_PRESENTATION = Object.freeze({
49
+ ...GEAR_UNITS,
50
+ capacity: Object.freeze({ label: "Capacity", unit: " lbs" }),
51
+ masteryLevel: Object.freeze({ label: "Mastery" }),
52
+ });
53
+
54
+ /**
55
+ * HM3's infobox declaration.
56
+ *
57
+ * @type {object}
58
+ */
59
+ export const HM3_INFOBOX = defineInfobox({
60
+ system: "hm3",
61
+ title: HM3_INFOBOX_TITLE,
62
+ fields: HM3_ITEM_FIELDS,
63
+ presentation: HM3_FIELD_PRESENTATION,
64
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "20.6.0",
3
+ "version": "21.0.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -154,6 +154,7 @@
154
154
  "devDependencies": {
155
155
  "@changesets/cli": "^3.0.0",
156
156
  "@types/node": "^26.2.0",
157
+ "npm-run-all": "^4.1.5",
157
158
  "vitest": "^5.0.0"
158
159
  },
159
160
  "scripts": {
@@ -163,7 +164,7 @@
163
164
  "build:types": "tsc -p tsconfig.dts.json",
164
165
  "format": "prettier --write .",
165
166
  "format:check": "prettier --check .",
166
- "lint": "npm run format:check && npm run lint:markdown && npm run lint:yaml && npm run lint:labels && npm run lint:content-format",
167
+ "lint": "run-p --aggregate-output -c --max-parallel 4 format:check lint:markdown lint:yaml lint:labels lint:content-format",
167
168
  "lint:markdown": "node bin/content-build.mjs markdown",
168
169
  "lint:yaml": "node bin/package-build.mjs yaml",
169
170
  "lint:labels": "node bin/package-build.mjs labels check",
@@ -108,11 +108,17 @@ const nonEmpty = (v) => Array.isArray(v) && v.length > 0;
108
108
  * @param {object|null|undefined} sohl - The note's `sohl` frontmatter block.
109
109
  * @param {Map<string, {name?: string, url?: string}>} index - Content index,
110
110
  * `"<type>:<shortcode>"` → the item's page.
111
- * @returns {object|null|undefined} The block with its info-block fields filled
112
- * in, or the input unchanged when there is nothing to derive from.
111
+ * @returns {object|null} The block with its info-block fields filled in, or the
112
+ * input unchanged when there is nothing to derive from — with an absent block
113
+ * reported as `null`, the value an empty one already carries.
113
114
  */
114
115
  export function deriveBeingInfo(sohl, index) {
115
- if (!isMap(sohl)) return sohl;
116
+ // A note declaring no `sohl:` key at all arrives as `undefined`, and the
117
+ // site emitter assigns this result straight into a page's front matter.
118
+ // js-yaml refuses to dump a property whose value is `undefined`, and the
119
+ // throw aborts the whole build rather than the one page — so "no block"
120
+ // is answered with the same `null` an empty block gets.
121
+ if (!isMap(sohl)) return sohl ?? null;
116
122
  const out = { ...sohl };
117
123
  const items = Array.isArray(out.items) ? out.items : [];
118
124
  if (items.length === 0) return out;
@@ -18,7 +18,7 @@
18
18
  *
19
19
  * Foundry's `Item.DEFAULT_ICON` is the white `icons/svg/item-bag.svg`, which is
20
20
  * invisible on the light Manuscript sheet and does not adapt to theme; every
21
- * SoHL item type instead defaults to a themed `systems/sohl/assets/icons/**`
21
+ * SoHL item type instead defaults to a themed `sohl/assets/icons/**`
22
22
  * SVG here (dark ink in light mode, cream in dark, via the build-time
23
23
  * adaptive-fill injection in `utils/svg-theme.mjs`).
24
24
  *
@@ -38,8 +38,10 @@
38
38
  * Injecting the map through configuration instead would re-open that drift,
39
39
  * because the two sides would once more be free to disagree.
40
40
  *
41
- * Paths are fully resolved (`systems/sohl/...`) the served path both layers
42
- * need. If a new item type is added, add its default here.
41
+ * **Paths name the package that owns the file**, as every pathname does: these
42
+ * icons are the `sohl` package's, so they are written `sohl/assets/…` and each
43
+ * surface derives its own address from that. If a new item type is added, add
44
+ * its default here.
43
45
  *
44
46
  * **The keys are SoHL *document* subtypes, not markdown note types.** The
45
47
  * runtime reads this with `itemData.type`, which is a Foundry Item subtype, and
@@ -51,19 +53,19 @@
51
53
  * build translates once and no second copy of those three rows exists.
52
54
  */
53
55
  export const DEFAULT_ITEM_ART = {
54
- affiliation: "systems/sohl/assets/icons/noun/shield.svg",
55
- affliction: "systems/sohl/assets/icons/other/sick.svg",
56
- armorgear: "systems/sohl/assets/icons/game-icons/lorc/breastplate.svg",
57
- attribute: "systems/sohl/assets/icons/other/charm.svg",
58
- concoctiongear: "systems/sohl/assets/icons/game-icons/badges/flask.svg",
59
- containergear: "systems/sohl/assets/icons/other/sack.svg",
60
- miscgear: "systems/sohl/assets/icons/other/question-mark.svg",
61
- mystery: "systems/sohl/assets/icons/other/sparkles.svg",
62
- mysticalability: "systems/sohl/assets/icons/other/hand-sparkles.svg",
63
- projectilegear: "systems/sohl/assets/icons/noun/arrow.svg",
64
- skill: "systems/sohl/assets/icons/other/head-gear.svg",
65
- trauma: "systems/sohl/assets/icons/other/injury.svg",
66
- weapongear: "systems/sohl/assets/icons/other/sword.svg",
56
+ affiliation: "sohl/assets/icons/noun/shield.svg",
57
+ affliction: "sohl/assets/icons/other/sick.svg",
58
+ armorgear: "sohl/assets/icons/game-icons/lorc/breastplate.svg",
59
+ attribute: "sohl/assets/icons/other/charm.svg",
60
+ concoctiongear: "sohl/assets/icons/game-icons/badges/flask.svg",
61
+ containergear: "sohl/assets/icons/other/sack.svg",
62
+ miscgear: "sohl/assets/icons/other/question-mark.svg",
63
+ mystery: "sohl/assets/icons/other/sparkles.svg",
64
+ mysticalability: "sohl/assets/icons/other/hand-sparkles.svg",
65
+ projectilegear: "sohl/assets/icons/noun/arrow.svg",
66
+ skill: "sohl/assets/icons/other/head-gear.svg",
67
+ trauma: "sohl/assets/icons/other/injury.svg",
68
+ weapongear: "sohl/assets/icons/other/sword.svg",
67
69
  };
68
70
 
69
71
  /**
package/sohl/index.mjs CHANGED
@@ -45,6 +45,9 @@ export * as actors from "./actors.mjs";
45
45
  /** This package's own knowledgebase body passes, named from `site.pass`. */
46
46
  export * as kbPasses from "./kb-passes.mjs";
47
47
 
48
+ /** Which of SoHL's facts a note's summary panel carries, and how they group. */
49
+ export * as infobox from "./infobox.mjs";
50
+
48
51
  // Flat as well as namespaced: the Foundry runtime imports these by name through
49
52
  // their own entry points, and they were this barrel's surface before the
50
53
  // compilers arrived.
@@ -0,0 +1,499 @@
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
+ * **SoHL's half of the infobox** — which of this system's facts a summary
16
+ * panel carries, and how they group.
17
+ *
18
+ * The mechanism is `engine/infobox.mjs`; this is the declaration, which is the
19
+ * `engine/` ÷ `sohl/` line everywhere else in the package. What a box looks
20
+ * like is note-format knowledge. That an attribute score lives at
21
+ * `system.scoreBase`, that a mystical ability is a practice or a knack, and
22
+ * that a weapon strikes in modes is SoHL's.
23
+ *
24
+ * **Almost every type is read straight off its own field declaration.**
25
+ * {@link NOTE_SCHEMAS} is the vocabulary the frontmatter linter checks against
26
+ * and, for an item type, the very list the compiler obeys — so a field added
27
+ * to a type reaches the box with no second edit anywhere.
28
+ *
29
+ * **Four types earn a builder of their own**, because their box is derived
30
+ * rather than read field by field:
31
+ *
32
+ * - a **being**, whose attributes, skills, mystical abilities and carried gear
33
+ * are one flat `sohl.items` list that has to be sorted before it can be
34
+ * shown;
35
+ * - **armour**, whose protection is shown as a full set of four aspects, with
36
+ * an unstated one rendered `0` rather than dropped — armour that stops
37
+ * nothing edged is a fact, not a gap;
38
+ * - a **weapon**, whose strike modes are shown one per line, with an unstated
39
+ * value rendered `—` for the same reason;
40
+ * - a **projectile**, whose impact is three declared fields composing into the
41
+ * one quantity a reader wants.
42
+ *
43
+ * Both placeholders are **data**, decided here, not a renderer's fallback.
44
+ * That is what lets one generic renderer draw them without knowing which field
45
+ * it is looking at.
46
+ *
47
+ * **What each field is called, and the few that carry no row**, is
48
+ * {@link SOHL_FIELD_PRESENTATION} — an overlay on the declaration rather than
49
+ * a list beside it.
50
+ *
51
+ * @module
52
+ */
53
+
54
+ import {
55
+ DURATION_LABELS,
56
+ GEAR_UNITS,
57
+ defineInfobox,
58
+ hasValue,
59
+ humanizeFieldName,
60
+ humanizeValue,
61
+ systemRowsSection,
62
+ } from "../engine/infobox.mjs";
63
+ import { currentType } from "../engine/ids.mjs";
64
+ import { subTypes } from "../engine/note-vocabulary.mjs";
65
+ import { systemBlock, systemData } from "../engine/system-block.mjs";
66
+ import { NOTE_SCHEMAS } from "./note-schemas.mjs";
67
+ import { GEAR_TYPE_TO_KEY } from "./being-info.mjs";
68
+
69
+ /** What this system's box is called. @type {string} */
70
+ export const SOHL_INFOBOX_TITLE = "SoHL";
71
+
72
+ /**
73
+ * What a value nobody stated is shown as, where showing nothing would be the
74
+ * wrong answer.
75
+ *
76
+ * Rule 4 drops an absent field, and that is right almost everywhere. It is
77
+ * wrong in a table of strike modes, where a column left blank on one row and
78
+ * filled on the next reads as a rendering fault rather than as a weapon that
79
+ * cannot be used that way.
80
+ *
81
+ * @type {string}
82
+ */
83
+ export const UNSTATED = "—";
84
+
85
+ /** The prefix every protection aspect's declared name carries. @type {string} */
86
+ const PROTECTION_PREFIX = "protection.";
87
+
88
+ /**
89
+ * The declarations of the four aspects armour is rated against, in the order a
90
+ * sheet shows them.
91
+ *
92
+ * Taken from the armour field declaration rather than listed: the declaration
93
+ * names `protection.blunt`, `protection.edged` and the rest, so the set, its
94
+ * order **and where each is authored** come from the same place the compiler
95
+ * reads them. Carrying the whole declaration rather than the aspect's word is
96
+ * what lets the grid resolve a value the way the compiler does — a note writes
97
+ * `sohl.system.protectionBase.blunt`, and a grid that read the declared
98
+ * *source* path instead would find nothing and call every aspect unstated.
99
+ *
100
+ * @type {readonly object[]}
101
+ */
102
+ export const PROTECTION_FIELDS = Object.freeze(
103
+ (NOTE_SCHEMAS.armorgear ?? []).filter(
104
+ (field) => typeof field.name === "string" && field.name.startsWith(PROTECTION_PREFIX),
105
+ ),
106
+ );
107
+
108
+ /** A skill family whose humanised name reads wrong. */
109
+ const SKILL_GROUP_LABELS = Object.freeze({ combattechnique: "Combat Techniques" });
110
+
111
+ /**
112
+ * SoHL's presentation overlay: what one of this system's fields is called, and
113
+ * the few that carry no row.
114
+ *
115
+ * **Not a second field list.** The fields come from {@link NOTE_SCHEMAS}, and
116
+ * a field this overlay does not mention still gets a row under its own
117
+ * humanised name — so a field added to a type reaches the box with no edit
118
+ * here. What the overlay adds is the two things a compiler's field list cannot
119
+ * say, because they are about a page rather than about a document:
120
+ *
121
+ * - **a reader's word** where the declaration's key is the compiler's. A key
122
+ * is named for the value it carries into a DataModel; `assocSkillCode` and
123
+ * `perceptionPenaltyBase` are exactly right there and wrong in a panel
124
+ * somebody reads.
125
+ * - **which facts belong on a page at all.** A value shown whole somewhere
126
+ * else in the same box — protection, strike modes, a projectile's impact —
127
+ * would otherwise be said twice, the second time a row at a time and worse;
128
+ * and a flag that steers a character sheet is not a fact about the subject.
129
+ *
130
+ * @type {Readonly<Record<string, {label?: string, withheld?: string}>>}
131
+ */
132
+ export const SOHL_FIELD_PRESENTATION = Object.freeze({
133
+ ...DURATION_LABELS,
134
+ ...GEAR_UNITS,
135
+
136
+ "protection.blunt": Object.freeze({ withheld: "shown whole, in the Protection grid" }),
137
+ "protection.edged": Object.freeze({ withheld: "shown whole, in the Protection grid" }),
138
+ "protection.piercing": Object.freeze({ withheld: "shown whole, in the Protection grid" }),
139
+ "protection.fire": Object.freeze({ withheld: "shown whole, in the Protection grid" }),
140
+ strikeModes: Object.freeze({ withheld: "shown one per line, in the Strike Modes section" }),
141
+ "impact.die": Object.freeze({ withheld: "shown whole, as the Impact row" }),
142
+ "impact.modifier": Object.freeze({ withheld: "shown whole, as the Impact row" }),
143
+ "impact.aspect": Object.freeze({ withheld: "shown whole, as the Impact row" }),
144
+ improveFlag: Object.freeze({
145
+ withheld: "character-sheet machinery — whether the item is flagged for improvement",
146
+ }),
147
+ facing: Object.freeze({
148
+ withheld: "a body-location layout, which has no summary shape",
149
+ }),
150
+
151
+ subType: Object.freeze({ label: "Subtype" }),
152
+ flexloc: Object.freeze({ label: "Flexible locations" }),
153
+ rigidloc: Object.freeze({ label: "Rigid locations" }),
154
+ perceptionPenaltyBase: Object.freeze({ label: "Perception penalty" }),
155
+ detailMaterial: Object.freeze({ label: "Material detail" }),
156
+ maxCapacity: Object.freeze({ label: "Max capacity", unit: " lbs" }),
157
+ scoreBase: Object.freeze({ label: "Score" }),
158
+ masteryLevelBase: Object.freeze({ label: "Mastery" }),
159
+ levelBase: Object.freeze({ label: "Level" }),
160
+ healingRateBase: Object.freeze({ label: "Healing rate" }),
161
+ skillBaseFormula: Object.freeze({ label: "Skill base" }),
162
+ initSkillMult: Object.freeze({ label: "Init multiplier" }),
163
+ initDiceFormula: Object.freeze({ label: "Initiative dice" }),
164
+ valueDesc: Object.freeze({ label: "Scale" }),
165
+ parentSkillCode: Object.freeze({ label: "Specialises" }),
166
+ assocSkillCode: Object.freeze({ label: "Associated skill" }),
167
+ assocAffiliationCode: Object.freeze({ label: "Associated affiliation" }),
168
+ bodyLocationCode: Object.freeze({ label: "Body location" }),
169
+ impairedByRoles: Object.freeze({ label: "Impaired when" }),
170
+ });
171
+
172
+ /** Whether a value is a plain mapping. */
173
+ function isMapping(value) {
174
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
175
+ }
176
+
177
+ /**
178
+ * What one entry of `sohl.items` names.
179
+ *
180
+ * An entry addresses its item three ways and every tree uses all three: a
181
+ * `model` address whose last two hyphen-separated segments are always
182
+ * `<type>-<shortcode>`, explicit `type` / `shortcode` keys, or a locally
183
+ * authored item stating its type with the shortcode inside its own `system`
184
+ * block. One decode covers all of them, so nothing downstream has to know
185
+ * which form a note happened to use.
186
+ *
187
+ * @param {object} entry - One `sohl.items` entry.
188
+ * @returns {{type: string, shortcode: string, name: string, system: object}|undefined}
189
+ * What it names, or `undefined` when it names nothing addressable.
190
+ */
191
+ export function decodeItem(entry) {
192
+ if (!isMapping(entry)) return undefined;
193
+ let type = typeof entry.type === "string" ? entry.type : "";
194
+ let shortcode = typeof entry.shortcode === "string" ? entry.shortcode : "";
195
+ if (!type && typeof entry.model === "string") {
196
+ const segments = entry.model.split("-");
197
+ if (segments.length >= 2) {
198
+ type = segments[segments.length - 2];
199
+ if (!shortcode) shortcode = segments[segments.length - 1];
200
+ }
201
+ }
202
+ const system = isMapping(entry.system) ? entry.system : {};
203
+ if (!shortcode && typeof system.shortcode === "string") shortcode = system.shortcode;
204
+ if (!type) return undefined;
205
+ return {
206
+ type: currentType(type),
207
+ shortcode,
208
+ name: typeof entry.name === "string" ? entry.name : "",
209
+ system,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Every decoded item a being embeds, in the order the note wrote them.
215
+ *
216
+ * @param {object} fm - The note's frontmatter.
217
+ * @param {string} block - The system's block key.
218
+ * @returns {object[]} The items.
219
+ */
220
+ function itemsOf(fm, block) {
221
+ const declared = systemBlock(fm, block)?.items;
222
+ return (Array.isArray(declared) ? declared : []).map(decodeItem).filter(Boolean);
223
+ }
224
+
225
+ /**
226
+ * One item as a value a renderer can draw: its name, and a link where the
227
+ * index reached it.
228
+ *
229
+ * @param {object} item - A decoded item.
230
+ * @param {(ref: unknown, hint?: object) => object|undefined} resolve - The
231
+ * medium's resolver.
232
+ * @returns {{text: string, url?: string, uuid?: string, address?: string}} The value.
233
+ */
234
+ function itemValue(item, resolve) {
235
+ const found = item.shortcode ? resolve?.(item.shortcode, { type: item.type }) : undefined;
236
+ const value = { text: item.name || found?.name || humanizeValue(item.shortcode) };
237
+ if (found?.url) value.url = found.url;
238
+ if (found?.uuid) value.uuid = found.uuid;
239
+ if (found?.address) value.address = found.address;
240
+ return value;
241
+ }
242
+
243
+ /**
244
+ * A being's box: attributes, skills, mystical abilities and equipment.
245
+ *
246
+ * Each is a section of its own, because a section is the unit that flows — the
247
+ * panel breaks between `ATTRIBUTES` and `SKILLS` rather than through either.
248
+ * A section with nothing in it is not emitted at all, which is what makes a
249
+ * sparse creature's box short rather than mostly empty.
250
+ *
251
+ * @param {object} fm - The note's frontmatter.
252
+ * @param {object} ctx - `{ block, resolve }`.
253
+ * @returns {object[]} The sections.
254
+ */
255
+ export function beingSections(fm, { block, resolve }) {
256
+ const items = itemsOf(fm, block);
257
+ const sections = [];
258
+
259
+ const cells = [];
260
+ for (const item of items) {
261
+ if (item.type !== "attribute") continue;
262
+ const score = item.system.scoreBase;
263
+ if (!hasValue(score)) continue;
264
+ cells.push({ label: String(item.shortcode).toUpperCase(), value: score });
265
+ }
266
+ if (cells.length) {
267
+ sections.push({ id: "attributes", label: "Attributes", layout: "grid", cells });
268
+ }
269
+
270
+ // Skill families come from the note vocabulary's own `subType` set, in its
271
+ // declared order — a family added to the format groups here with no second
272
+ // list to edit. A skill the index cannot place falls to the end under its
273
+ // own heading rather than vanishing from the panel.
274
+ const families = new Map((subTypes("skill") ?? []).map((value) => [value, []]));
275
+ const unplaced = [];
276
+ for (const item of items) {
277
+ if (item.type !== "skill") continue;
278
+ const mastery = item.system.masteryLevelBase;
279
+ if (!hasValue(mastery)) continue;
280
+ const found = item.shortcode ? resolve?.(item.shortcode, { type: "skill" }) : undefined;
281
+ const family = item.system.subType ?? found?.subType;
282
+ const value = itemValue(item, resolve);
283
+ const entry = { ...value, text: `${value.text} ${mastery}` };
284
+ if (family && families.has(family)) families.get(family).push(entry);
285
+ else unplaced.push(entry);
286
+ }
287
+ const groups = [];
288
+ for (const [family, entries] of families) {
289
+ if (!entries.length) continue;
290
+ groups.push({
291
+ label: SKILL_GROUP_LABELS[family] ?? humanizeFieldName(family),
292
+ entries,
293
+ });
294
+ }
295
+ if (unplaced.length) groups.push({ label: "Other", entries: unplaced });
296
+ if (groups.length) sections.push({ id: "skills", label: "Skills", layout: "runin", groups });
297
+
298
+ const mystical = [];
299
+ for (const item of items) {
300
+ if (item.type !== "mysticalability") continue;
301
+ mystical.push(itemValue(item, resolve));
302
+ }
303
+ if (mystical.length) {
304
+ sections.push({
305
+ id: "mysticalabilities",
306
+ label: "Mystical Abilities",
307
+ layout: "list",
308
+ entries: mystical,
309
+ });
310
+ }
311
+
312
+ const gear = new Map(Object.values(GEAR_TYPE_TO_KEY).map((key) => [key, []]));
313
+ for (const item of items) {
314
+ const key = GEAR_TYPE_TO_KEY[item.type];
315
+ if (!key) continue;
316
+ gear.get(key).push(itemValue(item, resolve));
317
+ }
318
+ const gearGroups = [];
319
+ for (const [key, entries] of gear) {
320
+ if (entries.length) gearGroups.push({ label: humanizeFieldName(key), entries });
321
+ }
322
+ if (gearGroups.length) {
323
+ sections.push({ id: "equipment", label: "Equipment", layout: "runin", groups: gearGroups });
324
+ }
325
+
326
+ return sections;
327
+ }
328
+
329
+ /**
330
+ * Armour's box: whatever the note states, then the protection it gives.
331
+ *
332
+ * Protection is shown whole, with an aspect nobody stated rendered `0`. Armour
333
+ * that stops nothing edged is a fact about the armour, and dropping the row
334
+ * would leave a reader to guess whether it was unstated or nil.
335
+ *
336
+ * @param {object} fm - The note's frontmatter.
337
+ * @param {object} ctx - The section context.
338
+ * @returns {object[]} The sections.
339
+ */
340
+ export function armorSections(fm, ctx) {
341
+ const sections = genericSections(fm, "armorgear", ctx);
342
+ const cells = PROTECTION_FIELDS.map((field) => {
343
+ const { value } = ctx.resolveField(field, fm, { block: ctx.block });
344
+ return {
345
+ label: humanizeFieldName(field.name),
346
+ value: hasValue(value) ? value : 0,
347
+ };
348
+ });
349
+ if (cells.length) {
350
+ sections.push({ id: "protection", label: "Protection", layout: "grid", cells });
351
+ }
352
+ return sections;
353
+ }
354
+
355
+ /**
356
+ * A weapon's box: whatever the note states, then one line per strike mode.
357
+ *
358
+ * A mode that states no attack modifier, no impact or no length is shown with
359
+ * {@link UNSTATED} in that place rather than with the clause missing: the
360
+ * modes are read against each other, and a line that is shorter than its
361
+ * neighbour for no visible reason reads as a fault.
362
+ *
363
+ * @param {object} fm - The note's frontmatter.
364
+ * @param {object} ctx - The section context.
365
+ * @returns {object[]} The sections.
366
+ */
367
+ export function weaponSections(fm, ctx) {
368
+ const sections = genericSections(fm, "weapongear", ctx);
369
+ const groups = [];
370
+ for (const [key, mode] of strikeModes(systemData(fm, ctx.block).strikeModes)) {
371
+ groups.push({
372
+ label: mode.name || humanizeValue(key),
373
+ entries: [
374
+ { text: `Attack ${signed(mode.attack?.modifier)}` },
375
+ { text: `Impact ${impactOf(mode.impactBase)}` },
376
+ { text: `Length ${hasValue(mode.lengthBase) ? mode.lengthBase : UNSTATED}` },
377
+ ],
378
+ });
379
+ }
380
+ if (groups.length) {
381
+ sections.push({ id: "strikemodes", label: "Strike Modes", layout: "runin", groups });
382
+ }
383
+ return sections;
384
+ }
385
+
386
+ /**
387
+ * A projectile's box: whatever the note states, then what it hits for.
388
+ *
389
+ * Impact is three declared fields — dice, modifier and aspect — and a reader
390
+ * wants the one quantity they compose into. Three rows reading `Die 6`,
391
+ * `Modifier 2`, `Aspect Piercing` say the declaration's structure rather than
392
+ * the projectile's, so the three are withheld and the row they make is added
393
+ * in their place.
394
+ *
395
+ * @param {object} fm - The note's frontmatter.
396
+ * @param {object} ctx - The section context.
397
+ * @returns {object[]} The sections.
398
+ */
399
+ export function projectileSections(fm, ctx) {
400
+ const sections = genericSections(fm, "projectilegear", ctx);
401
+ const impact = impactOf(systemData(fm, ctx.block).impactBase);
402
+ if (impact !== UNSTATED) {
403
+ const rows = sections[0]?.rows;
404
+ const row = { label: "Impact", kind: "text", value: impact };
405
+ if (rows) rows.push(row);
406
+ else sections.push({ id: "profile", layout: "rows", rows: [row] });
407
+ }
408
+ return sections;
409
+ }
410
+
411
+ /**
412
+ * A weapon's strike modes, whichever of the two shapes the note wrote.
413
+ *
414
+ * Both are live in the corpus and both name the same thing. A **list** carries
415
+ * the mode's identity inside it, as `shortcode`, which is the shape a
416
+ * compendium document holds; a **mapping** carries it as the key. So the
417
+ * fallback name comes from the shortcode in one and from the key in the other,
418
+ * and everything downstream sees one shape.
419
+ *
420
+ * @param {unknown} declared - What the note wrote at `strikeModes`.
421
+ * @returns {[string, object][]} Mode name → the mode.
422
+ */
423
+ export function strikeModes(declared) {
424
+ if (Array.isArray(declared)) {
425
+ return declared
426
+ .filter(isMapping)
427
+ .map((mode, at) => [String(mode.shortcode ?? at + 1), mode]);
428
+ }
429
+ if (isMapping(declared)) return Object.entries(declared).filter(([, mode]) => isMapping(mode));
430
+ return [];
431
+ }
432
+
433
+ /** A modifier with its sign, or {@link UNSTATED}. */
434
+ function signed(value) {
435
+ if (!hasValue(value) && value !== 0) return UNSTATED;
436
+ return Number(value) >= 0 ? `+${value}` : String(value);
437
+ }
438
+
439
+ /**
440
+ * An impact as dice and aspect, or {@link UNSTATED}.
441
+ *
442
+ * **A die of `0` is no die**, which is how the schema says a strike mode rolls
443
+ * nothing — a net envelops and does no damage. Read as a number it would print
444
+ * `1d0`, a roll nobody can make, so it composes to a flat modifier or to
445
+ * nothing at all.
446
+ *
447
+ * **An aspect alone is not an impact.** Without a die or a modifier there is no
448
+ * magnitude, and a line reading `Impact blunt` states the kind of a quantity
449
+ * that was never given. That is {@link UNSTATED}'s whole job.
450
+ *
451
+ * @param {unknown} impact - The authored `impactBase`.
452
+ * @returns {string} The impact, or {@link UNSTATED}.
453
+ */
454
+ function impactOf(impact) {
455
+ if (!isMapping(impact)) return UNSTATED;
456
+ const die = Number(impact.die ?? 0);
457
+ const modifier = Number(impact.modifier ?? 0);
458
+ const dice = die > 0 ? `${impact.numDice ?? 1}d${die}` : "";
459
+ const roll =
460
+ dice ? `${dice}${modifier ? signed(modifier) : ""}`
461
+ : modifier ? signed(modifier)
462
+ : "";
463
+ if (!roll) return UNSTATED;
464
+ const aspect = hasValue(impact.aspect) ? humanizeValue(impact.aspect) : "";
465
+ return [roll, aspect].filter(Boolean).join(" ");
466
+ }
467
+
468
+ /**
469
+ * The rows a type's own field declaration yields, as a section.
470
+ *
471
+ * Shared by the three builders above so each adds to what its type states
472
+ * rather than replacing it.
473
+ *
474
+ * @param {object} fm - The note's frontmatter.
475
+ * @param {string} type - The note type.
476
+ * @param {object} ctx - The section context.
477
+ * @returns {object[]} Zero or one section.
478
+ */
479
+ function genericSections(fm, type, ctx) {
480
+ return systemRowsSection(fm, NOTE_SCHEMAS[type], ctx);
481
+ }
482
+
483
+ /**
484
+ * SoHL's infobox declaration.
485
+ *
486
+ * @type {object}
487
+ */
488
+ export const SOHL_INFOBOX = defineInfobox({
489
+ system: "sohl",
490
+ title: SOHL_INFOBOX_TITLE,
491
+ fields: NOTE_SCHEMAS,
492
+ presentation: SOHL_FIELD_PRESENTATION,
493
+ sections: {
494
+ being: beingSections,
495
+ armorgear: armorSections,
496
+ weapongear: weaponSections,
497
+ projectilegear: projectileSections,
498
+ },
499
+ });