@docubook/flame 1.4.4 → 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.
- package/.docu/lib/build.deno.js +11 -0
- package/.docu/lib/build.impl-ST63VRTV.js +12 -0
- package/.docu/lib/build.node.js +10 -0
- package/.docu/lib/chunk-2QHMGZIL.js +2419 -0
- package/.docu/lib/chunk-654THQOR.js +461 -0
- package/.docu/lib/chunk-C6RZ2KBH.js +79 -0
- package/.docu/lib/chunk-HH4YXWEF.js +300 -0
- package/.docu/lib/chunk-J5NMYSBJ.js +59 -0
- package/.docu/lib/chunk-RE4NGTMT.js +185 -0
- package/.docu/lib/chunk-X6GYOIYZ.js +383 -0
- package/.docu/lib/chunk-ZOWTASXL.js +92 -0
- package/.docu/lib/clean.js +32 -0
- package/.docu/lib/deploy.deno.js +13 -0
- package/.docu/lib/deploy.node.js +10 -0
- package/.docu/lib/preview.deno.js +10 -0
- package/.docu/lib/preview.node.js +10 -0
- package/.docu/lib/server.deno.js +11 -0
- package/.docu/lib/server.node.js +11 -0
- package/.docu/node/build-summary.ts +126 -0
- package/.docu/node/build.deno.ts +7 -0
- package/.docu/node/build.impl.ts +424 -0
- package/.docu/node/build.node.ts +3 -0
- package/.docu/node/deploy.deno.ts +11 -0
- package/.docu/node/deploy.node.ts +6 -0
- package/.docu/node/deploy.shared.ts +85 -0
- package/.docu/node/deploy.ts +11 -0
- package/.docu/node/escapeHtml.ts +18 -0
- package/.docu/node/git.ts +79 -0
- package/.docu/node/html.shared.ts +110 -0
- package/.docu/node/hydrate.node.ts +287 -0
- package/.docu/node/hydrate.ts +16 -19
- package/.docu/node/mdx.ts +1 -1
- package/.docu/node/paths.ts +24 -0
- package/.docu/node/plugin-builder.ts +6 -2
- package/.docu/node/plugin.ts +11 -2
- package/.docu/node/preview.deno.ts +4 -0
- package/.docu/node/preview.impl.ts +96 -0
- package/.docu/node/preview.node.ts +4 -0
- package/.docu/node/security.ts +5 -0
- package/.docu/node/server-routes.ts +4 -4
- package/.docu/node/server.deno.ts +4 -0
- package/.docu/node/server.impl.ts +184 -0
- package/.docu/node/server.node.ts +4 -0
- package/.docu/styles/globals.css +20 -5
- package/README.md +57 -506
- package/bin/cli.js +89 -14
- package/bin/compile-lib.mjs +67 -0
- package/package.json +9 -5
- package/template/docs/getting-started/configuration.mdx +18 -0
- package/template/docs/getting-started/overview.mdx +50 -0
- package/template/docs/guide/deployment.mdx +27 -0
- package/template/docs/guide/routing.mdx +25 -0
- package/template/docs/index.mdx +8 -205
- package/template/docu.json +32 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const ESCAPE_RE = /[&<>"']/g;
|
|
2
|
+
|
|
3
|
+
const ESCAPE_MAP: Record<string, string> = {
|
|
4
|
+
"&": "&",
|
|
5
|
+
"<": "<",
|
|
6
|
+
">": ">",
|
|
7
|
+
'"': """,
|
|
8
|
+
"'": "'",
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/.docu/node/hydrate.ts
CHANGED
|
@@ -1,28 +1,13 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { mkdir,
|
|
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
|
-
|
|
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
|
-
|
|
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 "./
|
|
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
|
package/.docu/node/paths.ts
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
package/.docu/node/plugin.ts
CHANGED
|
@@ -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
|
-
*
|
|
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;
|