@heroiclands/package-build 17.1.0 → 18.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 (153) hide show
  1. package/CHANGELOG.md +1773 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -58
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +394 -111
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +137 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +110 -27
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/labels.mjs +191 -0
  74. package/manifest.mjs +43 -2
  75. package/package.json +19 -4
  76. package/release.mjs +62 -7
  77. package/sohl/actors.mjs +33 -487
  78. package/sohl/being-info.mjs +16 -7
  79. package/sohl/default-item-art.mjs +14 -3
  80. package/sohl/document-subtypes.mjs +16 -10
  81. package/sohl/item-builders.mjs +14 -5
  82. package/sohl/item-fields.mjs +68 -7
  83. package/sohl/items.mjs +44 -258
  84. package/sohl/note-schemas.mjs +2 -2
  85. package/types/content-config.d.mts +21 -42
  86. package/types/engine/actor-compiler.d.mts +204 -0
  87. package/types/engine/address-charset.d.mts +11 -8
  88. package/types/engine/address-diff.d.mts +53 -5
  89. package/types/engine/anchored-sections.d.mts +21 -0
  90. package/types/engine/anchors.d.mts +20 -0
  91. package/types/engine/base-compiler.d.mts +17 -17
  92. package/types/engine/bundle-notes.d.mts +173 -0
  93. package/types/engine/bundles.d.mts +60 -0
  94. package/types/engine/code-fences.d.mts +43 -0
  95. package/types/engine/compile-corpus.d.mts +32 -0
  96. package/types/engine/content-address.d.mts +205 -5
  97. package/types/engine/content-format-check.d.mts +6 -2
  98. package/types/engine/content-format.d.mts +57 -1
  99. package/types/engine/content-index.d.mts +64 -80
  100. package/types/engine/content-links.d.mts +52 -7
  101. package/types/engine/content-lint.d.mts +10 -1
  102. package/types/engine/content-package.d.mts +2 -1
  103. package/types/engine/content-tables.d.mts +21 -39
  104. package/types/engine/document-subtypes.d.mts +37 -3
  105. package/types/engine/field-spec.d.mts +76 -5
  106. package/types/engine/folder-notes.d.mts +159 -0
  107. package/types/engine/foreign-catalog.d.mts +53 -0
  108. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
  109. package/types/engine/frontmatter-lint.d.mts +10 -2
  110. package/types/engine/frontmatter.d.mts +64 -0
  111. package/types/engine/generate.d.mts +38 -0
  112. package/types/engine/helpers.d.mts +94 -30
  113. package/types/engine/ids.d.mts +96 -0
  114. package/types/engine/index-records.d.mts +68 -0
  115. package/types/engine/index.d.mts +9 -3
  116. package/types/engine/item-compiler.d.mts +131 -0
  117. package/types/engine/journals.d.mts +47 -9
  118. package/types/engine/metadata-index.d.mts +226 -0
  119. package/types/engine/note-claims.d.mts +57 -11
  120. package/types/engine/note-ids.d.mts +38 -0
  121. package/types/engine/note-renames.d.mts +102 -0
  122. package/types/engine/note-vocabulary.d.mts +44 -8
  123. package/types/engine/retired-fields.d.mts +75 -0
  124. package/types/engine/scenes.d.mts +3 -2
  125. package/types/engine/schema-check.d.mts +25 -4
  126. package/types/engine/site-build.d.mts +4 -4
  127. package/types/engine/site-index.d.mts +1 -1
  128. package/types/engine/sql-tables.d.mts +185 -0
  129. package/types/engine/subtype-registry.d.mts +49 -0
  130. package/types/engine/system-block.d.mts +40 -1
  131. package/types/engine/systems.d.mts +106 -0
  132. package/types/engine/web-wikilinks.d.mts +4 -2
  133. package/types/engine/wikilink-syntax.d.mts +10 -3
  134. package/types/engine/wikilinks.d.mts +41 -13
  135. package/types/engine/yaml-lint.d.mts +107 -0
  136. package/types/hm3/actors.d.mts +48 -0
  137. package/types/hm3/default-item-art.d.mts +42 -0
  138. package/types/hm3/document-subtypes.d.mts +24 -0
  139. package/types/hm3/index.d.mts +7 -0
  140. package/types/hm3/item-builders.d.mts +11 -0
  141. package/types/hm3/item-fields.d.mts +12 -0
  142. package/types/hm3/items.d.mts +23 -0
  143. package/types/hm3/template-priority.d.mts +21 -0
  144. package/types/manifest.d.mts +22 -1
  145. package/types/release.d.mts +9 -4
  146. package/types/sohl/actors.d.mts +7 -74
  147. package/types/sohl/being-info.d.mts +8 -27
  148. package/types/sohl/default-item-art.d.mts +5 -3
  149. package/types/sohl/items.d.mts +17 -32
  150. package/engine/foreign-manifests.mjs +0 -126
  151. package/engine/kb-manifest.mjs +0 -490
  152. package/types/engine/foreign-manifests.d.mts +0 -43
  153. package/types/engine/kb-manifest.d.mts +0 -241
