@heroiclands/package-build 23.4.1 → 23.5.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 (54) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +29 -25
  3. package/assets/images/provenance.yaml +1 -0
  4. package/bin/package-build.mjs +259 -101
  5. package/ci/prettier.mjs +73 -0
  6. package/config.mjs +10 -0
  7. package/container.mjs +232 -1
  8. package/content-config.mjs +5 -1
  9. package/docs/api.md +100 -52
  10. package/docs/authoring/assets.md +16 -1
  11. package/docs/commands.md +154 -51
  12. package/docs/configuration.md +39 -2
  13. package/docs/content-format.md +30 -6
  14. package/docs/diagnostics.md +1 -2
  15. package/docs/guides/e2e.md +38 -0
  16. package/docs/guides/icons.md +5 -0
  17. package/docs/guides/module-project.md +1 -1
  18. package/docs/guides/release-and-deploy.md +36 -1
  19. package/docs/project-setup.md +16 -59
  20. package/docs/reference/format-details.md +180 -34
  21. package/docs/reference/note-types.md +69 -69
  22. package/engine/asset-index.mjs +63 -22
  23. package/engine/asset-references.mjs +355 -0
  24. package/engine/asset-shipping.mjs +363 -0
  25. package/engine/asset-types.mjs +70 -0
  26. package/engine/base-compiler.mjs +24 -1
  27. package/engine/cdn-publish.mjs +643 -0
  28. package/engine/index.mjs +6 -0
  29. package/engine/note-vocabulary.mjs +4 -1
  30. package/engine/pathnames.mjs +21 -10
  31. package/engine/project-init.mjs +4 -29
  32. package/engine/rank-ladder.mjs +40 -16
  33. package/engine/scenes.mjs +1 -1
  34. package/engine/standing-terms.mjs +3 -1
  35. package/package.json +5 -6
  36. package/packagebuild-metadata.jsonl +13 -13
  37. package/release.mjs +69 -7
  38. package/sohl/actors.mjs +12 -4
  39. package/sohl/default-item-art.mjs +23 -19
  40. package/types/config.d.mts +7 -0
  41. package/types/container.d.mts +137 -1
  42. package/types/engine/asset-index.d.mts +3 -2
  43. package/types/engine/asset-references.d.mts +136 -0
  44. package/types/engine/asset-shipping.d.mts +240 -0
  45. package/types/engine/asset-types.d.mts +36 -0
  46. package/types/engine/base-compiler.d.mts +7 -1
  47. package/types/engine/cdn-publish.d.mts +312 -0
  48. package/types/engine/index.d.mts +2 -0
  49. package/types/engine/pathnames.d.mts +2 -1
  50. package/types/engine/rank-ladder.d.mts +25 -6
  51. package/types/release.d.mts +5 -3
  52. package/types/sohl/default-item-art.d.mts +2 -1
  53. package/labels.mjs +0 -191
  54. package/types/labels.d.mts +0 -40
package/config.mjs CHANGED
@@ -57,6 +57,7 @@
57
57
  */
58
58
 
59
59
  import path from "node:path";
60
+ import { shippingLicensesProblem } from "./engine/asset-types.mjs";
60
61
  import { fileURLToPath } from "node:url";
61
62
  import { loadPackConfig, locateConfigError, packConfigPath } from "./engine/pack-config.mjs";
62
63
 
