@docubook/flame 2.0.3 → 2.1.1

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 (72) hide show
  1. package/.docu/components/Context.tsx +6 -5
  2. package/.docu/components/DocsLayout.tsx +7 -4
  3. package/.docu/components/Menu.tsx +10 -9
  4. package/.docu/components/Navbar.tsx +24 -22
  5. package/.docu/components/Pagination.tsx +3 -2
  6. package/.docu/components/Search.tsx +6 -1
  7. package/.docu/components/Sidebar.tsx +22 -21
  8. package/.docu/components/Sublink.tsx +3 -3
  9. package/.docu/lib/build.deno.js +1 -1
  10. package/.docu/lib/build.impl-DxI2YYqL.js +2 -0
  11. package/.docu/lib/{build.impl-BSt6Vikt.js → build.impl-oTVkE6D9.js} +60 -31
  12. package/.docu/lib/build.impl-oTVkE6D9.js.map +1 -0
  13. package/.docu/lib/build.node.js +1 -1
  14. package/.docu/lib/clean.js +1 -1
  15. package/.docu/lib/deploy.deno.js +1 -1
  16. package/.docu/lib/deploy.node.js +1 -1
  17. package/.docu/lib/{deploy.shared-D6S_FE7d.js → deploy.shared-p8UHVvAw.js} +27 -8
  18. package/.docu/lib/deploy.shared-p8UHVvAw.js.map +1 -0
  19. package/.docu/lib/{html.shared-CsEFmy_Y.js → html.shared-RPCM1y5Q.js} +264 -73
  20. package/.docu/lib/html.shared-RPCM1y5Q.js.map +1 -0
  21. package/.docu/lib/{logger-DFKQUy8M.js → logger-NdMEl8u7.js} +3 -3
  22. package/.docu/lib/{logger-DFKQUy8M.js.map → logger-NdMEl8u7.js.map} +1 -1
  23. package/.docu/lib/paths-CHXABfXa.js +332 -0
  24. package/.docu/lib/paths-CHXABfXa.js.map +1 -0
  25. package/.docu/lib/preview.deno.js +1 -1
  26. package/.docu/lib/{preview.impl-BfESMUM7.js → preview.impl-CLHIPgry.js} +4 -4
  27. package/.docu/lib/preview.impl-CLHIPgry.js.map +1 -0
  28. package/.docu/lib/preview.node.js +1 -1
  29. package/.docu/lib/{utils-B_CoyKie.js → server-utils-BYWB7A6T.js} +3 -21
  30. package/.docu/lib/server-utils-BYWB7A6T.js.map +1 -0
  31. package/.docu/lib/server.deno.js +1 -1
  32. package/.docu/lib/{server.impl-CZ4eMe5L.js → server.impl-C4t3Xqb5.js} +24 -19
  33. package/.docu/lib/server.impl-C4t3Xqb5.js.map +1 -0
  34. package/.docu/lib/server.node.js +1 -1
  35. package/.docu/node/base-path.ts +306 -0
  36. package/.docu/node/build.impl.ts +50 -17
  37. package/.docu/node/build.ts +50 -17
  38. package/.docu/node/cache-key.ts +92 -4
  39. package/.docu/node/client-routes.ts +16 -1
  40. package/.docu/node/deploy.shared.ts +51 -15
  41. package/.docu/node/deploy.ts +7 -3
  42. package/.docu/node/html.shared.ts +33 -3
  43. package/.docu/node/html.ts +18 -3
  44. package/.docu/node/hydrate.node.ts +6 -0
  45. package/.docu/node/hydrate.ts +16 -1
  46. package/.docu/node/mdx.ts +102 -23
  47. package/.docu/node/paths.ts +115 -0
  48. package/.docu/node/preview.impl.ts +1 -1
  49. package/.docu/node/preview.ts +1 -1
  50. package/.docu/node/search-indexer.ts +20 -3
  51. package/.docu/node/seo.ts +41 -7
  52. package/.docu/node/server-routes.ts +34 -11
  53. package/.docu/node/server-utils.ts +152 -0
  54. package/.docu/node/server.impl.ts +22 -12
  55. package/.docu/node/server.ts +23 -27
  56. package/.docu/node/types.ts +13 -0
  57. package/.docu/node/utils.ts +56 -149
  58. package/.docu/pages/404.tsx +2 -1
  59. package/.docu/pages/index.tsx +6 -13
  60. package/bin/cli.js +17 -21
  61. package/docu.schema.json +10 -1
  62. package/package.json +5 -5
  63. package/template/docu.json +1 -0
  64. package/.docu/lib/build.impl-BSt6Vikt.js.map +0 -1
  65. package/.docu/lib/build.impl-xNDEh8DJ.js +0 -2
  66. package/.docu/lib/deploy.shared-D6S_FE7d.js.map +0 -1
  67. package/.docu/lib/html.shared-CsEFmy_Y.js.map +0 -1
  68. package/.docu/lib/paths-Cbz-dj4s.js +0 -72
  69. package/.docu/lib/paths-Cbz-dj4s.js.map +0 -1
  70. package/.docu/lib/preview.impl-BfESMUM7.js.map +0 -1
  71. package/.docu/lib/server.impl-CZ4eMe5L.js.map +0 -1
  72. package/.docu/lib/utils-B_CoyKie.js.map +0 -1
