@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
package/dist/build.js CHANGED
@@ -1,17 +1,21 @@
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 { buildManifest } from "./manifest.js";
13
+ import { collectBodyMeta, warnFoundryDocCollisions } from "./foundry-meta.js";
9
14
  import { compressImage } from "./images.js";
10
- import { IMAGE_EXT_RE, PASSTHROUGH_EXT_RE, COMPRESSIBLE_EXT_RE, contentTypeForExt, } from "./render/extensions.js";
15
+ import { IMAGE_EXT_RE, PASSTHROUGH_EXT_RE, COMPRESSIBLE_EXT_RE, } from "./render/extensions.js";
11
16
  import { buildFavicon } from "./favicon.js";
12
17
  import { renderMarkdown } from "./render/pipeline.js";
13
18
  import { extractH1 } from "./render/frontmatter.js";
14
- import { CLI_VERSION, MANIFEST_VERSION, ID_SCHEME } from "./version.js";
15
19
  import { renderLayout, render404 } from "./render/layout.js";
16
20
  import { writeFoundryImporter } from "./foundry-importer.js";
17
21
  import { slugify } from "./render/slug.js";
@@ -21,12 +25,11 @@ import { DEFAULT_CSS, renderThemeOverride } from "./render/styles.js";
21
25
  import { loadObsidianSnippets } from "./obsidian.js";
22
26
  import { loadSettings, writeSettings, SETTINGS_FILE } from "./settings.js";
23
27
  import { loadConfig } from "./config.js";
28
+ import { applyFrontmatterDefaults, compileFrontmatterRules } from "./frontmatter-defaults.js";
24
29
  import matter from "gray-matter";
25
- import { renderAuthMiddleware, LOGIN_HTML } from "./render/auth-template.js";
26
- import { htmlAttr } from "./escape.js";
30
+ import { renderAuthMiddleware, renderLoginPage } from "./render/auth-template.js";
27
31
  import { renderFooterHtml } from "./render/footer.js";
28
32
  import { buildRegistry } from "./render/handlers/types.js";
29
- import { battlemapLayerPaths } from "./render/handlers/builtin/battlemap.js";
30
33
  import { loadUserHandlers } from "./render/handlers/loader.js";
31
34
  import { BUILTIN_HANDLERS } from "./render/handlers/builtin/index.js";
32
35
  import { bundleHandlerAssets } from "./render/handlers/assets.js";
@@ -49,26 +52,110 @@ import { formatDuration, pMap, Progress } from "./util.js";
49
52
  * Single-role builds (the default `public`-only case) collapse
50
53
  * `_variants/public/...` up to the root.
51
54
  */
