@docubook/flame 1.4.3 → 1.5.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.
Files changed (55) hide show
  1. package/.docu/lib/build.deno.js +11 -0
  2. package/.docu/lib/build.impl-ST63VRTV.js +12 -0
  3. package/.docu/lib/build.node.js +10 -0
  4. package/.docu/lib/chunk-2QHMGZIL.js +2419 -0
  5. package/.docu/lib/chunk-654THQOR.js +461 -0
  6. package/.docu/lib/chunk-C6RZ2KBH.js +79 -0
  7. package/.docu/lib/chunk-HH4YXWEF.js +300 -0
  8. package/.docu/lib/chunk-J5NMYSBJ.js +59 -0
  9. package/.docu/lib/chunk-RE4NGTMT.js +185 -0
  10. package/.docu/lib/chunk-X6GYOIYZ.js +383 -0
  11. package/.docu/lib/chunk-ZOWTASXL.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-summary.ts +126 -0
  20. package/.docu/node/build.deno.ts +7 -0
  21. package/.docu/node/build.impl.ts +424 -0
  22. package/.docu/node/build.node.ts +3 -0
  23. package/.docu/node/deploy.deno.ts +11 -0
  24. package/.docu/node/deploy.node.ts +6 -0
  25. package/.docu/node/deploy.shared.ts +85 -0
  26. package/.docu/node/deploy.ts +11 -0
  27. package/.docu/node/escapeHtml.ts +18 -0
  28. package/.docu/node/git.ts +79 -0
  29. package/.docu/node/html.shared.ts +110 -0
  30. package/.docu/node/html.ts +1 -1
  31. package/.docu/node/hydrate.node.ts +287 -0
  32. package/.docu/node/hydrate.ts +16 -19
  33. package/.docu/node/mdx.ts +1 -1
  34. package/.docu/node/paths.ts +24 -0
  35. package/.docu/node/plugin-builder.ts +6 -2
  36. package/.docu/node/plugin.ts +11 -2
  37. package/.docu/node/preview.deno.ts +4 -0
  38. package/.docu/node/preview.impl.ts +96 -0
  39. package/.docu/node/preview.node.ts +4 -0
  40. package/.docu/node/security.ts +5 -0
  41. package/.docu/node/server-routes.ts +4 -4
  42. package/.docu/node/server.deno.ts +4 -0
  43. package/.docu/node/server.impl.ts +184 -0
  44. package/.docu/node/server.node.ts +4 -0
  45. package/.docu/styles/globals.css +20 -5
  46. package/README.md +57 -506
  47. package/bin/cli.js +89 -14
  48. package/bin/compile-lib.mjs +67 -0
  49. package/package.json +9 -5
  50. package/template/docs/getting-started/configuration.mdx +18 -0
  51. package/template/docs/getting-started/overview.mdx +50 -0
  52. package/template/docs/guide/deployment.mdx +27 -0
  53. package/template/docs/guide/routing.mdx +25 -0
  54. package/template/docs/index.mdx +8 -205
  55. package/template/docu.json +32 -1
