@heroiclands/package-build 23.4.1 → 23.5.0
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.
- package/CHANGELOG.md +27 -0
- package/README.md +29 -25
- package/assets/images/provenance.yaml +1 -0
- package/bin/package-build.mjs +259 -28
- package/ci/prettier.mjs +73 -0
- package/config.mjs +10 -0
- package/container.mjs +232 -1
- package/content-config.mjs +5 -1
- package/docs/api.md +100 -52
- package/docs/authoring/assets.md +16 -1
- package/docs/commands.md +154 -7
- package/docs/configuration.md +39 -2
- package/docs/content-format.md +30 -6
- package/docs/guides/e2e.md +38 -0
- package/docs/guides/icons.md +5 -0
- package/docs/guides/release-and-deploy.md +36 -1
- package/docs/reference/format-details.md +180 -34
- package/docs/reference/note-types.md +69 -69
- package/engine/asset-index.mjs +63 -22
- package/engine/asset-references.mjs +355 -0
- package/engine/asset-shipping.mjs +363 -0
- package/engine/asset-types.mjs +70 -0
- package/engine/base-compiler.mjs +24 -1
- package/engine/cdn-publish.mjs +618 -0
- package/engine/index.mjs +6 -0
- package/engine/note-vocabulary.mjs +4 -1
- package/engine/pathnames.mjs +21 -10
- package/engine/rank-ladder.mjs +40 -16
- package/engine/scenes.mjs +1 -1
- package/engine/standing-terms.mjs +3 -1
- package/package.json +4 -3
- package/packagebuild-metadata.jsonl +13 -13
- package/release.mjs +69 -7
- package/sohl/actors.mjs +12 -4
- package/sohl/default-item-art.mjs +23 -19
- package/types/config.d.mts +7 -0
- package/types/container.d.mts +137 -1
- package/types/engine/asset-index.d.mts +3 -2
- package/types/engine/asset-references.d.mts +136 -0
- package/types/engine/asset-shipping.d.mts +240 -0
- package/types/engine/asset-types.d.mts +36 -0
- package/types/engine/base-compiler.d.mts +7 -1
- package/types/engine/cdn-publish.d.mts +311 -0
- package/types/engine/index.d.mts +2 -0
- package/types/engine/pathnames.d.mts +2 -1
- package/types/engine/rank-ladder.d.mts +25 -6
- package/types/release.d.mts +5 -3
- package/types/sohl/default-item-art.d.mts +2 -1
package/engine/asset-index.mjs
CHANGED
|
@@ -27,12 +27,13 @@
|
|
|
27
27
|
* slug. A reader tells the two shapes apart by the `asset` block, which is what
|
|
28
28
|
* {@link module:engine/index-records.isAssetRecord} asks.
|
|
29
29
|
*
|
|
30
|
-
* **`path` is
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* it. Foundry
|
|
34
|
-
*
|
|
35
|
-
*
|
|
30
|
+
* **`path` is the source path.** The record is emitted by the package holding
|
|
31
|
+
* the bytes, so its path is that package's path — relative to that package's
|
|
32
|
+
* `assets/` directory — and names where the file sits in the repository. The
|
|
33
|
+
* book stages from it. Foundry and the website serve the **deployed** layout
|
|
34
|
+
* instead, `<icons|images>/<basename>`, which follows from the same path by
|
|
35
|
+
* dropping the directories between the type root and the file — see
|
|
36
|
+
* {@link module:engine/asset-shipping}.
|
|
36
37
|
*
|
|
37
38
|
* **Provenance resolves per address**, and the rule is stated rather than
|
|
38
39
|
* inferred from a directory:
|
|
@@ -43,8 +44,14 @@
|
|
|
43
44
|
* sidecar exists precisely because that answer is wrong for this one file.
|
|
44
45
|
* 2. Otherwise the nearest `provenance.yaml`, searching the file's own directory
|
|
45
46
|
* and then its ancestors, stopping at the type root.
|
|
46
|
-
* 3. Otherwise nothing
|
|
47
|
-
*
|
|
47
|
+
* 3. Otherwise nothing — and a file no record covers is a **finding** at the
|
|
48
|
+
* file. Every asset states who holds its rights, the terms it is used under
|
|
49
|
+
* and whether it is machine-generated, because the stage decides from those
|
|
50
|
+
* three answers whether the file ships.
|
|
51
|
+
*
|
|
52
|
+
* Resolution is **per file**, never per folder. One directory routinely holds
|
|
53
|
+
* files covered by its own `provenance.yaml` beside files a sidecar answers for,
|
|
54
|
+
* so nothing reads one file's answer as its neighbour's.
|
|
48
55
|
*
|
|
49
56
|
* An unknown key in a provenance file is a **finding** rather than a silent
|
|
50
57
|
* drop: `licence` beside `license` is otherwise an attribution record that looks
|
|
@@ -66,7 +73,7 @@ import path from "node:path";
|
|
|
66
73
|
import YAML from "yaml";
|
|
67
74
|
|
|
68
75
|
import { canonicalKey } from "./content-address.mjs";
|
|
69
|
-
import { positionOfYamlPath } from "./diagnostics.mjs";
|
|
76
|
+
import { formatDiagnostic, positionOfYamlPath } from "./diagnostics.mjs";
|
|
70
77
|
import { ASSET_SYSTEM, ASSET_TYPES, imageDimensions, isAssetShortcode } from "./asset-types.mjs";
|
|
71
78
|
|
|
72
79
|
/**
|
|
@@ -164,10 +171,12 @@ export const ASSET_RECORD_FIELDS = Object.freeze([
|
|
|
164
171
|
Object.freeze({
|
|
165
172
|
name: "ai",
|
|
166
173
|
from: "provenance",
|
|
174
|
+
required: true,
|
|
167
175
|
type: "boolean",
|
|
168
176
|
describe:
|
|
169
177
|
"Whether the file is machine-generated — the YAML boolean `true` " +
|
|
170
|
-
"or `false`, never a string."
|
|
178
|
+
"or `false`, never a string. Required: a record that does not say " +
|
|
179
|
+
"is a finding, never read as `false`.",
|
|
171
180
|
}),
|
|
172
181
|
Object.freeze({
|
|
173
182
|
name: "license",
|
|
@@ -257,6 +266,17 @@ const PROVENANCE_FIELD_BY_KEY = new Map(
|
|
|
257
266
|
]),
|
|
258
267
|
);
|
|
259
268
|
|
|
269
|
+
/**
|
|
270
|
+
* What a file carries when its record omits a required key, by key.
|
|
271
|
+
*
|
|
272
|
+
* @type {Readonly<Record<string, string>>}
|
|
273
|
+
*/
|
|
274
|
+
const MISSING_KEY_CONSEQUENCE = Object.freeze({
|
|
275
|
+
attribution: "no attribution",
|
|
276
|
+
license: "no licence",
|
|
277
|
+
ai: "no statement of whether it is machine-generated — and an unstated `ai` is never read as `false`, so none of them ships",
|
|
278
|
+
});
|
|
279
|
+
|
|
260
280
|
/**
|
|
261
281
|
* Read one provenance file, reporting every key that is not a provenance key.
|
|
262
282
|
*
|
|
@@ -289,7 +309,9 @@ function readProvenanceFile(file, findings) {
|
|
|
289
309
|
});
|
|
290
310
|
return {};
|
|
291
311
|
}
|
|
292
|
-
|
|
312
|
+
// An empty file is a record that states nothing, and is held to the
|
|
313
|
+
// required keys below like any other.
|
|
314
|
+
if (parsed == null) parsed = {};
|
|
293
315
|
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
294
316
|
findings.push({
|
|
295
317
|
file,
|
|
@@ -357,21 +379,24 @@ function readProvenanceFile(file, findings) {
|
|
|
357
379
|
}
|
|
358
380
|
|
|
359
381
|
// A record resolves wholesale — the nearest one is the whole answer, and no
|
|
360
|
-
// ancestor fills a key it leaves out. So an omitted `attribution`
|
|
361
|
-
// `
|
|
362
|
-
// rights holder
|
|
363
|
-
//
|
|
364
|
-
//
|
|
382
|
+
// ancestor fills a key it leaves out. So an omitted `attribution`, `license`
|
|
383
|
+
// or `ai` is not a value inherited from above; it is a file with no stated
|
|
384
|
+
// rights holder, no stated terms, or no statement of whether a machine made
|
|
385
|
+
// it, which is the one thing a provenance record exists to prevent.
|
|
386
|
+
// Reported against the file rather than a line, because the fault is an
|
|
387
|
+
// absence and has no position. An `ai` that is present but not a boolean
|
|
388
|
+
// already has its own finding above.
|
|
365
389
|
for (const key of REQUIRED_PROVENANCE_KEYS) {
|
|
366
|
-
|
|
390
|
+
const field = PROVENANCE_FIELD_BY_KEY.get(key);
|
|
391
|
+
const stated = field?.type === "boolean" ? Object.hasOwn(parsed, key) : Boolean(out[key]);
|
|
392
|
+
if (stated) continue;
|
|
367
393
|
findings.push({
|
|
368
394
|
file,
|
|
369
395
|
severity: "error",
|
|
370
396
|
message:
|
|
371
397
|
`a provenance record states \`${key}\`, and this one does not. ` +
|
|
372
398
|
"A record replaces rather than extends the one above it, so every " +
|
|
373
|
-
|
|
374
|
-
(key === "license" ? "licence" : key),
|
|
399
|
+
`file it covers would carry ${MISSING_KEY_CONSEQUENCE[key] ?? `no ${key}`}`,
|
|
375
400
|
});
|
|
376
401
|
}
|
|
377
402
|
return out;
|
|
@@ -497,8 +522,9 @@ function* walkAssetRoot(rootDir, extensions) {
|
|
|
497
522
|
* asserts that the address does not exist.
|
|
498
523
|
* @returns {Array<Record<string, any>>} One record per addressable file, in walk
|
|
499
524
|
* order; the caller imposes the index's total order.
|
|
500
|
-
* @throws {Error} When
|
|
501
|
-
* supplied.
|
|
525
|
+
* @throws {Error} When any finding is made and no `problems` array was
|
|
526
|
+
* supplied. Its message is every finding as a located diagnostic line, one
|
|
527
|
+
* per line; `file` and `position` name the first.
|
|
502
528
|
*/
|
|
503
529
|
export function collectAssetRecords(assetsBase, { contentPackage, problems }) {
|
|
504
530
|
const records = [];
|
|
@@ -548,6 +574,17 @@ export function collectAssetRecords(assetsBase, { contentPackage, problems }) {
|
|
|
548
574
|
// ancestor walk is not consulted when one is present.
|
|
549
575
|
: inheritedProvenance(path.dirname(absPath), rootDir, cache, findings);
|
|
550
576
|
|
|
577
|
+
if (provenance === null) {
|
|
578
|
+
report(
|
|
579
|
+
absPath,
|
|
580
|
+
`no provenance record covers this file — add \`${path.basename(absPath)}` +
|
|
581
|
+
`${PROVENANCE_SIDECAR_SUFFIX}\` beside it, or a \`${PROVENANCE_FILE}\` ` +
|
|
582
|
+
`in its directory or one above it up to ${root}/. Every asset states ` +
|
|
583
|
+
"its attribution, its licence and whether it is machine-generated, " +
|
|
584
|
+
"and the stage ships a file only on those answers",
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
551
588
|
for (const field of ASSET_RECORD_FIELDS) {
|
|
552
589
|
if (field.onlyType && field.onlyType !== type && provenance?.[field.name]) {
|
|
553
590
|
report(
|
|
@@ -584,10 +621,14 @@ export function collectAssetRecords(assetsBase, { contentPackage, problems }) {
|
|
|
584
621
|
|
|
585
622
|
if (findings.length) {
|
|
586
623
|
if (!problems) {
|
|
624
|
+
// Every finding, each a located diagnostic line, so a tree with a
|
|
625
|
+
// dozen uncovered files is fixed in one pass rather than one
|
|
626
|
+
// rebuild per file.
|
|
587
627
|
const first = findings[0];
|
|
588
|
-
const err = new Error(
|
|
628
|
+
const err = new Error(findings.map(formatDiagnostic).join("\n"));
|
|
589
629
|
err.file = first.file;
|
|
590
630
|
if (first.line) err.position = { line: first.line, column: first.column };
|
|
631
|
+
/** @type {any} */ (err).located = true;
|
|
591
632
|
throw err;
|
|
592
633
|
}
|
|
593
634
|
problems.push(...findings);
|
|
@@ -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
|
+
}
|