@docubook/flame 1.5.4 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,56 +1,111 @@
1
1
  /**
2
- * Deploy script - prepares .docu/dist for GitHub Pages
2
+ * Deploy script — 3 modes:
3
+ * 1. `flame deploy` → build + generate GitHub Actions workflow
4
+ * 2. `flame deploy --docker` → build + generate Docker deployment files
5
+ * 3. `flame deploy --docker --silent` → same as #2, minimal output
3
6
  *
4
- * Usage: bun deploy
5
- * Runs build, adds .nojekyll, and generates GitHub Actions workflow.
7
+ * Bun-native path — uses Bun.write() and Bun.spawn().
6
8
  */
7
9
 
8
- import { writeFile, mkdir } from "node:fs/promises";
10
+ import { mkdir } from "node:fs/promises";
9
11
  import { existsSync } from "node:fs";
10
12
  import { join } from "node:path";
11
13
  import { DIST_DIR, PROJECT_ROOT } from "./paths";
14
+ import { HEADERS_FILE, NGINX_CONF, DOCKERIGNORE } from "./deploy.shared";
15
+
16
+ export { HEADERS_FILE, NGINX_CONF, DOCKERIGNORE };
12
17
 
13
18
  const WORKFLOW_DIR = join(PROJECT_ROOT, ".github/workflows");
14
19
  const WORKFLOW_FILE = join(WORKFLOW_DIR, "deploy.yml");
15
20
 
16
- const HEADERS_FILE = `/assets/*
17
- Cache-Control: public, max-age=31536000, immutable
18
-
19
- /assets/chunks/*
20
- Cache-Control: public, max-age=31536000, immutable
21
- `;
21
+ const isDocker = !!process.env.FLAME_DEPLOY_DOCKER;
22
+ const isSilent = !!process.env.FLAME_DEPLOY_SILENT;
22
23
 
