@heroiclands/package-build 23.1.0 → 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 (248) hide show
  1. package/CHANGELOG.md +71 -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 +54 -6
  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/engine/yaml-lint.mjs +7 -4
  145. package/githooks/attribution.mjs +8 -0
  146. package/githooks/commit-message.mjs +9 -0
  147. package/hm3/actors.mjs +9 -48
  148. package/hm3/default-item-art.mjs +9 -9
  149. package/hm3/item-fields.mjs +2 -9
  150. package/hm3/template-priority.mjs +6 -7
  151. package/hugo-theme/data/banner-kinds.yaml +78 -0
  152. package/hugo-theme/data/banners.yaml +2 -3
  153. package/hugo-theme/layouts/404.html +3 -3
  154. package/hugo-theme/layouts/partials/banner-url.html +31 -7
  155. package/hugo-theme/layouts/partials/hero-banner.html +13 -14
  156. package/hugo-theme/layouts/partials/related.html +1 -1
  157. package/hugo-theme/layouts/partials/warn-missing-banner.html +1 -1
  158. package/hugo-theme/static/css/style.css +1 -1
  159. package/manifest.mjs +11 -4
  160. package/markdownlint-config.mjs +1 -1
  161. package/package.json +6 -3
  162. package/release.mjs +83 -18
  163. package/sohl/actors.mjs +16 -27
  164. package/sohl/default-item-art.mjs +3 -3
  165. package/sohl/document-subtypes.mjs +1 -16
  166. package/sohl/infobox.mjs +2 -3
  167. package/sohl/item-builders.mjs +5 -8
  168. package/sohl/item-fields.mjs +5 -35
  169. package/sohl/items.mjs +0 -4
  170. package/sohl/kb-passes.mjs +3 -3
  171. package/types/container.d.mts +116 -5
  172. package/types/content-config.d.mts +83 -35
  173. package/types/deploy.d.mts +42 -6
  174. package/types/e2e.d.mts +35 -3
  175. package/types/engine/address-charset.d.mts +4 -17
  176. package/types/engine/address.d.mts +17 -8
  177. package/types/engine/art-fields.d.mts +7 -4
  178. package/types/engine/art-slots.d.mts +6 -0
  179. package/types/engine/base-compiler.d.mts +15 -17
  180. package/types/engine/content-address.d.mts +9 -12
  181. package/types/engine/content-embeds.d.mts +4 -2
  182. package/types/engine/content-format-check.d.mts +0 -64
  183. package/types/engine/content-index.d.mts +82 -3
  184. package/types/engine/content-links.d.mts +2 -4
  185. package/types/engine/culture-choice.d.mts +51 -8
  186. package/types/engine/data-keys.d.mts +2 -2
  187. package/types/engine/derived-fields.d.mts +1 -2
  188. package/types/engine/diagnostics.d.mts +27 -4
  189. package/types/engine/document-subtypes.d.mts +12 -31
  190. package/types/engine/event-views.d.mts +14 -2
  191. package/types/engine/exported-scene.d.mts +3 -2
  192. package/types/engine/field-spec.d.mts +13 -122
  193. package/types/engine/folder-notes.d.mts +0 -39
  194. package/types/engine/foreign-catalog.d.mts +2 -1
  195. package/types/engine/foundry-entries.d.mts +4 -5
  196. package/types/engine/frontmatter-lint.d.mts +2 -2
  197. package/types/engine/frontmatter.d.mts +19 -45
  198. package/types/engine/generate.d.mts +1 -1
  199. package/types/engine/helpers.d.mts +25 -42
  200. package/types/engine/homepage.d.mts +3 -10
  201. package/types/engine/ids.d.mts +0 -114
  202. package/types/engine/index-records.d.mts +6 -4
  203. package/types/engine/index.d.mts +0 -1
  204. package/types/engine/infobox-render.d.mts +7 -1
  205. package/types/engine/infobox.d.mts +3 -2
  206. package/types/engine/item-docs.d.mts +1 -1
  207. package/types/engine/item-registry.d.mts +2 -4
  208. package/types/engine/literature-notes.d.mts +1 -1
  209. package/types/engine/markdownlint-run.d.mts +40 -0
  210. package/types/engine/metadata-index.d.mts +9 -1
  211. package/types/engine/note-addresses.d.mts +27 -2
  212. package/types/engine/note-claims.d.mts +3 -5
  213. package/types/engine/note-ids.d.mts +2 -4
  214. package/types/engine/note-package.d.mts +1 -1
  215. package/types/engine/note-state.d.mts +1 -3
  216. package/types/engine/note-vocabulary.d.mts +28 -21
  217. package/types/engine/pack-config.d.mts +2 -6
  218. package/types/engine/pack-router.d.mts +4 -4
  219. package/types/engine/pathnames.d.mts +3 -3
  220. package/types/engine/pdf-build.d.mts +98 -12
  221. package/types/engine/pdf-coverage.d.mts +60 -0
  222. package/types/engine/pdf-index.d.mts +34 -5
  223. package/types/engine/pdf-pages.d.mts +143 -0
  224. package/types/engine/pdf-render.d.mts +45 -1
  225. package/types/engine/pdf-toc.d.mts +12 -14
  226. package/types/engine/prose-config.d.mts +1 -1
  227. package/types/engine/prose-lint.d.mts +11 -10
  228. package/types/engine/runtime-only-fields.d.mts +2 -2
  229. package/types/engine/site-build.d.mts +9 -5
  230. package/types/engine/site-index.d.mts +4 -5
  231. package/types/engine/site-root.d.mts +1 -1
  232. package/types/engine/sql-tables.d.mts +31 -2
  233. package/types/engine/standing-terms.d.mts +2 -1
  234. package/types/engine/standings.d.mts +7 -9
  235. package/types/engine/system-block.d.mts +10 -44
  236. package/types/engine/walk-files.d.mts +15 -0
  237. package/types/engine/wikilink-syntax.d.mts +18 -19
  238. package/types/engine/yaml-lint.d.mts +5 -4
  239. package/types/hm3/actors.d.mts +1 -2
  240. package/types/hm3/item-fields.d.mts +2 -4
  241. package/types/hm3/template-priority.d.mts +6 -7
  242. package/types/manifest.d.mts +3 -2
  243. package/types/release.d.mts +12 -7
  244. package/types/sohl/actors.d.mts +2 -1
  245. package/types/sohl/document-subtypes.d.mts +1 -2
  246. package/types/sohl/item-fields.d.mts +3 -7
  247. package/engine/retired-fields.mjs +0 -631
  248. package/types/engine/retired-fields.d.mts +0 -354
