@docubook/flame 1.2.0 → 1.3.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.
@@ -0,0 +1,266 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve, join } from "node:path";
3
+ import { statSync } from "node:fs";
4
+ import React, { type ReactNode } from "react";
5
+ import { renderToString } from "react-dom/server";
6
+ import { compileMdx } from "./mdx";
7
+ import { getContentType } from "./utils";
8
+ import { DOCS_DIR, DIST_DIR, PROJECT_ROOT } from "./paths";
9
+ import { BuildPluginBuilder } from "./plugin-builder";
10
+ import type { PageContext } from "./plugin";
11
+ import type { DocuConfig, TocItem } from "./types";
12
+ import DocsPage from "../pages/docs/[[...slug]]";
13
+ import NotFoundPage from "../pages/404";
14
+ import IndexPage from "../pages/index";
15
+ import { DocsLayout } from "../components/DocsLayout";
16
+ import { generateNonce, isPathSafe, isSlugSafe, htmlResponse, SECURITY_HEADERS } from "./security";
17
+ import { htmlShell as createHtmlShell, hmrScript, errorHtml } from "./html";
18
+
19
+ export interface ServerState {
20
+ docuConfig: DocuConfig;
21
+ assetManifest: { css: string; js: string };
22
+ inlineThemeCss: string;
23
+ builder: BuildPluginBuilder | null;
24
+ }
25
+
26
+ function createHtmlResponse(
27
+ title: string,
28
+ description: string,
29
+ body: string,
30
+ status: number,
31
+ state: ServerState
32
+ ): Response {
33
+ const nonce = generateNonce();
34
+ const favicon = state.docuConfig.meta?.favicon || "/favicon.ico";
35
+ const html = createHtmlShell({
36
+ title,
37
+ description,
38
+ body,
39
+ favicon,
40
+ css: state.assetManifest.css,
41
+ js: state.assetManifest.js,
42
+ nonce,
43
+ extraScripts: hmrScript(nonce),
44
+ themeCss: state.inlineThemeCss,
45
+ });
46
+ return htmlResponse(html, nonce, status, process.env.NODE_ENV !== "production");
47
+ }
48
+
49
+ async function getDocsForSlug(
50
+ slug: string,
51
+ state: ServerState
52
+ ): Promise<{
53
+ content: ReactNode;
54
+ compiledSource: string;
55
+ frontmatter: Record<string, unknown>;
56
+ tocs: TocItem[];
57
+ filePath: string;
58
+ resolvedContent: string;
59
+ } | null> {
60
+ if (!isSlugSafe(slug, DOCS_DIR)) return null;
61
+
62
+ const paths = [
63
+ join(DOCS_DIR, slug, "index.mdx"),
64
+ join(DOCS_DIR, `${slug}.mdx`),
65
+ join(DOCS_DIR, slug, "index.md"),
66
+ join(DOCS_DIR, `${slug}.md`),
67
+ ];
68
+
69
+ let filePath: string | null = null;
70
+ let raw: string | null = null;
71
+ for (const p of paths) {
72
+ if (!p.startsWith(DOCS_DIR)) continue;
73
+ try {
74
+ raw = await readFile(p, "utf-8");
75
+ filePath = p;
76
+ break;
77
+ } catch (err) {
78
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
79
+ }
80
+ }
81
+ if (!filePath || !raw) return null;
82
+
83
+ const relPath = filePath.replace(PROJECT_ROOT + "/", "");
84
+
85
+ let content = raw;
86
+ if (state.builder) {
87
+ const transformed = await state.builder.runOnLoad(relPath, content);
88
+ if (transformed?.contents) {
89
+ content = transformed.contents;
90
+ }
91
+ }
92
+
93
+ const remarkPlugins = state.builder?.collectRemarkPlugins();
94
+ const rehypePlugins = state.builder?.collectRehypePlugins();
95
+ const result = await compileMdx(content, relPath, undefined, remarkPlugins, rehypePlugins);
96
+
97
+ let frontmatter = result.frontmatter as Record<string, unknown>;
98
+ if (state.builder) {
99
+ frontmatter = await state.builder.runTransformFrontmatterChain(frontmatter, {
100
+ slug: slug || "/",
101
+ filePath: relPath,
102
+ content,
103
+ });
104
+ }
105
+
106
+ return {
107
+ content: result.content,
108
+ compiledSource: result.compiledSource,
109
+ frontmatter,
110
+ tocs: result.tocs,
111
+ filePath: relPath,
112
+ resolvedContent: content,
113
+ };
114
+ }
115
+
116
+ async function renderDocsServerPage(
117
+ doc: NonNullable<Awaited<ReturnType<typeof getDocsForSlug>>>,
118
+ slug: string[],
119
+ pathname: string,
120
+ state: ServerState
121
+ ): Promise<Response> {
122
+ const title = (doc.frontmatter.title as string) || slug.join("/") || "Docs";
123
+ const description = (doc.frontmatter.description as string) || "";
124
+
125
+ const page = React.createElement(
126
+ DocsLayout,
127
+ { repoUrl: state.docuConfig.repo?.url, pathname },
128
+ React.createElement(DocsPage, {
129
+ slug,
130
+ title,
131
+ description,
132
+ date: doc.frontmatter.date as string | undefined,
133
+ content: doc.content,
134
+ tocs: doc.tocs,
135
+ filePath: doc.filePath,
136
+ repoUrl: state.docuConfig.repo?.url,
137
+ compiledSource: doc.compiledSource,
138
+ })
139
+ );
140
+
141
+ const body = renderToString(page);
142
+
143
+ if (state.builder) {
144
+ const ctx: PageContext = {
145
+ slug: slug.join("/") || "/",
146
+ filePath: doc.filePath,
147
+ frontmatter: doc.frontmatter,
148
+ content: doc.resolvedContent,
149
+ config: state.docuConfig,
150
+ };
151
+ const headExtra = state.builder.collectHead(ctx);
152
+ const bodyExtra = state.builder.collectBody(ctx);
153
+ const nonce = generateNonce();
154
+ const favicon = state.docuConfig.meta?.favicon || "/favicon.ico";
155
+ let html = createHtmlShell({
156
+ title,
157
+ description,
158
+ body,
159
+ favicon,
160
+ css: state.assetManifest.css,
161
+ js: state.assetManifest.js,
162
+ nonce,
163
+ extraScripts: hmrScript(nonce),
164
+ themeCss: state.inlineThemeCss,
165
+ headExtra,
166
+ bodyExtra,
167
+ });
168
+ html = await state.builder.runTransformHtmlChain(html, ctx);
169
+ return htmlResponse(html, nonce, 200, process.env.NODE_ENV !== "production");
170
+ }
171
+
172
+ return createHtmlResponse(title, description, body, 200, state);
173
+ }
174
+
175
+ function renderPage(
176
+ Component: React.ComponentType<Record<string, unknown>>,
177
+ title: string,
178
+ description: string,
179
+ status: number,
180
+ state: ServerState,
181
+ props: Record<string, unknown> = {}
182
+ ): Response {
183
+ const page = React.createElement(
184
+ DocsLayout,
185
+ { repoUrl: state.docuConfig.repo?.url, pathname: "/docs" },
186
+ React.createElement(Component, props)
187
+ );
188
+ const body = renderToString(page);
189
+ return createHtmlResponse(title, description, body, status, state);
190
+ }
191
+
192
+ export async function handleDocsIndex(state: ServerState): Promise<Response> {
193
+ const doc = await getDocsForSlug("", state);
194
+ if (!doc) return renderPage(NotFoundPage, "404 - Not Found", "", 404, state);
195
+ return renderDocsServerPage(doc, [], "/docs", state);
196
+ }
197
+
198
+ export async function handleDocsRoute(slug: string[], state: ServerState): Promise<Response> {
199
+ const path = slug.join("/");
200
+ const doc = await getDocsForSlug(path, state);
201
+ if (!doc) return renderPage(NotFoundPage, "404 - Not Found", "", 404, state);
202
+ return renderDocsServerPage(doc, slug, `/docs/${path}`, state);
203
+ }
204
+
205
+ export function handleIndex(state: ServerState): Response {
206
+ const page = React.createElement(IndexPage);
207
+ const body = renderToString(page);
208
+ return createHtmlResponse(
209
+ state.docuConfig.meta?.title || "DocuBook",
210
+ state.docuConfig.meta?.description || "",
211
+ body,
212
+ 200,
213
+ state
214
+ );
215
+ }
216
+
217
+ export function handleNotFound(state: ServerState): Response {
218
+ return renderPage(NotFoundPage, "404 - Not Found", "", 404, state);
219
+ }
220
+
221
+ export function serveStatic(pathname: string): Response | null {
222
+ if (!isPathSafe(pathname, DIST_DIR)) return null;
223
+ const decoded = decodeURIComponent(pathname);
224
+ const assetPath = resolve(DIST_DIR, decoded.slice(1));
225
+ try {
226
+ const s = statSync(assetPath);
227
+ if (s.isFile()) {
228
+ return new Response(Bun.file(assetPath), {
229
+ headers: { "Content-Type": getContentType(pathname) },
230
+ });
231
+ }
232
+ } catch (err) {
233
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
234
+ }
235
+
236
+ if (decoded.startsWith("/docs/assets/")) {
237
+ const docsAsset = resolve(DOCS_DIR, "assets", decoded.replace("/docs/assets/", ""));
238
+ const docsAssetsDir = resolve(DOCS_DIR, "assets");
239
+ const docsAssetsDirSlash = docsAssetsDir.endsWith("/") ? docsAssetsDir : docsAssetsDir + "/";
240
+ if (docsAsset !== docsAssetsDir && !docsAsset.startsWith(docsAssetsDirSlash)) return null;
241
+ try {
242
+ const s = statSync(docsAsset);
243
+ if (s.isFile()) {
244
+ return new Response(Bun.file(docsAsset), {
245
+ headers: { "Content-Type": getContentType(pathname) },
246
+ });
247
+ }
248
+ } catch (err) {
249
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
250
+ }
251
+ }
252
+ return null;
253
+ }
254
+
255
+ export function serverErrorResponse(error: unknown): Response {
256
+ const msg = error instanceof Error ? error.message : "Unknown error";
257
+ const st = error instanceof Error ? error.stack : undefined;
258
+ return new Response(errorHtml(msg, st), {
259
+ status: 500,
260
+ headers: {
261
+ "Content-Type": "text/html",
262
+ ...SECURITY_HEADERS,
263
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
264
+ },
265
+ });
266
+ }
@@ -1,22 +1,20 @@
1
- import { statSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
- import { resolve, join } from "node:path";
4
1
  import { watch } from "node:fs";
5
- import React from "react";
6
- import { renderToString } from "react-dom/server";
7
- import { getContentType } from "./utils";
8
- import { compileMdx } from "./mdx";
9
- import { DOCS_DIR, DIST_DIR, PAGES_DIR, PROJECT_ROOT, loadDocuConfig } from "./paths";
10
- import DocsPage from "../pages/docs/[[...slug]]";
11
- import NotFoundPage from "../pages/404";
12
- import IndexPage from "../pages/index";
13
- import { DocsLayout } from "../components/DocsLayout";
2
+ import { DOCS_DIR, PAGES_DIR, loadDocuConfig } from "./paths";
3
+ import { loadPlugins } from "./plugin-loader";
4
+ import { BuildPluginBuilder } from "./plugin-builder";
14
5
  import { buildClientBundle, computeInlineThemeCss } from "./hydrate";
15
6
  import { generateSearchIndex } from "./search-indexer";
16
7
  import { logger } from "./logger";
17
8
  import { initSentry, captureException } from "./sentry";
18
- import { SECURITY_HEADERS, generateNonce, htmlResponse } from "./security";
19
- import { htmlShell as createHtmlShell, hmrScript } from "./html";
9
+ import {
10
+ serveStatic,
11
+ handleDocsIndex,
12
+ handleDocsRoute,
13
+ handleIndex,
14
+ handleNotFound,
15
+ serverErrorResponse,
16
+ type ServerState,
17
+ } from "./server-routes";
20
18
 
21
19
  const docuConfig = loadDocuConfig();
22
20
 
@@ -40,6 +38,23 @@ logger.indexDone(records, Math.round(performance.now() - t));
40
38
 
41
39
  logger.routes();
42
40
 
41
+ // Plugin setup — all hooks active (onLoad, remark/rehype, frontmatter, head/body, html transform, handleRequest)
42
+ const hasPlugins = !!docuConfig.plugins?.length;
43
+ const builder = hasPlugins ? new BuildPluginBuilder(docuConfig) : null;
44
+ if (hasPlugins && builder) {
45
+ const plugins = await loadPlugins(docuConfig.plugins!);
46
+ for (const plugin of plugins) {
47
+ await plugin.setup(builder);
48
+ }
49
+ }
50
+
51
+ const state: ServerState = {
52
+ docuConfig,
53
+ assetManifest,
54
+ inlineThemeCss,
55
+ builder,
56
+ };
57
+
43
58
  let router: InstanceType<typeof Bun.FileSystemRouter> | null = null;
44
59
  try {
45
60
  router = new Bun.FileSystemRouter({
@@ -76,179 +91,6 @@ process.on("SIGTERM", () => {
76
91
  process.exit(0);
77
92
  });
78
93
 
79
- function createHtmlResponse(
80
- title: string,
81
- description: string,
82
- body: string,
83
- status = 200
84
- ): Response {
85
- const nonce = generateNonce();
86
- const favicon = docuConfig.meta?.favicon || "/favicon.ico";
87
- const html = createHtmlShell({
88
- title,
89
- description,
90
- body,
91
- favicon,
92
- css: assetManifest.css,
93
- js: assetManifest.js,
94
- nonce,
95
- extraScripts: hmrScript(nonce),
96
- themeCss: inlineThemeCss,
97
- });
98
- return htmlResponse(html, nonce, status);
99
- }
100
-
101
- async function getDocsForSlug(slug: string) {
102
- const resolved = resolve(DOCS_DIR, slug);
103
- if (!resolved.startsWith(DOCS_DIR)) return null;
104
-
105
- const paths = [
106
- join(DOCS_DIR, slug, "index.mdx"),
107
- join(DOCS_DIR, `${slug}.mdx`),
108
- join(DOCS_DIR, slug, "index.md"),
109
- join(DOCS_DIR, `${slug}.md`),
110
- ];
111
-
112
- let filePath: string | null = null;
113
- let raw: string | null = null;
114
- for (const p of paths) {
115
- if (!p.startsWith(DOCS_DIR)) continue;
116
- try {
117
- raw = await readFile(p, "utf-8");
118
- filePath = p;
119
- break;
120
- } catch (err) {
121
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
122
- }
123
- }
124
- if (!filePath || !raw) return null;
125
-
126
- const relPath = filePath.replace(PROJECT_ROOT + "/", "");
127
- const result = await compileMdx(raw, relPath);
128
-
129
- return {
130
- content: result.content,
131
- compiledSource: result.compiledSource,
132
- frontmatter: result.frontmatter,
133
- tocs: result.tocs,
134
- filePath: relPath,
135
- };
136
- }
137
-
138
- async function handleDocsIndex(): Promise<Response> {
139
- const doc = await getDocsForSlug("");
140
- if (!doc) return renderPage(NotFoundPage, "404 - Not Found", "", 404);
141
-
142
- const title = doc.frontmatter.title || "Docs";
143
- const description = doc.frontmatter.description || "";
144
-
145
- const page = React.createElement(
146
- DocsLayout,
147
- { repoUrl: docuConfig.repo?.url, pathname: "/docs" },
148
- React.createElement(DocsPage, {
149
- slug: [],
150
- title,
151
- description,
152
- date: doc.frontmatter.date,
153
- content: doc.content,
154
- tocs: doc.tocs,
155
- filePath: doc.filePath,
156
- repoUrl: docuConfig.repo?.url,
157
- compiledSource: doc.compiledSource,
158
- })
159
- );
160
-
161
- const body = renderToString(page);
162
- return createHtmlResponse(title, description, body);
163
- }
164
-
165
- async function handleDocsRoute(slug: string[]): Promise<Response> {
166
- const path = slug.join("/");
167
-
168
- const doc = await getDocsForSlug(path);
169
- if (!doc) return renderPage(NotFoundPage, "404 - Not Found", "", 404);
170
-
171
- const title = doc.frontmatter.title || path;
172
- const description = doc.frontmatter.description || "";
173
-
174
- const page = React.createElement(
175
- DocsLayout,
176
- { repoUrl: docuConfig.repo?.url, pathname: `/docs/${path}` },
177
- React.createElement(DocsPage, {
178
- slug,
179
- title,
180
- description,
181
- date: doc.frontmatter.date,
182
- content: doc.content,
183
- tocs: doc.tocs,
184
- filePath: doc.filePath,
185
- repoUrl: docuConfig.repo?.url,
186
- compiledSource: doc.compiledSource,
187
- })
188
- );
189
-
190
- const body = renderToString(page);
191
- return createHtmlResponse(title, description, body);
192
- }
193
-
194
- function renderPage(
195
- Component: React.ComponentType<Record<string, unknown>>,
196
- title: string,
197
- description: string,
198
- status: number,
199
- props: Record<string, unknown> = {}
200
- ): Response {
201
- const page = React.createElement(
202
- DocsLayout,
203
- { repoUrl: docuConfig.repo?.url, pathname: "/docs" },
204
- React.createElement(Component, props)
205
- );
206
- const body = renderToString(page);
207
- return createHtmlResponse(title, description, body, status);
208
- }
209
-
210
- function handleIndex(): Response {
211
- const page = React.createElement(IndexPage);
212
- const body = renderToString(page);
213
- return createHtmlResponse(
214
- docuConfig.meta?.title || "DocuBook",
215
- docuConfig.meta?.description || "",
216
- body
217
- );
218
- }
219
-
220
- function serveStatic(pathname: string): Response | null {
221
- const decoded = decodeURIComponent(pathname);
222
- const assetPath = resolve(DIST_DIR, decoded.slice(1));
223
- if (!assetPath.startsWith(DIST_DIR)) return null;
224
- try {
225
- const s = statSync(assetPath);
226
- if (s.isFile()) {
227
- return new Response(Bun.file(assetPath), {
228
- headers: { "Content-Type": getContentType(pathname) },
229
- });
230
- }
231
- } catch (err) {
232
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
233
- }
234
-
235
- if (decoded.startsWith("/docs/assets/")) {
236
- const docsAsset = resolve(DOCS_DIR, "assets", decoded.replace("/docs/assets/", ""));
237
- if (!docsAsset.startsWith(resolve(DOCS_DIR, "assets"))) return null;
238
- try {
239
- const s = statSync(docsAsset);
240
- if (s.isFile()) {
241
- return new Response(Bun.file(docsAsset), {
242
- headers: { "Content-Type": getContentType(pathname) },
243
- });
244
- }
245
- } catch (err) {
246
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
247
- }
248
- }
249
- return null;
250
- }
251
-
252
94
  const server = Bun.serve({
253
95
  port: PORT,
254
96
  development: true,
@@ -259,6 +101,22 @@ const server = Bun.serve({
259
101
  const pathname = url.pathname;
260
102
  const startTime = performance.now();
261
103
 
104
+ if (builder) {
105
+ const pluginResponse = await builder.runHandleRequest(req, {
106
+ port: server.port,
107
+ hostname: server.hostname,
108
+ });
109
+ if (pluginResponse) {
110
+ logger.request(
111
+ req.method,
112
+ pathname,
113
+ pluginResponse.status,
114
+ Math.round(performance.now() - startTime)
115
+ );
116
+ return pluginResponse;
117
+ }
118
+ }
119
+
262
120
  try {
263
121
  if (pathname === "/__hmr") {
264
122
  const stream = new ReadableStream({
@@ -295,19 +153,19 @@ const server = Bun.serve({
295
153
  const slug = slugParam ? slugParam.split("/") : [];
296
154
 
297
155
  if (slug.length === 0) {
298
- response = await handleDocsIndex();
156
+ response = await handleDocsIndex(state);
299
157
  } else {
300
- response = await handleDocsRoute(slug);
158
+ response = await handleDocsRoute(slug, state);
301
159
  }
302
160
  } else if (routeName === "/404") {
303
- response = renderPage(NotFoundPage, "404 - Not Found", "", 404);
161
+ response = handleNotFound(state);
304
162
  } else if (routeName === "/") {
305
- response = handleIndex();
163
+ response = handleIndex(state);
306
164
  } else {
307
- response = renderPage(NotFoundPage, "404 - Not Found", "", 404);
165
+ response = handleNotFound(state);
308
166
  }
309
167
  } else {
310
- response = renderPage(NotFoundPage, "404 - Not Found", "", 404);
168
+ response = handleNotFound(state);
311
169
  }
312
170
 
313
171
  logger.request(
@@ -319,45 +177,15 @@ const server = Bun.serve({
319
177
  return response;
320
178
  } catch (err) {
321
179
  captureException(err, { method: req.method, pathname });
322
- const message = err instanceof Error ? err.message : String(err);
323
- const stack = err instanceof Error ? err.stack || "" : "";
324
180
  logger.request(req.method, pathname, 500, Math.round(performance.now() - startTime));
325
- const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Error</title>
326
- <style>body{margin:0;padding:2rem;font-family:ui-monospace,monospace;background:#1a1a2e;color:#e0e0e0}
327
- h1{color:#ff6b6b}pre{background:#0d0d1a;border:1px solid #333;border-radius:8px;padding:1.5rem;overflow-x:auto;font-size:14px;line-height:1.6;white-space:pre-wrap;word-break:break-word}
328
- .msg{color:#ff6b6b;font-weight:bold}</style></head><body>
329
- <h1>🔥 Server Error</h1>
330
- <pre><span class="msg">${Bun.escapeHTML(message)}</span>\n\n${Bun.escapeHTML(stack)}</pre></body></html>`;
331
- return new Response(html, {
332
- status: 500,
333
- headers: {
334
- "Content-Type": "text/html",
335
- ...SECURITY_HEADERS,
336
- "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
337
- },
338
- });
181
+ return serverErrorResponse(err);
339
182
  }
340
183
  },
341
184
 
342
185
  error(error) {
343
186
  console.error(error);
344
187
  captureException(error);
345
- const msg = Bun.escapeHTML(error?.message || "Unknown error");
346
- const stack = Bun.escapeHTML(error?.stack || "");
347
- const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Error</title>
348
- <style>body{margin:0;padding:2rem;font-family:ui-monospace,monospace;background:#1a1a2e;color:#e0e0e0}
349
- h1{color:#ff6b6b}pre{background:#0d0d1a;border:1px solid #333;border-radius:8px;padding:1.5rem;overflow-x:auto;font-size:14px;line-height:1.6;white-space:pre-wrap;word-break:break-word}
350
- .msg{color:#ff6b6b;font-weight:bold}</style></head><body>
351
- <h1>🔥 Server Error</h1>
352
- <pre><span class="msg">${msg}</span>\n\n${stack}</pre></body></html>`;
353
- return new Response(html, {
354
- status: 500,
355
- headers: {
356
- "Content-Type": "text/html",
357
- ...SECURITY_HEADERS,
358
- "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
359
- },
360
- });
188
+ return serverErrorResponse(error);
361
189
  },
362
190
  });
363
191
 
@@ -67,6 +67,7 @@ export interface HomeConfig {
67
67
  }
68
68
 
69
69
  import type { ThemeConfig } from "@docubook/themes-colors";
70
+ import type { PluginEntry } from "./plugin";
70
71
 
71
72
  export interface DocuConfig {
72
73
  meta: DocuMeta;
@@ -78,6 +79,8 @@ export interface DocuConfig {
78
79
  themes?: {
79
80
  colors: ThemeConfig;
80
81
  };
82
+ /** List of DocuBook plugins to load. Empty by default — no-op when absent. */
83
+ plugins?: PluginEntry[];
81
84
  }
82
85
 
83
86
  export interface BuildCache {
@@ -1,5 +1,45 @@
1
1
  export { cn, parseDate, formatDate, formatDate2 } from "@docubook/core";
2
2
 
3
+ import { readdir, stat } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ export interface ScannedMdxFile {
7
+ path: string;
8
+ absPath: string;
9
+ mtime: number;
10
+ }
11
+
12
+ /**
13
+ * Scan a directory recursively for MDX/MD files.
14
+ * Skips "assets" directories, hidden directories (dot-prefixed), and root-level index.mdx.
15
+ * Shared between build.ts and search-indexer.ts.
16
+ */
17
+ export async function scanMdxFiles(dir: string, baseDir = ""): Promise<ScannedMdxFile[]> {
18
+ const files: ScannedMdxFile[] = [];
19
+ const entries = await readdir(dir, { withFileTypes: true });
20
+
21
+ for (const entry of entries) {
22
+ const fullPath = join(dir, entry.name);
23
+ const relativePath = baseDir ? `${baseDir}/${entry.name}` : entry.name;
24
+
25
+ if (entry.isDirectory()) {
26
+ if (entry.name === "assets" || entry.name.startsWith(".")) continue;
27
+ files.push(...(await scanMdxFiles(fullPath, relativePath)));
28
+ } else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
29
+ if (entry.name === "index.mdx" && !baseDir) continue;
30
+ const stats = await stat(fullPath);
31
+ let path = relativePath.replace(/\.(mdx|md)$/, "");
32
+
33
+ if (/\/index$/.test(path)) {
34
+ path = path.replace(/\/index$/, "");
35
+ }
36
+ files.push({ path, absPath: fullPath, mtime: stats.mtimeMs });
37
+ }
38
+ }
39
+
40
+ return files;
41
+ }
42
+
3
43
  export function isExternalUrl(url: string): boolean {
4
44
  return /^(https?:\/\/|\/\/)/.test(url);
5
45
  }
@@ -40,9 +80,26 @@ export async function getGitLastModifiedBatch(filePaths: string[]): Promise<Map<
40
80
  const result = new Map<string, string>();
41
81
  if (filePaths.length === 0) return result;
42
82
 
83
+ // Filter and validate paths — same guard as getGitLastModified
84
+ const safePaths: string[] = [];
85
+ for (const fp of filePaths) {
86
+ const cleanPath = fp.replace(/^\//, "");
87
+ if (
88
+ !cleanPath ||
89
+ !/^[a-zA-Z0-9\-_/.\s]+$/.test(cleanPath) ||
90
+ /(^|\/)\.\.($|\/)/.test(cleanPath)
91
+ ) {
92
+ console.warn(`[utils] getGitLastModifiedBatch: skipping invalid path "${fp}"`);
93
+ continue;
94
+ }
95
+ safePaths.push(cleanPath);
96
+ }
97
+
98
+ if (safePaths.length === 0) return result;
99
+
43
100
  try {
44
101
  const proc = Bun.spawn(
45
- ["git", "log", "--format=%cI", "--name-only", "--diff-filter=ACMR", ...filePaths],
102
+ ["git", "log", "--format=%cI", "--name-only", "--diff-filter=ACMR", ...safePaths],
46
103
  { stderr: "ignore" }
47
104
  );
48
105
  const text = await new Response(proc.stdout).text();
@@ -62,7 +62,9 @@ export default function DocsPage({
62
62
  <script
63
63
  id="mdx-compiled-source"
64
64
  type="application/json"
65
- dangerouslySetInnerHTML={{ __html: JSON.stringify(compiledSource) }}
65
+ dangerouslySetInnerHTML={{
66
+ __html: JSON.stringify(compiledSource).replace(/<\//g, "\\u003C/"),
67
+ }}
66
68
  />
67
69
  )}
68
70
  <div className="border-base-300 my-8 flex items-center border-b-2 border-dashed">