@wizzlethorpe/vaults 0.6.1 → 0.8.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 (102) hide show
  1. package/README.md +135 -17
  2. package/dist/build.js +433 -181
  3. package/dist/build.js.map +1 -1
  4. package/dist/commands/build.js +4 -4
  5. package/dist/commands/build.js.map +1 -1
  6. package/dist/commands/init.js +13 -10
  7. package/dist/commands/init.js.map +1 -1
  8. package/dist/commands/password.js +3 -1
  9. package/dist/commands/password.js.map +1 -1
  10. package/dist/commands/patreon.js +30 -20
  11. package/dist/commands/patreon.js.map +1 -1
  12. package/dist/commands/preview.js +10 -8
  13. package/dist/commands/preview.js.map +1 -1
  14. package/dist/commands/push.js +11 -9
  15. package/dist/commands/push.js.map +1 -1
  16. package/dist/commands/role.js +5 -0
  17. package/dist/commands/role.js.map +1 -1
  18. package/dist/config.js +30 -15
  19. package/dist/config.js.map +1 -1
  20. package/dist/escape.js +29 -0
  21. package/dist/escape.js.map +1 -0
  22. package/dist/favicon.js +3 -36
  23. package/dist/favicon.js.map +1 -1
  24. package/dist/foundry-importer.js +61 -0
  25. package/dist/foundry-importer.js.map +1 -0
  26. package/dist/images.js +0 -30
  27. package/dist/images.js.map +1 -1
  28. package/dist/index.js +37 -4
  29. package/dist/index.js.map +1 -1
  30. package/dist/migrate/0.6-legacy-auth-settings.js +96 -0
  31. package/dist/migrate/0.6-legacy-auth-settings.js.map +1 -0
  32. package/dist/migrate/0.7-vaults-dir.js +70 -0
  33. package/dist/migrate/0.7-vaults-dir.js.map +1 -0
  34. package/dist/migrate/registry.js +6 -0
  35. package/dist/migrate/registry.js.map +1 -0
  36. package/dist/migrate/run.js +38 -0
  37. package/dist/migrate/run.js.map +1 -0
  38. package/dist/migrate/types.js +8 -0
  39. package/dist/migrate/types.js.map +1 -0
  40. package/dist/paths.js +66 -0
  41. package/dist/paths.js.map +1 -0
  42. package/dist/render/auth-template.js +23 -141
  43. package/dist/render/auth-template.js.map +1 -1
  44. package/dist/render/bases.js +56 -44
  45. package/dist/render/bases.js.map +1 -1
  46. package/dist/render/callouts.js +29 -10
  47. package/dist/render/callouts.js.map +1 -1
  48. package/dist/render/embed.js +124 -26
  49. package/dist/render/embed.js.map +1 -1
  50. package/dist/render/extensions.js +68 -0
  51. package/dist/render/extensions.js.map +1 -0
  52. package/dist/render/external-links.js +32 -0
  53. package/dist/render/external-links.js.map +1 -0
  54. package/dist/render/footer.js +37 -0
  55. package/dist/render/footer.js.map +1 -0
  56. package/dist/render/frontmatter.js +17 -0
  57. package/dist/render/frontmatter.js.map +1 -0
  58. package/dist/render/handlers/assets.js +123 -0
  59. package/dist/render/handlers/assets.js.map +1 -0
  60. package/dist/render/handlers/builtin/dice.js +78 -0
  61. package/dist/render/handlers/builtin/dice.js.map +1 -0
  62. package/dist/render/handlers/builtin/fm-code.js +50 -0
  63. package/dist/render/handlers/builtin/fm-code.js.map +1 -0
  64. package/dist/render/handlers/builtin/fm.js +83 -0
  65. package/dist/render/handlers/builtin/fm.js.map +1 -0
  66. package/dist/render/handlers/builtin/index.js +10 -0
  67. package/dist/render/handlers/builtin/index.js.map +1 -0
  68. package/dist/render/handlers/builtin/inline-format.js +26 -0
  69. package/dist/render/handlers/builtin/inline-format.js.map +1 -0
  70. package/dist/render/handlers/builtin/statblock.js +491 -0
  71. package/dist/render/handlers/builtin/statblock.js.map +1 -0
  72. package/dist/render/handlers/dispatch.js +182 -0
  73. package/dist/render/handlers/dispatch.js.map +1 -0
  74. package/dist/render/handlers/loader.js +90 -0
  75. package/dist/render/handlers/loader.js.map +1 -0
  76. package/dist/render/handlers/types.js +60 -0
  77. package/dist/render/handlers/types.js.map +1 -0
  78. package/dist/render/image-srcs.js +42 -0
  79. package/dist/render/image-srcs.js.map +1 -0
  80. package/dist/render/layout.js +62 -9
  81. package/dist/render/layout.js.map +1 -1
  82. package/dist/render/pipeline.js +37 -8
  83. package/dist/render/pipeline.js.map +1 -1
  84. package/dist/render/preview.js +10 -5
  85. package/dist/render/preview.js.map +1 -1
  86. package/dist/render/slug.js +5 -0
  87. package/dist/render/slug.js.map +1 -1
  88. package/dist/render/styles.js +118 -11
  89. package/dist/render/styles.js.map +1 -1
  90. package/dist/render/wikilink.js +15 -4
  91. package/dist/render/wikilink.js.map +1 -1
  92. package/dist/scan.js +1 -1
  93. package/dist/scan.js.map +1 -1
  94. package/dist/settings.js +25 -4
  95. package/dist/settings.js.map +1 -1
  96. package/dist/version.js +36 -0
  97. package/dist/version.js.map +1 -0
  98. package/package.json +12 -12
  99. package/dist/api.js +0 -42
  100. package/dist/api.js.map +0 -1
  101. package/dist/render/mcp-template.js +0 -239
  102. package/dist/render/mcp-template.js.map +0 -1