@@ -27,9 +27,8 @@
27
27
  * written target expands to exactly one canonical address, from the
28
28
  * position's own defaults — the package it omits is always this one — so a
29
29
  * lookup finds one entry or none. There is no candidate set to disambiguate.
30
- * 3. **An unlabelled link.** `[[x]]` addresses nothing: the alias namespace it
31
- * once named is retired, and a shortcode is an address rather than
32
- * prose, so the link has neither a resolvable target nor text to show. The
30
+ * 3. **An unlabelled link.** `[[x]]` addresses nothing: a shortcode is an address
31
+ * rather than prose, so the link has neither a resolvable target nor text to show. The
33
32
  * correction is always `[[type-shortcode|Text]]`.
34
33
  * 4. **A wikilink authored in frontmatter.** Both builds walk a note's *body*
35
34
  * and copy frontmatter through verbatim, so a link written in a
@@ -47,7 +46,7 @@
47
46
  * **What this deliberately does not do.** Corpus reachability — "every rules
48
47
  * document is reachable from the book's root" — is a statement about what one
49
48
  * package publishes, not about the note format, so it belongs with the
50
- * publishing it describes; so does a retired hostname. Both are served by the
49
+ * publishing it describes; so does a withdrawn hostname. Both are served by the
51
50
  * link graph returned here rather than implemented here.
52
51
  *
53
52
  * @module
