@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
@@ -0,0 +1,363 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The **deployed layout** of a package's pictures, and which of them ship.
16
+ *
17
+ * A repository keeps `assets/icons/` and `assets/images/` arranged however its
18
+ * authors like. A Foundry package, the CDN and every location a GM fills hold
19
+ * them flat instead:
20
+ *
21
+ * ```text
22
+ * icons/<basename>
23
+ * images/<basename>
24
+ * ```
25
+ *
26
+ * The root is the type and the basename is the shortcode with its extension,
27
+ * so the deployed path follows from the address alone. A root's shortcodes are
28
+ * one flat namespace however deeply its source nests — two files sharing one
29
+ * are already a finding in {@link module:engine/asset-index} — so flattening
30
+ * never collides. Audio is not part of this layout.
31
+ *
32
+ * **What ships.** The stage copies a picture into the package only when its
33
+ * resolved provenance record says `ai: false` and its `license` is on
34
+ * {@link SHIPPING_LICENSES}. Everything else stays in the repository. The
35
+ * selection is {@link selectStagedAssets}, one function, so the stage and any
36
+ * command that publishes what the stage leaves out read the same answer.
37
+ *
38
+ * **An asset set** is a JSON file listing the pictures one location holds:
39
+ *
40
+ * ```json
41
+ * {
42
+ * "name": "Thalorna — shipped art",
43
+ * "package": "thalorna",
44
+ * "assets": [
45
+ * { "address": "thalorna-none-image-thorn", "source": "../images/thorn.webp",
46
+ * "target": "images/thorn.webp", "size": 48213 }
47
+ * ]
48
+ * }
49
+ * ```
50
+ *
51
+ * `name` is the set's display name and `package` the content package its
52
+ * addresses belong to — the address's first segment, not the Foundry id. Each
53
+ * entry in `assets`:
54
+ *
55
+ * - `target` is the file's path relative to the location's root, in the
56
+ * deployed layout, extension included.
57
+ * - `source` is where the file can be fetched from, resolved against the URL of
58
+ * the set file holding it — `new URL(source, setUrl)`.
59
+ * - `size` is in bytes.
60
+ *
61
+ * The staged package's own set is always `asset-sets/default.json`, a fixed
62
+ * name, and names itself after the package's manifest title with
63
+ * ` — shipped art` appended. Its files sit one folder up from it, so each
64
+ * `source` is `../<target>`.
65
+ *
66
+ * @module
67
+ */
68
+
69
+ import fs from "node:fs";
70
+ import path from "node:path";
71
+
72
+ import { collectAssetRecords } from "./asset-index.mjs";
73
+ import { canonicalKey } from "./content-address.mjs";
74
+ import { ASSET_SYSTEM, DEPLOYED_ASSET_TYPES, deployedSuffix } from "./asset-types.mjs";
75
+
76
+ export { DEPLOYED_ASSET_TYPES, deployedSuffix } from "./asset-types.mjs";
77
+
78
+ /**
79
+ * The licences a shipped picture may carry, as its provenance `license` spells
80
+ * them.
81
+ *
82
+ * A package adds to this list with `packageBuild.shippingLicenses`; the list a
83
+ * package ships under is this one together with its additions.
84
+ *
85
+ * `LicenseRef-PublicDomain` is the spelling for a work in the public domain,
86
+ * which SPDX gives no identifier of its own. `CC0` is accepted beside
87
+ * `CC0-1.0` because both spellings name the one dedication. Nothing is
88
+ * normalised: a value off this list is excluded, however close it reads.
89
+ *
90
+ * @type {readonly string[]}
91
+ */
92
+ export const SHIPPING_LICENSES = Object.freeze([
93
+ "LicenseRef-PublicDomain",
94
+ "CC0-1.0",
95
+ "CC0",
96
+ "CC-BY-3.0",
97
+ "CC-BY-4.0",
98
+ "CC-BY-SA-4.0",
99
+ "LicenseRef-NounProject-Pro",
100
+ ]);
101
+
102
+ /**
103
+ * The staged package's own asset set, relative to the package root — the set
104
+ * listing the pictures it ships.
105
+ *
106
+ * @type {string}
107
+ */
108
+ export const SHIPPED_SET_FILE = "asset-sets/default.json";
109
+
110
+ /**
111
+ * The shortcode a package's stand-in picture carries under each deployed root.
112
+ *
113
+ * @type {string}
114
+ */
115
+ export const STAND_IN_SHORTCODE = "missing";
116
+
117
+ /**
118
+ * The deployed path of one asset record, or `undefined` for a record of a type
119
+ * that does not deploy flat.
120
+ *
121
+ * @param {{type?: string, asset?: {path?: string}}} record - An asset record.
122
+ * @returns {string|undefined} `<icons|images>/<basename>`.
123
+ */
124
+ export function deployedAssetPath(record) {
125
+ const source = record?.asset?.path;
126
+ if (!source) return undefined;
127
+ const type = DEPLOYED_ASSET_TYPES.find((entry) => entry.type === record.type);
128
+ if (!type) return undefined;
129
+ const deployed = deployedSuffix(source);
130
+ return deployed.startsWith(`${type.root}/`) ? deployed : undefined;
131
+ }
132
+
133
+ /**
134
+ * Why one asset record does not ship, or `""` when it does.
135
+ *
136
+ * @param {{asset?: {ai?: unknown, license?: unknown}}} record - An asset record.
137
+ * @param {readonly string[]} [additions] - Licences the package adds to
138
+ * {@link SHIPPING_LICENSES}, from `packageBuild.shippingLicenses`.
139
+ * @returns {string} The reason, as a clause, or `""`.
140
+ */
141
+ export function exclusionReason(record, additions = []) {
142
+ const ai = record?.asset?.ai;
143
+ if (ai === true) return "it is machine-generated (`ai: true`)";
144
+ if (ai !== false) return "its provenance does not state `ai`";
145
+ const license = record?.asset?.license;
146
+ if (
147
+ !SHIPPING_LICENSES.includes(/** @type {string} */ (license)) &&
148
+ !additions.includes(/** @type {string} */ (license))
149
+ ) {
150
+ return license ?
151
+ `its licence \`${license}\` is not one a package ships`
152
+ : "its provenance states no licence";
153
+ }
154
+ return "";
155
+ }
156
+
157
+ /**
158
+ * Whether one asset record ships in the Foundry package.
159
+ *
160
+ * @param {object} record - An asset record.
161
+ * @param {readonly string[]} [additions] - The package's added licences.
162
+ * @returns {boolean} True for a deployed type with `ai: false` and a licence on
163
+ * {@link SHIPPING_LICENSES} or in `additions`.
164
+ */
165
+ export function shipsAsset(record, additions = []) {
166
+ return deployedAssetPath(record) !== undefined && exclusionReason(record, additions) === "";
167
+ }
168
+
169
+ /**
170
+ * The canonical address of a package's stand-in for one asset type.
171
+ *
172
+ * @param {string} pkg - The content package.
173
+ * @param {string} type - `icon` or `image`.
174
+ * @returns {string} `<package>-none-<type>-missing`.
175
+ */
176
+ export function standInAddress(pkg, type) {
177
+ return canonicalKey(pkg, ASSET_SYSTEM, type, STAND_IN_SHORTCODE);
178
+ }
179
+
180
+ /**
181
+ * Split a package's picture records into those the stage ships and those it
182
+ * leaves out.
183
+ *
184
+ * Audio and every other record are in neither list.
185
+ *
186
+ * @param {readonly object[]} records - Asset records, in any order.
187
+ * @param {readonly string[]} [additions] - The package's added licences.
188
+ * @returns {{staged: object[], excluded: {record: object, reason: string}[]}}
189
+ * Both in address order.
190
+ */
191
+ export function selectStagedAssets(records, additions = []) {
192
+ const pictures = records
193
+ .filter((record) => deployedAssetPath(record) !== undefined)
194
+ .sort((a, b) => (a.address.canonical < b.address.canonical ? -1 : 1));
195
+ const staged = [];
196
+ const excluded = [];
197
+ for (const record of pictures) {
198
+ const reason = exclusionReason(record, additions);
199
+ if (reason) excluded.push({ record, reason });
200
+ else staged.push(record);
201
+ }
202
+ return { staged, excluded };
203
+ }
204
+
205
+ /**
206
+ * One asset-set entry.
207
+ *
208
+ * @typedef {object} AssetsManifestEntry
209
+ * @property {string} address - The canonical address.
210
+ * @property {string} source - Where the file is fetched from, resolved against
211
+ * the URL of the set file holding it.
212
+ * @property {string} target - Its path relative to the location's root, in the
213
+ * deployed layout.
214
+ * @property {number} size - Its size in bytes.
215
+ */
216
+
217
+ /**
218
+ * An asset set: a display name, the package its addresses belong to, and one
219
+ * entry per file.
220
+ *
221
+ * @typedef {object} AssetSet
222
+ * @property {string} name - The set's display name.
223
+ * @property {string} package - The content package.
224
+ * @property {AssetsManifestEntry[]} assets - The files, in target order.
225
+ */
226
+
227
+ /**
228
+ * The display name of a staged package's own asset set.
229
+ *
230
+ * @param {string} title - The package's manifest title.
231
+ * @returns {string} `<title> — shipped art`.
232
+ */
233
+ export function shippedSetName(title) {
234
+ return `${title} — shipped art`;
235
+ }
236
+
237
+ /**
238
+ * The asset-set entries for a set of staged files, as the staged package's own
239
+ * set lists them.
240
+ *
241
+ * The set sits in `asset-sets/` under the package root, so each `source` is
242
+ * `../<target>`.
243
+ *
244
+ * @param {readonly object[]} staged - The records staged.
245
+ * @param {string} stageDir - The staged package root, where each file's size
246
+ * is measured — after any transform, since that is the file a location holds.
247
+ * @returns {AssetsManifestEntry[]} In target order.
248
+ */
249
+ export function assetsManifest(staged, stageDir) {
250
+ return staged
251
+ .map((record) => {
252
+ const target = /** @type {string} */ (deployedAssetPath(record));
253
+ return {
254
+ address: record.address.canonical,
255
+ source: path.posix.relative(path.posix.dirname(SHIPPED_SET_FILE), target),
256
+ target,
257
+ size: fs.statSync(path.join(stageDir, ...target.split("/"))).size,
258
+ };
259
+ })
260
+ .sort((a, b) => (a.target < b.target ? -1 : 1));
261
+ }
262
+
263
+ /**
264
+ * Stage a package's shipped pictures flat at the package root, and write the
265
+ * package's own asset set, `asset-sets/default.json`.
266
+ *
267
+ * The records are read by {@link module:engine/asset-index.collectAssetRecords},
268
+ * so provenance resolves exactly as the content index resolves it — per file,
269
+ * a sidecar replacing an inherited record. Any finding that walk makes stops the
270
+ * stage before a file is copied: a package whose provenance cannot be read is
271
+ * not one whose shipping can be decided.
272
+ *
273
+ * @param {object} opts - Options.
274
+ * @param {string} opts.assetsBase - The package's asset directory, the roots'
275
+ * parent.
276
+ * @param {string} opts.contentPackage - The package the trees belong to.
277
+ * @param {string} opts.stageDir - The staged package root.
278
+ * @param {string} opts.title - The package's manifest title, which names the
279
+ * set.
280
+ * @param {(sourcePath: string) => string|null|undefined} [opts.transform] -
281
+ * The repository's per-file transform.
282
+ * @param {readonly string[]} [opts.shippingLicenses] - Licences the package
283
+ * adds to {@link SHIPPING_LICENSES}.
284
+ * @returns {{staged: object[], excluded: {record: object, reason: string}[],
285
+ * findings: object[], manifest: AssetSet|null}}
286
+ * What was staged and left out, and the findings that stopped the stage —
287
+ * nothing is staged when `findings` is non-empty. A package with neither
288
+ * picture root stages nothing and writes no asset set.
289
+ */
290
+ export function stagePackageAssets({
291
+ assetsBase,
292
+ contentPackage,
293
+ stageDir,
294
+ title,
295
+ transform,
296
+ shippingLicenses = [],
297
+ }) {
298
+ const findings = [];
299
+ const hasPictures = DEPLOYED_ASSET_TYPES.some(({ root }) =>
300
+ fs.existsSync(path.join(assetsBase, root)),
301
+ );
302
+ if (!hasPictures) return { staged: [], excluded: [], findings, manifest: null };
303
+ const records = collectAssetRecords(assetsBase, { contentPackage, problems: findings });
304
+ if (findings.length) return { staged: [], excluded: [], findings, manifest: null };
305
+
306
+ const { staged, excluded } = selectStagedAssets(records, shippingLicenses);
307
+ for (const { root } of DEPLOYED_ASSET_TYPES) {
308
+ fs.rmSync(path.join(stageDir, root), { recursive: true, force: true });
309
+ }
310
+ for (const record of staged) {
311
+ const from = path.join(assetsBase, ...record.asset.path.split("/"));
312
+ const to = path.join(
313
+ stageDir,
314
+ .../** @type {string} */ (deployedAssetPath(record)).split("/"),
315
+ );
316
+ fs.mkdirSync(path.dirname(to), { recursive: true });
317
+ const transformed = transform ? transform(from) : null;
318
+ if (transformed != null) fs.writeFileSync(to, transformed);
319
+ else fs.copyFileSync(from, to);
320
+ }
321
+ const manifest = {
322
+ name: shippedSetName(title),
323
+ package: contentPackage,
324
+ assets: assetsManifest(staged, stageDir),
325
+ };
326
+ const setFile = path.join(stageDir, ...SHIPPED_SET_FILE.split("/"));
327
+ fs.mkdirSync(path.dirname(setFile), { recursive: true });
328
+ fs.writeFileSync(setFile, `${JSON.stringify(manifest, null, 4)}\n`);
329
+ return { staged, excluded, findings, manifest };
330
+ }
331
+
332
+ /**
333
+ * The `packageBuild.assets` rows that would stage a picture root themselves.
334
+ *
335
+ * The stage copies `icons/` and `images/` itself, filtered; a row copying
336
+ * either root, a directory inside one, or a directory holding one would put
337
+ * every file back unfiltered, so each is refused.
338
+ *
339
+ * @param {readonly {from: string, to: string}[]} rows - The configured rows.
340
+ * @param {object} opts - Options.
341
+ * @param {string} opts.rootDir - The repository root rows resolve against.
342
+ * @param {string} opts.assetsBase - The package's asset directory.
343
+ * @returns {{index: number, from: string, root: string}[]} One per offending
344
+ * row, naming the root it reaches.
345
+ */
346
+ export function rowsStagingPictureRoots(rows, { rootDir, assetsBase }) {
347
+ const out = [];
348
+ rows.forEach(({ from }, index) => {
349
+ const source = path.resolve(rootDir, from);
350
+ for (const { root } of DEPLOYED_ASSET_TYPES) {
351
+ const rootPath = path.resolve(assetsBase, root);
352
+ const inside = (child, parent) => {
353
+ const rel = path.relative(parent, child);
354
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
355
+ };
356
+ if (inside(source, rootPath) || inside(rootPath, source)) {
357
+ out.push({ index, from, root: path.relative(rootDir, rootPath) });
358
+ break;
359
+ }
360
+ }
361
+ });
362
+ return out;
363
+ }
@@ -148,6 +148,76 @@ export const ASSET_TYPES = Object.freeze([
148
148
  }),
