@heroiclands/package-build 23.1.1 → 23.2.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 (246) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/bin/content-commands.mjs +68 -149
  3. package/bin/package-build.mjs +56 -10
  4. package/ci/check-banners.mjs +42 -6
  5. package/ci/confirm-npm-version.mjs +135 -0
  6. package/ci/content-format-reference.mjs +14 -5
  7. package/config.mjs +1 -0
  8. package/container.mjs +185 -4
  9. package/content-config.mjs +333 -210
  10. package/deploy.mjs +116 -10
  11. package/docs/api.md +238 -246
  12. package/docs/authoring/assets.md +11 -5
  13. package/docs/authoring/frontmatter.md +1 -1
  14. package/docs/authoring/links-and-markup.md +87 -0
  15. package/docs/commands.md +138 -99
  16. package/docs/configuration.md +277 -125
  17. package/docs/content-format.md +2 -0
  18. package/docs/diagnostics.md +48 -13
  19. package/docs/guides/book.md +341 -21
  20. package/docs/guides/e2e.md +13 -2
  21. package/docs/guides/icons.md +14 -4
  22. package/docs/guides/module-project.md +18 -8
  23. package/docs/guides/release-and-deploy.md +32 -0
  24. package/docs/guides/site.md +5 -2
  25. package/docs/project-setup.md +30 -10
  26. package/docs/reference/format-details.md +373 -170
  27. package/docs/reference/note-types.md +128 -109
  28. package/docs/reference/theme-pages.md +31 -6
  29. package/e2e.mjs +140 -28
  30. package/engine/actor-compiler.mjs +22 -31
  31. package/engine/address-charset.mjs +4 -17
  32. package/engine/address-fields.mjs +1 -1
  33. package/engine/address-render.mjs +12 -1
  34. package/engine/address-values.mjs +6 -0
  35. package/engine/address.mjs +46 -33
  36. package/engine/art-fields.mjs +30 -5
  37. package/engine/art-slots.mjs +10 -1
  38. package/engine/asset-bindings.mjs +12 -1
  39. package/engine/base-compiler.mjs +34 -65
  40. package/engine/being-age.mjs +2 -7
  41. package/engine/being-measurements.mjs +12 -1
  42. package/engine/calendar-artifacts.mjs +6 -0
  43. package/engine/calendar-choice.mjs +12 -1
  44. package/engine/calendar-format.mjs +12 -1
  45. package/engine/calendar-human.mjs +12 -1
  46. package/engine/compile-corpus.mjs +3 -1
  47. package/engine/content-address.mjs +9 -12
  48. package/engine/content-alerts.mjs +13 -1
  49. package/engine/content-blocks.mjs +14 -3
  50. package/engine/content-embeds.mjs +11 -3
  51. package/engine/content-figures.mjs +9 -0
  52. package/engine/content-footnotes.mjs +12 -1
  53. package/engine/content-format-check.mjs +11 -262
  54. package/engine/content-format.yaml +5 -2
  55. package/engine/content-images.mjs +3 -0
  56. package/engine/content-index.mjs +443 -20
  57. package/engine/content-links.mjs +29 -35
  58. package/engine/content-lint.mjs +2 -3
  59. package/engine/content-poetry.mjs +12 -1
  60. package/engine/content-slug.mjs +2 -14
  61. package/engine/content-spans.mjs +13 -1
  62. package/engine/content-tables.mjs +12 -1
  63. package/engine/culture-choice.mjs +210 -13
  64. package/engine/data-keys.mjs +19 -16
  65. package/engine/date-conversion.mjs +12 -1
  66. package/engine/declaration-check.mjs +9 -0
  67. package/engine/derived-fields.mjs +6 -7
  68. package/engine/diagnostics.mjs +34 -4
  69. package/engine/docs-checks.mjs +9 -0
  70. package/engine/document-subtypes.mjs +47 -34
  71. package/engine/event-views.mjs +139 -14
  72. package/engine/exported-scene.mjs +92 -28
  73. package/engine/extension-attributes.mjs +12 -1
  74. package/engine/field-spec.mjs +11 -102
  75. package/engine/folder-notes.mjs +0 -64
  76. package/engine/foreign-catalog.mjs +2 -1
  77. package/engine/format-generated.mjs +9 -0
  78. package/engine/foundry-entries.mjs +10 -28
  79. package/engine/frontmatter-lint.mjs +83 -392
  80. package/engine/frontmatter.mjs +20 -70
  81. package/engine/generate.mjs +10 -15
  82. package/engine/heading-attributes.mjs +12 -1
  83. package/engine/helpers.mjs +41 -102
  84. package/engine/holdings.mjs +1 -1
  85. package/engine/homepage.mjs +3 -10
  86. package/engine/ids.mjs +1 -167
  87. package/engine/index-records.mjs +6 -4
  88. package/engine/index.mjs +0 -3
  89. package/engine/infobox-render.mjs +15 -3
  90. package/engine/infobox.mjs +13 -14
  91. package/engine/item-compiler.mjs +4 -10
  92. package/engine/item-docs.mjs +3 -5
  93. package/engine/item-registry.mjs +2 -10
  94. package/engine/journals.mjs +1 -5
  95. package/engine/literature-notes.mjs +4 -12
  96. package/engine/macros.mjs +1 -2
  97. package/engine/map-graphviz.mjs +12 -1
  98. package/engine/markdown-expressions.mjs +12 -1
  99. package/engine/markdownlint-run.mjs +567 -0
  100. package/engine/metadata-index.mjs +42 -12
  101. package/engine/note-addresses.mjs +143 -35
  102. package/engine/note-claims.mjs +14 -28
  103. package/engine/note-dates.mjs +3 -3
  104. package/engine/note-frontmatter.mjs +6 -0
  105. package/engine/note-ids.mjs +8 -14
  106. package/engine/note-package.mjs +8 -25
  107. package/engine/note-state.mjs +1 -3
  108. package/engine/note-vocabulary.mjs +148 -84
  109. package/engine/number-words.mjs +12 -1
  110. package/engine/pack-config.mjs +15 -25
  111. package/engine/pack-router.mjs +10 -16
  112. package/engine/pathnames.mjs +8 -22
  113. package/engine/pdf-build.mjs +773 -121
  114. package/engine/pdf-coverage.mjs +188 -0
  115. package/engine/pdf-index.mjs +54 -7
  116. package/engine/pdf-pages.mjs +247 -0
  117. package/engine/pdf-render.mjs +114 -4
  118. package/engine/pdf-toc.mjs +29 -32
  119. package/engine/project-init.mjs +46 -6
  120. package/engine/prose-config.mjs +1 -1
  121. package/engine/prose-lint.mjs +52 -88
  122. package/engine/readability-lint.mjs +9 -0
  123. package/engine/reckoning-markers.mjs +12 -1
  124. package/engine/reference-targets.mjs +7 -0
  125. package/engine/runtime-only-fields.mjs +5 -8
  126. package/engine/scene-levels.mjs +2 -2
  127. package/engine/scenes.mjs +1 -1
  128. package/engine/shortcode-references.mjs +7 -0
  129. package/engine/site-build.mjs +30 -16
  130. package/engine/site-index.mjs +10 -16
  131. package/engine/site-preview.mjs +6 -0
  132. package/engine/site-root.mjs +8 -1
  133. package/engine/social-tie-terms.mjs +12 -1
  134. package/engine/social-ties.mjs +3 -0
  135. package/engine/sql-tables.mjs +56 -20
  136. package/engine/standing-terms.mjs +11 -1
  137. package/engine/standings.mjs +11 -27
  138. package/engine/system-block.mjs +39 -141
  139. package/engine/system-vocabulary.mjs +6 -0
  140. package/engine/walk-files.mjs +69 -0
  141. package/engine/web-wikilinks.mjs +23 -3
  142. package/engine/wikilink-syntax.mjs +32 -21
  143. package/engine/wikilinks.mjs +27 -16
  144. package/githooks/attribution.mjs +8 -0
  145. package/githooks/commit-message.mjs +9 -0
  146. package/hm3/actors.mjs +9 -48
  147. package/hm3/default-item-art.mjs +9 -9
  148. package/hm3/item-fields.mjs +2 -9
  149. package/hm3/template-priority.mjs +6 -7
  150. package/hugo-theme/data/banner-kinds.yaml +78 -0
  151. package/hugo-theme/data/banners.yaml +2 -3
  152. package/hugo-theme/layouts/404.html +3 -3
  153. package/hugo-theme/layouts/partials/banner-url.html +31 -7
  154. package/hugo-theme/layouts/partials/hero-banner.html +13 -14
  155. package/hugo-theme/layouts/partials/related.html +1 -1
  156. package/hugo-theme/layouts/partials/warn-missing-banner.html +1 -1
  157. package/hugo-theme/static/css/style.css +1 -1
  158. package/manifest.mjs +11 -4
  159. package/markdownlint-config.mjs +1 -1
  160. package/package.json +6 -3
  161. package/release.mjs +21 -14
  162. package/sohl/actors.mjs +16 -27
  163. package/sohl/default-item-art.mjs +3 -3
  164. package/sohl/document-subtypes.mjs +1 -16
  165. package/sohl/infobox.mjs +2 -3
  166. package/sohl/item-builders.mjs +5 -8
  167. package/sohl/item-fields.mjs +5 -35
  168. package/sohl/items.mjs +0 -4
  169. package/sohl/kb-passes.mjs +3 -3
  170. package/types/container.d.mts +116 -5
  171. package/types/content-config.d.mts +83 -35
  172. package/types/deploy.d.mts +42 -6
  173. package/types/e2e.d.mts +35 -3
  174. package/types/engine/address-charset.d.mts +4 -17
  175. package/types/engine/address.d.mts +17 -8
  176. package/types/engine/art-fields.d.mts +7 -4
  177. package/types/engine/art-slots.d.mts +6 -0
  178. package/types/engine/base-compiler.d.mts +15 -17
  179. package/types/engine/content-address.d.mts +9 -12
  180. package/types/engine/content-embeds.d.mts +4 -2
  181. package/types/engine/content-format-check.d.mts +0 -64
  182. package/types/engine/content-index.d.mts +82 -3
  183. package/types/engine/content-links.d.mts +2 -4
  184. package/types/engine/culture-choice.d.mts +51 -8
  185. package/types/engine/data-keys.d.mts +2 -2
  186. package/types/engine/derived-fields.d.mts +1 -2
  187. package/types/engine/diagnostics.d.mts +27 -4
  188. package/types/engine/document-subtypes.d.mts +12 -31
  189. package/types/engine/event-views.d.mts +14 -2
  190. package/types/engine/exported-scene.d.mts +3 -2
  191. package/types/engine/field-spec.d.mts +13 -122
  192. package/types/engine/folder-notes.d.mts +0 -39
  193. package/types/engine/foreign-catalog.d.mts +2 -1
  194. package/types/engine/foundry-entries.d.mts +4 -5
  195. package/types/engine/frontmatter-lint.d.mts +2 -2
  196. package/types/engine/frontmatter.d.mts +19 -45
  197. package/types/engine/generate.d.mts +1 -1
  198. package/types/engine/helpers.d.mts +25 -42
  199. package/types/engine/homepage.d.mts +3 -10
  200. package/types/engine/ids.d.mts +0 -114
  201. package/types/engine/index-records.d.mts +6 -4
  202. package/types/engine/index.d.mts +0 -1
  203. package/types/engine/infobox-render.d.mts +7 -1
  204. package/types/engine/infobox.d.mts +3 -2
  205. package/types/engine/item-docs.d.mts +1 -1
  206. package/types/engine/item-registry.d.mts +2 -4
  207. package/types/engine/literature-notes.d.mts +1 -1
  208. package/types/engine/markdownlint-run.d.mts +40 -0
  209. package/types/engine/metadata-index.d.mts +9 -1
  210. package/types/engine/note-addresses.d.mts +27 -2
  211. package/types/engine/note-claims.d.mts +3 -5
  212. package/types/engine/note-ids.d.mts +2 -4
  213. package/types/engine/note-package.d.mts +1 -1
  214. package/types/engine/note-state.d.mts +1 -3
  215. package/types/engine/note-vocabulary.d.mts +28 -21
  216. package/types/engine/pack-config.d.mts +2 -6
  217. package/types/engine/pack-router.d.mts +4 -4
  218. package/types/engine/pathnames.d.mts +3 -3
  219. package/types/engine/pdf-build.d.mts +98 -12
  220. package/types/engine/pdf-coverage.d.mts +60 -0
  221. package/types/engine/pdf-index.d.mts +34 -5
  222. package/types/engine/pdf-pages.d.mts +143 -0
  223. package/types/engine/pdf-render.d.mts +45 -1
  224. package/types/engine/pdf-toc.d.mts +12 -14
  225. package/types/engine/prose-config.d.mts +1 -1
  226. package/types/engine/prose-lint.d.mts +11 -10
  227. package/types/engine/runtime-only-fields.d.mts +2 -2
  228. package/types/engine/site-build.d.mts +9 -5
  229. package/types/engine/site-index.d.mts +4 -5
  230. package/types/engine/site-root.d.mts +1 -1
  231. package/types/engine/sql-tables.d.mts +31 -2
  232. package/types/engine/standing-terms.d.mts +2 -1
  233. package/types/engine/standings.d.mts +7 -9
  234. package/types/engine/system-block.d.mts +10 -44
  235. package/types/engine/walk-files.d.mts +15 -0
  236. package/types/engine/wikilink-syntax.d.mts +18 -19
  237. package/types/hm3/actors.d.mts +1 -2
  238. package/types/hm3/item-fields.d.mts +2 -4
  239. package/types/hm3/template-priority.d.mts +6 -7
  240. package/types/manifest.d.mts +3 -2
  241. package/types/release.d.mts +8 -7
  242. package/types/sohl/actors.d.mts +2 -1
  243. package/types/sohl/document-subtypes.d.mts +1 -2
  244. package/types/sohl/item-fields.d.mts +3 -7
  245. package/engine/retired-fields.mjs +0 -631
  246. package/types/engine/retired-fields.d.mts +0 -354
