@docubook/flame 2.0.0-beta.1 → 2.0.0-beta.3
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/.docu/lib/build.deno.js +6 -9
- package/.docu/lib/build.deno.js.map +1 -0
- package/.docu/lib/build.impl-Bz_p421t.js +424 -0
- package/.docu/lib/build.impl-Bz_p421t.js.map +1 -0
- package/.docu/lib/build.impl-CtPrlYAE.js +2 -0
- package/.docu/lib/build.node.js +6 -9
- package/.docu/lib/build.node.js.map +1 -0
- package/.docu/lib/clean.js +25 -25
- package/.docu/lib/clean.js.map +1 -0
- package/.docu/lib/deno-DmEaKggj.js +20 -0
- package/.docu/lib/deno-DmEaKggj.js.map +1 -0
- package/.docu/lib/deploy.deno.js +9 -9
- package/.docu/lib/deploy.deno.js.map +1 -0
- package/.docu/lib/deploy.node.js +8 -8
- package/.docu/lib/deploy.node.js.map +1 -0
- package/.docu/lib/deploy.shared-fk8eM-r4.js +295 -0
- package/.docu/lib/deploy.shared-fk8eM-r4.js.map +1 -0
- package/.docu/lib/html.shared-FwgbE1WG.js +2696 -0
- package/.docu/lib/html.shared-FwgbE1WG.js.map +1 -0
- package/.docu/lib/logger-CQyNTE6L.js +306 -0
- package/.docu/lib/logger-CQyNTE6L.js.map +1 -0
- package/.docu/lib/node-DPTySdwG.js +80 -0
- package/.docu/lib/node-DPTySdwG.js.map +1 -0
- package/.docu/lib/paths-Bl2cdp9E.js +76 -0
- package/.docu/lib/paths-Bl2cdp9E.js.map +1 -0
- package/.docu/lib/preview.deno.js +7 -11
- package/.docu/lib/preview.deno.js.map +1 -0
- package/.docu/lib/preview.impl-CnsbLmDA.js +77 -0
- package/.docu/lib/preview.impl-CnsbLmDA.js.map +1 -0
- package/.docu/lib/preview.node.js +7 -11
- package/.docu/lib/preview.node.js.map +1 -0
- package/.docu/lib/server.deno.js +7 -12
- package/.docu/lib/server.deno.js.map +1 -0
- package/.docu/lib/server.impl-CXTzYqbF.js +358 -0
- package/.docu/lib/server.impl-CXTzYqbF.js.map +1 -0
- package/.docu/lib/server.node.js +7 -12
- package/.docu/lib/server.node.js.map +1 -0
- package/.docu/lib/utils-DA17MyQG.js +167 -0
- package/.docu/lib/utils-DA17MyQG.js.map +1 -0
- package/.docu/node/build.impl.ts +76 -11
- package/.docu/node/build.ts +83 -9
- package/.docu/node/cache-key.ts +311 -0
- package/.docu/node/hydrate.node.ts +124 -177
- package/.docu/node/hydrate.ts +13 -14
- package/.docu/node/mdx.ts +14 -2
- package/.docu/node/paths.ts +27 -1
- package/.docu/node/types.ts +23 -5
- package/bin/cli.js +56 -18
- package/bin/compile-lib.mjs +30 -28
- package/package.json +10 -16
- package/.docu/lib/build.impl-3ZOQORN3.js +0 -12
- package/.docu/lib/chunk-4IQXHHPF.js +0 -62
- package/.docu/lib/chunk-6EOUQCRM.js +0 -301
- package/.docu/lib/chunk-6HFBJUMG.js +0 -434
- package/.docu/lib/chunk-EOK6KATZ.js +0 -191
- package/.docu/lib/chunk-FERG25YW.js +0 -470
- package/.docu/lib/chunk-MQRFIDS4.js +0 -92
- package/.docu/lib/chunk-U7M5SK76.js +0 -330
- package/.docu/lib/chunk-UISOJ4RW.js +0 -114
- package/.docu/lib/chunk-URBATJEC.js +0 -2720
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils-DA17MyQG.js","names":[],"sources":["../node/security.ts","../node/utils.ts"],"sourcesContent":["import { randomBytes } from \"node:crypto\";\nimport { resolve } from \"node:path\";\nimport { realpathSync } from \"node:fs\";\n\nexport const SECURITY_HEADERS: Record<string, string> = {\n \"Strict-Transport-Security\": \"max-age=63072000; includeSubDomains; preload\",\n \"X-Frame-Options\": \"DENY\",\n \"X-Content-Type-Options\": \"nosniff\",\n \"Referrer-Policy\": \"strict-origin-when-cross-origin\",\n \"Permissions-Policy\": \"camera=(), microphone=(), geolocation=()\",\n};\n\nexport function generateNonce(): string {\n return randomBytes(16).toString(\"base64\");\n}\n\nexport function cspHeader(nonce: string, allowEval = false): string {\n const scriptSrc = allowEval\n ? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval'`\n : `script-src 'self' 'nonce-${nonce}'`;\n return [\n \"default-src 'self'\",\n scriptSrc,\n \"style-src 'self' 'unsafe-inline'\",\n \"img-src 'self' https: data:\",\n \"font-src 'self' data:\",\n \"connect-src 'self' https:\",\n \"frame-src https://www.youtube-nocookie.com\",\n \"frame-ancestors 'none'\",\n ].join(\"; \");\n}\n\nexport function isPathSafe(pathname: string, baseDir: string): boolean {\n const decoded = decodeURIComponent(pathname);\n const resolved = resolve(baseDir, decoded.slice(1));\n const baseDirSlash = baseDir.endsWith(\"/\") ? baseDir : baseDir + \"/\";\n if (!(resolved === baseDir || resolved.startsWith(baseDirSlash))) {\n return false;\n }\n\n if (resolved === baseDir) return true;\n try {\n const real = realpathSync(resolved);\n const realBase = realpathSync(baseDir);\n const realBaseDirSlash = realBase.endsWith(\"/\") ? realBase : realBase + \"/\";\n return real.startsWith(realBaseDirSlash);\n } catch (err) {\n const nodeErr = err as NodeJS.ErrnoException;\n if (nodeErr.code === \"ENOENT\") {\n return true;\n }\n console.error(\n `[security] isPathSafe error for pathname=\"${pathname}\": ${nodeErr.message} (code=${nodeErr.code})`\n );\n return false;\n }\n}\n\nexport function isSlugSafe(slug: string, docsDir: string): boolean {\n const resolved = resolve(docsDir, slug);\n const docsDirSlash = docsDir.endsWith(\"/\") ? docsDir : docsDir + \"/\";\n if (!(resolved === docsDir || resolved.startsWith(docsDirSlash))) {\n return false;\n }\n\n if (resolved === docsDir) return true;\n try {\n const real = realpathSync(resolved);\n const realDocsDir = realpathSync(docsDir);\n const realDocsDirSlash = realDocsDir.endsWith(\"/\") ? realDocsDir : realDocsDir + \"/\";\n return real.startsWith(realDocsDirSlash);\n } catch (err) {\n const nodeErr = err as NodeJS.ErrnoException;\n if (nodeErr.code === \"ENOENT\") {\n return true;\n }\n console.error(\n `[security] isSlugSafe error for slug=\"${slug}\": ${nodeErr.message} (code=${nodeErr.code})`\n );\n return false;\n }\n}\n\n/** Normalize an esbuild importer path to a canonical forward-slash absolute form. */\nexport function normalizeImporterPath(importer: string): string {\n return resolve(importer).replace(/\\\\/g, \"/\");\n}\n\nexport function injectNonce(html: string, nonce: string): string {\n return html.replace(/<script\\b(?![^>]*\\bsrc\\s*=)([^>]*)>/gi, (match) => {\n if (/nonce\\s*=/i.test(match)) {\n return match.replace(/nonce=\"[^\"]*\"/i, `nonce=\"${nonce}\"`);\n }\n return match.replace(/>$/, ` nonce=\"${nonce}\">`);\n });\n}\n\nexport interface PluginResponseLike {\n status: number;\n statusText?: string;\n headers: Headers;\n body: BodyInit | null;\n}\n\n/**\n * Wrap a plugin response with security headers.\n * - Fills in SECURITY_HEADERS defaults where plugin hasn't set a value\n * - Adds Content-Security-Policy for HTML responses (with optional unsafe-eval)\n * - Preserves plugin body, status, statusText unchanged\n */\nexport function wrapPluginResponse(\n pluginResponse: PluginResponseLike,\n allowEval = false\n): Response {\n const securedHeaders = new Headers(pluginResponse.headers);\n for (const [key, value] of Object.entries(SECURITY_HEADERS)) {\n if (!securedHeaders.has(key)) {\n securedHeaders.set(key, value);\n }\n }\n let body = pluginResponse.body;\n const contentType = securedHeaders.get(\"Content-Type\") || \"\";\n if (contentType.includes(\"text/html\") && !securedHeaders.has(\"Content-Security-Policy\")) {\n const nonce = generateNonce();\n securedHeaders.set(\"Content-Security-Policy\", cspHeader(nonce, allowEval));\n if (typeof body === \"string\") {\n body = injectNonce(body, nonce);\n }\n }\n return new Response(body, {\n status: pluginResponse.status,\n statusText: pluginResponse.statusText,\n headers: securedHeaders,\n });\n}\n\nexport function htmlResponse(\n html: string,\n nonce: string,\n status = 200,\n allowEval = false\n): Response {\n return new Response(html, {\n status,\n headers: {\n \"Content-Type\": \"text/html\",\n ...SECURITY_HEADERS,\n \"Content-Security-Policy\": cspHeader(nonce, allowEval),\n },\n });\n}\n","export { cn, parseDate, formatDate, formatDate2 } from \"@docubook/core\";\n\nimport { readdir, stat } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nexport interface ScannedMdxFile {\n path: string;\n absPath: string;\n mtime: number;\n}\n\n/**\n * Scan a directory recursively for MDX/MD files.\n * Skips \"assets\" directories, hidden directories (dot-prefixed), and root-level index.mdx.\n * Shared between build.ts and search-indexer.ts.\n */\nexport async function scanMdxFiles(dir: string, baseDir = \"\"): Promise<ScannedMdxFile[]> {\n const files: ScannedMdxFile[] = [];\n const entries = await readdir(dir, { withFileTypes: true });\n\n for (const entry of entries) {\n const fullPath = join(dir, entry.name);\n const relativePath = baseDir ? `${baseDir}/${entry.name}` : entry.name;\n\n if (entry.isDirectory()) {\n if (entry.name === \"assets\" || entry.name.startsWith(\".\")) continue;\n files.push(...(await scanMdxFiles(fullPath, relativePath)));\n } else if (entry.name.endsWith(\".mdx\") || entry.name.endsWith(\".md\")) {\n if (entry.name === \"index.mdx\" && !baseDir) continue;\n const stats = await stat(fullPath);\n let path = relativePath.replace(/\\.(mdx|md)$/, \"\");\n\n if (/\\/index$/.test(path)) {\n path = path.replace(/\\/index$/, \"\");\n }\n files.push({ path, absPath: fullPath, mtime: stats.mtimeMs });\n }\n }\n\n return files;\n}\n\nexport function isExternalUrl(url: string): boolean {\n return /^(https?:\\/\\/|\\/\\/)/.test(url);\n}\n\n/** Suffix an internal docs link with `.html` to match the flat static build output. */\nexport function docsHtmlHref(path: string): string {\n return `${path}.html`;\n}\n\n/** Map a `/docs{.html,/*.html}` request back to its extensionless route (dev server).\n * Handles both `/docs/*.html` (pages) and `/docs.html` (edge case). */\nexport function stripDocsHtmlSuffix(pathname: string): string {\n if (pathname === \"/docs.html\") return \"/docs\";\n if (pathname.startsWith(\"/docs/\") && pathname.endsWith(\".html\"))\n return pathname.slice(0, -\".html\".length);\n return pathname;\n}\n\nexport function getPath(url: string): string {\n try {\n return new URL(url).pathname;\n } catch (err) {\n console.error(\"Failed to parse URL\", url, err);\n return url;\n }\n}\n\n/** Get git last modified date for a file */\nexport async function getGitLastModified(filePath: string): Promise<string | null> {\n try {\n const cleanPath = filePath.replace(/^\\//, \"\");\n if (\n !cleanPath ||\n !/^[a-zA-Z0-9\\-_/.\\s]+$/.test(cleanPath) ||\n /(^|\\/)\\.\\.($|\\/)/.test(cleanPath)\n )\n return null;\n const proc = Bun.spawn([\"git\", \"log\", \"-1\", \"--format=%cI\", \"--\", cleanPath], {\n stderr: \"ignore\",\n });\n const text = await new Response(proc.stdout).text();\n const date = text.trim();\n return date || null;\n } catch (err) {\n console.error(\"Failed to get git last modified for\", filePath, err);\n return null;\n }\n}\n\n/** Batch git last modified dates for multiple files in a single spawn */\nexport async function getGitLastModifiedBatch(filePaths: string[]): Promise<Map<string, string>> {\n const result = new Map<string, string>();\n if (filePaths.length === 0) return result;\n\n // Filter and validate paths — same guard as getGitLastModified\n const safePaths: string[] = [];\n for (const fp of filePaths) {\n const cleanPath = fp.replace(/^\\//, \"\");\n if (\n !cleanPath ||\n !/^[a-zA-Z0-9\\-_/.\\s]+$/.test(cleanPath) ||\n /(^|\\/)\\.\\.($|\\/)/.test(cleanPath)\n ) {\n console.warn(`[utils] getGitLastModifiedBatch: skipping invalid path \"${fp}\"`);\n continue;\n }\n safePaths.push(cleanPath);\n }\n\n if (safePaths.length === 0) return result;\n\n try {\n const proc = Bun.spawn(\n [\"git\", \"log\", \"--format=%cI\", \"--name-only\", \"--diff-filter=ACMR\", ...safePaths],\n { stderr: \"ignore\" }\n );\n const text = await new Response(proc.stdout).text();\n let currentDate = \"\";\n\n for (const line of text.split(\"\\n\")) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n if (/^\\d{4}-\\d{2}-\\d{2}T/.test(trimmed)) {\n currentDate = trimmed;\n } else if (currentDate && !result.has(trimmed)) {\n result.set(trimmed, currentDate);\n }\n }\n } catch (err) {\n console.error(\"Failed to get git last modified batch for\", filePaths, err);\n }\n\n return result;\n}\n\nconst MIME_TYPES: Record<string, string> = {\n html: \"text/html\",\n css: \"text/css\",\n js: \"application/javascript\",\n json: \"application/json\",\n png: \"image/png\",\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n svg: \"image/svg+xml\",\n ico: \"image/x-icon\",\n woff: \"font/woff\",\n woff2: \"font/woff2\",\n};\n\nexport function getContentType(pathname: string): string {\n const ext = pathname.split(\".\").pop()?.toLowerCase();\n return MIME_TYPES[ext || \"\"] || \"application/octet-stream\";\n}\n"],"mappings":"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"}
|
package/.docu/node/build.impl.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* The Node/Deno build entries call `runBuildCli()`.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { readFile, writeFile, mkdir, readdir, copyFile } from "node:fs/promises";
|
|
9
|
+
import { readFile, writeFile, mkdir, readdir, copyFile, rename, unlink } from "node:fs/promises";
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
12
|
import { join, dirname } from "node:path";
|
|
@@ -39,7 +39,16 @@ import { initSentry, captureException } from "./sentry";
|
|
|
39
39
|
import { loadPlugins } from "./plugin-loader";
|
|
40
40
|
import { BuildPluginBuilder } from "./plugin-builder";
|
|
41
41
|
import { scanMdxFiles } from "./utils";
|
|
42
|
-
import type { BuildCache, CliArgs } from "./types";
|
|
42
|
+
import type { BuildCache, BuildCacheMeta, CliArgs } from "./types";
|
|
43
|
+
import { isCacheEntry } from "./types";
|
|
44
|
+
import {
|
|
45
|
+
BUILD_CACHE_VERSION,
|
|
46
|
+
atomicWriteFile,
|
|
47
|
+
hashMdxSources,
|
|
48
|
+
hookMemoryPressure,
|
|
49
|
+
runtimeStamp,
|
|
50
|
+
} from "./cache-key";
|
|
51
|
+
import { clearDerivedPageCaches } from "./mdx";
|
|
43
52
|
import { generateNonce, cspHeader } from "./security";
|
|
44
53
|
import type { PageMeta, PageContext } from "./plugin";
|
|
45
54
|
import { buildSeoMeta } from "./seo";
|
|
@@ -64,7 +73,12 @@ async function readCache(): Promise<BuildCache> {
|
|
|
64
73
|
try {
|
|
65
74
|
if (existsSync(CACHE_FILE)) {
|
|
66
75
|
const data = await readFile(CACHE_FILE, "utf-8");
|
|
67
|
-
|
|
76
|
+
const parsed = JSON.parse(data) as BuildCache;
|
|
77
|
+
const meta = parsed.__meta__ as BuildCacheMeta | undefined;
|
|
78
|
+
if (!meta || meta.version !== BUILD_CACHE_VERSION || meta.runtime !== runtimeStamp()) {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
return parsed;
|
|
68
82
|
}
|
|
69
83
|
} catch (err) {
|
|
70
84
|
console.error("Failed to load build cache:", (err as Error).message);
|
|
@@ -72,8 +86,19 @@ async function readCache(): Promise<BuildCache> {
|
|
|
72
86
|
return {};
|
|
73
87
|
}
|
|
74
88
|
|
|
89
|
+
function stampCache(cache: BuildCache): void {
|
|
90
|
+
cache.__meta__ = {
|
|
91
|
+
hash: `${BUILD_CACHE_VERSION}:${runtimeStamp()}`,
|
|
92
|
+
mtime: 0,
|
|
93
|
+
builtAt: Date.now(),
|
|
94
|
+
version: BUILD_CACHE_VERSION,
|
|
95
|
+
runtime: runtimeStamp(),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
75
99
|
async function writeCache(cache: BuildCache): Promise<void> {
|
|
76
|
-
|
|
100
|
+
stampCache(cache);
|
|
101
|
+
await atomicWriteFile(writeFile, rename, unlink, CACHE_FILE, JSON.stringify(cache, null, 2));
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
function parseConcurrency(): number {
|
|
@@ -84,13 +109,38 @@ type RebuildDecision = "yes" | "hash_check" | "no";
|
|
|
84
109
|
|
|
85
110
|
function shouldRebuild(path: string, mtime: number, cache: BuildCache): RebuildDecision {
|
|
86
111
|
const cached = cache[path];
|
|
87
|
-
if (!cached) return "yes";
|
|
88
|
-
if (mtime > cached.builtAt) return "hash_check";
|
|
112
|
+
if (!isCacheEntry(cached)) return "yes";
|
|
113
|
+
if (mtime > cached.builtAt + 2000) return "hash_check";
|
|
114
|
+
if (Math.abs(mtime - cached.builtAt) <= 2000 && mtime !== cached.mtime) return "hash_check";
|
|
115
|
+
if (mtime !== cached.mtime && mtime > cached.mtime) return "hash_check";
|
|
89
116
|
return "no";
|
|
90
117
|
}
|
|
91
118
|
|
|
92
119
|
let assetManifest = { js: "client.js", css: "client.css" };
|
|
93
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Reuse manifest.json on bundle cache hit; fall back to a full rebuild
|
|
123
|
+
* when the manifest is missing or malformed.
|
|
124
|
+
*/
|
|
125
|
+
async function resolveAssetManifest(
|
|
126
|
+
bundleHit: boolean,
|
|
127
|
+
mdxSources: Record<string, string>
|
|
128
|
+
): Promise<{ js: string; css: string }> {
|
|
129
|
+
if (!bundleHit) return buildClientBundle(mdxSources);
|
|
130
|
+
try {
|
|
131
|
+
const manifest = JSON.parse(await readFile(join(ASSETS_DIR, "manifest.json"), "utf-8")) as {
|
|
132
|
+
js?: string;
|
|
133
|
+
css?: string;
|
|
134
|
+
};
|
|
135
|
+
if (typeof manifest.js === "string" && typeof manifest.css === "string") {
|
|
136
|
+
return { js: manifest.js, css: manifest.css };
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// corrupt/missing manifest — rebuild below
|
|
140
|
+
}
|
|
141
|
+
return buildClientBundle(mdxSources);
|
|
142
|
+
}
|
|
143
|
+
|
|
94
144
|
let inlineThemeCss: string | undefined;
|
|
95
145
|
|
|
96
146
|
async function renderDocsPage(
|
|
@@ -180,7 +230,7 @@ async function renderDocsPage(
|
|
|
180
230
|
const seo = buildSeoMeta(docuConfig, frontmatter, slug || "");
|
|
181
231
|
// MDX content hydrates from the bundled ESM module (mdx-hydrate), not
|
|
182
232
|
// new Function — no 'unsafe-eval' needed in the CSP.
|
|
183
|
-
const csp = cspHeader(nonce);
|
|
233
|
+
const csp = nonce ? cspHeader(nonce) : undefined;
|
|
184
234
|
let html = htmlShell({
|
|
185
235
|
title,
|
|
186
236
|
description,
|
|
@@ -223,6 +273,8 @@ export async function runBuild(): Promise<void> {
|
|
|
223
273
|
const docuConfig = loadDocuConfig();
|
|
224
274
|
const args = parseArgs();
|
|
225
275
|
|
|
276
|
+
hookMemoryPressure(clearDerivedPageCaches);
|
|
277
|
+
|
|
226
278
|
logger.buildStart();
|
|
227
279
|
|
|
228
280
|
if (args.clean) {
|
|
@@ -311,14 +363,20 @@ export async function runBuild(): Promise<void> {
|
|
|
311
363
|
|
|
312
364
|
logger.bundleStart();
|
|
313
365
|
let t = performance.now();
|
|
314
|
-
|
|
366
|
+
const bundleHash = hashMdxSources(mdxSources);
|
|
367
|
+
const lastBundle = cache["__bundle__"];
|
|
368
|
+
const bundleHit =
|
|
369
|
+
isCacheEntry(lastBundle) &&
|
|
370
|
+
lastBundle.hash === bundleHash &&
|
|
371
|
+
existsSync(join(ASSETS_DIR, "manifest.json"));
|
|
372
|
+
assetManifest = await resolveAssetManifest(bundleHit, mdxSources);
|
|
315
373
|
logger.bundleDone(Math.round(performance.now() - t));
|
|
316
374
|
|
|
317
375
|
inlineThemeCss = computeInlineThemeCss();
|
|
318
376
|
|
|
319
377
|
const lastManifest = cache["__assets__"];
|
|
320
378
|
const assetsChanged =
|
|
321
|
-
!lastManifest || lastManifest.hash !== `${assetManifest.js}:${assetManifest.css}`;
|
|
379
|
+
!isCacheEntry(lastManifest) || lastManifest.hash !== `${assetManifest.js}:${assetManifest.css}`;
|
|
322
380
|
if (assetsChanged) {
|
|
323
381
|
cache["__assets__"] = {
|
|
324
382
|
hash: `${assetManifest.js}:${assetManifest.css}`,
|
|
@@ -326,6 +384,9 @@ export async function runBuild(): Promise<void> {
|
|
|
326
384
|
builtAt: Date.now(),
|
|
327
385
|
};
|
|
328
386
|
}
|
|
387
|
+
if (!bundleHit) {
|
|
388
|
+
cache["__bundle__"] = { hash: bundleHash, mtime: 0, builtAt: Date.now() };
|
|
389
|
+
}
|
|
329
390
|
|
|
330
391
|
logger.spinner.start("Building pages...");
|
|
331
392
|
t = performance.now();
|
|
@@ -366,7 +427,7 @@ export async function runBuild(): Promise<void> {
|
|
|
366
427
|
if (rebuildDecision === "hash_check") {
|
|
367
428
|
const contentHash = hashContent(rawMdx);
|
|
368
429
|
const cached = cache[file.path];
|
|
369
|
-
if (cached && cached.hash === contentHash) {
|
|
430
|
+
if (isCacheEntry(cached) && cached.hash === contentHash) {
|
|
370
431
|
if (!assetsChanged) {
|
|
371
432
|
const outputPath = join(DIST_DIR, "docs", `${file.path}.html`);
|
|
372
433
|
if (existsSync(outputPath)) {
|
|
@@ -438,7 +499,11 @@ export async function runBuild(): Promise<void> {
|
|
|
438
499
|
|
|
439
500
|
const landingPage = React.createElement(IndexPage);
|
|
440
501
|
const landingFavicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
|
|
441
|
-
const landingSeo = buildSeoMeta(
|
|
502
|
+
const landingSeo = buildSeoMeta(
|
|
503
|
+
docuConfig,
|
|
504
|
+
docuConfig.meta as unknown as Record<string, unknown>,
|
|
505
|
+
""
|
|
506
|
+
);
|
|
442
507
|
const landingNonce = generateNonce();
|
|
443
508
|
const landingHtml = htmlShell({
|
|
444
509
|
title: docuConfig.meta?.title || "DocuBook",
|
package/.docu/node/build.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, readdir, copyFile } from "node:fs/promises";
|
|
1
|
+
import { readFile, writeFile, mkdir, readdir, copyFile, rename, unlink } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
@@ -31,7 +31,16 @@ import { initSentry, captureException } from "./sentry";
|
|
|
31
31
|
import { loadPlugins } from "./plugin-loader";
|
|
32
32
|
import { BuildPluginBuilder } from "./plugin-builder";
|
|
33
33
|
import { scanMdxFiles } from "./utils";
|
|
34
|
-
import type { BuildCache, CliArgs } from "./types";
|
|
34
|
+
import type { BuildCache, BuildCacheMeta, CliArgs } from "./types";
|
|
35
|
+
import { isCacheEntry } from "./types";
|
|
36
|
+
import {
|
|
37
|
+
BUILD_CACHE_VERSION,
|
|
38
|
+
atomicWriteFile,
|
|
39
|
+
hashMdxSources,
|
|
40
|
+
hookMemoryPressure,
|
|
41
|
+
runtimeStamp,
|
|
42
|
+
} from "./cache-key";
|
|
43
|
+
import { clearDerivedPageCaches } from "./mdx";
|
|
35
44
|
import { generateNonce } from "./security";
|
|
36
45
|
import type { PageMeta, PageContext } from "./plugin";
|
|
37
46
|
import { buildSeoMeta } from "./seo";
|
|
@@ -58,7 +67,15 @@ async function readCache(): Promise<BuildCache> {
|
|
|
58
67
|
try {
|
|
59
68
|
if (existsSync(CACHE_FILE)) {
|
|
60
69
|
const data = await readFile(CACHE_FILE, "utf-8");
|
|
61
|
-
|
|
70
|
+
const parsed = JSON.parse(data) as BuildCache;
|
|
71
|
+
// Toolchain upgrade (Bun 1.3 → 1.4, Tailwind CLI bump) changes build
|
|
72
|
+
// output without changing page content — a stale cache would false-hit.
|
|
73
|
+
// Discard when the version stamp or runtime fingerprint mismatches.
|
|
74
|
+
const meta = parsed.__meta__ as BuildCacheMeta | undefined;
|
|
75
|
+
if (!meta || meta.version !== BUILD_CACHE_VERSION || meta.runtime !== runtimeStamp()) {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
62
79
|
}
|
|
63
80
|
} catch (err) {
|
|
64
81
|
console.error("Failed to load build cache:", (err as Error).message);
|
|
@@ -66,8 +83,21 @@ async function readCache(): Promise<BuildCache> {
|
|
|
66
83
|
return {};
|
|
67
84
|
}
|
|
68
85
|
|
|
86
|
+
/** Stamp the cache with the current toolchain fingerprint. */
|
|
87
|
+
function stampCache(cache: BuildCache): void {
|
|
88
|
+
cache.__meta__ = {
|
|
89
|
+
hash: `${BUILD_CACHE_VERSION}:${runtimeStamp()}`,
|
|
90
|
+
mtime: 0,
|
|
91
|
+
builtAt: Date.now(),
|
|
92
|
+
version: BUILD_CACHE_VERSION,
|
|
93
|
+
runtime: runtimeStamp(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
69
97
|
async function writeCache(cache: BuildCache): Promise<void> {
|
|
70
|
-
|
|
98
|
+
stampCache(cache);
|
|
99
|
+
// Atomic tmp+rename: a crash mid-write never leaves a corrupt cache file.
|
|
100
|
+
await atomicWriteFile(writeFile, rename, unlink, CACHE_FILE, JSON.stringify(cache, null, 2));
|
|
71
101
|
}
|
|
72
102
|
|
|
73
103
|
export function parseConcurrency(): number {
|
|
@@ -78,13 +108,41 @@ type RebuildDecision = "yes" | "hash_check" | "no";
|
|
|
78
108
|
|
|
79
109
|
export function shouldRebuild(path: string, mtime: number, cache: BuildCache): RebuildDecision {
|
|
80
110
|
const cached = cache[path];
|
|
81
|
-
if (!cached) return "yes";
|
|
82
|
-
|
|
111
|
+
if (!isCacheEntry(cached)) return "yes";
|
|
112
|
+
// 2s tolerance: mtimeMs (float, fs precision) vs builtAt (int, Date.now())
|
|
113
|
+
// can miss on fast rebuilds after Bun 1.4's 2x faster startup — equality
|
|
114
|
+
// within tolerance still falls through to the hash check, never to "no".
|
|
115
|
+
if (mtime > cached.builtAt + 2000) return "hash_check";
|
|
116
|
+
if (Math.abs(mtime - cached.builtAt) <= 2000 && mtime !== cached.mtime) return "hash_check";
|
|
117
|
+
if (mtime !== cached.mtime && mtime > cached.mtime) return "hash_check";
|
|
83
118
|
return "no";
|
|
84
119
|
}
|
|
85
120
|
|
|
86
121
|
let assetManifest = { js: "client.js", css: "client.css" };
|
|
87
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Reuse manifest.json on bundle cache hit; fall back to a full rebuild
|
|
125
|
+
* when the manifest is missing or malformed.
|
|
126
|
+
*/
|
|
127
|
+
async function resolveAssetManifest(
|
|
128
|
+
bundleHit: boolean,
|
|
129
|
+
mdxSources: Record<string, string>
|
|
130
|
+
): Promise<{ js: string; css: string }> {
|
|
131
|
+
if (!bundleHit) return buildClientBundle(mdxSources);
|
|
132
|
+
try {
|
|
133
|
+
const manifest = JSON.parse(await readFile(join(ASSETS_DIR, "manifest.json"), "utf-8")) as {
|
|
134
|
+
js?: string;
|
|
135
|
+
css?: string;
|
|
136
|
+
};
|
|
137
|
+
if (typeof manifest.js === "string" && typeof manifest.css === "string") {
|
|
138
|
+
return { js: manifest.js, css: manifest.css };
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// corrupt/missing manifest — rebuild below
|
|
142
|
+
}
|
|
143
|
+
return buildClientBundle(mdxSources);
|
|
144
|
+
}
|
|
145
|
+
|
|
88
146
|
let inlineThemeCss: string | undefined;
|
|
89
147
|
|
|
90
148
|
async function renderDocsPage(
|
|
@@ -211,6 +269,10 @@ async function copyDirectoryRecursive(src: string, dest: string): Promise<void>
|
|
|
211
269
|
async function build() {
|
|
212
270
|
const args = parseArgs();
|
|
213
271
|
|
|
272
|
+
// Bun 1.4 `process.on("memoryPressure")`: drop parsed page maps when the
|
|
273
|
+
// OS runs low on memory (long CI builds). No-op on older runtimes.
|
|
274
|
+
hookMemoryPressure(clearDerivedPageCaches);
|
|
275
|
+
|
|
214
276
|
logger.buildStart();
|
|
215
277
|
|
|
216
278
|
if (args.clean) {
|
|
@@ -299,14 +361,23 @@ async function build() {
|
|
|
299
361
|
|
|
300
362
|
logger.bundleStart();
|
|
301
363
|
let t = performance.now();
|
|
302
|
-
|
|
364
|
+
// Skip the JS bundle when compiled MDX sources are unchanged: the bundle
|
|
365
|
+
// is shared by every page, so its hash doubles as the content fingerprint.
|
|
366
|
+
// CSS still builds via its own content-keyed cache inside the hydrator.
|
|
367
|
+
const bundleHash = hashMdxSources(mdxSources);
|
|
368
|
+
const lastBundle = cache["__bundle__"];
|
|
369
|
+
const bundleHit =
|
|
370
|
+
isCacheEntry(lastBundle) &&
|
|
371
|
+
lastBundle.hash === bundleHash &&
|
|
372
|
+
existsSync(join(ASSETS_DIR, "manifest.json"));
|
|
373
|
+
assetManifest = await resolveAssetManifest(bundleHit, mdxSources);
|
|
303
374
|
logger.bundleDone(Math.round(performance.now() - t));
|
|
304
375
|
|
|
305
376
|
inlineThemeCss = computeInlineThemeCss();
|
|
306
377
|
|
|
307
378
|
const lastManifest = cache["__assets__"];
|
|
308
379
|
const assetsChanged =
|
|
309
|
-
!lastManifest || lastManifest.hash !== `${assetManifest.js}:${assetManifest.css}`;
|
|
380
|
+
!isCacheEntry(lastManifest) || lastManifest.hash !== `${assetManifest.js}:${assetManifest.css}`;
|
|
310
381
|
if (assetsChanged) {
|
|
311
382
|
cache["__assets__"] = {
|
|
312
383
|
hash: `${assetManifest.js}:${assetManifest.css}`,
|
|
@@ -314,6 +385,9 @@ async function build() {
|
|
|
314
385
|
builtAt: Date.now(),
|
|
315
386
|
};
|
|
316
387
|
}
|
|
388
|
+
if (!bundleHit) {
|
|
389
|
+
cache["__bundle__"] = { hash: bundleHash, mtime: 0, builtAt: Date.now() };
|
|
390
|
+
}
|
|
317
391
|
|
|
318
392
|
logger.spinner.start("Building pages...");
|
|
319
393
|
t = performance.now();
|
|
@@ -354,7 +428,7 @@ async function build() {
|
|
|
354
428
|
if (rebuildDecision === "hash_check") {
|
|
355
429
|
const contentHash = hashContent(rawMdx);
|
|
356
430
|
const cached = cache[file.path];
|
|
357
|
-
if (cached && cached.hash === contentHash) {
|
|
431
|
+
if (isCacheEntry(cached) && cached.hash === contentHash) {
|
|
358
432
|
if (!assetsChanged) {
|
|
359
433
|
const outputPath = join(DIST_DIR, "docs", `${file.path}.html`);
|
|
360
434
|
if (existsSync(outputPath)) {
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { FRAMEWORK_ROOT, STYLES_DIR, resolveProjectFile } from "./paths";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build cache version — bump when the toolchain output contract changes
|
|
8
|
+
* (e.g. Bun.build barrel optimization, Tailwind CLI upgrade). Old caches
|
|
9
|
+
* with a mismatched version are discarded on read (see build.ts readCache).
|
|
10
|
+
*/
|
|
11
|
+
export const BUILD_CACHE_VERSION = 3;
|
|
12
|
+
|
|
13
|
+
/** Toolchain fingerprint: Bun version on Bun, Deno version on Deno, Node elsewhere. */
|
|
14
|
+
export function runtimeStamp(): string {
|
|
15
|
+
const g = globalThis as Record<string, unknown>;
|
|
16
|
+
const bun = g.Bun as { version?: string } | undefined;
|
|
17
|
+
if (typeof bun?.version === "string" && bun.version.length > 0) return `bun-${bun.version}`;
|
|
18
|
+
const deno = g.Deno as { version?: { deno?: string } } | undefined;
|
|
19
|
+
if (typeof deno?.version?.deno === "string" && deno.version.deno.length > 0)
|
|
20
|
+
return `deno-${deno.version.deno}`;
|
|
21
|
+
const proc = g.process as { version?: string } | undefined;
|
|
22
|
+
if (typeof proc?.version === "string" && proc.version.length > 0) return `node-${proc.version}`;
|
|
23
|
+
return "node-unknown";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True when a CSS file can change Tailwind v4 output.
|
|
28
|
+
* v4 is CSS-first: `@theme`, `@source`, `@plugin`, `@custom-variant`,
|
|
29
|
+
* `@utility`, `@config`, `@apply`/`@variant`, and `@import "tailwindcss"`
|
|
30
|
+
* all live in CSS. Plain CSS without these cannot affect the CLI output,
|
|
31
|
+
* so it is excluded to avoid false cache busts. Broad match deliberate:
|
|
32
|
+
* false positive busts safe, false negative serves stale CSS.
|
|
33
|
+
*/
|
|
34
|
+
export function isTailwindRelevantCss(content: string): boolean {
|
|
35
|
+
return (
|
|
36
|
+
content.includes("@theme") ||
|
|
37
|
+
content.includes("@source") ||
|
|
38
|
+
content.includes("@plugin") ||
|
|
39
|
+
content.includes("@custom-variant") ||
|
|
40
|
+
content.includes("@utility") ||
|
|
41
|
+
content.includes("@config") ||
|
|
42
|
+
content.includes("@apply") ||
|
|
43
|
+
content.includes("@variant") ||
|
|
44
|
+
content.includes("@layer") ||
|
|
45
|
+
content.includes("tailwindcss")
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const IMPORT_RE = /@import\s+(?:url\()?["']([^"']+)["']/g;
|
|
50
|
+
const MAX_IMPORT_DEPTH = 10;
|
|
51
|
+
|
|
52
|
+
/** Resolve `./` + `../` imports only — bare specifiers are version-pinned deps. */
|
|
53
|
+
function resolveRelativeImport(spec: string, fromDir: string): string | undefined {
|
|
54
|
+
if (!spec.startsWith(".")) return undefined;
|
|
55
|
+
const clean = spec.split("?")[0]!.split("#")[0]!;
|
|
56
|
+
if (clean.length === 0) return undefined;
|
|
57
|
+
return join(fromDir, clean);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read a CSS file plus transitively imported relative files.
|
|
62
|
+
* Non-relevant files contribute "" themselves, but their imports are still
|
|
63
|
+
* followed (nested file may carry `@theme`). `visited` breaks import cycles.
|
|
64
|
+
* Missing/unreadable files resolve to "" — never throws.
|
|
65
|
+
*/
|
|
66
|
+
function readCssWithImports(path: string, visited: Set<string>, depth = 0): string {
|
|
67
|
+
if (depth > MAX_IMPORT_DEPTH || visited.has(path)) return "";
|
|
68
|
+
visited.add(path);
|
|
69
|
+
let content = "";
|
|
70
|
+
try {
|
|
71
|
+
if (!existsSync(path)) return "";
|
|
72
|
+
content = readFileSync(path, "utf-8");
|
|
73
|
+
} catch {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
let out = isTailwindRelevantCss(content) ? content : "";
|
|
77
|
+
try {
|
|
78
|
+
const dir = join(path, "..");
|
|
79
|
+
for (const m of content.matchAll(IMPORT_RE)) {
|
|
80
|
+
const resolved = resolveRelativeImport(m[1] ?? "", dir);
|
|
81
|
+
if (resolved) out += readCssWithImports(resolved, visited, depth + 1);
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// import scan failed — keep what we have
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function scanCssDir(dir: string, visited: Set<string>): string {
|
|
90
|
+
let out = "";
|
|
91
|
+
try {
|
|
92
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
93
|
+
for (const e of entries) {
|
|
94
|
+
const full = join(dir, e.name);
|
|
95
|
+
if (e.isDirectory()) {
|
|
96
|
+
if (e.name === "assets" || e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
97
|
+
out += scanCssDir(full, visited);
|
|
98
|
+
} else if (e.name.endsWith(".css")) {
|
|
99
|
+
out += readCssWithImports(full, visited);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// docs/ missing — nothing to add
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function scanRootCss(root: string, visited: Set<string>): string {
|
|
109
|
+
let out = "";
|
|
110
|
+
try {
|
|
111
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
112
|
+
for (const e of entries) {
|
|
113
|
+
if (e.isFile() && e.name.endsWith(".css")) {
|
|
114
|
+
out += readCssWithImports(join(root, e.name), visited);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// unreadable root — proceed without
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Hash @theme/@source/@plugin-bearing CSS in project docs/ + root *.css. */
|
|
124
|
+
function tailwindCssThemeInputs(root: string): string {
|
|
125
|
+
const visited = new Set<string>();
|
|
126
|
+
return scanCssDir(join(root, "docs"), visited) + scanRootCss(root, visited);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const TAILWIND_CONFIG_FILES = [
|
|
130
|
+
"tailwind.config.ts",
|
|
131
|
+
"tailwind.config.js",
|
|
132
|
+
"tailwind.config.mjs",
|
|
133
|
+
"tailwind.config.cjs",
|
|
134
|
+
"tailwind.config.mts",
|
|
135
|
+
"tailwind.config.cts",
|
|
136
|
+
"postcss.config.js",
|
|
137
|
+
"postcss.config.mjs",
|
|
138
|
+
"postcss.config.cjs",
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* JS config still affects v4 when referenced via `@config`
|
|
143
|
+
* (plus PostCSS pipeline config). Content hashed when present.
|
|
144
|
+
*/
|
|
145
|
+
function tailwindJsConfigInputs(root: string): string {
|
|
146
|
+
let out = "";
|
|
147
|
+
for (const name of TAILWIND_CONFIG_FILES) {
|
|
148
|
+
try {
|
|
149
|
+
const p = join(root, name);
|
|
150
|
+
if (existsSync(p)) out += readFileSync(p, "utf-8") + "\0";
|
|
151
|
+
} catch {
|
|
152
|
+
// unreadable config — skip
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readInstalledVersion(pkg: string, roots: string[]): string {
|
|
159
|
+
for (const root of roots) {
|
|
160
|
+
try {
|
|
161
|
+
const p = join(root, "node_modules", ...pkg.split("/"), "package.json");
|
|
162
|
+
if (existsSync(p)) {
|
|
163
|
+
const parsed = JSON.parse(readFileSync(p, "utf-8")) as { version?: string };
|
|
164
|
+
if (typeof parsed.version === "string" && parsed.version.length > 0) return parsed.version;
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
// try next root
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Pin resolved CSS-affecting deps into the key (installed version wins). */
|
|
174
|
+
function tailwindVersionPins(root: string): string {
|
|
175
|
+
const names = ["tailwindcss", "@tailwindcss/cli", "@tailwindcss/typography", "daisyui"] as const;
|
|
176
|
+
let extra = "";
|
|
177
|
+
const roots = [root, FRAMEWORK_ROOT];
|
|
178
|
+
for (const name of names) {
|
|
179
|
+
const installed = readInstalledVersion(name, roots);
|
|
180
|
+
if (installed) extra += `${name}@${installed}\0`;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const pkgPath = join(root, "package.json");
|
|
184
|
+
if (existsSync(pkgPath)) {
|
|
185
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
|
|
186
|
+
dependencies?: Record<string, string>;
|
|
187
|
+
devDependencies?: Record<string, string>;
|
|
188
|
+
};
|
|
189
|
+
for (const name of names) {
|
|
190
|
+
const range = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name] ?? "";
|
|
191
|
+
if (range) extra += `${name}:${range}\0`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// package.json unreadable — proceed without version pin
|
|
196
|
+
}
|
|
197
|
+
return extra;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Extra Tailwind inputs beyond globals.css + theme.
|
|
202
|
+
* v4 is CSS-first: user overrides live in `@theme`/`@source`/`@plugin`
|
|
203
|
+
* blocks inside CSS files under the project root (e.g. docs slash star dot css),
|
|
204
|
+
* plus JS config referenced via `@config` and installed plugin versions.
|
|
205
|
+
* Missing files resolve to "" — never throws.
|
|
206
|
+
*/
|
|
207
|
+
export function tailwindExtraInputs(root: string = resolveProjectFile()): string {
|
|
208
|
+
return (
|
|
209
|
+
tailwindCssThemeInputs(root) + "\0" + tailwindJsConfigInputs(root) + tailwindVersionPins(root)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Shared Tailwind cache key: globals.css + theme + tailwind config +
|
|
215
|
+
* toolchain version. Used by both hydrate.ts (Bun) and hydrate.node.ts
|
|
216
|
+
* (Vite) so the two runtimes agree on filenames. Segments joined with NUL
|
|
217
|
+
* so `("ab","c")` and `("a","bc")` hash differently.
|
|
218
|
+
*/
|
|
219
|
+
export function computeTailwindCacheKey(
|
|
220
|
+
globals: string,
|
|
221
|
+
themeSuffix: string,
|
|
222
|
+
projectRoot: string = resolveProjectFile()
|
|
223
|
+
): string {
|
|
224
|
+
const h = createHash("sha256");
|
|
225
|
+
h.update(globals);
|
|
226
|
+
h.update("\0");
|
|
227
|
+
h.update(themeSuffix);
|
|
228
|
+
h.update("\0");
|
|
229
|
+
h.update(tailwindExtraInputs(projectRoot));
|
|
230
|
+
h.update("\0");
|
|
231
|
+
h.update(runtimeStamp());
|
|
232
|
+
h.update("\0");
|
|
233
|
+
h.update(`v${BUILD_CACHE_VERSION}`);
|
|
234
|
+
return h.digest("hex").slice(0, 16);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Read globals.css content ("" when missing). Shared by both hydrators. */
|
|
238
|
+
export function readGlobalsCss(): string {
|
|
239
|
+
const globalsPath = join(STYLES_DIR, "globals.css");
|
|
240
|
+
return existsSync(globalsPath) ? readFileSync(globalsPath, "utf-8") : "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Atomic file write: tmp + rename so a crash mid-write never leaves a
|
|
245
|
+
* half-written cache/CSS behind. Tmp name includes pid so parallel
|
|
246
|
+
* builds (`bun run --parallel`) do not clobber each other.
|
|
247
|
+
*/
|
|
248
|
+
export async function atomicWriteFile(
|
|
249
|
+
writeFile: (p: string, data: string | Uint8Array) => Promise<void>,
|
|
250
|
+
rename: (from: string, to: string) => Promise<void>,
|
|
251
|
+
unlink: (p: string) => Promise<void>,
|
|
252
|
+
target: string,
|
|
253
|
+
data: string | Uint8Array
|
|
254
|
+
): Promise<void> {
|
|
255
|
+
const g = globalThis as Record<string, unknown>;
|
|
256
|
+
const proc = g.process as { pid?: number } | undefined;
|
|
257
|
+
const pid = typeof proc?.pid === "number" ? proc.pid : Math.floor(Math.random() * 1e9);
|
|
258
|
+
const tmp = `${target}.tmp-${pid}-${Date.now()}`;
|
|
259
|
+
try {
|
|
260
|
+
await writeFile(tmp, data);
|
|
261
|
+
await rename(tmp, target);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
try {
|
|
264
|
+
await unlink(tmp);
|
|
265
|
+
} catch {
|
|
266
|
+
// best-effort tmp cleanup
|
|
267
|
+
}
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Wire memory-pressure hook once: OS low-memory → drop parsed page maps. */
|
|
273
|
+
let memoryPressureHooked = false;
|
|
274
|
+
export function hookMemoryPressure(clear: () => void): void {
|
|
275
|
+
if (memoryPressureHooked) return;
|
|
276
|
+
memoryPressureHooked = true;
|
|
277
|
+
try {
|
|
278
|
+
const g = globalThis as Record<string, unknown>;
|
|
279
|
+
const proc = g.process as
|
|
280
|
+
| {
|
|
281
|
+
on?: (event: string, listener: (level: string) => void) => void;
|
|
282
|
+
}
|
|
283
|
+
| undefined;
|
|
284
|
+
proc?.on?.("memoryPressure", () => {
|
|
285
|
+
try {
|
|
286
|
+
clear();
|
|
287
|
+
} catch {
|
|
288
|
+
// never throw out of a pressure handler
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
} catch {
|
|
292
|
+
// runtimes without the event (Node < Bun 1.4 backport) — no-op
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Hash of compiled MDX module sources (sorted keys + content).
|
|
298
|
+
* Used to skip the JS bundle rebuild when no page content changed.
|
|
299
|
+
*/
|
|
300
|
+
export function hashMdxSources(mdxSources: Record<string, string>): string {
|
|
301
|
+
const h = createHash("sha256");
|
|
302
|
+
const slugs = Object.keys(mdxSources).sort();
|
|
303
|
+
h.update(`v${BUILD_CACHE_VERSION}:${runtimeStamp()}:`);
|
|
304
|
+
for (const slug of slugs) {
|
|
305
|
+
h.update(slug);
|
|
306
|
+
h.update("\0");
|
|
307
|
+
h.update(mdxSources[slug]);
|
|
308
|
+
h.update("\0");
|
|
309
|
+
}
|
|
310
|
+
return h.digest("hex").slice(0, 16);
|
|
311
|
+
}
|