149
149
  ]);
150
150
 
151
+ /**
152
+ * The asset types that deploy flat — `icon` and `image`, never `audio`.
153
+ *
154
+ * @type {readonly import("./asset-types.mjs").AssetType[]}
155
+ */
156
+ export const DEPLOYED_ASSET_TYPES = Object.freeze(
157
+ ASSET_TYPES.filter((entry) => entry.type === "icon" || entry.type === "image"),
158
+ );
159
+
160
+ /**
161
+ * The deployed roots, by name.
162
+ *
163
+ * @type {ReadonlySet<string>}
164
+ */
165
+ const DEPLOYED_ROOTS = new Set(DEPLOYED_ASSET_TYPES.map((entry) => entry.root));
166
+
167
+ /**
168
+ * Whether a source suffix sits under a picture root, and so deploys flat.
169
+ *
170
+ * @param {string} suffix - A path inside a package's `assets/` directory.
171
+ * @returns {boolean} True for `icons/…` and `images/…`.
172
+ */
173
+ export function isDeployedSuffix(suffix) {
174
+ const segments = String(suffix).split("/");
175
+ return segments.length >= 2 && DEPLOYED_ROOTS.has(segments[0]);
176
+ }
177
+
178
+ /**
179
+ * A source suffix rewritten into the deployed layout.
180
+ *
181
+ * `images/beings/characters/thorn.webp` becomes `images/thorn.webp`. A suffix
182
+ * under no deployed root — `audio/…`, `ui/…`, `generated/…` — is returned
183
+ * unchanged, because only the picture roots deploy flat.
184
+ *
185
+ * @param {string} suffix - A path inside a package's `assets/` directory,
186
+ * `/`-separated.
187
+ * @returns {string} The path the file has in a deployed location.
188
+ */
189
+ export function deployedSuffix(suffix) {
190
+ if (!isDeployedSuffix(suffix)) return suffix;
191
+ const segments = String(suffix).split("/");
192
+ return `${segments[0]}/${segments[segments.length - 1]}`;
193
+ }
194
+
195
+ /**
196
+ * What is wrong with a package's `packageBuild.shippingLicenses`, or `""` when
197
+ * nothing is.
198
+ *
199
+ * The key lists licences a package adds to the default shipping list, spelled
200
+ * as provenance spells them: a list of non-empty strings with no duplicates.
201
+ *
202
+ * @param {unknown} value - The declared value; `undefined` is absent and fine.
203
+ * @returns {string} The problem as a clause, or `""`.
204
+ */
205
+ export function shippingLicensesProblem(value) {
206
+ if (value === undefined) return "";
207
+ if (!Array.isArray(value)) return "must be a list of licence identifiers";
208
+ for (const [index, entry] of value.entries()) {
209
+ if (typeof entry !== "string" || entry.trim() === "") {
210
+ return `entry ${index} must be a non-empty string`;
211
+ }
212
+ }
213
+ const seen = new Set();
214
+ for (const entry of value) {
215
+ if (seen.has(entry)) return `lists \`${entry}\` more than once`;
216
+ seen.add(entry);
217
+ }
218
+ return "";
219
+ }
220
+
151
221
  /**
152
222
  * Every asset type name.
153
223
  *
@@ -108,6 +108,8 @@ import { checkAuthoredSystemData, checkEmittedSystemData } from "./schema-check.
108
108
  // `engine/being-age.mjs`.
109
109
  import { applyComputedBeingAge, presentAmongRecords } from "./being-age.mjs";
110
110
  import { reckoningContext } from "./reckoning-markers.mjs";
111
+ import { loadPackConfig } from "./pack-config.mjs";
112
+ import { annotateAssetReferences, assetReferenceLookup } from "./asset-references.mjs";
111
113
 
112
114
  /**
113
115
  * The tallies one pass accumulates while walking the tree.
@@ -449,6 +451,9 @@ export class BasePackCompiler {
449
451
  /** @type {object|undefined} */
