@wizzlethorpe/vaults 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +132 -15
  2. package/dist/build.js +111 -73
  3. package/dist/build.js.map +1 -1
  4. package/dist/commands/build.js +3 -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 +2 -0
  9. package/dist/commands/password.js.map +1 -1
  10. package/dist/commands/patreon.js +6 -0
  11. package/dist/commands/patreon.js.map +1 -1
  12. package/dist/commands/preview.js +6 -5
  13. package/dist/commands/preview.js.map +1 -1
  14. package/dist/commands/push.js +5 -2
  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 +9 -5
  19. package/dist/config.js.map +1 -1
  20. package/dist/index.js +34 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/migrate/0.6-legacy-auth-settings.js +98 -0
  23. package/dist/migrate/0.6-legacy-auth-settings.js.map +1 -0
  24. package/dist/migrate/0.7-vaults-dir.js +70 -0
  25. package/dist/migrate/0.7-vaults-dir.js.map +1 -0
  26. package/dist/migrate/registry.js +12 -0
  27. package/dist/migrate/registry.js.map +1 -0
  28. package/dist/migrate/run.js +38 -0
  29. package/dist/migrate/run.js.map +1 -0
  30. package/dist/migrate/types.js +8 -0
  31. package/dist/migrate/types.js.map +1 -0
  32. package/dist/paths.js +51 -0
  33. package/dist/paths.js.map +1 -0
  34. package/dist/render/auth-template.js +3 -0
  35. package/dist/render/auth-template.js.map +1 -1
  36. package/dist/render/footer.js +37 -0
  37. package/dist/render/footer.js.map +1 -0
  38. package/dist/render/handlers/assets.js +90 -0
  39. package/dist/render/handlers/assets.js.map +1 -0
  40. package/dist/render/handlers/builtin/dice.js +78 -0
  41. package/dist/render/handlers/builtin/dice.js.map +1 -0
  42. package/dist/render/handlers/builtin/fm.js +89 -0
  43. package/dist/render/handlers/builtin/fm.js.map +1 -0
  44. package/dist/render/handlers/builtin/index.js +9 -0
  45. package/dist/render/handlers/builtin/index.js.map +1 -0
  46. package/dist/render/handlers/builtin/statblock.js +351 -0
  47. package/dist/render/handlers/builtin/statblock.js.map +1 -0
  48. package/dist/render/handlers/dispatch.js +187 -0
  49. package/dist/render/handlers/dispatch.js.map +1 -0
  50. package/dist/render/handlers/loader.js +90 -0
  51. package/dist/render/handlers/loader.js.map +1 -0
  52. package/dist/render/handlers/types.js +40 -0
  53. package/dist/render/handlers/types.js.map +1 -0
  54. package/dist/render/layout.js +2 -0
  55. package/dist/render/layout.js.map +1 -1
  56. package/dist/render/pipeline.js +16 -0
  57. package/dist/render/pipeline.js.map +1 -1
  58. package/dist/render/styles.js +30 -1
  59. package/dist/render/styles.js.map +1 -1
  60. package/dist/scan.js +1 -1
  61. package/dist/scan.js.map +1 -1
  62. package/dist/settings.js +9 -3
  63. package/dist/settings.js.map +1 -1
  64. package/package.json +11 -12
  65. package/dist/api.js +0 -42
  66. package/dist/api.js.map +0 -1
  67. package/dist/render/mcp-template.js +0 -239
  68. package/dist/render/mcp-template.js.map +0 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # vaults
2
2
 
3
- Sync an Obsidian vault to a Cloudflare-hosted wiki. The CLI renders your notes locally to HTML and deploys them to your own Cloudflare Pages account. Supports role-based access (public, patron, dm, …) so different parts of the same vault can be visible to different audiences.
3
+ Sync an Obsidian vault to a Cloudflare-hosted wiki. The CLI renders your notes locally to HTML and deploys them to your own Cloudflare Pages account. Supports role-based access (public, patron, dm, …) so different parts of the same vault can be visible to different audiences. Patrons can be authenticated by password or by Patreon OAuth (linking roles to specific Patreon tier IDs).
4
4
 
5
5
  ## Install
6
6
 
@@ -28,6 +28,8 @@ vaults push # render + deploy to Cloudflare Pages
28
28
 
29
29
  The first push prompts for a Pages project name and runs `wrangler login` if you aren't authenticated. After that it just renders and deploys.