@@ -44,15 +44,12 @@
44
44
  * transmission — and is true of it whichever system is reading. What each
45
45
  * system makes of that value is declared elsewhere, in that system's own half.
46
46
  *
47
- * **The type names here are the specification's**, which renamed `armor`,
48
- * `projectile` and `concoction` off the `…gear` spellings that named a SoHL
49
- * document subtype rather than the thing the note is about. `weapon` is the one
47
+ * **The type names here are the specification's**: `armor`, `projectile`
48
+ * and `concoction` name the thing the note is about, not a SoHL document
49
+ * subtype in a `…gear` spelling. `weapon` is the one
50
50
  * the specification and this registry still spell differently: both systems
51
51
  * call that document a `weapongear`, so the name says nothing system-specific
52
- * and the table has no row for it. A note left on a renamed spelling still
53
- * reaches its entry — every type-keyed lookup normalises through
54
- * `RENAMED_TYPES` — and is reported rather than refused until the content trees
55
- * have swept.
52
+ * and the table has no row for it.
56
53
  *
57
54
  * **A type name and a subType value are held to the address charset**, so
58
55
  * both match `ADDRESS_SEGMENT_PATTERN` — the charset `engine/address-charset.mjs`
@@ -64,10 +61,8 @@
64
61
  * would be read back as two segments and resolve to nothing, reporting nothing
