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