@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.
@@ -16,7 +16,8 @@ function getContextRoutes() {
16
16
  }
17
17
 
18
18
  function getFirstItemHref(route: { href: string; items?: { href: string }[] }): string {
19
- return route.items?.[0]?.href ? `${route.href}${route.items[0].href}` : route.href;
19
+ if (!route.items?.length) return route.href;
20
+ return `${route.href}${getFirstItemHref(route.items[0])}`;
20
21
  }
21
22
 
22
23
  function getActiveContextRoute(path: string) {
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, mkdir, readdir, copyFile, stat } from "node:fs/promises";
1
+ import { readFile, writeFile, mkdir, readdir, copyFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { join, dirname } from "node:path";
@@ -14,12 +14,17 @@ import {
14
14
  PROJECT_ROOT,
15
15
  loadDocuConfig,
16
16
  } from "./paths";
17
- import { htmlShell as createHtmlShell } from "./html";
17
+ import { htmlShell } from "./html";
18
18
  import { generateSearchIndex } from "./search-indexer";
19
19
  import { buildClientBundle, computeInlineThemeCss } from "./hydrate";
20
20
  import { logger } from "./logger";
21
21
  import { initSentry, captureException } from "./sentry";
22
+ import { loadPlugins } from "./plugin-loader";
23
+ import { BuildPluginBuilder } from "./plugin-builder";
24
+ import { scanMdxFiles } from "./utils";
22
25
  import type { BuildCache, CliArgs } from "./types";
26
+ import { generateNonce } from "./security";
27
+ import type { PageMeta, PageContext } from "./plugin";
23
28
  import DocsPage from "../pages/docs/[[...slug]]";
24
29
  import IndexPage from "../pages/index";
25
30
  import NotFoundPage from "../pages/404";
@@ -55,77 +60,60 @@ async function writeCache(cache: BuildCache): Promise<void> {
55
60
  await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2));
56
61
  }
57
62
 
58
- async function findMdxFiles(dir: string, baseDir = ""): Promise<{ path: string; mtime: number }[]> {
59
- const files: { path: string; mtime: number }[] = [];
60
- try {
61
- const entries = await readdir(dir, { withFileTypes: true });
62
- for (const entry of entries) {
63
- const fullPath = join(dir, entry.name);
64
- const relativePath = baseDir ? `${baseDir}/${entry.name}` : entry.name;
65
-
66
- if (entry.isDirectory()) {
67
- if (entry.name === "assets" || entry.name.startsWith(".")) continue;
68
- files.push(...(await findMdxFiles(fullPath, relativePath)));
69
- } else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) {
70
- if (entry.name === "index.mdx" && !baseDir) continue;
71
- const stats = await stat(fullPath);
72
- let path = relativePath.replace(/\.(mdx|md)$/, "");
73
-
74
- if (/\/index$/.test(path)) {
75
- path = path.replace(/\/index$/, "");
76
- }
77
- files.push({ path, mtime: stats.mtimeMs });
78
- }
79
- }
80
- } catch (err) {
81
- console.error("Failed to scan docs directory:", (err as Error).message);
82
- }
83
- return files;
84
- }
85
-
86
63
  export function parseConcurrency(): number {
87
- return Math.max(1, parseInt(process.env.BUILD_CONCURRENCY || "10", 10) || 10);
64
+ return Math.max(1, parseInt(process.env.BUILD_CONCURRENCY || "4", 10) || 4);
88
65
  }
89
66
 
