@wizzlethorpe/vaults 0.13.5 → 0.15.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 (89) hide show
  1. package/README.md +2 -2
  2. package/dist/asset-refs.js +282 -0
  3. package/dist/asset-refs.js.map +1 -0
  4. package/dist/auth.js.map +1 -1
  5. package/dist/build.js +393 -544
  6. package/dist/build.js.map +1 -1
  7. package/dist/commands/build.js +0 -4
  8. package/dist/commands/build.js.map +1 -1
  9. package/dist/commands/preview.js +0 -4
  10. package/dist/commands/preview.js.map +1 -1
  11. package/dist/commands/push.js +4 -9
  12. package/dist/commands/push.js.map +1 -1
  13. package/dist/commands/role.js +55 -14
  14. package/dist/commands/role.js.map +1 -1
  15. package/dist/config.js +25 -5
  16. package/dist/config.js.map +1 -1
  17. package/dist/escape.js +20 -0
  18. package/dist/escape.js.map +1 -1
  19. package/dist/foundry-adventure.js +86 -0
  20. package/dist/foundry-adventure.js.map +1 -0
  21. package/dist/foundry-defaults.js +65 -0
  22. package/dist/foundry-defaults.js.map +1 -0
  23. package/dist/foundry-grafts.js +610 -0
  24. package/dist/foundry-grafts.js.map +1 -0
  25. package/dist/foundry-html.js +196 -0
  26. package/dist/foundry-html.js.map +1 -0
  27. package/dist/foundry-importer.bundle.js +1192 -322
  28. package/dist/foundry-importer.js +2 -7
  29. package/dist/foundry-importer.js.map +1 -1
  30. package/dist/foundry-meta.js +56 -0
  31. package/dist/foundry-meta.js.map +1 -0
  32. package/dist/foundry-module-journal.js +133 -0
  33. package/dist/foundry-module-journal.js.map +1 -0
  34. package/dist/foundry-module-render.js +75 -0
  35. package/dist/foundry-module-render.js.map +1 -0
  36. package/dist/foundry-module.js +1082 -0
  37. package/dist/foundry-module.js.map +1 -0
  38. package/dist/foundry-types.js +28 -0
  39. package/dist/foundry-types.js.map +1 -0
  40. package/dist/foundry-version.js +30 -0
  41. package/dist/foundry-version.js.map +1 -0
  42. package/dist/frontmatter-defaults.js +68 -0
  43. package/dist/frontmatter-defaults.js.map +1 -0
  44. package/dist/index.js +5 -9
  45. package/dist/index.js.map +1 -1
  46. package/dist/manifest.js +115 -0
  47. package/dist/manifest.js.map +1 -0
  48. package/dist/migrate/0.15-foundry-patch-keys.js +51 -0
  49. package/dist/migrate/0.15-foundry-patch-keys.js.map +1 -0
  50. package/dist/migrate/0.15-foundry-pinned-id.js +68 -0
  51. package/dist/migrate/0.15-foundry-pinned-id.js.map +1 -0
  52. package/dist/migrate/files.js +66 -0
  53. package/dist/migrate/files.js.map +1 -0
  54. package/dist/migrate/registry.js +4 -0
  55. package/dist/migrate/registry.js.map +1 -1
  56. package/dist/migrate/run.js +36 -2
  57. package/dist/migrate/run.js.map +1 -1
  58. package/dist/render/auth-template.js +331 -42
  59. package/dist/render/auth-template.js.map +1 -1
  60. package/dist/render/bases.js +22 -38
  61. package/dist/render/bases.js.map +1 -1
  62. package/dist/render/cover.js +23 -1
  63. package/dist/render/cover.js.map +1 -1
  64. package/dist/render/handlers/assets.js +5 -34
  65. package/dist/render/handlers/assets.js.map +1 -1
  66. package/dist/render/handlers/builtin/battlemap.js +4 -1
  67. package/dist/render/handlers/builtin/battlemap.js.map +1 -1
  68. package/dist/render/handlers/builtin/download.js +90 -0
  69. package/dist/render/handlers/builtin/download.js.map +1 -0
  70. package/dist/render/handlers/builtin/fm-code.js +2 -2
  71. package/dist/render/handlers/builtin/fm-code.js.map +1 -1
  72. package/dist/render/handlers/builtin/foundry-manifest.js +158 -0
  73. package/dist/render/handlers/builtin/foundry-manifest.js.map +1 -0
  74. package/dist/render/handlers/builtin/fvtt-link.js +39 -0
  75. package/dist/render/handlers/builtin/fvtt-link.js.map +1 -0
  76. package/dist/render/handlers/builtin/index.js +4 -1
  77. package/dist/render/handlers/builtin/index.js.map +1 -1
  78. package/dist/render/handlers/builtin/statblock.js +0 -4
  79. package/dist/render/handlers/builtin/statblock.js.map +1 -1
  80. package/dist/render/handlers/types.js.map +1 -1
  81. package/dist/render/pipeline.js +4 -1
  82. package/dist/render/pipeline.js.map +1 -1
  83. package/dist/render/slug.js +0 -5
  84. package/dist/render/slug.js.map +1 -1
  85. package/dist/settings.js +156 -21
  86. package/dist/settings.js.map +1 -1
  87. package/dist/zip.js +78 -0
  88. package/dist/zip.js.map +1 -0
  89. package/package.json +4 -3
