@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
package/e2e.mjs CHANGED
@@ -46,13 +46,16 @@ import path from "node:path";
46
46
  import process from "node:process";
47
47
  import { spawnSync } from "node:child_process";
48
48
 
49
- import { deployStage } from "./deploy.mjs";
49
+ import { deployStage, isRemoteTarget, packageSubpath } from "./deploy.mjs";
50
+ import { emitDiagnostic } from "./engine/diagnostics.mjs";
50
51
  import {
51
52
  containerAction,
52
53
  containerExists,
54
+ containerTreeRemover,
53
55
  dataEnvVar,
54
56
  resolveContainer,
55
57
  resolveDataRoot,
58
+ runDataRootScript,
56
59
  runDocker,
57
60
  runningFoundryContainers,
58
61
  } from "./container.mjs";
@@ -365,24 +368,41 @@ export function parseFastArgs(argv, build) {
365
368
  *
366
369
  * Writes `world.json` and compiles each LevelDB collection from JSON, so the
367
370
  * result is a genuine world Foundry launches without migration or setup. The
368
- * world directory is wiped and rewritten each time, which is what makes a run
369
- * repeatable.
371
+ * world directory is replaced each time, which is what makes a run repeatable.
372
+ *
373
+ * The world is built in a staging directory beside the worlds, then moved into
374
+ * place and handed to the Foundry container's user by a throwaway container
375
+ * mounting the same data root. Replacing the old world happens there too. Both
376
+ * halves matter: a database the host user wrote cannot be locked by the
377
+ * container's user, and a directory the container's user wrote (such as the
378
+ * `lost/` folder Foundry's repair makes) cannot be unlinked by the host user.
370
379
  *
371
380
  * @param {object} opts
372
381
  * @param {object} opts.config - The resolved package-build configuration.
373
382
  * @param {object} opts.packageJson - The repository's `package.json`.
374
383
  * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
375
384
  * @param {(message: string) => void} [opts.log] - Progress reporting.
385
+ * @param {(opts: object) => string} [opts.runScript] - Runs a shell script in a
386
+ * throwaway container on the data root; replaceable for tests.
376
387
  * @returns {Promise<{worldDir: string, world: E2EWorld}>} Where it landed.
377
388
  */
378
- export async function seedTestWorld({ config, packageJson, env = process.env, log = () => {} }) {
389
+ export async function seedTestWorld({
390
+ config,
391
+ packageJson,
392
+ env = process.env,
393
+ log = () => {},
394
+ runScript = runDataRootScript,
395
+ }) {
379
396
  const stage = config.e2eStage;
380
397
  const dataRoot = requireIsolatedDataRoot(stage, env);
381
398
  const world = resolveE2EWorld(config, env);
382
399
 
383
400
  const worldDir = path.join(dataRoot, "Data", "worlds", world.worldId);
384
- await fs.rm(worldDir, { recursive: true, force: true });
385
- await fs.mkdir(worldDir, { recursive: true });
401
+ // Beside `Data/`, not inside it, so Foundry never sees a half-built world.
402
+ const stagingName = `.seed-${world.worldId}`;
403
+ const stagingDir = path.join(dataRoot, stagingName);
404
+ await fs.rm(stagingDir, { recursive: true, force: true });
405
+ await fs.mkdir(stagingDir, { recursive: true });
386
406
 
387
407
  const coreVersion = String(config.compatibilityMinimum ?? "").split(".")[0];
388
408
  if (!coreVersion) {
@@ -394,7 +414,7 @@ export async function seedTestWorld({ config, packageJson, env = process.env, lo
394
414
  }
395
415
 
396
416
  await fs.writeFile(
397
- path.join(worldDir, "world.json"),
417
+ path.join(stagingDir, "world.json"),
398
418
  JSON.stringify(
399
419
  worldManifest({
400
420
  ...world,
@@ -427,7 +447,7 @@ export async function seedTestWorld({ config, packageJson, env = process.env, lo
427
447
  }
428
448
 
429
449
  const { compilePack } = await import("@foundryvtt/foundryvtt-cli");
430
- const sourceRoot = path.join(worldDir, ".seed-src");
450
+ const sourceRoot = path.join(stagingDir, ".seed-src");
431
451
  for (const [collection, documents] of Object.entries(collections)) {
432
452
  const sourceDir = path.join(sourceRoot, collection);
433
453
  await fs.mkdir(sourceDir, { recursive: true });
@@ -453,18 +473,126 @@ export async function seedTestWorld({ config, packageJson, env = process.env, lo
453
473
  for (const collection of await fs.readdir(sourceRoot)) {
454
474
  await compilePack(
455
475
  path.join(sourceRoot, collection),
456
- path.join(worldDir, "data", collection),
476
+ path.join(stagingDir, "data", collection),
457
477
  { log: false },
458
478
  );
459
479
  }
460
480
  await fs.rm(sourceRoot, { recursive: true, force: true });
461
481
 
482
+ // Hand the world to the user Foundry runs as.
483
+ const container = resolveContainer({ stage, config, env });
484
+ const owner = containerOwner({ container, dataRoot, runScript });
485
+ runScript({
486
+ image: container.image,
487
+ dataRoot,
488
+ user: "0:0",
489
+ script: [
490
+ "set -e",
491
+ 'rm -rf "/data/Data/worlds/$1"',
492
+ "mkdir -p /data/Data/worlds",
493
+ 'mv "/data/$2" "/data/Data/worlds/$1"',
494
+ 'chown -R "$3" "/data/Data/worlds/$1"',
495
+ ].join("\n"),
496
+ args: [world.worldId, stagingName, owner],
497
+ });
498
+
462
499
  log(`Seeded world '${world.worldId}' at ${worldDir}`);
463
500
  log(` GM user: ${world.gmName} (id ${world.gmId})`);
464
501
  log(` password: ${world.gmPassword}`);
465
502
  return { worldDir, world };
466
503
  }
467
504
 
505
+ /**
506
+ * The `uid:gid` the Foundry container runs as, asked of the container's own
507
+ * user so it is right whatever `--user` or the image says.
508
+ *
509
+ * @param {object} opts
510
+ * @param {import("./container.mjs").ResolvedContainer} opts.container - The stage's container.
511
+ * @param {string} opts.dataRoot - The stage's local data root.
512
+ * @param {(opts: object) => string} opts.runScript - Throwaway-container runner.
513
+ * @returns {string} For example `1000:1000`.
514
+ * @throws {Error} When the answer is not a numeric pair.
515
+ */
516
+ function containerOwner({ container, dataRoot, runScript }) {
517
+ const owner = runScript({
518
+ image: container.image,
519
+ dataRoot,
520
+ user: container.user,
521
+ script: 'echo "$(id -u):$(id -g)"',
522
+ }).trim();
523
+ if (!/^\d+:\d+$/.test(owner)) {
524
+ throw new Error(`Could not determine the container's user: '${owner}'.`);
525
+ }
526
+ return owner;
527
+ }
528
+
529
+ /**
530
+ * Deploy the staged package to the end-to-end stage so the container's user
531
+ * can run it.
532
+ *
533
+ * The package's databases (compendium packs) are written by the host user, and
534
+ * Foundry cannot lock them unless it owns them; a pack Foundry has repaired
535
+ * holds directories the host user cannot unlink. So the previous install is
536
+ * removed, and the new one handed over, by a throwaway container on the same
537
+ * data root. A remote destination is deployed as {@link deployStage} does.
538
+ *
539
+ * @param {object} opts
540
+ * @param {object} opts.config - The resolved package-build configuration.
541
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
542
+ * @param {(message: string) => void} [opts.log] - Progress reporting.
543
+ * @param {(opts: object) => string} [opts.runScript] - Throwaway-container runner.
544
+ * @returns {Promise<void>}
545
+ */
546
+ export async function deployForContainer({
547
+ config,
548
+ env = process.env,
549
+ log = () => {},
550
+ runScript = runDataRootScript,
551
+ }) {
552
+ const stage = config.e2eStage;
553
+ const target = env[dataEnvVar(stage)]?.trim() ?? "";
554
+ const local = target !== "" && !isRemoteTarget(target);
555
+ const relative = packageSubpath(config.packageKind, config.packageId).join("/");
556
+ const container = resolveContainer({ stage, config, env });
557
+
558
+ if (local) {
559
+ runScript({
560
+ image: container.image,
561
+ dataRoot: target,
562
+ user: "0:0",
563
+ script: 'rm -rf "/data/$1"',
564
+ args: [relative],
565
+ });
566
+ }
567
+ const { leftovers } = await deployStage({
568
+ stage,
569
+ source: path.join(config.rootDir, config.stageDir),
570
+ packageKind: config.packageKind,
571
+ packageId: config.packageId,
572
+ env,
573
+ prefix: config.envPrefix,
574
+ log,
575
+ removeTree: containerTreeRemover({ container, runScript }),
576
+ });
577
+ for (const leftover of leftovers) {
578
+ emitDiagnostic({
579
+ file: leftover.path,
580
+ severity: "warning",
581
+ message: `the new package is installed, but this previous tree could not be removed (${leftover.reason})`,
582
+ });
583
+ }
584
+ if (local) {
585
+ const owner = containerOwner({ container, dataRoot: target, runScript });
586
+ runScript({
587
+ image: container.image,
588
+ dataRoot: target,
589
+ user: "0:0",
590
+ script: 'chown -R "$2" "/data/$1"',
591
+ args: [relative, owner],
592
+ });
593
+ }
594
+ }
595
+
468
596
  /**
469
597
  * The end-to-end stage's data root, refused when it is another stage's.
470
598
  *
@@ -543,7 +671,7 @@ export async function waitForWorld({ url, container, stage, timeoutMs = 180_000,
543
671
  }
544
672
  throw new Error(
545
673
  `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the ` +
546
- `world to activate. Check \`package-build container logs\`.`,
674
+ `world to activate. Check \`package-build container ${stage} logs\`.`,
547
675
  );
548
676
  }
549
677
 
@@ -944,15 +1072,7 @@ export async function e2eRun({
944
1072
  }
945
1073
 
946
1074
  log(`▸ deploy ${stage}`);
947
- await deployStage({
948
- stage,
949
- source: path.join(config.rootDir, config.stageDir),
950
- packageKind: config.packageKind,
951
- packageId: config.packageId,
952
- env,
953
- prefix: config.envPrefix,
954
- log,
955
- });
1075
+ await deployForContainer({ config, env, log });
956
1076
 
957
1077
  log("▸ seed");
958
1078
  const { world } = await seedTestWorld({ config, packageJson, env, log });
@@ -1044,15 +1164,7 @@ export async function e2eFast({ config, argv = [], env = process.env, log = () =
1044
1164
  const exists = containerExists(container.name);
1045
1165
 
1046
1166
  log(`▸ deploy ${stage}`);
1047
- await deployStage({
1048
- stage,
1049
- source: path.join(config.rootDir, config.stageDir),
1050
- packageKind: config.packageKind,
1051
- packageId: config.packageId,
1052
- env,
1053
- prefix: config.envPrefix,
1054
- log,
1055
- });
1167
+ await deployForContainer({ config, env, log });
1056
1168
 
1057
1169
  const action = !exists || recreate ? "recreate" : "restart";
1058
1170
  log(`▸ container ${action}`);
@@ -52,7 +52,7 @@ import log from "loglevel";
52
52
 
53
53
  import { isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
54
54
  import { makeId } from "./helpers.mjs";
55
- import { emitDiagnostic } from "./diagnostics.mjs";
55
+ import { emitDiagnostic, locateFrontmatterKey } from "./diagnostics.mjs";
56
56
  // The `{#appearance}` / `{#dossier}` convention is the note format's, so the
57
57
  // extraction is shared; which field a section lands in stays the system's.
58
58
  export { extractAnchorSection, renderSection } from "./anchored-sections.mjs";
@@ -64,13 +64,12 @@ import { contentPackage } from "./content-package.mjs";
64
64
  // types are actors at all. Looked up in the system's declared map, never
65
65
  // inferred from the type itself.
66
66
  import { mapsNoteType, noteTypesFor, referencedSubtype } from "./document-subtypes.mjs";
67
- import { locateFrontmatterKey } from "./retired-fields.mjs";
68
67
  // An `items:` entry's `system:` overlay is merged verbatim, so it reaches the
69
68
  // document by a path no field declaration sits on — including the
70
69
  // fields the document is supposed to write for itself in play. `itemArt` is
71
70
  // the same table the Item pass defaults from, so an entry that copies no
72
71
  // template and names no art of its own carries what a note of its type would.
73
- import { itemArt, itemFields } from "./item-registry.mjs";
72
+ import { itemArt, itemFields, itemTypes } from "./item-registry.mjs";
74
73
  import { runtimeOnlyIn, runtimeOnlyMessage } from "./runtime-only-fields.mjs";
75
74
  // A `model:` is an address, read by the same grammar every wikilink is,
76
75
  // so an author writes one form and meets one set of messages.
@@ -326,7 +325,7 @@ export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = [], system = n
326
325
  // so a whole-package build cannot reach this. What can is a run
327
326
  // restricted to this one pack, or a caller constructing the
328
327
  // compiler itself — neither of which reordering a pack list fixes,
329
- // so the message no longer suggests it.
328
+ // so the message does not suggest it.
330
329
  throw new Error(
331
330
  `Items source directory ${itemsSourceDir} does not exist — ` +
332
331
  `an actor resolves its embedded items against the Item ` +
@@ -467,8 +466,7 @@ export class SystemActorCompiler extends BasePackCompiler {
467
466
  const address = beingDefaultArt(fm);
468
467
  const byTag = address ? this.artPathOf(address, "icon", "icon") : null;
469
468
  const base = byTag ?? fallback;
470
- // Nullish, not `||`: a note that names no art takes the default, one
471
- // that writes `""` ships blank on purpose.
469
+ // A note that names no art takes the default.
472
470
  const img = this.artPath(fm, "icon") ?? base;
473
471
  return { img, token: this.artPath(fm, "tokenIcon") ?? img };
474
472
  }
@@ -574,26 +572,20 @@ export class SystemActorCompiler extends BasePackCompiler {
574
572
  // configuration, and a consumer may put them anywhere. Every
575
573
  // Item pack, because a repository may ship more than one.
576
574
  //
577
- // **Optional, and empty is a legitimate package.** This used to
578
- // throw unless at least one Item pack was declared, which asked a
579
- // package to declare the very thing it may exist not to have. An Item
580
- // pack is system-bound by construction — Foundry requires `system` on
581
- // Item packs — so a deliberately system-agnostic module could satisfy
582
- // the guard only by naming a system. `harn-ensemble` is the case:
583
- // 2,512 beings whose embedded items address the `sohl` and `hm3`
584
- // catalogues, and five affiliation notes of its own.
575
+ // **Optional, and empty is a legitimate package.** An Item pack is
576
+ // system-bound by construction — Foundry requires `system` on Item
577
+ // packs — so a deliberately system-agnostic module could satisfy a
578
+ // requirement to declare one only by naming a system. `harn-ensemble`
579
+ // is the case: its beings' embedded items address the `sohl` and `hm3`
580
+ // catalogues, and it has a few affiliation notes of its own.
585
581
  //
586
- // The guard also did not test what it claimed. It counted *declared
587
- // directories*, not resolvable items, so an empty Item pack satisfied
588
- // it while a being naming a missing item still failed later. The
589
- // condition actually cared about is checked where it can be reported
590
- // precisely: {@link SystemActorCompiler#resolveEmbedded} already errors
591
- // per unresolved `(type, shortcode)`, naming the actor. A package whose
582
+ // The condition that matters is checked where it can be reported
583
+ // precisely: {@link SystemActorCompiler#resolveEmbedded} errors per
584
+ // unresolved `(type, shortcode)`, naming the actor. A package whose
592
585
  // actors embed nothing, or whose every address resolves against a
593
- // dependency catalogue through `foreignSourceDirs`, now compiles with
594
- // no Item pack at all — and one that is genuinely missing an item
595
- // still fails, saying which item and which actor rather than which
596
- // pack is absent.
586
+ // dependency catalogue through `foreignSourceDirs`, compiles with no
587
+ // Item pack at all — and one that is genuinely missing an item fails,
588
+ // saying which item and which actor rather than which pack is absent.
597
589
  Object.defineProperty(this, "itemsSourceDirs", {
598
590
  value: Object.freeze([...itemsSourceDirs]),
599
591
  writable: false,
@@ -679,7 +671,7 @@ export class SystemActorCompiler extends BasePackCompiler {
679
671
  * subtype, or why the reference names none.
680
672
  */
681
673
  embeddedSubtype(type) {
682
- return referencedSubtype(this.documentSubtypes, type, "Item");
674
+ return referencedSubtype(this.documentSubtypes, type, "Item", { declared: itemTypes() });
683
675
  }
684
676
 
685
677
  /**
@@ -742,13 +734,13 @@ export class SystemActorCompiler extends BasePackCompiler {
742
734
  const read = parseAddress(
743
735
  model,
744
736
  { types, packages, package: contentPackage(), system: this.system },
745
- { declared: true, legacyShortcodeCase: true },
737
+ { declared: true },
746
738
  );
747
739
  if (!read || read.reason) {
748
740
  const why =
749
- read?.reason === "not-lowercase" ?
750
- "capitalises a package, system or type segment — those three " +
751
- "are lowercase, and only the shortcode keeps its case"
741
+ read?.reason === "shortcode-not-lowercase" ?
742
+ `spells its shortcode \`${read.shortcode}\` with a capital — ` +
743
+ "a shortcode is lowercase"
752
744
  : read?.reason === "unknown-type" ? "names no known content type"
753
745
  : "is not an address — write `type-shortcode`, or " +
754
746
  "`package-system-type-shortcode` for another package's item";
@@ -892,8 +884,7 @@ export class SystemActorCompiler extends BasePackCompiler {
892
884
  // The two art rules an item note compiles under, applied to an entry
893
885
  // that is one in every respect but where it is written: the address it
894
886
  // names wins, the template it copies answers next, and the type's own
895
- // default answers last. Nullish coalescing throughout, so `icon: ""`
896
- // ships blank on purpose rather than collecting a default.
887
+ // default answers last.
897
888
  merged.img =
898
889
  this.artPath(overlay ?? {}, "icon") ??
899
890
  merged.img ??
@@ -42,26 +42,13 @@
42
42
  * The shape every address segment must match: **lowercase** ASCII letters and
43
43
  * digits only.
44
44
  *
45
- * Case *was* deliberately unconstrained, on the reasoning that case has no
46
- * bearing on the separator — which is true, and beside the point.
47
- *
48
45
  * **Two names that differ only in case are two names nobody can tell apart.** A
49
46
  * shortcode is how a person names a thing when writing a reference —
50
47
  * `model: weapongear-dgr`, `[[skill-melee|…]]` — and `Dgr` beside `dgr` is a
51
- * distinction you cannot say out loud and can only see by looking twice.
52
- *
53
- * The toolchain had already half-decided it: {@link canonicalKey} lowercases the
54
- * address it builds, so a note declaring `Clb` published
55
- * `sohl-sohl-weapongear-clb` and its `_id` derived from that. The authored name
56
- * and its address disagreed, and everything downstream keys on the address —
57
- * which left two notes differing only in case sharing one address, one `_id` and
58
- * one URL, with nothing to report it. It also forces two exceptions elsewhere:
59
- * the shortcode has to be exempted from the lowercase rule pinned on every
60
- * other segment, and its case has to be folded in the item catalogue because
61
- * an address is lowercased when read.
62
- *
63
- * One case, one spelling, no exceptions. Every tree already complies but two,
64
- * and nothing in any of them collides when folded.
48
+ * distinction you cannot say out loud and can only see by looking twice. So the
49
+ * shortcode is held to the same rule as every other segment: a note declaring
50
+ * `shortcode: Dgr` is refused, and so is a declared Address spelling one with a
51
+ * capital.
65
52
  *
66
53
  * @type {RegExp}
67
54
  */
@@ -307,7 +307,7 @@ function isMapping(value) {
307
307
  * `data.seat` — and the note writes `seat:` one level inside it. The finding
308
308
  * names that key for two reasons at once: it is the word on the line the
309
309
  * diagnostic opens, and it is what {@link
310
- * module:engine/retired-fields.locateFrontmatterKey} matches when the authored
310
+ * module:engine/diagnostics.locateFrontmatterKey} matches when the authored
311
311
  * value cannot be found in the fence, which a dotted path never would.
312
312
  *
313
313
  * @param {object} field - The declaration.
@@ -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 { assertSystemSegment } from "./systems.mjs";
4
15
 
@@ -2,6 +2,12 @@
2
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
+ * 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
+ *
5
11
  * SPDX-License-Identifier: GPL-3.0-or-later
6
12
  */
7
13
 
@@ -138,7 +138,7 @@ import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
138
138
  // `ids.mjs` is a leaf with no local imports, and `pack-config.mjs` reaches only
139
139
  // the configuration loader and the diagnostics writer — neither reads an
140
140
  // address, so the grammar sits below everything that consumes it.
141
- import { currentType, ITEM_PACK, JOURNAL_TYPES, packForType } from "./ids.mjs";
141
+ import { ITEM_PACK, JOURNAL_TYPES, packForType } from "./ids.mjs";
142
142
  import { HOMEPAGE_TYPE } from "./homepage.mjs";
143
143
  import { loadPackConfig } from "./pack-config.mjs";
144
144
 
@@ -167,7 +167,7 @@ export const CANONICAL_KEY_SEGMENTS = 4;
167
167
  /** The accepted `doc<type>` input prefix for readable note links. */
168
168
  export const ITEM_DOC_PREFIX = "doc";
169
169
 
170
- /** Package, system and type are one spelling each, so they fold to lowercase. */
170
+ /** Package, system and type are read lowercased; a capital in one is refused as `not-lowercase`. */
171
171
  const norm = (s) => String(s).toLowerCase().trim();
172
172
 
173
173
  /**
@@ -194,11 +194,16 @@ const norm = (s) => String(s).toLowerCase().trim();
194
194
  * - `no-content-index` — the package named publishes no content index, so there
195
195
  * is nothing to resolve the rest of the target against.
196
196
  * - `not-lowercase` — a package, system or type segment carries a capital.
197
+ * - `shortcode-not-lowercase` — the Address spells its shortcode with a
198
+ * capital and is otherwise well formed. `shortcode` carries the segment as
199
+ * written, and `lowercase` the reading of the lowercase spelling: complete
200
+ * for a declared Address, and as partial as the written value otherwise.
197
201
  * - `invalid-segment` — a declared Address contains a malformed segment.
198
202
  * - `no-package` — a declared Address cannot supply its package.
199
203
  * - `invalid-system` — a declared Address cannot supply a registered system.
200
204
  *
201
- * @typedef {{reason: string, package?: string}} AddressProblem
205
+ * @typedef {{reason: string, package?: string, shortcode?: string,
206
+ * lowercase?: AddressTuple}} AddressProblem
202
207
  */
203
208
 
204
209
  /**
@@ -220,12 +225,9 @@ export function docEntryTypes() {
220
225
  * `macro`; false for `doc`.
221
226
  */
222
227
  export function hasDocEntry(type) {
223
- // Through {@link currentType}, because the set is derived from the item
224
- // registry's keys and those are the *current* spelling of a note type. A
225
- // note still on a renamed one carries its documentation journal exactly as
226
- // before — this is the one lookup between an item compiling and its prose
227
- // silently compiling into nothing.
228
- return docEntryTypes().has(String(currentType(type)));
228
+ // The set is derived from the item registry's keys: the one lookup between
229
+ // an item compiling and its prose silently compiling into nothing.
230
+ return docEntryTypes().has(String(type));
229
231
  }
230
232
 
231
233
  /** The segment of a note's own document. */
@@ -294,32 +296,34 @@ export function resolveItemDocType(qualifier, types) {
294
296
  * label already removed.
295
297
  * @param {AddressDefaults} [defaults] - The position's defaults and the tree's
296
298
  * vocabularies.
297
- * @param {{declared?: boolean, legacyShortcodeCase?: boolean}} [options] - A declared Address validates every
298
- * segment and requires complete defaults. Package membership is a separate
299
- * resolution question in this mode; prose recognition keeps its vocabulary checks.
300
- * `legacyShortcodeCase` accepts case-insensitive model Shortcodes while the
301
- * completed tuple contains their canonical lowercase identity.
299
+ * @param {{declared?: boolean}} [options] - A declared Address is validated as
300
+ * written: every segment, the shortcode included, is lowercase, and the
301
+ * defaults must be complete. Package membership is a separate resolution
302
+ * question in this mode; prose recognition keeps its vocabulary checks.
302
303
  * @returns {AddressTuple|AddressProblem} The complete Address, or why there is
303
304
  * none.
304
305
  */
305
- export function parseAddress(
306
- written,
307
- defaults = {},
308
- { declared = false, legacyShortcodeCase = false } = {},
309
- ) {
306
+ export function parseAddress(written, defaults = {}, { declared = false } = {}) {
310
307
  if (isAddressTuple(written)) return written;
311
308
  if (declared) {
312
309
  if (typeof written !== "string") return { reason: "not-an-address" };
313
- const segments =
314
- written.includes("/") ? written.split("/") : written.split(ADDRESS_SEPARATOR);
315
- if (
316
- !segments.every((segment, index) =>
317
- isAddressSegment(
318
- legacyShortcodeCase && index === segments.length - 1 ? norm(segment) : segment,
319
- ),
320
- )
321
- )
322
- return { reason: "invalid-segment" };
310
+ const separator = written.includes("/") ? "/" : ADDRESS_SEPARATOR;
311
+ const segments = written.split(separator);
312
+ const shortcode = segments.at(-1);
313
+ if (!segments.slice(0, -1).every(isAddressSegment)) return { reason: "invalid-segment" };
314
+ if (!isAddressSegment(shortcode)) {
315
+ if (!/[A-Z]/.test(shortcode) || !isAddressSegment(shortcode.toLowerCase()))
316
+ return { reason: "invalid-segment" };
317
+ const lowercase = parseAddress(
318
+ [...segments.slice(0, -1), shortcode.toLowerCase()].join(separator),
319
+ defaults,
320
+ { declared },
321
+ );
322
+ // A value that is no Address even in lowercase is refused for that,
323
+ // the more basic fault, rather than for its case.
324
+ if (!isAddressTuple(lowercase)) return lowercase;
325
+ return { reason: "shortcode-not-lowercase", shortcode, lowercase };
326
+ }
323
327
  }
324
328
  const read = readWritten(written, defaults, declared);
325
329
  if (read.reason) return read;
@@ -565,15 +569,24 @@ function readWritten(written, vocabulary, declared = false) {
565
569
  const target = typeof written === "string" ? written : String(written ?? "");
566
570
  if (!target) return { reason: "not-an-address" };
567
571
  const read = readWrittenCased(target, vocabulary, declared);
568
- if (!read.reason && qualifyingSegments(target).some((s) => /[A-Z]/.test(s))) {
572
+ if (read.reason) return read;
573
+ if (qualifyingSegments(target).some((s) => /[A-Z]/.test(s))) {
569
574
  return { reason: "not-lowercase" };
570
575
  }
576
+ if (/[A-Z]/.test(read.shortcode)) {
577
+ return {
578
+ reason: "shortcode-not-lowercase",
579
+ shortcode: read.shortcode,
580
+ lowercase: { ...read, shortcode: read.shortcode.toLowerCase() },
581
+ };
582
+ }
571
583
  return read;
572
584
  }
573
585
 
574
586
  /**
575
- * The segments of a written value that must be lowercase — everything but the
576
- * shortcode, which keeps whatever the note declares.
587
+ * The package, system and type segments of a written value, which carry the
588
+ * `not-lowercase` finding. The shortcode is checked on its own, so its finding
589
+ * can name the segment and the lowercase spelling.
577
590
  *
578
591
  * @param {string} target - The value as authored.
579
592
  * @returns {string[]} The package / system / type segments, as written.
@@ -666,7 +679,7 @@ function readWrittenCased(target, vocabulary, declared = false) {
666
679
  * `null` when the segment names no known type, or the shortcode is empty.
667
680
  */
668
681
  function readTypeAndCode(rawType, rawCode, types) {
669
- const shortcode = norm(rawCode);
682
+ const shortcode = String(rawCode).trim();
670
683
  if (!shortcode) return null;
671
684
 
672
685
  const type = norm(rawType);
@@ -196,7 +196,8 @@ export function assetImageInfoByPathname(records = []) {
196
196
  * slot's. Omitted, any asset type is taken, which is an embed's rule: it
197
197
  * draws a file, and every asset type is a file.
198
198
  * @returns {{record: {package: string, asset: {path: string}}, pathname: string}
199
- * |{record: null, reason: string, type?: string}} The asset and the pathname
199
+ * |{record: null, reason: string, type?: string, shortcode?: string,
200
+ * declared?: string}} The asset and the pathname
200
201
  * it is at, or a reason from
201
202
  * {@link module:engine/wikilink-syntax.LINK_FINDING_REASONS}, or `not-accepted`
202
203
  * when `accepts` is given and the parsed type falls outside it.
@@ -214,6 +215,31 @@ export function readAssetAddress(index, value, defaultType, accepts) {
214
215
  if (read.reason === "not-accepted") {
215
216
  return { record: null, reason: "not-accepted", type: read.type };
216
217
  }
218
+ if (read.reason === "shortcode-not-lowercase") {
219
+ // Refused, never lowercased; the lowercase spelling is looked up only
220
+ // to name it in the finding.
221
+ const written = String(value);
222
+ const lower = artTarget(
223
+ `${written.slice(0, -read.shortcode.length)}${read.lowercase.shortcode}`,
224
+ defaultType,
225
+ accepts,
226
+ {
227
+ system: ASSET_SYSTEM,
228
+ package: index?.contentPackage ?? index?.packageId,
229
+ types: index?.types,
230
+ packages: index?.packages,
231
+ },
232
+ );
233
+ const canonical = lower.reason ? undefined : renderAddress(lower);
234
+ const declared =
235
+ canonical && (index?.assets?.get(canonical) ?? index?.foreign?.get(canonical));
236
+ return {
237
+ record: null,
238
+ reason: read.reason,
239
+ shortcode: read.shortcode,
240
+ ...(declared ? { declared: read.lowercase.shortcode } : {}),
241
+ };
242
+ }
217
243
  if (read.reason) return { record: null, reason: read.reason, type: read.type };
218
244
  // **Asset types only**, where the position declares no narrower set. An
219
245
  // address may name any type the vocabulary holds, and most of them name a
@@ -322,9 +348,9 @@ export function checkArtSlotSizes(records = [], { config, assetsBase = "" } = {}
322
348
  * The pathname an art value names, in the form an authored one takes.
323
349
  *
324
350
  * Handing the result to {@link module:engine/helpers.resolveImg} is what puts an
325
- * art address and a body image through one ownership rule. The two empties
326
- * survive it unchanged: `null` and an absent key mean *no art named, apply the
327
- * default*, and `""` means *ship blank on purpose*.
351
+ * art address and a body image through one ownership rule. `null` and an
352
+ * absent key mean *no art named, apply the default*; an empty string names
353
+ * nothing and is unresolved like any other address nothing answers.
328
354
  *
329
355
  * @param {object} index - From {@link module:engine/wikilinks.buildWikilinkIndex}.
330
356
  * @param {unknown} value - The value as authored.
@@ -339,7 +365,6 @@ export function checkArtSlotSizes(records = [], { config, assetsBase = "" } = {}
339
365
  */
340
366
  export function artPathname(index, value, defaultType, accepts) {
341
367
  if (value == null) return { pathname: null, resolved: true };
342
- if (value === "") return { pathname: "", resolved: true };
343
368
  const read = readAssetAddress(index, value, defaultType, accepts);
344
369
  if (!read.record) {
345
370
  return { pathname: null, resolved: false, reason: read.reason, type: read.type };