@wizzlethorpe/vaults 0.7.0 → 0.8.1

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 (84) hide show
  1. package/README.md +8 -7
  2. package/dist/build.js +363 -115
  3. package/dist/build.js.map +1 -1
  4. package/dist/commands/build.js +2 -1
  5. package/dist/commands/build.js.map +1 -1
  6. package/dist/commands/password.js +1 -1
  7. package/dist/commands/password.js.map +1 -1
  8. package/dist/commands/patreon.js +24 -20
  9. package/dist/commands/patreon.js.map +1 -1
  10. package/dist/commands/preview.js +5 -4
  11. package/dist/commands/preview.js.map +1 -1
  12. package/dist/commands/push.js +7 -8
  13. package/dist/commands/push.js.map +1 -1
  14. package/dist/config.js +21 -10
  15. package/dist/config.js.map +1 -1
  16. package/dist/escape.js +29 -0
  17. package/dist/escape.js.map +1 -0
  18. package/dist/favicon.js +3 -36
  19. package/dist/favicon.js.map +1 -1
  20. package/dist/foundry-importer.js +61 -0
  21. package/dist/foundry-importer.js.map +1 -0
  22. package/dist/images.js +0 -30
  23. package/dist/images.js.map +1 -1
  24. package/dist/index.js +3 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/migrate/0.6-legacy-auth-settings.js +3 -5
  27. package/dist/migrate/0.6-legacy-auth-settings.js.map +1 -1
  28. package/dist/migrate/registry.js +1 -7
  29. package/dist/migrate/registry.js.map +1 -1
  30. package/dist/paths.js +21 -6
  31. package/dist/paths.js.map +1 -1
  32. package/dist/render/auth-template.js +21 -142
  33. package/dist/render/auth-template.js.map +1 -1
  34. package/dist/render/bases.js +56 -44
  35. package/dist/render/bases.js.map +1 -1
  36. package/dist/render/callouts.js +29 -10
  37. package/dist/render/callouts.js.map +1 -1
  38. package/dist/render/embed.js +124 -26
  39. package/dist/render/embed.js.map +1 -1
  40. package/dist/render/extensions.js +68 -0
  41. package/dist/render/extensions.js.map +1 -0
  42. package/dist/render/external-links.js +32 -0
  43. package/dist/render/external-links.js.map +1 -0
  44. package/dist/render/frontmatter.js +17 -0
  45. package/dist/render/frontmatter.js.map +1 -0
  46. package/dist/render/handlers/assets.js +48 -15
  47. package/dist/render/handlers/assets.js.map +1 -1
  48. package/dist/render/handlers/builtin/dice.js +1 -1
  49. package/dist/render/handlers/builtin/dice.js.map +1 -1
  50. package/dist/render/handlers/builtin/fm-code.js +50 -0
  51. package/dist/render/handlers/builtin/fm-code.js.map +1 -0
  52. package/dist/render/handlers/builtin/fm.js +6 -12
  53. package/dist/render/handlers/builtin/fm.js.map +1 -1
  54. package/dist/render/handlers/builtin/index.js +2 -1
  55. package/dist/render/handlers/builtin/index.js.map +1 -1
  56. package/dist/render/handlers/builtin/inline-format.js +26 -0
  57. package/dist/render/handlers/builtin/inline-format.js.map +1 -0
  58. package/dist/render/handlers/builtin/statblock.js +158 -18
  59. package/dist/render/handlers/builtin/statblock.js.map +1 -1
  60. package/dist/render/handlers/dispatch.js +15 -20
  61. package/dist/render/handlers/dispatch.js.map +1 -1
  62. package/dist/render/handlers/types.js +41 -21
  63. package/dist/render/handlers/types.js.map +1 -1
  64. package/dist/render/image-srcs.js +42 -0
  65. package/dist/render/image-srcs.js.map +1 -0
  66. package/dist/render/layout.js +60 -9
  67. package/dist/render/layout.js.map +1 -1
  68. package/dist/render/pipeline.js +22 -9
  69. package/dist/render/pipeline.js.map +1 -1
  70. package/dist/render/preview.js +53 -18
  71. package/dist/render/preview.js.map +1 -1
  72. package/dist/render/slug.js +5 -0
  73. package/dist/render/slug.js.map +1 -1
  74. package/dist/render/styles.js +88 -10
  75. package/dist/render/styles.js.map +1 -1
  76. package/dist/render/wikilink.js +15 -4
  77. package/dist/render/wikilink.js.map +1 -1
  78. package/dist/scan.js +1 -1
  79. package/dist/scan.js.map +1 -1
  80. package/dist/settings.js +16 -1
  81. package/dist/settings.js.map +1 -1
  82. package/dist/version.js +36 -0
  83. package/dist/version.js.map +1 -0
  84. package/package.json +2 -1