@@ -65,6 +66,7 @@ const SECTION_KEYS = [
65
66
  "stageDir",
66
67
  "assets",
67
68
  "assetTransform",
69
+ "shippingLicenses",
68
70
  "baseStyles",
69
71
  "manifest",
70
72
  "manifestFlags",
@@ -556,6 +558,8 @@ function normalizeExceptions(value, field, where) {
556
558
  * @property {string} stageDir The staged package root, relative to
557
559
  * `rootDir`. Every asset `to:` lands under it.
558
560
  * @property {readonly Readonly<AssetSpec>[]} assets
561
+ * @property {readonly string[]} shippingLicenses Licences this package adds to the
562
+ * default shipping list.
559
563
  * @property {boolean} baseStyles Whether the package takes the shared base
560
564
  * stylesheet, staged into `stageDir` and
561
565
  * named first in the manifest's `styles`.
@@ -785,6 +789,9 @@ export function resolvePackageBuildConfig(shared) {
785
789
  }
786
790
  const assets = (section.assets ?? []).map(normalizeAsset);
787
791
 
792
+ const shippingProblem = shippingLicensesProblem(section.shippingLicenses);
793
+ if (shippingProblem) fail("packageBuild.shippingLicenses", shippingProblem);
794
+
788
795
  const clean = section.clean ?? {};
789
796
  if (!isMapping(clean)) fail("packageBuild.clean", "must be a mapping");
790
797
  rejectUnknownKeys(
@@ -899,6 +906,9 @@ export function resolvePackageBuildConfig(shared) {
899
906
  ARTIFACT_OF_KIND[/** @type {"systems"|"modules"} */ (shared.packageKind)]
900
907
  : requireNonEmptyString(releaseInput.artifact, "packageBuild.release.artifact"),
901
908
  assets: Object.freeze(assets),
909
+ shippingLicenses: Object.freeze([
910
+ .../** @type {string[]} */ (section.shippingLicenses ?? []),
911
+ ]),
902
912
  // On by default, because the rules it carries are what compiled content
903
913
  // needs to be legible and a package that declined them by omission
904
914
  // would ship the content unstyled without anything saying so. A package
package/container.mjs CHANGED
@@ -380,6 +380,216 @@ export function needsFoundryAccount({
380
380
  return !licensed;
381
381
  }
382
382
 
383
+ /** The S3 image: MinIO images cannot be pulled everywhere, SeaweedFS can. */
384
+ export const S3_IMAGE = "chrislusf/seaweedfs:3.80";
385
+
386
+ /** The name the S3 server answers to on the shared network, and in `awsConfig`. */
387
+ export const S3_ALIAS = "s3.localhost";
388
+
389
+ /** The port the S3 server listens on and publishes. */
390
+ export const S3_PORT = 8333;
391
+
392
+ /** The bucket the S3 server is created with. */
393
+ export const S3_BUCKET = "sohl-e2e";
394
+
395
+ /** The consumer's S3 identities, relative to the repository root. */
396
+ export const S3_IDENTITIES_FILE = "cypress/s3/s3-identities.json";
397
+
398
+ /** The consumer's Foundry `awsConfig`, relative to the repository root. */
399
+ export const S3_AWS_CONFIG_FILE = "cypress/s3/aws-e2e.json";
400
+
401
+ /**
402
+ * Whether the S3 server is asked for: `FOUNDRYVTT_CONTAINER_S3` set to `1` or
403
+ * `true`. Anything else, including unset, leaves the container as it is.
404
+ *
405
+ * @param {NodeJS.ProcessEnv} [env] - Environment to read.
406
+ * @returns {boolean} Whether to run the S3 server.
407
+ */
408
+ export function s3Requested(env = process.env) {
409
+ return ["1", "true"].includes(env.FOUNDRYVTT_CONTAINER_S3?.trim().toLowerCase() ?? "");
410
+ }
411
+
412
+ /**
413
+ * Everything the S3 opt-in does, as data.
414
+ *
415
+ * @typedef {object} S3Plan
416
+ * @property {string} network - The user-defined network the Foundry container
417
+ * and the S3 server share.
418
+ * @property {string} server - The S3 server's container name.
419
+ * @property {string} identities - Absolute path of the identities file.
420
+ * @property {string} awsConfig - Absolute path of the Foundry `awsConfig`.
421
+ * @property {string} awsConfigName - The file's name under the data root's
422
+ * `Config/`, and the value of `FOUNDRY_AWS_CONFIG`.
423
+ */
424
+
425
+ /**
426
+ * The S3 plan for a container, or `null` when the opt-in is off.
427
+ *
428
+ * @param {object} opts
429
+ * @param {string} opts.name - The Foundry container's name.
430
+ * @param {string} opts.rootDir - The consumer repository root, which holds the
431
+ * identities and the `awsConfig`.
432
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
433
+ * @returns {S3Plan|null} The plan.
434
+ */
435
+ export function resolveS3({ name, rootDir, env = process.env }) {
436
+ if (!s3Requested(env)) return null;
437
+ return {
438
+ network: `${name}-net`,
439
+ server: `${name}-s3`,
440
+ identities: path.resolve(rootDir, S3_IDENTITIES_FILE),
441
+ awsConfig: path.resolve(rootDir, S3_AWS_CONFIG_FILE),
442
+ awsConfigName: path.basename(S3_AWS_CONFIG_FILE),
443
+ };
444
+ }
445
+
446
+ /**
447
+ * The `docker run` argument vector for the S3 server.
448
+ *
449
+ * @param {S3Plan} plan - The S3 plan.
450
+ * @returns {string[]} Arguments after `docker`.
451
+ */
452
+ export function s3RunArgs(plan) {
453
+ return [
454
+ "run",
455
+ "--detach",
456
+ "--name",
457
+ plan.server,
458
+ "--network",
459
+ plan.network,
460
+ "--network-alias",
461
+ S3_ALIAS,
462
+ "--publish",
463
+ `${S3_PORT}:${S3_PORT}`,
464
+ "--volume",
465
+ `${plan.identities}:/etc/seaweedfs/s3.json:ro`,
466
+ S3_IMAGE,
467
+ "server",
468
+ "-dir=/data",
469
+ "-s3",
470
+ `-s3.port=${S3_PORT}`,
471
+ "-s3.config=/etc/seaweedfs/s3.json",
472
+ "-s3.allowedOrigins=*",
473
+ ];
474
+ }
475
+
476
+ /**
477
+ * The `docker exec` argument vector that runs one `weed shell` command in the
478
+ * S3 server.
479
+ *
480
+ * @param {S3Plan} plan - The S3 plan.
481
+ * @param {string} command - The shell command, such as `s3.bucket.list`.
482
+ * @returns {string[]} Arguments after `docker`.
483
+ */
484
+ export function s3ShellArgs(plan, command) {
485
+ return [
486
+ "exec",
487
+ plan.server,
488
+ "sh",
489
+ "-c",
490
+ `echo "${command}" | weed shell -master=localhost:9333`,
491
+ ];
492
+ }
493
+
494
+ /**
495
+ * Start the S3 server on its network and create the bucket, unless it is
496
+ * already running, and copy the `awsConfig` into the data root.
497
+ *
498
+ * @param {object} opts
499
+ * @param {S3Plan} opts.plan - The S3 plan.
500
+ * @param {string} opts.dataRoot - The Foundry user-data root.
501
+ * @param {(message: string) => void} [opts.log] - Progress reporting.
502
+ * @param {(args: string[]) => number} [opts.run] - Runs docker, returning the
503
+ * exit status.
504
+ * @param {(args: string[]) => string} [opts.capture] - Runs docker, returning
505
+ * its stdout, or `""` on failure.
506
+ * @param {(ms: number) => void} [opts.sleep] - Waits.
507
+ * @param {number} [opts.attempts] - How often to ask the server whether it is
508
+ * ready.
509
+ * @throws {Error} When an input file is missing or the server never answers.
510
+ */
511
+ export function ensureS3({
512
+ plan,
513
+ dataRoot,
514
+ log = () => {},
515
+ run = runDocker,
516
+ capture = captureDocker,
517
+ sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms),
518
+ attempts = 60,
519
+ }) {
520
+ for (const file of [plan.identities, plan.awsConfig]) {
521
+ if (!fs.existsSync(file)) {
522
+ throw new Error(`FOUNDRYVTT_CONTAINER_S3 needs ${file}, which does not exist.`);
523
+ }
524
+ }
525
+ const configDir = path.join(dataRoot, "Config");
526
+ fs.mkdirSync(configDir, { recursive: true });
527
+ fs.copyFileSync(plan.awsConfig, path.join(configDir, plan.awsConfigName));
528
+
529
+ if (
530
+ !capture(["network", "ls", "--filter", `name=^${plan.network}$`, "--format", "{{.Name}}"])
531
+ ) {
532
+ run(["network", "create", plan.network]);
533
+ }
534
+ const present = capture([
535
+ "ps",
536
+ "-a",
537
+ "--filter",
538
+ `name=^${plan.server}$`,
539
+ "--format",
540
+ "{{.Names}}",
541
+ ]);
542
+ if (present.split("\n").includes(plan.server)) {
543
+ const running = capture([
544
+ "ps",
545
+ "--filter",
546
+ `name=^${plan.server}$`,
547
+ "--format",
548
+ "{{.Names}}",
549
+ ]);
550
+ if (!running.split("\n").includes(plan.server)) run(["start", plan.server]);
551
+ return;
552
+ }
553
+ log(`Starting S3 server '${plan.server}' (${S3_IMAGE}) as ${S3_ALIAS}:${S3_PORT}`);
554
+ const status = run(s3RunArgs(plan));
555
+ if (status !== 0) throw new Error(`Starting the S3 server failed with status ${status}.`);
556
+ // `weed shell` exits 0 whether or not the filer is up; it names the filers
557
+ // it found once there is one.
558
+ for (let attempt = 0; ; attempt++) {
559
+ if (capture(s3ShellArgs(plan, "s3.bucket.list")).includes("filers: [")) break;
560
+ if (attempt >= attempts) {
561
+ throw new Error(`The S3 server '${plan.server}' did not become ready.`);
562
+ }
563
+ sleep(1000);
564
+ }
565
+ run(s3ShellArgs(plan, `s3.bucket.create -name ${S3_BUCKET}`));
566
+ }
567
+
568
+ /**
569
+ * Remove the S3 server and its network, tolerating either being absent.
570
+ *
571
+ * @param {S3Plan} plan - The S3 plan.
572
+ * @param {(args: string[]) => number} [run] - Runs docker.
573
+ * @param {(args: string[]) => string} [capture] - Runs docker for its stdout.
574
+ */
575
+ export function removeS3(plan, run = runDocker, capture = captureDocker) {
576
+ const present = capture([
577
+ "ps",
578
+ "-a",
579
+ "--filter",
580
+ `name=^${plan.server}$`,
581
+ "--format",
582
+ "{{.Names}}",
583
+ ]);
584
+ if (present.split("\n").includes(plan.server)) {
585
+ run(["stop", plan.server]);
586
+ run(["rm", plan.server]);
587
+ }
588
+ if (capture(["network", "ls", "--filter", `name=^${plan.network}$`, "--format", "{{.Name}}"])) {
589
+ run(["network", "rm", plan.network]);
590
+ }
591
+ }
592
+
383
593
  /**
384
594
  * The full `docker run` argument vector for a stage's container.
385
595
  *
@@ -399,6 +609,11 @@ export function needsFoundryAccount({
399
609
  * @param {string|null} [opts.licenseKey] - Dedicated licence key.
400
610
  * @param {boolean} [opts.account] - Whether to pass `FOUNDRY_USERNAME` and
401
611
  * `FOUNDRY_PASSWORD` through; see {@link needsFoundryAccount}.
612
+ * @param {string|null} [opts.network] - A user-defined network to attach the
613
+ * container to as it is created, so it can reach the S3 server before Foundry
614
+ * starts.
615
+ * @param {string|null} [opts.awsConfig] - Name of the `awsConfig` file under the
616
+ * data root's `Config/`, passed as `FOUNDRY_AWS_CONFIG`.
402
617
  * @param {string|null} [opts.user] - `--user` value to run the container as.
403
618
  * `felddy/foundryvtt` runs Foundry as user 1000 and ignores `FOUNDRY_UID` and
404
619
  * `FOUNDRY_GID`; under rootless Docker, container root is the host user, so
@@ -417,6 +632,8 @@ export function dockerRunArgs({
417
632
  world = null,
418
633
  licenseKey = null,
419
634
  account = true,
635
+ network = null,
636
+ awsConfig = null,
420
637
  user = null,
421
638
  }) {
422
639
  const args = [
@@ -444,6 +661,8 @@ export function dockerRunArgs({
444
661
  if (version) args.push("-e", `FOUNDRY_VERSION=${version}`);
445
662
  if (world !== null) args.push("-e", `FOUNDRY_WORLD=${world}`);
446
663
  if (licenseKey) args.push("-e", `FOUNDRY_LICENSE_KEY=${licenseKey}`);
664
+ if (network) args.push("--network", network);
665
+ if (awsConfig) args.push("-e", `FOUNDRY_AWS_CONFIG=${awsConfig}`);
447
666
  if (user?.trim()) args.push("--user", user.trim());
448
667
  args.push(image);
449
668
  return args;
@@ -660,6 +879,7 @@ export function clearStaleLock(dataRoot, log = () => {}) {
660
879
  * @property {string|null} licenseKey
661
880
  * @property {string|null} cacheDir
662
881
  * @property {string|null} user - From `FOUNDRYVTT_CONTAINER_USER`.
882
+ * @property {S3Plan|null} s3 - Set by `FOUNDRYVTT_CONTAINER_S3`.
663
883
  */
664
884
 
665
885
  /**
@@ -680,9 +900,10 @@ export function resolveContainer({ stage, config, env = process.env }) {
680
900
  e2eStage: config.e2eStage,
681
901
  });
682
902
  const port = resolveStagePort(stage, { env, stages });
903
+ const name = containerName(config.packageId, stage, config.containerName);
683
904
  return {
684
905
  stage,
685
- name: containerName(config.packageId, stage, config.containerName),
906
+ name,
686
907
  port,
687
908
  image: resolveImage({
688
909
  env,
@@ -696,6 +917,7 @@ export function resolveContainer({ stage, config, env = process.env }) {
696
917
  licenseKey: resolveLicenseKey(stage, env),
697
918
  cacheDir: env.FOUNDRYVTT_CACHE?.trim() || null,
698
919
  user: env.FOUNDRYVTT_CONTAINER_USER?.trim() || null,
920
+ s3: resolveS3({ name, rootDir: config.rootDir, env }),
699
921
  };
700
922
  }
701
923
 
@@ -710,10 +932,16 @@ export function resolveContainer({ stage, config, env = process.env }) {
710
932
  * @returns {number} The exit status.
711
933
  */
712
934
  export function startContainer(container, { dataRoot, env = process.env, log = () => {} }) {
935
+ // Before Foundry starts, so its S3 client finds the server on the first try.
936
+ if (container.s3) ensureS3({ plan: container.s3, dataRoot, log });
713
937
  if (containerExists(container.name)) {
714
938
  // Nothing is running against this data root — we are about to start it
715
939
  // — so any lock present was left by a previous crash.
716
940
  if (!containerExists(container.name, true)) clearStaleLock(dataRoot, log);
941
+ if (container.s3) {
942
+ // Fails harmlessly when the container is already on the network.
943
+ captureDocker(["network", "connect", container.s3.network, container.name]);
944
+ }
717
945
  log(`Starting existing container '${container.name}' → ${container.url}`);
718
946
  return runDocker(["start", container.name]);
719
947
  }
@@ -743,6 +971,8 @@ export function startContainer(container, { dataRoot, env = process.env, log = (
743
971
  version: container.version,
744
972
  licenseKey: container.licenseKey ?? env.FOUNDRY_LICENSE_KEY?.trim() ?? null,
745
973
  }),
974
+ network: container.s3?.network ?? null,
975
+ awsConfig: container.s3?.awsConfigName ?? null,
746
976
  user: container.user,
747
977
  }),
748
978
  );
@@ -818,6 +1048,7 @@ export function containerAction({ action, stage, config, env = process.env, log
818
1048
  }
819
1049
  case "rm":
820
1050
  removeContainer(container.name, log);
1051
+ if (container.s3) removeS3(container.s3);
821
1052
  return 0;
822
1053
  case "status":
823
1054
  return runDocker(["ps", "-a", "--filter", `name=^${container.name}$`]);
@@ -69,7 +69,7 @@ import YAML from "yaml";
69
69
  // Leaves with no local imports of their own, so naming them here cannot close
70
70
  // a cycle around a consumer's config file (see `engine/pack-config.mjs`).
71
71
  import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./engine/address-charset.mjs";
72
- import { ASSET_TYPE_NAMES } from "./engine/asset-types.mjs";
72
+ import { ASSET_TYPE_NAMES, shippingLicensesProblem } from "./engine/asset-types.mjs";
73
73
  import { isReservedPackage } from "./engine/packages.mjs";
74
74
  import { EMPTY_ICON_REGISTRY, checkIconRegistry } from "./engine/content-icons.mjs";
75
75
  import { MAP_TYPES, NO_PACK, PACK_BY_TYPE } from "./engine/ids.mjs";
@@ -2453,6 +2453,10 @@ function normalizePackageBuild(value) {
2453
2453
  "reads, and that package validates what is inside it",
2454
2454
  );
2455
2455
  }
2456
+ // Read by the asset reference pass as well as by the packaging commands, so
2457
+ // a malformed list is refused wherever the configuration loads.
2458
+ const problem = shippingLicensesProblem(value.shippingLicenses);
2459
+ if (problem) fail("packageBuild.shippingLicenses", problem);
2456
2460
  return /** @type {Readonly<PackageBuildSection>} */ (deepFreeze(structuredClone(value)));
2457
2461
  }
2458
2462
 
package/docs/api.md CHANGED
@@ -891,31 +891,67 @@ Emitting this package's content index. Every content build already walks the who
891
891
 
892
892
  The asset types — `icon`, `image` and `audio` — and the three roots they are walked from. An asset is addressed exactly as a note is; what differs is that a `.webp` carries no frontmatter and has nowhere to say what it is, so the root supplies the type and the list of roots is closed.
893
893
 
894
- | Export | Signature | Returns | Use it when |
895
- | ------------------ | ------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
896
- | `ASSET_TYPES` | `const ASSET_TYPES` | `readonly AssetType[]` — type, root, extensions, description | walking the roots, or presenting the vocabulary |
897
- | `ASSET_TYPE_NAMES` | `const ASSET_TYPE_NAMES` | `ReadonlySet<string>` | admitting the asset types into a type vocabulary an address is parsed against |
898
- | `ASSET_SYSTEM` | `const ASSET_SYSTEM` | `string` — always `none` | writing an asset address, whose `<system>` segment is a property of the type rather than of the reference |
899
- | `IMAGE_EXTENSIONS` | `const IMAGE_EXTENSIONS` | `readonly string[]` — lowercase, dot-led | deciding whether a file under a picture root is an asset at all |
900
- | `AUDIO_EXTENSIONS` | `const AUDIO_EXTENSIONS` | `readonly string[]` — lowercase, dot-led | the same question for `audio/` |
901
- | `isAssetType` | `isAssetType(type)` | `boolean` | scoping a rewrite rule, which may substitute a file and nothing else |
902
- | `assetTypeOfRoot` | `assetTypeOfRoot(root)` | `AssetType \| undefined` | reading a root directory name back to the type it declares |
903
- | `isAssetShortcode` | `isAssetShortcode(shortcode)` | `boolean` | telling an addressable filename from one that carries a version string, a hyphen or a date stamp |
904
- | `imageDimensions` | `imageDimensions(absPath, ext)` | `{width, height} \| undefined` | reading a raster file's own pixel size from its header, which an SVG carries none of and a partial file fails closed on |
894
+ | Export | Signature | Returns | Use it when |
895
+ | ------------------------- | -------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
896
+ | `ASSET_TYPES` | `const ASSET_TYPES` | `readonly AssetType[]` — type, root, extensions, description | walking the roots, or presenting the vocabulary |
897
+ | `ASSET_TYPE_NAMES` | `const ASSET_TYPE_NAMES` | `ReadonlySet<string>` | admitting the asset types into a type vocabulary an address is parsed against |
898
+ | `ASSET_SYSTEM` | `const ASSET_SYSTEM` | `string` — always `none` | writing an asset address, whose `<system>` segment is a property of the type rather than of the reference |
899
+ | `IMAGE_EXTENSIONS` | `const IMAGE_EXTENSIONS` | `readonly string[]` — lowercase, dot-led | deciding whether a file under a picture root is an asset at all |
900
+ | `AUDIO_EXTENSIONS` | `const AUDIO_EXTENSIONS` | `readonly string[]` — lowercase, dot-led | the same question for `audio/` |
901
+ | `isAssetType` | `isAssetType(type)` | `boolean` | scoping a rewrite rule, which may substitute a file and nothing else |
902
+ | `assetTypeOfRoot` | `assetTypeOfRoot(root)` | `AssetType \| undefined` | reading a root directory name back to the type it declares |
903
+ | `isAssetShortcode` | `isAssetShortcode(shortcode)` | `boolean` | telling an addressable filename from one that carries a version string, a hyphen or a date stamp |
904
+ | `imageDimensions` | `imageDimensions(absPath, ext)` | `{width, height} \| undefined` | reading a raster file's own pixel size from its header, which an SVG carries none of and a partial file fails closed on |
905
+ | `DEPLOYED_ASSET_TYPES` | `const DEPLOYED_ASSET_TYPES` | `readonly AssetType[]` — `icon` and `image` | naming the types a location holds flat, which audio is not |
906
+ | `isDeployedSuffix` | `isDeployedSuffix(suffix)` | `boolean` | asking whether a path inside `assets/` sits under a picture root, and so deploys flat |
907
+ | `deployedSuffix` | `deployedSuffix(suffix)` | `string` — `<icons\|images>/<basename>`, or the suffix unchanged | turning a source path inside `assets/` into the path the file has in the package, on the CDN and in every location |
908
+ | `shippingLicensesProblem` | `shippingLicensesProblem(value)` | `string` — a clause, or `""` | checking a `packageBuild.shippingLicenses` value: a list of non-empty, distinct strings |
905
909
 
906
910
  ### `engine.assetIndex`
907
911
 
908
- The asset record: one line of the content index per addressable file. The record is emitted by the package holding the bytes, so its `path` is that package's path and each consumer joins its own root onto it — Foundry its data directory, the website its CDN prefix, the book its asset base.
909
-
910
- | Export | Signature | Returns | Use it when |
911
- | --------------------------- | --------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
912
- | `ASSET_ROLES` | `const ASSET_ROLES` | `readonly string[]` | naming the closed set of roles a picture may declare |
913
- | `ASSET_RECORD_FIELDS` | `const ASSET_RECORD_FIELDS` | `readonly AssetRecordField[]` | reading what an `asset` block carries, and where each field comes from |
914
- | `PROVENANCE_KEYS` | `const PROVENANCE_KEYS` | `ReadonlySet<string>` | validating a provenance file, whose keys are derived from the record's own fields |
915
- | `REQUIRED_PROVENANCE_KEYS` | `const REQUIRED_PROVENANCE_KEYS` | `ReadonlySet<string>` | the keys a provenance file must state — a record resolves wholesale, so one omitting them leaves every file it covers with no rights holder or no terms |
916
- | `PROVENANCE_FILE` | `const PROVENANCE_FILE` | `string` — `provenance.yaml` | naming the file a directory records provenance for its subtree in |
917
- | `PROVENANCE_SIDECAR_SUFFIX` | `const PROVENANCE_SIDECAR_SUFFIX` | `string` — `.yaml` | naming a per-file record, which replaces an inherited one wholesale |
918
- | `collectAssetRecords` | `collectAssetRecords(assetsBase, { contentPackage, problems })` | `Array<Record<string, any>>` | reading a package's asset roots into index records, without walking its notes |
912
+ The asset record: one line of the content index per addressable file. The record is emitted by the package holding the bytes, so its `path` is that package's source path inside `assets/`, which the book stages from; Foundry and the website serve the deployed layout derived from it (`engine.assetShipping`). Every asset is covered by a provenance record that states `attribution`, `license` and `ai`, and a file no record covers is a finding.
913
+
914
+ | Export | Signature | Returns | Use it when |
915
+ | --------------------------- | --------------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
916
+ | `ASSET_ROLES` | `const ASSET_ROLES` | `readonly string[]` | naming the closed set of roles a picture may declare |
917
+ | `ASSET_RECORD_FIELDS` | `const ASSET_RECORD_FIELDS` | `readonly AssetRecordField[]` | reading what an `asset` block carries, and where each field comes from |
918
+ | `PROVENANCE_KEYS` | `const PROVENANCE_KEYS` | `ReadonlySet<string>` | validating a provenance file, whose keys are derived from the record's own fields |
919
+ | `REQUIRED_PROVENANCE_KEYS` | `const REQUIRED_PROVENANCE_KEYS` | `ReadonlySet<string>` | the keys a provenance file must state — `attribution`, `license` and `ai`; a record resolves wholesale, so one omitting them leaves every file it covers without that answer |
920
+ | `PROVENANCE_FILE` | `const PROVENANCE_FILE` | `string` — `provenance.yaml` | naming the file a directory records provenance for its subtree in |
921
+ | `PROVENANCE_SIDECAR_SUFFIX` | `const PROVENANCE_SIDECAR_SUFFIX` | `string` — `.yaml` | naming a per-file record, which replaces an inherited one wholesale |
922
+ | `collectAssetRecords` | `collectAssetRecords(assetsBase, { contentPackage, problems })` | `Array<Record<string, any>>` | reading a package's asset roots into index records, without walking its notes |
923
+
924
+ ### `engine.assetShipping`
925
+
926
+ The deployed layout of a package's pictures, and which of them ship. A Foundry package holds its icons and images flat — `icons/<basename>`, `images/<basename>` — and the stage copies a picture only when its provenance says `ai: false` and its licence is on the shipping list. Its asset set, `asset-sets/default.json`, lists what it ships. The selection is one function, so the stage and any command publishing what the stage leaves out read the same answer.
927
+
928
+ | Export | Signature | Returns | Use it when |
929
+ | ------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
930
+ | `SHIPPING_LICENSES` | `const SHIPPING_LICENSES` | `readonly string[]` | reading the licences a shipped picture may carry, exactly as provenance spells them |
931
+ | `SHIPPED_SET_FILE` | `const SHIPPED_SET_FILE` | `string` — `asset-sets/default.json` | naming the staged package's own asset set, relative to the package root |
932
+ | `STAND_IN_SHORTCODE` | `const STAND_IN_SHORTCODE` | `string` — `missing` | naming the shortcode of a package's stand-in picture |
933
+ | `DEPLOYED_ASSET_TYPES` | `const DEPLOYED_ASSET_TYPES` | `readonly AssetType[]` | re-exported from `engine.assetTypes` |
934
+ | `deployedSuffix` | `deployedSuffix(suffix)` | `string` | re-exported from `engine.assetTypes` |
935
+ | `deployedAssetPath` | `deployedAssetPath(record)` | `string \| undefined` — `<icons\|images>/<basename>` | reading the deployed path of one asset record; `undefined` for audio |
936
+ | `exclusionReason` | `exclusionReason(record, additions?)` | `string` — a clause, or `""` | saying why a picture does not ship |
937
+ | `shipsAsset` | `shipsAsset(record, additions?)` | `boolean` | asking whether one picture ships |
938
+ | `standInAddress` | `standInAddress(pkg, type)` | `string` — `<package>-none-<type>-missing` | naming the stand-in an excluded picture compiles to |
939
+ | `selectStagedAssets` | `selectStagedAssets(records, additions?)` | `{staged, excluded}` — both in address order, each excluded one with its reason | splitting a package's pictures into what ships and what does not |
940
+ | `shippedSetName` | `shippedSetName(title)` | `string` — `<title> — shipped art` | naming the staged package's own asset set |
941
+ | `assetsManifest` | `assetsManifest(staged, stageDir)` | `{address, source, target, size}[]` | listing staged files as the shipped set does, each `source` relative to `asset-sets/`, sizes measured in the stage |
942
+ | `stagePackageAssets` | `stagePackageAssets({ assetsBase, contentPackage, stageDir, title, transform, shippingLicenses })` | `{staged, excluded, findings, manifest}` | staging a package's shipped pictures flat and writing `asset-sets/default.json`; nothing is staged when `findings` is non-empty |
943
+ | `rowsStagingPictureRoots` | `rowsStagingPictureRoots(rows, { rootDir, assetsBase })` | `{index, from, root}[]` | finding `packageBuild.assets` rows that would copy a picture root around the filter |
944
+
945
+ ### `engine.assetReferences`
946
+
947
+ Every asset reference in a compiled document states the asset's address: an `<img>` carries `data-asset`, and a path field's address sits in `flags.<system>.assets` on the document holding it, nested to mirror the field's path. A reference to a picture its package does not ship compiles to that package's stand-in and keeps the excluded address.
948
+
949
+ | Export | Signature | Returns | Use it when |
950
+ | ------------------------- | ------------------------------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------ |
951
+ | `ASSET_PATH_FIELDS` | `const ASSET_PATH_FIELDS` | `Record<string, AssetPathField[]>` | reading which fields of which document types name a picture, and where each records its address |
952
+ | `assetReferenceLookup` | `assetReferenceLookup(linkIndex, config)` | `{byPath, byAddress}` | indexing every picture a compiled document can name, local and fetched, by Foundry path and by address |
953
+ | `annotateHtml` | `annotateHtml(html, lookup, report)` | `string` | marking each `<img>` in one HTML string with its address |
954
+ | `annotateAssetReferences` | `annotateAssetReferences(doc, documentType, { lookup, system, report })` | `object` — the document, changed in place | stating every reference's address on a finished document and drawing stand-ins |
919
955
 
920
956
  ### `engine.packages`
921
957
 
@@ -1719,36 +1755,48 @@ console.log(dataEnvVar("dev"));
1719
1755
  // -> "FOUNDRYVTT_DEV_DATA"
1720
1756
  ```
1721
1757
 
1722
- | Export | Signature | Returns | Use it when |
1723
- | -------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1724
- | `DEFAULT_STAGE_PORTS` | `const DEFAULT_STAGE_PORTS` | — | reading the host port each conventional stage (`dev`/`qa`/`prod`/`test`) publishes by default, chosen so all four can run at once |
1725
- | `CONTAINER_PORT` | `const CONTAINER_PORT` | — | the fixed port Foundry listens on inside the container |
1726
- | `CACHE_MOUNT` | `const CACHE_MOUNT` | — | the path a host-provided download cache is mounted at inside the container |
1727
- | `CONTAINER_ACTIONS` | `const CONTAINER_ACTIONS` | — | the closed set of actions `package-build container` accepts |
1728
- | `dataEnvVar` | `dataEnvVar(stage)` | `string` | deriving the environment variable that names a stage's Foundry data root, without hand-tabulating one per stage |
1729
- | `containerName` | `containerName(packageId, stage, base = null)` | `string` | computing the stable container name a stage runs under, so a signed Foundry licence (bound to the hostname) survives a recreate; pass `base` to share one container/licence across packages |
1730
- | `resolveStagePort` | `resolveStagePort(stage, { env, stages } = {})` | `number` | resolving the host port for a stage, honoring `FOUNDRYVTT_<STAGE>_PORT` over a declared or conventional default |
1731
- | `resolveFoundryVersion` | `resolveFoundryVersion(stage, { env, stages, compatibilityMinimum, e2eStage = "test" } = {})` | `string\|null` | pinning the exact Foundry build a stage runs, deriving the end-to-end stage's pin from the package's own `compatibility.minimum` so the floor is actually exercised |
1732
- | `resolveImage` | `resolveImage({ env, image, version, compatibilityMinimum } = {})` | `string` | resolving the `felddy/foundryvtt` image reference a run uses — the pinned build's own tag when a build is pinned |
1733
- | `resolveWorld` | `resolveWorld(stage, { env, stages } = {})` | `string\|null` | resolving which world a stage auto-launches — `""` declares "never auto-launch", distinct from `null` (leave `FOUNDRY_WORLD` alone) |
1734
- | `resolveLicenseKey` | `resolveLicenseKey(stage, env = process.env)` | `string\|null` | finding a stage's dedicated Foundry licence key, since Foundry is single-seat and two stages running at once need two keys |
1735
- | `resolveDataRoot` | `resolveDataRoot(stage, { env } = {})` | `string` | resolving the local Foundry data root path for a stage, checked for what a bind mount needs |
1736
- | `passthroughEnv` | `passthroughEnv(env = process.env)` | `[string, string][]` | collecting the image's own `FOUNDRY_*`/`CONTAINER_*` environment pairs to pass through (deliberately excludes `CONTAINER_CACHE`, which `dockerRunArgs` sets itself) |
1737
- | `needsFoundryAccount` | `needsFoundryAccount({ cacheDir, dataRoot, version, licenseKey, fileExists })` | `boolean` | deciding whether the image needs `FOUNDRY_USERNAME`/`FOUNDRY_PASSWORD`: only when the pinned build's archive is not cached, or the licence must come from the account |
1738
- | `dockerRunArgs` | `dockerRunArgs({ name, image, port, dataRoot, env, cacheDir, version, world, licenseKey, account, user })` | `string[]` | building the full `docker run` argument vector for a stage's container; `account: false` withholds the foundryvtt.com account |
1739
- | `runDocker` | `runDocker(args)` | `number` | running the `docker` CLI with inherited stdio and getting back its exit status |
1740
- | `captureDocker` | `captureDocker(args)` | `string` | running `docker` and capturing trimmed stdout, tolerating failure (returns `""`) |
1741
- | `dataRootScriptArgs` | `dataRootScriptArgs({ image, dataRoot, script, args, user })` | `string[]` | building the `docker run` vector for a throwaway container that runs a shell script against a stage's data root, mounted at `/data` |
1742
- | `runDataRootScript` | `runDataRootScript(opts)` | `string` | running that script and getting its stdout, throwing on a non-zero exit; the way to remove or hand over files that the Foundry container's user owns |
1743
- | `containerTreeRemover` | `containerTreeRemover({ container, runScript })` | `(tree, { dataRoot }) => void` | removing a tree beneath a stage's data root that the Foundry container's user owns, as root in a throwaway container; the remover `package-build deploy` passes to `deployStage` |
1744
- | `containerRemovalCommand` | `containerRemovalCommand({ image, dataRoot, tree })` | `string` | writing out the `docker run` command that removes such a tree, for a person to paste |
1745
- | `containerExists` | `containerExists(name, runningOnly = false)` | `boolean` | checking whether a container with exactly this name exists (optionally, only counting a running one) |
1746
- | `runningFoundryContainers` | `runningFoundryContainers(except = "")` | `string[]` | listing HeroicLands-convention Foundry containers currently running, to warn about a licence clash before a run starts |
1747
- | `clearStaleLock` | `clearStaleLock(dataRoot, log = () => {})` | — | removing a data-root lock left behind by a container that did not shut down cleanly; only safe to call while the container is stopped |
1748
- | `resolveContainer` | `resolveContainer({ stage, config, env = process.env })` | `ResolvedContainer` | resolving all of a stage's container settings in one call, from the package-build configuration |
1749
- | `startContainer` | `startContainer(container, { dataRoot, env, log })` | `number` | starting a stage's container, creating it first if it does not exist |
1750
- | `removeContainer` | `removeContainer(name, log = () => {})` | — | stopping and removing a container, tolerating "not running" and "no such container" |
1751
- | `containerAction` | `containerAction({ action, stage, config, env, log })` | `number` | performing one `CONTAINER_ACTIONS` action for a stage end to end |
1758
+ | Export | Signature | Returns | Use it when |
1759
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1760
+ | `DEFAULT_STAGE_PORTS` | `const DEFAULT_STAGE_PORTS` | — | reading the host port each conventional stage (`dev`/`qa`/`prod`/`test`) publishes by default, chosen so all four can run at once |
1761
+ | `CONTAINER_PORT` | `const CONTAINER_PORT` | — | the fixed port Foundry listens on inside the container |
1762
+ | `CACHE_MOUNT` | `const CACHE_MOUNT` | — | the path a host-provided download cache is mounted at inside the container |
1763
+ | `CONTAINER_ACTIONS` | `const CONTAINER_ACTIONS` | — | the closed set of actions `package-build container` accepts |
1764
+ | `dataEnvVar` | `dataEnvVar(stage)` | `string` | deriving the environment variable that names a stage's Foundry data root, without hand-tabulating one per stage |
1765
+ | `containerName` | `containerName(packageId, stage, base = null)` | `string` | computing the stable container name a stage runs under, so a signed Foundry licence (bound to the hostname) survives a recreate; pass `base` to share one container/licence across packages |
1766
+ | `resolveStagePort` | `resolveStagePort(stage, { env, stages } = {})` | `number` | resolving the host port for a stage, honoring `FOUNDRYVTT_<STAGE>_PORT` over a declared or conventional default |
1767
+ | `resolveFoundryVersion` | `resolveFoundryVersion(stage, { env, stages, compatibilityMinimum, e2eStage = "test" } = {})` | `string\|null` | pinning the exact Foundry build a stage runs, deriving the end-to-end stage's pin from the package's own `compatibility.minimum` so the floor is actually exercised |
1768
+ | `resolveImage` | `resolveImage({ env, image, version, compatibilityMinimum } = {})` | `string` | resolving the `felddy/foundryvtt` image reference a run uses — the pinned build's own tag when a build is pinned |
1769
+ | `resolveWorld` | `resolveWorld(stage, { env, stages } = {})` | `string\|null` | resolving which world a stage auto-launches — `""` declares "never auto-launch", distinct from `null` (leave `FOUNDRY_WORLD` alone) |
1770
+ | `resolveLicenseKey` | `resolveLicenseKey(stage, env = process.env)` | `string\|null` | finding a stage's dedicated Foundry licence key, since Foundry is single-seat and two stages running at once need two keys |
1771
+ | `resolveDataRoot` | `resolveDataRoot(stage, { env } = {})` | `string` | resolving the local Foundry data root path for a stage, checked for what a bind mount needs |
1772
+ | `passthroughEnv` | `passthroughEnv(env = process.env)` | `[string, string][]` | collecting the image's own `FOUNDRY_*`/`CONTAINER_*` environment pairs to pass through (deliberately excludes `CONTAINER_CACHE`, which `dockerRunArgs` sets itself) |
1773
+ | `needsFoundryAccount` | `needsFoundryAccount({ cacheDir, dataRoot, version, licenseKey, fileExists })` | `boolean` | deciding whether the image needs `FOUNDRY_USERNAME`/`FOUNDRY_PASSWORD`: only when the pinned build's archive is not cached, or the licence must come from the account |
1774
+ | `dockerRunArgs` | `dockerRunArgs({ name, image, port, dataRoot, env, cacheDir, version, world, licenseKey, account, network, awsConfig, user })` | `string[]` | building the full `docker run` argument vector for a stage's container; `account: false` withholds the foundryvtt.com account |
1775
+ | `runDocker` | `runDocker(args)` | `number` | running the `docker` CLI with inherited stdio and getting back its exit status |
1776
+ | `captureDocker` | `captureDocker(args)` | `string` | running `docker` and capturing trimmed stdout, tolerating failure (returns `""`) |
1777
+ | `dataRootScriptArgs` | `dataRootScriptArgs({ image, dataRoot, script, args, user })` | `string[]` | building the `docker run` vector for a throwaway container that runs a shell script against a stage's data root, mounted at `/data` |
1778
+ | `runDataRootScript` | `runDataRootScript(opts)` | `string` | running that script and getting its stdout, throwing on a non-zero exit; the way to remove or hand over files that the Foundry container's user owns |
1779
+ | `containerTreeRemover` | `containerTreeRemover({ container, runScript })` | `(tree, { dataRoot }) => void` | removing a tree beneath a stage's data root that the Foundry container's user owns, as root in a throwaway container; the remover `package-build deploy` passes to `deployStage` |
1780
+ | `containerRemovalCommand` | `containerRemovalCommand({ image, dataRoot, tree })` | `string` | writing out the `docker run` command that removes such a tree, for a person to paste |
1781
+ | `containerExists` | `containerExists(name, runningOnly = false)` | `boolean` | checking whether a container with exactly this name exists (optionally, only counting a running one) |
1782
+ | `runningFoundryContainers` | `runningFoundryContainers(except = "")` | `string[]` | listing HeroicLands-convention Foundry containers currently running, to warn about a licence clash before a run starts |
1783
+ | `clearStaleLock` | `clearStaleLock(dataRoot, log = () => {})` | — | removing a data-root lock left behind by a container that did not shut down cleanly; only safe to call while the container is stopped |
1784
+ | `S3_IMAGE` | `const S3_IMAGE` | `string` | reading the S3 server's image |
1785
+ | `S3_ALIAS` | `const S3_ALIAS` | `string` | reading the name the S3 server answers to on the network |
1786
+ | `S3_PORT` | `const S3_PORT` | `number` | reading the port the S3 server publishes |
1787
+ | `S3_BUCKET` | `const S3_BUCKET` | `string` | reading the bucket the S3 server is created with |
1788
+ | `S3_IDENTITIES_FILE` | `const S3_IDENTITIES_FILE` | `string` | reading the repository-relative SeaweedFS identities file the S3 opt-in takes |
1789
+ | `S3_AWS_CONFIG_FILE` | `const S3_AWS_CONFIG_FILE` | `string` | reading the repository-relative Foundry `awsConfig` the S3 opt-in takes |
1790
+ | `s3Requested` | `s3Requested(env = process.env)` | `boolean` | asking whether `FOUNDRYVTT_CONTAINER_S3` opts in |
1791
+ | `resolveS3` | `resolveS3({ name, rootDir, env })` | `S3Plan\|null` | planning the network, server and files of the S3 opt-in; `null` when off |
1792
+ | `s3RunArgs` | `s3RunArgs(plan)` | `string[]` | building the S3 server's `docker run` vector |
1793
+ | `s3ShellArgs` | `s3ShellArgs(plan, command)` | `string[]` | building the `docker exec` vector running one `weed shell` command |
1794
+ | `ensureS3` | `ensureS3({ plan, dataRoot, log, run, capture, sleep, attempts })` | `void` | starting the network, server and bucket and copying the `awsConfig` into the data root |
1795
+ | `removeS3` | `removeS3(plan, run, capture)` | `void` | removing the S3 server and its network |
1796
+ | `resolveContainer` | `resolveContainer({ stage, config, env = process.env })` | `ResolvedContainer` | resolving all of a stage's container settings in one call, from the package-build configuration |
1797
+ | `startContainer` | `startContainer(container, { dataRoot, env, log })` | `number` | starting a stage's container, creating it first if it does not exist |
1798
+ | `removeContainer` | `removeContainer(name, log = () => {})` | — | stopping and removing a container, tolerating "not running" and "no such container" |
1799
+ | `containerAction` | `containerAction({ action, stage, config, env, log })` | `number` | performing one `CONTAINER_ACTIONS` action for a stage end to end |
1752
1800
 
1753
1801
  ## `./coverage`
1754
1802