package/dist/build.js CHANGED
@@ -1,32 +1,34 @@
1
- import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
1
+ import { copyFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { createHash } from "node:crypto";
3
3
  import { relative } from "node:path";
4
4
  import { dirname, join } from "node:path";
5
5
  import { availableParallelism } from "node:os";
6
6
  import picomatch from "picomatch";
7
7
  import { scanVault } from "./scan.js";
8
- import { compressImage, COMPRESSIBLE_EXT_RE } from "./images.js";
8
+ import { compressImage } from "./images.js";
9
+ import { IMAGE_EXT_RE, PASSTHROUGH_EXT_RE, COMPRESSIBLE_EXT_RE, contentTypeForExt, } from "./render/extensions.js";
9
10
  import { buildFavicon } from "./favicon.js";
10
- // Any image format that can be referenced via ![[name.ext]]; superset of
11
- // COMPRESSIBLE_EXT_RE since SVGs/GIFs ship as-is rather than being recoded.
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;
19
11
  import { renderMarkdown } from "./render/pipeline.js";
12
+ import { extractH1 } from "./render/frontmatter.js";
13
+ import { CLI_VERSION, MANIFEST_VERSION, ID_SCHEME } from "./version.js";
20
14
  import { renderLayout, render404 } from "./render/layout.js";
15
+ import { writeFoundryImporter } from "./foundry-importer.js";
21
16
  import { slugify } from "./render/slug.js";
22
17
  import { buildPreview } from "./render/preview.js";
23
18
  import { resolvePageImage } from "./render/cover.js";
24
19
  import { DEFAULT_CSS, renderThemeOverride } from "./render/styles.js";
25
20
  import { loadObsidianSnippets } from "./obsidian.js";
26
21
  import { loadSettings, writeSettings, SETTINGS_FILE } from "./settings.js";
27
- import { loadConfig, saveConfig } from "./config.js";
22
+ import { loadConfig } from "./config.js";
28
23
  import matter from "gray-matter";
29
24
  import { renderAuthMiddleware, LOGIN_HTML } from "./render/auth-template.js";
25
+ import { renderFooterHtml } from "./render/footer.js";
26
+ import { buildRegistry } from "./render/handlers/types.js";
27
+ import { loadUserHandlers } from "./render/handlers/loader.js";
28
+ import { BUILTIN_HANDLERS } from "./render/handlers/builtin/index.js";
29
+ import { bundleHandlerAssets } from "./render/handlers/assets.js";
30
+ import { runMigrations } from "./migrate/run.js";
31
+ import { cacheDir } from "./paths.js";
30
32
  import { formatDuration, pMap, Progress } from "./util.js";
31
33
  /**
32
34
  * Output layout when there are multiple roles:
@@ -41,17 +43,16 @@ import { formatDuration, pMap, Progress } from "./util.js";
41
43
  * <pages>.preview.json
42
44
  * _search-index.json
43
45
  *
44
- * When there's a single role (the default `public`-only case) we collapse
45
- * `_variants/public/...` up to the root for backwards compatibility with
46
- * the current `vaults preview` and `vaults push` flow.
46
+ * Single-role builds (the default `public`-only case) collapse
47
+ * `_variants/public/...` up to the root.
47
48
  */
48
49
  export async function buildSite(opts) {
49
50
  const start = Date.now();
50
51
  const concurrency = Math.max(2, availableParallelism());
51
- // One-shot migration for vaults from before auth config moved to
52
- // .vaultrc.json. If the user's settings.md still has roles/auth_type/
53
- // role_passwords, copy them over before the canonicalizer strips them.
54
- await migrateLegacyAuthFromSettings(opts.vaultPath);
52
+ // Run any pending schema / layout migrations before reading anything
53
+ // else. The framework is idempotent: already-migrated vaults pay only
54
+ // the cost of a few stat() calls. See cli/src/migrate/.
55
+ await runMigrations(opts.vaultPath);
55
56
  // ── Settings (user-editable) ─────────────────────────────────────────────
56
57
  const settings = await loadSettings(opts.vaultPath);
57
58
  for (const w of settings.warnings)
@@ -66,6 +67,26 @@ export async function buildSite(opts) {
66
67
  imageQuality: opts.imageQuality === 85 ? settings.values.image_quality : opts.imageQuality,
67
68
  maxFileBytes: opts.maxFileBytes === 25 * 1024 * 1024 ? settings.values.max_file_bytes : opts.maxFileBytes,
68
69
  };
70
+ // ── Custom handlers ──────────────────────────────────────────────────────
71
+ // Built-ins ship with the CLI; user handlers live in `.vaults/handlers/`
72
+ // and can override built-in names (last-registered wins). One registry
73
+ // is built once and shared across every variant render.
74
+ const userHandlers = await loadUserHandlers(opts.vaultPath);
75
+ const handlerRegistry = buildRegistry(BUILTIN_HANDLERS, userHandlers.map((h) => h.handler));
76
+ if (userHandlers.length > 0) {
77
+ console.log(` loaded ${userHandlers.length} custom handler(s) from .vaults/handlers/`);
78
+ }
79
+ // Concatenate browser-side assets declared by built-in and user handlers
80
+ // into a single _handlers.js / _handlers.css emitted at the deploy root.
81
+ // Each unique source is included once, regardless of invocation count.
82
+ // Two independent flags so a deploy with only-JS or only-CSS doesn't
83
+ // reference a file that wasn't written.
84
+ const handlerAssets = await bundleHandlerAssets(userHandlers, BUILTIN_HANDLERS, opts.vaultPath);
85
+ const hasHandlerJs = handlerAssets.js.length > 0;
86
+ const hasHandlerCss = handlerAssets.css.length > 0;
87
+ // Footer markdown rendered once per build; the resulting HTML is
88
+ // embedded verbatim in every page's layout. Empty string = no footer.
89
+ const footerHtml = await renderFooterHtml(settings.values.footer);
69
90
  // ── CLI-managed state (auth) ─────────────────────────────────────────────
70
91
  const cfg = await loadConfig(opts.vaultPath, {});
71
92
  const roles = cfg.roles.length > 0 ? cfg.roles : ["public"];
@@ -101,8 +122,15 @@ export async function buildSite(opts) {
101
122
  }
102
123
  return true;
103
124
  });
104
- await rm(opts.outputDir, { recursive: true, force: true });
105
- await mkdir(opts.outputDir, { recursive: true });
125
+ // Atomic build: write into <outputDir>.tmp and rename at the end.
126
+ // A failed mid-build leaves the previous deploy intact instead of
127
+ // serving half a website. Re-point opts.outputDir at the work dir so
128
+ // every downstream writeFile / mkdir lands in the right place.
129
+ const finalOutputDir = opts.outputDir;
130
+ const workOutputDir = finalOutputDir + ".tmp";
131
+ await rm(workOutputDir, { recursive: true, force: true });
132
+ await mkdir(workOutputDir, { recursive: true });
133
+ opts = { ...opts, outputDir: workOutputDir };
106
134
  const markdownFiles = withinLimit.filter((f) => /\.md$/i.test(f.path));
107
135
  const imageFiles = withinLimit.filter((f) => IMAGE_EXT_RE.test(f.path));
108
136
  // .base files are consumed at build time (rendered into HTML where embedded)
@@ -151,6 +179,13 @@ export async function buildSite(opts) {
151
179
  const basename = f.path.split("/").pop().replace(/\.base$/i, "");
152
180
  baseSources.set(slugify(basename), await readFile(f.absolute, "utf8"));
153
181
  });
182
+ // Two parses per page: the regex-based parseFrontmatter is tolerant of
183
+ // malformed YAML and still extracts title/role; gray-matter gives us
184
+ // the full property set for Bases.
185
+ const parsedSources = new Map();
186
+ for (const f of markdownFiles) {
187
+ parsedSources.set(f.path, parseFullFrontmatterWithContent(sources.get(f.path)));
188
+ }
154
189
  // Parse role + title per page. Pages with an unrecognised role fall back
155
190
  // to the default with a warning; better than silently dropping them. We
156
191
  // also stash the full frontmatter on each PageMeta so the Bases plugin