450
452
  #stats;
451
453
 
454
+ /** Every picture a document can name, built once per pass. */
455
+ #assetLookup;
456
+
452
457
  /**
453
458
  * Whether this pass's pack is the one a claimed note belongs in.
454
459
  *
@@ -1089,10 +1094,28 @@ export class BasePackCompiler {
1089
1094
  /**
1090
1095
  * Write one document into a directory, named for its name and id.
1091
1096
  *
1097
+ * Every asset the document references states its address on the way out,
1098
+ * and an asset its package does not ship is drawn by that package's
1099
+ * stand-in — see {@link module:engine/asset-references}.
1100
+ *
1092
1101
  * @param {string} dir - The destination directory.
1093
1102
  * @param {object} doc - The document.
1103
+ * @param {string} [documentType] - Its Foundry document type; this pass's
1104
+ * own by default.
1094
1105
  */
1095
- writeTo(dir, doc) {
1106
+ writeTo(dir, doc, documentType = this.docType) {
1107
+ this.#assetLookup ??= assetReferenceLookup(this.linkIndex, loadPackConfig());
1108
+ const reported = new Set();
1109
+ annotateAssetReferences(doc, documentType, {
1110
+ lookup: this.#assetLookup,
1111
+ system: doc?._stats?.systemId ?? this.stats?.systemId,
1112
+ report: (message) => {
1113
+ if (reported.has(message)) return;
1114
+ reported.add(message);
1115
+ this.errorCount++;
1116
+ this.noteError(message);
1117
+ },
1118
+ });
1096
1119
  fs.writeFileSync(
1097
1120
  path.join(dir, makeFilename(doc.name, doc._id)),
1098
1121
  JSON.stringify(doc, null, 2),