@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.
Files changed (53) hide show
  1. package/dist/asset-refs.js +274 -0
  2. package/dist/asset-refs.js.map +1 -0
  3. package/dist/auth.js.map +1 -1
  4. package/dist/build.js +252 -492
  5. package/dist/build.js.map +1 -1
  6. package/dist/commands/build.js +54 -5
  7. package/dist/commands/build.js.map +1 -1
  8. package/dist/commands/preview.js +0 -4
  9. package/dist/commands/preview.js.map +1 -1
  10. package/dist/commands/push.js +4 -9
  11. package/dist/commands/push.js.map +1 -1
  12. package/dist/commands/role.js +55 -14
  13. package/dist/commands/role.js.map +1 -1
  14. package/dist/config.js +25 -5
  15. package/dist/config.js.map +1 -1
  16. package/dist/foundry-importer.bundle.js +1228 -327
  17. package/dist/foundry-importer.js +2 -7
  18. package/dist/foundry-importer.js.map +1 -1
  19. package/dist/foundry-meta.js +284 -0
  20. package/dist/foundry-meta.js.map +1 -0
  21. package/dist/foundry-module-journal.js +112 -0
  22. package/dist/foundry-module-journal.js.map +1 -0
  23. package/dist/foundry-module-render.js +75 -0
  24. package/dist/foundry-module-render.js.map +1 -0
  25. package/dist/foundry-module.js +1075 -0
  26. package/dist/foundry-module.js.map +1 -0
  27. package/dist/frontmatter-defaults.js +68 -0
  28. package/dist/frontmatter-defaults.js.map +1 -0
  29. package/dist/index.js +9 -9
  30. package/dist/index.js.map +1 -1
  31. package/dist/manifest.js +115 -0
  32. package/dist/manifest.js.map +1 -0
  33. package/dist/render/auth-template.js +323 -35
  34. package/dist/render/auth-template.js.map +1 -1
  35. package/dist/render/bases.js +22 -38
  36. package/dist/render/bases.js.map +1 -1
  37. package/dist/render/cover.js +23 -1
  38. package/dist/render/cover.js.map +1 -1
  39. package/dist/render/handlers/builtin/download.js +90 -0
  40. package/dist/render/handlers/builtin/download.js.map +1 -0
  41. package/dist/render/handlers/builtin/foundry-manifest.js +158 -0
  42. package/dist/render/handlers/builtin/foundry-manifest.js.map +1 -0
  43. package/dist/render/handlers/builtin/index.js +3 -1
  44. package/dist/render/handlers/builtin/index.js.map +1 -1
  45. package/dist/render/pipeline.js +4 -1
  46. package/dist/render/pipeline.js.map +1 -1
  47. package/dist/render/slug.js +0 -5
  48. package/dist/render/slug.js.map +1 -1
  49. package/dist/scan.js +8 -0
  50. package/dist/scan.js.map +1 -1
  51. package/dist/settings.js +112 -6
  52. package/dist/settings.js.map +1 -1
  53. 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