@heroiclands/package-build 23.1.1 → 23.2.1

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 (247) hide show
  1. package/CHANGELOG.md +69 -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/labels.mjs +2 -2
  159. package/manifest.mjs +11 -4
  160. package/markdownlint-config.mjs +1 -1
  161. package/package.json +6 -3
  162. package/release.mjs +21 -14
  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/hm3/actors.d.mts +1 -2
  239. package/types/hm3/item-fields.d.mts +2 -4
  240. package/types/hm3/template-priority.d.mts +6 -7
  241. package/types/manifest.d.mts +3 -2
  242. package/types/release.d.mts +8 -7
  243. package/types/sohl/actors.d.mts +2 -1
  244. package/types/sohl/document-subtypes.d.mts +1 -2
  245. package/types/sohl/item-fields.d.mts +3 -7
  246. package/engine/retired-fields.mjs +0 -631
  247. package/types/engine/retired-fields.d.mts +0 -354
@@ -1,11 +1,11 @@
1
1
  /*
2
- * This file is part of the Heroic Lands shared Hugo theme.
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
3
  * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
4
  *
5
5
  * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
6
  * You may copy, modify, and distribute it under the terms of that license.
7
7
  *
8
- * For full terms, see the LICENSE file in the project root or visit:
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
9
  * https://www.gnu.org/licenses/gpl-3.0.html
10
10
  *
11
11
  * SPDX-License-Identifier: GPL-3.0-or-later
@@ -25,6 +25,10 @@
25
25
  * So this fetches every declared name and fails on any non-200. It is the test
26
26
  * the template cannot make, moved to the one place that can make it.
27
27
  *
28
+ * It also refuses a name in `data/banner-kinds.yaml` that `banners.yaml` does
29
+ * not declare, before any request is made: a mapping to undrawn art is a dead
30
+ * URL on every page of that kind.
31
+ *
28
32
  * Usage:
29
33
  * node utils/check-banners.mjs [--base <url>] [--concurrency <n>]
30
34
  *
@@ -43,6 +47,7 @@ import { parse } from "yaml";
43
47
 
44
48
  const HERE = dirname(fileURLToPath(import.meta.url));
45
49
  const DATA_FILE = resolve(HERE, "..", "hugo-theme", "data", "banners.yaml");
50
+ const KINDS_FILE = resolve(HERE, "..", "hugo-theme", "data", "banner-kinds.yaml");
46
51
  const DEFAULT_BASE = "https://cdn.heroiclands.org";
47
52
 
48
53
  function parseArgs(argv) {
@@ -71,13 +76,13 @@ function parseArgs(argv) {
71
76
  }
72
77
 
73
78
  /** Diagnostics carry the fields they can know and drop the ones they cannot. */