@@ -90,7 +89,6 @@ import {
90
89
  import { loadForeignIndexes, noContentIndexPackages } from "./metadata-index.mjs";
91
90
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
92
91
  import { homepageAddresses, isHomepage } from "./homepage.mjs";
93
- import { RETIRED_TYPES } from "./ids.mjs";
94
92
  import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
95
93
  import { readQualifier } from "./wikilinks.mjs";
96
94
 
@@ -120,8 +118,8 @@ export function anchorsOf(body) {
120
118
  * Read a content tree into the index a link resolves against.
121
119
  *
122
120
  * **The corpus comes from the content index, not from a walk of this module's
123
- * own**. Every pass used to answer "which files are the content?" for
124
- * itself and throw the answer away; this one now reads
121
+ * own**. Passes do not each answer "which files are the content?" for
122
+ * themselves; this one reads
125
123
  * {@link module:engine/content-index.indexRecordsFor}, which is the same
126
124
  * derivation the published artifact and the compilers are driven from. So a
127
125
  * note the index records is a note the link check sees, and the addresses and
@@ -167,8 +165,9 @@ export function buildLinkIndex(
167
165
  const frontmatterLinks = [];
168
166
 
169
167
  // The one package every note in this tree belongs to. Taken from the
170
- // configuration this build resolved — never from a note (`package:` is
171
- // retired, so there is no second source an address could disagree with)
168
+ // configuration this build resolved — never from a note (a note
169
+ // declares no package, so there is no second source an address could
170
+ // disagree with)
172
171
  // and never from the ambient one, which is a different configuration
173
172
  // whenever a test injects one, `PACKAGE_BUILD_CONFIG` names one, or the
174
173
  // command runs from a worktree.
@@ -379,6 +378,8 @@ export function buildLinkIndex(
379
378
  target: u.target,
380
379
  reason: u.reason,
381
380
  ...(u.type ? { type: u.type } : {}),
381
+ ...(u.shortcode ? { shortcode: u.shortcode } : {}),
382
+ ...(u.declared ? { declared: u.declared } : {}),
382
383
  })),
383
384
  ...problems.map((problem) => ({
384
385
  text: problem.link,
@@ -786,8 +787,6 @@ function readAddress(url, packages) {
786
787
  * **What is checkable, stated plainly.** Only an address into this site is, and
787
788
  * only against facts this build already holds:
788
789
  *
789
- * - A **retired content type** in the path. The engine knows the retired names
790
- * and what replaced it, so this is a fact rather than a guess.
791
790
  * - A **hardcoded absolute URL** into this package's own prefix, or into one a
792
791
  * fetched index names. Every one of them has a better form to write, which
793
792
  * is why every one is reported — including a bare `/<package>/`, which names
@@ -890,30 +889,6 @@ export function auditHomepageLinks(index) {
890
889
  `relocation does not leave this page behind`,
891
890
  );
892
891
  }
893
-
894
- // The retired-type rule reads the path *inside* the package, so an
895
- // address that named one is fixed the same way wherever it was
896
- // written.
897
- const inPackage = prefix ? segments.slice(1) : segments;
898
- for (const [i, segment] of inPackage.entries()) {
899
- // `hasOwn`, not a plain lookup: a path segment spelled
900
- // `constructor` would otherwise inherit a truthy answer from
901
- // `Object.prototype` and be reported as retired.
902
- if (!Object.hasOwn(RETIRED_TYPES, segment)) continue;
903
- const replacement = RETIRED_TYPES[segment];
904
- const fixed = [...inPackage];
905
- fixed[i] = replacement;
906
- report(
907
- field,
908
- url,
909
- url,
910
- occurrence,
911
- `address "${url}" names content type "${segment}", ` +
912
- `retired in favour of "${replacement}" — both ` +
913
- `compiled to the same document, so the fix is ` +
914
- `mechanical: "${fixed.join("/")}/"`,
915
- );
916
- }
917
892
  }
918
893
  }
919
894
 
@@ -1015,6 +990,25 @@ export function auditLinks(index) {
1015
990
  deadAddresses.push({ ...at, reason: "not-an-address" });
1016
991
  continue;
1017
992
  }
993
+ // A capital in the shortcode is refused, never lowercased. The
994
+ // lowercase spelling is looked up only to name it in the finding.
995
+ const cased = readQualifier(target, index.types, index.packages, index.noIndexPackages);
996
+ if (cased?.reason === "shortcode-not-lowercase") {
997
+ const lower = `${target.slice(0, -cased.shortcode.length)}${cased.lowercase.shortcode}`;
998
+ const declared =
999
+ index.resolveAddress(lower) ?? index.stubAt?.(lower) ?? manifestHit(lower);
1000
+ deadAddresses.push({
1001
+ ...at,
1002
+ reason: "shortcode-not-lowercase",
1003
+ shortcode: cased.shortcode,
1004
+ ...(declared ? { declared: cased.lowercase.shortcode } : {}),
1005
+ });
1006
+ continue;
1007
+ }
1008
+ if (cased?.reason === "not-lowercase") {
1009
+ deadAddresses.push({ ...at, reason: "not-lowercase" });
1010
+ continue;
1011
+ }
1018
1012
  const resolved = index.resolveAddress(target);
1019
1013
  if (resolved) {
1020
1014
  if ((isGmNote(resolved.fm) || resolved.gm) && !isGmNote(note.fm)) {
@@ -44,13 +44,12 @@
44
44
  * **There is deliberately no third rule** requiring every note to repeat its
45
45
  * own `type-shortcode` address in a top-level `aliases:` list. It would serve
46
46
  * no build reader: both resolvers parse the hyphen
47
- * qualifier themselves. The field is retired, refused from
48
- * `retired-fields.mjs`.
47
+ * qualifier themselves.
49
48
  *
50
49
  * **What is deliberately absent.** Corpus reachability — "every Rules document
51
50
  * is reachable from the book's root" — is a statement about what one package
52
51
  * publishes, not about the note format, and belongs with the publishing it
53
- * describes. So do retired hostnames.
52
+ * describes. So do withdrawn hostnames.
54
53
  *
55
54
  * @module
56
55
  */
@@ -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
  /** The authored verse lines, excluding stanza separators. */
4
15
  export function verseLines(body) {
@@ -19,25 +19,13 @@
19
19
  * address, `type-shortcode`, derived in `engine/content-address.mjs` and
20
20
  * touching no display string at all.
21
21
  *
22
- * It is not the other way round: a `contentSlug` here that
23
- * derived a page's URL from `name.full`, abbreviating it through a table of 200
24
- * words so the result stayed short, and a `findSlugCollisions` to catch the two
25
- * notes that would then claim one URL. Its own header justified the readability
26
- * cost with a rename-survival story — *"every change appends to the legacy-URL
27
- * map, which emits a redirect"* — and no such map was ever written, in this
28
- * package or in any consumer. So a rename silently 404'd every existing link,
29
- * a display string was load-bearing, and a uniqueness check was needed to keep
30
- * it correct. An address has none of those properties, so all three went.
31
- *
32
- * What is left is the normalisation the rest of the build still needs, in the
33
- * two places it was always right for:
22
+ * The normalisation applies in two places:
34
23
  *
35
24
  * - **heading anchors**, where an author writes the matching key by hand — a map
36
25
  * note pins `locations.stair-foot` at a heading called *Stair Foot*;
37
26
  * - **pack filenames**, read back only by the unpacker.
38
27
  *
39
- * Neither is abbreviated, and neither ever was: abbreviation existed solely to
40
- * shorten a name-derived URL, so it left with it.
28
+ * Neither is abbreviated.
41
29
  *
42
30
  * What the rule does, and why:
43
31
  *
@@ -1,4 +1,16 @@
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
+ */
13
+
2
14
  import MarkdownIt from "markdown-it";
3
15
  import { alertMarkdownPlugin } from "./content-alerts.mjs";
4
16
  import { slugify } from "./content-slug.mjs";
@@ -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
  import { FENCE_LINE } from "./code-fences.mjs";
4
15
  import { PAGE_LIST_LANGUAGE } from "./page-lists.mjs";
@@ -1,32 +1,229 @@
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
+ */
13
+
14
+ /**
15
+ * `data.culture`: the people a note belongs to, named as a `lore` note of
16
+ * `subType: culture`.
17
+ *
18
+ * One declaration serves every type that takes the field. Where a type's
19
+ * subtypes differ on it — a setting guide requires it, other `doc` genres
20
+ * refuse it, and a culture note names only itself — {@link checkCultureSubType}
21
+ * scopes it, because
22
+ * `DataFieldSpec` declares the keys a type accepts and not the subType that may
23
+ * write them.
24
+ *
25
+ * @module
26
+ */
27
+
28
+ import {
29
+ canonicalKey,
30
+ isAddressTuple,
31
+ ownDocumentSystem,
32
+ parseAddress,
33
+ renderAddress,
34
+ } from "./address.mjs";
35
+ import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
36
+
37
+ /** The `lore` subType a culture names, and the one whose culture is itself. */
38
+ const CULTURE_SUBTYPE = "culture";
39
+
40
+ /** The `doc` subType that requires a culture; every other `doc` subType refuses it. */
41
+ const SETTING_GUIDE_SUBTYPE = "settingguide";
42
+
43
+ /**
44
+ * The Address a `data.culture` value names, read against the defaults of the
45
+ * position it is written in.
46
+ *
47
+ * @param {unknown} value - The value as written, or an already decoded tuple.
48
+ * @param {object} [index] - The link index, for the package and vocabularies.
49
+ * @returns {object} The tuple, or the parser's problem.
50
+ */
51
+ function cultureAddress(value, index) {
52
+ return parseAddress(value, {
53
+ package: index?.contentPackage,
54
+ system: "note",
55
+ type: "lore",
56
+ types: index?.types,
57
+ packages: index?.packages,
58
+ });
59
+ }
2
60
 
3
- import { isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
4
- import { positionOfFrontmatterPath } from "./diagnostics.mjs";
61
+ /**
62
+ * Whether a `data.culture` value names the note that carries it.
63
+ *
64
+ * The comparison is between canonical Addresses, so a culture note's shortcode,
65
+ * any longer form of its Address and a decoded tuple all name it alike. A note
66
+ * with no shortcode has no Address to compare against and names nothing.
67
+ *
68
+ * @param {object} fm - The note's frontmatter.
69
+ * @param {unknown} value - The `data.culture` value, as written or decoded.
70
+ * @param {object} [index] - The package and vocabularies a written value is
71
+ * read against, and the package the note's own Address is in.
72
+ * @returns {boolean} Whether the value is the note's own Address.
73
+ */
74
+ export function cultureNamesItself(fm, value, index) {
75
+ const shortcode = fm?.shortcode;
76
+ const type = String(fm?.type ?? "").toLowerCase();
77
+ if (typeof shortcode !== "string" || !shortcode.trim() || !type) return false;
78
+ if (value === undefined || value === null) return false;
79
+ const tuple = cultureAddress(value, index);
80
+ if (tuple.reason) return false;
81
+ return (
82
+ renderAddress(tuple) ===
83
+ canonicalKey(index?.contentPackage, ownDocumentSystem(type), type, shortcode.trim())
84
+ );
85
+ }
5
86
 
6
87
  /**
7
- * Require a being's primary culture to address a culture lore note.
88
+ * Whether a note is a `lore` note of `subType: culture`.
89
+ *
90
+ * @param {object} note - The note, as the link index hands it over.
91
+ * @returns {boolean} Whether it is a culture note.
92
+ */
93
+ function isCultureNote(note) {
94
+ return (
95
+ String(note?.type ?? note?.fm?.type ?? "").toLowerCase() === "lore" &&
96
+ String(note?.fm?.subType ?? "").toLowerCase() === CULTURE_SUBTYPE
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Require a note's `data.culture` to address a culture lore note.
102
+ *
103
+ * A value that is not a `lore` Address, or names nothing, is left to the
104
+ * reference check, which reports it in its own terms. A culture note's own
105
+ * value is {@link checkCultureSubType}'s to judge, since it names only the note
106
+ * itself.
107
+ *
8
108
  * @param {object} note
9
109
  * @param {{index?: object}} [options]
110
+ * @returns {object[]} One error at the value when it names lore of another subType.
10
111
  */
11
112
  export function checkCultureChoice(note, { index } = {}) {
12
113
  const value = note.fm?.data?.culture;
13
114
  if (value === undefined || value === null || !index?.addressHit) return [];
14
- const tuple = parseAddress(value, {
15
- package: index.contentPackage,
16
- system: "note",
17
- type: "lore",
18
- types: index.types,
19
- packages: index.packages,
20
- });
115
+ if (isCultureNote(note)) return [];
116
+ const tuple = cultureAddress(value, index);
21
117
  if (tuple.reason || tuple.type !== "lore") return [];
22
118
  const target = index.addressHit(renderAddress(tuple));
23
- if (!target || (target.fm ?? target).subType === "culture") return [];
119
+ if (!target || (target.fm ?? target).subType === CULTURE_SUBTYPE) return [];
120
+ const type = String(note.type ?? note.fm?.type ?? "note");
121
+ const article = /^[aeiou]/i.test(type) ? "an" : "a";
24
122
  return [
25
123
  {
26
124
  file: note.file,
27
125
  ...positionOfFrontmatterPath(note.raw ?? "", ["data", "culture"]),
28
126
  severity: "error",
29
- message: `data.culture ${isAddressTuple(value) ? renderAddress(value) : value} names lore whose subType is not culture`,
127
+ message:
128
+ `data.culture ${isAddressTuple(value) ? renderAddress(value) : value} names lore ` +
129
+ `whose subType is not culture; ${article} ${type}'s culture is a culture lore note`,
30
130
  },
31
131
  ];
32
132
  }
133
+
134
+ /**
135
+ * The `data.culture` field, declared once and spread into each type that takes
136
+ * it. A type gives its own `describe` where the field means something narrower
137
+ * there.
138
+ *
139
+ * @type {Readonly<object>}
140
+ */
141
+ export const CULTURE_FIELD = Object.freeze({
142
+ name: "culture",
143
+ shape: "an Address",
144
+ kind: "address",
145
+ ref: "lore",
146
+ accepts: Object.freeze(["lore"]),
147
+ check: checkCultureChoice,
148
+ describe: "The people this note belongs to, as a culture lore note.",
149
+ });
150
+
151
+ /**
152
+ * Scope `data.culture` to the subtypes that take it.
153
+ *
154
+ * A `doc` of `subType: settingguide` introduces one culture's setting and must
155
+ * name it; a missing value is an error at the `subType:` line. Every other `doc`
156
+ * subType refuses a written value, as an error at the key.
157
+ *
158
+ * A `lore` note of `subType: culture` is itself the culture. It may omit the
159
+ * field or name itself, in any form the Address parser reads; a value naming any
160
+ * other Address is an error at the value. A note with no shortcode has no
161
+ * Address to compare against, and the shortcode check reports it.
162
+ *
163
+ * @param {object} note - The note, as the link index hands it over.
164
+ * @param {{index?: object}} [options] - The link index, for the package and
165
+ * vocabularies a written value is read against.
166
+ * @returns {object[]} Findings.
167
+ */
168
+ export function checkCultureSubType(note, { index } = {}) {
169
+ const type = String(note?.type ?? note?.fm?.type ?? "").toLowerCase();
170
+ const subType = String(note?.fm?.subType ?? "").toLowerCase();
171
+ const data = note?.fm?.data;
172
+ const written =
173
+ data !== null &&
174
+ typeof data === "object" &&
175
+ !Array.isArray(data) &&
176
+ data.culture !== undefined &&
177
+ data.culture !== null;
178
+ const atKey = (message) => ({
179
+ file: note.file,
180
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "culture"], { key: true }),
181
+ severity: "error",
182
+ message,
183
+ });
184
+
185
+ if (type === "doc") {
186
+ if (subType === SETTING_GUIDE_SUBTYPE) {
187
+ if (written) return [];
188
+ return [
189
+ {
190
+ file: note.file,
191
+ ...positionInFrontmatter(note.raw ?? "", "subType", undefined, {
192
+ topLevel: true,
193
+ }),
194
+ severity: "error",
195
+ message:
196
+ "a setting guide introduces one culture, so this note must state " +
197
+ "`data.culture`, naming a culture lore note",
198
+ },
199
+ ];
200
+ }
201
+ if (!written) return [];
202
+ return [
203
+ atKey(
204
+ `\`data.culture\` is stated by a setting guide, and this note's \`subType\` ` +
205
+ `is not \`${SETTING_GUIDE_SUBTYPE}\``,
206
+ ),
207
+ ];
208
+ }
209
+
210
+ if (type === "lore" && subType === CULTURE_SUBTYPE && written) {
211
+ const shortcode = note.fm?.shortcode;
212
+ if (typeof shortcode !== "string" || !shortcode.trim()) return [];
213
+ const value = data.culture;
214
+ if (cultureAddress(value, index).reason) return [];
215
+ if (cultureNamesItself({ ...note.fm, type }, value, index)) return [];
216
+ return [
217
+ {
218
+ file: note.file,
219
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "culture"]),
220
+ severity: "error",
221
+ message:
222
+ `data.culture ${isAddressTuple(value) ? renderAddress(value) : value} ` +
223
+ `names another note; a culture note's data.culture names the note itself`,
224
+ },
225
+ ];
226
+ }
227
+
228
+ return [];
229
+ }
@@ -57,7 +57,7 @@ import { nearest } from "./near-miss.mjs";
57
57
  * @property {string} name - The key as an author writes it.
58
58
  * @property {string} [kind] - Its value's shape: `string`, `date`, `number`,
59
59
  * `integer`, `boolean`, `address`, `list`, `map`, `scalar-or-map`,
60
- * `list-or-map` or `string-or-list` — see {@link fitsKind}. Absent, the check
60
+ * or `string-or-list` — see {@link fitsKind}. Absent, the check
61
61
  * makes no claim about the value.
62
62
  * @property {readonly string[]} [allowed] - The closed set a present value is
63
63
  * one of, where the key takes a fixed word.
@@ -143,8 +143,6 @@ export function fitsKind(value, kind) {
143
143
  );
144
144
  case "scalar-or-map":
145
145
  return isScalar(value) || fitsKind(value, "map");
146
- case "list-or-map":
147
- return Array.isArray(value) || fitsKind(value, "map");
148
146
  case "string-or-list":
149
147
  return isScalar(value) || Array.isArray(value);
150
148
  default:
@@ -287,21 +285,25 @@ export function checkDataKeys(note, fields) {
287
285
  );
288
286
  return;
289
287
  }
290
- if (spec.allowed && !spec.allowed.includes(value)) {
291
- const guess = typeof value === "string" ? nearest(value, spec.allowed) : undefined;
292
- findings.push(
293
- finding(
294
- path,
295
- `\`${pathLabel(path)}\` should be ${spec.allowed.map((word) => `\`${word}\``).join(" or ")}, ` +
296
- `but reads ${JSON.stringify(value)}` +
297
- (guess ? `. Did you mean "${guess}"?` : ""),
298
- ),
299
- );
300
- return;
301
- }
288
+ if (!inAllowed(spec, value, path)) return;
302
289
  walk(spec, value, path);
303
290
  };
304
291
 
292
+ /** Whether a present value is in the spec's closed set, if it has one; else a finding. */
293
+ const inAllowed = (spec, value, path) => {
294
+ if (!spec.allowed || spec.allowed.includes(value)) return true;
295
+ const guess = typeof value === "string" ? nearest(value, spec.allowed) : undefined;
296
+ findings.push(
297
+ finding(
298
+ path,
299
+ `\`${pathLabel(path)}\` should be ${spec.allowed.map((word) => `\`${word}\``).join(" or ")}, ` +
300
+ `but reads ${JSON.stringify(value)}` +
301
+ (guess ? `. Did you mean "${guess}"?` : ""),
302
+ ),
303
+ );
304
+ return false;
305
+ };
306
+
305
307
  /** Close one map to its declared keys, and walk each that is present. */
306
308
  const closeMap = (inner, value, path) => {
307
309
  const names = inner.map((spec) => spec.name);
@@ -341,7 +343,8 @@ export function checkDataKeys(note, fields) {
341
343
  // A dotted group's own shape is checked here: the container check
342
344
  // reads its members' leaves and has nothing to say about the group.
343
345
  if (spec.grouped) visit(spec, value, path);
344
- else if (fitsKind(value, spec.kind)) walk(spec, value, path);
346
+ else if (fitsKind(value, spec.kind) && inAllowed(spec, value, path))
347
+ walk(spec, value, path);
345
348
  }
346
349
  return findings;
347
350
  }
@@ -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
  import { eraYear, formatCanonicalDate, parseCanonicalDate } from "./calendars.mjs";
4
15
  import { addressedCalendarDate } from "./calendar-human.mjs";
@@ -1,4 +1,13 @@
1
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
+ *
2
11
  * SPDX-License-Identifier: GPL-3.0-or-later
3
12
  */
4
13
 
@@ -15,16 +15,16 @@
15
15
  * `system` keys a note may **never** author, because the *compiler* writes them
16
16
  * from the note itself.
17
17
  *
18
- * The third of three refusals that read alike and state different facts:
18
+ * The companion of `runtime-only-fields.mjs`, and the two state different
19
+ * facts:
19
20
  *
20
21
  * | module | refuses a field | because |
21
22
  * | --- | --- | --- |
22
- * | `retired-fields.mjs` | a note may no longer declare | it has been withdrawn |
23
23
  * | `runtime-only-fields.mjs` | a note never could | the *document* writes it in play |
24
24
  * | this one | a note never could | the *compiler* writes it, from the note |
25
25
  *
26
- * The distinction between the last two is where the value comes from, and it
27
- * decides what an author is told. A runtime-only field has no compile-time
26
+ * The distinction is where the value comes from, and it decides what an author
27
+ * is told. A runtime-only field has no compile-time
28
28
  * value at all, so the fix is to delete the key. A derived one **does** have a
29
29
  * compile-time value — it is already being written, from somewhere else in the
30
30
  * same note — so the fix is to move the content to wherever the compiler reads
@@ -54,7 +54,7 @@
54
54
  */
55
55
 
56
56
  import { getFrontmatter } from "./frontmatter.mjs";
57
- import { locateFrontmatterKey } from "./retired-fields.mjs";
57
+ import { locateFrontmatterKey } from "./diagnostics.mjs";
58
58
  import { SYSTEM_DATA_KEY, systemData } from "./system-block.mjs";
59
59
 
60
60
  /**
@@ -88,8 +88,7 @@ export function derivedMessage(key, from, file) {
88
88
  /**
89
89
  * The derived keys a note actually writes, in declaration order.
90
90
  *
91
- * **Presence is the whole test**, as it is for a retired or a runtime-only
92
- * field: an authored empty string is as much a claim on the key as prose is,
91
+ * **Presence is the whole test**, as it is for a runtime-only field: an authored empty string is as much a claim on the key as prose is,
93
92
  * and it is the same belief the message exists to correct. So the question is
94
93
  * whether the path resolves to anything at all, never whether the value is a
95
94
  * usable one.
@@ -42,6 +42,7 @@
42
42
  * which would send a reader to the frontmatter every time.
43
43
  */
44
44
 
45
+ import fs from "node:fs";
45
46
  import path from "node:path";
46
47
  import YAML, { LineCounter } from "yaml";
47
48
 
@@ -183,10 +184,8 @@ export function positionInBody(body, offset, { bodyLine = 1, bodyColumn = 1, lin
183
184
  * The key match tolerates leading whitespace by default, so a nested key of the
184
185
  * same name answers when no top-level one is present — which is usually what a
185
186
  * reader wants, the key being unique in nearly every note. Pass `topLevel` where
186
- * it is not: `aliases` is both a retired top-level field and a **permitted**
187
- * `name.aliases`, and a finding about the first must never open on the
188
- * second, which would tell an author to delete a field they are allowed to
189
- * write.
187
+ * it is not: a finding about a top-level key must never open on a nested key
188
+ * of the same name.
190
189
  *
191
190
  * @param {string} raw - The file's full contents, frontmatter included.
192
191
  * @param {string} key - The top-level frontmatter key.
@@ -235,6 +234,37 @@ export function positionInFrontmatter(raw, key, value = undefined, { topLevel =
235
234
  };
236
235
  }
237
236
 
237
+ /**
238
+ * A frontmatter key's position in a note's file, or nothing.
239
+ *
240
+ * {@link positionInFrontmatter} answers the same question from the file's
241
+ * *text*; this reads the file to ask it.
242
+ *
243
+ * @param {string|undefined} absPath - The note's file.
244
+ * @param {string} key - The frontmatter key.
245
+ * @param {string} [value] - When given, prefer the occurrence whose line also
246
+ * carries this text — so a finding about one entry of a block opens on that
247
+ * entry rather than on the key that introduces it.
248
+ * @param {object} [options] - Options, forwarded to
249
+ * {@link positionInFrontmatter}.
250
+ * @param {boolean} [options.topLevel=false] - Require the key at column 1, so
251
+ * an identically named nested key cannot answer for it.
252
+ * @returns {{line?: number, column?: number}|undefined} Spreadable position
253
+ * fields, dropped rather than guessed when the file cannot be read or the key
254
+ * cannot be found — as `formatDiagnostic` requires.
255
+ */
256
+ export function locateFrontmatterKey(absPath, key, value = undefined, { topLevel = false } = {}) {
257
+ if (!absPath) return undefined;
258
+ let raw;
259
+ try {
260
+ raw = fs.readFileSync(absPath, "utf8");
261
+ } catch {
262
+ return undefined;
263
+ }
264
+ const at = positionInFrontmatter(raw, key, value, { topLevel });
265
+ return at.line === undefined ? undefined : at;
266
+ }
267
+
238
268
  /** Escape a literal for use inside a `RegExp`. */
239
269
  function escape(literal) {
240
270
  return String(literal).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");