@wizzlethorpe/vaults 0.13.5 → 0.15.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 +2 -2
- package/dist/asset-refs.js +282 -0
- package/dist/asset-refs.js.map +1 -0
- package/dist/auth.js.map +1 -1
- package/dist/build.js +393 -544
- package/dist/build.js.map +1 -1
- package/dist/commands/build.js +0 -4
- 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/escape.js +20 -0
- package/dist/escape.js.map +1 -1
- package/dist/foundry-adventure.js +86 -0
- package/dist/foundry-adventure.js.map +1 -0
- package/dist/foundry-defaults.js +65 -0
- package/dist/foundry-defaults.js.map +1 -0
- package/dist/foundry-grafts.js +610 -0
- package/dist/foundry-grafts.js.map +1 -0
- package/dist/foundry-html.js +196 -0
- package/dist/foundry-html.js.map +1 -0
- package/dist/foundry-importer.bundle.js +1192 -322
- package/dist/foundry-importer.js +2 -7
- package/dist/foundry-importer.js.map +1 -1
- package/dist/foundry-meta.js +56 -0
- package/dist/foundry-meta.js.map +1 -0
- package/dist/foundry-module-journal.js +133 -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 +1082 -0
- package/dist/foundry-module.js.map +1 -0
- package/dist/foundry-types.js +28 -0
- package/dist/foundry-types.js.map +1 -0
- package/dist/foundry-version.js +30 -0
- package/dist/foundry-version.js.map +1 -0
- package/dist/frontmatter-defaults.js +68 -0
- package/dist/frontmatter-defaults.js.map +1 -0
- package/dist/index.js +5 -9
- package/dist/index.js.map +1 -1
- package/dist/manifest.js +115 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrate/0.15-foundry-patch-keys.js +51 -0
- package/dist/migrate/0.15-foundry-patch-keys.js.map +1 -0
- package/dist/migrate/0.15-foundry-pinned-id.js +68 -0
- package/dist/migrate/0.15-foundry-pinned-id.js.map +1 -0
- package/dist/migrate/files.js +66 -0
- package/dist/migrate/files.js.map +1 -0
- package/dist/migrate/registry.js +4 -0
- package/dist/migrate/registry.js.map +1 -1
- package/dist/migrate/run.js +36 -2
- package/dist/migrate/run.js.map +1 -1
- package/dist/render/auth-template.js +331 -42
- 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/assets.js +5 -34
- package/dist/render/handlers/assets.js.map +1 -1
- package/dist/render/handlers/builtin/battlemap.js +4 -1
- package/dist/render/handlers/builtin/battlemap.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/fm-code.js +2 -2
- package/dist/render/handlers/builtin/fm-code.js.map +1 -1
- 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/fvtt-link.js +39 -0
- package/dist/render/handlers/builtin/fvtt-link.js.map +1 -0
- package/dist/render/handlers/builtin/index.js +4 -1
- package/dist/render/handlers/builtin/index.js.map +1 -1
- package/dist/render/handlers/builtin/statblock.js +0 -4
- package/dist/render/handlers/builtin/statblock.js.map +1 -1
- package/dist/render/handlers/types.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/settings.js +156 -21
- package/dist/settings.js.map +1 -1
- package/dist/zip.js +78 -0
- package/dist/zip.js.map +1 -0
- package/package.json +4 -3
package/dist/build.js
CHANGED
|
@@ -1,38 +1,44 @@
|
|
|
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 { warnFoundryDocCollisions } from "./foundry-meta.js";
|
|
9
13
|
import { compressImage } from "./images.js";
|
|
10
|
-
import { IMAGE_EXT_RE, PASSTHROUGH_EXT_RE, COMPRESSIBLE_EXT_RE,
|
|
14
|
+
import { IMAGE_EXT_RE, PASSTHROUGH_EXT_RE, COMPRESSIBLE_EXT_RE, } from "./render/extensions.js";
|
|
11
15
|
import { buildFavicon } from "./favicon.js";
|
|
12
16
|
import { renderMarkdown } from "./render/pipeline.js";
|
|
13
17
|
import { extractH1 } from "./render/frontmatter.js";
|
|
14
|
-
import { CLI_VERSION, MANIFEST_VERSION, ID_SCHEME } from "./version.js";
|
|
15
18
|
import { renderLayout, render404 } from "./render/layout.js";
|
|
16
|
-
import { writeFoundryImporter } from "./foundry-importer.js";
|
|
17
19
|
import { slugify } from "./render/slug.js";
|
|
18
20
|
import { buildPreview } from "./render/preview.js";
|
|
19
21
|
import { resolvePageImage } from "./render/cover.js";
|
|
20
22
|
import { DEFAULT_CSS, renderThemeOverride } from "./render/styles.js";
|
|
21
23
|
import { loadObsidianSnippets } from "./obsidian.js";
|
|
22
24
|
import { loadSettings, writeSettings, SETTINGS_FILE } from "./settings.js";
|
|
23
|
-
import { loadConfig } from "./config.js";
|
|
25
|
+
import { loadConfig, saveConfig } from "./config.js";
|
|
26
|
+
import { applyFrontmatterDefaults, compileFrontmatterRules } from "./frontmatter-defaults.js";
|
|
24
27
|
import matter from "gray-matter";
|
|
25
|
-
import { renderAuthMiddleware,
|
|
26
|
-
import { htmlAttr } from "./escape.js";
|
|
28
|
+
import { renderAuthMiddleware, renderLoginPage } from "./render/auth-template.js";
|
|
27
29
|
import { renderFooterHtml } from "./render/footer.js";
|
|
28
30
|
import { buildRegistry } from "./render/handlers/types.js";
|
|
29
|
-
import { battlemapLayerPaths } from "./render/handlers/builtin/battlemap.js";
|
|
30
31
|
import { loadUserHandlers } from "./render/handlers/loader.js";
|
|
31
32
|
import { BUILTIN_HANDLERS } from "./render/handlers/builtin/index.js";
|
|
32
33
|
import { bundleHandlerAssets } from "./render/handlers/assets.js";
|
|
33
34
|
import { runMigrations } from "./migrate/run.js";
|
|
34
35
|
import { cacheDir } from "./paths.js";
|
|
35
36
|
import { formatDuration, pMap, Progress } from "./util.js";
|
|
37
|
+
import { buildGrafts, contentHash, moduleManifest, moduleGrafts, packsFor, pagesFrom, withFolderIndexes } from "./foundry-grafts.js";
|
|
38
|
+
import { toFoundryHtml, dualVariantBody } from "./foundry-html.js";
|
|
39
|
+
import { zip } from "./zip.js";
|
|
40
|
+
import { moduleVersion } from "./foundry-version.js";
|
|
41
|
+
import { loadDataJson } from "./foundry-meta.js";
|
|
36
42
|
/**
|
|
37
43
|
* Output layout when there are multiple roles:
|
|
38
44
|
*
|
|
@@ -49,26 +55,110 @@ import { formatDuration, pMap, Progress } from "./util.js";
|
|
|
49
55
|
* Single-role builds (the default `public`-only case) collapse
|
|
50
56
|
* `_variants/public/...` up to the root.
|
|
51
57
|
*/
|
|
52
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Add basename-slug keys to an asset index, first-write-wins in the vault's
|
|
60
|
+
* sorted path order.
|
|
61
|
+
*
|
|
62
|
+
* Obsidian resolves `![[map.png]]` by basename, so two files with the same
|
|
63
|
+
* name in different folders compete for one key. Writing the key from inside
|
|
64
|
+
* the concurrent staging pass meant whichever finished second won, and that
|
|
65
|
+
* varied between builds on identical input — a page could silently get a
|
|
66
|
+
* different image run to run. Doing it here, sequentially over the sorted
|
|
67
|
+
* list, makes the winner deterministic and lets us say which files collided.
|
|
68
|
+
* (Full-path keys stay in the staging pass; those are unique by definition.)
|
|
69
|
+
*/
|
|
70
|
+
function addBasenameKeys(index, files, label) {
|
|
71
|
+
const claimed = new Map(); // slug → winning source path
|
|
72
|
+
for (const f of files) {
|
|
73
|
+
const entry = index.get(f.path);
|
|
74
|
+
if (!entry)
|
|
75
|
+
continue; // staging failed for this file; nothing to point at
|
|
76
|
+
const slug = slugify(f.path.split("/").pop());
|
|
77
|
+
const winner = claimed.get(slug);
|
|
78
|
+
if (winner === undefined) {
|
|
79
|
+
claimed.set(slug, f.path);
|
|
80
|
+
index.set(slug, entry);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
console.warn(` ${label} name collision: '${f.path}' and '${winner}' share a filename. `
|
|
84
|
+
+ `Bare references like the basename resolve to '${winner}'; `
|
|
85
|
+
+ `use the folder path to reach the other.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Emit `sitemap.xml` and `robots.txt` at the deploy root.
|
|
90
|
+
*
|
|
91
|
+
* **Only the default role's pages are listed.** A sitemap naming gated pages
|
|
92
|
+
* would advertise that they exist, and their URLs, to anyone who fetches it —
|
|
93
|
+
* the middleware would still refuse the content, but the leak is the point of
|
|
94
|
+
* a sitemap, so it must never see above the lowest tier.
|
|
95
|
+
*
|
|
96
|
+
* Both files are written only when `site_url` is set, because a sitemap needs
|
|
97
|
+
* absolute URLs and nothing else in the build knows the deploy's public
|
|
98
|
+
* hostname (a Pages project can answer on several).
|
|
99
|
+
*/
|
|
100
|
+
async function writeSitemap(outputDir, siteUrl, pagePaths) {
|
|
101
|
+
const base = siteUrl.replace(/\/+$/, "");
|
|
102
|
+
const urls = pagePaths
|
|
103
|
+
.map((p) => p.replace(/\.md$/i, ""))
|
|
104
|
+
.map((p) => (p === "index" ? "" : p.replace(/\/index$/i, "")))
|
|
105
|
+
.sort()
|
|
106
|
+
.map((p) => `${base}/${p.split("/").map(encodeURIComponent).join("/")}`)
|
|
107
|
+
// index.md becomes the bare base URL rather than "<base>/".
|
|
108
|
+
.map((u) => u.replace(/\/$/, ""));
|
|
109
|
+
const body = [...new Set(urls)]
|
|
110
|
+
.map((u) => ` <url><loc>${htmlEscape(u)}</loc></url>`)
|
|
111
|
+
.join("\n");
|
|
112
|
+
await writeFile(join(outputDir, "sitemap.xml"), `<?xml version="1.0" encoding="UTF-8"?>\n`
|
|
113
|
+
+ `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`);
|
|
114
|
+
await writeFile(join(outputDir, "robots.txt"), `User-agent: *\nAllow: /\nSitemap: ${base}/sitemap.xml\n`);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Warn about a `default_frontmatter` rule assigning a role the vault has not
|
|
118
|
+
* configured.
|
|
119
|
+
*
|
|
120
|
+
* `default_role` validated itself and said so when it was wrong. Moving the
|
|
121
|
+
* job into a rule would have dropped that: a typo would silently supply a role
|
|
122
|
+
* nothing recognises, and every page it matched would fall back to the lowest
|
|
123
|
+
* tier — publishing a vault meant to be private, quietly.
|
|
124
|
+
*/
|
|
125
|
+
function warnUnknownRoles(rules, known, roles) {
|
|
126
|
+
for (const rule of rules) {
|
|
127
|
+
const role = rule.data?.["role"];
|
|
128
|
+
if (typeof role === "string" && role && !known.has(role)) {
|
|
129
|
+
console.warn(` settings.md: default_frontmatter rule '${rule.match}' assigns role `
|
|
130
|
+
+ `"${role}", which is not one of [${roles.join(", ")}]. Pages matching it `
|
|
131
|
+
+ `fall back to "${roles[0]}".`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export async function buildSite(input) {
|
|
53
136
|
const start = Date.now();
|
|
54
137
|
const concurrency = Math.max(2, availableParallelism());
|
|
55
138
|
// Run any pending schema / layout migrations before reading anything
|
|
56
139
|
// else. The framework is idempotent: already-migrated vaults pay only
|
|
57
140
|
// the cost of a few stat() calls. See cli/src/migrate/.
|
|
58
|
-
await runMigrations(
|
|
141
|
+
await runMigrations(input.vaultPath);
|
|
59
142
|
// ── Settings (user-editable) ─────────────────────────────────────────────
|
|
60
|
-
const settings = await loadSettings(
|
|
143
|
+
const settings = await loadSettings(input.vaultPath);
|
|
61
144
|
for (const w of settings.warnings)
|
|
62
145
|
console.warn(` ${w}`);
|
|
63
146
|
if (settings.exists && settings.changed) {
|
|
64
|
-
await writeSettings(
|
|
147
|
+
await writeSettings(input.vaultPath, settings.values);
|
|
65
148
|
console.log(` rewrote ${SETTINGS_FILE} to canonical format`);
|
|
66
149
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
150
|
+
// settings.md is the single source of truth for vault properties (see the
|
|
151
|
+
// SCHEMA in settings.ts). These used to also be CLI flags, with "was the
|
|
152
|
+
// flag passed?" inferred by comparing against the flag's default — and the
|
|
153
|
+
// defaults matched the schema's, so `build -q 85` against an
|
|
154
|
+
// `image_quality: 40` vault silently produced 40. The flags are gone; the
|
|
155
|
+
// vault decides.
|
|
156
|
+
let opts = {
|
|
157
|
+
...input,
|
|
158
|
+
siteUrl: settings.values.site_url,
|
|
159
|
+
vaultName: settings.values.vault_name,
|
|
160
|
+
imageQuality: settings.values.image_quality,
|
|
161
|
+
maxFileBytes: settings.values.max_file_bytes,
|
|
72
162
|
};
|
|
73
163
|
// ── Custom handlers ──────────────────────────────────────────────────────
|
|
74
164
|
// Built-ins ship with the CLI; user handlers live in `.vaults/handlers/`
|
|
@@ -94,20 +184,13 @@ export async function buildSite(opts) {
|
|
|
94
184
|
const cfg = await loadConfig(opts.vaultPath, {});
|
|
95
185
|
const roles = cfg.roles.length > 0 ? cfg.roles : ["public"];
|
|
96
186
|
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
|
-
}
|
|
187
|
+
// A page's role comes from its frontmatter, and `default_frontmatter` is
|
|
188
|
+
// what supplies one to pages that state none — a DM-by-default vault sets
|
|
189
|
+
// `role: dm` in a rule matching `**`. This is the floor for anything that
|
|
190
|
+
// reaches here without a role at all, which means a vault whose rules do not
|
|
191
|
+
// cover it.
|
|
192
|
+
const defaultRole = roles[0];
|
|
193
|
+
warnUnknownRoles(settings.values.default_frontmatter, allRoleSet, roles);
|
|
111
194
|
// ── Scan + filter ────────────────────────────────────────────────────────
|
|
112
195
|
console.log(`Scanning ${opts.vaultPath}...`);
|
|
113
196
|
const scanStart = Date.now();
|
|
@@ -151,9 +234,45 @@ export async function buildSite(opts) {
|
|
|
151
234
|
// the passthrough pool (still reference-gated). The user-facing
|
|
152
235
|
// warning lists exactly which paths got dropped so unintentional
|
|
153
236
|
// omissions surface immediately.
|
|
237
|
+
// Read the ```download blocks first: a file named by one is shipped whatever
|
|
238
|
+
// its extension, so it must not also be reported as skipped. Warning about a
|
|
239
|
+
// file that then gets staged sends the reader off to set
|
|
240
|
+
// include_unknown_files for no reason.
|
|
241
|
+
const downloadPaths = new Set();
|
|
242
|
+
const manifestPaths = new Set();
|
|
243
|
+
const manifestDownloads = new Map();
|
|
244
|
+
for (const f of markdownFiles) {
|
|
245
|
+
const source = await readFile(f.absolute, "utf8");
|
|
246
|
+
for (const path of downloadFilePaths(source))
|
|
247
|
+
downloadPaths.add(path);
|
|
248
|
+
for (const path of foundryManifestPaths(source))
|
|
249
|
+
manifestPaths.add(path);
|
|
250
|
+
}
|
|
251
|
+
// A foundry-manifest block names only the manifest. The zip is whatever the
|
|
252
|
+
// manifest's own download field says, so read it and ship that too — the
|
|
253
|
+
// author should not have to repeat a path the manifest already states, and
|
|
254
|
+
// an install needs both halves present or it fails on the second fetch.
|
|
255
|
+
for (const rel of manifestPaths) {
|
|
256
|
+
downloadPaths.add(rel);
|
|
257
|
+
const file = withinLimit.find((f) => f.path === rel);
|
|
258
|
+
if (!file)
|
|
259
|
+
continue;
|
|
260
|
+
const { path, absolute } = manifestDownloadPath(await readFile(file.absolute, "utf8"), settings.values.site_url);
|
|
261
|
+
if (absolute) {
|
|
262
|
+
console.warn(` ${rel}: "download" points outside this vault (${absolute}), so the file is not`
|
|
263
|
+
+ ` staged into the deploy. If it is meant to be this vault's own file, either`
|
|
264
|
+
+ ` set 'site_url' to the host it names or write the path relative and let the`
|
|
265
|
+
+ ` build make it absolute.`);
|
|
266
|
+
}
|
|
267
|
+
if (path) {
|
|
268
|
+
downloadPaths.add(path);
|
|
269
|
+
manifestDownloads.set(rel, path);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
154
272
|
const unknownFiles = withinLimit.filter((f) => !/\.md$|\.base$/i.test(f.path)
|
|
155
273
|
&& !IMAGE_EXT_RE.test(f.path)
|
|
156
|
-
&& !PASSTHROUGH_EXT_RE.test(f.path)
|
|
274
|
+
&& !PASSTHROUGH_EXT_RE.test(f.path)
|
|
275
|
+
&& !downloadPaths.has(f.path));
|
|
157
276
|
const includeUnknown = settings.values.include_unknown_files;
|
|
158
277
|
if (unknownFiles.length > 0) {
|
|
159
278
|
if (includeUnknown) {
|
|
@@ -173,7 +292,26 @@ export async function buildSite(opts) {
|
|
|
173
292
|
// Effective passthrough list: recognised media plus (optionally) unknowns.
|
|
174
293
|
const stagedPassthroughs = includeUnknown
|
|
175
294
|
? [...passthroughFiles, ...unknownFiles]
|
|
176
|
-
: passthroughFiles;
|
|
295
|
+
: [...passthroughFiles];
|
|
296
|
+
// Files named by a ```download block join the pool whatever their
|
|
297
|
+
// extension. A download is usually a .zip or a module.json, which the
|
|
298
|
+
// passthrough list calls unknown and drops — but naming one in a block is
|
|
299
|
+
// the author asking for it by name, which is exactly the intent
|
|
300
|
+
// include_unknown_files exists to require. Role gating is untouched: these
|
|
301
|
+
// are still reference-gated, so a download on a patron page reaches the
|
|
302
|
+
// patron variant and no other.
|
|
303
|
+
if (downloadPaths.size > 0) {
|
|
304
|
+
const already = new Set(stagedPassthroughs.map((f) => f.path));
|
|
305
|
+
const promoted = withinLimit.filter((f) => downloadPaths.has(f.path) && !already.has(f.path));
|
|
306
|
+
const missing = [...downloadPaths].filter((p) => !withinLimit.some((f) => f.path === p));
|
|
307
|
+
for (const p of missing) {
|
|
308
|
+
console.warn(` download block names '${p}', which is not in the vault; the link will 404.`);
|
|
309
|
+
}
|
|
310
|
+
if (promoted.length > 0) {
|
|
311
|
+
console.log(` staging ${promoted.length} file(s) named by download / foundry-manifest blocks`);
|
|
312
|
+
stagedPassthroughs.push(...promoted);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
177
315
|
// ── Shared content (read once, reused across roles) ─────────────────────
|
|
178
316
|
const sources = new Map();
|
|
179
317
|
await pMap(markdownFiles, concurrency, async (f) => {
|
|
@@ -191,9 +329,17 @@ export async function buildSite(opts) {
|
|
|
191
329
|
// normalized for Obsidian quirks first; malformed YAML throws inside
|
|
192
330
|
// parsePageFrontmatter and aborts the build rather than silently dropping a
|
|
193
331
|
// page's metadata (and, with it, its role gate).
|
|
332
|
+
const frontmatterRules = compileFrontmatterRules(settings.values.default_frontmatter);
|
|
194
333
|
const parsedSources = new Map();
|
|
195
334
|
for (const f of markdownFiles) {
|
|
196
|
-
|
|
335
|
+
const parsed = parsePageFrontmatter(sources.get(f.path), f.path);
|
|
336
|
+
// Applied here, at the one place a page's frontmatter is read, so that
|
|
337
|
+
// roles, the rendered wiki, the manifest the Foundry client syncs from and
|
|
338
|
+
// the module compiler all see the same page. A default that only some of
|
|
339
|
+
// them honoured would be a way for a synced vault and an installed module
|
|
340
|
+
// to disagree about the same file.
|
|
341
|
+
applyFrontmatterDefaults(f.path, parsed.data, frontmatterRules);
|
|
342
|
+
parsedSources.set(f.path, parsed);
|
|
197
343
|
}
|
|
198
344
|
// Derive role/title/aliases per page from that parse. A role that's present
|
|
199
345
|
// but isn't a configured role fails the build (collected below): falling back
|
|
@@ -225,7 +371,7 @@ export async function buildSite(opts) {
|
|
|
225
371
|
if (roleErrors.length > 0) {
|
|
226
372
|
throw new Error(`Unknown frontmatter role on ${roleErrors.length} page(s) (build aborted so gated pages can't leak):\n${roleErrors.join("\n")}`);
|
|
227
373
|
}
|
|
228
|
-
// Stage assets referenced inside each page's foundry.
|
|
374
|
+
// Stage assets referenced inside each page's foundry.patch_json (Scene
|
|
229
375
|
// backgrounds / ambient sounds / tile art live in that JSON, not the page
|
|
230
376
|
// frontmatter, so the asset scanners below consult p.foundryAssets).
|
|
231
377
|
await Promise.all(allPageMetas.map(async (p) => {
|
|
@@ -265,12 +411,13 @@ export async function buildSite(opts) {
|
|
|
265
411
|
// key is what stops identically-named assets in different scene folders
|
|
266
412
|
// (e.g. a shared `Water Fountain (Loop).ogg`) from colliding under one
|
|
267
413
|
// basename slug and staging only one of them.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
imageIndex.set(f.path,
|
|
414
|
+
// Full-path key only; basename keys are added afterwards, in sorted
|
|
415
|
+
// order, so a duplicated filename resolves deterministically.
|
|
416
|
+
imageIndex.set(f.path, { sourcePath: f.path, outputPath: compressed.outputPath });
|
|
271
417
|
}, (done, total) => progress.update(done, total));
|
|
272
418
|
progress.done(`${imageFiles.length} processed (${cacheHits} cached, ${imageFiles.length - cacheHits} compressed)`);
|
|
273
419
|
}
|
|
420
|
+
addBasenameKeys(imageIndex, imageFiles, "image");
|
|
274
421
|
// ── Passthrough files (audio, video, PDF, epub) ────────────────────────
|
|
275
422
|
// Staged once and copied into a variant only when a visible page in that
|
|
276
423
|
// variant references the file by basename or relative path. Same gating
|
|
@@ -288,12 +435,11 @@ export async function buildSite(opts) {
|
|
|
288
435
|
// Dual-keyed like imageIndex: basename slug for body refs, full
|
|
289
436
|
// vault-relative path for `@vault/PATH` refs (ambient sounds in
|
|
290
437
|
// 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);
|
|
438
|
+
passthroughIndex.set(f.path, { sourcePath: f.path, outputPath: f.path });
|
|
294
439
|
}, (done, total) => progress.update(done, total));
|
|
295
440
|
progress.done(`${stagedPassthroughs.length} staged`);
|
|
296
441
|
}
|
|
442
|
+
addBasenameKeys(passthroughIndex, stagedPassthroughs, "passthrough");
|
|
297
443
|
// Shared CSS bundle.
|
|
298
444
|
//
|
|
299
445
|
// Every file written to outputDir ROOT (rather than into _variants/<role>/)
|
|
@@ -325,20 +471,13 @@ export async function buildSite(opts) {
|
|
|
325
471
|
// One combined hash keeps it simple; any shared-asset change busts all.
|
|
326
472
|
// katex version rides in the hash so a dependency upgrade re-fetches the
|
|
327
473
|
// (otherwise never-changing) /katex/katex.min.css on math pages.
|
|
328
|
-
const katexVersion =
|
|
474
|
+
const katexVersion = katexRequire()("katex/package.json").version;
|
|
329
475
|
const assetVersion = createHash("md5")
|
|
330
476
|
.update(DEFAULT_CSS + themeOverride + userCss + handlerAssets.js + handlerAssets.css + katexVersion)
|
|
331
477
|
.digest("hex")
|
|
332
478
|
.slice(0, 10);
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
// verifies against its trust cache.
|
|
336
|
-
await writeFoundryImporter(opts.outputDir);
|
|
337
|
-
// Foundry-import bundles are written per-variant inside the role loop
|
|
338
|
-
// below (instead of at the root) so the middleware role-gates them. A
|
|
339
|
-
// public visitor can't fetch the dm-tier handler bundle even if it
|
|
340
|
-
// contains different content. The path stays `/_handlers.foundry.{js,css}`
|
|
341
|
-
// — the middleware rewrites root requests to the matching variant.
|
|
479
|
+
const foundryEnabled = settings.values.foundry.package !== "none";
|
|
480
|
+
const foundryPackaging = settings.values.foundry.package === "adventure" ? "adventure" : "compendium";
|
|
342
481
|
// Favicon; either user-supplied via settings.favicon, or a generated
|
|
343
482
|
// default with the vault's first letter in accent on the theme background.
|
|
344
483
|
try {
|
|
@@ -379,10 +518,65 @@ export async function buildSite(opts) {
|
|
|
379
518
|
if (cover)
|
|
380
519
|
meta.coverImage = cover;
|
|
381
520
|
}
|
|
521
|
+
warnFoundryDocCollisions(allPageMetas);
|
|
522
|
+
// Said once for the vault, not once per role: it is a fact about the
|
|
523
|
+
// settings, and the per-variant emitter would repeat it for each.
|
|
524
|
+
if (foundryEnabled && !settings.values.foundry.core_version
|
|
525
|
+
&& allPageMetas.some((p) => p.frontmatter?.["foundry"]?.source)) {
|
|
526
|
+
console.warn(" foundry.core_version is not set, so documents carry no _stats.coreVersion."
|
|
527
|
+
+ " Foundry rejects those and builds a degraded copy instead: a Scene loses its levels."
|
|
528
|
+
+ " Set it to the full Foundry version your exported JSON came from, e.g. 14.359.");
|
|
529
|
+
}
|
|
530
|
+
// The module is only emitted when there is a URL to fetch the vault from,
|
|
531
|
+
// so without one the deploy carries content Foundry has no way to install.
|
|
532
|
+
if (foundryEnabled && !opts.siteUrl) {
|
|
533
|
+
console.warn(" site_url is not set, so no Foundry module is written."
|
|
534
|
+
+ " The vault deploys, but there is nothing to install in Foundry."
|
|
535
|
+
+ " Set it to the URL this vault is served from, e.g. https://notes.example.com.");
|
|
536
|
+
}
|
|
537
|
+
// Read each page's `foundry.patch_json` once, before any variant is
|
|
538
|
+
// rendered: the file is the same whoever is reading, and a Scene sidecar is
|
|
539
|
+
// the largest thing in the vault to be re-parsing per role. It stays separate
|
|
540
|
+
// from the page's inline patch, because it is a weaker statement than one —
|
|
541
|
+
// an export carries whatever Foundry had, including its placeholders.
|
|
542
|
+
const foundryPatches = new Map();
|
|
543
|
+
await Promise.all(allPageMetas.map(async (p) => {
|
|
544
|
+
const fo = p.frontmatter?.["foundry"];
|
|
545
|
+
if (!fo || typeof fo !== "object" || Array.isArray(fo))
|
|
546
|
+
return;
|
|
547
|
+
const block = fo;
|
|
548
|
+
const ref = block["patch_json"];
|
|
549
|
+
if (typeof ref !== "string" || !ref.trim())
|
|
550
|
+
return;
|
|
551
|
+
const loaded = await loadDataJson(opts.vaultPath, ref.trim(), p.path);
|
|
552
|
+
if (!loaded || typeof loaded !== "object" || Array.isArray(loaded))
|
|
553
|
+
return;
|
|
554
|
+
foundryPatches.set(p.path, loaded);
|
|
555
|
+
}));
|
|
556
|
+
// A module id from the vault name: stable, lowercase, no spaces. It names
|
|
557
|
+
// the packs too, so changing it orphans what a reader already built.
|
|
558
|
+
const foundryModuleId = opts.vaultName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
|
559
|
+
|| "vault";
|
|
382
560
|
// ── Per-role variant builds ─────────────────────────────────────────────
|
|
383
561
|
const perRolePageCount = {};
|
|
384
562
|
const collapseToRoot = roles.length === 1;
|
|
563
|
+
let defaultRolePagePaths = [];
|
|
385
564
|
let katexCopied = false;
|
|
565
|
+
// What the Foundry provider compares a cached file against, so a rebuild
|
|
566
|
+
// re-fetches only what changed. Keyed "<variant>/<path>", and hashed once
|
|
567
|
+
// per asset rather than once per variant: every variant copies the same
|
|
568
|
+
// staged file. Roles run least privileged first, so by the time a variant
|
|
569
|
+
// writes its grafts.json the map holds every variant it can reference.
|
|
570
|
+
const assetHashes = new Map();
|
|
571
|
+
const hashOf = new Map();
|
|
572
|
+
const hashAsset = async (path, dir) => {
|
|
573
|
+
let digest = hashOf.get(path);
|
|
574
|
+
if (!digest) {
|
|
575
|
+
digest = createHash("md5").update(await readFile(join(dir, path))).digest("hex").slice(0, 16);
|
|
576
|
+
hashOf.set(path, digest);
|
|
577
|
+
}
|
|
578
|
+
return digest;
|
|
579
|
+
};
|
|
386
580
|
for (const role of roles) {
|
|
387
581
|
const variantDir = collapseToRoot
|
|
388
582
|
? opts.outputDir
|
|
@@ -400,6 +594,7 @@ export async function buildSite(opts) {
|
|
|
400
594
|
role,
|
|
401
595
|
visibleRoles,
|
|
402
596
|
redactRoles,
|
|
597
|
+
gated: !collapseToRoot,
|
|
403
598
|
variantDir,
|
|
404
599
|
vaultName: opts.vaultName,
|
|
405
600
|
vaultPath: opts.vaultPath,
|
|
@@ -408,6 +603,7 @@ export async function buildSite(opts) {
|
|
|
408
603
|
parsedSources,
|
|
409
604
|
baseSources,
|
|
410
605
|
imageIndex,
|
|
606
|
+
manifestDownloads,
|
|
411
607
|
imageStagingDir,
|
|
412
608
|
passthroughIndex,
|
|
413
609
|
passthroughStagingDir: otherStagingDir,
|
|
@@ -422,6 +618,12 @@ export async function buildSite(opts) {
|
|
|
422
618
|
allWarnings: opts.allWarnings,
|
|
423
619
|
});
|
|
424
620
|
perRolePageCount[role] = stats.pageCount;
|
|
621
|
+
for (const path of stats.assetPaths) {
|
|
622
|
+
assetHashes.set(`${role}/${path}`, await hashAsset(path, variantDir));
|
|
623
|
+
}
|
|
624
|
+
// Only the default (lowest) role feeds the sitemap; see writeSitemap.
|
|
625
|
+
if (role === roles[0])
|
|
626
|
+
defaultRolePagePaths = stats.pagePaths;
|
|
425
627
|
if (!collapseToRoot)
|
|
426
628
|
console.log(` variant '${role}': ${stats.pageCount} pages`);
|
|
427
629
|
// KaTeX stylesheet + fonts, shared at the deploy root. Copied lazily on
|
|
@@ -431,36 +633,98 @@ export async function buildSite(opts) {
|
|
|
431
633
|
await copyKatexAssets(join(opts.outputDir, "katex"));
|
|
432
634
|
katexCopied = true;
|
|
433
635
|
}
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
636
|
+
// The entry list graft builds from, inside the variant directory so the
|
|
637
|
+
// auth middleware gates it: a role only ever receives the pages it may
|
|
638
|
+
// read, and the GM's variant is the one that lists everything.
|
|
639
|
+
if (foundryEnabled) {
|
|
640
|
+
const graftPages = withFolderIndexes(pagesFrom(allPageMetas, visibleRoles, foundryPatches), roles);
|
|
641
|
+
const grafts = buildGrafts(graftPages, {
|
|
642
|
+
vaultId: foundryModuleId,
|
|
643
|
+
roles,
|
|
644
|
+
playerRole: settings.values.foundry.player_role,
|
|
645
|
+
buildRole: role,
|
|
646
|
+
packs: packsFor(foundryModuleId),
|
|
647
|
+
coreVersion: settings.values.foundry.core_version,
|
|
648
|
+
system: settings.values.foundry.system,
|
|
649
|
+
packaging: foundryPackaging,
|
|
650
|
+
title: opts.vaultName,
|
|
651
|
+
assets: Object.fromEntries(assetHashes),
|
|
652
|
+
});
|
|
653
|
+
for (const warning of grafts.warnings)
|
|
654
|
+
console.warn(` warning: ${warning}`);
|
|
655
|
+
// A second body per page, with links resolved to UUIDs and media pointed
|
|
656
|
+
// at markers the provider fills in. Foundry gets this one; the wiki keeps
|
|
657
|
+
// the plain `.body.html`, since the same HTML cannot serve both.
|
|
658
|
+
// A player-visible page's body carries both renders: the GM's inside a
|
|
659
|
+
// secret section, the player's in the open (see dualVariantBody). Roles
|
|
660
|
+
// build lowest first, so the player variant's body is already on disk.
|
|
661
|
+
const playerRole = settings.values.foundry.player_role;
|
|
662
|
+
const ceiling = roles.indexOf(playerRole);
|
|
663
|
+
const playerDir = collapseToRoot
|
|
664
|
+
? opts.outputDir
|
|
665
|
+
: join(opts.outputDir, "_variants", playerRole);
|
|
666
|
+
// Only bodies an entry references: a page kept out of Foundry, or one
|
|
667
|
+
// whose prose goes nowhere, must not move the hash and prompt a rebuild.
|
|
668
|
+
const referenced = new Set([...JSON.stringify(grafts.file.entries).matchAll(/@vaults\/[^/"]+\/((?:[^"\\]|\\.)+?)\.foundry\.html/g)]
|
|
669
|
+
.map((m) => JSON.parse(`"${m[1]}"`) + ".md"));
|
|
670
|
+
const bodyHashes = new Map();
|
|
671
|
+
await pMap(graftPages.filter((p) => referenced.has(p.path)), concurrency, async (page) => {
|
|
672
|
+
const base = page.path.replace(/\.md$/i, "");
|
|
673
|
+
const body = await readFile(join(variantDir, `${base}.body.html`), "utf8");
|
|
674
|
+
const gm = toFoundryHtml(body, grafts.links, role);
|
|
675
|
+
const observable = ceiling >= 0 && roles.indexOf(page.role) <= ceiling;
|
|
676
|
+
const dual = observable && roles.indexOf(role) > ceiling;
|
|
677
|
+
const out = dual
|
|
678
|
+
? dualVariantBody(gm, toFoundryHtml(await readFile(join(playerDir, `${base}.body.html`), "utf8"), grafts.links, playerRole))
|
|
679
|
+
: gm;
|
|
680
|
+
await writeFile(join(variantDir, `${base}.foundry.html`), out);
|
|
681
|
+
bodyHashes.set(page.path, createHash("md5").update(out).digest("hex"));
|
|
682
|
+
});
|
|
683
|
+
grafts.file.contentHash = contentHash(grafts.file.entries, grafts.file.assets ?? {}, bodyHashes);
|
|
684
|
+
await mkdir(join(variantDir, "_foundry"), { recursive: true });
|
|
685
|
+
await writeFile(join(variantDir, "_foundry", "grafts.json"), JSON.stringify(grafts.file, null, 2));
|
|
686
|
+
// The freshness signal, apart from the 1MB+ file it describes: the
|
|
687
|
+
// module reads this on world load to ask "anything new since I built?".
|
|
688
|
+
await writeFile(join(variantDir, "_foundry", "version.json"), JSON.stringify({ content: grafts.file.contentHash }));
|
|
451
689
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
690
|
+
}
|
|
691
|
+
// The module a reader installs, served by the vault itself: no release, no
|
|
692
|
+
// zip, no registry — the GM sends players a link to their own vault. It is
|
|
693
|
+
// generated once and then inert, so pushing content never reinstalls it.
|
|
694
|
+
if (foundryEnabled && opts.siteUrl) {
|
|
695
|
+
const dir = join(opts.outputDir, "_foundry");
|
|
696
|
+
await mkdir(dir, { recursive: true });
|
|
697
|
+
// Versioned from the manifest itself, so it reads as a date and moves only
|
|
698
|
+
// when the module changes. `assetVersion` is a hash of the stylesheet: it
|
|
699
|
+
// cannot be ordered, which is what Foundry needs to offer an update.
|
|
700
|
+
const manifest = moduleManifest({
|
|
701
|
+
moduleId: foundryModuleId,
|
|
702
|
+
title: opts.vaultName,
|
|
703
|
+
vaultUrl: opts.siteUrl,
|
|
704
|
+
systemId: settings.values.foundry.system,
|
|
705
|
+
packaging: foundryPackaging,
|
|
706
|
+
extra: settings.values.foundry.module,
|
|
462
707
|
});
|
|
463
|
-
|
|
708
|
+
const stamped = moduleVersion(manifest, cfg.foundryModule);
|
|
709
|
+
manifest["version"] = stamped.version;
|
|
710
|
+
if (stamped.version !== cfg.foundryModule?.version) {
|
|
711
|
+
await saveConfig(input.vaultPath, { ...cfg, foundryModule: stamped });
|
|
712
|
+
}
|
|
713
|
+
await writeFile(join(dir, "module.json"), JSON.stringify(manifest, null, 2));
|
|
714
|
+
// The module itself, which is what Foundry installs from the manifest. It
|
|
715
|
+
// holds no content: a manifest, and the one line naming the vault to read.
|
|
716
|
+
//
|
|
717
|
+
// The marker exists only inside the archive. graft reads a module's entry
|
|
718
|
+
// file from `modules/<id>/grafts.json` — the installed copy — so serving
|
|
719
|
+
// one at the deploy root would be a file nothing fetches, sharing a name
|
|
720
|
+
// with the per-variant entry list the middleware rewrites `/_foundry/` to.
|
|
721
|
+
// Pack directories are absent on purpose: Foundry creates them.
|
|
722
|
+
const manifestJson = await readFile(join(dir, "module.json"));
|
|
723
|
+
const marker = Buffer.from(JSON.stringify(moduleGrafts(opts.siteUrl, !collapseToRoot), null, 2) + "\n");
|
|
724
|
+
await writeFile(join(dir, "module.zip"), zip([
|
|
725
|
+
{ name: `${foundryModuleId}/module.json`, data: manifestJson },
|
|
726
|
+
{ name: `${foundryModuleId}/grafts.json`, data: marker },
|
|
727
|
+
]));
|
|
464
728
|
}
|
|
465
729
|
// ── Pages Functions ─────────────────────────────────────────────────────
|
|
466
730
|
// Auth middleware ships only for multi-role builds. Single-role deploys
|
|
@@ -496,30 +760,40 @@ export async function buildSite(opts) {
|
|
|
496
760
|
: null;
|
|
497
761
|
const middleware = renderAuthMiddleware({
|
|
498
762
|
roles,
|
|
763
|
+
foundry: foundryEnabled,
|
|
499
764
|
rolePasswords: cfg.rolePasswords,
|
|
500
765
|
...(patreonForFn ? { patreon: patreonForFn } : {}),
|
|
501
766
|
...(oidcForFn ? { oidc: oidcForFn } : {}),
|
|
502
767
|
});
|
|
503
768
|
await writeFile(join(fnDir, "_middleware.js"), middleware);
|
|
504
|
-
// Login page
|
|
769
|
+
// Login page, showing only the methods this deploy actually has. A role
|
|
770
|
+
// is reachable by password only if a hash was set for it, so a vault
|
|
771
|
+
// authenticating purely through Patreon or OIDC gets no password form
|
|
772
|
+
// and no role selector.
|
|
505
773
|
const protectedRoles = roles.slice(1);
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
774
|
+
const passwordRoles = protectedRoles.filter((r) => cfg.rolePasswords[r]);
|
|
775
|
+
const patreonRoles = patreonForFn ? Object.keys(patreonForFn.tiers) : [];
|
|
776
|
+
const oidcRoles = oidcForFn ? Object.keys(oidcForFn.roleRules ?? {}) : [];
|
|
777
|
+
await writeFile(join(opts.outputDir, "login.html"), renderLoginPage({
|
|
778
|
+
passwordRoles,
|
|
779
|
+
patreonRoles,
|
|
780
|
+
oidcDisplayName: oidcForFn ? oidcForFn.displayName : null,
|
|
781
|
+
}));
|
|
782
|
+
// The error is a role nobody can reach, not a role without a password —
|
|
783
|
+
// password-less is the point when a provider grants the role instead.
|
|
784
|
+
const unreachable = protectedRoles.filter((r) => !cfg.rolePasswords[r] && !patreonRoles.includes(r) && !oidcRoles.includes(r));
|
|
785
|
+
if (unreachable.length > 0) {
|
|
786
|
+
console.warn(` WARNING: no way to sign in as role(s): ${unreachable.join(", ")}. `
|
|
787
|
+
+ `Set a password ('vaults password <role>'), map a Patreon tier `
|
|
788
|
+
+ `('vaults patreon link <role> <tier-id>'), or add an OIDC rule `
|
|
789
|
+
+ `('vaults oidc configure'). Pages at these roles will be unreachable.`);
|
|
521
790
|
}
|
|
522
791
|
}
|
|
792
|
+
// Search-engine files. Written from the default role's page list only, so a
|
|
793
|
+
// gated page is never named. Skipped entirely without a site_url.
|
|
794
|
+
if (opts.siteUrl) {
|
|
795
|
+
await writeSitemap(opts.outputDir, opts.siteUrl, defaultRolePagePaths);
|
|
796
|
+
}
|
|
523
797
|
// Drop the staging dirs; their contents have been copied into each
|
|
524
798
|
// variant that needs them, so they're no longer required for the deploy.
|
|
525
799
|
await rm(imageStagingDir, { recursive: true, force: true });
|
|
@@ -532,8 +806,6 @@ export async function buildSite(opts) {
|
|
|
532
806
|
await rename(workOutputDir, finalOutputDir);
|
|
533
807
|
console.log(`Built in ${formatDuration(Date.now() - start)}.`);
|
|
534
808
|
return {
|
|
535
|
-
files,
|
|
536
|
-
withinLimit,
|
|
537
809
|
roles,
|
|
538
810
|
perRolePageCount,
|
|
539
811
|
imageCount: imageFiles.length,
|
|
@@ -596,6 +868,7 @@ async function buildVariant(a) {
|
|
|
596
868
|
bases: a.baseSources,
|
|
597
869
|
defaultImageWidth: a.settings.default_image_width,
|
|
598
870
|
redactRoles: a.redactRoles,
|
|
871
|
+
gated: a.gated,
|
|
599
872
|
handlers: a.handlerRegistry,
|
|
600
873
|
outlinksByPath,
|
|
601
874
|
};
|
|
@@ -634,7 +907,6 @@ async function buildVariant(a) {
|
|
|
634
907
|
const previewMode = previewModeOf(a.settings.preview_mode);
|
|
635
908
|
const previewModeMobile = previewModeOf(a.settings.preview_mode_mobile);
|
|
636
909
|
const previewsEnabled = previewMode !== "none" || previewModeMobile !== "none";
|
|
637
|
-
const bodyMeta = new Map();
|
|
638
910
|
await pMap(visibleMetas, a.concurrency, async (p) => {
|
|
639
911
|
const r = rendered.get(p.path);
|
|
640
912
|
const backlinkPaths = backlinkMap.get(p.path) ?? new Set();
|
|
@@ -674,7 +946,6 @@ async function buildVariant(a) {
|
|
|
674
946
|
// remark/rehype pipeline land in journals as-is, no client-side render.
|
|
675
947
|
const bodyPath = outputBase + ".body.html";
|
|
676
948
|
await writeFile(join(a.variantDir, bodyPath), r.html);
|
|
677
|
-
bodyMeta.set(bodyPath, await collectBodyMeta(p, a.vaultPath));
|
|
678
949
|
// Preview JSON feeds the popover; skip it only when neither device class
|
|
679
950
|
// shows previews (both modes "none").
|
|
680
951
|
if (previewsEnabled) {
|
|
@@ -723,21 +994,39 @@ async function buildVariant(a) {
|
|
|
723
994
|
// under the variants that need them so guessing a DM-only image URL on
|
|
724
995
|
// the public wiki structurally 404s. coverImage feeds in here too so
|
|
725
996
|
// images named via `image:` frontmatter (no body embed) still ship.
|
|
726
|
-
await copyReferencedImages(visibleSources, visibleMetas, a.imageIndex, a.imageStagingDir, a.variantDir);
|
|
997
|
+
const copiedImages = await copyReferencedImages(visibleSources, visibleMetas, a.imageIndex, a.imageStagingDir, a.variantDir);
|
|
727
998
|
// Passthrough files (audio/video/pdf/epub) follow the same gating
|
|
728
999
|
// contract as images: ship only into variants whose visible pages
|
|
729
1000
|
// reference the file. A DM-only audio cue can't ride along into the
|
|
730
1001
|
// public deploy because no public-tier source mentions it.
|
|
731
|
-
await copyReferencedPassthroughs(visibleSources, visibleMetas, a.passthroughIndex, a.passthroughStagingDir, a.variantDir);
|
|
732
|
-
return {
|
|
1002
|
+
const copiedOther = await copyReferencedPassthroughs(visibleSources, visibleMetas, a.passthroughIndex, a.passthroughStagingDir, a.variantDir, a.manifestDownloads);
|
|
1003
|
+
return {
|
|
1004
|
+
pageCount: visibleMetas.length,
|
|
1005
|
+
pagePaths: visibleMetas.map((m) => m.path),
|
|
1006
|
+
hasMath: hasMathCss,
|
|
1007
|
+
assetPaths: [...copiedImages, ...copiedOther],
|
|
1008
|
+
};
|
|
733
1009
|
}
|
|
734
1010
|
/**
|
|
735
|
-
*
|
|
1011
|
+
* Resolve the katex package that *rehype-katex* renders with, which is not
|
|
1012
|
+
* necessarily this package's own katex dependency: under pnpm's isolated
|
|
1013
|
+
* node_modules, rehype-katex gets the copy matching its own range. The
|
|
1014
|
+
* stylesheet has to come from that copy, because KaTeX renames its CSS
|
|
1015
|
+
* classes between versions ('sizing' became 'katex-sizing' in 0.18), and a
|
|
1016
|
+
* stylesheet from a different version silently stops matching the markup:
|
|
1017
|
+
* subscripts render at full size instead of 0.7em.
|
|
1018
|
+
*/
|
|
1019
|
+
function katexRequire() {
|
|
1020
|
+
const here = createRequire(import.meta.url);
|
|
1021
|
+
return createRequire(here.resolve("rehype-katex"));
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Copy KaTeX's stylesheet and fonts (from the katex rehype-katex uses) to
|
|
736
1025
|
* <outputDir>/katex/. woff2 only: the CSS lists woff2 first, so any browser
|
|
737
1026
|
* that supports it (all modern ones) never requests the woff/ttf fallbacks.
|
|
738
1027
|
*/
|
|
739
1028
|
async function copyKatexAssets(destDir) {
|
|
740
|
-
const distDir = join(dirname(
|
|
1029
|
+
const distDir = join(dirname(katexRequire().resolve("katex/package.json")), "dist");
|
|
741
1030
|
await mkdir(join(destDir, "fonts"), { recursive: true });
|
|
742
1031
|
await copyFile(join(distDir, "katex.min.css"), join(destDir, "katex.min.css"));
|
|
743
1032
|
for (const f of await readdir(join(distDir, "fonts"))) {
|
|
@@ -746,141 +1035,6 @@ async function copyKatexAssets(destDir) {
|
|
|
746
1035
|
}
|
|
747
1036
|
}
|
|
748
1037
|
}
|
|
749
|
-
/**
|
|
750
|
-
* Build the per-body manifest meta from a page's frontmatter + resolved
|
|
751
|
-
* cover image. `role` always lands so the Foundry side can apply the
|
|
752
|
-
* dmRole permission gate; the foundry / image fields are conditional.
|
|
753
|
-
*
|
|
754
|
-
* Frontmatter shape forwarded to clients:
|
|
755
|
-
* foundry:
|
|
756
|
-
* base: <UUID> | <Type>[:<subtype>] # required for instantiation
|
|
757
|
-
* sync: false # default true; skip Foundry entirely
|
|
758
|
-
* journal: false # default true; doc only, no journal page
|
|
759
|
-
* link: doc # default journal; link to the doc instead
|
|
760
|
-
* embed: false # default true
|
|
761
|
-
* data: { … deep-merged into the doc }
|
|
762
|
-
*/
|
|
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
1038
|
/** Coerce settings.theme to the layout's narrowed union, defaulting to
|
|
885
1039
|
* "auto" for any unrecognised value rather than failing the build. */
|
|
886
1040
|
function themeOf(s) {
|
|
@@ -889,119 +1043,6 @@ function themeOf(s) {
|
|
|
889
1043
|
function previewModeOf(s) {
|
|
890
1044
|
return s === "none" || s === "sticky" ? s : "normal";
|
|
891
1045
|
}
|
|
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
1046
|
// `> [!type]…` opens a callout; the rest of the contiguous blockquote (lines
|
|
1006
1047
|
// starting with `>`, blank line ends) is its body. Used to strip role-gated
|
|
1007
1048
|
// callouts from the source before any downstream pass sees it.
|
|
@@ -1041,98 +1082,6 @@ function stripRoleGatedCallouts(source, redactRoles) {
|
|
|
1041
1082
|
}
|
|
1042
1083
|
return out.join("\n");
|
|
1043
1084
|
}
|
|
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
1085
|
/**
|
|
1137
1086
|
* Build synthesised index.md for any folder (including the root) that has
|
|
1138
1087
|
* pages but no existing index.md. When `inlineTitle` is true, the layout
|
|
@@ -1423,106 +1372,6 @@ function kindLabel(kind) {
|
|
|
1423
1372
|
default: return kind;
|
|
1424
1373
|
}
|
|
1425
1374
|
}
|
|
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
|
-
/**
|
|
1513
|
-
* Deterministic JSON encoder. Object keys are sorted recursively so two
|
|
1514
|
-
* frontmatters with the same shape but different key order produce the same
|
|
1515
|
-
* hash; otherwise the manifest would churn on every YAML reformat.
|
|
1516
|
-
*/
|
|
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
1375
|
/**
|
|
1527
1376
|
* Strip an HTML body to plain text. Used to feed the search index from
|
|
1528
1377
|
* the rendered article (post-wikilink, post-callout-redaction) so search
|