@wizzlethorpe/vaults 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -17
- package/dist/build.js +433 -181
- package/dist/build.js.map +1 -1
- package/dist/commands/build.js +4 -4
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/init.js +13 -10
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/password.js +3 -1
- package/dist/commands/password.js.map +1 -1
- package/dist/commands/patreon.js +30 -20
- package/dist/commands/patreon.js.map +1 -1
- package/dist/commands/preview.js +10 -8
- package/dist/commands/preview.js.map +1 -1
- package/dist/commands/push.js +11 -9
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/role.js +5 -0
- package/dist/commands/role.js.map +1 -1
- package/dist/config.js +30 -15
- 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 +37 -4
- package/dist/index.js.map +1 -1
- package/dist/migrate/0.6-legacy-auth-settings.js +96 -0
- package/dist/migrate/0.6-legacy-auth-settings.js.map +1 -0
- package/dist/migrate/0.7-vaults-dir.js +70 -0
- package/dist/migrate/0.7-vaults-dir.js.map +1 -0
- package/dist/migrate/registry.js +6 -0
- package/dist/migrate/registry.js.map +1 -0
- package/dist/migrate/run.js +38 -0
- package/dist/migrate/run.js.map +1 -0
- package/dist/migrate/types.js +8 -0
- package/dist/migrate/types.js.map +1 -0
- package/dist/paths.js +66 -0
- package/dist/paths.js.map +1 -0
- package/dist/render/auth-template.js +23 -141
- 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/footer.js +37 -0
- package/dist/render/footer.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 +123 -0
- package/dist/render/handlers/assets.js.map +1 -0
- package/dist/render/handlers/builtin/dice.js +78 -0
- package/dist/render/handlers/builtin/dice.js.map +1 -0
- 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 +83 -0
- package/dist/render/handlers/builtin/fm.js.map +1 -0
- package/dist/render/handlers/builtin/index.js +10 -0
- package/dist/render/handlers/builtin/index.js.map +1 -0
- 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 +491 -0
- package/dist/render/handlers/builtin/statblock.js.map +1 -0
- package/dist/render/handlers/dispatch.js +182 -0
- package/dist/render/handlers/dispatch.js.map +1 -0
- package/dist/render/handlers/loader.js +90 -0
- package/dist/render/handlers/loader.js.map +1 -0
- package/dist/render/handlers/types.js +60 -0
- package/dist/render/handlers/types.js.map +1 -0
- package/dist/render/image-srcs.js +42 -0
- package/dist/render/image-srcs.js.map +1 -0
- package/dist/render/layout.js +62 -9
- package/dist/render/layout.js.map +1 -1
- package/dist/render/pipeline.js +37 -8
- package/dist/render/pipeline.js.map +1 -1
- package/dist/render/preview.js +10 -5
- 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 +118 -11
- 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 +25 -4
- package/dist/settings.js.map +1 -1
- package/dist/version.js +36 -0
- package/dist/version.js.map +1 -0
- package/package.json +12 -12
- package/dist/api.js +0 -42
- package/dist/api.js.map +0 -1
- package/dist/render/mcp-template.js +0 -239
- package/dist/render/mcp-template.js.map +0 -1
package/dist/build.js
CHANGED
|
@@ -1,32 +1,34 @@
|
|
|
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";
|
|
24
19
|
import { DEFAULT_CSS, renderThemeOverride } from "./render/styles.js";
|
|
25
20
|
import { loadObsidianSnippets } from "./obsidian.js";
|
|
26
21
|
import { loadSettings, writeSettings, SETTINGS_FILE } from "./settings.js";
|
|
27
|
-
import { loadConfig
|
|
22
|
+
import { loadConfig } from "./config.js";
|
|
28
23
|
import matter from "gray-matter";
|
|
29
24
|
import { renderAuthMiddleware, LOGIN_HTML } from "./render/auth-template.js";
|
|
25
|
+
import { renderFooterHtml } from "./render/footer.js";
|
|
26
|
+
import { buildRegistry } from "./render/handlers/types.js";
|
|
27
|
+
import { loadUserHandlers } from "./render/handlers/loader.js";
|
|
28
|
+
import { BUILTIN_HANDLERS } from "./render/handlers/builtin/index.js";
|
|
29
|
+
import { bundleHandlerAssets } from "./render/handlers/assets.js";
|
|
30
|
+
import { runMigrations } from "./migrate/run.js";
|
|
31
|
+
import { cacheDir } from "./paths.js";
|
|
30
32
|
import { formatDuration, pMap, Progress } from "./util.js";
|
|
31
33
|
/**
|
|
32
34
|
* Output layout when there are multiple roles:
|
|
@@ -41,17 +43,16 @@ import { formatDuration, pMap, Progress } from "./util.js";
|
|
|
41
43
|
* <pages>.preview.json
|
|
42
44
|
* _search-index.json
|
|
43
45
|
*
|
|
44
|
-
*
|
|
45
|
-
* `_variants/public/...` up to the root
|
|
46
|
-
* 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.
|
|
47
48
|
*/
|
|
48
49
|
export async function buildSite(opts) {
|
|
49
50
|
const start = Date.now();
|
|
50
51
|
const concurrency = Math.max(2, availableParallelism());
|
|
51
|
-
//
|
|
52
|
-
// .
|
|
53
|
-
//
|
|
54
|
-
await
|
|
52
|
+
// Run any pending schema / layout migrations before reading anything
|
|
53
|
+
// else. The framework is idempotent: already-migrated vaults pay only
|
|
54
|
+
// the cost of a few stat() calls. See cli/src/migrate/.
|
|
55
|
+
await runMigrations(opts.vaultPath);
|
|
55
56
|
// ── Settings (user-editable) ─────────────────────────────────────────────
|
|
56
57
|
const settings = await loadSettings(opts.vaultPath);
|
|
57
58
|
for (const w of settings.warnings)
|
|
@@ -66,6 +67,26 @@ export async function buildSite(opts) {
|
|
|
66
67
|
imageQuality: opts.imageQuality === 85 ? settings.values.image_quality : opts.imageQuality,
|
|
67
68
|
maxFileBytes: opts.maxFileBytes === 25 * 1024 * 1024 ? settings.values.max_file_bytes : opts.maxFileBytes,
|
|
68
69
|
};
|
|
70
|
+
// ── Custom handlers ──────────────────────────────────────────────────────
|
|
71
|
+
// Built-ins ship with the CLI; user handlers live in `.vaults/handlers/`
|
|
72
|
+
// and can override built-in names (last-registered wins). One registry
|
|
73
|
+
// is built once and shared across every variant render.
|
|
74
|
+
const userHandlers = await loadUserHandlers(opts.vaultPath);
|
|
75
|
+
const handlerRegistry = buildRegistry(BUILTIN_HANDLERS, userHandlers.map((h) => h.handler));
|
|
76
|
+
if (userHandlers.length > 0) {
|
|
77
|
+
console.log(` loaded ${userHandlers.length} custom handler(s) from .vaults/handlers/`);
|
|
78
|
+
}
|
|
79
|
+
// Concatenate browser-side assets declared by built-in and user handlers
|
|
80
|
+
// into a single _handlers.js / _handlers.css emitted at the deploy root.
|
|
81
|
+
// Each unique source is included once, regardless of invocation count.
|
|
82
|
+
// Two independent flags so a deploy with only-JS or only-CSS doesn't
|
|
83
|
+
// reference a file that wasn't written.
|
|
84
|
+
const handlerAssets = await bundleHandlerAssets(userHandlers, BUILTIN_HANDLERS, opts.vaultPath);
|
|
85
|
+
const hasHandlerJs = handlerAssets.js.length > 0;
|
|
86
|
+
const hasHandlerCss = handlerAssets.css.length > 0;
|
|
87
|
+
// Footer markdown rendered once per build; the resulting HTML is
|
|
88
|
+
// embedded verbatim in every page's layout. Empty string = no footer.
|
|
89
|
+
const footerHtml = await renderFooterHtml(settings.values.footer);
|
|
69
90
|
// ── CLI-managed state (auth) ─────────────────────────────────────────────
|
|
70
91
|
const cfg = await loadConfig(opts.vaultPath, {});
|
|
71
92
|
const roles = cfg.roles.length > 0 ? cfg.roles : ["public"];
|
|
@@ -101,8 +122,15 @@ export async function buildSite(opts) {
|
|
|
101
122
|
}
|
|
102
123
|
return true;
|
|
103
124
|
});
|
|
104
|
-
|
|
105
|
-
|
|
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 };
|
|
106
134
|
const markdownFiles = withinLimit.filter((f) => /\.md$/i.test(f.path));
|
|
107
135
|
const imageFiles = withinLimit.filter((f) => IMAGE_EXT_RE.test(f.path));
|
|
108
136
|
// .base files are consumed at build time (rendered into HTML where embedded)
|
|
@@ -151,6 +179,13 @@ export async function buildSite(opts) {
|
|
|
151
179
|
const basename = f.path.split("/").pop().replace(/\.base$/i, "");
|
|
152
180
|
baseSources.set(slugify(basename), await readFile(f.absolute, "utf8"));
|
|
153
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
|
+
}
|
|
154
189
|
// Parse role + title per page. Pages with an unrecognised role fall back
|
|
155
190
|
// to the default with a warning; better than silently dropping them. We
|
|
156
191
|
// also stash the full frontmatter on each PageMeta so the Bases plugin
|
|
@@ -158,7 +193,7 @@ export async function buildSite(opts) {
|
|
|
158
193
|
const allPageMetas = markdownFiles.map((f) => {
|
|
159
194
|
const src = sources.get(f.path);
|
|
160
195
|
const meta = parseFrontmatter(src);
|
|
161
|
-
const fullFm =
|
|
196
|
+
const fullFm = parsedSources.get(f.path).data;
|
|
162
197
|
let role = meta.role ?? defaultRole;
|
|
163
198
|
if (!allRoleSet.has(role)) {
|
|
164
199
|
console.warn(` ${f.path}: role "${role}" not in settings.roles, using "${defaultRole}"`);
|
|
@@ -183,8 +218,8 @@ export async function buildSite(opts) {
|
|
|
183
218
|
const imageStagingDir = join(opts.outputDir, ".image-staging");
|
|
184
219
|
const imageIndex = new Map();
|
|
185
220
|
if (imageFiles.length > 0) {
|
|
186
|
-
const
|
|
187
|
-
await mkdir(
|
|
221
|
+
const cacheImageDir = join(cacheDir(opts.vaultPath), "images", `q${opts.imageQuality}`);
|
|
222
|
+
await mkdir(cacheImageDir, { recursive: true });
|
|
188
223
|
let cacheHits = 0;
|
|
189
224
|
const progress = new Progress("Images");
|
|
190
225
|
progress.update(0, imageFiles.length);
|
|
@@ -192,7 +227,7 @@ export async function buildSite(opts) {
|
|
|
192
227
|
// SVGs / non-compressible images pass through; everything else gets
|
|
193
228
|
// recoded to webp for size. Either way they land in the staging dir.
|
|
194
229
|
const compressed = opts.imageQuality > 0 && COMPRESSIBLE_EXT_RE.test(f.path)
|
|
195
|
-
? await compressImageCached(f, opts.imageQuality,
|
|
230
|
+
? await compressImageCached(f, opts.imageQuality, cacheImageDir, () => { cacheHits++; })
|
|
196
231
|
: { body: await readFile(f.absolute), outputPath: f.path };
|
|
197
232
|
const dest = join(imageStagingDir, compressed.outputPath);
|
|
198
233
|
await mkdir(dirname(dest), { recursive: true });
|
|
@@ -234,12 +269,30 @@ export async function buildSite(opts) {
|
|
|
234
269
|
const themeOverride = renderThemeOverride({
|
|
235
270
|
lightAccent: settings.values.accent_color,
|
|
236
271
|
lightBg: settings.values.bg_color,
|
|
272
|
+
darkAccent: settings.values.accent_color_dark,
|
|
273
|
+
darkBg: settings.values.bg_color_dark,
|
|
237
274
|
});
|
|
238
275
|
await writeFile(join(opts.outputDir, "styles.css"), DEFAULT_CSS + themeOverride);
|
|
239
276
|
const userCss = await loadObsidianSnippets(opts.vaultPath);
|
|
240
277
|
await writeFile(join(opts.outputDir, "user.css"), userCss);
|
|
241
278
|
if (userCss)
|
|
242
279
|
console.log(` loaded user.css from .obsidian/snippets/`);
|
|
280
|
+
// Browser-side handler assets (built-in + user) concatenated into a
|
|
281
|
+
// single deploy-root JS and CSS file. Skipped entirely if no handler
|
|
282
|
+
// declared any assets (purely declarative handlers stay overhead-free).
|
|
283
|
+
if (hasHandlerJs)
|
|
284
|
+
await writeFile(join(opts.outputDir, "_handlers.js"), handlerAssets.js);
|
|
285
|
+
if (hasHandlerCss)
|
|
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.
|
|
243
296
|
// Favicon; either user-supplied via settings.favicon, or a generated
|
|
244
297
|
// default with the vault's first letter in accent on the theme background.
|
|
245
298
|
try {
|
|
@@ -259,11 +312,19 @@ export async function buildSite(opts) {
|
|
|
259
312
|
// Computed once against the final imageIndex so OG meta tags, Bases card
|
|
260
313
|
// covers, hover previews, and Foundry actor/item reskin all resolve to the
|
|
261
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);
|
|
262
322
|
for (const meta of allPageMetas) {
|
|
263
323
|
const src = sources.get(meta.path);
|
|
264
324
|
if (!src)
|
|
265
325
|
continue;
|
|
266
|
-
const
|
|
326
|
+
const stripped = stripRoleGatedCallouts(src, allRoleTypes);
|
|
327
|
+
const cover = resolvePageImage(stripped, meta.frontmatter, imageIndex, settings.values.auto_image);
|
|
267
328
|
if (cover)
|
|
268
329
|
meta.coverImage = cover;
|
|
269
330
|
}
|
|
@@ -287,8 +348,10 @@ export async function buildSite(opts) {
|
|
|
287
348
|
redactRoles,
|
|
288
349
|
variantDir,
|
|
289
350
|
vaultName: opts.vaultName,
|
|
351
|
+
vaultPath: opts.vaultPath,
|
|
290
352
|
allPageMetas,
|
|
291
353
|
sources,
|
|
354
|
+
parsedSources,
|
|
292
355
|
baseSources,
|
|
293
356
|
imageIndex,
|
|
294
357
|
imageStagingDir,
|
|
@@ -296,18 +359,45 @@ export async function buildSite(opts) {
|
|
|
296
359
|
passthroughStagingDir: otherStagingDir,
|
|
297
360
|
settings: settings.values,
|
|
298
361
|
authConfigured: roles.length > 1,
|
|
362
|
+
handlerRegistry,
|
|
363
|
+
hasHandlerJs,
|
|
364
|
+
hasHandlerCss,
|
|
365
|
+
footerHtml,
|
|
299
366
|
concurrency,
|
|
300
367
|
allWarnings: opts.allWarnings,
|
|
301
368
|
});
|
|
302
369
|
perRolePageCount[role] = stats.pageCount;
|
|
303
370
|
if (!collapseToRoot)
|
|
304
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
|
+
}
|
|
305
390
|
// Write a per-variant _manifest.json so external clients (Foundry, MCP,
|
|
306
391
|
// etc.) can do an incremental diff. Includes EVERY file that variant
|
|
307
392
|
// serves; html, md, images (as relative paths into shared root), css.
|
|
308
393
|
// bodyMeta carries per-page Foundry reskin metadata; folded into each
|
|
309
394
|
// body row's hash so meta-only changes trigger a re-sync.
|
|
310
|
-
const manifest = await buildManifest(opts.outputDir, variantDir, stats.bodyMeta, !collapseToRoot, roles, opts.vaultName
|
|
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
|
+
});
|
|
311
401
|
await writeFile(join(variantDir, "_manifest.json"), JSON.stringify(manifest));
|
|
312
402
|
}
|
|
313
403
|
// ── Pages Functions ─────────────────────────────────────────────────────
|
|
@@ -320,11 +410,12 @@ export async function buildSite(opts) {
|
|
|
320
410
|
// mapped to a tier. clientSecret stays out of the bundle — it lives in
|
|
321
411
|
// the Wrangler secret PATREON_CLIENT_SECRET, read from env in the
|
|
322
412
|
// Function. The CLI uploads it on every push.
|
|
323
|
-
const
|
|
413
|
+
const patreon = cfg.oauth?.patreon;
|
|
414
|
+
const patreonForFn = patreon && patreon.tiers && Object.keys(patreon.tiers).length > 0
|
|
324
415
|
? {
|
|
325
|
-
clientId:
|
|
326
|
-
campaignId:
|
|
327
|
-
tiers:
|
|
416
|
+
clientId: patreon.clientId,
|
|
417
|
+
campaignId: patreon.campaignId,
|
|
418
|
+
tiers: patreon.tiers,
|
|
328
419
|
}
|
|
329
420
|
: null;
|
|
330
421
|
const middleware = renderAuthMiddleware({
|
|
@@ -353,6 +444,12 @@ export async function buildSite(opts) {
|
|
|
353
444
|
// variant that needs them, so they're no longer required for the deploy.
|
|
354
445
|
await rm(imageStagingDir, { recursive: true, force: true });
|
|
355
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);
|
|
356
453
|
console.log(`Built in ${formatDuration(Date.now() - start)}.`);
|
|
357
454
|
return {
|
|
358
455
|
files,
|
|
@@ -371,10 +468,18 @@ async function buildVariant(a) {
|
|
|
371
468
|
if (!a.visibleRoles.has(m.role))
|
|
372
469
|
continue;
|
|
373
470
|
visibleMetas.push(m);
|
|
374
|
-
|
|
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));
|
|
375
480
|
}
|
|
376
481
|
// Synthesize folder indexes from the visible set only.
|
|
377
|
-
const folderIndexes = generateFolderIndexes(visibleMetas, a.role);
|
|
482
|
+
const folderIndexes = generateFolderIndexes(visibleMetas, a.role, a.settings.inline_title);
|
|
378
483
|
for (const fi of folderIndexes) {
|
|
379
484
|
visibleMetas.push({ path: fi.path, title: fi.title, role: a.role });
|
|
380
485
|
visibleSources.set(fi.path, fi.markdown);
|
|
@@ -399,19 +504,26 @@ async function buildVariant(a) {
|
|
|
399
504
|
markdownContent.set(basenameSlug, visibleSources.get(p.path));
|
|
400
505
|
markdownContent.set(pathSlug, visibleSources.get(p.path));
|
|
401
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);
|
|
402
511
|
const context = {
|
|
403
512
|
pages: pageIndex,
|
|
404
513
|
images: a.imageIndex,
|
|
514
|
+
passthroughs: a.passthroughIndex,
|
|
405
515
|
markdownContent,
|
|
406
516
|
bases: a.baseSources,
|
|
407
517
|
defaultImageWidth: a.settings.default_image_width,
|
|
408
518
|
redactRoles: a.redactRoles,
|
|
519
|
+
handlers: a.handlerRegistry,
|
|
520
|
+
outlinksByPath,
|
|
409
521
|
};
|
|
410
522
|
const rendered = new Map();
|
|
411
523
|
const progress = new Progress(`Pages (${a.role})`);
|
|
412
524
|
progress.update(0, visibleMetas.length);
|
|
413
525
|
await pMap(visibleMetas, a.concurrency, async (p) => {
|
|
414
|
-
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));
|
|
415
527
|
rendered.set(p.path, {
|
|
416
528
|
title: result.title,
|
|
417
529
|
html: result.html,
|
|
@@ -453,6 +565,10 @@ async function buildVariant(a) {
|
|
|
453
565
|
centerImages: a.settings.center_images,
|
|
454
566
|
backlinks,
|
|
455
567
|
authConfigured: a.authConfigured,
|
|
568
|
+
hasHandlerJs: a.hasHandlerJs,
|
|
569
|
+
hasHandlerCss: a.hasHandlerCss,
|
|
570
|
+
footerHtml: a.footerHtml,
|
|
571
|
+
theme: themeOf(a.settings.theme),
|
|
456
572
|
...(p.mtime != null ? { mtime: p.mtime } : {}),
|
|
457
573
|
...(p.birthtime != null ? { birthtime: p.birthtime } : {}),
|
|
458
574
|
...(p.coverImage ? { coverImage: p.coverImage } : {}),
|
|
@@ -467,7 +583,7 @@ async function buildVariant(a) {
|
|
|
467
583
|
// remark/rehype pipeline land in journals as-is, no client-side render.
|
|
468
584
|
const bodyPath = outputBase + ".body.html";
|
|
469
585
|
await writeFile(join(a.variantDir, bodyPath), r.html);
|
|
470
|
-
bodyMeta.set(bodyPath, collectBodyMeta(p));
|
|
586
|
+
bodyMeta.set(bodyPath, await collectBodyMeta(p, a.vaultPath));
|
|
471
587
|
const source = visibleSources.get(p.path);
|
|
472
588
|
const preview = await buildPreview(source, r.title);
|
|
473
589
|
await writeFile(join(a.variantDir, outputBase + ".preview.json"), JSON.stringify(preview));
|
|
@@ -482,6 +598,10 @@ async function buildVariant(a) {
|
|
|
482
598
|
defaultImageWidth: a.settings.default_image_width,
|
|
483
599
|
centerImages: a.settings.center_images,
|
|
484
600
|
authConfigured: a.authConfigured,
|
|
601
|
+
hasHandlerJs: a.hasHandlerJs,
|
|
602
|
+
hasHandlerCss: a.hasHandlerCss,
|
|
603
|
+
footerHtml: a.footerHtml,
|
|
604
|
+
theme: themeOf(a.settings.theme),
|
|
485
605
|
}));
|
|
486
606
|
// Per-variant search index. `text` is the page's RENDERED HTML body
|
|
487
607
|
// collapsed to plain text (tags stripped, entities decoded), so search
|
|
@@ -504,32 +624,101 @@ async function buildVariant(a) {
|
|
|
504
624
|
// contract as images: ship only into variants whose visible pages
|
|
505
625
|
// reference the file. A DM-only audio cue can't ride along into the
|
|
506
626
|
// public deploy because no public-tier source mentions it.
|
|
507
|
-
await copyReferencedPassthroughs(visibleSources, a.passthroughIndex, a.passthroughStagingDir, a.variantDir);
|
|
627
|
+
await copyReferencedPassthroughs(visibleSources, visibleMetas, a.passthroughIndex, a.passthroughStagingDir, a.variantDir);
|
|
508
628
|
return { pageCount: visibleMetas.length, bodyMeta };
|
|
509
629
|
}
|
|
510
630
|
/**
|
|
511
631
|
* Build the per-body manifest meta from a page's frontmatter + resolved
|
|
512
632
|
* cover image. `role` always lands so the Foundry side can apply the
|
|
513
|
-
* dmRole permission gate; the
|
|
633
|
+
* dmRole permission gate; the foundry / image fields are conditional.
|
|
634
|
+
*
|
|
635
|
+
* Frontmatter shape forwarded to clients:
|
|
636
|
+
* foundry:
|
|
637
|
+
* base: <UUID> | <Type>[:<subtype>] # required for instantiation
|
|
638
|
+
* embed: false # default true
|
|
639
|
+
* data: { … deep-merged into the doc }
|
|
514
640
|
*/
|
|
515
|
-
function collectBodyMeta(p) {
|
|
641
|
+
async function collectBodyMeta(p, vaultPath) {
|
|
516
642
|
const fm = p.frontmatter ?? {};
|
|
517
643
|
const out = { role: p.role };
|
|
518
644
|
const basename = p.path.split("/").pop().replace(/\.md$/i, "");
|
|
519
645
|
if (p.title && p.title !== basename)
|
|
520
646
|
out.title = p.title;
|
|
521
|
-
const fb = fm["foundry_base"];
|
|
522
|
-
if (typeof fb === "string" && fb.trim().length > 0) {
|
|
523
|
-
out.foundry_base = fb.trim();
|
|
524
|
-
}
|
|
525
647
|
const fo = fm["foundry"];
|
|
526
648
|
if (fo && typeof fo === "object" && !Array.isArray(fo)) {
|
|
527
|
-
|
|
649
|
+
const block = {};
|
|
650
|
+
const base = fo["base"];
|
|
651
|
+
if (typeof base === "string" && base.trim().length > 0)
|
|
652
|
+
block.base = base.trim();
|
|
653
|
+
const embed = fo["embed"];
|
|
654
|
+
if (typeof embed === "boolean")
|
|
655
|
+
block.embed = embed;
|
|
656
|
+
const data = fo["data"];
|
|
657
|
+
if (data && typeof data === "object" && !Array.isArray(data))
|
|
658
|
+
block.data = data;
|
|
659
|
+
// foundry.id: an explicit Foundry document id for this page. When set,
|
|
660
|
+
// overrides the SHA1-derived id used for both the JournalEntryPage and
|
|
661
|
+
// (if foundry.base is present) the instantiated derived doc. Lets users
|
|
662
|
+
// hardcode UUIDs that other Foundry-side code (macros, scene flags,
|
|
663
|
+
// module integrations) needs to reference. Foundry ids are 16 chars from
|
|
664
|
+
// [A-Za-z0-9]; a malformed value is dropped with a warning rather than
|
|
665
|
+
// failing the build.
|
|
666
|
+
const idVal = fo["id"];
|
|
667
|
+
if (typeof idVal === "string") {
|
|
668
|
+
const trimmed = idVal.trim();
|
|
669
|
+
if (FOUNDRY_ID_RE.test(trimmed))
|
|
670
|
+
block.id = trimmed;
|
|
671
|
+
else if (trimmed.length > 0) {
|
|
672
|
+
console.warn(` ${p.path}: foundry.id "${trimmed}" is not a valid Foundry id (16 chars [A-Za-z0-9]); ignoring`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// foundry.data_json: vault-relative path to a JSON file. Read + parse
|
|
676
|
+
// at build time and inline into the meta as `data_json`. The Foundry
|
|
677
|
+
// module deep-merges it onto the base doc BEFORE foundry.data, so a
|
|
678
|
+
// user can layer hand-tuned overrides on top of an exported sheet.
|
|
679
|
+
// Folding the parsed object into meta means the body-row hash already
|
|
680
|
+
// changes when the JSON content does — no separate change-detection.
|
|
681
|
+
const dataJsonPath = fo["data_json"];
|
|
682
|
+
if (typeof dataJsonPath === "string" && dataJsonPath.trim().length > 0) {
|
|
683
|
+
const parsed = await loadDataJson(vaultPath, dataJsonPath.trim(), p.path);
|
|
684
|
+
if (parsed !== null)
|
|
685
|
+
block.data_json = parsed;
|
|
686
|
+
}
|
|
687
|
+
if (Object.keys(block).length > 0)
|
|
688
|
+
out.foundry = block;
|
|
528
689
|
}
|
|
529
690
|
if (p.coverImage)
|
|
530
691
|
out.image = p.coverImage;
|
|
531
692
|
return out;
|
|
532
693
|
}
|
|
694
|
+
/** Read + parse a vault-relative JSON file referenced by `foundry.data_json`.
|
|
695
|
+
* Warns on missing / unparseable file and returns null so the page renders
|
|
696
|
+
* without the overlay rather than failing the build. */
|
|
697
|
+
async function loadDataJson(vaultPath, relPath, pagePath) {
|
|
698
|
+
const abs = join(vaultPath, relPath);
|
|
699
|
+
try {
|
|
700
|
+
const raw = await readFile(abs, "utf8");
|
|
701
|
+
return JSON.parse(raw);
|
|
702
|
+
}
|
|
703
|
+
catch (err) {
|
|
704
|
+
const code = err.code;
|
|
705
|
+
if (code === "ENOENT") {
|
|
706
|
+
console.warn(` ${pagePath}: foundry.data_json "${relPath}" not found, skipping`);
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
console.warn(` ${pagePath}: foundry.data_json "${relPath}" failed to parse: ${err.message}`);
|
|
710
|
+
}
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
/** Foundry document ids: exactly 16 chars from [A-Za-z0-9]. Validated when
|
|
715
|
+
* authors set `foundry.id` to override the SHA1-derived default. */
|
|
716
|
+
const FOUNDRY_ID_RE = /^[A-Za-z0-9]{16}$/;
|
|
717
|
+
/** Coerce settings.theme to the layout's narrowed union, defaulting to
|
|
718
|
+
* "auto" for any unrecognised value rather than failing the build. */
|
|
719
|
+
function themeOf(s) {
|
|
720
|
+
return s === "light" || s === "dark" ? s : "auto";
|
|
721
|
+
}
|
|
533
722
|
const EMBED_RE = /!\[\[([^\[\]|#\n]+?)(?:\|[^\[\]#\n]*)?\]\]/g;
|
|
534
723
|
async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, stagingDir, variantDir) {
|
|
535
724
|
const refs = new Set();
|
|
@@ -546,19 +735,27 @@ async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, st
|
|
|
546
735
|
// Pages can name their cover via `image:` frontmatter alone (no body embed);
|
|
547
736
|
// pull those in too. coverImage was resolved to the served URL upstream, so
|
|
548
737
|
// strip the leading slash + decode to get back to the staging-relative path.
|
|
738
|
+
// `@vault/PATH` references inside any frontmatter string field also gate
|
|
739
|
+
// an asset into this variant — common for Scene background.src / Playlist
|
|
740
|
+
// sound.path that point at vault-shipped media. Page-role gating still
|
|
741
|
+
// applies because we only walk visibleMetas (= pages this variant can see).
|
|
549
742
|
for (const p of visibleMetas) {
|
|
550
|
-
if (
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
try {
|
|
556
|
-
outputPath = decodeURIComponent(p.coverImage.replace(/^\//, ""));
|
|
743
|
+
if (p.coverImage && !/^https?:\/\//i.test(p.coverImage)) {
|
|
744
|
+
try {
|
|
745
|
+
refs.add(decodeURIComponent(p.coverImage.replace(/^\//, "")));
|
|
746
|
+
}
|
|
747
|
+
catch { /* malformed coverImage URL — ignore */ }
|
|
557
748
|
}
|
|
558
|
-
|
|
559
|
-
|
|
749
|
+
if (p.frontmatter) {
|
|
750
|
+
forEachString(p.frontmatter, (s) => {
|
|
751
|
+
const path = vaultRefPath(s);
|
|
752
|
+
if (path && IMAGE_EXT_RE.test(path)) {
|
|
753
|
+
const image = imageIndex.get(slugify(path.split("/").pop()));
|
|
754
|
+
if (image)
|
|
755
|
+
refs.add(image.outputPath);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
560
758
|
}
|
|
561
|
-
refs.add(outputPath);
|
|
562
759
|
}
|
|
563
760
|
for (const outputPath of refs) {
|
|
564
761
|
const src = join(stagingDir, outputPath);
|
|
@@ -580,6 +777,73 @@ async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, st
|
|
|
580
777
|
const MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+\.[a-z0-9]+)(?:\s+["'][^"']*["'])?\)/gi;
|
|
581
778
|
// `[[file.ext]]` and `![[file.ext]]` — Obsidian-flavoured wikilinks/embeds.
|
|
582
779
|
const WIKI_LINK_RE = /!?\[\[([^\[\]|#\n]+\.[a-z0-9]+)(?:\|[^\[\]#\n]*)?(?:#[^\[\]\n]*)?\]\]/gi;
|
|
780
|
+
// `> [!type]…` opens a callout; the rest of the contiguous blockquote (lines
|
|
781
|
+
// starting with `>`, blank line ends) is its body. Used to strip role-gated
|
|
782
|
+
// callouts from the source before any downstream pass sees it.
|
|
783
|
+
const CALLOUT_HEAD_RE = /^>\s*\[!(\w+)\]/;
|
|
784
|
+
/**
|
|
785
|
+
* Drop callout blocks whose type is in `redactRoles` from the source. Walks
|
|
786
|
+
* line-by-line; on a callout-head line whose type is redacted, drops every
|
|
787
|
+
* subsequent line that is part of the same blockquote (starts with `>`).
|
|
788
|
+
* A blank line ends the blockquote per CommonMark.
|
|
789
|
+
*
|
|
790
|
+
* Approximate by markdown standards (doesn't handle lazy-continuation lines
|
|
791
|
+
* or nested blockquotes containing role-gated children), but covers every
|
|
792
|
+
* pattern the asset scanner needs to gate against. The renderer's
|
|
793
|
+
* calloutPlugin still runs as the source of truth for visual redaction;
|
|
794
|
+
* this strip is the asset-leak guard.
|
|
795
|
+
*/
|
|
796
|
+
function stripRoleGatedCallouts(source, redactRoles) {
|
|
797
|
+
if (redactRoles.size === 0)
|
|
798
|
+
return source;
|
|
799
|
+
const lines = source.split("\n");
|
|
800
|
+
const out = [];
|
|
801
|
+
let dropping = false;
|
|
802
|
+
for (const line of lines) {
|
|
803
|
+
if (dropping) {
|
|
804
|
+
if (line.startsWith(">"))
|
|
805
|
+
continue; // still inside the blockquote
|
|
806
|
+
dropping = false;
|
|
807
|
+
out.push(line); // blank or non-`>` line ends + keeps the line
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
const head = CALLOUT_HEAD_RE.exec(line);
|
|
811
|
+
if (head && redactRoles.has(head[1].toLowerCase())) {
|
|
812
|
+
dropping = true;
|
|
813
|
+
continue; // drop the head line
|
|
814
|
+
}
|
|
815
|
+
out.push(line);
|
|
816
|
+
}
|
|
817
|
+
return out.join("\n");
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Visit every string value reachable from `value` (object / array / scalar)
|
|
821
|
+
* and call `fn` once per string. Used to surface `@vault/PATH` references
|
|
822
|
+
* inside parsed frontmatter (e.g., a Scene's `foundry.data.background.src`
|
|
823
|
+
* or a Playlist's `foundry.data.sounds[N].path`) so the per-variant asset
|
|
824
|
+
* scanner can include those files alongside body-referenced ones.
|
|
825
|
+
*/
|
|
826
|
+
function forEachString(value, fn) {
|
|
827
|
+
if (typeof value === "string")
|
|
828
|
+
return fn(value);
|
|
829
|
+
if (Array.isArray(value)) {
|
|
830
|
+
for (const v of value)
|
|
831
|
+
forEachString(v, fn);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (value && typeof value === "object") {
|
|
835
|
+
for (const v of Object.values(value))
|
|
836
|
+
forEachString(v, fn);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
/** Extract a vault path from a `@vault/PATH` string, or null when the
|
|
840
|
+
* string isn't a vault reference. Trailing fragment / query stripped. */
|
|
841
|
+
function vaultRefPath(s) {
|
|
842
|
+
if (!s.startsWith("@vault/"))
|
|
843
|
+
return null;
|
|
844
|
+
const rest = s.slice("@vault/".length).split("#")[0].split("?")[0];
|
|
845
|
+
return rest.length > 0 ? rest : null;
|
|
846
|
+
}
|
|
583
847
|
/**
|
|
584
848
|
* Per-variant reference scan for passthrough files. A file lands in this
|
|
585
849
|
* variant's deploy only if a visible page mentions it — same gating story
|
|
@@ -589,7 +853,7 @@ const WIKI_LINK_RE = /!?\[\[([^\[\]|#\n]+\.[a-z0-9]+)(?:\|[^\[\]#\n]*)?(?:#[^\[\
|
|
|
589
853
|
* the whole point of the change; a stray DM-only audio cue stays in the
|
|
590
854
|
* dm variant only.
|
|
591
855
|
*/
|
|
592
|
-
async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stagingDir, variantDir) {
|
|
856
|
+
async function copyReferencedPassthroughs(visibleSources, visibleMetas, passthroughIndex, stagingDir, variantDir) {
|
|
593
857
|
if (passthroughIndex.size === 0)
|
|
594
858
|
return;
|
|
595
859
|
const refs = new Set();
|
|
@@ -610,6 +874,22 @@ async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stag
|
|
|
610
874
|
refs.add(entry.outputPath);
|
|
611
875
|
}
|
|
612
876
|
}
|
|
877
|
+
// `@vault/PATH` references inside any frontmatter string also gate a
|
|
878
|
+
// passthrough into this variant. Same per-page-role visibility rules
|
|
879
|
+
// (only walking visibleMetas) — a dm-tier page's @vault/Audio/secret.ogg
|
|
880
|
+
// ships only to the dm variant.
|
|
881
|
+
for (const p of visibleMetas) {
|
|
882
|
+
if (!p.frontmatter)
|
|
883
|
+
continue;
|
|
884
|
+
forEachString(p.frontmatter, (s) => {
|
|
885
|
+
const path = vaultRefPath(s);
|
|
886
|
+
if (path) {
|
|
887
|
+
const entry = passthroughIndex.get(slugify(path.split("/").pop()));
|
|
888
|
+
if (entry)
|
|
889
|
+
refs.add(entry.outputPath);
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
}
|
|
613
893
|
for (const outputPath of refs) {
|
|
614
894
|
const src = join(stagingDir, outputPath);
|
|
615
895
|
const dst = join(variantDir, outputPath);
|
|
@@ -624,9 +904,11 @@ async function copyReferencedPassthroughs(visibleSources, passthroughIndex, stag
|
|
|
624
904
|
}
|
|
625
905
|
/**
|
|
626
906
|
* Build synthesised index.md for any folder (including the root) that has
|
|
627
|
-
* pages but no existing index.md.
|
|
907
|
+
* pages but no existing index.md. When `inlineTitle` is true, the layout
|
|
908
|
+
* already injects an <h1> from the page's title, so the synthesised body
|
|
909
|
+
* skips its own `# Title` heading to avoid the duplicate.
|
|
628
910
|
*/
|
|
629
|
-
function generateFolderIndexes(existing, _role) {
|
|
911
|
+
function generateFolderIndexes(existing, _role, inlineTitle) {
|
|
630
912
|
const existingPaths = new Set(existing.map((p) => p.path));
|
|
631
913
|
const folders = new Map();
|
|
632
914
|
folders.set("", { folders: new Set(), pages: [] });
|
|
@@ -675,11 +957,28 @@ function generateFolderIndexes(existing, _role) {
|
|
|
675
957
|
const propsBlock = propsYaml ? `properties:\n${propsYaml}\n` : "";
|
|
676
958
|
sections.push(`## Pages\n\n\`\`\`base\n${filtersBlock}\n${propsBlock}views:\n - type: table\n name: Contents\n order:\n${orderYaml}\n\`\`\``);
|
|
677
959
|
}
|
|
678
|
-
|
|
679
|
-
|
|
960
|
+
// With inline_title on, the layout injects an <h1> from the page's
|
|
961
|
+
// title — which it learns from the markdown's title source. We can
|
|
962
|
+
// either author the title as a `# Heading` (off-mode) or as YAML
|
|
963
|
+
// frontmatter (on-mode); the latter avoids the duplicated <h1> while
|
|
964
|
+
// still letting the renderer surface the right title.
|
|
965
|
+
const displayTitle = title || "Home";
|
|
966
|
+
const heading = inlineTitle ? "" : (title ? `# ${title}\n\n` : "");
|
|
967
|
+
const frontmatter = inlineTitle ? `---\ntitle: ${yamlString(displayTitle)}\n---\n\n` : "";
|
|
968
|
+
out.push({
|
|
969
|
+
path: indexPath,
|
|
970
|
+
title: displayTitle,
|
|
971
|
+
markdown: `${frontmatter}${heading}${sections.join("\n\n")}\n`,
|
|
972
|
+
});
|
|
680
973
|
}
|
|
681
974
|
return out;
|
|
682
975
|
}
|
|
976
|
+
/** YAML-quote a string only when needed (special chars or ambiguous flow). */
|
|
977
|
+
function yamlString(s) {
|
|
978
|
+
if (/^[A-Za-z0-9_ .-]+$/.test(s) && !/^(true|false|null|yes|no)$/i.test(s))
|
|
979
|
+
return s;
|
|
980
|
+
return JSON.stringify(s);
|
|
981
|
+
}
|
|
683
982
|
/**
|
|
684
983
|
* Pick a small set of columns for an auto-generated folder index based on
|
|
685
984
|
* what frontmatter the pages in that folder actually have. The first
|
|
@@ -696,7 +995,13 @@ function chooseColumns(pages) {
|
|
|
696
995
|
if (["title", "role", "aliases", "tags"].includes(key))
|
|
697
996
|
continue;
|
|
698
997
|
const v = fm[key];
|
|
699
|
-
if (v == null || v === ""
|
|
998
|
+
if (v == null || v === "")
|
|
999
|
+
continue;
|
|
1000
|
+
// Skip non-scalar values — arrays and plain objects render as
|
|
1001
|
+
// "[object Object]" or comma-joined junk in a table cell. Dates
|
|
1002
|
+
// are technically objects but renderValue formats them nicely,
|
|
1003
|
+
// so let them through.
|
|
1004
|
+
if (typeof v === "object" && !(v instanceof Date))
|
|
700
1005
|
continue;
|
|
701
1006
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
702
1007
|
}
|
|
@@ -739,6 +1044,35 @@ async function compressImageCached(file, quality, cacheDir, onHit) {
|
|
|
739
1044
|
* spread it directly into the LayoutInput; missing frontmatter contributes
|
|
740
1045
|
* nothing to the layout.
|
|
741
1046
|
*/
|
|
1047
|
+
// Pre-compute outgoing wikilinks per page (vault path → set of vault paths).
|
|
1048
|
+
// Bases needs this before render runs so file.hasLink() can answer truthfully
|
|
1049
|
+
// during render; the wikilink plugin's per-render outlinks list is collected
|
|
1050
|
+
// after Bases has already drawn the table. A regex scan over markdown source
|
|
1051
|
+
// (rather than rebuilding the AST) is fine: this only needs to detect link
|
|
1052
|
+
// targets, and an embedded ![[image.png]] resolves to no page anyway.
|
|
1053
|
+
const WIKILINK_SCAN_RE = /(?<!!)(?<!\[)\[\[([^\[\]|#\n]+?)(?:#[^\[\]|\n]+?)?(?:\|[^\[\]#\n]+?)?\]\]/g;
|
|
1054
|
+
function collectOutlinksByPath(metas, sources, pageIndex) {
|
|
1055
|
+
const out = new Map();
|
|
1056
|
+
for (const p of metas) {
|
|
1057
|
+
const src = sources.get(p.path);
|
|
1058
|
+
if (!src)
|
|
1059
|
+
continue;
|
|
1060
|
+
const targets = new Set();
|
|
1061
|
+
for (const match of src.matchAll(WIKILINK_SCAN_RE)) {
|
|
1062
|
+
const name = match[1].trim();
|
|
1063
|
+
const slug = slugify(name);
|
|
1064
|
+
const last = name.includes("/") ? name.split("/").pop() : "";
|
|
1065
|
+
const page = pageIndex.get(slug)
|
|
1066
|
+
?? pageIndex.get(slugify(name + "/index"))
|
|
1067
|
+
?? (last ? pageIndex.get(slugify(last)) : undefined);
|
|
1068
|
+
if (page && page.path !== p.path)
|
|
1069
|
+
targets.add(page.path);
|
|
1070
|
+
}
|
|
1071
|
+
if (targets.size > 0)
|
|
1072
|
+
out.set(p.path, targets);
|
|
1073
|
+
}
|
|
1074
|
+
return out;
|
|
1075
|
+
}
|
|
742
1076
|
function extractFrontmatterBlock(source) {
|
|
743
1077
|
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
|
|
744
1078
|
if (!m || !m[1] || !m[1].trim())
|
|
@@ -759,23 +1093,26 @@ function parseFrontmatter(source) {
|
|
|
759
1093
|
};
|
|
760
1094
|
}
|
|
761
1095
|
/**
|
|
762
|
-
* Full YAML frontmatter, used by the Bases plugin
|
|
763
|
-
*
|
|
764
|
-
* to gray-matter
|
|
765
|
-
*
|
|
1096
|
+
* Full YAML frontmatter + body content, used by the Bases plugin (frontmatter
|
|
1097
|
+
* properties) and threaded through to renderMarkdown so the pipeline doesn't
|
|
1098
|
+
* have to call gray-matter again. `data` is real YAML; falls back to {} on
|
|
1099
|
+
* malformed YAML so the page still renders. Content matches what gray-matter
|
|
1100
|
+
* would give the pipeline (frontmatter block stripped from the head).
|
|
766
1101
|
*/
|
|
767
|
-
function
|
|
1102
|
+
function parseFullFrontmatterWithContent(source) {
|
|
768
1103
|
if (!source.startsWith("---"))
|
|
769
|
-
return {};
|
|
1104
|
+
return { data: {}, content: source };
|
|
770
1105
|
try {
|
|
771
|
-
const
|
|
772
|
-
|
|
1106
|
+
const m = matter(source);
|
|
1107
|
+
const data = (m.data && typeof m.data === "object" ? m.data : {});
|
|
1108
|
+
return { data, content: m.content };
|
|
773
1109
|
}
|
|
774
1110
|
catch {
|
|
775
1111
|
// Malformed YAML; the existing parseFrontmatter is more forgiving for
|
|
776
|
-
// the title/role/aliases keys we actually need. Return empty
|
|
777
|
-
// the
|
|
778
|
-
|
|
1112
|
+
// the title/role/aliases keys we actually need. Return empty data + the
|
|
1113
|
+
// body with the leading `---\n…\n---\n` stripped via regex so the rest
|
|
1114
|
+
// of the pipeline still sees a clean body.
|
|
1115
|
+
return { data: {}, content: source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "") };
|
|
779
1116
|
}
|
|
780
1117
|
}
|
|
781
1118
|
/**
|
|
@@ -809,10 +1146,6 @@ function parseAliases(fm) {
|
|
|
809
1146
|
function unquote(s) {
|
|
810
1147
|
return s.replace(/^["']|["']$/g, "");
|
|
811
1148
|
}
|
|
812
|
-
function extractH1(source) {
|
|
813
|
-
const h1 = /^#\s+(.+)$/m.exec(source);
|
|
814
|
-
return h1?.[1] ? h1[1].trim() : null;
|
|
815
|
-
}
|
|
816
1149
|
function basenameNoExt(path) {
|
|
817
1150
|
return path.split("/").pop().replace(/\.md$/i, "");
|
|
818
1151
|
}
|
|
@@ -865,13 +1198,7 @@ function kindLabel(kind) {
|
|
|
865
1198
|
default: return kind;
|
|
866
1199
|
}
|
|
867
1200
|
}
|
|
868
|
-
|
|
869
|
-
* Walk the variant directory and produce a manifest of every file with its MD5
|
|
870
|
-
* hash + size + mtime + content type. Shared assets (anything OUTSIDE the
|
|
871
|
-
* variant dir but inside the deploy root) are listed too; clients use a
|
|
872
|
-
* single manifest to diff the entire site, not just the role-specific bits.
|
|
873
|
-
*/
|
|
874
|
-
async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles, vaultName) {
|
|
1201
|
+
async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles, vaultName, assets) {
|
|
875
1202
|
const files = [];
|
|
876
1203
|
const seen = new Set();
|
|
877
1204
|
// Variant-specific files: use pathBase=variantDir so paths come out as
|
|
@@ -896,7 +1223,30 @@ async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles,
|
|
|
896
1223
|
// like the Foundry module use it as the default label + root folder when
|
|
897
1224
|
// a user adds the vault, so they get something readable instead of a
|
|
898
1225
|
// host-derived slug.
|
|
899
|
-
|
|
1226
|
+
// Asset advertisement so clients (Foundry, MCP) fetch the right paths
|
|
1227
|
+
// instead of guessing well-known names — lets us move things later.
|
|
1228
|
+
const assetBlock = {};
|
|
1229
|
+
if (assets.hasHandlerJs || assets.hasHandlerCss) {
|
|
1230
|
+
assetBlock.browser = {
|
|
1231
|
+
...(assets.hasHandlerJs ? { js: "/_handlers.js" } : {}),
|
|
1232
|
+
...(assets.hasHandlerCss ? { css: "/_handlers.css" } : {}),
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
if (assets.hasFoundryJs || assets.hasFoundryCss) {
|
|
1236
|
+
assetBlock.foundry = {
|
|
1237
|
+
...(assets.hasFoundryJs ? { js: "/_handlers.foundry.js" } : {}),
|
|
1238
|
+
...(assets.hasFoundryCss ? { css: "/_handlers.foundry.css" } : {}),
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
return {
|
|
1242
|
+
manifest_version: MANIFEST_VERSION,
|
|
1243
|
+
cli_version: CLI_VERSION,
|
|
1244
|
+
id_scheme: ID_SCHEME,
|
|
1245
|
+
name: vaultName,
|
|
1246
|
+
auth: { required: authRequired, roles },
|
|
1247
|
+
...(Object.keys(assetBlock).length > 0 ? { assets: assetBlock } : {}),
|
|
1248
|
+
files,
|
|
1249
|
+
};
|
|
900
1250
|
}
|
|
901
1251
|
async function walkAndIndex(dir, pathBase, out, seen, skipDirNames, bodyMeta) {
|
|
902
1252
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -919,7 +1269,7 @@ async function walkAndIndex(dir, pathBase, out, seen, skipDirNames, bodyMeta) {
|
|
|
919
1269
|
const body = await readFile(abs);
|
|
920
1270
|
const info = await stat(abs);
|
|
921
1271
|
const meta = bodyMeta.get(path);
|
|
922
|
-
// Fold meta JSON into the hash so meta-only edits (e.g. a
|
|
1272
|
+
// Fold meta JSON into the hash so meta-only edits (e.g. a foundry.base
|
|
923
1273
|
// tweak with no body change) still bump the row hash and trigger sync.
|
|
924
1274
|
const hasher = createHash("md5").update(body);
|
|
925
1275
|
if (meta)
|
|
@@ -948,104 +1298,6 @@ function stableStringify(value) {
|
|
|
948
1298
|
const keys = Object.keys(obj).sort();
|
|
949
1299
|
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
|
|
950
1300
|
}
|
|
951
|
-
function contentTypeForExt(filename) {
|
|
952
|
-
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
953
|
-
const map = {
|
|
954
|
-
html: "text/html; charset=utf-8",
|
|
955
|
-
md: "text/markdown; charset=utf-8",
|
|
956
|
-
json: "application/json",
|
|
957
|
-
css: "text/css; charset=utf-8",
|
|
958
|
-
js: "application/javascript; charset=utf-8",
|
|
959
|
-
png: "image/png",
|
|
960
|
-
jpg: "image/jpeg",
|
|
961
|
-
jpeg: "image/jpeg",
|
|
962
|
-
webp: "image/webp",
|
|
963
|
-
gif: "image/gif",
|
|
964
|
-
svg: "image/svg+xml",
|
|
965
|
-
avif: "image/avif",
|
|
966
|
-
pdf: "application/pdf",
|
|
967
|
-
mp3: "audio/mpeg",
|
|
968
|
-
wav: "audio/wav",
|
|
969
|
-
ogg: "audio/ogg",
|
|
970
|
-
};
|
|
971
|
-
return map[ext] ?? "application/octet-stream";
|
|
972
|
-
}
|
|
973
|
-
/**
|
|
974
|
-
* Pre-canonicalisation migration. Earlier versions stored roles, auth_type,
|
|
975
|
-
* and role_passwords in settings.md frontmatter; they now live in
|
|
976
|
-
* .vaultrc.json. If we still see them in settings.md (legacy vault), copy
|
|
977
|
-
* over what's missing in .vaultrc.json so the imminent canonicaliser doesn't
|
|
978
|
-
* silently drop them.
|
|
979
|
-
*
|
|
980
|
-
* Idempotent: returns true and logs only if it actually moved something.
|
|
981
|
-
*/
|
|
982
|
-
async function migrateLegacyAuthFromSettings(vaultPath) {
|
|
983
|
-
const settingsPath = join(vaultPath, SETTINGS_FILE);
|
|
984
|
-
let raw;
|
|
985
|
-
try {
|
|
986
|
-
raw = await readFile(settingsPath, "utf8");
|
|
987
|
-
}
|
|
988
|
-
catch {
|
|
989
|
-
return false;
|
|
990
|
-
}
|
|
991
|
-
const fm = (matter(raw).data ?? {});
|
|
992
|
-
const hasLegacy = "roles" in fm || "auth_type" in fm || "role_passwords" in fm;
|
|
993
|
-
if (!hasLegacy)
|
|
994
|
-
return false;
|
|
995
|
-
const cfg = await loadConfig(vaultPath, {});
|
|
996
|
-
const moved = [];
|
|
997
|
-
// roles: only migrate if cfg is still at the default ["public"].
|
|
998
|
-
if (Array.isArray(fm.roles)) {
|
|
999
|
-
const list = fm.roles.filter((r) => typeof r === "string");
|
|
1000
|
-
const isDefault = cfg.roles.length === 0 || (cfg.roles.length === 1 && cfg.roles[0] === "public");
|
|
1001
|
-
if (list.length > 0 && isDefault && !arraysEqual(list, ["public"])) {
|
|
1002
|
-
cfg.roles = list;
|
|
1003
|
-
moved.push("roles");
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
if (typeof fm.auth_type === "string" && cfg.authType === "password" && fm.auth_type !== "password") {
|
|
1007
|
-
cfg.authType = fm.auth_type;
|
|
1008
|
-
moved.push("auth_type");
|
|
1009
|
-
}
|
|
1010
|
-
if (fm.role_passwords && typeof fm.role_passwords === "object" && !Array.isArray(fm.role_passwords)
|
|
1011
|
-
&& Object.keys(cfg.rolePasswords).length === 0) {
|
|
1012
|
-
const map = fm.role_passwords;
|
|
1013
|
-
const cleaned = {};
|
|
1014
|
-
for (const [k, v] of Object.entries(map))
|
|
1015
|
-
if (typeof v === "string")
|
|
1016
|
-
cleaned[k] = v;
|
|
1017
|
-
if (Object.keys(cleaned).length > 0) {
|
|
1018
|
-
cfg.rolePasswords = cleaned;
|
|
1019
|
-
moved.push("role_passwords");
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
if (moved.length === 0)
|
|
1023
|
-
return false;
|
|
1024
|
-
await saveConfig(vaultPath, cfg);
|
|
1025
|
-
console.log(` migrated ${moved.join(", ")} from settings.md → .vaultrc.json`);
|
|
1026
|
-
return true;
|
|
1027
|
-
}
|
|
1028
|
-
function arraysEqual(a, b) {
|
|
1029
|
-
return a.length === b.length && a.every((x, i) => x === b[i]);
|
|
1030
|
-
}
|
|
1031
|
-
function extractPlainText(source, max) {
|
|
1032
|
-
return source
|
|
1033
|
-
.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "")
|
|
1034
|
-
.replace(/%%[\s\S]*?%%/g, "")
|
|
1035
|
-
.replace(/```[\s\S]*?```/g, "")
|
|
1036
|
-
.replace(/!\[\[[^\]]+\]\]/g, "")
|
|
1037
|
-
.replace(/\[\[([^\]|#]+)(?:[#|][^\]]+)?\]\]/g, "$1")
|
|
1038
|
-
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
|
|
1039
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
1040
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
1041
|
-
.replace(/[*_~]+([^*_~\n]+)[*_~]+/g, "$1")
|
|
1042
|
-
.replace(/^>\s?\[![^\]]+\][+-]?\s*(.*)$/gm, "$1")
|
|
1043
|
-
.replace(/^>\s?/gm, "")
|
|
1044
|
-
.replace(/^#{1,6}\s+/gm, "")
|
|
1045
|
-
.replace(/\s+/g, " ")
|
|
1046
|
-
.trim()
|
|
1047
|
-
.slice(0, max);
|
|
1048
|
-
}
|
|
1049
1301
|
/**
|
|
1050
1302
|
* Strip an HTML body to plain text. Used to feed the search index from
|
|
1051
1303
|
* the rendered article (post-wikilink, post-callout-redaction) so search
|