@wizzlethorpe/vaults 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/build.js CHANGED
@@ -10,10 +10,17 @@ import { buildFavicon } from "./favicon.js";
10
10
  // Any image format that can be referenced via ![[name.ext]]; superset of
11
11
  // COMPRESSIBLE_EXT_RE since SVGs/GIFs ship as-is rather than being recoded.
12
12
  const IMAGE_EXT_RE = /\.(png|jpe?g|webp|gif|svg|avif|tiff?)$/i;
13
+ // Recognised non-image media that ride alongside the wiki: audio, video,
14
+ // portable docs. Ship per-variant just like images (only into variants
15
+ // whose visible pages reference them) so DM-only audio cues can't leak
16
+ // into the public deploy. Anything outside this list is treated as
17
+ // "unknown" and skipped by default; see the include_unknown_files setting.
18
+ const PASSTHROUGH_EXT_RE = /\.(ogg|mp3|m4a|wav|flac|opus|aac|mp4|webm|mov|ogv|pdf|epub)$/i;
13
19
  import { renderMarkdown } from "./render/pipeline.js";
14
20
  import { renderLayout, render404 } from "./render/layout.js";
15
21
  import { slugify } from "./render/slug.js";
16
22
  import { buildPreview } from "./render/preview.js";
23
+ import { resolvePageImage } from "./render/cover.js";
17
24
  import { DEFAULT_CSS, renderThemeOverride } from "./render/styles.js";
18
25
  import { loadObsidianSnippets } from "./obsidian.js";
19
26
  import { loadSettings, writeSettings, SETTINGS_FILE } from "./settings.js";
