@wizzlethorpe/vaults 0.13.2 → 0.14.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/dist/asset-refs.js +274 -0
- package/dist/asset-refs.js.map +1 -0
- package/dist/auth.js.map +1 -1
- package/dist/build.js +252 -492
- package/dist/build.js.map +1 -1
- package/dist/commands/build.js +54 -5
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/preview.js +0 -4
- package/dist/commands/preview.js.map +1 -1
- package/dist/commands/push.js +4 -9
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/role.js +55 -14
- package/dist/commands/role.js.map +1 -1
- package/dist/config.js +25 -5
- package/dist/config.js.map +1 -1
- package/dist/foundry-importer.bundle.js +1228 -327
- package/dist/foundry-importer.js +2 -7
- package/dist/foundry-importer.js.map +1 -1
- package/dist/foundry-meta.js +284 -0
- package/dist/foundry-meta.js.map +1 -0
- package/dist/foundry-module-journal.js +112 -0
- package/dist/foundry-module-journal.js.map +1 -0
- package/dist/foundry-module-render.js +75 -0
- package/dist/foundry-module-render.js.map +1 -0
- package/dist/foundry-module.js +1075 -0
- package/dist/foundry-module.js.map +1 -0
- package/dist/frontmatter-defaults.js +68 -0
- package/dist/frontmatter-defaults.js.map +1 -0
- package/dist/index.js +9 -9
- package/dist/index.js.map +1 -1
- package/dist/manifest.js +115 -0
- package/dist/manifest.js.map +1 -0
- package/dist/render/auth-template.js +323 -35
- package/dist/render/auth-template.js.map +1 -1
- package/dist/render/bases.js +22 -38
- package/dist/render/bases.js.map +1 -1
- package/dist/render/cover.js +23 -1
- package/dist/render/cover.js.map +1 -1
- package/dist/render/handlers/builtin/download.js +90 -0
- package/dist/render/handlers/builtin/download.js.map +1 -0
- package/dist/render/handlers/builtin/foundry-manifest.js +158 -0
- package/dist/render/handlers/builtin/foundry-manifest.js.map +1 -0
- package/dist/render/handlers/builtin/index.js +3 -1
- package/dist/render/handlers/builtin/index.js.map +1 -1
- package/dist/render/pipeline.js +4 -1
- package/dist/render/pipeline.js.map +1 -1
- package/dist/render/slug.js +0 -5
- package/dist/render/slug.js.map +1 -1
- package/dist/scan.js +8 -0
- package/dist/scan.js.map +1 -1
- package/dist/settings.js +112 -6
- package/dist/settings.js.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
// Compile a vault into an installable Foundry VTT module.
|
|
2
|
+
//
|
|
3
|
+
// The Foundry *sync* module resolves a page's `foundry.base` against whatever
|
|
4
|
+
// the reader has installed, at sync time, in their world. A standalone module
|
|
5
|
+
// has no world to look in and no reader to ask, so it can only contain what
|
|
6
|
+
// this build can construct on its own: a blank document of a known type with
|
|
7
|
+
// `data_json` and `data` merged onto it.
|
|
8
|
+
//
|
|
9
|
+
// That is not a limitation to work around. Baking a cloned compendium document
|
|
10
|
+
// into a redistributable module is a licensing act — plainly so for a paid
|
|
11
|
+
// module's content, and not obviously fine even for the SRD — and the same is
|
|
12
|
+
// true of anything resolved out of a reader's Moulinette library, which is the
|
|
13
|
+
// entire premise of that feature. So the rule is: build what the vault owns,
|
|
14
|
+
// and say clearly what was left out.
|
|
15
|
+
//
|
|
16
|
+
// A `foundry.base` priority list already encodes the answer. `[Compendium.x,
|
|
17
|
+
// Scene]` means "use x if you have it, otherwise a blank Scene", and a
|
|
18
|
+
// standalone module *is* the otherwise case — so each list resolves to its
|
|
19
|
+
// last self-contained rung.
|
|
20
|
+
//
|
|
21
|
+
// Requires @foundryvtt/foundryvtt-cli, which is resolved lazily and is not a
|
|
22
|
+
// dependency of this package: it pulls classic-level, whose native build every
|
|
23
|
+
// user who never compiles a module would otherwise pay for.
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { execFile } from "node:child_process";
|
|
26
|
+
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
27
|
+
import { createRequire } from "node:module";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
import { dirname, join, resolve } from "node:path";
|
|
30
|
+
import { promisify } from "node:util";
|
|
31
|
+
import matter from "gray-matter";
|
|
32
|
+
import { canonicalFoundryType, loadDataJson } from "./foundry-meta.js";
|
|
33
|
+
import { loadConfig } from "./config.js";
|
|
34
|
+
import { applyFrontmatterDefaults, compileFrontmatterRules } from "./frontmatter-defaults.js";
|
|
35
|
+
import { buildFolders, renderBody } from "./foundry-module-render.js";
|
|
36
|
+
import { buildJournalEntries, journalEntryId, journalPageId, transformForModule, } from "./foundry-module-journal.js";
|
|
37
|
+
import { loadSettings } from "./settings.js";
|
|
38
|
+
import { scanVault } from "./scan.js";
|
|
39
|
+
const execFileAsync = promisify(execFile);
|
|
40
|
+
/**
|
|
41
|
+
* Embedded collections that a LevelDB pack stores as their own keyed entries
|
|
42
|
+
* rather than inline in the parent. Each member needs an `_id` and a `_key` of
|
|
43
|
+
* the form `!<parent>.<collection>!<parentId>.<childId>`, and the Foundry CLI
|
|
44
|
+
* fails with "Key cannot be null or undefined" when one is missing — which
|
|
45
|
+
* names neither the document nor the field, so it is worth being explicit here.
|
|
46
|
+
*/
|
|
47
|
+
const EMBEDDED_FIELDS = new Set([
|
|
48
|
+
"items", "effects", "results", "pages", "sounds", "cards",
|
|
49
|
+
"drawings", "lights", "notes", "templates", "tiles", "tokens", "walls", "regions",
|
|
50
|
+
]);
|
|
51
|
+
/**
|
|
52
|
+
* Where a rendered page body lands, per document type.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately not the same table as the sync path's DESCRIPTION_FIELDS, which
|
|
55
|
+
* is keyed by game system and covers only Actor and Item on dnd5e. The two
|
|
56
|
+
* answer different questions: sync embeds a live JournalEntryPage and can
|
|
57
|
+
* check `game.system.id` because it is running inside the world, while a
|
|
58
|
+
* module inlines rendered HTML at build time, where no system is running and
|
|
59
|
+
* a RollTable's description is a plain field every system shares.
|
|
60
|
+
*
|
|
61
|
+
* The consequence worth knowing: a synced RollTable has an empty description
|
|
62
|
+
* and a module RollTable carries the page's prose. That is the sync path being
|
|
63
|
+
* conservative rather than this one overreaching, but they do differ.
|
|
64
|
+
*/
|
|
65
|
+
const DESCRIPTION_PATH = {
|
|
66
|
+
Item: "system.description.value",
|
|
67
|
+
Actor: "system.details.biography.value",
|
|
68
|
+
RollTable: "description",
|
|
69
|
+
JournalEntry: "",
|
|
70
|
+
Scene: "",
|
|
71
|
+
Macro: "",
|
|
72
|
+
Cards: "description",
|
|
73
|
+
Playlist: "description",
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* What Foundry stamps on a document, derived from the manifest rather than
|
|
77
|
+
* hardcoded: the core version it was verified against, and the system it
|
|
78
|
+
* requires. It is provenance metadata — Foundry shows it, nothing depends on
|
|
79
|
+
* it — so guessing would be worse than reading what the author already
|
|
80
|
+
* declared two fields away.
|
|
81
|
+
*/
|
|
82
|
+
/**
|
|
83
|
+
* The system a module's documents belong to.
|
|
84
|
+
*
|
|
85
|
+
* Foundry requires every Actor and Item pack to declare one, and refuses to
|
|
86
|
+
* load the whole manifest if any is missing. Most modules never set a
|
|
87
|
+
* top-level `system` key: they name it in `relationships.requires` with
|
|
88
|
+
* `type: "system"`, which is what the package schema actually documents. Only
|
|
89
|
+
* reading the top-level key meant WANDS shipped ten packs with none, and
|
|
90
|
+
* Foundry rejected the module outright.
|
|
91
|
+
*/
|
|
92
|
+
export function systemIdOf(manifest) {
|
|
93
|
+
const top = manifest["system"];
|
|
94
|
+
if (typeof top === "string" && top)
|
|
95
|
+
return top;
|
|
96
|
+
const rel = (manifest["relationships"] ?? {});
|
|
97
|
+
for (const key of ["systems", "requires"]) {
|
|
98
|
+
const list = Array.isArray(rel[key]) ? rel[key] : [];
|
|
99
|
+
for (const raw of list) {
|
|
100
|
+
if (!raw || typeof raw !== "object")
|
|
101
|
+
continue;
|
|
102
|
+
const entry = raw;
|
|
103
|
+
// `systems` entries are systems by definition; `requires` is mixed.
|
|
104
|
+
if (key === "requires" && entry["type"] !== "system")
|
|
105
|
+
continue;
|
|
106
|
+
if (typeof entry["id"] === "string" && entry["id"])
|
|
107
|
+
return entry["id"];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Put a pack the compiler created into the author's folder tree.
|
|
114
|
+
*
|
|
115
|
+
* `packFolders` is hand-authored and names its packs one by one, so a pack
|
|
116
|
+
* that did not exist when it was written belongs to no folder and lands loose
|
|
117
|
+
* at the top of the compendium sidebar, outside the module's own folder. That
|
|
118
|
+
* happened the first time the journal pack appeared, and would happen again
|
|
119
|
+
* for every document type a vault starts using.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately narrow. It only adds, never moves or removes: a pack the author
|
|
122
|
+
* has filed somewhere is filed where they wanted it, and the tree they built
|
|
123
|
+
* is theirs. Without a `packFolders` there is nothing to be outside of, so
|
|
124
|
+
* nothing to do.
|
|
125
|
+
*/
|
|
126
|
+
export function fileNewPacks(manifest, packNames) {
|
|
127
|
+
const tree = manifest["packFolders"];
|
|
128
|
+
if (!Array.isArray(tree) || tree.length === 0)
|
|
129
|
+
return;
|
|
130
|
+
const filed = new Set();
|
|
131
|
+
const walk = (folders) => {
|
|
132
|
+
for (const raw of folders) {
|
|
133
|
+
if (!raw || typeof raw !== "object")
|
|
134
|
+
continue;
|
|
135
|
+
const folder = raw;
|
|
136
|
+
for (const name of Array.isArray(folder["packs"]) ? folder["packs"] : []) {
|
|
137
|
+
if (typeof name === "string")
|
|
138
|
+
filed.add(name);
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(folder["folders"]))
|
|
141
|
+
walk(folder["folders"]);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
walk(tree);
|
|
145
|
+
const loose = packNames.filter((n) => !filed.has(n));
|
|
146
|
+
if (loose.length === 0)
|
|
147
|
+
return;
|
|
148
|
+
// The first top-level folder, which is the module's own by convention: the
|
|
149
|
+
// tree exists to gather its packs under one heading in the sidebar.
|
|
150
|
+
const root = tree[0];
|
|
151
|
+
const existing = Array.isArray(root["packs"]) ? root["packs"] : [];
|
|
152
|
+
root["packs"] = [...existing, ...loose];
|
|
153
|
+
console.log(` filed ${loose.join(", ")} under "${root["name"]}"`);
|
|
154
|
+
}
|
|
155
|
+
function statsFor(manifest) {
|
|
156
|
+
const compat = (manifest["compatibility"] ?? {});
|
|
157
|
+
const rel = (manifest["relationships"] ?? {});
|
|
158
|
+
const requires = Array.isArray(rel["requires"]) ? rel["requires"] : [];
|
|
159
|
+
const system = requires.find((r) => r && typeof r === "object" && r["type"] === "system");
|
|
160
|
+
const systemCompat = (system?.["compatibility"] ?? {});
|
|
161
|
+
return {
|
|
162
|
+
coreVersion: compat["verified"] ?? compat["minimum"] ?? null,
|
|
163
|
+
systemId: systemIdOf(manifest),
|
|
164
|
+
systemVersion: systemCompat["verified"] ?? systemCompat["minimum"] ?? null,
|
|
165
|
+
createdTime: null, modifiedTime: null, lastModifiedBy: null,
|
|
166
|
+
compendiumSource: null, duplicateSource: null, exportSource: null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function setPath(obj, dotted, value) {
|
|
170
|
+
const segs = dotted.split(".");
|
|
171
|
+
let cur = obj;
|
|
172
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
173
|
+
const seg = segs[i];
|
|
174
|
+
if (cur[seg] == null || typeof cur[seg] !== "object")
|
|
175
|
+
cur[seg] = {};
|
|
176
|
+
cur = cur[seg];
|
|
177
|
+
}
|
|
178
|
+
cur[segs[segs.length - 1]] = value;
|
|
179
|
+
}
|
|
180
|
+
/** What a pack of each type is called in Foundry's compendium list. */
|
|
181
|
+
const PACK_LABEL = {
|
|
182
|
+
Actor: "Actors", Item: "Items", Scene: "Scenes", JournalEntry: "Journals",
|
|
183
|
+
RollTable: "Roll Tables", Macro: "Macros", Cards: "Card Decks", Playlist: "Playlists",
|
|
184
|
+
};
|
|
185
|
+
/** LevelDB collection prefix per document type. */
|
|
186
|
+
// Adventure schema field per document type. `journal`, not `journals`.
|
|
187
|
+
// Mirrors foundry/scripts/target.mjs; the Foundry-side copy is canonical.
|
|
188
|
+
const ADVENTURE_FIELD = {
|
|
189
|
+
Actor: "actors", Item: "items", Scene: "scenes", JournalEntry: "journal",
|
|
190
|
+
RollTable: "tables", Macro: "macros", Cards: "cards", Playlist: "playlists",
|
|
191
|
+
};
|
|
192
|
+
export const PACK_KEY = {
|
|
193
|
+
Actor: "actors", Item: "items", Scene: "scenes", JournalEntry: "journal",
|
|
194
|
+
RollTable: "tables", Macro: "macros", Cards: "cards", Playlist: "playlists",
|
|
195
|
+
};
|
|
196
|
+
/** Parse one `foundry.base` spec. Mirrors the module's own reading of it. */
|
|
197
|
+
function parseBase(spec) {
|
|
198
|
+
if (typeof spec !== "string" || !spec)
|
|
199
|
+
return null;
|
|
200
|
+
if (spec.startsWith("@moulinette/"))
|
|
201
|
+
return null;
|
|
202
|
+
if (spec.includes("."))
|
|
203
|
+
return null; // a UUID: not self-contained
|
|
204
|
+
const [typeRaw, subtype] = spec.split(":");
|
|
205
|
+
// The package's one copy of the type table, not a second: cli/src can import
|
|
206
|
+
// across itself, and this repo has already paid for the version of this
|
|
207
|
+
// question where five copies disagreed.
|
|
208
|
+
const blank = canonicalFoundryType(typeRaw);
|
|
209
|
+
return blank ? { blank, ...(subtype ? { subtype } : {}) } : null;
|
|
210
|
+
}
|
|
211
|
+
/** The last rung of a `foundry.base` a module can build on its own, or null. */
|
|
212
|
+
export function resolveSelfContainedBase(specs) {
|
|
213
|
+
const parsed = specs.map(parseBase).filter((p) => p !== null);
|
|
214
|
+
return parsed.length > 0 ? parsed[parsed.length - 1] : null;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* A stable 16-char Foundry id ([A-Za-z0-9]) for a seed.
|
|
218
|
+
*
|
|
219
|
+
* base64 rather than hex, matching what the standalone compiler used: hex would
|
|
220
|
+
* work, but changing the derivation would renumber every derived id in every
|
|
221
|
+
* already-published pack, which reads as a content change to anyone diffing.
|
|
222
|
+
*/
|
|
223
|
+
function derivedId(seed) {
|
|
224
|
+
return createHash("sha1").update(seed).digest("base64")
|
|
225
|
+
.replace(/[^A-Za-z0-9]/g, "").slice(0, 16).padEnd(16, "0");
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* A page's document id, when it did not pin one.
|
|
229
|
+
*
|
|
230
|
+
* Namespaced by module id rather than by anything about where the vault sits
|
|
231
|
+
* on disk. A filesystem path would make the ids depend on the build machine,
|
|
232
|
+
* so the same vault compiled in CI and locally would produce different `_id`s
|
|
233
|
+
* and every rebuild would read as an unrelated set of documents rather than an
|
|
234
|
+
* update to the existing ones. The journal ids already key off the module for
|
|
235
|
+
* the same reason.
|
|
236
|
+
*/
|
|
237
|
+
function documentId(moduleId, pagePath) {
|
|
238
|
+
return derivedId(`vaults-module:${moduleId}:${pagePath}`);
|
|
239
|
+
}
|
|
240
|
+
/** Recursively rewrite `@vault/PATH` strings to a module-relative asset path. */
|
|
241
|
+
function rewriteAssetPaths(value, moduleId, seen) {
|
|
242
|
+
if (typeof value === "string") {
|
|
243
|
+
if (value.startsWith("@vault/")) {
|
|
244
|
+
const rel = value.slice("@vault/".length);
|
|
245
|
+
seen.add(rel);
|
|
246
|
+
return `modules/${moduleId}/assets/${rel}`;
|
|
247
|
+
}
|
|
248
|
+
return value;
|
|
249
|
+
}
|
|
250
|
+
if (Array.isArray(value))
|
|
251
|
+
return value.map((v) => rewriteAssetPaths(v, moduleId, seen));
|
|
252
|
+
if (value && typeof value === "object") {
|
|
253
|
+
const out = {};
|
|
254
|
+
for (const [k, v] of Object.entries(value))
|
|
255
|
+
out[k] = rewriteAssetPaths(v, moduleId, seen);
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
/** Strip `@moulinette/` strings, dropping whatever contained them. */
|
|
261
|
+
export function stripMoulinette(value, found) {
|
|
262
|
+
if (Array.isArray(value)) {
|
|
263
|
+
const kept = [];
|
|
264
|
+
for (const item of value) {
|
|
265
|
+
if (typeof item === "string" && item.startsWith("@moulinette/")) {
|
|
266
|
+
found.add(item);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const walked = stripMoulinette(item, found);
|
|
270
|
+
if (walked !== undefined)
|
|
271
|
+
kept.push(walked);
|
|
272
|
+
}
|
|
273
|
+
return kept;
|
|
274
|
+
}
|
|
275
|
+
if (value && typeof value === "object") {
|
|
276
|
+
const out = {};
|
|
277
|
+
let viable = true;
|
|
278
|
+
for (const [k, v] of Object.entries(value)) {
|
|
279
|
+
if (typeof v === "string" && v.startsWith("@moulinette/")) {
|
|
280
|
+
found.add(v);
|
|
281
|
+
viable = false;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const walked = stripMoulinette(v, found);
|
|
285
|
+
if (walked === undefined)
|
|
286
|
+
continue;
|
|
287
|
+
out[k] = walked;
|
|
288
|
+
}
|
|
289
|
+
return viable ? out : undefined;
|
|
290
|
+
}
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Stamp `_id` and `_key` on embedded documents, recursing.
|
|
295
|
+
*
|
|
296
|
+
* Keys nest by collection path, so an effect on an item is
|
|
297
|
+
* `!items.effects!<item>.<effect>` and an effect on an item *on an actor* is
|
|
298
|
+
* `!actors.items.effects!<actor>.<item>.<effect>`. The Foundry CLI refuses to
|
|
299
|
+
* invent these and fails the whole pack with "Key cannot be null or
|
|
300
|
+
* undefined", naming neither the document nor the field.
|
|
301
|
+
*/
|
|
302
|
+
export function keyEmbedded(doc, collection, idPath) {
|
|
303
|
+
for (const [field, list] of Object.entries(doc)) {
|
|
304
|
+
if (!Array.isArray(list) || !EMBEDDED_FIELDS.has(field))
|
|
305
|
+
continue;
|
|
306
|
+
list.forEach((entry, i) => {
|
|
307
|
+
if (!entry || typeof entry !== "object")
|
|
308
|
+
return;
|
|
309
|
+
const child = entry;
|
|
310
|
+
if (typeof child["_id"] !== "string" || !child["_id"]) {
|
|
311
|
+
child["_id"] = derivedId(`${idPath}:${field}:${i}`);
|
|
312
|
+
}
|
|
313
|
+
const nextCollection = `${collection}.${field}`;
|
|
314
|
+
const nextPath = `${idPath}.${child["_id"]}`;
|
|
315
|
+
child["_key"] = `!${nextCollection}!${nextPath}`;
|
|
316
|
+
keyEmbedded(child, nextCollection, nextPath);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function deepMerge(target, source) {
|
|
321
|
+
for (const [k, v] of Object.entries(source)) {
|
|
322
|
+
const existing = target[k];
|
|
323
|
+
if (v && typeof v === "object" && !Array.isArray(v)
|
|
324
|
+
&& existing && typeof existing === "object" && !Array.isArray(existing)) {
|
|
325
|
+
deepMerge(existing, v);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
target[k] = v;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return target;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Locate the Foundry CLI without depending on it.
|
|
335
|
+
*
|
|
336
|
+
* It is an optional peer: it pulls classic-level, and making every user of
|
|
337
|
+
* this package pay for a native build so that a minority can compile a module
|
|
338
|
+
* is the wrong trade. Absent, the build says what to install rather than
|
|
339
|
+
* failing with a resolution error nobody can act on.
|
|
340
|
+
*/
|
|
341
|
+
async function findFoundryCli(vaultPath) {
|
|
342
|
+
const roots = [import.meta.url, `file://${join(vaultPath, "x")}`, `file://${join(process.cwd(), "x")}`];
|
|
343
|
+
// A global install is not on any of those resolution paths, and telling
|
|
344
|
+
// someone to install globally and then failing to find it is worse than not
|
|
345
|
+
// suggesting it — so ask npm where its global root is and look there too.
|
|
346
|
+
try {
|
|
347
|
+
const { stdout } = await execFileAsync("npm", ["root", "-g"]);
|
|
348
|
+
roots.push(`file://${join(stdout.trim(), "x")}`);
|
|
349
|
+
}
|
|
350
|
+
catch { /* npm not on PATH; the local roots may still have it */ }
|
|
351
|
+
for (const from of roots) {
|
|
352
|
+
try {
|
|
353
|
+
const pkg = createRequire(from).resolve("@foundryvtt/foundryvtt-cli/package.json");
|
|
354
|
+
return join(dirname(pkg), "fvtt.mjs");
|
|
355
|
+
}
|
|
356
|
+
catch { /* try the next root */ }
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
async function scanPages(vaultPath, defaultRole, rules) {
|
|
361
|
+
const files = await scanVault(vaultPath);
|
|
362
|
+
const pages = [];
|
|
363
|
+
for (const f of files) {
|
|
364
|
+
// index.md is included. The compendium side skipped it, which was right
|
|
365
|
+
// for a folder overview that is not itself an entry — but it has no
|
|
366
|
+
// foundry.base, so the document pass drops it anyway. The journal side
|
|
367
|
+
// needs it: sync makes a page for every .md in a directory, and dropping
|
|
368
|
+
// index pages both loses the article and turns every [[index]] link in
|
|
369
|
+
// the vault into plain text.
|
|
370
|
+
if (!/\.md$/i.test(f.path))
|
|
371
|
+
continue;
|
|
372
|
+
const parsed = matter(await readFile(f.absolute, "utf8"));
|
|
373
|
+
// Same defaults the wiki and the sync manifest see — the compiler must not
|
|
374
|
+
// read a different version of the page than they do.
|
|
375
|
+
const fm = applyFrontmatterDefaults(f.path, parsed.data, rules);
|
|
376
|
+
const fo = fm["foundry"];
|
|
377
|
+
pages.push({
|
|
378
|
+
path: f.path,
|
|
379
|
+
title: typeof fm["title"] === "string" ? fm["title"] : f.path.split("/").pop().replace(/\.md$/i, ""),
|
|
380
|
+
body: parsed.content,
|
|
381
|
+
role: typeof fm["role"] === "string" && fm["role"] ? fm["role"] : defaultRole,
|
|
382
|
+
image: typeof fm["image"] === "string" ? fm["image"] : "",
|
|
383
|
+
foundry: fo && typeof fo === "object" && !Array.isArray(fo) ? fo : null,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
return pages;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Which pack a page belongs in.
|
|
390
|
+
*
|
|
391
|
+
* Declarations win where they exist: a vault that wants Spells and Items as
|
|
392
|
+
* two Item packs has to say so, because grouping by document type cannot know
|
|
393
|
+
* that Foundry treats them as separate compendiums while the schema does not.
|
|
394
|
+
* Without declarations, one pack per document type is a reasonable default and
|
|
395
|
+
* needs no configuration at all — which is what a small vault wants.
|
|
396
|
+
*/
|
|
397
|
+
function assignPack(page, docType, decls, moduleId) {
|
|
398
|
+
for (const decl of decls) {
|
|
399
|
+
const prefix = `${decl.folder}/`;
|
|
400
|
+
if (page.path.startsWith(prefix) || page.path.includes(`/${prefix}`)) {
|
|
401
|
+
return decl.type === docType ? decl : null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (decls.length > 0)
|
|
405
|
+
return null;
|
|
406
|
+
return {
|
|
407
|
+
folder: "", name: `${moduleId}-${PACK_KEY[docType]}`,
|
|
408
|
+
label: `${docType}`, type: docType,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/** The page's folder path inside its pack, for compendium folders. */
|
|
412
|
+
function subfolderOf(page, decl) {
|
|
413
|
+
const override = page.foundry?.["folder"];
|
|
414
|
+
if (typeof override === "string" && override.trim())
|
|
415
|
+
return override.trim().replace(/^\/+|\/+$/g, "");
|
|
416
|
+
const dir = page.path.split("/").slice(0, -1).join("/");
|
|
417
|
+
if (!decl.folder)
|
|
418
|
+
return dir;
|
|
419
|
+
const marker = `${decl.folder}/`;
|
|
420
|
+
const at = dir.indexOf(marker);
|
|
421
|
+
return at === -1 ? "" : dir.slice(at + marker.length);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Build the module. Returns null (having said why) when the vault has no
|
|
425
|
+
* module.json to build from, which is how a vault opts out by not having one.
|
|
426
|
+
*/
|
|
427
|
+
export async function buildFoundryModule(opts) {
|
|
428
|
+
// The module lives wherever its manifest does. A vault that distributes
|
|
429
|
+
// through its own deploy keeps one at the root; a vault that already has a
|
|
430
|
+
// module directory it maintains by hand — lang files, styles, Babele
|
|
431
|
+
// translations — keeps one there, and the compiled packs belong beside them.
|
|
432
|
+
const settings = await loadSettings(opts.vaultPath);
|
|
433
|
+
let manifest = null;
|
|
434
|
+
let moduleDirRel = "";
|
|
435
|
+
for (const dir of ["", "foundry"]) {
|
|
436
|
+
try {
|
|
437
|
+
manifest = JSON.parse(await readFile(join(opts.vaultPath, dir, "module.json"), "utf8"));
|
|
438
|
+
moduleDirRel = dir;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
catch { /* try the other location */ }
|
|
442
|
+
}
|
|
443
|
+
// No module.json: the manifest can be stated in settings.md instead, which is
|
|
444
|
+
// where a vault says everything else about itself. A file earns its place
|
|
445
|
+
// once the module has scripts, styles or translations to sit beside; for a
|
|
446
|
+
// module that is only compiled content, it was four keys in a file of its
|
|
447
|
+
// own that nothing else read.
|
|
448
|
+
const inline = settings.values.foundry.module;
|
|
449
|
+
if (!manifest && Object.keys(inline).length > 0) {
|
|
450
|
+
manifest = {
|
|
451
|
+
title: settings.values.vault_name,
|
|
452
|
+
// The generation this module targets. Stated so a compiled module does
|
|
453
|
+
// not silently claim compatibility with whatever Foundry happens to run.
|
|
454
|
+
compatibility: { minimum: "13", verified: "14" },
|
|
455
|
+
...structuredClone(inline),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
else if (manifest && Object.keys(inline).length > 0) {
|
|
459
|
+
console.warn(` --module: both ${join(moduleDirRel, "module.json")} and settings.md's `
|
|
460
|
+
+ `'foundry.module' define this module. Using the file; delete one so there `
|
|
461
|
+
+ `is a single answer to what the module is.`);
|
|
462
|
+
}
|
|
463
|
+
if (!manifest) {
|
|
464
|
+
console.warn(" --module: this vault does not say what module to build. Either set "
|
|
465
|
+
+ "'foundry.module' in settings.md (id, title, version, compatibility) or add a "
|
|
466
|
+
+ "module.json at the vault root or in foundry/. Every key you put there is "
|
|
467
|
+
+ "preserved; only `packs` is rewritten.");
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
const moduleId = typeof manifest["id"] === "string" ? manifest["id"] : "";
|
|
471
|
+
if (!moduleId) {
|
|
472
|
+
console.warn(" --module: module.json needs a string `id`.");
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
const version = typeof manifest["version"] === "string" ? manifest["version"] : "0.0.0";
|
|
476
|
+
const stats = statsFor(manifest);
|
|
477
|
+
const flags = (manifest["flags"] ?? {});
|
|
478
|
+
const decls = (flags["vaults"]?.["packs"] ?? []);
|
|
479
|
+
// Which roles may be redistributed.
|
|
480
|
+
//
|
|
481
|
+
// A module is handed to other people, so the default is the vault's lowest
|
|
482
|
+
// role and nothing else: a `role: dm` page carries content its author chose
|
|
483
|
+
// not to publish, and compiling it into a downloadable zip publishes it
|
|
484
|
+
// further than the wiki ever would. Widening is possible but has to be said
|
|
485
|
+
// out loud, in module.json, rather than being the default nobody checked.
|
|
486
|
+
const cfg = await loadConfig(opts.vaultPath, {});
|
|
487
|
+
const roles = cfg.roles.length > 0 ? cfg.roles : ["public"];
|
|
488
|
+
// Pages carry their own role by the time they reach here, supplied by
|
|
489
|
+
// `default_frontmatter` where they stated none. This is the floor.
|
|
490
|
+
const defaultRole = roles[0];
|
|
491
|
+
const declaredRoles = flags["vaults"]?.["roles"];
|
|
492
|
+
const allowedRoles = new Set(Array.isArray(declaredRoles) && declaredRoles.length > 0
|
|
493
|
+
? declaredRoles.filter((r) => typeof r === "string")
|
|
494
|
+
: [roles[0]]);
|
|
495
|
+
const cli = await findFoundryCli(opts.vaultPath);
|
|
496
|
+
if (!cli) {
|
|
497
|
+
console.warn(" --module: needs @foundryvtt/foundryvtt-cli to write compendium packs, which is not"
|
|
498
|
+
+ " installed. It is optional on purpose (it builds LevelDB bindings), so:"
|
|
499
|
+
+ "\n npm i -g @foundryvtt/foundryvtt-cli");
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
const skipped = [];
|
|
503
|
+
const planned = [];
|
|
504
|
+
// Vault files by path, plus a basename key, so a cover written the Obsidian
|
|
505
|
+
// way (a bare filename) resolves the same as a full path.
|
|
506
|
+
const imageIndex = new Map();
|
|
507
|
+
for (const f of await scanVault(opts.vaultPath)) {
|
|
508
|
+
if (!/\.(png|jpe?g|webp|gif|svg|avif)$/i.test(f.path))
|
|
509
|
+
continue;
|
|
510
|
+
imageIndex.set(f.path, f.path);
|
|
511
|
+
const key = `basename:${f.path.split("/").pop().toLowerCase()}`;
|
|
512
|
+
if (!imageIndex.has(key))
|
|
513
|
+
imageIndex.set(key, f.path);
|
|
514
|
+
}
|
|
515
|
+
const gated = [];
|
|
516
|
+
const journalPages = [];
|
|
517
|
+
const frontmatterRules = compileFrontmatterRules(settings.values.default_frontmatter);
|
|
518
|
+
for (const page of await scanPages(opts.vaultPath, defaultRole, frontmatterRules)) {
|
|
519
|
+
if (!allowedRoles.has(page.role)) {
|
|
520
|
+
gated.push(page.path);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
// `sync: false` means the page is not for Foundry at all — no journal, no
|
|
524
|
+
// document. A module is Foundry content by definition, so it honours that
|
|
525
|
+
// the same way the sync path does.
|
|
526
|
+
if (page.foundry?.["sync"] === false)
|
|
527
|
+
continue;
|
|
528
|
+
// `journal: false` exists for a page whose only job is to make a document.
|
|
529
|
+
// It keeps its document and contributes no article, here as there.
|
|
530
|
+
if (page.foundry?.["journal"] !== false)
|
|
531
|
+
journalPages.push(page);
|
|
532
|
+
if (!page.foundry)
|
|
533
|
+
continue;
|
|
534
|
+
const block = page.foundry;
|
|
535
|
+
const specs = (Array.isArray(block["base"]) ? block["base"] : [block["base"]])
|
|
536
|
+
.filter((x) => typeof x === "string" && x.length > 0);
|
|
537
|
+
if (specs.length === 0)
|
|
538
|
+
continue;
|
|
539
|
+
const target = resolveSelfContainedBase(specs);
|
|
540
|
+
if (!target) {
|
|
541
|
+
skipped.push({
|
|
542
|
+
path: page.path,
|
|
543
|
+
reason: `base is ${specs.join(", ")} — nothing a module can build without the reader's own content`,
|
|
544
|
+
});
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const decl = assignPack(page, target.blank, decls, moduleId);
|
|
548
|
+
if (!decl)
|
|
549
|
+
continue; // declared vault, page outside any declared pack
|
|
550
|
+
if (specs.length > 1 && specs.filter((sp) => sp.includes(".") || sp.startsWith("@moulinette/")).length > 0) {
|
|
551
|
+
skipped.push({
|
|
552
|
+
path: page.path,
|
|
553
|
+
reason: `built as a blank ${target.blank}; higher rung(s) need content this module cannot carry`,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
planned.push({
|
|
557
|
+
page, decl, docType: target.blank,
|
|
558
|
+
...(target.subtype ? { subtype: target.subtype } : {}),
|
|
559
|
+
id: typeof block["id"] === "string" && block["id"] ? block["id"] : documentId(moduleId, page.path),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
if (gated.length > 0) {
|
|
563
|
+
console.log(` ${gated.length} page(s) left out: not in role(s) ${[...allowedRoles].join(", ")}.`
|
|
564
|
+
+ ` A module is redistributable, so gated pages stay out unless`
|
|
565
|
+
+ ` flags.vaults.roles says otherwise.`);
|
|
566
|
+
}
|
|
567
|
+
if (planned.length === 0 && journalPages.length === 0) {
|
|
568
|
+
console.warn(" --module: no page produced anything this module can carry; nothing built.");
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
// Every compiled page, by name, so a wikilink between them becomes a real
|
|
572
|
+
// @UUID cross-reference rather than losing its link and keeping its text.
|
|
573
|
+
// Built before rendering, since a page can link to one compiled after it.
|
|
574
|
+
const adventure = opts.foundryPackage === "adventure";
|
|
575
|
+
// An adventure's documents are addressed as world documents, because that is
|
|
576
|
+
// what import turns them into. A compendium UUID would keep naming the pack
|
|
577
|
+
// copy, so every link in an imported adventure would lead back out of the
|
|
578
|
+
// world to a second copy of the thing beside it.
|
|
579
|
+
const uuidFor = (packName, rest) => adventure ? rest : `Compendium.${moduleId}.${packName}.${rest}`;
|
|
580
|
+
const linkIndex = new Map();
|
|
581
|
+
for (const p of planned) {
|
|
582
|
+
const uuid = uuidFor(p.decl.name, `${p.docType}.${p.id}`);
|
|
583
|
+
linkIndex.set(p.page.title.toLowerCase(), { uuid, name: p.page.title });
|
|
584
|
+
const basename = p.page.path.split("/").pop().replace(/\.md$/i, "");
|
|
585
|
+
if (!linkIndex.has(basename.toLowerCase()))
|
|
586
|
+
linkIndex.set(basename.toLowerCase(), { uuid, name: basename });
|
|
587
|
+
}
|
|
588
|
+
// ── Journals ────────────────────────────────────────────────────────────
|
|
589
|
+
//
|
|
590
|
+
// Read from the rendered deploy rather than re-rendered from markdown, so a
|
|
591
|
+
// page's battlemap, statblock and `fm:` values are the ones the wiki shows.
|
|
592
|
+
const journalTargets = new Map();
|
|
593
|
+
const journalSources = [];
|
|
594
|
+
const journalPackName = `${moduleId}-journal`;
|
|
595
|
+
// Journals mirror the sync model by default: a page becomes an article, and
|
|
596
|
+
// a document made from that page embeds it, so the same vault produces the
|
|
597
|
+
// same thing whether a reader synced it or installed it.
|
|
598
|
+
//
|
|
599
|
+
// A vault whose pages exist only to describe compendium entries can opt out
|
|
600
|
+
// with `flags.vaults.journal: false`. That is a real case, not a
|
|
601
|
+
// hypothetical: WANDS's prose is already the text of each item, so carrying
|
|
602
|
+
// it a second time as an article would duplicate the whole compendium.
|
|
603
|
+
// Which pages become articles is the page's own business, said the same way
|
|
604
|
+
// it is said to the sync client: `foundry.journal: false`. A vault that wants
|
|
605
|
+
// a whole folder excluded sets it once in default_frontmatter rather than in
|
|
606
|
+
// module config, so a synced world and an installed module cannot disagree
|
|
607
|
+
// about which pages have articles.
|
|
608
|
+
if (opts.renderedDir) {
|
|
609
|
+
for (const page of journalPages) {
|
|
610
|
+
const html = await readRenderedBody(opts, page.path);
|
|
611
|
+
if (html === null)
|
|
612
|
+
continue;
|
|
613
|
+
journalSources.push({ path: page.path, title: page.title, html });
|
|
614
|
+
const eId = journalEntryId(moduleId, page.path);
|
|
615
|
+
const pId = journalPageId(moduleId, page.path);
|
|
616
|
+
journalTargets.set(page.path, {
|
|
617
|
+
uuid: uuidFor(journalPackName, `JournalEntry.${eId}.JournalEntryPage.${pId}`),
|
|
618
|
+
entryId: eId, pageId: pId,
|
|
619
|
+
});
|
|
620
|
+
// A link is written against the page's URL, which has no .md on it.
|
|
621
|
+
journalTargets.set(page.path.replace(/\.md$/i, ""), journalTargets.get(page.path));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
// ── Second pass: assemble ───────────────────────────────────────────────
|
|
625
|
+
const byPack = new Map();
|
|
626
|
+
const assets = new Set();
|
|
627
|
+
const moulinette = new Set();
|
|
628
|
+
let documents = 0;
|
|
629
|
+
for (const p of planned) {
|
|
630
|
+
const block = p.page.foundry;
|
|
631
|
+
const prefix = PACK_KEY[p.docType];
|
|
632
|
+
const doc = {
|
|
633
|
+
_id: p.id,
|
|
634
|
+
name: p.page.title,
|
|
635
|
+
...(p.subtype ? { type: p.subtype } : {}),
|
|
636
|
+
};
|
|
637
|
+
const dataJson = typeof block["data_json"] === "string"
|
|
638
|
+
? await loadDataJson(opts.vaultPath, block["data_json"], p.page.path)
|
|
639
|
+
: null;
|
|
640
|
+
if (dataJson)
|
|
641
|
+
deepMerge(doc, dataJson);
|
|
642
|
+
const inline = block["data"];
|
|
643
|
+
if (inline && typeof inline === "object" && !Array.isArray(inline)) {
|
|
644
|
+
deepMerge(doc, inline);
|
|
645
|
+
}
|
|
646
|
+
// The page's prose is the compendium entry for most types; a sidecar
|
|
647
|
+
// exported from Foundry generally carries an empty description because
|
|
648
|
+
// the writing lives in the vault.
|
|
649
|
+
// `embed: false` exists to keep an article off a document sheet — usually
|
|
650
|
+
// because the page carries DM-only material. Rendering the same body into
|
|
651
|
+
// a module's description would do exactly what the flag forbids, and into
|
|
652
|
+
// something redistributable.
|
|
653
|
+
const descPath = block["embed"] === false ? "" : DESCRIPTION_PATH[p.docType];
|
|
654
|
+
if (descPath) {
|
|
655
|
+
const target = journalTargets.get(p.page.path);
|
|
656
|
+
// Embed the module's own journal page, exactly as the sync path embeds
|
|
657
|
+
// the world's. That is what makes a module document and a synced
|
|
658
|
+
// document the same thing rather than two renderings of one page.
|
|
659
|
+
// Falls back to inlined HTML when the module carries no journal.
|
|
660
|
+
const html = target
|
|
661
|
+
? `<p>@Embed[${target.uuid} inline]</p>`
|
|
662
|
+
: renderBody(p.page.body, linkIndex);
|
|
663
|
+
// Set even when empty. A page whose prose lives entirely in a handler
|
|
664
|
+
// fence renders to nothing, and a sheet reading an absent field is not
|
|
665
|
+
// the same as one reading a blank string.
|
|
666
|
+
setPath(doc, descPath, html);
|
|
667
|
+
// dnd5e sheets read a sibling `chat` description and throw on undefined.
|
|
668
|
+
// Walked with setPath rather than by hand: a page with no body never had
|
|
669
|
+
// the intermediate objects created, and reaching through them crashed
|
|
670
|
+
// the whole build on the first such page.
|
|
671
|
+
if (p.docType === "Item" || p.docType === "Actor") {
|
|
672
|
+
const chatPath = [...descPath.split(".").slice(0, -1), "chat"];
|
|
673
|
+
const existing = chatPath.reduce((o, k) => (o && typeof o === "object" ? o[k] : undefined), doc);
|
|
674
|
+
if (existing === undefined)
|
|
675
|
+
setPath(doc, chatPath.join("."), "");
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
doc["sort"] ??= 0;
|
|
679
|
+
doc["flags"] ??= {};
|
|
680
|
+
doc["ownership"] ??= { default: 0 };
|
|
681
|
+
doc["_stats"] ??= stats;
|
|
682
|
+
// The cover the wiki shows. The sync path sets a document's img from it,
|
|
683
|
+
// and a prototype token for an Actor, so a module that skipped it would
|
|
684
|
+
// hand over the same document with no picture.
|
|
685
|
+
if (p.page.image) {
|
|
686
|
+
const rel = resolveCover(p.page, imageIndex);
|
|
687
|
+
if (rel) {
|
|
688
|
+
assets.add(rel);
|
|
689
|
+
const url = `modules/${moduleId}/assets/${rel}`;
|
|
690
|
+
doc["img"] ??= url;
|
|
691
|
+
if (p.docType === "Actor") {
|
|
692
|
+
const token = (doc["prototypeToken"] ??= {});
|
|
693
|
+
const texture = (token["texture"] ??= {});
|
|
694
|
+
texture["src"] ??= url;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
console.warn(` ${p.page.path}: cover image '${p.page.image}' not found in the vault.`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (p.docType === "RollTable")
|
|
702
|
+
assembleRollTableResults(doc, p.id, stats);
|
|
703
|
+
const cleaned = stripMoulinette(doc, moulinette);
|
|
704
|
+
const withAssets = rewriteAssetPaths(cleaned, moduleId, assets);
|
|
705
|
+
const bucket = byPack.get(p.decl.name) ?? { decl: p.decl, docs: [], entries: [] };
|
|
706
|
+
for (const variant of expandVariants(withAssets, block, prefix)) {
|
|
707
|
+
variant["_key"] = `!${prefix}!${variant["_id"]}`;
|
|
708
|
+
keyEmbedded(variant, prefix, variant["_id"]);
|
|
709
|
+
bucket.docs.push(variant);
|
|
710
|
+
bucket.entries.push({ key: variant, folderPath: subfolderOf(p.page, p.decl) });
|
|
711
|
+
documents++;
|
|
712
|
+
}
|
|
713
|
+
byPack.set(p.decl.name, bucket);
|
|
714
|
+
}
|
|
715
|
+
if (journalSources.length > 0) {
|
|
716
|
+
const entries = buildJournalEntries(journalSources, moduleId, String(manifest["title"] ?? moduleId), stats);
|
|
717
|
+
for (const entry of entries) {
|
|
718
|
+
for (const page of entry.pages) {
|
|
719
|
+
const text = page["text"];
|
|
720
|
+
text["content"] = transformForModule(String(text["content"]), moduleId, journalTargets, assets);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
byPack.set(journalPackName, {
|
|
724
|
+
decl: { folder: "", name: journalPackName, label: "", type: "JournalEntry" },
|
|
725
|
+
docs: entries,
|
|
726
|
+
entries: [],
|
|
727
|
+
});
|
|
728
|
+
documents += entries.length;
|
|
729
|
+
}
|
|
730
|
+
// An ungated deploy serves its manifest untouched, so whatever is written in
|
|
731
|
+
// `download` is what Foundry's installer receives. A gated one is rewritten
|
|
732
|
+
// at serve time — the middleware resolves the relative path onto the request
|
|
733
|
+
// origin and signs it — so only this case has to produce an absolute URL
|
|
734
|
+
// itself, and it needs the vault to have said where it lives.
|
|
735
|
+
opts.gated = roles.length > 1;
|
|
736
|
+
const selfServedBase = opts.gated ? "" : settings.values.site_url.replace(/\/+$/, "");
|
|
737
|
+
return finishModule(opts, manifest, moduleId, version, cli, byPack, assets, moulinette, skipped, documents, stats, moduleDirRel, selfServedBase);
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Give a RollTable the shape Foundry expects.
|
|
741
|
+
*
|
|
742
|
+
* A page authors a result's prose as `{ name }` / `{ description }`, or as
|
|
743
|
+
* `{ uuid }` for a document result, with an optional weight and range; a table
|
|
744
|
+
* document wants each one typed, named, ranged, imaged and keyed. Foundry does
|
|
745
|
+
* not fill these in — a result with no `type` simply never draws — so the
|
|
746
|
+
* compiler does, rather than making every page restate the same nine fields.
|
|
747
|
+
*
|
|
748
|
+
* `text` is Foundry's pre-13 single prose field, deprecated but still what an
|
|
749
|
+
* older page carries. It maps the way Foundry's own migration maps it: to
|
|
750
|
+
* `description` on a text result, to `name` on a document one. What the page
|
|
751
|
+
* states outright always wins, because filling a field in is the job here and
|
|
752
|
+
* overwriting one is not: results authored with `name` — the shape the sync
|
|
753
|
+
* path passes straight through, and the shape the landing demo documents —
|
|
754
|
+
* used to compile to a table of blank rows.
|
|
755
|
+
*/
|
|
756
|
+
export function assembleRollTableResults(doc, tableId, stats) {
|
|
757
|
+
const raw = Array.isArray(doc["results"]) ? doc["results"] : [];
|
|
758
|
+
doc["results"] = raw.map((r, i) => {
|
|
759
|
+
const rid = derivedId(`${tableId}:${i}`);
|
|
760
|
+
const uuid = typeof r["uuid"] === "string" ? r["uuid"] : "";
|
|
761
|
+
const isDoc = uuid.length > 0;
|
|
762
|
+
const text = typeof r["text"] === "string" ? r["text"] : "";
|
|
763
|
+
const stated = (field) => typeof r[field] === "string" ? r[field] : null;
|
|
764
|
+
// What a text result *shows* is its description. `name` is a short title,
|
|
765
|
+
// and is empty in every table Foundry itself ships. So a result that
|
|
766
|
+
// states only a name is one an author wrote the text of and would see
|
|
767
|
+
// rendered blank; treat it as the body, which is what they meant. A
|
|
768
|
+
// document result is the other way round: the name labels the link.
|
|
769
|
+
const lone = !isDoc && stated("name") !== null
|
|
770
|
+
&& stated("description") === null && !text;
|
|
771
|
+
return {
|
|
772
|
+
_id: rid,
|
|
773
|
+
type: isDoc ? "document" : "text",
|
|
774
|
+
name: lone ? "" : (stated("name") ?? (isDoc ? text : "")),
|
|
775
|
+
description: lone ? stated("name") : (stated("description") ?? (isDoc ? "" : text)),
|
|
776
|
+
...(isDoc ? { documentUuid: uuid } : {}),
|
|
777
|
+
img: r["img"] ?? "icons/svg/d20-black.svg",
|
|
778
|
+
weight: r["weight"] ?? 1,
|
|
779
|
+
range: r["range"] ?? [i + 1, i + 1],
|
|
780
|
+
drawn: false,
|
|
781
|
+
flags: {},
|
|
782
|
+
_stats: stats,
|
|
783
|
+
_key: `!tables.results!${tableId}.${rid}`,
|
|
784
|
+
};
|
|
785
|
+
});
|
|
786
|
+
doc["img"] ??= "icons/svg/d20-grey.svg";
|
|
787
|
+
doc["formula"] ??= `1d${raw.length || 1}`;
|
|
788
|
+
doc["replacement"] ??= true;
|
|
789
|
+
doc["displayRoll"] ??= true;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* The wiki's rendered body for a page, or null when the deploy has none.
|
|
793
|
+
*
|
|
794
|
+
* A single-role build collapses its variant to the deploy root, so both
|
|
795
|
+
* layouts are tried rather than deriving which one applies.
|
|
796
|
+
*/
|
|
797
|
+
async function readRenderedBody(opts, pagePath) {
|
|
798
|
+
const rel = pagePath.replace(/\.md$/i, ".body.html");
|
|
799
|
+
const candidates = opts.renderedRole
|
|
800
|
+
? [join(opts.renderedDir, "_variants", opts.renderedRole, rel), join(opts.renderedDir, rel)]
|
|
801
|
+
: [join(opts.renderedDir, rel)];
|
|
802
|
+
for (const c of candidates) {
|
|
803
|
+
try {
|
|
804
|
+
return await readFile(c, "utf8");
|
|
805
|
+
}
|
|
806
|
+
catch { /* try the other layout */ }
|
|
807
|
+
}
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Resolve an `image:` value to a vault-relative path.
|
|
812
|
+
*
|
|
813
|
+
* Obsidian lets a cover be a bare basename, so accept that as well as a real
|
|
814
|
+
* path, the same way the wiki's own image index does.
|
|
815
|
+
*/
|
|
816
|
+
function resolveCover(page, index) {
|
|
817
|
+
const raw = page.image.replace(/^\/+/, "");
|
|
818
|
+
if (index.has(raw))
|
|
819
|
+
return raw;
|
|
820
|
+
const sibling = [...page.path.split("/").slice(0, -1), raw].join("/");
|
|
821
|
+
if (index.has(sibling))
|
|
822
|
+
return sibling;
|
|
823
|
+
return index.get(`basename:${raw.split("/").pop().toLowerCase()}`) ?? null;
|
|
824
|
+
}
|
|
825
|
+
/** A page with `foundry.variants` becomes one document per variant. */
|
|
826
|
+
/** A deep copy with every `_key` removed. */
|
|
827
|
+
function stripKeys(value) {
|
|
828
|
+
if (Array.isArray(value))
|
|
829
|
+
return value.map(stripKeys);
|
|
830
|
+
if (value && typeof value === "object") {
|
|
831
|
+
const out = {};
|
|
832
|
+
for (const [k, v] of Object.entries(value)) {
|
|
833
|
+
if (k !== "_key")
|
|
834
|
+
out[k] = stripKeys(v);
|
|
835
|
+
}
|
|
836
|
+
return out;
|
|
837
|
+
}
|
|
838
|
+
return value;
|
|
839
|
+
}
|
|
840
|
+
function expandVariants(base, block, prefix) {
|
|
841
|
+
const variants = block["variants"];
|
|
842
|
+
if (!Array.isArray(variants) || variants.length === 0)
|
|
843
|
+
return [base];
|
|
844
|
+
return variants.map((v) => {
|
|
845
|
+
const entry = v;
|
|
846
|
+
const clone = structuredClone(base);
|
|
847
|
+
if (entry.data)
|
|
848
|
+
deepMerge(clone, entry.data);
|
|
849
|
+
if (entry.id)
|
|
850
|
+
clone["_id"] = entry.id;
|
|
851
|
+
clone["_key"] = `!${prefix}!${clone["_id"]}`;
|
|
852
|
+
return clone;
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
/** Write the packs, the manifest and the zip, and report what was left out. */
|
|
856
|
+
/**
|
|
857
|
+
* Copy the vault's Foundry-targeted handler assets into the module.
|
|
858
|
+
*
|
|
859
|
+
* A handler that ships browser assets renders a placeholder at build time and
|
|
860
|
+
* fills it in at runtime — so without its script the page is a blank gap,
|
|
861
|
+
* which is what a compiled module produced. Sync solves this by fetching
|
|
862
|
+
* `_handlers.foundry.*` from the deploy; a module has no deploy to fetch from,
|
|
863
|
+
* so it carries them and declares them itself.
|
|
864
|
+
*
|
|
865
|
+
* Appended to whatever the author declared rather than replacing it: these are
|
|
866
|
+
* the vault's assets, and the module's own scripts and styles are its own.
|
|
867
|
+
*/
|
|
868
|
+
async function bundleHandlerAssets(opts, manifest, moduleDir) {
|
|
869
|
+
if (!opts.renderedDir)
|
|
870
|
+
return;
|
|
871
|
+
for (const [kind, file, key] of [
|
|
872
|
+
["script", "_handlers.foundry.js", "scripts"],
|
|
873
|
+
["style", "_handlers.foundry.css", "styles"],
|
|
874
|
+
]) {
|
|
875
|
+
const candidates = opts.renderedRole
|
|
876
|
+
? [join(opts.renderedDir, "_variants", opts.renderedRole, file), join(opts.renderedDir, file)]
|
|
877
|
+
: [join(opts.renderedDir, file)];
|
|
878
|
+
let content = null;
|
|
879
|
+
for (const c of candidates) {
|
|
880
|
+
try {
|
|
881
|
+
content = await readFile(c, "utf8");
|
|
882
|
+
break;
|
|
883
|
+
}
|
|
884
|
+
catch { /* try the other layout */ }
|
|
885
|
+
}
|
|
886
|
+
if (content === null)
|
|
887
|
+
continue;
|
|
888
|
+
const name = file.replace(/^_/, "");
|
|
889
|
+
await writeFile(join(moduleDir, name), content);
|
|
890
|
+
const declared = Array.isArray(manifest[key]) ? manifest[key] : [];
|
|
891
|
+
if (!declared.includes(name))
|
|
892
|
+
manifest[key] = [...declared, name];
|
|
893
|
+
console.log(` bundled the vault's handler ${kind}s as ${name}`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
async function finishModule(opts, manifest, moduleId, version, cli, byPack, assets, moulinette, skipped, documents, stats, moduleDirRel,
|
|
897
|
+
/** Absolute base for a public vault's own URLs; "" when the middleware will
|
|
898
|
+
* rewrite them at serve time, or when the vault never said where it lives. */
|
|
899
|
+
selfServedBase) {
|
|
900
|
+
// Two ways a module reaches a reader, and the manifest already says which.
|
|
901
|
+
//
|
|
902
|
+
// A manifest that names its own `download` is published elsewhere — a
|
|
903
|
+
// GitHub release, usually — so the compiled packs belong beside it, in the
|
|
904
|
+
// module directory its own release tooling zips, and those URLs are the
|
|
905
|
+
// author's to manage. Rewriting them would break the versioned-URL dance a
|
|
906
|
+
// release script does at tag time.
|
|
907
|
+
//
|
|
908
|
+
// A manifest with no `download` is served by this vault, so it gets a zip in
|
|
909
|
+
// the deploy and a relative URL pointing at it.
|
|
910
|
+
const inPlace = typeof manifest["download"] === "string" && manifest["download"].length > 0;
|
|
911
|
+
const staging = await mkdtemp(join(tmpdir(), "vaults-module-"));
|
|
912
|
+
const moduleDir = inPlace ? join(opts.vaultPath, moduleDirRel) : join(staging, moduleId);
|
|
913
|
+
const jsonDir = join(staging, "_json");
|
|
914
|
+
await mkdir(join(moduleDir, "packs"), { recursive: true });
|
|
915
|
+
const packs = [];
|
|
916
|
+
const packNames = [];
|
|
917
|
+
// Declared on every pack: Foundry only demands it for
|
|
918
|
+
// Actor and Item packs, but a module whose packs disagree about which
|
|
919
|
+
// system they belong to is not a thing worth being able to express.
|
|
920
|
+
const systemId = systemIdOf(manifest);
|
|
921
|
+
// One Adventure holding everything, rather than a pack per document type.
|
|
922
|
+
// The buckets are still built the same way — the folders and documents an
|
|
923
|
+
// adventure carries are the ones a compendium would have — they are just
|
|
924
|
+
// gathered into a single document instead of written to separate packs.
|
|
925
|
+
if (opts.foundryPackage === "adventure") {
|
|
926
|
+
const packName = `${moduleId}-adventure`;
|
|
927
|
+
const dir = join(jsonDir, packName);
|
|
928
|
+
await mkdir(dir, { recursive: true });
|
|
929
|
+
const advId = derivedId(`vaults-module-adventure:${moduleId}`);
|
|
930
|
+
const adventure = {
|
|
931
|
+
_id: advId,
|
|
932
|
+
// The LevelDB row key. A document without one fails the whole pack.
|
|
933
|
+
_key: `!adventures!${advId}`,
|
|
934
|
+
name: manifest["title"] ?? moduleId,
|
|
935
|
+
caption: manifest["description"] ?? "",
|
|
936
|
+
description: manifest["description"] ?? "",
|
|
937
|
+
folders: [],
|
|
938
|
+
flags: {},
|
|
939
|
+
};
|
|
940
|
+
for (const field of Object.values(ADVENTURE_FIELD))
|
|
941
|
+
adventure[field] = [];
|
|
942
|
+
for (const [packName2, bucket] of byPack) {
|
|
943
|
+
const field = ADVENTURE_FIELD[bucket.decl.type];
|
|
944
|
+
if (!field) {
|
|
945
|
+
skipped.push({ path: packName2, reason: `an Adventure cannot hold a ${bucket.decl.type}` });
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
const { folderDocs, leafFor } = buildFolders(bucket.entries, packName2, bucket.decl.type, stats);
|
|
949
|
+
for (const entry of bucket.entries)
|
|
950
|
+
entry.key["folder"] = leafFor.get(entry.key) ?? null;
|
|
951
|
+
// `_key` addresses a row in a pack. Inside an adventure these are data
|
|
952
|
+
// in a field, not rows, so the keys the compendium layout stamped on
|
|
953
|
+
// them mean nothing here and are stripped rather than shipped.
|
|
954
|
+
adventure["folders"].push(...folderDocs.map(stripKeys));
|
|
955
|
+
adventure[field].push(...bucket.docs.map(stripKeys));
|
|
956
|
+
}
|
|
957
|
+
await writeFile(join(dir, `${adventure["_id"]}.json`), JSON.stringify(adventure, null, 2));
|
|
958
|
+
await execFileAsync(process.execPath, [
|
|
959
|
+
cli, "package", "pack", "-n", packName, "--type", "Module", "--id", moduleId,
|
|
960
|
+
"--in", dir, "--out", join(moduleDir, "packs"),
|
|
961
|
+
]);
|
|
962
|
+
packs.push({
|
|
963
|
+
name: packName,
|
|
964
|
+
label: `${manifest["title"] ?? moduleId}`,
|
|
965
|
+
path: `packs/${packName}`,
|
|
966
|
+
type: "Adventure",
|
|
967
|
+
...(systemId ? { system: systemId } : {}),
|
|
968
|
+
});
|
|
969
|
+
packNames.push(packName);
|
|
970
|
+
console.log(` ${packName}: 1 adventure holding ${documents} document(s)`);
|
|
971
|
+
byPack.clear();
|
|
972
|
+
}
|
|
973
|
+
for (const [packName, bucket] of byPack) {
|
|
974
|
+
const dir = join(jsonDir, packName);
|
|
975
|
+
await mkdir(dir, { recursive: true });
|
|
976
|
+
// Compendium folders are documents in the pack too, so a pack of five
|
|
977
|
+
// hundred items is browsable instead of one flat list.
|
|
978
|
+
const { folderDocs, leafFor } = buildFolders(bucket.entries, packName, bucket.decl.type, stats);
|
|
979
|
+
for (const entry of bucket.entries)
|
|
980
|
+
entry.key["folder"] = leafFor.get(entry.key) ?? null;
|
|
981
|
+
for (const folder of folderDocs) {
|
|
982
|
+
await writeFile(join(dir, `folder-${folder._id}.json`), JSON.stringify(folder, null, 2));
|
|
983
|
+
}
|
|
984
|
+
for (const doc of bucket.docs) {
|
|
985
|
+
await writeFile(join(dir, `${doc["_id"]}.json`), JSON.stringify(doc, null, 2));
|
|
986
|
+
}
|
|
987
|
+
await execFileAsync(process.execPath, [
|
|
988
|
+
cli, "package", "pack", "-n", packName, "--type", "Module", "--id", moduleId,
|
|
989
|
+
"--in", dir, "--out", join(moduleDir, "packs"),
|
|
990
|
+
]);
|
|
991
|
+
packs.push({
|
|
992
|
+
name: packName,
|
|
993
|
+
label: bucket.decl.folder ? bucket.decl.label
|
|
994
|
+
: `${manifest["title"] ?? moduleId}: ${PACK_LABEL[bucket.decl.type] ?? bucket.decl.type}`,
|
|
995
|
+
path: `packs/${packName}`,
|
|
996
|
+
type: bucket.decl.type,
|
|
997
|
+
...(systemId ? { system: systemId } : {}),
|
|
998
|
+
});
|
|
999
|
+
packNames.push(packName);
|
|
1000
|
+
console.log(` ${packName}: ${bucket.docs.length} document(s), ${folderDocs.length} folder(s)`);
|
|
1001
|
+
}
|
|
1002
|
+
for (const rel of assets) {
|
|
1003
|
+
const from = join(opts.vaultPath, rel);
|
|
1004
|
+
const to = join(moduleDir, "assets", rel);
|
|
1005
|
+
try {
|
|
1006
|
+
await mkdir(dirname(to), { recursive: true });
|
|
1007
|
+
await copyFile(from, to);
|
|
1008
|
+
}
|
|
1009
|
+
catch {
|
|
1010
|
+
console.warn(` asset '${rel}' is referenced but missing; the module will point at nothing.`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (assets.size > 0)
|
|
1014
|
+
console.log(` ${assets.size} asset(s) bundled`);
|
|
1015
|
+
// Own only `packs`: everything else the author put in
|
|
1016
|
+
// module.json is theirs and survives.
|
|
1017
|
+
manifest["packs"] = packs;
|
|
1018
|
+
fileNewPacks(manifest, packNames);
|
|
1019
|
+
await bundleHandlerAssets(opts, manifest, moduleDir);
|
|
1020
|
+
const zipName = `${moduleId}-${version}.zip`;
|
|
1021
|
+
let manifestPath;
|
|
1022
|
+
let zipPath = "";
|
|
1023
|
+
if (inPlace) {
|
|
1024
|
+
manifestPath = join(moduleDir, "module.json");
|
|
1025
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
const outDir = join(opts.vaultPath, opts.outputDir);
|
|
1029
|
+
await mkdir(outDir, { recursive: true });
|
|
1030
|
+
manifestPath = join(outDir, "module.json");
|
|
1031
|
+
// Foundry's installer fetches `download` from Node, with no base to
|
|
1032
|
+
// resolve against: a relative URL fails with "Failed to parse URL from
|
|
1033
|
+
// /downloads/….zip" and the module cannot be installed at all.
|
|
1034
|
+
//
|
|
1035
|
+
// A gated vault still writes one, because its middleware resolves the path
|
|
1036
|
+
// onto whichever host the request arrived on and signs the result — which
|
|
1037
|
+
// is also why it must not be absolute there: a manifest hard-coding one of
|
|
1038
|
+
// a vault's hostnames is unsignable when read over another.
|
|
1039
|
+
const rel = `/${opts.outputDir}`;
|
|
1040
|
+
if (!selfServedBase && !opts.gated) {
|
|
1041
|
+
console.warn(` this vault serves its own module but has no 'site_url' in settings.md, so `
|
|
1042
|
+
+ `the manifest can only name '${rel}/${zipName}' relatively. Foundry's installer `
|
|
1043
|
+
+ `resolves that against nothing and refuses it. Set site_url and rebuild.`);
|
|
1044
|
+
}
|
|
1045
|
+
if (selfServedBase) {
|
|
1046
|
+
manifest["download"] = `${selfServedBase}${rel}/${zipName}`;
|
|
1047
|
+
manifest["manifest"] = `${selfServedBase}${rel}/module.json`;
|
|
1048
|
+
}
|
|
1049
|
+
else {
|
|
1050
|
+
manifest["download"] = `${rel}/${zipName}`;
|
|
1051
|
+
manifest["manifest"] = `${rel}/module.json`;
|
|
1052
|
+
}
|
|
1053
|
+
await writeFile(join(moduleDir, "module.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
1054
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
1055
|
+
zipPath = join(opts.vaultPath, opts.outputDir, zipName);
|
|
1056
|
+
await rm(zipPath, { force: true });
|
|
1057
|
+
await execFileAsync("zip", ["-qr", resolve(zipPath), moduleId], { cwd: staging });
|
|
1058
|
+
}
|
|
1059
|
+
await rm(staging, { recursive: true, force: true });
|
|
1060
|
+
if (moulinette.size > 0) {
|
|
1061
|
+
console.warn(` ${moulinette.size} @moulinette/ reference(s) dropped: a standalone module cannot`
|
|
1062
|
+
+ ` resolve them, since that happens against the reader's own library at sync time.`);
|
|
1063
|
+
}
|
|
1064
|
+
if (skipped.length > 0) {
|
|
1065
|
+
console.warn(` ${skipped.length} page(s) not fully carried:`);
|
|
1066
|
+
for (const s of skipped.slice(0, 8))
|
|
1067
|
+
console.warn(` ${s.path}: ${s.reason}`);
|
|
1068
|
+
if (skipped.length > 8)
|
|
1069
|
+
console.warn(` … and ${skipped.length - 8} more`);
|
|
1070
|
+
console.warn(` To redistribute that content, build those documents yourself and put them in`
|
|
1071
|
+
+ ` the page's data_json — a module may not carry someone else's compendium.`);
|
|
1072
|
+
}
|
|
1073
|
+
return { moduleId, version, documents, packs: packNames, skipped, manifestPath, zipPath };
|
|
1074
|
+
}
|
|
1075
|
+
//# sourceMappingURL=foundry-module.js.map
|