package/dist/build.js CHANGED
@@ -1,23 +1,18 @@
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";
@@ -48,9 +43,8 @@ import { formatDuration, pMap, Progress } from "./util.js";
48
43
  * <pages>.preview.json
49
44
  * _search-index.json
50
45
  *
51
- * When there's a single role (the default `public`-only case) we collapse
52
- * `_variants/public/...` up to the root for backwards compatibility with
53
- * 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.
54
48
  */
55
49
  export async function buildSite(opts) {
56
50
  const start = Date.now();
@@ -78,10 +72,7 @@ export async function buildSite(opts) {
78
72
  // and can override built-in names (last-registered wins). One registry
79
73
  // is built once and shared across every variant render.
80
74
  const userHandlers = await loadUserHandlers(opts.vaultPath);
81
- const handlerRegistry = buildRegistry([
82
- ...BUILTIN_HANDLERS,
83
- ...userHandlers.map((h) => h.handler),
84
- ]);
75
+ const handlerRegistry = buildRegistry(BUILTIN_HANDLERS, userHandlers.map((h) => h.handler));
85
76
  if (userHandlers.length > 0) {
86
77
  console.log(` loaded ${userHandlers.length} custom handler(s) from .vaults/handlers/`);
87
78
  }
@@ -131,8 +122,15 @@ export async function buildSite(opts) {
131
122
  }
132
123
  return true;
133
124
  });
134
- await rm(opts.outputDir, { recursive: true, force: true });
135
- 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 };
136
134
  const markdownFiles = withinLimit.filter((f) => /\.md$/i.test(f.path));
137
135
  const imageFiles = withinLimit.filter((f) => IMAGE_EXT_RE.test(f.path));
138
136
  // .base files are consumed at build time (rendered into HTML where embedded)
@@ -181,6 +179,13 @@ export async function buildSite(opts) {
181
179
  const basename = f.path.split("/").pop().replace(/\.base$/i, "");
182
180
  baseSources.set(slugify(basename), await readFile(f.absolute, "utf8"));
183
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
+ }
184
189
  // Parse role + title per page. Pages with an unrecognised role fall back
185
190
  // to the default with a warning; better than silently dropping them. We
186
191
  // also stash the full frontmatter on each PageMeta so the Bases plugin
