@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
@@ -36,15 +36,23 @@
36
36
  * being built, and the whole of it is the suffix. That is the ordinary case and
37
37
  * the one nearly every note writes.
38
38
  *
39
- * The four forms, for a `harnensemble` note writing `images/map.webp`
39
+ * The four forms, for a `harnensemble` note writing `images/places/map.webp`
40
40
  * (`harnensemble` ships as the Foundry module `harn-ensemble`):
41
41
  *
42
42
  * | Form | Address |
43
43
  * | --------- | ------------------------------------------------------------- |
44
- * | `foundry` | `modules/harn-ensemble/assets/images/map.webp` |
45
- * | `local` | `assets/images/map.webp` |
44
+ * | `foundry` | `modules/harn-ensemble/images/map.webp` |
45
+ * | `local` | `assets/images/places/map.webp` |
46
46
  * | `web` | `https://cdn.heroiclands.org/harnensemble/images/map.webp` |
47
- * | `pdf` | `assets/images/map.webp` |
47
+ * | `pdf` | `assets/images/places/map.webp` |
48
+ *
49
+ * **Foundry and the website serve the deployed layout.** A suffix under
50
+ * `icons/` or `images/` reaches them flat — the type root and the basename,
51
+ * whatever directories the repository nests it in — because that is how the
52
+ * stage lays a package's pictures out and how the CDN holds them; see
53
+ * {@link module:engine/asset-shipping}. Every other suffix keeps its path, and
54
+ * sits under `assets/` in the install. `local` and `pdf` name the source file,
55
+ * so they keep the repository's own arrangement.
48
56
  *
49
57
  * **`<package>` and `<foundry-id>` are two different names.** The package is
50
58
  * `harnensemble` — what the content is called, what the website serves it under,
@@ -91,6 +99,7 @@
91
99
  */
92
100
 
93
101
  import { PACKAGEBUILD_PACKAGE } from "./packages.mjs";
102
+ import { deployedSuffix, isDeployedSuffix } from "./asset-types.mjs";
94
103
 
95
104
  /**
96
105
  * The directory a package ships its files in, and the segment that marks a
@@ -146,7 +155,8 @@ export const PATHNAME_SURFACES = Object.freeze(["foundry", "local", "web", "pdf"
146
155
  * package owns, `package` for an owned file.
147
156
  * @property {string|null} package The content package that owns the file, or
148
157
  * `null` when no package does.
149
- * @property {string|null} suffix The path inside that package's shipped tree.
158
+ * @property {string|null} suffix The path inside that package's `assets/`
159
+ * directory, as its repository arranges it.
150
160
  * @property {boolean} own Whether the owner is the package being built, and so
151
161
  * whether `local` names a file this build may open.
152
162
  * @property {string|null} foundry The address inside a Foundry install.
@@ -407,6 +417,10 @@ export function resolvePathname(raw, config) {
407
417
  const suffix = named ? segments.slice(2).join("/") : authored;
408
418
  const entry = owner ? packages.get(owner) : undefined;
409
419
  const host = String(config.site?.assets ?? "").replace(/\/+$/, "");
420
+ // A picture deploys flat at the package root; anything else keeps its
421
+ // path under the package's `assets/`.
422
+ const deployed = deployedSuffix(suffix);
423
+ const installed = isDeployedSuffix(suffix) ? deployed : `${ASSETS_SEGMENT}/${suffix}`;
410
424
 
411
425
  return {
412
426
  authored,
@@ -418,14 +432,11 @@ export function resolvePathname(raw, config) {
418
432
  // the first segment names a package this build has never heard of:
419
433
  // both are "there is no install path to derive", and guessing one
420
434
  // writes an address into a document nobody would check.
421
- foundry:
422
- entry?.root && entry.id ?
423
- `${entry.root}/${entry.id}/${ASSETS_SEGMENT}/${suffix}`
424
- : null,
435
+ foundry: entry?.root && entry.id ? `${entry.root}/${entry.id}/${installed}` : null,
425
436
  local: `${ASSETS_SEGMENT}/${suffix}`,
426
437
  // `null` where no asset host is configured. The website is the one
427
438
  // surface whose address is not derivable from the repository itself.
428
- web: host && owner ? `${host}/${owner}/${suffix}` : null,
439
+ web: host && owner ? `${host}/${owner}/${deployed}` : null,
429
440
  pdf: `${ASSETS_SEGMENT}/${suffix}`,
430
441
  };
431
442
  }
@@ -20,7 +20,6 @@ import prettier from "prettier";
20
20
  import prettierConfig from "../prettier-config.mjs";
21
21
  import { CONFIG_FILENAMES, configFromData } from "./pack-config.mjs";
22
22
  import { positionOfYamlPath } from "./diagnostics.mjs";
23
- import { checkLabelRegistry } from "../labels.mjs";
24
23
  import { formatNoteFrontmatter } from "./note-format.mjs";
25
24
 
26
25
  /** Package kinds the initializer can create. */