52
- export async function buildSite(opts) {
55
+ /**
56
+ * Add basename-slug keys to an asset index, first-write-wins in the vault's
57
+ * sorted path order.
58
+ *
59
+ * Obsidian resolves `![[map.png]]` by basename, so two files with the same
60
+ * name in different folders compete for one key. Writing the key from inside
61
+ * the concurrent staging pass meant whichever finished second won, and that
62
+ * varied between builds on identical input — a page could silently get a
63
+ * different image run to run. Doing it here, sequentially over the sorted
64
+ * list, makes the winner deterministic and lets us say which files collided.
65
+ * (Full-path keys stay in the staging pass; those are unique by definition.)
66
+ */
67
+ function addBasenameKeys(index, files, label) {
68
+ const claimed = new Map(); // slug → winning source path
69
+ for (const f of files) {
70
+ const entry = index.get(f.path);
71
+ if (!entry)
72
+ continue; // staging failed for this file; nothing to point at
73
+ const slug = slugify(f.path.split("/").pop());
74
+ const winner = claimed.get(slug);
75
+ if (winner === undefined) {
76
+ claimed.set(slug, f.path);
77
+ index.set(slug, entry);
78
+ continue;
79
+ }
80
+ console.warn(` ${label} name collision: '${f.path}' and '${winner}' share a filename. `
81
+ + `Bare references like the basename resolve to '${winner}'; `
82
+ + `use the folder path to reach the other.`);
83
+ }
84
+ }
85
+ /**
86
+ * Emit `sitemap.xml` and `robots.txt` at the deploy root.
87
+ *
88
+ * **Only the default role's pages are listed.** A sitemap naming gated pages
89
+ * would advertise that they exist, and their URLs, to anyone who fetches it —
90
+ * the middleware would still refuse the content, but the leak is the point of
91
+ * a sitemap, so it must never see above the lowest tier.
92
+ *
93
+ * Both files are written only when `site_url` is set, because a sitemap needs
94
+ * absolute URLs and nothing else in the build knows the deploy's public
95
+ * hostname (a Pages project can answer on several).
96
+ */
97
+ async function writeSitemap(outputDir, siteUrl, pagePaths) {
98
+ const base = siteUrl.replace(/\/+$/, "");
99
+ const urls = pagePaths
100
+ .map((p) => p.replace(/\.md$/i, ""))
101
+ .map((p) => (p === "index" ? "" : p.replace(/\/index$/i, "")))
102
+ .sort()
103
+ .map((p) => `${base}/${p.split("/").map(encodeURIComponent).join("/")}`)
104
+ // index.md becomes the bare base URL rather than "<base>/".
105
+ .map((u) => u.replace(/\/$/, ""));
106
+ const body = [...new Set(urls)]
107
+ .map((u) => ` <url><loc>${htmlEscape(u)}</loc></url>`)
108
+ .join("\n");
109
+ await writeFile(join(outputDir, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8"?>\n`
110
+ + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`);
111
+ await writeFile(join(outputDir, "robots.txt"), `User-agent: *\nAllow: /\nSitemap: ${base}/sitemap.xml\n`);
112
+ }
113
+ /**
114
+ * Warn about a `default_frontmatter` rule assigning a role the vault has not
115
+ * configured.
116
+ *
117
+ * `default_role` validated itself and said so when it was wrong. Moving the
118
+ * job into a rule would have dropped that: a typo would silently supply a role
119
+ * nothing recognises, and every page it matched would fall back to the lowest
120
+ * tier — publishing a vault meant to be private, quietly.
121
+ */
122
+ function warnUnknownRoles(rules, known, roles) {
123
+ for (const rule of rules) {
124
+ const role = rule.data?.["role"];
125
+ if (typeof role === "string" && role && !known.has(role)) {
126
+ console.warn(` settings.md: default_frontmatter rule '${rule.match}' assigns role `
127
+ + `"${role}", which is not one of [${roles.join(", ")}]. Pages matching it `
128
+ + `fall back to "${roles[0]}".`);
129
+ }
130
+ }
131
+ }
132
+ export async function buildSite(input) {
53
133
  const start = Date.now();
54
134
  const concurrency = Math.max(2, availableParallelism());
55
135
  // Run any pending schema / layout migrations before reading anything
56
136
  // else. The framework is idempotent: already-migrated vaults pay only
57
137
  // the cost of a few stat() calls. See cli/src/migrate/.
58
- await runMigrations(opts.vaultPath);
138
+ await runMigrations(input.vaultPath);
59
139
  // ── Settings (user-editable) ─────────────────────────────────────────────
60
- const settings = await loadSettings(opts.vaultPath);
140
+ const settings = await loadSettings(input.vaultPath);
61
141
  for (const w of settings.warnings)
62
142
  console.warn(` ${w}`);
63
143
  if (settings.exists && settings.changed) {
64
- await writeSettings(opts.vaultPath, settings.values);
144
+ await writeSettings(input.vaultPath, settings.values);
65
145
  console.log(` rewrote ${SETTINGS_FILE} to canonical format`);
66
146
  }
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,
147
+ // settings.md is the single source of truth for vault properties (see the
148
+ // SCHEMA in settings.ts). These used to also be CLI flags, with "was the
149
+ // flag passed?" inferred by comparing against the flag's default — and the
150
+ // defaults matched the schema's, so `build -q 85` against an
151
+ // `image_quality: 40` vault silently produced 40. The flags are gone; the
152
+ // vault decides.
153
+ let opts = {
154
+ ...input,
155
+ siteUrl: settings.values.site_url,
156
+ vaultName: settings.values.vault_name,
157
+ imageQuality: settings.values.image_quality,
158
+ maxFileBytes: settings.values.max_file_bytes,
72
159
  };
73
160
  // ── Custom handlers ──────────────────────────────────────────────────────
74
161
  // Built-ins ship with the CLI; user handlers live in `.vaults/handlers/`
@@ -94,20 +181,13 @@ export async function buildSite(opts) {
94
181
  const cfg = await loadConfig(opts.vaultPath, {});
95
182
  const roles = cfg.roles.length > 0 ? cfg.roles : ["public"];
96
183
  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
- }
184
+ // A page's role comes from its frontmatter, and `default_frontmatter` is
185
+ // what supplies one to pages that state none a DM-by-default vault sets
186
+ // `role: dm` in a rule matching `**`. This is the floor for anything that
187
+ // reaches here without a role at all, which means a vault whose rules do not
188
+ // cover it.
189
+ const defaultRole = roles[0];
190
+ warnUnknownRoles(settings.values.default_frontmatter, allRoleSet, roles);
111
191
  // ── Scan + filter ────────────────────────────────────────────────────────
112
192
  console.log(`Scanning ${opts.vaultPath}...`);
113
193
  const scanStart = Date.now();
@@ -151,9 +231,45 @@ export async function buildSite(opts) {
151
231
  // the passthrough pool (still reference-gated). The user-facing
152
232
  // warning lists exactly which paths got dropped so unintentional
153
233
  // omissions surface immediately.
234
+ // Read the ```download blocks first: a file named by one is shipped whatever
235
+ // its extension, so it must not also be reported as skipped. Warning about a
236
+ // file that then gets staged sends the reader off to set
237
+ // include_unknown_files for no reason.
238
+ const downloadPaths = new Set();
239
+ const manifestPaths = new Set();
240
+ const manifestDownloads = new Map();
241
+ for (const f of markdownFiles) {
242
+ const source = await readFile(f.absolute, "utf8");
243
+ for (const path of downloadFilePaths(source))
244
+ downloadPaths.add(path);
245
+ for (const path of foundryManifestPaths(source))
246
+ manifestPaths.add(path);
247
+ }
248
+ // A foundry-manifest block names only the manifest. The zip is whatever the
249
+ // manifest's own download field says, so read it and ship that too — the
250
+ // author should not have to repeat a path the manifest already states, and
251
+ // an install needs both halves present or it fails on the second fetch.
252
+ for (const rel of manifestPaths) {
253
+ downloadPaths.add(rel);
254
+ const file = withinLimit.find((f) => f.path === rel);
255
+ if (!file)
256
+ continue;
257
+ const { path, absolute } = manifestDownloadPath(await readFile(file.absolute, "utf8"), settings.values.site_url);
258
+ if (absolute) {
259
+ console.warn(` ${rel}: "download" points outside this vault (${absolute}), so the file is not`
260
+ + ` staged into the deploy. If it is meant to be this vault's own file, either`
261
+ + ` set 'site_url' to the host it names or write the path relative and let the`
262
+ + ` build make it absolute.`);
263
+ }
264
+ if (path) {
265
+ downloadPaths.add(path);
266
+ manifestDownloads.set(rel, path);
267
+ }
268
+ }
154
269
  const unknownFiles = withinLimit.filter((f) => !/\.md$|\.base$/i.test(f.path)
155
270
  && !IMAGE_EXT_RE.test(f.path)
156
- && !PASSTHROUGH_EXT_RE.test(f.path));
271
+ && !PASSTHROUGH_EXT_RE.test(f.path)
272
+ && !downloadPaths.has(f.path));
157
273
  const includeUnknown = settings.values.include_unknown_files;
158
274
  if (unknownFiles.length > 0) {
159
275
  if (includeUnknown) {
@@ -173,7 +289,26 @@ export async function buildSite(opts) {
173
289
  // Effective passthrough list: recognised media plus (optionally) unknowns.
174
290
  const stagedPassthroughs = includeUnknown
175
291
  ? [...passthroughFiles, ...unknownFiles]
176
- : passthroughFiles;
292
+ : [...passthroughFiles];
293
+ // Files named by a ```download block join the pool whatever their
294
+ // extension. A download is usually a .zip or a module.json, which the
295
+ // passthrough list calls unknown and drops — but naming one in a block is
296
+ // the author asking for it by name, which is exactly the intent
297
+ // include_unknown_files exists to require. Role gating is untouched: these
298
+ // are still reference-gated, so a download on a patron page reaches the
299
+ // patron variant and no other.
300
+ if (downloadPaths.size > 0) {
301
+ const already = new Set(stagedPassthroughs.map((f) => f.path));
302
+ const promoted = withinLimit.filter((f) => downloadPaths.has(f.path) && !already.has(f.path));
303
+ const missing = [...downloadPaths].filter((p) => !withinLimit.some((f) => f.path === p));
304
+ for (const p of missing) {
305
+ console.warn(` download block names '${p}', which is not in the vault; the link will 404.`);
306
+ }
307
+ if (promoted.length > 0) {
308
+ console.log(` staging ${promoted.length} file(s) named by download / foundry-manifest blocks`);
309
+ stagedPassthroughs.push(...promoted);
310
+ }
311
+ }
177
312
  // ── Shared content (read once, reused across roles) ─────────────────────
178
313
  const sources = new Map();
179
314
  await pMap(markdownFiles, concurrency, async (f) => {
@@ -191,9 +326,17 @@ export async function buildSite(opts) {
191
326
  // normalized for Obsidian quirks first; malformed YAML throws inside
192
327
  // parsePageFrontmatter and aborts the build rather than silently dropping a
193
328
  // page's metadata (and, with it, its role gate).
329
+ const frontmatterRules = compileFrontmatterRules(settings.values.default_frontmatter);
194
330
  const parsedSources = new Map();
195
331
  for (const f of markdownFiles) {
196
- parsedSources.set(f.path, parsePageFrontmatter(sources.get(f.path), f.path));
332
+ const parsed = parsePageFrontmatter(sources.get(f.path), f.path);
333
+ // Applied here, at the one place a page's frontmatter is read, so that
334
+ // roles, the rendered wiki, the manifest the Foundry client syncs from and
335
+ // the module compiler all see the same page. A default that only some of
336
+ // them honoured would be a way for a synced vault and an installed module
337
+ // to disagree about the same file.
338
+ applyFrontmatterDefaults(f.path, parsed.data, frontmatterRules);
339
+ parsedSources.set(f.path, parsed);
197
340
  }
198
341
  // Derive role/title/aliases per page from that parse. A role that's present
199
342
  // but isn't a configured role fails the build (collected below): falling back
@@ -265,12 +408,13 @@ export async function buildSite(opts) {
265
408
  // key is what stops identically-named assets in different scene folders
266
409
  // (e.g. a shared `Water Fountain (Loop).ogg`) from colliding under one
267
410
  // 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);
411
+ // Full-path key only; basename keys are added afterwards, in sorted
412
+ // order, so a duplicated filename resolves deterministically.
413
+ imageIndex.set(f.path, { sourcePath: f.path, outputPath: compressed.outputPath });
271
414
  }, (done, total) => progress.update(done, total));
272
415
  progress.done(`${imageFiles.length} processed (${cacheHits} cached, ${imageFiles.length - cacheHits} compressed)`);
273
416
  }
417
+ addBasenameKeys(imageIndex, imageFiles, "image");
274
418
  // ── Passthrough files (audio, video, PDF, epub) ────────────────────────
275
419
  // Staged once and copied into a variant only when a visible page in that
276
420
  // variant references the file by basename or relative path. Same gating
@@ -288,12 +432,11 @@ export async function buildSite(opts) {
288
432
  // Dual-keyed like imageIndex: basename slug for body refs, full
289
433
  // vault-relative path for `@vault/PATH` refs (ambient sounds in
290
434
  // 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);
435
+ passthroughIndex.set(f.path, { sourcePath: f.path, outputPath: f.path });
294
436
  }, (done, total) => progress.update(done, total));
295
437
  progress.done(`${stagedPassthroughs.length} staged`);
296
438
  }
439
+ addBasenameKeys(passthroughIndex, stagedPassthroughs, "passthrough");
297
440
  // Shared CSS bundle.
298
441
  //
299
442
  // Every file written to outputDir ROOT (rather than into _variants/<role>/)
@@ -325,15 +468,18 @@ export async function buildSite(opts) {
325
468
  // One combined hash keeps it simple; any shared-asset change busts all.
326
469
  // katex version rides in the hash so a dependency upgrade re-fetches the
327
470
  // (otherwise never-changing) /katex/katex.min.css on math pages.
328
- const katexVersion = createRequire(import.meta.url)("katex/package.json").version;
471
+ const katexVersion = katexRequire()("katex/package.json").version;
329
472
  const assetVersion = createHash("md5")
330
473
  .update(DEFAULT_CSS + themeOverride + userCss + handlerAssets.js + handlerAssets.css + katexVersion)
331
474
  .digest("hex")
332
475
  .slice(0, 10);
333
476
  // 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);
477
+ // sync time. Skipped entirely when the vault has opted out of the Foundry
478
+ // integration it is ~60KB shipped to every deploy, and a course site or
479
+ // research wiki will never fetch it.
480
+ const foundryEnabled = settings.values.foundry.package !== "none";
481
+ if (foundryEnabled)
482
+ await writeFoundryImporter(opts.outputDir);
337
483
  // Foundry-import bundles are written per-variant inside the role loop
338
484
  // below (instead of at the root) so the middleware role-gates them. A
339
485
  // public visitor can't fetch the dm-tier handler bundle even if it
@@ -379,9 +525,11 @@ export async function buildSite(opts) {
379
525
  if (cover)
380
526
  meta.coverImage = cover;
381
527
  }
528
+ warnFoundryDocCollisions(allPageMetas);
382
529
  // ── Per-role variant builds ─────────────────────────────────────────────
383
530
  const perRolePageCount = {};
384
531
  const collapseToRoot = roles.length === 1;
532
+ let defaultRolePagePaths = [];
385
533
  let katexCopied = false;
386
534
  for (const role of roles) {
387
535
  const variantDir = collapseToRoot
@@ -400,6 +548,7 @@ export async function buildSite(opts) {
400
548
  role,
401
549
  visibleRoles,
402
550
  redactRoles,
551
+ gated: !collapseToRoot,
403
552
  variantDir,
404
553
  vaultName: opts.vaultName,
405
554
  vaultPath: opts.vaultPath,
@@ -408,6 +557,7 @@ export async function buildSite(opts) {
408
557
  parsedSources,
409
558
  baseSources,
410
559
  imageIndex,
560
+ manifestDownloads,
411
561
  imageStagingDir,
412
562
  passthroughIndex,
413
563
  passthroughStagingDir: otherStagingDir,
@@ -422,6 +572,9 @@ export async function buildSite(opts) {
422
572
  allWarnings: opts.allWarnings,
423
573
  });
424
574
  perRolePageCount[role] = stats.pageCount;
575
+ // Only the default (lowest) role feeds the sitemap; see writeSitemap.
576
+ if (role === roles[0])
577
+ defaultRolePagePaths = stats.pagePaths;
425
578
  if (!collapseToRoot)
426
579
  console.log(` variant '${role}': ${stats.pageCount} pages`);
427
580
  // KaTeX stylesheet + fonts, shared at the deploy root. Copied lazily on
@@ -441,7 +594,7 @@ export async function buildSite(opts) {
441
594
  // Foundry-import subset bundles. The Foundry module fetches these by
442
595
  // their canonical `/_handlers.foundry.{js,css}` paths; the middleware
443
596
  // role-gates per the requesting bearer's variant.
444
- if (handlerAssets.foundry) {
597
+ if (foundryEnabled && handlerAssets.foundry) {
445
598
  if (handlerAssets.foundry.js.length > 0) {
446
599
  await writeFile(join(variantDir, "_handlers.foundry.js"), handlerAssets.foundry.js);
447
600
  }
@@ -457,9 +610,9 @@ export async function buildSite(opts) {
457
610
  const manifest = await buildManifest(opts.outputDir, variantDir, stats.bodyMeta, !collapseToRoot, roles, opts.vaultName, {
458
611
  hasHandlerJs,
459
612
  hasHandlerCss,
460
- hasFoundryJs: (handlerAssets.foundry?.js.length ?? 0) > 0,
461
- hasFoundryCss: (handlerAssets.foundry?.css.length ?? 0) > 0,
462
- });
613
+ hasFoundryJs: foundryEnabled && (handlerAssets.foundry?.js.length ?? 0) > 0,
614
+ hasFoundryCss: foundryEnabled && (handlerAssets.foundry?.css.length ?? 0) > 0,
615
+ }, settings.values.foundry.package, allRoleSet.has(settings.values.foundry.player_role) ? settings.values.foundry.player_role : "");
463
616
  await writeFile(join(variantDir, "_manifest.json"), JSON.stringify(manifest));
464
617
  }
465
618
  // ── Pages Functions ─────────────────────────────────────────────────────
@@ -496,30 +649,40 @@ export async function buildSite(opts) {
496
649
  : null;
497
650
  const middleware = renderAuthMiddleware({
498
651
  roles,
652
+ foundry: foundryEnabled,
499
653
  rolePasswords: cfg.rolePasswords,
500
654
  ...(patreonForFn ? { patreon: patreonForFn } : {}),
501
655
  ...(oidcForFn ? { oidc: oidcForFn } : {}),
502
656
  });
503
657
  await writeFile(join(fnDir, "_middleware.js"), middleware);
504
- // Login page; drop in the role list (everything above the default).
658
+ // Login page, showing only the methods this deploy actually has. A role
659
+ // is reachable by password only if a hash was set for it, so a vault
660
+ // authenticating purely through Patreon or OIDC gets no password form
661
+ // and no role selector.
505
662
  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.`);
663
+ const passwordRoles = protectedRoles.filter((r) => cfg.rolePasswords[r]);
664
+ const patreonRoles = patreonForFn ? Object.keys(patreonForFn.tiers) : [];
665
+ const oidcRoles = oidcForFn ? Object.keys(oidcForFn.roleRules ?? {}) : [];
666
+ await writeFile(join(opts.outputDir, "login.html"), renderLoginPage({
667
+ passwordRoles,
668
+ patreonRoles,
669
+ oidcDisplayName: oidcForFn ? oidcForFn.displayName : null,
670
+ }));
671
+ // The error is a role nobody can reach, not a role without a password —
672
+ // password-less is the point when a provider grants the role instead.
673
+ const unreachable = protectedRoles.filter((r) => !cfg.rolePasswords[r] && !patreonRoles.includes(r) && !oidcRoles.includes(r));
674
+ if (unreachable.length > 0) {
675
+ console.warn(` WARNING: no way to sign in as role(s): ${unreachable.join(", ")}. `
676
+ + `Set a password ('vaults password <role>'), map a Patreon tier `
677
+ + `('vaults patreon link <role> <tier-id>'), or add an OIDC rule `
678
+ + `('vaults oidc configure'). Pages at these roles will be unreachable.`);
521
679
  }
522
680
  }
681
+ // Search-engine files. Written from the default role's page list only, so a
682
+ // gated page is never named. Skipped entirely without a site_url.
683
+ if (opts.siteUrl) {
684
+ await writeSitemap(opts.outputDir, opts.siteUrl, defaultRolePagePaths);
685
+ }
523
686
  // Drop the staging dirs; their contents have been copied into each
524
687
  // variant that needs them, so they're no longer required for the deploy.
525
688
  await rm(imageStagingDir, { recursive: true, force: true });
@@ -532,8 +695,6 @@ export async function buildSite(opts) {
532
695
  await rename(workOutputDir, finalOutputDir);
533
696
  console.log(`Built in ${formatDuration(Date.now() - start)}.`);
534
697
  return {
535
- files,
536
- withinLimit,
537
698
  roles,
538
699
  perRolePageCount,
539
700
  imageCount: imageFiles.length,
@@ -596,6 +757,7 @@ async function buildVariant(a) {
596
757
  bases: a.baseSources,
597
758
  defaultImageWidth: a.settings.default_image_width,
598
759
  redactRoles: a.redactRoles,
760
+ gated: a.gated,
599
761
  handlers: a.handlerRegistry,
600
762
  outlinksByPath,
601
763
  };
@@ -728,16 +890,34 @@ async function buildVariant(a) {
728
890
  // contract as images: ship only into variants whose visible pages
729
891
  // reference the file. A DM-only audio cue can't ride along into the
730
892
  // 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 };
893
+ await copyReferencedPassthroughs(visibleSources, visibleMetas, a.passthroughIndex, a.passthroughStagingDir, a.variantDir, a.manifestDownloads);
894
+ return {
895
+ pageCount: visibleMetas.length,
896
+ pagePaths: visibleMetas.map((m) => m.path),
897
+ bodyMeta,
898
+ hasMath: hasMathCss,
899
+ };
900
+ }
901
+ /**
902
+ * Resolve the katex package that *rehype-katex* renders with, which is not
903
+ * necessarily this package's own katex dependency: under pnpm's isolated
904
+ * node_modules, rehype-katex gets the copy matching its own range. The
905
+ * stylesheet has to come from that copy, because KaTeX renames its CSS
906
+ * classes between versions ('sizing' became 'katex-sizing' in 0.18), and a
907
+ * stylesheet from a different version silently stops matching the markup:
908
+ * subscripts render at full size instead of 0.7em.
909
+ */
910
+ function katexRequire() {
911
+ const here = createRequire(import.meta.url);
912
+ return createRequire(here.resolve("rehype-katex"));
733
913
  }
734
914
  /**
735
- * Copy KaTeX's stylesheet and fonts (from the katex package dependency) to
915
+ * Copy KaTeX's stylesheet and fonts (from the katex rehype-katex uses) to
736
916
  * <outputDir>/katex/. woff2 only: the CSS lists woff2 first, so any browser
737
917
  * that supports it (all modern ones) never requests the woff/ttf fallbacks.
738
918
  */
739
919
  async function copyKatexAssets(destDir) {
740
- const distDir = join(dirname(createRequire(import.meta.url).resolve("katex/package.json")), "dist");
920
+ const distDir = join(dirname(katexRequire().resolve("katex/package.json")), "dist");
741
921
  await mkdir(join(destDir, "fonts"), { recursive: true });
742
922
  await copyFile(join(distDir, "katex.min.css"), join(destDir, "katex.min.css"));
743
923
  for (const f of await readdir(join(distDir, "fonts"))) {
@@ -760,127 +940,6 @@ async function copyKatexAssets(destDir) {
760
940
  * embed: false # default true
761
941
  * data: { … deep-merged into the doc }
762
942
  */
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
943
  /** Coerce settings.theme to the layout's narrowed union, defaulting to
885
944
  * "auto" for any unrecognised value rather than failing the build. */
886
945
  function themeOf(s) {
@@ -889,119 +948,6 @@ function themeOf(s) {
889
948
  function previewModeOf(s) {
890
949
  return s === "none" || s === "sticky" ? s : "normal";
891
950
  }
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
951
  // `> [!type]…` opens a callout; the rest of the contiguous blockquote (lines
1006
952
  // starting with `>`, blank line ends) is its body. Used to strip role-gated
1007
953
  // callouts from the source before any downstream pass sees it.
@@ -1041,98 +987,6 @@ function stripRoleGatedCallouts(source, redactRoles) {
1041
987
  }
1042
988
  return out.join("\n");
1043
989
  }
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
990
  /**
1137
991
  * Build synthesised index.md for any folder (including the root) that has
1138
992
  * pages but no existing index.md. When `inlineTitle` is true, the layout
@@ -1423,106 +1277,12 @@ function kindLabel(kind) {
1423
1277
  default: return kind;
1424
1278
  }
1425
1279
  }
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
1280
  /**
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.
1281
+ * Per-page extension on .body.html manifest entries, consumed by the Foundry
1282
+ * sync. Always carries the page's role (so the Foundry side can map roles to
1283
+ * JournalEntry ownership against a per-vault dmRole setting); other fields
1284
+ * are present only when the corresponding frontmatter is set.
1516
1285
  */
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
1286
  /**
1527
1287
  * Strip an HTML body to plain text. Used to feed the search index from
1528
1288
  * the rendered article (post-wikilink, post-callout-redaction) so search