@heroiclands/package-build 21.1.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTENT.md +107 -3
  3. package/assets/fonts/libertinus/LibertinusMono-Regular.otf +0 -0
  4. package/assets/fonts/libertinus/LibertinusSans-Bold.otf +0 -0
  5. package/assets/fonts/libertinus/LibertinusSans-Italic.otf +0 -0
  6. package/assets/fonts/libertinus/LibertinusSans-Regular.otf +0 -0
  7. package/assets/fonts/libertinus/OFL.txt +94 -0
  8. package/assets/fonts/libertinus/provenance.yaml +11 -0
  9. package/assets/images/banners/afflictionbnr.webp +0 -0
  10. package/assets/images/banners/armorclothingbnr.webp +0 -0
  11. package/assets/images/banners/attributebnr.webp +0 -0
  12. package/assets/images/banners/containerbnr.webp +0 -0
  13. package/assets/images/banners/devdocsbnr.webp +0 -0
  14. package/assets/images/banners/miscgearbnr.webp +0 -0
  15. package/assets/images/banners/mysticalabilitybnr.webp +0 -0
  16. package/assets/images/banners/projectilebnr.webp +0 -0
  17. package/assets/images/banners/rulesbnr.webp +0 -0
  18. package/assets/images/banners/skillbnr.webp +0 -0
  19. package/assets/images/banners/traumabnr.webp +0 -0
  20. package/assets/images/banners/userguidebnr.webp +0 -0
  21. package/assets/images/banners/weapongearbnr.webp +0 -0
  22. package/assets/images/provenance.yaml +4 -0
  23. package/bin/content-build.mjs +19 -5
  24. package/ci/emit-asset-index.mjs +32 -0
  25. package/content-config.mjs +30 -1
  26. package/docs/api.md +100 -26
  27. package/docs/configuration.md +16 -7
  28. package/docs/content-format.md +512 -109
  29. package/engine/actor-compiler.mjs +58 -9
  30. package/engine/art-fields.mjs +292 -0
  31. package/engine/asset-index.mjs +397 -0
  32. package/engine/asset-types.mjs +192 -0
  33. package/engine/base-compiler.mjs +69 -1
  34. package/engine/bundles.mjs +5 -5
  35. package/engine/compile-corpus.mjs +4 -0
  36. package/engine/content-address.mjs +14 -2
  37. package/engine/content-charset.mjs +5 -1
  38. package/engine/content-embeds.mjs +314 -0
  39. package/engine/content-html.mjs +5 -1
  40. package/engine/content-icons.mjs +5 -1
  41. package/engine/content-images.mjs +26 -5
  42. package/engine/content-index.mjs +68 -33
  43. package/engine/content-links.mjs +164 -24
  44. package/engine/frontmatter-lint.mjs +36 -23
  45. package/engine/helpers.mjs +83 -31
  46. package/engine/index-records.mjs +63 -4
  47. package/engine/index.mjs +15 -0
  48. package/engine/infobox-registry.mjs +50 -3
  49. package/engine/infobox.mjs +12 -8
  50. package/engine/item-compiler.mjs +10 -10
  51. package/engine/journals.mjs +3 -0
  52. package/engine/macros.mjs +16 -13
  53. package/engine/map-notes.mjs +29 -22
  54. package/engine/metadata-index.mjs +44 -36
  55. package/engine/note-vocabulary.mjs +71 -29
  56. package/engine/pack-router.mjs +14 -0
  57. package/engine/packagebuild-index.mjs +124 -0
  58. package/engine/packages.mjs +83 -0
  59. package/engine/pathnames.mjs +74 -0
  60. package/engine/pdf-build.mjs +133 -8
  61. package/engine/pdf-render.mjs +9 -2
  62. package/engine/scenes.mjs +12 -9
  63. package/engine/site-build.mjs +43 -16
  64. package/engine/site-index.mjs +10 -1
  65. package/engine/subtype-registry.mjs +21 -0
  66. package/engine/web-wikilinks.mjs +46 -12
  67. package/engine/wikilink-syntax.mjs +20 -2
  68. package/engine/wikilinks.mjs +15 -2
  69. package/hm3/actors.mjs +11 -38
  70. package/package.json +5 -2
  71. package/packagebuild-metadata.jsonl +13 -0
  72. package/sohl/actors.mjs +12 -86
  73. package/sohl/note-schemas.mjs +4 -16
  74. package/types/content-config.d.mts +8 -0
  75. package/types/engine/actor-compiler.d.mts +29 -0
  76. package/types/engine/art-fields.d.mts +192 -0
  77. package/types/engine/asset-index.d.mts +92 -0
  78. package/types/engine/asset-types.d.mts +110 -0
  79. package/types/engine/base-compiler.d.mts +42 -1
  80. package/types/engine/compile-corpus.d.mts +4 -1
  81. package/types/engine/content-charset.d.mts +4 -0
  82. package/types/engine/content-embeds.d.mts +218 -0
  83. package/types/engine/content-html.d.mts +4 -0
  84. package/types/engine/content-icons.d.mts +4 -0
  85. package/types/engine/content-images.d.mts +15 -2
  86. package/types/engine/content-index.d.mts +27 -15
  87. package/types/engine/frontmatter-lint.d.mts +11 -14
  88. package/types/engine/helpers.d.mts +7 -2
  89. package/types/engine/index-records.d.mts +44 -3
  90. package/types/engine/index.d.mts +5 -0
  91. package/types/engine/infobox-registry.d.mts +36 -1
  92. package/types/engine/infobox.d.mts +6 -4
  93. package/types/engine/macros.d.mts +9 -5
  94. package/types/engine/map-notes.d.mts +10 -6
  95. package/types/engine/metadata-index.d.mts +2 -17
  96. package/types/engine/note-vocabulary.d.mts +30 -0
  97. package/types/engine/packagebuild-index.d.mts +56 -0
  98. package/types/engine/packages.d.mts +62 -0
  99. package/types/engine/pathnames.d.mts +41 -81
  100. package/types/engine/pdf-build.d.mts +58 -1
  101. package/types/engine/pdf-render.d.mts +3 -1
  102. package/types/engine/scenes.d.mts +2 -1
  103. package/types/engine/site-build.d.mts +5 -1
  104. package/types/engine/site-index.d.mts +4 -1
  105. package/types/engine/subtype-registry.d.mts +20 -0
  106. package/types/engine/web-wikilinks.d.mts +14 -4
  107. package/types/engine/wikilink-syntax.d.mts +13 -1
  108. package/types/engine/wikilinks.d.mts +7 -1
  109. 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
+ }