30
30
 
31
+ If you'd rather not `cd` into the vault every time, set `VAULT_PATH=~/Documents/MyVault` in your shell rc and run `vaults` from anywhere.
32
+
31
33
  ## How it works
32
34
 
33
35
  ```
@@ -41,24 +43,49 @@ Cloudflare Pages ← per-user, your account
41
43
  ```
42
44
 
43
45
  - **Per-tier deploys.** A page tagged `role: dm` in its frontmatter only ships to the dm variant. Public visitors *cannot* fetch it; the file structurally doesn't exist in their variant.
44
- - **Images are gated too.** Only images embedded by visible pages are copied into a given variant.
45
- - **Incremental sync.** External clients (the [Foundry VTT module](https://github.com/wizzlethorpe/vaults-foundry)) can pull changes via `/_manifest.json` + `/_batch` endpoints; the CLI computes content hashes so the diff is minimal.
46
+ - **Inline gating with callouts.** Drop a `> [!dm]` callout in an otherwise public page; the entire block is stripped from the public deploy. Same for any other configured role.
47
+ - **Images and media are gated too.** Only images, audio, video, PDFs, and EPUBs embedded by visible pages are copied into a given variant. Unknown extensions are skipped by default (toggle `include_unknown_files`).
48
+ - **Incremental sync.** External clients (the [Foundry VTT module](https://github.com/wizzlethorpe/vaults)) probe `/_manifest.json` to discover the deploy's name, auth requirements, and role order, then pull `/_batch` (text) and `/_batch-images` (binary) for changed content. Manifest hashes fold in per-page frontmatter, so a role flip or title rename triggers a sync without a body diff.
49
+ - **Bases support.** `.base` files render as cards / table / list inside the wiki, just like inside Obsidian.
50
+ - **Social meta.** OG / Twitter card tags are auto-generated. Pages without an explicit `image:` frontmatter use the first body embed (toggle with `auto_image`).
46
51
 
47
52
  ## Commands
48
53
 
54
+ ### Build / deploy
55
+
49
56
  | Command | What it does |
50
57
  |---|---|
51
58
  | `vaults init` | Write a `settings.md` with sensible defaults. |
52
59
  | `vaults build` | Render the vault to a local directory (no deploy). |
53
60
  | `vaults preview` | Render + serve locally via `wrangler pages dev` so you can click around with auth working. |
54
61
  | `vaults push` | Render + deploy to Cloudflare Pages. |
62
+ | `vaults push --dry-run` | Render without deploying. |
63
+ | `vaults push --rotate-secret` | Generate a fresh `SESSION_SECRET`, invalidating every issued auth token at once. |
64
+ | `vaults push --all-warnings` / `vaults build --all-warnings` | Don't truncate the broken-link / missing-image report. |
65
+
66
+ ### Roles and passwords
67
+
68
+ | Command | What it does |
69
+ |---|---|
55
70
  | `vaults role add <name>` | Add an access tier. The first role becomes the default (no password). |
56
71
  | `vaults role remove <name>` | Remove an access tier. |
57
72
  | `vaults role list` | List configured roles. |
58
73
  | `vaults role promote <name>` / `demote <name>` | Reorder tiers. |
59
74
  | `vaults password <role>` | Set or change a role's password (PBKDF2-SHA256). |
60
- | `vaults push --rotate-secret` | Generate a fresh `SESSION_SECRET`, invalidating every issued auth token at once. |
61
- | `vaults push --all-warnings` / `vaults build --all-warnings` | Don't truncate the broken-link / missing-image report. |
75
+
76
+ ### Patreon OAuth (optional)
77
+
78
+ Link roles to Patreon tier IDs, so any patron at that tier can sign in with Patreon and pick up the corresponding role. Coexists with passwords; either grants the role.
79
+
80
+ | Command | What it does |
81
+ |---|---|
82
+ | `vaults patreon configure` | Prompts for Patreon OAuth client credentials and walks you through picking a campaign. The client secret is stored as a Wrangler secret on next push. |
83
+ | `vaults patreon link <role> <tier-id>` | Map a role to a numeric Patreon tier ID. |
84
+ | `vaults patreon unlink <role>` | Remove a role's tier mapping (password access stays). |
85
+ | `vaults patreon status` | Show current configuration and tier mappings. |
86
+ | `vaults patreon clear` | Remove the entire Patreon configuration. |
87
+
88
+ You'll need to register a Patreon OAuth client at <https://www.patreon.com/portal/registration> and add `https://your-deploy-url/auth/patreon/callback` as a redirect URI before running `configure`.
62
89
 
63
90
  Run any command with `--help` for the full flag list.
64
91
 
@@ -71,18 +98,20 @@ Run any command with `--help` for the full flag list.
71
98
  vault_name: My Wiki
72
99
  default_role: public
73
100
  accent_color: "#7a4a8c"
74
- accent_color_dark: "#b58af5"
101
+ bg_color: "#1a1a2e"
75
102
  favicon: assets/icons/wiki.png
76
103
  inline_title: true
77
- default_image_width: 50vw
104
+ default_image_width: 300px
78
105
  center_images: true
106
+ auto_image: true
107
+ include_unknown_files: false
79
108
  ignore:
80
109
  - Templates/**
81
110
  - "*.draft.md"
82
111
  ---
83
112
  ```
84
113
 
85
- Open it in Obsidian; the frontmatter shows up as a Properties form.
114
+ Open it in Obsidian; the frontmatter shows up as a Properties form. Unknown keys are warned about and stripped on next push, so the file stays canonical. Auth config (roles, passwords, Patreon credentials) is **not** in `settings.md`; it lives in `.vaultrc.json` and is managed by the CLI.
86
115
 
87
116
  ## Page frontmatter
88
117
 
@@ -95,30 +124,118 @@ title: Optional override # default: filename or first H1
95
124
  aliases: # extra names that resolve to this page from wikilinks
96
125
  - Pale Mountains
97
126
  - The Pale Mountains
127
+ image: assets/banner.webp # optional cover image (OG / Twitter / Bases / Foundry)
128
+ foundry_base: Compendium.dnd5e.monsters.Actor.bandit # optional Foundry doc to clone
129
+ foundry: # optional doc overrides
130
+ system.attributes.hp.value: 22
98
131
  ---
99
132
  ```
100
133
 
101
134
  Wikilinks (`[[Page]]`, `[[Page|alias]]`, `[[NPCs/Page#section]]`), image embeds (`![[image.png]]`), transclusions (`![[Page]]`), and Obsidian callouts all render the same way they do in Obsidian.
102
135
 
136
+ ## Custom handlers
137
+
138
+ Vault authors can extend the renderer with custom inline-code and code-block transforms. Drop a Node ESM module into `.vaults/handlers/<name>.mjs` that exports a `handler` (or `handlers: Handler[]`); vaults-cli loads them at build time and runs them over every page.
139
+
140
+ - **Inline handler**: matches inline code like `` `prefix: content` ``.
141
+ - **Code-block handler**: matches fenced ` ```language ` blocks.
142
+
143
+ Both return either `{ markdown }` (re-processed through the rest of the pipeline) or `{ html }` (sanitized and inserted as-is).
144
+
145
+ ```js
146
+ // .vaults/handlers/seealso.mjs
147
+ export const handler = {
148
+ codeBlock: "seealso",
149
+ render: (content) => ({
150
+ markdown: content
151
+ .split(/\r?\n/)
152
+ .filter(Boolean)
153
+ .map((p) => `- [[${p.trim()}]]`)
154
+ .join("\n"),
155
+ }),
156
+ };
157
+ ```
158
+
159
+ ### Browser-side assets
160
+
161
+ If your handler needs to ship browser-side JavaScript or CSS, declare them with the `assets` field. Paths are relative to the handler file. Every declared asset across all handlers is concatenated into one `_handlers.js` and one `_handlers.css` at the deploy root, deduped by absolute path so a shared utility file is only included once.
162
+
163
+ ```js
164
+ // .vaults/handlers/widget.mjs
165
+ export const handler = {
166
+ codeBlock: "widget",
167
+ assets: {
168
+ scripts: ["./widget.runtime.js"],
169
+ styles: ["./widget.css"],
170
+ },
171
+ render: (content) => ({
172
+ html: `<div class="widget" data-config="${content}"></div>`,
173
+ }),
174
+ };
175
+ ```
176
+
177
+ ```js
178
+ // .vaults/handlers/widget.runtime.js
179
+ (function () {
180
+ document.querySelectorAll('.widget').forEach((el) => {
181
+ // wire up el.dataset.config into something interactive
182
+ });
183
+ })();
184
+ ```
185
+
186
+ The deployed page references `/_handlers.js` (deferred) and `/_handlers.css`; the runtime then finds and hydrates the handler's HTML. Wrap your runtime in an IIFE to avoid global pollution.
187
+
188
+ **File-naming convention.** Handler module files end in `.mjs`. Browser-side runtime / CSS files end in `.js` / `.css`. The loader only treats `.mjs` files as handler modules; `.js` files in the same directory are picked up only if a handler's `assets.scripts` references them.
189
+
190
+ ### Built-ins
191
+
192
+ - **`dice:` (inline)** — `` `dice: 1d20+5` `` renders as a clickable button on the deploy that re-rolls on click. Mirrors [Obsidian Dice Roller](https://github.com/javalent/dice-roller) syntax.
193
+
194
+ User handlers can override built-ins of the same name. Trust model: handlers run with the same permissions as the rest of the build, so only run `vaults push` on vaults whose contents you trust.
195
+
103
196
  ## Auth
104
197
 
105
198
  Multi-role deploys ship with a small Cloudflare Pages Function (`_middleware.js`) that:
106
199
 
107
200
  - **Gates per-role variants** via a signed cookie (`SameSite=None; Secure; Partitioned`).
108
- - **Issues bearer tokens** through an OAuth-style `/connect` flow used by the [Foundry module](https://github.com/wizzlethorpe/vaults-foundry).
201
+ - **Issues bearer tokens** through an OAuth-style `/connect` flow used by the [Foundry module](https://github.com/wizzlethorpe/vaults).
202
+ - **Handles Patreon login** at `/auth/patreon/login` and `/auth/patreon/callback` when configured.
109
203
  - **Exposes** `/_batch` (text) and `/_batch-images` (binary) for bulk content sync.
204
+ - **Publishes** `/_manifest.json` with the deploy's name, role order, and auth requirements so external clients can probe the deploy before picking an auth flow.
110
205
 
111
206
  Tokens are stateless HMAC-signed JWTs; revocation = rotate `SESSION_SECRET` via `vaults push --rotate-secret`.
112
207
 
208
+ Single-role (public-only) deploys skip the middleware entirely; everything serves as plain static assets.
209
+
113
210
  ## Files this CLI manages locally
114
211
 
115
- | File | Tracked in git? | What it holds |
116
- |---|---|---|
117
- | `settings.md` | yes | User-editable settings. |
118
- | `.vaultrc.json` | **no** | CLI-managed: `SESSION_SECRET`, role password hashes, project name, cached settings. |
119
- | `.vault-cache/` | **no** | Build cache: rendered output, image webp cache. |
212
+ ```
213
+ MyVault/
214
+ ├── settings.md user-editable settings (Obsidian Properties UI)
215
+ ├── …content…
216
+ ├── .env ← secrets only (SESSION_SECRET, PATREON_CLIENT_SECRET) gitignored
217
+ └── .vaults/ ← all vaults-cli internal state lives here
218
+ ├── .gitignore ← keeps cache + config out of git automatically
219
+ ├── config.json ← CLI-managed: roles, password hashes, project name, Patreon config
220
+ ├── cache/ ← build cache (rendered HTML, image webp cache)
221
+ └── handlers/ ← optional: custom inline / code-block handlers
222
+ ```
223
+
224
+ `settings.md` lives at the vault root (and only there) so Obsidian renders it as an editable Properties form. Everything else is internal and tucked under `.vaults/`. `vaults init` writes `.vaults/.gitignore` automatically; if your vault is a git repo, this is enough to keep the cache + secrets-bearing config from being tracked.
225
+
226
+ ## Migrations
227
+
228
+ vaults-cli runs schema and layout migrations automatically before every `build` / `push` / `preview`. They're idempotent: already-migrated vaults pay only the cost of a few `stat()` calls.
229
+
230
+ To run them manually or inspect what would change:
231
+
232
+ ```bash
233
+ vaults migrate --list # show all known migrations
234
+ vaults migrate --dry-run # show what would apply on this vault
235
+ vaults migrate # apply pending migrations
236
+ ```
120
237
 
121
- `vaults init` adds `.vaultrc.json` and `.vault-cache` to `.gitignore` if your vault is a git repo.
238
+ If you're upgrading from a pre-0.7 vault, the first run of any command will move `.vaultrc.json` → `.vaults/config.json` and `.vault-cache/` `.vaults/cache/` and write a `.vaults/.gitignore`. Renames are atomic on the same filesystem so even large caches migrate instantly.
122
239
 
123
240
  ## License
124
241
 
package/dist/build.js CHANGED
@@ -24,9 +24,16 @@ import { resolvePageImage } from "./render/cover.js";
24
24
  import { DEFAULT_CSS, renderThemeOverride } from "./render/styles.js";
25
25
  import { loadObsidianSnippets } from "./obsidian.js";
26
26
  import { loadSettings, writeSettings, SETTINGS_FILE } from "./settings.js";
27
- import { loadConfig, saveConfig } from "./config.js";
27
+ import { loadConfig } from "./config.js";
28
28
  import matter from "gray-matter";
29
29
  import { renderAuthMiddleware, LOGIN_HTML } from "./render/auth-template.js";
30
+ import { renderFooterHtml } from "./render/footer.js";
31
+ import { buildRegistry } from "./render/handlers/types.js";
32
+ import { loadUserHandlers } from "./render/handlers/loader.js";
33
+ import { BUILTIN_HANDLERS } from "./render/handlers/builtin/index.js";
34
+ import { bundleHandlerAssets } from "./render/handlers/assets.js";
35
+ import { runMigrations } from "./migrate/run.js";
36
+ import { cacheDir } from "./paths.js";
30
37
  import { formatDuration, pMap, Progress } from "./util.js";
31
38
  /**
32
39
  * Output layout when there are multiple roles:
@@ -48,10 +55,10 @@ import { formatDuration, pMap, Progress } from "./util.js";
48
55
  export async function buildSite(opts) {
49
56
  const start = Date.now();
50
57
  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);
58
+ // Run any pending schema / layout migrations before reading anything
59
+ // else. The framework is idempotent: already-migrated vaults pay only
60
+ // the cost of a few stat() calls. See cli/src/migrate/.
61
+ await runMigrations(opts.vaultPath);
55
62
  // ── Settings (user-editable) ─────────────────────────────────────────────
56
63
  const settings = await loadSettings(opts.vaultPath);
57
64
  for (const w of settings.warnings)
@@ -66,6 +73,29 @@ export async function buildSite(opts) {
66
73
  imageQuality: opts.imageQuality === 85 ? settings.values.image_quality : opts.imageQuality,
67
74
  maxFileBytes: opts.maxFileBytes === 25 * 1024 * 1024 ? settings.values.max_file_bytes : opts.maxFileBytes,
68
75
  };
76
+ // ── Custom handlers ──────────────────────────────────────────────────────
77
+ // Built-ins ship with the CLI; user handlers live in `.vaults/handlers/`
78
+ // and can override built-in names (last-registered wins). One registry
79
+ // is built once and shared across every variant render.
80
+ const userHandlers = await loadUserHandlers(opts.vaultPath);
81
+ const handlerRegistry = buildRegistry([
82
+ ...BUILTIN_HANDLERS,
83
+ ...userHandlers.map((h) => h.handler),
84
+ ]);
85
+ if (userHandlers.length > 0) {
86
+ console.log(` loaded ${userHandlers.length} custom handler(s) from .vaults/handlers/`);
87
+ }
88
+ // Concatenate browser-side assets declared by built-in and user handlers
89
+ // into a single _handlers.js / _handlers.css emitted at the deploy root.
90
+ // Each unique source is included once, regardless of invocation count.
91
+ // Two independent flags so a deploy with only-JS or only-CSS doesn't
92
+ // reference a file that wasn't written.
93
+ const handlerAssets = await bundleHandlerAssets(userHandlers, BUILTIN_HANDLERS, opts.vaultPath);
94
+ const hasHandlerJs = handlerAssets.js.length > 0;
95
+ const hasHandlerCss = handlerAssets.css.length > 0;
96
+ // Footer markdown rendered once per build; the resulting HTML is
97
+ // embedded verbatim in every page's layout. Empty string = no footer.
98
+ const footerHtml = await renderFooterHtml(settings.values.footer);
69
99
  // ── CLI-managed state (auth) ─────────────────────────────────────────────
70
100
  const cfg = await loadConfig(opts.vaultPath, {});
71
101
  const roles = cfg.roles.length > 0 ? cfg.roles : ["public"];
@@ -183,8 +213,8 @@ export async function buildSite(opts) {
183
213
  const imageStagingDir = join(opts.outputDir, ".image-staging");
184
214
  const imageIndex = new Map();
185
215
  if (imageFiles.length > 0) {
186
- const cacheDir = join(opts.vaultPath, ".vault-cache", "images", `q${opts.imageQuality}`);
187
- await mkdir(cacheDir, { recursive: true });
216
+ const cacheImageDir = join(cacheDir(opts.vaultPath), "images", `q${opts.imageQuality}`);
217
+ await mkdir(cacheImageDir, { recursive: true });
188
218
  let cacheHits = 0;
189
219
  const progress = new Progress("Images");
190
220
  progress.update(0, imageFiles.length);
@@ -192,7 +222,7 @@ export async function buildSite(opts) {
192
222
  // SVGs / non-compressible images pass through; everything else gets
193
223
  // recoded to webp for size. Either way they land in the staging dir.
194
224
  const compressed = opts.imageQuality > 0 && COMPRESSIBLE_EXT_RE.test(f.path)
195
- ? await compressImageCached(f, opts.imageQuality, cacheDir, () => { cacheHits++; })
225
+ ? await compressImageCached(f, opts.imageQuality, cacheImageDir, () => { cacheHits++; })
196
226
  : { body: await readFile(f.absolute), outputPath: f.path };
197
227
  const dest = join(imageStagingDir, compressed.outputPath);
198
228
  await mkdir(dirname(dest), { recursive: true });
@@ -240,6 +270,13 @@ export async function buildSite(opts) {
240
270
  await writeFile(join(opts.outputDir, "user.css"), userCss);
241
271
  if (userCss)
242
272
  console.log(` loaded user.css from .obsidian/snippets/`);
273
+ // Browser-side handler assets (built-in + user) concatenated into a
274
+ // single deploy-root JS and CSS file. Skipped entirely if no handler
275
+ // declared any assets (purely declarative handlers stay overhead-free).
276
+ if (hasHandlerJs)
277
+ await writeFile(join(opts.outputDir, "_handlers.js"), handlerAssets.js);
278
+ if (hasHandlerCss)
279
+ await writeFile(join(opts.outputDir, "_handlers.css"), handlerAssets.css);
243
280
  // Favicon; either user-supplied via settings.favicon, or a generated
244
281
  // default with the vault's first letter in accent on the theme background.
245
282
  try {
@@ -296,6 +333,10 @@ export async function buildSite(opts) {
296
333
  passthroughStagingDir: otherStagingDir,
297
334
  settings: settings.values,
298
335
  authConfigured: roles.length > 1,
336
+ handlerRegistry,
337
+ hasHandlerJs,
338
+ hasHandlerCss,
339
+ footerHtml,
299
340
  concurrency,
300
341
  allWarnings: opts.allWarnings,
301
342
  });
@@ -374,7 +415,7 @@ async function buildVariant(a) {
374
415
  visibleSources.set(m.path, a.sources.get(m.path));
375
416
  }
376
417
  // Synthesize folder indexes from the visible set only.
377
- const folderIndexes = generateFolderIndexes(visibleMetas, a.role);
418
+ const folderIndexes = generateFolderIndexes(visibleMetas, a.role, a.settings.inline_title);
378
419
  for (const fi of folderIndexes) {
379
420
  visibleMetas.push({ path: fi.path, title: fi.title, role: a.role });
380
421
  visibleSources.set(fi.path, fi.markdown);
@@ -406,6 +447,7 @@ async function buildVariant(a) {
406
447
  bases: a.baseSources,
407
448
  defaultImageWidth: a.settings.default_image_width,
408
449
  redactRoles: a.redactRoles,
450
+ handlers: a.handlerRegistry,
409
451
  };
410
452
  const rendered = new Map();
411
453
  const progress = new Progress(`Pages (${a.role})`);
@@ -453,6 +495,9 @@ async function buildVariant(a) {
453
495
  centerImages: a.settings.center_images,
454
496
  backlinks,
455
497
  authConfigured: a.authConfigured,
498
+ hasHandlerJs: a.hasHandlerJs,
499
+ hasHandlerCss: a.hasHandlerCss,
500
+ footerHtml: a.footerHtml,
456
501
  ...(p.mtime != null ? { mtime: p.mtime } : {}),
457
502
  ...(p.birthtime != null ? { birthtime: p.birthtime } : {}),
458
503
  ...(p.coverImage ? { coverImage: p.coverImage } : {}),
@@ -482,14 +527,20 @@ async function buildVariant(a) {
482
527
  defaultImageWidth: a.settings.default_image_width,
483
528
  centerImages: a.settings.center_images,
484
529
  authConfigured: a.authConfigured,
530
+ hasHandlerJs: a.hasHandlerJs,
531
+ hasHandlerCss: a.hasHandlerCss,
532
+ footerHtml: a.footerHtml,
485
533
  }));
486
- // Per-variant search index.
534
+ // Per-variant search index. `text` is the page's RENDERED HTML body
535
+ // collapsed to plain text (tags stripped, entities decoded), so search
536
+ // snippets read the same way the page reads — no leftover markdown
537
+ // syntax (`|`, `**`, raw HTML) bleeding into the dropdown.
487
538
  const searchIndex = visibleMetas.map((p) => ({
488
539
  title: p.title,
489
540
  path: p.path,
490
541
  href: "/" + p.path.replace(/\.md$/i, "").split("/").map(encodeURIComponent).join("/"),
491
542
  folder: p.path.includes("/") ? p.path.split("/").slice(0, -1).join("/") : "",
492
- text: extractPlainText(visibleSources.get(p.path) ?? "", 1500),
543
+ text: htmlToText(rendered.get(p.path)?.html ?? "", 1500),
493
544
  }));
494
545
  await writeFile(join(a.variantDir, "_search-index.json"), JSON.stringify(searchIndex));
495
546
  // Copy whichever images this variant's pages reference. Images live only
@@ -621,9 +672,11 @@ async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stag
621
672
  }
622
673
  /**
623
674
  * Build synthesised index.md for any folder (including the root) that has
624
- * pages but no existing index.md.
675
+ * pages but no existing index.md. When `inlineTitle` is true, the layout
676
+ * already injects an <h1> from the page's title, so the synthesised body
677
+ * skips its own `# Title` heading to avoid the duplicate.
625
678
  */
626
- function generateFolderIndexes(existing, _role) {
679
+ function generateFolderIndexes(existing, _role, inlineTitle) {
627
680
  const existingPaths = new Set(existing.map((p) => p.path));
628
681
  const folders = new Map();
629
682
  folders.set("", { folders: new Set(), pages: [] });
@@ -672,11 +725,28 @@ function generateFolderIndexes(existing, _role) {
672
725
  const propsBlock = propsYaml ? `properties:\n${propsYaml}\n` : "";
673
726
  sections.push(`## Pages\n\n\`\`\`base\n${filtersBlock}\n${propsBlock}views:\n - type: table\n name: Contents\n order:\n${orderYaml}\n\`\`\``);
674
727
  }
675
- const heading = title ? `# ${title}\n\n` : "";
676
- out.push({ path: indexPath, title: title || "Home", markdown: `${heading}${sections.join("\n\n")}\n` });
728
+ // With inline_title on, the layout injects an <h1> from the page's
729
+ // title which it learns from the markdown's title source. We can
730
+ // either author the title as a `# Heading` (off-mode) or as YAML
731
+ // frontmatter (on-mode); the latter avoids the duplicated <h1> while
732
+ // still letting the renderer surface the right title.
733
+ const displayTitle = title || "Home";
734
+ const heading = inlineTitle ? "" : (title ? `# ${title}\n\n` : "");
735
+ const frontmatter = inlineTitle ? `---\ntitle: ${yamlString(displayTitle)}\n---\n\n` : "";
736
+ out.push({
737
+ path: indexPath,
738
+ title: displayTitle,
739
+ markdown: `${frontmatter}${heading}${sections.join("\n\n")}\n`,
740
+ });
677
741
  }
678
742
  return out;
679
743
  }
744
+ /** YAML-quote a string only when needed (special chars or ambiguous flow). */
745
+ function yamlString(s) {
746
+ if (/^[A-Za-z0-9_ .-]+$/.test(s) && !/^(true|false|null|yes|no)$/i.test(s))
747
+ return s;
748
+ return JSON.stringify(s);
749
+ }
680
750
  /**
681
751
  * Pick a small set of columns for an auto-generated folder index based on
682
752
  * what frontmatter the pages in that folder actually have. The first
@@ -967,64 +1037,6 @@ function contentTypeForExt(filename) {
967
1037
  };
968
1038
  return map[ext] ?? "application/octet-stream";
969
1039
  }
970
- /**
971
- * Pre-canonicalisation migration. Earlier versions stored roles, auth_type,
972
- * and role_passwords in settings.md frontmatter; they now live in
973
- * .vaultrc.json. If we still see them in settings.md (legacy vault), copy
974
- * over what's missing in .vaultrc.json so the imminent canonicaliser doesn't
975
- * silently drop them.
976
- *
977
- * Idempotent: returns true and logs only if it actually moved something.
978
- */
979
- async function migrateLegacyAuthFromSettings(vaultPath) {
980
- const settingsPath = join(vaultPath, SETTINGS_FILE);
981
- let raw;
982
- try {
983
- raw = await readFile(settingsPath, "utf8");
984
- }
985
- catch {
986
- return false;
987
- }
988
- const fm = (matter(raw).data ?? {});
989
- const hasLegacy = "roles" in fm || "auth_type" in fm || "role_passwords" in fm;
990
- if (!hasLegacy)
991
- return false;
992
- const cfg = await loadConfig(vaultPath, {});
993
- const moved = [];
994
- // roles: only migrate if cfg is still at the default ["public"].
995
- if (Array.isArray(fm.roles)) {
996
- const list = fm.roles.filter((r) => typeof r === "string");
997
- const isDefault = cfg.roles.length === 0 || (cfg.roles.length === 1 && cfg.roles[0] === "public");
998
- if (list.length > 0 && isDefault && !arraysEqual(list, ["public"])) {
999
- cfg.roles = list;
1000
- moved.push("roles");
1001
- }
1002
- }
1003
- if (typeof fm.auth_type === "string" && cfg.authType === "password" && fm.auth_type !== "password") {
1004
- cfg.authType = fm.auth_type;
1005
- moved.push("auth_type");
1006
- }
1007
- if (fm.role_passwords && typeof fm.role_passwords === "object" && !Array.isArray(fm.role_passwords)
1008
- && Object.keys(cfg.rolePasswords).length === 0) {
1009
- const map = fm.role_passwords;
1010
- const cleaned = {};
1011
- for (const [k, v] of Object.entries(map))
1012
- if (typeof v === "string")
1013
- cleaned[k] = v;
1014
- if (Object.keys(cleaned).length > 0) {
1015
- cfg.rolePasswords = cleaned;
1016
- moved.push("role_passwords");
1017
- }
1018
- }
1019
- if (moved.length === 0)
1020
- return false;
1021
- await saveConfig(vaultPath, cfg);
1022
- console.log(` migrated ${moved.join(", ")} from settings.md → .vaultrc.json`);
1023
- return true;
1024
- }
1025
- function arraysEqual(a, b) {
1026
- return a.length === b.length && a.every((x, i) => x === b[i]);
1027
- }
1028
1040
  function extractPlainText(source, max) {
1029
1041
  return source
1030
1042
  .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "")
@@ -1043,4 +1055,30 @@ function extractPlainText(source, max) {
1043
1055
  .trim()
1044
1056
  .slice(0, max);
1045
1057
  }
1058
+ /**
1059
+ * Strip an HTML body to plain text. Used to feed the search index from
1060
+ * the rendered article (post-wikilink, post-callout-redaction) so search
1061
+ * snippets read like prose, not markdown source. Tables, code blocks,
1062
+ * and inline HTML the user wrote all collapse to their text content;
1063
+ * common entities are decoded back to characters; numeric entities are
1064
+ * also handled. We replace tags with spaces (rather than empty string)
1065
+ * so adjacent block elements don't fuse their text together.
1066
+ */
1067
+ function htmlToText(html, max) {
1068
+ return html
1069
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
1070
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
1071
+ .replace(/<[^>]+>/g, " ")
1072
+ .replace(/&nbsp;/g, " ")
1073
+ .replace(/&amp;/g, "&")
1074
+ .replace(/&lt;/g, "<")
1075
+ .replace(/&gt;/g, ">")
1076
+ .replace(/&quot;/g, '"')
1077
+ .replace(/&apos;|&#39;/g, "'")
1078
+ .replace(/&#x([0-9a-fA-F]+);/g, (_m, h) => String.fromCharCode(parseInt(h, 16)))
1079
+ .replace(/&#(\d+);/g, (_m, n) => String.fromCharCode(Number(n)))
1080
+ .replace(/\s+/g, " ")
1081
+ .trim()
1082
+ .slice(0, max);
1083
+ }
1046
1084
  //# sourceMappingURL=build.js.map