@@ -94,13 +93,12 @@ function projectScripts(answers) {
94
93
  "serve:site": "run-s build:deps build:content-index build:site-content serve:site-html",
95
94
  "serve:site-html": "hugo server --source build/hugo",
96
95
  "build:book": "package-build pdf",
97
- lint: "run-s lint:format lint:markdown lint:addresses lint:content-links lint:labels",
96
+ lint: "run-s lint:format lint:markdown lint:addresses lint:content-links",
98
97
  "lint:format": "package-build format",
99
98
  "lint:markdown": "package-build markdown",
100
99
  "lint:markdown:fix": "package-build markdown --fix",
101
100
  "lint:addresses": "package-build lint",
102
101
  "lint:content-links": "package-build links",
103
- "lint:labels": "package-build labels check",
104
102
  format: "package-build format --write",
105
103
  "format:check": "package-build format",
106
104
  changeset: "changeset",
@@ -125,8 +123,7 @@ function projectScripts(answers) {
125
123
  "push:qa": "package-build deploy qa",
126
124
  "push:prod": "package-build deploy prod",
127
125
  });
128
- scripts.lint =
129
- "run-s lint:format lint:markdown lint:lang lint:addresses lint:content-links lint:labels";
126
+ scripts.lint = "run-s lint:format lint:markdown lint:lang lint:addresses lint:content-links";
130
127
  return scripts;
131
128
  }
132
129
 
@@ -295,14 +292,6 @@ function projectText(answers) {
295
292
  updateInternalDependencies: "patch",
296
293
  ignore: [],
297
294
  }),
