@docubook/flame 2.0.0-beta.2 → 2.0.0-beta.4

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 (51) hide show
  1. package/.docu/components/Pagination.tsx +12 -1
  2. package/.docu/lib/build.deno.js +1 -1
  3. package/.docu/lib/{build.impl-BJsi28mD.js → build.impl-CT-YLyLn.js} +73 -25
  4. package/.docu/lib/build.impl-CT-YLyLn.js.map +1 -0
  5. package/.docu/lib/build.impl-cJPJeQ8r.js +2 -0
  6. package/.docu/lib/build.node.js +1 -1
  7. package/.docu/lib/clean.js +1 -1
  8. package/.docu/lib/deploy.deno.js +1 -1
  9. package/.docu/lib/deploy.node.js +1 -1
  10. package/.docu/lib/{deploy.shared-D3UWafa6.js → deploy.shared-CmOzm3b5.js} +3 -3
  11. package/.docu/lib/{deploy.shared-D3UWafa6.js.map → deploy.shared-CmOzm3b5.js.map} +1 -1
  12. package/.docu/lib/{html.shared-DSn-7_6t.js → html.shared-WwQnhDty.js} +269 -34
  13. package/.docu/lib/html.shared-WwQnhDty.js.map +1 -0
  14. package/.docu/lib/{logger-CycvLCrQ.js → logger-Cz0CPMi8.js} +9 -15
  15. package/.docu/lib/logger-Cz0CPMi8.js.map +1 -0
  16. package/.docu/lib/{paths-BtOIPBQ9.js → paths-Bl2cdp9E.js} +27 -3
  17. package/.docu/lib/paths-Bl2cdp9E.js.map +1 -0
  18. package/.docu/lib/preview.deno.js +1 -1
  19. package/.docu/lib/{preview.impl-CXJS2tQS.js → preview.impl-BaYhTRBW.js} +4 -4
  20. package/.docu/lib/{preview.impl-CXJS2tQS.js.map → preview.impl-BaYhTRBW.js.map} +1 -1
  21. package/.docu/lib/preview.node.js +1 -1
  22. package/.docu/lib/server.deno.js +1 -1
  23. package/.docu/lib/{server.impl-CJuWimfN.js → server.impl-l8hXfV_0.js} +7 -7
  24. package/.docu/lib/server.impl-l8hXfV_0.js.map +1 -0
  25. package/.docu/lib/server.node.js +1 -1
  26. package/.docu/lib/{utils-DA17MyQG.js → utils-B_CoyKie.js} +28 -6
  27. package/.docu/lib/utils-B_CoyKie.js.map +1 -0
  28. package/.docu/node/build.impl.ts +115 -43
  29. package/.docu/node/build.ts +134 -44
  30. package/.docu/node/cache-key.ts +311 -0
  31. package/.docu/node/html.shared.ts +2 -1
  32. package/.docu/node/html.ts +2 -1
  33. package/.docu/node/hydrate.node.ts +8 -11
  34. package/.docu/node/hydrate.ts +13 -14
  35. package/.docu/node/mdx.ts +14 -2
  36. package/.docu/node/paths.ts +27 -1
  37. package/.docu/node/plugin-builder.ts +10 -1
  38. package/.docu/node/route.ts +19 -21
  39. package/.docu/node/security.ts +18 -1
  40. package/.docu/node/server-routes.ts +3 -3
  41. package/.docu/node/types.ts +23 -5
  42. package/.docu/node/utils.ts +22 -2
  43. package/bin/cli.js +56 -18
  44. package/package.json +11 -17
  45. package/.docu/lib/build.impl-BJsi28mD.js.map +0 -1
  46. package/.docu/lib/build.impl-yVLaa1eQ.js +0 -2
  47. package/.docu/lib/html.shared-DSn-7_6t.js.map +0 -1
  48. package/.docu/lib/logger-CycvLCrQ.js.map +0 -1
  49. package/.docu/lib/paths-BtOIPBQ9.js.map +0 -1
  50. package/.docu/lib/server.impl-CJuWimfN.js.map +0 -1
  51. package/.docu/lib/utils-DA17MyQG.js.map +0 -1
@@ -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";
@@ -38,8 +38,17 @@ import { logger } from "./logger";
38
38
  import { initSentry, captureException } from "./sentry";
39
39
  import { loadPlugins } from "./plugin-loader";
40
40
  import { BuildPluginBuilder } from "./plugin-builder";