65
62
  * about why.
66
63
  *
67
- * A subType reaches no address of its own. It did when this rule was written —
68
- * a `doc`'s was its section, a path segment — and sections are retired from
69
- * the note format one release later. It keeps the rule regardless, and the
70
- * reason is not inertia: a subType is a vocabulary term the whole toolchain
64
+ * A subType reaches no address of its own. It keeps the charset rule regardless,
65
+ * and the reason is not inertia: a subType is a vocabulary term the whole toolchain
71
66
  * keys on, it is one closed set away from being an address again, and a charset
72
67
  * that held for a type, a shortcode and a package but not for a subType would
73
68
  * be a rule nobody could state in one sentence. The registry below is checked
@@ -84,10 +79,7 @@ import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./address-charset.mjs
84
79
  // The system registry, so the top-level block keys below are the systems this
85
80
  // toolchain recognises rather than a second list of them.
86
81
  import { SYSTEM_IDS } from "./systems.mjs";
87
- // The retirement window for a renamed type, read rather than restated: a
88
- // vocabulary that answered only to the current spelling would report every key
89
- // of an unswept note as unknown.
90
- import { currentType } from "./ids.mjs";
82
+ import { packForType } from "./ids.mjs";
91
83
  // What a place is next to and reachable from — the two relation lists and
92
84
  // the checks that hold them to their closed sets and to each other.
93
85
  import { checkBorders, checkRoutes } from "./place-relations.mjs";
@@ -118,7 +110,7 @@ import { checkNoteEvents } from "./note-events.mjs";
118
110
  // list the compiled Item is held to, so the lint and the compile cannot differ.
119
111
  import { AFFILIATION_STANDINGS } from "../sohl/affiliation-standings.mjs";
120
112
  import { EVENT_ENTRY } from "./note-event-terms.mjs";
