@heroiclands/package-build 20.7.0 → 21.0.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 +159 -0
- package/CONTENT.md +134 -44
- package/bin/content-build.mjs +37 -5
- package/bin/package-build.mjs +77 -0
- package/content-config.mjs +59 -1
- package/docs/api.md +149 -19
- package/docs/commands.md +75 -0
- package/docs/configuration.md +37 -10
- package/docs/content-format.md +450 -49
- package/engine/content-format.mjs +52 -3
- package/engine/content-images.mjs +699 -0
- package/engine/dependency-bump.mjs +218 -0
- package/engine/frontmatter-lint.mjs +89 -2
- package/engine/helpers.mjs +81 -142
- package/engine/index.mjs +15 -0
- package/engine/infobox-registry.mjs +81 -0
- package/engine/infobox-render.mjs +381 -0
- package/engine/infobox.mjs +963 -0
- package/engine/item-registry.mjs +5 -5
- package/engine/journals.mjs +22 -1
- package/engine/map-notes.mjs +11 -5
- package/engine/metadata-index.mjs +5 -0
- package/engine/note-vocabulary.mjs +57 -2
- package/engine/pathnames.mjs +374 -0
- package/engine/pdf-build.mjs +208 -9
- package/engine/pdf-render.mjs +461 -21
- package/engine/pdf-toc.mjs +77 -5
- package/engine/scenes.mjs +2 -1
- package/engine/site-build.mjs +106 -7
- package/engine/site-index.mjs +93 -4
- package/engine/wikilinks.mjs +93 -0
- package/hm3/default-item-art.mjs +14 -15
- package/hm3/index.mjs +3 -0
- package/hm3/infobox.mjs +64 -0
- package/package.json +1 -1
- package/sohl/default-item-art.mjs +18 -16
- package/sohl/index.mjs +3 -0
- package/sohl/infobox.mjs +499 -0
- package/types/content-config.d.mts +7 -0
- package/types/engine/content-format.d.mts +36 -0
- package/types/engine/content-images.d.mts +281 -0
- package/types/engine/dependency-bump.d.mts +89 -0
- package/types/engine/frontmatter-lint.d.mts +23 -0
- package/types/engine/helpers.d.mts +30 -72
- package/types/engine/index.d.mts +5 -0
- package/types/engine/infobox-registry.d.mts +36 -0
- package/types/engine/infobox-render.d.mts +87 -0
- package/types/engine/infobox.d.mts +443 -0
- package/types/engine/item-registry.d.mts +5 -5
- package/types/engine/journals.d.mts +9 -1
- package/types/engine/note-vocabulary.d.mts +51 -0
- package/types/engine/pathnames.d.mts +189 -0
- package/types/engine/pdf-build.d.mts +46 -0
- package/types/engine/pdf-render.d.mts +99 -1
- package/types/engine/pdf-toc.d.mts +10 -5
- package/types/engine/site-build.d.mts +11 -3
- package/types/engine/site-index.d.mts +35 -3
- package/types/engine/wikilinks.d.mts +22 -0
- package/types/hm3/default-item-art.d.mts +5 -6
- package/types/hm3/index.d.mts +1 -0
- package/types/hm3/infobox.d.mts +22 -0
- package/types/sohl/index.d.mts +1 -0
- package/types/sohl/infobox.d.mts +145 -0
package/engine/item-registry.mjs
CHANGED
|
@@ -170,11 +170,11 @@ export function itemFields(type, system) {
|
|
|
170
170
|
* `defaultItemArt` was written for. Only the error's *owner* changed: it now
|
|
171
171
|
* names the registry the consumer declares and can add to.
|
|
172
172
|
*
|
|
173
|
-
* **Resolved by the same rule a note's `img:` is.** The
|
|
174
|
-
* {@link resolveImg}, so `icons/relic.svg` means the consumer's own
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* One spelling, one meaning, wherever it is written.
|
|
173
|
+
* **Resolved by the same rule a note's `img:` is.** The pathname goes through
|
|
174
|
+
* {@link resolveImg}, so `icons/relic.svg` means the consumer's own package in
|
|
175
|
+
* the registry exactly as it does on a note, and `sohl/assets/…` — as every
|
|
176
|
+
* SoHL default is written — names the `sohl` package's file wherever it is
|
|
177
|
+
* compiled. One spelling, one meaning, wherever it is written.
|
|
178
178
|
*
|
|
179
179
|
* @param {string} type - the item type.
|
|
180
180
|
* @param {string} [system] - The system compiling it, where a build declares
|
package/engine/journals.mjs
CHANGED
|
@@ -50,7 +50,9 @@ import log from "loglevel";
|
|
|
50
50
|
|
|
51
51
|
import { sohlField, makeId, resolveName, defaultStats, md, folderField } from "./helpers.mjs";
|
|
52
52
|
import { BasePackCompiler } from "./base-compiler.mjs";
|
|
53
|
-
import { anchorPageId } from "./wikilinks.mjs";
|
|
53
|
+
import { anchorPageId, resolveReference } from "./wikilinks.mjs";
|
|
54
|
+
import { infoboxesToHtml, linkToUuid } from "./infobox-render.mjs";
|
|
55
|
+
import { noteInfoboxes } from "./infobox-registry.mjs";
|
|
54
56
|
import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
|
|
55
57
|
import { JOURNAL_TYPES } from "./ids.mjs";
|
|
56
58
|
|
|
@@ -277,6 +279,13 @@ export function buildPages(rawPages, entryId, noteName) {
|
|
|
277
279
|
* entry: a module may ship the same content for two systems, and each pack's
|
|
278
280
|
* documents record the system version they were built against. A
|
|
279
281
|
* caller with no pack in hand gets the package-wide block.
|
|
282
|
+
* @param {string} [params.infobox] - The note's infobox, already rendered to
|
|
283
|
+
* HTML. Prepended to the entry's first page, which is where the format puts
|
|
284
|
+
* it: the box is generated content in document order, before the prose, and
|
|
285
|
+
* a Foundry page is narrow enough that inlining it is the only arrangement
|
|
286
|
+
* that reads. It is **not** a page of its own — a page is what a UUID
|
|
287
|
+
* addresses, and a summary a reader has to navigate to is a summary they do
|
|
288
|
+
* not see.
|
|
280
289
|
* @returns {object} The JournalEntry document, keyed for the pack.
|
|
281
290
|
*/
|
|
282
291
|
export function buildJournalEntry({
|
|
@@ -287,9 +296,13 @@ export function buildJournalEntry({
|
|
|
287
296
|
folder = null,
|
|
288
297
|
flags,
|
|
289
298
|
stats = defaultStats(),
|
|
299
|
+
infobox = "",
|
|
290
300
|
}) {
|
|
291
301
|
const rawPages = splitPages(markdown, leadName);
|
|
292
302
|
const pages = buildPages(rawPages, id, name);
|
|
303
|
+
if (infobox.trim() && pages.length) {
|
|
304
|
+
pages[0].text.content = `${infobox}\n${pages[0].text.content}`;
|
|
305
|
+
}
|
|
293
306
|
return {
|
|
294
307
|
name,
|
|
295
308
|
pages,
|
|
@@ -422,10 +435,18 @@ export class Journals extends BasePackCompiler {
|
|
|
422
435
|
const { value: authoredFolder } = folderField(fm);
|
|
423
436
|
const folder = this.folderResolver(authoredFolder, { isAddress: true });
|
|
424
437
|
|
|
438
|
+
// What the note summarises, in the panel every medium draws from one
|
|
439
|
+
// definition. Links are compendium references rather than website
|
|
440
|
+
// URLs: a player reading this at the table stays in Foundry.
|
|
441
|
+
const boxes = noteInfoboxes(fm, {
|
|
442
|
+
resolve: (ref, hint) => resolveReference(this.linkIndex, ref, hint),
|
|
443
|
+
});
|
|
444
|
+
|
|
425
445
|
return buildJournalEntry({
|
|
426
446
|
id,
|
|
427
447
|
name,
|
|
428
448
|
markdown,
|
|
449
|
+
infobox: infoboxesToHtml(boxes, { link: linkToUuid }),
|
|
429
450
|
// A doc-carrying note's lead page is the document itself, not an
|
|
430
451
|
// "Introduction" — see {@link splitPages}.
|
|
431
452
|
leadName: ownsDoc ? name : undefined,
|
package/engine/map-notes.mjs
CHANGED
|
@@ -59,6 +59,7 @@ import { CURATED_REGION_EVENTS, EXCLUDED_REGION_EVENTS } from "./region-events.m
|
|
|
59
59
|
// A map's background art is `img`, as every other note type's art is. `image`,
|
|
60
60
|
// the spelling a map alone once used, is retired and gone.
|
|
61
61
|
import { sohlField } from "./frontmatter.mjs";
|
|
62
|
+
import { resolveImg } from "./helpers.mjs";
|
|
62
63
|
|
|
63
64
|
/* -------------------------------------------------------------------- */
|
|
64
65
|
/* Note types and their canvas profiles */
|
|
@@ -907,7 +908,7 @@ export function buildScene(fm, ctx) {
|
|
|
907
908
|
// Read from the note rather than from its `sohl:` block: art is not
|
|
908
909
|
// system-specific, so `img` is authored at the top level like every other
|
|
909
910
|
// type's, and `sohlField` honours the block for anything already there.
|
|
910
|
-
const img = sohlField(fm, "img");
|
|
911
|
+
const img = resolveImg(sohlField(fm, "img"));
|
|
911
912
|
if (!img) throw new Error("a map note needs an `img`");
|
|
912
913
|
|
|
913
914
|
const warn = (message) => {
|
|
@@ -985,7 +986,7 @@ export function buildScene(fm, ctx) {
|
|
|
985
986
|
* still works.
|
|
986
987
|
* @returns {object} The Level document, keyed for the pack.
|
|
987
988
|
*/
|
|
988
|
-
export function buildLevel(sohl, sceneId, img = sohlField({ sohl }, "img")) {
|
|
989
|
+
export function buildLevel(sohl, sceneId, img = resolveImg(sohlField({ sohl }, "img"))) {
|
|
989
990
|
const level = {
|
|
990
991
|
_id: DEFAULT_LEVEL_ID,
|
|
991
992
|
name: sohl.levelName ?? "Ground",
|
|
@@ -994,7 +995,9 @@ export function buildLevel(sohl, sceneId, img = sohlField({ sohl }, "img")) {
|
|
|
994
995
|
color: sohl.backgroundColor ?? "#999999",
|
|
995
996
|
src: img,
|
|
996
997
|
},
|
|
997
|
-
|
|
998
|
+
// The overlay is a pathname like the background, and resolves by the
|
|
999
|
+
// same rule — a scene draws the two from one authored statement each.
|
|
1000
|
+
foreground: { src: resolveImg(sohl.overlay ?? null) },
|
|
998
1001
|
sort: 0,
|
|
999
1002
|
_key: `!scenes.levels!${sceneId}.${DEFAULT_LEVEL_ID}`,
|
|
1000
1003
|
};
|
|
@@ -1117,7 +1120,9 @@ export function buildTiles(sohl, geom, ctx) {
|
|
|
1117
1120
|
rotation: spec.rotation ?? 0,
|
|
1118
1121
|
alpha: spec.alpha ?? 1,
|
|
1119
1122
|
sort: 0,
|
|
1120
|
-
texture
|
|
1123
|
+
// A tile's texture is a pathname, resolved by the same rule the
|
|
1124
|
+
// scene's own background is.
|
|
1125
|
+
texture: { src: resolveImg(spec.image) },
|
|
1121
1126
|
_key: `!scenes.tiles!${ctx.sceneId}.${id}`,
|
|
1122
1127
|
};
|
|
1123
1128
|
});
|
|
@@ -1146,7 +1151,8 @@ export function buildSounds(sohl, geom, ctx) {
|
|
|
1146
1151
|
x,
|
|
1147
1152
|
y,
|
|
1148
1153
|
radius: spec.radius ?? 0,
|
|
1149
|
-
|
|
1154
|
+
// An ambient sound is a file a package ships, like the pictures.
|
|
1155
|
+
path: resolveImg(spec.path),
|
|
1150
1156
|
repeat: spec.repeat ?? true,
|
|
1151
1157
|
volume: spec.volume ?? 0.5,
|
|
1152
1158
|
walls: spec.walls ?? true,
|
|
@@ -362,6 +362,11 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
|
|
|
362
362
|
doc: record.documentation ?? undefined,
|
|
363
363
|
anchors: foundry?.anchors,
|
|
364
364
|
type: parts.type,
|
|
365
|
+
// What the note *is*, not only where it lives. A consumer
|
|
366
|
+
// grouping a reference by the family its target declares —
|
|
367
|
+
// an infobox sorting a being's skills — has no other way to
|
|
368
|
+
// learn it about a note another package publishes.
|
|
369
|
+
subType: record.subType ?? undefined,
|
|
365
370
|
package: pkg,
|
|
366
371
|
});
|
|
367
372
|
}
|
|
@@ -262,6 +262,21 @@ export const DRAFT_TAG = "draft";
|
|
|
262
262
|
* Kind and character are separate groups because one slot could not hold both: a
|
|
263
263
|
* fishing village is a `village` that is `fishing`, and the single-valued field
|
|
264
264
|
* this replaced had to spell it `Fishing Village` as a value of its own.
|
|
265
|
+
*
|
|
266
|
+
* **A group carrying `exclusive` is a single-valued slot**, and that is the one
|
|
267
|
+
* closure a tag vocabulary can make. Its tags are not several things the subject
|
|
268
|
+
* may be at once — they are the alternative answers to one question, so a note
|
|
269
|
+
* naming two of them has named none, and both together are refused as an error.
|
|
270
|
+
* The property is opt-in and changes nothing for a group without it: a place is
|
|
271
|
+
* freely a `port` and a `town`, and `draft` is orthogonal to everything. The
|
|
272
|
+
* value is what the slot is called, for the message a reader gets.
|
|
273
|
+
*
|
|
274
|
+
* **Closure stops at the slot, and deliberately.** A tag outside an exclusive
|
|
275
|
+
* group's list does not fill that group's slot and is not refused for failing
|
|
276
|
+
* to — `tags:` is open and a being tagged `undead` is describing the subject in
|
|
277
|
+
* the author's own words. What is refused is a near miss of a declared value,
|
|
278
|
+
* and two values of one slot; there is no third refusal to make without taking
|
|
279
|
+
* back the openness of the region these tags sit in.
|
|
265
280
|
*/
|
|
266
281
|
export const DECLARED_TAGS = Object.freeze({
|
|
267
282
|
/** What a place *is*. */
|
|
@@ -343,6 +358,15 @@ export const DECLARED_TAGS = Object.freeze({
|
|
|
343
358
|
"unguilded",
|
|
344
359
|
]),
|
|
345
360
|
}),
|
|
361
|
+
/**
|
|
362
|
+
* What kind of being this is — a person, or one of the beasts and made
|
|
363
|
+
* things. A being is one or the other, so the group is a slot.
|
|
364
|
+
*/
|
|
365
|
+
beingKind: Object.freeze({
|
|
366
|
+
types: ["being"],
|
|
367
|
+
exclusive: "kind",
|
|
368
|
+
tags: Object.freeze(["character", "creature"]),
|
|
369
|
+
}),
|
|
346
370
|
/** A note's working state, which any note may carry. */
|
|
347
371
|
state: Object.freeze({ types: null, tags: Object.freeze([DRAFT_TAG]) }),
|
|
348
372
|
});
|
|
@@ -355,8 +379,39 @@ export const DECLARED_TAGS = Object.freeze({
|
|
|
355
379
|
* @returns {readonly string[]} The tags, in declaration order.
|
|
356
380
|
*/
|
|
357
381
|
export function declaredTags(type, groups = DECLARED_TAGS) {
|
|
358
|
-
|
|
359
|
-
|
|
382
|
+
return Object.freeze(applicableTagGroups(type, groups).flatMap((g) => g.tags));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The declared groups scoped to this type, in declaration order.
|
|
387
|
+
*
|
|
388
|
+
* The one reading of `types` that the flattened list and the slot check share,
|
|
389
|
+
* so the two can never disagree about which groups a `being` is held to.
|
|
390
|
+
*
|
|
391
|
+
* @param {string} type - The note's type.
|
|
392
|
+
* @param {object} [groups] - The grouped declaration.
|
|
393
|
+
* @returns {object[]} The groups that apply.
|
|
394
|
+
*/
|
|
395
|
+
export function applicableTagGroups(type, groups = DECLARED_TAGS) {
|
|
396
|
+
return Object.values(groups).filter((g) => !g.types || g.types.includes(type));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The single-valued slots a note of this type has, in declaration order.
|
|
401
|
+
*
|
|
402
|
+
* A group carrying `exclusive` states alternatives rather than attributes, so a
|
|
403
|
+
* note carrying two of its tags has answered one question twice. Only such a
|
|
404
|
+
* group is returned: the check has nothing to say about a group whose tags
|
|
405
|
+
* genuinely accumulate.
|
|
406
|
+
*
|
|
407
|
+
* @param {string} type - The note's type.
|
|
408
|
+
* @param {object} [groups] - The grouped declaration.
|
|
409
|
+
* @returns {{slot: string, tags: readonly string[]}[]} The slots and their values.
|
|
410
|
+
*/
|
|
411
|
+
export function exclusiveTagGroups(type, groups = DECLARED_TAGS) {
|
|
412
|
+
return applicableTagGroups(type, groups)
|
|
413
|
+
.filter((g) => g.exclusive)
|
|
414
|
+
.map((g) => ({ slot: g.exclusive, tags: g.tags }));
|
|
360
415
|
}
|
|
361
416
|
|
|
362
417
|
/**
|
|
@@ -0,0 +1,374 @@
|
|
|
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
|
+
* One authored pathname, and the four addresses it resolves to.
|
|
16
|
+
*
|
|
17
|
+
* A note names a file once — in `img:`, in `data.portrait:`, in the body of a
|
|
18
|
+
* markdown image — and four surfaces have to serve it: a Foundry install, this
|
|
19
|
+
* repository's own working tree, the website, and the book. Each addresses the
|
|
20
|
+
* same file differently, so the authored pathname is a *statement of
|
|
21
|
+
* ownership* and every surface derives its own address from it. One statement,
|
|
22
|
+
* four derivations, one rule.
|
|
23
|
+
*
|
|
24
|
+
* ## The rule
|
|
25
|
+
*
|
|
26
|
+
* **The first segment says which package owns the file, when it is followed by
|
|
27
|
+
* `assets/`.** Everything after `assets/` is the *suffix* — the path inside
|
|
28
|
+
* that package's shipped tree, and the one piece every form is built from.
|
|
29
|
+
*
|
|
30
|
+
* | Authored | Owner | Suffix |
|
|
31
|
+
* | --------------------------- | ---------------- | ------------------- |
|
|
32
|
+
* | `sohl/assets/icons/a.svg` | the `sohl` package | `icons/a.svg` |
|
|
33
|
+
* | `images/beings/b.webp` | **this** package | `images/beings/b.webp` |
|
|
34
|
+
*
|
|
35
|
+
* A pathname that does not open with `<package>/assets/` belongs to the package
|
|
36
|
+
* being built, and the whole of it is the suffix. That is the ordinary case and
|
|
37
|
+
* the one nearly every note writes.
|
|
38
|
+
*
|
|
39
|
+
* The four forms, for a `thalorna` note writing `images/map.webp` (`thalorna`
|
|
40
|
+
* ships as the Foundry module `sohl-thalorna`):
|
|
41
|
+
*
|
|
42
|
+
* | Form | Address |
|
|
43
|
+
* | --------- | -------------------------------------------------- |
|
|
44
|
+
* | `foundry` | `modules/sohl-thalorna/assets/images/map.webp` |
|
|
45
|
+
* | `local` | `assets/images/map.webp` |
|
|
46
|
+
* | `web` | `https://cdn.heroiclands.org/thalorna/images/map.webp` |
|
|
47
|
+
* | `pdf` | `assets/images/map.webp` |
|
|
48
|
+
*
|
|
49
|
+
* **`<package>` and `<foundry-id>` are two different names.** The package is
|
|
50
|
+
* `thalorna` — what the content is called, what the website serves it under,
|
|
51
|
+
* and what a note writes. The Foundry id is `sohl-thalorna` — what Foundry
|
|
52
|
+
* installs the module as, and the only place that name appears. They coincide
|
|
53
|
+
* for `sohl` and `hm3`, which is exactly why the two are kept apart here rather
|
|
54
|
+
* than treated as one value.
|
|
55
|
+
*
|
|
56
|
+
* `local` and `pdf` read the same and mean different places: `local` is the file
|
|
57
|
+
* in the owning repository's working tree, `pdf` is where the book stages a copy
|
|
58
|
+
* beside its Typst source. They are derived separately because only one of them
|
|
59
|
+
* is a file a build may open — see {@link PathnameForms.own}.
|
|
60
|
+
*
|
|
61
|
+
* ## What is not a package pathname
|
|
62
|
+
*
|
|
63
|
+
* **An off-install address passes through on every surface**: a URL, a
|
|
64
|
+
* protocol-relative `//host/…`, or a `/`-rooted path, which Foundry serves from
|
|
65
|
+
* the data root and which names no package at all. That is how a note addresses
|
|
66
|
+
* core Foundry art (`/icons/svg/mystery-man.svg`) or a package this build knows
|
|
67
|
+
* nothing about (`/systems/dnd5e/icons/spell.webp`).
|
|
68
|
+
*
|
|
69
|
+
* **A package this build has never heard of keeps its ownership.** The website
|
|
70
|
+
* and the book need only the package's name and the suffix, so both resolve;
|
|
71
|
+
* the Foundry address needs the package's kind and its Foundry id, which only a
|
|
72
|
+
* declared relationship carries, so that one form comes back `null` and the
|
|
73
|
+
* caller that needs it refuses. Reading such a pathname as this package's own
|
|
74
|
+
* would file one package's name inside another's tree and say nothing.
|
|
75
|
+
*
|
|
76
|
+
* **A `systems/…` or `modules/…` pathname is refused.** It is a Foundry address
|
|
77
|
+
* written where an ownership statement belongs: it resolves for Foundry and for
|
|
78
|
+
* nothing else, because neither the website nor the book has any such directory.
|
|
79
|
+
* {@link pathnameProblem} names the replacement, and every surface refuses the
|
|
80
|
+
* value rather than deriving an address from it — a wrong address that resolves
|
|
81
|
+
* to a 404 is the failure this module exists to remove, and inventing one here
|
|
82
|
+
* would reintroduce it one directory along.
|
|
83
|
+
*
|
|
84
|
+
* ## The two empties
|
|
85
|
+
*
|
|
86
|
+
* `null` — or an absent key, which arrives as `undefined` — means **unset**: the
|
|
87
|
+
* note names no file and the caller's default applies. `""` means **blank on
|
|
88
|
+
* purpose**: the note names no file and wants none, so no default may replace
|
|
89
|
+
* it. `resolvePathname` returns `null` for the first and a form object whose
|
|
90
|
+
* every address is `""` for the second, so the two stay distinguishable all the
|
|
91
|
+
* way to the caller.
|
|
92
|
+
*
|
|
93
|
+
* @module
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The directory a package ships its files in, and the segment that marks a
|
|
98
|
+
* pathname's first segment as a package name.
|
|
99
|
+
*
|
|
100
|
+
* One constant rather than a literal in six places: it is the second segment of
|
|
101
|
+
* an authored package pathname, the last segment of a Foundry asset root, the
|
|
102
|
+
* whole of the `local` form's prefix, and the directory the book stages into.
|
|
103
|
+
*
|
|
104
|
+
* @type {string}
|
|
105
|
+
*/
|
|
106
|
+
export const ASSETS_SEGMENT = "assets";
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The Foundry roots that mark a pathname as written in Foundry's own spelling.
|
|
110
|
+
*
|
|
111
|
+
* `worlds/` is left out for the reason it was always left out: a package may not
|
|
112
|
+
* ship files out of a world, so a note writing one has made a different mistake
|
|
113
|
+
* and gets the ordinary "this package owns it" reading, which yields a plainly
|
|
114
|
+
* broken path rather than a plausible one.
|
|
115
|
+
*
|
|
116
|
+
* @type {readonly string[]}
|
|
117
|
+
*/
|
|
118
|
+
const FOUNDRY_ROOTS = Object.freeze(["systems", "modules"]);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The relationship kinds whose packages a note may address.
|
|
122
|
+
*
|
|
123
|
+
* `conflicts` is absent: a package this one cannot run beside is not one whose
|
|
124
|
+
* artwork it cites.
|
|
125
|
+
*
|
|
126
|
+
* @type {readonly string[]}
|
|
127
|
+
*/
|
|
128
|
+
const ADDRESSABLE_RELATIONSHIPS = Object.freeze(["systems", "requires", "recommends"]);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The surfaces one authored pathname resolves for.
|
|
132
|
+
*
|
|
133
|
+
* Exported so a test can assert that every form a resolution carries is one of
|
|
134
|
+
* these, and that none is missing — the guard against a fifth surface being
|
|
135
|
+
* added to one caller and forgotten in the resolver.
|
|
136
|
+
*
|
|
137
|
+
* @type {readonly string[]}
|
|
138
|
+
*/
|
|
139
|
+
export const PATHNAME_SURFACES = Object.freeze(["foundry", "local", "web", "pdf"]);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* One authored pathname, resolved.
|
|
143
|
+
*
|
|
144
|
+
* @typedef {object} PathnameForms
|
|
145
|
+
* @property {string} authored The pathname exactly as the note wrote it.
|
|
146
|
+
* @property {"blank"|"external"|"package"} state `blank` for `""`, `external`
|
|
147
|
+
* for an address no package owns, `package` for an owned file.
|
|
148
|
+
* @property {string|null} package The content package that owns the file, or
|
|
149
|
+
* `null` when no package does.
|
|
150
|
+
* @property {string|null} suffix The path inside that package's shipped tree.
|
|
151
|
+
* @property {boolean} own Whether the owner is the package being built, and so
|
|
152
|
+
* whether `local` names a file this build may open.
|
|
153
|
+
* @property {string|null} foundry The address inside a Foundry install.
|
|
154
|
+
* @property {string|null} local The file in the owning repository's tree.
|
|
155
|
+
* @property {string|null} web The address the website serves.
|
|
156
|
+
* @property {string|null} pdf Where the book stages its copy.
|
|
157
|
+
*/
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether a pathname addresses something outside every package.
|
|
161
|
+
*
|
|
162
|
+
* Three shapes, each a different kind of "no package owns this": a URI scheme
|
|
163
|
+
* (`https:`, `data:`), a protocol-relative `//host/…`, and a `/`-rooted path,
|
|
164
|
+
* which Foundry serves from the data root rather than from any package.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} s - A non-empty authored pathname.
|
|
167
|
+
* @returns {boolean} Whether every surface emits it unchanged.
|
|
168
|
+
*/
|
|
169
|
+
function isExternal(s) {
|
|
170
|
+
// Checked before the single-slash case, which would otherwise claim it.
|
|
171
|
+
if (s.startsWith("//")) return true;
|
|
172
|
+
if (s.startsWith("/")) return true;
|
|
173
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(s);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The pathname a Foundry-spelled one should be written as.
|
|
178
|
+
*
|
|
179
|
+
* Both shapes a tree carries fold into the same answer: a served path that
|
|
180
|
+
* already names an `assets/` directory keeps its suffix, and one that does not —
|
|
181
|
+
* `hm3` serves its pictures from `images/` at its own root — gains the segment,
|
|
182
|
+
* because `assets/` is where a package's files sit in every form this module
|
|
183
|
+
* derives.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} s - A pathname opening with a Foundry root.
|
|
186
|
+
* @returns {string} What to write instead.
|
|
187
|
+
*/
|
|
188
|
+
function convertedSpelling(s) {
|
|
189
|
+
const [, id, ...rest] = s.split("/");
|
|
190
|
+
const tail = rest.join("/");
|
|
191
|
+
if (rest[0] === ASSETS_SEGMENT) return `${id}/${tail}`;
|
|
192
|
+
return `${id}/${ASSETS_SEGMENT}/${tail}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* What is wrong with an authored pathname, or `""` when nothing is.
|
|
197
|
+
*
|
|
198
|
+
* Config-free, and a sentence rather than a code, so the lint that has a line
|
|
199
|
+
* and a column to attach it to and the resolver that has only a file say the
|
|
200
|
+
* same thing about the same value.
|
|
201
|
+
*
|
|
202
|
+
* @param {string|null|undefined} raw - The pathname, as authored.
|
|
203
|
+
* @returns {string} The problem, as a finding's sentence, or `""`.
|
|
204
|
+
*/
|
|
205
|
+
export function pathnameProblem(raw) {
|
|
206
|
+
if (raw == null) return "";
|
|
207
|
+
const s = String(raw);
|
|
208
|
+
if (!s || isExternal(s)) return "";
|
|
209
|
+
const root = s.split("/")[0];
|
|
210
|
+
if (!FOUNDRY_ROOTS.includes(root)) return "";
|
|
211
|
+
return (
|
|
212
|
+
`\`${s}\` is a Foundry address — write \`${convertedSpelling(s)}\`. A pathname ` +
|
|
213
|
+
"names the package that owns the file and the path inside that package's " +
|
|
214
|
+
"`assets/`, and each surface derives its own address from it: Foundry gets " +
|
|
215
|
+
"the path inside the install, the website gets one on the asset host, and " +
|
|
216
|
+
"the book gets a staged copy. A `" +
|
|
217
|
+
`${root}/` +
|
|
218
|
+
"` path is only one of those three, so the other two serve a file that " +
|
|
219
|
+
"is not there"
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Every game system this build compiles content for.
|
|
225
|
+
*
|
|
226
|
+
* Four declarations say so, and a package makes whichever of them its situation
|
|
227
|
+
* calls for: `systems:` states a system it stamps content against, `packs[].system`
|
|
228
|
+
* gates a pack on one, `requiresSystem` restricts the package to one, and
|
|
229
|
+
* `relationships.systems` names one in the shipped manifest. `harn-ensemble`
|
|
230
|
+
* ships an HM3 pack and a SoHL pack and declares neither relationship, because
|
|
231
|
+
* naming a system in a relationship is what would stop Foundry loading the
|
|
232
|
+
* module in the other one's world — so reading any single declaration would
|
|
233
|
+
* miss the package whose content cites both systems' artwork.
|
|
234
|
+
*
|
|
235
|
+
* @param {object} config - The resolved build configuration.
|
|
236
|
+
* @returns {string[]} The system ids, deduplicated.
|
|
237
|
+
*/
|
|
238
|
+
function declaredSystemIds(config) {
|
|
239
|
+
return [
|
|
240
|
+
...new Set(
|
|
241
|
+
[
|
|
242
|
+
...Object.keys(config.systems ?? {}),
|
|
243
|
+
config.requiresSystem,
|
|
244
|
+
...(config.packs ?? []).map((pack) => pack.system),
|
|
245
|
+
...(config.relationships?.systems ?? []).map((rel) => rel.id),
|
|
246
|
+
].filter(Boolean),
|
|
247
|
+
),
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Every content package this build can resolve a pathname against.
|
|
253
|
+
*
|
|
254
|
+
* The package being built; every game system it compiles content for, which
|
|
255
|
+
* Foundry serves from `systems/<id>`; and every other package it declares a
|
|
256
|
+
* relationship with, which states that package's Foundry id and — where the two
|
|
257
|
+
* words differ — what its content is called.
|
|
258
|
+
*
|
|
259
|
+
* @param {object} config - The resolved build configuration.
|
|
260
|
+
* @returns {Map<string, {root: string|null, id: string|null, own: boolean}>} The
|
|
261
|
+
* packages, by content package name. `root` is the Foundry directory the
|
|
262
|
+
* package is served from, `null` where the package ships no Foundry package.
|
|
263
|
+
*/
|
|
264
|
+
export function packageAddresses(config) {
|
|
265
|
+
/** @type {Map<string, {root: string|null, id: string|null, own: boolean}>} */
|
|
266
|
+
const out = new Map();
|
|
267
|
+
if (config.contentPackage) {
|
|
268
|
+
out.set(config.contentPackage, {
|
|
269
|
+
// `assetRoot` is `<root>/<id>/assets`, and `null` for a
|
|
270
|
+
// `documentation` package — which is the same "Foundry serves no
|
|
271
|
+
// files for this" the `root` below says.
|
|
272
|
+
root: config.assetRoot ? config.packageKind : null,
|
|
273
|
+
id: config.foundryPackage ?? null,
|
|
274
|
+
own: true,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
// The package being built wins every collision: it is the one whose files
|
|
278
|
+
// this repository actually holds, and another declaration of the same name
|
|
279
|
+
// describes that very package from outside.
|
|
280
|
+
for (const id of declaredSystemIds(config)) {
|
|
281
|
+
if (out.has(id)) continue;
|
|
282
|
+
out.set(id, { root: "systems", id, own: false });
|
|
283
|
+
}
|
|
284
|
+
for (const kind of ADDRESSABLE_RELATIONSHIPS) {
|
|
285
|
+
for (const rel of config.relationships?.[kind] ?? []) {
|
|
286
|
+
const name = rel.contentPackage ?? rel.id;
|
|
287
|
+
if (out.has(name)) continue;
|
|
288
|
+
const type = rel.type ?? (kind === "systems" ? "system" : "module");
|
|
289
|
+
out.set(name, { root: `${type}s`, id: rel.id, own: false });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Resolve one authored pathname into the address each surface serves.
|
|
297
|
+
*
|
|
298
|
+
* @param {string|null|undefined} raw - The pathname, as authored.
|
|
299
|
+
* @param {object} config - The resolved build configuration. Required rather
|
|
300
|
+
* than defaulted, which is what keeps this module a leaf: it reads a
|
|
301
|
+
* configuration and never loads one, so the lint can import it without a
|
|
302
|
+
* repository to resolve.
|
|
303
|
+
* @returns {PathnameForms|null} The four forms, or `null` when the note names
|
|
304
|
+
* no file at all.
|
|
305
|
+
* @throws {Error} When the pathname is written in Foundry's own spelling, which
|
|
306
|
+
* resolves on one surface and nowhere else.
|
|
307
|
+
*/
|
|
308
|
+
export function resolvePathname(raw, config) {
|
|
309
|
+
if (raw == null) return null;
|
|
310
|
+
const authored = String(raw);
|
|
311
|
+
if (authored === "") {
|
|
312
|
+
return {
|
|
313
|
+
authored,
|
|
314
|
+
state: "blank",
|
|
315
|
+
package: null,
|
|
316
|
+
suffix: null,
|
|
317
|
+
own: false,
|
|
318
|
+
foundry: "",
|
|
319
|
+
local: "",
|
|
320
|
+
web: "",
|
|
321
|
+
pdf: "",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (isExternal(authored)) {
|
|
325
|
+
return {
|
|
326
|
+
authored,
|
|
327
|
+
state: "external",
|
|
328
|
+
package: null,
|
|
329
|
+
suffix: null,
|
|
330
|
+
own: false,
|
|
331
|
+
foundry: authored,
|
|
332
|
+
local: authored,
|
|
333
|
+
web: authored,
|
|
334
|
+
pdf: authored,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const problem = pathnameProblem(authored);
|
|
338
|
+
if (problem) throw new Error(`package-build: ${problem}.`);
|
|
339
|
+
|
|
340
|
+
const packages = packageAddresses(config);
|
|
341
|
+
const segments = authored.split("/");
|
|
342
|
+
// The first segment names a package when an `assets/` follows it. That is
|
|
343
|
+
// the whole test, and it is deliberately not "when the name is one this
|
|
344
|
+
// build knows": a pathname whose owner this build has never heard of still
|
|
345
|
+
// has a package, a suffix, and a correct address on the website, and
|
|
346
|
+
// reading it as this package's own would put one package's name inside
|
|
347
|
+
// another's tree and report nothing.
|
|
348
|
+
const named = segments.length > 2 && segments[1] === ASSETS_SEGMENT ? segments[0] : "";
|
|
349
|
+
const owner = named || (config.contentPackage ?? null);
|
|
350
|
+
const suffix = named ? segments.slice(2).join("/") : authored;
|
|
351
|
+
const entry = owner ? packages.get(owner) : undefined;
|
|
352
|
+
const host = String(config.site?.assets ?? "").replace(/\/+$/, "");
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
authored,
|
|
356
|
+
state: "package",
|
|
357
|
+
package: owner,
|
|
358
|
+
suffix,
|
|
359
|
+
own: entry?.own === true,
|
|
360
|
+
// `null` where the owning package ships no Foundry package, and where
|
|
361
|
+
// the first segment names a package this build has never heard of:
|
|
362
|
+
// both are "there is no install path to derive", and guessing one
|
|
363
|
+
// writes an address into a document nobody would check.
|
|
364
|
+
foundry:
|
|
365
|
+
entry?.root && entry.id ?
|
|
366
|
+
`${entry.root}/${entry.id}/${ASSETS_SEGMENT}/${suffix}`
|
|
367
|
+
: null,
|
|
368
|
+
local: `${ASSETS_SEGMENT}/${suffix}`,
|
|
369
|
+
// `null` where no asset host is configured. The website is the one
|
|
370
|
+
// surface whose address is not derivable from the repository itself.
|
|
371
|
+
web: host && owner ? `${host}/${owner}/${suffix}` : null,
|
|
372
|
+
pdf: `${ASSETS_SEGMENT}/${suffix}`,
|
|
373
|
+
};
|
|
374
|
+
}
|