41
- import { scanMdxFiles } from "./utils";
42
- import type { BuildCache, CliArgs } from "./types";
41
+ import { scanMdxFiles, resolveDocsIndexSource, DEFAULT_FAVICON } from "./utils";
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
- return JSON.parse(data);
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
- await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2));
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,40 @@ 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
+ // Any mtime drift vs the recorded one — a newer edit, or mtime moving
114
+ // backwards (rsync -a, cp -p, snapshot restore) — falls through to the
115
+ // hash check: hashing an unchanged file is cheap, serving a stale page
116
+ // is not. Only an untouched mtime skips without reading the file.
117
+ if (mtime !== cached.mtime) return "hash_check";
89
118
  return "no";
90
119
  }
91
120
 
92
121
  let assetManifest = { js: "client.js", css: "client.css" };
93
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
+
94
146
  let inlineThemeCss: string | undefined;
95
147
 
96
148
  async function renderDocsPage(
@@ -176,11 +228,11 @@ async function renderDocsPage(
176
228
  const bodyExtra = builder?.collectBody(ctx);
177
229
 
178
230
  const depth = slug ? slug.split("/").length : 1;
179
- const favicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
231
+ const favicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
180
232
  const seo = buildSeoMeta(docuConfig, frontmatter, slug || "");
181
233
  // MDX content hydrates from the bundled ESM module (mdx-hydrate), not
182
234
  // new Function — no 'unsafe-eval' needed in the CSP.
183
- const csp = cspHeader(nonce);
235
+ const csp = nonce ? cspHeader(nonce) : undefined;
184
236
  let html = htmlShell({
185
237
  title,
186
238
  description,
@@ -223,6 +275,8 @@ export async function runBuild(): Promise<void> {
223
275
  const docuConfig = loadDocuConfig();
224
276
  const args = parseArgs();
225
277
 
278
+ hookMemoryPressure(clearDerivedPageCaches);
279
+
226
280
  logger.buildStart();
227
281
 
228
282
  if (args.clean) {
@@ -287,15 +341,16 @@ export async function runBuild(): Promise<void> {
287
341
  });
288
342
  await Promise.all(prePassTasks);
289
343
 
290
- // The docs root (index.mdx) renders with slug "" — mirror that key so the
291
- // index page hydrates too. Its render has its own try/catch; skip on error.
292
- const indexMdxPath = join(DOCS_DIR, "index.mdx");
293
- if (existsSync(indexMdxPath)) {
344
+ // The docs root (index.mdx, or index.md) renders with slug "" — mirror that
345
+ // key so the index page hydrates too. Its render has its own try/catch; skip
346
+ // on error.
347
+ const indexSource = resolveDocsIndexSource(DOCS_DIR);
348
+ if (indexSource) {
294
349
  try {
295
- const indexRaw = await readFile(indexMdxPath, "utf-8");
350
+ const indexRaw = await readFile(indexSource, "utf-8");
296
351
  let indexContent = indexRaw;
297
352
  if (builder) {
298
- const relPath = indexMdxPath.replace(PROJECT_ROOT + "/", "");
353
+ const relPath = indexSource.replace(PROJECT_ROOT + "/", "");
299
354
  const transformed = await builder.runOnLoad(relPath, indexContent);
300
355
  if (transformed?.contents) indexContent = transformed.contents;
301
356
  }
@@ -311,14 +366,20 @@ export async function runBuild(): Promise<void> {
311
366
 
312
367
  logger.bundleStart();
313
368
  let t = performance.now();
314
- assetManifest = await buildClientBundle(mdxSources);
369
+ const bundleHash = hashMdxSources(mdxSources);
370
+ const lastBundle = cache["__bundle__"];
371
+ const bundleHit =
372
+ isCacheEntry(lastBundle) &&
373
+ lastBundle.hash === bundleHash &&
374
+ existsSync(join(ASSETS_DIR, "manifest.json"));
375
+ assetManifest = await resolveAssetManifest(bundleHit, mdxSources);
315
376
  logger.bundleDone(Math.round(performance.now() - t));
316
377
 
317
378
  inlineThemeCss = computeInlineThemeCss();
318
379
 
319
380
  const lastManifest = cache["__assets__"];
320
381
  const assetsChanged =
321
- !lastManifest || lastManifest.hash !== `${assetManifest.js}:${assetManifest.css}`;
382
+ !isCacheEntry(lastManifest) || lastManifest.hash !== `${assetManifest.js}:${assetManifest.css}`;
322
383
  if (assetsChanged) {
323
384
  cache["__assets__"] = {
324
385
  hash: `${assetManifest.js}:${assetManifest.css}`,
@@ -326,15 +387,17 @@ export async function runBuild(): Promise<void> {
326
387
  builtAt: Date.now(),
327
388
  };
328
389
  }
390
+ if (!bundleHit) {
391
+ cache["__bundle__"] = { hash: bundleHash, mtime: 0, builtAt: Date.now() };
392
+ }
329
393
 
330
394
  logger.spinner.start("Building pages...");
331
395
  t = performance.now();
332
396
 
333
397
  const allRelPaths = mdxFiles.map((f) => f.absPath.replace(PROJECT_ROOT + "/", ""));
334
398
 
335
- const indexMdxFull = join(DOCS_DIR, "index.mdx");
336
- if (existsSync(indexMdxFull)) {
337
- allRelPaths.push(indexMdxFull.replace(PROJECT_ROOT + "/", ""));
399
+ if (indexSource) {
400
+ allRelPaths.push(indexSource.replace(PROJECT_ROOT + "/", ""));
338
401
  }
339
402
  const gitDates = await getGitLastModifiedBatch(allRelPaths);
340
403
 
@@ -366,7 +429,7 @@ export async function runBuild(): Promise<void> {
366
429
  if (rebuildDecision === "hash_check") {
367
430
  const contentHash = hashContent(rawMdx);
368
431
  const cached = cache[file.path];
369
- if (cached && cached.hash === contentHash) {
432
+ if (isCacheEntry(cached) && cached.hash === contentHash) {
370
433
  if (!assetsChanged) {
371
434
  const outputPath = join(DIST_DIR, "docs", `${file.path}.html`);
372
435
  if (existsSync(outputPath)) {
@@ -415,30 +478,39 @@ export async function runBuild(): Promise<void> {
415
478
  await Promise.all(buildTasks.slice(i, i + CONCURRENCY).map((fn) => fn()));
416
479
  }
417
480
 
418
- try {
419
- const indexMdxPath = join(DOCS_DIR, "index.mdx");
420
- const indexRaw = await readFile(indexMdxPath, "utf-8");
421
- const indexRelPath = indexMdxPath.replace(PROJECT_ROOT + "/", "");
422
- const indexHtml = await renderDocsPage(
423
- docuConfig,
424
- "",
425
- indexRaw,
426
- indexRelPath,
427
- gitDates,
428
- builder,
429
- generateNonce()
430
- );
431
- await mkdir(join(DIST_DIR, "docs"), { recursive: true });
432
- await writeFile(join(DIST_DIR, "docs", "index.html"), indexHtml);
433
- } catch (err) {
434
- const msg = err instanceof Error ? err.message : String(err);
435
- errors.push(`index.mdx: ${msg}`);
436
- console.error(`\n\u274C Failed to build index: ${msg}\n`);
481
+ if (indexSource) {
482
+ try {
483
+ const indexRaw = await readFile(indexSource, "utf-8");
484
+ const indexRelPath = indexSource.replace(PROJECT_ROOT + "/", "");
485
+ const indexHtml = await renderDocsPage(
486
+ docuConfig,
487
+ "",
488
+ indexRaw,
489
+ indexRelPath,
490
+ gitDates,
491
+ builder,
492
+ generateNonce()
493
+ );
494
+ await mkdir(join(DIST_DIR, "docs"), { recursive: true });
495
+ await writeFile(join(DIST_DIR, "docs", "index.html"), indexHtml);
496
+ } catch (err) {
497
+ const msg = err instanceof Error ? err.message : String(err);
498
+ errors.push(`index: ${msg}`);
499
+ console.error(`\n❌ Failed to build index: ${msg}\n`);
500
+ }
501
+ } else {
502
+ const msg = "docs root index: docs/index.mdx (or docs/index.md) not found";
503
+ errors.push(`index: ${msg}`);
504
+ console.error(`\n❌ Failed to build index: ${msg}\n`);
437
505
  }
438
506
 
439
507
  const landingPage = React.createElement(IndexPage);
440
- const landingFavicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
441
- const landingSeo = buildSeoMeta(docuConfig, docuConfig.meta as Record<string, unknown>, "");
508
+ const landingFavicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
509
+ const landingSeo = buildSeoMeta(
510
+ docuConfig,
511
+ docuConfig.meta as unknown as Record<string, unknown>,
512
+ ""
513
+ );
442
514
  const landingNonce = generateNonce();
443
515
  const landingHtml = htmlShell({
444
516
  title: docuConfig.meta?.title || "DocuBook",
@@ -459,7 +531,7 @@ export async function runBuild(): Promise<void> {
459
531
  { repoUrl: docuConfig.repo?.url },
460
532
  React.createElement(NotFoundPage)
461
533
  );
462
- const notFoundFavicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
534
+ const notFoundFavicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
463
535
  const notFoundNonce = generateNonce();
464
536
  const notFoundHtml = htmlShell({
465
537
  title: "404 - Not Found",
@@ -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";
@@ -30,9 +30,18 @@ import { logger } from "./logger";
30
30
  import { initSentry, captureException } from "./sentry";
31
31
  import { loadPlugins } from "./plugin-loader";
32
32
  import { BuildPluginBuilder } from "./plugin-builder";
33
- import { scanMdxFiles } from "./utils";
34
- import type { BuildCache, CliArgs } from "./types";
35
- import { generateNonce } from "./security";
33
+ import { scanMdxFiles, resolveDocsIndexSource, DEFAULT_FAVICON } from "./utils";
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";
44
+ import { generateNonce, cspHeader } from "./security";
36
45
  import type { PageMeta, PageContext } from "./plugin";
37
46
  import { buildSeoMeta } from "./seo";
38
47
  import DocsPage from "../pages/docs/[[...slug]]";
@@ -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,40 @@ 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
+ // Any mtime drift vs the recorded one — a newer edit, or mtime moving
113
+ // backwards (rsync -a, cp -p, snapshot restore) — falls through to the
114
+ // hash check: hashing an unchanged file is cheap, serving a stale page
115
+ // is not. Only an untouched mtime skips without reading the file.
116
+ if (mtime !== cached.mtime) return "hash_check";
83
117
  return "no";
84
118
  }
85
119
 
86
120
  let assetManifest = { js: "client.js", css: "client.css" };
87
121
 
122
+ /**
123
+ * Reuse manifest.json on bundle cache hit; fall back to a full rebuild
124
+ * when the manifest is missing or malformed.
125
+ */
126
+ async function resolveAssetManifest(
127
+ bundleHit: boolean,
128
+ mdxSources: Record<string, string>
129
+ ): Promise<{ js: string; css: string }> {
130
+ if (!bundleHit) return buildClientBundle(mdxSources);
131
+ try {
132
+ const manifest = JSON.parse(await readFile(join(ASSETS_DIR, "manifest.json"), "utf-8")) as {
133
+ js?: string;
134
+ css?: string;
135
+ };
136
+ if (typeof manifest.js === "string" && typeof manifest.css === "string") {
137
+ return { js: manifest.js, css: manifest.css };
138
+ }
139
+ } catch {
140
+ // corrupt/missing manifest — rebuild below
141
+ }
142
+ return buildClientBundle(mdxSources);
143
+ }
144
+
88
145
  let inlineThemeCss: string | undefined;
89
146
 
90
147
  async function renderDocsPage(
@@ -169,14 +226,18 @@ async function renderDocsPage(
169
226
  const bodyExtra = builder?.collectBody(ctx);
170
227
 
171
228
  const depth = slug ? slug.split("/").length : 1;
172
- const favicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
229
+ const favicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
173
230
  const seo = buildSeoMeta(docuConfig, frontmatter, slug || "");
231
+ // Parity with build.impl.ts: static hosts without header control (GitHub
232
+ // Pages) rely on the <meta> CSP for the per-page script policy.
233
+ const csp = nonce ? cspHeader(nonce) : undefined;
174
234
  let html = htmlShell({
175
235
  title,
176
236
  description,
177
237
  body,
178
238
  favicon,
179
239
  seo,
240
+ csp,
180
241
  css: assetManifest.css,
181
242
  js: assetManifest.js,
182
243
  nonce,
@@ -211,6 +272,10 @@ async function copyDirectoryRecursive(src: string, dest: string): Promise<void>
211
272
  async function build() {
212
273
  const args = parseArgs();
213
274
 
275
+ // Bun 1.4 `process.on("memoryPressure")`: drop parsed page maps when the
276
+ // OS runs low on memory (long CI builds). No-op on older runtimes.
277
+ hookMemoryPressure(clearDerivedPageCaches);
278
+
214
279
  logger.buildStart();
215
280
 
216
281
  if (args.clean) {
@@ -275,15 +340,16 @@ async function build() {
275
340
  });
276
341
  await Promise.all(prePassTasks);
277
342
 
278
- // The docs root (index.mdx) renders with slug "" — mirror that key so the
279
- // index page hydrates too. Its render has its own try/catch; skip on error.
280
- const indexMdxPath = join(DOCS_DIR, "index.mdx");
281
- if (existsSync(indexMdxPath)) {
343
+ // The docs root (index.mdx, or index.md) renders with slug "" — mirror that
344
+ // key so the index page hydrates too. Its render has its own try/catch; skip
345
+ // on error.
346
+ const indexSource = resolveDocsIndexSource(DOCS_DIR);
347
+ if (indexSource) {
282
348
  try {
283
- const indexRaw = await readFile(indexMdxPath, "utf-8");
349
+ const indexRaw = await readFile(indexSource, "utf-8");
284
350
  let indexContent = indexRaw;
285
351
  if (builder) {
286
- const relPath = indexMdxPath.replace(PROJECT_ROOT + "/", "");
352
+ const relPath = indexSource.replace(PROJECT_ROOT + "/", "");
287
353
  const transformed = await builder.runOnLoad(relPath, indexContent);
288
354
  if (transformed?.contents) indexContent = transformed.contents;
289
355
  }
@@ -299,14 +365,23 @@ async function build() {
299
365
 
300
366
  logger.bundleStart();
301
367
  let t = performance.now();
302
- assetManifest = await buildClientBundle(mdxSources);
368
+ // Skip the JS bundle when compiled MDX sources are unchanged: the bundle
369
+ // is shared by every page, so its hash doubles as the content fingerprint.
370
+ // CSS still builds via its own content-keyed cache inside the hydrator.
371
+ const bundleHash = hashMdxSources(mdxSources);
372
+ const lastBundle = cache["__bundle__"];
373
+ const bundleHit =
374
+ isCacheEntry(lastBundle) &&
375
+ lastBundle.hash === bundleHash &&
376
+ existsSync(join(ASSETS_DIR, "manifest.json"));
377
+ assetManifest = await resolveAssetManifest(bundleHit, mdxSources);
303
378
  logger.bundleDone(Math.round(performance.now() - t));
304
379
 
305
380
  inlineThemeCss = computeInlineThemeCss();
306
381
 
307
382
  const lastManifest = cache["__assets__"];
308
383
  const assetsChanged =
309
- !lastManifest || lastManifest.hash !== `${assetManifest.js}:${assetManifest.css}`;
384
+ !isCacheEntry(lastManifest) || lastManifest.hash !== `${assetManifest.js}:${assetManifest.css}`;
310
385
  if (assetsChanged) {
311
386
  cache["__assets__"] = {
312
387
  hash: `${assetManifest.js}:${assetManifest.css}`,
@@ -314,15 +389,17 @@ async function build() {
314
389
  builtAt: Date.now(),
315
390
  };
316
391
  }
392
+ if (!bundleHit) {
393
+ cache["__bundle__"] = { hash: bundleHash, mtime: 0, builtAt: Date.now() };
394
+ }
317
395
 
318
396
  logger.spinner.start("Building pages...");
319
397
  t = performance.now();
320
398
 
321
399
  const allRelPaths = mdxFiles.map((f) => f.absPath.replace(PROJECT_ROOT + "/", ""));
322
400
 
323
- const indexMdxFull = join(DOCS_DIR, "index.mdx");
324
- if (existsSync(indexMdxFull)) {
325
- allRelPaths.push(indexMdxFull.replace(PROJECT_ROOT + "/", ""));
401
+ if (indexSource) {
402
+ allRelPaths.push(indexSource.replace(PROJECT_ROOT + "/", ""));
326
403
  }
327
404
  const gitDates = await getGitLastModifiedBatch(allRelPaths);
328
405
 
@@ -354,7 +431,7 @@ async function build() {
354
431
  if (rebuildDecision === "hash_check") {
355
432
  const contentHash = hashContent(rawMdx);
356
433
  const cached = cache[file.path];
357
- if (cached && cached.hash === contentHash) {
434
+ if (isCacheEntry(cached) && cached.hash === contentHash) {
358
435
  if (!assetsChanged) {
359
436
  const outputPath = join(DIST_DIR, "docs", `${file.path}.html`);
360
437
  if (existsSync(outputPath)) {
@@ -402,38 +479,49 @@ async function build() {
402
479
  await Promise.all(buildTasks.slice(i, i + CONCURRENCY).map((fn) => fn()));
403
480
  }
404
481
 
405
- try {
406
- const indexMdxPath = join(DOCS_DIR, "index.mdx");
407
- const indexRaw = await readFile(indexMdxPath, "utf-8");
408
- const indexRelPath = indexMdxPath.replace(PROJECT_ROOT + "/", "");
409
- const indexHtml = await renderDocsPage(
410
- "",
411
- indexRaw,
412
- indexRelPath,
413
- gitDates,
414
- builder,
415
- generateNonce()
416
- );
417
- await mkdir(join(DIST_DIR, "docs"), { recursive: true });
418
- await writeFile(join(DIST_DIR, "docs", "index.html"), indexHtml);
419
- } catch (err) {
420
- const msg = err instanceof Error ? err.message : String(err);
421
- errors.push(`index.mdx: ${msg}`);
422
- console.error(`\n\u274C Failed to build index: ${msg}\n`);
482
+ if (indexSource) {
483
+ try {
484
+ const indexRaw = await readFile(indexSource, "utf-8");
485
+ const indexRelPath = indexSource.replace(PROJECT_ROOT + "/", "");
486
+ const indexHtml = await renderDocsPage(
487
+ "",
488
+ indexRaw,
489
+ indexRelPath,
490
+ gitDates,
491
+ builder,
492
+ generateNonce()
493
+ );
494
+ await mkdir(join(DIST_DIR, "docs"), { recursive: true });
495
+ await writeFile(join(DIST_DIR, "docs", "index.html"), indexHtml);
496
+ } catch (err) {
497
+ const msg = err instanceof Error ? err.message : String(err);
498
+ errors.push(`index: ${msg}`);
499
+ console.error(`\n❌ Failed to build index: ${msg}\n`);
500
+ }
501
+ } else {
502
+ const msg = "docs root index: docs/index.mdx (or docs/index.md) not found";
503
+ errors.push(`index: ${msg}`);
504
+ console.error(`\n❌ Failed to build index: ${msg}\n`);
423
505
  }
424
506
 
425
507
  const landingPage = React.createElement(IndexPage);
426
- const landingFavicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
427
- const landingSeo = buildSeoMeta(docuConfig, {}, "");
508
+ const landingFavicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
509
+ const landingSeo = buildSeoMeta(
510
+ docuConfig,
511
+ docuConfig.meta as unknown as Record<string, unknown>,
512
+ ""
513
+ );
514
+ const landingNonce = generateNonce();
428
515
  const landingHtml = htmlShell({
429
516
  title: docuConfig.meta?.title || "DocuBook",
430
517
  description: docuConfig.meta?.description || "",
431
518
  body: renderToString(landingPage),
432
519
  favicon: landingFavicon,
433
520
  seo: landingSeo,
521
+ csp: cspHeader(landingNonce),
434
522
  css: assetManifest.css,
435
523
  js: assetManifest.js,
436
- nonce: generateNonce(),
524
+ nonce: landingNonce,
437
525
  themeCss: inlineThemeCss,
438
526
  });
439
527
  await writeFile(join(DIST_DIR, "index.html"), landingHtml);
@@ -443,16 +531,18 @@ async function build() {
443
531
  { repoUrl: docuConfig.repo?.url },
444
532
  React.createElement(NotFoundPage)
445
533
  );
446
- const notFoundFavicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
534
+ const notFoundFavicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
535
+ const notFoundNonce = generateNonce();
447
536
  const notFoundHtml = htmlShell({
448
537
  title: "404 - Not Found",
449
538
  description: "",
450
539
  body: renderToString(notFoundPage),
451
540
  favicon: notFoundFavicon,
452
541
  headExtra: ['<meta name="robots" content="noindex,follow">'],
542
+ csp: cspHeader(notFoundNonce),
453
543
  css: assetManifest.css,
454
544
  js: assetManifest.js,
455
- nonce: generateNonce(),
545
+ nonce: notFoundNonce,
456
546
  themeCss: inlineThemeCss,
457
547
  // Served as the static-host fallback at ANY requested path — relative
458
548
  // depth can never be right there, so use root-absolute asset URLs.