121
- import { checkCultureChoice } from "./culture-choice.mjs";
113
+ import { CULTURE_FIELD, checkCultureSubType } from "./culture-choice.mjs";
122
114
  import { LITERATURE_FIELDS, checkLiteratureNote } from "./literature-notes.mjs";
123
115
  import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
124
116
  import { parseNoteDate } from "./note-dates.mjs";
@@ -148,15 +140,12 @@ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
148
140
  * @typedef {object} DataFieldSpec
149
141
  * @property {string} name - The key under `data:`, dotted for a nested one
150
142
  * (`charges.value`).
151
- * @property {"string"|"number"|"boolean"|"list"|"map"|"list-or-map"|"scalar-or-map"|"address"|"shortcode"} [kind] -
143
+ * @property {"string"|"number"|"boolean"|"list"|"map"|"scalar-or-map"|"address"|"shortcode"} [kind] -
152
144
  * The value's shape, for the lint. Absent means no claim is made about the
153
145
  * value — which is the honest answer wherever the specification's stated
154
146
  * shape and the shape notes are authored in today disagree.
155
147
  * @property {"address"|"shortcode"} [entryKind] - Type of each list or pack-map value.
156
- * @property {"address"|"shortcode"} [keyKind] - Type of each map key. On a
157
- * `list-or-map` field it describes the map form alone, and `entryKind` the
158
- * list form alone: reading both of one value would check a map's entries as
159
- * if they were its keys.
148
+ * @property {"address"|"shortcode"} [keyKind] - Type of each map key.
160
149
  * @property {boolean} [standings] - The map's entries are standings held in the
161
150
  * body each is keyed by, so a row names the two together — see
162
151
  * {@link module:engine/standings}.
@@ -164,6 +153,9 @@ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
164
153
  * so each takes a row labelled and anchored by the post's own name.
165
154
  * @property {"subType"} [keySelector] - An alternative `subType:<skill-subtype>` key.
166
155
  * @property {readonly string[]} [accepts] - Allowed Address types, separate from `ref`.
156
+ * @property {string} [declines] - The literal value that declines the art this
157
+ * Address field names. It is not an Address, is never resolved, and is not
158
+ * reported as unresolved.
167
159
  * @property {readonly ("prose"|"event")[]} [anchors] - The anchor kinds an
168
160
  * Address here may name with `#<anchor>`. Absent, an anchor is refused.
169
161
  * @property {boolean} [single] - The Address names exactly one event: an
@@ -179,6 +171,10 @@ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
179
171
  * one — `icon` for `icon` and `tokenIcon`, `image` for `banner` — so `icon: anvil` names `icon-anvil` while a value carrying the
180
172
  * separator states its own address. Distinct from a `FieldSpec`'s `code`
181
173
  * ({@link module:engine/field-spec}), which names a **Shortcode** instead.
174
+ * @property {string} [sceneProperty] - On a map, the exported Scene property an
175
+ * Address field's resolved path is written into, as a `data.fixup` path. The
176
+ * Scene compile applies the field as one more fixup — see
177
+ * {@link module:engine/exported-scene}.
182
178
  * @property {"pack"} [keys] - For a `scalar-or-map` field, what its keys name.
183
179
  * `"pack"` means each is a pack this package declares, so a key naming none
184
180
  * is a finding of its own: it addresses a hierarchy nothing will ever read.
@@ -196,6 +192,8 @@ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
196
192
  * entries are not Addresses (`entryKind` says that).
197
193
  * @property {object} [values] - What the value under each key of a keyed map
198
194
  * is, where the keys are data rather than declared names.
195
+ * @property {readonly string[]} [allowed] - The closed set a present string
196
+ * value is one of; any other word is a finding at the value.
199
197
  * @property {string} describe - One line, for the author-facing reference.
200
198
  */
201
199
 
@@ -243,6 +241,18 @@ const NUM = Object.freeze({ shape: "number", kind: "number" });
243
241
  /** A string, including the enumerated-value fields the format spells as one. */
244
242
  const TEXT = Object.freeze({ shape: "string", kind: "string" });
245
243
 
244
+ /** The words a being's `gender` takes. */
245
+ const BEING_GENDERS = Object.freeze(["male", "female", "nonbinary", "none", "other"]);
246
+
247
+ /** The words a being's `frame` takes, lightest to heaviest. */
248
+ const BEING_FRAMES = Object.freeze(["scant", "light", "medium", "heavy", "massive"]);
249
+
250
+ /** Words as code spans, joined by commas and a final "or". */
251
+ function wordList(words) {
252
+ const spans = words.map((word) => `\`${word}\``);
253
+ return `${spans.slice(0, -1).join(", ")} or ${spans.at(-1)}`;
254
+ }
255
+
246
256
  /** A list. */
247
257
  const LIST = Object.freeze({ shape: "list", kind: "list" });
248
258
 