23
- async function deploy() {
24
- console.log("📦 Building for production...\n");
24
+ /** Logger that no-ops all non-error output in silent mode. */
25
+ const log = isSilent
26
+ ? { info: () => {}, ok: () => {}, created: () => {}, out: () => {} }
27
+ : {
28
+ info: (m: string) => console.log(m),
29
+ ok: () => console.log("\n✅ Ready to deploy!"),
30
+ created: (m: string) => console.log(m),
31
+ out: (m: string) => console.log(m),
32
+ };
25
33
 
26
- // Run build
34
+ async function runBuild() {
27
35
  const build = Bun.spawn(["bun", "run", "build"], {
28
- stdout: "inherit",
29
- stderr: "inherit",
36
+ stdout: isSilent ? "ignore" : "inherit",
37
+ stderr: isSilent ? "ignore" : "inherit",
30
38
  });
31
39
  const exitCode = await build.exited;
32
40
  if (exitCode !== 0) {
33
41
  console.error("\n❌ Build failed");
34
42
  process.exit(1);
35
43
  }
44
+ }
45
+
46
+ export const DOCKERFILE_BUN = `FROM oven/bun:1 AS builder
47
+ WORKDIR /app
48
+ COPY package.json bun.lock ./
49
+ RUN bun install --frozen-lockfile
50
+ COPY . .
51
+ RUN bun run build
52
+
53
+ FROM nginx:alpine
54
+ COPY --from=builder /app/.docu/dist /usr/share/nginx/html
55
+ COPY nginx.conf /etc/nginx/conf.d/default.conf
56
+ USER nginx
57
+ EXPOSE 80
58
+ CMD ["nginx", "-g", "daemon off;"]
59
+ `;
60
+
61
+ async function writeDockerFiles() {
62
+ const dockerDir = PROJECT_ROOT;
36
63
 
37
- // Add .nojekyll
38
- await writeFile(join(DIST_DIR, ".nojekyll"), "");
64
+ if (!existsSync(join(dockerDir, "Dockerfile"))) {
65
+ await Bun.write(join(dockerDir, "Dockerfile"), DOCKERFILE_BUN);
66
+ log.created("📄 Created Dockerfile");
67
+ }
39
68
 
40
- // _headers: long-cache immutable assets for Netlify/Cloudflare Pages.
41
- // GitHub Pages ignores it (its CDN caches separately) — harmless to emit.
42
- await writeFile(join(DIST_DIR, "_headers"), HEADERS_FILE);
69
+ if (!existsSync(join(dockerDir, "nginx.conf"))) {
70
+ await Bun.write(join(dockerDir, "nginx.conf"), NGINX_CONF);
71
+ log.created("📄 Created nginx.conf");
72
+ }
43
73
 
44
- // Generate GitHub Actions workflow
74
+ if (!existsSync(join(dockerDir, ".dockerignore"))) {
75
+ await Bun.write(join(dockerDir, ".dockerignore"), DOCKERIGNORE);
76
+ log.created("📄 Created .dockerignore");
77
+ }
78
+ }
79
+
80
+ async function writeGhaWorkflow() {
45
81
  if (!existsSync(WORKFLOW_FILE)) {
46
82
  await mkdir(WORKFLOW_DIR, { recursive: true });
47
- await writeFile(WORKFLOW_FILE, GITHUB_ACTIONS_WORKFLOW);
48
- console.log("\n📄 Created .github/workflows/deploy.yml");
83
+ await Bun.write(WORKFLOW_FILE, GITHUB_ACTIONS_WORKFLOW);
84
+ log.created("📄 Created .github/workflows/deploy.yml");
49
85
  }
86
+ }
87
+
88
+ async function deploy() {
89
+ log.info("📦 Building for production...\n");
90
+ await runBuild();
91
+
92
+ // Common: .nojekyll + _headers
93
+ await Bun.write(join(DIST_DIR, ".nojekyll"), "");
94
+ await Bun.write(join(DIST_DIR, "_headers"), HEADERS_FILE);
50
95
 
51
- console.log("\n✅ Ready to deploy!");
52
- console.log(" Output: .docu/dist/");
53
- console.log(" Push to GitHub and enable Pages (Settings → Pages → Source: GitHub Actions)");
96
+ if (isDocker) {
97
+ await writeDockerFiles();
98
+ } else {
99
+ await writeGhaWorkflow();
100
+ }
101
+
102
+ log.ok();
103
+ log.out(" Output: .docu/dist/");
104
+ if (isDocker) {
105
+ log.out(" Run: docker build -t my-docs . && docker run -p 80:80 my-docs");
106
+ } else {
107
+ log.out(" Push to GitHub and enable Pages (Settings → Pages → Source: GitHub Actions)");
108
+ }
54
109
  }
55
110
 
56
111
  const GITHUB_ACTIONS_WORKFLOW = `name: Deploy to GitHub Pages
@@ -73,7 +128,7 @@ jobs:
73
128
  build:
74
129
  runs-on: ubuntu-latest
75
130
  steps:
76
- - uses: actions/checkout@v4
131
+ - uses: actions/checkout@v7
77
132
  with:
78
133
  fetch-depth: 0
79
134
 
@@ -103,7 +158,9 @@ jobs:
103
158
  uses: actions/deploy-pages@v4
104
159
  `;
105
160
 
106
- deploy().catch((err) => {
107
- console.error("Deploy failed:", err);
108
- process.exit(1);
109
- });
161
+ if (import.meta.main) {
162
+ deploy().catch((err) => {
163
+ console.error("Deploy failed:", err);
164
+ process.exit(1);
165
+ });
166
+ }
@@ -79,6 +79,7 @@ export function htmlShell(opts: HtmlShellOptions): string {
79
79
  <title>${escapeHtml(title)}</title>
80
80
  <meta name="description" content="${escapeHtml(description)}">
81
81
  ${favicon ? `<link rel="icon" type="image/x-icon" href="${escapeHtml(resolvePath(favicon))}">` : ""}${themeStyle}
82
+ <link rel="preload" href="${escapeHtml(assetPrefix + css)}" as="style">
82
83
  <link rel="stylesheet" href="${escapeHtml(assetPrefix + css)}">
83
84
  ${csp ? `<meta http-equiv="Content-Security-Policy" content="${escapeHtml(csp)}">` : ""}
84
85
  ${seoTags}
@@ -86,6 +87,7 @@ export function htmlShell(opts: HtmlShellOptions): string {
86
87
  </head>
87
88
  <body>
88
89
  <div id="root">${body}</div>
90
+ <link rel="modulepreload" href="${escapeHtml(assetPrefix + js)}">
89
91
  <script type="module"${nonceAttr} src="${escapeHtml(assetPrefix + js)}"></script>${extraScripts ? `\n ${extraScripts}` : ""}${bodyInjection}
90
92
  </body>
91
93
  </html>`;
@@ -1,30 +1,5 @@
1
- import type { SeoMeta } from "./seo";
2
-
3
- export interface HtmlShellOptions {
4
- title: string;
5
- description: string;
6
- body: string;
7
- favicon: string;
8
- css: string;
9
- js: string;
10
- nonce?: string;
11
- /**
12
- * Content-Security-Policy value (from `cspHeader()` in security.ts).
13
- * When provided, injects `<meta http-equiv="Content-Security-Policy">` in `<head>`.
14
- * Essential for static deployment where HTTP headers cannot be set.
15
- */
16
- csp?: string;
17
- extraScripts?: string;
18
- themeCss?: string;
19
- /** Depth from document root (0=root, 1=subdir, 2=sub/subdir). Used for relative asset paths. */
20
- depth?: number;
21
- /** HTML strings to inject before `</head>` (from plugin `injectHead` hooks). */
22
- headExtra?: string[];
23
- /** HTML strings to inject before `</body>`, after the main script (from plugin `injectBody` hooks). */
24
- bodyExtra?: string[];
25
- /** SEO meta tags derived from config + frontmatter */
26
- seo?: SeoMeta;
27
- }
1
+ import type { HtmlShellOptions } from "./html.shared";
2
+ export type { HtmlShellOptions };
28
3
 
29
4
  export function htmlShell(opts: HtmlShellOptions): string {
30
5
  const {
@@ -69,6 +44,7 @@ export function htmlShell(opts: HtmlShellOptions): string {
69
44
  <title>${Bun.escapeHTML(title)}</title>
70
45
  <meta name="description" content="${Bun.escapeHTML(description)}">
71
46
  ${favicon ? `<link rel="icon" type="image/x-icon" href="${Bun.escapeHTML(resolvePath(favicon))}">` : ""}${themeStyle}
47
+ <link rel="preload" href="${Bun.escapeHTML(assetPrefix + css)}" as="style">
72
48
  <link rel="stylesheet" href="${Bun.escapeHTML(assetPrefix + css)}">
73
49
  ${csp ? `<meta http-equiv="Content-Security-Policy" content="${Bun.escapeHTML(csp)}">` : ""}
74
50
  ${seoTags}
@@ -76,6 +52,7 @@ export function htmlShell(opts: HtmlShellOptions): string {
76
52
  </head>
77
53
  <body>
78
54
  <div id="root">${body}</div>
55
+ <link rel="modulepreload" href="${Bun.escapeHTML(assetPrefix + js)}">
79
56
  <script type="module"${nonceAttr} src="${Bun.escapeHTML(assetPrefix + js)}"></script>${extraScripts ? `\n ${extraScripts}` : ""}${bodyInjection}
80
57
  </body>
81
58
  </html>`;
@@ -58,10 +58,42 @@ function resolveTailwindBin(): string {
58
58
  return join(dirname(pkgPath), binRel);
59
59
  }
60
60
 
61
- /** Run Tailwind CLI to produce minified CSS. */
62
- async function runTailwind(outputCss: string): Promise<void> {
61
+ /** Compute a cache key from globals.css + theme config content. */
62
+ function tailwindCacheKey(): string {
63
+ const globalsPath = join(STYLES_DIR, "globals.css");
64
+ const globalsContent = existsSync(globalsPath) ? readFileSync(globalsPath, "utf-8") : "";
65
+ let themeSuffix = "";
66
+ try {
67
+ const themeColors = getThemeConfig();
68
+ if (themeColors) {
69
+ themeSuffix = JSON.stringify(themeColors);
70
+ }
71
+ } catch {
72
+ // theme config unavailable — proceed without it
73
+ }
74
+ return createHash("md5")
75
+ .update(globalsContent + themeSuffix)
76
+ .digest("hex")
77
+ .slice(0, 16);
78
+ }
79
+
80
+ /**
81
+ * Build Tailwind CSS with content-based caching.
82
+ * If a CSS file for the current input hash already exists, skip the subprocess.
83
+ * Returns the filename (e.g. "client-abc123.css") and CSS content.
84
+ */
85
+ async function buildTailwindCss(key: string): Promise<{ file: string; content: string }> {
86
+ const cachedFile = `client-${key}.css`;
87
+ const cachedPath = join(ASSETS_DIR, cachedFile);
88
+
89
+ if (existsSync(cachedPath)) {
90
+ const content = readFileSync(cachedPath, "utf-8");
91
+ return { file: cachedFile, content };
92
+ }
93
+
94
+ const tmpCss = join(ASSETS_DIR, `_tmp-${key}.css`);
63
95
  const bin = resolveTailwindBin();
64
- const twArgs = ["-i", join(STYLES_DIR, "globals.css"), "-o", outputCss, "--minify"];
96
+ const twArgs = ["-i", join(STYLES_DIR, "globals.css"), "-o", tmpCss, "--minify"];
65
97
  const isDeno = "Deno" in globalThis;
66
98
  const args = isDeno ? ["run", "-A", bin, ...twArgs] : [bin, ...twArgs];
67
99
  try {
@@ -70,6 +102,31 @@ async function runTailwind(outputCss: string): Promise<void> {
70
102
  const stderr = (err as { stderr?: string }).stderr ?? String(err);
71
103
  throw new Error(`Tailwind CSS build failed:\n${stderr}`, { cause: err });
72
104
  }
105
+
106
+ let cssContent = await readFile(tmpCss, "utf-8");
107
+ await unlink(tmpCss);
108
+
109
+ try {
110
+ const themeColors = getThemeConfig();
111
+ if (themeColors) {
112
+ cssContent = buildThemeCss(cssContent, themeColors);
113
+ }
114
+ } catch (err) {
115
+ console.warn(
116
+ `[flame] Failed to resolve theme config, falling back to globals.css only: ${err instanceof Error ? err.message : String(err)}`
117
+ );
118
+ }
119
+
120
+ // Use the same input-derived key for lookup and output — if inputs change,
121
+ // the key changes, cache busting works without a separate content hash.
122
+ const cssFile = `client-${key}.css`;
123
+ const outPath = join(ASSETS_DIR, cssFile);
124
+
125
+ if (!existsSync(outPath)) {
126
+ await writeFile(outPath, cssContent);
127
+ }
128
+
129
+ return { file: cssFile, content: cssContent };
73
130
  }
74
131
 
75
132
  const NODE_BUILTINS_RE = new RegExp(
@@ -111,8 +168,10 @@ function scanDirLucideIcons(dir: string, set: Set<string>): void {
111
168
  }
112
169
  }
113
170
  }
114
- } catch {
115
- /* skip unreadable dirs */
171
+ } catch (err) {
172
+ console.warn(
173
+ `[flame] Failed to scan lucide icons: ${err instanceof Error ? err.message : String(err)}`
174
+ );
116
175
  }
117
176
  }
118
177
 
@@ -138,7 +197,8 @@ function collectAllLucideIcons(): string[] {
138
197
  /** Build the client JS bundle and Tailwind CSS. */
139
198
  export async function buildClientBundle(): Promise<{ js: string; css: string }> {
140
199
  await mkdir(ASSETS_DIR, { recursive: true });
141
- await cleanOldBundles();
200
+ const twKey = tailwindCacheKey();
201
+ await cleanOldBundles(new Set([`client-${twKey}.css`]));
142
202
 
143
203
  const nodeEnv = process.env.NODE_ENV || "development";
144
204
  const esbuild = await import("esbuild");
@@ -153,10 +213,8 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
153
213
  bundle: true,
154
214
  outdir: ASSETS_DIR,
155
215
  entryNames: "client-[hash]",
156
- chunkNames: "chunks/[name]-[hash]",
157
216
  platform: "browser",
158
217
  format: "esm",
159
- splitting: true,
160
218
  minify: nodeEnv === "production",
161
219
  define: { "process.env.NODE_ENV": JSON.stringify(nodeEnv) },
162
220
  jsx: "automatic",
@@ -185,17 +243,17 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
185
243
  if (args.namespace === "lucide-virt") {
186
244
  return { path: getLucideRealEntry(), namespace: "file" };
187
245
  }
188
- // Files that do dynamic name lookups (namespace import)
189
- // need the full barrel — bypass the virtual module.
246
+ // mdx-content Icon.tsx uses namespace import for arbitrary
247
+ // user-provided icon names in MDX — keep full barrel there.
190
248
  if (args.importer) {
191
249
  const normalized = normalizeImporterPath(args.importer);
192
- if (
193
- normalized.endsWith("/.docu/components/Lucide.tsx") ||
194
- normalized.includes("/mdx-content/dist/")
195
- ) {
250
+ if (normalized.includes("/mdx-content/dist/")) {
196
251
  return { path: getLucideRealEntry(), namespace: "file" };
197
252
  }
198
253
  }
254
+ // All other files get tree-shaken via the virtual module.
255
+ // Lucide.tsx renders only config-defined icons, which are
256
+ // collected by extractConfigIcons() + collectAllLucideIcons().
199
257
  return { path: args.path, namespace: "lucide-virt" };
200
258
  });
201
259
  build.onLoad({ filter: /.*/, namespace: "lucide-virt" }, () => {
@@ -226,7 +284,6 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
226
284
  });
227
285
  },
228
286
  },
229
-
230
287
  ],