@@ -0,0 +1,18 @@
1
+ const ESCAPE_RE = /[&<>"']/g;
2
+
3
+ const ESCAPE_MAP: Record<string, string> = {
4
+ "&": "&amp;",
5
+ "<": "&lt;",
6
+ ">": "&gt;",
7
+ '"': "&quot;",
8
+ "'": "&#x27;",
9
+ };
10
+
11
+ /**
12
+ * Runtime-neutral HTML escaping — same character set as `Bun.escapeHTML`
13
+ * (& < > " '). Used by the shared HTML shell so Node/Deno entries never
14
+ * touch the Bun global.
15
+ */
16
+ export function escapeHtml(input: string): string {
17
+ return input.replace(ESCAPE_RE, (ch) => ESCAPE_MAP[ch]);
18
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Runtime-neutral git helpers — same behavior as the git functions in
3
+ * `utils.ts` (Bun-only, protected) but spawning via `node:child_process`,
4
+ * which works on Bun, Node.js, and Deno. `mdx.ts` imports from here.
5
+ */
6
+
7
+ import { execFile } from "node:child_process";
8
+
9
+ function runGit(args: string[]): Promise<string> {
10
+ return new Promise((resolve, reject) => {
11
+ execFile("git", args, { maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
12
+ if (err) reject(err);
13
+ else resolve(stdout);
14
+ });
15
+ });
16
+ }
17
+
18
+ function sanitizePath(filePath: string): string | null {
19
+ const cleanPath = filePath.replace(/^\//, "");
20
+ if (!cleanPath || !/^[a-zA-Z0-9\-_/.\s]+$/.test(cleanPath) || /(^|\/)\.\.($|\/)/.test(cleanPath))
21
+ return null;
22
+ return cleanPath;
23
+ }
24
+
25
+ /** Get git last modified date for a file */
26
+ export async function getGitLastModified(filePath: string): Promise<string | null> {
27
+ const cleanPath = sanitizePath(filePath);
28
+ if (!cleanPath) return null;
29
+ try {
30
+ const text = await runGit(["log", "-1", "--format=%cI", "--", cleanPath]);
31
+ const date = text.trim();
32
+ return date || null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /** Batch git last modified dates for multiple files in a single spawn */
39
+ export async function getGitLastModifiedBatch(filePaths: string[]): Promise<Map<string, string>> {
40
+ const result = new Map<string, string>();
41
+ if (filePaths.length === 0) return result;
42
+
43
+ const safePaths: string[] = [];
44
+ for (const fp of filePaths) {
45
+ const cleanPath = sanitizePath(fp);
46
+ if (!cleanPath) {
47
+ console.warn(`[git] getGitLastModifiedBatch: skipping invalid path "${fp}"`);
48
+ continue;
49
+ }
50
+ safePaths.push(cleanPath);
51
+ }
52
+
53
+ if (safePaths.length === 0) return result;
54
+
55
+ try {
56
+ const text = await runGit([
57
+ "log",
58
+ "--format=%cI",
59
+ "--name-only",
60
+ "--diff-filter=ACMR",
61
+ ...safePaths,
62
+ ]);
63
+ let currentDate = "";
64
+
65
+ for (const line of text.split("\n")) {
66
+ const trimmed = line.trim();
67
+ if (!trimmed) continue;
68
+ if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
69
+ currentDate = trimmed;
70
+ } else if (currentDate && !result.has(trimmed)) {
71
+ result.set(trimmed, currentDate);
72
+ }
73
+ }
74
+ } catch (err) {
75
+ console.error("Failed to get git last modified batch for", filePaths, err);
76
+ }
77
+
78
+ return result;
79
+ }
@@ -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
+ }
@@ -59,7 +59,7 @@ export function htmlShell(opts: HtmlShellOptions): string {
59
59
  </head>
60
60
  <body>
61
61
  <div id="root">${body}</div>
62
- <script${nonceAttr} src="${Bun.escapeHTML(assetPrefix + js)}"></script>${extraScripts ? `\n ${extraScripts}` : ""}${bodyInjection}
62
+ <script type="module"${nonceAttr} src="${Bun.escapeHTML(assetPrefix + js)}"></script>${extraScripts ? `\n ${extraScripts}` : ""}${bodyInjection}
63
63
  </body>
64
64
  </html>`;
65
65
  }
@@ -0,0 +1,287 @@
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
+ name: "mdx-jsx-runtime",
231
+ setup(build) {
232
+ build.onLoad({ filter: /next-mdx-remote[/\\].*jsx-runtime/ }, () => {
233
+ const source =
234
+ nodeEnv === "production"
235
+ ? `module.exports.jsxRuntime = require("react/jsx-runtime");`
236
+ : `module.exports.jsxRuntime = require("react/jsx-dev-runtime");`;
237
+ return { contents: source, loader: "js" };
238
+ });
239
+ },
240
+ },
241
+ ],
242
+ });
243
+ } finally {
244
+ // esbuild's service child process keeps Deno's event loop alive after a
245
+ // one-shot build (node-compat gap) — stop it so `flame build` exits.
246
+ await esbuild.stop();
247
+ }
248
+
249
+ // With splitting enabled esbuild emits the entry plus shared/dynamic chunks.
250
+ // `entryPoint` is set on the user entry AND on every dynamic-import chunk
251
+ // (esbuild treats dynamic imports as entry points), so match the resolved
252
+ // source path instead of grabbing the first truthy `entryPoint`.
253
+ const { outputs } = result.metafile!;
254
+ const jsOutput = Object.keys(outputs).find((p) => {
255
+ const o = outputs[p];
256
+ return o.entryPoint && resolve(workingDir, o.entryPoint) === entryPath;
257
+ });
258
+ if (!jsOutput) {
259
+ throw new Error("Client bundle produced no output files");
260
+ }
261
+ const jsFile = basename(jsOutput);
262
+
263
+ const tmpCss = join(ASSETS_DIR, "_tmp.css");
264
+ await runTailwind(tmpCss);
265
+
266
+ let cssContent = await readFile(tmpCss, "utf-8");
267
+
268
+ try {
269
+ const themeColors = getThemeConfig();
270
+ if (themeColors) {
271
+ cssContent = buildThemeCss(cssContent, themeColors);
272
+ }
273
+ } catch (err) {
274
+ console.warn(
275
+ `[flame] Failed to resolve theme config, falling back to globals.css only: ${err instanceof Error ? err.message : String(err)}`
276
+ );
277
+ }
278
+
279
+ const cssHash = createHash("md5").update(cssContent).digest("hex").slice(0, 8);
280
+ const cssFile = `client-${cssHash}.css`;
281
+ await writeFile(join(ASSETS_DIR, cssFile), cssContent);
282
+ await unlink(tmpCss);
283
+
284
+ await writeFile(join(ASSETS_DIR, "manifest.json"), JSON.stringify({ js: jsFile, css: cssFile }));
285
+
286
+ return { js: jsFile, css: cssFile };
287
+ }
@@ -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"],
@@ -124,7 +115,13 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
124
115
  if (!result.outputs[0]) {
125
116
  throw new Error("Client bundle produced no output files");
126
117
  }
127
- const jsFile = result.outputs[0].path.split("/").pop()!;
118
+ // With splitting enabled Bun emits entry + chunk artifacts; select the entry
119
+ // explicitly rather than by position (chunks may precede it in the array).
120
+ const entry = result.outputs.find((o) => o.kind === "entry-point");
121
+ if (!entry) {
122
+ throw new Error("Client bundle produced no entry-point output");
123
+ }
124
+ const jsFile = entry.path.split("/").pop()!;
128
125
  const tmpCss = join(ASSETS_DIR, "_tmp.css");
129
126
  const proc = Bun.spawn(
130
127
  [
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);