@docubook/flame 2.0.0-beta.3 → 2.0.0-beta.5

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 (47) hide show
  1. package/.docu/components/Pagination.tsx +12 -1
  2. package/.docu/components/Toc.tsx +1 -1
  3. package/.docu/lib/build.deno.js +1 -1
  4. package/.docu/lib/{build.impl-Bz_p421t.js → build.impl-BBlR3si3.js} +22 -21
  5. package/.docu/lib/build.impl-BBlR3si3.js.map +1 -0
  6. package/.docu/lib/build.impl-DpoDUmei.js +2 -0
  7. package/.docu/lib/build.node.js +1 -1
  8. package/.docu/lib/clean.js +1 -1
  9. package/.docu/lib/deploy.deno.js +1 -1
  10. package/.docu/lib/deploy.node.js +1 -1
  11. package/.docu/lib/{deploy.shared-fk8eM-r4.js → deploy.shared-Dxv3lsOb.js} +2 -2
  12. package/.docu/lib/{deploy.shared-fk8eM-r4.js.map → deploy.shared-Dxv3lsOb.js.map} +1 -1
  13. package/.docu/lib/{html.shared-FwgbE1WG.js → html.shared-DBMwv_Z6.js} +43 -31
  14. package/.docu/lib/html.shared-DBMwv_Z6.js.map +1 -0
  15. package/.docu/lib/{logger-CQyNTE6L.js → logger-DYmvFF1d.js} +2 -2
  16. package/.docu/lib/{logger-CQyNTE6L.js.map → logger-DYmvFF1d.js.map} +1 -1
  17. package/.docu/lib/preview.deno.js +1 -1
  18. package/.docu/lib/{preview.impl-CnsbLmDA.js → preview.impl-anVcZv2Z.js} +3 -3
  19. package/.docu/lib/{preview.impl-CnsbLmDA.js.map → preview.impl-anVcZv2Z.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-CXTzYqbF.js → server.impl-Cvoa6Diq.js} +6 -6
  23. package/.docu/lib/server.impl-Cvoa6Diq.js.map +1 -0
  24. package/.docu/lib/server.node.js +1 -1
  25. package/.docu/lib/{utils-DA17MyQG.js → utils-B_CoyKie.js} +28 -6
  26. package/.docu/lib/utils-B_CoyKie.js.map +1 -0
  27. package/.docu/node/build.impl.ts +42 -35
  28. package/.docu/node/build.ts +57 -41
  29. package/.docu/node/cache-key.ts +1 -1
  30. package/.docu/node/html.shared.ts +2 -1
  31. package/.docu/node/html.ts +2 -1
  32. package/.docu/node/mdx-manifest.d.ts +2 -3
  33. package/.docu/node/mdx.ts +12 -6
  34. package/.docu/node/plugin-builder.ts +10 -1
  35. package/.docu/node/route.ts +19 -21
  36. package/.docu/node/search-indexer.ts +10 -4
  37. package/.docu/node/search.ts +8 -2
  38. package/.docu/node/security.ts +18 -1
  39. package/.docu/node/server-routes.ts +3 -3
  40. package/.docu/node/types.ts +1 -1
  41. package/.docu/node/utils.ts +22 -2
  42. package/package.json +6 -6
  43. package/.docu/lib/build.impl-Bz_p421t.js.map +0 -1
  44. package/.docu/lib/build.impl-CtPrlYAE.js +0 -2
  45. package/.docu/lib/html.shared-FwgbE1WG.js.map +0 -1
  46. package/.docu/lib/server.impl-CXTzYqbF.js.map +0 -1
  47. package/.docu/lib/utils-DA17MyQG.js.map +0 -1
@@ -30,7 +30,7 @@ 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";
33
+ import { scanMdxFiles, resolveDocsIndexSource, DEFAULT_FAVICON } from "./utils";
34
34
  import type { BuildCache, BuildCacheMeta, CliArgs } from "./types";
35
35
  import { isCacheEntry } from "./types";
