@wizzlethorpe/vaults 0.6.1 → 0.7.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 (104) hide show
  1. package/README.md +135 -17
  2. package/dist/build.js +501 -190
  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 +74 -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/battlemap.js +199 -0
  61. package/dist/render/handlers/builtin/battlemap.js.map +1 -0
  62. package/dist/render/handlers/builtin/dice.js +78 -0
  63. package/dist/render/handlers/builtin/dice.js.map +1 -0
  64. package/dist/render/handlers/builtin/fm-code.js +50 -0
  65. package/dist/render/handlers/builtin/fm-code.js.map +1 -0
  66. package/dist/render/handlers/builtin/fm.js +83 -0
  67. package/dist/render/handlers/builtin/fm.js.map +1 -0
  68. package/dist/render/handlers/builtin/index.js +13 -0
  69. package/dist/render/handlers/builtin/index.js.map +1 -0
  70. package/dist/render/handlers/builtin/inline-format.js +26 -0
  71. package/dist/render/handlers/builtin/inline-format.js.map +1 -0
  72. package/dist/render/handlers/builtin/statblock.js +491 -0
  73. package/dist/render/handlers/builtin/statblock.js.map +1 -0
  74. package/dist/render/handlers/dispatch.js +182 -0
  75. package/dist/render/handlers/dispatch.js.map +1 -0
  76. package/dist/render/handlers/loader.js +90 -0
  77. package/dist/render/handlers/loader.js.map +1 -0
  78. package/dist/render/handlers/types.js +60 -0
  79. package/dist/render/handlers/types.js.map +1 -0
  80. package/dist/render/image-srcs.js +42 -0
  81. package/dist/render/image-srcs.js.map +1 -0
  82. package/dist/render/layout.js +62 -9
  83. package/dist/render/layout.js.map +1 -1
  84. package/dist/render/pipeline.js +38 -9
  85. package/dist/render/pipeline.js.map +1 -1
  86. package/dist/render/preview.js +53 -18
  87. package/dist/render/preview.js.map +1 -1
  88. package/dist/render/slug.js +5 -0
  89. package/dist/render/slug.js.map +1 -1
  90. package/dist/render/styles.js +124 -15
  91. package/dist/render/styles.js.map +1 -1
  92. package/dist/render/wikilink.js +15 -4
  93. package/dist/render/wikilink.js.map +1 -1
  94. package/dist/scan.js +1 -1
  95. package/dist/scan.js.map +1 -1
  96. package/dist/settings.js +25 -4
  97. package/dist/settings.js.map +1 -1
  98. package/dist/version.js +36 -0
  99. package/dist/version.js.map +1 -0
  100. package/package.json +12 -12
  101. package/dist/api.js +0 -42
  102. package/dist/api.js.map +0 -1
  103. package/dist/render/mcp-template.js +0 -239
  104. 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}"`);
@@ -174,6 +209,16 @@ export async function buildSite(opts) {
174
209
  birthtime: f.birthtime,
175
210
  };
176
211
  });
212
+ // Stage assets referenced inside each page's foundry.data_json (Scene
213
+ // backgrounds / ambient sounds / tile art live in that JSON, not the page
214
+ // frontmatter, so the asset scanners below consult p.foundryAssets).
215
+ await Promise.all(allPageMetas.map(async (p) => {
216
+ if (!p.frontmatter)
217
+ return;
218
+ const refs = await collectDataJsonVaultRefs(opts.vaultPath, p.frontmatter, p.path);
219
+ if (refs.length > 0)
220
+ p.foundryAssets = refs;
221
+ }));
177
222
  // ── Image compression (staged; copied per-variant later) ────────────────
178
223
  // Compress once into a private staging dir under the deploy root. Each
179
224
  // variant's render pass copies whichever images its visible pages
@@ -183,8 +228,8 @@ export async function buildSite(opts) {
183
228
  const imageStagingDir = join(opts.outputDir, ".image-staging");
184
229
  const imageIndex = new Map();
185
230
  if (imageFiles.length > 0) {
186
- const cacheDir = join(opts.vaultPath, ".vault-cache", "images", `q${opts.imageQuality}`);
187
- await mkdir(cacheDir, { recursive: true });
231
+ const cacheImageDir = join(cacheDir(opts.vaultPath), "images", `q${opts.imageQuality}`);
232
+ await mkdir(cacheImageDir, { recursive: true });
188
233
  let cacheHits = 0;
189
234
  const progress = new Progress("Images");
190
235
  progress.update(0, imageFiles.length);
@@ -192,15 +237,21 @@ export async function buildSite(opts) {
192
237
  // SVGs / non-compressible images pass through; everything else gets
193
238
  // recoded to webp for size. Either way they land in the staging dir.
194
239
  const compressed = opts.imageQuality > 0 && COMPRESSIBLE_EXT_RE.test(f.path)
195
- ? await compressImageCached(f, opts.imageQuality, cacheDir, () => { cacheHits++; })
240
+ ? await compressImageCached(f, opts.imageQuality, cacheImageDir, () => { cacheHits++; })
196
241
  : { body: await readFile(f.absolute), outputPath: f.path };
197
242
  const dest = join(imageStagingDir, compressed.outputPath);
198
243
  await mkdir(dirname(dest), { recursive: true });
199
244
  await writeFile(dest, compressed.body);
200
- imageIndex.set(slugify(f.path.split("/").pop()), {
201
- sourcePath: f.path,
202
- outputPath: compressed.outputPath,
203
- });
245
+ // Two keys for one entry: basename slug for body wikilinks/embeds
246
+ // (Obsidian resolves those by basename), and the full vault-relative
247
+ // path for `@vault/PATH` refs (frontmatter, data_json). Paths contain
248
+ // "/" and slugs don't, so the keyspaces never overlap. The full-path
249
+ // key is what stops identically-named assets in different scene folders
250
+ // (e.g. a shared `Water Fountain (Loop).ogg`) from colliding under one
251
+ // basename slug and staging only one of them.
252
+ const entry = { sourcePath: f.path, outputPath: compressed.outputPath };
253
+ imageIndex.set(slugify(f.path.split("/").pop()), entry);
254
+ imageIndex.set(f.path, entry);
204
255
  }, (done, total) => progress.update(done, total));
205
256
  progress.done(`${imageFiles.length} processed (${cacheHits} cached, ${imageFiles.length - cacheHits} compressed)`);
206
257
  }
