@docubook/flame 1.4.4 → 1.5.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 (53) hide show
  1. package/.docu/lib/build.deno.js +11 -0
  2. package/.docu/lib/build.impl-7KJ4ZTAJ.js +12 -0
  3. package/.docu/lib/build.node.js +10 -0
  4. package/.docu/lib/chunk-7ZEUL6PR.js +383 -0
  5. package/.docu/lib/chunk-AI7QAMMZ.js +2410 -0
  6. package/.docu/lib/chunk-E4OIJWCU.js +368 -0
  7. package/.docu/lib/chunk-IR5TVJOV.js +79 -0
  8. package/.docu/lib/chunk-J5NMYSBJ.js +59 -0
  9. package/.docu/lib/chunk-PTRZ2S2C.js +298 -0
  10. package/.docu/lib/chunk-RE4NGTMT.js +185 -0
  11. package/.docu/lib/chunk-TE52TIEW.js +92 -0
  12. package/.docu/lib/clean.js +32 -0
  13. package/.docu/lib/deploy.deno.js +13 -0
  14. package/.docu/lib/deploy.node.js +10 -0
  15. package/.docu/lib/preview.deno.js +10 -0
  16. package/.docu/lib/preview.node.js +10 -0
  17. package/.docu/lib/server.deno.js +11 -0
  18. package/.docu/lib/server.node.js +11 -0
  19. package/.docu/node/build.deno.ts +7 -0
  20. package/.docu/node/build.impl.ts +416 -0
  21. package/.docu/node/build.node.ts +3 -0
  22. package/.docu/node/deploy.deno.ts +11 -0
  23. package/.docu/node/deploy.node.ts +6 -0
  24. package/.docu/node/deploy.shared.ts +85 -0
  25. package/.docu/node/deploy.ts +11 -0
  26. package/.docu/node/escapeHtml.ts +18 -0
  27. package/.docu/node/git.ts +79 -0
  28. package/.docu/node/html.shared.ts +110 -0
  29. package/.docu/node/hydrate.node.ts +276 -0
  30. package/.docu/node/hydrate.ts +17 -31
  31. package/.docu/node/mdx.ts +1 -1
  32. package/.docu/node/paths.ts +24 -0
  33. package/.docu/node/plugin-builder.ts +6 -2
  34. package/.docu/node/plugin.ts +11 -2
  35. package/.docu/node/preview.deno.ts +4 -0
  36. package/.docu/node/preview.impl.ts +96 -0
  37. package/.docu/node/preview.node.ts +4 -0
  38. package/.docu/node/security.ts +5 -0
  39. package/.docu/node/server-routes.ts +4 -4
  40. package/.docu/node/server.deno.ts +4 -0
  41. package/.docu/node/server.impl.ts +184 -0
  42. package/.docu/node/server.node.ts +4 -0
  43. package/.docu/styles/globals.css +20 -5
  44. package/README.md +57 -506
  45. package/bin/cli.js +99 -14
  46. package/bin/compile-lib.mjs +67 -0
  47. package/package.json +9 -7
  48. package/template/docs/getting-started/configuration.mdx +18 -0
  49. package/template/docs/getting-started/overview.mdx +50 -0
  50. package/template/docs/guide/deployment.mdx +27 -0
  51. package/template/docs/guide/routing.mdx +25 -0
  52. package/template/docs/index.mdx +8 -205
  53. package/template/docu.json +32 -1
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Runtime-neutral HTML shell — identical templates to `html.ts` (Bun-only,
3
+ * protected) but escaping via the pure `escapeHtml()` so it runs on
4
+ * Node.js and Deno as well as Bun. Shared modules (`server-routes.ts`) and
5
+ * the non-Bun entries import from here; `build.ts`/`server.ts` keep using
6
+ * `html.ts` untouched.
7
+ */
8
+
9
+ import { escapeHtml } from "./escapeHtml";
10
+
11
+ export interface HtmlShellOptions {
12
+ title: string;
13
+ description: string;
14
+ body: string;
15
+ favicon: string;
16
+ css: string;
17
+ js: string;
18
+ nonce?: string;
19
+ /**
20
+ * Content-Security-Policy value (from `cspHeader()` in security.ts).
21
+ * When provided, injects `<meta http-equiv="Content-Security-Policy">` in `<head>`.
22
+ * Essential for static deployment where HTTP headers cannot be set.
23
+ */
24
+ csp?: string;
25
+ extraScripts?: string;
26
+ themeCss?: string;
27
+ /** Depth from document root (0=root, 1=subdir, 2=sub/subdir). Used for relative asset paths. */
28
+ depth?: number;
29
+ /** HTML strings to inject before `</head>` (from plugin `injectHead` hooks). */
30
+ headExtra?: string[];
31
+ /** HTML strings to inject before `</body>`, after the main script (from plugin `injectBody` hooks). */
32
+ bodyExtra?: string[];
33
+ }
34
+
35
+ export function htmlShell(opts: HtmlShellOptions): string {
36
+ const {
37
+ title,
38
+ description,
39
+ body,
40
+ favicon,
41
+ css,
42
+ js,
43
+ nonce,
44
+ csp,
45
+ extraScripts,
46
+ themeCss,
47
+ depth = 0,
48
+ headExtra,
49
+ bodyExtra,
50
+ } = opts;
51
+ const nonceAttr = nonce ? ` nonce="${escapeHtml(nonce)}"` : "";
52
+ const themeStyle = themeCss ? `\n <style${nonceAttr}>${escapeHtml(themeCss)}</style>` : "";
53
+ const headInjection = headExtra?.length ? `\n ${headExtra.join("\n ")}` : "";
54
+ const bodyInjection = bodyExtra?.length ? `\n ${bodyExtra.join("\n ")}` : "";
55
+ const depthPrefix = depth === 0 ? "" : "../".repeat(depth);
56
+ const assetPrefix = depthPrefix + "assets/";
57
+ const resolvePath = (path: string) => (path.startsWith("/") ? depthPrefix + path.slice(1) : path);
58
+ return `<!DOCTYPE html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="UTF-8">
62
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
63
+ <title>${escapeHtml(title)}</title>
64
+ <meta name="description" content="${escapeHtml(description)}">
65
+ ${favicon ? `<link rel="icon" type="image/x-icon" href="${escapeHtml(resolvePath(favicon))}">` : ""}${themeStyle}
66
+ <link rel="stylesheet" href="${escapeHtml(assetPrefix + css)}">
67
+ ${csp ? `<meta http-equiv="Content-Security-Policy" content="${escapeHtml(csp)}">` : ""}
68
+ <script${nonceAttr}>try{if(localStorage.getItem("theme")==="dark")document.documentElement.classList.add("dark")}catch(e){}</script>${headInjection}
69
+ </head>
70
+ <body>
71
+ <div id="root">${body}</div>
72
+ <script type="module"${nonceAttr} src="${escapeHtml(assetPrefix + js)}"></script>${extraScripts ? `\n ${extraScripts}` : ""}${bodyInjection}
73
+ </body>
74
+ </html>`;
75
+ }
76
+
77
+ export function errorHtml(message: string, stack?: string): string {
78
+ const msg = escapeHtml(message || "Unknown error");
79
+ const st = escapeHtml(stack || "");
80
+ return `<!DOCTYPE html>
81
+ <html lang="en">
82
+ <head>
83
+ <meta charset="utf-8">
84
+ <title>Server Error</title>
85
+ <style>
86
+ *{margin:0;padding:0;box-sizing:border-box}
87
+ body{padding:2rem;font-family:ui-monospace,monospace;background:#1a1a2e;color:#e0e0e0}
88
+ h1{color:#ff6b6b;font-size:1.5rem;margin-bottom:1rem}
89
+ 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}
90
+ .msg{color:#ff6b6b;font-weight:bold}
91
+ </style>
92
+ </head>
93
+ <body>
94
+ <h1>🔥 Server Error</h1>
95
+ <pre><span class="msg">${msg}</span>${st ? `\n\n${st}` : ""}</pre>
96
+ </body>
97
+ </html>`;
98
+ }
99
+
100
+ export function hmrScript(nonce: string): string {
101
+ return `<script nonce="${escapeHtml(nonce)}">
102
+ (function(){
103
+ const es = new EventSource("/__hmr");
104
+ es.onmessage = function(e) {
105
+ if (e.data === "reload") window.location.reload();
106
+ };
107
+ es.onerror = function() { es.close(); setTimeout(() => { window.location.reload(); }, 2000); };
108
+ })();
109
+ </script>`;
110
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Client bundle builder for Node/Deno runtimes.
3
+ *
4
+ * Wraps esbuild with the plugins needed to produce a browser-ready client
5
+ * bundle (JS + CSS) from the same components Bun.build handles natively.
6
+ * Theme helpers (getThemeConfig, buildThemeCss, computeInlineThemeCss)
7
+ * are re-exported from `hydrate.ts` — that module's `buildClientBundle`
8
+ * is Bun-only and unused here.
9
+ */
10
+
11
+ import { execFile } from "node:child_process";
12
+ import { builtinModules, createRequire } from "node:module";
13
+ import { basename, dirname, join, resolve } from "node:path";
14
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
15
+ import { promisify } from "node:util";
16
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
17
+ import { createHash } from "node:crypto";
18
+ import {
19
+ ASSETS_DIR,
20
+ FRAMEWORK_ROOT,
21
+ cleanOldBundles,
22
+ LIB_DIR,
23
+ STYLES_DIR,
24
+ loadDocuConfig,
25
+ } from "./paths";
26
+ import { buildThemeCss, getThemeConfig } from "./hydrate";
27
+ import { resolveRoutes } from "./fs-scanner";
28
+ import { normalizeImporterPath } from "./security";
29
+ import type { DocuConfig, DocuRoute } from "./types";
30
+
31
+ /** Extract Lucide icon names from user docu.json configuration. */
32
+ function extractConfigIcons(config: DocuConfig): string[] {
33
+ const icons: string[] = [];
34
+ const pushIf = (s: string | undefined) => {
35
+ if (s) icons.push(s);
36
+ };
37
+ config.home?.hero?.actions?.forEach((a) => pushIf(a.icon));
38
+ config.home?.features?.forEach((f) => pushIf(f.icon));
39
+ (function walk(routes: DocuRoute[]) {
40
+ for (const r of routes) {
41
+ pushIf(r.context?.icon);
42
+ if (r.items) walk(r.items);
43
+ }
44
+ })(config.routes ?? []);
45
+ return [...new Set(icons.filter((n) => /^[A-Z]/.test(n)))];
46
+ }
47
+
48
+ export { buildThemeCss, computeInlineThemeCss, getThemeConfig } from "./hydrate";
49
+
50
+ const execFileAsync = promisify(execFile);
51
+
52
+ /** Resolve the @tailwindcss/cli binary path from the installed package. */
53
+ function resolveTailwindBin(): string {
54
+ const require = createRequire(import.meta.url);
55
+ const pkgPath = require.resolve("@tailwindcss/cli/package.json");
56
+ const pkg = require(pkgPath) as { bin: string | Record<string, string> };
57
+ const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin.tailwindcss;
58
+ return join(dirname(pkgPath), binRel);
59
+ }
60
+
61
+ /** Run Tailwind CLI to produce minified CSS. */
62
+ async function runTailwind(outputCss: string): Promise<void> {
63
+ const bin = resolveTailwindBin();
64
+ const twArgs = ["-i", join(STYLES_DIR, "globals.css"), "-o", outputCss, "--minify"];
65
+ const isDeno = "Deno" in globalThis;
66
+ const args = isDeno ? ["run", "-A", bin, ...twArgs] : [bin, ...twArgs];
67
+ try {
68
+ await execFileAsync(process.execPath, args, { maxBuffer: 16 * 1024 * 1024 });
69
+ } catch (err) {
70
+ const stderr = (err as { stderr?: string }).stderr ?? String(err);
71
+ throw new Error(`Tailwind CSS build failed:\n${stderr}`, { cause: err });
72
+ }
73
+ }
74
+
75
+ const NODE_BUILTINS_RE = new RegExp(
76
+ `^(node:.*|${builtinModules.map((m) => m.replace(/\//g, "\\/")).join("|")})$`
77
+ );
78
+
79
+ let lucideRealEntry: string | undefined;
80
+
81
+ /** Resolve the real lucide-react entry path once (cached). */
82
+ function getLucideRealEntry(): string {
83
+ if (!lucideRealEntry) {
84
+ lucideRealEntry = createRequire(import.meta.url).resolve("lucide-react");
85
+ }
86
+ return lucideRealEntry;
87
+ }
88
+
89
+ const LUCIDE_IMPORT_RE = /import\s*\{([^}]+)\}\s*from\s*["']lucide-react["']/g;
90
+ const LUCIDE_ICON_RE = /^[A-Z]/;
91
+
92
+ /** Walk a directory scanning JS/TS/TSX files for `lucide-react` named imports. */
93
+ function scanDirLucideIcons(dir: string, set: Set<string>): void {
94
+ if (!existsSync(dir)) return;
95
+ try {
96
+ const entries = readdirSync(dir, { withFileTypes: true });
97
+ for (const e of entries) {
98
+ const full = join(dir, e.name);
99
+ if (e.isDirectory()) {
100
+ if (e.name !== "node_modules") scanDirLucideIcons(full, set);
101
+ } else if (/\.(js|ts|tsx)$/.test(e.name)) {
102
+ const content = readFileSync(full, "utf-8");
103
+ for (const m of content.matchAll(LUCIDE_IMPORT_RE)) {
104
+ for (const s of m[1].split(",")) {
105
+ const name = s
106
+ .trim()
107
+ .split(/\s+as\s+/)[0]
108
+ .trim();
109
+ if (LUCIDE_ICON_RE.test(name)) set.add(name);
110
+ }
111
+ }
112
+ }
113
+ }
114
+ } catch {
115
+ /* skip unreadable dirs */
116
+ }
117
+ }
118
+
119
+ /** Collect every lucide icon name imported across flame sources and deps. */
120
+ function collectAllLucideIcons(): string[] {
121
+ const icons = new Set<string>();
122
+ // Scan flame's own components and pages
123
+ scanDirLucideIcons(join(FRAMEWORK_ROOT, ".docu/components"), icons);
124
+ scanDirLucideIcons(join(FRAMEWORK_ROOT, ".docu/pages"), icons);
125
+ // Scan dependency dist directories. In development (monorepo) they live under
126
+ // packages/; in production they are under node_modules/@docubook/.
127
+ const depDirs = [
128
+ join(FRAMEWORK_ROOT, "..", "mdx-content", "dist"),
129
+ join(FRAMEWORK_ROOT, "..", "ui-react", "dist"),
130
+ join(FRAMEWORK_ROOT, "..", "core", "dist"),
131
+ join(FRAMEWORK_ROOT, "..", "runt", "dist"),
132
+ join(FRAMEWORK_ROOT, "..", "themes-colors", "dist"),
133
+ ];
134
+ for (const d of depDirs) scanDirLucideIcons(resolve(d), icons);
135
+ return [...icons];
136
+ }
137
+
138
+ /** Build the client JS bundle and Tailwind CSS. */
139
+ export async function buildClientBundle(): Promise<{ js: string; css: string }> {
140
+ await mkdir(ASSETS_DIR, { recursive: true });
141
+ await cleanOldBundles();
142
+
143
+ const nodeEnv = process.env.NODE_ENV || "development";
144
+ const esbuild = await import("esbuild");
145
+ const { build } = esbuild;
146
+
147
+ const entryPath = join(LIB_DIR, "client.ts");
148
+ const workingDir = process.cwd();
149
+ let result: Awaited<ReturnType<typeof build>>;
150
+ try {
151
+ result = await build({
152
+ entryPoints: [entryPath],
153
+ bundle: true,
154
+ outdir: ASSETS_DIR,
155
+ entryNames: "client-[hash]",
156
+ chunkNames: "chunks/[name]-[hash]",
157
+ platform: "browser",
158
+ format: "esm",
159
+ splitting: true,
160
+ minify: nodeEnv === "production",
161
+ define: { "process.env.NODE_ENV": JSON.stringify(nodeEnv) },
162
+ jsx: "automatic",
163
+ jsxDev: nodeEnv !== "production",
164
+ metafile: true,
165
+ logLevel: "silent",
166
+ plugins: [
167
+ {
168
+ name: "node-builtin-stub",
169
+ setup(build) {
170
+ build.onResolve({ filter: NODE_BUILTINS_RE }, (args) => ({
171
+ path: args.path,
172
+ namespace: "node-stub",
173
+ }));
174
+ build.onLoad({ filter: /.*/, namespace: "node-stub" }, () => ({
175
+ contents: "module.exports = {};",
176
+ loader: "js",
177
+ }));
178
+ },
179
+ },
180
+ {
181
+ name: "lucide-optimize",
182
+ setup(build) {
183
+ build.onResolve({ filter: /^lucide-react$/ }, (args) => {
184
+ // Imports from within our virtual module go to the real package.
185
+ if (args.namespace === "lucide-virt") {
186
+ return { path: getLucideRealEntry(), namespace: "file" };
187
+ }
188
+ // Files that do dynamic name lookups (namespace import)
189
+ // need the full barrel — bypass the virtual module.
190
+ if (args.importer) {
191
+ const normalized = normalizeImporterPath(args.importer);
192
+ if (
193
+ normalized.endsWith("/.docu/components/Lucide.tsx") ||
194
+ normalized.includes("/mdx-content/dist/")
195
+ ) {
196
+ return { path: getLucideRealEntry(), namespace: "file" };
197
+ }
198
+ }
199
+ return { path: args.path, namespace: "lucide-virt" };
200
+ });
201
+ build.onLoad({ filter: /.*/, namespace: "lucide-virt" }, () => {
202
+ const scanned = collectAllLucideIcons();
203
+ const configured = extractConfigIcons(loadDocuConfig());
204
+ const allIcons = [...new Set([...scanned, ...configured])];
205
+ return {
206
+ contents: `export { ${allIcons.join(", ")} } from "lucide-react";`,
207
+ loader: "js",
208
+ };
209
+ });
210
+ },
211
+ },
212
+ {
213
+ name: "docu-config",
214
+ setup(build) {
215
+ build.onResolve({ filter: /docu\.json$/ }, (args) => ({
216
+ path: args.path,
217
+ namespace: "docu-config",
218
+ }));
219
+ build.onLoad({ filter: /.*/, namespace: "docu-config" }, () => {
220
+ const config = loadDocuConfig();
221
+ const resolved = {
222
+ ...config,
223
+ routes: resolveRoutes(config.routes as DocuRoute[] | undefined),
224
+ };
225
+ return { contents: JSON.stringify(resolved), loader: "json" };
226
+ });
227
+ },
228
+ },
229
+
230
+ ],
231
+ });
232
+ } finally {
233
+ // esbuild's service child process keeps Deno's event loop alive after a
234
+ // one-shot build (node-compat gap) — stop it so `flame build` exits.
235
+ await esbuild.stop();
236
+ }
237
+
238
+ // With splitting enabled esbuild emits the entry plus shared/dynamic chunks.
239
+ // `entryPoint` is set on the user entry AND on every dynamic-import chunk
240
+ // (esbuild treats dynamic imports as entry points), so match the resolved
241
+ // source path instead of grabbing the first truthy `entryPoint`.
242
+ const { outputs } = result.metafile!;
243
+ const jsOutput = Object.keys(outputs).find((p) => {
244
+ const o = outputs[p];
245
+ return o.entryPoint && resolve(workingDir, o.entryPoint) === entryPath;
246
+ });
247
+ if (!jsOutput) {
248
+ throw new Error("Client bundle produced no output files");
249
+ }
250
+ const jsFile = basename(jsOutput);
251
+
252
+ const tmpCss = join(ASSETS_DIR, "_tmp.css");
253
+ await runTailwind(tmpCss);
254
+
255
+ let cssContent = await readFile(tmpCss, "utf-8");
256
+
257
+ try {
258
+ const themeColors = getThemeConfig();
259
+ if (themeColors) {
260
+ cssContent = buildThemeCss(cssContent, themeColors);
261
+ }
262
+ } catch (err) {
263
+ console.warn(
264
+ `[flame] Failed to resolve theme config, falling back to globals.css only: ${err instanceof Error ? err.message : String(err)}`
265
+ );
266
+ }
267
+
268
+ const cssHash = createHash("md5").update(cssContent).digest("hex").slice(0, 8);
269
+ const cssFile = `client-${cssHash}.css`;
270
+ await writeFile(join(ASSETS_DIR, cssFile), cssContent);
271
+ await unlink(tmpCss);
272
+
273
+ await writeFile(join(ASSETS_DIR, "manifest.json"), JSON.stringify({ js: jsFile, css: cssFile }));
274
+
275
+ return { js: jsFile, css: cssFile };
276
+ }
@@ -1,28 +1,13 @@
1
1
  import { join } from "node:path";
2
- import { mkdir, readdir, unlink } from "node:fs/promises";
2
+ import { mkdir, unlink } from "node:fs/promises";
3
3
  import { resolveTheme, generateThemeCss, presetRegistry } from "@docubook/themes-colors";
4
- import { ASSETS_DIR, LIB_DIR, STYLES_DIR, loadDocuConfig } from "./paths";
4
+ import { ASSETS_DIR, cleanOldBundles, LIB_DIR, STYLES_DIR, loadDocuConfig } from "./paths";
5
5
  import { resolveRoutes } from "./fs-scanner";
6
6
  import type { DocuRoute } from "./types";
7
7
  import type { ThemeConfig } from "@docubook/themes-colors";
8
8
 
9
9
  const themeRegistry = presetRegistry;
10
10
 
11
- async function cleanOldBundles() {
12
- try {
13
- const files = await readdir(ASSETS_DIR);
14
- for (const file of files) {
15
- if (file.startsWith("client.") || file.startsWith("client-")) {
16
- await unlink(join(ASSETS_DIR, file));
17
- }
18
- }
19
- } catch (err) {
20
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
21
- console.error("Failed to clean old bundles:", (err as Error).message);
22
- }
23
- }
24
- }
25
-
26
11
  /**
27
12
  * Read the effective theme config with this priority:
28
13
  * 1. FLAME_THEME env var (CLI --theme flag)
@@ -78,7 +63,13 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
78
63
  const result = await Bun.build({
79
64
  entrypoints: [join(LIB_DIR, "client.ts")],
80
65
  outdir: ASSETS_DIR,
81
- naming: "client-[hash].[ext]",
66
+ format: "esm",
67
+ splitting: true,
68
+ naming: {
69
+ entry: "client-[hash].[ext]",
70
+ chunk: "chunks/[name]-[hash].[ext]",
71
+ asset: "[name]-[hash].[ext]",
72
+ },
82
73
  target: "browser",
83
74
  minify: nodeEnv === "production",
84
75
  optimizeImports: ["lucide-react"],
@@ -101,18 +92,7 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
101
92
  });
102
93
  },
103
94
  },
104
- {
105
- name: "mdx-jsx-runtime",
106
- setup(build) {
107
- build.onLoad({ filter: /next-mdx-remote[/\\].*jsx-runtime/ }, () => {
108
- const source =
109
- nodeEnv === "production"
110
- ? `module.exports.jsxRuntime = require("react/jsx-runtime");`
111
- : `module.exports.jsxRuntime = require("react/jsx-dev-runtime");`;
112
- return { contents: source, loader: "js" };
113
- });
114
- },
115
- },
95
+
116
96
  ],
117
97
  });
118
98
 
@@ -124,7 +104,13 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
124
104
  if (!result.outputs[0]) {
125
105
  throw new Error("Client bundle produced no output files");
126
106
  }
127
- const jsFile = result.outputs[0].path.split("/").pop()!;
107
+ // With splitting enabled Bun emits entry + chunk artifacts; select the entry
108
+ // explicitly rather than by position (chunks may precede it in the array).
109
+ const entry = result.outputs.find((o) => o.kind === "entry-point");
110
+ if (!entry) {
111
+ throw new Error("Client bundle produced no entry-point output");
112
+ }
113
+ const jsFile = entry.path.split("/").pop()!;
128
114
  const tmpCss = join(ASSETS_DIR, "_tmp.css");
129
115
  const proc = Bun.spawn(
130
116
  [
package/.docu/node/mdx.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  MDXRemote,
10
10
  } from "@docubook/core";
11
11
  import { createMdxComponents } from "@docubook/mdx-content";
12
- import { getGitLastModified, getGitLastModifiedBatch } from "./utils";
12
+ import { getGitLastModified, getGitLastModifiedBatch } from "./git";
13
13
 
14
14
  /**
15
15
  * Return the value with `.html` appended, or null if the value should be left
@@ -1,5 +1,6 @@
1
1
  import { resolve, join } from "node:path";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
+ import { readdir, rm, unlink } from "node:fs/promises";
3
4
  import type { DocuConfig } from "./types";
4
5
 
5
6
  /**
@@ -31,6 +32,29 @@ export const DOCU_CONFIG_PATH = join(PROJECT_ROOT, "docu.json");
31
32
  // Config singleton
32
33
  let _config: DocuConfig | null = null;
33
34
 
35
+ /** Clean stale client bundles and split chunks from a previous build. */
36
+ export async function cleanOldBundles() {
37
+ try {
38
+ const files = await readdir(ASSETS_DIR);
39
+ for (const file of files) {
40
+ if (file.startsWith("client.") || file.startsWith("client-")) {
41
+ await unlink(join(ASSETS_DIR, file));
42
+ }
43
+ }
44
+ } catch (err) {
45
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
46
+ console.error("Failed to clean old bundles:", (err as Error).message);
47
+ }
48
+ }
49
+ // Stale split chunks accumulate across builds (content-hashed names); drop
50
+ // the whole chunks dir so only the new build's chunks remain.
51
+ try {
52
+ await rm(join(ASSETS_DIR, "chunks"), { recursive: true, force: true });
53
+ } catch (err) {
54
+ console.error("Failed to clean old chunks:", (err as Error).message);
55
+ }
56
+ }
57
+
34
58
  export function loadDocuConfig(): DocuConfig {
35
59
  if (_config) return _config;
36
60
  if (!existsSync(DOCU_CONFIG_PATH)) {
@@ -191,9 +191,13 @@ export class BuildPluginBuilder implements PluginBuilder {
191
191
  * @param callback - Receives config and page metadata array. May return a Promise.
192
192
  *
193
193
  * @example
194
- * build.onEnd((config, pages) => {
194
+ * build.onEnd(async (config, pages) => {
195
195
  * const xml = generateSitemap(pages, config.meta.baseURL);
196
- * await Bun.write(".docu/dist/sitemap.xml", xml);
196
+ * const out = ".docu/dist/sitemap.xml";
197
+ * // Bun.write on Bun for speed, writeFile on Node/Deno
198
+ * await (typeof Bun !== "undefined"
199
+ * ? Bun.write(out, xml)
200
+ * : writeFile(out, xml));
197
201
  * });
198
202
  */
199
203
  onEnd(callback: (config: DocuConfig, pages: PageMeta[]) => Awaitable<void>): void {
@@ -66,6 +66,11 @@ export type { DocuConfig };
66
66
  * - Plugins register lifecycle callbacks through typed methods
67
67
  * - Callbacks are executed sequentially in registration order
68
68
  * - `config` provides read-only access to the resolved build config
69
+ *
70
+ * Hooks run on Bun, Node, and Deno — use `node:` APIs
71
+ * (e.g. `node:fs/promises`) for portability, or guard `Bun.*`
72
+ * usage behind a `typeof Bun !== "undefined"` check to keep
73
+ * Bun's faster natives when available.
69
74
  */
70
75
  export interface PluginBuilder {
71
76
  /** Resolved DocuBook configuration (read-only after setup phase). */
@@ -93,9 +98,13 @@ export interface PluginBuilder {
93
98
  * @param callback - Receives config and aggregated page metadata. May return a Promise.
94
99
  *
95
100
  * @example
96
- * build.onEnd((config, pages) => {
101
+ * build.onEnd(async (config, pages) => {
97
102
  * const xml = generateSitemap(pages, config.meta.baseURL);
98
- * await Bun.write(join(DIST_DIR, "sitemap.xml"), xml);
103
+ * const out = join(DIST_DIR, "sitemap.xml");
104
+ * // Bun.write on Bun for speed, writeFile on Node/Deno
105
+ * await (typeof Bun !== "undefined"
106
+ * ? Bun.write(out, xml)
107
+ * : writeFile(out, xml));
99
108
  * });
100
109
  */
101
110
  onEnd(callback: (config: DocuConfig, pages: PageMeta[]) => void | Promise<void>): void;
@@ -0,0 +1,4 @@
1
+ import { denoAdapter } from "@docubook/runt";
2
+ import { runPreview } from "./preview.impl";
3
+
4
+ await runPreview(denoAdapter);
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Runtime-neutral preview server — mirror of `preview.ts` (Bun-only,
3
+ * protected) driven by a `RuntimeAdapter` and `node:fs` file reads.
4
+ */
5
+
6
+ import { existsSync, statSync, readFileSync } from "node:fs";
7
+ import { readFile } from "node:fs/promises";
8
+ import { resolve } from "node:path";
9
+ import type { RuntimeAdapter, ServerHandle } from "@docubook/runt";
10
+ import { logger } from "./logger";
11
+ import { DIST_DIR } from "./paths";
12
+ import { getContentType } from "./utils";
13
+ import { SECURITY_HEADERS, generateNonce, cspHeader, injectNonce } from "./security";
14
+
15
+ function resolveFile(pathname: string): string | null {
16
+ const path = pathname.slice(1);
17
+ const isWithinDist = (p: string) => p === DIST_DIR || p.startsWith(DIST_DIR + "/");
18
+
19
+ if (!path.includes(".")) {
20
+ const withIndex = resolve(DIST_DIR, path, "index.html");
21
+ if (isWithinDist(withIndex) && existsSync(withIndex)) return withIndex;
22
+ const withHtml = resolve(DIST_DIR, path + ".html");
23
+ if (isWithinDist(withHtml) && existsSync(withHtml)) return withHtml;
24
+ }
25
+
26
+ const exact = resolve(DIST_DIR, path);
27
+ if (!isWithinDist(exact)) return null;
28
+ try {
29
+ if (statSync(exact).isFile()) return exact;
30
+ } catch (err) {
31
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export async function runPreview(adapter: RuntimeAdapter): Promise<ServerHandle | null> {
37
+ const PORT = parseInt(process.env.PORT || "4173", 10);
38
+
39
+ logger.buildStart();
40
+
41
+ if (!existsSync(DIST_DIR)) {
42
+ logger.spinner.start("Checking build output...");
43
+ logger.spinner.info("dist not found. Run \x1b[1mflame build\x1b[0m first.");
44
+ process.exit(0);
45
+ }
46
+
47
+ const notFoundPath = resolve(DIST_DIR, "404.html");
48
+
49
+ const handle = await adapter.serve(
50
+ async (req) => {
51
+ const url = new URL(req.url);
52
+ const pathname = decodeURIComponent(url.pathname);
53
+
54
+ const filePath = resolveFile(pathname);
55
+
56
+ if (filePath) {
57
+ const contentType = getContentType(filePath);
58
+ if (contentType === "text/html") {
59
+ const nonce = generateNonce();
60
+ const html = await readFile(filePath, "utf-8");
61
+ const modified = injectNonce(html, nonce);
62
+ return new Response(modified, {
63
+ headers: {
64
+ "Content-Type": "text/html",
65
+ ...SECURITY_HEADERS,
66
+ "Content-Security-Policy": cspHeader(nonce, true),
67
+ },
68
+ });
69
+ }
70
+ return new Response(readFileSync(filePath), {
71
+ headers: { "Content-Type": contentType },
72
+ });
73
+ }
74
+
75
+ if (existsSync(notFoundPath)) {
76
+ const nonce = generateNonce();
77
+ const html = await readFile(notFoundPath, "utf-8");
78
+ const modified = injectNonce(html, nonce);
79
+ return new Response(modified, {
80
+ status: 404,
81
+ headers: {
82
+ "Content-Type": "text/html",
83
+ ...SECURITY_HEADERS,
84
+ "Content-Security-Policy": cspHeader(nonce, true),
85
+ },
86
+ });
87
+ }
88
+
89
+ return new Response("404 - Not Found", { status: 404 });
90
+ },
91
+ { port: PORT }
92
+ );
93
+
94
+ logger.ready(handle.port);
95
+ return handle;
96
+ }
@@ -0,0 +1,4 @@
1
+ import { nodeAdapter } from "@docubook/runt";
2
+ import { runPreview } from "./preview.impl";
3
+
4
+ await runPreview(nodeAdapter);
@@ -80,6 +80,11 @@ export function isSlugSafe(slug: string, docsDir: string): boolean {
80
80
  }
81
81
  }
82
82
 
83
+ /** Normalize an esbuild importer path to a canonical forward-slash absolute form. */
84
+ export function normalizeImporterPath(importer: string): string {
85
+ return resolve(importer).replace(/\\/g, "/");
86
+ }
87
+
83
88
  export function injectNonce(html: string, nonce: string): string {
84
89
  return html.replace(/<script\b(?![^>]*\bsrc\s*=)([^>]*)>/gi, (match) => {
85
90
  if (/nonce\s*=/i.test(match)) {