@docubook/flame 2.0.0-beta.2 → 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.
Files changed (39) hide show
  1. package/.docu/lib/build.deno.js +1 -1
  2. package/.docu/lib/{build.impl-BJsi28mD.js → build.impl-Bz_p421t.js} +60 -13
  3. package/.docu/lib/build.impl-Bz_p421t.js.map +1 -0
  4. package/.docu/lib/build.impl-CtPrlYAE.js +2 -0
  5. package/.docu/lib/build.node.js +1 -1
  6. package/.docu/lib/clean.js +1 -1
  7. package/.docu/lib/deploy.deno.js +1 -1
  8. package/.docu/lib/deploy.node.js +1 -1
  9. package/.docu/lib/{deploy.shared-D3UWafa6.js → deploy.shared-fk8eM-r4.js} +3 -3
  10. package/.docu/lib/{deploy.shared-D3UWafa6.js.map → deploy.shared-fk8eM-r4.js.map} +1 -1
  11. package/.docu/lib/{html.shared-DSn-7_6t.js → html.shared-FwgbE1WG.js} +242 -13
  12. package/.docu/lib/html.shared-FwgbE1WG.js.map +1 -0
  13. package/.docu/lib/{logger-CycvLCrQ.js → logger-CQyNTE6L.js} +9 -15
  14. package/.docu/lib/logger-CQyNTE6L.js.map +1 -0
  15. package/.docu/lib/{paths-BtOIPBQ9.js → paths-Bl2cdp9E.js} +27 -3
  16. package/.docu/lib/paths-Bl2cdp9E.js.map +1 -0
  17. package/.docu/lib/preview.deno.js +1 -1
  18. package/.docu/lib/{preview.impl-CXJS2tQS.js → preview.impl-CnsbLmDA.js} +3 -3
  19. package/.docu/lib/{preview.impl-CXJS2tQS.js.map → preview.impl-CnsbLmDA.js.map} +1 -1
  20. package/.docu/lib/preview.node.js +1 -1
  21. package/.docu/lib/server.deno.js +1 -1
  22. package/.docu/lib/{server.impl-CJuWimfN.js → server.impl-CXTzYqbF.js} +4 -4
  23. package/.docu/lib/{server.impl-CJuWimfN.js.map → server.impl-CXTzYqbF.js.map} +1 -1
  24. package/.docu/lib/server.node.js +1 -1
  25. package/.docu/node/build.impl.ts +76 -11
  26. package/.docu/node/build.ts +83 -9
  27. package/.docu/node/cache-key.ts +311 -0
  28. package/.docu/node/hydrate.node.ts +8 -11
  29. package/.docu/node/hydrate.ts +13 -14
  30. package/.docu/node/mdx.ts +14 -2
  31. package/.docu/node/paths.ts +27 -1
  32. package/.docu/node/types.ts +23 -5
  33. package/bin/cli.js +56 -18
  34. package/package.json +10 -16
  35. package/.docu/lib/build.impl-BJsi28mD.js.map +0 -1
  36. package/.docu/lib/build.impl-yVLaa1eQ.js +0 -2
  37. package/.docu/lib/html.shared-DSn-7_6t.js.map +0 -1
  38. package/.docu/lib/logger-CycvLCrQ.js.map +0 -1
  39. package/.docu/lib/paths-BtOIPBQ9.js.map +0 -1
@@ -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
- return JSON.parse(data);
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
- await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2));
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
- if (mtime > cached.builtAt) return "hash_check";
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
- assetManifest = await buildClientBundle(mdxSources);
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
+ }
@@ -13,8 +13,7 @@ import { builtinModules, createRequire } from "node:module";
13
13
  import { basename, dirname, join, resolve } from "node:path";
14
14
  import { existsSync, readFileSync, readdirSync } from "node:fs";
15
15
  import { promisify } from "node:util";
16
- import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
17
- import { createHash } from "node:crypto";
16
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
18
17
  import { build as viteBuild } from "vite";
