@docubook/flame 2.0.0-alpha.2 → 2.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  runBuildCli
3
- } from "./chunk-3LRUTZZD.js";
4
- import "./chunk-42JQLAKP.js";
3
+ } from "./chunk-C3RCUTUE.js";
4
+ import "./chunk-LXJLSXQI.js";
5
5
  import "./chunk-EOK6KATZ.js";
6
- import "./chunk-IN2QAGDZ.js";
6
+ import "./chunk-IVM5UM44.js";
7
7
  import "./chunk-4IQXHHPF.js";
8
8
 
9
9
  // .docu/node/build.deno.ts
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  runBuild,
3
3
  runBuildCli
4
- } from "./chunk-3LRUTZZD.js";
5
- import "./chunk-42JQLAKP.js";
4
+ } from "./chunk-C3RCUTUE.js";
5
+ import "./chunk-LXJLSXQI.js";
6
6
  import "./chunk-EOK6KATZ.js";
7
- import "./chunk-IN2QAGDZ.js";
7
+ import "./chunk-IVM5UM44.js";
8
8
  import "./chunk-4IQXHHPF.js";
9
9
  export {
10
10
  runBuild,
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  runBuildCli
3
- } from "./chunk-3LRUTZZD.js";
4
- import "./chunk-42JQLAKP.js";
3
+ } from "./chunk-C3RCUTUE.js";
4
+ import "./chunk-LXJLSXQI.js";
5
5
  import "./chunk-EOK6KATZ.js";
6
- import "./chunk-IN2QAGDZ.js";
6
+ import "./chunk-IVM5UM44.js";
7
7
  import "./chunk-4IQXHHPF.js";
8
8
 
9
9
  // .docu/node/build.node.ts
