@kenjura/ursa 0.96.0 → 0.97.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/CHANGELOG.md +26 -0
- package/README.md +72 -16
- package/bin/ursa.js +14 -1
- package/meta/templates/default-template/menu.js +18 -1
- package/meta/templates/default-template/search.js +11 -0
- package/meta/templates/default-template/widgets.js +4 -0
- package/package.json +1 -2
- package/src/dev.js +13 -23
- package/src/helper/__test__/contentHash.test.js +16 -6
- package/src/helper/assetBundler.js +93 -19
- package/src/helper/automenu.js +36 -11
- package/src/helper/build/__test__/autoIndex.test.js +2 -132
- package/src/helper/build/__test__/graph.test.js +259 -3
- package/src/helper/build/__test__/pass.test.js +553 -0
- package/src/helper/build/autoIndex.js +2 -371
- package/src/helper/build/excludeFilter.js +1 -2
- package/src/helper/build/footer.js +27 -14
- package/src/helper/build/graph.js +575 -152
- package/src/helper/build/index.js +0 -2
- package/src/helper/build/metadata.js +19 -5
- package/src/helper/build/pass.js +497 -0
- package/src/helper/build/precedence.js +174 -0
- package/src/helper/build/site.js +1270 -0
- package/src/helper/build/templates.js +1 -2
- package/src/helper/build/tracedFs.js +247 -0
- package/src/helper/contentHash.js +0 -78
- package/src/helper/customMenu.js +1 -1
- package/src/helper/fileRenderer.js +119 -111
- package/src/helper/findScriptJs.js +1 -1
- package/src/helper/findStyleCss.js +1 -1
- package/src/helper/folderConfig.js +7 -18
- package/src/helper/fullTextIndex.js +41 -29
- package/src/helper/imageProcessor.js +45 -0
- package/src/helper/linkValidator.js +118 -127
- package/src/helper/mdxRenderer.js +27 -5
- package/src/helper/menuLabels.js +30 -5
- package/src/helper/whitelistFilter.js +1 -2
- package/src/jobs/generate.js +67 -1829
- package/src/serve.js +317 -697
- package/src/helper/__test__/dependencyTracker.test.js +0 -157
- package/src/helper/build/cacheBust.js +0 -141
- package/src/helper/build/navCache.js +0 -145
- package/src/helper/build/watchCache.js +0 -33
- package/src/helper/dependencyTracker.js +0 -384
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The build as a graph of nodes (docs/SERVE.md §4).
|
|
3
|
+
*
|
|
4
|
+
* Every output file the build produces is owned by exactly one node defined
|
|
5
|
+
* here, and every node's compute function reads its inputs through the graph
|
|
6
|
+
* — `ctx.read`, `ctx.exists`, `ctx.listDir`, `ctx.get` — or through helpers
|
|
7
|
+
* whose fs calls are traced (tracedFs.js), so the engine records exactly what
|
|
8
|
+
* each output consumed. Nothing in this file decides what to rebuild; it only
|
|
9
|
+
* says how each thing is built and what it reads. The engine does the rest.
|
|
10
|
+
*
|
|
11
|
+
* `generate` and `serve` run the same nodes (pass.js). Node ids are
|
|
12
|
+
* `kind:key`, where the key is a path relative to the docroot (documents,
|
|
13
|
+
* directories, images), a template name (meta), or empty (site-wide).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { basename, dirname, extname, join, posix, relative } from "path";
|
|
17
|
+
import { mkdir, readFile as fsReadFile, writeFile } from "fs/promises";
|
|
18
|
+
import { existsSync } from "fs";
|
|
19
|
+
import * as esbuild from "esbuild";
|
|
20
|
+
import o2x from "object-to-xml";
|
|
21
|
+
|
|
22
|
+
import { hashBytes, currentRecorder } from "./tracedFs.js";
|
|
23
|
+
import {
|
|
24
|
+
ARTICLE_EXTENSIONS,
|
|
25
|
+
AUTO_INDEX,
|
|
26
|
+
INDEX_BASENAMES,
|
|
27
|
+
candidatesForOutput,
|
|
28
|
+
isArticle,
|
|
29
|
+
isHandwrittenHtml,
|
|
30
|
+
isIndexBasename,
|
|
31
|
+
isIndexCandidate,
|
|
32
|
+
outputPathFor,
|
|
33
|
+
} from "./precedence.js";
|
|
34
|
+
import { getTemplates } from "./templates.js";
|
|
35
|
+
import { getFooter } from "./footer.js";
|
|
36
|
+
import { getTransformedMetadata } from "./metadata.js";
|
|
37
|
+
import { toTitleCase } from "./titleCase.js";
|
|
38
|
+
import { parseExcludeOption, createExcludeFilter } from "./excludeFilter.js";
|
|
39
|
+
import { generateAutoIndexHtmlFromSource } from "./autoIndex.js";
|
|
40
|
+
import { getUrsaVersion } from "../ursaVersion.js";
|
|
41
|
+
import { createWhitelistFilter } from "../whitelistFilter.js";
|
|
42
|
+
import { isHiddenOrSystemPath } from "../hiddenPaths.js";
|
|
43
|
+
import { getFolderConfig, isFolderHidden, isFolderSelfHidden } from "../folderConfig.js";
|
|
44
|
+
import { getFolderLabel } from "../menuLabels.js";
|
|
45
|
+
import { IMAGE_EXTENSIONS, isMedia } from "../staticAssets.js";
|
|
46
|
+
import { extractMetadata, isMetadataOnly, getAutoIndexConfig } from "../metadataExtractor.js";
|
|
47
|
+
import { injectFrontmatterTable } from "../frontmatterTable.js";
|
|
48
|
+
import { extractSections } from "../sectionExtractor.js";
|
|
49
|
+
import { renderFile, renderFileAsync } from "../fileRenderer.js";
|
|
50
|
+
import { generateBreadcrumbs } from "../breadcrumbs.js";
|
|
51
|
+
import { getAutomenu } from "../automenu.js";
|
|
52
|
+
import {
|
|
53
|
+
findCustomMenu,
|
|
54
|
+
extractMenuFrontmatter,
|
|
55
|
+
parseCustomMenu,
|
|
56
|
+
combineAutoAndManualMenu,
|
|
57
|
+
} from "../customMenu.js";
|
|
58
|
+
import { findAllStyleCss } from "../findStyleCss.js";
|
|
59
|
+
import { findAllScriptJs } from "../findScriptJs.js";
|
|
60
|
+
import {
|
|
61
|
+
parseTemplateAssets,
|
|
62
|
+
rewriteTemplateWithBundles,
|
|
63
|
+
bundleCssContent,
|
|
64
|
+
bundleJsContent,
|
|
65
|
+
resolveMetaAssetPath,
|
|
66
|
+
versionCssUrls,
|
|
67
|
+
rewriteJsonFetches,
|
|
68
|
+
} from "../assetBundler.js";
|
|
69
|
+
import {
|
|
70
|
+
buildValidPaths,
|
|
71
|
+
collectInternalHrefs,
|
|
72
|
+
markInactiveLinks,
|
|
73
|
+
resolveNormalizedHref,
|
|
74
|
+
resolveRelativeUrls,
|
|
75
|
+
} from "../linkValidator.js";
|
|
76
|
+
import { transformImageTags, willHavePreview, renderPreview, getPreviewFilename, isImageExtension } from "../imageProcessor.js";
|
|
77
|
+
import { documentWordCounts, mergeWordCounts } from "../fullTextIndex.js";
|
|
78
|
+
import { buildSourceTimestampIndex } from "../sourceTimestamps.js";
|
|
79
|
+
|
|
80
|
+
const DEFAULT_TEMPLATE_NAME = process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
|
|
81
|
+
const MENU_FILE_NAMES = ["menu.md", "menu.txt", "_menu.md", "_menu.txt"];
|
|
82
|
+
const REACT_RUNTIME_MARKER = "ursa-react-runtime/2";
|
|
83
|
+
|
|
84
|
+
export function nodeId(kind, key = "") {
|
|
85
|
+
return `${kind}:${key}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parseNodeId(id) {
|
|
89
|
+
const i = id.indexOf(":");
|
|
90
|
+
return { kind: id.slice(0, i), key: id.slice(i + 1) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Node kinds whose key is a document path in the document set. */
|
|
94
|
+
export const DOC_KINDS = ["bodyHtml", "pageHtml", "docData", "docWords"];
|
|
95
|
+
/** Node kinds whose key is a directory in the document set ("" is the root). */
|
|
96
|
+
export const DIR_KINDS = ["dirIndexJson", "dirListingHtml", "autoIndexPage", "dirSet"];
|
|
97
|
+
/** Node kinds kept only while something depends on them. */
|
|
98
|
+
export const INTERNAL_KINDS = [
|
|
99
|
+
"linkResolution", "outputOwner", "customMenuFor", "cssBundle", "jsBundle",
|
|
100
|
+
"metaAsset", "metaBundle", "imageInfo", "docMeta",
|
|
101
|
+
];
|
|
102
|
+
/** Site-wide singletons. */
|
|
103
|
+
export const SITE_KINDS = [
|
|
104
|
+
"documentSet", "validPaths", "templates", "metaAssets", "menuData", "menuHtml",
|
|
105
|
+
"footer", "reactRuntime", "ursaMetadata", "searchIndex", "fullTextIndex",
|
|
106
|
+
"recentActivity", "customMenus",
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Create the node definitions for one site.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} env
|
|
113
|
+
* @param {string} env.source - Absolute docroot (no trailing slash)
|
|
114
|
+
* @param {string} env.meta - Absolute meta directory
|
|
115
|
+
* @param {string} env.output - Absolute output directory
|
|
116
|
+
* @param {string|null} env.whitelist - Whitelist file path
|
|
117
|
+
* @param {string|null} env.exclude - Exclude option (paths or a file)
|
|
118
|
+
* @param {boolean} env.jsonOnly - Emit only .json data files
|
|
119
|
+
* @param {{buildId: number, now: Date, gitHash: string|null}} env.session - Per-session build metadata (§7)
|
|
120
|
+
* @param {(msg: string) => void} env.log
|
|
121
|
+
* @param {(key: string, msg: string) => void} env.warn - De-duplicated per pass
|
|
122
|
+
* @returns {{resolve: (id: string) => ({fn: Function, fingerprint?: Function}|null)}}
|
|
123
|
+
*/
|
|
124
|
+
export function createSite(env) {
|
|
125
|
+
const { source, meta, output } = env;
|
|
126
|
+
const log = env.log ?? (() => {});
|
|
127
|
+
const warn = env.warn ?? ((k, m) => console.warn(m));
|
|
128
|
+
const abs = (rel) => (rel ? join(source, rel) : source);
|
|
129
|
+
const outAbs = (rel) => join(output, rel);
|
|
130
|
+
const relOf = (absPath) => relative(source, absPath).split("\\").join("/");
|
|
131
|
+
const urlOf = (rel) => "/" + rel;
|
|
132
|
+
|
|
133
|
+
// -------------------------------------------------------------------------
|
|
134
|
+
// Output writing
|
|
135
|
+
// -------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Write an output file only when its bytes differ (minimality, §1) and
|
|
139
|
+
* declare ownership of it. Returns the content hash.
|
|
140
|
+
*/
|
|
141
|
+
async function writeOutput(ctx, rel, content) {
|
|
142
|
+
const path = outAbs(rel);
|
|
143
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
|
|
144
|
+
const hash = hashBytes(buf);
|
|
145
|
+
let same = false;
|
|
146
|
+
try {
|
|
147
|
+
const existing = await fsReadFile(path);
|
|
148
|
+
same = existing.length === buf.length && hashBytes(existing) === hash;
|
|
149
|
+
} catch {
|
|
150
|
+
same = false;
|
|
151
|
+
}
|
|
152
|
+
if (!same) {
|
|
153
|
+
await mkdir(dirname(path), { recursive: true });
|
|
154
|
+
await writeFile(path, buf);
|
|
155
|
+
env.onWrite?.(rel);
|
|
156
|
+
}
|
|
157
|
+
ctx.own(rel);
|
|
158
|
+
return hash;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// -------------------------------------------------------------------------
|
|
162
|
+
// Shared helpers
|
|
163
|
+
// -------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/** Filename-derived title, as the search index and recent activity name a document. */
|
|
166
|
+
function titleOf(rel) {
|
|
167
|
+
const ext = extname(rel);
|
|
168
|
+
const base = basename(rel, ext);
|
|
169
|
+
const dir = dirname(rel);
|
|
170
|
+
const titleBase = base === "index" || base === "home" ? basename(dir === "." ? source : dir) : base;
|
|
171
|
+
return toTitleCase(titleBase || base);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Directory of a document relative to the docroot, with trailing slash ("" at the root). */
|
|
175
|
+
function dirWithSlash(rel) {
|
|
176
|
+
const d = dirname(rel);
|
|
177
|
+
return d === "." ? "" : d + "/";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function dirRelOf(rel) {
|
|
181
|
+
const d = dirname(rel);
|
|
182
|
+
return d === "." ? "" : d;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function indexOutputFor(dirRel) {
|
|
186
|
+
return dirRel ? `${dirRel}/index.html` : "index.html";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Pre-load frontmatter for the given documents into the active recorder so
|
|
191
|
+
* label lookups made by the menu/breadcrumb/auto-index helpers hit `docMeta`
|
|
192
|
+
* projections instead of file contents (see menuLabels.readFrontmatterInfo).
|
|
193
|
+
*/
|
|
194
|
+
async function preloadFrontmatter(ctx, docs, { nonDocuments = [] } = {}) {
|
|
195
|
+
const rec = currentRecorder();
|
|
196
|
+
if (!rec) return;
|
|
197
|
+
if (!rec.frontmatter) rec.frontmatter = new Map();
|
|
198
|
+
for (const d of docs) {
|
|
199
|
+
if (!isArticle(d)) continue;
|
|
200
|
+
const info = await ctx.get(nodeId("docMeta", d));
|
|
201
|
+
rec.frontmatter.set(abs(d), { meta: info.meta, isMetadataOnly: info.isMetadataOnly });
|
|
202
|
+
}
|
|
203
|
+
// Files that are not documents have no frontmatter worth reading; a
|
|
204
|
+
// stylesheet's or image's content must not become an input of the menu
|
|
205
|
+
const none = { meta: null, isMetadataOnly: false };
|
|
206
|
+
for (const f of nonDocuments) rec.frontmatter.set(abs(f), none);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Documents in a folder (and, one level down, the index documents of its
|
|
211
|
+
* subfolders) whose labels an auto-index listing shows. Read from directory
|
|
212
|
+
* listings only, so a document added elsewhere is not an input.
|
|
213
|
+
*/
|
|
214
|
+
async function indexDocsAround(ctx, dirRel) {
|
|
215
|
+
const out = [];
|
|
216
|
+
for (const entry of await ctx.listDir(abs(dirRel))) {
|
|
217
|
+
const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
|
|
218
|
+
if (entry.kind === "file" && isArticle(entry.name)) out.push(rel);
|
|
219
|
+
if (entry.kind === "dir") {
|
|
220
|
+
for (const child of await ctx.listDir(abs(rel))) {
|
|
221
|
+
if (child.kind === "file" && isArticle(child.name) && isIndexBasename(basename(child.name, extname(child.name)))) {
|
|
222
|
+
out.push(`${rel}/${child.name}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Every document at or below a folder, to `depth` levels (Infinity for all). */
|
|
231
|
+
async function docsBelow(ctx, dirRel, depth) {
|
|
232
|
+
const out = [];
|
|
233
|
+
const walk = async (rel, level) => {
|
|
234
|
+
for (const entry of await ctx.listDir(abs(rel))) {
|
|
235
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
236
|
+
if (entry.kind === "file" && isArticle(entry.name)) out.push(childRel);
|
|
237
|
+
else if (entry.kind === "dir" && level < depth && !isHiddenOrSystemPath(abs(childRel), source)) await walk(childRel, level + 1);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
await walk(dirRel, 1);
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Ancestors' index documents, for breadcrumb labels: each candidate name is
|
|
246
|
+
* probed (a recorded lookup), so only the folder's own index files are inputs.
|
|
247
|
+
*/
|
|
248
|
+
function ancestorIndexDocs(ctx, dirRel) {
|
|
249
|
+
const out = [];
|
|
250
|
+
let cur = dirRel;
|
|
251
|
+
while (cur) {
|
|
252
|
+
for (const base of INDEX_BASENAMES) {
|
|
253
|
+
for (const ext of ARTICLE_EXTENSIONS) {
|
|
254
|
+
const candidate = `${cur}/${base}${ext}`;
|
|
255
|
+
if (ctx.exists(abs(candidate))) out.push(candidate);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const parent = dirname(cur);
|
|
259
|
+
cur = parent === "." ? "" : parent;
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Resolve the internal links in HTML through per-href nodes and mark the
|
|
266
|
+
* broken ones inactive.
|
|
267
|
+
*/
|
|
268
|
+
async function resolveLinks(ctx, html, docUrlPath) {
|
|
269
|
+
const hrefs = collectInternalHrefs(html, docUrlPath);
|
|
270
|
+
const map = new Map();
|
|
271
|
+
for (const h of hrefs) map.set(h, await ctx.get(nodeId("linkResolution", h)));
|
|
272
|
+
return markInactiveLinks(html, (n) => map.get(n) ?? null, docUrlPath);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Site-absolute lookup path for an image src, the way transformImageTags resolves it. */
|
|
276
|
+
function imageLookupPath(src, docUrlPath) {
|
|
277
|
+
let lookupPath = src.split("?")[0].split("#")[0];
|
|
278
|
+
if (!lookupPath.startsWith("/")) {
|
|
279
|
+
const docDir = docUrlPath.substring(0, docUrlPath.lastIndexOf("/")) || "/";
|
|
280
|
+
const parts = docDir.split("/").filter(Boolean);
|
|
281
|
+
for (const part of lookupPath.split("/")) {
|
|
282
|
+
if (part === "..") parts.pop();
|
|
283
|
+
else if (part !== ".") parts.push(part);
|
|
284
|
+
}
|
|
285
|
+
lookupPath = "/" + parts.join("/");
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
lookupPath = decodeURIComponent(lookupPath);
|
|
289
|
+
} catch {
|
|
290
|
+
// keep as-is
|
|
291
|
+
}
|
|
292
|
+
return lookupPath;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Rewrite <img> tags to previews with lightbox anchors, and version every
|
|
297
|
+
* image, stylesheet and script reference with the content hash of what it
|
|
298
|
+
* points at. Returns the HTML and the images (docroot-relative) it uses.
|
|
299
|
+
*/
|
|
300
|
+
async function finishAssets(ctx, html, docUrlPath) {
|
|
301
|
+
// Images referenced by the page: one imageInfo node each
|
|
302
|
+
const imgRe = /<img[^>]*src=["']([^"']+)["'][^>]*>/gi;
|
|
303
|
+
const imageMap = new Map();
|
|
304
|
+
const hashes = new Map(); // site-absolute URL (no query) → hash
|
|
305
|
+
const images = [];
|
|
306
|
+
let m;
|
|
307
|
+
while ((m = imgRe.exec(html)) !== null) {
|
|
308
|
+
const src = m[1];
|
|
309
|
+
if (/^(https?:)?\/\/|^data:/i.test(src)) continue;
|
|
310
|
+
const lookup = imageLookupPath(src, docUrlPath);
|
|
311
|
+
if (imageMap.has(lookup)) continue;
|
|
312
|
+
const rel = lookup.replace(/^\//, "");
|
|
313
|
+
if (!IMAGE_EXTENSIONS.test(rel)) continue;
|
|
314
|
+
const info = await ctx.get(nodeId("imageInfo", rel));
|
|
315
|
+
if (info) {
|
|
316
|
+
imageMap.set(lookup, { original: info.original, preview: info.preview });
|
|
317
|
+
hashes.set(info.original, info.hash);
|
|
318
|
+
hashes.set(info.preview, info.hash);
|
|
319
|
+
images.push(rel);
|
|
320
|
+
} else {
|
|
321
|
+
imageMap.set(lookup, null);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
for (const [k, v] of [...imageMap]) if (!v) imageMap.delete(k);
|
|
325
|
+
html = transformImageTags(html, imageMap, docUrlPath);
|
|
326
|
+
|
|
327
|
+
// Stylesheets and scripts: the react runtime, meta assets, docroot assets
|
|
328
|
+
const refRe = /<(?:link[^>]+href|script[^>]+src)=["']([^"'?]+\.(?:css|js))["']/gi;
|
|
329
|
+
while ((m = refRe.exec(html)) !== null) {
|
|
330
|
+
const url = m[1];
|
|
331
|
+
if (hashes.has(url) || /^(https?:)?\/\//i.test(url)) continue;
|
|
332
|
+
const hash = await assetHash(ctx, url);
|
|
333
|
+
if (hash) hashes.set(url, hash);
|
|
334
|
+
}
|
|
335
|
+
html = versionHtmlRefs(html, (url) => hashes.get(url) ?? null);
|
|
336
|
+
return { html, images };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Content hash of the output file a site-absolute URL names, via the node
|
|
341
|
+
* that owns it; null when nothing ursa builds lives there.
|
|
342
|
+
*/
|
|
343
|
+
async function assetHash(ctx, url) {
|
|
344
|
+
if (url === "/public/react-runtime.js") return env.jsonOnly ? null : (await ctx.get(nodeId("reactRuntime"))).hash;
|
|
345
|
+
if (url.startsWith("/public/")) {
|
|
346
|
+
const rel = url.slice("/public/".length);
|
|
347
|
+
const assets = await ctx.get(nodeId("metaAssets"));
|
|
348
|
+
if (assets.byRel[rel]) return (await ctx.get(nodeId("metaAsset", rel))).hash;
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
if (!url.startsWith("/") || url.startsWith("//")) return null;
|
|
352
|
+
let rel = url.replace(/^\//, "");
|
|
353
|
+
try {
|
|
354
|
+
rel = decodeURIComponent(rel);
|
|
355
|
+
} catch {
|
|
356
|
+
// keep as written
|
|
357
|
+
}
|
|
358
|
+
if (IMAGE_EXTENSIONS.test(rel)) return (await ctx.get(nodeId("imageInfo", rel)))?.hash ?? null;
|
|
359
|
+
if (isMedia(rel)) return (await ctx.get(nodeId("staticAsset", rel)))?.hash ?? null;
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** `?v=<hash>` on <link href>, <script src> and <img src> whose target is known. */
|
|
364
|
+
function versionHtmlRefs(html, lookup) {
|
|
365
|
+
const ver = (before, url, after) => {
|
|
366
|
+
if (url.includes("?")) return before + url + after;
|
|
367
|
+
const hash = lookup(url);
|
|
368
|
+
return hash ? `${before}${url}?v=${hash}${after}` : before + url + after;
|
|
369
|
+
};
|
|
370
|
+
html = html.replace(/(<link[^>]+href=["'])([^"']+\.css)(["'][^>]*>)/gi, (_, b, u, a) => ver(b, u, a));
|
|
371
|
+
html = html.replace(/(<script[^>]+src=["'])([^"']+\.js)(["'][^>]*>)/gi, (_, b, u, a) => ver(b, u, a));
|
|
372
|
+
html = html.replace(/(<img[^>]+src=["'])([^"']+\.(?:jpg|jpeg|png|gif|webp|svg|ico))(["'][^>]*>)/gi, (_, b, u, a) => ver(b, u, a));
|
|
373
|
+
return html;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Fill a template. A function replacer: `$&` in a document body must not be interpreted. */
|
|
377
|
+
function fillTemplate(template, replacements) {
|
|
378
|
+
const pattern = /\$\{(title|menu|meta|transformedMetadata|body|styleLink|customScript|searchIndex|footer)\}/g;
|
|
379
|
+
return template.replace(pattern, (match) => replacements[match] ?? match);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Body attributes: menu position, custom menu path, build id (for JSON fetch cache-busting). */
|
|
383
|
+
function bodyAttributes(html, customMenuInfo) {
|
|
384
|
+
const attrs = [];
|
|
385
|
+
if (customMenuInfo) attrs.push(`data-custom-menu="${customMenuInfo.menuJsonPath}"`);
|
|
386
|
+
attrs.push(`data-menu-position="${customMenuInfo?.menuPosition || "top"}"`);
|
|
387
|
+
attrs.push(`data-build="${env.session.buildId}"`);
|
|
388
|
+
return html.replace(/<body([^>]*)>/, `<body$1 ${attrs.join(" ")}>`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Assemble a page from a body and write it. Shared by documents, auto-indices and listings. */
|
|
392
|
+
async function assemblePage(ctx, {
|
|
393
|
+
templateName, dirRel, docUrlPath, title, meta, body, transformedMetadata = "",
|
|
394
|
+
hydrationScript = "", useFolderAssets = true,
|
|
395
|
+
}) {
|
|
396
|
+
const templates = await ctx.get(nodeId("templates"));
|
|
397
|
+
if (!templates[templateName]) {
|
|
398
|
+
throw new Error(`Template not found. Requested: "${templateName}". Available templates: ${Object.keys(templates).join(", ") || "none"}`);
|
|
399
|
+
}
|
|
400
|
+
const template = await ctx.get(nodeId("metaBundle", templateName));
|
|
401
|
+
let styleLink = "";
|
|
402
|
+
let customScript = "";
|
|
403
|
+
if (useFolderAssets) {
|
|
404
|
+
const css = await ctx.get(nodeId("cssBundle", dirRel));
|
|
405
|
+
const js = await ctx.get(nodeId("jsBundle", dirRel));
|
|
406
|
+
if (css) styleLink = `<link rel="stylesheet" href="${css.url}" />`;
|
|
407
|
+
if (js) customScript = `<script src="${js.url}"></script>`;
|
|
408
|
+
}
|
|
409
|
+
if (hydrationScript) customScript = customScript ? customScript + "\n" + hydrationScript : hydrationScript;
|
|
410
|
+
const menu = await ctx.get(nodeId("menuHtml"));
|
|
411
|
+
const footer = await ctx.get(nodeId("footer"));
|
|
412
|
+
const customMenuInfo = await ctx.get(nodeId("customMenuFor", dirRel));
|
|
413
|
+
|
|
414
|
+
let html = fillTemplate(template, {
|
|
415
|
+
"${title}": title,
|
|
416
|
+
"${menu}": menu,
|
|
417
|
+
"${meta}": meta,
|
|
418
|
+
"${transformedMetadata}": transformedMetadata,
|
|
419
|
+
"${body}": body,
|
|
420
|
+
"${styleLink}": styleLink,
|
|
421
|
+
"${customScript}": customScript,
|
|
422
|
+
"${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
|
|
423
|
+
"${footer}": footer,
|
|
424
|
+
});
|
|
425
|
+
html = bodyAttributes(html, customMenuInfo);
|
|
426
|
+
html = resolveRelativeUrls(html, docUrlPath);
|
|
427
|
+
html = await resolveLinks(ctx, html, docUrlPath);
|
|
428
|
+
return finishAssets(ctx, html, docUrlPath);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// -------------------------------------------------------------------------
|
|
432
|
+
// Node families
|
|
433
|
+
// -------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
const families = {
|
|
436
|
+
// ----- Site-wide -------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* What participates in the build: a projection of directory listings (plus
|
|
440
|
+
* the whitelist/exclude files and each folder's config.json). Changes only
|
|
441
|
+
* when a name appears, disappears or changes kind.
|
|
442
|
+
*/
|
|
443
|
+
documentSet: () => async (ctx) => {
|
|
444
|
+
const outputInside = output.startsWith(source + "/") ? output : null;
|
|
445
|
+
const includeFilter = process.env.INCLUDE_FILTER
|
|
446
|
+
? (fileName) => fileName.match(process.env.INCLUDE_FILTER)
|
|
447
|
+
: () => true;
|
|
448
|
+
const excludeFilter = env.exclude
|
|
449
|
+
? createExcludeFilter(await parseExcludeOption(env.exclude, source + "/"), source + "/")
|
|
450
|
+
: () => true;
|
|
451
|
+
const whitelistFilter = env.whitelist ? await createWhitelistFilter(env.whitelist, source) : () => true;
|
|
452
|
+
|
|
453
|
+
const files = [];
|
|
454
|
+
const dirs = [];
|
|
455
|
+
const walk = async (dirRel) => {
|
|
456
|
+
const dirAbs = abs(dirRel);
|
|
457
|
+
for (const entry of await ctx.listDir(dirAbs)) {
|
|
458
|
+
const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
|
|
459
|
+
const entryAbs = join(dirAbs, entry.name);
|
|
460
|
+
if (outputInside && entryAbs === outputInside) continue;
|
|
461
|
+
if (isHiddenOrSystemPath(entryAbs, source)) continue;
|
|
462
|
+
if (entry.kind === "dir") {
|
|
463
|
+
if (isFolderSelfHidden(entryAbs)) continue;
|
|
464
|
+
if (!excludeFilter(entryAbs + "/") || !includeFilter(entryAbs)) continue;
|
|
465
|
+
dirs.push(rel);
|
|
466
|
+
await walk(rel);
|
|
467
|
+
} else if (entry.kind === "file") {
|
|
468
|
+
if (!includeFilter(entryAbs) || !excludeFilter(entryAbs) || !whitelistFilter(entryAbs)) continue;
|
|
469
|
+
files.push(rel);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
await walk("");
|
|
474
|
+
files.sort();
|
|
475
|
+
dirs.sort();
|
|
476
|
+
|
|
477
|
+
const articles = files.filter((f) => isArticle(f));
|
|
478
|
+
const html = files.filter((f) => isHandwrittenHtml(f));
|
|
479
|
+
const images = files.filter((f) => IMAGE_EXTENSIONS.test(f));
|
|
480
|
+
const media = files.filter((f) => isMedia(f));
|
|
481
|
+
const dirsWithDocuments = new Set();
|
|
482
|
+
for (const f of [...articles, ...html]) {
|
|
483
|
+
let d = dirname(f);
|
|
484
|
+
while (d && d !== ".") {
|
|
485
|
+
dirsWithDocuments.add(d);
|
|
486
|
+
d = dirname(d);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const value = { files, dirs, articles, html, images, media, dirsWithDocuments: [...dirsWithDocuments].sort() };
|
|
490
|
+
attachSets(value);
|
|
491
|
+
return value;
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
/** Canonical-URL map for link resolution. Changes only on add/remove/rename. */
|
|
495
|
+
validPaths: () => async (ctx) => {
|
|
496
|
+
const set = await ctx.get(nodeId("documentSet"));
|
|
497
|
+
const map = buildValidPaths(
|
|
498
|
+
[...set.articles, ...set.html].map((r) => "/" + r),
|
|
499
|
+
"",
|
|
500
|
+
set.dirs.map((r) => "/" + r),
|
|
501
|
+
{ dirsWithDocuments: new Set(set.dirsWithDocuments) }
|
|
502
|
+
);
|
|
503
|
+
const value = { entries: [...map.entries()] };
|
|
504
|
+
Object.defineProperty(value, "map", { value: map, enumerable: false });
|
|
505
|
+
return value;
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
/** Canonical `.html` path for one normalized href, or null. One node per href seen. */
|
|
509
|
+
linkResolution: (normalized) => async (ctx) => {
|
|
510
|
+
const vp = await ctx.get(nodeId("validPaths"));
|
|
511
|
+
return resolveNormalizedHref(normalized, vp.map);
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
templates: () => async () => getTemplates(meta),
|
|
515
|
+
|
|
516
|
+
/** Every file under meta/shared and the template folders (minus index.html), by public path. */
|
|
517
|
+
metaAssets: () => async (ctx) => {
|
|
518
|
+
const byRel = {};
|
|
519
|
+
const templatesDir = join(meta, "templates");
|
|
520
|
+
const copyDir = async (dirAbs, prefix, exclude = []) => {
|
|
521
|
+
for (const entry of await ctx.listDir(dirAbs)) {
|
|
522
|
+
if (exclude.includes(entry.name)) continue;
|
|
523
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
524
|
+
if (entry.kind === "dir") await copyDir(join(dirAbs, entry.name), rel);
|
|
525
|
+
else if (entry.kind === "file") byRel[rel] = join(dirAbs, entry.name);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
if (ctx.exists(templatesDir)) {
|
|
529
|
+
await copyDir(join(meta, "shared"), "");
|
|
530
|
+
for (const entry of await ctx.listDir(templatesDir)) {
|
|
531
|
+
if (entry.kind === "dir") await copyDir(join(templatesDir, entry.name), "", ["index.html"]);
|
|
532
|
+
}
|
|
533
|
+
// Files at the meta root are not part of any template
|
|
534
|
+
const orphans = [];
|
|
535
|
+
for (const entry of await ctx.listDir(meta)) {
|
|
536
|
+
if (entry.name === "templates" || entry.name === "shared") continue;
|
|
537
|
+
orphans.push(entry.name);
|
|
538
|
+
}
|
|
539
|
+
if (orphans.length > 0) {
|
|
540
|
+
warn("meta-orphans",
|
|
541
|
+
`⚠️ ${orphans.length} file(s)/folder(s) in the meta directory are not in meta/templates/ or meta/shared/ and won't be included: ${orphans.slice(0, 10).join(", ")}${orphans.length > 10 ? ", …" : ""}`);
|
|
542
|
+
}
|
|
543
|
+
} else {
|
|
544
|
+
// Legacy flat layout: everything but HTML
|
|
545
|
+
const walkLegacy = async (dirAbs, prefix) => {
|
|
546
|
+
for (const entry of await ctx.listDir(dirAbs)) {
|
|
547
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
548
|
+
if (entry.kind === "dir") await walkLegacy(join(dirAbs, entry.name), rel);
|
|
549
|
+
else if (entry.kind === "file" && !entry.name.endsWith(".html")) byRel[rel] = join(dirAbs, entry.name);
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
await walkLegacy(meta, "");
|
|
553
|
+
}
|
|
554
|
+
const rels = Object.keys(byRel).sort();
|
|
555
|
+
return { rels, byRel };
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
/** One copied meta asset in output/public. */
|
|
559
|
+
metaAsset: (rel) => async (ctx) => {
|
|
560
|
+
const assets = await ctx.get(nodeId("metaAssets"));
|
|
561
|
+
const src = assets.byRel[rel];
|
|
562
|
+
if (!src) return null;
|
|
563
|
+
let content = await ctx.read(src, null);
|
|
564
|
+
if (rel.endsWith(".css")) {
|
|
565
|
+
// Version url() targets that are meta assets (fonts, images); never other stylesheets
|
|
566
|
+
const css = content.toString("utf8");
|
|
567
|
+
const urls = collectCssUrls(css);
|
|
568
|
+
const hashes = new Map();
|
|
569
|
+
for (const u of urls) {
|
|
570
|
+
const target = metaUrlToRel(u, dirname(rel));
|
|
571
|
+
if (target && target !== rel && !target.endsWith(".css") && assets.byRel[target]) {
|
|
572
|
+
hashes.set(u, (await ctx.get(nodeId("metaAsset", target))).hash);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
content = versionCssUrls(css, (u) => hashes.get(u) ?? null);
|
|
576
|
+
} else if (rel.endsWith(".js")) {
|
|
577
|
+
content = rewriteJsonFetches(content.toString("utf8"));
|
|
578
|
+
}
|
|
579
|
+
const hash = await writeOutput(ctx, posix.join("public", rel), content);
|
|
580
|
+
return { url: `/public/${rel}`, hash };
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* A template with its `/public/` CSS and JS references bundled into one
|
|
585
|
+
* file each; owns the bundles. The value is the rewritten template HTML,
|
|
586
|
+
* whose bundle URLs carry `?v=<content hash>` — so it changes exactly when
|
|
587
|
+
* a page using the template must be rewritten.
|
|
588
|
+
*/
|
|
589
|
+
metaBundle: (templateName) => async (ctx) => {
|
|
590
|
+
const templates = await ctx.get(nodeId("templates"));
|
|
591
|
+
const t = templates[templateName];
|
|
592
|
+
if (!t) throw new Error(`Template not found: "${templateName}"`);
|
|
593
|
+
const templateHtml = typeof t === "string" ? t : t.html;
|
|
594
|
+
const templateDir = typeof t === "string" ? meta : t.dir || meta;
|
|
595
|
+
const assets = parseTemplateAssets(templateHtml);
|
|
596
|
+
if (assets.cssFiles.length === 0 && assets.jsFiles.length === 0) return templateHtml;
|
|
597
|
+
|
|
598
|
+
const metaAssets = await ctx.get(nodeId("metaAssets"));
|
|
599
|
+
const urls = {};
|
|
600
|
+
let cssOk = false;
|
|
601
|
+
if (assets.cssFiles.length > 0) {
|
|
602
|
+
const cssPaths = assets.cssFiles.map((p) => resolveMetaAssetPath(p, templateDir, meta)).filter(Boolean);
|
|
603
|
+
if (cssPaths.length > 0) {
|
|
604
|
+
let css = await bundleCssContent(cssPaths, { minify: true });
|
|
605
|
+
const hashes = new Map();
|
|
606
|
+
for (const u of collectCssUrls(css)) {
|
|
607
|
+
const target = metaUrlToRel(u, "");
|
|
608
|
+
if (target && !target.endsWith(".css") && metaAssets.byRel[target]) {
|
|
609
|
+
hashes.set(u, (await ctx.get(nodeId("metaAsset", target))).hash);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
css = versionCssUrls(css, (u) => hashes.get(u) ?? null);
|
|
613
|
+
const rel = `public/${templateName}.bundle.css`;
|
|
614
|
+
const hash = await writeOutput(ctx, rel, css);
|
|
615
|
+
urls.cssUrl = `/${rel}?v=${hash}`;
|
|
616
|
+
cssOk = true;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
let jsOk = false;
|
|
620
|
+
if (assets.jsFiles.length > 0) {
|
|
621
|
+
const jsPaths = assets.jsFiles.map((p) => resolveMetaAssetPath(p, templateDir, meta)).filter(Boolean);
|
|
622
|
+
if (jsPaths.length > 0) {
|
|
623
|
+
const { success, code } = await bundleJsContent(jsPaths, { minify: true, minifySyntax: true });
|
|
624
|
+
if (success) {
|
|
625
|
+
const rel = `public/${templateName}.bundle.js`;
|
|
626
|
+
const hash = await writeOutput(ctx, rel, rewriteJsonFetches(code));
|
|
627
|
+
urls.jsUrl = `/${rel}?v=${hash}`;
|
|
628
|
+
jsOk = true;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// Only rewrite the tags whose bundle exists; a JS syntax error keeps the
|
|
633
|
+
// individual <script> tags so one broken file does not break them all
|
|
634
|
+
const toRewrite = { ...assets, cssFiles: cssOk ? assets.cssFiles : [], jsFiles: jsOk ? assets.jsFiles : [] };
|
|
635
|
+
return rewriteTemplateWithBundles(templateHtml, templateName, toRewrite, urls);
|
|
636
|
+
},
|
|
637
|
+
|
|
638
|
+
/** React + ReactDOM bundled for MDX hydration; a function of ursa's version only. */
|
|
639
|
+
reactRuntime: () => async (ctx) => {
|
|
640
|
+
ctx.constant("ursa-version");
|
|
641
|
+
const result = await esbuild.build({
|
|
642
|
+
stdin: {
|
|
643
|
+
contents: `
|
|
644
|
+
import React from 'react';
|
|
645
|
+
import * as ReactDOM from 'react-dom';
|
|
646
|
+
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
647
|
+
import * as _jsx_runtime from 'react/jsx-runtime';
|
|
648
|
+
window.React = React;
|
|
649
|
+
window.ReactDOM = { ...ReactDOM, hydrateRoot, createRoot };
|
|
650
|
+
window._jsx_runtime = _jsx_runtime;
|
|
651
|
+
window.__ursaReactRuntime = ${JSON.stringify(REACT_RUNTIME_MARKER)};
|
|
652
|
+
`,
|
|
653
|
+
resolveDir: dirname(new URL(import.meta.url).pathname),
|
|
654
|
+
loader: "js",
|
|
655
|
+
},
|
|
656
|
+
bundle: true,
|
|
657
|
+
format: "iife",
|
|
658
|
+
platform: "browser",
|
|
659
|
+
target: "es2020",
|
|
660
|
+
minify: true,
|
|
661
|
+
write: false,
|
|
662
|
+
});
|
|
663
|
+
const code = result.outputFiles[0].text;
|
|
664
|
+
const hash = await writeOutput(ctx, "public/react-runtime.js", code);
|
|
665
|
+
return { hash };
|
|
666
|
+
},
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Footer HTML. Build id, timestamp and git hash are fixed for the session
|
|
670
|
+
* (§7) and excluded from the node's fingerprint (see `footerFingerprint`):
|
|
671
|
+
* a new `generate` run must not rewrite every page just to stamp a new
|
|
672
|
+
* build id into pages whose inputs did not change.
|
|
673
|
+
*/
|
|
674
|
+
footer: () => async () =>
|
|
675
|
+
getFooter(source + "/", source, env.session.buildId, { now: env.session.now, gitHash: env.session.gitHash }),
|
|
676
|
+
|
|
677
|
+
/** Embedded in every JSON output. */
|
|
678
|
+
ursaMetadata: () => async (ctx) => {
|
|
679
|
+
ctx.constant("ursa-version");
|
|
680
|
+
let docVersion = "unknown";
|
|
681
|
+
for (const p of [join(source, "package.json"), join(dirname(source), "package.json")]) {
|
|
682
|
+
if (ctx.exists(p)) {
|
|
683
|
+
try {
|
|
684
|
+
const pkg = JSON.parse(await ctx.read(p));
|
|
685
|
+
if (pkg.version) {
|
|
686
|
+
docVersion = pkg.version;
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
} catch {
|
|
690
|
+
// try the next
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return { ursaVersion: getUrsaVersion(), docVersion };
|
|
695
|
+
},
|
|
696
|
+
|
|
697
|
+
/** The auto-menu: full tree (owns menu-data.json) plus the root-level markup. */
|
|
698
|
+
menuData: () => async (ctx) => {
|
|
699
|
+
const set = await ctx.get(nodeId("documentSet"));
|
|
700
|
+
const vp = await ctx.get(nodeId("validPaths"));
|
|
701
|
+
await preloadFrontmatter(ctx, set.articles, { nonDocuments: set.files.filter((f) => !set.articleSet.has(f)) });
|
|
702
|
+
const result = await getAutomenu(source, vp.map);
|
|
703
|
+
if (!env.jsonOnly) await writeOutput(ctx, "public/menu-data.json", JSON.stringify(result.menuData));
|
|
704
|
+
return { html: result.html, menuData: result.menuData };
|
|
705
|
+
},
|
|
706
|
+
|
|
707
|
+
/** Root-level menu markup inlined in every page; moves only when the root level changes. */
|
|
708
|
+
menuHtml: () => async (ctx) => {
|
|
709
|
+
const { html } = await ctx.get(nodeId("menuData"));
|
|
710
|
+
return renderFile({ fileContents: html, type: ".md" });
|
|
711
|
+
},
|
|
712
|
+
|
|
713
|
+
/** Directories holding a custom menu file. */
|
|
714
|
+
customMenus: () => async (ctx) => {
|
|
715
|
+
const set = await ctx.get(nodeId("documentSet"));
|
|
716
|
+
const dirs = new Set();
|
|
717
|
+
for (const f of set.files) {
|
|
718
|
+
if (MENU_FILE_NAMES.includes(basename(f))) dirs.add(dirRelOf(f));
|
|
719
|
+
}
|
|
720
|
+
return [...dirs].sort();
|
|
721
|
+
},
|
|
722
|
+
|
|
723
|
+
/** One custom menu's JSON. */
|
|
724
|
+
customMenu: (menuDirRel) => async (ctx) => {
|
|
725
|
+
const menuDir = abs(menuDirRel);
|
|
726
|
+
const info = findCustomMenu(menuDir, source);
|
|
727
|
+
if (!info || relOf(info.menuDir) !== menuDirRel) return null;
|
|
728
|
+
const below = await ctx.get(nodeId("dirSet", menuDirRel));
|
|
729
|
+
await preloadFrontmatter(ctx, below.articles, { nonDocuments: below.files.filter((f) => !isArticle(f)) });
|
|
730
|
+
const { frontmatter, body } = extractMenuFrontmatter(info.content);
|
|
731
|
+
const autoGenerate = frontmatter["auto-generate-menu"] === true || frontmatter["auto-generate-menu"] === "true";
|
|
732
|
+
const menuPosition = frontmatter["menu-position"] || "top";
|
|
733
|
+
const depth = parseInt(frontmatter["menu-depth"], 10) || 10;
|
|
734
|
+
const menuData = autoGenerate
|
|
735
|
+
? combineAutoAndManualMenu(body, info.menuDir, source, depth)
|
|
736
|
+
: parseCustomMenu(body, info.menuDir, source);
|
|
737
|
+
const rel = `public/custom-menu-${menuId(menuDirRel)}.json`;
|
|
738
|
+
const hash = env.jsonOnly ? null : await writeOutput(ctx, rel, JSON.stringify({ menuData, menuPosition }));
|
|
739
|
+
return { url: "/" + rel, hash, menuPosition };
|
|
740
|
+
},
|
|
741
|
+
|
|
742
|
+
/** The custom menu a folder's pages use: the nearest menu file up the tree, or null. */
|
|
743
|
+
customMenuFor: (dirRel) => async () => {
|
|
744
|
+
const info = findCustomMenu(abs(dirRel), source);
|
|
745
|
+
if (!info) return null;
|
|
746
|
+
const { frontmatter } = extractMenuFrontmatter(info.content);
|
|
747
|
+
const menuDirRel = relOf(info.menuDir);
|
|
748
|
+
return {
|
|
749
|
+
menuJsonPath: `/public/custom-menu-${menuId(menuDirRel)}.json`,
|
|
750
|
+
menuDir: menuDirRel,
|
|
751
|
+
menuPosition: frontmatter["menu-position"] || "top",
|
|
752
|
+
};
|
|
753
|
+
},
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* The folder's inherited stylesheets bundled into public/<folder>.bundle.css.
|
|
757
|
+
* Value: {url} with `?v=<hash>`, or null when the chain is empty. The chain
|
|
758
|
+
* is a set of recorded lookups, so adding a style.css anywhere above the
|
|
759
|
+
* folder is observed.
|
|
760
|
+
*/
|
|
761
|
+
cssBundle: (dirRel) => async (ctx) => {
|
|
762
|
+
const paths = await findAllStyleCss(abs(dirRel), source);
|
|
763
|
+
if (paths.length === 0) return null;
|
|
764
|
+
let css = await bundleCssContent(paths, { minify: true, rebaseUrls: true, sourceDir: source });
|
|
765
|
+
const hashes = new Map();
|
|
766
|
+
for (const u of collectCssUrls(css)) {
|
|
767
|
+
const hash = await assetHash(ctx, u);
|
|
768
|
+
if (hash) hashes.set(u, hash);
|
|
769
|
+
}
|
|
770
|
+
css = versionCssUrls(css, (u) => hashes.get(u) ?? null);
|
|
771
|
+
const rel = `public/${bundleName(dirRel)}.bundle.css`;
|
|
772
|
+
const hash = await writeOutput(ctx, rel, css);
|
|
773
|
+
return { url: `/${rel}?v=${hash}` };
|
|
774
|
+
},
|
|
775
|
+
|
|
776
|
+
jsBundle: (dirRel) => async (ctx) => {
|
|
777
|
+
const paths = await findAllScriptJs(abs(dirRel), source);
|
|
778
|
+
if (paths.length === 0) return null;
|
|
779
|
+
const { success, code } = await bundleJsContent(paths, { minify: true, minifySyntax: true });
|
|
780
|
+
if (!success) return null;
|
|
781
|
+
const rel = `public/${bundleName(dirRel)}.bundle.js`;
|
|
782
|
+
const hash = await writeOutput(ctx, rel, rewriteJsonFetches(code));
|
|
783
|
+
return { url: `/${rel}?v=${hash}` };
|
|
784
|
+
},
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Cheap facts about an image: content hash, URLs, whether a preview will
|
|
788
|
+
* exist. Null when the file is absent — the miss is recorded, so a dead
|
|
789
|
+
* image link comes alive when the file appears (§8.4).
|
|
790
|
+
*/
|
|
791
|
+
imageInfo: (rel) => async (ctx) => {
|
|
792
|
+
const path = abs(rel);
|
|
793
|
+
if (!isImageExtension(extname(rel))) return null;
|
|
794
|
+
if (!ctx.exists(path)) return null;
|
|
795
|
+
if (isFolderHidden(dirname(path), source) || isHiddenOrSystemPath(path, source)) return null;
|
|
796
|
+
const buf = await ctx.read(path, null);
|
|
797
|
+
const hash = hashBytes(buf);
|
|
798
|
+
const preview = await willHavePreview(path);
|
|
799
|
+
const original = urlOf(rel);
|
|
800
|
+
return {
|
|
801
|
+
original,
|
|
802
|
+
preview: preview ? urlOf(posix.join(dirRelOf(rel), getPreviewFilename(basename(rel)))) : original,
|
|
803
|
+
willPreview: preview,
|
|
804
|
+
hash,
|
|
805
|
+
};
|
|
806
|
+
},
|
|
807
|
+
|
|
808
|
+
/** The original image copied through. */
|
|
809
|
+
imageCopy: (rel) => async (ctx) => {
|
|
810
|
+
const info = await ctx.get(nodeId("imageInfo", rel));
|
|
811
|
+
if (!info) return null;
|
|
812
|
+
const hash = await writeOutput(ctx, rel, await ctx.read(abs(rel), null));
|
|
813
|
+
return { hash };
|
|
814
|
+
},
|
|
815
|
+
|
|
816
|
+
/** The WebP preview (expensive; scheduled after pages). */
|
|
817
|
+
imagePreview: (rel) => async (ctx) => {
|
|
818
|
+
const info = await ctx.get(nodeId("imageInfo", rel));
|
|
819
|
+
if (!info || !info.willPreview) return null;
|
|
820
|
+
const buf = await renderPreview(abs(rel));
|
|
821
|
+
if (!buf) return null;
|
|
822
|
+
const hash = await writeOutput(ctx, info.preview.replace(/^\//, ""), buf);
|
|
823
|
+
return { hash };
|
|
824
|
+
},
|
|
825
|
+
|
|
826
|
+
/** Fonts, audio, video, PDFs, archives: copied through. */
|
|
827
|
+
staticAsset: (rel) => async (ctx) => {
|
|
828
|
+
const path = abs(rel);
|
|
829
|
+
if (!ctx.exists(path)) return null;
|
|
830
|
+
if (isFolderHidden(dirname(path), source) || isHiddenOrSystemPath(path, source)) return null;
|
|
831
|
+
const buf = await ctx.read(path, null);
|
|
832
|
+
const hash = await writeOutput(ctx, rel, buf);
|
|
833
|
+
return { url: urlOf(rel), hash };
|
|
834
|
+
},
|
|
835
|
+
|
|
836
|
+
// ----- Per document ----------------------------------------------------
|
|
837
|
+
|
|
838
|
+
/** Frontmatter projection: a body edit leaves it unchanged. */
|
|
839
|
+
docMeta: (rel) => async (ctx) => {
|
|
840
|
+
const raw = await ctx.read(abs(rel));
|
|
841
|
+
let meta = null;
|
|
842
|
+
try {
|
|
843
|
+
meta = extractMetadata(raw);
|
|
844
|
+
} catch (e) {
|
|
845
|
+
warn(`frontmatter:${rel}`, `⚠️ ${rel}: could not parse frontmatter: ${e.message}`);
|
|
846
|
+
}
|
|
847
|
+
return {
|
|
848
|
+
meta,
|
|
849
|
+
isMetadataOnly: /\.(md|mdx)$/i.test(rel) && isMetadataOnly(raw),
|
|
850
|
+
autoIndex: getAutoIndexConfig(meta),
|
|
851
|
+
template: meta?.template || null,
|
|
852
|
+
hydrate: meta?.hydrate === true,
|
|
853
|
+
};
|
|
854
|
+
},
|
|
855
|
+
|
|
856
|
+
/** Rendered body HTML (plus hydration script), before the template. */
|
|
857
|
+
bodyHtml: (rel) => async (ctx) => {
|
|
858
|
+
const path = abs(rel);
|
|
859
|
+
const raw = await ctx.read(path);
|
|
860
|
+
const type = extname(rel);
|
|
861
|
+
const base = basename(rel, type);
|
|
862
|
+
const dir = dirWithSlash(rel);
|
|
863
|
+
let meta = null;
|
|
864
|
+
try {
|
|
865
|
+
meta = extractMetadata(raw);
|
|
866
|
+
} catch {
|
|
867
|
+
meta = null;
|
|
868
|
+
}
|
|
869
|
+
const title = titleOf(rel);
|
|
870
|
+
const shouldHydrate = type === ".mdx" && meta?.hydrate === true;
|
|
871
|
+
|
|
872
|
+
const renderResult = await renderFileAsync({
|
|
873
|
+
fileContents: raw,
|
|
874
|
+
type,
|
|
875
|
+
dirname: dir,
|
|
876
|
+
basename: base,
|
|
877
|
+
filePath: path,
|
|
878
|
+
sourceRoot: source,
|
|
879
|
+
useWorker: true,
|
|
880
|
+
hydrate: shouldHydrate,
|
|
881
|
+
});
|
|
882
|
+
let body;
|
|
883
|
+
let hydrationScript = "";
|
|
884
|
+
if (typeof renderResult === "object" && renderResult !== null) {
|
|
885
|
+
body = renderResult.html;
|
|
886
|
+
hydrationScript = renderResult.hydrationScript || "";
|
|
887
|
+
// Every module esbuild loaded is an input of this document
|
|
888
|
+
for (const input of renderResult.inputs ?? []) {
|
|
889
|
+
try {
|
|
890
|
+
await ctx.read(input, null);
|
|
891
|
+
} catch {
|
|
892
|
+
// recorded as missing; a later creation re-renders
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (renderResult.failed) {
|
|
896
|
+
// Resolution failed somewhere: watch the folders an import could
|
|
897
|
+
// appear in so creating the missing component re-renders this page
|
|
898
|
+
await ctx.listDir(dirname(path));
|
|
899
|
+
for (const d of renderResult.componentDirs ?? []) await ctx.listDir(d);
|
|
900
|
+
}
|
|
901
|
+
} else {
|
|
902
|
+
body = renderResult;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Inject default H1 if body doesn't start with one
|
|
906
|
+
if (!body || !body.trimStart().startsWith("<h1")) {
|
|
907
|
+
const h1Title = meta?.title || title;
|
|
908
|
+
body = `<h1>${h1Title}</h1>\n` + (body || "");
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Breadcrumbs before the H1 (folder labels come from docMeta projections)
|
|
912
|
+
await preloadFrontmatter(ctx, ancestorIndexDocs(ctx, dirRelOf(rel)));
|
|
913
|
+
const breadcrumbs = generateBreadcrumbs(dir, base, meta, source);
|
|
914
|
+
if (breadcrumbs) body = breadcrumbs + body;
|
|
915
|
+
|
|
916
|
+
// Frontmatter table after the first H1 (markdown only)
|
|
917
|
+
if ((type === ".md" || type === ".mdx") && meta) {
|
|
918
|
+
body = injectFrontmatterTable(body, meta);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// `generate-auto-index: true` renders a listing of the folder's source tree
|
|
922
|
+
const autoIndexConfig = getAutoIndexConfig(meta);
|
|
923
|
+
if (base === "index" && meta && autoIndexConfig.enabled) {
|
|
924
|
+
await preloadFrontmatter(ctx, await docsBelow(ctx, dirRelOf(rel), autoIndexConfig.depth + 1));
|
|
925
|
+
const autoIndexHtml = await generateAutoIndexHtmlFromSource(dirname(path), autoIndexConfig.depth);
|
|
926
|
+
if (autoIndexHtml) {
|
|
927
|
+
body = autoIndexConfig.position === "bottom" ? body + "\n" + autoIndexHtml : autoIndexHtml + "\n" + body;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
return { body, hydrationScript, meta, title, type, base, dir };
|
|
932
|
+
},
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The page: owns `<path>.html`, and the folder's `index.html` too when this
|
|
936
|
+
* document is the folder's index by promotion (§8.3). Writes nothing when
|
|
937
|
+
* another source owns its output path (§8.2).
|
|
938
|
+
*/
|
|
939
|
+
pageHtml: (rel) => async (ctx) => {
|
|
940
|
+
if (env.jsonOnly) return null;
|
|
941
|
+
const outRel = outputPathFor(rel);
|
|
942
|
+
const owner = await ctx.get(nodeId("outputOwner", outRel));
|
|
943
|
+
if (owner !== rel) {
|
|
944
|
+
if (owner && owner !== AUTO_INDEX) {
|
|
945
|
+
warn(`shadow:${outRel}`, `⚠️ ${rel} is not rendered: ${outRel} is produced by ${owner}`);
|
|
946
|
+
}
|
|
947
|
+
return { shadowedBy: owner };
|
|
948
|
+
}
|
|
949
|
+
const rendered = await ctx.get(nodeId("bodyHtml", rel));
|
|
950
|
+
const { body, hydrationScript, meta, title } = rendered;
|
|
951
|
+
const dirRel = dirRelOf(rel);
|
|
952
|
+
const docUrlPath = "/" + outRel;
|
|
953
|
+
const templateName = meta?.template || DEFAULT_TEMPLATE_NAME;
|
|
954
|
+
|
|
955
|
+
// Lazy: only load transformMetadata.js when the template uses it
|
|
956
|
+
const templates = await ctx.get(nodeId("templates"));
|
|
957
|
+
const templateHtml = templates[templateName]?.html ?? templates[templateName] ?? "";
|
|
958
|
+
const transformedMetadata = templateHtml.includes("${transformedMetadata}")
|
|
959
|
+
? await getTransformedMetadata(dirname(abs(rel)), meta)
|
|
960
|
+
: "";
|
|
961
|
+
|
|
962
|
+
const { html, images } = await assemblePage(ctx, {
|
|
963
|
+
templateName,
|
|
964
|
+
dirRel,
|
|
965
|
+
docUrlPath,
|
|
966
|
+
title: meta?.title || title,
|
|
967
|
+
meta: JSON.stringify(meta),
|
|
968
|
+
body,
|
|
969
|
+
transformedMetadata,
|
|
970
|
+
hydrationScript,
|
|
971
|
+
});
|
|
972
|
+
const hash = await writeOutput(ctx, outRel, html);
|
|
973
|
+
|
|
974
|
+
// Folder-index promotion: also serve as <dir>/index.html when this document owns it
|
|
975
|
+
let promotedTo = null;
|
|
976
|
+
if (isIndexCandidate(rel)) {
|
|
977
|
+
const idxRel = indexOutputFor(dirRel);
|
|
978
|
+
if (idxRel !== outRel && (await ctx.get(nodeId("outputOwner", idxRel))) === rel) {
|
|
979
|
+
await writeOutput(ctx, idxRel, html);
|
|
980
|
+
promotedTo = idxRel;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return { hash, out: outRel, promotedTo, images, template: templateName };
|
|
984
|
+
},
|
|
985
|
+
|
|
986
|
+
/** The document's .json (and .xml) beside its page. */
|
|
987
|
+
docData: (rel) => async (ctx) => {
|
|
988
|
+
const outRel = outputPathFor(rel);
|
|
989
|
+
const owner = await ctx.get(nodeId("outputOwner", outRel));
|
|
990
|
+
if (owner !== rel) return { shadowedBy: owner };
|
|
991
|
+
const raw = await ctx.read(abs(rel));
|
|
992
|
+
const { body, meta, base } = await ctx.get(nodeId("bodyHtml", rel));
|
|
993
|
+
const type = extname(rel);
|
|
994
|
+
const sections = type === ".md" || type === ".mdx" ? extractSections(raw) : [];
|
|
995
|
+
const transformedMetadata = await getTransformedMetadata(dirname(abs(rel)), meta);
|
|
996
|
+
const ursaMetadata = await ctx.get(nodeId("ursaMetadata"));
|
|
997
|
+
const jsonObject = {
|
|
998
|
+
name: base,
|
|
999
|
+
url: "/" + outRel,
|
|
1000
|
+
contents: raw,
|
|
1001
|
+
bodyHtml: body,
|
|
1002
|
+
metadata: meta,
|
|
1003
|
+
sections,
|
|
1004
|
+
transformedMetadata,
|
|
1005
|
+
_ursa_metadata: ursaMetadata,
|
|
1006
|
+
};
|
|
1007
|
+
const jsonRel = outRel.replace(/\.html$/, ".json");
|
|
1008
|
+
const xmlRel = outRel.replace(/\.html$/, ".xml");
|
|
1009
|
+
const hash = await writeOutput(ctx, jsonRel, JSON.stringify(jsonObject));
|
|
1010
|
+
// The mode is an input: a full build after a JSON-only one must write the XML
|
|
1011
|
+
if (ctx.constant("json-only") !== "true") {
|
|
1012
|
+
await writeOutput(ctx, xmlRel, `<article>${o2x(jsonObject)}</article>`);
|
|
1013
|
+
} else if (existsSync(outAbs(xmlRel))) {
|
|
1014
|
+
ctx.own(xmlRel); // a full build's XML is left in place, not orphaned
|
|
1015
|
+
}
|
|
1016
|
+
return { hash };
|
|
1017
|
+
},
|
|
1018
|
+
|
|
1019
|
+
/** Per-document word counts for the full-text index. */
|
|
1020
|
+
docWords: (rel) => async (ctx) => {
|
|
1021
|
+
const raw = await ctx.read(abs(rel));
|
|
1022
|
+
return documentWordCounts({ title: titleOf(rel), content: raw });
|
|
1023
|
+
},
|
|
1024
|
+
|
|
1025
|
+
/** A hand-written .html copied through with link processing. */
|
|
1026
|
+
htmlPassthrough: (rel) => async (ctx) => {
|
|
1027
|
+
if (env.jsonOnly) return null;
|
|
1028
|
+
let html = await ctx.read(abs(rel));
|
|
1029
|
+
const docUrlPath = "/" + rel;
|
|
1030
|
+
html = resolveRelativeUrls(html, docUrlPath);
|
|
1031
|
+
html = await resolveLinks(ctx, html, docUrlPath);
|
|
1032
|
+
const finished = await finishAssets(ctx, html, docUrlPath);
|
|
1033
|
+
const hash = await writeOutput(ctx, rel, finished.html);
|
|
1034
|
+
return { hash, images: finished.images };
|
|
1035
|
+
},
|
|
1036
|
+
|
|
1037
|
+
// ----- Per directory ---------------------------------------------------
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Which source produces an output path, by the precedence list. Returns
|
|
1041
|
+
* the winning source (docroot-relative), AUTO_INDEX for a folder index
|
|
1042
|
+
* nobody claims, or null.
|
|
1043
|
+
*/
|
|
1044
|
+
outputOwner: (outRel) => async (ctx) => {
|
|
1045
|
+
const set = await ctx.get(nodeId("documentSet"));
|
|
1046
|
+
for (const candidate of candidatesForOutput(outRel)) {
|
|
1047
|
+
if (candidate === AUTO_INDEX) return AUTO_INDEX;
|
|
1048
|
+
if (set.htmlSet.has(candidate)) return candidate;
|
|
1049
|
+
if (set.articleSet.has(candidate)) {
|
|
1050
|
+
// A frontmatter-only index supplies the folder's label; the auto-index is still the page
|
|
1051
|
+
if (isIndexBasename(basename(candidate, extname(candidate)))) {
|
|
1052
|
+
const info = await ctx.get(nodeId("docMeta", candidate));
|
|
1053
|
+
if (info.isMetadataOnly) continue;
|
|
1054
|
+
}
|
|
1055
|
+
return candidate;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return null;
|
|
1059
|
+
},
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* The document set restricted to one folder's subtree. A projection: it
|
|
1063
|
+
* changes only when something under that folder is added, removed or
|
|
1064
|
+
* renamed, so a document added elsewhere does not reach the folder's
|
|
1065
|
+
* listings.
|
|
1066
|
+
*/
|
|
1067
|
+
dirSet: (dirRel) => async (ctx) => {
|
|
1068
|
+
const set = await ctx.get(nodeId("documentSet"));
|
|
1069
|
+
const prefix = dirRel ? dirRel + "/" : "";
|
|
1070
|
+
const under = (list) => list.filter((f) => f.startsWith(prefix));
|
|
1071
|
+
return { files: under(set.files), articles: under(set.articles), html: under(set.html), dirs: under(set.dirs) };
|
|
1072
|
+
},
|
|
1073
|
+
|
|
1074
|
+
/** `<dir>.json`: the folder's records, recursively. */
|
|
1075
|
+
dirIndexJson: (dirRel) => async (ctx) => {
|
|
1076
|
+
if (!dirRel) return null;
|
|
1077
|
+
const below = await ctx.get(nodeId("dirSet", dirRel));
|
|
1078
|
+
const records = [];
|
|
1079
|
+
for (const d of below.articles) {
|
|
1080
|
+
const outRel = outputPathFor(d);
|
|
1081
|
+
if ((await ctx.get(nodeId("outputOwner", outRel))) !== d) continue;
|
|
1082
|
+
const info = await ctx.get(nodeId("docMeta", d));
|
|
1083
|
+
records.push({ name: basename(d, extname(d)), url: "/" + outRel, metadata: info.meta });
|
|
1084
|
+
}
|
|
1085
|
+
const hash = await writeOutput(ctx, `${dirRel}.json`, JSON.stringify(records));
|
|
1086
|
+
return { hash };
|
|
1087
|
+
},
|
|
1088
|
+
|
|
1089
|
+
/** `<dir>.html`: a plain listing page, only when no document owns that path. */
|
|
1090
|
+
dirListingHtml: (dirRel) => async (ctx) => {
|
|
1091
|
+
if (!dirRel || env.jsonOnly) return null;
|
|
1092
|
+
const outRel = `${dirRel}.html`;
|
|
1093
|
+
const owner = await ctx.get(nodeId("outputOwner", outRel));
|
|
1094
|
+
if (owner) return { ownedBy: owner };
|
|
1095
|
+
const below = await ctx.get(nodeId("dirSet", dirRel));
|
|
1096
|
+
const items = below.files
|
|
1097
|
+
.map((f) => {
|
|
1098
|
+
const ext = extname(f);
|
|
1099
|
+
const href = "/" + (ext ? f.slice(0, -ext.length) : f) + ".html";
|
|
1100
|
+
return `<li><a href="${href}">${basename(f, ext)}</a></li>`;
|
|
1101
|
+
});
|
|
1102
|
+
const body = `<ul>${items.join("")}</ul>`;
|
|
1103
|
+
const { html } = await assemblePage(ctx, {
|
|
1104
|
+
templateName: DEFAULT_TEMPLATE_NAME,
|
|
1105
|
+
dirRel: dirRelOf(dirRel),
|
|
1106
|
+
docUrlPath: "/" + outRel,
|
|
1107
|
+
title: "Index",
|
|
1108
|
+
meta: "{}",
|
|
1109
|
+
body,
|
|
1110
|
+
useFolderAssets: false,
|
|
1111
|
+
});
|
|
1112
|
+
const hash = await writeOutput(ctx, outRel, html);
|
|
1113
|
+
return { hash };
|
|
1114
|
+
},
|
|
1115
|
+
|
|
1116
|
+
/** The generated index.html for a folder no document claims. */
|
|
1117
|
+
autoIndexPage: (dirRel) => async (ctx) => {
|
|
1118
|
+
if (env.jsonOnly) return null;
|
|
1119
|
+
const idxRel = indexOutputFor(dirRel);
|
|
1120
|
+
const owner = await ctx.get(nodeId("outputOwner", idxRel));
|
|
1121
|
+
if (owner !== AUTO_INDEX) return { ownedBy: owner };
|
|
1122
|
+
const dirAbs = abs(dirRel);
|
|
1123
|
+
await preloadFrontmatter(ctx, await indexDocsAround(ctx, dirRel));
|
|
1124
|
+
const listing = await generateAutoIndexHtmlFromSource(dirAbs, 1);
|
|
1125
|
+
if (!listing) return { empty: true };
|
|
1126
|
+
|
|
1127
|
+
const folderName = basename(dirAbs);
|
|
1128
|
+
const folderDisplayName = dirRel ? getFolderLabel(dirAbs, getFolderConfig(dirAbs), folderName) : "Home";
|
|
1129
|
+
await preloadFrontmatter(ctx, ancestorIndexDocs(ctx, dirRel));
|
|
1130
|
+
const breadcrumbHtml = generateBreadcrumbs(dirRel ? dirRel + "/" : "/", "index", null, source);
|
|
1131
|
+
const body = `${breadcrumbHtml}<h1>${folderDisplayName}</h1>\n${listing}`;
|
|
1132
|
+
const { html } = await assemblePage(ctx, {
|
|
1133
|
+
templateName: DEFAULT_TEMPLATE_NAME,
|
|
1134
|
+
dirRel,
|
|
1135
|
+
docUrlPath: "/" + idxRel,
|
|
1136
|
+
title: folderDisplayName,
|
|
1137
|
+
meta: "{}",
|
|
1138
|
+
body,
|
|
1139
|
+
});
|
|
1140
|
+
const hash = await writeOutput(ctx, idxRel, html);
|
|
1141
|
+
return { hash };
|
|
1142
|
+
},
|
|
1143
|
+
|
|
1144
|
+
// ----- Aggregates ------------------------------------------------------
|
|
1145
|
+
|
|
1146
|
+
searchIndex: () => async (ctx) => {
|
|
1147
|
+
const set = await ctx.get(nodeId("documentSet"));
|
|
1148
|
+
const entries = [];
|
|
1149
|
+
for (const d of set.articles) {
|
|
1150
|
+
const outRel = outputPathFor(d);
|
|
1151
|
+
if ((await ctx.get(nodeId("outputOwner", outRel))) !== d) continue;
|
|
1152
|
+
entries.push({ title: titleOf(d), path: outRel, url: "/" + outRel, content: "" });
|
|
1153
|
+
}
|
|
1154
|
+
const hash = await writeOutput(ctx, "public/search-index.json", JSON.stringify(entries));
|
|
1155
|
+
return { hash, entries: entries.length };
|
|
1156
|
+
},
|
|
1157
|
+
|
|
1158
|
+
fullTextIndex: () => async (ctx) => {
|
|
1159
|
+
const set = await ctx.get(nodeId("documentSet"));
|
|
1160
|
+
const docs = [];
|
|
1161
|
+
for (const d of set.articles) {
|
|
1162
|
+
const outRel = outputPathFor(d);
|
|
1163
|
+
if ((await ctx.get(nodeId("outputOwner", outRel))) !== d) continue;
|
|
1164
|
+
docs.push({ path: "/" + outRel, counts: await ctx.get(nodeId("docWords", d)) });
|
|
1165
|
+
}
|
|
1166
|
+
const index = mergeWordCounts(docs);
|
|
1167
|
+
const hash = await writeOutput(ctx, "public/fulltext-index.json", JSON.stringify(index));
|
|
1168
|
+
return { hash, words: Object.keys(index).length };
|
|
1169
|
+
},
|
|
1170
|
+
|
|
1171
|
+
/** Ten most recently edited documents, dated from git (or mtime). */
|
|
1172
|
+
recentActivity: () => async (ctx) => {
|
|
1173
|
+
const set = await ctx.get(nodeId("documentSet"));
|
|
1174
|
+
// Content changes are what move a document's date, so depend on each file
|
|
1175
|
+
const docs = [];
|
|
1176
|
+
for (const d of set.articles) {
|
|
1177
|
+
const outRel = outputPathFor(d);
|
|
1178
|
+
if ((await ctx.get(nodeId("outputOwner", outRel))) !== d) continue;
|
|
1179
|
+
await ctx.read(abs(d), null);
|
|
1180
|
+
docs.push(d);
|
|
1181
|
+
}
|
|
1182
|
+
const timestamps = await buildSourceTimestampIndex(source, { log: (m) => log(m) });
|
|
1183
|
+
const entries = [];
|
|
1184
|
+
for (const d of docs) {
|
|
1185
|
+
entries.push({ title: titleOf(d), url: "/" + outputPathFor(d), mtime: await timestamps.get(abs(d)) });
|
|
1186
|
+
}
|
|
1187
|
+
entries.sort((a, b) => b.mtime - a.mtime || (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
|
|
1188
|
+
const top10 = entries.slice(0, 10);
|
|
1189
|
+
const hash = await writeOutput(ctx, "public/recent-activity.json", JSON.stringify(top10));
|
|
1190
|
+
return { hash };
|
|
1191
|
+
},
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
/** Per-family value fingerprints where the default (hash of the value) is wrong. */
|
|
1195
|
+
const fingerprints = {
|
|
1196
|
+
footer: footerFingerprint,
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
return {
|
|
1200
|
+
/** Graph resolver: node id → definition. */
|
|
1201
|
+
resolve(id) {
|
|
1202
|
+
const { kind, key } = parseNodeId(id);
|
|
1203
|
+
const family = families[kind];
|
|
1204
|
+
if (!family) return null;
|
|
1205
|
+
return { fn: family(key), fingerprint: fingerprints[kind] };
|
|
1206
|
+
},
|
|
1207
|
+
families: Object.keys(families),
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* The footer without its per-session build metadata line and git comment.
|
|
1213
|
+
* Only the parts that are functions of the source tree (footer.md, the doc
|
|
1214
|
+
* package.json) count as a change.
|
|
1215
|
+
*/
|
|
1216
|
+
export function footerFingerprint(html) {
|
|
1217
|
+
const stable = String(html ?? "")
|
|
1218
|
+
.replace(/<div class="footer-meta">[\s\S]*?<\/div>/, "")
|
|
1219
|
+
.replace(/<!-- git: [^>]*-->/, "");
|
|
1220
|
+
return hashBytes(stable);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ---------------------------------------------------------------------------
|
|
1224
|
+
// Small helpers
|
|
1225
|
+
// ---------------------------------------------------------------------------
|
|
1226
|
+
|
|
1227
|
+
/** Sets for membership tests, attached non-enumerably so they stay out of the fingerprint. */
|
|
1228
|
+
function attachSets(set) {
|
|
1229
|
+
const define = (name, arr) => Object.defineProperty(set, name, { value: new Set(arr), enumerable: false });
|
|
1230
|
+
define("articleSet", set.articles);
|
|
1231
|
+
define("htmlSet", set.html);
|
|
1232
|
+
define("imageSet", set.images);
|
|
1233
|
+
define("mediaSet", set.media);
|
|
1234
|
+
define("dirSet", set.dirs);
|
|
1235
|
+
define("fileSet", set.files);
|
|
1236
|
+
return set;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function bundleName(dirRel) {
|
|
1240
|
+
return dirRel.replace(/^\/+|\/+$/g, "").replace(/\//g, "-") || "root";
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function menuId(menuDirRel) {
|
|
1244
|
+
if (!menuDirRel) return "root";
|
|
1245
|
+
return menuDirRel.replace(/[\/\\]/g, "-").replace(/[^a-zA-Z0-9-]/g, "");
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/** Every url() target in a stylesheet (raw, as written). */
|
|
1249
|
+
function collectCssUrls(css) {
|
|
1250
|
+
const out = new Set();
|
|
1251
|
+
const re = /url\(\s*(['"]?)(?!data:)([^'"\)]+?)\1\s*\)/gi;
|
|
1252
|
+
let m;
|
|
1253
|
+
while ((m = re.exec(css)) !== null) {
|
|
1254
|
+
if (!m[2].includes("?") && !m[2].startsWith("#")) out.add(m[2]);
|
|
1255
|
+
}
|
|
1256
|
+
return [...out];
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* A url() written in meta CSS → the public-relative asset it names, or null
|
|
1261
|
+
* for anything external. `/public/x` is x; a relative path resolves against
|
|
1262
|
+
* the stylesheet's own public-relative directory (bundles live at the root).
|
|
1263
|
+
*/
|
|
1264
|
+
function metaUrlToRel(url, cssDirRel) {
|
|
1265
|
+
if (/^(https?:)?\/\//i.test(url) || url.startsWith("data:")) return null;
|
|
1266
|
+
if (url.startsWith("/public/")) return url.slice("/public/".length);
|
|
1267
|
+
if (url.startsWith("/")) return null;
|
|
1268
|
+
return posix.normalize(posix.join(cssDirRel || "", url));
|
|
1269
|
+
}
|
|
1270
|
+
|