package/dist/build.js CHANGED
@@ -1,38 +1,44 @@
1
1
  import { copyFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { createHash } from "node:crypto";
3
3
  import { createRequire } from "node:module";
4
- import { relative } from "node:path";
5
4
  import { dirname, join } from "node:path";
6
5
  import { availableParallelism } from "node:os";
7
6
  import picomatch from "picomatch";
8
7
  import { scanVault } from "./scan.js";
8
+ import { htmlEscape } from "./escape.js";
9
+ import { collectDataJsonVaultRefs, copyReferencedImages, copyReferencedPassthroughs, } from "./asset-refs.js";
10
+ import { downloadFilePaths } from "./render/handlers/builtin/download.js";
11
+ import { foundryManifestPaths, manifestDownloadPath } from "./render/handlers/builtin/foundry-manifest.js";
12
+ import { warnFoundryDocCollisions } from "./foundry-meta.js";
9
13
  import { compressImage } from "./images.js";
10
- import { IMAGE_EXT_RE, PASSTHROUGH_EXT_RE, COMPRESSIBLE_EXT_RE, contentTypeForExt, } from "./render/extensions.js";
14
+ import { IMAGE_EXT_RE, PASSTHROUGH_EXT_RE, COMPRESSIBLE_EXT_RE, } from "./render/extensions.js";
11
15
  import { buildFavicon } from "./favicon.js";
12
16
  import { renderMarkdown } from "./render/pipeline.js";
13
17
  import { extractH1 } from "./render/frontmatter.js";
14
- import { CLI_VERSION, MANIFEST_VERSION, ID_SCHEME } from "./version.js";
15
18
  import { renderLayout, render404 } from "./render/layout.js";
16
- import { writeFoundryImporter } from "./foundry-importer.js";
17
19
  import { slugify } from "./render/slug.js";
18
20
  import { buildPreview } from "./render/preview.js";
19
21
  import { resolvePageImage } from "./render/cover.js";
20
22
  import { DEFAULT_CSS, renderThemeOverride } from "./render/styles.js";
21
23
  import { loadObsidianSnippets } from "./obsidian.js";
22
24
  import { loadSettings, writeSettings, SETTINGS_FILE } from "./settings.js";
23
- import { loadConfig } from "./config.js";
25
+ import { loadConfig, saveConfig } from "./config.js";
26
+ import { applyFrontmatterDefaults, compileFrontmatterRules } from "./frontmatter-defaults.js";
24
27
  import matter from "gray-matter";
25
- import { renderAuthMiddleware, LOGIN_HTML } from "./render/auth-template.js";
26
- import { htmlAttr } from "./escape.js";
28
+ import { renderAuthMiddleware, renderLoginPage } from "./render/auth-template.js";
27
29
  import { renderFooterHtml } from "./render/footer.js";
28
30
  import { buildRegistry } from "./render/handlers/types.js";
29
- import { battlemapLayerPaths } from "./render/handlers/builtin/battlemap.js";
30
31
  import { loadUserHandlers } from "./render/handlers/loader.js";
31
32
  import { BUILTIN_HANDLERS } from "./render/handlers/builtin/index.js";
32
33
  import { bundleHandlerAssets } from "./render/handlers/assets.js";
33
34
  import { runMigrations } from "./migrate/run.js";
34
35
  import { cacheDir } from "./paths.js";
35
36
  import { formatDuration, pMap, Progress } from "./util.js";
37
+ import { buildGrafts, contentHash, moduleManifest, moduleGrafts, packsFor, pagesFrom, withFolderIndexes } from "./foundry-grafts.js";
38
+ import { toFoundryHtml, dualVariantBody } from "./foundry-html.js";
39
+ import { zip } from "./zip.js";
40
+ import { moduleVersion } from "./foundry-version.js";
41
+ import { loadDataJson } from "./foundry-meta.js";
36
42
  /**
37
43
  * Output layout when there are multiple roles:
38
44
  *
@@ -49,26 +55,110 @@ import { formatDuration, pMap, Progress } from "./util.js";
49
55
  * Single-role builds (the default `public`-only case) collapse
50
56
  * `_variants/public/...` up to the root.
51
57
  */
52
- export async function buildSite(opts) {
58
+ /**
59
+ * Add basename-slug keys to an asset index, first-write-wins in the vault's
60
+ * sorted path order.
61
+ *
62
+ * Obsidian resolves `![[map.png]]` by basename, so two files with the same
63
+ * name in different folders compete for one key. Writing the key from inside
64
+ * the concurrent staging pass meant whichever finished second won, and that
65
+ * varied between builds on identical input — a page could silently get a
66
+ * different image run to run. Doing it here, sequentially over the sorted
67
+ * list, makes the winner deterministic and lets us say which files collided.
68
+ * (Full-path keys stay in the staging pass; those are unique by definition.)
69
+ */
70
+ function addBasenameKeys(index, files, label) {
71
+ const claimed = new Map(); // slug → winning source path
72
+ for (const f of files) {
73
+ const entry = index.get(f.path);
74
+ if (!entry)
75
+ continue; // staging failed for this file; nothing to point at
76
+ const slug = slugify(f.path.split("/").pop());
77
+ const winner = claimed.get(slug);
78
+ if (winner === undefined) {
79
+ claimed.set(slug, f.path);
80
+ index.set(slug, entry);
81
+ continue;
82
+ }
83
+ console.warn(` ${label} name collision: '${f.path}' and '${winner}' share a filename. `
84
+ + `Bare references like the basename resolve to '${winner}'; `
85
+ + `use the folder path to reach the other.`);
86
+ }
87
+ }
88
+ /**
89
+ * Emit `sitemap.xml` and `robots.txt` at the deploy root.
90
+ *
91
+ * **Only the default role's pages are listed.** A sitemap naming gated pages
92
+ * would advertise that they exist, and their URLs, to anyone who fetches it —
93
+ * the middleware would still refuse the content, but the leak is the point of
94
+ * a sitemap, so it must never see above the lowest tier.
95
+ *
96
+ * Both files are written only when `site_url` is set, because a sitemap needs
97
+ * absolute URLs and nothing else in the build knows the deploy's public
98
+ * hostname (a Pages project can answer on several).
99
+ */
100
+ async function writeSitemap(outputDir, siteUrl, pagePaths) {
101
+ const base = siteUrl.replace(/\/+$/, "");
102
+ const urls = pagePaths
103
+ .map((p) => p.replace(/\.md$/i, ""))
104
+ .map((p) => (p === "index" ? "" : p.replace(/\/index$/i, "")))
105
+ .sort()
106
+ .map((p) => `${base}/${p.split("/").map(encodeURIComponent).join("/")}`)
107
+ // index.md becomes the bare base URL rather than "<base>/".
108
+ .map((u) => u.replace(/\/$/, ""));
109
+ const body = [...new Set(urls)]
110
+ .map((u) => ` <url><loc>${htmlEscape(u)}</loc></url>`)
111
+ .join("\n");
112
+ await writeFile(join(outputDir, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8"?>\n`
113
+ + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`);
114
+ await writeFile(join(outputDir, "robots.txt"), `User-agent: *\nAllow: /\nSitemap: ${base}/sitemap.xml\n`);
115
+ }
116
+ /**
117
+ * Warn about a `default_frontmatter` rule assigning a role the vault has not
118
+ * configured.
119
+ *
120
+ * `default_role` validated itself and said so when it was wrong. Moving the
121
+ * job into a rule would have dropped that: a typo would silently supply a role
122
+ * nothing recognises, and every page it matched would fall back to the lowest
123
+ * tier — publishing a vault meant to be private, quietly.
124
+ */
125
+ function warnUnknownRoles(rules, known, roles) {
126
+ for (const rule of rules) {
127
+ const role = rule.data?.["role"];
128
+ if (typeof role === "string" && role && !known.has(role)) {
129
+ console.warn(` settings.md: default_frontmatter rule '${rule.match}' assigns role `
130
+ + `"${role}", which is not one of [${roles.join(", ")}]. Pages matching it `
131
+ + `fall back to "${roles[0]}".`);
132
+ }
133
+ }
134
+ }
135
+ export async function buildSite(input) {
53
136
  const start = Date.now();
54
137
  const concurrency = Math.max(2, availableParallelism());
55
138
  // Run any pending schema / layout migrations before reading anything
56
139
  // else. The framework is idempotent: already-migrated vaults pay only
57
140
  // the cost of a few stat() calls. See cli/src/migrate/.
58
- await runMigrations(opts.vaultPath);
141
+ await runMigrations(input.vaultPath);
59
142
  // ── Settings (user-editable) ─────────────────────────────────────────────
60
- const settings = await loadSettings(opts.vaultPath);
143
+ const settings = await loadSettings(input.vaultPath);
61
144
  for (const w of settings.warnings)
62
145
  console.warn(` ${w}`);
63
146
  if (settings.exists && settings.changed) {
64
- await writeSettings(opts.vaultPath, settings.values);
147
+ await writeSettings(input.vaultPath, settings.values);
65
148
  console.log(` rewrote ${SETTINGS_FILE} to canonical format`);
66
149
  }
67
- opts = {
68
- ...opts,
69
- vaultName: opts.vaultName === "Vault" ? settings.values.vault_name : opts.vaultName,
70
- imageQuality: opts.imageQuality === 85 ? settings.values.image_quality : opts.imageQuality,
71
- maxFileBytes: opts.maxFileBytes === 25 * 1024 * 1024 ? settings.values.max_file_bytes : opts.maxFileBytes,
150
+ // settings.md is the single source of truth for vault properties (see the
151
+ // SCHEMA in settings.ts). These used to also be CLI flags, with "was the
152
+ // flag passed?" inferred by comparing against the flag's default — and the
153
+ // defaults matched the schema's, so `build -q 85` against an
154
+ // `image_quality: 40` vault silently produced 40. The flags are gone; the
155
+ // vault decides.
156
+ let opts = {
157
+ ...input,
158
+ siteUrl: settings.values.site_url,
159
+ vaultName: settings.values.vault_name,
160
+ imageQuality: settings.values.image_quality,
161
+ maxFileBytes: settings.values.max_file_bytes,
72
162
  };
73
163
  // ── Custom handlers ──────────────────────────────────────────────────────
74
164
  // Built-ins ship with the CLI; user handlers live in `.vaults/handlers/`
@@ -94,20 +184,13 @@ export async function buildSite(opts) {
94
184
  const cfg = await loadConfig(opts.vaultPath, {});
95
185
  const roles = cfg.roles.length > 0 ? cfg.roles : ["public"];
96
186
  const allRoleSet = new Set(roles);
97
- // Pages without a 'role:' frontmatter fall back to settings.default_role
98
- // when set (and valid); otherwise the lowest-tier role. This lets a
99
- // DM-by-default vault flip the polarity instead of tagging every private
100
- // page individually.
101
- let defaultRole = roles[0];
102
- if (settings.values.default_role) {
103
- if (allRoleSet.has(settings.values.default_role)) {
104
- defaultRole = settings.values.default_role;
105
- }
106
- else {
107
- console.warn(` settings.md: default_role "${settings.values.default_role}" `
108
- + `not in configured roles [${roles.join(", ")}], using "${defaultRole}"`);
109
- }
110
- }
187
+ // A page's role comes from its frontmatter, and `default_frontmatter` is
188
+ // what supplies one to pages that state none a DM-by-default vault sets
189
+ // `role: dm` in a rule matching `**`. This is the floor for anything that
190
+ // reaches here without a role at all, which means a vault whose rules do not
191
+ // cover it.
192
+ const defaultRole = roles[0];
193
+ warnUnknownRoles(settings.values.default_frontmatter, allRoleSet, roles);
111
194
  // ── Scan + filter ────────────────────────────────────────────────────────
112
195
  console.log(`Scanning ${opts.vaultPath}...`);
113
196
  const scanStart = Date.now();
@@ -151,9 +234,45 @@ export async function buildSite(opts) {
151
234
  // the passthrough pool (still reference-gated). The user-facing
152
235
  // warning lists exactly which paths got dropped so unintentional
153
236
  // omissions surface immediately.
237
+ // Read the ```download blocks first: a file named by one is shipped whatever
238
+ // its extension, so it must not also be reported as skipped. Warning about a
239
+ // file that then gets staged sends the reader off to set
240
+ // include_unknown_files for no reason.
241
+ const downloadPaths = new Set();
242
+ const manifestPaths = new Set();
243
+ const manifestDownloads = new Map();
244
+ for (const f of markdownFiles) {
245
+ const source = await readFile(f.absolute, "utf8");
246
+ for (const path of downloadFilePaths(source))
247
+ downloadPaths.add(path);
248
+ for (const path of foundryManifestPaths(source))
249
+ manifestPaths.add(path);
250
+ }
251
+ // A foundry-manifest block names only the manifest. The zip is whatever the
252
+ // manifest's own download field says, so read it and ship that too — the
253
+ // author should not have to repeat a path the manifest already states, and
254
+ // an install needs both halves present or it fails on the second fetch.
255
+ for (const rel of manifestPaths) {
256
+ downloadPaths.add(rel);
257
+ const file = withinLimit.find((f) => f.path === rel);
258
+ if (!file)
259
+ continue;
260
+ const { path, absolute } = manifestDownloadPath(await readFile(file.absolute, "utf8"), settings.values.site_url);
261
+ if (absolute) {
262
+ console.warn(` ${rel}: "download" points outside this vault (${absolute}), so the file is not`
263
+ + ` staged into the deploy. If it is meant to be this vault's own file, either`
264
+ + ` set 'site_url' to the host it names or write the path relative and let the`
265
+ + ` build make it absolute.`);
266
+ }
267
+ if (path) {
268
+ downloadPaths.add(path);
269
+ manifestDownloads.set(rel, path);
270
+ }
271
+ }
154
272
  const unknownFiles = withinLimit.filter((f) => !/\.md$|\.base$/i.test(f.path)
155
273
  && !IMAGE_EXT_RE.test(f.path)
156
- && !PASSTHROUGH_EXT_RE.test(f.path));
274
+ && !PASSTHROUGH_EXT_RE.test(f.path)
275
+ && !downloadPaths.has(f.path));
157
276
  const includeUnknown = settings.values.include_unknown_files;
158
277
  if (unknownFiles.length > 0) {
159
278
  if (includeUnknown) {
@@ -173,7 +292,26 @@ export async function buildSite(opts) {
173
292
  // Effective passthrough list: recognised media plus (optionally) unknowns.
174
293
  const stagedPassthroughs = includeUnknown
175
294
  ? [...passthroughFiles, ...unknownFiles]
176
- : passthroughFiles;
295
+ : [...passthroughFiles];
296
+ // Files named by a ```download block join the pool whatever their
297
+ // extension. A download is usually a .zip or a module.json, which the
298
+ // passthrough list calls unknown and drops — but naming one in a block is
299
+ // the author asking for it by name, which is exactly the intent
300
+ // include_unknown_files exists to require. Role gating is untouched: these
301
+ // are still reference-gated, so a download on a patron page reaches the
302
+ // patron variant and no other.
303
+ if (downloadPaths.size > 0) {
304
+ const already = new Set(stagedPassthroughs.map((f) => f.path));
305
+ const promoted = withinLimit.filter((f) => downloadPaths.has(f.path) && !already.has(f.path));
306
+ const missing = [...downloadPaths].filter((p) => !withinLimit.some((f) => f.path === p));
307
+ for (const p of missing) {
308
+ console.warn(` download block names '${p}', which is not in the vault; the link will 404.`);
309
+ }
310
+ if (promoted.length > 0) {
311
+ console.log(` staging ${promoted.length} file(s) named by download / foundry-manifest blocks`);
312
+ stagedPassthroughs.push(...promoted);
313
+ }
314
+ }
177
315
  // ── Shared content (read once, reused across roles) ─────────────────────
178
316
  const sources = new Map();
179
317
  await pMap(markdownFiles, concurrency, async (f) => {
@@ -191,9 +329,17 @@ export async function buildSite(opts) {
191
329
  // normalized for Obsidian quirks first; malformed YAML throws inside
192
330
  // parsePageFrontmatter and aborts the build rather than silently dropping a
193
331
  // page's metadata (and, with it, its role gate).
332
+ const frontmatterRules = compileFrontmatterRules(settings.values.default_frontmatter);
194
333
  const parsedSources = new Map();
195
334
  for (const f of markdownFiles) {
196
- parsedSources.set(f.path, parsePageFrontmatter(sources.get(f.path), f.path));
335
+ const parsed = parsePageFrontmatter(sources.get(f.path), f.path);
336
+ // Applied here, at the one place a page's frontmatter is read, so that
337
+ // roles, the rendered wiki, the manifest the Foundry client syncs from and
338
+ // the module compiler all see the same page. A default that only some of
339
+ // them honoured would be a way for a synced vault and an installed module
340
+ // to disagree about the same file.
341
+ applyFrontmatterDefaults(f.path, parsed.data, frontmatterRules);
342
+ parsedSources.set(f.path, parsed);
197
343
  }
198
344
  // Derive role/title/aliases per page from that parse. A role that's present
199
345
  // but isn't a configured role fails the build (collected below): falling back
@@ -225,7 +371,7 @@ export async function buildSite(opts) {
225
371
  if (roleErrors.length > 0) {
226
372
  throw new Error(`Unknown frontmatter role on ${roleErrors.length} page(s) (build aborted so gated pages can't leak):\n${roleErrors.join("\n")}`);
227
373
  }
228
- // Stage assets referenced inside each page's foundry.data_json (Scene
374
+ // Stage assets referenced inside each page's foundry.patch_json (Scene
229
375
  // backgrounds / ambient sounds / tile art live in that JSON, not the page
230
376
  // frontmatter, so the asset scanners below consult p.foundryAssets).
231
377
  await Promise.all(allPageMetas.map(async (p) => {
@@ -265,12 +411,13 @@ export async function buildSite(opts) {
265
411
  // key is what stops identically-named assets in different scene folders
266
412
  // (e.g. a shared `Water Fountain (Loop).ogg`) from colliding under one
267
413
  // basename slug and staging only one of them.
268
- const entry = { sourcePath: f.path, outputPath: compressed.outputPath };
269
- imageIndex.set(slugify(f.path.split("/").pop()), entry);
270
- imageIndex.set(f.path, entry);
414
+ // Full-path key only; basename keys are added afterwards, in sorted
415
+ // order, so a duplicated filename resolves deterministically.
416
+ imageIndex.set(f.path, { sourcePath: f.path, outputPath: compressed.outputPath });
271
417
  }, (done, total) => progress.update(done, total));
272
418
  progress.done(`${imageFiles.length} processed (${cacheHits} cached, ${imageFiles.length - cacheHits} compressed)`);
273
419
  }
420
+ addBasenameKeys(imageIndex, imageFiles, "image");
274
421
  // ── Passthrough files (audio, video, PDF, epub) ────────────────────────
275
422
  // Staged once and copied into a variant only when a visible page in that
276
423
  // variant references the file by basename or relative path. Same gating
@@ -288,12 +435,11 @@ export async function buildSite(opts) {
288
435
  // Dual-keyed like imageIndex: basename slug for body refs, full
289
436
  // vault-relative path for `@vault/PATH` refs (ambient sounds in
290
437
  // data_json), so same-named files in different folders don't collide.
291
- const entry = { sourcePath: f.path, outputPath: f.path };
292
- passthroughIndex.set(slugify(f.path.split("/").pop()), entry);
293
- passthroughIndex.set(f.path, entry);
438
+ passthroughIndex.set(f.path, { sourcePath: f.path, outputPath: f.path });
294
439
  }, (done, total) => progress.update(done, total));
295
440
  progress.done(`${stagedPassthroughs.length} staged`);
296
441
  }
442
+ addBasenameKeys(passthroughIndex, stagedPassthroughs, "passthrough");
297
443
  // Shared CSS bundle.
298
444
  //
299
445
  // Every file written to outputDir ROOT (rather than into _variants/<role>/)
@@ -325,20 +471,13 @@ export async function buildSite(opts) {
325
471
  // One combined hash keeps it simple; any shared-asset change busts all.
326
472
  // katex version rides in the hash so a dependency upgrade re-fetches the
327
473
  // (otherwise never-changing) /katex/katex.min.css on math pages.
328
- const katexVersion = createRequire(import.meta.url)("katex/package.json").version;
474
+ const katexVersion = katexRequire()("katex/package.json").version;
329
475
  const assetVersion = createHash("md5")
330
476
  .update(DEFAULT_CSS + themeOverride + userCss + handlerAssets.js + handlerAssets.css + katexVersion)
331
477
  .digest("hex")
332
478
  .slice(0, 10);
333
- // Foundry importer bundle: one ESM file the Foundry module fetches at
334
- // sync time, plus a tiny version manifest with the SHA-256 the host
335
- // verifies against its trust cache.
336
- await writeFoundryImporter(opts.outputDir);
337
- // Foundry-import bundles are written per-variant inside the role loop
338
- // below (instead of at the root) so the middleware role-gates them. A
339
- // public visitor can't fetch the dm-tier handler bundle even if it
340
- // contains different content. The path stays `/_handlers.foundry.{js,css}`
341
- // — the middleware rewrites root requests to the matching variant.
479
+ const foundryEnabled = settings.values.foundry.package !== "none";
480
+ const foundryPackaging = settings.values.foundry.package === "adventure" ? "adventure" : "compendium";
342
481
  // Favicon; either user-supplied via settings.favicon, or a generated
343
482
  // default with the vault's first letter in accent on the theme background.
344
483
  try {
@@ -379,10 +518,65 @@ export async function buildSite(opts) {
379
518
  if (cover)
380
519
  meta.coverImage = cover;
381
520
  }
521
+ warnFoundryDocCollisions(allPageMetas);
522
+ // Said once for the vault, not once per role: it is a fact about the
523
+ // settings, and the per-variant emitter would repeat it for each.
524
+ if (foundryEnabled && !settings.values.foundry.core_version
525
+ && allPageMetas.some((p) => p.frontmatter?.["foundry"]?.source)) {
526
+ console.warn(" foundry.core_version is not set, so documents carry no _stats.coreVersion."
527
+ + " Foundry rejects those and builds a degraded copy instead: a Scene loses its levels."
528
+ + " Set it to the full Foundry version your exported JSON came from, e.g. 14.359.");
529
+ }
530
+ // The module is only emitted when there is a URL to fetch the vault from,
531
+ // so without one the deploy carries content Foundry has no way to install.
532
+ if (foundryEnabled && !opts.siteUrl) {
533
+ console.warn(" site_url is not set, so no Foundry module is written."
534
+ + " The vault deploys, but there is nothing to install in Foundry."
535
+ + " Set it to the URL this vault is served from, e.g. https://notes.example.com.");
536
+ }
537
+ // Read each page's `foundry.patch_json` once, before any variant is
538
+ // rendered: the file is the same whoever is reading, and a Scene sidecar is
539
+ // the largest thing in the vault to be re-parsing per role. It stays separate
540
+ // from the page's inline patch, because it is a weaker statement than one —
541
+ // an export carries whatever Foundry had, including its placeholders.
542
+ const foundryPatches = new Map();
543
+ await Promise.all(allPageMetas.map(async (p) => {
544
+ const fo = p.frontmatter?.["foundry"];
545
+ if (!fo || typeof fo !== "object" || Array.isArray(fo))
546
+ return;
547
+ const block = fo;
548
+ const ref = block["patch_json"];
549
+ if (typeof ref !== "string" || !ref.trim())
550
+ return;
551
+ const loaded = await loadDataJson(opts.vaultPath, ref.trim(), p.path);
552
+ if (!loaded || typeof loaded !== "object" || Array.isArray(loaded))
553
+ return;
554
+ foundryPatches.set(p.path, loaded);
555
+ }));
556
+ // A module id from the vault name: stable, lowercase, no spaces. It names
557
+ // the packs too, so changing it orphans what a reader already built.
558
+ const foundryModuleId = opts.vaultName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
559
+ || "vault";
382
560
  // ── Per-role variant builds ─────────────────────────────────────────────
383
561
  const perRolePageCount = {};
384
562
  const collapseToRoot = roles.length === 1;
563
+ let defaultRolePagePaths = [];
385
564
  let katexCopied = false;
565
+ // What the Foundry provider compares a cached file against, so a rebuild
566
+ // re-fetches only what changed. Keyed "<variant>/<path>", and hashed once
567
+ // per asset rather than once per variant: every variant copies the same
568
+ // staged file. Roles run least privileged first, so by the time a variant
569
+ // writes its grafts.json the map holds every variant it can reference.
570
+ const assetHashes = new Map();
571
+ const hashOf = new Map();
572
+ const hashAsset = async (path, dir) => {
573
+ let digest = hashOf.get(path);
574
+ if (!digest) {
575
+ digest = createHash("md5").update(await readFile(join(dir, path))).digest("hex").slice(0, 16);
576
+ hashOf.set(path, digest);
577
+ }
578
+ return digest;
579
+ };
386
580
  for (const role of roles) {
387
581
  const variantDir = collapseToRoot
388
582
  ? opts.outputDir
@@ -400,6 +594,7 @@ export async function buildSite(opts) {
400
594
  role,
401
595
  visibleRoles,
402
596
  redactRoles,
597
+ gated: !collapseToRoot,
403
598
  variantDir,
404
599
  vaultName: opts.vaultName,
405
600
  vaultPath: opts.vaultPath,
@@ -408,6 +603,7 @@ export async function buildSite(opts) {
408
603
  parsedSources,
409
604
  baseSources,
410
605
  imageIndex,
606
+ manifestDownloads,
411
607
  imageStagingDir,
412
608
  passthroughIndex,
413
609
  passthroughStagingDir: otherStagingDir,
@@ -422,6 +618,12 @@ export async function buildSite(opts) {
422
618
  allWarnings: opts.allWarnings,
423
619
  });
424
620
  perRolePageCount[role] = stats.pageCount;
621
+ for (const path of stats.assetPaths) {
622
+ assetHashes.set(`${role}/${path}`, await hashAsset(path, variantDir));
623
+ }
624
+ // Only the default (lowest) role feeds the sitemap; see writeSitemap.
625
+ if (role === roles[0])
626
+ defaultRolePagePaths = stats.pagePaths;
425
627
  if (!collapseToRoot)
426
628
  console.log(` variant '${role}': ${stats.pageCount} pages`);
427
629
  // KaTeX stylesheet + fonts, shared at the deploy root. Copied lazily on
@@ -431,36 +633,98 @@ export async function buildSite(opts) {
431
633
  await copyKatexAssets(join(opts.outputDir, "katex"));
432
634
  katexCopied = true;
433
635
  }
434
- // Foundry-import opt-in bundles, emitted INSIDE the variant directory
435
- // (not at the deploy root) so the auth middleware role-gates them.
436
- // Single-role builds collapse variantDir to outputDir, so the file
437
- // ends up at root automatically. The Foundry module fetches by the
438
- // canonical `/_handlers.foundry.{js,css}` path; the middleware
439
- // rewrites that to the matching `_variants/<role>/...` per the
440
- // requesting bearer token's role.
441
- // Foundry-import subset bundles. The Foundry module fetches these by
442
- // their canonical `/_handlers.foundry.{js,css}` paths; the middleware
443
- // role-gates per the requesting bearer's variant.
444
- if (handlerAssets.foundry) {
445
- if (handlerAssets.foundry.js.length > 0) {
446
- await writeFile(join(variantDir, "_handlers.foundry.js"), handlerAssets.foundry.js);
447
- }
448
- if (handlerAssets.foundry.css.length > 0) {
449
- await writeFile(join(variantDir, "_handlers.foundry.css"), handlerAssets.foundry.css);
450
- }
636
+ // The entry list graft builds from, inside the variant directory so the
637
+ // auth middleware gates it: a role only ever receives the pages it may
638
+ // read, and the GM's variant is the one that lists everything.
639
+ if (foundryEnabled) {
640
+ const graftPages = withFolderIndexes(pagesFrom(allPageMetas, visibleRoles, foundryPatches), roles);
641
+ const grafts = buildGrafts(graftPages, {
642
+ vaultId: foundryModuleId,
643
+ roles,
644
+ playerRole: settings.values.foundry.player_role,
645
+ buildRole: role,
646
+ packs: packsFor(foundryModuleId),
647
+ coreVersion: settings.values.foundry.core_version,
648
+ system: settings.values.foundry.system,
649
+ packaging: foundryPackaging,
650
+ title: opts.vaultName,
651
+ assets: Object.fromEntries(assetHashes),
652
+ });
653
+ for (const warning of grafts.warnings)
654
+ console.warn(` warning: ${warning}`);
655
+ // A second body per page, with links resolved to UUIDs and media pointed
656
+ // at markers the provider fills in. Foundry gets this one; the wiki keeps
657
+ // the plain `.body.html`, since the same HTML cannot serve both.
658
+ // A player-visible page's body carries both renders: the GM's inside a
659
+ // secret section, the player's in the open (see dualVariantBody). Roles
660
+ // build lowest first, so the player variant's body is already on disk.
661
+ const playerRole = settings.values.foundry.player_role;
662
+ const ceiling = roles.indexOf(playerRole);
663
+ const playerDir = collapseToRoot
664
+ ? opts.outputDir
665
+ : join(opts.outputDir, "_variants", playerRole);
666
+ // Only bodies an entry references: a page kept out of Foundry, or one
667
+ // whose prose goes nowhere, must not move the hash and prompt a rebuild.
668
+ const referenced = new Set([...JSON.stringify(grafts.file.entries).matchAll(/@vaults\/[^/"]+\/((?:[^"\\]|\\.)+?)\.foundry\.html/g)]
669
+ .map((m) => JSON.parse(`"${m[1]}"`) + ".md"));
670
+ const bodyHashes = new Map();
671
+ await pMap(graftPages.filter((p) => referenced.has(p.path)), concurrency, async (page) => {
672
+ const base = page.path.replace(/\.md$/i, "");
673
+ const body = await readFile(join(variantDir, `${base}.body.html`), "utf8");
674
+ const gm = toFoundryHtml(body, grafts.links, role);
675
+ const observable = ceiling >= 0 && roles.indexOf(page.role) <= ceiling;
676
+ const dual = observable && roles.indexOf(role) > ceiling;
677
+ const out = dual
678
+ ? dualVariantBody(gm, toFoundryHtml(await readFile(join(playerDir, `${base}.body.html`), "utf8"), grafts.links, playerRole))
679
+ : gm;
680
+ await writeFile(join(variantDir, `${base}.foundry.html`), out);
681
+ bodyHashes.set(page.path, createHash("md5").update(out).digest("hex"));
682
+ });
683
+ grafts.file.contentHash = contentHash(grafts.file.entries, grafts.file.assets ?? {}, bodyHashes);
684
+ await mkdir(join(variantDir, "_foundry"), { recursive: true });
685
+ await writeFile(join(variantDir, "_foundry", "grafts.json"), JSON.stringify(grafts.file, null, 2));
686
+ // The freshness signal, apart from the 1MB+ file it describes: the
687
+ // module reads this on world load to ask "anything new since I built?".
688
+ await writeFile(join(variantDir, "_foundry", "version.json"), JSON.stringify({ content: grafts.file.contentHash }));
451
689
  }
452
- // Write a per-variant _manifest.json so external clients (Foundry, MCP,
453
- // etc.) can do an incremental diff. Includes EVERY file that variant
454
- // serves; html, md, images (as relative paths into shared root), css.
455
- // bodyMeta carries per-page Foundry reskin metadata; folded into each
456
- // body row's hash so meta-only changes trigger a re-sync.
457
- const manifest = await buildManifest(opts.outputDir, variantDir, stats.bodyMeta, !collapseToRoot, roles, opts.vaultName, {
458
- hasHandlerJs,
459
- hasHandlerCss,
460
- hasFoundryJs: (handlerAssets.foundry?.js.length ?? 0) > 0,
461
- hasFoundryCss: (handlerAssets.foundry?.css.length ?? 0) > 0,
690
+ }
691
+ // The module a reader installs, served by the vault itself: no release, no
692
+ // zip, no registry the GM sends players a link to their own vault. It is
693
+ // generated once and then inert, so pushing content never reinstalls it.
694
+ if (foundryEnabled && opts.siteUrl) {
695
+ const dir = join(opts.outputDir, "_foundry");
696
+ await mkdir(dir, { recursive: true });
697
+ // Versioned from the manifest itself, so it reads as a date and moves only
698
+ // when the module changes. `assetVersion` is a hash of the stylesheet: it
699
+ // cannot be ordered, which is what Foundry needs to offer an update.
700
+ const manifest = moduleManifest({
701
+ moduleId: foundryModuleId,
702
+ title: opts.vaultName,
703
+ vaultUrl: opts.siteUrl,
704
+ systemId: settings.values.foundry.system,
705
+ packaging: foundryPackaging,
706
+ extra: settings.values.foundry.module,
462
707
  });
463
- await writeFile(join(variantDir, "_manifest.json"), JSON.stringify(manifest));
708
+ const stamped = moduleVersion(manifest, cfg.foundryModule);
709
+ manifest["version"] = stamped.version;
710
+ if (stamped.version !== cfg.foundryModule?.version) {
711
+ await saveConfig(input.vaultPath, { ...cfg, foundryModule: stamped });
712
+ }
713
+ await writeFile(join(dir, "module.json"), JSON.stringify(manifest, null, 2));
714
+ // The module itself, which is what Foundry installs from the manifest. It
715
+ // holds no content: a manifest, and the one line naming the vault to read.
716
+ //
717
+ // The marker exists only inside the archive. graft reads a module's entry
718
+ // file from `modules/<id>/grafts.json` — the installed copy — so serving
719
+ // one at the deploy root would be a file nothing fetches, sharing a name
720
+ // with the per-variant entry list the middleware rewrites `/_foundry/` to.
721
+ // Pack directories are absent on purpose: Foundry creates them.
722
+ const manifestJson = await readFile(join(dir, "module.json"));
723
+ const marker = Buffer.from(JSON.stringify(moduleGrafts(opts.siteUrl, !collapseToRoot), null, 2) + "\n");
724
+ await writeFile(join(dir, "module.zip"), zip([
725
+ { name: `${foundryModuleId}/module.json`, data: manifestJson },
726
+ { name: `${foundryModuleId}/grafts.json`, data: marker },
727
+ ]));
464
728
  }
465
729
  // ── Pages Functions ─────────────────────────────────────────────────────
466
730
  // Auth middleware ships only for multi-role builds. Single-role deploys
@@ -496,30 +760,40 @@ export async function buildSite(opts) {
496
760
  : null;
497
761
  const middleware = renderAuthMiddleware({
498
762
  roles,
763
+ foundry: foundryEnabled,
499
764
  rolePasswords: cfg.rolePasswords,
500
765
  ...(patreonForFn ? { patreon: patreonForFn } : {}),
501
766
  ...(oidcForFn ? { oidc: oidcForFn } : {}),
502
767
  });
503
768
  await writeFile(join(fnDir, "_middleware.js"), middleware);
504
- // Login page; drop in the role list (everything above the default).
769
+ // Login page, showing only the methods this deploy actually has. A role
770
+ // is reachable by password only if a hash was set for it, so a vault
771
+ // authenticating purely through Patreon or OIDC gets no password form
772
+ // and no role selector.
505
773
  const protectedRoles = roles.slice(1);
506
- const opts_html = protectedRoles
507
- .map((r) => `<option value="${r}">${r}</option>`)
508
- .join("");
509
- const patreonRolesAttr = patreonForFn
510
- ? ` data-patreon-roles="${Object.keys(patreonForFn.tiers).join(",")}"`
511
- : "";
512
- // displayName is free text (unlike role names), so attribute-escape it.
513
- const oidcAttr = oidcForFn ? ` data-oidc="${htmlAttr(oidcForFn.displayName)}"` : "";
514
- await writeFile(join(opts.outputDir, "login.html"), LOGIN_HTML
515
- .replace("__ROLE_OPTIONS__", opts_html)
516
- .replace("__PATREON_ROLES_ATTR__", patreonRolesAttr)
517
- .replace("__OIDC_ATTR__", oidcAttr));
518
- const missing = protectedRoles.filter((r) => !cfg.rolePasswords[r]);
519
- if (missing.length > 0) {
520
- console.warn(` WARNING: no password set for role(s): ${missing.join(", ")}. Run 'vaults password <role>' before pushing.`);
774
+ const passwordRoles = protectedRoles.filter((r) => cfg.rolePasswords[r]);
775
+ const patreonRoles = patreonForFn ? Object.keys(patreonForFn.tiers) : [];
776
+ const oidcRoles = oidcForFn ? Object.keys(oidcForFn.roleRules ?? {}) : [];
777
+ await writeFile(join(opts.outputDir, "login.html"), renderLoginPage({
778
+ passwordRoles,
779
+ patreonRoles,
780
+ oidcDisplayName: oidcForFn ? oidcForFn.displayName : null,
781
+ }));
782
+ // The error is a role nobody can reach, not a role without a password —
783
+ // password-less is the point when a provider grants the role instead.
784
+ const unreachable = protectedRoles.filter((r) => !cfg.rolePasswords[r] && !patreonRoles.includes(r) && !oidcRoles.includes(r));
785
+ if (unreachable.length > 0) {
786
+ console.warn(` WARNING: no way to sign in as role(s): ${unreachable.join(", ")}. `
787
+ + `Set a password ('vaults password <role>'), map a Patreon tier `
788
+ + `('vaults patreon link <role> <tier-id>'), or add an OIDC rule `
789
+ + `('vaults oidc configure'). Pages at these roles will be unreachable.`);
521
790
  }
522
791
  }
792
+ // Search-engine files. Written from the default role's page list only, so a
793
+ // gated page is never named. Skipped entirely without a site_url.
794
+ if (opts.siteUrl) {
795
+ await writeSitemap(opts.outputDir, opts.siteUrl, defaultRolePagePaths);
796
+ }
523
797
  // Drop the staging dirs; their contents have been copied into each
524
798
  // variant that needs them, so they're no longer required for the deploy.
525
799
  await rm(imageStagingDir, { recursive: true, force: true });
@@ -532,8 +806,6 @@ export async function buildSite(opts) {
532
806
  await rename(workOutputDir, finalOutputDir);
533
807
  console.log(`Built in ${formatDuration(Date.now() - start)}.`);
534
808
  return {
535
- files,
536
- withinLimit,
537
809
  roles,
538
810
  perRolePageCount,
539
811
  imageCount: imageFiles.length,
@@ -596,6 +868,7 @@ async function buildVariant(a) {
596
868
  bases: a.baseSources,
597
869
  defaultImageWidth: a.settings.default_image_width,
598
870
  redactRoles: a.redactRoles,
871
+ gated: a.gated,
599
872
  handlers: a.handlerRegistry,
600
873
  outlinksByPath,
601
874
  };
@@ -634,7 +907,6 @@ async function buildVariant(a) {
634
907
  const previewMode = previewModeOf(a.settings.preview_mode);
635
908
  const previewModeMobile = previewModeOf(a.settings.preview_mode_mobile);
636
909
  const previewsEnabled = previewMode !== "none" || previewModeMobile !== "none";
637
- const bodyMeta = new Map();
638
910
  await pMap(visibleMetas, a.concurrency, async (p) => {
639
911
  const r = rendered.get(p.path);
640
912
  const backlinkPaths = backlinkMap.get(p.path) ?? new Set();
@@ -674,7 +946,6 @@ async function buildVariant(a) {
674
946
  // remark/rehype pipeline land in journals as-is, no client-side render.
675
947
  const bodyPath = outputBase + ".body.html";
676
948
  await writeFile(join(a.variantDir, bodyPath), r.html);
677
- bodyMeta.set(bodyPath, await collectBodyMeta(p, a.vaultPath));
678
949
  // Preview JSON feeds the popover; skip it only when neither device class
679
950
  // shows previews (both modes "none").
680
951
  if (previewsEnabled) {
@@ -723,21 +994,39 @@ async function buildVariant(a) {
723
994
  // under the variants that need them so guessing a DM-only image URL on
724
995
  // the public wiki structurally 404s. coverImage feeds in here too so
725
996
  // images named via `image:` frontmatter (no body embed) still ship.
726
- await copyReferencedImages(visibleSources, visibleMetas, a.imageIndex, a.imageStagingDir, a.variantDir);
997
+ const copiedImages = await copyReferencedImages(visibleSources, visibleMetas, a.imageIndex, a.imageStagingDir, a.variantDir);
727
998
  // Passthrough files (audio/video/pdf/epub) follow the same gating
728
999
  // contract as images: ship only into variants whose visible pages
729
1000
  // reference the file. A DM-only audio cue can't ride along into the
730
1001
  // public deploy because no public-tier source mentions it.
731
- await copyReferencedPassthroughs(visibleSources, visibleMetas, a.passthroughIndex, a.passthroughStagingDir, a.variantDir);
732
- return { pageCount: visibleMetas.length, bodyMeta, hasMath: hasMathCss };
1002
+ const copiedOther = await copyReferencedPassthroughs(visibleSources, visibleMetas, a.passthroughIndex, a.passthroughStagingDir, a.variantDir, a.manifestDownloads);
1003
+ return {
1004
+ pageCount: visibleMetas.length,
1005
+ pagePaths: visibleMetas.map((m) => m.path),
1006
+ hasMath: hasMathCss,
1007
+ assetPaths: [...copiedImages, ...copiedOther],
1008
+ };
733
1009
  }
734
1010
  /**
735
- * Copy KaTeX's stylesheet and fonts (from the katex package dependency) to
1011
+ * Resolve the katex package that *rehype-katex* renders with, which is not
1012
+ * necessarily this package's own katex dependency: under pnpm's isolated
1013
+ * node_modules, rehype-katex gets the copy matching its own range. The
1014
+ * stylesheet has to come from that copy, because KaTeX renames its CSS
1015
+ * classes between versions ('sizing' became 'katex-sizing' in 0.18), and a
1016
+ * stylesheet from a different version silently stops matching the markup:
1017
+ * subscripts render at full size instead of 0.7em.
1018
+ */
1019
+ function katexRequire() {
1020
+ const here = createRequire(import.meta.url);
1021
+ return createRequire(here.resolve("rehype-katex"));
1022
+ }
1023
+ /**
1024
+ * Copy KaTeX's stylesheet and fonts (from the katex rehype-katex uses) to
736
1025
  * <outputDir>/katex/. woff2 only: the CSS lists woff2 first, so any browser
737
1026
  * that supports it (all modern ones) never requests the woff/ttf fallbacks.
738
1027
  */
739
1028
  async function copyKatexAssets(destDir) {
740
- const distDir = join(dirname(createRequire(import.meta.url).resolve("katex/package.json")), "dist");
1029
+ const distDir = join(dirname(katexRequire().resolve("katex/package.json")), "dist");
741
1030
  await mkdir(join(destDir, "fonts"), { recursive: true });
742
1031
  await copyFile(join(distDir, "katex.min.css"), join(destDir, "katex.min.css"));
743
1032
  for (const f of await readdir(join(distDir, "fonts"))) {
@@ -746,141 +1035,6 @@ async function copyKatexAssets(destDir) {
746
1035
  }
747
1036
  }
748
1037
  }
749
- /**
750
- * Build the per-body manifest meta from a page's frontmatter + resolved
751
- * cover image. `role` always lands so the Foundry side can apply the
752
- * dmRole permission gate; the foundry / image fields are conditional.
753
- *
754
- * Frontmatter shape forwarded to clients:
755
- * foundry:
756
- * base: <UUID> | <Type>[:<subtype>] # required for instantiation
757
- * sync: false # default true; skip Foundry entirely
758
- * journal: false # default true; doc only, no journal page
759
- * link: doc # default journal; link to the doc instead
760
- * embed: false # default true
761
- * data: { … deep-merged into the doc }
762
- */
763
- async function collectBodyMeta(p, vaultPath) {
764
- const fm = p.frontmatter ?? {};
765
- const out = { role: p.role };
766
- const basename = p.path.split("/").pop().replace(/\.md$/i, "");
767
- if (p.title && p.title !== basename)
768
- out.title = p.title;
769
- const fo = fm["foundry"];
770
- if (fo && typeof fo === "object" && !Array.isArray(fo)) {
771
- const block = {};
772
- const base = fo["base"];
773
- if (typeof base === "string" && base.trim().length > 0)
774
- block.base = base.trim();
775
- const embed = fo["embed"];
776
- if (typeof embed === "boolean")
777
- block.embed = embed;
778
- // foundry.sync: false keeps the page out of Foundry altogether — no
779
- // JournalEntryPage, no derived doc. The page still renders on the wiki.
780
- // Unlike `embed`, which only suppresses the article inside a derived
781
- // doc's description, this drops the page from the sync set entirely.
782
- const sync = fo["sync"];
783
- if (typeof sync === "boolean")
784
- block.sync = sync;
785
- // foundry.journal: false makes the derived doc without the JournalEntryPage
786
- // that normally accompanies it. For a page that exists to carry a Scene or
787
- // an Actor and has no article worth reading in the sidebar.
788
- const journal = fo["journal"];
789
- if (typeof journal === "boolean")
790
- block.journal = journal;
791
- // foundry.link: "doc" makes wikilinks to this page resolve to the document
792
- // it instantiates rather than to its journal page. Implied by
793
- // `journal: false`, where there is no journal page to link to.
794
- const link = fo["link"];
795
- if (link === "doc" || link === "journal")
796
- block.link = link;
797
- const data = fo["data"];
798
- if (data && typeof data === "object" && !Array.isArray(data))
799
- block.data = data;
800
- // foundry.id: an explicit Foundry document id for this page. When set,
801
- // overrides the SHA1-derived id used for both the JournalEntryPage and
802
- // (if foundry.base is present) the instantiated derived doc. Lets users
803
- // hardcode UUIDs that other Foundry-side code (macros, scene flags,
804
- // module integrations) needs to reference. Foundry ids are 16 chars from
805
- // [A-Za-z0-9]; a malformed value is dropped with a warning rather than
806
- // failing the build.
807
- const idVal = fo["id"];
808
- if (typeof idVal === "string") {
809
- const trimmed = idVal.trim();
810
- if (FOUNDRY_ID_RE.test(trimmed))
811
- block.id = trimmed;
812
- else if (trimmed.length > 0) {
813
- console.warn(` ${p.path}: foundry.id "${trimmed}" is not a valid Foundry id (16 chars [A-Za-z0-9]); ignoring`);
814
- }
815
- }
816
- // foundry.data_json: vault-relative path to a JSON file. Read + parse
817
- // at build time and inline into the meta as `data_json`. The Foundry
818
- // module deep-merges it onto the base doc BEFORE foundry.data, so a
819
- // user can layer hand-tuned overrides on top of an exported sheet.
820
- // Folding the parsed object into meta means the body-row hash already
821
- // changes when the JSON content does — no separate change-detection.
822
- const dataJsonPath = fo["data_json"];
823
- if (typeof dataJsonPath === "string" && dataJsonPath.trim().length > 0) {
824
- const parsed = await loadDataJson(vaultPath, dataJsonPath.trim(), p.path);
825
- if (parsed !== null)
826
- block.data_json = parsed;
827
- }
828
- if (Object.keys(block).length > 0)
829
- out.foundry = block;
830
- }
831
- if (p.coverImage)
832
- out.image = p.coverImage;
833
- return out;
834
- }
835
- /** Read + parse a vault-relative JSON file referenced by `foundry.data_json`.
836
- * Warns on missing / unparseable file and returns null so the page renders
837
- * without the overlay rather than failing the build. */
838
- async function loadDataJson(vaultPath, relPath, pagePath) {
839
- const abs = join(vaultPath, relPath);
840
- try {
841
- const raw = await readFile(abs, "utf8");
842
- return JSON.parse(raw);
843
- }
844
- catch (err) {
845
- const code = err.code;
846
- if (code === "ENOENT") {
847
- console.warn(` ${pagePath}: foundry.data_json "${relPath}" not found, skipping`);
848
- }
849
- else {
850
- console.warn(` ${pagePath}: foundry.data_json "${relPath}" failed to parse: ${err.message}`);
851
- }
852
- return null;
853
- }
854
- }
855
- /** Collect the `@vault/...` paths a page's foundry block references, from both
856
- * `foundry.data_json` and `foundry.data`. A Scene's bulk asset refs
857
- * (backgrounds, ambient sounds, tiles) live in that JSON content, and a token's
858
- * ring subject lives in the inline `data` overlay; neither appears anywhere the
859
- * per-variant asset scanners look, so without this they never ship and Foundry
860
- * 404s them. Returns vault-relative paths. */
861
- async function collectDataJsonVaultRefs(vaultPath, fm, pagePath) {
862
- const fo = fm["foundry"];
863
- if (!fo || typeof fo !== "object" || Array.isArray(fo))
864
- return [];
865
- const block = fo;
866
- const out = [];
867
- const collect = (from) => forEachString(from, (s) => {
868
- const path = vaultRefPath(s);
869
- if (path)
870
- out.push(path);
871
- });
872
- const rel = block["data_json"];
873
- if (typeof rel === "string" && rel.trim()) {
874
- const parsed = await loadDataJson(vaultPath, rel.trim(), pagePath);
875
- if (parsed !== null)
876
- collect(parsed);
877
- }
878
- collect(block["data"]);
879
- return out;
880
- }
881
- /** Foundry document ids: exactly 16 chars from [A-Za-z0-9]. Validated when
882
- * authors set `foundry.id` to override the SHA1-derived default. */
883
- const FOUNDRY_ID_RE = /^[A-Za-z0-9]{16}$/;
884
1038
  /** Coerce settings.theme to the layout's narrowed union, defaulting to
885
1039
  * "auto" for any unrecognised value rather than failing the build. */
886
1040
  function themeOf(s) {
@@ -889,119 +1043,6 @@ function themeOf(s) {
889
1043
  function previewModeOf(s) {
890
1044
  return s === "none" || s === "sticky" ? s : "normal";
891
1045
  }
892
- const EMBED_RE = /!\[\[([^\[\]|#\n]+?)(?:\|[^\[\]#\n]*)?\]\]/g;
893
- // A ```gallery code block. Its body lists images by name (one per line,
894
- // optional `| caption`), which the gallery handler renders but the source
895
- // scanners would otherwise never see — so we read the block here to stage
896
- // the referenced images per variant, the same way `![[ ]]` embeds are staged.
897
- const GALLERY_BLOCK_RE = /^```gallery[^\n]*\n([\s\S]*?)^```/gm;
898
- /** Image basenames referenced inside a page's ```gallery blocks. */
899
- function galleryImageNames(source) {
900
- const names = [];
901
- for (const block of source.matchAll(GALLERY_BLOCK_RE)) {
902
- for (const line of block[1].split("\n")) {
903
- const trimmed = line.trim();
904
- if (!trimmed || trimmed.startsWith("#"))
905
- continue;
906
- const name = (trimmed.split("|")[0] ?? "").trim();
907
- if (name)
908
- names.push(name);
909
- }
910
- }
911
- return names;
912
- }
913
- async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, stagingDir, variantDir) {
914
- const refs = new Set();
915
- for (const source of visibleSources.values()) {
916
- for (const m of source.matchAll(EMBED_RE)) {
917
- const name = m[1].trim();
918
- if (!IMAGE_EXT_RE.test(name))
919
- continue;
920
- const image = imageIndex.get(slugify(name));
921
- if (image)
922
- refs.add(image.outputPath);
923
- }
924
- for (const name of galleryImageNames(source)) {
925
- const image = imageIndex.get(slugify(name.split("/").pop()));
926
- if (image)
927
- refs.add(image.outputPath);
928
- }
929
- // Standard-Markdown image refs, `![alt](path/to/img.png)`. Without this
930
- // only Obsidian `![[embed]]` syntax staged images, so CommonMark-syntax
931
- // images rendered into HTML but 404'd on deploy. Mirrors the same MD_LINK_RE
932
- // pass copyReferencedPassthroughs runs for `[label](file.pdf)` links.
933
- for (const m of source.matchAll(MD_LINK_RE)) {
934
- const name = m[1].trim();
935
- if (/^(https?:|mailto:|#)/i.test(name))
936
- continue;
937
- if (!IMAGE_EXT_RE.test(name))
938
- continue;
939
- const image = imageIndex.get(slugify(name.split("/").pop()));
940
- if (image)
941
- refs.add(image.outputPath);
942
- }
943
- // Layers named inside ```battlemap blocks. A web-only layer (e.g. a
944
- // composited tile overlay) has no other reference to stage it, so look
945
- // it up by its full vault-relative path.
946
- for (const path of battlemapLayerPaths(source)) {
947
- const image = imageIndex.get(path);
948
- if (image)
949
- refs.add(image.outputPath);
950
- }
951
- }
952
- // Pages can name their cover via `image:` frontmatter alone (no body embed);
953
- // pull those in too. coverImage was resolved to the served URL upstream, so
954
- // strip the leading slash + decode to get back to the staging-relative path.
955
- // `@vault/PATH` references inside any frontmatter string field also gate
956
- // an asset into this variant — common for Scene background.src / Playlist
957
- // sound.path that point at vault-shipped media. Page-role gating still
958
- // applies because we only walk visibleMetas (= pages this variant can see).
959
- for (const p of visibleMetas) {
960
- if (p.coverImage && !/^https?:\/\//i.test(p.coverImage)) {
961
- try {
962
- refs.add(decodeURIComponent(p.coverImage.replace(/^\//, "")));
963
- }
964
- catch { /* malformed coverImage URL — ignore */ }
965
- }
966
- if (p.frontmatter) {
967
- forEachString(p.frontmatter, (s) => {
968
- const path = vaultRefPath(s);
969
- if (path && IMAGE_EXT_RE.test(path)) {
970
- const image = imageIndex.get(path);
971
- if (image)
972
- refs.add(image.outputPath);
973
- }
974
- });
975
- }
976
- // Image refs inside the page's foundry.data_json (Scene backgrounds, tiles).
977
- for (const path of p.foundryAssets ?? []) {
978
- if (!IMAGE_EXT_RE.test(path))
979
- continue;
980
- const image = imageIndex.get(path);
981
- if (image)
982
- refs.add(image.outputPath);
983
- }
984
- }
985
- for (const outputPath of refs) {
986
- const src = join(stagingDir, outputPath);
987
- const dst = join(variantDir, outputPath);
988
- await mkdir(dirname(dst), { recursive: true });
989
- try {
990
- await copyFile(src, dst);
991
- }
992
- catch (err) {
993
- // Source may legitimately be missing if the file is in the index but
994
- // wasn't compressed (e.g. quality=0 path). Surface but don't crash.
995
- console.warn(` warning: could not copy image ${outputPath}: ${err.message}`);
996
- }
997
- }
998
- }
999
- // `[label](path/to/file.ext)` style markdown link. Captures the URL part.
1000
- // `\.[a-z0-9]+` requires an extension; we don't want to scoop up plain
1001
- // internal page links (e.g. `(href)` without an extension).
1002
- const MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+\.[a-z0-9]+)(?:\s+["'][^"']*["'])?\)/gi;
1003
- // `[[file.ext]]` and `![[file.ext]]` — Obsidian-flavoured wikilinks/embeds.
1004
- const WIKI_LINK_RE = /!?\[\[([^\[\]|#\n]+\.[a-z0-9]+)(?:\|[^\[\]#\n]*)?(?:#[^\[\]\n]*)?\]\]/gi;
1005
1046
  // `> [!type]…` opens a callout; the rest of the contiguous blockquote (lines
1006
1047
  // starting with `>`, blank line ends) is its body. Used to strip role-gated
1007
1048
  // callouts from the source before any downstream pass sees it.
@@ -1041,98 +1082,6 @@ function stripRoleGatedCallouts(source, redactRoles) {
1041
1082
  }
1042
1083
  return out.join("\n");
1043
1084
  }
1044
- /**
1045
- * Visit every string value reachable from `value` (object / array / scalar)
1046
- * and call `fn` once per string. Used to surface `@vault/PATH` references
1047
- * inside parsed frontmatter (e.g., a Scene's `foundry.data.background.src`
1048
- * or a Playlist's `foundry.data.sounds[N].path`) so the per-variant asset
1049
- * scanner can include those files alongside body-referenced ones.
1050
- */
1051
- function forEachString(value, fn) {
1052
- if (typeof value === "string")
1053
- return fn(value);
1054
- if (Array.isArray(value)) {
1055
- for (const v of value)
1056
- forEachString(v, fn);
1057
- return;
1058
- }
1059
- if (value && typeof value === "object") {
1060
- for (const v of Object.values(value))
1061
- forEachString(v, fn);
1062
- }
1063
- }
1064
- /** Extract a vault path from a `@vault/PATH` string, or null when the
1065
- * string isn't a vault reference. Trailing fragment / query stripped. */
1066
- function vaultRefPath(s) {
1067
- if (!s.startsWith("@vault/"))
1068
- return null;
1069
- const rest = s.slice("@vault/".length).split("#")[0].split("?")[0];
1070
- return rest.length > 0 ? rest : null;
1071
- }
1072
- /**
1073
- * Per-variant reference scan for passthrough files. A file lands in this
1074
- * variant's deploy only if a visible page mentions it — same gating story
1075
- * as images. Match patterns cover Obsidian embeds (`![[file.pdf]]`),
1076
- * Obsidian wikilinks (`[[file.pdf]]`), and standard markdown links
1077
- * (`[label](path/file.pdf)`). Anything not matched is dropped — that's
1078
- * the whole point of the change; a stray DM-only audio cue stays in the
1079
- * dm variant only.
1080
- */
1081
- async function copyReferencedPassthroughs(visibleSources, visibleMetas, passthroughIndex, stagingDir, variantDir) {
1082
- if (passthroughIndex.size === 0)
1083
- return;
1084
- const refs = new Set();
1085
- for (const source of visibleSources.values()) {
1086
- for (const m of source.matchAll(WIKI_LINK_RE)) {
1087
- const name = m[1].trim();
1088
- const entry = passthroughIndex.get(slugify(name.split("/").pop()));
1089
- if (entry)
1090
- refs.add(entry.outputPath);
1091
- }
1092
- for (const m of source.matchAll(MD_LINK_RE)) {
1093
- const name = m[1].trim();
1094
- // Skip http(s) links and anchor-only refs.
1095
- if (/^(https?:|mailto:|#)/i.test(name))
1096
- continue;
1097
- const entry = passthroughIndex.get(slugify(name.split("/").pop()));
1098
- if (entry)
1099
- refs.add(entry.outputPath);
1100
- }
1101
- }
1102
- // `@vault/PATH` references inside any frontmatter string also gate a
1103
- // passthrough into this variant. Same per-page-role visibility rules
1104
- // (only walking visibleMetas) — a dm-tier page's @vault/Audio/secret.ogg
1105
- // ships only to the dm variant.
1106
- for (const p of visibleMetas) {
1107
- if (!p.frontmatter)
1108
- continue;
1109
- forEachString(p.frontmatter, (s) => {
1110
- const path = vaultRefPath(s);
1111
- if (path) {
1112
- const entry = passthroughIndex.get(path);
1113
- if (entry)
1114
- refs.add(entry.outputPath);
1115
- }
1116
- });
1117
- // Audio/video/pdf refs inside the page's foundry.data_json (ambient sounds).
1118
- for (const path of p.foundryAssets ?? []) {
1119
- const entry = passthroughIndex.get(path);
1120
- if (entry)
1121
- refs.add(entry.outputPath);
1122
- }
1123
- }
1124
- for (const outputPath of refs) {
1125
- const src = join(stagingDir, outputPath);
1126
- const dst = join(variantDir, outputPath);
1127
- await mkdir(dirname(dst), { recursive: true });
1128
- try {
1129
- await copyFile(src, dst);
1130
- }
1131
- catch (err) {
1132
- console.warn(` warning: could not copy ${outputPath}: ${err.message}`);
1133
- }
1134
- }
1135
- }
1136
1085
  /**
1137
1086
  * Build synthesised index.md for any folder (including the root) that has
1138
1087
  * pages but no existing index.md. When `inlineTitle` is true, the layout
@@ -1423,106 +1372,6 @@ function kindLabel(kind) {
1423
1372
  default: return kind;
1424
1373
  }
1425
1374
  }
1426
- async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles, vaultName, assets) {
1427
- const files = [];
1428
- const seen = new Set();
1429
- // Variant-specific files: use pathBase=variantDir so paths come out as
1430
- // "index.html", not "_variants/<role>/index.html". This matches the public
1431
- // URL the client uses; the auth middleware does the variant rewrite.
1432
- await walkAndIndex(variantDir, variantDir, files, seen, [], bodyMeta);
1433
- // Shared assets under the deploy root (attachments, css). Skip the variant
1434
- // tree itself and anything inside `functions/` (Function code isn't served).
1435
- if (rootDir !== variantDir) {
1436
- await walkAndIndex(rootDir, rootDir, files, seen, [
1437
- "_variants", "functions", ".image-staging", ".other-staging",
1438
- ], bodyMeta);
1439
- }
1440
- files.sort((a, b) => a.path.localeCompare(b.path));
1441
- // `auth.required` lets clients (Foundry, MCP) tell up-front whether the
1442
- // deploy has middleware. Single-role builds collapse to a pure-static
1443
- // deploy with no /_batch / /_connect endpoints — clients fall back to
1444
- // direct CDN GETs in that case. `auth.roles` ships the role order
1445
- // (lowest→highest) so clients can rank a page's tier against a chosen
1446
- // cutoff (e.g. Foundry's per-vault dmRole).
1447
- // `name` is the vault's display name (settings.md `vault_name`); clients
1448
- // like the Foundry module use it as the default label + root folder when
1449
- // a user adds the vault, so they get something readable instead of a
1450
- // host-derived slug.
1451
- // Asset advertisement so clients (Foundry, MCP) fetch the right paths
1452
- // instead of guessing well-known names — lets us move things later.
1453
- const assetBlock = {};
1454
- if (assets.hasHandlerJs || assets.hasHandlerCss) {
1455
- assetBlock.browser = {
1456
- ...(assets.hasHandlerJs ? { js: "/_handlers.js" } : {}),
1457
- ...(assets.hasHandlerCss ? { css: "/_handlers.css" } : {}),
1458
- };
1459
- }
1460
- if (assets.hasFoundryJs || assets.hasFoundryCss) {
1461
- assetBlock.foundry = {
1462
- ...(assets.hasFoundryJs ? { js: "/_handlers.foundry.js" } : {}),
1463
- ...(assets.hasFoundryCss ? { css: "/_handlers.foundry.css" } : {}),
1464
- };
1465
- }
1466
- return {
1467
- manifest_version: MANIFEST_VERSION,
1468
- cli_version: CLI_VERSION,
1469
- id_scheme: ID_SCHEME,
1470
- name: vaultName,
1471
- auth: { required: authRequired, roles },
1472
- ...(Object.keys(assetBlock).length > 0 ? { assets: assetBlock } : {}),
1473
- files,
1474
- };
1475
- }
1476
- async function walkAndIndex(dir, pathBase, out, seen, skipDirNames, bodyMeta) {
1477
- const entries = await readdir(dir, { withFileTypes: true });
1478
- for (const ent of entries) {
1479
- if (ent.name === "_manifest.json")
1480
- continue;
1481
- const abs = join(dir, ent.name);
1482
- if (ent.isDirectory()) {
1483
- if (skipDirNames.includes(ent.name))
1484
- continue;
1485
- await walkAndIndex(abs, pathBase, out, seen, skipDirNames, bodyMeta);
1486
- continue;
1487
- }
1488
- if (!ent.isFile())
1489
- continue;
1490
- const path = relative(pathBase, abs).split(/[/\\]/).join("/");
1491
- if (seen.has(path))
1492
- continue;
1493
- seen.add(path);
1494
- const body = await readFile(abs);
1495
- const info = await stat(abs);
1496
- const meta = bodyMeta.get(path);
1497
- // Fold meta JSON into the hash so meta-only edits (e.g. a foundry.base
1498
- // tweak with no body change) still bump the row hash and trigger sync.
1499
- const hasher = createHash("md5").update(body);
1500
- if (meta)
1501
- hasher.update("\x00meta:" + stableStringify(meta));
1502
- out.push({
1503
- path,
1504
- hash: hasher.digest("hex"),
1505
- size: info.size,
1506
- mtime: Math.floor(info.mtimeMs / 1000),
1507
- content_type: contentTypeForExt(ent.name),
1508
- ...(meta ? { meta } : {}),
1509
- });
1510
- }
1511
- }
1512
- /**
1513
- * Deterministic JSON encoder. Object keys are sorted recursively so two
1514
- * frontmatters with the same shape but different key order produce the same
1515
- * hash; otherwise the manifest would churn on every YAML reformat.
1516
- */
1517
- function stableStringify(value) {
1518
- if (value === null || typeof value !== "object")
1519
- return JSON.stringify(value);
1520
- if (Array.isArray(value))
1521
- return "[" + value.map(stableStringify).join(",") + "]";
1522
- const obj = value;
1523
- const keys = Object.keys(obj).sort();
1524
- return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
1525
- }
1526
1375
  /**
1527
1376
  * Strip an HTML body to plain text. Used to feed the search index from
1528
1377
  * the rendered article (post-wikilink, post-callout-redaction) so search