@@ -0,0 +1,84 @@
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 item-type registry** — every content type that compiles into an HM3
16
+ * Foundry Item, keyed to the builder that produces its `system` block.
17
+ *
18
+ * The same arrangement as `sohl/item-builders.mjs`, and a repository feeding
19
+ * both systems names both: `itemBuilders: [sohl, hm3]`. The engine keeps them
20
+ * apart from there on — `itemBuilder(type, system)` takes the system that is
21
+ * asking, and a type both registries declare **throws** rather than resolving
22
+ * when nobody says which (#58). That guard existed before there was a second
23
+ * registry to trip it; this is the registry it was written for.
24
+ *
25
+ * **The builders are generated, not written**, from the field declarations in
26
+ * `item-fields.mjs`, compiled against the `hm3` block. `buildFromFields` takes
27
+ * the block as an argument precisely so one declaration mechanism serves any
28
+ * number of systems: `data.weight` reaches `system.weightBase` under `sohl:`
29
+ * and `system.weight` under `hm3:`, from two declarations and one engine.
30
+ *
31
+ * **No finalizers.** SoHL has one — a combat technique's strike mode, whose
32
+ * *presence* depends on another field's value — and HM3 has no such
33
+ * conditional. The absence is worth stating: a finalizer is opaque to every
34
+ * reader a declaration is legible to, so having none is the state to keep.
35
+ *
36
+ * **A leaf.** It imports the declaration primitives, the field table and the
37
+ * art map, and never the resolved configuration — the configuration file
38
+ * imports *this*, so a read back out would close a cycle around the
39
+ * configuration's own evaluation.
40
+ *
41
+ * @module
42
+ */
43
+
44
+ import { buildFromFields } from "../engine/field-spec.mjs";
45
+ import { hm3DefaultItemArt } from "./default-item-art.mjs";
46
+ import { HM3_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
47
+ import { HM3_ITEM_FIELDS } from "./item-fields.mjs";
48
+
49
+ /**
50
+ * The frontmatter block these builders read.
51
+ *
52
+ * Taken from the map rather than spelled here, so the block name, the subtype
53
+ * map and the registry are one statement.
54
+ *
55
+ * @type {string}
56
+ */
57
+ const BLOCK = HM3_DOCUMENT_SUBTYPES.block;
58
+
59
+ /**
60
+ * Build one registry entry: the declared fields, the builder they generate, and
61
+ * the default art for the type.
62
+ *
63
+ * @param {string} type - The note type, and the art map's key.
64
+ * @returns {Readonly<{system: (fm: object) => object, img: string, fields: readonly object[]}>}
65
+ * The registry entry.
66
+ */
67
+ function entryFor(type) {
68
+ const fields = HM3_ITEM_FIELDS[type];
69
+ return Object.freeze({
70
+ system: buildFromFields(fields, { block: BLOCK }),
71
+ img: hm3DefaultItemArt(type),
72
+ fields,
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Every HM3 item type, paired with the builder for its `system` block, the
78
+ * default art for the type, and the frontmatter fields it declares.
79
+ *
80
+ * @type {Readonly<Record<string, Readonly<{system: (fm: object) => object, img: string, fields: readonly object[]}>>>}
81
+ */
82
+ export const HM3_ITEM_BUILDERS = Object.freeze(
83
+ Object.fromEntries(Object.keys(HM3_ITEM_FIELDS).map((type) => [type, entryFor(type)])),
84
+ );
@@ -0,0 +1,180 @@
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 `hm3:` frontmatter vocabulary of every HM3 item type.
16
+ *
17
+ * The same arrangement `sohl/item-fields.mjs` describes: the declaration **is**
18
+ * the builder — {@link buildFromFields} turns each list into the function that
19
+ * runs — so a field that is not here is not emitted, and there is no second
20
+ * statement of the mapping to drift.
21
+ *
22
+ * **Deliberately shorter than SoHL's, and that is the honest answer.** The
23
+ * mapping tables in `docs/content-format.md` are the specification for what a
24
+ * shared `data:` fact becomes in each system, and HM3's column is `NA` far more
25
+ * often than SoHL's: a weapon's weight and value cross over, its quality and
26
+ * durability do not. Every row below is one the specification states. Where it
27
+ * says nothing — HM3's `armorlocation` has no `data:` row at all, and `trauma`
28
+ * and `mysticalability` have none either — this file declares nothing rather
29
+ * than inventing a plausible path. A guessed `to` compiles clean and is
30
+ * discarded by Foundry at load without a word, which is exactly the failure the
31
+ * schema check exists to catch and exactly the failure a guess would create.
32
+ *
33
+ * **One shared source, two destinations.** `name` is the shared property a
34
+ * field draws from and `to` is where it lands, so a single authored `weight`
35
+ * feeds `sohl.system.weightBase` *and* `hm3.system.weight` — one authored fact,
36
+ * two documents (#58). That is the whole reason the two are declared
37
+ * separately.
38
+ *
39
+ * The specification writes those sources as `data.weight`, and the sources here
40
+ * are written bare, exactly as SoHL's are: the `data:` region is #128's
41
+ * migration and neither half has moved to it. Spelling HM3's differently would
42
+ * make a note feeding both systems author the same fact twice for no gain, and
43
+ * would take these rows out of reach of `content-build content-format fields`,
44
+ * which pairs a `data.<key>` claim with a bare declared `<key>`. Both halves
45
+ * move together when #128 lands.
46
+ *
47
+ * **What a note authors under `hm3.system` is not declared here.** HM3 fields
48
+ * that no shared `data:` property feeds — a skill's `type`, an armour's
49
+ * `protection`, a character's `abilities` — are written at their own paths in
50
+ * the note's `hm3.system` block and reach the document through the verbatim
51
+ * passthrough. They are checked against HM3's published `schema.json` like
52
+ * everything else; they simply have no shared source to declare.
53
+ *
54
+ * **A leaf**, like its SoHL counterpart: declaration primitives only, never the
55
+ * resolved configuration.
56
+ *
57
+ * @module
58
+ */
59
+
60
+ import { AS_AUTHORED, NUMBER } from "../engine/field-spec.mjs";
61
+
62
+ /* --------------------------------------------------------------------- */
63
+ /* Rows shared by the four gear types */
64
+ /* --------------------------------------------------------------------- */
65
+
66
+ /**
67
+ * What HM3's gear template takes from the shared `data:` region.
68
+ *
69
+ * `weight` and `value` are the two rows every gear table in the content format
70
+ * gives an HM3 destination. `quality` and `durability` are SoHL's alone — HM3
71
+ * carries `weaponQuality` and `armorQuality` on the subtypes that have them,
72
+ * which is a different fact about a different thing, so the shared row is `NA`
73
+ * and nothing is emitted.
74
+ *
75
+ * @type {readonly import("../engine/field-spec.mjs").FieldSpec[]}
76
+ */
77
+ const GEAR_COMMON = Object.freeze([
78
+ {
79
+ name: "weight",
80
+ to: "weight",
81
+ ...AS_AUTHORED,
82
+ kind: "number",
83
+ default: 0,
84
+ describe: "Weight of one, in pounds.",
85
+ },
86
+ {
87
+ name: "value",
88
+ to: "value",
89
+ ...AS_AUTHORED,
90
+ kind: "number",
91
+ default: 0,
92
+ describe: "Worth of one, in pence.",
93
+ },
94
+ ]);
95
+
96
+ /**
97
+ * The stack count, for the gear types whose content-format table gives it an
98
+ * HM3 destination.
99
+ *
100
+ * `armor`, `containergear` and `weapongear` are excluded on purpose: their
101
+ * tables state that quantity is always 1 and may not be authored, so the note
102
+ * has nothing to say and HM3's own `initial` is the right value to ship.
103
+ *
104
+ * @type {import("../engine/field-spec.mjs").FieldSpec}
105
+ */
106
+ const QUANTITY = Object.freeze({
107
+ name: "quantity",
108
+ to: "quantity",
109
+ ...NUMBER,
110
+ default: 1,
111
+ describe: "How many the stack holds.",
112
+ });
113
+
114
+ /* --------------------------------------------------------------------- */
115
+ /* Per-type declarations */
116
+ /* --------------------------------------------------------------------- */
117
+
118
+ /**
119
+ * Every HM3 item type's frontmatter vocabulary, in the order the `system` block
120
+ * emits it.
121
+ *
122
+ * The keys are **note** types, not HM3 document subtypes — `projectile`
123
+ * rather than `missilegear`, `mysticalability` rather than `psionic` — because
124
+ * a registry is addressed by what a note calls itself. What the document is
125
+ * called is the map's answer, and only the map's.
126
+ *
127
+ * @type {Readonly<Record<string, readonly import("../engine/field-spec.mjs").FieldSpec[]>>}
128
+ */
129
+ export const HM3_ITEM_FIELDS = Object.freeze({
130
+ armor: Object.freeze([...GEAR_COMMON]),
131
+
132
+ // The one subtype that extends the Foundry base directly, with none of the
133
+ // shared templates. Its content-format table has a single row, and that row
134
+ // is `NA` on both sides — so an armour location is authored entirely under
135
+ // `hm3.system` (`probWeight`, `impactType`, the per-aspect protections) and
136
+ // there is nothing for a shared source to feed.
137
+ armorlocation: Object.freeze([]),
138
+
139
+ containergear: Object.freeze([
140
+ ...GEAR_COMMON,
141
+ {
142
+ name: "capacity",
143
+ to: "capacity.max",
144
+ ...NUMBER,
145
+ default: 0,
146
+ describe: "How much the container holds, in pounds.",
147
+ },
148
+ ]),
149
+
150
+ miscgear: Object.freeze([...GEAR_COMMON, QUANTITY]),
151
+
152
+ // A note's `projectile` is HM3's `missilegear`; the fields are the gear
153
+ // template's, and the aspect, impact and range that make it a missile are
154
+ // authored under `hm3.system`.
155
+ projectile: Object.freeze([...GEAR_COMMON, QUANTITY]),
156
+
157
+ // `hm3.system.type` — "Craft", "Physical", "Communication", "Combat",
158
+ // "Magic", "Ritual" — has no shared source: the content format states
159
+ // outright that it does not map onto the note's `subType` and must be
160
+ // written for each HM3 skill. So it is authored at its own path and passes
161
+ // through, and only the mastery level is declared here.
162
+ skill: Object.freeze([
163
+ {
164
+ name: "masteryLevel",
165
+ to: "masteryLevel",
166
+ ...NUMBER,
167
+ default: 0,
168
+ describe: "Mastery level, as a percentage.",
169
+ },
170
+ ]),
171
+
172
+ // Both one-to-many item rows: every shared row in their content-format
173
+ // tables is `NA`, so what an HM3 trauma or mystical ability carries — a
174
+ // severity and heal rate, a convocation and level, a circle and deity — is
175
+ // authored under `hm3.system` against whichever subtype `hm3.type` names.
176
+ trauma: Object.freeze([]),
177
+ mysticalability: Object.freeze([]),
178
+
179
+ weapongear: Object.freeze([...GEAR_COMMON]),
180
+ });
package/hm3/items.mjs ADDED
@@ -0,0 +1,96 @@
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 Item pass** — the two things about compiling a note into an HM3 Item
16
+ * that are facts about HM3 rather than about the note format (#139).
17
+ *
18
+ * Everything else is {@link module:engine/item-compiler}'s, and is the same
19
+ * code the SoHL pass runs: which notes are claimed, which subtype each becomes,
20
+ * which registry builds it, the authored `hm3.system` passthrough, the schema
21
+ * check, and the compendium envelope. A second system is a map and a handful of
22
+ * emitted keys, which is the arrangement #79 and #58 were building towards.
23
+ *
24
+ * **What HM3's compiler writes on every item: one key, and only when there is
25
+ * something to write.** The content format gives an item's `{#appearance}`
26
+ * section a home in HM3 — `description` — and SoHL none, since no SoHL Item
27
+ * subtype declares such a field. So the section is rendered here and nowhere
28
+ * else. Where a note has no such section nothing is emitted at all, which
29
+ * matters for `armorlocation`: it is the one HM3 subtype that extends the
30
+ * Foundry base directly, declaring neither `description` nor `notes`, so an
31
+ * unconditional key would be a finding on every armour-location document in the
32
+ * pack.
33
+ *
34
+ * **And one flag, for the fact HM3's `system` has no field for.** The template
35
+ * priority is a shared statement — a note declaring `data.templatePriority` says
36
+ * the same thing to both systems — but HM3 declares no `system` field for it, so
37
+ * it lands under this system's own flag scope as `flags.hm3.templatePriority`,
38
+ * exactly as the Actor pass writes it. This pass wrote no such flag until #283:
39
+ * an item note declaring the priority compiled into a SoHL item that knew it was
40
+ * a template and an HM3 item that did not.
41
+ *
42
+ * **There is no HM3 equivalent of `docHtml`.** SoHL points an item at the
43
+ * JournalEntry its prose compiled into, and HM3's data model has nowhere to put
44
+ * such a pointer; inventing one would emit a key Foundry discards at load
45
+ * without a word. The prose still compiles into its JournalEntry — the journals
46
+ * pass claims every doc-carrying type regardless of system — it simply is not
47
+ * addressed from the item.
48
+ *
49
+ * @module
50
+ */
51
+
52
+ import { renderSection } from "../engine/anchored-sections.mjs";
53
+ import { SystemItemCompiler } from "../engine/item-compiler.mjs";
54
+ import { HM3_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
55
+ import { templateFlags } from "./template-priority.mjs";
56
+
57
+ export class Hm3Items extends SystemItemCompiler {
58
+ /**
59
+ * HM3's note-type → document-subtype map — the one declaration that says
60
+ * which block this pass reads, which notes it claims, and what each becomes
61
+ * (#58/#79).
62
+ *
63
+ * @type {import("../engine/document-subtypes.mjs").DocumentSubtypeMap}
64
+ */
65
+ static documentSubtypes = HM3_DOCUMENT_SUBTYPES;
66
+
67
+ /**
68
+ * The `system.*` field HM3 writes on an item from the note's prose.
69
+ *
70
+ * @param {object} fm - The note's frontmatter.
71
+ * @param {object} at - What the pass already knows about this note.
72
+ * @param {string} at.markdown - The note body, tables expanded and
73
+ * wikilinks resolved.
74
+ * @returns {object} The shared `system` fields — `description`, or nothing.
75
+ */
76
+ commonSystem(fm, { markdown }) {
77
+ const description = renderSection(markdown, "appearance");
78
+ return description ? { description } : {};
79
+ }
80
+
81
+ /**
82
+ * The `flags` HM3 writes on an item: whatever the note authors, plus the
83
+ * template priority.
84
+ *
85
+ * The same statement the Actor pass records, through the same
86
+ * {@link module:hm3/template-priority.templateFlags} — see that module for
87
+ * why the priority lives in flags at all, and for what its absence here
88
+ * cost (#283).
89
+ *
90
+ * @param {object} fm - The note's frontmatter.
91
+ * @returns {object} The flags to emit.
92
+ */
93
+ commonFlags(fm) {
94
+ return templateFlags(fm, this.system);
95
+ }
96
+ }
@@ -0,0 +1,85 @@
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
+ * **Where HM3 records the template priority** — one statement, read by both of
16
+ * this system's passes (#283).
17
+ *
18
+ * `data.templatePriority` is the shared fact that a note is a *starting
19
+ * template*, and the specification states it as a row every type maps:
20
+ * `system.templatePriority` in SoHL, `flags.hm3.templatePriority` here. It lands
21
+ * in flags because HM3's data model declares no field for it, and an undeclared
22
+ * `system` key is discarded at load without a word.
23
+ *
24
+ * It was written by the Actor pass alone. The Item pass emitted only whatever
25
+ * `flags` the note itself authored, so an item note declaring the priority
26
+ * compiled into a SoHL item that knew it was a template and an HM3 item that did
27
+ * not — silently on both sides of the build, since an omitted flag is exactly how
28
+ * this system says *not a template*, making a lost priority and a deliberate one
29
+ * the same output. Nothing could report it either: the emitted-`system` check
30
+ * compares against a declared schema, and a flag is declared by nothing.
31
+ *
32
+ * So the rule lives here rather than in either pass, and both call it. Two
33
+ * copies of it would be two chances to diverge again, which is the failure this
34
+ * module exists to close.
35
+ *
36
+ * @module
37
+ */
38
+
39
+ import log from "loglevel";
40
+
41
+ import { resolveName, statedTemplatePriority } from "../engine/helpers.mjs";
42
+ import { blockProperty } from "../engine/system-block.mjs";
43
+
44
+ /**
45
+ * A document's `flags`: whatever the note authors, plus this system's template
46
+ * priority.
47
+ *
48
+ * A note that is not a template — or states nothing — writes nothing, rather
49
+ * than a `null` nothing reads.
50
+ *
51
+ * **Read through the shared resolver, not a field declaration** (#266). A
52
+ * `FieldSpec`'s shared source is a single position, and this value has five:
53
+ * `data:`, this block, the top level, and the retiring `archetype` spelling in
54
+ * the latter two. The resolver is the single implementation of what a note said,
55
+ * so the two systems cannot disagree about it. It is read against **this**
56
+ * block: the `sohl:` block is not a source for an HM3 document, so a tree that
57
+ * still states the priority there (`harn-ensemble`, on 2,502 notes) writes no
58
+ * HM3 flag until it sweeps to `data:`.
59
+ *
60
+ * @param {object} fm - The note's frontmatter.
61
+ * @param {string} block - This pass's system block.
62
+ * @returns {object} The flags to emit.
63
+ */
64
+ export function templateFlags(fm, block) {
65
+ const authored = blockProperty(fm, block, "flags", {});
66
+ const stated = statedTemplatePriority(fm, resolveName(fm), { block });
67
+ if (stated == null) return authored;
68
+ // Coerced, as the field declaration this replaced coerced it: a note may
69
+ // state the priority as a YAML string, and `"3"` is a priority.
70
+ const value = Number(stated);
71
+ if (!Number.isFinite(value)) {
72
+ log.warn(
73
+ `${resolveName(fm)}: template priority ${JSON.stringify(stated)} is ` +
74
+ `not a number; no template flag written.`,
75
+ );
76
+ return authored;
77
+ }
78
+ return {
79
+ ...authored,
80
+ [block]: {
81
+ .../** @type {Record<string, unknown>} */ (authored)[block],
82
+ templatePriority: value,
83
+ },
84
+ };
85
+ }
package/labels.mjs ADDED
@@ -0,0 +1,191 @@
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 label registry has two faces that must agree.
16
+ *
17
+ * `.github/labels.yml` is the machine source synced to GitHub, and the §3 table
18
+ * in `.github/ISSUE_REPORTING.md` is the documented reference a person reads.
19
+ * Neither is derived from the other, so either can drift — or invent a label
20
+ * the other has never heard of — and nothing notices until someone files an
21
+ * issue against a label that does not exist, or the sync pushes a label the
22
+ * documentation never mentions.
23
+ *
24
+ * **This lives here because every repository wants it and only the paths ever
25
+ * differed.** It was a `utils/check-labels.mjs` copied per repository, which is
26
+ * the shape a shared check takes just before it starts drifting between copies
27
+ * — the same argument that moved the no-attribution check to a shared action.
28
+ *
29
+ * The checks are pure and text-in: the caller reads the two files and decides
30
+ * what to do with the findings, so this module needs no filesystem and is
31
+ * testable without one.
32
+ *
33
+ * @module
34
+ */
35
+
36
+ import { parse } from "yaml";
37
+
38
+ /** GitHub rejects a label description beyond this with a 422. */
39
+ export const MAX_DESCRIPTION = 100;
40
+
41
+ /**
42
+ * The line a label's `name:` entry sits on, 1-based.
43
+ *
44
+ * Located rather than tracked, because `yaml`'s document API would have to be
45
+ * threaded through the parse for a value this simple to recover: a registry is
46
+ * a flat list of `- name: <label>`, so the first line declaring that name is
47
+ * the entry. A name that cannot be found yields no position, which
48
+ * `reportFindings` drops rather than guessing.
49
+ *
50
+ * @param {string} text - The registry file's contents.
51
+ * @param {string} name - The label name.
52
+ * @returns {{line?: number, column?: number}} Spreadable position fields.
53
+ */
54
+ function positionOfLabel(text, name) {
55
+ const lines = text.split("\n");
56
+ for (let i = 0; i < lines.length; i += 1) {
57
+ const m = lines[i].match(/^\s*-?\s*name:\s*["']?(.+?)["']?\s*$/);
58
+ if (m && m[1] === name) return { line: i + 1, column: lines[i].indexOf(name) + 1 };
59
+ }
60
+ return {};
61
+ }
62
+
63
+ /**
64
+ * The line a label's row sits on in the documented table, 1-based.
65
+ *
66
+ * @param {string} text - The documentation file's contents.
67
+ * @param {string} name - The label name.
68
+ * @returns {{line?: number, column?: number}} Spreadable position fields.
69
+ */
70
+ function positionOfDocRow(text, name) {
71
+ const lines = text.split("\n");
72
+ for (let i = 0; i < lines.length; i += 1) {
73
+ const m = lines[i].match(/^\|\s*`([a-z][a-z-]*)`\s*\|/);
74
+ if (m && m[1] === name) return { line: i + 1, column: lines[i].indexOf(name) + 1 };
75
+ }
76
+ return {};
77
+ }
78
+
79
+ /**
80
+ * The label names the documented §3 table lists.
81
+ *
82
+ * A registry row is a table row whose first cell is a backticked label name,
83
+ * which is narrow on purpose: §3 carries prose and other tables, and a looser
84
+ * match would read a heading or an example as a label.
85
+ *
86
+ * @param {string} text - The documentation file's contents.
87
+ * @returns {{names: Set<string>, found: boolean}} The names, and whether §3 was
88
+ * located at all — an absent section is a different failure from an empty one.
89
+ */
90
+ export function documentedLabels(text) {
91
+ const lines = text.split("\n");
92
+ const start = lines.findIndex((l) => /^##\s+3\./.test(l));
93
+ if (start < 0) return { names: new Set(), found: false };
94
+ const after = lines.findIndex((l, i) => i > start && /^##\s+\d/.test(l));
95
+ const section = lines.slice(start, after < 0 ? lines.length : after);
96
+ const names = new Set();
97
+ for (const line of section) {
98
+ const m = line.match(/^\|\s*`([a-z][a-z-]*)`\s*\|/);
99
+ if (m) names.add(m[1]);
100
+ }
101
+ return { names, found: true };
102
+ }
103
+
104
+ /**
105
+ * Check the machine registry against the documented table.
106
+ *
107
+ * Findings are returned per file rather than merged, because each names a
108
+ * position in a different document and the caller reports them against the
109
+ * file they belong to.
110
+ *
111
+ * @param {object} sources
112
+ * @param {string} sources.registryText - `.github/labels.yml`.
113
+ * @param {string} sources.docText - `.github/ISSUE_REPORTING.md`.
114
+ * @param {string} [sources.docPath] - The doc's path, for the message naming it.
115
+ * @returns {{registry: object[], doc: object[], count: number}} Findings for
116
+ * each file, and how many labels the registry declares.
117
+ */
118
+ export function checkLabelRegistry({
119
+ registryText,
120
+ docText,
121
+ docPath = ".github/ISSUE_REPORTING.md",
122
+ }) {
123
+ const registryFindings = [];
124
+ const docFindings = [];
125
+
126
+ let entries;
127
+ try {
128
+ entries = parse(registryText);
129
+ } catch (error) {
130
+ registryFindings.push({
131
+ severity: "error",
132
+ message: `label registry is not valid YAML — ${error.message}`,
133
+ });
134
+ return { registry: registryFindings, doc: docFindings, count: 0 };
135
+ }
136
+ if (!Array.isArray(entries)) {
137
+ registryFindings.push({
138
+ severity: "error",
139
+ message: "label registry must be a list of `{ name, description }` entries",
140
+ });
141
+ return { registry: registryFindings, doc: docFindings, count: 0 };
142
+ }
143
+
144
+ // Caught here rather than mid-sync, where it surfaces as a bare 422 from
145
+ // the GitHub API naming neither the label nor the limit.
146
+ for (const entry of entries) {
147
+ const length = (entry?.description ?? "").length;
148
+ if (length > MAX_DESCRIPTION) {
149
+ registryFindings.push({
150
+ ...positionOfLabel(registryText, entry.name),
151
+ severity: "error",
152
+ message:
153
+ `"${entry.name}" has a ${length}-character description; ` +
154
+ `GitHub rejects anything over ${MAX_DESCRIPTION}`,
155
+ });
156
+ }
157
+ }
158
+
159
+ const registryNames = new Set(entries.map((e) => e?.name).filter(Boolean));
160
+ const { names: docNames, found } = documentedLabels(docText);
161
+ if (!found) {
162
+ docFindings.push({
163
+ severity: "error",
164
+ message: `no §3 section found in ${docPath}, so the registry has nothing to agree with`,
165
+ });
166
+ return { registry: registryFindings, doc: docFindings, count: registryNames.size };
167
+ }
168
+
169
+ for (const name of registryNames) {
170
+ if (docNames.has(name)) continue;
171
+ registryFindings.push({
172
+ ...positionOfLabel(registryText, name),
173
+ severity: "error",
174
+ message:
175
+ `"${name}" is in the registry but not in ${docPath} §3; ` +
176
+ `the two are edited together, so add the row`,
177
+ });
178
+ }
179
+ for (const name of docNames) {
180
+ if (registryNames.has(name)) continue;
181
+ docFindings.push({
182
+ ...positionOfDocRow(docText, name),
183
+ severity: "error",
184
+ message:
185
+ `"${name}" is documented in §3 but absent from the registry, ` +
186
+ `so it is never synced and an issue cannot carry it`,
187
+ });
188
+ }
189
+
190
+ return { registry: registryFindings, doc: docFindings, count: registryNames.size };
191
+ }