231
288
  });
232
289
  } finally {
@@ -235,10 +292,7 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
235
292
  await esbuild.stop();
236
293
  }
237
294
 
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`.
295
+ // The single entry produces one output. Match resolved source path.
242
296
  const { outputs } = result.metafile!;
243
297
  const jsOutput = Object.keys(outputs).find((p) => {
244
298
  const o = outputs[p];
@@ -249,26 +303,7 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
249
303
  }
250
304
  const jsFile = basename(jsOutput);
251
305
 
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);
306
+ const { file: cssFile } = await buildTailwindCss(twKey);
272
307
 
273
308
  await writeFile(join(ASSETS_DIR, "manifest.json"), JSON.stringify({ js: jsFile, css: cssFile }));
274
309
 
@@ -1,5 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { mkdir, unlink } from "node:fs/promises";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { createHash } from "node:crypto";
3
5
  import { resolveTheme, generateThemeCss, presetRegistry } from "@docubook/themes-colors";
4
6
  import { ASSETS_DIR, cleanOldBundles, LIB_DIR, STYLES_DIR, loadDocuConfig } from "./paths";
5
7
  import { resolveRoutes } from "./fs-scanner";
@@ -55,24 +57,86 @@ export function computeInlineThemeCss(): string | undefined {
55
57
  return undefined;
56
58
  }
57
59
 
60
+ /** Compute Tailwind cache key from globals.css + theme config. */
61
+ function twCacheKey(): string {
62
+ const globalsPath = join(STYLES_DIR, "globals.css");
63
+ const globals = existsSync(globalsPath) ? readFileSync(globalsPath, "utf-8") : "";
64
+ let themeSuffix = "";
65
+ try {
66
+ const themeColors = getThemeConfig();
67
+ if (themeColors) themeSuffix = JSON.stringify(themeColors);
68
+ } catch {
69
+ // theme config unavailable — proceed without
70
+ }
71
+ return createHash("md5")
72
+ .update(globals + themeSuffix)
73
+ .digest("hex")
74
+ .slice(0, 16);
75
+ }
76
+
77
+ /** Run Tailwind CLI, caching by content hash. */
78
+ async function buildTailwindCss(key: string): Promise<{ file: string; content: string }> {
79
+ const cachedFile = `client-${key}.css`;
80
+ const cachedPath = join(ASSETS_DIR, cachedFile);
81
+
82
+ if (existsSync(cachedPath)) {
83
+ const content = await Bun.file(cachedPath).text();
84
+ return { file: cachedFile, content };
85
+ }
86
+
87
+ const tmpCss = join(ASSETS_DIR, `_tmp-${key}.css`);
88
+ const proc = Bun.spawn(
89
+ [
90
+ "bun",
91
+ "x",
92
+ "@tailwindcss/cli",
93
+ "-i",
94
+ join(STYLES_DIR, "globals.css"),
95
+ "-o",
96
+ tmpCss,
97
+ "--minify",
98
+ ],
99
+ { stdout: "ignore", stderr: "pipe" }
100
+ );
101
+ await proc.exited;
102
+ if (proc.exitCode !== 0) {
103
+ const err = await new Response(proc.stderr).text();
104
+ throw new Error(`Tailwind CSS build failed:\n${err}`);
105
+ }
106
+
107
+ let cssContent = await Bun.file(tmpCss).text();
108
+ await unlink(tmpCss);
109
+
110
+ try {
111
+ const themeColors = getThemeConfig();
112
+ if (themeColors) cssContent = buildThemeCss(cssContent, themeColors);
113
+ } catch (err) {
114
+ console.warn(
115
+ `[flame] Failed to resolve theme config: ${err instanceof Error ? err.message : String(err)}`
116
+ );
117
+ }
118
+
119
+ // Use the same input-derived key for lookup and output — if inputs change,
120
+ // the key changes, cache busting works without a separate content hash.
121
+ const cssFile = `client-${key}.css`;
122
+ const outPath = join(ASSETS_DIR, cssFile);
123
+ if (!existsSync(outPath)) await Bun.write(outPath, cssContent);
124
+
125
+ return { file: cssFile, content: cssContent };
126
+ }
127
+
58
128
  export async function buildClientBundle(): Promise<{ js: string; css: string }> {
59
129
  await mkdir(ASSETS_DIR, { recursive: true });
60
- await cleanOldBundles();
130
+ const twKey = twCacheKey();
131
+ await cleanOldBundles(new Set([`client-${twKey}.css`]));
61
132
 
62
133
  const nodeEnv = process.env.NODE_ENV || "development";
63
134
  const result = await Bun.build({
64
135
  entrypoints: [join(LIB_DIR, "client.ts")],
65
136
  outdir: ASSETS_DIR,
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
- },
137
+ naming: "client-[hash].[ext]",
73
138
  target: "browser",
74
139
  minify: nodeEnv === "production",
75
- optimizeImports: ["lucide-react"],
76
140
  define: { "process.env.NODE_ENV": JSON.stringify(nodeEnv) },
77
141
  plugins: [
78
142
  {
@@ -92,7 +156,6 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
92
156
  });
93
157
  },
94
158
  },
95
-
96
159
  ],
97
160
  });
98
161
 
@@ -104,50 +167,13 @@ export async function buildClientBundle(): Promise<{ js: string; css: string }>
104
167
  if (!result.outputs[0]) {
105
168
  throw new Error("Client bundle produced no output files");
106
169
  }
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
170
  const entry = result.outputs.find((o) => o.kind === "entry-point");
110
171
  if (!entry) {
111
172
  throw new Error("Client bundle produced no entry-point output");
112
173
  }
113
174
  const jsFile = entry.path.split("/").pop()!;
114
- const tmpCss = join(ASSETS_DIR, "_tmp.css");
115
- const proc = Bun.spawn(
116
- [
117
- "bun",
118
- "x",
119
- "@tailwindcss/cli",
120
- "-i",
121
- join(STYLES_DIR, "globals.css"),
122
- "-o",
123
- tmpCss,
124
- "--minify",
125
- ],
126
- { stdout: "ignore", stderr: "pipe" }
127
- );
128
- await proc.exited;
129
- if (proc.exitCode !== 0) {
130
- const err = await new Response(proc.stderr).text();
131
- throw new Error(`Tailwind CSS build failed:\n${err}`);
132
- }
133
175
 
134
- let cssContent = await Bun.file(tmpCss).text();
135
-
136
- try {
137
- const themeColors = getThemeConfig();
138
- if (themeColors) {
139
- cssContent = buildThemeCss(cssContent, themeColors);
140
- }
141
- } catch (err) {
142
- console.warn(
143
- `[flame] Failed to resolve theme config, falling back to globals.css only: ${err instanceof Error ? err.message : String(err)}`
144
- );
145
- }
146
-
147
- const cssHash = new Bun.CryptoHasher("md5").update(cssContent).digest("hex").slice(0, 8);
148
- const cssFile = `client-${cssHash}.css`;
149
- await Bun.write(join(ASSETS_DIR, cssFile), cssContent);
150
- await unlink(tmpCss);
176
+ const { file: cssFile } = await buildTailwindCss(twKey);
151
177
 
152
178
  await Bun.write(join(ASSETS_DIR, "manifest.json"), JSON.stringify({ js: jsFile, css: cssFile }));
153
179
 
@@ -32,11 +32,12 @@ export const DOCU_CONFIG_PATH = join(PROJECT_ROOT, "docu.json");
32
32
  // Config singleton
33
33
  let _config: DocuConfig | null = null;
34
34
 
35
- /** Clean stale client bundles and split chunks from a previous build. */
36
- export async function cleanOldBundles() {
35
+ /** Clean stale client bundles from a previous build. */
36
+ export async function cleanOldBundles(preserve?: Set<string>) {
37
37
  try {
38
38
  const files = await readdir(ASSETS_DIR);
39
39
  for (const file of files) {
40
+ if (preserve?.has(file)) continue;
40
41
  if (file.startsWith("client.") || file.startsWith("client-")) {
41
42
  await unlink(join(ASSETS_DIR, file));
42
43
  }
@@ -46,12 +47,13 @@ export async function cleanOldBundles() {
46
47
  console.error("Failed to clean old bundles:", (err as Error).message);
47
48
  }
48
49
  }
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
50
  try {
52
51
  await rm(join(ASSETS_DIR, "chunks"), { recursive: true, force: true });
53
52
  } catch (err) {
54
- console.error("Failed to clean old chunks:", (err as Error).message);
53
+ // chunks dir may not exist, or permission error — log to avoid silent failure
54
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
55
+ console.warn("[flame] Failed to clean chunks dir:", (err as Error).message);
56
+ }
55
57
  }
56
58
  }
57
59
 
@@ -279,7 +279,8 @@ export function serveStatic(pathname: string): Response | null {
279
279
 
280
280
  export function serverErrorResponse(error: unknown): Response {
281
281
  const msg = error instanceof Error ? error.message : "Unknown error";
282
- const st = error instanceof Error ? error.stack : undefined;
282
+ const st =
283
+ process.env.NODE_ENV !== "production" && error instanceof Error ? error.stack : undefined;
283
284
  return new Response(errorHtml(msg, st), {
284
285
  status: 500,
285
286
  headers: {
package/bin/cli.js CHANGED
@@ -12,10 +12,13 @@ const __dirname = import.meta.dirname;
12
12
  // Deno's npm compat layer may expose `Bun` via globals, check execPath first.
13
13
  const runtime =
14
14
  process.env.FLAME_RUNTIME ||
15
- (process.execPath.includes("deno") ? "deno" :
16
- typeof Bun !== "undefined" ? "bun" :
17
- typeof Deno !== "undefined" ? "deno" :
18
- "node");
15
+ (process.execPath.includes("deno")
16
+ ? "deno"
17
+ : typeof Bun !== "undefined"
18
+ ? "bun"
19
+ : typeof Deno !== "undefined"
20
+ ? "deno"
21
+ : "node");
19
22
 
20
23
  const COMMAND_MAP = {
21
24
  bun: {
@@ -48,11 +51,15 @@ if (!COMMAND_MAP) {
48
51
 
49
52
  const command = process.argv[2];
50
53
 
51
- // Parse --theme flag: set env before importing build script
54
+ // Parse flags
52
55
  const themeIndex = process.argv.indexOf("--theme");
53
56
  if (themeIndex !== -1 && themeIndex + 1 < process.argv.length) {
54
57
  process.env.FLAME_THEME = process.argv[themeIndex + 1];
55
58
  }
59
+ const hasDocker = process.argv.includes("--docker");
60
+ const hasSilent = process.argv.includes("--silent");
61
+ if (hasDocker) process.env.FLAME_DEPLOY_DOCKER = "1";
62
+ if (hasSilent) process.env.FLAME_DEPLOY_SILENT = "1";
56
63
 
57
64
  if (!command || command === "--help" || command === "-h") {
58
65
  console.log(`
