@wizzlethorpe/vaults 0.4.0 → 0.6.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.
- package/dist/build.js +253 -45
- package/dist/build.js.map +1 -1
- package/dist/commands/init.js +6 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/patreon.js +413 -0
- package/dist/commands/patreon.js.map +1 -0
- package/dist/commands/preview.js +7 -0
- package/dist/commands/preview.js.map +1 -1
- package/dist/commands/push.js +7 -0
- package/dist/commands/push.js.map +1 -1
- package/dist/config.js +79 -4
- package/dist/config.js.map +1 -1
- package/dist/dotenv.js +112 -0
- package/dist/dotenv.js.map +1 -0
- package/dist/favicon.js +8 -31
- package/dist/favicon.js.map +1 -1
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -1
- package/dist/render/auth-template.js +434 -37
- package/dist/render/auth-template.js.map +1 -1
- package/dist/render/bases.js +64 -5
- package/dist/render/bases.js.map +1 -1
- package/dist/render/callouts.js +4 -1
- package/dist/render/callouts.js.map +1 -1
- package/dist/render/cover.js +41 -0
- package/dist/render/cover.js.map +1 -0
- package/dist/render/layout.js +190 -31
- package/dist/render/layout.js.map +1 -1
- package/dist/render/patreon-match.js +42 -0
- package/dist/render/patreon-match.js.map +1 -0
- package/dist/render/pipeline.js +11 -5
- package/dist/render/pipeline.js.map +1 -1
- package/dist/render/styles.js +277 -41
- package/dist/render/styles.js.map +1 -1
- package/dist/sensitive.js +60 -0
- package/dist/sensitive.js.map +1 -0
- package/dist/settings.js +15 -0
- package/dist/settings.js.map +1 -1
- package/package.json +1 -1
package/dist/build.js
CHANGED
|
@@ -10,10 +10,17 @@ import { buildFavicon } from "./favicon.js";
|
|
|
10
10
|
// Any image format that can be referenced via ![[name.ext]]; superset of
|
|
11
11
|
// COMPRESSIBLE_EXT_RE since SVGs/GIFs ship as-is rather than being recoded.
|
|
12
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;
|
|
13
19
|
import { renderMarkdown } from "./render/pipeline.js";
|
|
14
20
|
import { renderLayout, render404 } from "./render/layout.js";
|
|
15
21
|
import { slugify } from "./render/slug.js";
|
|
16
22
|
import { buildPreview } from "./render/preview.js";
|
|
23
|
+
import { resolvePageImage } from "./render/cover.js";
|
|
17
24
|
import { DEFAULT_CSS, renderThemeOverride } from "./render/styles.js";
|
|
18
25
|
import { loadObsidianSnippets } from "./obsidian.js";
|
|
19
26
|
import { loadSettings, writeSettings, SETTINGS_FILE } from "./settings.js";
|
|
@@ -101,7 +108,37 @@ export async function buildSite(opts) {
|
|
|
101
108
|
// .base files are consumed at build time (rendered into HTML where embedded)
|
|
102
109
|
// and never shipped to the deploy.
|
|
103
110
|
const baseFiles = withinLimit.filter((f) => /\.base$/i.test(f.path));
|
|
104
|
-
const
|
|
111
|
+
const passthroughFiles = withinLimit.filter((f) => PASSTHROUGH_EXT_RE.test(f.path)
|
|
112
|
+
&& !IMAGE_EXT_RE.test(f.path)
|
|
113
|
+
&& !/\.md$|\.base$/i.test(f.path));
|
|
114
|
+
// Anything else: skipped by default so role-gated content can't leak
|
|
115
|
+
// through a stray file. include_unknown_files = true folds them into
|
|
116
|
+
// the passthrough pool (still reference-gated). The user-facing
|
|
117
|
+
// warning lists exactly which paths got dropped so unintentional
|
|
118
|
+
// omissions surface immediately.
|
|
119
|
+
const unknownFiles = withinLimit.filter((f) => !/\.md$|\.base$/i.test(f.path)
|
|
120
|
+
&& !IMAGE_EXT_RE.test(f.path)
|
|
121
|
+
&& !PASSTHROUGH_EXT_RE.test(f.path));
|
|
122
|
+
const includeUnknown = settings.values.include_unknown_files;
|
|
123
|
+
if (unknownFiles.length > 0) {
|
|
124
|
+
if (includeUnknown) {
|
|
125
|
+
console.log(` including ${unknownFiles.length} unknown-extension file(s) (include_unknown_files=true)`);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
console.warn(` skipping ${unknownFiles.length} file(s) with unrecognized extensions:`);
|
|
129
|
+
const shown = unknownFiles.slice(0, 10);
|
|
130
|
+
for (const f of shown)
|
|
131
|
+
console.warn(` ${f.path}`);
|
|
132
|
+
if (unknownFiles.length > shown.length) {
|
|
133
|
+
console.warn(` … and ${unknownFiles.length - shown.length} more`);
|
|
134
|
+
}
|
|
135
|
+
console.warn(` Set 'include_unknown_files: true' in settings.md to ship them.`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Effective passthrough list: recognised media plus (optionally) unknowns.
|
|
139
|
+
const stagedPassthroughs = includeUnknown
|
|
140
|
+
? [...passthroughFiles, ...unknownFiles]
|
|
141
|
+
: passthroughFiles;
|
|
105
142
|
// ── Shared content (read once, reused across roles) ─────────────────────
|
|
106
143
|
const sources = new Map();
|
|
107
144
|
await pMap(markdownFiles, concurrency, async (f) => {
|
|
@@ -167,26 +204,36 @@ export async function buildSite(opts) {
|
|
|
167
204
|
}, (done, total) => progress.update(done, total));
|
|
168
205
|
progress.done(`${imageFiles.length} processed (${cacheHits} cached, ${imageFiles.length - cacheHits} compressed)`);
|
|
169
206
|
}
|
|
170
|
-
// ── Passthrough files (
|
|
171
|
-
// Staged once
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// known limitation.
|
|
207
|
+
// ── Passthrough files (audio, video, PDF, epub) ────────────────────────
|
|
208
|
+
// Staged once and copied into a variant only when a visible page in that
|
|
209
|
+
// variant references the file by basename or relative path. Same gating
|
|
210
|
+
// story as images: a DM-only audio cue can't ride along into the public
|
|
211
|
+
// deploy because no public-tier source mentions it.
|
|
176
212
|
const otherStagingDir = join(opts.outputDir, ".other-staging");
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
progress
|
|
180
|
-
|
|
213
|
+
const passthroughIndex = new Map();
|
|
214
|
+
if (stagedPassthroughs.length > 0) {
|
|
215
|
+
const progress = new Progress("Passthroughs");
|
|
216
|
+
progress.update(0, stagedPassthroughs.length);
|
|
217
|
+
await pMap(stagedPassthroughs, concurrency, async (f) => {
|
|
181
218
|
const dest = join(otherStagingDir, f.path);
|
|
182
219
|
await mkdir(dirname(dest), { recursive: true });
|
|
183
220
|
await copyFile(f.absolute, dest);
|
|
221
|
+
passthroughIndex.set(slugify(f.path.split("/").pop()), {
|
|
222
|
+
sourcePath: f.path,
|
|
223
|
+
outputPath: f.path,
|
|
224
|
+
});
|
|
184
225
|
}, (done, total) => progress.update(done, total));
|
|
185
|
-
progress.done(`${
|
|
226
|
+
progress.done(`${stagedPassthroughs.length} staged`);
|
|
186
227
|
}
|
|
187
|
-
// Shared CSS bundle
|
|
228
|
+
// Shared CSS bundle.
|
|
229
|
+
//
|
|
230
|
+
// Every file written to outputDir ROOT (rather than into _variants/<role>/)
|
|
231
|
+
// must also appear in `isSharedAsset` over in render/auth-template.ts —
|
|
232
|
+
// otherwise the variant rewrite traps it and it 404s for everyone. If you
|
|
233
|
+
// add a new root-level file here, add it there too.
|
|
188
234
|
const themeOverride = renderThemeOverride({
|
|
189
235
|
lightAccent: settings.values.accent_color,
|
|
236
|
+
lightBg: settings.values.bg_color,
|
|
190
237
|
});
|
|
191
238
|
await writeFile(join(opts.outputDir, "styles.css"), DEFAULT_CSS + themeOverride);
|
|
192
239
|
const userCss = await loadObsidianSnippets(opts.vaultPath);
|
|
@@ -194,12 +241,13 @@ export async function buildSite(opts) {
|
|
|
194
241
|
if (userCss)
|
|
195
242
|
console.log(` loaded user.css from .obsidian/snippets/`);
|
|
196
243
|
// Favicon; either user-supplied via settings.favicon, or a generated
|
|
197
|
-
// default with the vault's first letter on the
|
|
244
|
+
// default with the vault's first letter in accent on the theme background.
|
|
198
245
|
try {
|
|
199
246
|
const favicon = await buildFavicon({
|
|
200
247
|
vaultPath: opts.vaultPath,
|
|
201
248
|
faviconPath: settings.values.favicon,
|
|
202
249
|
letter: (opts.vaultName || "V").trim().charAt(0).toUpperCase() || "V",
|
|
250
|
+
backgroundColor: settings.values.bg_color || "#f4ecd8",
|
|
203
251
|
accentColor: settings.values.accent_color || "#a8201a",
|
|
204
252
|
});
|
|
205
253
|
await writeFile(join(opts.outputDir, "favicon.ico"), favicon);
|
|
@@ -207,6 +255,18 @@ export async function buildSite(opts) {
|
|
|
207
255
|
catch (err) {
|
|
208
256
|
console.warn(` warning: could not generate favicon: ${err.message}`);
|
|
209
257
|
}
|
|
258
|
+
// ── Resolve per-page cover images ───────────────────────────────────────
|
|
259
|
+
// Computed once against the final imageIndex so OG meta tags, Bases card
|
|
260
|
+
// covers, hover previews, and Foundry actor/item reskin all resolve to the
|
|
261
|
+
// same URL. settings.auto_image flips body-fallback discovery on/off.
|
|
262
|
+
for (const meta of allPageMetas) {
|
|
263
|
+
const src = sources.get(meta.path);
|
|
264
|
+
if (!src)
|
|
265
|
+
continue;
|
|
266
|
+
const cover = resolvePageImage(src, meta.frontmatter, imageIndex, settings.values.auto_image);
|
|
267
|
+
if (cover)
|
|
268
|
+
meta.coverImage = cover;
|
|
269
|
+
}
|
|
210
270
|
// ── Per-role variant builds ─────────────────────────────────────────────
|
|
211
271
|
const perRolePageCount = {};
|
|
212
272
|
const collapseToRoot = roles.length === 1;
|
|
@@ -232,8 +292,8 @@ export async function buildSite(opts) {
|
|
|
232
292
|
baseSources,
|
|
233
293
|
imageIndex,
|
|
234
294
|
imageStagingDir,
|
|
235
|
-
|
|
236
|
-
otherStagingDir,
|
|
295
|
+
passthroughIndex,
|
|
296
|
+
passthroughStagingDir: otherStagingDir,
|
|
237
297
|
settings: settings.values,
|
|
238
298
|
authConfigured: roles.length > 1,
|
|
239
299
|
concurrency,
|
|
@@ -245,7 +305,9 @@ export async function buildSite(opts) {
|
|
|
245
305
|
// Write a per-variant _manifest.json so external clients (Foundry, MCP,
|
|
246
306
|
// etc.) can do an incremental diff. Includes EVERY file that variant
|
|
247
307
|
// serves; html, md, images (as relative paths into shared root), css.
|
|
248
|
-
|
|
308
|
+
// bodyMeta carries per-page Foundry reskin metadata; folded into each
|
|
309
|
+
// 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);
|
|
249
311
|
await writeFile(join(variantDir, "_manifest.json"), JSON.stringify(manifest));
|
|
250
312
|
}
|
|
251
313
|
// ── Pages Functions ─────────────────────────────────────────────────────
|
|
@@ -254,9 +316,21 @@ export async function buildSite(opts) {
|
|
|
254
316
|
if (!collapseToRoot) {
|
|
255
317
|
const fnDir = join(opts.outputDir, "functions");
|
|
256
318
|
await mkdir(fnDir, { recursive: true });
|
|
319
|
+
// Patreon overlay rides only when configured AND at least one role is
|
|
320
|
+
// mapped to a tier. clientSecret stays out of the bundle — it lives in
|
|
321
|
+
// the Wrangler secret PATREON_CLIENT_SECRET, read from env in the
|
|
322
|
+
// Function. The CLI uploads it on every push.
|
|
323
|
+
const patreonForFn = cfg.patreon && cfg.patreon.tiers && Object.keys(cfg.patreon.tiers).length > 0
|
|
324
|
+
? {
|
|
325
|
+
clientId: cfg.patreon.clientId,
|
|
326
|
+
campaignId: cfg.patreon.campaignId,
|
|
327
|
+
tiers: cfg.patreon.tiers,
|
|
328
|
+
}
|
|
329
|
+
: null;
|
|
257
330
|
const middleware = renderAuthMiddleware({
|
|
258
331
|
roles,
|
|
259
332
|
rolePasswords: cfg.rolePasswords,
|
|
333
|
+
...(patreonForFn ? { patreon: patreonForFn } : {}),
|
|
260
334
|
});
|
|
261
335
|
await writeFile(join(fnDir, "_middleware.js"), middleware);
|
|
262
336
|
// Login page; drop in the role list (everything above the default).
|
|
@@ -264,7 +338,12 @@ export async function buildSite(opts) {
|
|
|
264
338
|
const opts_html = protectedRoles
|
|
265
339
|
.map((r) => `<option value="${r}">${r}</option>`)
|
|
266
340
|
.join("");
|
|
267
|
-
|
|
341
|
+
const patreonRolesAttr = patreonForFn
|
|
342
|
+
? ` data-patreon-roles="${Object.keys(patreonForFn.tiers).join(",")}"`
|
|
343
|
+
: "";
|
|
344
|
+
await writeFile(join(opts.outputDir, "login.html"), LOGIN_HTML
|
|
345
|
+
.replace("__ROLE_OPTIONS__", opts_html)
|
|
346
|
+
.replace("__PATREON_ROLES_ATTR__", patreonRolesAttr));
|
|
268
347
|
const missing = protectedRoles.filter((r) => !cfg.rolePasswords[r]);
|
|
269
348
|
if (missing.length > 0) {
|
|
270
349
|
console.warn(` WARNING: no password set for role(s): ${missing.join(", ")}. Run 'vaults password <role>' before pushing.`);
|
|
@@ -281,7 +360,7 @@ export async function buildSite(opts) {
|
|
|
281
360
|
roles,
|
|
282
361
|
perRolePageCount,
|
|
283
362
|
imageCount: imageFiles.length,
|
|
284
|
-
otherCount:
|
|
363
|
+
otherCount: stagedPassthroughs.length,
|
|
285
364
|
};
|
|
286
365
|
}
|
|
287
366
|
async function buildVariant(a) {
|
|
@@ -356,6 +435,7 @@ async function buildVariant(a) {
|
|
|
356
435
|
}
|
|
357
436
|
}
|
|
358
437
|
// Pass 2: write layouts + preview JSON.
|
|
438
|
+
const bodyMeta = new Map();
|
|
359
439
|
await pMap(visibleMetas, a.concurrency, async (p) => {
|
|
360
440
|
const r = rendered.get(p.path);
|
|
361
441
|
const backlinkPaths = backlinkMap.get(p.path) ?? new Set();
|
|
@@ -375,6 +455,8 @@ async function buildVariant(a) {
|
|
|
375
455
|
authConfigured: a.authConfigured,
|
|
376
456
|
...(p.mtime != null ? { mtime: p.mtime } : {}),
|
|
377
457
|
...(p.birthtime != null ? { birthtime: p.birthtime } : {}),
|
|
458
|
+
...(p.coverImage ? { coverImage: p.coverImage } : {}),
|
|
459
|
+
...(extractFrontmatterBlock(visibleSources.get(p.path)) ?? {}),
|
|
378
460
|
});
|
|
379
461
|
const outputBase = p.path.replace(/\.md$/i, "");
|
|
380
462
|
const htmlDest = join(a.variantDir, outputBase + ".html");
|
|
@@ -383,7 +465,9 @@ async function buildVariant(a) {
|
|
|
383
465
|
// .body.html holds just the rendered article content (no layout shell).
|
|
384
466
|
// Foundry imports this so callouts/embeds rendered by the vault's
|
|
385
467
|
// remark/rehype pipeline land in journals as-is, no client-side render.
|
|
386
|
-
|
|
468
|
+
const bodyPath = outputBase + ".body.html";
|
|
469
|
+
await writeFile(join(a.variantDir, bodyPath), r.html);
|
|
470
|
+
bodyMeta.set(bodyPath, collectBodyMeta(p));
|
|
387
471
|
const source = visibleSources.get(p.path);
|
|
388
472
|
const preview = await buildPreview(source, r.title);
|
|
389
473
|
await writeFile(join(a.variantDir, outputBase + ".preview.json"), JSON.stringify(preview));
|
|
@@ -410,27 +494,41 @@ async function buildVariant(a) {
|
|
|
410
494
|
await writeFile(join(a.variantDir, "_search-index.json"), JSON.stringify(searchIndex));
|
|
411
495
|
// Copy whichever images this variant's pages reference. Images live only
|
|
412
496
|
// under the variants that need them so guessing a DM-only image URL on
|
|
413
|
-
// the public wiki structurally 404s.
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
497
|
+
// the public wiki structurally 404s. coverImage feeds in here too so
|
|
498
|
+
// images named via `image:` frontmatter (no body embed) still ship.
|
|
499
|
+
await copyReferencedImages(visibleSources, visibleMetas, a.imageIndex, a.imageStagingDir, a.variantDir);
|
|
500
|
+
// Passthrough files (audio/video/pdf/epub) follow the same gating
|
|
501
|
+
// contract as images: ship only into variants whose visible pages
|
|
502
|
+
// reference the file. A DM-only audio cue can't ride along into the
|
|
503
|
+
// public deploy because no public-tier source mentions it.
|
|
504
|
+
await copyReferencedPassthroughs(visibleSources, a.passthroughIndex, a.passthroughStagingDir, a.variantDir);
|
|
505
|
+
return { pageCount: visibleMetas.length, bodyMeta };
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Build the per-body manifest meta from a page's frontmatter + resolved
|
|
509
|
+
* cover image. `role` always lands so the Foundry side can apply the
|
|
510
|
+
* dmRole permission gate; the foundry_base / image fields are conditional.
|
|
511
|
+
*/
|
|
512
|
+
function collectBodyMeta(p) {
|
|
513
|
+
const fm = p.frontmatter ?? {};
|
|
514
|
+
const out = { role: p.role };
|
|
515
|
+
const basename = p.path.split("/").pop().replace(/\.md$/i, "");
|
|
516
|
+
if (p.title && p.title !== basename)
|
|
517
|
+
out.title = p.title;
|
|
518
|
+
const fb = fm["foundry_base"];
|
|
519
|
+
if (typeof fb === "string" && fb.trim().length > 0) {
|
|
520
|
+
out.foundry_base = fb.trim();
|
|
521
|
+
}
|
|
522
|
+
const fo = fm["foundry"];
|
|
523
|
+
if (fo && typeof fo === "object" && !Array.isArray(fo)) {
|
|
524
|
+
out.foundry = fo;
|
|
429
525
|
}
|
|
430
|
-
|
|
526
|
+
if (p.coverImage)
|
|
527
|
+
out.image = p.coverImage;
|
|
528
|
+
return out;
|
|
431
529
|
}
|
|
432
530
|
const EMBED_RE = /!\[\[([^\[\]|#\n]+?)(?:\|[^\[\]#\n]*)?\]\]/g;
|
|
433
|
-
async function copyReferencedImages(visibleSources, imageIndex, stagingDir, variantDir) {
|
|
531
|
+
async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, stagingDir, variantDir) {
|
|
434
532
|
const refs = new Set();
|
|
435
533
|
for (const source of visibleSources.values()) {
|
|
436
534
|
for (const m of source.matchAll(EMBED_RE)) {
|
|
@@ -442,6 +540,23 @@ async function copyReferencedImages(visibleSources, imageIndex, stagingDir, vari
|
|
|
442
540
|
refs.add(image.outputPath);
|
|
443
541
|
}
|
|
444
542
|
}
|
|
543
|
+
// Pages can name their cover via `image:` frontmatter alone (no body embed);
|
|
544
|
+
// pull those in too. coverImage was resolved to the served URL upstream, so
|
|
545
|
+
// strip the leading slash + decode to get back to the staging-relative path.
|
|
546
|
+
for (const p of visibleMetas) {
|
|
547
|
+
if (!p.coverImage)
|
|
548
|
+
continue;
|
|
549
|
+
if (/^https?:\/\//i.test(p.coverImage))
|
|
550
|
+
continue;
|
|
551
|
+
let outputPath;
|
|
552
|
+
try {
|
|
553
|
+
outputPath = decodeURIComponent(p.coverImage.replace(/^\//, ""));
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
refs.add(outputPath);
|
|
559
|
+
}
|
|
445
560
|
for (const outputPath of refs) {
|
|
446
561
|
const src = join(stagingDir, outputPath);
|
|
447
562
|
const dst = join(variantDir, outputPath);
|
|
@@ -456,6 +571,54 @@ async function copyReferencedImages(visibleSources, imageIndex, stagingDir, vari
|
|
|
456
571
|
}
|
|
457
572
|
}
|
|
458
573
|
}
|
|
574
|
+
// `[label](path/to/file.ext)` style markdown link. Captures the URL part.
|
|
575
|
+
// `\.[a-z0-9]+` requires an extension; we don't want to scoop up plain
|
|
576
|
+
// internal page links (e.g. `(href)` without an extension).
|
|
577
|
+
const MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+\.[a-z0-9]+)(?:\s+["'][^"']*["'])?\)/gi;
|
|
578
|
+
// `[[file.ext]]` and `![[file.ext]]` — Obsidian-flavoured wikilinks/embeds.
|
|
579
|
+
const WIKI_LINK_RE = /!?\[\[([^\[\]|#\n]+\.[a-z0-9]+)(?:\|[^\[\]#\n]*)?(?:#[^\[\]\n]*)?\]\]/gi;
|
|
580
|
+
/**
|
|
581
|
+
* Per-variant reference scan for passthrough files. A file lands in this
|
|
582
|
+
* variant's deploy only if a visible page mentions it — same gating story
|
|
583
|
+
* as images. Match patterns cover Obsidian embeds (`![[file.pdf]]`),
|
|
584
|
+
* Obsidian wikilinks (`[[file.pdf]]`), and standard markdown links
|
|
585
|
+
* (`[label](path/file.pdf)`). Anything not matched is dropped — that's
|
|
586
|
+
* the whole point of the change; a stray DM-only audio cue stays in the
|
|
587
|
+
* dm variant only.
|
|
588
|
+
*/
|
|
589
|
+
async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stagingDir, variantDir) {
|
|
590
|
+
if (passthroughIndex.size === 0)
|
|
591
|
+
return;
|
|
592
|
+
const refs = new Set();
|
|
593
|
+
for (const source of visibleSources.values()) {
|
|
594
|
+
for (const m of source.matchAll(WIKI_LINK_RE)) {
|
|
595
|
+
const name = m[1].trim();
|
|
596
|
+
const entry = passthroughIndex.get(slugify(name.split("/").pop()));
|
|
597
|
+
if (entry)
|
|
598
|
+
refs.add(entry.outputPath);
|
|
599
|
+
}
|
|
600
|
+
for (const m of source.matchAll(MD_LINK_RE)) {
|
|
601
|
+
const name = m[1].trim();
|
|
602
|
+
// Skip http(s) links and anchor-only refs.
|
|
603
|
+
if (/^(https?:|mailto:|#)/i.test(name))
|
|
604
|
+
continue;
|
|
605
|
+
const entry = passthroughIndex.get(slugify(name.split("/").pop()));
|
|
606
|
+
if (entry)
|
|
607
|
+
refs.add(entry.outputPath);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
for (const outputPath of refs) {
|
|
611
|
+
const src = join(stagingDir, outputPath);
|
|
612
|
+
const dst = join(variantDir, outputPath);
|
|
613
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
614
|
+
try {
|
|
615
|
+
await copyFile(src, dst);
|
|
616
|
+
}
|
|
617
|
+
catch (err) {
|
|
618
|
+
console.warn(` warning: could not copy ${outputPath}: ${err.message}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
459
622
|
/**
|
|
460
623
|
* Build synthesised index.md for any folder (including the root) that has
|
|
461
624
|
* pages but no existing index.md.
|
|
@@ -565,6 +728,20 @@ async function compressImageCached(file, quality, cacheDir, onHit) {
|
|
|
565
728
|
await writeFile(cachePath, compressed.body);
|
|
566
729
|
return { body: compressed.body, outputPath: compressed.outputPath };
|
|
567
730
|
}
|
|
731
|
+
/**
|
|
732
|
+
* Pull the raw `---\n...\n---` frontmatter block out of a markdown source so
|
|
733
|
+
* the layout can show it verbatim (preserving the user's exact formatting,
|
|
734
|
+
* comments, and key order). Returns the inner-block text or null when the
|
|
735
|
+
* page has no frontmatter. The shape `{ frontmatterYaml }` is so callers can
|
|
736
|
+
* spread it directly into the LayoutInput; missing frontmatter contributes
|
|
737
|
+
* nothing to the layout.
|
|
738
|
+
*/
|
|
739
|
+
function extractFrontmatterBlock(source) {
|
|
740
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
|
|
741
|
+
if (!m || !m[1] || !m[1].trim())
|
|
742
|
+
return null;
|
|
743
|
+
return { frontmatterYaml: m[1] };
|
|
744
|
+
}
|
|
568
745
|
function parseFrontmatter(source) {
|
|
569
746
|
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
|
|
570
747
|
if (!block)
|
|
@@ -691,24 +868,34 @@ function kindLabel(kind) {
|
|
|
691
868
|
* variant dir but inside the deploy root) are listed too; clients use a
|
|
692
869
|
* single manifest to diff the entire site, not just the role-specific bits.
|
|
693
870
|
*/
|
|
694
|
-
async function buildManifest(rootDir, variantDir) {
|
|
871
|
+
async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles, vaultName) {
|
|
695
872
|
const files = [];
|
|
696
873
|
const seen = new Set();
|
|
697
874
|
// Variant-specific files: use pathBase=variantDir so paths come out as
|
|
698
875
|
// "index.html", not "_variants/<role>/index.html". This matches the public
|
|
699
876
|
// URL the client uses; the auth middleware does the variant rewrite.
|
|
700
|
-
await walkAndIndex(variantDir, variantDir, files, seen);
|
|
877
|
+
await walkAndIndex(variantDir, variantDir, files, seen, [], bodyMeta);
|
|
701
878
|
// Shared assets under the deploy root (attachments, css). Skip the variant
|
|
702
879
|
// tree itself and anything inside `functions/` (Function code isn't served).
|
|
703
880
|
if (rootDir !== variantDir) {
|
|
704
881
|
await walkAndIndex(rootDir, rootDir, files, seen, [
|
|
705
882
|
"_variants", "functions", ".image-staging", ".other-staging",
|
|
706
|
-
]);
|
|
883
|
+
], bodyMeta);
|
|
707
884
|
}
|
|
708
885
|
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
709
|
-
|
|
886
|
+
// `auth.required` lets clients (Foundry, MCP) tell up-front whether the
|
|
887
|
+
// deploy has middleware. Single-role builds collapse to a pure-static
|
|
888
|
+
// deploy with no /_batch / /_connect endpoints — clients fall back to
|
|
889
|
+
// direct CDN GETs in that case. `auth.roles` ships the role order
|
|
890
|
+
// (lowest→highest) so clients can rank a page's tier against a chosen
|
|
891
|
+
// cutoff (e.g. Foundry's per-vault dmRole).
|
|
892
|
+
// `name` is the vault's display name (settings.md `vault_name`); clients
|
|
893
|
+
// like the Foundry module use it as the default label + root folder when
|
|
894
|
+
// a user adds the vault, so they get something readable instead of a
|
|
895
|
+
// host-derived slug.
|
|
896
|
+
return { name: vaultName, auth: { required: authRequired, roles }, files };
|
|
710
897
|
}
|
|
711
|
-
async function walkAndIndex(dir, pathBase, out, seen, skipDirNames
|
|
898
|
+
async function walkAndIndex(dir, pathBase, out, seen, skipDirNames, bodyMeta) {
|
|
712
899
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
713
900
|
for (const ent of entries) {
|
|
714
901
|
if (ent.name === "_manifest.json")
|
|
@@ -717,7 +904,7 @@ async function walkAndIndex(dir, pathBase, out, seen, skipDirNames = []) {
|
|
|
717
904
|
if (ent.isDirectory()) {
|
|
718
905
|
if (skipDirNames.includes(ent.name))
|
|
719
906
|
continue;
|
|
720
|
-
await walkAndIndex(abs, pathBase, out, seen, skipDirNames);
|
|
907
|
+
await walkAndIndex(abs, pathBase, out, seen, skipDirNames, bodyMeta);
|
|
721
908
|
continue;
|
|
722
909
|
}
|
|
723
910
|
if (!ent.isFile())
|
|
@@ -728,15 +915,36 @@ async function walkAndIndex(dir, pathBase, out, seen, skipDirNames = []) {
|
|
|
728
915
|
seen.add(path);
|
|
729
916
|
const body = await readFile(abs);
|
|
730
917
|
const info = await stat(abs);
|
|
918
|
+
const meta = bodyMeta.get(path);
|
|
919
|
+
// Fold meta JSON into the hash so meta-only edits (e.g. a foundry_base
|
|
920
|
+
// tweak with no body change) still bump the row hash and trigger sync.
|
|
921
|
+
const hasher = createHash("md5").update(body);
|
|
922
|
+
if (meta)
|
|
923
|
+
hasher.update("\x00meta:" + stableStringify(meta));
|
|
731
924
|
out.push({
|
|
732
925
|
path,
|
|
733
|
-
hash:
|
|
926
|
+
hash: hasher.digest("hex"),
|
|
734
927
|
size: info.size,
|
|
735
928
|
mtime: Math.floor(info.mtimeMs / 1000),
|
|
736
929
|
content_type: contentTypeForExt(ent.name),
|
|
930
|
+
...(meta ? { meta } : {}),
|
|
737
931
|
});
|
|
738
932
|
}
|
|
739
933
|
}
|
|
934
|
+
/**
|
|
935
|
+
* Deterministic JSON encoder. Object keys are sorted recursively so two
|
|
936
|
+
* frontmatters with the same shape but different key order produce the same
|
|
937
|
+
* hash; otherwise the manifest would churn on every YAML reformat.
|
|
938
|
+
*/
|
|
939
|
+
function stableStringify(value) {
|
|
940
|
+
if (value === null || typeof value !== "object")
|
|
941
|
+
return JSON.stringify(value);
|
|
942
|
+
if (Array.isArray(value))
|
|
943
|
+
return "[" + value.map(stableStringify).join(",") + "]";
|
|
944
|
+
const obj = value;
|
|
945
|
+
const keys = Object.keys(obj).sort();
|
|
946
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
|
|
947
|
+
}
|
|
740
948
|
function contentTypeForExt(filename) {
|
|
741
949
|
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
742
950
|
const map = {
|