@wizzlethorpe/vaults 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/asset-refs.js +274 -0
- package/dist/asset-refs.js.map +1 -0
- package/dist/auth.js.map +1 -1
- package/dist/build.js +252 -492
- package/dist/build.js.map +1 -1
- package/dist/commands/build.js +54 -5
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/preview.js +0 -4
- package/dist/commands/preview.js.map +1 -1
- package/dist/commands/push.js +4 -9
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/role.js +55 -14
- package/dist/commands/role.js.map +1 -1
- package/dist/config.js +25 -5
- package/dist/config.js.map +1 -1
- package/dist/foundry-importer.bundle.js +1228 -327
- package/dist/foundry-importer.js +2 -7
- package/dist/foundry-importer.js.map +1 -1
- package/dist/foundry-meta.js +284 -0
- package/dist/foundry-meta.js.map +1 -0
- package/dist/foundry-module-journal.js +112 -0
- package/dist/foundry-module-journal.js.map +1 -0
- package/dist/foundry-module-render.js +75 -0
- package/dist/foundry-module-render.js.map +1 -0
- package/dist/foundry-module.js +1075 -0
- package/dist/foundry-module.js.map +1 -0
- package/dist/frontmatter-defaults.js +68 -0
- package/dist/frontmatter-defaults.js.map +1 -0
- package/dist/index.js +9 -9
- package/dist/index.js.map +1 -1
- package/dist/manifest.js +115 -0
- package/dist/manifest.js.map +1 -0
- package/dist/render/auth-template.js +323 -35
- package/dist/render/auth-template.js.map +1 -1
- package/dist/render/bases.js +22 -38
- package/dist/render/bases.js.map +1 -1
- package/dist/render/cover.js +23 -1
- package/dist/render/cover.js.map +1 -1
- package/dist/render/handlers/builtin/download.js +90 -0
- package/dist/render/handlers/builtin/download.js.map +1 -0
- package/dist/render/handlers/builtin/foundry-manifest.js +158 -0
- package/dist/render/handlers/builtin/foundry-manifest.js.map +1 -0
- package/dist/render/handlers/builtin/index.js +3 -1
- package/dist/render/handlers/builtin/index.js.map +1 -1
- package/dist/render/pipeline.js +4 -1
- package/dist/render/pipeline.js.map +1 -1
- package/dist/render/slug.js +0 -5
- package/dist/render/slug.js.map +1 -1
- package/dist/scan.js +8 -0
- package/dist/scan.js.map +1 -1
- package/dist/settings.js +112 -6
- package/dist/settings.js.map +1 -1
- package/package.json +2 -1
package/dist/build.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { copyFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import { relative } from "node:path";
|
|
5
4
|
import { dirname, join } from "node:path";
|
|
6
5
|
import { availableParallelism } from "node:os";
|
|
7
6
|
import picomatch from "picomatch";
|
|
8
7
|
import { scanVault } from "./scan.js";
|
|
8
|
+
import { htmlEscape } from "./escape.js";
|
|
9
|
+
import { collectDataJsonVaultRefs, copyReferencedImages, copyReferencedPassthroughs, } from "./asset-refs.js";
|
|
10
|
+
import { downloadFilePaths } from "./render/handlers/builtin/download.js";
|
|
11
|
+
import { foundryManifestPaths, manifestDownloadPath } from "./render/handlers/builtin/foundry-manifest.js";
|
|
12
|
+
import { buildManifest } from "./manifest.js";
|
|
13
|
+
import { collectBodyMeta, warnFoundryDocCollisions } from "./foundry-meta.js";
|
|
9
14
|
import { compressImage } from "./images.js";
|
|
10
|
-
import { IMAGE_EXT_RE, PASSTHROUGH_EXT_RE, COMPRESSIBLE_EXT_RE,
|
|
15
|
+
import { IMAGE_EXT_RE, PASSTHROUGH_EXT_RE, COMPRESSIBLE_EXT_RE, } from "./render/extensions.js";
|
|
11
16
|
import { buildFavicon } from "./favicon.js";
|
|
12
17
|
import { renderMarkdown } from "./render/pipeline.js";
|
|
13
18
|
import { extractH1 } from "./render/frontmatter.js";
|
|
14
|
-
import { CLI_VERSION, MANIFEST_VERSION, ID_SCHEME } from "./version.js";
|
|
15
19
|
import { renderLayout, render404 } from "./render/layout.js";
|
|
16
20
|
import { writeFoundryImporter } from "./foundry-importer.js";
|
|
17
21
|
import { slugify } from "./render/slug.js";
|
|
@@ -21,12 +25,11 @@ import { DEFAULT_CSS, renderThemeOverride } from "./render/styles.js";
|
|
|
21
25
|
import { loadObsidianSnippets } from "./obsidian.js";
|
|
22
26
|
import { loadSettings, writeSettings, SETTINGS_FILE } from "./settings.js";
|
|
23
27
|
import { loadConfig } from "./config.js";
|
|
28
|
+
import { applyFrontmatterDefaults, compileFrontmatterRules } from "./frontmatter-defaults.js";
|
|
24
29
|
import matter from "gray-matter";
|
|
25
|
-
import { renderAuthMiddleware,
|
|
26
|
-
import { htmlAttr } from "./escape.js";
|
|
30
|
+
import { renderAuthMiddleware, renderLoginPage } from "./render/auth-template.js";
|
|
27
31
|
import { renderFooterHtml } from "./render/footer.js";
|
|
28
32
|
import { buildRegistry } from "./render/handlers/types.js";
|
|
29
|
-
import { battlemapLayerPaths } from "./render/handlers/builtin/battlemap.js";
|
|
30
33
|
import { loadUserHandlers } from "./render/handlers/loader.js";
|
|
31
34
|
import { BUILTIN_HANDLERS } from "./render/handlers/builtin/index.js";
|
|
32
35
|
import { bundleHandlerAssets } from "./render/handlers/assets.js";
|
|
@@ -49,26 +52,110 @@ import { formatDuration, pMap, Progress } from "./util.js";
|
|
|
49
52
|
* Single-role builds (the default `public`-only case) collapse
|
|
50
53
|
* `_variants/public/...` up to the root.
|
|
51
54
|
*/
|
|
52
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Add basename-slug keys to an asset index, first-write-wins in the vault's
|
|
57
|
+
* sorted path order.
|
|
58
|
+
*
|
|
59
|
+
* Obsidian resolves `![[map.png]]` by basename, so two files with the same
|
|
60
|
+
* name in different folders compete for one key. Writing the key from inside
|
|
61
|
+
* the concurrent staging pass meant whichever finished second won, and that
|
|
62
|
+
* varied between builds on identical input — a page could silently get a
|
|
63
|
+
* different image run to run. Doing it here, sequentially over the sorted
|
|
64
|
+
* list, makes the winner deterministic and lets us say which files collided.
|
|
65
|
+
* (Full-path keys stay in the staging pass; those are unique by definition.)
|
|
66
|
+
*/
|
|
67
|
+
function addBasenameKeys(index, files, label) {
|
|
68
|
+
const claimed = new Map(); // slug → winning source path
|
|
69
|
+
for (const f of files) {
|
|
70
|
+
const entry = index.get(f.path);
|
|
71
|
+
if (!entry)
|
|
72
|
+
continue; // staging failed for this file; nothing to point at
|
|
73
|
+
const slug = slugify(f.path.split("/").pop());
|
|
74
|
+
const winner = claimed.get(slug);
|
|
75
|
+
if (winner === undefined) {
|
|
76
|
+
claimed.set(slug, f.path);
|
|
77
|
+
index.set(slug, entry);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
console.warn(` ${label} name collision: '${f.path}' and '${winner}' share a filename. `
|
|
81
|
+
+ `Bare references like the basename resolve to '${winner}'; `
|
|
82
|
+
+ `use the folder path to reach the other.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Emit `sitemap.xml` and `robots.txt` at the deploy root.
|
|
87
|
+
*
|
|
88
|
+
* **Only the default role's pages are listed.** A sitemap naming gated pages
|
|
89
|
+
* would advertise that they exist, and their URLs, to anyone who fetches it —
|
|
90
|
+
* the middleware would still refuse the content, but the leak is the point of
|
|
91
|
+
* a sitemap, so it must never see above the lowest tier.
|
|
92
|
+
*
|
|
93
|
+
* Both files are written only when `site_url` is set, because a sitemap needs
|
|
94
|
+
* absolute URLs and nothing else in the build knows the deploy's public
|
|
95
|
+
* hostname (a Pages project can answer on several).
|
|
96
|
+
*/
|
|
97
|
+
async function writeSitemap(outputDir, siteUrl, pagePaths) {
|
|
98
|
+
const base = siteUrl.replace(/\/+$/, "");
|
|
99
|
+
const urls = pagePaths
|
|
100
|
+
.map((p) => p.replace(/\.md$/i, ""))
|
|
101
|
+
.map((p) => (p === "index" ? "" : p.replace(/\/index$/i, "")))
|
|
102
|
+
.sort()
|
|
103
|
+
.map((p) => `${base}/${p.split("/").map(encodeURIComponent).join("/")}`)
|
|
104
|
+
// index.md becomes the bare base URL rather than "<base>/".
|
|
105
|
+
.map((u) => u.replace(/\/$/, ""));
|
|
106
|
+
const body = [...new Set(urls)]
|
|
107
|
+
.map((u) => ` <url><loc>${htmlEscape(u)}</loc></url>`)
|
|
108
|
+
.join("\n");
|
|
109
|
+
await writeFile(join(outputDir, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8"?>\n`
|
|
110
|
+
+ `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`);
|
|
111
|
+
await writeFile(join(outputDir, "robots.txt"), `User-agent: *\nAllow: /\nSitemap: ${base}/sitemap.xml\n`);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Warn about a `default_frontmatter` rule assigning a role the vault has not
|
|
115
|
+
* configured.
|
|
116
|
+
*
|
|
117
|
+
* `default_role` validated itself and said so when it was wrong. Moving the
|
|
118
|
+
* job into a rule would have dropped that: a typo would silently supply a role
|
|
119
|
+
* nothing recognises, and every page it matched would fall back to the lowest
|
|
120
|
+
* tier — publishing a vault meant to be private, quietly.
|
|
121
|
+
*/
|
|
122
|
+
function warnUnknownRoles(rules, known, roles) {
|
|
123
|
+
for (const rule of rules) {
|
|
124
|
+
const role = rule.data?.["role"];
|
|
125
|
+
if (typeof role === "string" && role && !known.has(role)) {
|
|
126
|
+
console.warn(` settings.md: default_frontmatter rule '${rule.match}' assigns role `
|
|
127
|
+
+ `"${role}", which is not one of [${roles.join(", ")}]. Pages matching it `
|
|
128
|
+
+ `fall back to "${roles[0]}".`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export async function buildSite(input) {
|
|
53
133
|
const start = Date.now();
|
|
54
134
|
const concurrency = Math.max(2, availableParallelism());
|
|
55
135
|
// Run any pending schema / layout migrations before reading anything
|
|
56
136
|
// else. The framework is idempotent: already-migrated vaults pay only
|
|
57
137
|
// the cost of a few stat() calls. See cli/src/migrate/.
|
|
58
|
-
await runMigrations(
|
|
138
|
+
await runMigrations(input.vaultPath);
|
|
59
139
|
// ── Settings (user-editable) ─────────────────────────────────────────────
|
|
60
|
-
const settings = await loadSettings(
|
|
140
|
+
const settings = await loadSettings(input.vaultPath);
|
|
61
141
|
for (const w of settings.warnings)
|
|
62
142
|
console.warn(` ${w}`);
|
|
63
143
|
if (settings.exists && settings.changed) {
|
|
64
|
-
await writeSettings(
|
|
144
|
+
await writeSettings(input.vaultPath, settings.values);
|
|
65
145
|
console.log(` rewrote ${SETTINGS_FILE} to canonical format`);
|
|
66
146
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
147
|
+
// settings.md is the single source of truth for vault properties (see the
|
|
148
|
+
// SCHEMA in settings.ts). These used to also be CLI flags, with "was the
|
|
149
|
+
// flag passed?" inferred by comparing against the flag's default — and the
|
|
150
|
+
// defaults matched the schema's, so `build -q 85` against an
|
|
151
|
+
// `image_quality: 40` vault silently produced 40. The flags are gone; the
|
|
152
|
+
// vault decides.
|
|
153
|
+
let opts = {
|
|
154
|
+
...input,
|
|
155
|
+
siteUrl: settings.values.site_url,
|
|
156
|
+
vaultName: settings.values.vault_name,
|
|
157
|
+
imageQuality: settings.values.image_quality,
|
|
158
|
+
maxFileBytes: settings.values.max_file_bytes,
|
|
72
159
|
};
|
|
73
160
|
// ── Custom handlers ──────────────────────────────────────────────────────
|
|
74
161
|
// Built-ins ship with the CLI; user handlers live in `.vaults/handlers/`
|
|
@@ -94,20 +181,13 @@ export async function buildSite(opts) {
|
|
|
94
181
|
const cfg = await loadConfig(opts.vaultPath, {});
|
|
95
182
|
const roles = cfg.roles.length > 0 ? cfg.roles : ["public"];
|
|
96
183
|
const allRoleSet = new Set(roles);
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
defaultRole = settings.values.default_role;
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
console.warn(` settings.md: default_role "${settings.values.default_role}" `
|
|
108
|
-
+ `not in configured roles [${roles.join(", ")}], using "${defaultRole}"`);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
184
|
+
// A page's role comes from its frontmatter, and `default_frontmatter` is
|
|
185
|
+
// what supplies one to pages that state none — a DM-by-default vault sets
|
|
186
|
+
// `role: dm` in a rule matching `**`. This is the floor for anything that
|
|
187
|
+
// reaches here without a role at all, which means a vault whose rules do not
|
|
188
|
+
// cover it.
|
|
189
|
+
const defaultRole = roles[0];
|
|
190
|
+
warnUnknownRoles(settings.values.default_frontmatter, allRoleSet, roles);
|
|
111
191
|
// ── Scan + filter ────────────────────────────────────────────────────────
|
|
112
192
|
console.log(`Scanning ${opts.vaultPath}...`);
|
|
113
193
|
const scanStart = Date.now();
|
|
@@ -151,9 +231,45 @@ export async function buildSite(opts) {
|
|
|
151
231
|
// the passthrough pool (still reference-gated). The user-facing
|
|
152
232
|
// warning lists exactly which paths got dropped so unintentional
|
|
153
233
|
// omissions surface immediately.
|
|
234
|
+
// Read the ```download blocks first: a file named by one is shipped whatever
|
|
235
|
+
// its extension, so it must not also be reported as skipped. Warning about a
|
|
236
|
+
// file that then gets staged sends the reader off to set
|
|
237
|
+
// include_unknown_files for no reason.
|
|
238
|
+
const downloadPaths = new Set();
|
|
239
|
+
const manifestPaths = new Set();
|
|
240
|
+
const manifestDownloads = new Map();
|
|
241
|
+
for (const f of markdownFiles) {
|
|
242
|
+
const source = await readFile(f.absolute, "utf8");
|
|
243
|
+
for (const path of downloadFilePaths(source))
|
|
244
|
+
downloadPaths.add(path);
|
|
245
|
+
for (const path of foundryManifestPaths(source))
|
|
246
|
+
manifestPaths.add(path);
|
|
247
|
+
}
|
|
248
|
+
// A foundry-manifest block names only the manifest. The zip is whatever the
|
|
249
|
+
// manifest's own download field says, so read it and ship that too — the
|
|
250
|
+
// author should not have to repeat a path the manifest already states, and
|
|
251
|
+
// an install needs both halves present or it fails on the second fetch.
|
|
252
|
+
for (const rel of manifestPaths) {
|
|
253
|
+
downloadPaths.add(rel);
|
|
254
|
+
const file = withinLimit.find((f) => f.path === rel);
|
|
255
|
+
if (!file)
|
|
256
|
+
continue;
|
|
257
|
+
const { path, absolute } = manifestDownloadPath(await readFile(file.absolute, "utf8"), settings.values.site_url);
|
|
258
|
+
if (absolute) {
|
|
259
|
+
console.warn(` ${rel}: "download" points outside this vault (${absolute}), so the file is not`
|
|
260
|
+
+ ` staged into the deploy. If it is meant to be this vault's own file, either`
|
|
261
|
+
+ ` set 'site_url' to the host it names or write the path relative and let the`
|
|
262
|
+
+ ` build make it absolute.`);
|
|
263
|
+
}
|
|
264
|
+
if (path) {
|
|
265
|
+
downloadPaths.add(path);
|
|
266
|
+
manifestDownloads.set(rel, path);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
154
269
|
const unknownFiles = withinLimit.filter((f) => !/\.md$|\.base$/i.test(f.path)
|
|
155
270
|
&& !IMAGE_EXT_RE.test(f.path)
|
|
156
|
-
&& !PASSTHROUGH_EXT_RE.test(f.path)
|
|
271
|
+
&& !PASSTHROUGH_EXT_RE.test(f.path)
|
|
272
|
+
&& !downloadPaths.has(f.path));
|
|
157
273
|
const includeUnknown = settings.values.include_unknown_files;
|
|
158
274
|
if (unknownFiles.length > 0) {
|
|
159
275
|
if (includeUnknown) {
|
|
@@ -173,7 +289,26 @@ export async function buildSite(opts) {
|
|
|
173
289
|
// Effective passthrough list: recognised media plus (optionally) unknowns.
|
|
174
290
|
const stagedPassthroughs = includeUnknown
|
|
175
291
|
? [...passthroughFiles, ...unknownFiles]
|
|
176
|
-
: passthroughFiles;
|
|
292
|
+
: [...passthroughFiles];
|
|
293
|
+
// Files named by a ```download block join the pool whatever their
|
|
294
|
+
// extension. A download is usually a .zip or a module.json, which the
|
|
295
|
+
// passthrough list calls unknown and drops — but naming one in a block is
|
|
296
|
+
// the author asking for it by name, which is exactly the intent
|
|
297
|
+
// include_unknown_files exists to require. Role gating is untouched: these
|
|
298
|
+
// are still reference-gated, so a download on a patron page reaches the
|
|
299
|
+
// patron variant and no other.
|
|
300
|
+
if (downloadPaths.size > 0) {
|
|
301
|
+
const already = new Set(stagedPassthroughs.map((f) => f.path));
|
|
302
|
+
const promoted = withinLimit.filter((f) => downloadPaths.has(f.path) && !already.has(f.path));
|
|
303
|
+
const missing = [...downloadPaths].filter((p) => !withinLimit.some((f) => f.path === p));
|
|
304
|
+
for (const p of missing) {
|
|
305
|
+
console.warn(` download block names '${p}', which is not in the vault; the link will 404.`);
|
|
306
|
+
}
|
|
307
|
+
if (promoted.length > 0) {
|
|
308
|
+
console.log(` staging ${promoted.length} file(s) named by download / foundry-manifest blocks`);
|
|
309
|
+
stagedPassthroughs.push(...promoted);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
177
312
|
// ── Shared content (read once, reused across roles) ─────────────────────
|
|
178
313
|
const sources = new Map();
|
|
179
314
|
await pMap(markdownFiles, concurrency, async (f) => {
|
|
@@ -191,9 +326,17 @@ export async function buildSite(opts) {
|
|
|
191
326
|
// normalized for Obsidian quirks first; malformed YAML throws inside
|
|
192
327
|
// parsePageFrontmatter and aborts the build rather than silently dropping a
|
|
193
328
|
// page's metadata (and, with it, its role gate).
|
|
329
|
+
const frontmatterRules = compileFrontmatterRules(settings.values.default_frontmatter);
|
|
194
330
|
const parsedSources = new Map();
|
|
195
331
|
for (const f of markdownFiles) {
|
|
196
|
-
|
|
332
|
+
const parsed = parsePageFrontmatter(sources.get(f.path), f.path);
|
|
333
|
+
// Applied here, at the one place a page's frontmatter is read, so that
|
|
334
|
+
// roles, the rendered wiki, the manifest the Foundry client syncs from and
|
|
335
|
+
// the module compiler all see the same page. A default that only some of
|
|
336
|
+
// them honoured would be a way for a synced vault and an installed module
|
|
337
|
+
// to disagree about the same file.
|
|
338
|
+
applyFrontmatterDefaults(f.path, parsed.data, frontmatterRules);
|
|
339
|
+
parsedSources.set(f.path, parsed);
|
|
197
340
|
}
|
|
198
341
|
// Derive role/title/aliases per page from that parse. A role that's present
|
|
199
342
|
// but isn't a configured role fails the build (collected below): falling back
|
|
@@ -265,12 +408,13 @@ export async function buildSite(opts) {
|
|
|
265
408
|
// key is what stops identically-named assets in different scene folders
|
|
266
409
|
// (e.g. a shared `Water Fountain (Loop).ogg`) from colliding under one
|
|
267
410
|
// basename slug and staging only one of them.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
imageIndex.set(f.path,
|
|
411
|
+
// Full-path key only; basename keys are added afterwards, in sorted
|
|
412
|
+
// order, so a duplicated filename resolves deterministically.
|
|
413
|
+
imageIndex.set(f.path, { sourcePath: f.path, outputPath: compressed.outputPath });
|
|
271
414
|
}, (done, total) => progress.update(done, total));
|
|
272
415
|
progress.done(`${imageFiles.length} processed (${cacheHits} cached, ${imageFiles.length - cacheHits} compressed)`);
|
|
273
416
|
}
|
|
417
|
+
addBasenameKeys(imageIndex, imageFiles, "image");
|
|
274
418
|
// ── Passthrough files (audio, video, PDF, epub) ────────────────────────
|
|
275
419
|
// Staged once and copied into a variant only when a visible page in that
|
|
276
420
|
// variant references the file by basename or relative path. Same gating
|
|
@@ -288,12 +432,11 @@ export async function buildSite(opts) {
|
|
|
288
432
|
// Dual-keyed like imageIndex: basename slug for body refs, full
|
|
289
433
|
// vault-relative path for `@vault/PATH` refs (ambient sounds in
|
|
290
434
|
// data_json), so same-named files in different folders don't collide.
|
|
291
|
-
|
|
292
|
-
passthroughIndex.set(slugify(f.path.split("/").pop()), entry);
|
|
293
|
-
passthroughIndex.set(f.path, entry);
|
|
435
|
+
passthroughIndex.set(f.path, { sourcePath: f.path, outputPath: f.path });
|
|
294
436
|
}, (done, total) => progress.update(done, total));
|
|
295
437
|
progress.done(`${stagedPassthroughs.length} staged`);
|
|
296
438
|
}
|
|
439
|
+
addBasenameKeys(passthroughIndex, stagedPassthroughs, "passthrough");
|
|
297
440
|
// Shared CSS bundle.
|
|
298
441
|
//
|
|
299
442
|
// Every file written to outputDir ROOT (rather than into _variants/<role>/)
|
|
@@ -325,15 +468,18 @@ export async function buildSite(opts) {
|
|
|
325
468
|
// One combined hash keeps it simple; any shared-asset change busts all.
|
|
326
469
|
// katex version rides in the hash so a dependency upgrade re-fetches the
|
|
327
470
|
// (otherwise never-changing) /katex/katex.min.css on math pages.
|
|
328
|
-
const katexVersion =
|
|
471
|
+
const katexVersion = katexRequire()("katex/package.json").version;
|
|
329
472
|
const assetVersion = createHash("md5")
|
|
330
473
|
.update(DEFAULT_CSS + themeOverride + userCss + handlerAssets.js + handlerAssets.css + katexVersion)
|
|
331
474
|
.digest("hex")
|
|
332
475
|
.slice(0, 10);
|
|
333
476
|
// Foundry importer bundle: one ESM file the Foundry module fetches at
|
|
334
|
-
// sync time
|
|
335
|
-
//
|
|
336
|
-
|
|
477
|
+
// sync time. Skipped entirely when the vault has opted out of the Foundry
|
|
478
|
+
// integration — it is ~60KB shipped to every deploy, and a course site or
|
|
479
|
+
// research wiki will never fetch it.
|
|
480
|
+
const foundryEnabled = settings.values.foundry.package !== "none";
|
|
481
|
+
if (foundryEnabled)
|
|
482
|
+
await writeFoundryImporter(opts.outputDir);
|
|
337
483
|
// Foundry-import bundles are written per-variant inside the role loop
|
|
338
484
|
// below (instead of at the root) so the middleware role-gates them. A
|
|
339
485
|
// public visitor can't fetch the dm-tier handler bundle even if it
|
|
@@ -379,9 +525,11 @@ export async function buildSite(opts) {
|
|
|
379
525
|
if (cover)
|
|
380
526
|
meta.coverImage = cover;
|
|
381
527
|
}
|
|
528
|
+
warnFoundryDocCollisions(allPageMetas);
|
|
382
529
|
// ── Per-role variant builds ─────────────────────────────────────────────
|
|
383
530
|
const perRolePageCount = {};
|
|
384
531
|
const collapseToRoot = roles.length === 1;
|
|
532
|
+
let defaultRolePagePaths = [];
|
|
385
533
|
let katexCopied = false;
|
|
386
534
|
for (const role of roles) {
|
|
387
535
|
const variantDir = collapseToRoot
|
|
@@ -400,6 +548,7 @@ export async function buildSite(opts) {
|
|
|
400
548
|
role,
|
|
401
549
|
visibleRoles,
|
|
402
550
|
redactRoles,
|
|
551
|
+
gated: !collapseToRoot,
|
|
403
552
|
variantDir,
|
|
404
553
|
vaultName: opts.vaultName,
|
|
405
554
|
vaultPath: opts.vaultPath,
|
|
@@ -408,6 +557,7 @@ export async function buildSite(opts) {
|
|
|
408
557
|
parsedSources,
|
|
409
558
|
baseSources,
|
|
410
559
|
imageIndex,
|
|
560
|
+
manifestDownloads,
|
|
411
561
|
imageStagingDir,
|
|
412
562
|
passthroughIndex,
|
|
413
563
|
passthroughStagingDir: otherStagingDir,
|
|
@@ -422,6 +572,9 @@ export async function buildSite(opts) {
|
|
|
422
572
|
allWarnings: opts.allWarnings,
|
|
423
573
|
});
|
|
424
574
|
perRolePageCount[role] = stats.pageCount;
|
|
575
|
+
// Only the default (lowest) role feeds the sitemap; see writeSitemap.
|
|
576
|
+
if (role === roles[0])
|
|
577
|
+
defaultRolePagePaths = stats.pagePaths;
|
|
425
578
|
if (!collapseToRoot)
|
|
426
579
|
console.log(` variant '${role}': ${stats.pageCount} pages`);
|
|
427
580
|
// KaTeX stylesheet + fonts, shared at the deploy root. Copied lazily on
|
|
@@ -441,7 +594,7 @@ export async function buildSite(opts) {
|
|
|
441
594
|
// Foundry-import subset bundles. The Foundry module fetches these by
|
|
442
595
|
// their canonical `/_handlers.foundry.{js,css}` paths; the middleware
|
|
443
596
|
// role-gates per the requesting bearer's variant.
|
|
444
|
-
if (handlerAssets.foundry) {
|
|
597
|
+
if (foundryEnabled && handlerAssets.foundry) {
|
|
445
598
|
if (handlerAssets.foundry.js.length > 0) {
|
|
446
599
|
await writeFile(join(variantDir, "_handlers.foundry.js"), handlerAssets.foundry.js);
|
|
447
600
|
}
|
|
@@ -457,9 +610,9 @@ export async function buildSite(opts) {
|
|
|
457
610
|
const manifest = await buildManifest(opts.outputDir, variantDir, stats.bodyMeta, !collapseToRoot, roles, opts.vaultName, {
|
|
458
611
|
hasHandlerJs,
|
|
459
612
|
hasHandlerCss,
|
|
460
|
-
hasFoundryJs: (handlerAssets.foundry?.js.length ?? 0) > 0,
|
|
461
|
-
hasFoundryCss: (handlerAssets.foundry?.css.length ?? 0) > 0,
|
|
462
|
-
});
|
|
613
|
+
hasFoundryJs: foundryEnabled && (handlerAssets.foundry?.js.length ?? 0) > 0,
|
|
614
|
+
hasFoundryCss: foundryEnabled && (handlerAssets.foundry?.css.length ?? 0) > 0,
|
|
615
|
+
}, settings.values.foundry.package, allRoleSet.has(settings.values.foundry.player_role) ? settings.values.foundry.player_role : "");
|
|
463
616
|
await writeFile(join(variantDir, "_manifest.json"), JSON.stringify(manifest));
|
|
464
617
|
}
|
|
465
618
|
// ── Pages Functions ─────────────────────────────────────────────────────
|
|
@@ -496,30 +649,40 @@ export async function buildSite(opts) {
|
|
|
496
649
|
: null;
|
|
497
650
|
const middleware = renderAuthMiddleware({
|
|
498
651
|
roles,
|
|
652
|
+
foundry: foundryEnabled,
|
|
499
653
|
rolePasswords: cfg.rolePasswords,
|
|
500
654
|
...(patreonForFn ? { patreon: patreonForFn } : {}),
|
|
501
655
|
...(oidcForFn ? { oidc: oidcForFn } : {}),
|
|
502
656
|
});
|
|
503
657
|
await writeFile(join(fnDir, "_middleware.js"), middleware);
|
|
504
|
-
// Login page
|
|
658
|
+
// Login page, showing only the methods this deploy actually has. A role
|
|
659
|
+
// is reachable by password only if a hash was set for it, so a vault
|
|
660
|
+
// authenticating purely through Patreon or OIDC gets no password form
|
|
661
|
+
// and no role selector.
|
|
505
662
|
const protectedRoles = roles.slice(1);
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
663
|
+
const passwordRoles = protectedRoles.filter((r) => cfg.rolePasswords[r]);
|
|
664
|
+
const patreonRoles = patreonForFn ? Object.keys(patreonForFn.tiers) : [];
|
|
665
|
+
const oidcRoles = oidcForFn ? Object.keys(oidcForFn.roleRules ?? {}) : [];
|
|
666
|
+
await writeFile(join(opts.outputDir, "login.html"), renderLoginPage({
|
|
667
|
+
passwordRoles,
|
|
668
|
+
patreonRoles,
|
|
669
|
+
oidcDisplayName: oidcForFn ? oidcForFn.displayName : null,
|
|
670
|
+
}));
|
|
671
|
+
// The error is a role nobody can reach, not a role without a password —
|
|
672
|
+
// password-less is the point when a provider grants the role instead.
|
|
673
|
+
const unreachable = protectedRoles.filter((r) => !cfg.rolePasswords[r] && !patreonRoles.includes(r) && !oidcRoles.includes(r));
|
|
674
|
+
if (unreachable.length > 0) {
|
|
675
|
+
console.warn(` WARNING: no way to sign in as role(s): ${unreachable.join(", ")}. `
|
|
676
|
+
+ `Set a password ('vaults password <role>'), map a Patreon tier `
|
|
677
|
+
+ `('vaults patreon link <role> <tier-id>'), or add an OIDC rule `
|
|
678
|
+
+ `('vaults oidc configure'). Pages at these roles will be unreachable.`);
|
|
521
679
|
}
|
|
522
680
|
}
|
|
681
|
+
// Search-engine files. Written from the default role's page list only, so a
|
|
682
|
+
// gated page is never named. Skipped entirely without a site_url.
|
|
683
|
+
if (opts.siteUrl) {
|
|
684
|
+
await writeSitemap(opts.outputDir, opts.siteUrl, defaultRolePagePaths);
|
|
685
|
+
}
|
|
523
686
|
// Drop the staging dirs; their contents have been copied into each
|
|
524
687
|
// variant that needs them, so they're no longer required for the deploy.
|
|
525
688
|
await rm(imageStagingDir, { recursive: true, force: true });
|
|
@@ -532,8 +695,6 @@ export async function buildSite(opts) {
|
|
|
532
695
|
await rename(workOutputDir, finalOutputDir);
|
|
533
696
|
console.log(`Built in ${formatDuration(Date.now() - start)}.`);
|
|
534
697
|
return {
|
|
535
|
-
files,
|
|
536
|
-
withinLimit,
|
|
537
698
|
roles,
|
|
538
699
|
perRolePageCount,
|
|
539
700
|
imageCount: imageFiles.length,
|
|
@@ -596,6 +757,7 @@ async function buildVariant(a) {
|
|
|
596
757
|
bases: a.baseSources,
|
|
597
758
|
defaultImageWidth: a.settings.default_image_width,
|
|
598
759
|
redactRoles: a.redactRoles,
|
|
760
|
+
gated: a.gated,
|
|
599
761
|
handlers: a.handlerRegistry,
|
|
600
762
|
outlinksByPath,
|
|
601
763
|
};
|
|
@@ -728,16 +890,34 @@ async function buildVariant(a) {
|
|
|
728
890
|
// contract as images: ship only into variants whose visible pages
|
|
729
891
|
// reference the file. A DM-only audio cue can't ride along into the
|
|
730
892
|
// public deploy because no public-tier source mentions it.
|
|
731
|
-
await copyReferencedPassthroughs(visibleSources, visibleMetas, a.passthroughIndex, a.passthroughStagingDir, a.variantDir);
|
|
732
|
-
return {
|
|
893
|
+
await copyReferencedPassthroughs(visibleSources, visibleMetas, a.passthroughIndex, a.passthroughStagingDir, a.variantDir, a.manifestDownloads);
|
|
894
|
+
return {
|
|
895
|
+
pageCount: visibleMetas.length,
|
|
896
|
+
pagePaths: visibleMetas.map((m) => m.path),
|
|
897
|
+
bodyMeta,
|
|
898
|
+
hasMath: hasMathCss,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Resolve the katex package that *rehype-katex* renders with, which is not
|
|
903
|
+
* necessarily this package's own katex dependency: under pnpm's isolated
|
|
904
|
+
* node_modules, rehype-katex gets the copy matching its own range. The
|
|
905
|
+
* stylesheet has to come from that copy, because KaTeX renames its CSS
|
|
906
|
+
* classes between versions ('sizing' became 'katex-sizing' in 0.18), and a
|
|
907
|
+
* stylesheet from a different version silently stops matching the markup:
|
|
908
|
+
* subscripts render at full size instead of 0.7em.
|
|
909
|
+
*/
|
|
910
|
+
function katexRequire() {
|
|
911
|
+
const here = createRequire(import.meta.url);
|
|
912
|
+
return createRequire(here.resolve("rehype-katex"));
|
|
733
913
|
}
|
|
734
914
|
/**
|
|
735
|
-
* Copy KaTeX's stylesheet and fonts (from the katex
|
|
915
|
+
* Copy KaTeX's stylesheet and fonts (from the katex rehype-katex uses) to
|
|
736
916
|
* <outputDir>/katex/. woff2 only: the CSS lists woff2 first, so any browser
|
|
737
917
|
* that supports it (all modern ones) never requests the woff/ttf fallbacks.
|
|
738
918
|
*/
|
|
739
919
|
async function copyKatexAssets(destDir) {
|
|
740
|
-
const distDir = join(dirname(
|
|
920
|
+
const distDir = join(dirname(katexRequire().resolve("katex/package.json")), "dist");
|
|
741
921
|
await mkdir(join(destDir, "fonts"), { recursive: true });
|
|
742
922
|
await copyFile(join(distDir, "katex.min.css"), join(destDir, "katex.min.css"));
|
|
743
923
|
for (const f of await readdir(join(distDir, "fonts"))) {
|
|
@@ -760,127 +940,6 @@ async function copyKatexAssets(destDir) {
|
|
|
760
940
|
* embed: false # default true
|
|
761
941
|
* data: { … deep-merged into the doc }
|
|
762
942
|
*/
|
|
763
|
-
async function collectBodyMeta(p, vaultPath) {
|
|
764
|
-
const fm = p.frontmatter ?? {};
|
|
765
|
-
const out = { role: p.role };
|
|
766
|
-
const basename = p.path.split("/").pop().replace(/\.md$/i, "");
|
|
767
|
-
if (p.title && p.title !== basename)
|
|
768
|
-
out.title = p.title;
|
|
769
|
-
const fo = fm["foundry"];
|
|
770
|
-
if (fo && typeof fo === "object" && !Array.isArray(fo)) {
|
|
771
|
-
const block = {};
|
|
772
|
-
const base = fo["base"];
|
|
773
|
-
if (typeof base === "string" && base.trim().length > 0)
|
|
774
|
-
block.base = base.trim();
|
|
775
|
-
const embed = fo["embed"];
|
|
776
|
-
if (typeof embed === "boolean")
|
|
777
|
-
block.embed = embed;
|
|
778
|
-
// foundry.sync: false keeps the page out of Foundry altogether — no
|
|
779
|
-
// JournalEntryPage, no derived doc. The page still renders on the wiki.
|
|
780
|
-
// Unlike `embed`, which only suppresses the article inside a derived
|
|
781
|
-
// doc's description, this drops the page from the sync set entirely.
|
|
782
|
-
const sync = fo["sync"];
|
|
783
|
-
if (typeof sync === "boolean")
|
|
784
|
-
block.sync = sync;
|
|
785
|
-
// foundry.journal: false makes the derived doc without the JournalEntryPage
|
|
786
|
-
// that normally accompanies it. For a page that exists to carry a Scene or
|
|
787
|
-
// an Actor and has no article worth reading in the sidebar.
|
|
788
|
-
const journal = fo["journal"];
|
|
789
|
-
if (typeof journal === "boolean")
|
|
790
|
-
block.journal = journal;
|
|
791
|
-
// foundry.link: "doc" makes wikilinks to this page resolve to the document
|
|
792
|
-
// it instantiates rather than to its journal page. Implied by
|
|
793
|
-
// `journal: false`, where there is no journal page to link to.
|
|
794
|
-
const link = fo["link"];
|
|
795
|
-
if (link === "doc" || link === "journal")
|
|
796
|
-
block.link = link;
|
|
797
|
-
const data = fo["data"];
|
|
798
|
-
if (data && typeof data === "object" && !Array.isArray(data))
|
|
799
|
-
block.data = data;
|
|
800
|
-
// foundry.id: an explicit Foundry document id for this page. When set,
|
|
801
|
-
// overrides the SHA1-derived id used for both the JournalEntryPage and
|
|
802
|
-
// (if foundry.base is present) the instantiated derived doc. Lets users
|
|
803
|
-
// hardcode UUIDs that other Foundry-side code (macros, scene flags,
|
|
804
|
-
// module integrations) needs to reference. Foundry ids are 16 chars from
|
|
805
|
-
// [A-Za-z0-9]; a malformed value is dropped with a warning rather than
|
|
806
|
-
// failing the build.
|
|
807
|
-
const idVal = fo["id"];
|
|
808
|
-
if (typeof idVal === "string") {
|
|
809
|
-
const trimmed = idVal.trim();
|
|
810
|
-
if (FOUNDRY_ID_RE.test(trimmed))
|
|
811
|
-
block.id = trimmed;
|
|
812
|
-
else if (trimmed.length > 0) {
|
|
813
|
-
console.warn(` ${p.path}: foundry.id "${trimmed}" is not a valid Foundry id (16 chars [A-Za-z0-9]); ignoring`);
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
// foundry.data_json: vault-relative path to a JSON file. Read + parse
|
|
817
|
-
// at build time and inline into the meta as `data_json`. The Foundry
|
|
818
|
-
// module deep-merges it onto the base doc BEFORE foundry.data, so a
|
|
819
|
-
// user can layer hand-tuned overrides on top of an exported sheet.
|
|
820
|
-
// Folding the parsed object into meta means the body-row hash already
|
|
821
|
-
// changes when the JSON content does — no separate change-detection.
|
|
822
|
-
const dataJsonPath = fo["data_json"];
|
|
823
|
-
if (typeof dataJsonPath === "string" && dataJsonPath.trim().length > 0) {
|
|
824
|
-
const parsed = await loadDataJson(vaultPath, dataJsonPath.trim(), p.path);
|
|
825
|
-
if (parsed !== null)
|
|
826
|
-
block.data_json = parsed;
|
|
827
|
-
}
|
|
828
|
-
if (Object.keys(block).length > 0)
|
|
829
|
-
out.foundry = block;
|
|
830
|
-
}
|
|
831
|
-
if (p.coverImage)
|
|
832
|
-
out.image = p.coverImage;
|
|
833
|
-
return out;
|
|
834
|
-
}
|
|
835
|
-
/** Read + parse a vault-relative JSON file referenced by `foundry.data_json`.
|
|
836
|
-
* Warns on missing / unparseable file and returns null so the page renders
|
|
837
|
-
* without the overlay rather than failing the build. */
|
|
838
|
-
async function loadDataJson(vaultPath, relPath, pagePath) {
|
|
839
|
-
const abs = join(vaultPath, relPath);
|
|
840
|
-
try {
|
|
841
|
-
const raw = await readFile(abs, "utf8");
|
|
842
|
-
return JSON.parse(raw);
|
|
843
|
-
}
|
|
844
|
-
catch (err) {
|
|
845
|
-
const code = err.code;
|
|
846
|
-
if (code === "ENOENT") {
|
|
847
|
-
console.warn(` ${pagePath}: foundry.data_json "${relPath}" not found, skipping`);
|
|
848
|
-
}
|
|
849
|
-
else {
|
|
850
|
-
console.warn(` ${pagePath}: foundry.data_json "${relPath}" failed to parse: ${err.message}`);
|
|
851
|
-
}
|
|
852
|
-
return null;
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
/** Collect the `@vault/...` paths a page's foundry block references, from both
|
|
856
|
-
* `foundry.data_json` and `foundry.data`. A Scene's bulk asset refs
|
|
857
|
-
* (backgrounds, ambient sounds, tiles) live in that JSON content, and a token's
|
|
858
|
-
* ring subject lives in the inline `data` overlay; neither appears anywhere the
|
|
859
|
-
* per-variant asset scanners look, so without this they never ship and Foundry
|
|
860
|
-
* 404s them. Returns vault-relative paths. */
|
|
861
|
-
async function collectDataJsonVaultRefs(vaultPath, fm, pagePath) {
|
|
862
|
-
const fo = fm["foundry"];
|
|
863
|
-
if (!fo || typeof fo !== "object" || Array.isArray(fo))
|
|
864
|
-
return [];
|
|
865
|
-
const block = fo;
|
|
866
|
-
const out = [];
|
|
867
|
-
const collect = (from) => forEachString(from, (s) => {
|
|
868
|
-
const path = vaultRefPath(s);
|
|
869
|
-
if (path)
|
|
870
|
-
out.push(path);
|
|
871
|
-
});
|
|
872
|
-
const rel = block["data_json"];
|
|
873
|
-
if (typeof rel === "string" && rel.trim()) {
|
|
874
|
-
const parsed = await loadDataJson(vaultPath, rel.trim(), pagePath);
|
|
875
|
-
if (parsed !== null)
|
|
876
|
-
collect(parsed);
|
|
877
|
-
}
|
|
878
|
-
collect(block["data"]);
|
|
879
|
-
return out;
|
|
880
|
-
}
|
|
881
|
-
/** Foundry document ids: exactly 16 chars from [A-Za-z0-9]. Validated when
|
|
882
|
-
* authors set `foundry.id` to override the SHA1-derived default. */
|
|
883
|
-
const FOUNDRY_ID_RE = /^[A-Za-z0-9]{16}$/;
|
|
884
943
|
/** Coerce settings.theme to the layout's narrowed union, defaulting to
|
|
885
944
|
* "auto" for any unrecognised value rather than failing the build. */
|
|
886
945
|
function themeOf(s) {
|
|
@@ -889,119 +948,6 @@ function themeOf(s) {
|
|
|
889
948
|
function previewModeOf(s) {
|
|
890
949
|
return s === "none" || s === "sticky" ? s : "normal";
|
|
891
950
|
}
|
|
892
|
-
const EMBED_RE = /!\[\[([^\[\]|#\n]+?)(?:\|[^\[\]#\n]*)?\]\]/g;
|
|
893
|
-
// A ```gallery code block. Its body lists images by name (one per line,
|
|
894
|
-
// optional `| caption`), which the gallery handler renders but the source
|
|
895
|
-
// scanners would otherwise never see — so we read the block here to stage
|
|
896
|
-
// the referenced images per variant, the same way `![[ ]]` embeds are staged.
|
|
897
|
-
const GALLERY_BLOCK_RE = /^```gallery[^\n]*\n([\s\S]*?)^```/gm;
|
|
898
|
-
/** Image basenames referenced inside a page's ```gallery blocks. */
|
|
899
|
-
function galleryImageNames(source) {
|
|
900
|
-
const names = [];
|
|
901
|
-
for (const block of source.matchAll(GALLERY_BLOCK_RE)) {
|
|
902
|
-
for (const line of block[1].split("\n")) {
|
|
903
|
-
const trimmed = line.trim();
|
|
904
|
-
if (!trimmed || trimmed.startsWith("#"))
|
|
905
|
-
continue;
|
|
906
|
-
const name = (trimmed.split("|")[0] ?? "").trim();
|
|
907
|
-
if (name)
|
|
908
|
-
names.push(name);
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
return names;
|
|
912
|
-
}
|
|
913
|
-
async function copyReferencedImages(visibleSources, visibleMetas, imageIndex, stagingDir, variantDir) {
|
|
914
|
-
const refs = new Set();
|
|
915
|
-
for (const source of visibleSources.values()) {
|
|
916
|
-
for (const m of source.matchAll(EMBED_RE)) {
|
|
917
|
-
const name = m[1].trim();
|
|
918
|
-
if (!IMAGE_EXT_RE.test(name))
|
|
919
|
-
continue;
|
|
920
|
-
const image = imageIndex.get(slugify(name));
|
|
921
|
-
if (image)
|
|
922
|
-
refs.add(image.outputPath);
|
|
923
|
-
}
|
|
924
|
-
for (const name of galleryImageNames(source)) {
|
|
925
|
-
const image = imageIndex.get(slugify(name.split("/").pop()));
|
|
926
|
-
if (image)
|
|
927
|
-
refs.add(image.outputPath);
|
|
928
|
-
}
|
|
929
|
-
// Standard-Markdown image refs, ``. Without this
|
|
930
|
-
// only Obsidian `![[embed]]` syntax staged images, so CommonMark-syntax
|
|
931
|
-
// images rendered into HTML but 404'd on deploy. Mirrors the same MD_LINK_RE
|
|
932
|
-
// pass copyReferencedPassthroughs runs for `[label](file.pdf)` links.
|
|
933
|
-
for (const m of source.matchAll(MD_LINK_RE)) {
|
|
934
|
-
const name = m[1].trim();
|
|
935
|
-
if (/^(https?:|mailto:|#)/i.test(name))
|
|
936
|
-
continue;
|
|
937
|
-
if (!IMAGE_EXT_RE.test(name))
|
|
938
|
-
continue;
|
|
939
|
-
const image = imageIndex.get(slugify(name.split("/").pop()));
|
|
940
|
-
if (image)
|
|
941
|
-
refs.add(image.outputPath);
|
|
942
|
-
}
|
|
943
|
-
// Layers named inside ```battlemap blocks. A web-only layer (e.g. a
|
|
944
|
-
// composited tile overlay) has no other reference to stage it, so look
|
|
945
|
-
// it up by its full vault-relative path.
|
|
946
|
-
for (const path of battlemapLayerPaths(source)) {
|
|
947
|
-
const image = imageIndex.get(path);
|
|
948
|
-
if (image)
|
|
949
|
-
refs.add(image.outputPath);
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
// Pages can name their cover via `image:` frontmatter alone (no body embed);
|
|
953
|
-
// pull those in too. coverImage was resolved to the served URL upstream, so
|
|
954
|
-
// strip the leading slash + decode to get back to the staging-relative path.
|
|
955
|
-
// `@vault/PATH` references inside any frontmatter string field also gate
|
|
956
|
-
// an asset into this variant — common for Scene background.src / Playlist
|
|
957
|
-
// sound.path that point at vault-shipped media. Page-role gating still
|
|
958
|
-
// applies because we only walk visibleMetas (= pages this variant can see).
|
|
959
|
-
for (const p of visibleMetas) {
|
|
960
|
-
if (p.coverImage && !/^https?:\/\//i.test(p.coverImage)) {
|
|
961
|
-
try {
|
|
962
|
-
refs.add(decodeURIComponent(p.coverImage.replace(/^\//, "")));
|
|
963
|
-
}
|
|
964
|
-
catch { /* malformed coverImage URL — ignore */ }
|
|
965
|
-
}
|
|
966
|
-
if (p.frontmatter) {
|
|
967
|
-
forEachString(p.frontmatter, (s) => {
|
|
968
|
-
const path = vaultRefPath(s);
|
|
969
|
-
if (path && IMAGE_EXT_RE.test(path)) {
|
|
970
|
-
const image = imageIndex.get(path);
|
|
971
|
-
if (image)
|
|
972
|
-
refs.add(image.outputPath);
|
|
973
|
-
}
|
|
974
|
-
});
|
|
975
|
-
}
|
|
976
|
-
// Image refs inside the page's foundry.data_json (Scene backgrounds, tiles).
|
|
977
|
-
for (const path of p.foundryAssets ?? []) {
|
|
978
|
-
if (!IMAGE_EXT_RE.test(path))
|
|
979
|
-
continue;
|
|
980
|
-
const image = imageIndex.get(path);
|
|
981
|
-
if (image)
|
|
982
|
-
refs.add(image.outputPath);
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
for (const outputPath of refs) {
|
|
986
|
-
const src = join(stagingDir, outputPath);
|
|
987
|
-
const dst = join(variantDir, outputPath);
|
|
988
|
-
await mkdir(dirname(dst), { recursive: true });
|
|
989
|
-
try {
|
|
990
|
-
await copyFile(src, dst);
|
|
991
|
-
}
|
|
992
|
-
catch (err) {
|
|
993
|
-
// Source may legitimately be missing if the file is in the index but
|
|
994
|
-
// wasn't compressed (e.g. quality=0 path). Surface but don't crash.
|
|
995
|
-
console.warn(` warning: could not copy image ${outputPath}: ${err.message}`);
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
// `[label](path/to/file.ext)` style markdown link. Captures the URL part.
|
|
1000
|
-
// `\.[a-z0-9]+` requires an extension; we don't want to scoop up plain
|
|
1001
|
-
// internal page links (e.g. `(href)` without an extension).
|
|
1002
|
-
const MD_LINK_RE = /\[[^\]]*\]\(([^)\s]+\.[a-z0-9]+)(?:\s+["'][^"']*["'])?\)/gi;
|
|
1003
|
-
// `[[file.ext]]` and `![[file.ext]]` — Obsidian-flavoured wikilinks/embeds.
|
|
1004
|
-
const WIKI_LINK_RE = /!?\[\[([^\[\]|#\n]+\.[a-z0-9]+)(?:\|[^\[\]#\n]*)?(?:#[^\[\]\n]*)?\]\]/gi;
|
|
1005
951
|
// `> [!type]…` opens a callout; the rest of the contiguous blockquote (lines
|
|
1006
952
|
// starting with `>`, blank line ends) is its body. Used to strip role-gated
|
|
1007
953
|
// callouts from the source before any downstream pass sees it.
|
|
@@ -1041,98 +987,6 @@ function stripRoleGatedCallouts(source, redactRoles) {
|
|
|
1041
987
|
}
|
|
1042
988
|
return out.join("\n");
|
|
1043
989
|
}
|
|
1044
|
-
/**
|
|
1045
|
-
* Visit every string value reachable from `value` (object / array / scalar)
|
|
1046
|
-
* and call `fn` once per string. Used to surface `@vault/PATH` references
|
|
1047
|
-
* inside parsed frontmatter (e.g., a Scene's `foundry.data.background.src`
|
|
1048
|
-
* or a Playlist's `foundry.data.sounds[N].path`) so the per-variant asset
|
|
1049
|
-
* scanner can include those files alongside body-referenced ones.
|
|
1050
|
-
*/
|
|
1051
|
-
function forEachString(value, fn) {
|
|
1052
|
-
if (typeof value === "string")
|
|
1053
|
-
return fn(value);
|
|
1054
|
-
if (Array.isArray(value)) {
|
|
1055
|
-
for (const v of value)
|
|
1056
|
-
forEachString(v, fn);
|
|
1057
|
-
return;
|
|
1058
|
-
}
|
|
1059
|
-
if (value && typeof value === "object") {
|
|
1060
|
-
for (const v of Object.values(value))
|
|
1061
|
-
forEachString(v, fn);
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
/** Extract a vault path from a `@vault/PATH` string, or null when the
|
|
1065
|
-
* string isn't a vault reference. Trailing fragment / query stripped. */
|
|
1066
|
-
function vaultRefPath(s) {
|
|
1067
|
-
if (!s.startsWith("@vault/"))
|
|
1068
|
-
return null;
|
|
1069
|
-
const rest = s.slice("@vault/".length).split("#")[0].split("?")[0];
|
|
1070
|
-
return rest.length > 0 ? rest : null;
|
|
1071
|
-
}
|
|
1072
|
-
/**
|
|
1073
|
-
* Per-variant reference scan for passthrough files. A file lands in this
|
|
1074
|
-
* variant's deploy only if a visible page mentions it — same gating story
|
|
1075
|
-
* as images. Match patterns cover Obsidian embeds (`![[file.pdf]]`),
|
|
1076
|
-
* Obsidian wikilinks (`[[file.pdf]]`), and standard markdown links
|
|
1077
|
-
* (`[label](path/file.pdf)`). Anything not matched is dropped — that's
|
|
1078
|
-
* the whole point of the change; a stray DM-only audio cue stays in the
|
|
1079
|
-
* dm variant only.
|
|
1080
|
-
*/
|
|
1081
|
-
async function copyReferencedPassthroughs(visibleSources, visibleMetas, passthroughIndex, stagingDir, variantDir) {
|
|
1082
|
-
if (passthroughIndex.size === 0)
|
|
1083
|
-
return;
|
|
1084
|
-
const refs = new Set();
|
|
1085
|
-
for (const source of visibleSources.values()) {
|
|
1086
|
-
for (const m of source.matchAll(WIKI_LINK_RE)) {
|
|
1087
|
-
const name = m[1].trim();
|
|
1088
|
-
const entry = passthroughIndex.get(slugify(name.split("/").pop()));
|
|
1089
|
-
if (entry)
|
|
1090
|
-
refs.add(entry.outputPath);
|
|
1091
|
-
}
|
|
1092
|
-
for (const m of source.matchAll(MD_LINK_RE)) {
|
|
1093
|
-
const name = m[1].trim();
|
|
1094
|
-
// Skip http(s) links and anchor-only refs.
|
|
1095
|
-
if (/^(https?:|mailto:|#)/i.test(name))
|
|
1096
|
-
continue;
|
|
1097
|
-
const entry = passthroughIndex.get(slugify(name.split("/").pop()));
|
|
1098
|
-
if (entry)
|
|
1099
|
-
refs.add(entry.outputPath);
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
// `@vault/PATH` references inside any frontmatter string also gate a
|
|
1103
|
-
// passthrough into this variant. Same per-page-role visibility rules
|
|
1104
|
-
// (only walking visibleMetas) — a dm-tier page's @vault/Audio/secret.ogg
|
|
1105
|
-
// ships only to the dm variant.
|
|
1106
|
-
for (const p of visibleMetas) {
|
|
1107
|
-
if (!p.frontmatter)
|
|
1108
|
-
continue;
|
|
1109
|
-
forEachString(p.frontmatter, (s) => {
|
|
1110
|
-
const path = vaultRefPath(s);
|
|
1111
|
-
if (path) {
|
|
1112
|
-
const entry = passthroughIndex.get(path);
|
|
1113
|
-
if (entry)
|
|
1114
|
-
refs.add(entry.outputPath);
|
|
1115
|
-
}
|
|
1116
|
-
});
|
|
1117
|
-
// Audio/video/pdf refs inside the page's foundry.data_json (ambient sounds).
|
|
1118
|
-
for (const path of p.foundryAssets ?? []) {
|
|
1119
|
-
const entry = passthroughIndex.get(path);
|
|
1120
|
-
if (entry)
|
|
1121
|
-
refs.add(entry.outputPath);
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
for (const outputPath of refs) {
|
|
1125
|
-
const src = join(stagingDir, outputPath);
|
|
1126
|
-
const dst = join(variantDir, outputPath);
|
|
1127
|
-
await mkdir(dirname(dst), { recursive: true });
|
|
1128
|
-
try {
|
|
1129
|
-
await copyFile(src, dst);
|
|
1130
|
-
}
|
|
1131
|
-
catch (err) {
|
|
1132
|
-
console.warn(` warning: could not copy ${outputPath}: ${err.message}`);
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
990
|
/**
|
|
1137
991
|
* Build synthesised index.md for any folder (including the root) that has
|
|
1138
992
|
* pages but no existing index.md. When `inlineTitle` is true, the layout
|
|
@@ -1423,106 +1277,12 @@ function kindLabel(kind) {
|
|
|
1423
1277
|
default: return kind;
|
|
1424
1278
|
}
|
|
1425
1279
|
}
|
|
1426
|
-
async function buildManifest(rootDir, variantDir, bodyMeta, authRequired, roles, vaultName, assets) {
|
|
1427
|
-
const files = [];
|
|
1428
|
-
const seen = new Set();
|
|
1429
|
-
// Variant-specific files: use pathBase=variantDir so paths come out as
|
|
1430
|
-
// "index.html", not "_variants/<role>/index.html". This matches the public
|
|
1431
|
-
// URL the client uses; the auth middleware does the variant rewrite.
|
|
1432
|
-
await walkAndIndex(variantDir, variantDir, files, seen, [], bodyMeta);
|
|
1433
|
-
// Shared assets under the deploy root (attachments, css). Skip the variant
|
|
1434
|
-
// tree itself and anything inside `functions/` (Function code isn't served).
|
|
1435
|
-
if (rootDir !== variantDir) {
|
|
1436
|
-
await walkAndIndex(rootDir, rootDir, files, seen, [
|
|
1437
|
-
"_variants", "functions", ".image-staging", ".other-staging",
|
|
1438
|
-
], bodyMeta);
|
|
1439
|
-
}
|
|
1440
|
-
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
1441
|
-
// `auth.required` lets clients (Foundry, MCP) tell up-front whether the
|
|
1442
|
-
// deploy has middleware. Single-role builds collapse to a pure-static
|
|
1443
|
-
// deploy with no /_batch / /_connect endpoints — clients fall back to
|
|
1444
|
-
// direct CDN GETs in that case. `auth.roles` ships the role order
|
|
1445
|
-
// (lowest→highest) so clients can rank a page's tier against a chosen
|
|
1446
|
-
// cutoff (e.g. Foundry's per-vault dmRole).
|
|
1447
|
-
// `name` is the vault's display name (settings.md `vault_name`); clients
|
|
1448
|
-
// like the Foundry module use it as the default label + root folder when
|
|
1449
|
-
// a user adds the vault, so they get something readable instead of a
|
|
1450
|
-
// host-derived slug.
|
|
1451
|
-
// Asset advertisement so clients (Foundry, MCP) fetch the right paths
|
|
1452
|
-
// instead of guessing well-known names — lets us move things later.
|
|
1453
|
-
const assetBlock = {};
|
|
1454
|
-
if (assets.hasHandlerJs || assets.hasHandlerCss) {
|
|
1455
|
-
assetBlock.browser = {
|
|
1456
|
-
...(assets.hasHandlerJs ? { js: "/_handlers.js" } : {}),
|
|
1457
|
-
...(assets.hasHandlerCss ? { css: "/_handlers.css" } : {}),
|
|
1458
|
-
};
|
|
1459
|
-
}
|
|
1460
|
-
if (assets.hasFoundryJs || assets.hasFoundryCss) {
|
|
1461
|
-
assetBlock.foundry = {
|
|
1462
|
-
...(assets.hasFoundryJs ? { js: "/_handlers.foundry.js" } : {}),
|
|
1463
|
-
...(assets.hasFoundryCss ? { css: "/_handlers.foundry.css" } : {}),
|
|
1464
|
-
};
|
|
1465
|
-
}
|
|
1466
|
-
return {
|
|
1467
|
-
manifest_version: MANIFEST_VERSION,
|
|
1468
|
-
cli_version: CLI_VERSION,
|
|
1469
|
-
id_scheme: ID_SCHEME,
|
|
1470
|
-
name: vaultName,
|
|
1471
|
-
auth: { required: authRequired, roles },
|
|
1472
|
-
...(Object.keys(assetBlock).length > 0 ? { assets: assetBlock } : {}),
|
|
1473
|
-
files,
|
|
1474
|
-
};
|
|
1475
|
-
}
|
|
1476
|
-
async function walkAndIndex(dir, pathBase, out, seen, skipDirNames, bodyMeta) {
|
|
1477
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
1478
|
-
for (const ent of entries) {
|
|
1479
|
-
if (ent.name === "_manifest.json")
|
|
1480
|
-
continue;
|
|
1481
|
-
const abs = join(dir, ent.name);
|
|
1482
|
-
if (ent.isDirectory()) {
|
|
1483
|
-
if (skipDirNames.includes(ent.name))
|
|
1484
|
-
continue;
|
|
1485
|
-
await walkAndIndex(abs, pathBase, out, seen, skipDirNames, bodyMeta);
|
|
1486
|
-
continue;
|
|
1487
|
-
}
|
|
1488
|
-
if (!ent.isFile())
|
|
1489
|
-
continue;
|
|
1490
|
-
const path = relative(pathBase, abs).split(/[/\\]/).join("/");
|
|
1491
|
-
if (seen.has(path))
|
|
1492
|
-
continue;
|
|
1493
|
-
seen.add(path);
|
|
1494
|
-
const body = await readFile(abs);
|
|
1495
|
-
const info = await stat(abs);
|
|
1496
|
-
const meta = bodyMeta.get(path);
|
|
1497
|
-
// Fold meta JSON into the hash so meta-only edits (e.g. a foundry.base
|
|
1498
|
-
// tweak with no body change) still bump the row hash and trigger sync.
|
|
1499
|
-
const hasher = createHash("md5").update(body);
|
|
1500
|
-
if (meta)
|
|
1501
|
-
hasher.update("\x00meta:" + stableStringify(meta));
|
|
1502
|
-
out.push({
|
|
1503
|
-
path,
|
|
1504
|
-
hash: hasher.digest("hex"),
|
|
1505
|
-
size: info.size,
|
|
1506
|
-
mtime: Math.floor(info.mtimeMs / 1000),
|
|
1507
|
-
content_type: contentTypeForExt(ent.name),
|
|
1508
|
-
...(meta ? { meta } : {}),
|
|
1509
|
-
});
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1512
1280
|
/**
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1281
|
+
* Per-page extension on .body.html manifest entries, consumed by the Foundry
|
|
1282
|
+
* sync. Always carries the page's role (so the Foundry side can map roles to
|
|
1283
|
+
* JournalEntry ownership against a per-vault dmRole setting); other fields
|
|
1284
|
+
* are present only when the corresponding frontmatter is set.
|
|
1516
1285
|
*/
|
|
1517
|
-
function stableStringify(value) {
|
|
1518
|
-
if (value === null || typeof value !== "object")
|
|
1519
|
-
return JSON.stringify(value);
|
|
1520
|
-
if (Array.isArray(value))
|
|
1521
|
-
return "[" + value.map(stableStringify).join(",") + "]";
|
|
1522
|
-
const obj = value;
|
|
1523
|
-
const keys = Object.keys(obj).sort();
|
|
1524
|
-
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
|
|
1525
|
-
}
|
|
1526
1286
|
/**
|
|
1527
1287
|
* Strip an HTML body to plain text. Used to feed the search index from
|
|
1528
1288
|
* the rendered article (post-wikilink, post-callout-redaction) so search
|