@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,355 @@
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
+ * Every asset reference in a compiled document states the asset's address.
16
+ *
17
+ * A compiled document names a picture by its Foundry path. That path says where
18
+ * the file sits in one install and nothing more, so each reference also carries
19
+ * the asset's canonical address, which says which file it is wherever a world
20
+ * keeps it:
21
+ *
22
+ * - **HTML.** Each `<img>` whose `src` names an asset carries
23
+ * `data-asset="<address>"` beside it.
24
+ * - **Path fields.** The address goes in `flags.<system>.assets` on the
25
+ * document holding the field, as nested objects mirroring the field's path —
26
+ * never a dotted key, which Foundry's `expandObject` splits on update.
27
+ *
28
+ * | Document | Flag on | Keys |
29
+ * | --- | --- | --- |
30
+ * | Actor, Item, ActiveEffect, Macro, RollTable | the document | `img` |
31
+ * | Actor token art | `prototypeToken.flags` | `texture.src` |
32
+ * | Table result | the result | `img` |
33
+ * | Journal image page | the page | `src` |
34
+ *
35
+ * `<system>` is the system the document is compiled for — its
36
+ * `_stats.systemId`.
37
+ *
38
+ * **A stand-in replaces an asset the package does not ship.** A reference to
39
+ * an excluded picture compiles to its package's own
40
+ * `<package>-none-<icon|image>-missing` and keeps the excluded asset's address,
41
+ * which is how a world finds the real file once it holds it. An excluded
42
+ * picture with no shipped stand-in is a finding against the note.
43
+ *
44
+ * The pass runs over the finished document, so it answers the same way
45
+ * whichever compiler built it and whether the path came from an art address,
46
+ * an authored pathname or a body image.
47
+ *
48
+ * @module
49
+ */
50
+
51
+ import { isAssetRecord } from "./index-records.mjs";
52
+ import { ASSETS_SEGMENT, resolvePathname } from "./pathnames.mjs";
53
+ import { deployedAssetPath, exclusionReason, standInAddress } from "./asset-shipping.mjs";
54
+
55
+ /**
56
+ * The embedded collections each document type holds, and their document type.
57
+ *
58
+ * @type {Readonly<Record<string, Readonly<Record<string, string>>>>}
59
+ */
60
+ const EMBEDDED_COLLECTIONS = Object.freeze({
61
+ Actor: Object.freeze({ items: "Item", effects: "ActiveEffect" }),
62
+ Item: Object.freeze({ effects: "ActiveEffect" }),
63
+ JournalEntry: Object.freeze({ pages: "JournalEntryPage" }),
64
+ RollTable: Object.freeze({ results: "TableResult" }),
65
+ Adventure: Object.freeze({
66
+ actors: "Actor",
67
+ items: "Item",
68
+ journal: "JournalEntry",
69
+ tables: "RollTable",
70
+ macros: "Macro",
71
+ }),
72
+ });
73
+
74
+ /**
75
+ * One path field: the object whose `flags` record it, and the field's path
76
+ * inside that object.
77
+ *
78
+ * @typedef {object} AssetPathField
79
+ * @property {readonly string[]} holder - From the document to the object
80
+ * carrying the flag; empty for the document itself.
81
+ * @property {readonly string[]} field - From that object to the value.
82
+ * @property {(doc: object) => boolean} [when] - Whether the field is a picture
83
+ * on this document.
84
+ */
85
+
86
+ /**
87
+ * The path fields naming an asset, by document type.
88
+ *
89
+ * @type {Readonly<Record<string, readonly AssetPathField[]>>}
90
+ */
91
+ export const ASSET_PATH_FIELDS = Object.freeze({
92
+ Actor: Object.freeze([
93
+ { holder: [], field: ["img"] },
94
+ { holder: ["prototypeToken"], field: ["texture", "src"] },
95
+ ]),
96
+ Item: Object.freeze([{ holder: [], field: ["img"] }]),
97
+ ActiveEffect: Object.freeze([{ holder: [], field: ["img"] }]),
98
+ Macro: Object.freeze([{ holder: [], field: ["img"] }]),
99
+ RollTable: Object.freeze([{ holder: [], field: ["img"] }]),
100
+ TableResult: Object.freeze([{ holder: [], field: ["img"] }]),
101
+ JournalEntryPage: Object.freeze([
102
+ { holder: [], field: ["src"], when: (doc) => doc?.type === "image" },
103
+ ]),
104
+ });
105
+
106
+ /**
107
+ * One asset a compiled path can name.
108
+ *
109
+ * @typedef {object} AssetReferenceEntry
110
+ * @property {string} address - The canonical address.
111
+ * @property {string} package - The content package shipping it.
112
+ * @property {string} type - `icon` or `image`.
113
+ * @property {string} foundry - Its path in a Foundry install.
114
+ * @property {boolean} shipped - Whether its package ships it.
115
+ */
116
+
117
+ /**
118
+ * Every picture a compiled document can name, by Foundry path and by address.
119
+ *
120
+ * Built from this package's asset records and every fetched dependency's, so a
121
+ * reference to another package's picture states its address too.
122
+ *
123
+ * @param {{assets?: Map<string, object>, foreign?: Map<string, object>}} linkIndex -
124
+ * The compile's link index.
125
+ * @param {object} config - The resolved build configuration.
126
+ * @returns {{byPath: Map<string, AssetReferenceEntry>, byAddress: Map<string, AssetReferenceEntry>}}
127
+ */
128
+ export function assetReferenceLookup(linkIndex, config) {
129
+ const byPath = new Map();
130
+ const byAddress = new Map();
131
+ // Keyed by the rendered canonical address in both maps — a local record's
132
+ // own `address.canonical` is the parsed form, and a fetched dependency's
133
+ // entry carries none — so the key is the address.
134
+ const entries = [
135
+ ...(linkIndex?.foreign?.entries() ?? []),
136
+ ...(linkIndex?.assets?.entries() ?? []),
137
+ ];
138
+ for (const [key, record] of entries) {
139
+ if (!isAssetRecord(record) || !record.package) continue;
140
+ if (deployedAssetPath(record) === undefined) continue;
141
+ let forms;
142
+ try {
143
+ forms = resolvePathname(
144
+ `${record.package}/${ASSETS_SEGMENT}/${record.asset.path}`,
145
+ config,
146
+ );
147
+ } catch {
148
+ continue;
149
+ }
150
+ if (!forms?.foundry) continue;
151
+ const entry = {
152
+ address: key,
153
+ package: record.package,
154
+ type: record.type,
155
+ foundry: forms.foundry,
156
+ // Additions are this package's own: a dependency's list is its own.
157
+ shipped:
158
+ exclusionReason(
159
+ record,
160
+ record.package === config.contentPackage ?
161
+ (config.packageBuild?.shippingLicenses ?? [])
162
+ : [],
163
+ ) === "",
164
+ };
165
+ byPath.set(entry.foundry, entry);
166
+ byAddress.set(entry.address, entry);
167
+ }
168
+ return { byPath, byAddress };
169
+ }
170
+
171
+ /**
172
+ * What one compiled path becomes: the path to write and the address to state.
173
+ *
174
+ * @param {string} value - A compiled path.
175
+ * @param {{byPath: Map<string, AssetReferenceEntry>, byAddress: Map<string, AssetReferenceEntry>}} lookup
176
+ * @param {(message: string) => void} report - Called once per excluded asset
177
+ * with no stand-in.
178
+ * @returns {{src: string, address: string}|null} `null` for a path naming no
179
+ * asset.
180
+ */
181
+ function resolveReference(value, lookup, report) {
182
+ const entry = lookup.byPath.get(value);
183
+ if (!entry) return null;
184
+ if (entry.shipped) return { src: value, address: entry.address };
185
+ const standIn = lookup.byAddress.get(standInAddress(entry.package, entry.type));
186
+ if (standIn?.shipped) return { src: standIn.foundry, address: entry.address };
187
+ report(
188
+ `\`${entry.address}\` is not shipped in the \`${entry.package}\` package, and that ` +
189
+ `package has no shipped stand-in \`${standInAddress(entry.package, entry.type)}\` ` +
190
+ "to draw in its place — add a human-made `missing` picture under its " +
191
+ `${entry.type === "icon" ? "icons" : "images"}/ root, licensed to ship`,
192
+ );
193
+ return { src: value, address: entry.address };
194
+ }
195
+
196
+ /**
197
+ * Read a nested property.
198
+ *
199
+ * @param {any} obj
200
+ * @param {readonly string[]} keys
201
+ * @returns {any}
202
+ */
203
+ function getPath(obj, keys) {
204
+ let at = obj;
205
+ for (const key of keys) {
206
+ if (at == null || typeof at !== "object") return undefined;
207
+ at = at[key];
208
+ }
209
+ return at;
210
+ }
211
+
212
+ /**
213
+ * Write a nested property, creating plain objects on the way.
214
+ *
215
+ * @param {Record<string, any>} obj
216
+ * @param {readonly string[]} keys
217
+ * @param {unknown} value
218
+ */
219
+ function setPath(obj, keys, value) {
220
+ let at = obj;
221
+ for (const key of keys.slice(0, -1)) {
222
+ if (at[key] == null || typeof at[key] !== "object") at[key] = {};
223
+ at = at[key];
224
+ }
225
+ at[keys[keys.length - 1]] = value;
226
+ }
227
+
228
+ /**
229
+ * Record one field's address on the object holding it.
230
+ *
231
+ * Copies the `flags` objects it writes into, so a `flags` block the document
232
+ * shares with an authored note is never changed in place.
233
+ *
234
+ * @param {Record<string, any>} holder
235
+ * @param {string} system
236
+ * @param {readonly string[]} field
237
+ * @param {string} address
238
+ */
239
+ function flagAddress(holder, system, field, address) {
240
+ const flags = { ...(holder.flags ?? {}) };
241
+ const scope = { ...(flags[system] ?? {}) };
242
+ const assets = structuredClone(scope.assets ?? {});
243
+ setPath(assets, field, address);
244
+ scope.assets = assets;
245
+ flags[system] = scope;
246
+ holder.flags = flags;
247
+ }
248
+
249
+ /** One `<img …>` tag. */
250
+ const IMG_TAG = /<img\b[^>]*>/gi;
251
+
252
+ /** The `src` attribute inside a tag. */
253
+ const SRC_ATTRIBUTE = /(\ssrc\s*=\s*)(["'])(.*?)\2/i;
254
+
255
+ /**
256
+ * Mark every `<img>` in one HTML string with its asset's address.
257
+ *
258
+ * @param {string} html
259
+ * @param {Parameters<typeof resolveReference>[1]} lookup
260
+ * @param {(message: string) => void} report
261
+ * @returns {string} The HTML, every asset `<img>` carrying `data-asset`.
262
+ */
263
+ export function annotateHtml(html, lookup, report) {
264
+ if (typeof html !== "string" || !html.includes("<img")) return html;
265
+ return html.replace(IMG_TAG, (tag) => {
266
+ if (/\sdata-asset\s*=/i.test(tag)) return tag;
267
+ const src = tag.match(SRC_ATTRIBUTE);
268
+ if (!src) return tag;
269
+ const resolved = resolveReference(src[3], lookup, report);
270
+ if (!resolved) return tag;
271
+ const rewritten = tag.replace(SRC_ATTRIBUTE, `$1$2${resolved.src}$2`);
272
+ const close = rewritten.endsWith("/>") ? "/>" : ">";
273
+ const body = rewritten.slice(0, -close.length).replace(/\s+$/, "");
274
+ return `${body} data-asset="${resolved.address}"${close === "/>" ? " />" : ">"}`;
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Mark every HTML string anywhere under a value, in place.
280
+ *
281
+ * @param {any} value
282
+ * @param {Parameters<typeof resolveReference>[1]} lookup
283
+ * @param {(message: string) => void} report
284
+ */
285
+ function annotateStrings(value, lookup, report) {
286
+ if (Array.isArray(value)) {
287
+ value.forEach((item, index) => {
288
+ if (typeof item !== "string") annotateStrings(item, lookup, report);
289
+ else {
290
+ const annotated = annotateHtml(item, lookup, report);
291
+ if (annotated !== item) value[index] = annotated;
292
+ }
293
+ });
294
+ return;
295
+ }
296
+ if (value == null || typeof value !== "object") return;
297
+ // Written back only where something changed: a document holds frozen
298
+ // values — a parsed Address among them — that no `<img>` ever sits in.
299
+ for (const [key, item] of Object.entries(value)) {
300
+ if (typeof item !== "string") annotateStrings(item, lookup, report);
301
+ else {
302
+ const annotated = annotateHtml(item, lookup, report);
303
+ if (annotated !== item) value[key] = annotated;
304
+ }
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Annotate one document's path fields, and its embedded documents', in place.
310
+ *
311
+ * @param {Record<string, any>} doc
312
+ * @param {string} documentType
313
+ * @param {string|null|undefined} system
314
+ * @param {Parameters<typeof resolveReference>[1]} lookup
315
+ * @param {(message: string) => void} report
316
+ */
317
+ function annotateFields(doc, documentType, system, lookup, report) {
318
+ if (doc == null || typeof doc !== "object") return;
319
+ for (const { holder: holderPath, field, when } of ASSET_PATH_FIELDS[documentType] ?? []) {
320
+ if (when && !when(doc)) continue;
321
+ const holder = holderPath.length ? getPath(doc, holderPath) : doc;
322
+ if (holder == null || typeof holder !== "object") continue;
323
+ const value = getPath(holder, field);
324
+ if (typeof value !== "string" || !value) continue;
325
+ const resolved = resolveReference(value, lookup, report);
326
+ if (!resolved) continue;
327
+ setPath(holder, field, resolved.src);
328
+ if (system) flagAddress(holder, system, field, resolved.address);
329
+ }
330
+ for (const [key, childType] of Object.entries(EMBEDDED_COLLECTIONS[documentType] ?? {})) {
331
+ const children = doc[key];
332
+ if (!Array.isArray(children)) continue;
333
+ for (const child of children) annotateFields(child, childType, system, lookup, report);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * State the address of every asset one compiled document references, and draw
339
+ * a stand-in for each the package does not ship. Changes `doc` in place.
340
+ *
341
+ * @param {Record<string, any>} doc - The compiled document.
342
+ * @param {string} documentType - Its Foundry document type.
343
+ * @param {object} opts - Options.
344
+ * @param {Parameters<typeof resolveReference>[1]} opts.lookup - From
345
+ * {@link assetReferenceLookup}.
346
+ * @param {string|null|undefined} opts.system - The flag scope; no flag is
347
+ * written without one.
348
+ * @param {(message: string) => void} opts.report - Receives each finding.
349
+ * @returns {Record<string, any>} `doc`.
350
+ */
351
+ export function annotateAssetReferences(doc, documentType, { lookup, system, report }) {
352
+ annotateFields(doc, documentType, system, lookup, report);
353
+ annotateStrings(doc, lookup, report);
354
+ return doc;
355
+ }