@@ -218,10 +269,12 @@ export async function buildSite(opts) {
218
269
  const dest = join(otherStagingDir, f.path);
219
270
  await mkdir(dirname(dest), { recursive: true });
220
271
  await copyFile(f.absolute, dest);
221
- passthroughIndex.set(slugify(f.path.split("/").pop()), {
222
- sourcePath: f.path,
223
- outputPath: f.path,
224
- });
272
+ // Dual-keyed like imageIndex: basename slug for body refs, full
273
+ // vault-relative path for `@vault/PATH` refs (ambient sounds in
274
+ // data_json), so same-named files in different folders don't collide.
275
+ const entry = { sourcePath: f.path, outputPath: f.path };
276
+ passthroughIndex.set(slugify(f.path.split("/").pop()), entry);
277
+ passthroughIndex.set(f.path, entry);
225
278
  }, (done, total) => progress.update(done, total));
226
279
  progress.done(`${stagedPassthroughs.length} staged`);
227
280
  }
@@ -234,12 +287,30 @@ export async function buildSite(opts) {
234
287
  const themeOverride = renderThemeOverride({
235
288
  lightAccent: settings.values.accent_color,
236
289
  lightBg: settings.values.bg_color,
290
+ darkAccent: settings.values.accent_color_dark,
291
+ darkBg: settings.values.bg_color_dark,
237
292
  });
238
293
  await writeFile(join(opts.outputDir, "styles.css"), DEFAULT_CSS + themeOverride);
239
294
  const userCss = await loadObsidianSnippets(opts.vaultPath);
240
295
  await writeFile(join(opts.outputDir, "user.css"), userCss);
241
296
  if (userCss)
242
297
  console.log(` loaded user.css from .obsidian/snippets/`);
298
+ // Browser-side handler assets (built-in + user) concatenated into a
299
+ // single deploy-root JS and CSS file. Skipped entirely if no handler
300
+ // declared any assets (purely declarative handlers stay overhead-free).
301
+ if (hasHandlerJs)
302
+ await writeFile(join(opts.outputDir, "_handlers.js"), handlerAssets.js);
303
+ if (hasHandlerCss)
304
+ await writeFile(join(opts.outputDir, "_handlers.css"), handlerAssets.css);
305
+ // Foundry importer bundle: one ESM file the Foundry module fetches at
306
+ // sync time, plus a tiny version manifest with the SHA-256 the host
307
+ // verifies against its trust cache.
308
+ await writeFoundryImporter(opts.outputDir);
309
+ // Foundry-import bundles are written per-variant inside the role loop
310
+ // below (instead of at the root) so the middleware role-gates them. A
311
+ // public visitor can't fetch the dm-tier handler bundle even if it
312
+ // contains different content. The path stays `/_handlers.foundry.{js,css}`
313
+ // — the middleware rewrites root requests to the matching variant.
243
314
  // Favicon; either user-supplied via settings.favicon, or a generated
244
315
  // default with the vault's first letter in accent on the theme background.
245
316
  try {
@@ -259,11 +330,19 @@ export async function buildSite(opts) {
259
330
  // Computed once against the final imageIndex so OG meta tags, Bases card
260
331
  // covers, hover previews, and Foundry actor/item reskin all resolve to the
261
332
  // same URL. settings.auto_image flips body-fallback discovery on/off.
333
+ //
334
+ // Pre-strip every role-typed callout from the body before discovery: the
335
+ // cover URL has to be the same across all variants the page is visible
336
+ // in, so it must not come from inside a `[!dm]` block (which would leak
337
+ // the image to public deploys). Frontmatter `image:` values are honoured
338
+ // as-is — those are explicit author intent.
339
+ const allRoleTypes = new Set(roles);
262
340
  for (const meta of allPageMetas) {
263
341
  const src = sources.get(meta.path);
264
342
  if (!src)
265
343
  continue;
266
- const cover = resolvePageImage(src, meta.frontmatter, imageIndex, settings.values.auto_image);
344
+ const stripped = stripRoleGatedCallouts(src, allRoleTypes);
345
+ const cover = resolvePageImage(stripped, meta.frontmatter, imageIndex, settings.values.auto_image);
267
346
  if (cover)
268
347
  meta.coverImage = cover;
269
348
  }
@@ -287,8 +366,10 @@ export async function buildSite(opts) {
287
366
  redactRoles,
288
367
  variantDir,
289
368
  vaultName: opts.vaultName,
369
+ vaultPath: opts.vaultPath,
290
370
  allPageMetas,
291
371
  sources,
372
+ parsedSources,
292
373
  baseSources,
293
374
  imageIndex,
294
375
  imageStagingDir,
@@ -296,18 +377,45 @@ export async function buildSite(opts) {
296
377
  passthroughStagingDir: otherStagingDir,
297
378
  settings: settings.values,
298
379
  authConfigured: roles.length > 1,
380
+ handlerRegistry,
381
+ hasHandlerJs,
382
+ hasHandlerCss,
383
+ footerHtml,
299
384
  concurrency,
300
385
  allWarnings: opts.allWarnings,
301
386
  });
302
387
  perRolePageCount[role] = stats.pageCount;
303
388
  if (!collapseToRoot)
304
389
  console.log(` variant '${role}': ${stats.pageCount} pages`);
390
+ // Foundry-import opt-in bundles, emitted INSIDE the variant directory
391
+ // (not at the deploy root) so the auth middleware role-gates them.
392
+ // Single-role builds collapse variantDir to outputDir, so the file
393
+ // ends up at root automatically. The Foundry module fetches by the
394
+ // canonical `/_handlers.foundry.{js,css}` path; the middleware
395
+ // rewrites that to the matching `_variants/<role>/...` per the
396
+ // requesting bearer token's role.
397
+ // Foundry-import subset bundles. The Foundry module fetches these by
398
+ // their canonical `/_handlers.foundry.{js,css}` paths; the middleware
399
+ // role-gates per the requesting bearer's variant.
400
+ if (handlerAssets.foundry) {
401
+ if (handlerAssets.foundry.js.length > 0) {
402
+ await writeFile(join(variantDir, "_handlers.foundry.js"), handlerAssets.foundry.js);
403
+ }
404
+ if (handlerAssets.foundry.css.length > 0) {
405
+ await writeFile(join(variantDir, "_handlers.foundry.css"), handlerAssets.foundry.css);
406
+ }
407
+ }
305
408
  // Write a per-variant _manifest.json so external clients (Foundry, MCP,
306
409
  // etc.) can do an incremental diff. Includes EVERY file that variant
307
410
  // serves; html, md, images (as relative paths into shared root), css.
308
411
  // bodyMeta carries per-page Foundry reskin metadata; folded into each
309
412
  // 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);
413
+ const manifest = await buildManifest(opts.outputDir, variantDir, stats.bodyMeta, !collapseToRoot, roles, opts.vaultName, {
414
+ hasHandlerJs,
415
+ hasHandlerCss,
416
+ hasFoundryJs: (handlerAssets.foundry?.js.length ?? 0) > 0,
417
+ hasFoundryCss: (handlerAssets.foundry?.css.length ?? 0) > 0,
418
+ });
311
419
  await writeFile(join(variantDir, "_manifest.json"), JSON.stringify(manifest));
312
420
  }
313
421
  // ── Pages Functions ─────────────────────────────────────────────────────
@@ -320,11 +428,12 @@ export async function buildSite(opts) {
320
428
  // mapped to a tier. clientSecret stays out of the bundle — it lives in
321
429
  // the Wrangler secret PATREON_CLIENT_SECRET, read from env in the
322
430
  // Function. The CLI uploads it on every push.
323
- const patreonForFn = cfg.patreon && cfg.patreon.tiers && Object.keys(cfg.patreon.tiers).length > 0
431
+ const patreon = cfg.oauth?.patreon;
432
+ const patreonForFn = patreon && patreon.tiers && Object.keys(patreon.tiers).length > 0
324
433
  ? {
325
- clientId: cfg.patreon.clientId,
326
- campaignId: cfg.patreon.campaignId,
327
- tiers: cfg.patreon.tiers,
434
+ clientId: patreon.clientId,
435
+ campaignId: patreon.campaignId,
436
+ tiers: patreon.tiers,
328
437
  }
329
438
  : null;
330
439
  const middleware = renderAuthMiddleware({
@@ -353,6 +462,12 @@ export async function buildSite(opts) {
353
462
  // variant that needs them, so they're no longer required for the deploy.
354
463
  await rm(imageStagingDir, { recursive: true, force: true });
355
464
  await rm(otherStagingDir, { recursive: true, force: true });
465
+ // Atomic swap: move the freshly-built tree into the final location.
466
+ // rm-then-rename: Node's rename refuses to overwrite a non-empty dir.
467
+ // A crash between rm and rename leaves the output missing, which is
468
+ // visibly broken rather than silently half-built.
469
+ await rm(finalOutputDir, { recursive: true, force: true });
470
+ await rename(workOutputDir, finalOutputDir);
356
471
  console.log(`Built in ${formatDuration(Date.now() - start)}.`);
357
472
  return {
358
473
  files,
@@ -371,10 +486,18 @@ async function buildVariant(a) {
371
486
  if (!a.visibleRoles.has(m.role))
372
487
  continue;
373
488
  visibleMetas.push(m);
374
- visibleSources.set(m.path, a.sources.get(m.path));
489
+ // Strip role-gated callouts from the source BEFORE it enters any
490
+ // downstream pass (renderer, transclusion, asset scanner, outlinks).
491
+ // The renderer's calloutPlugin redacts at render time, but the source
492
+ // is what the asset scanner walks — without this strip, an
493
+ // `![[secret.webp]]` inside a `[!dm]` callout on a `role: public`
494
+ // page would copy the file into the public deploy and be reachable
495
+ // by URL even though the article hides the callout.
496
+ const raw = a.sources.get(m.path);
497
+ visibleSources.set(m.path, stripRoleGatedCallouts(raw, a.redactRoles));
375
498
  }
376
499
  // Synthesize folder indexes from the visible set only.
377
- const folderIndexes = generateFolderIndexes(visibleMetas, a.role);
500
+ const folderIndexes = generateFolderIndexes(visibleMetas, a.role, a.settings.inline_title);
378
501
  for (const fi of folderIndexes) {
379
502
  visibleMetas.push({ path: fi.path, title: fi.title, role: a.role });
380
503
  visibleSources.set(fi.path, fi.markdown);
@@ -399,19 +522,26 @@ async function buildVariant(a) {
399
522
  markdownContent.set(basenameSlug, visibleSources.get(p.path));
400
523
  markdownContent.set(pathSlug, visibleSources.get(p.path));
401
524
  }
525
+ // Pre-compute outlinks per page so the Bases plugin can answer
526
+ // file.hasLink() during render (Bases runs before the wikilink plugin
527
+ // populates the per-render outlinks list).
528
+ const outlinksByPath = collectOutlinksByPath(visibleMetas, visibleSources, pageIndex);
402
529
  const context = {
403
530
  pages: pageIndex,
404
531
  images: a.imageIndex,
532
+ passthroughs: a.passthroughIndex,
405
533
  markdownContent,
406
534
  bases: a.baseSources,
407
535
  defaultImageWidth: a.settings.default_image_width,
408
536
  redactRoles: a.redactRoles,
537
+ handlers: a.handlerRegistry,
538
+ outlinksByPath,
409
539
  };
410
540
  const rendered = new Map();
411
541
  const progress = new Progress(`Pages (${a.role})`);
412
542
  progress.update(0, visibleMetas.length);
413
543
  await pMap(visibleMetas, a.concurrency, async (p) => {
414
- const result = await renderMarkdown(visibleSources.get(p.path), context, basenameNoExt(p.path));
544
+ const result = await renderMarkdown(visibleSources.get(p.path), context, basenameNoExt(p.path), a.parsedSources.get(p.path));
415
545
  rendered.set(p.path, {
416
546
  title: result.title,
417
547
  html: result.html,
@@ -453,6 +583,10 @@ async function buildVariant(a) {
453
583
  centerImages: a.settings.center_images,
454
584
  backlinks,
455
585
  authConfigured: a.authConfigured,
586
+ hasHandlerJs: a.hasHandlerJs,
587
+ hasHandlerCss: a.hasHandlerCss,
588
+ footerHtml: a.footerHtml,
589
+ theme: themeOf(a.settings.theme),
456
590
  ...(p.mtime != null ? { mtime: p.mtime } : {}),
457
591
  ...(p.birthtime != null ? { birthtime: p.birthtime } : {}),
458
592
  ...(p.coverImage ? { coverImage: p.coverImage } : {}),
@@ -467,9 +601,14 @@ async function buildVariant(a) {
467
601
  // remark/rehype pipeline land in journals as-is, no client-side render.
468
602
  const bodyPath = outputBase + ".body.html";
469
603
  await writeFile(join(a.variantDir, bodyPath), r.html);
470
- bodyMeta.set(bodyPath, collectBodyMeta(p));
604
+ bodyMeta.set(bodyPath, await collectBodyMeta(p, a.vaultPath));
471
605
  const source = visibleSources.get(p.path);
472
- const preview = await buildPreview(source, r.title);
606
+ const preview = await buildPreview(source, r.title, {
607
+ frontmatter: a.parsedSources.get(p.path)?.data ?? {},
608
+ registry: a.handlerRegistry,
609
+ renderContext: context,
610
+ pagePath: p.path,
611
+ });
473
612
  await writeFile(join(a.variantDir, outputBase + ".preview.json"), JSON.stringify(preview));
474
613
  });
475
614
  progress.done(`${visibleMetas.length} rendered`);
@@ -482,6 +621,10 @@ async function buildVariant(a) {
482
621
  defaultImageWidth: a.settings.default_image_width,
483
622
  centerImages: a.settings.center_images,
484
623
  authConfigured: a.authConfigured,
624
+ hasHandlerJs: a.hasHandlerJs,
625
+ hasHandlerCss: a.hasHandlerCss,
626
+ footerHtml: a.footerHtml,
627
+ theme: themeOf(a.settings.theme),
485
628
  }));
486
629
  // Per-variant search index. `text` is the page's RENDERED HTML body
487
630
  // collapsed to plain text (tags stripped, entities decoded), so search
@@ -504,32 +647,123 @@ async function buildVariant(a) {
504
647
  // contract as images: ship only into variants whose visible pages
505
648
  // reference the file. A DM-only audio cue can't ride along into the
506
649
  // public deploy because no public-tier source mentions it.
507
- await copyReferencedPassthroughs(visibleSources, a.passthroughIndex, a.passthroughStagingDir, a.variantDir);
650
+ await copyReferencedPassthroughs(visibleSources, visibleMetas, a.passthroughIndex, a.passthroughStagingDir, a.variantDir);
508
651
  return { pageCount: visibleMetas.length, bodyMeta };
509
652
  }
510
653
  /**
511
654
  * Build the per-body manifest meta from a page's frontmatter + resolved
512
655
  * cover image. `role` always lands so the Foundry side can apply the
513
- * dmRole permission gate; the foundry_base / image fields are conditional.
656
+ * dmRole permission gate; the foundry / image fields are conditional.
657
+ *
658
+ * Frontmatter shape forwarded to clients:
659
+ * foundry:
660
+ * base: <UUID> | <Type>[:<subtype>] # required for instantiation
661
+ * embed: false # default true
662
+ * data: { … deep-merged into the doc }
514
663
  */
515
- function collectBodyMeta(p) {
664
+ async function collectBodyMeta(p, vaultPath) {
516
665
  const fm = p.frontmatter ?? {};
517
666
  const out = { role: p.role };
518
667
  const basename = p.path.split("/").pop().replace(/\.md$/i, "");
519
668
  if (p.title && p.title !== basename)
520
669
  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
670
  const fo = fm["foundry"];
526
671
  if (fo && typeof fo === "object" && !Array.isArray(fo)) {
527
- out.foundry = fo;
672
+ const block = {};
673
+ const base = fo["base"];
674
+ if (typeof base === "string" && base.trim().length > 0)
675
+ block.base = base.trim();
676
+ const embed = fo["embed"];
677
+ if (typeof embed === "boolean")
678
+ block.embed = embed;
679
+ const data = fo["data"];
680
+ if (data && typeof data === "object" && !Array.isArray(data))
681
+ block.data = data;
682
+ // foundry.id: an explicit Foundry document id for this page. When set,
683
+ // overrides the SHA1-derived id used for both the JournalEntryPage and
684
+ // (if foundry.base is present) the instantiated derived doc. Lets users
685
+ // hardcode UUIDs that other Foundry-side code (macros, scene flags,
686
+ // module integrations) needs to reference. Foundry ids are 16 chars from
687
+ // [A-Za-z0-9]; a malformed value is dropped with a warning rather than
688
+ // failing the build.
689
+ const idVal = fo["id"];
690
+ if (typeof idVal === "string") {
691
+ const trimmed = idVal.trim();
692
+ if (FOUNDRY_ID_RE.test(trimmed))
693
+ block.id = trimmed;
694
+ else if (trimmed.length > 0) {
695
+ console.warn(` ${p.path}: foundry.id "${trimmed}" is not a valid Foundry id (16 chars [A-Za-z0-9]); ignoring`);
696
+ }
697
+ }
698
+ // foundry.data_json: vault-relative path to a JSON file. Read + parse
699
+ // at build time and inline into the meta as `data_json`. The Foundry
700
+ // module deep-merges it onto the base doc BEFORE foundry.data, so a
701
+ // user can layer hand-tuned overrides on top of an exported sheet.
702
+ // Folding the parsed object into meta means the body-row hash already
703
+ // changes when the JSON content does — no separate change-detection.
704
+ const dataJsonPath = fo["data_json"];
705
+ if (typeof dataJsonPath === "string" && dataJsonPath.trim().length > 0) {
706
+ const parsed = await loadDataJson(vaultPath, dataJsonPath.trim(), p.path);
707
+ if (parsed !== null)
708
+ block.data_json = parsed;
709
+ }
710
+ if (Object.keys(block).length > 0)
711
+ out.foundry = block;
528
712
  }
529
713
  if (p.coverImage)
530
714
  out.image = p.coverImage;
531
715
  return out;
532
716
  }
717
+ /** Read + parse a vault-relative JSON file referenced by `foundry.data_json`.
718
+ * Warns on missing / unparseable file and returns null so the page renders
719
+ * without the overlay rather than failing the build. */
720
+ async function loadDataJson(vaultPath, relPath, pagePath) {
721
+ const abs = join(vaultPath, relPath);
722
+ try {
723
+ const raw = await readFile(abs, "utf8");
724
+ return JSON.parse(raw);
725
+ }
726
+ catch (err) {
727
+ const code = err.code;
728
+ if (code === "ENOENT") {
729
+ console.warn(` ${pagePath}: foundry.data_json "${relPath}" not found, skipping`);
730
+ }
731
+ else {
732
+ console.warn(` ${pagePath}: foundry.data_json "${relPath}" failed to parse: ${err.message}`);
733
+ }
734
+ return null;
735
+ }
736
+ }
737
+ /** Collect the `@vault/...` paths referenced inside a page's foundry.data_json
738
+ * file. A Scene's bulk asset refs (backgrounds, ambient sounds, tiles) live in
739
+ * that JSON content rather than the page frontmatter, so the per-variant asset
740
+ * scanners would otherwise never stage them. Returns vault-relative paths. */
741
+ async function collectDataJsonVaultRefs(vaultPath, fm, pagePath) {
742
+ const fo = fm["foundry"];
743
+ if (!fo || typeof fo !== "object" || Array.isArray(fo))
744
+ return [];
745
+ const rel = fo["data_json"];
746
+ if (typeof rel !== "string" || !rel.trim())
747
+ return [];
748
+ const parsed = await loadDataJson(vaultPath, rel.trim(), pagePath);
749
+ if (parsed === null)
750
+ return [];
751
+ const out = [];
752
+ forEachString(parsed, (s) => {
753
+ const path = vaultRefPath(s);
754
+ if (path)
755
+ out.push(path);
756
+ });
757
+ return out;
758
+ }
759
+ /** Foundry document ids: exactly 16 chars from [A-Za-z0-9]. Validated when
760
+ * authors set `foundry.id` to override the SHA1-derived default. */
761
+ const FOUNDRY_ID_RE = /^[A-Za-z0-9]{16}$/;
762
+ /** Coerce settings.theme to the layout's narrowed union, defaulting to
763
+ * "auto" for any unrecognised value rather than failing the build. */
764
+ function themeOf(s) {
765
+ return s === "light" || s === "dark" ? s : "auto";
766
+ }
533
767
  const EMBED_RE = /!\[\[([^\[\]|#\n]+?)(?:\|[^\[\]#\n]*)?\]\]/g;
534
768
  async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, stagingDir, variantDir) {
535
769
  const refs = new Set();
@@ -546,19 +780,35 @@ async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, st
546
780
  // Pages can name their cover via `image:` frontmatter alone (no body embed);
547
781
  // pull those in too. coverImage was resolved to the served URL upstream, so
548
782
  // strip the leading slash + decode to get back to the staging-relative path.
783
+ // `@vault/PATH` references inside any frontmatter string field also gate
784
+ // an asset into this variant — common for Scene background.src / Playlist
785
+ // sound.path that point at vault-shipped media. Page-role gating still
786
+ // applies because we only walk visibleMetas (= pages this variant can see).
549
787
  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(/^\//, ""));
788
+ if (p.coverImage && !/^https?:\/\//i.test(p.coverImage)) {
789
+ try {
790
+ refs.add(decodeURIComponent(p.coverImage.replace(/^\//, "")));
791
+ }
792
+ catch { /* malformed coverImage URL — ignore */ }
557
793
  }
558
- catch {
559
- continue;
794
+ if (p.frontmatter) {
795
+ forEachString(p.frontmatter, (s) => {
796
+ const path = vaultRefPath(s);
797
+ if (path && IMAGE_EXT_RE.test(path)) {
798
+ const image = imageIndex.get(path);
799
+ if (image)
800
+ refs.add(image.outputPath);
801
+ }
802
+ });
803
+ }
804
+ // Image refs inside the page's foundry.data_json (Scene backgrounds, tiles).
805
+ for (const path of p.foundryAssets ?? []) {
806
+ if (!IMAGE_EXT_RE.test(path))
807
+ continue;
808
+ const image = imageIndex.get(path);
809
+ if (image)
810
+ refs.add(image.outputPath);
560
811
  }
561
- refs.add(outputPath);
562
812
  }
563
813
  for (const outputPath of refs) {
564
814
  const src = join(stagingDir, outputPath);
@@ -580,6 +830,73 @@ async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, st
580
830
  const MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+\.[a-z0-9]+)(?:\s+["'][^"']*["'])?\)/gi;
581
831
  // `[[file.ext]]` and `![[file.ext]]` — Obsidian-flavoured wikilinks/embeds.
582
832
  const WIKI_LINK_RE = /!?\[\[([^\[\]|#\n]+\.[a-z0-9]+)(?:\|[^\[\]#\n]*)?(?:#[^\[\]\n]*)?\]\]/gi;
833
+ // `> [!type]…` opens a callout; the rest of the contiguous blockquote (lines
834
+ // starting with `>`, blank line ends) is its body. Used to strip role-gated
835
+ // callouts from the source before any downstream pass sees it.
836
+ const CALLOUT_HEAD_RE = /^>\s*\[!(\w+)\]/;
837
+ /**
838
+ * Drop callout blocks whose type is in `redactRoles` from the source. Walks
839
+ * line-by-line; on a callout-head line whose type is redacted, drops every
840
+ * subsequent line that is part of the same blockquote (starts with `>`).
841
+ * A blank line ends the blockquote per CommonMark.
842
+ *
843
+ * Approximate by markdown standards (doesn't handle lazy-continuation lines
844
+ * or nested blockquotes containing role-gated children), but covers every
845
+ * pattern the asset scanner needs to gate against. The renderer's
846
+ * calloutPlugin still runs as the source of truth for visual redaction;
847
+ * this strip is the asset-leak guard.
848
+ */
849
+ function stripRoleGatedCallouts(source, redactRoles) {
850
+ if (redactRoles.size === 0)
851
+ return source;
852
+ const lines = source.split("\n");
853
+ const out = [];
854
+ let dropping = false;
855
+ for (const line of lines) {
856
+ if (dropping) {
857
+ if (line.startsWith(">"))
858
+ continue; // still inside the blockquote
859
+ dropping = false;
860
+ out.push(line); // blank or non-`>` line ends + keeps the line
861
+ continue;
862
+ }
863
+ const head = CALLOUT_HEAD_RE.exec(line);
864
+ if (head && redactRoles.has(head[1].toLowerCase())) {
865
+ dropping = true;
866
+ continue; // drop the head line
867
+ }
868
+ out.push(line);
869
+ }
870
+ return out.join("\n");
871
+ }
872
+ /**
873
+ * Visit every string value reachable from `value` (object / array / scalar)
874
+ * and call `fn` once per string. Used to surface `@vault/PATH` references
875
+ * inside parsed frontmatter (e.g., a Scene's `foundry.data.background.src`
876
+ * or a Playlist's `foundry.data.sounds[N].path`) so the per-variant asset
877
+ * scanner can include those files alongside body-referenced ones.
878
+ */
879
+ function forEachString(value, fn) {
880
+ if (typeof value === "string")
881
+ return fn(value);
882
+ if (Array.isArray(value)) {
883
+ for (const v of value)
884
+ forEachString(v, fn);
885
+ return;
886
+ }
887
+ if (value && typeof value === "object") {
888
+ for (const v of Object.values(value))
889
+ forEachString(v, fn);
890
+ }
891
+ }
892
+ /** Extract a vault path from a `@vault/PATH` string, or null when the
893
+ * string isn't a vault reference. Trailing fragment / query stripped. */
894
+ function vaultRefPath(s) {
895
+ if (!s.startsWith("@vault/"))
896
+ return null;
897
+ const rest = s.slice("@vault/".length).split("#")[0].split("?")[0];
898
+ return rest.length > 0 ? rest : null;
899
+ }
583
900
  /**
584
901
  * Per-variant reference scan for passthrough files. A file lands in this
585
902
  * variant's deploy only if a visible page mentions it — same gating story
@@ -589,7 +906,7 @@ const WIKI_LINK_RE = /!?\[\[([^\[\]|#\n]+\.[a-z0-9]+)(?:\|[^\[\]#\n]*)?(?:#[^\[\
589
906
  * the whole point of the change; a stray DM-only audio cue stays in the
590
907
  * dm variant only.
591
908
  */
592
- async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stagingDir, variantDir) {
909
+ async function copyReferencedPassthroughs(visibleSources, visibleMetas, passthroughIndex, stagingDir, variantDir) {
593
910
  if (passthroughIndex.size === 0)
594
911
  return;
595
912
  const refs = new Set();
@@ -610,6 +927,28 @@ async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stag
610
927
  refs.add(entry.outputPath);
611
928
  }
612
929
  }
930
+ // `@vault/PATH` references inside any frontmatter string also gate a
931
+ // passthrough into this variant. Same per-page-role visibility rules
932
+ // (only walking visibleMetas) — a dm-tier page's @vault/Audio/secret.ogg
933
+ // ships only to the dm variant.
934
+ for (const p of visibleMetas) {
935
+ if (!p.frontmatter)
936
+ continue;
937
+ forEachString(p.frontmatter, (s) => {
938
+ const path = vaultRefPath(s);
939
+ if (path) {
940
+ const entry = passthroughIndex.get(path);
941
+ if (entry)
942
+ refs.add(entry.outputPath);
943
+ }
944
+ });
945
+ // Audio/video/pdf refs inside the page's foundry.data_json (ambient sounds).
946
+ for (const path of p.foundryAssets ?? []) {
947
+ const entry = passthroughIndex.get(path);
948
+ if (entry)
949
+ refs.add(entry.outputPath);
950
+ }
951
+ }
613
952
  for (const outputPath of refs) {
614
953
  const src = join(stagingDir, outputPath);
615
954
  const dst = join(variantDir, outputPath);
@@ -624,9 +963,11 @@ async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stag
624
963
  }
625
964
  /**
626
965
  * Build synthesised index.md for any folder (including the root) that has
627
- * pages but no existing index.md.
966
+ * pages but no existing index.md. When `inlineTitle` is true, the layout
967
+ * already injects an <h1> from the page's title, so the synthesised body
968
+ * skips its own `# Title` heading to avoid the duplicate.
628
969
  */
629
- function generateFolderIndexes(existing, _role) {
970
+ function generateFolderIndexes(existing, _role, inlineTitle) {
630
971
  const existingPaths = new Set(existing.map((p) => p.path));
631
972
  const folders = new Map();
632
973
  folders.set("", { folders: new Set(), pages: [] });
@@ -675,11 +1016,28 @@ function generateFolderIndexes(existing, _role) {
675
1016
  const propsBlock = propsYaml ? `properties:\n${propsYaml}\n` : "";
676
1017
  sections.push(`## Pages\n\n\`\`\`base\n${filtersBlock}\n${propsBlock}views:\n - type: table\n name: Contents\n order:\n${orderYaml}\n\`\`\``);
677
1018
  }
678
- const heading = title ? `# ${title}\n\n` : "";
679
- out.push({ path: indexPath, title: title || "Home", markdown: `${heading}${sections.join("\n\n")}\n` });
1019
+ // With inline_title on, the layout injects an <h1> from the page's
1020
+ // title which it learns from the markdown's title source. We can
1021
+ // either author the title as a `# Heading` (off-mode) or as YAML
1022
+ // frontmatter (on-mode); the latter avoids the duplicated <h1> while
1023
+ // still letting the renderer surface the right title.
1024
+ const displayTitle = title || "Home";
1025
+ const heading = inlineTitle ? "" : (title ? `# ${title}\n\n` : "");
1026
+ const frontmatter = inlineTitle ? `---\ntitle: ${yamlString(displayTitle)}\n---\n\n` : "";
1027
+ out.push({
1028
+ path: indexPath,
1029
+ title: displayTitle,
1030
+ markdown: `${frontmatter}${heading}${sections.join("\n\n")}\n`,
1031
+ });
680
1032
  }
681
1033
  return out;
682
1034
  }
1035
+ /** YAML-quote a string only when needed (special chars or ambiguous flow). */
1036
+ function yamlString(s) {
1037
+ if (/^[A-Za-z0-9_ .-]+$/.test(s) && !/^(true|false|null|yes|no)$/i.test(s))
1038
+ return s;
1039
+ return JSON.stringify(s);
1040
+ }
683
1041
  /**
684
1042
  * Pick a small set of columns for an auto-generated folder index based on
685
1043
  * what frontmatter the pages in that folder actually have. The first
@@ -696,7 +1054,13 @@ function chooseColumns(pages) {
696
1054
  if (["title", "role", "aliases", "tags"].includes(key))
697
1055
  continue;
698
1056
  const v = fm[key];
699
- if (v == null || v === "" || (Array.isArray(v) && v.length === 0))
1057
+ if (v == null || v === "")
1058
+ continue;
1059
+ // Skip non-scalar values — arrays and plain objects render as
1060
+ // "[object Object]" or comma-joined junk in a table cell. Dates
1061
+ // are technically objects but renderValue formats them nicely,
1062
+ // so let them through.
1063
+ if (typeof v === "object" && !(v instanceof Date))
700
1064
  continue;
701
1065
  counts.set(key, (counts.get(key) ?? 0) + 1);
702
1066
  }
@@ -739,6 +1103,35 @@ async function compressImageCached(file, quality, cacheDir, onHit) {
739
1103
  * spread it directly into the LayoutInput; missing frontmatter contributes
740
1104
  * nothing to the layout.
741
1105
  */
1106
+ // Pre-compute outgoing wikilinks per page (vault path → set of vault paths).
1107
+ // Bases needs this before render runs so file.hasLink() can answer truthfully
1108
+ // during render; the wikilink plugin's per-render outlinks list is collected
1109
+ // after Bases has already drawn the table. A regex scan over markdown source
1110
+ // (rather than rebuilding the AST) is fine: this only needs to detect link
1111
+ // targets, and an embedded ![[image.png]] resolves to no page anyway.
1112
+ const WIKILINK_SCAN_RE = /(?<!!)(?<!\[)\[\[([^\[\]|#\n]+?)(?:#[^\[\]|\n]+?)?(?:\|[^\[\]#\n]+?)?\]\]/g;
1113
+ function collectOutlinksByPath(metas, sources, pageIndex) {
1114
+ const out = new Map();
1115
+ for (const p of metas) {
1116
+ const src = sources.get(p.path);
1117
+ if (!src)
1118
+ continue;
1119
+ const targets = new Set();
1120
+ for (const match of src.matchAll(WIKILINK_SCAN_RE)) {
1121
+ const name = match[1].trim();
1122
+ const slug = slugify(name);
1123
+ const last = name.includes("/") ? name.split("/").pop() : "";
1124
+ const page = pageIndex.get(slug)
1125
+ ?? pageIndex.get(slugify(name + "/index"))
1126
+ ?? (last ? pageIndex.get(slugify(last)) : undefined);
1127
+ if (page && page.path !== p.path)
1128
+ targets.add(page.path);
1129
+ }
1130
+ if (targets.size > 0)
1131
+ out.set(p.path, targets);
1132
+ }
1133
+ return out;
1134
+ }
742
1135
  function extractFrontmatterBlock(source) {
743
1136
  const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
744
1137
  if (!m || !m[1] || !m[1].trim())
@@ -759,23 +1152,26 @@ function parseFrontmatter(source) {
759
1152
  };
760
1153
  }
761
1154
  /**
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.
1155
+ * Full YAML frontmatter + body content, used by the Bases plugin (frontmatter
1156
+ * properties) and threaded through to renderMarkdown so the pipeline doesn't
1157
+ * have to call gray-matter again. `data` is real YAML; falls back to {} on
1158
+ * malformed YAML so the page still renders. Content matches what gray-matter
1159
+ * would give the pipeline (frontmatter block stripped from the head).
766
1160
  */
767
- function parseFullFrontmatter(source) {
1161
+ function parseFullFrontmatterWithContent(source) {
768
1162
  if (!source.startsWith("---"))
769
- return {};
1163
+ return { data: {}, content: source };
770
1164
  try {
771
- const data = matter(source).data;
772
- return (data && typeof data === "object" ? data : {});
1165
+ const m = matter(source);
1166
+ const data = (m.data && typeof m.data === "object" ? m.data : {});
1167
+ return { data, content: m.content };
773
1168
  }
774
1169
  catch {
775
1170
  // 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 {};
1171
+ // the title/role/aliases keys we actually need. Return empty data + the
1172
+ // body with the leading `---\n…\n---\n` stripped via regex so the rest
1173
+ // of the pipeline still sees a clean body.
1174
+ return { data: {}, content: source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "") };
779
1175
  }
780
1176
  }
781
1177
  /**
@@ -809,10 +1205,6 @@ function parseAliases(fm) {
809
1205
  function unquote(s) {
810
1206
  return s.replace(/^["']|["']$/g, "");
811
1207
  }
812
- function extractH1(source) {
813
- const h1 = /^#\s+(.+)$/m.exec(source);
814
- return h1?.[1] ? h1[1].trim() : null;
815
- }
816
1208
  function basenameNoExt(path) {
817
1209
  return path.split("/").pop().replace(/\.md$/i, "");
818
1210
  }
@@ -865,13 +1257,7 @@ function kindLabel(kind) {
865
1257
  default: return kind;
866
1258
  }
867
1259
  }
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) {
1260
+ async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles, vaultName, assets) {
875
1261
  const files = [];
876
1262
  const seen = new Set();
877
1263
  // Variant-specific files: use pathBase=variantDir so paths come out as
@@ -896,7 +1282,30 @@ async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles,
896
1282
  // like the Foundry module use it as the default label + root folder when
897
1283
  // a user adds the vault, so they get something readable instead of a
898
1284
  // host-derived slug.
899
- return { name: vaultName, auth: { required: authRequired, roles }, files };
1285
+ // Asset advertisement so clients (Foundry, MCP) fetch the right paths
1286
+ // instead of guessing well-known names — lets us move things later.
1287
+ const assetBlock = {};
1288
+ if (assets.hasHandlerJs || assets.hasHandlerCss) {
1289
+ assetBlock.browser = {
1290
+ ...(assets.hasHandlerJs ? { js: "/_handlers.js" } : {}),
1291
+ ...(assets.hasHandlerCss ? { css: "/_handlers.css" } : {}),
1292
+ };
1293
+ }
1294
+ if (assets.hasFoundryJs || assets.hasFoundryCss) {
1295
+ assetBlock.foundry = {
1296
+ ...(assets.hasFoundryJs ? { js: "/_handlers.foundry.js" } : {}),
1297
+ ...(assets.hasFoundryCss ? { css: "/_handlers.foundry.css" } : {}),
1298
+ };
1299
+ }
1300
+ return {
1301
+ manifest_version: MANIFEST_VERSION,
1302
+ cli_version: CLI_VERSION,
1303
+ id_scheme: ID_SCHEME,
1304
+ name: vaultName,
1305
+ auth: { required: authRequired, roles },
1306
+ ...(Object.keys(assetBlock).length > 0 ? { assets: assetBlock } : {}),
1307
+ files,
1308
+ };
900
1309
  }
901
1310
  async function walkAndIndex(dir, pathBase, out, seen, skipDirNames, bodyMeta) {
902
1311
  const entries = await readdir(dir, { withFileTypes: true });
@@ -919,7 +1328,7 @@ async function walkAndIndex(dir, pathBase, out, seen, skipDirNames, bodyMeta) {
919
1328
  const body = await readFile(abs);
920
1329
  const info = await stat(abs);
921
1330
  const meta = bodyMeta.get(path);
922
- // Fold meta JSON into the hash so meta-only edits (e.g. a foundry_base
1331
+ // Fold meta JSON into the hash so meta-only edits (e.g. a foundry.base
923
1332
  // tweak with no body change) still bump the row hash and trigger sync.
924
1333
  const hasher = createHash("md5").update(body);
925
1334
  if (meta)
@@ -948,104 +1357,6 @@ function stableStringify(value) {
948
1357
  const keys = Object.keys(obj).sort();
949
1358
  return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
950
1359
  }
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
1360
  /**
1050
1361
  * Strip an HTML body to plain text. Used to feed the search index from
1051
1362
  * the rendered article (post-wikilink, post-callout-redaction) so search