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