74
- function diag(line, message) {
75
- const where = line ? `${DATA_FILE}:${line}` : DATA_FILE;
79
+ function diag(line, message, file = DATA_FILE) {
80
+ const where = line ? `${file}:${line}` : file;
76
81
  console.error(`${where}: error: ${message}`);
77
82
  }
78
83
 
79
- function fail(message, line) {
80
- diag(line, message);
84
+ function fail(message, line, file) {
85
+ diag(line, message, file);
81
86
  process.exit(1);
82
87
  }
83
88
 
@@ -123,6 +128,36 @@ function readInventory() {
123
128
  return { source, dir, available };
124
129
  }
125
130
 
131
+ /** Every banner name `banner-kinds.yaml` maps a page kind to, with the kind that maps it. */
132
+ function mappedNames() {
133
+ const source = readFileSync(KINDS_FILE, "utf8");
134
+ const kinds = parse(source)?.kinds;
135
+ if (!kinds || typeof kinds !== "object") fail("`kinds` must be a map", 0, KINDS_FILE);
136
+ const mapped = [];
137
+ for (const [type, value] of Object.entries(kinds)) {
138
+ if (typeof value === "string") mapped.push({ kind: type, name: value });
139
+ else
140
+ for (const [subType, name] of Object.entries(value ?? {}))
141
+ mapped.push({ kind: `${type}/${subType}`, name });
142
+ }
143
+ return { source, mapped };
144
+ }
145
+
146
+ /** Fail on a mapped name the inventory does not declare. */
147
+ function checkMapping(available) {
148
+ const { source, mapped } = mappedNames();
149
+ const undeclared = mapped.filter(({ name }) => !available.includes(name));
150
+ for (const { kind, name } of undeclared) {
151
+ const line = source.split("\n").findIndex((l) => l.trim().endsWith(`: ${name}`));
152
+ diag(
153
+ line === -1 ? 0 : line + 1,
154
+ `${kind} maps to banner ${name}, which banners.yaml does not declare`,
155
+ KINDS_FILE,
156
+ );
157
+ }
158
+ if (undeclared.length) process.exit(1);
159
+ }
160
+
126
161
  async function probe(url) {
127
162
  try {
128
163
  // HEAD first — it is what the question actually is. Some object stores
@@ -147,6 +182,7 @@ async function probe(url) {
147
182
  async function main() {
148
183
  const { base, concurrency } = parseArgs(process.argv.slice(2));
149
184
  const { source, dir, available } = readInventory();
185
+ checkMapping(available);
150
186
  const root = `${base.replace(/\/$/, "")}/${dir.replace(/^\//, "")}`;
151
187
 
152
188
  const failures = [];
@@ -0,0 +1,135 @@
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
+ * Asserts that npm serves an exact package version.
16
+ *
17
+ * `npm publish` returns before the registry serves the new version, and a
18
+ * version that is accepted but never served is indistinguishable from success
19
+ * by the publish alone. This polls `npm view <name>@<version> version` until
20
+ * it prints the version, then exits 0. When the time budget runs out it exits 1
21
+ * with an `::error::` annotation naming the package, the version and the last
22
+ * registry answer.
23
+ *
24
+ * Usage: `node ci/confirm-npm-version.mjs <name> <version> [--interval <s>]
25
+ * [--timeout <s>]`. The defaults are 15 seconds between queries and 600 seconds
26
+ * overall.
27
+ */
28
+
29
+ import { spawnSync } from "node:child_process";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ /** Seconds between registry queries. */
33
+ export const DEFAULT_INTERVAL_SECONDS = 15;
34
+ /** Seconds the registry has to serve the version. */
35
+ export const DEFAULT_TIMEOUT_SECONDS = 600;
36
+
37
+ /**
38
+ * Ask the registry which version `name@version` resolves to.
39
+ *
40
+ * @param {string} spec - `<name>@<version>`.
41
+ * @returns {string} The version npm printed, or its error text when the query
42
+ * fails (an unpublished version answers E404).
43
+ */
44
+ export function queryNpm(spec) {
45
+ const result = spawnSync("npm", ["view", spec, "version"], { encoding: "utf8" });
46
+ if (result.error) return result.error.message;
47
+ const out = (result.stdout ?? "").trim();
48
+ if (result.status === 0) return out;
49
+ const err = (result.stderr ?? "").trim().replace(/\s*\n\s*/g, " | ");
50
+ return err || out || `npm exited ${result.status}`;
51
+ }
52
+
53
+ /**
54
+ * Poll until the registry serves `version` of `name`.
55
+ *
56
+ * @param {object} options
57
+ * @param {string} options.name - Package name.
58
+ * @param {string} options.version - Exact version expected.
59
+ * @param {(spec: string) => string} [options.query] - Registry query; returns
60
+ * the registry's answer for `<name>@<version>`.
61
+ * @param {(ms: number) => Promise<void>} [options.sleep] - Waits between queries.
62
+ * @param {number} [options.intervalSeconds] - Seconds between queries.
63
+ * @param {number} [options.timeoutSeconds] - Seconds before giving up.
64
+ * @param {(line: string) => void} [options.log] - Progress output.
65
+ * @returns {Promise<{ok: true, attempts: number} | {ok: false, attempts: number, last: string}>}
66
+ */
67
+ export async function confirmNpmVersion({
68
+ name,
69
+ version,
70
+ query = queryNpm,
71
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
72
+ intervalSeconds = DEFAULT_INTERVAL_SECONDS,
73
+ timeoutSeconds = DEFAULT_TIMEOUT_SECONDS,
74
+ log = () => {},
75
+ }) {
76
+ const spec = `${name}@${version}`;
77
+ const attemptLimit = Math.floor(timeoutSeconds / intervalSeconds) + 1;
78
+ let last = "";
79
+ for (let attempts = 1; attempts <= attemptLimit; attempts++) {
80
+ last = query(spec);
81
+ if (last === version) {
82
+ log(`${spec} is served by npm.`);
83
+ return { ok: true, attempts };
84
+ }
85
+ log(`${spec} is not served yet (${last || "empty answer"}).`);
86
+ if (attempts < attemptLimit) await sleep(intervalSeconds * 1000);
87
+ }
88
+ return { ok: false, attempts: attemptLimit, last };
89
+ }
90
+
91
+ function parseArguments(argv) {
92
+ const positional = [];
93
+ const options = {};
94
+ for (let i = 0; i < argv.length; i++) {
95
+ const arg = argv[i];
96
+ if (arg === "--interval" || arg === "--timeout") {
97
+ const value = Number(argv[++i]);
98
+ if (!Number.isFinite(value) || value <= 0) {
99
+ throw new Error(`${arg} takes a positive number of seconds.`);
100
+ }
101
+ options[arg === "--interval" ? "intervalSeconds" : "timeoutSeconds"] = value;
102
+ } else if (arg.startsWith("--")) {
103
+ throw new Error(`Unknown option ${arg}.`);
104
+ } else {
105
+ positional.push(arg);
106
+ }
107
+ }
108
+ if (positional.length !== 2) {
109
+ throw new Error(
110
+ "Usage: confirm-npm-version.mjs <name> <version> [--interval <s>] [--timeout <s>]",
111
+ );
112
+ }
113
+ return { name: positional[0], version: positional[1], ...options };
114
+ }
115
+
116
+ async function main(argv) {
117
+ let options;
118
+ try {
119
+ options = parseArguments(argv);
120
+ } catch (error) {
121
+ console.error(`::error::${error.message}`);
122
+ return 2;
123
+ }
124
+ const outcome = await confirmNpmVersion({ ...options, log: (line) => console.log(line) });
125
+ if (outcome.ok) return 0;
126
+ const timeout = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
127
+ console.error(
128
+ `::error::npm does not serve ${options.name}@${options.version} after ${timeout}s and ${outcome.attempts} queries. Last registry answer: ${outcome.last || "(empty)"}`,
129
+ );
130
+ return 1;
131
+ }
132
+
133
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
134
+ process.exitCode = await main(process.argv.slice(2));
135
+ }
@@ -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
 
@@ -12,6 +21,7 @@ import {
12
21
  NOTE_TOP_LEVEL_FIELDS,
13
22
  NOTE_VOCABULARY,
14
23
  SHARED_DATA_FIELDS,
24
+ dataFields,
15
25
  } from "../engine/note-vocabulary.mjs";
16
26
  import { EXPRESSION_HELPERS } from "../engine/markdown-expressions.mjs";
17
27
  import { PAGE_LIST_ATTRIBUTES } from "../engine/page-lists.mjs";
@@ -98,7 +108,7 @@ const rows = [
98
108
  "",
99
109
  "## Shared `data` fields",
100
110
  "",
101
- "These fields are accepted by every note type. A field's value can still be irrelevant to a particular output; the build reports that where it can.",
111
+ "These fields are accepted by every note type. A field's value can still be irrelevant to a particular output; the build reports that where it can. `data.icon` is not shared: it is listed under each type whose document carries art.",
102
112
  "",
103
113
  "| Field | Shape | Meaning |",
104
114
  "| --- | --- | --- |",
@@ -127,13 +137,12 @@ for (const [type, spec] of contract.types) {
127
137
  );
128
138
  }
129
139
  rows.push("| Field | Shape | Meaning |", "| --- | --- | --- |");
140
+ const typeData = dataFields(type).filter((field) => !SHARED_DATA_FIELDS.includes(field));
130
141
  rows.push(
131
- ...(declared.data?.length ?
132
- fields(declared.data)
133
- : ["| — | — | No type-specific `data` fields. |"]),
142
+ ...(typeData.length ? fields(typeData) : ["| — | — | No type-specific `data` fields. |"]),
134
143
  );
135
144
  rows.push("");
136
- rows.push(...innerKeyTable(declared.data ?? []));
145
+ rows.push(...innerKeyTable(typeData));
137
146
  const claims = contract.claims.filter((claim) => !claim.shared && claim.noteType === type);
138
147
  if (claims.length) {
139
148
  rows.push("**System mappings**", "", "| Source | System | Target |", "| --- | --- | --- |");
package/config.mjs CHANGED
@@ -94,6 +94,7 @@ const SECTION_KEYS = [
94
94
  export const DERIVED_MANIFEST_KEYS = Object.freeze({
95
95
  id: "`foundryPackage`, itself derived from package.json `name`",
96
96
  version: "package.json `version`",
97
+ authors: "the `authors` list at the top level of package-build.config.yaml",
97
98
  description: "`packageBuild.manifest.descriptionHtml`",
98
99
  url: "package.json `repository`",
99
100
  bugs: "package.json `repository`",
package/container.mjs CHANGED
@@ -217,10 +217,12 @@ export function resolveFoundryVersion(
217
217
  /**
218
218
  * The image a run uses.
219
219
  *
220
- * `FOUNDRYVTT_CONTAINER_IMAGE` wins, then `packageBuild.container.image`, then
221
- * felddy's major tag for whichever version the package already implies — the
222
- * pinned build, or failing that the compatibility floor. Only a package that
223
- * claims neither floats.
220
+ * `FOUNDRYVTT_CONTAINER_IMAGE` wins, then `packageBuild.container.image`. A
221
+ * pinned exact build takes felddy's tag for that build: each image installs its
222
+ * own build by default and warns that a different `FOUNDRY_VERSION` "may not
223
+ * function properly". Otherwise felddy's major tag for whichever version the
224
+ * package implies — the pin, or failing that the compatibility floor. Only a
225
+ * package that claims neither floats.
224
226
  *
225
227
  * @param {object} [opts]
226
228
  * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
@@ -238,6 +240,7 @@ export function resolveImage({
238
240
  const explicit = env.FOUNDRYVTT_CONTAINER_IMAGE?.trim();
239
241
  if (explicit) return explicit;
240
242
  if (image) return image;
243
+ if (EXACT_BUILD.test(version ?? "")) return `felddy/foundryvtt:${version}`;
241
244
  const major = (version ?? compatibilityMinimum ?? "").split(".")[0];
242
245
  return major ? `felddy/foundryvtt:${major}` : FLOATING_IMAGE;
243
246
  }
@@ -331,6 +334,52 @@ export function passthroughEnv(env = process.env) {
331
334
  return pairs;
332
335
  }
333
336
 
337
+ /** The account variables the image signs in to foundryvtt.com with. */
338
+ const ACCOUNT_VARIABLES = Object.freeze(["FOUNDRY_USERNAME", "FOUNDRY_PASSWORD"]);
339
+
340
+ /** The shortest value the image reads as a licence key rather than an index. */
341
+ const LICENSE_KEY_MIN_LENGTH = 24;
342
+
343
+ /**
344
+ * Whether the image has to sign in to foundryvtt.com to start.
345
+ *
346
+ * Given `FOUNDRY_USERNAME` and `FOUNDRY_PASSWORD`, the image signs in and asks
347
+ * for a download URL on every container it creates, even when the release is
348
+ * already in its cache. Each of those is a request foundryvtt.com rate-limits,
349
+ * and one refused with HTTP 429 stops the container before Foundry starts. The
350
+ * account serves two purposes only: downloading a release that is not cached,
351
+ * and fetching a licence key for an unlicensed data root. When neither applies
352
+ * the account is withheld, and the release installs from the cache with no
353
+ * network request.
354
+ *
355
+ * The image caches `foundryvtt-<version>.zip` in the mounted cache, or in
356
+ * `container_cache` beneath the data root when no cache is mounted.
357
+ *
358
+ * @param {object} opts
359
+ * @param {string|null} [opts.cacheDir] - The host cache mounted into the container.
360
+ * @param {string} opts.dataRoot - The host data root mounted at `/data`.
361
+ * @param {string|null} [opts.version] - The pinned build; with none, the
362
+ * archive's name is unknown.
363
+ * @param {string|null} [opts.licenseKey] - The licence key the image is given.
364
+ * @param {(file: string) => boolean} [opts.fileExists] - Replaceable for tests.
365
+ * @returns {boolean} Whether to pass the account through.
366
+ */
367
+ export function needsFoundryAccount({
368
+ cacheDir = null,
369
+ dataRoot,
370
+ version = null,
371
+ licenseKey = null,
372
+ fileExists = (file) => fs.existsSync(file),
373
+ }) {
374
+ if (!version) return true;
375
+ const cache = cacheDir ?? path.join(dataRoot, "container_cache");
376
+ if (!fileExists(path.join(cache, `foundryvtt-${version}.zip`))) return true;
377
+ const licensed =
378
+ (licenseKey?.length ?? 0) >= LICENSE_KEY_MIN_LENGTH ||
379
+ fileExists(path.join(dataRoot, "Config", "license.json"));
380
+ return !licensed;
381
+ }
382
+
334
383
  /**
335
384
  * The full `docker run` argument vector for a stage's container.
336
385
  *
@@ -348,6 +397,13 @@ export function passthroughEnv(env = process.env) {
348
397
  * @param {string|null} [opts.version] - Exact build to pin.
349
398
  * @param {string|null} [opts.world] - World to auto-launch; `""` forces none.
350
399
  * @param {string|null} [opts.licenseKey] - Dedicated licence key.
400
+ * @param {boolean} [opts.account] - Whether to pass `FOUNDRY_USERNAME` and
401
+ * `FOUNDRY_PASSWORD` through; see {@link needsFoundryAccount}.
402
+ * @param {string|null} [opts.user] - `--user` value to run the container as.
403
+ * `felddy/foundryvtt` runs Foundry as user 1000 and ignores `FOUNDRY_UID` and
404
+ * `FOUNDRY_GID`; under rootless Docker, container root is the host user, so
405
+ * `0:0` makes every file under the data root theirs. Blank or `null` leaves
406
+ * the image's own user.
351
407
  * @returns {string[]} Arguments after `docker`.
352
408
  */
353
409
  export function dockerRunArgs({
@@ -360,6 +416,8 @@ export function dockerRunArgs({
360
416
  version = null,
361
417
  world = null,
362
418
  licenseKey = null,
419
+ account = true,
420
+ user = null,
363
421
  }) {
364
422
  const args = [
365
423
  "run",
@@ -380,11 +438,13 @@ export function dockerRunArgs({
380
438
  args.push("--volume", `${cacheDir}:${CACHE_MOUNT}`, "-e", `CONTAINER_CACHE=${CACHE_MOUNT}`);
381
439
  }
382
440
  for (const [key, value] of passthroughEnv(env)) {
441
+ if (!account && ACCOUNT_VARIABLES.includes(key)) continue;
383
442
  args.push("-e", `${key}=${value}`);
384
443
  }
385
444
  if (version) args.push("-e", `FOUNDRY_VERSION=${version}`);
386
445
  if (world !== null) args.push("-e", `FOUNDRY_WORLD=${world}`);
387
446
  if (licenseKey) args.push("-e", `FOUNDRY_LICENSE_KEY=${licenseKey}`);
447
+ if (user?.trim()) args.push("--user", user.trim());
388
448
  args.push(image);
389
449
  return args;
390
450
  }
@@ -410,6 +470,118 @@ export function runDocker(args) {
410
470
  return result.status ?? 0;
411
471
  }
412
472
 
473
+ /**
474
+ * The `docker run` argument vector for a short-lived container that runs a
475
+ * shell script against a stage's data root.
476
+ *
477
+ * It uses the stage's own image and mounts the data root at `/data`, so the
478
+ * script sees files exactly as the Foundry container does. Under rootless
479
+ * Docker, only the container's user namespace can unlink what the container's
480
+ * user wrote, and only it can hand files to that user.
481
+ *
482
+ * @param {object} opts
483
+ * @param {string} opts.image - Image reference.
484
+ * @param {string} opts.dataRoot - Host directory to mount at `/data`.
485
+ * @param {string} opts.script - A `sh` script; `$1`, `$2` … are `args`.
486
+ * @param {string[]} [opts.args] - Positional arguments for the script.
487
+ * @param {string|null} [opts.user] - `--user` value; `null` runs as the image's
488
+ * own user.
489
+ * @returns {string[]} Arguments after `docker`.
490
+ */
491
+ export function dataRootScriptArgs({ image, dataRoot, script, args = [], user = null }) {
492
+ const argv = [
493
+ "run",
494
+ "--rm",
495
+ "--volume",
496
+ `${path.resolve(dataRoot)}:/data`,
497
+ "--entrypoint",
498
+ "sh",
499
+ ];
500
+ if (user?.trim()) argv.push("--user", user.trim());
501
+ argv.push(image, "-c", script, "sh", ...args);
502
+ return argv;
503
+ }
504
+
505
+ /**
506
+ * Run a shell script against a stage's data root in a throwaway container.
507
+ *
508
+ * @param {object} opts - As for {@link dataRootScriptArgs}.
509
+ * @returns {string} The script's standard output.
510
+ * @throws {Error} When `docker` is missing or the script exits non-zero.
511
+ */
512
+ export function runDataRootScript(opts) {
513
+ const result = spawnSync("docker", dataRootScriptArgs(opts), { encoding: "utf8" });
514
+ if (result.error?.code === "ENOENT") {
515
+ throw new Error(
516
+ "docker was not found on PATH. Install Docker and make sure the `docker` CLI is available.",
517
+ );
518
+ }
519
+ if (result.status !== 0) {
520
+ throw new Error(
521
+ `A helper container failed (exit ${result.status}): ` +
522
+ `${(result.stderr || result.stdout || "").trim()}`,
523
+ );
524
+ }
525
+ return result.stdout ?? "";
526
+ }
527
+
528
+ /**
529
+ * A tree's path beneath the data root, as the helper container sees it there.
530
+ *
531
+ * @param {string} dataRoot - Host directory mounted at `/data`.
532
+ * @param {string} tree - A host path beneath it.
533
+ * @returns {string} The `/`-separated path relative to the data root.
534
+ * @throws {Error} When the tree is the data root or lies outside it.
535
+ */
536
+ function beneathDataRoot(dataRoot, tree) {
537
+ const relative = path.relative(path.resolve(dataRoot), path.resolve(tree));
538
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
539
+ throw new Error(`${tree} is not beneath the data root ${dataRoot}.`);
540
+ }
541
+ return relative.split(path.sep).join("/");
542
+ }
543
+
544
+ /**
545
+ * A remover for trees under a stage's data root that the container's user
546
+ * owns, run as root in a throwaway container on that data root — the same
547
+ * mechanism that hands an end-to-end install to the container's user.
548
+ *
549
+ * @param {object} opts
550
+ * @param {ResolvedContainer} opts.container - The stage's container.
551
+ * @param {(opts: object) => string} [opts.runScript] - Throwaway-container runner.
552
+ * @returns {(tree: string, where: {dataRoot: string}) => void} Removes one tree.
553
+ */
554
+ export function containerTreeRemover({ container, runScript = runDataRootScript }) {
555
+ return (tree, { dataRoot }) => {
556
+ const relative = beneathDataRoot(dataRoot, tree);
557
+ runScript({
558
+ image: container.image,
559
+ dataRoot,
560
+ user: "0:0",
561
+ script: 'rm -rf "/data/$1"',
562
+ args: [relative],
563
+ });
564
+ };
565
+ }
566
+
567
+ /**
568
+ * The command {@link containerTreeRemover} runs, written out for a person to
569
+ * paste when the deploy could not run it.
570
+ *
571
+ * @param {object} opts
572
+ * @param {string} opts.image - Image reference.
573
+ * @param {string} opts.dataRoot - Host directory mounted at `/data`.
574
+ * @param {string} opts.tree - The tree to remove, beneath the data root.
575
+ * @returns {string} A `docker run` command line.
576
+ */
577
+ export function containerRemovalCommand({ image, dataRoot, tree }) {
578
+ const relative = beneathDataRoot(dataRoot, tree);
579
+ return (
580
+ `docker run --rm --user 0:0 --volume ${path.resolve(dataRoot)}:/data ` +
581
+ `--entrypoint rm ${image} -rf /data/${relative}`
582
+ );
583
+ }
584
+
413
585
  /**
414
586
  * Run the `docker` CLI and capture its output, tolerating failure.
415
587
  *
@@ -487,6 +659,7 @@ export function clearStaleLock(dataRoot, log = () => {}) {
487
659
  * @property {string|null} world
488
660
  * @property {string|null} licenseKey
489
661
  * @property {string|null} cacheDir
662
+ * @property {string|null} user - From `FOUNDRYVTT_CONTAINER_USER`.
490
663
  */
491
664
 
492
665
  /**
@@ -522,6 +695,7 @@ export function resolveContainer({ stage, config, env = process.env }) {
522
695
  world: resolveWorld(stage, { env, stages }),
523
696
  licenseKey: resolveLicenseKey(stage, env),
524
697
  cacheDir: env.FOUNDRYVTT_CACHE?.trim() || null,
698
+ user: env.FOUNDRYVTT_CONTAINER_USER?.trim() || null,
525
699
  };
526
700
  }
527
701
 
@@ -563,6 +737,13 @@ export function startContainer(container, { dataRoot, env = process.env, log = (
563
737
  version: container.version,
564
738
  world: container.world,
565
739
  licenseKey: container.licenseKey,
740
+ account: needsFoundryAccount({
741
+ cacheDir: container.cacheDir,
742
+ dataRoot,
743
+ version: container.version,
744
+ licenseKey: container.licenseKey ?? env.FOUNDRY_LICENSE_KEY?.trim() ?? null,
745
+ }),
746
+ user: container.user,
566
747
  }),
567
748
  );
568
749
  if (status === 0) {