90
- export function shouldRebuild(path: string, mtime: number, cache: BuildCache): boolean {
67
+ type RebuildDecision = "yes" | "hash_check" | "no";
68
+
69
+ export function shouldRebuild(path: string, mtime: number, cache: BuildCache): RebuildDecision {
91
70
  const cached = cache[path];
92
- if (!cached) return true;
93
- return mtime > cached.builtAt;
71
+ if (!cached) return "yes";
72
+ if (mtime > cached.builtAt) return "hash_check";
73
+ return "no";
94
74
  }
95
75
 
96
76
  let assetManifest = { js: "client.js", css: "client.css" };
97
77
 
98
78
  let inlineThemeCss: string | undefined;
99
79
 
100
- function htmlShell(title: string, description: string, body: string): string {
101
- const favicon = docuConfig.meta?.favicon || "/favicon.ico";
102
- return createHtmlShell({
103
- title,
104
- description,
105
- body,
106
- favicon,
107
- css: assetManifest.css,
108
- js: assetManifest.js,
109
- themeCss: inlineThemeCss,
110
- });
111
- }
112
-
113
80
  async function renderDocsPage(
114
81
  slug: string,
115
82
  rawMdx: string,
116
83
  filePath: string,
117
- gitDates?: Map<string, string>
84
+ gitDates?: Map<string, string>,
85
+ builder?: BuildPluginBuilder,
86
+ nonce?: string
118
87
  ): Promise<string> {
88
+ let content = rawMdx;
89
+ if (builder) {
90
+ const transformed = await builder.runOnLoad(filePath, content);
91
+ if (transformed?.contents) {
92
+ content = transformed.contents;
93
+ }
94
+ }
95
+
119
96
  let result;
120
97
  try {
121
- result = await compileMdx(rawMdx, filePath, gitDates);
98
+ const remarkPlugins = builder?.collectRemarkPlugins();
99
+ const rehypePlugins = builder?.collectRehypePlugins();
100
+ result = await compileMdx(content, filePath, gitDates, remarkPlugins, rehypePlugins);
122
101
  } catch (err) {
123
102
  const msg = err instanceof Error ? err.message : "Unknown MDX error";
124
103
  throw new Error(`MDX Error in: docs/${slug}.mdx\n${msg}`, { cause: err });
125
104
  }
126
105
 
127
- const title = result.frontmatter.title || slug || "Docs";
128
- const description = result.frontmatter.description || "";
106
+ let frontmatter = result.frontmatter as Record<string, unknown>;
107
+ if (builder) {
108
+ frontmatter = await builder.runTransformFrontmatterChain(frontmatter, {
109
+ slug,
110
+ filePath,
111
+ content,
112
+ });
113
+ }
114
+
115
+ const title = (frontmatter.title as string) || slug || "Docs";
116
+ const description = (frontmatter.description as string) || "";
129
117
  const slugParts = slug ? slug.split("/") : [];
130
118
 
131
119
  const page = React.createElement(
@@ -135,7 +123,7 @@ async function renderDocsPage(
135
123
  slug: slugParts,
136
124
  title,
137
125
  description,
138
- date: result.frontmatter.date || undefined,
126
+ date: (frontmatter.date as string) || undefined,
139
127
  content: result.content,
140
128
  tocs: result.tocs,
141
129
  filePath,
@@ -145,7 +133,30 @@ async function renderDocsPage(
145
133
  );
146
134
 
147
135
  const body = renderToString(page);
148
- return htmlShell(title, description, body);
136
+
137
+ const ctx: PageContext = { slug, filePath, frontmatter, content, config: docuConfig };
138
+ const headExtra = builder?.collectHead(ctx);
139
+ const bodyExtra = builder?.collectBody(ctx);
140
+
141
+ const favicon = docuConfig.meta?.favicon || "/favicon.ico";
142
+ let html = htmlShell({
143
+ title,
144
+ description,
145
+ body,
146
+ favicon,
147
+ css: assetManifest.css,
148
+ js: assetManifest.js,
149
+ nonce,
150
+ themeCss: inlineThemeCss,
151
+ headExtra,
152
+ bodyExtra,
153
+ });
154
+
155
+ if (builder) {
156
+ html = await builder.runTransformHtmlChain(html, ctx);
157
+ }
158
+
159
+ return html;
149
160
  }
150
161
 
151
162
  async function copyDirectoryRecursive(src: string, dest: string): Promise<void> {
@@ -182,7 +193,7 @@ async function build() {
182
193
 
183
194
  await copyDirectoryRecursive(DOCS_ASSETS_DIR, join(DIST_DIR, "docs", "assets"));
184
195
 
185
- const mdxFiles = await findMdxFiles(DOCS_DIR);
196
+ const mdxFiles = await scanMdxFiles(DOCS_DIR);
186
197
  const cache = args.force ? {} : await readCache();
187
198
  let built = 0;
188
199
  let skipped = 0;
@@ -205,20 +216,25 @@ async function build() {
205
216
  };
206
217
  }
207
218
 
219
+ const hasPlugins = !!docuConfig.plugins?.length;
220
+ const builder = hasPlugins ? new BuildPluginBuilder(docuConfig) : null;
221
+ if (hasPlugins && builder) {
222
+ const plugins = await loadPlugins(docuConfig.plugins!);
223
+ for (const plugin of plugins) {
224
+ await plugin.setup(builder);
225
+ }
226
+ await builder.runOnStart();
227
+ }
228
+
208
229
  logger.spinner.start("Building pages...");
209
230
  t = performance.now();
210
231
 
211
- const allRelPaths = mdxFiles
212
- .map((f) => {
213
- const mdxPath1 = join(DOCS_DIR, f.path, "index.mdx");
214
- const mdxPath2 = join(DOCS_DIR, `${f.path}.mdx`);
215
- const mdxPath3 = join(DOCS_DIR, `${f.path}.md`);
216
- for (const p of [mdxPath1, mdxPath2, mdxPath3]) {
217
- if (existsSync(p)) return p.replace(PROJECT_ROOT + "/", "");
218
- }
219
- return null;
220
- })
221
- .filter((p): p is string => p !== null);
232
+ const allRelPaths = mdxFiles.map((f) => f.absPath.replace(PROJECT_ROOT + "/", ""));
233
+
234
+ const indexMdxFull = join(DOCS_DIR, "index.mdx");
235
+ if (existsSync(indexMdxFull)) {
236
+ allRelPaths.push(indexMdxFull.replace(PROJECT_ROOT + "/", ""));
237
+ }
222
238
  const gitDates = await getGitLastModifiedBatch(allRelPaths);
223
239
 
224
240
  const CONCURRENCY = parseConcurrency();
@@ -226,40 +242,54 @@ async function build() {
226
242
  const errors: string[] = [];
227
243
 
228
244
  for (const file of mdxFiles) {
229
- const mdxPath1 = join(DOCS_DIR, file.path, "index.mdx");
230
- const mdxPath2 = join(DOCS_DIR, `${file.path}.mdx`);
231
- const mdxPath3 = join(DOCS_DIR, `${file.path}.md`);
245
+ const rebuildDecision = shouldRebuild(file.path, file.mtime, cache);
232
246
 
233
- let rawMdx: string | null = null;
234
- let absPath = "";
235
- for (const p of [mdxPath1, mdxPath2, mdxPath3]) {
236
- try {
237
- rawMdx = await readFile(p, "utf-8");
238
- absPath = p;
239
- break;
240
- } catch (err) {
241
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
247
+ if (rebuildDecision === "no") {
248
+ const outputPath = join(DIST_DIR, "docs", `${file.path}.html`);
249
+ if (existsSync(outputPath) && !assetsChanged) {
250
+ skipped++;
251
+ continue;
242
252
  }
243
253
  }
244
- if (!rawMdx) continue;
245
254
 
246
- let needRebuild = assetsChanged || shouldRebuild(file.path, file.mtime, cache);
247
- if (!needRebuild) {
248
- const outputPath = join(DIST_DIR, "docs", `${file.path}.html`);
249
- if (!existsSync(outputPath)) needRebuild = true;
250
- }
251
- if (!needRebuild) {
252
- skipped++;
255
+ let rawMdx: string;
256
+ try {
257
+ rawMdx = await readFile(file.absPath, "utf-8");
258
+ } catch (err) {
259
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
253
260
  continue;
254
261
  }
255
262
 
256
- const relPath = absPath.replace(PROJECT_ROOT + "/", "");
263
+ if (rebuildDecision === "hash_check") {
264
+ const contentHash = hashContent(rawMdx);
265
+ const cached = cache[file.path];
266
+ if (cached && cached.hash === contentHash) {
267
+ if (!assetsChanged) {
268
+ const outputPath = join(DIST_DIR, "docs", `${file.path}.html`);
269
+ if (existsSync(outputPath)) {
270
+ cache[file.path] = { ...cached, mtime: file.mtime, builtAt: Date.now() };
271
+ skipped++;
272
+ continue;
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ const relPath = file.absPath.replace(PROJECT_ROOT + "/", "");
257
279
  const capturedRawMdx = rawMdx;
258
280
  const capturedFile = file;
259
281
 
260
282
  buildTasks.push(async () => {
261
283
  try {
262
- const html = await renderDocsPage(capturedFile.path, capturedRawMdx, relPath, gitDates);
284
+ const pageNonce = generateNonce();
285
+ const html = await renderDocsPage(
286
+ capturedFile.path,
287
+ capturedRawMdx,
288
+ relPath,
289
+ gitDates,
290
+ builder,
291
+ pageNonce
292
+ );
263
293
  const outputPath = join(DIST_DIR, "docs", `${capturedFile.path}.html`);
264
294
  await mkdir(dirname(outputPath), { recursive: true });
265
295
  await writeFile(outputPath, html);
@@ -285,7 +315,14 @@ async function build() {
285
315
  const indexMdxPath = join(DOCS_DIR, "index.mdx");
286
316
  const indexRaw = await readFile(indexMdxPath, "utf-8");
287
317
  const indexRelPath = indexMdxPath.replace(PROJECT_ROOT + "/", "");
288
- const indexHtml = await renderDocsPage("", indexRaw, indexRelPath, gitDates);
318
+ const indexHtml = await renderDocsPage(
319
+ "",
320
+ indexRaw,
321
+ indexRelPath,
322
+ gitDates,
323
+ builder,
324
+ generateNonce()
325
+ );
289
326
  await mkdir(join(DIST_DIR, "docs"), { recursive: true });
290
327
  await writeFile(join(DIST_DIR, "docs", "index.html"), indexHtml);
291
328
  } catch (err) {
@@ -295,11 +332,17 @@ async function build() {
295
332
  }
296
333
 
297
334
  const landingPage = React.createElement(IndexPage);
298
- const landingHtml = htmlShell(
299
- docuConfig.meta?.title || "DocuBook",
300
- docuConfig.meta?.description || "",
301
- renderToString(landingPage)
302
- );
335
+ const landingFavicon = docuConfig.meta?.favicon || "/favicon.ico";
336
+ const landingHtml = htmlShell({
337
+ title: docuConfig.meta?.title || "DocuBook",
338
+ description: docuConfig.meta?.description || "",
339
+ body: renderToString(landingPage),
340
+ favicon: landingFavicon,
341
+ css: assetManifest.css,
342
+ js: assetManifest.js,
343
+ nonce: generateNonce(),
344
+ themeCss: inlineThemeCss,
345
+ });
303
346
  await writeFile(join(DIST_DIR, "index.html"), landingHtml);
304
347
 
305
348
  const notFoundPage = React.createElement(
@@ -307,13 +350,33 @@ async function build() {
307
350
  { repoUrl: docuConfig.repo?.url },
308
351
  React.createElement(NotFoundPage)
309
352
  );
310
- const notFoundHtml = htmlShell("404 - Not Found", "", renderToString(notFoundPage));
353
+ const notFoundFavicon = docuConfig.meta?.favicon || "/favicon.ico";
354
+ const notFoundHtml = htmlShell({
355
+ title: "404 - Not Found",
356
+ description: "",
357
+ body: renderToString(notFoundPage),
358
+ favicon: notFoundFavicon,
359
+ css: assetManifest.css,
360
+ js: assetManifest.js,
361
+ nonce: generateNonce(),
362
+ themeCss: inlineThemeCss,
363
+ });
311
364
  await writeFile(join(DIST_DIR, "404.html"), notFoundHtml);
312
365
 
313
366
  logger.spinner.stop(
314
367
  `Built ${built} pages (${skipped} cached) \x1b[90m(${Math.round(performance.now() - t)}ms)\x1b[0m`
315
368
  );
316
369
 
370
+ if (builder) {
371
+ const pages: PageMeta[] = mdxFiles.map((f) => ({
372
+ slug: f.path,
373
+ title: f.path.split("/").pop() || f.path,
374
+ filePath: join(DOCS_DIR, f.path),
375
+ outputPath: join(DIST_DIR, "docs", `${f.path}.html`),
376
+ }));
377
+ await builder.runOnEnd(pages);
378
+ }
379
+
317
380
  logger.indexStart();
318
381
  t = performance.now();
319
382
  const indexCount = await generateSearchIndex();
@@ -330,10 +393,12 @@ async function build() {
330
393
  }
331
394
  }
332
395
 
333
- initSentry()
334
- .then(() => build())
335
- .catch((err) => {
336
- captureException(err);
337
- console.error("Build failed:", err);
338
- process.exit(1);
339
- });
396
+ if (!process.env.VITEST) {
397
+ initSentry()
398
+ .then(() => build())
399
+ .catch((err) => {
400
+ captureException(err);
401
+ console.error("Build failed:", err);
402
+ process.exit(1);
403
+ });
404
+ }
@@ -6,14 +6,39 @@ export interface HtmlShellOptions {
6
6
  css: string;
7
7
  js: string;
8
8
  nonce?: string;
9
+ /**
10
+ * Content-Security-Policy value (from `cspHeader()` in security.ts).
11
+ * When provided, injects `<meta http-equiv="Content-Security-Policy">` in `<head>`.
12
+ * Essential for static deployment where HTTP headers cannot be set.
13
+ */
14
+ csp?: string;
9
15
  extraScripts?: string;
10
16
  themeCss?: string;
17
+ /** HTML strings to inject before `</head>` (from plugin `injectHead` hooks). */
18
+ headExtra?: string[];
19
+ /** HTML strings to inject before `</body>`, after the main script (from plugin `injectBody` hooks). */
20
+ bodyExtra?: string[];
11
21
  }
12
22
 
13
23
  export function htmlShell(opts: HtmlShellOptions): string {
14
- const { title, description, body, favicon, css, js, nonce, extraScripts, themeCss } = opts;
24
+ const {
25
+ title,
26
+ description,
27
+ body,
28
+ favicon,
29
+ css,
30
+ js,
31
+ nonce,
32
+ csp,
33
+ extraScripts,
34
+ themeCss,
35
+ headExtra,
36
+ bodyExtra,
37
+ } = opts;
15
38
  const nonceAttr = nonce ? ` nonce="${Bun.escapeHTML(nonce)}"` : "";
16
39
  const themeStyle = themeCss ? `\n <style${nonceAttr}>${Bun.escapeHTML(themeCss)}</style>` : "";
40
+ const headInjection = headExtra?.length ? `\n ${headExtra.join("\n ")}` : "";
41
+ const bodyInjection = bodyExtra?.length ? `\n ${bodyExtra.join("\n ")}` : "";
17
42
  return `<!DOCTYPE html>
18
43
  <html lang="en">
19
44
  <head>
@@ -23,15 +48,39 @@ export function htmlShell(opts: HtmlShellOptions): string {
23
48
  <meta name="description" content="${Bun.escapeHTML(description)}">
24
49
  <link rel="icon" type="image/x-icon" href="${Bun.escapeHTML(favicon)}">${themeStyle}
25
50
  <link rel="stylesheet" href="/assets/${Bun.escapeHTML(css)}">
26
- <script${nonceAttr}>try{if(localStorage.getItem("theme")==="dark")document.documentElement.classList.add("dark")}catch(e){}</script>
51
+ ${csp ? `<meta http-equiv="Content-Security-Policy" content="${Bun.escapeHTML(csp)}">` : ""}
52
+ <script${nonceAttr}>try{if(localStorage.getItem("theme")==="dark")document.documentElement.classList.add("dark")}catch(e){}</script>${headInjection}
27
53
  </head>
28
54
  <body>
29
55
  <div id="root">${body}</div>
30
- <script${nonceAttr} src="/assets/${Bun.escapeHTML(js)}"></script>${extraScripts ? `\n ${extraScripts}` : ""}
56
+ <script${nonceAttr} src="/assets/${Bun.escapeHTML(js)}"></script>${extraScripts ? `\n ${extraScripts}` : ""}${bodyInjection}
31
57
  </body>
32
58
  </html>`;
33
59
  }
34
60
 
61
+ export function errorHtml(message: string, stack?: string): string {
62
+ const msg = Bun.escapeHTML(message || "Unknown error");
63
+ const st = Bun.escapeHTML(stack || "");
64
+ return `<!DOCTYPE html>
65
+ <html lang="en">
66
+ <head>
67
+ <meta charset="utf-8">
68
+ <title>Server Error</title>
69
+ <style>
70
+ *{margin:0;padding:0;box-sizing:border-box}
71
+ body{padding:2rem;font-family:ui-monospace,monospace;background:#1a1a2e;color:#e0e0e0}
72
+ h1{color:#ff6b6b;font-size:1.5rem;margin-bottom:1rem}
73
+ 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}
74
+ .msg{color:#ff6b6b;font-weight:bold}
75
+ </style>
76
+ </head>
77
+ <body>
78
+ <h1>🔥 Server Error</h1>
79
+ <pre><span class="msg">${msg}</span>${st ? `\n\n${st}` : ""}</pre>
80
+ </body>
81
+ </html>`;
82
+ }
83
+
35
84
  export function hmrScript(nonce: string): string {
36
85
  return `<script nonce="${Bun.escapeHTML(nonce)}">
37
86
  (function(){
package/.docu/node/mdx.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import type { Pluggable } from "unified";
2
3
  import {
3
4
  serialize,
4
5
  extractTocsFromRawMdx,
@@ -19,10 +20,21 @@ export interface MdxResult {
19
20
  tocs: ReturnType<typeof extractTocsFromRawMdx>;
20
21
  }
21
22
 
23
+ /**
24
+ * Compile MDX/MD content into a React element and compiled source.
25
+ *
26
+ * @param rawMdx - Raw MDX/MD file content
27
+ * @param filePath - Relative file path for git date lookup
28
+ * @param gitDates - Optional pre-fetched git last-modified map
29
+ * @param remarkPlugins - Additional remark plugins (merged after defaults, optional)
30
+ * @param rehypePlugins - Additional rehype plugins (merged after defaults, optional)
31
+ */
22
32
  export async function compileMdx(
23
33
  rawMdx: string,
24
34
  filePath: string,
25
- gitDates?: Map<string, string>
35
+ gitDates?: Map<string, string>,
36
+ remarkPlugins?: Pluggable[],
37
+ rehypePlugins?: Pluggable[]
26
38
  ): Promise<MdxResult> {
27
39
  const tocs = extractTocsFromRawMdx(rawMdx);
28
40
  const { frontmatter, strippedContent } = extractFrontmatterWithContent<{
@@ -31,10 +43,16 @@ export async function compileMdx(
31
43
  date?: string;
32
44
  }>(rawMdx);
33
45
 
46
+ const defaultRemark = createDefaultRemarkPlugins();
47
+ const defaultRehype = createDefaultRehypePlugins();
48
+
49
+ const finalRemark = remarkPlugins?.length ? [...defaultRemark, ...remarkPlugins] : defaultRemark;
50
+ const finalRehype = rehypePlugins?.length ? [...defaultRehype, ...rehypePlugins] : defaultRehype;
51
+
34
52
  const serialized = await serialize(strippedContent, {
35
53
  mdxOptions: {
36
- rehypePlugins: createDefaultRehypePlugins(),
37
- remarkPlugins: createDefaultRemarkPlugins(),
54
+ rehypePlugins: finalRehype,
55
+ remarkPlugins: finalRemark,
38
56
  },
39
57
  });
40
58