@heroiclands/package-build 21.0.0 → 21.2.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 +228 -0
- package/CONTENT.md +109 -7
- package/assets/fonts/libertinus/LibertinusMono-Regular.otf +0 -0
- package/assets/fonts/libertinus/LibertinusSans-Bold.otf +0 -0
- package/assets/fonts/libertinus/LibertinusSans-Italic.otf +0 -0
- package/assets/fonts/libertinus/LibertinusSans-Regular.otf +0 -0
- package/assets/fonts/libertinus/OFL.txt +94 -0
- package/assets/fonts/libertinus/provenance.yaml +11 -0
- package/assets/images/banners/afflictionbnr.webp +0 -0
- package/assets/images/banners/armorclothingbnr.webp +0 -0
- package/assets/images/banners/attributebnr.webp +0 -0
- package/assets/images/banners/containerbnr.webp +0 -0
- package/assets/images/banners/devdocsbnr.webp +0 -0
- package/assets/images/banners/miscgearbnr.webp +0 -0
- package/assets/images/banners/mysticalabilitybnr.webp +0 -0
- package/assets/images/banners/projectilebnr.webp +0 -0
- package/assets/images/banners/rulesbnr.webp +0 -0
- package/assets/images/banners/skillbnr.webp +0 -0
- package/assets/images/banners/traumabnr.webp +0 -0
- package/assets/images/banners/userguidebnr.webp +0 -0
- package/assets/images/banners/weapongearbnr.webp +0 -0
- package/assets/images/provenance.yaml +4 -0
- package/bin/content-build.mjs +19 -5
- package/ci/emit-asset-index.mjs +32 -0
- package/content-config.mjs +30 -1
- package/docs/api.md +100 -26
- package/docs/configuration.md +16 -7
- package/docs/content-format.md +512 -109
- package/engine/actor-compiler.mjs +58 -9
- package/engine/art-fields.mjs +292 -0
- package/engine/asset-index.mjs +397 -0
- package/engine/asset-types.mjs +192 -0
- package/engine/base-compiler.mjs +69 -1
- package/engine/bundles.mjs +5 -5
- package/engine/compile-corpus.mjs +4 -0
- package/engine/content-address.mjs +14 -2
- package/engine/content-charset.mjs +5 -1
- package/engine/content-embeds.mjs +314 -0
- package/engine/content-html.mjs +5 -1
- package/engine/content-icons.mjs +5 -1
- package/engine/content-images.mjs +26 -5
- package/engine/content-index.mjs +68 -33
- package/engine/content-links.mjs +164 -24
- package/engine/frontmatter-lint.mjs +36 -23
- package/engine/helpers.mjs +83 -31
- package/engine/index-records.mjs +63 -4
- package/engine/index.mjs +15 -0
- package/engine/infobox-registry.mjs +50 -3
- package/engine/infobox-render.mjs +4 -3
- package/engine/infobox.mjs +12 -8
- package/engine/item-compiler.mjs +10 -10
- package/engine/journals.mjs +3 -0
- package/engine/macros.mjs +16 -13
- package/engine/map-notes.mjs +29 -22
- package/engine/metadata-index.mjs +44 -36
- package/engine/note-vocabulary.mjs +71 -29
- package/engine/pack-router.mjs +14 -0
- package/engine/packagebuild-index.mjs +124 -0
- package/engine/packages.mjs +83 -0
- package/engine/pathnames.mjs +74 -0
- package/engine/pdf-build.mjs +133 -10
- package/engine/pdf-render.mjs +41 -41
- package/engine/scenes.mjs +12 -9
- package/engine/site-build.mjs +43 -16
- package/engine/site-index.mjs +10 -1
- package/engine/subtype-registry.mjs +21 -0
- package/engine/web-wikilinks.mjs +46 -12
- package/engine/wikilink-syntax.mjs +20 -2
- package/engine/wikilinks.mjs +15 -2
- package/hm3/actors.mjs +11 -38
- package/hm3/infobox.mjs +1 -1
- package/package.json +5 -2
- package/packagebuild-metadata.jsonl +13 -0
- package/sohl/actors.mjs +12 -86
- package/sohl/infobox.mjs +1 -1
- package/sohl/note-schemas.mjs +4 -16
- package/types/content-config.d.mts +8 -0
- package/types/engine/actor-compiler.d.mts +29 -0
- package/types/engine/art-fields.d.mts +192 -0
- package/types/engine/asset-index.d.mts +92 -0
- package/types/engine/asset-types.d.mts +110 -0
- package/types/engine/base-compiler.d.mts +42 -1
- package/types/engine/compile-corpus.d.mts +4 -1
- package/types/engine/content-charset.d.mts +4 -0
- package/types/engine/content-embeds.d.mts +218 -0
- package/types/engine/content-html.d.mts +4 -0
- package/types/engine/content-icons.d.mts +4 -0
- package/types/engine/content-images.d.mts +15 -2
- package/types/engine/content-index.d.mts +27 -15
- package/types/engine/frontmatter-lint.d.mts +11 -14
- package/types/engine/helpers.d.mts +7 -2
- package/types/engine/index-records.d.mts +44 -3
- package/types/engine/index.d.mts +5 -0
- package/types/engine/infobox-registry.d.mts +36 -1
- package/types/engine/infobox.d.mts +6 -4
- package/types/engine/macros.d.mts +9 -5
- package/types/engine/map-notes.d.mts +10 -6
- package/types/engine/metadata-index.d.mts +2 -17
- package/types/engine/note-vocabulary.d.mts +30 -0
- package/types/engine/packagebuild-index.d.mts +56 -0
- package/types/engine/packages.d.mts +62 -0
- package/types/engine/pathnames.d.mts +41 -81
- package/types/engine/pdf-build.d.mts +58 -1
- package/types/engine/pdf-render.d.mts +3 -3
- package/types/engine/scenes.d.mts +2 -1
- package/types/engine/site-build.d.mts +5 -1
- package/types/engine/site-index.d.mts +4 -1
- package/types/engine/subtype-registry.d.mts +20 -0
- package/types/engine/web-wikilinks.d.mts +14 -4
- package/types/engine/wikilink-syntax.d.mts +13 -1
- package/types/engine/wikilinks.d.mts +7 -1
- package/types/sohl/actors.d.mts +0 -1
|
@@ -0,0 +1,397 @@
|
|
|
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 **asset record**: one line of the content index per addressable file.
|
|
16
|
+
*
|
|
17
|
+
* `engine/content-index.mjs` walks `assets/content` and emits a record per note.
|
|
18
|
+
* This walks the three asset roots beside it and emits a record per file, into
|
|
19
|
+
* the same JSON Lines index. There is no asset-specific index and no asset
|
|
20
|
+
* emitter that a consumer has to know about separately — one file, two record
|
|
21
|
+
* shapes, and the module says so.
|
|
22
|
+
*
|
|
23
|
+
* **What an asset record is not.** It carries no frontmatter, no anchors, no
|
|
24
|
+
* `foundry` block and no page address. A file declares nothing about itself, so
|
|
25
|
+
* there is nothing to copy through; it compiles into no document, so there is no
|
|
26
|
+
* UUID; and it publishes no page, so `address` carries the canonical key and no
|
|
27
|
+
* slug. A reader tells the two shapes apart by the `asset` block, which is what
|
|
28
|
+
* {@link module:engine/index-records.isAssetRecord} asks.
|
|
29
|
+
*
|
|
30
|
+
* **`path` is what makes resolution one step.** The record is emitted by the
|
|
31
|
+
* package holding the bytes, so its path is that package's path — relative to
|
|
32
|
+
* that package's `assets/` directory — and each consumer joins its own root onto
|
|
33
|
+
* it. Foundry prefixes `<kind>/<foundry id>/assets/`, the website its CDN prefix
|
|
34
|
+
* for the package, the book the asset base it was configured with. Nothing has
|
|
35
|
+
* to be looked up a second time.
|
|
36
|
+
*
|
|
37
|
+
* **Provenance resolves per address**, and the rule is stated rather than
|
|
38
|
+
* inferred from a directory:
|
|
39
|
+
*
|
|
40
|
+
* 1. A sibling `<filename.ext>.yaml`, which **replaces** an inherited record
|
|
41
|
+
* wholesale rather than merging over it. Merging would make a record's
|
|
42
|
+
* meaning depend on what a directory two levels up happens to say, and a
|
|
43
|
+
* sidecar exists precisely because that answer is wrong for this one file.
|
|
44
|
+
* 2. Otherwise the nearest `provenance.yaml`, searching the file's own directory
|
|
45
|
+
* and then its ancestors, stopping at the type root.
|
|
46
|
+
* 3. Otherwise nothing, and the record's provenance fields are blank. A package
|
|
47
|
+
* that records no attribution is a fact to state, not a walk to fail.
|
|
48
|
+
*
|
|
49
|
+
* An unknown key in a provenance file is a **finding** rather than a silent
|
|
50
|
+
* drop: `licence` beside `license` is otherwise an attribution record that looks
|
|
51
|
+
* complete and carries nothing.
|
|
52
|
+
*
|
|
53
|
+
* @module
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import fs from "node:fs";
|
|
57
|
+
import path from "node:path";
|
|
58
|
+
|
|
59
|
+
import YAML from "yaml";
|
|
60
|
+
|
|
61
|
+
import { canonicalKey } from "./content-address.mjs";
|
|
62
|
+
import { positionOfYamlPath } from "./diagnostics.mjs";
|
|
63
|
+
import { ASSET_SYSTEM, ASSET_TYPES, isAssetShortcode } from "./asset-types.mjs";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The file a directory records provenance for its subtree in.
|
|
67
|
+
*
|
|
68
|
+
* @type {string}
|
|
69
|
+
*/
|
|
70
|
+
export const PROVENANCE_FILE = "provenance.yaml";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The suffix a per-file provenance sidecar carries.
|
|
74
|
+
*
|
|
75
|
+
* Appended to the **whole** filename, extension included — `anvil.svg.yaml`,
|
|
76
|
+
* not `anvil.yaml` — because the address holds one file whose format is free to
|
|
77
|
+
* change, and a sidecar named after the address alone would be orphaned the day
|
|
78
|
+
* an SVG became a WebP without anything saying so.
|
|
79
|
+
*
|
|
80
|
+
* @type {string}
|
|
81
|
+
*/
|
|
82
|
+
export const PROVENANCE_SIDECAR_SUFFIX = ".yaml";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One field of the `asset` block.
|
|
86
|
+
*
|
|
87
|
+
* @typedef {object} AssetRecordField
|
|
88
|
+
* @property {string} name - The key inside `asset`.
|
|
89
|
+
* @property {"walk"|"provenance"} from - Where the value comes from: the walk
|
|
90
|
+
* itself, or the provenance record resolved for the address.
|
|
91
|
+
* @property {string} describe - One line, for the author-facing reference.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Every field an `asset` block carries, in the order it is documented.
|
|
96
|
+
*
|
|
97
|
+
* **The emitter builds a record from this list**, rather than from a literal
|
|
98
|
+
* that a second list somewhere else would have to be kept in step with. So a
|
|
99
|
+
* field added here is emitted, a field removed here stops being emitted, and the
|
|
100
|
+
* completeness guard derives what it checks from the same declaration instead of
|
|
101
|
+
* hand-copying it.
|
|
102
|
+
*
|
|
103
|
+
* Every field is always present, blank where nothing states it. A fixed record
|
|
104
|
+
* shape is what lets a consumer read `asset.license` without branching on
|
|
105
|
+
* whether the package happened to record one, and blank is the honest answer to
|
|
106
|
+
* "what does this package say about where this file came from" when it says
|
|
107
|
+
* nothing.
|
|
108
|
+
*
|
|
109
|
+
* @type {readonly AssetRecordField[]}
|
|
110
|
+
*/
|
|
111
|
+
export const ASSET_RECORD_FIELDS = Object.freeze([
|
|
112
|
+
Object.freeze({
|
|
113
|
+
name: "path",
|
|
114
|
+
from: "walk",
|
|
115
|
+
describe:
|
|
116
|
+
"Where the file sits inside the emitting package's asset directory. " +
|
|
117
|
+
"Each consumer joins its own root onto it.",
|
|
118
|
+
}),
|
|
119
|
+
Object.freeze({
|
|
120
|
+
name: "attribution",
|
|
121
|
+
from: "provenance",
|
|
122
|
+
describe: "Who made the file, or what tool generated it.",
|
|
123
|
+
}),
|
|
124
|
+
Object.freeze({
|
|
125
|
+
name: "source",
|
|
126
|
+
from: "provenance",
|
|
127
|
+
describe: "Where it came from — a URL, or a sentence.",
|
|
128
|
+
}),
|
|
129
|
+
Object.freeze({
|
|
130
|
+
name: "license",
|
|
131
|
+
from: "provenance",
|
|
132
|
+
describe: "The licence it is used under — an SPDX identifier, or terms.",
|
|
133
|
+
}),
|
|
134
|
+
Object.freeze({
|
|
135
|
+
name: "notes",
|
|
136
|
+
from: "provenance",
|
|
137
|
+
describe: "Anything else a person reading the attribution needs.",
|
|
138
|
+
}),
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The keys a provenance file may declare.
|
|
143
|
+
*
|
|
144
|
+
* Derived from {@link ASSET_RECORD_FIELDS} rather than restated, so the file
|
|
145
|
+
* format and the record cannot disagree about which keys exist.
|
|
146
|
+
*
|
|
147
|
+
* @type {ReadonlySet<string>}
|
|
148
|
+
*/
|
|
149
|
+
export const PROVENANCE_KEYS = Object.freeze(
|
|
150
|
+
new Set(
|
|
151
|
+
ASSET_RECORD_FIELDS.filter((field) => field.from === "provenance").map(
|
|
152
|
+
(field) => field.name,
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Read one provenance file, reporting every key that is not a provenance key.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} file - The provenance file.
|
|
161
|
+
* @param {object[]} findings - Collects a diagnostic per unknown key.
|
|
162
|
+
* @returns {Record<string, string>} The recognised keys, as strings.
|
|
163
|
+
*/
|
|
164
|
+
function readProvenanceFile(file, findings) {
|
|
165
|
+
let text;
|
|
166
|
+
try {
|
|
167
|
+
text = fs.readFileSync(file, "utf8");
|
|
168
|
+
} catch (err) {
|
|
169
|
+
findings.push({
|
|
170
|
+
file,
|
|
171
|
+
severity: "error",
|
|
172
|
+
message: `provenance file cannot be read: ${err.message}`,
|
|
173
|
+
});
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let parsed;
|
|
178
|
+
try {
|
|
179
|
+
parsed = YAML.parse(text);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
findings.push({
|
|
182
|
+
file,
|
|
183
|
+
severity: "error",
|
|
184
|
+
message: `provenance file is not valid YAML: ${err.message}`,
|
|
185
|
+
});
|
|
186
|
+
return {};
|
|
187
|
+
}
|
|
188
|
+
if (parsed == null) return {};
|
|
189
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
190
|
+
findings.push({
|
|
191
|
+
file,
|
|
192
|
+
severity: "error",
|
|
193
|
+
message:
|
|
194
|
+
"a provenance file is a map of " +
|
|
195
|
+
`${[...PROVENANCE_KEYS].join(", ")} — this one is not a map`,
|
|
196
|
+
});
|
|
197
|
+
return {};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const out = {};
|
|
201
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
202
|
+
if (!PROVENANCE_KEYS.has(key)) {
|
|
203
|
+
findings.push({
|
|
204
|
+
file,
|
|
205
|
+
...positionOfYamlPath(text, [key], { key: true }),
|
|
206
|
+
severity: "error",
|
|
207
|
+
message:
|
|
208
|
+
`\`${key}\` is not a provenance key — a record states ` +
|
|
209
|
+
`${[...PROVENANCE_KEYS].join(", ")}, and anything else is ` +
|
|
210
|
+
"dropped rather than recorded",
|
|
211
|
+
});
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
out[key] = value == null ? "" : String(value);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The provenance a directory inherits, memoised per directory.
|
|
221
|
+
*
|
|
222
|
+
* The walk asks this once per file and the answer is the same for every file in
|
|
223
|
+
* a directory, so a tree of 4,500 icons would otherwise re-read and re-parse the
|
|
224
|
+
* same thirty-odd files thousands of times.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} dir - The directory the file sits in.
|
|
227
|
+
* @param {string} root - The type root the search stops at.
|
|
228
|
+
* @param {Map<string, Record<string, string>|null>} cache - Per-directory answers.
|
|
229
|
+
* @param {object[]} findings - Collects a diagnostic per unknown key.
|
|
230
|
+
* @returns {Record<string, string>|null} The nearest record, or null.
|
|
231
|
+
*/
|
|
232
|
+
function inheritedProvenance(dir, root, cache, findings) {
|
|
233
|
+
if (cache.has(dir)) return cache.get(dir);
|
|
234
|
+
const own = path.join(dir, PROVENANCE_FILE);
|
|
235
|
+
let answer;
|
|
236
|
+
if (fs.existsSync(own)) {
|
|
237
|
+
answer = readProvenanceFile(own, findings);
|
|
238
|
+
} else if (path.resolve(dir) === path.resolve(root)) {
|
|
239
|
+
// The search stops at the type root: `assets/` above it is the
|
|
240
|
+
// package's own furniture, and a record there would speak for trees it
|
|
241
|
+
// says nothing about.
|
|
242
|
+
answer = null;
|
|
243
|
+
} else {
|
|
244
|
+
answer = inheritedProvenance(path.dirname(dir), root, cache, findings);
|
|
245
|
+
}
|
|
246
|
+
cache.set(dir, answer);
|
|
247
|
+
return answer;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The `asset` block for one file.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} relPath - The file's path below the package's asset directory.
|
|
254
|
+
* @param {Record<string, string>|null} provenance - The resolved record.
|
|
255
|
+
* @returns {Record<string, string>} The block, every field present.
|
|
256
|
+
*/
|
|
257
|
+
function assetBlock(relPath, provenance) {
|
|
258
|
+
const block = {};
|
|
259
|
+
for (const field of ASSET_RECORD_FIELDS) {
|
|
260
|
+
const value = field.from === "walk" ? relPath : provenance?.[field.name];
|
|
261
|
+
block[field.name] = typeof value === "string" ? value : "";
|
|
262
|
+
}
|
|
263
|
+
return block;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Every file under one root that is an asset of its type.
|
|
268
|
+
*
|
|
269
|
+
* The layout beneath the root is arbitrary, so the walk derives the address from
|
|
270
|
+
* the file it finds and never from the path above it.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} rootDir - The absolute root directory.
|
|
273
|
+
* @param {readonly string[]} extensions - Lowercase, dot-led.
|
|
274
|
+
* @yields {{absPath: string, relPath: string, shortcode: string}}
|
|
275
|
+
*/
|
|
276
|
+
function* walkAssetRoot(rootDir, extensions) {
|
|
277
|
+
if (!fs.existsSync(rootDir)) return;
|
|
278
|
+
const stack = [rootDir];
|
|
279
|
+
while (stack.length > 0) {
|
|
280
|
+
const dir = stack.pop();
|
|
281
|
+
let entries;
|
|
282
|
+
try {
|
|
283
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
284
|
+
} catch {
|
|
285
|
+
// A directory that cannot be read holds no address this build can
|
|
286
|
+
// state. Reported by the caller, which knows the root it is walking.
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
for (const entry of entries) {
|
|
290
|
+
const absPath = path.join(dir, entry.name);
|
|
291
|
+
if (entry.isDirectory()) {
|
|
292
|
+
stack.push(absPath);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (!entry.isFile()) continue;
|
|
296
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
297
|
+
if (!extensions.includes(ext)) continue;
|
|
298
|
+
yield {
|
|
299
|
+
absPath,
|
|
300
|
+
relPath: path.relative(rootDir, absPath).split(path.sep).join("/"),
|
|
301
|
+
shortcode: entry.name.slice(0, entry.name.length - ext.length),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Read a package's asset trees into index records.
|
|
309
|
+
*
|
|
310
|
+
* @param {string} assetsBase - The package's asset directory, the three roots'
|
|
311
|
+
* parent. A directory that does not exist yields nothing: a package with no
|
|
312
|
+
* art is ordinary.
|
|
313
|
+
* @param {object} options - Options.
|
|
314
|
+
* @param {string} options.contentPackage - The package the trees belong to.
|
|
315
|
+
* @param {object[]} [options.problems] - Supplied by a **reader**: a file that
|
|
316
|
+
* cannot be addressed is pushed here as a diagnostic and skipped. Omitted, it
|
|
317
|
+
* throws — the contract the emitter needs, since an index missing an asset
|
|
318
|
+
* asserts that the address does not exist.
|
|
319
|
+
* @returns {Array<Record<string, any>>} One record per addressable file, in walk
|
|
320
|
+
* order; the caller imposes the index's total order.
|
|
321
|
+
* @throws {Error} When a file cannot be addressed and no `problems` array was
|
|
322
|
+
* supplied. `file` rides on the error.
|
|
323
|
+
*/
|
|
324
|
+
export function collectAssetRecords(assetsBase, { contentPackage, problems }) {
|
|
325
|
+
const records = [];
|
|
326
|
+
const findings = [];
|
|
327
|
+
|
|
328
|
+
// Collected rather than thrown at the point of discovery, so an emitter and
|
|
329
|
+
// a reader see the same set: the emitter throws on the first of them below,
|
|
330
|
+
// while a reader gets every one at once instead of losing the rest to the
|
|
331
|
+
// first bad filename.
|
|
332
|
+
const report = (file, message) => findings.push({ file, severity: "error", message });
|
|
333
|
+
|
|
334
|
+
for (const { type, root, extensions } of ASSET_TYPES) {
|
|
335
|
+
const rootDir = path.join(assetsBase, root);
|
|
336
|
+
const cache = new Map();
|
|
337
|
+
/** @type {Map<string, string>} shortcode → the file already claiming it. */
|
|
338
|
+
const claimed = new Map();
|
|
339
|
+
|
|
340
|
+
for (const { absPath, relPath, shortcode } of walkAssetRoot(rootDir, extensions)) {
|
|
341
|
+
if (!isAssetShortcode(shortcode)) {
|
|
342
|
+
report(
|
|
343
|
+
absPath,
|
|
344
|
+
`"${shortcode}" is not a shortcode — an asset is addressed ` +
|
|
345
|
+
`\`${contentPackage}-${ASSET_SYSTEM}-${type}-<shortcode>\`, and a ` +
|
|
346
|
+
"shortcode is lowercase letters and digits only, so this file " +
|
|
347
|
+
"has no address. Rename it",
|
|
348
|
+
);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const already = claimed.get(shortcode);
|
|
352
|
+
if (already) {
|
|
353
|
+
report(
|
|
354
|
+
absPath,
|
|
355
|
+
`two files under ${root}/ are named "${shortcode}", so both ` +
|
|
356
|
+
`claim \`${contentPackage}-${ASSET_SYSTEM}-${type}-${shortcode}\` — ` +
|
|
357
|
+
`the other is ${already}. A root's shortcodes are one flat ` +
|
|
358
|
+
"namespace however deeply it nests, so rename one of them",
|
|
359
|
+
);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
claimed.set(shortcode, relPath);
|
|
363
|
+
|
|
364
|
+
const sidecar = `${absPath}${PROVENANCE_SIDECAR_SUFFIX}`;
|
|
365
|
+
const provenance =
|
|
366
|
+
fs.existsSync(sidecar) ?
|
|
367
|
+
readProvenanceFile(sidecar, findings)
|
|
368
|
+
// A sidecar replaces an inherited record wholesale, so the
|
|
369
|
+
// ancestor walk is not consulted when one is present.
|
|
370
|
+
: inheritedProvenance(path.dirname(absPath), rootDir, cache, findings);
|
|
371
|
+
|
|
372
|
+
records.push({
|
|
373
|
+
package: contentPackage,
|
|
374
|
+
type,
|
|
375
|
+
shortcode,
|
|
376
|
+
// The canonical key, and no slug: an asset publishes no page, so
|
|
377
|
+
// there is no page address for a slug to name.
|
|
378
|
+
address: {
|
|
379
|
+
canonical: canonicalKey(contentPackage, ASSET_SYSTEM, type, shortcode),
|
|
380
|
+
},
|
|
381
|
+
asset: assetBlock(`${root}/${relPath}`, provenance),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (findings.length) {
|
|
387
|
+
if (!problems) {
|
|
388
|
+
const first = findings[0];
|
|
389
|
+
const err = new Error(first.message);
|
|
390
|
+
err.file = first.file;
|
|
391
|
+
if (first.line) err.position = { line: first.line, column: first.column };
|
|
392
|
+
throw err;
|
|
393
|
+
}
|
|
394
|
+
problems.push(...findings);
|
|
395
|
+
}
|
|
396
|
+
return records;
|
|
397
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
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 asset types — `icon`, `image` and `audio` — and the three roots they are
|
|
16
|
+
* walked from.
|
|
17
|
+
*
|
|
18
|
+
* An asset is addressed exactly as a note is: `<package>-none-<type>-<shortcode>`.
|
|
19
|
+
* What differs is where the address comes from. A note declares its own `type:`
|
|
20
|
+
* in frontmatter, so no directory has to; a `.webp` carries no frontmatter and
|
|
21
|
+
* has nowhere to say what it is, which is why the **root supplies the type** and
|
|
22
|
+
* the list of roots is closed. A directory under `assets/` that is not one of
|
|
23
|
+
* them declares nothing, so nothing in it is addressable — `assets/ui` falls out
|
|
24
|
+
* of that rule rather than needing an exemption.
|
|
25
|
+
*
|
|
26
|
+
* **Extension decides whether a file is an asset; root decides its type.** The
|
|
27
|
+
* filter is load-bearing rather than tidy-minded: `provenance.yaml` files live
|
|
28
|
+
* *inside* these roots at any depth, so a walk that took every file would read
|
|
29
|
+
* attribution records as assets.
|
|
30
|
+
*
|
|
31
|
+
* **Beneath the root the layout is arbitrary.** The filename is the shortcode
|
|
32
|
+
* and the directories above it are the package's own business, so a tree may be
|
|
33
|
+
* rearranged wholesale without a reference changing. A root's shortcodes are one
|
|
34
|
+
* flat namespace however deeply it nests, which is why two files under one root
|
|
35
|
+
* sharing a basename are two claims on one address.
|
|
36
|
+
*
|
|
37
|
+
* **A font is not one of these.** An asset type exists so a note can name a file
|
|
38
|
+
* and a package can substitute it, and a font answers to neither half: a
|
|
39
|
+
* stylesheet names a file with `url()` and the book names a *family*, so neither
|
|
40
|
+
* consumer could use an address. `assets/fonts` is not a root.
|
|
41
|
+
*
|
|
42
|
+
* This module is a **leaf** — `engine/address-charset.mjs` is the whole of its
|
|
43
|
+
* dependency — so the configuration validator and the content index can both
|
|
44
|
+
* name it without closing a cycle.
|
|
45
|
+
*
|
|
46
|
+
* @module
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The `<system>` segment every asset address carries.
|
|
53
|
+
*
|
|
54
|
+
* Spelled here rather than imported from `engine/systems.mjs` so this module
|
|
55
|
+
* stays a leaf. The two are held to one value by the address round-trip guard,
|
|
56
|
+
* which reads both.
|
|
57
|
+
*
|
|
58
|
+
* @type {string}
|
|
59
|
+
*/
|
|
60
|
+
export const ASSET_SYSTEM = "none";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* File extensions that make a file a picture, lowercase and dot-led.
|
|
64
|
+
*
|
|
65
|
+
* Foundry's own `IMAGE_FILE_EXTENSIONS`, because these files are installed into
|
|
66
|
+
* a Foundry data directory and a format Foundry will not display is not one this
|
|
67
|
+
* toolchain should hand it an address for.
|
|
68
|
+
*
|
|
69
|
+
* @type {readonly string[]}
|
|
70
|
+
*/
|
|
71
|
+
export const IMAGE_EXTENSIONS = Object.freeze([
|
|
72
|
+
".apng",
|
|
73
|
+
".avif",
|
|
74
|
+
".bmp",
|
|
75
|
+
".gif",
|
|
76
|
+
".jpeg",
|
|
77
|
+
".jpg",
|
|
78
|
+
".png",
|
|
79
|
+
".svg",
|
|
80
|
+
".tiff",
|
|
81
|
+
".webp",
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* File extensions that make a file a sound, lowercase and dot-led.
|
|
86
|
+
*
|
|
87
|
+
* Foundry's own `AUDIO_FILE_EXTENSIONS`, for the reason above.
|
|
88
|
+
*
|
|
89
|
+
* @type {readonly string[]}
|
|
90
|
+
*/
|
|
91
|
+
export const AUDIO_EXTENSIONS = Object.freeze([
|
|
92
|
+
".aac",
|
|
93
|
+
".flac",
|
|
94
|
+
".m4a",
|
|
95
|
+
".mid",
|
|
96
|
+
".mp3",
|
|
97
|
+
".ogg",
|
|
98
|
+
".opus",
|
|
99
|
+
".wav",
|
|
100
|
+
".webm",
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One asset type: what it is called, which directory holds it, and which files
|
|
105
|
+
* in that directory are assets of it.
|
|
106
|
+
*
|
|
107
|
+
* @typedef {object} AssetType
|
|
108
|
+
* @property {string} type - The type name, and the third segment of an address.
|
|
109
|
+
* @property {string} root - The directory below `paths.assets` that holds it.
|
|
110
|
+
* @property {readonly string[]} extensions - Lowercase, dot-led.
|
|
111
|
+
* @property {string} describe - One line, for the author-facing reference.
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The three asset types, in address order.
|
|
116
|
+
*
|
|
117
|
+
* The directory is named for what it holds and the type for what an address
|
|
118
|
+
* reaches, so the two differ by a letter and the mapping is **declared** rather
|
|
119
|
+
* than derived from the name.
|
|
120
|
+
*
|
|
121
|
+
* `icon` and `image` are two types rather than one because an icon has to stay
|
|
122
|
+
* coherent drawn into a 32×32 slot while an image is unbounded — a fitness
|
|
123
|
+
* property of the asset itself. They therefore have separate shortcode
|
|
124
|
+
* namespaces, and `icon-anvil` and `image-anvil` are different addresses.
|
|
125
|
+
*
|
|
126
|
+
* @type {readonly AssetType[]}
|
|
127
|
+
*/
|
|
128
|
+
export const ASSET_TYPES = Object.freeze([
|
|
129
|
+
Object.freeze({
|
|
130
|
+
type: "audio",
|
|
131
|
+
root: "audio",
|
|
132
|
+
extensions: AUDIO_EXTENSIONS,
|
|
133
|
+
describe: "A sound clip — an ambient loop, an effect.",
|
|
134
|
+
}),
|
|
135
|
+
Object.freeze({
|
|
136
|
+
type: "icon",
|
|
137
|
+
root: "icons",
|
|
138
|
+
extensions: IMAGE_EXTENSIONS,
|
|
139
|
+
describe: "A picture that stays legible drawn into a 32×32 slot.",
|
|
140
|
+
}),
|
|
141
|
+
Object.freeze({
|
|
142
|
+
type: "image",
|
|
143
|
+
root: "images",
|
|
144
|
+
extensions: IMAGE_EXTENSIONS,
|
|
145
|
+
describe: "A picture of unbounded size — a portrait, a map, a banner.",
|
|
146
|
+
}),
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Every asset type name.
|
|
151
|
+
*
|
|
152
|
+
* @type {ReadonlySet<string>}
|
|
153
|
+
*/
|
|
154
|
+
export const ASSET_TYPE_NAMES = Object.freeze(new Set(ASSET_TYPES.map((entry) => entry.type)));
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether a type name addresses a file rather than a note.
|
|
158
|
+
*
|
|
159
|
+
* The one test the rewrite scoping rests on: a rewrite rule may substitute an
|
|
160
|
+
* asset and nothing else, so a fourth asset type is covered by this answer
|
|
161
|
+
* rather than by editing a list somewhere else.
|
|
162
|
+
*
|
|
163
|
+
* @param {unknown} type - The type name.
|
|
164
|
+
* @returns {boolean} True for `icon`, `image` or `audio`.
|
|
165
|
+
*/
|
|
166
|
+
export function isAssetType(type) {
|
|
167
|
+
return typeof type === "string" && ASSET_TYPE_NAMES.has(type.toLowerCase());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The asset type a root directory declares, or `undefined`.
|
|
172
|
+
*
|
|
173
|
+
* @param {unknown} root - A directory name below `paths.assets`.
|
|
174
|
+
* @returns {AssetType|undefined} The type it holds.
|
|
175
|
+
*/
|
|
176
|
+
export function assetTypeOfRoot(root) {
|
|
177
|
+
return ASSET_TYPES.find((entry) => entry.root === root);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether a filename can be an address at all.
|
|
182
|
+
*
|
|
183
|
+
* A shortcode is lowercase alphanumerics, so a version string, a hyphen or a
|
|
184
|
+
* date stamp in a basename means the file cannot be addressed. The build says so
|
|
185
|
+
* rather than inventing a shortcode for it.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} shortcode - The basename with its extension removed.
|
|
188
|
+
* @returns {boolean} Whether it matches {@link ADDRESS_SEGMENT_PATTERN}.
|
|
189
|
+
*/
|
|
190
|
+
export function isAssetShortcode(shortcode) {
|
|
191
|
+
return typeof shortcode === "string" && ADDRESS_SEGMENT_PATTERN.test(shortcode);
|
|
192
|
+
}
|