@wizzlethorpe/vaults 0.1.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/dist/api.js +42 -0
  4. package/dist/api.js.map +1 -0
  5. package/dist/auth.js +62 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/build.js +758 -0
  8. package/dist/build.js.map +1 -0
  9. package/dist/commands/build.js +23 -0
  10. package/dist/commands/build.js.map +1 -0
  11. package/dist/commands/init.js +67 -0
  12. package/dist/commands/init.js.map +1 -0
  13. package/dist/commands/password.js +74 -0
  14. package/dist/commands/password.js.map +1 -0
  15. package/dist/commands/preview.js +60 -0
  16. package/dist/commands/preview.js.map +1 -0
  17. package/dist/commands/push.js +191 -0
  18. package/dist/commands/push.js.map +1 -0
  19. package/dist/commands/role.js +122 -0
  20. package/dist/commands/role.js.map +1 -0
  21. package/dist/config.js +79 -0
  22. package/dist/config.js.map +1 -0
  23. package/dist/favicon.js +91 -0
  24. package/dist/favicon.js.map +1 -0
  25. package/dist/images.js +47 -0
  26. package/dist/images.js.map +1 -0
  27. package/dist/index.js +154 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/obsidian.js +47 -0
  30. package/dist/obsidian.js.map +1 -0
  31. package/dist/render/auth-template.js +677 -0
  32. package/dist/render/auth-template.js.map +1 -0
  33. package/dist/render/callouts.js +65 -0
  34. package/dist/render/callouts.js.map +1 -0
  35. package/dist/render/embed.js +190 -0
  36. package/dist/render/embed.js.map +1 -0
  37. package/dist/render/layout.js +414 -0
  38. package/dist/render/layout.js.map +1 -0
  39. package/dist/render/mcp-template.js +239 -0
  40. package/dist/render/mcp-template.js.map +1 -0
  41. package/dist/render/pipeline.js +59 -0
  42. package/dist/render/pipeline.js.map +1 -0
  43. package/dist/render/preview.js +81 -0
  44. package/dist/render/preview.js.map +1 -0
  45. package/dist/render/slug.js +12 -0
  46. package/dist/render/slug.js.map +1 -0
  47. package/dist/render/styles.js +383 -0
  48. package/dist/render/styles.js.map +1 -0
  49. package/dist/render/types.js +2 -0
  50. package/dist/render/types.js.map +1 -0
  51. package/dist/render/wikilink.js +55 -0
  52. package/dist/render/wikilink.js.map +1 -0
  53. package/dist/scan.js +45 -0
  54. package/dist/scan.js.map +1 -0
  55. package/dist/settings.js +157 -0
  56. package/dist/settings.js.map +1 -0
  57. package/dist/util.js +60 -0
  58. package/dist/util.js.map +1 -0
  59. package/package.json +64 -0
