@docubook/flame 1.2.1 → 1.3.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.
@@ -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, isPathSafe, isSlugSafe, 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,180 +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, process.env.NODE_ENV !== "production");
99
- }
100
-
101
- async function getDocsForSlug(slug: string) {
102
- if (!isSlugSafe(slug, DOCS_DIR)) return null;
103
-
104
- const paths = [
105
- join(DOCS_DIR, slug, "index.mdx"),
106
- join(DOCS_DIR, `${slug}.mdx`),
107
- join(DOCS_DIR, slug, "index.md"),
108
- join(DOCS_DIR, `${slug}.md`),
109
- ];
110
-
111
- let filePath: string | null = null;
112
- let raw: string | null = null;
113
- for (const p of paths) {
114
- if (!p.startsWith(DOCS_DIR)) continue;
115
- try {
116
- raw = await readFile(p, "utf-8");
117
- filePath = p;
118
- break;
119
- } catch (err) {
120
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
121
- }
122
- }
123
- if (!filePath || !raw) return null;
124
-
125
- const relPath = filePath.replace(PROJECT_ROOT + "/", "");
126
- const result = await compileMdx(raw, relPath);
127
-
128
- return {
129
- content: result.content,
130
- compiledSource: result.compiledSource,
131
- frontmatter: result.frontmatter,
132
- tocs: result.tocs,
133
- filePath: relPath,
134
- };
135
- }
136
-
137
- async function handleDocsIndex(): Promise<Response> {
138
- const doc = await getDocsForSlug("");
139
- if (!doc) return renderPage(NotFoundPage, "404 - Not Found", "", 404);
140
-
141
- const title = doc.frontmatter.title || "Docs";
142
- const description = doc.frontmatter.description || "";
143
-
144
- const page = React.createElement(
145
- DocsLayout,
146
- { repoUrl: docuConfig.repo?.url, pathname: "/docs" },
147
- React.createElement(DocsPage, {
148
- slug: [],
149
- title,
150
- description,
151
- date: doc.frontmatter.date,
152
- content: doc.content,
153
- tocs: doc.tocs,
154
- filePath: doc.filePath,
155
- repoUrl: docuConfig.repo?.url,
156
- compiledSource: doc.compiledSource,
157
- })
158
- );
159
-
160
- const body = renderToString(page);
161
- return createHtmlResponse(title, description, body);
162
- }
163
-
164
- async function handleDocsRoute(slug: string[]): Promise<Response> {
165
- const path = slug.join("/");
166
-
167
- const doc = await getDocsForSlug(path);
168
- if (!doc) return renderPage(NotFoundPage, "404 - Not Found", "", 404);
169
-
170
- const title = doc.frontmatter.title || path;
171
- const description = doc.frontmatter.description || "";
172
-
173
- const page = React.createElement(
174
- DocsLayout,
175
- { repoUrl: docuConfig.repo?.url, pathname: `/docs/${path}` },
176
- React.createElement(DocsPage, {
177
- slug,
178
- title,
179
- description,
180
- date: doc.frontmatter.date,
181
- content: doc.content,
182
- tocs: doc.tocs,
183
- filePath: doc.filePath,
184
- repoUrl: docuConfig.repo?.url,
185
- compiledSource: doc.compiledSource,
186
- })
187
- );
188
-
189
- const body = renderToString(page);
190
- return createHtmlResponse(title, description, body);
191
- }
192
-
193
- function renderPage(
194
- Component: React.ComponentType<Record<string, unknown>>,
195
- title: string,
196
- description: string,
197
- status: number,
198
- props: Record<string, unknown> = {}
199
- ): Response {
200
- const page = React.createElement(
201
- DocsLayout,
202
- { repoUrl: docuConfig.repo?.url, pathname: "/docs" },
203
- React.createElement(Component, props)
204
- );
205
- const body = renderToString(page);
206
- return createHtmlResponse(title, description, body, status);
207
- }
208
-
209
- function handleIndex(): Response {
210
- const page = React.createElement(IndexPage);
211
- const body = renderToString(page);
212
- return createHtmlResponse(
213
- docuConfig.meta?.title || "DocuBook",
214
- docuConfig.meta?.description || "",
215
- body
216
- );
217
- }
218
-
219
- function serveStatic(pathname: string): Response | null {
220
- if (!isPathSafe(pathname, DIST_DIR)) return null;
221
- const decoded = decodeURIComponent(pathname);
222
- const assetPath = resolve(DIST_DIR, decoded.slice(1));
223
- try {
224
- const s = statSync(assetPath);
225
- if (s.isFile()) {
226
- return new Response(Bun.file(assetPath), {
227
- headers: { "Content-Type": getContentType(pathname) },
228
- });
229
- }
230
- } catch (err) {
231
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
232
- }
233
-
234
- if (decoded.startsWith("/docs/assets/")) {
235
- const docsAsset = resolve(DOCS_DIR, "assets", decoded.replace("/docs/assets/", ""));
236
- const docsAssetsDir = resolve(DOCS_DIR, "assets");
237
- const docsAssetsDirSlash = docsAssetsDir.endsWith("/") ? docsAssetsDir : docsAssetsDir + "/";
238
- if (docsAsset !== docsAssetsDir && !docsAsset.startsWith(docsAssetsDirSlash)) return null;
239
- try {
240
- const s = statSync(docsAsset);
241
- if (s.isFile()) {
242
- return new Response(Bun.file(docsAsset), {
243
- headers: { "Content-Type": getContentType(pathname) },
244
- });
245
- }
246
- } catch (err) {
247
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
248
- }
249
- }
250
- return null;
251
- }
252
-
253
94
  const server = Bun.serve({
254
95
  port: PORT,
255
96
  development: true,
@@ -260,6 +101,22 @@ const server = Bun.serve({
260
101
  const pathname = url.pathname;
261
102
  const startTime = performance.now();
262
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
+
263
120
  try {
264
121
  if (pathname === "/__hmr") {
265
122
  const stream = new ReadableStream({
@@ -296,19 +153,19 @@ const server = Bun.serve({
296
153
  const slug = slugParam ? slugParam.split("/") : [];
297
154
 
298
155
  if (slug.length === 0) {
299
- response = await handleDocsIndex();
156
+ response = await handleDocsIndex(state);
300
157
  } else {
301
- response = await handleDocsRoute(slug);
158
+ response = await handleDocsRoute(slug, state);
302
159
  }
303
160
  } else if (routeName === "/404") {
304
- response = renderPage(NotFoundPage, "404 - Not Found", "", 404);
161
+ response = handleNotFound(state);
305
162
  } else if (routeName === "/") {
306
- response = handleIndex();
163
+ response = handleIndex(state);
307
164
  } else {
308
- response = renderPage(NotFoundPage, "404 - Not Found", "", 404);
165
+ response = handleNotFound(state);
309
166
  }
310
167
  } else {
311
- response = renderPage(NotFoundPage, "404 - Not Found", "", 404);
168
+ response = handleNotFound(state);
312
169
  }
313
170
 
314
171
  logger.request(
@@ -320,45 +177,15 @@ const server = Bun.serve({
320
177
  return response;
321
178
  } catch (err) {
322
179
  captureException(err, { method: req.method, pathname });
323
- const message = err instanceof Error ? err.message : String(err);
324
- const stack = err instanceof Error ? err.stack || "" : "";
325
180
  logger.request(req.method, pathname, 500, Math.round(performance.now() - startTime));
326
- const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Error</title>
327
- <style>body{margin:0;padding:2rem;font-family:ui-monospace,monospace;background:#1a1a2e;color:#e0e0e0}
328
- 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}
329
- .msg{color:#ff6b6b;font-weight:bold}</style></head><body>
330
- <h1>🔥 Server Error</h1>
331
- <pre><span class="msg">${Bun.escapeHTML(message)}</span>\n\n${Bun.escapeHTML(stack)}</pre></body></html>`;
332
- return new Response(html, {
333
- status: 500,
334
- headers: {
335
- "Content-Type": "text/html",
336
- ...SECURITY_HEADERS,
337
- "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
338
- },
339
- });
181
+ return serverErrorResponse(err);
340
182
  }
341
183
  },
342
184
 
343
185
  error(error) {
344
186
  console.error(error);
345
187
  captureException(error);
346
- const msg = Bun.escapeHTML(error?.message || "Unknown error");
347
- const stack = Bun.escapeHTML(error?.stack || "");
348
- const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Error</title>
349
- <style>body{margin:0;padding:2rem;font-family:ui-monospace,monospace;background:#1a1a2e;color:#e0e0e0}
350
- 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}
351
- .msg{color:#ff6b6b;font-weight:bold}</style></head><body>
352
- <h1>🔥 Server Error</h1>
353
- <pre><span class="msg">${msg}</span>\n\n${stack}</pre></body></html>`;
354
- return new Response(html, {
355
- status: 500,
356
- headers: {
357
- "Content-Type": "text/html",
358
- ...SECURITY_HEADERS,
359
- "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
360
- },
361
- });
188
+ return serverErrorResponse(error);
362
189
  },
363
190
  });
364
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">
package/README.md CHANGED
@@ -282,6 +282,54 @@ Reference in MDX:
282
282
 
283
283
  ---
284
284
 
285
+ ## Plugins
286
+
287
+ Flame supports a plugin system to extend the build pipeline and dev server. Plugins can inject head/body HTML, transform content, add remark/rehype plugins, intercept API requests, and more.
288
+
289
+ ### Usage in `docu.json`
290
+
291
+ ```json
292
+ {
293
+ "plugins": [
294
+ "@docubook/plugin-sitemap",
295
+ ["./plugins/analytics", { "id": "G-XXXXXXX" }]
296
+ ]
297
+ }
298
+ ```
299
+
300
+ String = npm package or relative path. Tuple `["name", opts]` = factory with options.
301
+
302
+ ### Available Hooks
303
+
304
+ | Hook | Purpose |
305
+ | --------------------------------- | ------------------------------------------------- |
306
+ | `onStart` | Validate config before build |
307
+ | `onEnd` | Generate files after build (sitemap, RSS) |
308
+ | `onLoad` | Transform raw file content before MDX compilation |
309
+ | `transformFrontmatter` | Mutate frontmatter (reading time, SEO) |
310
+ | `transformHtml` | Modify final HTML before writing to disk |
311
+ | `injectHead` / `injectBody` | Inject HTML into `<head>` or before `</body>` |
312
+ | `remarkPlugins` / `rehypePlugins` | Add remark/rehype plugins to the MDX pipeline |
313
+ | `handleRequest` | Intercept dev server requests (custom API) |
314
+
315
+ ### Quick Example
316
+
317
+ ```typescript
318
+ import type { DocuBookPlugin } from "@docubook/flame";
319
+
320
+ export default {
321
+ name: "reading-time",
322
+ setup(build) {
323
+ build.transformFrontmatter((fm, ctx) => ({
324
+ ...fm,
325
+ readingTime: `${Math.ceil((ctx.content ?? "").split(/\s+/).length / 200)} min read`,
326
+ }));
327
+ },
328
+ } satisfies DocuBookPlugin;
329
+ ```
330
+
331
+ See the [full plugin guide](docs/getting-started/plugins.mdx) for step-by-step instructions.
332
+
285
333
  ## Architecture
286
334
 
287
335
  - **Bun** — runtime, bundler, file watcher
package/docu.schema.json CHANGED
@@ -118,6 +118,34 @@
118
118
  "description": "Documentation navigation routes. Leave empty for auto-detection from docs/ folder.",
119
119
  "items": { "$ref": "#/$defs/route" }
120
120
  },
121
+ "plugins": {
122
+ "type": "array",
123
+ "description": "List of DocuBook plugins to load",
124
+ "items": {
125
+ "oneOf": [
126
+ {
127
+ "type": "string",
128
+ "description": "Plugin package name or relative path (e.g. @docubook/plugin-sitemap)"
129
+ },
130
+ {
131
+ "type": "array",
132
+ "description": "Plugin package name with factory options",
133
+ "items": [
134
+ {
135
+ "type": "string",
136
+ "description": "Plugin package name"
137
+ },
138
+ {
139
+ "type": "object",
140
+ "description": "Plugin factory options"
141
+ }
142
+ ],
143
+ "minItems": 2,
144
+ "maxItems": 2
145
+ }
146
+ ]
147
+ }
148
+ },
121
149
  "themes": {
122
150
  "type": "object",
123
151
  "description": "Color themes configuration. Use preset name or custom hex values.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docubook/flame",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "A blazing-fast React + MDX framework powered by Bun, built for modern documentation experiences.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,7 @@
34
34
  "react",
35
35
  "react-dom"
36
36
  ],
37
- "homepage": "https://docubook.pro/",
37
+ "homepage": "https://github.com/DocuBook/docubook/tree/main/packages/flame",
38
38
  "repository": {
39
39
  "type": "git",
40
40
  "url": "git+https://github.com/DocuBook/docubook.git",
@@ -51,13 +51,13 @@
51
51
  "lucide-react": "^1.14.0",
52
52
  "react": "^19.0.0",
53
53
  "react-dom": "^19.0.0",
54
- "@docubook/mdx-content": "^3.2.1",
55
- "@docubook/core": "^1.7.0",
56
- "@docubook/themes-colors": "^0.10.1",
57
- "@docubook/ui-react": "^0.1.3"
54
+ "@docubook/mdx-content": "^3.2.2",
55
+ "@docubook/core": "^1.7.2",
56
+ "@docubook/themes-colors": "^0.10.2",
57
+ "@docubook/ui-react": "^0.1.4"
58
58
  },
59
59
  "peerDependencies": {
60
- "@sentry/bun": "^9.0.0"
60
+ "@sentry/bun": "^10.0.0"
61
61
  },
62
62
  "peerDependenciesMeta": {
63
63
  "@sentry/bun": {