298
- ".github/labels.yml": YAML.stringify([
299
- {
300
- name: "documentation",
301
- color: "0075ca",
302
- description: "Documentation and author guides.",
303
- },
304
- { name: "devops", color: "e07b31", description: "Build, CI, and release work." },
305
- ]),
306
295
  ".github/ISSUE_REPORTING.md": [
307
296
  "# Issue reporting",
308
297
  "",
@@ -314,12 +303,9 @@ function projectText(answers) {
314
303
  "",
315
304
  "Use the organization issue type for a bug, feature, task, spike, or epic.",
316
305
  "",
317
- "## 3. Labels — the closed registry",
306
+ "## 3. Labels",
318
307
  "",
319
- "| Label | Use it for |",
320
- "| ----- | ---------- |",
321
- "| `documentation` | Documentation and author guides. |",
322
- "| `devops` | Build, CI, and release work. |",
308
+ "Labels are organization-level, defined in `HeroicLands/.github`.",
323
309
  "",
324
310
  ].join("\n"),
325
311
  ".github/workflows/build.yml": [
@@ -437,7 +423,6 @@ export function checkProject(rootDir) {
437
423
  "README.md",
438
424
  "LICENSE.md",
439
425
  ".changeset/config.json",
440
- ".github/labels.yml",
441
426
  ".github/ISSUE_REPORTING.md",
442
427
  ".github/workflows/build.yml",
443
428
  ];
@@ -525,15 +510,5 @@ export function checkProject(rootDir) {
525
510
  } else if (!fs.readdirSync(contentRoot).some((file) => file.endsWith(".md"))) {
526
511
  add("assets/content", "the content tree has no notes");
527
512
  }
528
- const labelsPath = path.join(root, ".github/labels.yml");
529
- const reportingPath = path.join(root, ".github/ISSUE_REPORTING.md");
530
- if (fs.existsSync(labelsPath) && fs.existsSync(reportingPath)) {
531
- const result = checkLabelRegistry({
532
- registryText: fs.readFileSync(labelsPath, "utf8"),
533
- docText: fs.readFileSync(reportingPath, "utf8"),
534
- });
535
- for (const finding of result.registry) add(".github/labels.yml", finding.message);
536
- for (const finding of result.doc) add(".github/ISSUE_REPORTING.md", finding.message);
537
- }
538
513
  return findings;
539
514
  }
@@ -47,18 +47,47 @@ import { RUNG_FIELDS } from "./standing-terms.mjs";
47
47
 
48
48
  export { RUNG_FIELDS };
49
49
 
50
- /** Every affiliation has an ordinary standing that a being can name as rank 1. */
50
+ /**
51
+ * Every affiliation's ladder has its two floor rungs: level 0, those the body
52
+ * excludes, and level 1, its ordinary members.
53
+ *
54
+ * A body that can name its members can name those it shuts out, whether or not
55
+ * it ever does, so both rungs are required of every ladder. A missing or empty
56
+ * ladder is one finding naming both; a ladder lacking either rung is one
57
+ * finding per rung it lacks, at the `ranks` key.
58
+ *
59
+ * @param {object} note - The note being checked.
60
+ * @param {string} note.file - Its path, as a diagnostic spells it.
61
+ * @param {string} [note.raw] - Its source, for the position.
62
+ * @param {object} [note.fm] - Its parsed frontmatter.
63
+ * @returns {Array<{file: string, line?: number, column?: number,
64
+ * severity: string, message: string}>} The findings.
65
+ */
51
66
  export function checkAffiliationRankFloor(note) {
52
67
  if (note.fm?.type !== "affiliation") return [];
53
68
  const governance = note.fm?.data?.governance;
54
69
  const ranks = governance?.ranks;
55
70
  // A non-list already has a shape finding. Do not repeat it here.
56
71
  if (ranks !== undefined && ranks !== null && !Array.isArray(ranks)) return [];
57
- if (
72
+ const has = (level) =>
58
73
  Array.isArray(ranks) &&
59
- ranks.some((rung) => integer(rung?.level) && Number(rung.level) === 1)
60
- )
61
- return [];
74
+ ranks.some((rung) => integer(rung?.level) && Number(rung.level) === level);
75
+
76
+ const messages = [];
77
+ if (!Array.isArray(ranks) || ranks.length === 0) {
78
+ messages.push(
79
+ "affiliation needs data.governance.ranks with a level-0 rung naming those the " +
80
+ "body excludes and a level 1 standing for its members",
81
+ );
82
+ } else {
83
+ if (!has(1)) messages.push("affiliation data.governance.ranks needs a level 1 standing");
84
+ if (!has(0))
85
+ messages.push(
86
+ "data.governance.ranks must have a level-0 rung naming those the body " +
87
+ "excludes, beside its level-1 members",
88
+ );
89
+ }
90
+ if (messages.length === 0) return [];
62
91
 
63
92
  const path = Array.isArray(ranks) ? ["data", "governance", "ranks"] : ["data", "governance"];
64
93
  const atRankOrGovernance = positionOfFrontmatterPath(note.raw ?? "", path, { key: true });
@@ -66,17 +95,12 @@ export function checkAffiliationRankFloor(note) {
66
95
  atRankOrGovernance.line !== undefined ?
67
96
  atRankOrGovernance
68
97
  : positionOfFrontmatterPath(note.raw ?? "", ["data"], { key: true });
69
- return [
70
- {
71
- file: note.file,
72
- ...position,
73
- severity: "error",
74
- message:
75
- !Array.isArray(ranks) || ranks.length === 0 ?
76
- "affiliation needs data.governance.ranks with a level 1 standing"
77
- : "affiliation data.governance.ranks needs a level 1 standing",
78
- },
79
- ];
98
+ return messages.map((message) => ({
99
+ file: note.file,
100
+ ...position,
101
+ severity: "error",
102
+ message,
103
+ }));
80
104
  }
81
105
 
82
106
  /**
package/engine/scenes.mjs CHANGED
@@ -220,7 +220,7 @@ export class Scenes extends BasePackCompiler {
220
220
  // A scene that references nothing ships fine as a plain `scenes`
221
221
  // entry; only pins need the id-preserving import an Adventure gives.
222
222
  if (!place.pinned) continue;
223
- this.writeTo(this.adventureDir, this.#buildAdventure(place));
223
+ this.writeTo(this.adventureDir, this.#buildAdventure(place), "Adventure");
224
224
  this.adventureCount++;
225
225
  }
226
226
  }
@@ -82,7 +82,9 @@ export const RUNG_FIELDS = Object.freeze([
82
82
  kind: "integer",
83
83
  required: true,
84
84
  shape: "a whole number — the rung's position on this body's own ladder",
85
- describe: "The rung's position on the ladder; a member's `rank` names it.",
85
+ describe:
86
+ "The rung's position on the ladder — `0` the excluded, `1` the ordinary member; " +
87
+ "a member's `rank` names it.",
86
88
  }),
87
89
  Object.freeze({
88
90
  name: "title",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "23.4.1",
3
+ "version": "23.5.1",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -120,7 +120,6 @@
120
120
  "hugo-theme",
121
121
  "index.mjs",
122
122
  "lang.mjs",
123
- "labels.mjs",
124
123
  "manifest.mjs",
125
124
  "markdownlint-config.mjs",
126
125
  "prettier-config.mjs",
@@ -136,6 +135,7 @@
136
135
  "@viz-js/viz": "^3.30.0",
137
136
  "acorn": "^8.18.0",
138
137
  "archiver": "^8.0.0",
138
+ "aws4fetch": "^1.0.20",
139
139
  "classic-level": "^3.0.0",
140
140
  "dale-chall": "^2.0.1",
141
141
  "dotenv": "^18.0.0",
@@ -189,13 +189,12 @@
189
189
  "prepack": "run-s build:types build:asset-index",
190
190
  "build:types": "tsc -p tsconfig.dts.json",
191
191
  "build:asset-index": "node ci/emit-asset-index.mjs",
192
- "format": "prettier --write .",
193
- "format:check": "prettier --check .",
194
- "lint": "run-p --aggregate-output -c --max-parallel 4 format:check lint:markdown lint:yaml lint:labels lint:image-roles lint:banners lint:content-format lint:docs-links",
192
+ "format": "node ci/prettier.mjs --write",
193
+ "format:check": "node ci/prettier.mjs --check",
194
+ "lint": "run-p --aggregate-output -c --max-parallel 4 format:check lint:markdown lint:yaml lint:image-roles lint:banners lint:content-format lint:docs-links",
195
195
  "lint:markdown": "node bin/package-build.mjs markdown",
196
196
  "lint:docs-links": "node bin/package-build.mjs docs links",
197
197
  "lint:yaml": "node bin/package-build.mjs yaml",
198
- "lint:labels": "node bin/package-build.mjs labels check",
199
198
  "lint:image-roles": "vitest run tests/theme-image-roles.test.ts",
200
199
  "lint:banners": "node ci/check-banners.mjs",
201
200
  "lint:markdown:fix": "node bin/package-build.mjs markdown --fix",
@@ -1,13 +1,13 @@
1
- {"address":{"canonical":{"address":"packagebuild-none-image-afflictionbnr","anchor":null,"anchorKind":null}},"asset":{"ai":false,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/afflictionbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"afflictionbnr","type":"image"}
2
- {"address":{"canonical":{"address":"packagebuild-none-image-armorclothingbnr","anchor":null,"anchorKind":null}},"asset":{"ai":false,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/armorclothingbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"armorclothingbnr","type":"image"}
3
- {"address":{"canonical":{"address":"packagebuild-none-image-attributebnr","anchor":null,"anchorKind":null}},"asset":{"ai":false,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/attributebnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"attributebnr","type":"image"}
4
- {"address":{"canonical":{"address":"packagebuild-none-image-containerbnr","anchor":null,"anchorKind":null}},"asset":{"ai":false,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/containerbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"containerbnr","type":"image"}
5
- {"address":{"canonical":{"address":"packagebuild-none-image-devdocsbnr","anchor":null,"anchorKind":null}},"asset":{"ai":false,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/devdocsbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"devdocsbnr","type":"image"}
6
- {"address":{"canonical":{"address":"packagebuild-none-image-miscgearbnr","anchor":null,"anchorKind":null}},"asset":{"ai":false,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/miscgearbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"miscgearbnr","type":"image"}
7
- {"address":{"canonical":{"address":"packagebuild-none-image-mysticalabilitybnr","anchor":null,"anchorKind":null}},"asset":{"ai":false,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/mysticalabilitybnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"mysticalabilitybnr","type":"image"}
8
- {"address":{"canonical":{"address":"packagebuild-none-image-projectilebnr","anchor":null,"anchorKind":null}},"asset":{"ai":false,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/projectilebnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"projectilebnr","type":"image"}
9
- {"address":{"canonical":{"address":"packagebuild-none-image-rulesbnr","anchor":null,"anchorKind":null}},"asset":{"ai":false,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/rulesbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"rulesbnr","type":"image"}
10
- {"address":{"canonical":{"address":"packagebuild-none-image-skillbnr","anchor":null,"anchorKind":null}},"asset":{"ai":false,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/skillbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"skillbnr","type":"image"}
11
- {"address":{"canonical":{"address":"packagebuild-none-image-traumabnr","anchor":null,"anchorKind":null}},"asset":{"ai":false,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/traumabnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"traumabnr","type":"image"}
12
- {"address":{"canonical":{"address":"packagebuild-none-image-userguidebnr","anchor":null,"anchorKind":null}},"asset":{"ai":false,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/userguidebnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"userguidebnr","type":"image"}
13
- {"address":{"canonical":{"address":"packagebuild-none-image-weapongearbnr","anchor":null,"anchorKind":null}},"asset":{"ai":false,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/weapongearbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"weapongearbnr","type":"image"}
1
+ {"address":{"canonical":{"address":"packagebuild-none-image-afflictionbnr","anchor":null,"anchorKind":null}},"asset":{"ai":true,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/afflictionbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"afflictionbnr","type":"image"}
2
+ {"address":{"canonical":{"address":"packagebuild-none-image-armorclothingbnr","anchor":null,"anchorKind":null}},"asset":{"ai":true,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/armorclothingbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"armorclothingbnr","type":"image"}
3
+ {"address":{"canonical":{"address":"packagebuild-none-image-attributebnr","anchor":null,"anchorKind":null}},"asset":{"ai":true,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/attributebnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"attributebnr","type":"image"}
4
+ {"address":{"canonical":{"address":"packagebuild-none-image-containerbnr","anchor":null,"anchorKind":null}},"asset":{"ai":true,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/containerbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"containerbnr","type":"image"}
5
+ {"address":{"canonical":{"address":"packagebuild-none-image-devdocsbnr","anchor":null,"anchorKind":null}},"asset":{"ai":true,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/devdocsbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"devdocsbnr","type":"image"}
6
+ {"address":{"canonical":{"address":"packagebuild-none-image-miscgearbnr","anchor":null,"anchorKind":null}},"asset":{"ai":true,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/miscgearbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"miscgearbnr","type":"image"}
7
+ {"address":{"canonical":{"address":"packagebuild-none-image-mysticalabilitybnr","anchor":null,"anchorKind":null}},"asset":{"ai":true,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/mysticalabilitybnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"mysticalabilitybnr","type":"image"}
8
+ {"address":{"canonical":{"address":"packagebuild-none-image-projectilebnr","anchor":null,"anchorKind":null}},"asset":{"ai":true,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/projectilebnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"projectilebnr","type":"image"}
9
+ {"address":{"canonical":{"address":"packagebuild-none-image-rulesbnr","anchor":null,"anchorKind":null}},"asset":{"ai":true,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/rulesbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"rulesbnr","type":"image"}
10
+ {"address":{"canonical":{"address":"packagebuild-none-image-skillbnr","anchor":null,"anchorKind":null}},"asset":{"ai":true,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/skillbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"skillbnr","type":"image"}
11
+ {"address":{"canonical":{"address":"packagebuild-none-image-traumabnr","anchor":null,"anchorKind":null}},"asset":{"ai":true,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/traumabnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"traumabnr","type":"image"}
12
+ {"address":{"canonical":{"address":"packagebuild-none-image-userguidebnr","anchor":null,"anchorKind":null}},"asset":{"ai":true,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/userguidebnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"userguidebnr","type":"image"}
13
+ {"address":{"canonical":{"address":"packagebuild-none-image-weapongearbnr","anchor":null,"anchorKind":null}},"asset":{"ai":true,"attribution":"Tom Rodriguez","height":768,"license":"CC-BY-SA-4.0","notes":"","path":"images/banners/weapongearbnr.webp","role":"","source":"","width":1792},"package":"packagebuild","shortcode":"weapongearbnr","type":"image"}
package/release.mjs CHANGED
@@ -24,7 +24,10 @@
24
24
  * by what the manifest says, not chosen here — see `manifest.mjs`. A package
25
25
  * that publishes a DataModel schema (`packageBuild.schema`, staged through
26
26
  * `packageBuild.assets`) gets a fourth: `schema.json`, published whenever the
27
- * stage carries one and silently skipped otherwise.
27
+ * stage carries one and silently skipped otherwise. A package that stages
28
+ * pictures gets `icons.zip` and `images.zip` as well: each picture root of the
29
+ * stage with the package's asset set, `asset-sets/default.json`, for a GM
30
+ * placing the files by hand.
28
31
  *
29
32
  * Kept apart from `stage.mjs` because this is the only part of assembling a
30
33
  * package that needs a dependency. A repository that never cuts a release from
@@ -44,6 +47,7 @@ import path from "node:path";
44
47
  import { ZipArchive } from "archiver";
45
48
 
46
49
  import { SCHEMA_ARTIFACT_FILE } from "./engine/foreign-catalog.mjs";
50
+ import { DEPLOYED_ASSET_TYPES, SHIPPED_SET_FILE } from "./engine/asset-shipping.mjs";
47
51
 
48
52
  /** The earliest instant a zip entry's date field can hold. */
49
53
  const ZIP_EPOCH_MS = Date.UTC(1980, 0, 1);
@@ -74,9 +78,10 @@ function archiveDate() {
74
78
  * @param {import("archiver").ZipArchive} archive
75
79
  * @param {string} root - The directory whose contents become the archive.
76
80
  * @param {Date} date - The date recorded on every entry.
81
+ * @param {string} [prefix] - Prepended to every entry's name, `/`-terminated.
77
82
  * @returns {Promise<void>}
78
83
  */
79
- async function appendTree(archive, root, date) {
84
+ async function appendTree(archive, root, date, prefix = "") {
80
85
  /** @param {string} rel - Path relative to `root`, `/`-separated, or "". */
81
86
  async function walk(rel) {
82
87
  const entries = await fsp.readdir(path.join(root, rel), { withFileTypes: true });
@@ -88,11 +93,15 @@ async function appendTree(archive, root, date) {
88
93
  for (const entry of entries) {
89
94
  const name = rel ? `${rel}/${entry.name}` : entry.name;
90
95
  if (entry.isDirectory()) {
91
- archive.append(Buffer.alloc(0), { name: `${name}/`, date, mode: 0o755 });
96
+ archive.append(Buffer.alloc(0), {
97
+ name: `${prefix}${name}/`,
98
+ date,
99
+ mode: 0o755,
100
+ });
92
101
  await walk(name);
93
102
  } else {
94
103
  const body = await fsp.readFile(path.join(root, name));
95
- archive.append(body, { name, date, mode: 0o644 });
104
+ archive.append(body, { name: `${prefix}${name}`, date, mode: 0o644 });
96
105
  }
97
106
  }
98
107
  }
@@ -123,9 +132,10 @@ async function appendTree(archive, root, date) {
123
132
  * @param {boolean} [opts.pdf] - Whether to build the books that ship beside the
124
133
  * archive. `true` by default; `false` skips the build and reports the skip.
125
134
  * @returns {Promise<{zip: string, manifest: string, metadata?: string,
126
- * schema?: string, pdfs: string[], pdfFindings: object[], pdfSkipped: string|null,
127
- * bytes: number, version: string}>} The paths written, what the book build
128
- * found, the archive's size, and the version the manifest declares.
135
+ * schema?: string, pictures: string[], pdfs: string[], pdfFindings: object[],
136
+ * pdfSkipped: string|null, bytes: number, version: string}>} The paths
137
+ * written, what the book build found, the archive's size, and the version
138
+ * the manifest declares. `pictures` is each picture archive written.
129
139
  * `metadata` is absent when the manifest advertises no content index,
130
140
  * `schema` is absent when the stage carries no `schema.json`, and `pdfs` is
131
141
  * every book's PDF that was written, in declared order. `pdfSkipped` is
@@ -184,6 +194,7 @@ export async function packRelease({
184
194
 
185
195
  const metadata = await publishMetadataIndex({ manifest, stage, out, metadataDir });
186
196
  const schema = await publishSchemaAsset({ stage, out });
197
+ const pictures = await publishPictureArchives({ stage, out });
187
198
 
188
199
  // Last, and never fatal: the archive and the manifest are the release, and
189
200
  // a book that failed to set is a reported problem rather than a reason to
@@ -198,6 +209,7 @@ export async function packRelease({
198
209
  manifest: path.join(out, manifestName),
199
210
  ...(metadata ? { metadata } : {}),
200
211
  ...(schema ? { schema } : {}),
212
+ pictures,
201
213
  pdfs: book.pdfs,
202
214
  pdfFindings: book.findings,
203
215
  pdfSkipped: book.pdfs.length ? null : book.reason,
@@ -324,3 +336,53 @@ async function publishSchemaAsset({ stage, out }) {
324
336
  await fsp.copyFile(src, dest);
325
337
  return dest;
326
338
  }
339
+
340
+ /**
341
+ * Zip each picture root of the stage, with its asset set, beside the
342
+ * archive — `icons.zip` and `images.zip`.
343
+ *
344
+ * Each archive unpacks at a location's root: `icons/` or `images/`, and the
345
+ * `asset-sets/default.json` listing every picture the package ships. A stage
346
+ * with no such set staged no pictures and publishes neither.
347
+ *
348
+ * @param {object} opts
349
+ * @param {string} opts.stage - The staged tree.
350
+ * @param {string} opts.out - Where release assets are written.
351
+ * @returns {Promise<string[]>} The archives written.
352
+ */
353
+ async function publishPictureArchives({ stage, out }) {
354
+ const list = path.join(stage, ...SHIPPED_SET_FILE.split("/"));
355
+ if (!fs.existsSync(list)) return [];
356
+ const written = [];
357
+ for (const { root } of DEPLOYED_ASSET_TYPES) {
358
+ const dir = path.join(stage, root);
359
+ if (!fs.existsSync(dir)) continue;
360
+ const zipPath = path.join(out, `${root}.zip`);
361
+ const output = fs.createWriteStream(zipPath);
362
+ const archive = new ZipArchive({ zlib: { level: 9 } });
363
+ const closed = new Promise((resolve, reject) => {
364
+ output.on("close", resolve);
365
+ output.on("error", reject);
366
+ archive.on("error", reject);
367
+ archive.on("warning", reject);
368
+ });
369
+ archive.pipe(output);
370
+ const date = archiveDate();
371
+ archive.append(Buffer.alloc(0), {
372
+ name: `${path.posix.dirname(SHIPPED_SET_FILE)}/`,
373
+ date,
374
+ mode: 0o755,
375
+ });
376
+ archive.append(await fsp.readFile(list), {
377
+ name: SHIPPED_SET_FILE,
378
+ date,
379
+ mode: 0o644,
380
+ });
381
+ archive.append(Buffer.alloc(0), { name: `${root}/`, date, mode: 0o755 });
382
+ await appendTree(archive, dir, date, `${root}/`);
383
+ await archive.finalize();
384
+ await closed;
385
+ written.push(zipPath);
386
+ }
387
+ return written;
388
+ }
package/sohl/actors.mjs CHANGED
@@ -55,7 +55,13 @@
55
55
  * @module
56
56
  */
57
57
 
58
- import { sohlField, resolveName, systemTemplatePriority, folderField } from "../engine/helpers.mjs";
58
+ import {
59
+ sohlField,
60
+ resolveName,
61
+ systemTemplatePriority,
62
+ folderField,
63
+ resolveImg,
64
+ } from "../engine/helpers.mjs";
59
65
  import { openingMasteryLevel } from "./skill-base.mjs";
60
66
  import { SystemActorCompiler, renderSection } from "../engine/actor-compiler.mjs";
61
67
  import { withDraftNotice } from "../engine/draft-notice.mjs";
@@ -84,9 +90,11 @@ const SYSTEM = SOHL_DOCUMENT_SUBTYPES.block;
84
90
 
85
91
  // Default art per actor **subtype**, applied when frontmatter supplies no `img`
86
92
  // / `portrait`. Beings default to the generic person icon; another subtype adds
87
- // its own entry here as the map gains a row for it.
93
+ // its own entry here as the map gains a row for it. Each is an authored
94
+ // pathname, resolved like any `img:`, so its Foundry path follows the deployed
95
+ // layout of the package that ships it.
88
96
  const DEFAULT_IMG = {
89
- being: "systems/sohl/assets/icons/game-icons/delapouite/person.svg",
97
+ being: "sohl/assets/icons/game-icons/delapouite/person.svg",
90
98
  };
91
99
 
92
100
  /**
@@ -313,7 +321,7 @@ export class Actors extends SystemActorCompiler {
313
321
  // The being's own default sits above the subtype's: only the note's
314
322
  // tags say whether it is a person or a creature, and only this pass
315
323
  // reads them.
316
- const art = this.actorArt(fm, defaultActorImg(subType));
324
+ const art = this.actorArt(fm, resolveImg(defaultActorImg(subType)));
317
325
 
318
326
  const items = this.buildEmbeddedItems(itemsMap, id, fm, ctx);
319
327
 
@@ -38,10 +38,12 @@
38
38
  * Injecting the map through configuration instead would re-open that drift,
39
39
  * because the two sides would once more be free to disagree.
40
40
  *
41
- * **Paths name the package that owns the file**, as every pathname does: these
42
- * icons are the `sohl` package's, so they are written `sohl/assets/…` and each
43
- * surface derives its own address from that. If a new item type is added, add
44
- * its default here.
41
+ * **Values are the deployed form of the file's path**, `sohl/icons/<basename>`:
42
+ * the owning package's name, then the flat location a staged package serves its
43
+ * icons from. A runtime prefixes `systems/` and has a Foundry address.
44
+ * {@link defaultItemArt} returns the same file as an authored pathname,
45
+ * `sohl/assets/icons/<basename>`, which is what the build resolves through
46
+ * `resolveImg`. If a new item type is added, add its default here.
45
47
  *
46
48
  * **The keys are SoHL *document* subtypes, not markdown note types.** The
47
49
  * runtime reads this with `itemData.type`, which is a Foundry Item subtype, and
@@ -53,19 +55,19 @@
53
55
  * build translates once and no second copy of those three rows exists.
54
56
  */
55
57
  export const DEFAULT_ITEM_ART = {
56
- affiliation: "sohl/assets/icons/noun/shieldnn.svg",
57
- affliction: "sohl/assets/icons/other/sick.svg",
58
- armorgear: "sohl/assets/icons/game-icons/lorc/breastplate.svg",
59
- attribute: "sohl/assets/icons/game-icons/lorc/charm.svg",
60
- concoctiongear: "sohl/assets/icons/game-icons/badges/flask.svg",
61
- containergear: "sohl/assets/icons/other/sack.svg",
62
- miscgear: "sohl/assets/icons/other/questionmark.svg",
63
- mystery: "sohl/assets/icons/game-icons/delapouite/sparkles.svg",
64
- mysticalability: "sohl/assets/icons/other/handsparkles.svg",
65
- projectilegear: "sohl/assets/icons/noun/arrow.svg",
66
- skill: "sohl/assets/icons/other/headgear.svg",
67
- trauma: "sohl/assets/icons/other/injury.svg",
68
- weapongear: "sohl/assets/icons/other/sword.svg",
58
+ affiliation: "sohl/icons/shieldnn.svg",
59
+ affliction: "sohl/icons/sick.svg",
60
+ armorgear: "sohl/icons/breastplate.svg",
61
+ attribute: "sohl/icons/charm.svg",
62
+ concoctiongear: "sohl/icons/flask.svg",
63
+ containergear: "sohl/icons/sack.svg",
64
+ miscgear: "sohl/icons/questionmark.svg",
65
+ mystery: "sohl/icons/sparkles.svg",
66
+ mysticalability: "sohl/icons/handsparkles.svg",
67
+ projectilegear: "sohl/icons/arrow.svg",
68
+ skill: "sohl/icons/headgear.svg",
69
+ trauma: "sohl/icons/injury.svg",
70
+ weapongear: "sohl/icons/sword.svg",
69
71
  };
70
72
 
71
73
  /**
@@ -80,7 +82,8 @@ export const DEFAULT_ITEM_ART = {
80
82
  *
81
83
  * @param {string} type - The SoHL Item document subtype — `armorgear`, not the
82
84
  * `armor` note type that compiles into one.
83
- * @returns {string} the default image path for that subtype.
85
+ * @returns {string} the default image as an authored pathname,
86
+ * `sohl/assets/icons/<basename>`.
84
87
  */
85
88
  export function defaultItemArt(type) {
86
89
  if (!(type in DEFAULT_ITEM_ART)) {
@@ -91,5 +94,6 @@ export function defaultItemArt(type) {
91
94
  // The map's inferred type has no index signature — deliberately, so callers
92
95
  // reading it directly get the precise key set. The `in` guard above has
93
96
  // already proved this key is present.
94
- return /** @type {Record<string, string>} */ (DEFAULT_ITEM_ART)[type];
97
+ const deployed = /** @type {Record<string, string>} */ (DEFAULT_ITEM_ART)[type];
98
+ return deployed.replace("/icons/", "/assets/icons/");
95
99
  }
@@ -102,6 +102,8 @@ export const DERIVED_MANIFEST_KEYS: Readonly<Record<string, string>>;
102
102
  * @property {string} stageDir The staged package root, relative to
103
103
  * `rootDir`. Every asset `to:` lands under it.
104
104
  * @property {readonly Readonly<AssetSpec>[]} assets
105
+ * @property {readonly string[]} shippingLicenses Licences this package adds to the
106
+ * default shipping list.
105
107
  * @property {boolean} baseStyles Whether the package takes the shared base
106
108
  * stylesheet, staged into `stageDir` and
107
109
  * named first in the manifest's `styles`.
@@ -238,6 +240,11 @@ export type PackageBuildConfig = {
238
240
  */
239
241
  stageDir: string;
240
242
  assets: readonly Readonly<AssetSpec>[];
243
+ /**
244
+ * Licences this package adds to the
245
+ * default shipping list.
246
+ */
247
+ shippingLicenses: readonly string[];
241
248
  /**
242
249
  * Whether the package takes the shared base
243
250
  * stylesheet, staged into `stageDir` and