package/dist/build.js ADDED
@@ -0,0 +1,758 @@
1
+ import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import { relative } from "node:path";
4
+ import { dirname, join } from "node:path";
5
+ import { availableParallelism } from "node:os";
6
+ import picomatch from "picomatch";
7
+ import { scanVault } from "./scan.js";
8
+ import { compressImage, COMPRESSIBLE_EXT_RE } from "./images.js";
9
+ 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
+ import { renderMarkdown } from "./render/pipeline.js";
14
+ import { renderLayout, render404 } from "./render/layout.js";
15
+ import { slugify } from "./render/slug.js";
16
+ import { buildPreview } from "./render/preview.js";
17
+ import { DEFAULT_CSS, renderThemeOverride } from "./render/styles.js";
18
+ import { loadObsidianSnippets } from "./obsidian.js";
19
+ import { loadSettings, writeSettings, SETTINGS_FILE } from "./settings.js";
20
+ import { loadConfig, saveConfig } from "./config.js";
21
+ import matter from "gray-matter";
22
+ import { renderAuthMiddleware, LOGIN_HTML } from "./render/auth-template.js";
23
+ import { formatDuration, pMap, Progress } from "./util.js";
24
+ /**
25
+ * Output layout when there are multiple roles:
26
+ *
27
+ * <outputDir>/
28
+ * attachments/... (shared images)
29
+ * <other files>... (shared)
30
+ * styles.css, user.css (shared)
31
+ * _variants/
32
+ * <role>/
33
+ * <pages>.html
34
+ * <pages>.preview.json
35
+ * _search-index.json
36
+ *
37
+ * When there's a single role (the default `public`-only case) we collapse
38
+ * `_variants/public/...` up to the root for backwards compatibility with
39
+ * the current `vaults preview` and `vaults push` flow.
40
+ */
41
+ export async function buildSite(opts) {
42
+ const start = Date.now();
43
+ const concurrency = Math.max(2, availableParallelism());
44
+ // One-shot migration for vaults from before auth config moved to
45
+ // .vaultrc.json. If the user's settings.md still has roles/auth_type/
46
+ // role_passwords, copy them over before the canonicalizer strips them.
47
+ await migrateLegacyAuthFromSettings(opts.vaultPath);
48
+ // ── Settings (user-editable) ─────────────────────────────────────────────
49
+ const settings = await loadSettings(opts.vaultPath);
50
+ for (const w of settings.warnings)
51
+ console.warn(` ${w}`);
52
+ if (settings.exists && settings.changed) {
53
+ await writeSettings(opts.vaultPath, settings.values);
54
+ console.log(` rewrote ${SETTINGS_FILE} to canonical format`);
55
+ }
56
+ opts = {
57
+ ...opts,
58
+ vaultName: opts.vaultName === "Vault" ? settings.values.vault_name : opts.vaultName,
59
+ imageQuality: opts.imageQuality === 85 ? settings.values.image_quality : opts.imageQuality,
60
+ maxFileBytes: opts.maxFileBytes === 25 * 1024 * 1024 ? settings.values.max_file_bytes : opts.maxFileBytes,
61
+ };
62
+ // ── CLI-managed state (auth) ─────────────────────────────────────────────
63
+ const cfg = await loadConfig(opts.vaultPath, {});
64
+ const roles = cfg.roles.length > 0 ? cfg.roles : ["public"];
65
+ const allRoleSet = new Set(roles);
66
+ // Pages without a 'role:' frontmatter fall back to settings.default_role
67
+ // when set (and valid); otherwise the lowest-tier role. This lets a
68
+ // DM-by-default vault flip the polarity instead of tagging every private
69
+ // page individually.
70
+ let defaultRole = roles[0];
71
+ if (settings.values.default_role) {
72
+ if (allRoleSet.has(settings.values.default_role)) {
73
+ defaultRole = settings.values.default_role;
74
+ }
75
+ else {
76
+ console.warn(` settings.md: default_role "${settings.values.default_role}" `
77
+ + `not in configured roles [${roles.join(", ")}], using "${defaultRole}"`);
78
+ }
79
+ }
80
+ // ── Scan + filter ────────────────────────────────────────────────────────
81
+ console.log(`Scanning ${opts.vaultPath}...`);
82
+ const scanStart = Date.now();
83
+ const allFiles = await scanVault(opts.vaultPath);
84
+ const ignoreMatchers = settings.values.ignore.map((p) => picomatch(p));
85
+ const isIgnored = (path) => ignoreMatchers.some((m) => m(path));
86
+ const files = allFiles.filter((f) => f.path !== SETTINGS_FILE && !isIgnored(f.path));
87
+ const ignoredCount = allFiles.length - files.length - 1;
88
+ console.log(` found ${files.length} files in ${formatDuration(Date.now() - scanStart)}`
89
+ + (ignoredCount > 0 ? ` (${ignoredCount} ignored by patterns)` : ""));
90
+ const withinLimit = files.filter((f) => {
91
+ if (f.size > opts.maxFileBytes) {
92
+ console.warn(` skipping ${f.path} (${f.size} bytes > ${opts.maxFileBytes} limit)`);
93
+ return false;
94
+ }
95
+ return true;
96
+ });
97
+ await rm(opts.outputDir, { recursive: true, force: true });
98
+ await mkdir(opts.outputDir, { recursive: true });
99
+ const markdownFiles = withinLimit.filter((f) => /\.md$/i.test(f.path));
100
+ const imageFiles = withinLimit.filter((f) => IMAGE_EXT_RE.test(f.path));
101
+ const otherFiles = withinLimit.filter((f) => !/\.md$/i.test(f.path) && !IMAGE_EXT_RE.test(f.path));
102
+ // ── Shared content (read once, reused across roles) ─────────────────────
103
+ const sources = new Map();
104
+ await pMap(markdownFiles, concurrency, async (f) => {
105
+ sources.set(f.path, await readFile(f.absolute, "utf8"));
106
+ });
107
+ // Parse role + title per page. Pages with an unrecognised role fall back to
108
+ // the default with a warning — better than silently dropping them.
109
+ const allPageMetas = markdownFiles.map((f) => {
110
+ const src = sources.get(f.path);
111
+ const meta = parseFrontmatter(src);
112
+ let role = meta.role ?? defaultRole;
113
+ if (!allRoleSet.has(role)) {
114
+ console.warn(` ${f.path}: role "${role}" not in settings.roles, using "${defaultRole}"`);
115
+ role = defaultRole;
116
+ }
117
+ return {
118
+ path: f.path,
119
+ title: meta.title ?? extractH1(src) ?? basenameNoExt(f.path),
120
+ role,
121
+ ...(meta.aliases && meta.aliases.length > 0 ? { aliases: meta.aliases } : {}),
122
+ mtime: f.mtime,
123
+ birthtime: f.birthtime,
124
+ };
125
+ });
126
+ // ── Image compression (staged; copied per-variant later) ────────────────
127
+ // Compress once into a private staging dir under the deploy root. Each
128
+ // variant's render pass copies whichever images its visible pages
129
+ // reference. The staging dir is removed at the end so images only ship
130
+ // to the variants that need them — that's how DM-only art is kept off
131
+ // the public deploy without a separate auth gate.
132
+ const imageStagingDir = join(opts.outputDir, ".image-staging");
133
+ const imageIndex = new Map();
134
+ if (imageFiles.length > 0) {
135
+ const cacheDir = join(opts.vaultPath, ".vault-cache", "images", `q${opts.imageQuality}`);
136
+ await mkdir(cacheDir, { recursive: true });
137
+ let cacheHits = 0;
138
+ const progress = new Progress("Images");
139
+ progress.update(0, imageFiles.length);
140
+ await pMap(imageFiles, concurrency, async (f) => {
141
+ // SVGs / non-compressible images pass through; everything else gets
142
+ // recoded to webp for size. Either way they land in the staging dir.
143
+ const compressed = opts.imageQuality > 0 && COMPRESSIBLE_EXT_RE.test(f.path)
144
+ ? await compressImageCached(f, opts.imageQuality, cacheDir, () => { cacheHits++; })
145
+ : { body: await readFile(f.absolute), outputPath: f.path };
146
+ const dest = join(imageStagingDir, compressed.outputPath);
147
+ await mkdir(dirname(dest), { recursive: true });
148
+ await writeFile(dest, compressed.body);
149
+ imageIndex.set(slugify(f.path.split("/").pop()), {
150
+ sourcePath: f.path,
151
+ outputPath: compressed.outputPath,
152
+ });
153
+ }, (done, total) => progress.update(done, total));
154
+ progress.done(`${imageFiles.length} processed (${cacheHits} cached, ${imageFiles.length - cacheHits} compressed)`);
155
+ }
156
+ // ── Passthrough files (PDFs, audio, etc.) ───────────────────────────────
157
+ // Staged once, copied into every variant. We don't scan markdown to find
158
+ // out which files are referenced (links can be plain text, embedded HTML,
159
+ // or arbitrary URLs), so we ship them into every tier — the trade-off is
160
+ // that DM-only PDFs are reachable from any role's deploy. Document as a
161
+ // known limitation.
162
+ const otherStagingDir = join(opts.outputDir, ".other-staging");
163
+ if (otherFiles.length > 0) {
164
+ const progress = new Progress("Other");
165
+ progress.update(0, otherFiles.length);
166
+ await pMap(otherFiles, concurrency, async (f) => {
167
+ const dest = join(otherStagingDir, f.path);
168
+ await mkdir(dirname(dest), { recursive: true });
169
+ await copyFile(f.absolute, dest);
170
+ }, (done, total) => progress.update(done, total));
171
+ progress.done(`${otherFiles.length} copied`);
172
+ }
173
+ // Shared CSS bundle
174
+ const themeOverride = renderThemeOverride({
175
+ lightAccent: settings.values.accent_color,
176
+ darkAccent: settings.values.accent_color_dark,
177
+ });
178
+ await writeFile(join(opts.outputDir, "styles.css"), DEFAULT_CSS + themeOverride);
179
+ const userCss = await loadObsidianSnippets(opts.vaultPath);
180
+ await writeFile(join(opts.outputDir, "user.css"), userCss);
181
+ if (userCss)
182
+ console.log(` loaded user.css from .obsidian/snippets/`);
183
+ // Favicon — either user-supplied via settings.favicon, or a generated
184
+ // default with the vault's first letter on the accent colour.
185
+ try {
186
+ const favicon = await buildFavicon({
187
+ vaultPath: opts.vaultPath,
188
+ faviconPath: settings.values.favicon,
189
+ letter: (opts.vaultName || "V").trim().charAt(0).toUpperCase() || "V",
190
+ accentColor: settings.values.accent_color || "#a8201a",
191
+ });
192
+ await writeFile(join(opts.outputDir, "favicon.ico"), favicon);
193
+ }
194
+ catch (err) {
195
+ console.warn(` warning: could not generate favicon: ${err.message}`);
196
+ }
197
+ // ── Per-role variant builds ─────────────────────────────────────────────
198
+ const perRolePageCount = {};
199
+ const collapseToRoot = roles.length === 1;
200
+ for (const role of roles) {
201
+ const variantDir = collapseToRoot
202
+ ? opts.outputDir
203
+ : join(opts.outputDir, "_variants", role);
204
+ if (!collapseToRoot)
205
+ await mkdir(variantDir, { recursive: true });
206
+ // Roles up to and including this one are visible. Anything higher is
207
+ // redacted (callouts dropped, pages skipped, wikilinks broken).
208
+ const idx = roles.indexOf(role);
209
+ const visibleRoles = new Set(roles.slice(0, idx + 1));
210
+ const redactRoles = new Set(roles.slice(idx + 1));
211
+ const stats = await buildVariant({
212
+ role,
213
+ visibleRoles,
214
+ redactRoles,
215
+ variantDir,
216
+ vaultName: opts.vaultName,
217
+ allPageMetas,
218
+ sources,
219
+ imageIndex,
220
+ imageStagingDir,
221
+ otherFiles,
222
+ otherStagingDir,
223
+ settings: settings.values,
224
+ authConfigured: roles.length > 1,
225
+ concurrency,
226
+ allWarnings: opts.allWarnings,
227
+ });
228
+ perRolePageCount[role] = stats.pageCount;
229
+ if (!collapseToRoot)
230
+ console.log(` variant '${role}': ${stats.pageCount} pages`);
231
+ // Write a per-variant _manifest.json so external clients (Foundry, MCP,
232
+ // etc.) can do an incremental diff. Includes EVERY file that variant
233
+ // serves — html, md, images (as relative paths into shared root), css.
234
+ const manifest = await buildManifest(opts.outputDir, variantDir);
235
+ await writeFile(join(variantDir, "_manifest.json"), JSON.stringify(manifest));
236
+ }
237
+ // ── Pages Functions ─────────────────────────────────────────────────────
238
+ // Auth middleware ships only for multi-role builds. Single-role deploys
239
+ // are pure static and need no functions.
240
+ if (!collapseToRoot) {
241
+ const fnDir = join(opts.outputDir, "functions");
242
+ await mkdir(fnDir, { recursive: true });
243
+ const middleware = renderAuthMiddleware({
244
+ roles,
245
+ rolePasswords: cfg.rolePasswords,
246
+ });
247
+ await writeFile(join(fnDir, "_middleware.js"), middleware);
248
+ // Login page — drop in the role list (everything above the default).
249
+ const protectedRoles = roles.slice(1);
250
+ const opts_html = protectedRoles
251
+ .map((r) => `<option value="${r}">${r}</option>`)
252
+ .join("");
253
+ await writeFile(join(opts.outputDir, "login.html"), LOGIN_HTML.replace("__ROLE_OPTIONS__", opts_html));
254
+ const missing = protectedRoles.filter((r) => !cfg.rolePasswords[r]);
255
+ if (missing.length > 0) {
256
+ console.warn(` WARNING: no password set for role(s): ${missing.join(", ")}. Run 'vaults password <role>' before pushing.`);
257
+ }
258
+ }
259
+ // Drop the staging dirs — their contents have been copied into each
260
+ // variant that needs them, so they're no longer required for the deploy.
261
+ await rm(imageStagingDir, { recursive: true, force: true });
262
+ await rm(otherStagingDir, { recursive: true, force: true });
263
+ console.log(`Built in ${formatDuration(Date.now() - start)}.`);
264
+ return {
265
+ files,
266
+ withinLimit,
267
+ roles,
268
+ perRolePageCount,
269
+ imageCount: imageFiles.length,
270
+ otherCount: otherFiles.length,
271
+ };
272
+ }
273
+ async function buildVariant(a) {
274
+ // Pages this variant can see (page.role is in visibleRoles).
275
+ const visibleSources = new Map();
276
+ const visibleMetas = [];
277
+ for (const m of a.allPageMetas) {
278
+ if (!a.visibleRoles.has(m.role))
279
+ continue;
280
+ visibleMetas.push(m);
281
+ visibleSources.set(m.path, a.sources.get(m.path));
282
+ }
283
+ // Synthesize folder indexes from the visible set only.
284
+ const folderIndexes = generateFolderIndexes(visibleMetas, a.role);
285
+ for (const fi of folderIndexes) {
286
+ visibleMetas.push({ path: fi.path, title: fi.title, role: a.role });
287
+ visibleSources.set(fi.path, fi.markdown);
288
+ }
289
+ // Per-variant page index for wikilink resolution. Basename, full-path,
290
+ // and Obsidian frontmatter aliases are all keyed. Folder-index basenames
291
+ // and aliases don't overwrite earlier entries (first-write-wins) so
292
+ // ambiguous shorthands resolve to whichever page sorted first.
293
+ const pageIndex = new Map();
294
+ const markdownContent = new Map();
295
+ for (const p of visibleMetas) {
296
+ const basenameSlug = slugify(p.path.split("/").pop());
297
+ const pathSlug = slugify(p.path.replace(/\.md$/i, ""));
298
+ if (!pageIndex.has(basenameSlug))
299
+ pageIndex.set(basenameSlug, p);
300
+ pageIndex.set(pathSlug, p);
301
+ for (const alias of p.aliases ?? []) {
302
+ const aliasSlug = slugify(alias);
303
+ if (aliasSlug && !pageIndex.has(aliasSlug))
304
+ pageIndex.set(aliasSlug, p);
305
+ }
306
+ markdownContent.set(basenameSlug, visibleSources.get(p.path));
307
+ markdownContent.set(pathSlug, visibleSources.get(p.path));
308
+ }
309
+ const context = {
310
+ pages: pageIndex,
311
+ images: a.imageIndex,
312
+ markdownContent,
313
+ defaultImageWidth: a.settings.default_image_width,
314
+ redactRoles: a.redactRoles,
315
+ };
316
+ const rendered = new Map();
317
+ const progress = new Progress(`Pages (${a.role})`);
318
+ progress.update(0, visibleMetas.length);
319
+ await pMap(visibleMetas, a.concurrency, async (p) => {
320
+ const result = await renderMarkdown(visibleSources.get(p.path), context, basenameNoExt(p.path));
321
+ rendered.set(p.path, {
322
+ title: result.title,
323
+ html: result.html,
324
+ outlinks: result.outlinks,
325
+ warnings: result.warnings,
326
+ });
327
+ }, (done, total) => progress.update(done, total));
328
+ reportWarnings(a.role, rendered, a.allWarnings);
329
+ // Invert outlinks → backlinks. (Cross-role links can only point downwards
330
+ // because higher-role pages aren't in this variant's index.)
331
+ const backlinkMap = new Map();
332
+ for (const [from, info] of rendered) {
333
+ const seen = new Set();
334
+ for (const target of info.outlinks) {
335
+ if (target === from || seen.has(target))
336
+ continue;
337
+ seen.add(target);
338
+ if (!backlinkMap.has(target))
339
+ backlinkMap.set(target, new Set());
340
+ backlinkMap.get(target).add(from);
341
+ }
342
+ }
343
+ // Pass 2: write layouts + preview JSON.
344
+ await pMap(visibleMetas, a.concurrency, async (p) => {
345
+ const r = rendered.get(p.path);
346
+ const backlinkPaths = backlinkMap.get(p.path) ?? new Set();
347
+ const backlinks = visibleMetas
348
+ .filter((m) => backlinkPaths.has(m.path))
349
+ .sort((x, y) => x.title.localeCompare(y.title, undefined, { numeric: true, sensitivity: "base" }));
350
+ const html = renderLayout({
351
+ title: r.title,
352
+ pagePath: p.path,
353
+ bodyHtml: r.html,
354
+ pages: visibleMetas,
355
+ vaultName: a.vaultName,
356
+ inlineTitle: a.settings.inline_title,
357
+ defaultImageWidth: a.settings.default_image_width,
358
+ centerImages: a.settings.center_images,
359
+ backlinks,
360
+ authConfigured: a.authConfigured,
361
+ ...(p.mtime != null ? { mtime: p.mtime } : {}),
362
+ ...(p.birthtime != null ? { birthtime: p.birthtime } : {}),
363
+ });
364
+ const outputBase = p.path.replace(/\.md$/i, "");
365
+ const htmlDest = join(a.variantDir, outputBase + ".html");
366
+ await mkdir(dirname(htmlDest), { recursive: true });
367
+ await writeFile(htmlDest, html);
368
+ // .body.html holds just the rendered article content (no layout shell).
369
+ // Foundry imports this so callouts/embeds rendered by the vault's
370
+ // remark/rehype pipeline land in journals as-is, no client-side render.
371
+ await writeFile(join(a.variantDir, outputBase + ".body.html"), r.html);
372
+ const source = visibleSources.get(p.path);
373
+ const preview = await buildPreview(source, r.title);
374
+ await writeFile(join(a.variantDir, outputBase + ".preview.json"), JSON.stringify(preview));
375
+ });
376
+ progress.done(`${visibleMetas.length} rendered`);
377
+ // 404 page using the same layout shell — middleware fetches this when a
378
+ // variant rewrite returns 404 instead of leaking Pages's blank "Not found".
379
+ await writeFile(join(a.variantDir, "404.html"), render404({
380
+ pages: visibleMetas,
381
+ vaultName: a.vaultName,
382
+ inlineTitle: a.settings.inline_title,
383
+ defaultImageWidth: a.settings.default_image_width,
384
+ centerImages: a.settings.center_images,
385
+ authConfigured: a.authConfigured,
386
+ }));
387
+ // Per-variant search index.
388
+ const searchIndex = visibleMetas.map((p) => ({
389
+ title: p.title,
390
+ path: p.path,
391
+ href: "/" + p.path.replace(/\.md$/i, "").split("/").map(encodeURIComponent).join("/"),
392
+ folder: p.path.includes("/") ? p.path.split("/").slice(0, -1).join("/") : "",
393
+ text: extractPlainText(visibleSources.get(p.path) ?? "", 1500),
394
+ }));
395
+ await writeFile(join(a.variantDir, "_search-index.json"), JSON.stringify(searchIndex));
396
+ // Copy whichever images this variant's pages reference. Images live only
397
+ // under the variants that need them so guessing a DM-only image URL on
398
+ // the public wiki structurally 404s.
399
+ await copyReferencedImages(visibleSources, a.imageIndex, a.imageStagingDir, a.variantDir);
400
+ // Passthrough files (PDFs, audio, etc.) ship into every variant — the
401
+ // build doesn't scan markdown for arbitrary references, so we can't tell
402
+ // which role-restricted pages link to a given PDF. Limitation: DM-only
403
+ // data files in this category aren't role-gated.
404
+ for (const f of a.otherFiles) {
405
+ const src = join(a.otherStagingDir, f.path);
406
+ const dst = join(a.variantDir, f.path);
407
+ await mkdir(dirname(dst), { recursive: true });
408
+ try {
409
+ await copyFile(src, dst);
410
+ }
411
+ catch (err) {
412
+ console.warn(` warning: could not copy ${f.path}: ${err.message}`);
413
+ }
414
+ }
415
+ return { pageCount: visibleMetas.length };
416
+ }
417
+ const EMBED_RE = /!\[\[([^\[\]|#\n]+?)(?:\|[^\[\]#\n]*)?\]\]/g;
418
+ async function copyReferencedImages(visibleSources, imageIndex, stagingDir, variantDir) {
419
+ const refs = new Set();
420
+ for (const source of visibleSources.values()) {
421
+ for (const m of source.matchAll(EMBED_RE)) {
422
+ const name = m[1].trim();
423
+ if (!IMAGE_EXT_RE.test(name))
424
+ continue;
425
+ const image = imageIndex.get(slugify(name));
426
+ if (image)
427
+ refs.add(image.outputPath);
428
+ }
429
+ }
430
+ for (const outputPath of refs) {
431
+ const src = join(stagingDir, outputPath);
432
+ const dst = join(variantDir, outputPath);
433
+ await mkdir(dirname(dst), { recursive: true });
434
+ try {
435
+ await copyFile(src, dst);
436
+ }
437
+ catch (err) {
438
+ // Source may legitimately be missing if the file is in the index but
439
+ // wasn't compressed (e.g. quality=0 path). Surface but don't crash.
440
+ console.warn(` warning: could not copy image ${outputPath}: ${err.message}`);
441
+ }
442
+ }
443
+ }
444
+ /**
445
+ * Build synthesised index.md for any folder (including the root) that has
446
+ * pages but no existing index.md.
447
+ */
448
+ function generateFolderIndexes(existing, _role) {
449
+ const existingPaths = new Set(existing.map((p) => p.path));
450
+ const folders = new Map();
451
+ folders.set("", { folders: new Set(), pages: [] });
452
+ for (const page of existing) {
453
+ const parts = page.path.split("/");
454
+ if (parts.length === 1) {
455
+ folders.get("").pages.push(page);
456
+ continue;
457
+ }
458
+ for (let i = 0; i < parts.length - 1; i++) {
459
+ const folder = parts.slice(0, i + 1).join("/");
460
+ if (!folders.has(folder))
461
+ folders.set(folder, { folders: new Set(), pages: [] });
462
+ const parent = i === 0 ? "" : parts.slice(0, i).join("/");
463
+ folders.get(parent).folders.add(parts[i]);
464
+ }
465
+ const directParent = parts.slice(0, -1).join("/");
466
+ folders.get(directParent).pages.push(page);
467
+ }
468
+ const out = [];
469
+ for (const [folder, { folders: subfolders, pages }] of folders) {
470
+ const indexPath = folder === "" ? "index.md" : `${folder}/index.md`;
471
+ if (existingPaths.has(indexPath))
472
+ continue;
473
+ const title = folder === "" ? "" : folder.split("/").pop();
474
+ const lines = [];
475
+ if (subfolders.size > 0) {
476
+ lines.push("");
477
+ const sorted = [...subfolders].sort((x, y) => x.localeCompare(y, undefined, { numeric: true, sensitivity: "base" }));
478
+ for (const sub of sorted)
479
+ lines.push(`- [[${folder ? folder + "/" : ""}${sub}/index|${sub}]]`);
480
+ }
481
+ if (pages.length > 0) {
482
+ lines.push("");
483
+ const sorted = [...pages].sort((x, y) => x.title.localeCompare(y.title, undefined, { numeric: true, sensitivity: "base" }));
484
+ for (const p of sorted)
485
+ lines.push(`- [[${p.path.replace(/\.md$/i, "")}|${p.title}]]`);
486
+ }
487
+ if (subfolders.size === 0 && pages.length === 0)
488
+ continue;
489
+ const heading = title ? `# ${title}\n` : "";
490
+ out.push({ path: indexPath, title: title || "Home", markdown: `${heading}${lines.join("\n")}\n` });
491
+ }
492
+ return out;
493
+ }
494
+ async function compressImageCached(file, quality, cacheDir, onHit) {
495
+ const outputPath = file.path.replace(COMPRESSIBLE_EXT_RE, ".webp");
496
+ const cacheKey = `${file.hash}.webp`;
497
+ const cachePath = join(cacheDir, cacheKey);
498
+ try {
499
+ await stat(cachePath);
500
+ onHit();
501
+ return { body: await readFile(cachePath), outputPath };
502
+ }
503
+ catch { /* miss */ }
504
+ const compressed = await compressImage(file.absolute, file.path, quality);
505
+ await writeFile(cachePath, compressed.body);
506
+ return { body: compressed.body, outputPath: compressed.outputPath };
507
+ }
508
+ function parseFrontmatter(source) {
509
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
510
+ if (!block)
511
+ return {};
512
+ const fm = block[1] ?? "";
513
+ const titleMatch = /^title:\s*(.+?)\s*$/m.exec(fm);
514
+ const roleMatch = /^role:\s*(\w+)\s*$/m.exec(fm);
515
+ return {
516
+ ...(titleMatch?.[1] ? { title: titleMatch[1].replace(/^["']|["']$/g, "") } : {}),
517
+ ...(roleMatch?.[1] ? { role: roleMatch[1] } : {}),
518
+ ...({ aliases: parseAliases(fm) }),
519
+ };
520
+ }
521
+ /**
522
+ * Pull `aliases:` out of frontmatter. Supports the inline form
523
+ * (`aliases: [Foo, Bar]`) and the block form (`aliases:\n- Foo\n- Bar`,
524
+ * indented or not — Obsidian writes both shapes depending on version).
525
+ */
526
+ function parseAliases(fm) {
527
+ const inline = /^aliases:\s*\[([^\]\n]*)\]\s*$/m.exec(fm);
528
+ if (inline) {
529
+ return inline[1].split(",").map((s) => unquote(s.trim())).filter(Boolean);
530
+ }
531
+ const blockHead = /^aliases:\s*$/m.exec(fm);
532
+ if (!blockHead)
533
+ return [];
534
+ const after = fm.slice(blockHead.index + blockHead[0].length).split("\n");
535
+ const out = [];
536
+ for (const line of after) {
537
+ if (!line)
538
+ continue;
539
+ // Allow zero or more leading spaces — Obsidian sometimes writes block
540
+ // arrays without indentation, which strict YAML wouldn't accept but
541
+ // both Obsidian and gray-matter parse fine.
542
+ const item = /^\s*-\s+(.+?)\s*$/.exec(line);
543
+ if (!item)
544
+ break;
545
+ out.push(unquote(item[1]));
546
+ }
547
+ return out;
548
+ }
549
+ function unquote(s) {
550
+ return s.replace(/^["']|["']$/g, "");
551
+ }
552
+ function extractH1(source) {
553
+ const h1 = /^#\s+(.+)$/m.exec(source);
554
+ return h1?.[1] ? h1[1].trim() : null;
555
+ }
556
+ function basenameNoExt(path) {
557
+ return path.split("/").pop().replace(/\.md$/i, "");
558
+ }
559
+ /**
560
+ * Print a compact summary of render-time warnings (broken wikilinks, missing
561
+ * images, missing transclusions) for the given variant. Truncates at 20
562
+ * pages-with-issues to avoid scrolling off the screen for large vaults.
563
+ */
564
+ function reportWarnings(role, rendered, allWarnings) {
565
+ const issuesByPage = new Map();
566
+ let total = 0;
567
+ for (const [path, info] of rendered) {
568
+ if (info.warnings.length === 0)
569
+ continue;
570
+ issuesByPage.set(path, info.warnings.map((w) => ({ kind: kindLabel(w.kind), target: w.target })));
571
+ total += info.warnings.length;
572
+ }
573
+ if (total === 0)
574
+ return;
575
+ const counts = {};
576
+ for (const issues of issuesByPage.values()) {
577
+ for (const i of issues)
578
+ counts[i.kind] = (counts[i.kind] ?? 0) + 1;
579
+ }
580
+ const summary = Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(", ");
581
+ console.warn(` ⚠ ${role}: ${summary} across ${issuesByPage.size} page(s)`);
582
+ const pages = [...issuesByPage].sort((a, b) => a[0].localeCompare(b[0]));
583
+ const shown = allWarnings ? pages : pages.slice(0, 20);
584
+ for (const [path, issues] of shown) {
585
+ console.warn(` ${path}`);
586
+ const seen = new Set();
587
+ for (const i of issues) {
588
+ const key = `${i.kind}:${i.target}`;
589
+ if (seen.has(key))
590
+ continue;
591
+ seen.add(key);
592
+ console.warn(` ${i.kind}: ${i.target}`);
593
+ }
594
+ }
595
+ if (pages.length > shown.length) {
596
+ console.warn(` … and ${pages.length - shown.length} more page(s) with warnings (use --all-warnings to show)`);
597
+ }
598
+ }
599
+ function kindLabel(kind) {
600
+ switch (kind) {
601
+ case "broken-link": return "broken link";
602
+ case "missing-image": return "missing image";
603
+ case "missing-page": return "missing page";
604
+ case "missing-section": return "missing section";
605
+ default: return kind;
606
+ }
607
+ }
608
+ /**
609
+ * Walk the variant directory and produce a manifest of every file with its MD5
610
+ * hash + size + mtime + content type. Shared assets (anything OUTSIDE the
611
+ * variant dir but inside the deploy root) are listed too — clients use a
612
+ * single manifest to diff the entire site, not just the role-specific bits.
613
+ */
614
+ async function buildManifest(rootDir, variantDir) {
615
+ const files = [];
616
+ const seen = new Set();
617
+ // Variant-specific files: use pathBase=variantDir so paths come out as
618
+ // "index.html", not "_variants/<role>/index.html". This matches the public
619
+ // URL the client uses; the auth middleware does the variant rewrite.
620
+ await walkAndIndex(variantDir, variantDir, files, seen);
621
+ // Shared assets under the deploy root (attachments, css). Skip the variant
622
+ // tree itself and anything inside `functions/` (Function code isn't served).
623
+ if (rootDir !== variantDir) {
624
+ await walkAndIndex(rootDir, rootDir, files, seen, [
625
+ "_variants", "functions", ".image-staging", ".other-staging",
626
+ ]);
627
+ }
628
+ files.sort((a, b) => a.path.localeCompare(b.path));
629
+ return { files };
630
+ }
631
+ async function walkAndIndex(dir, pathBase, out, seen, skipDirNames = []) {
632
+ const entries = await readdir(dir, { withFileTypes: true });
633
+ for (const ent of entries) {
634
+ if (ent.name === "_manifest.json")
635
+ continue;
636
+ const abs = join(dir, ent.name);
637
+ if (ent.isDirectory()) {
638
+ if (skipDirNames.includes(ent.name))
639
+ continue;
640
+ await walkAndIndex(abs, pathBase, out, seen, skipDirNames);
641
+ continue;
642
+ }
643
+ if (!ent.isFile())
644
+ continue;
645
+ const path = relative(pathBase, abs).split(/[/\\]/).join("/");
646
+ if (seen.has(path))
647
+ continue;
648
+ seen.add(path);
649
+ const body = await readFile(abs);
650
+ const info = await stat(abs);
651
+ out.push({
652
+ path,
653
+ hash: createHash("md5").update(body).digest("hex"),
654
+ size: info.size,
655
+ mtime: Math.floor(info.mtimeMs / 1000),
656
+ content_type: contentTypeForExt(ent.name),
657
+ });
658
+ }
659
+ }
660
+ function contentTypeForExt(filename) {
661
+ const ext = filename.split(".").pop()?.toLowerCase() ?? "";
662
+ const map = {
663
+ html: "text/html; charset=utf-8",
664
+ md: "text/markdown; charset=utf-8",
665
+ json: "application/json",
666
+ css: "text/css; charset=utf-8",
667
+ js: "application/javascript; charset=utf-8",
668
+ png: "image/png",
669
+ jpg: "image/jpeg",
670
+ jpeg: "image/jpeg",
671
+ webp: "image/webp",
672
+ gif: "image/gif",
673
+ svg: "image/svg+xml",
674
+ avif: "image/avif",
675
+ pdf: "application/pdf",
676
+ mp3: "audio/mpeg",
677
+ wav: "audio/wav",
678
+ ogg: "audio/ogg",
679
+ };
680
+ return map[ext] ?? "application/octet-stream";
681
+ }
682
+ /**
683
+ * Pre-canonicalisation migration. Earlier versions stored roles, auth_type,
684
+ * and role_passwords in settings.md frontmatter; they now live in
685
+ * .vaultrc.json. If we still see them in settings.md (legacy vault), copy
686
+ * over what's missing in .vaultrc.json so the imminent canonicaliser doesn't
687
+ * silently drop them.
688
+ *
689
+ * Idempotent: returns true and logs only if it actually moved something.
690
+ */
691
+ async function migrateLegacyAuthFromSettings(vaultPath) {
692
+ const settingsPath = join(vaultPath, SETTINGS_FILE);
693
+ let raw;
694
+ try {
695
+ raw = await readFile(settingsPath, "utf8");
696
+ }
697
+ catch {
698
+ return false;
699
+ }
700
+ const fm = (matter(raw).data ?? {});
701
+ const hasLegacy = "roles" in fm || "auth_type" in fm || "role_passwords" in fm;
702
+ if (!hasLegacy)
703
+ return false;
704
+ const cfg = await loadConfig(vaultPath, {});
705
+ const moved = [];
706
+ // roles: only migrate if cfg is still at the default ["public"].
707
+ if (Array.isArray(fm.roles)) {
708
+ const list = fm.roles.filter((r) => typeof r === "string");
709
+ const isDefault = cfg.roles.length === 0 || (cfg.roles.length === 1 && cfg.roles[0] === "public");
710
+ if (list.length > 0 && isDefault && !arraysEqual(list, ["public"])) {
711
+ cfg.roles = list;
712
+ moved.push("roles");
713
+ }
714
+ }
715
+ if (typeof fm.auth_type === "string" && cfg.authType === "password" && fm.auth_type !== "password") {
716
+ cfg.authType = fm.auth_type;
717
+ moved.push("auth_type");
718
+ }
719
+ if (fm.role_passwords && typeof fm.role_passwords === "object" && !Array.isArray(fm.role_passwords)
720
+ && Object.keys(cfg.rolePasswords).length === 0) {
721
+ const map = fm.role_passwords;
722
+ const cleaned = {};
723
+ for (const [k, v] of Object.entries(map))
724
+ if (typeof v === "string")
725
+ cleaned[k] = v;
726
+ if (Object.keys(cleaned).length > 0) {
727
+ cfg.rolePasswords = cleaned;
728
+ moved.push("role_passwords");
729
+ }
730
+ }
731
+ if (moved.length === 0)
732
+ return false;
733
+ await saveConfig(vaultPath, cfg);
734
+ console.log(` migrated ${moved.join(", ")} from settings.md → .vaultrc.json`);
735
+ return true;
736
+ }
737
+ function arraysEqual(a, b) {
738
+ return a.length === b.length && a.every((x, i) => x === b[i]);
739
+ }
740
+ function extractPlainText(source, max) {
741
+ return source
742
+ .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "")
743
+ .replace(/%%[\s\S]*?%%/g, "")
744
+ .replace(/```[\s\S]*?```/g, "")
745
+ .replace(/!\[\[[^\]]+\]\]/g, "")
746
+ .replace(/\[\[([^\]|#]+)(?:[#|][^\]]+)?\]\]/g, "$1")
747
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
748
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
749
+ .replace(/`([^`]+)`/g, "$1")
750
+ .replace(/[*_~]+([^*_~\n]+)[*_~]+/g, "$1")
751
+ .replace(/^>\s?\[![^\]]+\][+-]?\s*(.*)$/gm, "$1")
752
+ .replace(/^>\s?/gm, "")
753
+ .replace(/^#{1,6}\s+/gm, "")
754
+ .replace(/\s+/g, " ")
755
+ .trim()
756
+ .slice(0, max);
757
+ }
758
+ //# sourceMappingURL=build.js.map