@@ -188,7 +193,7 @@ export async function buildSite(opts) {
188
193
  const allPageMetas = markdownFiles.map((f) => {
189
194
  const src = sources.get(f.path);
190
195
  const meta = parseFrontmatter(src);
191
- const fullFm = parseFullFrontmatter(src);
196
+ const fullFm = parsedSources.get(f.path).data;
192
197
  let role = meta.role ?? defaultRole;
193
198
  if (!allRoleSet.has(role)) {
194
199
  console.warn(` ${f.path}: role "${role}" not in settings.roles, using "${defaultRole}"`);
@@ -264,6 +269,8 @@ export async function buildSite(opts) {
264
269
  const themeOverride = renderThemeOverride({
265
270
  lightAccent: settings.values.accent_color,
266
271
  lightBg: settings.values.bg_color,
272
+ darkAccent: settings.values.accent_color_dark,
273
+ darkBg: settings.values.bg_color_dark,
267
274
  });
268
275
  await writeFile(join(opts.outputDir, "styles.css"), DEFAULT_CSS + themeOverride);
269
276
  const userCss = await loadObsidianSnippets(opts.vaultPath);
@@ -277,6 +284,15 @@ export async function buildSite(opts) {
277
284
  await writeFile(join(opts.outputDir, "_handlers.js"), handlerAssets.js);
278
285
  if (hasHandlerCss)
279
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.
280
296
  // Favicon; either user-supplied via settings.favicon, or a generated
281
297
  // default with the vault's first letter in accent on the theme background.
282
298
  try {
@@ -296,11 +312,19 @@ export async function buildSite(opts) {
296
312
  // Computed once against the final imageIndex so OG meta tags, Bases card
297
313
  // covers, hover previews, and Foundry actor/item reskin all resolve to the
298
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);
299
322
  for (const meta of allPageMetas) {
300
323
  const src = sources.get(meta.path);
301
324
  if (!src)
302
325
  continue;
303
- 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);
304
328
  if (cover)
305
329
  meta.coverImage = cover;
306
330
  }
@@ -324,8 +348,10 @@ export async function buildSite(opts) {
324
348
  redactRoles,
325
349
  variantDir,
326
350
  vaultName: opts.vaultName,
351
+ vaultPath: opts.vaultPath,
327
352
  allPageMetas,
328
353
  sources,
354
+ parsedSources,
329
355
  baseSources,
330
356
  imageIndex,
331
357
  imageStagingDir,
@@ -343,12 +369,35 @@ export async function buildSite(opts) {
343
369
  perRolePageCount[role] = stats.pageCount;
344
370
  if (!collapseToRoot)
345
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
+ }
346
390
  // Write a per-variant _manifest.json so external clients (Foundry, MCP,
347
391
  // etc.) can do an incremental diff. Includes EVERY file that variant
348
392
  // serves; html, md, images (as relative paths into shared root), css.
349
393
  // bodyMeta carries per-page Foundry reskin metadata; folded into each
350
394
  // body row's hash so meta-only changes trigger a re-sync.
351
- 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
+ });
352
401
  await writeFile(join(variantDir, "_manifest.json"), JSON.stringify(manifest));
353
402
  }
354
403
  // ── Pages Functions ─────────────────────────────────────────────────────
@@ -361,11 +410,12 @@ export async function buildSite(opts) {
361
410
  // mapped to a tier. clientSecret stays out of the bundle — it lives in
362
411
  // the Wrangler secret PATREON_CLIENT_SECRET, read from env in the
363
412
  // Function. The CLI uploads it on every push.
364
- 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
365
415
  ? {
366
- clientId: cfg.patreon.clientId,
367
- campaignId: cfg.patreon.campaignId,
368
- tiers: cfg.patreon.tiers,
416
+ clientId: patreon.clientId,
417
+ campaignId: patreon.campaignId,
418
+ tiers: patreon.tiers,
369
419
  }
370
420
  : null;
371
421
  const middleware = renderAuthMiddleware({
@@ -394,6 +444,12 @@ export async function buildSite(opts) {
394
444
  // variant that needs them, so they're no longer required for the deploy.
395
445
  await rm(imageStagingDir, { recursive: true, force: true });
396
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);
397
453
  console.log(`Built in ${formatDuration(Date.now() - start)}.`);
398
454
  return {
399
455
  files,
@@ -412,7 +468,15 @@ async function buildVariant(a) {
412
468
  if (!a.visibleRoles.has(m.role))
413
469
  continue;
414
470
  visibleMetas.push(m);
415
- 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));
416
480
  }
417
481
  // Synthesize folder indexes from the visible set only.
418
482
  const folderIndexes = generateFolderIndexes(visibleMetas, a.role, a.settings.inline_title);
@@ -440,20 +504,26 @@ async function buildVariant(a) {
440
504
  markdownContent.set(basenameSlug, visibleSources.get(p.path));
441
505
  markdownContent.set(pathSlug, visibleSources.get(p.path));
442
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);
443
511
  const context = {
444
512
  pages: pageIndex,
445
513
  images: a.imageIndex,
514
+ passthroughs: a.passthroughIndex,
446
515
  markdownContent,
447
516
  bases: a.baseSources,
448
517
  defaultImageWidth: a.settings.default_image_width,
449
518
  redactRoles: a.redactRoles,
450
519
  handlers: a.handlerRegistry,
520
+ outlinksByPath,
451
521
  };
452
522
  const rendered = new Map();
453
523
  const progress = new Progress(`Pages (${a.role})`);
454
524
  progress.update(0, visibleMetas.length);
455
525
  await pMap(visibleMetas, a.concurrency, async (p) => {
456
- 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));
457
527
  rendered.set(p.path, {
458
528
  title: result.title,
459
529
  html: result.html,
@@ -498,6 +568,7 @@ async function buildVariant(a) {
498
568
  hasHandlerJs: a.hasHandlerJs,
499
569
  hasHandlerCss: a.hasHandlerCss,
500
570
  footerHtml: a.footerHtml,
571
+ theme: themeOf(a.settings.theme),
501
572
  ...(p.mtime != null ? { mtime: p.mtime } : {}),
502
573
  ...(p.birthtime != null ? { birthtime: p.birthtime } : {}),
503
574
  ...(p.coverImage ? { coverImage: p.coverImage } : {}),
@@ -512,9 +583,14 @@ async function buildVariant(a) {
512
583
  // remark/rehype pipeline land in journals as-is, no client-side render.
513
584
  const bodyPath = outputBase + ".body.html";
514
585
  await writeFile(join(a.variantDir, bodyPath), r.html);
515
- bodyMeta.set(bodyPath, collectBodyMeta(p));
586
+ bodyMeta.set(bodyPath, await collectBodyMeta(p, a.vaultPath));
516
587
  const source = visibleSources.get(p.path);
517
- const preview = await buildPreview(source, r.title);
588
+ const preview = await buildPreview(source, r.title, {
589
+ frontmatter: a.parsedSources.get(p.path)?.data ?? {},
590
+ registry: a.handlerRegistry,
591
+ renderContext: context,
592
+ pagePath: p.path,
593
+ });
518
594
  await writeFile(join(a.variantDir, outputBase + ".preview.json"), JSON.stringify(preview));
519
595
  });
520
596
  progress.done(`${visibleMetas.length} rendered`);
@@ -530,6 +606,7 @@ async function buildVariant(a) {
530
606
  hasHandlerJs: a.hasHandlerJs,
531
607
  hasHandlerCss: a.hasHandlerCss,
532
608
  footerHtml: a.footerHtml,
609
+ theme: themeOf(a.settings.theme),
533
610
  }));
534
611
  // Per-variant search index. `text` is the page's RENDERED HTML body
535
612
  // collapsed to plain text (tags stripped, entities decoded), so search
@@ -552,32 +629,101 @@ async function buildVariant(a) {
552
629
  // contract as images: ship only into variants whose visible pages
553
630
  // reference the file. A DM-only audio cue can't ride along into the
554
631
  // public deploy because no public-tier source mentions it.
555
- await copyReferencedPassthroughs(visibleSources, a.passthroughIndex, a.passthroughStagingDir, a.variantDir);
632
+ await copyReferencedPassthroughs(visibleSources, visibleMetas, a.passthroughIndex, a.passthroughStagingDir, a.variantDir);
556
633
  return { pageCount: visibleMetas.length, bodyMeta };
557
634
  }
558
635
  /**
559
636
  * Build the per-body manifest meta from a page's frontmatter + resolved
560
637
  * cover image. `role` always lands so the Foundry side can apply the
561
- * dmRole permission gate; the foundry_base / image fields are conditional.
638
+ * dmRole permission gate; the foundry / image fields are conditional.
639
+ *
640
+ * Frontmatter shape forwarded to clients:
641
+ * foundry:
642
+ * base: <UUID> | <Type>[:<subtype>] # required for instantiation
643
+ * embed: false # default true
644
+ * data: { … deep-merged into the doc }
562
645
  */
563
- function collectBodyMeta(p) {
646
+ async function collectBodyMeta(p, vaultPath) {
564
647
  const fm = p.frontmatter ?? {};
565
648
  const out = { role: p.role };
566
649
  const basename = p.path.split("/").pop().replace(/\.md$/i, "");
567
650
  if (p.title && p.title !== basename)
568
651
  out.title = p.title;
569
- const fb = fm["foundry_base"];
570
- if (typeof fb === "string" && fb.trim().length > 0) {
571
- out.foundry_base = fb.trim();
572
- }
573
652
  const fo = fm["foundry"];
574
653
  if (fo && typeof fo === "object" && !Array.isArray(fo)) {
575
- out.foundry = fo;
654
+ const block = {};
655
+ const base = fo["base"];
656
+ if (typeof base === "string" && base.trim().length > 0)
657
+ block.base = base.trim();
658
+ const embed = fo["embed"];
659
+ if (typeof embed === "boolean")
660
+ block.embed = embed;
661
+ const data = fo["data"];
662
+ if (data && typeof data === "object" && !Array.isArray(data))
663
+ block.data = data;
664
+ // foundry.id: an explicit Foundry document id for this page. When set,
665
+ // overrides the SHA1-derived id used for both the JournalEntryPage and
666
+ // (if foundry.base is present) the instantiated derived doc. Lets users
667
+ // hardcode UUIDs that other Foundry-side code (macros, scene flags,
668
+ // module integrations) needs to reference. Foundry ids are 16 chars from
669
+ // [A-Za-z0-9]; a malformed value is dropped with a warning rather than
670
+ // failing the build.
671
+ const idVal = fo["id"];
672
+ if (typeof idVal === "string") {
673
+ const trimmed = idVal.trim();
674
+ if (FOUNDRY_ID_RE.test(trimmed))
675
+ block.id = trimmed;
676
+ else if (trimmed.length > 0) {
677
+ console.warn(` ${p.path}: foundry.id "${trimmed}" is not a valid Foundry id (16 chars [A-Za-z0-9]); ignoring`);
678
+ }
679
+ }
680
+ // foundry.data_json: vault-relative path to a JSON file. Read + parse
681
+ // at build time and inline into the meta as `data_json`. The Foundry
682
+ // module deep-merges it onto the base doc BEFORE foundry.data, so a
683
+ // user can layer hand-tuned overrides on top of an exported sheet.
684
+ // Folding the parsed object into meta means the body-row hash already
685
+ // changes when the JSON content does — no separate change-detection.
686
+ const dataJsonPath = fo["data_json"];
687
+ if (typeof dataJsonPath === "string" && dataJsonPath.trim().length > 0) {
688
+ const parsed = await loadDataJson(vaultPath, dataJsonPath.trim(), p.path);
689
+ if (parsed !== null)
690
+ block.data_json = parsed;
691
+ }
692
+ if (Object.keys(block).length > 0)
693
+ out.foundry = block;
576
694
  }
577
695
  if (p.coverImage)
578
696
  out.image = p.coverImage;
579
697
  return out;
580
698
  }
699
+ /** Read + parse a vault-relative JSON file referenced by `foundry.data_json`.
700
+ * Warns on missing / unparseable file and returns null so the page renders
701
+ * without the overlay rather than failing the build. */
702
+ async function loadDataJson(vaultPath, relPath, pagePath) {
703
+ const abs = join(vaultPath, relPath);
704
+ try {
705
+ const raw = await readFile(abs, "utf8");
706
+ return JSON.parse(raw);
707
+ }
708
+ catch (err) {
709
+ const code = err.code;
710
+ if (code === "ENOENT") {
711
+ console.warn(` ${pagePath}: foundry.data_json "${relPath}" not found, skipping`);
712
+ }
713
+ else {
714
+ console.warn(` ${pagePath}: foundry.data_json "${relPath}" failed to parse: ${err.message}`);
715
+ }
716
+ return null;
717
+ }
718
+ }
719
+ /** Foundry document ids: exactly 16 chars from [A-Za-z0-9]. Validated when
720
+ * authors set `foundry.id` to override the SHA1-derived default. */
721
+ const FOUNDRY_ID_RE = /^[A-Za-z0-9]{16}$/;
722
+ /** Coerce settings.theme to the layout's narrowed union, defaulting to
723
+ * "auto" for any unrecognised value rather than failing the build. */
724
+ function themeOf(s) {
725
+ return s === "light" || s === "dark" ? s : "auto";
726
+ }
581
727
  const EMBED_RE = /!\[\[([^\[\]|#\n]+?)(?:\|[^\[\]#\n]*)?\]\]/g;
582
728
  async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, stagingDir, variantDir) {
583
729
  const refs = new Set();
@@ -594,19 +740,27 @@ async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, st
594
740
  // Pages can name their cover via `image:` frontmatter alone (no body embed);
595
741
  // pull those in too. coverImage was resolved to the served URL upstream, so
596
742
  // strip the leading slash + decode to get back to the staging-relative path.
743
+ // `@vault/PATH` references inside any frontmatter string field also gate
744
+ // an asset into this variant — common for Scene background.src / Playlist
745
+ // sound.path that point at vault-shipped media. Page-role gating still
746
+ // applies because we only walk visibleMetas (= pages this variant can see).
597
747
  for (const p of visibleMetas) {
598
- if (!p.coverImage)
599
- continue;
600
- if (/^https?:\/\//i.test(p.coverImage))
601
- continue;
602
- let outputPath;
603
- try {
604
- outputPath = decodeURIComponent(p.coverImage.replace(/^\//, ""));
748
+ if (p.coverImage && !/^https?:\/\//i.test(p.coverImage)) {
749
+ try {
750
+ refs.add(decodeURIComponent(p.coverImage.replace(/^\//, "")));
751
+ }
752
+ catch { /* malformed coverImage URL — ignore */ }
605
753
  }
606
- catch {
607
- continue;
754
+ if (p.frontmatter) {
755
+ forEachString(p.frontmatter, (s) => {
756
+ const path = vaultRefPath(s);
757
+ if (path && IMAGE_EXT_RE.test(path)) {
758
+ const image = imageIndex.get(slugify(path.split("/").pop()));
759
+ if (image)
760
+ refs.add(image.outputPath);
761
+ }
762
+ });
608
763
  }
609
- refs.add(outputPath);
610
764
  }
611
765
  for (const outputPath of refs) {
612
766
  const src = join(stagingDir, outputPath);
@@ -628,6 +782,73 @@ async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, st
628
782
  const MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+\.[a-z0-9]+)(?:\s+["'][^"']*["'])?\)/gi;
629
783
  // `[[file.ext]]` and `![[file.ext]]` — Obsidian-flavoured wikilinks/embeds.
630
784
  const WIKI_LINK_RE = /!?\[\[([^\[\]|#\n]+\.[a-z0-9]+)(?:\|[^\[\]#\n]*)?(?:#[^\[\]\n]*)?\]\]/gi;
785
+ // `> [!type]…` opens a callout; the rest of the contiguous blockquote (lines
786
+ // starting with `>`, blank line ends) is its body. Used to strip role-gated
787
+ // callouts from the source before any downstream pass sees it.
788
+ const CALLOUT_HEAD_RE = /^>\s*\[!(\w+)\]/;
789
+ /**
790
+ * Drop callout blocks whose type is in `redactRoles` from the source. Walks
791
+ * line-by-line; on a callout-head line whose type is redacted, drops every
792
+ * subsequent line that is part of the same blockquote (starts with `>`).
793
+ * A blank line ends the blockquote per CommonMark.
794
+ *
795
+ * Approximate by markdown standards (doesn't handle lazy-continuation lines
796
+ * or nested blockquotes containing role-gated children), but covers every
797
+ * pattern the asset scanner needs to gate against. The renderer's
798
+ * calloutPlugin still runs as the source of truth for visual redaction;
799
+ * this strip is the asset-leak guard.
800
+ */
801
+ function stripRoleGatedCallouts(source, redactRoles) {
802
+ if (redactRoles.size === 0)
803
+ return source;
804
+ const lines = source.split("\n");
805
+ const out = [];
806
+ let dropping = false;
807
+ for (const line of lines) {
808
+ if (dropping) {
809
+ if (line.startsWith(">"))
810
+ continue; // still inside the blockquote
811
+ dropping = false;
812
+ out.push(line); // blank or non-`>` line ends + keeps the line
813
+ continue;
814
+ }
815
+ const head = CALLOUT_HEAD_RE.exec(line);
816
+ if (head && redactRoles.has(head[1].toLowerCase())) {
817
+ dropping = true;
818
+ continue; // drop the head line
819
+ }
820
+ out.push(line);
821
+ }
822
+ return out.join("\n");
823
+ }
824
+ /**
825
+ * Visit every string value reachable from `value` (object / array / scalar)
826
+ * and call `fn` once per string. Used to surface `@vault/PATH` references
827
+ * inside parsed frontmatter (e.g., a Scene's `foundry.data.background.src`
828
+ * or a Playlist's `foundry.data.sounds[N].path`) so the per-variant asset
829
+ * scanner can include those files alongside body-referenced ones.
830
+ */
831
+ function forEachString(value, fn) {
832
+ if (typeof value === "string")
833
+ return fn(value);
834
+ if (Array.isArray(value)) {
835
+ for (const v of value)
836
+ forEachString(v, fn);
837
+ return;
838
+ }
839
+ if (value && typeof value === "object") {
840
+ for (const v of Object.values(value))
841
+ forEachString(v, fn);
842
+ }
843
+ }
844
+ /** Extract a vault path from a `@vault/PATH` string, or null when the
845
+ * string isn't a vault reference. Trailing fragment / query stripped. */
846
+ function vaultRefPath(s) {
847
+ if (!s.startsWith("@vault/"))
848
+ return null;
849
+ const rest = s.slice("@vault/".length).split("#")[0].split("?")[0];
850
+ return rest.length > 0 ? rest : null;
851
+ }
631
852
  /**
632
853
  * Per-variant reference scan for passthrough files. A file lands in this
633
854
  * variant's deploy only if a visible page mentions it — same gating story
@@ -637,7 +858,7 @@ const WIKI_LINK_RE = /!?\[\[([^\[\]|#\n]+\.[a-z0-9]+)(?:\|[^\[\]#\n]*)?(?:#[^\[\
637
858
  * the whole point of the change; a stray DM-only audio cue stays in the
638
859
  * dm variant only.
639
860
  */
640
- async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stagingDir, variantDir) {
861
+ async function copyReferencedPassthroughs(visibleSources, visibleMetas, passthroughIndex, stagingDir, variantDir) {
641
862
  if (passthroughIndex.size === 0)
642
863
  return;
643
864
  const refs = new Set();
@@ -658,6 +879,22 @@ async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stag
658
879
  refs.add(entry.outputPath);
659
880
  }
660
881
  }
882
+ // `@vault/PATH` references inside any frontmatter string also gate a
883
+ // passthrough into this variant. Same per-page-role visibility rules
884
+ // (only walking visibleMetas) — a dm-tier page's @vault/Audio/secret.ogg
885
+ // ships only to the dm variant.
886
+ for (const p of visibleMetas) {
887
+ if (!p.frontmatter)
888
+ continue;
889
+ forEachString(p.frontmatter, (s) => {
890
+ const path = vaultRefPath(s);
891
+ if (path) {
892
+ const entry = passthroughIndex.get(slugify(path.split("/").pop()));
893
+ if (entry)
894
+ refs.add(entry.outputPath);
895
+ }
896
+ });
897
+ }
661
898
  for (const outputPath of refs) {
662
899
  const src = join(stagingDir, outputPath);
663
900
  const dst = join(variantDir, outputPath);
@@ -763,7 +1000,13 @@ function chooseColumns(pages) {
763
1000
  if (["title", "role", "aliases", "tags"].includes(key))
764
1001
  continue;
765
1002
  const v = fm[key];
766
- if (v == null || v === "" || (Array.isArray(v) && v.length === 0))
1003
+ if (v == null || v === "")
1004
+ continue;
1005
+ // Skip non-scalar values — arrays and plain objects render as
1006
+ // "[object Object]" or comma-joined junk in a table cell. Dates
1007
+ // are technically objects but renderValue formats them nicely,
1008
+ // so let them through.
1009
+ if (typeof v === "object" && !(v instanceof Date))
767
1010
  continue;
768
1011
  counts.set(key, (counts.get(key) ?? 0) + 1);
769
1012
  }
@@ -806,6 +1049,35 @@ async function compressImageCached(file, quality, cacheDir, onHit) {
806
1049
  * spread it directly into the LayoutInput; missing frontmatter contributes
807
1050
  * nothing to the layout.
808
1051
  */
1052
+ // Pre-compute outgoing wikilinks per page (vault path → set of vault paths).
1053
+ // Bases needs this before render runs so file.hasLink() can answer truthfully
1054
+ // during render; the wikilink plugin's per-render outlinks list is collected
1055
+ // after Bases has already drawn the table. A regex scan over markdown source
1056
+ // (rather than rebuilding the AST) is fine: this only needs to detect link
1057
+ // targets, and an embedded ![[image.png]] resolves to no page anyway.
1058
+ const WIKILINK_SCAN_RE = /(?<!!)(?<!\[)\[\[([^\[\]|#\n]+?)(?:#[^\[\]|\n]+?)?(?:\|[^\[\]#\n]+?)?\]\]/g;
1059
+ function collectOutlinksByPath(metas, sources, pageIndex) {
1060
+ const out = new Map();
1061
+ for (const p of metas) {
1062
+ const src = sources.get(p.path);
1063
+ if (!src)
1064
+ continue;
1065
+ const targets = new Set();
1066
+ for (const match of src.matchAll(WIKILINK_SCAN_RE)) {
1067
+ const name = match[1].trim();
1068
+ const slug = slugify(name);
1069
+ const last = name.includes("/") ? name.split("/").pop() : "";
1070
+ const page = pageIndex.get(slug)
1071
+ ?? pageIndex.get(slugify(name + "/index"))
1072
+ ?? (last ? pageIndex.get(slugify(last)) : undefined);
1073
+ if (page && page.path !== p.path)
1074
+ targets.add(page.path);
1075
+ }
1076
+ if (targets.size > 0)
1077
+ out.set(p.path, targets);
1078
+ }
1079
+ return out;
1080
+ }
809
1081
  function extractFrontmatterBlock(source) {
810
1082
  const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
811
1083
  if (!m || !m[1] || !m[1].trim())
@@ -826,23 +1098,26 @@ function parseFrontmatter(source) {
826
1098
  };
827
1099
  }
828
1100
  /**
829
- * Full YAML frontmatter, used by the Bases plugin so any property a user
830
- * defines (location, status, npc-class, etc.) is queryable. We delegate
831
- * to gray-matter so we get real YAML rather than the regex-narrow set
832
- * `parseFrontmatter` extracts.
1101
+ * Full YAML frontmatter + body content, used by the Bases plugin (frontmatter
1102
+ * properties) and threaded through to renderMarkdown so the pipeline doesn't
1103
+ * have to call gray-matter again. `data` is real YAML; falls back to {} on
1104
+ * malformed YAML so the page still renders. Content matches what gray-matter
1105
+ * would give the pipeline (frontmatter block stripped from the head).
833
1106
  */
834
- function parseFullFrontmatter(source) {
1107
+ function parseFullFrontmatterWithContent(source) {
835
1108
  if (!source.startsWith("---"))
836
- return {};
1109
+ return { data: {}, content: source };
837
1110
  try {
838
- const data = matter(source).data;
839
- return (data && typeof data === "object" ? data : {});
1111
+ const m = matter(source);
1112
+ const data = (m.data && typeof m.data === "object" ? m.data : {});
1113
+ return { data, content: m.content };
840
1114
  }
841
1115
  catch {
842
1116
  // Malformed YAML; the existing parseFrontmatter is more forgiving for
843
- // the title/role/aliases keys we actually need. Return empty here so
844
- // the page still renders.
845
- return {};
1117
+ // the title/role/aliases keys we actually need. Return empty data + the
1118
+ // body with the leading `---\n…\n---\n` stripped via regex so the rest
1119
+ // of the pipeline still sees a clean body.
1120
+ return { data: {}, content: source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "") };
846
1121
  }
847
1122
  }
848
1123
  /**
@@ -876,10 +1151,6 @@ function parseAliases(fm) {
876
1151
  function unquote(s) {
877
1152
  return s.replace(/^["']|["']$/g, "");
878
1153
  }
879
- function extractH1(source) {
880
- const h1 = /^#\s+(.+)$/m.exec(source);
881
- return h1?.[1] ? h1[1].trim() : null;
882
- }
883
1154
  function basenameNoExt(path) {
884
1155
  return path.split("/").pop().replace(/\.md$/i, "");
885
1156
  }
@@ -932,13 +1203,7 @@ function kindLabel(kind) {
932
1203
  default: return kind;
933
1204
  }
934
1205
  }
935
- /**
936
- * Walk the variant directory and produce a manifest of every file with its MD5
937
- * hash + size + mtime + content type. Shared assets (anything OUTSIDE the
938
- * variant dir but inside the deploy root) are listed too; clients use a
939
- * single manifest to diff the entire site, not just the role-specific bits.
940
- */
941
- async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles, vaultName) {
1206
+ async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles, vaultName, assets) {
942
1207
  const files = [];
943
1208
  const seen = new Set();
944
1209
  // Variant-specific files: use pathBase=variantDir so paths come out as
@@ -963,7 +1228,30 @@ async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles,
963
1228
  // like the Foundry module use it as the default label + root folder when
964
1229
  // a user adds the vault, so they get something readable instead of a
965
1230
  // host-derived slug.
966
- return { name: vaultName, auth: { required: authRequired, roles }, files };
1231
+ // Asset advertisement so clients (Foundry, MCP) fetch the right paths
1232
+ // instead of guessing well-known names — lets us move things later.
1233
+ const assetBlock = {};
1234
+ if (assets.hasHandlerJs || assets.hasHandlerCss) {
1235
+ assetBlock.browser = {
1236
+ ...(assets.hasHandlerJs ? { js: "/_handlers.js" } : {}),
1237
+ ...(assets.hasHandlerCss ? { css: "/_handlers.css" } : {}),
1238
+ };
1239
+ }
1240
+ if (assets.hasFoundryJs || assets.hasFoundryCss) {
1241
+ assetBlock.foundry = {
1242
+ ...(assets.hasFoundryJs ? { js: "/_handlers.foundry.js" } : {}),
1243
+ ...(assets.hasFoundryCss ? { css: "/_handlers.foundry.css" } : {}),
1244
+ };
1245
+ }
1246
+ return {
1247
+ manifest_version: MANIFEST_VERSION,
1248
+ cli_version: CLI_VERSION,
1249
+ id_scheme: ID_SCHEME,
1250
+ name: vaultName,
1251
+ auth: { required: authRequired, roles },
1252
+ ...(Object.keys(assetBlock).length > 0 ? { assets: assetBlock } : {}),
1253
+ files,
1254
+ };
967
1255
  }
968
1256
  async function walkAndIndex(dir, pathBase, out, seen, skipDirNames, bodyMeta) {
969
1257
  const entries = await readdir(dir, { withFileTypes: true });
@@ -986,7 +1274,7 @@ async function walkAndIndex(dir, pathBase, out, seen, skipDirNames, bodyMeta) {
986
1274
  const body = await readFile(abs);
987
1275
  const info = await stat(abs);
988
1276
  const meta = bodyMeta.get(path);
989
- // Fold meta JSON into the hash so meta-only edits (e.g. a foundry_base
1277
+ // Fold meta JSON into the hash so meta-only edits (e.g. a foundry.base
990
1278
  // tweak with no body change) still bump the row hash and trigger sync.
991
1279
  const hasher = createHash("md5").update(body);
992
1280
  if (meta)
@@ -1015,46 +1303,6 @@ function stableStringify(value) {
1015
1303
  const keys = Object.keys(obj).sort();
1016
1304
  return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
1017
1305
  }
1018
- function contentTypeForExt(filename) {
1019
- const ext = filename.split(".").pop()?.toLowerCase() ?? "";
1020
- const map = {
1021
- html: "text/html; charset=utf-8",
1022
- md: "text/markdown; charset=utf-8",
1023
- json: "application/json",
1024
- css: "text/css; charset=utf-8",
1025
- js: "application/javascript; charset=utf-8",
1026
- png: "image/png",
1027
- jpg: "image/jpeg",
1028
- jpeg: "image/jpeg",
1029
- webp: "image/webp",
1030
- gif: "image/gif",
1031
- svg: "image/svg+xml",
1032
- avif: "image/avif",
1033
- pdf: "application/pdf",
1034
- mp3: "audio/mpeg",
1035
- wav: "audio/wav",
1036
- ogg: "audio/ogg",
1037
- };
1038
- return map[ext] ?? "application/octet-stream";
1039
- }
1040
- function extractPlainText(source, max) {
1041
- return source
1042
- .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "")
1043
- .replace(/%%[\s\S]*?%%/g, "")
1044
- .replace(/```[\s\S]*?```/g, "")
1045
- .replace(/!\[\[[^\]]+\]\]/g, "")
1046
- .replace(/\[\[([^\]|#]+)(?:[#|][^\]]+)?\]\]/g, "$1")
1047
- .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
1048
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
1049
- .replace(/`([^`]+)`/g, "$1")
1050
- .replace(/[*_~]+([^*_~\n]+)[*_~]+/g, "$1")
1051
- .replace(/^>\s?\[![^\]]+\][+-]?\s*(.*)$/gm, "$1")
1052
- .replace(/^>\s?/gm, "")
1053
- .replace(/^#{1,6}\s+/gm, "")
1054
- .replace(/\s+/g, " ")
1055
- .trim()
1056
- .slice(0, max);
1057
- }
1058
1306
  /**
1059
1307
  * Strip an HTML body to plain text. Used to feed the search index from
1060
1308
  * the rendered article (post-wikilink, post-callout-redaction) so search