36
36
  import {
@@ -41,7 +41,7 @@ import {
41
41
  runtimeStamp,
42
42
  } from "./cache-key";
43
43
  import { clearDerivedPageCaches } from "./mdx";
44
- import { generateNonce } from "./security";
44
+ import { generateNonce, cspHeader } from "./security";
45
45
  import type { PageMeta, PageContext } from "./plugin";
46
46
  import { buildSeoMeta } from "./seo";
47
47
  import DocsPage from "../pages/docs/[[...slug]]";
@@ -109,12 +109,11 @@ type RebuildDecision = "yes" | "hash_check" | "no";
109
109
  export function shouldRebuild(path: string, mtime: number, cache: BuildCache): RebuildDecision {
110
110
  const cached = cache[path];
111
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";
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";
118
117
  return "no";
119
118
  }
120
119
 
@@ -227,14 +226,18 @@ async function renderDocsPage(
227
226
  const bodyExtra = builder?.collectBody(ctx);
228
227
 
229
228
  const depth = slug ? slug.split("/").length : 1;
230
- const favicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
229
+ const favicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
231
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;
232
234
  let html = htmlShell({
233
235
  title,
234
236
  description,
235
237
  body,
236
238
  favicon,
237
239
  seo,
240
+ csp,
238
241
  css: assetManifest.css,
239
242
  js: assetManifest.js,
240
243
  nonce,
@@ -337,15 +340,16 @@ async function build() {
337
340
  });
338
341
  await Promise.all(prePassTasks);
339
342
 
340
- // The docs root (index.mdx) renders with slug "" — mirror that key so the
341
- // index page hydrates too. Its render has its own try/catch; skip on error.
342
- const indexMdxPath = join(DOCS_DIR, "index.mdx");
343
- 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) {
344
348
  try {
345
- const indexRaw = await readFile(indexMdxPath, "utf-8");
349
+ const indexRaw = await readFile(indexSource, "utf-8");
346
350
  let indexContent = indexRaw;
347
351
  if (builder) {
348
- const relPath = indexMdxPath.replace(PROJECT_ROOT + "/", "");
352
+ const relPath = indexSource.replace(PROJECT_ROOT + "/", "");
349
353
  const transformed = await builder.runOnLoad(relPath, indexContent);
350
354
  if (transformed?.contents) indexContent = transformed.contents;
351
355
  }
@@ -394,9 +398,8 @@ async function build() {
394
398
 
395
399
  const allRelPaths = mdxFiles.map((f) => f.absPath.replace(PROJECT_ROOT + "/", ""));
396
400
 
397
- const indexMdxFull = join(DOCS_DIR, "index.mdx");
398
- if (existsSync(indexMdxFull)) {
399
- allRelPaths.push(indexMdxFull.replace(PROJECT_ROOT + "/", ""));
401
+ if (indexSource) {
402
+ allRelPaths.push(indexSource.replace(PROJECT_ROOT + "/", ""));
400
403
  }
401
404
  const gitDates = await getGitLastModifiedBatch(allRelPaths);
402
405
 
@@ -476,38 +479,49 @@ async function build() {
476
479
  await Promise.all(buildTasks.slice(i, i + CONCURRENCY).map((fn) => fn()));
477
480
  }
478
481
 
479
- try {
480
- const indexMdxPath = join(DOCS_DIR, "index.mdx");
481
- const indexRaw = await readFile(indexMdxPath, "utf-8");
482
- const indexRelPath = indexMdxPath.replace(PROJECT_ROOT + "/", "");
483
- const indexHtml = await renderDocsPage(
484
- "",
485
- indexRaw,
486
- indexRelPath,
487
- gitDates,
488
- builder,
489
- generateNonce()
490
- );
491
- await mkdir(join(DIST_DIR, "docs"), { recursive: true });
492
- await writeFile(join(DIST_DIR, "docs", "index.html"), indexHtml);
493
- } catch (err) {
494
- const msg = err instanceof Error ? err.message : String(err);
495
- errors.push(`index.mdx: ${msg}`);
496
- 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`);
497
505
  }
498
506
 
499
507
  const landingPage = React.createElement(IndexPage);
500
- const landingFavicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
501
- 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();
502
515
  const landingHtml = htmlShell({
503
516
  title: docuConfig.meta?.title || "DocuBook",
504
517
  description: docuConfig.meta?.description || "",
505
518
  body: renderToString(landingPage),
506
519
  favicon: landingFavicon,
507
520
  seo: landingSeo,
521
+ csp: cspHeader(landingNonce),
508
522
  css: assetManifest.css,
509
523
  js: assetManifest.js,
510
- nonce: generateNonce(),
524
+ nonce: landingNonce,
511
525
  themeCss: inlineThemeCss,
512
526
  });
513
527
  await writeFile(join(DIST_DIR, "index.html"), landingHtml);
@@ -517,16 +531,18 @@ async function build() {
517
531
  { repoUrl: docuConfig.repo?.url },
518
532
  React.createElement(NotFoundPage)
519
533
  );
520
- const notFoundFavicon = docuConfig.meta?.favicon || "/docs/assets/images/favicon.ico";
534
+ const notFoundFavicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
535
+ const notFoundNonce = generateNonce();
521
536
  const notFoundHtml = htmlShell({
522
537
  title: "404 - Not Found",
523
538
  description: "",
524
539
  body: renderToString(notFoundPage),
525
540
  favicon: notFoundFavicon,
526
541
  headExtra: ['<meta name="robots" content="noindex,follow">'],
542
+ csp: cspHeader(notFoundNonce),
527
543
  css: assetManifest.css,
528
544
  js: assetManifest.js,
529
- nonce: generateNonce(),
545
+ nonce: notFoundNonce,
530
546
  themeCss: inlineThemeCss,
531
547
  // Served as the static-host fallback at ANY requested path — relative
532
548
  // depth can never be right there, so use root-absolute asset URLs.
@@ -8,7 +8,7 @@ import { FRAMEWORK_ROOT, STYLES_DIR, resolveProjectFile } from "./paths";
8
8
  * (e.g. Bun.build barrel optimization, Tailwind CLI upgrade). Old caches
9
9
  * with a mismatched version are discarded on read (see build.ts readCache).
10
10
  */
11
- export const BUILD_CACHE_VERSION = 3;
11
+ export const BUILD_CACHE_VERSION = 4;
12
12
 
13
13
  /** Toolchain fingerprint: Bun version on Bun, Deno version on Deno, Node elsewhere. */
14
14
  export function runtimeStamp(): string {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { escapeHtml } from "./escapeHtml";
10
+ import { cspMeta } from "./security";
10
11
 
11
12
  import type { SeoMeta } from "./seo";
12
13
 
@@ -86,7 +87,7 @@ export function htmlShell(opts: HtmlShellOptions): string {
86
87
  ${favicon ? `<link rel="icon" type="image/x-icon" href="${escapeHtml(resolvePath(favicon))}">` : ""}${themeStyle}
87
88
  <link rel="preload" href="${escapeHtml(assetPrefix + css)}" as="style">
88
89
  <link rel="stylesheet" href="${escapeHtml(assetPrefix + css)}">
89
- ${csp ? `<meta http-equiv="Content-Security-Policy" content="${escapeHtml(csp)}">` : ""}
90
+ ${csp ? `<meta http-equiv="Content-Security-Policy" content="${escapeHtml(cspMeta(csp))}">` : ""}
90
91
  ${seoTags}
91
92
  <script${nonceAttr}>try{if(localStorage.getItem("theme")==="dark")document.documentElement.classList.add("dark")}catch(e){}</script>${headInjection}
92
93
  </head>
@@ -1,5 +1,6 @@
1
1
  import type { HtmlShellOptions } from "./html.shared";
2
2
  export type { HtmlShellOptions };
3
+ import { cspMeta } from "./security";
3
4
 
4
5
  export function htmlShell(opts: HtmlShellOptions): string {
5
6
  const {
@@ -48,7 +49,7 @@ export function htmlShell(opts: HtmlShellOptions): string {
48
49
  ${favicon ? `<link rel="icon" type="image/x-icon" href="${Bun.escapeHTML(resolvePath(favicon))}">` : ""}${themeStyle}
49
50
  <link rel="preload" href="${Bun.escapeHTML(assetPrefix + css)}" as="style">
50
51
  <link rel="stylesheet" href="${Bun.escapeHTML(assetPrefix + css)}">
51
- ${csp ? `<meta http-equiv="Content-Security-Policy" content="${Bun.escapeHTML(csp)}">` : ""}
52
+ ${csp ? `<meta http-equiv="Content-Security-Policy" content="${Bun.escapeHTML(cspMeta(csp))}">` : ""}
52
53
  ${seoTags}
53
54
  <script${nonceAttr}>try{if(localStorage.getItem("theme")==="dark")document.documentElement.classList.add("dark")}catch(e){}</script>${headInjection}
54
55
  </head>
@@ -1,5 +1,4 @@
1
1
  // Virtual module served by the `mdx-hydrate` esbuild plugin (hydrate.node.ts).
2
2
  // Maps doc slug → compiled MDX module. Generated at bundle time; never on disk.
3
- declare module "./mdx-manifest" {
4
- export const mdxModules: Record<string, { default: React.ComponentType }>;
5
- }
3
+ import type { ComponentType } from "react";
4
+ export declare const mdxModules: Record<string, { default: ComponentType }>;
package/.docu/node/mdx.ts CHANGED
@@ -3,7 +3,8 @@ import type { Pluggable } from "unified";
3
3
  import { z, type ZodType } from "zod";
4
4
  import {
5
5
  serialize,
6
- extractTocsFromRawMdx,
6
+ rehypeCollectTocs,
7
+ type TocItem,
7
8
  extractFrontmatterWithContent,
8
9
  createDefaultRehypePlugins,
9
10
  createDefaultRemarkPlugins,
@@ -108,7 +109,7 @@ export interface MdxResult {
108
109
  content: React.ReactElement;
109
110
  compiledSource: string;
110
111
  frontmatter: Frontmatter;
111
- tocs: ReturnType<typeof extractTocsFromRawMdx>;
112
+ tocs: TocItem[];
112
113
  }
113
114
 
114
115
  /**
@@ -186,7 +187,13 @@ async function serializeWithDocPlugins(
186
187
  // transforms see already-fixed hrefs. rehypeDocsHtmlLinks handles plain
187
188
  // markdown [text](path) → <a> elements in the HAST phase.
188
189
  const finalRemark = [...defaultRemark, remarkMdxJsxDocsHtmlLinks, ...(opts.remarkPlugins ?? [])];
189
- const finalRehype = [...defaultRehype, rehypeDocsHtmlLinks, ...(opts.rehypePlugins ?? [])];
190
+ const tocs: TocItem[] = [];
191
+ const finalRehype: Pluggable[] = [
192
+ ...defaultRehype,
193
+ rehypeDocsHtmlLinks,
194
+ ...(opts.rehypePlugins ?? []),
195
+ [rehypeCollectTocs, tocs],
196
+ ];
190
197
 
191
198
  // v2 contract: plain markdown + directives only — authored JSX tags are
192
199
  // not parsed (dropped, content kept as text). Return the frontmatter parsed
@@ -198,7 +205,7 @@ async function serializeWithDocPlugins(
198
205
  rehypePlugins: finalRehype,
199
206
  remarkPlugins: finalRemark,
200
207
  },
201
- }).then((serialized) => ({ ...serialized, frontmatter, strippedContent }));
208
+ }).then((serialized) => ({ ...serialized, frontmatter, strippedContent, tocs }));
202
209
  }
203
210
 
204
211
  /**
@@ -221,7 +228,6 @@ export async function compileMdx(
221
228
  /** Pre-pass extracted data — avoids re-parsing frontmatter in the SSR phase. */
222
229
  pre?: { frontmatter: Frontmatter; strippedContent: string }
223
230
  ): Promise<MdxResult> {
224
- const tocs = extractTocsFromRawMdx(rawMdx);
225
231
  const frontmatter =
226
232
  pre?.frontmatter ??
227
233
  (frontmatterSchema
@@ -252,7 +258,7 @@ export async function compileMdx(
252
258
  content,
253
259
  compiledSource: serialized.compiledSource,
254
260
  frontmatter: { ...frontmatter, date },
255
- tocs,
261
+ tocs: serialized.tocs,
256
262
  };
257
263
  }
258
264
 
@@ -408,6 +408,8 @@ export class BuildPluginBuilder implements PluginBuilder {
408
408
  * Execute the transformHtml chain in pipeline pattern.
409
409
  * Each callback receives the **previous** callback's return value (or the
410
410
  * original HTML for the first). Every callback **must** return a string.
411
+ * Callbacks returning a non-string (e.g. `undefined`) are skipped with a
412
+ * warning — the current HTML passes through unchanged for that step.
411
413
  * Errors inside individual callbacks are caught and logged — the current
412
414
  * HTML passes through unchanged for that step.
413
415
  *
@@ -419,7 +421,14 @@ export class BuildPluginBuilder implements PluginBuilder {
419
421
  let result = html;
420
422
  for (let i = 0; i < this._transformHtml.length; i++) {
421
423
  try {
422
- result = await this._transformHtml[i](result, context);
424
+ const next = await this._transformHtml[i](result, context);
425
+ if (typeof next === "string") {
426
+ result = next;
427
+ } else {
428
+ console.warn(
429
+ `[plugin] transformHtml callback #${i + 1} returned invalid type (expected a string), keeping previous HTML`
430
+ );
431
+ }
423
432
  } catch (err) {
424
433
  console.error(
425
434
  `[plugin] transformHtml callback #${i + 1} error: ${err instanceof Error ? err.message : String(err)}`
@@ -47,6 +47,22 @@ export function getRouteMap(): Map<string, string> {
47
47
  return map;
48
48
  }
49
49
 
50
+ /**
51
+ * Single pagination entry builder (DRY) — one `readPageFrontmatter` call
52
+ * serves both prev + next from the parse-once registry, so no file is
53
+ * re-read or re-parsed. `description` rides along on prev too; the UI
54
+ * keeps the paired prev minimal by design and only renders the rich
55
+ * title + description when prev stands alone (last page, no next).
56
+ */
57
+ function toPaginationEntry(href: string, routeMap: Map<string, string>) {
58
+ const fm = readPageFrontmatter(href);
59
+ return {
60
+ href,
61
+ title: fm.title || routeMap.get(href) || "",
62
+ description: fm.description || "",
63
+ };
64
+ }
65
+
50
66
  /**
51
67
  * Frontmatter for a page — read from the parse-once registry (populated
52
68
  * during compilation) instead of re-reading + re-parsing the file. Falls
@@ -86,15 +102,7 @@ export function getPreviousNext(pathname: string) {
86
102
  const routeMap = getRouteMap();
87
103
  const first = paths[0];
88
104
  if (!first) return { prev: null, next: null };
89
- const fm = readPageFrontmatter(first);
90
- return {
91
- prev: null,
92
- next: {
93
- href: first,
94
- title: fm.title || routeMap.get(first) || "",
95
- description: fm.description || "",
96
- },
97
- };
105
+ return { prev: null, next: toPaginationEntry(first, routeMap) };
98
106
  }
99
107
 
100
108
  const paths = flattenRoutes();
@@ -109,19 +117,9 @@ export function getPreviousNext(pathname: string) {
109
117
  const prevHref = index > 0 ? paths[index - 1] : null;
110
118
  const nextHref = index < paths.length - 1 ? paths[index + 1] : null;
111
119
 
112
- const prevFm = prevHref ? readPageFrontmatter(prevHref) : null;
113
- const nextFm = nextHref ? readPageFrontmatter(nextHref) : null;
114
120
  return {
115
- prev: prevHref
116
- ? { href: prevHref, title: prevFm?.title || routeMap.get(prevHref) || "" }
117
- : null,
118
- next: nextHref
119
- ? {
120
- href: nextHref,
121
- title: nextFm?.title || routeMap.get(nextHref) || "",
122
- description: nextFm?.description || "",
123
- }
124
- : null,
121
+ prev: prevHref ? toPaginationEntry(prevHref, routeMap) : null,
122
+ next: nextHref ? toPaginationEntry(nextHref, routeMap) : null,
125
123
  };
126
124
  }
127
125
 
@@ -39,6 +39,7 @@ export interface SearchRecord {
39
39
  interface Frontmatter {
40
40
  title?: string;
41
41
  description?: string;
42
+ [key: string]: unknown;
42
43
  }
43
44
 
44
45
  function getSectionTitle(filePath: string): string {
@@ -142,15 +143,20 @@ export function extractRecords(filePath: string, raw: string): SearchRecord[] {
142
143
  const level = headingMatch[1].length;
143
144
  const title = headingMatch[2].replace(/[*`[\]]/g, "").trim();
144
145
 
146
+ if (level === 1) {
147
+ // Frontmatter title is authoritative; use H1 only as a fallback.
148
+ for (let i = 2; i <= 6; i++) {
149
+ (hierarchy as Record<string, string | null>)[`lvl${i}`] = null;
150
+ }
151
+ if (!hierarchy.lvl1) hierarchy.lvl1 = title;
152
+ continue;
153
+ }
154
+
145
155
  for (let i = level; i <= 6; i++) {
146
156
  (hierarchy as Record<string, string | null>)[`lvl${i}`] = null;
147
157
  }
148
158
  hierarchy[`lvl${level}` as keyof typeof hierarchy] = title;
149
159
 
150
- if (level === 1 && !hierarchy.lvl1) {
151
- hierarchy.lvl1 = title;
152
- }
153
-
154
160
  if (level >= 2) {
155
161
  records.push({
156
162
  url: `${url}#${slugify(title)}`,
@@ -54,10 +54,16 @@ function levenshtein(a: string, b: string): number {
54
54
  function fuzzyMatch(query: string, text: string): number {
55
55
  const q = query.toLowerCase();
56
56
  const t = text.toLowerCase();
57
+ const words = t.split(/\s+/);
58
+ const shortQuery = q.length <= 3;
57
59
 
58
- if (t.includes(q)) return 1;
60
+ // For short queries, prefer a meaningful word prefix (`seq` → `sequence`)
61
+ // over an isolated exact occurrence in lower-signal text. This keeps search
62
+ // useful without removing substring or typo matching.
63
+ if (words.some((word) => word === q)) return shortQuery ? 0.9 : 1;
64
+ if (words.some((word) => word.startsWith(q))) return 0.95;
65
+ if (t.includes(q)) return shortQuery ? 0.7 : 1;
59
66
 
60
- const words = t.split(/\s+/);
61
67
  let bestScore = 0;
62
68
 
63
69
  for (const word of words) {
@@ -30,6 +30,15 @@ export function cspHeader(nonce: string, allowEval = false): string {
30
30
  ].join("; ");
31
31
  }
32
32
 
33
+ /**
34
+ * Strip `frame-ancestors` for `<meta http-equiv="Content-Security-Policy">`.
35
+ * Browsers ignore `frame-ancestors` in meta CSP (header-only directive)
36
+ * and log a console warning. HTTP header from `cspHeader()` keeps it.
37
+ */
38
+ export function cspMeta(csp: string): string {
39
+ return csp.replace(/;\s*frame-ancestors 'none'/, "");
40
+ }
41
+
33
42
  export function isPathSafe(pathname: string, baseDir: string): boolean {
34
43
  const decoded = decodeURIComponent(pathname);
35
44
  const resolved = resolve(baseDir, decoded.slice(1));
@@ -87,12 +96,20 @@ export function normalizeImporterPath(importer: string): string {
87
96
  }
88
97
 
89
98
  export function injectNonce(html: string, nonce: string): string {
90
- return html.replace(/<script\b(?![^>]*\bsrc\s*=)([^>]*)>/gi, (match) => {
99
+ const scripts = html.replace(/<script\b(?![^>]*\bsrc\s*=)([^>]*)>/gi, (match) => {
91
100
  if (/nonce\s*=/i.test(match)) {
92
101
  return match.replace(/nonce="[^"]*"/i, `nonce="${nonce}"`);
93
102
  }
94
103
  return match.replace(/>$/, ` nonce="${nonce}">`);
95
104
  });
105
+ // Keep the <meta> CSP nonce in sync: browsers intersect the meta policy
106
+ // with the response-header policy, so a stale build-time nonce would block
107
+ // the very scripts re-tagged above.
108
+ return scripts.replace(/<meta\b[^>]*Content-Security-Policy[^>]*>/gi, (tag) =>
109
+ tag.replace(/'nonce-[^']*'|&#(?:x27;|39;)nonce-[^&]*(?:&#(?:x27;|39;))/i, (match) =>
110
+ match.startsWith("&#") ? `&#x27;nonce-${nonce}&#x27;` : `'nonce-${nonce}'`
111
+ )
112
+ );
96
113
  }
97
114
 
98
115
  export interface PluginResponseLike {
@@ -4,7 +4,7 @@ import { readFileSync, statSync } from "node:fs";
4
4
  import React, { type ReactNode } from "react";
5
5
  import { renderToString } from "react-dom/server";
6
6
  import { compileMdx, frontmatterField } from "./mdx";
7
- import { getContentType } from "./utils";
7
+ import { DEFAULT_FAVICON, getContentType } from "./utils";
8
8
  import { DOCS_DIR, DIST_DIR, PROJECT_ROOT } from "./paths";
9
9
  import { BuildPluginBuilder } from "./plugin-builder";
10
10
  import type { PageContext } from "./plugin";
@@ -32,7 +32,7 @@ function createHtmlResponse(
32
32
  depth = 0
33
33
  ): Response {
34
34
  const nonce = generateNonce();
35
- const favicon = state.docuConfig.meta?.favicon || "/favicon.ico";
35
+ const favicon = state.docuConfig.meta?.favicon || DEFAULT_FAVICON;
36
36
  const html = createHtmlShell({
37
37
  title,
38
38
  description,
@@ -187,7 +187,7 @@ async function renderDocsServerPage(
187
187
  const headExtra = state.builder.collectHead(ctx);
188
188
  const bodyExtra = state.builder.collectBody(ctx);
189
189
  const nonce = generateNonce();
190
- const favicon = state.docuConfig.meta?.favicon || "/favicon.ico";
190
+ const favicon = state.docuConfig.meta?.favicon || DEFAULT_FAVICON;
191
191
  let html = createHtmlShell({
192
192
  title,
193
193
  description,
@@ -106,7 +106,7 @@ export interface BuildCacheMeta extends BuildCacheEntry {
106
106
  }
107
107
 
108
108
  export interface BuildCache {
109
- [path: string]: BuildCacheEntry;
109
+ [path: string]: BuildCacheEntry | undefined;
110
110
  __meta__?: BuildCacheMeta;
111
111
  __assets__?: BuildCacheEntry;
112
112
  __bundle__?: BuildCacheEntry;
@@ -1,5 +1,6 @@
1
1
  export { cn, parseDate, formatDate, formatDate2 } from "@docubook/core";
2
2
 
3
+ import { existsSync } from "node:fs";
3
4
  import { readdir, stat } from "node:fs/promises";
4
5
  import { join } from "node:path";
5
6
 
@@ -11,7 +12,8 @@ export interface ScannedMdxFile {
11
12
 
12
13
  /**
13
14
  * Scan a directory recursively for MDX/MD files.
14
- * Skips "assets" directories, hidden directories (dot-prefixed), and root-level index.mdx.
15
+ * Skips "assets" directories, hidden directories (dot-prefixed), and root-level
16
+ * index.mdx/index.md (the docs root renders separately in build).
15
17
  * Shared between build.ts and search-indexer.ts.
16
18
  */
17
19
  export async function scanMdxFiles(dir: string, baseDir = ""): Promise<ScannedMdxFile[]> {
@@ -26,7 +28,9 @@ export async function scanMdxFiles(dir: string, baseDir = ""): Promise<ScannedMd
26
28
  if (entry.name === "assets" || entry.name.startsWith(".")) continue;
27
29
  files.push(...(await scanMdxFiles(fullPath, relativePath)));
28
30
  } else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
29
- if (entry.name === "index.mdx" && !baseDir) continue;
31
+ // Root index renders separately (either extension) — scanning it would
32
+ // produce a duplicate "index" page colliding with dist/docs/index.html.
33
+ if (!baseDir && (entry.name === "index.mdx" || entry.name === "index.md")) continue;
30
34
  const stats = await stat(fullPath);
31
35
  let path = relativePath.replace(/\.(mdx|md)$/, "");
32
36
 
@@ -44,6 +48,22 @@ export function isExternalUrl(url: string): boolean {
44
48
  return /^(https?:\/\/|\/\/)/.test(url);
45
49
  }
46
50
 
51
+ /**
52
+ * Resolve the docs root index source — docs/index.mdx preferred, docs/index.md
53
+ * as fallback (mirrors the dev server's getDocsForSlug extension handling).
54
+ */
55
+ export function resolveDocsIndexSource(docsDir: string): string | undefined {
56
+ for (const ext of [".mdx", ".md"]) {
57
+ const candidate = join(docsDir, `index${ext}`);
58
+ if (existsSync(candidate)) return candidate;
59
+ }
60
+ return undefined;
61
+ }
62
+
63
+ /** Default favicon — resolves in both build output (`docs/assets/` is copied
64
+ * to `dist/docs/assets/`) and dev (served from `docs/assets/` via fallback). */
65
+ export const DEFAULT_FAVICON = "/docs/assets/images/favicon.ico";
66
+
47
67
  /** Suffix an internal docs link with `.html` to match the flat static build output. */
48
68
  export function docsHtmlHref(path: string): string {
49
69
  return `${path}.html`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docubook/flame",
3
- "version": "2.0.0-beta.3",
3
+ "version": "2.0.0-beta.5",
4
4
  "description": "A blazing-fast React + MDX framework powered by Bun, built for modern documentation experiences.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,6 +45,10 @@
45
45
  "author-url": "https://wildan.dev",
46
46
  "license": "MIT",
47
47
  "dependencies": {
48
+ "@docubook/core": "^2.0.0-beta.5",
49
+ "@docubook/markdown": "^2.0.0-beta.5",
50
+ "@docubook/themes-colors": "^2.0.0-beta.5",
51
+ "@docubook/ui-react": "^2.0.0-beta.5",
48
52
  "@mdx-js/react": "^3.0.1",
49
53
  "@tailwindcss/cli": "4.3.0",
50
54
  "@tailwindcss/typography": "0.5.16",
@@ -55,11 +59,7 @@
55
59
  "react-dom": "^19.2.7",
56
60
  "unified": "^11.0.0",
57
61
  "vite": "^8.2.1",
58
- "zod": "^4.4.3",
59
- "@docubook/core": "^2.0.0-beta.3",
60
- "@docubook/markdown": "^2.0.0-beta.3",
61
- "@docubook/themes-colors": "^2.0.0-beta.3",
62
- "@docubook/ui-react": "^2.0.0-beta.3"
62
+ "zod": "^4.4.3"
63
63
  },
64
64
  "peerDependencies": {
65
65
  "@sentry/bun": "^10.0.0"