@@ -258,8 +268,6 @@ const TEXT_LIST = Object.freeze({
258
268
  *
259
269
  * The two are the same fact at different lengths rather than two different
260
270
  * facts, so a single value means a list of one and a consumer reads one shape.
261
- * That is what separates this from `list-or-map`, whose halves say different
262
- * things.
263
271
  */
264
272
  const TEXT_OR_LIST = Object.freeze({
265
273
  shape: "string or list",
@@ -278,17 +286,11 @@ const LINKS = Object.freeze({ shape: "list of Addresses", kind: "list", entryKin
278
286
  *
279
287
  * A map keyed by the body's Address, whose entry is that membership's `rank`
280
288
  * and `office` — the pair a flat list of bodies cannot state, because it has
281
- * nowhere to say which body a standing is held in.
282
- *
283
- * **`list-or-map` is the sweep's shape**, and it says which half each
284
- * declaration describes: `entryKind` the list's entries, `keyKind` the map's
285
- * keys. A tree still writing the list form keeps the check it had, and the map
286
- * form earns the two the body can answer — see {@link module:engine/standings}.
289
+ * nowhere to say which body a standing is held in. A list is an error.
287
290
  */
288
291
  const STANDINGS = Object.freeze({
289
- shape: "a standing map keyed by Address, or a list of Addresses",
290
- kind: "list-or-map",
291
- entryKind: "address",
292
+ shape: "a standing map keyed by Address",
293
+ kind: "map",
292
294
  keyKind: "address",
293
295
  values: Object.freeze({
294
296
  kind: "map",
@@ -484,13 +486,10 @@ export const NOTE_TOP_LEVEL_KEY_SET = Object.freeze(new Set(NOTE_TOP_LEVEL_KEYS)
484
486
  * row in twenty-five tables would be twenty-five chances for one of them to
485
487
  * disagree with the rest.
486
488
  *
487
- * Both are art slots, and they are legal everywhere for different reasons.
488
- * `icon` is the document's profile art and most types compile into a document
489
- * that carries one; where a type's passes emit none, the frontmatter lint says
490
- * so as a warning rather than the vocabulary refusing the key, because the
491
- * value may still be read by a page template. `banner` reaches no compiled
492
- * document at all — it is the page's hero image — and a page is what every note
493
- * publishes.
489
+ * `banner` is an art slot legal everywhere: it reaches no compiled document, it
490
+ * is the page's hero image, and a page is what every note publishes. `icon` is
491
+ * not shared; it is declared by {@link ICON_FIELD} for the types whose
492
+ * document carries art.
494
493
  *
495
494
  * @type {readonly DataFieldSpec[]}
496
495
  */
@@ -533,24 +532,54 @@ export const SHARED_DATA_FIELDS = Object.freeze([
533
532
  ]),
534
533
  describe: "HârnWorld source details shared by every system — `realm` and `ritual`.",
535
534
  }),
536
- Object.freeze({
537
- name: "icon",
538
- ...LINK,
539
- ref: "icon",
540
- accepts: ART_SLOTS.find((slot) => slot.key === "icon").accepts,
541
- describe: "The document's profile art — an `icon` address, resolved into `img`.",
542
- }),
543
535
  Object.freeze({
544
536
  name: "banner",
545
537
  ...LINK,
546
538
  ref: "image",
547
539
  accepts: ART_SLOTS.find((slot) => slot.key === "banner").accepts,
540
+ declines: ART_SLOTS.find((slot) => slot.key === "banner").declines,
548
541
  describe:
549
- "The page's hero image — an `image` address, cut to 1792×768. " +
550
- "Reaches no compiled document.",
542
+ "The page's hero image — an `image` address, cut to 1792×768, or `none` " +
543
+ "to decline a hero image. Reaches no compiled document.",
551
544
  }),
552
545
  ]);
553
546
 
547
+ /**
548
+ * The profile-art field, declared for the types whose document carries art.
549
+ *
550
+ * @type {DataFieldSpec}
551
+ */
552
+ const ICON_FIELD = Object.freeze({
553
+ name: "icon",
554
+ ...LINK,
555
+ ref: "icon",
556
+ accepts: ART_SLOTS.find((slot) => slot.key === "icon").accepts,
557
+ describe: "The document's profile art — an `icon` address, resolved into `img`.",
558
+ });
559
+
560
+ /**
561
+ * The document types whose builder emits `icon` art: the Actor, Item and Macro
562
+ * passes and the Adventure (bundle) pass. A Scene and a JournalEntry carry
563
+ * none. Every pass declares this through its `emitsArt`, and a test holds the
564
+ * two statements together.
565
+ *
566
+ * @type {ReadonlySet<string>}
567
+ */
568
+ const ICON_DOCUMENT_TYPES = new Set(["Actor", "Item", "Macro", "Adventure"]);
569
+
570
+ /**
571
+ * Whether a note type compiles into a document carrying `icon` art.
572
+ *
573
+ * A folder and a homepage route like any other type but compile into no art.
574
+ *
575
+ * @param {string} type - The current content type.
576
+ * @returns {boolean} Whether `data.icon` is declared for the type.
577
+ */
578
+ function emitsIcon(type) {
579
+ if (type === "folder" || type === "homepage") return false;
580
+ return ICON_DOCUMENT_TYPES.has(packForType(type).docType);
581
+ }
582
+
554
583
  /**
555
584
  * An Address naming one event: `place-ironfells#sack`, or a note holding
556
585
  * exactly one event, with a bare shortcode read as a `lore` note.
@@ -686,9 +715,7 @@ function checkPlacePurpose(note) {
686
715
  *
687
716
  * It is a **presentation** fact and nothing more. A draft note compiles,
688
717
  * validates, publishes and resolves like any other; only a link *into* it
689
- * renders marked. What it emphatically is not is the retired `draft:` field,
690
- * whose entire effect was to move a note from published to unresolvable — see
691
- * {@link draftRetiredMessage}.
718
+ * renders marked.
692
719
  */
693
720
  export const DRAFT_TAG = "draft";
694
721
 
@@ -960,13 +987,6 @@ export const NOTE_VOCABULARY = Object.freeze({
960
987
  TEMPLATE_PRIORITY,
961
988
  { name: "archetypes", ...TEXT_LIST, describe: "Archetypal behaviours the being fits." },
962
989
  { name: "occupation", ...TEXT, describe: "What the being does for a living." },
963
- {
964
- name: "stations",
965
- ...LINKS,
966
- ref: "lore",
967
- accepts: ["lore"],
968
- describe: "Stations the being holds.",
969
- },
970
990
  {
971
991
  name: "lore",
972
992
  ...LINKS,
@@ -977,11 +997,7 @@ export const NOTE_VOCABULARY = Object.freeze({
977
997
  "subject to, the traditions it was raised in.",
978
998
  },
979
999
  {
980
- name: "culture",
981
- ...LINK,
982
- ref: "lore",
983
- accepts: ["lore"],
984
- check: checkCultureChoice,
1000
+ ...CULTURE_FIELD,
985
1001
  describe: "The being's primary culture, as a culture lore note.",
986
1002
  },
987
1003
  {
@@ -1016,8 +1032,9 @@ export const NOTE_VOCABULARY = Object.freeze({
1016
1032
  {
1017
1033
  name: "gender",
1018
1034
  ...TEXT,
1035
+ allowed: BEING_GENDERS,
1019
1036
  describe:
1020
- "One of `male`, `female`, `nonbinary`, `none` or `other`. " +
1037
+ `One of ${wordList(BEING_GENDERS)}. ` +
1021
1038
  "`none` says the being has no gender; an absent field says its " +
1022
1039
  "gender is unrecorded.",
1023
1040
  },
@@ -1042,7 +1059,7 @@ export const NOTE_VOCABULARY = Object.freeze({
1042
1059
  ref: "lore",
1043
1060
  accepts: ["lore"],
1044
1061
  check: checkCalendarChoice,
1045
- describe: "Calendar used to display this being's dates.",
1062
+ describe: "Calendar that displays this being's dates.",
1046
1063
  },
1047
1064
  {
1048
1065
  name: "died",
@@ -1088,8 +1105,8 @@ export const NOTE_VOCABULARY = Object.freeze({
1088
1105
  {
1089
1106
  name: "frame",
1090
1107
  ...TEXT,
1091
- describe:
1092
- "Relative frame — one of `scant`, `light`, `medium`, `heavy` or `massive`.",
1108
+ allowed: BEING_FRAMES,
1109
+ describe: `Relative frame — one of ${wordList(BEING_FRAMES)}.`,
1093
1110
  },
1094
1111
  { name: "appearance.eye_color", ...TEXT, describe: "Eye colour." },
1095
1112
  { name: "appearance.hair_color", ...TEXT, describe: "Hair colour." },
@@ -1230,6 +1247,10 @@ export const NOTE_VOCABULARY = Object.freeze({
1230
1247
  accepts: ["affiliation"],
1231
1248
  describe: "Affiliations it is subordinate to.",
1232
1249
  },
1250
+ {
1251
+ ...CULTURE_FIELD,
1252
+ describe: "The people this affiliation belongs to, as a culture lore note.",
1253
+ },
1233
1254
  {
1234
1255
  name: "relations",
1235
1256
  kind: "map",
@@ -1525,8 +1546,20 @@ export const NOTE_VOCABULARY = Object.freeze({
1525
1546
  "concept",
1526
1547
  "settingguide",
1527
1548
  ]),
1528
- check: checkCitedPopulations,
1529
- data: Object.freeze([]),
1549
+ // A setting guide introduces one culture and names it; no other genre
1550
+ // states one.
1551
+ check: (note, context) => [
1552
+ ...checkCitedPopulations(note, context),
1553
+ ...checkCultureSubType(note, context),
1554
+ ],
1555
+ data: Object.freeze([
1556
+ {
1557
+ ...CULTURE_FIELD,
1558
+ describe:
1559
+ "The culture a setting guide introduces, as a culture lore note. " +
1560
+ "Required on `settingguide`; no other subType states it.",
1561
+ },
1562
+ ]),
1530
1563
  }),
1531
1564
 
1532
1565
  macro: Object.freeze({ stubbable: true, data: Object.freeze([]) }),
@@ -1607,15 +1640,28 @@ export const NOTE_VOCABULARY = Object.freeze({
1607
1640
  ]),
1608
1641
  // A lore note is prose, and what it *is* about is its subType — with
1609
1642
  // two exceptions. A calendar is a division of the year, and a division
1610
- // is data; a work of literature states whose it is, in what tongue and
1611
- // about what. The checks scope each family to the subType that means
1612
- // it, because `DataFieldSpec` declares the keys a type accepts and not
1613
- // the subType that may write them.
1643
+ // is data; a work of literature states in what tongue it is and what it
1644
+ // concerns. The checks scope each family to the subType that means it,
1645
+ // because `DataFieldSpec` declares the keys a type accepts and not the
1646
+ // subType that may write them. Any lore note may name the people it
1647
+ // belongs to; a culture note names only itself.
1614
1648
  check: (note, context) => [
1615
1649
  ...checkCalendarNote(note, context),
1616
1650
  ...checkLiteratureNote(note),
1651
+ ...checkCultureSubType(note, context),
1617
1652
  ],
1618
- data: Object.freeze([...CALENDAR_FIELDS, ...LITERATURE_FIELDS, EVENTS_FIELD]),
1653
+ data: Object.freeze([
1654
+ ...CALENDAR_FIELDS,
1655
+ {
1656
+ ...CULTURE_FIELD,
1657
+ describe:
1658
+ "The people this lore belongs to — for a work of literature, the people " +
1659
+ "whose work it is — as a culture lore note. A culture note is its own " +
1660
+ "culture, and states the field only as itself.",
1661
+ },
1662
+ ...LITERATURE_FIELDS,
1663
+ EVENTS_FIELD,
1664
+ ]),
1619
1665
  }),
1620
1666
 
1621
1667
  place: Object.freeze({
@@ -1638,7 +1684,7 @@ export const NOTE_VOCABULARY = Object.freeze({
1638
1684
  ref: "lore",
1639
1685
  accepts: ["lore"],
1640
1686
  check: checkCalendarChoice,
1641
- describe: "Calendar used to display this place's dates.",
1687
+ describe: "Calendar that displays this place's dates.",
1642
1688
  },
1643
1689
  {
1644
1690
  name: "demonym",
@@ -1668,6 +1714,10 @@ export const NOTE_VOCABULARY = Object.freeze({
1668
1714
  accepts: ["place"],
1669
1715
  describe: "Enclosing places this one sits within.",
1670
1716
  },
1717
+ {
1718
+ ...CULTURE_FIELD,
1719
+ describe: "The people this place belongs to, as a culture lore note.",
1720
+ },
1671
1721
  {
1672
1722
  name: "population",
1673
1723
  ...NUM,
@@ -1836,6 +1886,18 @@ export const NOTE_VOCABULARY = Object.freeze({
1836
1886
  }),
1837
1887
  describe: "Asset address replacements in an exported Scene.",
1838
1888
  },
1889
+ {
1890
+ name: "thumbnail",
1891
+ ...LINK,
1892
+ ref: "image",
1893
+ accepts: Object.freeze(["image"]),
1894
+ // The Scene property the resolved path is written into, as a
1895
+ // fixup path: the compile applies it as one more `data.fixup`.
1896
+ sceneProperty: ".thumb",
1897
+ describe:
1898
+ "The map's thumbnail — an `image` address, resolved into the Scene's " +
1899
+ "`thumb` the way a `data.fixup` entry is. Background art is untouched.",
1900
+ },
1839
1901
  {
1840
1902
  name: "place",
1841
1903
  ...LINK,
@@ -1853,11 +1915,8 @@ export const NOTE_VOCABULARY = Object.freeze({
1853
1915
  * What a note carrying a subType outside the address charset is told.
1854
1916
  *
1855
1917
  * **Why the charset holds for a subType, which reaches no address.** The rule
1856
- * said
1857
- * "the hyphen separates the segments of an address", and that was true of a
1858
- * subType when it shipped: `sectionOf` returned a `doc`'s subType, so the value
1859
- * was a URL path segment. Sections are retired and it is not one. The rule
1860
- * stays, on its own footing: a subType is a vocabulary term the whole toolchain
1918
+ * says "the hyphen separates the segments of an address", and a subType is not
1919
+ * one. The rule stands on its own footing: a subType is a vocabulary term the whole toolchain
1861
1920
  * keys on, and it is one closed set away from being an address segment again —
1862
1921
  * so the reason to spell it in the address charset is that a charset holding
1863
1922
  * for a type, a shortcode and a `contentPackage` but not for a subType is a
@@ -1906,7 +1965,7 @@ export function typeCharsetMessage(type) {
1906
1965
  *
1907
1966
  * The message states the reason **per key**, as {@link typeCharsetMessage} and
1908
1967
  * {@link subTypeCharsetMessage} do: a type is an address segment, and a subType
1909
- * is not one, since sections are retired, so a single claim covering both
1968
+ * is not one, so a single claim covering both
1910
1969
  * would be half wrong.
1911
1970
  *
1912
1971
  * @param {Readonly<Record<string, TypeVocabulary>>} vocabulary - The registry.
@@ -1953,11 +2012,16 @@ assertVocabularyCharset(NOTE_VOCABULARY);
1953
2012
  * one, and is why the lint makes no claim rather than refusing every key.
1954
2013
  */
1955
2014
  export function dataFields(type, vocabulary = NOTE_VOCABULARY) {
1956
- const entry = vocabulary?.[currentType(type)];
2015
+ const entry = vocabulary?.[type];
1957
2016
  if (!entry) return undefined;
1958
2017
  // The shared keys first, so a type's own declarations keep their authored
1959
2018
  // order behind them and a reader meets the art before the mechanics.
1960
- return Object.freeze([...SHARED_DATA_FIELDS, ...(entry.data ?? [])]);
2019
+ const key = type;
2020
+ return Object.freeze([
2021
+ ...SHARED_DATA_FIELDS,
2022
+ ...(emitsIcon(key) ? [ICON_FIELD] : []),
2023
+ ...(entry.data ?? []),
2024
+ ]);
1961
2025
  }
1962
2026
 
1963
2027
  /**
@@ -1971,7 +2035,7 @@ export function dataFields(type, vocabulary = NOTE_VOCABULARY) {
1971
2035
  * it has no `subType` at all — see {@link TypeVocabulary}.
1972
2036
  */
1973
2037
  export function subTypes(type, vocabulary = NOTE_VOCABULARY) {
1974
- const entry = vocabulary?.[currentType(type)];
2038
+ const entry = vocabulary?.[type];
1975
2039
  if (!entry || !Object.hasOwn(entry, "subTypes")) return undefined;
1976
2040
  return entry.subTypes;
1977
2041
  }
@@ -1,4 +1,15 @@
1
- /* SPDX-License-Identifier: GPL-3.0-or-later */
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
+ */
2
13
 
3
14
  /** English cardinal words for finite whole numbers. */
4
15
  const ONES = [
@@ -14,7 +14,7 @@
14
14
  /**
15
15
  * The resolved package-build configuration the pack pipeline reads.
16
16
  *
17
- * One module, one import: everything the compilers used to hard-code — the
17
+ * One module, one import: everything the compilers need — the
18
18
  * content package, the Foundry package and its kind, every path, the `_stats`
19
19
  * identity, the item-type membership, and the pack list — arrives from the
20
20
  * consuming repository's `package-build.config.yaml`.
@@ -123,11 +123,7 @@ export const CONFIG_BASENAME = "package-build.config";
123
123
  * the order the names are *reported* in, which is the order a reader should
124
124
  * reach for them: YAML first, `.mjs` last.
125
125
  *
126
- * The `package-build.config.*` stem this package read before the two toolchains
127
- * merged is **not** resolved. 3.0.0 renames the file rather than accepting both:
128
- * a deprecation window here would mean a repository could sit indefinitely on a
129
- * name for a package that no longer exists, and the upgrade already requires
130
- * touching the consumer's manifest and imports.
126
+ * Only the `package-build.config.*` stem is resolved.
131
127
  *
132
128
  * @type {readonly string[]}
133
129
  */
@@ -142,7 +138,7 @@ export const CONFIG_FILENAMES = Object.freeze([
142
138
  *
143
139
  * **Two of them in one directory is an error.** Picking one by precedence would
144
140
  * mean a repository mid-conversion silently builds from the file its author is
145
- * no longer editing, and the build would look entirely healthy while doing it.
141
+ * not editing, and the build would look entirely healthy while doing it.
146
142
  * The walk continues past a directory holding none, so a repository may still
147
143
  * sit inside one that has its own.
148
144
  *
@@ -163,7 +159,7 @@ export function findConfigFile(from) {
163
159
  `package-build: ${dir} holds more than one configuration ` +
164
160
  `(${found.map((f) => path.basename(f)).join(", ")}). A ` +
165
161
  `repository declares its build in exactly one file — ` +
166
- `delete the one you are no longer editing.`,
162
+ `delete the one you are not using.`,
167
163
  );
168
164
  }
169
165
  if (found.length === 1) return found[0];
@@ -267,8 +263,8 @@ function readPackageJson(rootDir) {
267
263
  } catch (err) {
268
264
  throw new Error(
269
265
  `package-build: ${manifestPath} could not be read, and the ` +
270
- `configuration derives its Foundry package id, system version, ` +
271
- `homepage and author from it.`,
266
+ `configuration derives its Foundry package id, system version ` +
267
+ `and homepage from it.`,
272
268
  { cause: err },
273
269
  );
274
270
  }
@@ -555,24 +551,18 @@ export function configFromData(data, configPath) {
555
551
  input.foundryPackage = foundryPackageId(rootDir);
556
552
  }
557
553
 
558
- // `homepage` and `author` are the same kind of fact, transcribed the same
559
- // way, and read regardless of `packageKind`: a documentation package
560
- // publishes a site too, and needs both for it.
561
- for (const [field, label] of [
562
- ["homepage", "`homepage`"],
563
- ["author", "`author`"],
564
- ]) {
565
- if (input[field] !== undefined) {
566
- throw new Error(
567
- `package-build: ${configPath} declares \`${field}\`, which a data ` +
568
- `configuration may not: it is \`package.json\`'s own ${label}. ` +
569
- `Remove the key.`,
570
- );
571
- }
554
+ // `homepage` is `package.json`'s own, transcribed, and read regardless of
555
+ // `packageKind`: a documentation package publishes a site too, and needs
556
+ // it for that.
557
+ if (input.homepage !== undefined) {
558
+ throw new Error(
559
+ `package-build: ${configPath} declares \`homepage\`, which a data ` +
560
+ `configuration may not: it is \`package.json\`'s own \`homepage\`. ` +
561
+ `Remove the key.`,
562
+ );
572
563
  }
573
564
  const { pkg } = readPackageJson(rootDir);
574
565
  if (pkg.homepage !== undefined) input.homepage = pkg.homepage;
575
- if (pkg.author !== undefined) input.author = pkg.author;
576
566
 
577
567
  if (input.itemBuilders !== undefined) {
578
568
  const declared = input.itemBuilders;
@@ -15,14 +15,10 @@
15
15
  * Which pack a note's document lands in, when a document type has more than
16
16
  * one.
17
17
  *
18
- * A repository used to be able to ship exactly one pack per document type: the
19
- * pipeline ran one compile pass per type and routed every note of that type
20
- * into that pass's pack. Editorial grouping of same-type documents into
21
- * separate compendiums is ordinary Foundry practice — "Core Spells" and
22
- * "Expanded Spells" are two Item packs — and `sohl-kethira-basic` shipped
23
- * three. Collapsing them is a breaking change for every existing world,
24
- * because a compendium UUID carries its pack name
25
- * (`Compendium.<pkg>.characteristics.Item.<id>`).
18
+ * Editorial grouping of same-type documents into separate compendiums is
19
+ * ordinary Foundry practice — "Core Spells" and "Expanded Spells" are two Item
20
+ * packs. Collapsing them breaks every existing world, because a compendium
21
+ * UUID carries its pack name (`Compendium.<pkg>.characteristics.Item.<id>`).
26
22
  *
27
23
  * **A note declares its pack; the configuration declares the default.** The
28
24
  * routing key is one frontmatter field, `pack:`, naming a pack from the
@@ -100,10 +96,10 @@ export class PackRoutingError extends Error {
100
96
  /**
101
97
  * The frontmatter field a note declares its pack in.
102
98
  *
103
- * Deliberately close to the retired `package:` and deliberately not the same
104
- * word: `package:` said which *distribution* owned a note — now the
105
- * repository's `contentPackage`, and no longer authorable — while `pack:`
106
- * says which *compendium* receives its document.
99
+ * Deliberately close to `package:` and deliberately not the same word:
100
+ * `package:` would say which *distribution* owns a note — that is the
101
+ * repository's `contentPackage`, and not authorable — while `pack:` says which
102
+ * *compendium* receives its document.
107
103
  */
108
104
  export const PACK_FIELD = "pack";
109
105
 
@@ -132,9 +128,7 @@ export { NO_PACK };
132
128
  */
133
129
  export function declaresNoPack(fm, system) {
134
130
  const declared =
135
- system === undefined ?
136
- (fm?.data?.[PACK_FIELD] ?? fm?.[PACK_FIELD])
137
- : blockDataProperty(fm, system, PACK_FIELD);
131
+ system === undefined ? fm?.data?.[PACK_FIELD] : blockDataProperty(fm, system, PACK_FIELD);
138
132
  return declared === NO_PACK;
139
133
  }
140
134
 
@@ -274,7 +268,7 @@ export function createPackRouter(packs) {
274
268
  const authoredInBlock = inBlock != null && inBlock !== "";
275
269
  let declared =
276
270
  system === undefined ?
277
- (fm?.data?.[PACK_FIELD] ?? fm?.[PACK_FIELD])
271
+ fm?.data?.[PACK_FIELD]
278
272
  : blockDataProperty(fm, system, PACK_FIELD);
279
273
  // The declaration names where the note's *own* document goes. A pass
280
274
  // writing a document derived from it — an item's prose becoming a