@@ -4,8 +4,9 @@ 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 { DEFAULT_FAVICON, getContentType } from "./utils";
8
- import { DOCS_DIR, DIST_DIR, PAGES_DIR, PROJECT_ROOT } from "./paths";
7
+ import { getContentType } from "./server-utils";
8
+ import { defaultFavicon } from "./paths";
9
+ import { DOCS_DIR, DIST_DIR, PAGES_DIR, PROJECT_ROOT, servedBasePath, docsDepth } from "./paths";
9
10
  import { BuildPluginBuilder } from "./plugin-builder";
10
11
  import type { PageContext, PageType } from "./plugin";
11
12
  import type { AssetEntry, AssetManifest, DocuConfig, TocItem } from "./types";
@@ -34,7 +35,7 @@ async function createHtmlResponse(
34
35
  depth = 0
35
36
  ): Promise<Response> {
36
37
  const nonce = generateNonce();
37
- const favicon = state.docuConfig.meta?.favicon || DEFAULT_FAVICON;
38
+ const favicon = state.docuConfig.meta?.favicon || defaultFavicon();
38
39
  const assets: AssetEntry = state.assetManifest[pageType];
39
40
  const context: PageContext = {
40
41
  ...page,
@@ -55,6 +56,10 @@ async function createHtmlResponse(
55
56
  headExtra: state.builder?.collectHead(context),
56
57
  bodyExtra: state.builder?.collectBody(context),
57
58
  depth,
59
+ basePath: servedBasePath(),
60
+ // Dev serves the project from the origin root, so the host's deployment
61
+ // path never applies here — only the static build writes it into HTML.
62
+ deployPath: "",
58
63
  // 404 pages can be requested at arbitrary depths (e.g. a noLink section
59
64
  // path typed in the address bar) — relative asset paths would resolve
60
65
  // against the wrong directory and break CSS/JS.
@@ -185,8 +190,8 @@ async function renderDocsServerPage(
185
190
 
186
191
  const body = renderToString(page);
187
192
 
188
- // Match build.ts depth calculation: slug.split("/").length, fallback to 1 for empty
189
- const depth = slug.length || 1;
193
+ // Same relative climb as the static build — see docsDepth.
194
+ const depth = docsDepth(slug.join("/"), servedBasePath());
190
195
 
191
196
  return createHtmlResponse(
192
197
  title,
@@ -233,16 +238,33 @@ async function renderPage(
233
238
 
234
239
  export async function handleDocsIndex(state: ServerState): Promise<Response> {
235
240
  const doc = await getDocsForSlug("", state);
236
- if (!doc) return renderPage(NotFoundPage, "404 - Not Found", "", 404, state, {}, 1);
237
- return renderDocsServerPage(doc, [], "/docs", state);
241
+ if (!doc)
242
+ return renderPage(
243
+ NotFoundPage,
244
+ "404 - Not Found",
245
+ "",
246
+ 404,
247
+ state,
248
+ {},
249
+ docsDepth("", servedBasePath())
250
+ );
251
+ return renderDocsServerPage(doc, [], servedBasePath() || "/", state);
238
252
  }
239
253
 
240
254
  export async function handleDocsRoute(slug: string[], state: ServerState): Promise<Response> {
241
255
  const path = slug.join("/");
242
256
  const doc = await getDocsForSlug(path, state);
243
257
  if (!doc)
244
- return renderPage(NotFoundPage, "404 - Not Found", "", 404, state, {}, slug.length || 1);
245
- return renderDocsServerPage(doc, slug, `/docs/${path}`, state);
258
+ return renderPage(
259
+ NotFoundPage,
260
+ "404 - Not Found",
261
+ "",
262
+ 404,
263
+ state,
264
+ {},
265
+ docsDepth(path, servedBasePath())
266
+ );
267
+ return renderDocsServerPage(doc, slug, `${servedBasePath()}/${path}`, state);
246
268
  }
247
269
 
248
270
  export async function handleIndex(state: ServerState): Promise<Response> {
@@ -287,9 +309,10 @@ export function serveStatic(pathname: string): Response | null {
287
309
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
288
310
  }
289
311
 
290
- if (decoded.startsWith("/docs/assets/")) {
312
+ const docsAssetsPrefix = `${servedBasePath()}/assets/`;
313
+ if (decoded.startsWith(docsAssetsPrefix)) {
291
314
  const docsAssetsDir = resolve(DOCS_DIR, "assets");
292
- const requestedRelative = decoded.slice("/docs/assets/".length);
315
+ const requestedRelative = decoded.slice(docsAssetsPrefix.length);
293
316
  const docsAsset = resolve(docsAssetsDir, requestedRelative);
294
317
  const docsAssetsDirWithSep = docsAssetsDir.endsWith("/") ? docsAssetsDir : docsAssetsDir + "/";
295
318
  if (docsAsset !== docsAssetsDir && !docsAsset.startsWith(docsAssetsDirWithSep)) return null;
@@ -0,0 +1,152 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Server-only helpers that touch the filesystem, git, or `node:path`.
7
+ *
8
+ * Kept OUT of `./utils` (which is bundled into the browser): Bun's bundler
9
+ * injects a full `node:path` polyfill into browser builds for any module that
10
+ * imports it, even when the calls are never reached at runtime. Everything in
11
+ * this file is imported exclusively by server-side modules (build, dev server,
12
+ * search indexer) and their tests.
13
+ */
14
+
15
+ export interface ScannedMdxFile {
16
+ path: string;
17
+ absPath: string;
18
+ mtime: number;
19
+ }
20
+
21
+ /**
22
+ * Scan a directory recursively for MDX/MD files.
23
+ * Skips "assets" directories, hidden directories (dot-prefixed), and root-level
24
+ * index.mdx/index.md (the docs root renders separately in build).
25
+ * Shared between build.ts and search-indexer.ts.
26
+ */
27
+ export async function scanMdxFiles(dir: string, baseDir = ""): Promise<ScannedMdxFile[]> {
28
+ const files: ScannedMdxFile[] = [];
29
+ const entries = await readdir(dir, { withFileTypes: true });
30
+
31
+ for (const entry of entries) {
32
+ const fullPath = join(dir, entry.name);
33
+ const relativePath = baseDir ? `${baseDir}/${entry.name}` : entry.name;
34
+
35
+ if (entry.isDirectory()) {
36
+ if (entry.name === "assets" || entry.name.startsWith(".")) continue;
37
+ files.push(...(await scanMdxFiles(fullPath, relativePath)));
38
+ } else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
39
+ // Root index renders separately (either extension) — scanning it would
40
+ // produce a duplicate "index" page colliding with dist/docs/index.html.
41
+ if (!baseDir && (entry.name === "index.mdx" || entry.name === "index.md")) continue;
42
+ const stats = await stat(fullPath);
43
+ let path = relativePath.replace(/\.(mdx|md)$/, "");
44
+
45
+ if (/\/index$/.test(path)) {
46
+ path = path.replace(/\/index$/, "");
47
+ }
48
+ files.push({ path, absPath: fullPath, mtime: stats.mtimeMs });
49
+ }
50
+ }
51
+
52
+ return files;
53
+ }
54
+
55
+ /**
56
+ * Resolve the docs root index source — docs/index.mdx preferred, docs/index.md
57
+ * as fallback (mirrors the dev server's getDocsForSlug extension handling).
58
+ */
59
+ export function resolveDocsIndexSource(docsDir: string): string | undefined {
60
+ for (const ext of [".mdx", ".md"]) {
61
+ const candidate = join(docsDir, `index${ext}`);
62
+ if (existsSync(candidate)) return candidate;
63
+ }
64
+ return undefined;
65
+ }
66
+
67
+ /** Get git last modified date for a file */
68
+ export async function getGitLastModified(filePath: string): Promise<string | null> {
69
+ try {
70
+ const cleanPath = filePath.replace(/^\//, "");
71
+ if (
72
+ !cleanPath ||
73
+ !/^[a-zA-Z0-9\-_/.\s]+$/.test(cleanPath) ||
74
+ /(^|\/)\.\.($|\/)/.test(cleanPath)
75
+ )
76
+ return null;
77
+ const proc = Bun.spawn(["git", "log", "-1", "--format=%cI", "--", cleanPath], {
78
+ stderr: "ignore",
79
+ });
80
+ const text = await new Response(proc.stdout).text();
81
+ const date = text.trim();
82
+ return date || null;
83
+ } catch (err) {
84
+ console.error("Failed to get git last modified for", filePath, err);
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /** Batch git last modified dates for multiple files in a single spawn */
90
+ export async function getGitLastModifiedBatch(filePaths: string[]): Promise<Map<string, string>> {
91
+ const result = new Map<string, string>();
92
+ if (filePaths.length === 0) return result;
93
+
94
+ // Filter and validate paths — same guard as getGitLastModified
95
+ const safePaths: string[] = [];
96
+ for (const fp of filePaths) {
97
+ const cleanPath = fp.replace(/^\//, "");
98
+ if (
99
+ !cleanPath ||
100
+ !/^[a-zA-Z0-9\-_/.\s]+$/.test(cleanPath) ||
101
+ /(^|\/)\.\.($|\/)/.test(cleanPath)
102
+ ) {
103
+ console.warn(`[server-utils] getGitLastModifiedBatch: skipping invalid path "${fp}"`);
104
+ continue;
105
+ }
106
+ safePaths.push(cleanPath);
107
+ }
108
+
109
+ if (safePaths.length === 0) return result;
110
+
111
+ try {
112
+ const proc = Bun.spawn(
113
+ ["git", "log", "--format=%cI", "--name-only", "--diff-filter=ACMR", ...safePaths],
114
+ { stderr: "ignore" }
115
+ );
116
+ const text = await new Response(proc.stdout).text();
117
+ let currentDate = "";
118
+
119
+ for (const line of text.split("\n")) {
120
+ const trimmed = line.trim();
121
+ if (!trimmed) continue;
122
+ if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
123
+ currentDate = trimmed;
124
+ } else if (currentDate && !result.has(trimmed)) {
125
+ result.set(trimmed, currentDate);
126
+ }
127
+ }
128
+ } catch (err) {
129
+ console.error("Failed to get git last modified batch for", filePaths, err);
130
+ }
131
+
132
+ return result;
133
+ }
134
+
135
+ const MIME_TYPES: Record<string, string> = {
136
+ html: "text/html",
137
+ css: "text/css",
138
+ js: "application/javascript",
139
+ json: "application/json",
140
+ png: "image/png",
141
+ jpg: "image/jpeg",
142
+ jpeg: "image/jpeg",
143
+ svg: "image/svg+xml",
144
+ ico: "image/x-icon",
145
+ woff: "font/woff",
146
+ woff2: "font/woff2",
147
+ };
148
+
149
+ export function getContentType(pathname: string): string {
150
+ const ext = pathname.split(".").pop()?.toLowerCase();
151
+ return MIME_TYPES[ext || ""] || "application/octet-stream";
152
+ }
@@ -9,7 +9,7 @@ import { watch, existsSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { createRequire } from "node:module";
11
11
  import type { RuntimeAdapter, ServerHandle } from "./runtime";
12
- import { DOCS_DIR, loadDocuConfig } from "./paths";
12
+ import { DOCS_DIR, loadDocuConfig, resolveBasePath, assertValidBasePath } from "./paths";
13
13
  import { loadPlugins } from "./plugin-loader";
14
14
  import { BuildPluginBuilder } from "./plugin-builder";
15
15
  import { buildClientBundle, computeInlineThemeCss } from "./hydrate.node";
@@ -26,10 +26,15 @@ import {
26
26
  type ServerState,
27
27
  } from "./server-routes";
28
28
  import { wrapPluginResponse } from "./security";
29
- import { stripDocsHtmlSuffix } from "./utils";
29
+ import { matchDocsSlug, stripDocsHtmlSuffix } from "./utils";
30
30
 
31
31
  export async function runServer(adapter: RuntimeAdapter): Promise<ServerHandle> {
32
32
  const docuConfig = loadDocuConfig();
33
+ const resolvedBasePath = resolveBasePath(docuConfig);
34
+
35
+ // Report normalized prefixes (and stop on a value that cannot be honored)
36
+ // before the server announces a site that would be built somewhere else.
37
+ for (const warning of assertValidBasePath()) logger.warn(warning.message);
33
38
 
34
39
  const parsedPort = parseInt(process.env.PORT ?? "3000", 10);
35
40
  const PORT =
@@ -149,7 +154,7 @@ export async function runServer(adapter: RuntimeAdapter): Promise<ServerHandle>
149
154
  const url = new URL(req.url);
150
155
  // Generated links carry `.html` (matching the static build output);
151
156
  // route them to the same handler as their extensionless form.
152
- const pathname = stripDocsHtmlSuffix(url.pathname);
157
+ const pathname = stripDocsHtmlSuffix(url.pathname, resolvedBasePath);
153
158
  const startTime = performance.now();
154
159
 
155
160
  if (builder) {
@@ -197,18 +202,23 @@ export async function runServer(adapter: RuntimeAdapter): Promise<ServerHandle>
197
202
 
198
203
  let response: Response;
199
204
 
200
- // Manual route matching — same routes Bun.FileSystemRouter derives
201
- // from `.docu/pages/` ("/", "/docs/[[...slug]]", "/404").
205
+ // The landing page owns `/` in every deployment. At the deployment root
206
+ // (`meta.basePath: ""`) docs would otherwise claim it, but the build
207
+ // skips the docs index there — so dev must not serve it either.
208
+ // Static assets were already handled above, so a root deployment may
209
+ // still match every other path as a docs route.
202
210
  if (pathname === "/") {
203
211
  response = await handleIndex(state);
204
- } else if (pathname === "/docs" || pathname === "/docs/") {
205
- response = await handleDocsIndex(state);
206
- } else if (pathname.startsWith("/docs/")) {
207
- const slug = pathname.slice("/docs/".length).split("/").filter(Boolean);
208
- response =
209
- slug.length === 0 ? await handleDocsIndex(state) : await handleDocsRoute(slug, state);
210
212
  } else {
211
- response = await handleNotFound(state);
213
+ const docsSlug = matchDocsSlug(pathname, resolvedBasePath);
214
+ if (docsSlug) {
215
+ response =
216
+ docsSlug.length === 0
217
+ ? await handleDocsIndex(state)
218
+ : await handleDocsRoute(docsSlug, state);
219
+ } else {
220
+ response = await handleNotFound(state);
221
+ }
212
222
  }
213
223
 
214
224
  logger.request(
@@ -1,7 +1,7 @@
1
1
  import { watch, existsSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { createRequire } from "node:module";
4
- import { DOCS_DIR, PAGES_DIR, loadDocuConfig } from "./paths";
4
+ import { DOCS_DIR, loadDocuConfig, resolveBasePath, assertValidBasePath } from "./paths";
5
5
  import { loadPlugins } from "./plugin-loader";
6
6
  import { BuildPluginBuilder } from "./plugin-builder";
7
7
  import { buildClientBundle, computeInlineThemeCss } from "./hydrate";
@@ -18,9 +18,14 @@ import {
18
18
  type ServerState,
19
19
  } from "./server-routes";
20
20
  import { wrapPluginResponse } from "./security";
21
- import { stripDocsHtmlSuffix } from "./utils";
21
+ import { matchDocsSlug, stripDocsHtmlSuffix } from "./utils";
22
22
 
23
23
  const docuConfig = loadDocuConfig();
24
+ const resolvedBasePath = resolveBasePath(docuConfig);
25
+
26
+ // Report normalized prefixes (and stop on a value that cannot be honored)
27
+ // before the server announces a site that would be built somewhere else.
28
+ for (const warning of assertValidBasePath()) logger.warn(warning.message);
24
29
 
25
30
  const parsedPort = parseInt(process.env.PORT ?? "3000", 10);
26
31
  const PORT =
@@ -64,16 +69,6 @@ const state: ServerState = {
64
69
  builder,
65
70
  };
66
71
 
67
- let router: InstanceType<typeof Bun.FileSystemRouter> | null = null;
68
- try {
69
- router = new Bun.FileSystemRouter({
70
- style: "nextjs",
71
- dir: PAGES_DIR,
72
- });
73
- } catch (e) {
74
- logger.warn(`FileSystemRouter failed: ${e instanceof Error ? e.message : String(e)}`);
75
- }
76
-
77
72
  const hmrClients = new Set<ReadableStreamDefaultController>();
78
73
 
79
74
  const require = createRequire(import.meta.url);
@@ -151,7 +146,7 @@ const server = Bun.serve({
151
146
  const url = new URL(req.url);
152
147
  // Generated links carry `.html` (matching the static build output);
153
148
  // route them to the same handler as their extensionless form.
154
- const pathname = stripDocsHtmlSuffix(url.pathname);
149
+ const pathname = stripDocsHtmlSuffix(url.pathname, resolvedBasePath);
155
150
  const startTime = performance.now();
156
151
 
157
152
  if (builder) {
@@ -199,24 +194,25 @@ const server = Bun.serve({
199
194
  if (staticRes) return staticRes;
200
195
  }
201
196
 
202
- const match = router?.match(pathname);
203
197
  let response: Response;
204
198
 
205
- const routeName = match?.name;
206
-
207
- if (routeName === "/docs/[[...slug]]") {
208
- const slugParam = match?.params?.slug;
209
- const slug = slugParam ? slugParam.split("/") : [];
210
-
211
- if (slug.length === 0) {
212
- response = await handleDocsIndex(state);
213
- } else {
214
- response = await handleDocsRoute(slug, state);
215
- }
216
- } else if (routeName === "/") {
199
+ // The landing page owns `/` in every deployment. At the deployment root
200
+ // (`meta.basePath: ""`) docs would otherwise claim it, but the build
201
+ // skips the docs index there — so dev must not serve it either.
202
+ // Static assets were already handled above, so a root deployment may
203
+ // still match every other path as a docs route.
204
+ if (pathname === "/") {
217
205
  response = await handleIndex(state);
218
206
  } else {
219
- response = await handleNotFound(state);
207
+ const docsSlug = matchDocsSlug(pathname, resolvedBasePath);
208
+ if (docsSlug) {
209
+ response =
210
+ docsSlug.length === 0
211
+ ? await handleDocsIndex(state)
212
+ : await handleDocsRoute(docsSlug, state);
213
+ } else {
214
+ response = await handleNotFound(state);
215
+ }
220
216
  }
221
217
 
222
218
  logger.request(
@@ -18,7 +18,15 @@ export interface DocuRoute {
18
18
  export interface DocuMeta {
19
19
  title: string;
20
20
  description: string;
21
+ /** Origin + deployment root, e.g. `https://docubook.pro` or `https://user.github.io`. */
21
22
  baseURL: string;
23
+ /**
24
+ * URL prefix the docs site is served under, e.g. "/docs" (default) or
25
+ * "/repo" for a GitHub Pages project site. An empty string serves the site
26
+ * from the deployment root. Set it independently of `baseURL`: the prefix is
27
+ * appended to `baseURL` when building canonical/OG URLs.
28
+ */
29
+ basePath?: string;
22
30
  favicon?: string;
23
31
  /** Default OG image path (e.g. /docs/assets/images/og.png). Used when page frontmatter has no image. */
24
32
  ogImage?: string;
@@ -114,6 +122,11 @@ export interface BuildCacheEntry {
114
122
  export interface BuildCacheMeta extends BuildCacheEntry {
115
123
  version: number;
116
124
  runtime: string;
125
+ /**
126
+ * Fingerprint of the framework's rendering sources. Optional so a cache file
127
+ * written before this field existed is treated as a mismatch and discarded.
128
+ */
129
+ render?: string;
117
130
  }
118
131
 
119
132
  export interface BuildCache {
@@ -1,175 +1,82 @@
1
1
  export { cn, parseDate, formatDate, formatDate2 } from "@docubook/core";
2
+ export {
3
+ withBasePath,
4
+ assetHref,
5
+ applyBasePath,
6
+ rebaseContentPath,
7
+ stripDocsHtmlSuffix,
8
+ matchDocsSlug,
9
+ } from "./base-path";
2
10
 
3
- import { existsSync } from "node:fs";
4
- import { readdir, stat } from "node:fs/promises";
5
- import { join } from "node:path";
6
-
7
- export interface ScannedMdxFile {
8
- path: string;
9
- absPath: string;
10
- mtime: number;
11
- }
11
+ import { DEFAULT_BASE_PATH, isDocsPath, rebaseContentPath } from "./base-path";
12
12
 
13
13
  /**
14
- * Scan a directory recursively for MDX/MD files.
15
- * Skips "assets" directories, hidden directories (dot-prefixed), and root-level
16
- * index.mdx/index.md (the docs root renders separately in build).
17
- * Shared between build.ts and search-indexer.ts.
14
+ * Client-safe utilities shared by SSR and the browser bundle.
15
+ *
16
+ * This module MUST NOT import Node built-ins (`node:fs`, `node:path`, ...):
17
+ * Bun's bundler injects a `node:path` polyfill into browser builds for any
18
+ * bundled module that imports it. Filesystem/git helpers live in
19
+ * `./server-utils` (server-only).
18
20
  */
19
- export async function scanMdxFiles(dir: string, baseDir = ""): Promise<ScannedMdxFile[]> {
20
- const files: ScannedMdxFile[] = [];
21
- const entries = await readdir(dir, { withFileTypes: true });
22
-
23
- for (const entry of entries) {
24
- const fullPath = join(dir, entry.name);
25
- const relativePath = baseDir ? `${baseDir}/${entry.name}` : entry.name;
26
-
27
- if (entry.isDirectory()) {
28
- if (entry.name === "assets" || entry.name.startsWith(".")) continue;
29
- files.push(...(await scanMdxFiles(fullPath, relativePath)));
30
- } else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
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;
34
- const stats = await stat(fullPath);
35
- let path = relativePath.replace(/\.(mdx|md)$/, "");
36
-
37
- if (/\/index$/.test(path)) {
38
- path = path.replace(/\/index$/, "");
39
- }
40
- files.push({ path, absPath: fullPath, mtime: stats.mtimeMs });
41
- }
42
- }
43
-
44
- return files;
45
- }
46
21
 
47
22
  export function isExternalUrl(url: string): boolean {
48
23
  return /^(https?:\/\/|\/\/)/.test(url);
49
24
  }
50
25
 
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
-
67
26
  /** Suffix an internal docs link with `.html` to match the flat static build output. */
68
27
  export function docsHtmlHref(path: string): string {
69
28
  return `${path}.html`;
70
29
  }
71
30
 
72
- /** Map a `/docs{.html,/*.html}` request back to its extensionless route (dev server).
73
- * Handles both `/docs/*.html` (pages) and `/docs.html` (edge case). */
74
- export function stripDocsHtmlSuffix(pathname: string): string {
75
- if (pathname === "/docs.html") return "/docs";
76
- if (pathname.startsWith("/docs/") && pathname.endsWith(".html"))
77
- return pathname.slice(0, -".html".length);
78
- return pathname;
79
- }
31
+ /**
32
+ * Resolve an authored nav/menu link for the current deployment.
33
+ *
34
+ * Docs pages ship as flat `.html` files, so `/docs/guide/routing` has to request
35
+ * `/docs/guide/routing.html`, while the docs root itself stays a directory index
36
+ * and keeps no suffix. Authored `/docs/...` paths are re-based onto the
37
+ * configured prefix, so one `docu.json` keeps working at a custom subpath
38
+ * (`/repo`) or at a root deployment (`""`). External URLs, hash targets and
39
+ * paths that already name a file pass through untouched.
40
+ */
41
+ export function docsNavHref(link: string, resolvedBasePath: string = DEFAULT_BASE_PATH): string {
42
+ if (isExternalUrl(link) || !link.startsWith("/")) return link;
80
43
 
81
- export function getPath(url: string): string {
82
- try {
83
- return new URL(url).pathname;
84
- } catch (err) {
85
- console.error("Failed to parse URL", url, err);
86
- return url;
87
- }
88
- }
44
+ const cut = link.search(/[?#]/);
45
+ const suffix = cut === -1 ? "" : link.slice(cut);
46
+ const pathname = (cut === -1 ? link : link.slice(0, cut)).replace(/\/+$/, "") || "/";
89
47
 
90
- /** Get git last modified date for a file */
91
- export async function getGitLastModified(filePath: string): Promise<string | null> {
92
- try {
93
- const cleanPath = filePath.replace(/^\//, "");
94
- if (
95
- !cleanPath ||
96
- !/^[a-zA-Z0-9\-_/.\s]+$/.test(cleanPath) ||
97
- /(^|\/)\.\.($|\/)/.test(cleanPath)
98
- )
99
- return null;
100
- const proc = Bun.spawn(["git", "log", "-1", "--format=%cI", "--", cleanPath], {
101
- stderr: "ignore",
102
- });
103
- const text = await new Response(proc.stdout).text();
104
- const date = text.trim();
105
- return date || null;
106
- } catch (err) {
107
- console.error("Failed to get git last modified for", filePath, err);
108
- return null;
109
- }
110
- }
48
+ // Sibling routes on the same origin are not docs pages — leave them verbatim.
49
+ if (!isDocsPath(pathname, resolvedBasePath)) return link;
111
50
 
112
- /** Batch git last modified dates for multiple files in a single spawn */
113
- export async function getGitLastModifiedBatch(filePaths: string[]): Promise<Map<string, string>> {
114
- const result = new Map<string, string>();
115
- if (filePaths.length === 0) return result;
51
+ const rebased = rebaseContentPath(pathname, resolvedBasePath);
116
52
 
117
- // Filter and validate paths — same guard as getGitLastModified
118
- const safePaths: string[] = [];
119
- for (const fp of filePaths) {
120
- const cleanPath = fp.replace(/^\//, "");
121
- if (
122
- !cleanPath ||
123
- !/^[a-zA-Z0-9\-_/.\s]+$/.test(cleanPath) ||
124
- /(^|\/)\.\.($|\/)/.test(cleanPath)
125
- ) {
126
- console.warn(`[utils] getGitLastModifiedBatch: skipping invalid path "${fp}"`);
127
- continue;
128
- }
129
- safePaths.push(cleanPath);
53
+ // The docs root is a directory index, and anything with an extension is
54
+ // already a file — neither takes the `.html` suffix.
55
+ if (rebased === "/" || rebased === resolvedBasePath || /\.[a-z0-9]+$/i.test(rebased)) {
56
+ return `${rebased}${suffix}`;
130
57
  }
58
+ return `${rebased}.html${suffix}`;
59
+ }
131
60
 
132
- if (safePaths.length === 0) return result;
61
+ /**
62
+ * Does `pathname` refer to a link returned by {@link docsNavHref}?
63
+ *
64
+ * Nav links reach the browser in two forms: the generated `.html` URL and the
65
+ * extensionless route a visitor may have typed (the dev server maps both), so
66
+ * active states have to accept the pair.
67
+ */
68
+ export function docsNavActive(pathname: string, href: string): boolean {
69
+ const extensionless = href.endsWith(".html") ? href.slice(0, -".html".length) : href;
70
+ return (
71
+ pathname === href || pathname === extensionless || pathname.startsWith(`${extensionless}/`)
72
+ );
73
+ }
133
74
 
75
+ export function getPath(url: string): string {
134
76
  try {
135
- const proc = Bun.spawn(
136
- ["git", "log", "--format=%cI", "--name-only", "--diff-filter=ACMR", ...safePaths],
137
- { stderr: "ignore" }
138
- );
139
- const text = await new Response(proc.stdout).text();
140
- let currentDate = "";
141
-
142
- for (const line of text.split("\n")) {
143
- const trimmed = line.trim();
144
- if (!trimmed) continue;
145
- if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
146
- currentDate = trimmed;
147
- } else if (currentDate && !result.has(trimmed)) {
148
- result.set(trimmed, currentDate);
149
- }
150
- }
77
+ return new URL(url).pathname;
151
78
  } catch (err) {
152
- console.error("Failed to get git last modified batch for", filePaths, err);
79
+ console.error("Failed to parse URL", url, err);
80
+ return url;
153
81
  }
154
-
155
- return result;
156
- }
157
-
158
- const MIME_TYPES: Record<string, string> = {
159
- html: "text/html",
160
- css: "text/css",
161
- js: "application/javascript",
162
- json: "application/json",
163
- png: "image/png",
164
- jpg: "image/jpeg",
165
- jpeg: "image/jpeg",
166
- svg: "image/svg+xml",
167
- ico: "image/x-icon",
168
- woff: "font/woff",
169
- woff2: "font/woff2",
170
- };
171
-
172
- export function getContentType(pathname: string): string {
173
- const ext = pathname.split(".").pop()?.toLowerCase();
174
- return MIME_TYPES[ext || ""] || "application/octet-stream";
175
82
  }