@@ -101,7 +108,37 @@ export async function buildSite(opts) {
101
108
  // .base files are consumed at build time (rendered into HTML where embedded)
102
109
  // and never shipped to the deploy.
103
110
  const baseFiles = withinLimit.filter((f) => /\.base$/i.test(f.path));
104
- const otherFiles = withinLimit.filter((f) => !/\.md$/i.test(f.path) && !IMAGE_EXT_RE.test(f.path) && !/\.base$/i.test(f.path));
111
+ const passthroughFiles = withinLimit.filter((f) => PASSTHROUGH_EXT_RE.test(f.path)
112
+ && !IMAGE_EXT_RE.test(f.path)
113
+ && !/\.md$|\.base$/i.test(f.path));
114
+ // Anything else: skipped by default so role-gated content can't leak
115
+ // through a stray file. include_unknown_files = true folds them into
116
+ // the passthrough pool (still reference-gated). The user-facing
117
+ // warning lists exactly which paths got dropped so unintentional
118
+ // omissions surface immediately.
119
+ const unknownFiles = withinLimit.filter((f) => !/\.md$|\.base$/i.test(f.path)
120
+ && !IMAGE_EXT_RE.test(f.path)
121
+ && !PASSTHROUGH_EXT_RE.test(f.path));
122
+ const includeUnknown = settings.values.include_unknown_files;
123
+ if (unknownFiles.length > 0) {
124
+ if (includeUnknown) {
125
+ console.log(` including ${unknownFiles.length} unknown-extension file(s) (include_unknown_files=true)`);
126
+ }
127
+ else {
128
+ console.warn(` skipping ${unknownFiles.length} file(s) with unrecognized extensions:`);
129
+ const shown = unknownFiles.slice(0, 10);
130
+ for (const f of shown)
131
+ console.warn(` ${f.path}`);
132
+ if (unknownFiles.length > shown.length) {
133
+ console.warn(` … and ${unknownFiles.length - shown.length} more`);
134
+ }
135
+ console.warn(` Set 'include_unknown_files: true' in settings.md to ship them.`);
136
+ }
137
+ }
138
+ // Effective passthrough list: recognised media plus (optionally) unknowns.
139
+ const stagedPassthroughs = includeUnknown
140
+ ? [...passthroughFiles, ...unknownFiles]
141
+ : passthroughFiles;
105
142
  // ── Shared content (read once, reused across roles) ─────────────────────
106
143
  const sources = new Map();
107
144
  await pMap(markdownFiles, concurrency, async (f) => {
@@ -167,26 +204,36 @@ export async function buildSite(opts) {
167
204
  }, (done, total) => progress.update(done, total));
168
205
  progress.done(`${imageFiles.length} processed (${cacheHits} cached, ${imageFiles.length - cacheHits} compressed)`);
169
206
  }
170
- // ── Passthrough files (PDFs, audio, etc.) ───────────────────────────────
171
- // Staged once, copied into every variant. We don't scan markdown to find
172
- // out which files are referenced (links can be plain text, embedded HTML,
173
- // or arbitrary URLs), so we ship them into every tier; the trade-off is
174
- // that DM-only PDFs are reachable from any role's deploy. Document as a
175
- // known limitation.
207
+ // ── Passthrough files (audio, video, PDF, epub) ────────────────────────
208
+ // Staged once and copied into a variant only when a visible page in that
209
+ // variant references the file by basename or relative path. Same gating
210
+ // story as images: a DM-only audio cue can't ride along into the public
211
+ // deploy because no public-tier source mentions it.
176
212
  const otherStagingDir = join(opts.outputDir, ".other-staging");
177
- if (otherFiles.length > 0) {
178
- const progress = new Progress("Other");
179
- progress.update(0, otherFiles.length);
180
- await pMap(otherFiles, concurrency, async (f) => {
213
+ const passthroughIndex = new Map();
214
+ if (stagedPassthroughs.length > 0) {
215
+ const progress = new Progress("Passthroughs");
216
+ progress.update(0, stagedPassthroughs.length);
217
+ await pMap(stagedPassthroughs, concurrency, async (f) => {
181
218
  const dest = join(otherStagingDir, f.path);
182
219
  await mkdir(dirname(dest), { recursive: true });
183
220
  await copyFile(f.absolute, dest);
221
+ passthroughIndex.set(slugify(f.path.split("/").pop()), {
222
+ sourcePath: f.path,
223
+ outputPath: f.path,
224
+ });
184
225
  }, (done, total) => progress.update(done, total));
185
- progress.done(`${otherFiles.length} copied`);
226
+ progress.done(`${stagedPassthroughs.length} staged`);
186
227
  }
187
- // Shared CSS bundle
228
+ // Shared CSS bundle.
229
+ //
230
+ // Every file written to outputDir ROOT (rather than into _variants/<role>/)
231
+ // must also appear in `isSharedAsset` over in render/auth-template.ts —
232
+ // otherwise the variant rewrite traps it and it 404s for everyone. If you
233
+ // add a new root-level file here, add it there too.
188
234
  const themeOverride = renderThemeOverride({
189
235
  lightAccent: settings.values.accent_color,
236
+ lightBg: settings.values.bg_color,
190
237
  });
191
238
  await writeFile(join(opts.outputDir, "styles.css"), DEFAULT_CSS + themeOverride);
192
239
  const userCss = await loadObsidianSnippets(opts.vaultPath);
@@ -194,12 +241,13 @@ export async function buildSite(opts) {
194
241
  if (userCss)
195
242
  console.log(` loaded user.css from .obsidian/snippets/`);
196
243
  // Favicon; either user-supplied via settings.favicon, or a generated
197
- // default with the vault's first letter on the accent colour.
244
+ // default with the vault's first letter in accent on the theme background.
198
245
  try {
199
246
  const favicon = await buildFavicon({
200
247
  vaultPath: opts.vaultPath,
201
248
  faviconPath: settings.values.favicon,
202
249
  letter: (opts.vaultName || "V").trim().charAt(0).toUpperCase() || "V",
250
+ backgroundColor: settings.values.bg_color || "#f4ecd8",
203
251
  accentColor: settings.values.accent_color || "#a8201a",
204
252
  });
205
253
  await writeFile(join(opts.outputDir, "favicon.ico"), favicon);
@@ -207,6 +255,18 @@ export async function buildSite(opts) {
207
255
  catch (err) {
208
256
  console.warn(` warning: could not generate favicon: ${err.message}`);
209
257
  }
258
+ // ── Resolve per-page cover images ───────────────────────────────────────
259
+ // Computed once against the final imageIndex so OG meta tags, Bases card
260
+ // covers, hover previews, and Foundry actor/item reskin all resolve to the
261
+ // same URL. settings.auto_image flips body-fallback discovery on/off.
262
+ for (const meta of allPageMetas) {
263
+ const src = sources.get(meta.path);
264
+ if (!src)
265
+ continue;
266
+ const cover = resolvePageImage(src, meta.frontmatter, imageIndex, settings.values.auto_image);
267
+ if (cover)
268
+ meta.coverImage = cover;
269
+ }
210
270
  // ── Per-role variant builds ─────────────────────────────────────────────
211
271
  const perRolePageCount = {};
212
272
  const collapseToRoot = roles.length === 1;
@@ -232,8 +292,8 @@ export async function buildSite(opts) {
232
292
  baseSources,
233
293
  imageIndex,
234
294
  imageStagingDir,
235
- otherFiles,
236
- otherStagingDir,
295
+ passthroughIndex,
296
+ passthroughStagingDir: otherStagingDir,
237
297
  settings: settings.values,
238
298
  authConfigured: roles.length > 1,
239
299
  concurrency,
@@ -245,7 +305,9 @@ export async function buildSite(opts) {
245
305
  // Write a per-variant _manifest.json so external clients (Foundry, MCP,
246
306
  // etc.) can do an incremental diff. Includes EVERY file that variant
247
307
  // serves; html, md, images (as relative paths into shared root), css.
248
- const manifest = await buildManifest(opts.outputDir, variantDir);
308
+ // bodyMeta carries per-page Foundry reskin metadata; folded into each
309
+ // body row's hash so meta-only changes trigger a re-sync.
310
+ const manifest = await buildManifest(opts.outputDir, variantDir, stats.bodyMeta, !collapseToRoot, roles, opts.vaultName);
249
311
  await writeFile(join(variantDir, "_manifest.json"), JSON.stringify(manifest));
250
312
  }
251
313
  // ── Pages Functions ─────────────────────────────────────────────────────
@@ -254,9 +316,21 @@ export async function buildSite(opts) {
254
316
  if (!collapseToRoot) {
255
317
  const fnDir = join(opts.outputDir, "functions");
256
318
  await mkdir(fnDir, { recursive: true });
319
+ // Patreon overlay rides only when configured AND at least one role is
320
+ // mapped to a tier. clientSecret stays out of the bundle — it lives in
321
+ // the Wrangler secret PATREON_CLIENT_SECRET, read from env in the
322
+ // Function. The CLI uploads it on every push.
323
+ const patreonForFn = cfg.patreon && cfg.patreon.tiers && Object.keys(cfg.patreon.tiers).length > 0
324
+ ? {
325
+ clientId: cfg.patreon.clientId,
326
+ campaignId: cfg.patreon.campaignId,
327
+ tiers: cfg.patreon.tiers,
328
+ }
329
+ : null;
257
330
  const middleware = renderAuthMiddleware({
258
331
  roles,
259
332
  rolePasswords: cfg.rolePasswords,
333
+ ...(patreonForFn ? { patreon: patreonForFn } : {}),
260
334
  });
261
335
  await writeFile(join(fnDir, "_middleware.js"), middleware);
262
336
  // Login page; drop in the role list (everything above the default).
@@ -264,7 +338,12 @@ export async function buildSite(opts) {
264
338
  const opts_html = protectedRoles
265
339
  .map((r) => `<option value="${r}">${r}</option>`)
266
340
  .join("");
267
- await writeFile(join(opts.outputDir, "login.html"), LOGIN_HTML.replace("__ROLE_OPTIONS__", opts_html));
341
+ const patreonRolesAttr = patreonForFn
342
+ ? ` data-patreon-roles="${Object.keys(patreonForFn.tiers).join(",")}"`
343
+ : "";
344
+ await writeFile(join(opts.outputDir, "login.html"), LOGIN_HTML
345
+ .replace("__ROLE_OPTIONS__", opts_html)
346
+ .replace("__PATREON_ROLES_ATTR__", patreonRolesAttr));
268
347
  const missing = protectedRoles.filter((r) => !cfg.rolePasswords[r]);
269
348
  if (missing.length > 0) {
270
349
  console.warn(` WARNING: no password set for role(s): ${missing.join(", ")}. Run 'vaults password <role>' before pushing.`);
@@ -281,7 +360,7 @@ export async function buildSite(opts) {
281
360
  roles,
282
361
  perRolePageCount,
283
362
  imageCount: imageFiles.length,
284
- otherCount: otherFiles.length,
363
+ otherCount: stagedPassthroughs.length,
285
364
  };
286
365
  }
287
366
  async function buildVariant(a) {
@@ -356,6 +435,7 @@ async function buildVariant(a) {
356
435
  }
357
436
  }
358
437
  // Pass 2: write layouts + preview JSON.
438
+ const bodyMeta = new Map();
359
439
  await pMap(visibleMetas, a.concurrency, async (p) => {
360
440
  const r = rendered.get(p.path);
361
441
  const backlinkPaths = backlinkMap.get(p.path) ?? new Set();
@@ -375,6 +455,8 @@ async function buildVariant(a) {
375
455
  authConfigured: a.authConfigured,
376
456
  ...(p.mtime != null ? { mtime: p.mtime } : {}),
377
457
  ...(p.birthtime != null ? { birthtime: p.birthtime } : {}),
458
+ ...(p.coverImage ? { coverImage: p.coverImage } : {}),
459
+ ...(extractFrontmatterBlock(visibleSources.get(p.path)) ?? {}),
378
460
  });
379
461
  const outputBase = p.path.replace(/\.md$/i, "");
380
462
  const htmlDest = join(a.variantDir, outputBase + ".html");
@@ -383,7 +465,9 @@ async function buildVariant(a) {
383
465
  // .body.html holds just the rendered article content (no layout shell).
384
466
  // Foundry imports this so callouts/embeds rendered by the vault's
385
467
  // remark/rehype pipeline land in journals as-is, no client-side render.
386
- await writeFile(join(a.variantDir, outputBase + ".body.html"), r.html);
468
+ const bodyPath = outputBase + ".body.html";
469
+ await writeFile(join(a.variantDir, bodyPath), r.html);
470
+ bodyMeta.set(bodyPath, collectBodyMeta(p));
387
471
  const source = visibleSources.get(p.path);
388
472
  const preview = await buildPreview(source, r.title);
389
473
  await writeFile(join(a.variantDir, outputBase + ".preview.json"), JSON.stringify(preview));
@@ -410,27 +494,41 @@ async function buildVariant(a) {
410
494
  await writeFile(join(a.variantDir, "_search-index.json"), JSON.stringify(searchIndex));
411
495
  // Copy whichever images this variant's pages reference. Images live only
412
496
  // under the variants that need them so guessing a DM-only image URL on
413
- // the public wiki structurally 404s.
414
- await copyReferencedImages(visibleSources, a.imageIndex, a.imageStagingDir, a.variantDir);
415
- // Passthrough files (PDFs, audio, etc.) ship into every variant; the
416
- // build doesn't scan markdown for arbitrary references, so we can't tell
417
- // which role-restricted pages link to a given PDF. Limitation: DM-only
418
- // data files in this category aren't role-gated.
419
- for (const f of a.otherFiles) {
420
- const src = join(a.otherStagingDir, f.path);
421
- const dst = join(a.variantDir, f.path);
422
- await mkdir(dirname(dst), { recursive: true });
423
- try {
424
- await copyFile(src, dst);
425
- }
426
- catch (err) {
427
- console.warn(` warning: could not copy ${f.path}: ${err.message}`);
428
- }
497
+ // the public wiki structurally 404s. coverImage feeds in here too so
498
+ // images named via `image:` frontmatter (no body embed) still ship.
499
+ await copyReferencedImages(visibleSources, visibleMetas, a.imageIndex, a.imageStagingDir, a.variantDir);
500
+ // Passthrough files (audio/video/pdf/epub) follow the same gating
501
+ // contract as images: ship only into variants whose visible pages
502
+ // reference the file. A DM-only audio cue can't ride along into the
503
+ // public deploy because no public-tier source mentions it.
504
+ await copyReferencedPassthroughs(visibleSources, a.passthroughIndex, a.passthroughStagingDir, a.variantDir);
505
+ return { pageCount: visibleMetas.length, bodyMeta };
506
+ }
507
+ /**
508
+ * Build the per-body manifest meta from a page's frontmatter + resolved
509
+ * cover image. `role` always lands so the Foundry side can apply the
510
+ * dmRole permission gate; the foundry_base / image fields are conditional.
511
+ */
512
+ function collectBodyMeta(p) {
513
+ const fm = p.frontmatter ?? {};
514
+ const out = { role: p.role };
515
+ const basename = p.path.split("/").pop().replace(/\.md$/i, "");
516
+ if (p.title && p.title !== basename)
517
+ out.title = p.title;
518
+ const fb = fm["foundry_base"];
519
+ if (typeof fb === "string" && fb.trim().length > 0) {
520
+ out.foundry_base = fb.trim();
521
+ }
522
+ const fo = fm["foundry"];
523
+ if (fo && typeof fo === "object" && !Array.isArray(fo)) {
524
+ out.foundry = fo;
429
525
  }
430
- return { pageCount: visibleMetas.length };
526
+ if (p.coverImage)
527
+ out.image = p.coverImage;
528
+ return out;
431
529
  }
432
530
  const EMBED_RE = /!\[\[([^\[\]|#\n]+?)(?:\|[^\[\]#\n]*)?\]\]/g;
433
- async function copyReferencedImages(visibleSources, imageIndex, stagingDir, variantDir) {
531
+ async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, stagingDir, variantDir) {
434
532
  const refs = new Set();
435
533
  for (const source of visibleSources.values()) {
436
534
  for (const m of source.matchAll(EMBED_RE)) {
@@ -442,6 +540,23 @@ async function copyReferencedImages(visibleSources, imageIndex, stagingDir, vari
442
540
  refs.add(image.outputPath);
443
541
  }
444
542
  }
543
+ // Pages can name their cover via `image:` frontmatter alone (no body embed);
544
+ // pull those in too. coverImage was resolved to the served URL upstream, so
545
+ // strip the leading slash + decode to get back to the staging-relative path.
546
+ for (const p of visibleMetas) {
547
+ if (!p.coverImage)
548
+ continue;
549
+ if (/^https?:\/\//i.test(p.coverImage))
550
+ continue;
551
+ let outputPath;
552
+ try {
553
+ outputPath = decodeURIComponent(p.coverImage.replace(/^\//, ""));
554
+ }
555
+ catch {
556
+ continue;
557
+ }
558
+ refs.add(outputPath);
559
+ }
445
560
  for (const outputPath of refs) {
446
561
  const src = join(stagingDir, outputPath);
447
562
  const dst = join(variantDir, outputPath);
@@ -456,6 +571,54 @@ async function copyReferencedImages(visibleSources, imageIndex, stagingDir, vari
456
571
  }
457
572
  }
458
573
  }
574
+ // `[label](path/to/file.ext)` style markdown link. Captures the URL part.
575
+ // `\.[a-z0-9]+` requires an extension; we don't want to scoop up plain
576
+ // internal page links (e.g. `(href)` without an extension).
577
+ const MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+\.[a-z0-9]+)(?:\s+["'][^"']*["'])?\)/gi;
578
+ // `[[file.ext]]` and `![[file.ext]]` — Obsidian-flavoured wikilinks/embeds.
579
+ const WIKI_LINK_RE = /!?\[\[([^\[\]|#\n]+\.[a-z0-9]+)(?:\|[^\[\]#\n]*)?(?:#[^\[\]\n]*)?\]\]/gi;
580
+ /**
581
+ * Per-variant reference scan for passthrough files. A file lands in this
582
+ * variant's deploy only if a visible page mentions it — same gating story
583
+ * as images. Match patterns cover Obsidian embeds (`![[file.pdf]]`),
584
+ * Obsidian wikilinks (`[[file.pdf]]`), and standard markdown links
585
+ * (`[label](path/file.pdf)`). Anything not matched is dropped — that's
586
+ * the whole point of the change; a stray DM-only audio cue stays in the
587
+ * dm variant only.
588
+ */
589
+ async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stagingDir, variantDir) {
590
+ if (passthroughIndex.size === 0)
591
+ return;
592
+ const refs = new Set();
593
+ for (const source of visibleSources.values()) {
594
+ for (const m of source.matchAll(WIKI_LINK_RE)) {
595
+ const name = m[1].trim();
596
+ const entry = passthroughIndex.get(slugify(name.split("/").pop()));
597
+ if (entry)
598
+ refs.add(entry.outputPath);
599
+ }
600
+ for (const m of source.matchAll(MD_LINK_RE)) {
601
+ const name = m[1].trim();
602
+ // Skip http(s) links and anchor-only refs.
603
+ if (/^(https?:|mailto:|#)/i.test(name))
604
+ continue;
605
+ const entry = passthroughIndex.get(slugify(name.split("/").pop()));
606
+ if (entry)
607
+ refs.add(entry.outputPath);
608
+ }
609
+ }
610
+ for (const outputPath of refs) {
611
+ const src = join(stagingDir, outputPath);
612
+ const dst = join(variantDir, outputPath);
613
+ await mkdir(dirname(dst), { recursive: true });
614
+ try {
615
+ await copyFile(src, dst);
616
+ }
617
+ catch (err) {
618
+ console.warn(` warning: could not copy ${outputPath}: ${err.message}`);
619
+ }
620
+ }
621
+ }
459
622
  /**
460
623
  * Build synthesised index.md for any folder (including the root) that has
461
624
  * pages but no existing index.md.
@@ -565,6 +728,20 @@ async function compressImageCached(file, quality, cacheDir, onHit) {
565
728
  await writeFile(cachePath, compressed.body);
566
729
  return { body: compressed.body, outputPath: compressed.outputPath };
567
730
  }
731
+ /**
732
+ * Pull the raw `---\n...\n---` frontmatter block out of a markdown source so
733
+ * the layout can show it verbatim (preserving the user's exact formatting,
734
+ * comments, and key order). Returns the inner-block text or null when the
735
+ * page has no frontmatter. The shape `{ frontmatterYaml }` is so callers can
736
+ * spread it directly into the LayoutInput; missing frontmatter contributes
737
+ * nothing to the layout.
738
+ */
739
+ function extractFrontmatterBlock(source) {
740
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
741
+ if (!m || !m[1] || !m[1].trim())
742
+ return null;
743
+ return { frontmatterYaml: m[1] };
744
+ }
568
745
  function parseFrontmatter(source) {
569
746
  const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
570
747
  if (!block)
@@ -691,24 +868,34 @@ function kindLabel(kind) {
691
868
  * variant dir but inside the deploy root) are listed too; clients use a
692
869
  * single manifest to diff the entire site, not just the role-specific bits.
693
870
  */
694
- async function buildManifest(rootDir, variantDir) {
871
+ async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles, vaultName) {
695
872
  const files = [];
696
873
  const seen = new Set();
697
874
  // Variant-specific files: use pathBase=variantDir so paths come out as
698
875
  // "index.html", not "_variants/<role>/index.html". This matches the public
699
876
  // URL the client uses; the auth middleware does the variant rewrite.
700
- await walkAndIndex(variantDir, variantDir, files, seen);
877
+ await walkAndIndex(variantDir, variantDir, files, seen, [], bodyMeta);
701
878
  // Shared assets under the deploy root (attachments, css). Skip the variant
702
879
  // tree itself and anything inside `functions/` (Function code isn't served).
703
880
  if (rootDir !== variantDir) {
704
881
  await walkAndIndex(rootDir, rootDir, files, seen, [
705
882
  "_variants", "functions", ".image-staging", ".other-staging",
706
- ]);
883
+ ], bodyMeta);
707
884
  }
708
885
  files.sort((a, b) => a.path.localeCompare(b.path));
709
- return { files };
886
+ // `auth.required` lets clients (Foundry, MCP) tell up-front whether the
887
+ // deploy has middleware. Single-role builds collapse to a pure-static
888
+ // deploy with no /_batch / /_connect endpoints — clients fall back to
889
+ // direct CDN GETs in that case. `auth.roles` ships the role order
890
+ // (lowest→highest) so clients can rank a page's tier against a chosen
891
+ // cutoff (e.g. Foundry's per-vault dmRole).
892
+ // `name` is the vault's display name (settings.md `vault_name`); clients
893
+ // like the Foundry module use it as the default label + root folder when
894
+ // a user adds the vault, so they get something readable instead of a
895
+ // host-derived slug.
896
+ return { name: vaultName, auth: { required: authRequired, roles }, files };
710
897
  }
711
- async function walkAndIndex(dir, pathBase, out, seen, skipDirNames = []) {
898
+ async function walkAndIndex(dir, pathBase, out, seen, skipDirNames, bodyMeta) {
712
899
  const entries = await readdir(dir, { withFileTypes: true });
713
900
  for (const ent of entries) {
714
901
  if (ent.name === "_manifest.json")
@@ -717,7 +904,7 @@ async function walkAndIndex(dir, pathBase, out, seen, skipDirNames = []) {
717
904
  if (ent.isDirectory()) {
718
905
  if (skipDirNames.includes(ent.name))
719
906
  continue;
720
- await walkAndIndex(abs, pathBase, out, seen, skipDirNames);
907
+ await walkAndIndex(abs, pathBase, out, seen, skipDirNames, bodyMeta);
721
908
  continue;
722
909
  }
723
910
  if (!ent.isFile())
@@ -728,15 +915,36 @@ async function walkAndIndex(dir, pathBase, out, seen, skipDirNames = []) {
728
915
  seen.add(path);
729
916
  const body = await readFile(abs);
730
917
  const info = await stat(abs);
918
+ const meta = bodyMeta.get(path);
919
+ // Fold meta JSON into the hash so meta-only edits (e.g. a foundry_base
920
+ // tweak with no body change) still bump the row hash and trigger sync.
921
+ const hasher = createHash("md5").update(body);
922
+ if (meta)
923
+ hasher.update("\x00meta:" + stableStringify(meta));
731
924
  out.push({
732
925
  path,
733
- hash: createHash("md5").update(body).digest("hex"),
926
+ hash: hasher.digest("hex"),
734
927
  size: info.size,
735
928
  mtime: Math.floor(info.mtimeMs / 1000),
736
929
  content_type: contentTypeForExt(ent.name),
930
+ ...(meta ? { meta } : {}),
737
931
  });
738
932
  }
739
933
  }
934
+ /**
935
+ * Deterministic JSON encoder. Object keys are sorted recursively so two
936
+ * frontmatters with the same shape but different key order produce the same
937
+ * hash; otherwise the manifest would churn on every YAML reformat.
938
+ */
939
+ function stableStringify(value) {
940
+ if (value === null || typeof value !== "object")
941
+ return JSON.stringify(value);
942
+ if (Array.isArray(value))
943
+ return "[" + value.map(stableStringify).join(",") + "]";
944
+ const obj = value;
945
+ const keys = Object.keys(obj).sort();
946
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
947
+ }
740
948
  function contentTypeForExt(filename) {
741
949
  const ext = filename.split(".").pop()?.toLowerCase() ?? "";
742
950
  const map = {