@@ -158,7 +193,7 @@ export async function buildSite(opts) {
158
193
  const allPageMetas = markdownFiles.map((f) => {
159
194
  const src = sources.get(f.path);
160
195
  const meta = parseFrontmatter(src);
161
- const fullFm = parseFullFrontmatter(src);
196
+ const fullFm = parsedSources.get(f.path).data;
162
197
  let role = meta.role ?? defaultRole;
163
198
  if (!allRoleSet.has(role)) {
164
199
  console.warn(` ${f.path}: role "${role}" not in settings.roles, using "${defaultRole}"`);
@@ -183,8 +218,8 @@ export async function buildSite(opts) {
183
218
  const imageStagingDir = join(opts.outputDir, ".image-staging");
184
219
  const imageIndex = new Map();
185
220
  if (imageFiles.length > 0) {
186
- const cacheDir = join(opts.vaultPath, ".vault-cache", "images", `q${opts.imageQuality}`);
187
- await mkdir(cacheDir, { recursive: true });
221
+ const cacheImageDir = join(cacheDir(opts.vaultPath), "images", `q${opts.imageQuality}`);
222
+ await mkdir(cacheImageDir, { recursive: true });
188
223
  let cacheHits = 0;
189
224
  const progress = new Progress("Images");
190
225
  progress.update(0, imageFiles.length);
@@ -192,7 +227,7 @@ export async function buildSite(opts) {
192
227
  // SVGs / non-compressible images pass through; everything else gets
193
228
  // recoded to webp for size. Either way they land in the staging dir.
194
229
  const compressed = opts.imageQuality > 0 && COMPRESSIBLE_EXT_RE.test(f.path)
195
- ? await compressImageCached(f, opts.imageQuality, cacheDir, () => { cacheHits++; })
230
+ ? await compressImageCached(f, opts.imageQuality, cacheImageDir, () => { cacheHits++; })
196
231
  : { body: await readFile(f.absolute), outputPath: f.path };
197
232
  const dest = join(imageStagingDir, compressed.outputPath);
198
233
  await mkdir(dirname(dest), { recursive: true });
@@ -234,12 +269,30 @@ export async function buildSite(opts) {
234
269
  const themeOverride = renderThemeOverride({
235
270
  lightAccent: settings.values.accent_color,
236
271
  lightBg: settings.values.bg_color,
272
+ darkAccent: settings.values.accent_color_dark,
273
+ darkBg: settings.values.bg_color_dark,
237
274
  });
238
275
  await writeFile(join(opts.outputDir, "styles.css"), DEFAULT_CSS + themeOverride);
239
276
  const userCss = await loadObsidianSnippets(opts.vaultPath);
240
277
  await writeFile(join(opts.outputDir, "user.css"), userCss);
241
278
  if (userCss)
242
279
  console.log(` loaded user.css from .obsidian/snippets/`);
280
+ // Browser-side handler assets (built-in + user) concatenated into a
281
+ // single deploy-root JS and CSS file. Skipped entirely if no handler
282
+ // declared any assets (purely declarative handlers stay overhead-free).
283
+ if (hasHandlerJs)
284
+ await writeFile(join(opts.outputDir, "_handlers.js"), handlerAssets.js);
285
+ if (hasHandlerCss)
286
+ await writeFile(join(opts.outputDir, "_handlers.css"), handlerAssets.css);
287
+ // Foundry importer bundle: one ESM file the Foundry module fetches at
288
+ // sync time, plus a tiny version manifest with the SHA-256 the host
289
+ // verifies against its trust cache.
290
+ await writeFoundryImporter(opts.outputDir);
291
+ // Foundry-import bundles are written per-variant inside the role loop
292
+ // below (instead of at the root) so the middleware role-gates them. A
293
+ // public visitor can't fetch the dm-tier handler bundle even if it
294
+ // contains different content. The path stays `/_handlers.foundry.{js,css}`
295
+ // — the middleware rewrites root requests to the matching variant.
243
296
  // Favicon; either user-supplied via settings.favicon, or a generated
244
297
  // default with the vault's first letter in accent on the theme background.
245
298
  try {
@@ -259,11 +312,19 @@ export async function buildSite(opts) {
259
312
  // Computed once against the final imageIndex so OG meta tags, Bases card
260
313
  // covers, hover previews, and Foundry actor/item reskin all resolve to the
261
314
  // same URL. settings.auto_image flips body-fallback discovery on/off.
315
+ //
316
+ // Pre-strip every role-typed callout from the body before discovery: the
317
+ // cover URL has to be the same across all variants the page is visible
318
+ // in, so it must not come from inside a `[!dm]` block (which would leak
319
+ // the image to public deploys). Frontmatter `image:` values are honoured
320
+ // as-is — those are explicit author intent.
321
+ const allRoleTypes = new Set(roles);
262
322
  for (const meta of allPageMetas) {
263
323
  const src = sources.get(meta.path);
264
324
  if (!src)
265
325
  continue;
266
- const cover = resolvePageImage(src, meta.frontmatter, imageIndex, settings.values.auto_image);
326
+ const stripped = stripRoleGatedCallouts(src, allRoleTypes);
327
+ const cover = resolvePageImage(stripped, meta.frontmatter, imageIndex, settings.values.auto_image);
267
328
  if (cover)
268
329
  meta.coverImage = cover;
269
330
  }
@@ -287,8 +348,10 @@ export async function buildSite(opts) {
287
348
  redactRoles,
288
349
  variantDir,
289
350
  vaultName: opts.vaultName,
351
+ vaultPath: opts.vaultPath,
290
352
  allPageMetas,
291
353
  sources,
354
+ parsedSources,
292
355
  baseSources,
293
356
  imageIndex,
294
357
  imageStagingDir,
@@ -296,18 +359,45 @@ export async function buildSite(opts) {
296
359
  passthroughStagingDir: otherStagingDir,
297
360
  settings: settings.values,
298
361
  authConfigured: roles.length > 1,
362
+ handlerRegistry,
363
+ hasHandlerJs,
364
+ hasHandlerCss,
365
+ footerHtml,
299
366
  concurrency,
300
367
  allWarnings: opts.allWarnings,
301
368
  });
302
369
  perRolePageCount[role] = stats.pageCount;
303
370
  if (!collapseToRoot)
304
371
  console.log(` variant '${role}': ${stats.pageCount} pages`);
372
+ // Foundry-import opt-in bundles, emitted INSIDE the variant directory
373
+ // (not at the deploy root) so the auth middleware role-gates them.
374
+ // Single-role builds collapse variantDir to outputDir, so the file
375
+ // ends up at root automatically. The Foundry module fetches by the
376
+ // canonical `/_handlers.foundry.{js,css}` path; the middleware
377
+ // rewrites that to the matching `_variants/<role>/...` per the
378
+ // requesting bearer token's role.
379
+ // Foundry-import subset bundles. The Foundry module fetches these by
380
+ // their canonical `/_handlers.foundry.{js,css}` paths; the middleware
381
+ // role-gates per the requesting bearer's variant.
382
+ if (handlerAssets.foundry) {
383
+ if (handlerAssets.foundry.js.length > 0) {
384
+ await writeFile(join(variantDir, "_handlers.foundry.js"), handlerAssets.foundry.js);
385
+ }
386
+ if (handlerAssets.foundry.css.length > 0) {
387
+ await writeFile(join(variantDir, "_handlers.foundry.css"), handlerAssets.foundry.css);
388
+ }
389
+ }
305
390
  // Write a per-variant _manifest.json so external clients (Foundry, MCP,
306
391
  // etc.) can do an incremental diff. Includes EVERY file that variant
307
392
  // serves; html, md, images (as relative paths into shared root), css.
308
393
  // bodyMeta carries per-page Foundry reskin metadata; folded into each
309
394
  // 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);
395
+ const manifest = await buildManifest(opts.outputDir, variantDir, stats.bodyMeta, !collapseToRoot, roles, opts.vaultName, {
396
+ hasHandlerJs,
397
+ hasHandlerCss,
398
+ hasFoundryJs: (handlerAssets.foundry?.js.length ?? 0) > 0,
399
+ hasFoundryCss: (handlerAssets.foundry?.css.length ?? 0) > 0,
400
+ });
311
401
  await writeFile(join(variantDir, "_manifest.json"), JSON.stringify(manifest));
312
402
  }
313
403
  // ── Pages Functions ─────────────────────────────────────────────────────
@@ -320,11 +410,12 @@ export async function buildSite(opts) {
320
410
  // mapped to a tier. clientSecret stays out of the bundle — it lives in
321
411
  // the Wrangler secret PATREON_CLIENT_SECRET, read from env in the
322
412
  // Function. The CLI uploads it on every push.
323
- const patreonForFn = cfg.patreon && cfg.patreon.tiers && Object.keys(cfg.patreon.tiers).length > 0
413
+ const patreon = cfg.oauth?.patreon;
414
+ const patreonForFn = patreon && patreon.tiers && Object.keys(patreon.tiers).length > 0
324
415
  ? {
325
- clientId: cfg.patreon.clientId,
326
- campaignId: cfg.patreon.campaignId,
327
- tiers: cfg.patreon.tiers,
416
+ clientId: patreon.clientId,
417
+ campaignId: patreon.campaignId,
418
+ tiers: patreon.tiers,
328
419
  }
329
420
  : null;
330
421
  const middleware = renderAuthMiddleware({
@@ -353,6 +444,12 @@ export async function buildSite(opts) {
353
444
  // variant that needs them, so they're no longer required for the deploy.
354
445
  await rm(imageStagingDir, { recursive: true, force: true });
355
446
  await rm(otherStagingDir, { recursive: true, force: true });
447
+ // Atomic swap: move the freshly-built tree into the final location.
448
+ // rm-then-rename: Node's rename refuses to overwrite a non-empty dir.
449
+ // A crash between rm and rename leaves the output missing, which is
450
+ // visibly broken rather than silently half-built.
451
+ await rm(finalOutputDir, { recursive: true, force: true });
452
+ await rename(workOutputDir, finalOutputDir);
356
453
  console.log(`Built in ${formatDuration(Date.now() - start)}.`);
357
454
  return {
358
455
  files,
@@ -371,10 +468,18 @@ async function buildVariant(a) {
371
468
  if (!a.visibleRoles.has(m.role))
372
469
  continue;
373
470
  visibleMetas.push(m);
374
- visibleSources.set(m.path, a.sources.get(m.path));
471
+ // Strip role-gated callouts from the source BEFORE it enters any
472
+ // downstream pass (renderer, transclusion, asset scanner, outlinks).
473
+ // The renderer's calloutPlugin redacts at render time, but the source
474
+ // is what the asset scanner walks — without this strip, an
475
+ // `![[secret.webp]]` inside a `[!dm]` callout on a `role: public`
476
+ // page would copy the file into the public deploy and be reachable
477
+ // by URL even though the article hides the callout.
478
+ const raw = a.sources.get(m.path);
479
+ visibleSources.set(m.path, stripRoleGatedCallouts(raw, a.redactRoles));
375
480
  }
376
481
  // Synthesize folder indexes from the visible set only.
377
- const folderIndexes = generateFolderIndexes(visibleMetas, a.role);
482
+ const folderIndexes = generateFolderIndexes(visibleMetas, a.role, a.settings.inline_title);
378
483
  for (const fi of folderIndexes) {
379
484
  visibleMetas.push({ path: fi.path, title: fi.title, role: a.role });
380
485
  visibleSources.set(fi.path, fi.markdown);
@@ -399,19 +504,26 @@ async function buildVariant(a) {
399
504
  markdownContent.set(basenameSlug, visibleSources.get(p.path));
400
505
  markdownContent.set(pathSlug, visibleSources.get(p.path));
401
506
  }
507
+ // Pre-compute outlinks per page so the Bases plugin can answer
508
+ // file.hasLink() during render (Bases runs before the wikilink plugin
509
+ // populates the per-render outlinks list).
510
+ const outlinksByPath = collectOutlinksByPath(visibleMetas, visibleSources, pageIndex);
402
511
  const context = {
403
512
  pages: pageIndex,
404
513
  images: a.imageIndex,
514
+ passthroughs: a.passthroughIndex,
405
515
  markdownContent,
406
516
  bases: a.baseSources,
407
517
  defaultImageWidth: a.settings.default_image_width,
408
518
  redactRoles: a.redactRoles,
519
+ handlers: a.handlerRegistry,
520
+ outlinksByPath,
409
521
  };
410
522
  const rendered = new Map();
411
523
  const progress = new Progress(`Pages (${a.role})`);
412
524
  progress.update(0, visibleMetas.length);
413
525
  await pMap(visibleMetas, a.concurrency, async (p) => {
414
- const result = await renderMarkdown(visibleSources.get(p.path), context, basenameNoExt(p.path));
526
+ const result = await renderMarkdown(visibleSources.get(p.path), context, basenameNoExt(p.path), a.parsedSources.get(p.path));
415
527
  rendered.set(p.path, {
416
528
  title: result.title,
417
529
  html: result.html,
@@ -453,6 +565,10 @@ async function buildVariant(a) {
453
565
  centerImages: a.settings.center_images,
454
566
  backlinks,
455
567
  authConfigured: a.authConfigured,
568
+ hasHandlerJs: a.hasHandlerJs,
569
+ hasHandlerCss: a.hasHandlerCss,
570
+ footerHtml: a.footerHtml,
571
+ theme: themeOf(a.settings.theme),
456
572
  ...(p.mtime != null ? { mtime: p.mtime } : {}),
457
573
  ...(p.birthtime != null ? { birthtime: p.birthtime } : {}),
458
574
  ...(p.coverImage ? { coverImage: p.coverImage } : {}),
@@ -467,7 +583,7 @@ async function buildVariant(a) {
467
583
  // remark/rehype pipeline land in journals as-is, no client-side render.
468
584
  const bodyPath = outputBase + ".body.html";
469
585
  await writeFile(join(a.variantDir, bodyPath), r.html);
470
- bodyMeta.set(bodyPath, collectBodyMeta(p));
586
+ bodyMeta.set(bodyPath, await collectBodyMeta(p, a.vaultPath));
471
587
  const source = visibleSources.get(p.path);
472
588
  const preview = await buildPreview(source, r.title);
473
589
  await writeFile(join(a.variantDir, outputBase + ".preview.json"), JSON.stringify(preview));
@@ -482,6 +598,10 @@ async function buildVariant(a) {
482
598
  defaultImageWidth: a.settings.default_image_width,
483
599
  centerImages: a.settings.center_images,
484
600
  authConfigured: a.authConfigured,
601
+ hasHandlerJs: a.hasHandlerJs,
602
+ hasHandlerCss: a.hasHandlerCss,
603
+ footerHtml: a.footerHtml,
604
+ theme: themeOf(a.settings.theme),
485
605
  }));
486
606
  // Per-variant search index. `text` is the page's RENDERED HTML body
487
607
  // collapsed to plain text (tags stripped, entities decoded), so search
@@ -504,32 +624,101 @@ async function buildVariant(a) {
504
624
  // contract as images: ship only into variants whose visible pages
505
625
  // reference the file. A DM-only audio cue can't ride along into the
506
626
  // public deploy because no public-tier source mentions it.
507
- await copyReferencedPassthroughs(visibleSources, a.passthroughIndex, a.passthroughStagingDir, a.variantDir);
627
+ await copyReferencedPassthroughs(visibleSources, visibleMetas, a.passthroughIndex, a.passthroughStagingDir, a.variantDir);
508
628
  return { pageCount: visibleMetas.length, bodyMeta };
509
629
  }
510
630
  /**
511
631
  * Build the per-body manifest meta from a page's frontmatter + resolved
512
632
  * cover image. `role` always lands so the Foundry side can apply the
513
- * dmRole permission gate; the foundry_base / image fields are conditional.
633
+ * dmRole permission gate; the foundry / image fields are conditional.
634
+ *
635
+ * Frontmatter shape forwarded to clients:
636
+ * foundry:
637
+ * base: <UUID> | <Type>[:<subtype>] # required for instantiation
638
+ * embed: false # default true
639
+ * data: { … deep-merged into the doc }
514
640
  */
515
- function collectBodyMeta(p) {
641
+ async function collectBodyMeta(p, vaultPath) {
516
642
  const fm = p.frontmatter ?? {};
517
643
  const out = { role: p.role };
518
644
  const basename = p.path.split("/").pop().replace(/\.md$/i, "");
519
645
  if (p.title && p.title !== basename)
520
646
  out.title = p.title;
521
- const fb = fm["foundry_base"];
522
- if (typeof fb === "string" && fb.trim().length > 0) {
523
- out.foundry_base = fb.trim();
524
- }
525
647
  const fo = fm["foundry"];
526
648
  if (fo && typeof fo === "object" && !Array.isArray(fo)) {
527
- out.foundry = fo;
649
+ const block = {};
650
+ const base = fo["base"];
651
+ if (typeof base === "string" && base.trim().length > 0)
652
+ block.base = base.trim();
653
+ const embed = fo["embed"];
654
+ if (typeof embed === "boolean")
655
+ block.embed = embed;
656
+ const data = fo["data"];
657
+ if (data && typeof data === "object" && !Array.isArray(data))
658
+ block.data = data;
659
+ // foundry.id: an explicit Foundry document id for this page. When set,
660
+ // overrides the SHA1-derived id used for both the JournalEntryPage and
661
+ // (if foundry.base is present) the instantiated derived doc. Lets users
662
+ // hardcode UUIDs that other Foundry-side code (macros, scene flags,
663
+ // module integrations) needs to reference. Foundry ids are 16 chars from
664
+ // [A-Za-z0-9]; a malformed value is dropped with a warning rather than
665
+ // failing the build.
666
+ const idVal = fo["id"];
667
+ if (typeof idVal === "string") {
668
+ const trimmed = idVal.trim();
669
+ if (FOUNDRY_ID_RE.test(trimmed))
670
+ block.id = trimmed;
671
+ else if (trimmed.length > 0) {
672
+ console.warn(` ${p.path}: foundry.id "${trimmed}" is not a valid Foundry id (16 chars [A-Za-z0-9]); ignoring`);
673
+ }
674
+ }
675
+ // foundry.data_json: vault-relative path to a JSON file. Read + parse
676
+ // at build time and inline into the meta as `data_json`. The Foundry
677
+ // module deep-merges it onto the base doc BEFORE foundry.data, so a
678
+ // user can layer hand-tuned overrides on top of an exported sheet.
679
+ // Folding the parsed object into meta means the body-row hash already
680
+ // changes when the JSON content does — no separate change-detection.
681
+ const dataJsonPath = fo["data_json"];
682
+ if (typeof dataJsonPath === "string" && dataJsonPath.trim().length > 0) {
683
+ const parsed = await loadDataJson(vaultPath, dataJsonPath.trim(), p.path);
684
+ if (parsed !== null)
685
+ block.data_json = parsed;
686
+ }
687
+ if (Object.keys(block).length > 0)
688
+ out.foundry = block;
528
689
  }
529
690
  if (p.coverImage)
530
691
  out.image = p.coverImage;
531
692
  return out;
532
693
  }
694
+ /** Read + parse a vault-relative JSON file referenced by `foundry.data_json`.
695
+ * Warns on missing / unparseable file and returns null so the page renders
696
+ * without the overlay rather than failing the build. */
697
+ async function loadDataJson(vaultPath, relPath, pagePath) {
698
+ const abs = join(vaultPath, relPath);
699
+ try {
700
+ const raw = await readFile(abs, "utf8");
701
+ return JSON.parse(raw);
702
+ }
703
+ catch (err) {
704
+ const code = err.code;
705
+ if (code === "ENOENT") {
706
+ console.warn(` ${pagePath}: foundry.data_json "${relPath}" not found, skipping`);
707
+ }
708
+ else {
709
+ console.warn(` ${pagePath}: foundry.data_json "${relPath}" failed to parse: ${err.message}`);
710
+ }
711
+ return null;
712
+ }
713
+ }
714
+ /** Foundry document ids: exactly 16 chars from [A-Za-z0-9]. Validated when
715
+ * authors set `foundry.id` to override the SHA1-derived default. */
716
+ const FOUNDRY_ID_RE = /^[A-Za-z0-9]{16}$/;
717
+ /** Coerce settings.theme to the layout's narrowed union, defaulting to
718
+ * "auto" for any unrecognised value rather than failing the build. */
719
+ function themeOf(s) {
720
+ return s === "light" || s === "dark" ? s : "auto";
721
+ }
533
722
  const EMBED_RE = /!\[\[([^\[\]|#\n]+?)(?:\|[^\[\]#\n]*)?\]\]/g;
534
723
  async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, stagingDir, variantDir) {
535
724
  const refs = new Set();
@@ -546,19 +735,27 @@ async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, st
546
735
  // Pages can name their cover via `image:` frontmatter alone (no body embed);
547
736
  // pull those in too. coverImage was resolved to the served URL upstream, so
548
737
  // strip the leading slash + decode to get back to the staging-relative path.
738
+ // `@vault/PATH` references inside any frontmatter string field also gate
739
+ // an asset into this variant — common for Scene background.src / Playlist
740
+ // sound.path that point at vault-shipped media. Page-role gating still
741
+ // applies because we only walk visibleMetas (= pages this variant can see).
549
742
  for (const p of visibleMetas) {
550
- if (!p.coverImage)
551
- continue;
552
- if (/^https?:\/\//i.test(p.coverImage))
553
- continue;
554
- let outputPath;
555
- try {
556
- outputPath = decodeURIComponent(p.coverImage.replace(/^\//, ""));
743
+ if (p.coverImage && !/^https?:\/\//i.test(p.coverImage)) {
744
+ try {
745
+ refs.add(decodeURIComponent(p.coverImage.replace(/^\//, "")));
746
+ }
747
+ catch { /* malformed coverImage URL — ignore */ }
557
748
  }
558
- catch {
559
- continue;
749
+ if (p.frontmatter) {
750
+ forEachString(p.frontmatter, (s) => {
751
+ const path = vaultRefPath(s);
752
+ if (path && IMAGE_EXT_RE.test(path)) {
753
+ const image = imageIndex.get(slugify(path.split("/").pop()));
754
+ if (image)
755
+ refs.add(image.outputPath);
756
+ }
757
+ });
560
758
  }
561
- refs.add(outputPath);
562
759
  }
563
760
  for (const outputPath of refs) {
564
761
  const src = join(stagingDir, outputPath);
@@ -580,6 +777,73 @@ async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, st
580
777
  const MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+\.[a-z0-9]+)(?:\s+["'][^"']*["'])?\)/gi;
581
778
  // `[[file.ext]]` and `![[file.ext]]` — Obsidian-flavoured wikilinks/embeds.
582
779
  const WIKI_LINK_RE = /!?\[\[([^\[\]|#\n]+\.[a-z0-9]+)(?:\|[^\[\]#\n]*)?(?:#[^\[\]\n]*)?\]\]/gi;
780
+ // `> [!type]…` opens a callout; the rest of the contiguous blockquote (lines
781
+ // starting with `>`, blank line ends) is its body. Used to strip role-gated
782
+ // callouts from the source before any downstream pass sees it.
783
+ const CALLOUT_HEAD_RE = /^>\s*\[!(\w+)\]/;
784
+ /**
785
+ * Drop callout blocks whose type is in `redactRoles` from the source. Walks
786
+ * line-by-line; on a callout-head line whose type is redacted, drops every
787
+ * subsequent line that is part of the same blockquote (starts with `>`).
788
+ * A blank line ends the blockquote per CommonMark.
789
+ *
790
+ * Approximate by markdown standards (doesn't handle lazy-continuation lines
791
+ * or nested blockquotes containing role-gated children), but covers every
792
+ * pattern the asset scanner needs to gate against. The renderer's
793
+ * calloutPlugin still runs as the source of truth for visual redaction;
794
+ * this strip is the asset-leak guard.
795
+ */
796
+ function stripRoleGatedCallouts(source, redactRoles) {
797
+ if (redactRoles.size === 0)
798
+ return source;
799
+ const lines = source.split("\n");
800
+ const out = [];
801
+ let dropping = false;
802
+ for (const line of lines) {
803
+ if (dropping) {
804
+ if (line.startsWith(">"))
805
+ continue; // still inside the blockquote
806
+ dropping = false;
807
+ out.push(line); // blank or non-`>` line ends + keeps the line
808
+ continue;
809
+ }
810
+ const head = CALLOUT_HEAD_RE.exec(line);
811
+ if (head && redactRoles.has(head[1].toLowerCase())) {
812
+ dropping = true;
813
+ continue; // drop the head line
814
+ }
815
+ out.push(line);
816
+ }
817
+ return out.join("\n");
818
+ }
819
+ /**
820
+ * Visit every string value reachable from `value` (object / array / scalar)
821
+ * and call `fn` once per string. Used to surface `@vault/PATH` references
822
+ * inside parsed frontmatter (e.g., a Scene's `foundry.data.background.src`
823
+ * or a Playlist's `foundry.data.sounds[N].path`) so the per-variant asset
824
+ * scanner can include those files alongside body-referenced ones.
825
+ */
826
+ function forEachString(value, fn) {
827
+ if (typeof value === "string")
828
+ return fn(value);
829
+ if (Array.isArray(value)) {
830
+ for (const v of value)
831
+ forEachString(v, fn);
832
+ return;
833
+ }
834
+ if (value && typeof value === "object") {
835
+ for (const v of Object.values(value))
836
+ forEachString(v, fn);
837
+ }
838
+ }
839
+ /** Extract a vault path from a `@vault/PATH` string, or null when the
840
+ * string isn't a vault reference. Trailing fragment / query stripped. */
841
+ function vaultRefPath(s) {
842
+ if (!s.startsWith("@vault/"))
843
+ return null;
844
+ const rest = s.slice("@vault/".length).split("#")[0].split("?")[0];
845
+ return rest.length > 0 ? rest : null;
846
+ }
583
847
  /**
584
848
  * Per-variant reference scan for passthrough files. A file lands in this
585
849
  * variant's deploy only if a visible page mentions it — same gating story
@@ -589,7 +853,7 @@ const WIKI_LINK_RE = /!?\[\[([^\[\]|#\n]+\.[a-z0-9]+)(?:\|[^\[\]#\n]*)?(?:#[^\[\
589
853
  * the whole point of the change; a stray DM-only audio cue stays in the
590
854
  * dm variant only.
591
855
  */
592
- async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stagingDir, variantDir) {
856
+ async function copyReferencedPassthroughs(visibleSources, visibleMetas, passthroughIndex, stagingDir, variantDir) {
593
857
  if (passthroughIndex.size === 0)
594
858
  return;
595
859
  const refs = new Set();
@@ -610,6 +874,22 @@ async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stag
610
874
  refs.add(entry.outputPath);
611
875
  }
612
876
  }
877
+ // `@vault/PATH` references inside any frontmatter string also gate a
878
+ // passthrough into this variant. Same per-page-role visibility rules
879
+ // (only walking visibleMetas) — a dm-tier page's @vault/Audio/secret.ogg
880
+ // ships only to the dm variant.
881
+ for (const p of visibleMetas) {
882
+ if (!p.frontmatter)
883
+ continue;
884
+ forEachString(p.frontmatter, (s) => {
885
+ const path = vaultRefPath(s);
886
+ if (path) {
887
+ const entry = passthroughIndex.get(slugify(path.split("/").pop()));
888
+ if (entry)
889
+ refs.add(entry.outputPath);
890
+ }
891
+ });
892
+ }
613
893
  for (const outputPath of refs) {
614
894
  const src = join(stagingDir, outputPath);
615
895
  const dst = join(variantDir, outputPath);
@@ -624,9 +904,11 @@ async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stag
624
904
  }
625
905
  /**
626
906
  * Build synthesised index.md for any folder (including the root) that has
627
- * pages but no existing index.md.
907
+ * pages but no existing index.md. When `inlineTitle` is true, the layout
908
+ * already injects an <h1> from the page's title, so the synthesised body
909
+ * skips its own `# Title` heading to avoid the duplicate.
628
910
  */
629
- function generateFolderIndexes(existing, _role) {
911
+ function generateFolderIndexes(existing, _role, inlineTitle) {
630
912
  const existingPaths = new Set(existing.map((p) => p.path));
631
913
  const folders = new Map();
632
914
  folders.set("", { folders: new Set(), pages: [] });
@@ -675,11 +957,28 @@ function generateFolderIndexes(existing, _role) {
675
957
  const propsBlock = propsYaml ? `properties:\n${propsYaml}\n` : "";
676
958
  sections.push(`## Pages\n\n\`\`\`base\n${filtersBlock}\n${propsBlock}views:\n - type: table\n name: Contents\n order:\n${orderYaml}\n\`\`\``);
677
959
  }
678
- const heading = title ? `# ${title}\n\n` : "";
679
- out.push({ path: indexPath, title: title || "Home", markdown: `${heading}${sections.join("\n\n")}\n` });
960
+ // With inline_title on, the layout injects an <h1> from the page's
961
+ // title which it learns from the markdown's title source. We can
962
+ // either author the title as a `# Heading` (off-mode) or as YAML
963
+ // frontmatter (on-mode); the latter avoids the duplicated <h1> while
964
+ // still letting the renderer surface the right title.
965
+ const displayTitle = title || "Home";
966
+ const heading = inlineTitle ? "" : (title ? `# ${title}\n\n` : "");
967
+ const frontmatter = inlineTitle ? `---\ntitle: ${yamlString(displayTitle)}\n---\n\n` : "";
968
+ out.push({
969
+ path: indexPath,
970
+ title: displayTitle,
971
+ markdown: `${frontmatter}${heading}${sections.join("\n\n")}\n`,
972
+ });
680
973
  }
681
974
  return out;
682
975
  }
976
+ /** YAML-quote a string only when needed (special chars or ambiguous flow). */
977
+ function yamlString(s) {
978
+ if (/^[A-Za-z0-9_ .-]+$/.test(s) && !/^(true|false|null|yes|no)$/i.test(s))
979
+ return s;
980
+ return JSON.stringify(s);
981
+ }
683
982
  /**
684
983
  * Pick a small set of columns for an auto-generated folder index based on
685
984
  * what frontmatter the pages in that folder actually have. The first
@@ -696,7 +995,13 @@ function chooseColumns(pages) {
696
995
  if (["title", "role", "aliases", "tags"].includes(key))
697
996
  continue;
698
997
  const v = fm[key];
699
- if (v == null || v === "" || (Array.isArray(v) && v.length === 0))
998
+ if (v == null || v === "")
999
+ continue;
1000
+ // Skip non-scalar values — arrays and plain objects render as
1001
+ // "[object Object]" or comma-joined junk in a table cell. Dates
1002
+ // are technically objects but renderValue formats them nicely,
1003
+ // so let them through.
1004
+ if (typeof v === "object" && !(v instanceof Date))
700
1005
  continue;
701
1006
  counts.set(key, (counts.get(key) ?? 0) + 1);
702
1007
  }
@@ -739,6 +1044,35 @@ async function compressImageCached(file, quality, cacheDir, onHit) {
739
1044
  * spread it directly into the LayoutInput; missing frontmatter contributes
740
1045
  * nothing to the layout.
741
1046
  */
1047
+ // Pre-compute outgoing wikilinks per page (vault path → set of vault paths).
1048
+ // Bases needs this before render runs so file.hasLink() can answer truthfully
1049
+ // during render; the wikilink plugin's per-render outlinks list is collected
1050
+ // after Bases has already drawn the table. A regex scan over markdown source
1051
+ // (rather than rebuilding the AST) is fine: this only needs to detect link
1052
+ // targets, and an embedded ![[image.png]] resolves to no page anyway.
1053
+ const WIKILINK_SCAN_RE = /(?<!!)(?<!\[)\[\[([^\[\]|#\n]+?)(?:#[^\[\]|\n]+?)?(?:\|[^\[\]#\n]+?)?\]\]/g;
1054
+ function collectOutlinksByPath(metas, sources, pageIndex) {
1055
+ const out = new Map();
1056
+ for (const p of metas) {
1057
+ const src = sources.get(p.path);
1058
+ if (!src)
1059
+ continue;
1060
+ const targets = new Set();
1061
+ for (const match of src.matchAll(WIKILINK_SCAN_RE)) {
1062
+ const name = match[1].trim();
1063
+ const slug = slugify(name);
1064
+ const last = name.includes("/") ? name.split("/").pop() : "";
1065
+ const page = pageIndex.get(slug)
1066
+ ?? pageIndex.get(slugify(name + "/index"))
1067
+ ?? (last ? pageIndex.get(slugify(last)) : undefined);
1068
+ if (page && page.path !== p.path)
1069
+ targets.add(page.path);
1070
+ }
1071
+ if (targets.size > 0)
1072
+ out.set(p.path, targets);
1073
+ }
1074
+ return out;
1075
+ }
742
1076
  function extractFrontmatterBlock(source) {
743
1077
  const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
744
1078
  if (!m || !m[1] || !m[1].trim())
@@ -759,23 +1093,26 @@ function parseFrontmatter(source) {
759
1093
  };
760
1094
  }
761
1095
  /**
762
- * Full YAML frontmatter, used by the Bases plugin so any property a user
763
- * defines (location, status, npc-class, etc.) is queryable. We delegate
764
- * to gray-matter so we get real YAML rather than the regex-narrow set
765
- * `parseFrontmatter` extracts.
1096
+ * Full YAML frontmatter + body content, used by the Bases plugin (frontmatter
1097
+ * properties) and threaded through to renderMarkdown so the pipeline doesn't
1098
+ * have to call gray-matter again. `data` is real YAML; falls back to {} on
1099
+ * malformed YAML so the page still renders. Content matches what gray-matter
1100
+ * would give the pipeline (frontmatter block stripped from the head).
766
1101
  */
767
- function parseFullFrontmatter(source) {
1102
+ function parseFullFrontmatterWithContent(source) {
768
1103
  if (!source.startsWith("---"))
769
- return {};
1104
+ return { data: {}, content: source };
770
1105
  try {
771
- const data = matter(source).data;
772
- return (data && typeof data === "object" ? data : {});
1106
+ const m = matter(source);
1107
+ const data = (m.data && typeof m.data === "object" ? m.data : {});
1108
+ return { data, content: m.content };
773
1109
  }
774
1110
  catch {
775
1111
  // Malformed YAML; the existing parseFrontmatter is more forgiving for
776
- // the title/role/aliases keys we actually need. Return empty here so
777
- // the page still renders.
778
- return {};
1112
+ // the title/role/aliases keys we actually need. Return empty data + the
1113
+ // body with the leading `---\n…\n---\n` stripped via regex so the rest
1114
+ // of the pipeline still sees a clean body.
1115
+ return { data: {}, content: source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "") };
779
1116
  }
780
1117
  }
781
1118
  /**
@@ -809,10 +1146,6 @@ function parseAliases(fm) {
809
1146
  function unquote(s) {
810
1147
  return s.replace(/^["']|["']$/g, "");
811
1148
  }
812
- function extractH1(source) {
813
- const h1 = /^#\s+(.+)$/m.exec(source);
814
- return h1?.[1] ? h1[1].trim() : null;
815
- }
816
1149
  function basenameNoExt(path) {
817
1150
  return path.split("/").pop().replace(/\.md$/i, "");
818
1151
  }
@@ -865,13 +1198,7 @@ function kindLabel(kind) {
865
1198
  default: return kind;
866
1199
  }
867
1200
  }
868
- /**
869
- * Walk the variant directory and produce a manifest of every file with its MD5
870
- * hash + size + mtime + content type. Shared assets (anything OUTSIDE the
871
- * variant dir but inside the deploy root) are listed too; clients use a
872
- * single manifest to diff the entire site, not just the role-specific bits.
873
- */
874
- async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles, vaultName) {
1201
+ async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles, vaultName, assets) {
875
1202
  const files = [];
876
1203
  const seen = new Set();
877
1204
  // Variant-specific files: use pathBase=variantDir so paths come out as
@@ -896,7 +1223,30 @@ async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles,
896
1223
  // like the Foundry module use it as the default label + root folder when
897
1224
  // a user adds the vault, so they get something readable instead of a
898
1225
  // host-derived slug.
899
- return { name: vaultName, auth: { required: authRequired, roles }, files };
1226
+ // Asset advertisement so clients (Foundry, MCP) fetch the right paths
1227
+ // instead of guessing well-known names — lets us move things later.
1228
+ const assetBlock = {};
1229
+ if (assets.hasHandlerJs || assets.hasHandlerCss) {
1230
+ assetBlock.browser = {
1231
+ ...(assets.hasHandlerJs ? { js: "/_handlers.js" } : {}),
1232
+ ...(assets.hasHandlerCss ? { css: "/_handlers.css" } : {}),
1233
+ };
1234
+ }
1235
+ if (assets.hasFoundryJs || assets.hasFoundryCss) {
1236
+ assetBlock.foundry = {
1237
+ ...(assets.hasFoundryJs ? { js: "/_handlers.foundry.js" } : {}),
1238
+ ...(assets.hasFoundryCss ? { css: "/_handlers.foundry.css" } : {}),
1239
+ };
1240
+ }
1241
+ return {
1242
+ manifest_version: MANIFEST_VERSION,
1243
+ cli_version: CLI_VERSION,
1244
+ id_scheme: ID_SCHEME,
1245
+ name: vaultName,
1246
+ auth: { required: authRequired, roles },
1247
+ ...(Object.keys(assetBlock).length > 0 ? { assets: assetBlock } : {}),
1248
+ files,
1249
+ };
900
1250
  }
901
1251
  async function walkAndIndex(dir, pathBase, out, seen, skipDirNames, bodyMeta) {
902
1252
  const entries = await readdir(dir, { withFileTypes: true });
@@ -919,7 +1269,7 @@ async function walkAndIndex(dir, pathBase, out, seen, skipDirNames, bodyMeta) {
919
1269
  const body = await readFile(abs);
920
1270
  const info = await stat(abs);
921
1271
  const meta = bodyMeta.get(path);
922
- // Fold meta JSON into the hash so meta-only edits (e.g. a foundry_base
1272
+ // Fold meta JSON into the hash so meta-only edits (e.g. a foundry.base
923
1273
  // tweak with no body change) still bump the row hash and trigger sync.
924
1274
  const hasher = createHash("md5").update(body);
925
1275
  if (meta)
@@ -948,104 +1298,6 @@ function stableStringify(value) {
948
1298
  const keys = Object.keys(obj).sort();
949
1299
  return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
950
1300
  }
951
- function contentTypeForExt(filename) {
952
- const ext = filename.split(".").pop()?.toLowerCase() ?? "";
953
- const map = {
954
- html: "text/html; charset=utf-8",
955
- md: "text/markdown; charset=utf-8",
956
- json: "application/json",
957
- css: "text/css; charset=utf-8",
958
- js: "application/javascript; charset=utf-8",
959
- png: "image/png",
960
- jpg: "image/jpeg",
961
- jpeg: "image/jpeg",
962
- webp: "image/webp",
963
- gif: "image/gif",
964
- svg: "image/svg+xml",
965
- avif: "image/avif",
966
- pdf: "application/pdf",
967
- mp3: "audio/mpeg",
968
- wav: "audio/wav",
969
- ogg: "audio/ogg",
970
- };
971
- return map[ext] ?? "application/octet-stream";
972
- }
973
- /**
974
- * Pre-canonicalisation migration. Earlier versions stored roles, auth_type,
975
- * and role_passwords in settings.md frontmatter; they now live in
976
- * .vaultrc.json. If we still see them in settings.md (legacy vault), copy
977
- * over what's missing in .vaultrc.json so the imminent canonicaliser doesn't
978
- * silently drop them.
979
- *
980
- * Idempotent: returns true and logs only if it actually moved something.
981
- */
982
- async function migrateLegacyAuthFromSettings(vaultPath) {
983
- const settingsPath = join(vaultPath, SETTINGS_FILE);
984
- let raw;
985
- try {
986
- raw = await readFile(settingsPath, "utf8");
987
- }
988
- catch {
989
- return false;
990
- }
991
- const fm = (matter(raw).data ?? {});
992
- const hasLegacy = "roles" in fm || "auth_type" in fm || "role_passwords" in fm;
993
- if (!hasLegacy)
994
- return false;
995
- const cfg = await loadConfig(vaultPath, {});
996
- const moved = [];
997
- // roles: only migrate if cfg is still at the default ["public"].
998
- if (Array.isArray(fm.roles)) {
999
- const list = fm.roles.filter((r) => typeof r === "string");
1000
- const isDefault = cfg.roles.length === 0 || (cfg.roles.length === 1 && cfg.roles[0] === "public");
1001
- if (list.length > 0 && isDefault && !arraysEqual(list, ["public"])) {
1002
- cfg.roles = list;
1003
- moved.push("roles");
1004
- }
1005
- }
1006
- if (typeof fm.auth_type === "string" && cfg.authType === "password" && fm.auth_type !== "password") {
1007
- cfg.authType = fm.auth_type;
1008
- moved.push("auth_type");
1009
- }
1010
- if (fm.role_passwords && typeof fm.role_passwords === "object" && !Array.isArray(fm.role_passwords)
1011
- && Object.keys(cfg.rolePasswords).length === 0) {
1012
- const map = fm.role_passwords;
1013
- const cleaned = {};
1014
- for (const [k, v] of Object.entries(map))
1015
- if (typeof v === "string")
1016
- cleaned[k] = v;
1017
- if (Object.keys(cleaned).length > 0) {
1018
- cfg.rolePasswords = cleaned;
1019
- moved.push("role_passwords");
1020
- }
1021
- }
1022
- if (moved.length === 0)
1023
- return false;
1024
- await saveConfig(vaultPath, cfg);
1025
- console.log(` migrated ${moved.join(", ")} from settings.md → .vaultrc.json`);
1026
- return true;
1027
- }
1028
- function arraysEqual(a, b) {
1029
- return a.length === b.length && a.every((x, i) => x === b[i]);
1030
- }
1031
- function extractPlainText(source, max) {
1032
- return source
1033
- .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "")
1034
- .replace(/%%[\s\S]*?%%/g, "")
1035
- .replace(/```[\s\S]*?```/g, "")
1036
- .replace(/!\[\[[^\]]+\]\]/g, "")
1037
- .replace(/\[\[([^\]|#]+)(?:[#|][^\]]+)?\]\]/g, "$1")
1038
- .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
1039
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
1040
- .replace(/`([^`]+)`/g, "$1")
1041
- .replace(/[*_~]+([^*_~\n]+)[*_~]+/g, "$1")
1042
- .replace(/^>\s?\[![^\]]+\][+-]?\s*(.*)$/gm, "$1")
1043
- .replace(/^>\s?/gm, "")
1044
- .replace(/^#{1,6}\s+/gm, "")
1045
- .replace(/\s+/g, " ")
1046
- .trim()
1047
- .slice(0, max);
1048
- }
1049
1301
  /**
1050
1302
  * Strip an HTML body to plain text. Used to feed the search index from
1051
1303
  * the rendered article (post-wikilink, post-callout-redaction) so search