@@ -12,10 +12,14 @@ import {
12
12
  frontmatterField,
13
13
  generateSearchIndex,
14
14
  getGitLastModifiedBatch,
15
+ getPageContent,
16
+ getPageFrontmatter,
17
+ getPageStripped,
15
18
  htmlShell,
16
19
  initSentry,
17
- loadPlugins
18
- } from "./chunk-42JQLAKP.js";
20
+ loadPlugins,
21
+ registerPageContent
22
+ } from "./chunk-LXJLSXQI.js";
19
23
  import {
20
24
  cspHeader,
21
25
  generateNonce,
@@ -23,7 +27,7 @@ import {
23
27
  } from "./chunk-EOK6KATZ.js";
24
28
  import {
25
29
  logger
26
- } from "./chunk-IN2QAGDZ.js";
30
+ } from "./chunk-IVM5UM44.js";
27
31
  import {
28
32
  ASSETS_DIR,
29
33
  CACHE_FILE,
@@ -109,7 +113,18 @@ async function renderDocsPage(docuConfig, slug, rawMdx, filePath, gitDates, buil
109
113
  try {
110
114
  const remarkPlugins = builder?.collectRemarkPlugins();
111
115
  const rehypePlugins = builder?.collectRehypePlugins();
112
- result = await compileMdx(content, filePath, gitDates, remarkPlugins, rehypePlugins);
116
+ const preFm = getPageFrontmatter(`/${slug}`);
117
+ const preStripped = getPageStripped(`/${slug}`);
118
+ const pre = preFm !== void 0 && preStripped !== void 0 ? { frontmatter: preFm, strippedContent: preStripped } : void 0;
119
+ result = await compileMdx(
120
+ content,
121
+ filePath,
122
+ gitDates,
123
+ remarkPlugins,
124
+ rehypePlugins,
125
+ void 0,
126
+ pre
127
+ );
113
128
  } catch (err) {
114
129
  const msg = err instanceof Error ? err.message : "Unknown MDX error";
115
130
  throw new Error(`MDX Error in: docs/${slug}.mdx
@@ -222,6 +237,7 @@ async function runBuild() {
222
237
  } catch {
223
238
  return;
224
239
  }
240
+ registerPageContent(`/${file.path}`, raw);
225
241
  let content = raw;
226
242
  if (builder) {
227
243
  const relPath = file.absPath.replace(PROJECT_ROOT + "/", "");
@@ -230,7 +246,12 @@ async function runBuild() {
230
246
  }
231
247
  const remarkPlugins = builder?.collectRemarkPlugins();
232
248
  const rehypePlugins = builder?.collectRehypePlugins();
233
- mdxSources[file.path] = await compileMdxModule(content, remarkPlugins, rehypePlugins);
249
+ mdxSources[file.path] = await compileMdxModule(
250
+ content,
251
+ remarkPlugins,
252
+ rehypePlugins,
253
+ `/${file.path}`
254
+ );
234
255
  });
235
256
  await Promise.all(prePassTasks);
236
257
  const indexMdxPath = join(DOCS_DIR, "index.mdx");
@@ -285,12 +306,14 @@ async function runBuild() {
285
306
  continue;
286
307
  }
287
308
  }
288
- let rawMdx;
289
- try {
290
- rawMdx = await readFile(file.absPath, "utf-8");
291
- } catch (err) {
292
- if (err.code !== "ENOENT") throw err;
293
- continue;
309
+ let rawMdx = getPageContent(`/${file.path}`);
310
+ if (rawMdx === void 0) {
311
+ try {
312
+ rawMdx = await readFile(file.absPath, "utf-8");
313
+ } catch (err) {
314
+ if (err.code !== "ENOENT") throw err;
315
+ continue;
316
+ }
294
317
  }
295
318
  if (rebuildDecision === "hash_check") {
296
319
  const contentHash = hashContent(rawMdx);
@@ -418,8 +441,9 @@ async function runBuild() {
418
441
  }
419
442
  logger.indexStart();
420
443
  t = performance.now();
421
- const indexCount = await generateSearchIndex();
422
- logger.indexDone(indexCount, Math.round(performance.now() - t));
444
+ const indexSkipped = built === 0 && existsSync(join(ASSETS_DIR, "search-index.json"));
445
+ const indexCount = indexSkipped ? 0 : await generateSearchIndex();
446
+ logger.indexDone(indexCount, Math.round(performance.now() - t), indexSkipped);
423
447
  logger.routes();
424
448
  await writeCache(cache);
425
449
  if (errors.length > 0) {
@@ -15,7 +15,7 @@ import {
15
15
  htmlShell,
16
16
  initSentry,
17
17
  loadPlugins
18
- } from "./chunk-42JQLAKP.js";
18
+ } from "./chunk-LXJLSXQI.js";
19
19
  import {
20
20
  SECURITY_HEADERS,
21
21
  generateNonce,
@@ -28,7 +28,7 @@ import {
28
28
  } from "./chunk-EOK6KATZ.js";
29
29
  import {
30
30
  logger
31
- } from "./chunk-IN2QAGDZ.js";
31
+ } from "./chunk-IVM5UM44.js";
32
32
  import {
33
33
  DIST_DIR,
34
34
  DOCS_DIR,
@@ -5,7 +5,7 @@ import {
5
5
  // package.json
6
6
  var package_default = {
7
7
  name: "@docubook/flame",
8
- version: "2.0.0-alpha.2",
8
+ version: "2.0.0-beta.0",
9
9
  description: "A blazing-fast React + MDX framework powered by Bun, built for modern documentation experiences.",
10
10
  type: "module",
11
11
  bin: {
@@ -239,9 +239,11 @@ ${c.bold}${c.cyan} \u{1F525} DocuBook Flame${c.reset} ${c.dim}v${package_defaul
239
239
  if (guard("info", "index_start")) return;
240
240
  this.spinner.start("Generating search index...");
241
241
  },
242
- indexDone(records, ms) {
243
- if (guard("info", "index_done", { records, duration_ms: ms })) return;
244
- this.spinner.stop(`Search index generated ${c.dim}(${records} records, ${ms}ms)${c.reset}`);
242
+ indexDone(records, ms, skipped = false) {
243
+ if (guard("info", "index_done", { records, duration_ms: ms, skipped })) return;
244
+ this.spinner.stop(
245
+ skipped ? `Search index cached ${c.dim}(${ms}ms)${c.reset}` : `Search index generated ${c.dim}(${records} records, ${ms}ms)${c.reset}`
246
+ );
245
247
  },
246
248
  routes() {
247
249
  if (guard("debug", "routes")) return;
@@ -7,7 +7,7 @@ import {
7
7
  } from "./chunk-EOK6KATZ.js";
8
8
  import {
9
9
  logger
10
- } from "./chunk-IN2QAGDZ.js";
10
+ } from "./chunk-IVM5UM44.js";
11
11
  import {
12
12
  DIST_DIR
13
13
  } from "./chunk-4IQXHHPF.js";
@@ -1101,15 +1101,12 @@ var frontmatterSchema = z.object({
1101
1101
  description: z.coerce.string().optional(),
1102
1102
  image: z.coerce.string().optional(),
1103
1103
  date: z.coerce.string().optional()
1104
- });
1104
+ }).passthrough();
1105
1105
  function frontmatterField(frontmatter, key) {
1106
1106
  return typeof frontmatter[key] === "string" ? frontmatter[key] : "";
1107
1107
  }
1108
- async function serializeWithDocPlugins(rawMdx, opts = {}) {
1109
- const { strippedContent } = extractFrontmatterWithContent(
1110
- rawMdx,
1111
- opts.frontmatterSchema
1112
- );
1108
+ async function serializeWithDocPlugins(rawMdx, opts = {}, pre) {
1109
+ const { strippedContent, frontmatter } = pre ?? extractFrontmatterWithContent(rawMdx, opts.frontmatterSchema);
1113
1110
  const defaultRemark = createDefaultRemarkPlugins();
1114
1111
  const defaultRehype = createDefaultRehypePlugins();
1115
1112
  const finalRemark = [...defaultRemark, remarkMdxJsxDocsHtmlLinks, ...opts.remarkPlugins ?? []];
@@ -1121,16 +1118,16 @@ async function serializeWithDocPlugins(rawMdx, opts = {}) {
1121
1118
  rehypePlugins: finalRehype,
1122
1119
  remarkPlugins: finalRemark
1123
1120
  }
1124
- });
1121
+ }).then((serialized) => ({ ...serialized, frontmatter, strippedContent }));
1125
1122
  }
1126
- async function compileMdx(rawMdx, filePath, gitDates, remarkPlugins, rehypePlugins, frontmatterSchema2) {
1123
+ async function compileMdx(rawMdx, filePath, gitDates, remarkPlugins, rehypePlugins, frontmatterSchema2, pre) {
1127
1124
  const tocs = extractTocsFromRawMdx(rawMdx);
1128
- const { frontmatter } = extractFrontmatterWithContent(rawMdx, frontmatterSchema2);
1129
- const serialized = await serializeWithDocPlugins(rawMdx, {
1130
- remarkPlugins,
1131
- rehypePlugins,
1132
- frontmatterSchema: frontmatterSchema2
1133
- });
1125
+ const frontmatter = pre?.frontmatter ?? extractFrontmatterWithContent(rawMdx, frontmatterSchema2).frontmatter;
1126
+ const serialized = await serializeWithDocPlugins(
1127
+ rawMdx,
1128
+ { remarkPlugins, rehypePlugins, frontmatterSchema: frontmatterSchema2 },
1129
+ pre
1130
+ );
1134
1131
  const components = createMdxComponents();
1135
1132
  const content = React.createElement(MDXRemote, {
1136
1133
  compiledSource: serialized.compiledSource,
@@ -1146,12 +1143,37 @@ async function compileMdx(rawMdx, filePath, gitDates, remarkPlugins, rehypePlugi
1146
1143
  tocs
1147
1144
  };
1148
1145
  }
1149
- async function compileMdxModule(rawMdx, remarkPlugins, rehypePlugins) {
1146
+ var pageFrontmatter = /* @__PURE__ */ new Map();
1147
+ function registerPageFrontmatter(href, frontmatter) {
1148
+ pageFrontmatter.set(href, frontmatter);
1149
+ }
1150
+ function getPageFrontmatter(href) {
1151
+ return pageFrontmatter.get(href);
1152
+ }
1153
+ var pageStripped = /* @__PURE__ */ new Map();
1154
+ function registerPageStripped(href, stripped) {
1155
+ pageStripped.set(href, stripped);
1156
+ }
1157
+ function getPageStripped(href) {
1158
+ return pageStripped.get(href);
1159
+ }
1160
+ var pageContent = /* @__PURE__ */ new Map();
1161
+ function registerPageContent(href, raw) {
1162
+ pageContent.set(href, raw);
1163
+ }
1164
+ function getPageContent(href) {
1165
+ return pageContent.get(href);
1166
+ }
1167
+ async function compileMdxModule(rawMdx, remarkPlugins, rehypePlugins, href) {
1150
1168
  const serialized = await serializeWithDocPlugins(rawMdx, {
1151
1169
  outputFormat: "program",
1152
1170
  remarkPlugins,
1153
1171
  rehypePlugins
1154
1172
  });
1173
+ if (href) {
1174
+ registerPageFrontmatter(href, serialized.frontmatter);
1175
+ registerPageStripped(href, serialized.strippedContent);
1176
+ }
1155
1177
  return serialized.compiledSource;
1156
1178
  }
1157
1179
 
@@ -1278,7 +1300,7 @@ async function generateSearchIndex(docsDir, outputDir) {
1278
1300
  const mdxFiles = await scanMdxFiles(docs);
1279
1301
  const results = await Promise.all(
1280
1302
  mdxFiles.map(async (file) => {
1281
- const raw = await readFile2(file.absPath, "utf-8");
1303
+ const raw = getPageContent(`/${file.path}`) ?? await readFile2(file.absPath, "utf-8");
1282
1304
  return extractRecords(file.path, raw);
1283
1305
  })
1284
1306
  );
@@ -1364,25 +1386,23 @@ function getRouteMap() {
1364
1386
  routes.forEach((route) => traverse(route));
1365
1387
  return map;
1366
1388
  }
1367
- var descriptionCache = /* @__PURE__ */ new Map();
1368
- function readDescription(href) {
1369
- const cached = descriptionCache.get(href);
1370
- if (cached !== void 0) return cached;
1371
- let description = "";
1389
+ function readPageFrontmatter(href) {
1390
+ const registered = getPageFrontmatter(href);
1391
+ if (registered) return registered;
1392
+ let fm = {};
1372
1393
  const rel = href.replace(/^\/|$/g, "");
1373
1394
  for (const ext of [".mdx", ".md"]) {
1374
1395
  for (const file of [join4(DOCS_DIR, `${rel}${ext}`), join4(DOCS_DIR, `${rel}/index${ext}`)]) {
1375
1396
  try {
1376
- const fm = extractFrontmatter(readFileSync2(file, "utf-8"));
1377
- description = typeof fm.description === "string" ? fm.description : "";
1378
- if (description) break;
1397
+ fm = extractFrontmatter(readFileSync2(file, "utf-8"));
1398
+ break;
1379
1399
  } catch {
1380
1400
  }
1381
1401
  }
1382
- if (description) break;
1402
+ if (Object.keys(fm).length) break;
1383
1403
  }
1384
- descriptionCache.set(href, description);
1385
- return description;
1404
+ registerPageFrontmatter(href, fm);
1405
+ return fm;
1386
1406
  }
1387
1407
  function getPreviousNext(pathname) {
1388
1408
  const normalizedPath = pathname.replace(/^\/|$/g, "");
@@ -1391,12 +1411,13 @@ function getPreviousNext(pathname) {
1391
1411
  const routeMap2 = getRouteMap();
1392
1412
  const first = paths2[0];
1393
1413
  if (!first) return { prev: null, next: null };
1414
+ const fm = readPageFrontmatter(first);
1394
1415
  return {
1395
1416
  prev: null,
1396
1417
  next: {
1397
1418
  href: first,
1398
- title: routeMap2.get(first) || "",
1399
- description: readDescription(first)
1419
+ title: fm.title || routeMap2.get(first) || "",
1420
+ description: fm.description || ""
1400
1421
  }
1401
1422
  };
1402
1423
  }
@@ -1408,12 +1429,14 @@ function getPreviousNext(pathname) {
1408
1429
  const routeMap = getRouteMap();
1409
1430
  const prevHref = index > 0 ? paths[index - 1] : null;
1410
1431
  const nextHref = index < paths.length - 1 ? paths[index + 1] : null;
1432
+ const prevFm = prevHref ? readPageFrontmatter(prevHref) : null;
1433
+ const nextFm = nextHref ? readPageFrontmatter(nextHref) : null;
1411
1434
  return {
1412
- prev: prevHref ? { href: prevHref, title: routeMap.get(prevHref) || "" } : null,
1435
+ prev: prevHref ? { href: prevHref, title: prevFm?.title || routeMap.get(prevHref) || "" } : null,
1413
1436
  next: nextHref ? {
1414
1437
  href: nextHref,
1415
- title: routeMap.get(nextHref) || "",
1416
- description: readDescription(nextHref)
1438
+ title: nextFm?.title || routeMap.get(nextHref) || "",
1439
+ description: nextFm?.description || ""
1417
1440
  } : null
1418
1441
  };
1419
1442
  }
@@ -2650,6 +2673,10 @@ export {
2650
2673
  getGitLastModifiedBatch,
2651
2674
  frontmatterField,
2652
2675
  compileMdx,
2676
+ getPageFrontmatter,
2677
+ getPageStripped,
2678
+ registerPageContent,
2679
+ getPageContent,
2653
2680
  compileMdxModule,
2654
2681
  generateSearchIndex,
2655
2682
  initSentry,
@@ -137,7 +137,7 @@ async function runBuild() {
137
137
  process.env.FLAME_BUILD_SILENT = "1";
138
138
  process.env.LOG_LEVEL = "error";
139
139
  }
140
- const { runBuildCli } = await import("./build.impl-AZBZN66Q.js");
140
+ const { runBuildCli } = await import("./build.impl-S4DS5XPH.js");
141
141
  await runBuildCli();
142
142
  }
143
143
  async function writeDockerFiles() {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  logger
3
- } from "./chunk-IN2QAGDZ.js";
3
+ } from "./chunk-IVM5UM44.js";
4
4
  import "./chunk-4IQXHHPF.js";
5
5
 
6
6
  // .docu/node/clean.ts
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runDeploy
3
- } from "./chunk-PJVCY4FK.js";
3
+ } from "./chunk-O326MYJE.js";
4
4
  import "./chunk-4IQXHHPF.js";
5
5
 
6
6
  // .docu/node/deploy.deno.ts
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runDeploy
3
- } from "./chunk-PJVCY4FK.js";
3
+ } from "./chunk-O326MYJE.js";
4
4
  import "./chunk-4IQXHHPF.js";
5
5
 
6
6
  // .docu/node/deploy.node.ts
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  runPreview
3
- } from "./chunk-PGIHPW5L.js";
3
+ } from "./chunk-JMQI3FKV.js";
4
4
  import {
5
5
  denoAdapter
6
6
  } from "./chunk-UISOJ4RW.js";
7
7
  import "./chunk-EOK6KATZ.js";
8
- import "./chunk-IN2QAGDZ.js";
8
+ import "./chunk-IVM5UM44.js";
9
9
  import "./chunk-4IQXHHPF.js";
10
10
 
11
11
  // .docu/node/preview.deno.ts
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  runPreview
3
- } from "./chunk-PGIHPW5L.js";
3
+ } from "./chunk-JMQI3FKV.js";
4
4
  import {
5
5
  nodeAdapter
6
6
  } from "./chunk-UISOJ4RW.js";
7
7
  import "./chunk-EOK6KATZ.js";
8
- import "./chunk-IN2QAGDZ.js";
8
+ import "./chunk-IVM5UM44.js";
9
9
  import "./chunk-4IQXHHPF.js";
10
10
 
11
11
  // .docu/node/preview.node.ts
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  runServer
3
- } from "./chunk-HF3KKRQL.js";
4
- import "./chunk-42JQLAKP.js";
3
+ } from "./chunk-D3QTSBR4.js";
4
+ import "./chunk-LXJLSXQI.js";
5
5
  import {
6
6
  denoAdapter
7
7
  } from "./chunk-UISOJ4RW.js";
8
8
  import "./chunk-EOK6KATZ.js";
9
- import "./chunk-IN2QAGDZ.js";
9
+ import "./chunk-IVM5UM44.js";
10
10
  import "./chunk-4IQXHHPF.js";
11
11
 
12
12
  // .docu/node/server.deno.ts
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  runServer
3
- } from "./chunk-HF3KKRQL.js";
4
- import "./chunk-42JQLAKP.js";
3
+ } from "./chunk-D3QTSBR4.js";
4
+ import "./chunk-LXJLSXQI.js";
5
5
  import {
6
6
  nodeAdapter
7
7
  } from "./chunk-UISOJ4RW.js";
8
8
  import "./chunk-EOK6KATZ.js";
9
- import "./chunk-IN2QAGDZ.js";
9
+ import "./chunk-IVM5UM44.js";
10
10
  import "./chunk-4IQXHHPF.js";
11
11
 
12
12
  // .docu/node/server.node.ts
@@ -12,7 +12,16 @@ import { createHash } from "node:crypto";
12
12
  import { join, dirname } from "node:path";
13
13
  import React from "react";
14
14
  import { renderToString } from "react-dom/server";
15
- import { compileMdx, compileMdxModule, frontmatterField, getGitLastModifiedBatch } from "./mdx";
15
+ import {
16
+ compileMdx,
17
+ compileMdxModule,
18
+ frontmatterField,
19
+ getGitLastModifiedBatch,
20
+ getPageContent,
21
+ getPageFrontmatter,
22
+ getPageStripped,
23
+ registerPageContent,
24
+ } from "./mdx";
16
25
  import {
17
26
  DOCS_DIR,
18
27
  DIST_DIR,
@@ -105,7 +114,24 @@ async function renderDocsPage(
105
114
  try {
106
115
  const remarkPlugins = builder?.collectRemarkPlugins();
107
116
  const rehypePlugins = builder?.collectRehypePlugins();
108
- result = await compileMdx(content, filePath, gitDates, remarkPlugins, rehypePlugins);
117
+ // Parse-once: reuse the prePass frontmatter + stripped content so the
118
+ // SSR phase does not re-extract them. Only when the prePass actually
119
+ // registered them — otherwise compileMdx does its own extraction.
120
+ const preFm = getPageFrontmatter(`/${slug}`);
121
+ const preStripped = getPageStripped(`/${slug}`);
122
+ const pre =
123
+ preFm !== undefined && preStripped !== undefined
124
+ ? { frontmatter: preFm, strippedContent: preStripped }
125
+ : undefined;
126
+ result = await compileMdx(
127
+ content,
128
+ filePath,
129
+ gitDates,
130
+ remarkPlugins,
131
+ rehypePlugins,
132
+ undefined,
133
+ pre
134
+ );
109
135
  } catch (err) {
110
136
  const msg = err instanceof Error ? err.message : "Unknown MDX error";
111
137
  throw new Error(`MDX Error in: docs/${slug}.mdx\n${msg}`, { cause: err });
@@ -241,6 +267,9 @@ export async function runBuild(): Promise<void> {
241
267
  } catch {
242
268
  return;
243
269
  }
270
+ // Cache the original content so the page loop does not re-read the file
271
+ // (one disk read per file — the frontmatter is parsed once here too).
272
+ registerPageContent(`/${file.path}`, raw);
244
273
  let content = raw;
245
274
  if (builder) {
246
275
  const relPath = file.absPath.replace(PROJECT_ROOT + "/", "");
@@ -249,7 +278,12 @@ export async function runBuild(): Promise<void> {
249
278
  }
250
279
  const remarkPlugins = builder?.collectRemarkPlugins();
251
280
  const rehypePlugins = builder?.collectRehypePlugins();
252
- mdxSources[file.path] = await compileMdxModule(content, remarkPlugins, rehypePlugins);
281
+ mdxSources[file.path] = await compileMdxModule(
282
+ content,
283
+ remarkPlugins,
284
+ rehypePlugins,
285
+ `/${file.path}`
286
+ );
253
287
  });
254
288
  await Promise.all(prePassTasks);
255
289
 
@@ -319,12 +353,14 @@ export async function runBuild(): Promise<void> {
319
353
  }
320
354
  }
321
355
 
322
- let rawMdx: string;
323
- try {
324
- rawMdx = await readFile(file.absPath, "utf-8");
325
- } catch (err) {
326
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
327
- continue;
356
+ let rawMdx = getPageContent(`/${file.path}`);
357
+ if (rawMdx === undefined) {
358
+ try {
359
+ rawMdx = await readFile(file.absPath, "utf-8");
360
+ } catch (err) {
361
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
362
+ continue;
363
+ }
328
364
  }
329
365
 
330
366
  if (rebuildDecision === "hash_check") {
@@ -458,8 +494,11 @@ export async function runBuild(): Promise<void> {
458
494
 
459
495
  logger.indexStart();
460
496
  t = performance.now();
461
- const indexCount = await generateSearchIndex();
462
- logger.indexDone(indexCount, Math.round(performance.now() - t));
497
+ // No content changed (all pages cached) → the aggregated index is
498
+ // unchanged too; reuse the existing file instead of regenerating it.
499
+ const indexSkipped = built === 0 && existsSync(join(ASSETS_DIR, "search-index.json"));
500
+ const indexCount = indexSkipped ? 0 : await generateSearchIndex();
501
+ logger.indexDone(indexCount, Math.round(performance.now() - t), indexSkipped);
463
502
 
464
503
  logger.routes();
465
504
 
@@ -4,7 +4,16 @@ import { createHash } from "node:crypto";
4
4
  import { join, dirname } from "node:path";
5
5
  import React from "react";
6
6
  import { renderToString } from "react-dom/server";
7
- import { compileMdx, compileMdxModule, frontmatterField, getGitLastModifiedBatch } from "./mdx";
7
+ import {
8
+ compileMdx,
9
+ compileMdxModule,
10
+ frontmatterField,
11
+ getGitLastModifiedBatch,
12
+ getPageContent,
13
+ getPageFrontmatter,
14
+ getPageStripped,
15
+ registerPageContent,
16
+ } from "./mdx";
8
17
  import {
9
18
  DOCS_DIR,
10
19
  DIST_DIR,
@@ -98,7 +107,24 @@ async function renderDocsPage(
98
107
  try {
99
108
  const remarkPlugins = builder?.collectRemarkPlugins();
100
109
  const rehypePlugins = builder?.collectRehypePlugins();
101
- result = await compileMdx(content, filePath, gitDates, remarkPlugins, rehypePlugins);
110
+ // Parse-once: reuse the prePass frontmatter + stripped content so the
111
+ // SSR phase does not re-extract them. Only when the prePass actually
112
+ // registered them — otherwise compileMdx does its own extraction.
113
+ const preFm = getPageFrontmatter(`/${slug}`);
114
+ const preStripped = getPageStripped(`/${slug}`);
115
+ const pre =
116
+ preFm !== undefined && preStripped !== undefined
117
+ ? { frontmatter: preFm, strippedContent: preStripped }
118
+ : undefined;
119
+ result = await compileMdx(
120
+ content,
121
+ filePath,
122
+ gitDates,
123
+ remarkPlugins,
124
+ rehypePlugins,
125
+ undefined,
126
+ pre
127
+ );
102
128
  } catch (err) {
103
129
  const msg = err instanceof Error ? err.message : "Unknown MDX error";
104
130
  throw new Error(`MDX Error in: docs/${slug}.mdx\n${msg}`, { cause: err });
@@ -229,6 +255,9 @@ async function build() {
229
255
  } catch {
230
256
  return;
231
257
  }
258
+ // Cache the original content so the page loop does not re-read the file
259
+ // (one disk read per file — the frontmatter is parsed once here too).
260
+ registerPageContent(`/${file.path}`, raw);
232
261
  let content = raw;
233
262
  if (builder) {
234
263
  const relPath = file.absPath.replace(PROJECT_ROOT + "/", "");
@@ -237,7 +266,12 @@ async function build() {
237
266
  }
238
267
  const remarkPlugins = builder?.collectRemarkPlugins();
239
268
  const rehypePlugins = builder?.collectRehypePlugins();
240
- mdxSources[file.path] = await compileMdxModule(content, remarkPlugins, rehypePlugins);
269
+ mdxSources[file.path] = await compileMdxModule(
270
+ content,
271
+ remarkPlugins,
272
+ rehypePlugins,
273
+ `/${file.path}`
274
+ );
241
275
  });
242
276
  await Promise.all(prePassTasks);
243
277
 
@@ -307,12 +341,14 @@ async function build() {
307
341
  }
308
342
  }
309
343
 
310
- let rawMdx: string;
311
- try {
312
- rawMdx = await readFile(file.absPath, "utf-8");
313
- } catch (err) {
314
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
315
- continue;
344
+ let rawMdx = getPageContent(`/${file.path}`);
345
+ if (rawMdx === undefined) {
346
+ try {
347
+ rawMdx = await readFile(file.absPath, "utf-8");
348
+ } catch (err) {
349
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
350
+ continue;
351
+ }
316
352
  }
317
353
 
318
354
  if (rebuildDecision === "hash_check") {
@@ -440,8 +476,11 @@ async function build() {
440
476
 
441
477
  logger.indexStart();
442
478
  t = performance.now();
443
- const indexCount = await generateSearchIndex();
444
- logger.indexDone(indexCount, Math.round(performance.now() - t));
479
+ // No content changed (all pages cached) → the aggregated index is
480
+ // unchanged too; reuse the existing file instead of regenerating it.
481
+ const indexSkipped = built === 0 && existsSync(join(ASSETS_DIR, "search-index.json"));
482
+ const indexCount = indexSkipped ? 0 : await generateSearchIndex();
483
+ logger.indexDone(indexCount, Math.round(performance.now() - t), indexSkipped);
445
484
 
446
485
  logger.routes();
447
486
  console.log("");
@@ -179,9 +179,13 @@ export const logger = {
179
179
  this.spinner.start("Generating search index...");
180
180
  },
181
181
 
182
- indexDone(records: number, ms: number) {
183
- if (guard("info", "index_done", { records, duration_ms: ms })) return;
184
- this.spinner.stop(`Search index generated ${c.dim}(${records} records, ${ms}ms)${c.reset}`);
182
+ indexDone(records: number, ms: number, skipped = false) {
183
+ if (guard("info", "index_done", { records, duration_ms: ms, skipped })) return;
184
+ this.spinner.stop(
185
+ skipped
186
+ ? `Search index cached ${c.dim}(${ms}ms)${c.reset}`
187
+ : `Search index generated ${c.dim}(${records} records, ${ms}ms)${c.reset}`
188
+ );
185
189
  },
186
190
 
187
191
  routes() {
package/.docu/node/mdx.ts CHANGED
@@ -115,13 +115,19 @@ export interface MdxResult {
115
115
  * DocuBook frontmatter contract — single source of truth for frontmatter
116
116
  * fields. Add new properties here; types and validation derive from it.
117
117
  * YAML coerces unquoted values, so string fields use `z.coerce.*`.
118
+ * `.passthrough()` keeps unknown fields (e.g. `author: wildan`, `tags`)
119
+ * in the parsed output — arbitrary frontmatter metadata stays available
120
+ * via `frontmatterField(frontmatter, key)` instead of being silently
121
+ * stripped by Zod's default object parsing.
118
122
  */
119
- export const frontmatterSchema = z.object({
120
- title: z.coerce.string().optional(),
121
- description: z.coerce.string().optional(),
122
- image: z.coerce.string().optional(),
123
- date: z.coerce.string().optional(),
124
- });
123
+ export const frontmatterSchema = z
124
+ .object({
125
+ title: z.coerce.string().optional(),
126
+ description: z.coerce.string().optional(),
127
+ image: z.coerce.string().optional(),
128
+ date: z.coerce.string().optional(),
129
+ })
130
+ .passthrough();
125
131
 
126
132
  export type Frontmatter = z.infer<typeof frontmatterSchema>;
127
133
 
@@ -162,12 +168,13 @@ async function serializeWithDocPlugins(
162
168
  remarkPlugins?: Pluggable[];
163
169
  rehypePlugins?: Pluggable[];
164
170
  frontmatterSchema?: FrontmatterSchema;
165
- } = {}
171
+ } = {},
172
+ pre?: { frontmatter: Frontmatter; strippedContent: string }
166
173
  ) {
167
- const { strippedContent } = extractFrontmatterWithContent<Frontmatter>(
168
- rawMdx,
169
- opts.frontmatterSchema
170
- );
174
+ // Parse-once: when the prePass already extracted the frontmatter + stripped
175
+ // content, reuse it instead of re-parsing (the SSR phase skips extraction).
176
+ const { strippedContent, frontmatter } =
177
+ pre ?? extractFrontmatterWithContent<Frontmatter>(rawMdx, opts.frontmatterSchema);
171
178
 
172
179
  const defaultRemark = createDefaultRemarkPlugins();
173
180
  const defaultRehype = createDefaultRehypePlugins();
@@ -179,7 +186,8 @@ async function serializeWithDocPlugins(
179
186
  const finalRehype = [...defaultRehype, rehypeDocsHtmlLinks, ...(opts.rehypePlugins ?? [])];
180
187
 
181
188
  // v2 contract: plain markdown + directives only — authored JSX tags are
182
- // not parsed (dropped, content kept as text).
189
+ // not parsed (dropped, content kept as text). Return the frontmatter parsed
190
+ // above — `serialize()` only parses it when `parseFrontmatter` is set.
183
191
  return serialize(strippedContent, {
184
192
  outputFormat: opts.outputFormat,
185
193
  format: "md",
@@ -187,7 +195,7 @@ async function serializeWithDocPlugins(
187
195
  rehypePlugins: finalRehype,
188
196
  remarkPlugins: finalRemark,
189
197
  },
190
- });
198
+ }).then((serialized) => ({ ...serialized, frontmatter, strippedContent }));
191
199
  }
192
200
 
193
201
  /**
@@ -206,15 +214,19 @@ export async function compileMdx(
206
214
  gitDates?: Map<string, string>,
207
215
  remarkPlugins?: Pluggable[],
208
216
  rehypePlugins?: Pluggable[],
209
- frontmatterSchema?: FrontmatterSchema
217
+ frontmatterSchema?: FrontmatterSchema,
218
+ /** Pre-pass extracted data — avoids re-parsing frontmatter in the SSR phase. */
219
+ pre?: { frontmatter: Frontmatter; strippedContent: string }
210
220
  ): Promise<MdxResult> {
211
221
  const tocs = extractTocsFromRawMdx(rawMdx);
212
- const { frontmatter } = extractFrontmatterWithContent<Frontmatter>(rawMdx, frontmatterSchema);
213
- const serialized = await serializeWithDocPlugins(rawMdx, {
214
- remarkPlugins,
215
- rehypePlugins,
216
- frontmatterSchema,
217
- });
222
+ const frontmatter =
223
+ pre?.frontmatter ??
224
+ extractFrontmatterWithContent<Frontmatter>(rawMdx, frontmatterSchema).frontmatter;
225
+ const serialized = await serializeWithDocPlugins(
226
+ rawMdx,
227
+ { remarkPlugins, rehypePlugins, frontmatterSchema },
228
+ pre
229
+ );
218
230
 
219
231
  const components = createMdxComponents();
220
232
  const content = React.createElement(MDXRemote, {
@@ -245,15 +257,69 @@ export async function compileMdx(
245
257
  * instead of `new Function(compiledSource)`. Uses the same plugin chain as
246
258
  * `compileMdx` so the hydrated tree matches the SSR output.
247
259
  */
260
+ /**
261
+ * Frontmatter records collected once during compilation — pagination title /
262
+ * description and other metadata consumers read from here instead of
263
+ * re-reading + re-parsing files (parse-once contract: the frontmatter is
264
+ * already parsed by `serializeWithDocPlugins`).
265
+ */
266
+ const pageFrontmatter = new Map<string, Frontmatter>();
267
+
268
+ /** Register a page's frontmatter, keyed by its href. */
269
+ export function registerPageFrontmatter(href: string, frontmatter: Frontmatter): void {
270
+ pageFrontmatter.set(href, frontmatter);
271
+ }
272
+
273
+ /** Look up a page's frontmatter (undefined when not yet compiled). */
274
+ export function getPageFrontmatter(href: string): Frontmatter | undefined {
275
+ return pageFrontmatter.get(href);
276
+ }
277
+
278
+ /**
279
+ * Frontmatter-stripped content from the prePass — lets the SSR compile
280
+ * phase skip its own `extractFrontmatterWithContent` (parse-once across
281
+ * both compile phases).
282
+ */
283
+ const pageStripped = new Map<string, string>();
284
+
285
+ export function registerPageStripped(href: string, stripped: string): void {
286
+ pageStripped.set(href, stripped);
287
+ }
288
+
289
+ export function getPageStripped(href: string): string | undefined {
290
+ return pageStripped.get(href);
291
+ }
292
+
293
+ /**
294
+ * Original (pre-transform) file content, cached during the prePass so the
295
+ * page loop does not re-read the file from disk — one read per file.
296
+ */
297
+ const pageContent = new Map<string, string>();
298
+
299
+ export function registerPageContent(href: string, raw: string): void {
300
+ pageContent.set(href, raw);
301
+ }
302
+
303
+ export function getPageContent(href: string): string | undefined {
304
+ return pageContent.get(href);
305
+ }
306
+
248
307
  export async function compileMdxModule(
249
308
  rawMdx: string,
250
309
  remarkPlugins?: Pluggable[],
251
- rehypePlugins?: Pluggable[]
310
+ rehypePlugins?: Pluggable[],
311
+ /** Page href — registers the frontmatter (title/description/image/date)
312
+ * once for pagination and metadata consumers. */
313
+ href?: string
252
314
  ): Promise<string> {
253
315
  const serialized = await serializeWithDocPlugins(rawMdx, {
254
316
  outputFormat: "program",
255
317
  remarkPlugins,
256
318
  rehypePlugins,
257
319
  });
320
+ if (href) {
321
+ registerPageFrontmatter(href, serialized.frontmatter as Frontmatter);
322
+ registerPageStripped(href, serialized.strippedContent);
323
+ }
258
324
  return serialized.compiledSource;
259
325
  }
@@ -5,6 +5,7 @@ import { DOCS_DIR } from "./paths";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { extractFrontmatter } from "@docubook/core";
8
+ import { getPageFrontmatter, registerPageFrontmatter } from "./mdx";
8
9
  import type { Frontmatter } from "./mdx";
9
10
 
10
11
  const docuConfig = loadDocuConfig();
@@ -46,29 +47,31 @@ export function getRouteMap(): Map<string, string> {
46
47
  return map;
47
48
  }
48
49
 
49
- /** Build-time cache of href → frontmatter description (docs content is static). */
50
- const descriptionCache = new Map<string, string>();
51
-
52
- function readDescription(href: string): string {
53
- const cached = descriptionCache.get(href);
54
- if (cached !== undefined) return cached;
55
-
56
- let description = "";
50
+ /**
51
+ * Frontmatter for a page — read from the parse-once registry (populated
52
+ * during compilation) instead of re-reading + re-parsing the file. Falls
53
+ * back to a direct read only for pages the dev server has not compiled yet,
54
+ * and caches the result back into the registry.
55
+ */
56
+ function readPageFrontmatter(href: string): Frontmatter {
57
+ const registered = getPageFrontmatter(href);
58
+ if (registered) return registered;
59
+
60
+ let fm: Frontmatter = {};
57
61
  const rel = href.replace(/^\/|$/g, "");
58
62
  for (const ext of [".mdx", ".md"]) {
59
63
  for (const file of [join(DOCS_DIR, `${rel}${ext}`), join(DOCS_DIR, `${rel}/index${ext}`)]) {
60
64
  try {
61
- const fm = extractFrontmatter<Frontmatter>(readFileSync(file, "utf-8"));
62
- description = typeof fm.description === "string" ? fm.description : "";
63
- if (description) break;
65
+ fm = extractFrontmatter<Frontmatter>(readFileSync(file, "utf-8"));
66
+ break;
64
67
  } catch {
65
68
  // not this file — try the next candidate
66
69
  }
67
70
  }
68
- if (description) break;
71
+ if (Object.keys(fm).length) break;
69
72
  }
70
- descriptionCache.set(href, description);
71
- return description;
73
+ registerPageFrontmatter(href, fm);
74
+ return fm;
72
75
  }
73
76
 
74
77
  export function getPreviousNext(pathname: string) {
@@ -83,12 +86,13 @@ export function getPreviousNext(pathname: string) {
83
86
  const routeMap = getRouteMap();
84
87
  const first = paths[0];
85
88
  if (!first) return { prev: null, next: null };
89
+ const fm = readPageFrontmatter(first);
86
90
  return {
87
91
  prev: null,
88
92
  next: {
89
93
  href: first,
90
- title: routeMap.get(first) || "",
91
- description: readDescription(first),
94
+ title: fm.title || routeMap.get(first) || "",
95
+ description: fm.description || "",
92
96
  },
93
97
  };
94
98
  }
@@ -105,13 +109,17 @@ export function getPreviousNext(pathname: string) {
105
109
  const prevHref = index > 0 ? paths[index - 1] : null;
106
110
  const nextHref = index < paths.length - 1 ? paths[index + 1] : null;
107
111
 
112
+ const prevFm = prevHref ? readPageFrontmatter(prevHref) : null;
113
+ const nextFm = nextHref ? readPageFrontmatter(nextHref) : null;
108
114
  return {
109
- prev: prevHref ? { href: prevHref, title: routeMap.get(prevHref) || "" } : null,
115
+ prev: prevHref
116
+ ? { href: prevHref, title: prevFm?.title || routeMap.get(prevHref) || "" }
117
+ : null,
110
118
  next: nextHref
111
119
  ? {
112
120
  href: nextHref,
113
- title: routeMap.get(nextHref) || "",
114
- description: readDescription(nextHref),
121
+ title: nextFm?.title || routeMap.get(nextHref) || "",
122
+ description: nextFm?.description || "",
115
123
  }
116
124
  : null,
117
125
  };
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { readFile, writeFile, mkdir } from "node:fs/promises";
15
15
  import { resolve, join } from "node:path";
16
+ import { getPageContent } from "./mdx";
16
17
  import { extractFrontmatterWithContent } from "@docubook/core";
17
18
  import { frontmatterField } from "./mdx";
18
19
  import { DOCS_DIR, ASSETS_DIR, loadDocuConfig } from "./paths";
@@ -193,7 +194,9 @@ export async function generateSearchIndex(docsDir?: string, outputDir?: string):
193
194
  const mdxFiles = await scanMdxFiles(docs);
194
195
  const results = await Promise.all(
195
196
  mdxFiles.map(async (file) => {
196
- const raw = await readFile(file.absPath, "utf-8");
197
+ // Parse-once: reuse the prePass-cached raw content when available —
198
+ // no second disk read of every file.
199
+ const raw = getPageContent(`/${file.path}`) ?? (await readFile(file.absPath, "utf-8"));
197
200
  return extractRecords(file.path, raw);
198
201
  })
199
202
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docubook/flame",
3
- "version": "2.0.0-alpha.2",
3
+ "version": "2.0.0-beta.0",
4
4
  "description": "A blazing-fast React + MDX framework powered by Bun, built for modern documentation experiences.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,10 +56,10 @@
56
56
  "react-dom": "^19.2.7",
57
57
  "unified": "^11.0.0",
58
58
  "zod": "^4.4.3",
59
- "@docubook/markdown": "^2.0.0-alpha.2",
60
- "@docubook/ui-react": "^2.0.0-alpha.2",
61
- "@docubook/themes-colors": "^2.0.0-alpha.2",
62
- "@docubook/core": "^2.0.0-alpha.2"
59
+ "@docubook/core": "^2.0.0-beta.0",
60
+ "@docubook/markdown": "^2.0.0-beta.0",
61
+ "@docubook/themes-colors": "^2.0.0-beta.0",
62
+ "@docubook/ui-react": "^2.0.0-beta.0"
63
63
  },
64
64
  "peerDependencies": {
65
65
  "@sentry/bun": "^10.0.0"