@@ -71,6 +78,8 @@ if (!command || command === "--help" || command === "-h") {
71
78
  Options:
72
79
  --help Show this help message
73
80
  --theme <name> Override theme preset (e.g. freshlime, coffee). Works with dev, build, preview.
81
+ --docker Generate Docker deployment files (Dockerfile + nginx.conf). Works with deploy.
82
+ --silent Suppress non-essential output. Works with deploy.
74
83
  `);
75
84
  process.exit(0);
76
85
  }
@@ -120,11 +129,15 @@ if (command === "init") {
120
129
  }
121
130
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
122
131
 
132
+ // Runtime detection via shebang (#!/usr/bin/env node) makes typeof Bun
133
+ // unavailable — check for bun.lock as a reliable Bun indicator.
134
+ const isBun = existsSync(join(targetDir, "bun.lock"));
135
+ const pkgManager = runtime === "deno" ? "deno" : isBun ? "bun" : "node";
123
136
  const nextSteps = {
124
137
  bun: " bun install\n bun run dev",
125
138
  node: " npm install\n npm run dev",
126
139
  deno: " deno task dev\n\n ⚠️ If you see a freshness error, run:\n DENO_ALLOW_NEWER=true deno task dev",
127
- }[runtime];
140
+ }[pkgManager];
128
141
  console.log(`\n ✓ Project scaffolded!\n\n Next steps:\n${nextSteps}\n`);
129
142
  process.exit(0);
130
143
  } catch (err) {