19
18
  import {
20
19
  ASSETS_DIR,
@@ -25,6 +24,7 @@ import {
25
24
  loadDocuConfig,
26
25
  } from "./paths";
27
26
  import { buildThemeCss, getThemeConfig } from "./hydrate";
27
+ import { atomicWriteFile, computeTailwindCacheKey, readGlobalsCss } from "./cache-key";
28
28
  import { resolveRoutes } from "./fs-scanner";
29
29
  import { normalizeImporterPath } from "./security";
30
30
  import type { DocuConfig, DocuRoute } from "./types";
@@ -59,10 +59,9 @@ function resolveTailwindBin(): string {
59
59
  return join(dirname(pkgPath), binRel);
60
60
  }
61
61
 
62
- /** Compute a cache key from globals.css + theme config content. */
62
+ /** Compute a cache key from globals.css + theme + config + toolchain. */
63
63
  function tailwindCacheKey(): string {
64
- const globalsPath = join(STYLES_DIR, "globals.css");
65
- const globalsContent = existsSync(globalsPath) ? readFileSync(globalsPath, "utf-8") : "";
64
+ const globalsContent = readGlobalsCss();
66
65
  let themeSuffix = "";
67
66
  try {
68
67
  const themeColors = getThemeConfig();
@@ -72,10 +71,7 @@ function tailwindCacheKey(): string {
72
71
  } catch {
73
72
  // theme config unavailable — proceed without it
74
73
  }
75
- return createHash("sha256")
76
- .update(globalsContent + themeSuffix)
77
- .digest("hex")
78
- .slice(0, 16);
74
+ return computeTailwindCacheKey(globalsContent, themeSuffix);
79
75
  }
80
76
 
81
77
  /**
@@ -105,7 +101,7 @@ async function buildTailwindCss(key: string): Promise<{ file: string; content: s
105
101
  }
106
102
 
107
103
  let cssContent = await readFile(tmpCss, "utf-8");
108
- await unlink(tmpCss);
104
+ await unlink(tmpCss).catch(() => {});
109
105
 
110
106
  try {
111
107
  const themeColors = getThemeConfig();
@@ -120,11 +116,12 @@ async function buildTailwindCss(key: string): Promise<{ file: string; content: s
120
116
 
121
117
  // Use the same input-derived key for lookup and output — if inputs change,
122
118
  // the key changes, cache busting works without a separate content hash.
119
+ // Atomic tmp+rename with pid suffix: parallel builds never clobber each other.
123
120
  const cssFile = `client-${key}.css`;
124
121
  const outPath = join(ASSETS_DIR, cssFile);
125
122
 
126
123
  if (!existsSync(outPath)) {
127
- await writeFile(outPath, cssContent);
124
+ await atomicWriteFile(writeFile, rename, unlink, outPath, cssContent);
128
125
  }
129
126
 
130
127
  return { file: cssFile, content: cssContent };
@@ -1,9 +1,9 @@
1
1
  import { join } from "node:path";
2
- import { mkdir, unlink } from "node:fs/promises";
3
- import { existsSync, readFileSync } from "node:fs";
4
- import { createHash } from "node:crypto";
2
+ import { mkdir, unlink, rename } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
5
4
  import { resolveTheme, generateThemeCss, presetRegistry } from "@docubook/themes-colors";
6
5
  import { ASSETS_DIR, cleanOldBundles, LIB_DIR, STYLES_DIR, loadDocuConfig } from "./paths";
6
+ import { atomicWriteFile, computeTailwindCacheKey, readGlobalsCss } from "./cache-key";
7
7
  import { resolveRoutes } from "./fs-scanner";
8
8
  import type { DocuRoute } from "./types";
9
9
  import type { ThemeConfig } from "@docubook/themes-colors";
@@ -57,10 +57,9 @@ export function computeInlineThemeCss(): string | undefined {
57
57
  return undefined;
58
58
  }
59
59
 
60
- /** Compute Tailwind cache key from globals.css + theme config. */
60
+ /** Compute Tailwind cache key from globals.css + theme + config + toolchain. */
61
61
  function twCacheKey(): string {
62
- const globalsPath = join(STYLES_DIR, "globals.css");
63
- const globals = existsSync(globalsPath) ? readFileSync(globalsPath, "utf-8") : "";
62
+ const globals = readGlobalsCss();
64
63
  let themeSuffix = "";
65
64
  try {
66
65
  const themeColors = getThemeConfig();
@@ -68,10 +67,7 @@ function twCacheKey(): string {
68
67
  } catch {
69
68
  // theme config unavailable — proceed without
70
69
  }
71
- return createHash("sha256")
72
- .update(globals + themeSuffix)
73
- .digest("hex")
74
- .slice(0, 16);
70
+ return computeTailwindCacheKey(globals, themeSuffix);
75
71
  }
76
72
 
77
73
  /** Run Tailwind CLI, caching by content hash. */
@@ -105,7 +101,7 @@ async function buildTailwindCss(key: string): Promise<{ file: string; content: s
105
101
  }
106
102
 
107
103
  let cssContent = await Bun.file(tmpCss).text();
108
- await unlink(tmpCss);
104
+ await unlink(tmpCss).catch(() => {});
109
105
 
110
106
  try {
111
107
  const themeColors = getThemeConfig();
@@ -116,11 +112,14 @@ async function buildTailwindCss(key: string): Promise<{ file: string; content: s
116
112
  );
117
113
  }
118
114
 
119
- // Use the same input-derived key for lookup and output — if inputs change,
120
- // the key changes, cache busting works without a separate content hash.
121
115
  const cssFile = `client-${key}.css`;
122
116
  const outPath = join(ASSETS_DIR, cssFile);
123
- if (!existsSync(outPath)) await Bun.write(outPath, cssContent);
117
+ // Atomic tmp+rename with pid suffix: parallel builds never clobber each
118
+ // other, and a crash cannot leave a half-written CSS file behind.
119
+ if (!existsSync(outPath)) {
120
+ const { writeFile } = await import("node:fs/promises");
121
+ await atomicWriteFile(writeFile, rename, unlink, outPath, cssContent);
122
+ }
124
123
 
125
124
  return { file: cssFile, content: cssContent };
126
125
  }
package/.docu/node/mdx.ts CHANGED
@@ -174,7 +174,10 @@ async function serializeWithDocPlugins(
174
174
  // Parse-once: when the prePass already extracted the frontmatter + stripped
175
175
  // content, reuse it instead of re-parsing (the SSR phase skips extraction).
176
176
  const { strippedContent, frontmatter } =
177
- pre ?? extractFrontmatterWithContent<Frontmatter>(rawMdx, opts.frontmatterSchema);
177
+ pre ??
178
+ (opts.frontmatterSchema
179
+ ? extractFrontmatterWithContent<Frontmatter>(rawMdx, opts.frontmatterSchema)
180
+ : extractFrontmatterWithContent<Frontmatter>(rawMdx));
178
181
 
179
182
  const defaultRemark = createDefaultRemarkPlugins();
180
183
  const defaultRehype = createDefaultRehypePlugins();
@@ -221,7 +224,9 @@ export async function compileMdx(
221
224
  const tocs = extractTocsFromRawMdx(rawMdx);
222
225
  const frontmatter =
223
226
  pre?.frontmatter ??
224
- extractFrontmatterWithContent<Frontmatter>(rawMdx, frontmatterSchema).frontmatter;
227
+ (frontmatterSchema
228
+ ? extractFrontmatterWithContent<Frontmatter>(rawMdx, frontmatterSchema).frontmatter
229
+ : extractFrontmatterWithContent<Frontmatter>(rawMdx).frontmatter);
225
230
  const serialized = await serializeWithDocPlugins(
226
231
  rawMdx,
227
232
  { remarkPlugins, rehypePlugins, frontmatterSchema },
@@ -304,6 +309,13 @@ export function getPageContent(href: string): string | undefined {
304
309
  return pageContent.get(href);
305
310
  }
306
311
 
312
+ /** Drop derived page maps under OS memory pressure (Bun 1.4 `memoryPressure`). */
313
+ export function clearDerivedPageCaches(): void {
314
+ pageContent.clear();
315
+ pageStripped.clear();
316
+ pageFrontmatter.clear();
317
+ }
318
+
307
319
  export async function compileMdxModule(
308
320
  rawMdx: string,
309
321
  remarkPlugins?: Pluggable[],
@@ -10,7 +10,28 @@ import type { DocuConfig } from "./types";
10
10
 
11
11
  // .docu/node/paths.ts → package root is 2 levels up
12
12
  export const FRAMEWORK_ROOT = resolve(import.meta.dirname, "../..");
13
- export const PROJECT_ROOT = process.cwd();
13
+
14
+ /**
15
+ * Find the project root by walking up from cwd until docu.json is found.
16
+ * Falls back to cwd when not found (tests, ad-hoc scripts). Bun 1.4
17
+ * `run --parallel --filter` may change cwd per workspace task, so a bare
18
+ * process.cwd() can point at the wrong package.
19
+ */
20
+ function findProjectRoot(): string {
21
+ let dir = process.cwd();
22
+ for (let i = 0; i < 6; i++) {
23
+ try {
24
+ if (existsSync(join(dir, "docu.json"))) return dir;
25
+ } catch {
26
+ break;
27
+ }
28
+ const parent = resolve(dir, "..");
29
+ if (parent === dir) break;
30
+ dir = parent;
31
+ }
32
+ return process.cwd();
33
+ }
34
+ export const PROJECT_ROOT = findProjectRoot();
14
35
 
15
36
  // Framework paths (internal)
16
37
  export const PAGES_DIR = join(FRAMEWORK_ROOT, ".docu/pages");
@@ -29,6 +50,11 @@ export const DOCS_DIR = join(PROJECT_ROOT, "docs");
29
50
  export const DOCS_ASSETS_DIR = join(PROJECT_ROOT, "docs/assets");
30
51
  export const DOCU_CONFIG_PATH = join(PROJECT_ROOT, "docu.json");
31
52
 
53
+ /** Resolve a project-relative file against the discovered PROJECT_ROOT. */
54
+ export function resolveProjectFile(...segments: string[]): string {
55
+ return join(PROJECT_ROOT, ...segments);
56
+ }
57
+
32
58
  // Config singleton
33
59
  let _config: DocuConfig | null = null;
34
60