/** * Mandu CSS Builder * Tailwind CSS v4 CLI 기반 CSS 빌드 및 감시 * * 특징: * - Tailwind v4 Oxide Engine (Rust) 사용 * - Zero Config: @import "tailwindcss" 자동 감지 * - 출력: .mandu/client/globals.css * * #152: Tailwind CLI --watch 모드가 Bun.spawn에서 hang되는 문제 수정 * - 원인: @tailwindcss/cli v4 --watch 모드가 Bun subprocess에서 파일 미생성 * - 해결: 자체 파일 감시 + 단발 빌드 반복 방식으로 전환 */ import { spawn } from "bun"; import path from "path"; import fs from "fs/promises"; import { watch as fsWatch, type FSWatcher } from "fs"; import { withPerf } from "../perf"; /** * Tailwind CLI 실행 명령어를 결정한다. * Windows에서 Bun.spawn은 PATH 기반 명령어 해석이 불안정하므로 (#152) * `bun run mandu` 환경에서는 process.execPath (bun 절대 경로)를 사용한다. * Standalone Mandu binary에서는 process.execPath가 mandu.exe를 가리키므로 * `mandu x @tailwindcss/cli`로 오해석된다. 이 경우 PATH에서 Bun을 찾는다. */ function isBunExecutable(executablePath: string | undefined): boolean { if (!executablePath) return false; const base = path.basename(executablePath).toLowerCase(); return base === "bun" || base === "bun.exe"; } type WhichExecutable = (command: string) => string | null | undefined; function resolveBunExecutable( execPath = process.execPath, which: WhichExecutable = (command) => Bun.which(command), ): string { if (isBunExecutable(execPath)) return execPath; const fromPath = which("bun"); if (fromPath) return fromPath; return process.platform === "win32" ? "bun.exe" : "bun"; } function getTailwindCommand( args: string[], execPath = process.execPath, which?: WhichExecutable, ): string[] { return [resolveBunExecutable(execPath, which), "x", ...args]; } // ========== Types ========== export interface CSSBuildOptions { /** 프로젝트 루트 디렉토리 */ rootDir: string; /** CSS 입력 파일 (기본: "app/globals.css") */ input?: string; /** CSS 출력 파일 (기본: ".mandu/client/globals.css") */ output?: string; /** Watch 모드 활성화 */ watch?: boolean; /** Minify 활성화 (production) */ minify?: boolean; /** 빌드 완료 콜백 */ onBuild?: (result: CSSBuildResult) => void; /** 에러 콜백 */ onError?: (error: Error) => void; } export interface CSSBuildResult { success: boolean; outputPath: string; buildTime?: number; error?: string; } export interface CSSWatcher { /** 출력 파일 경로 (절대 경로) */ outputPath: string; /** 서버 경로 (/.mandu/client/globals.css) */ serverPath: string; /** 감시 중지 */ close: () => void; } // ========== Constants ========== const DEFAULT_INPUT = "app/globals.css"; const DEFAULT_OUTPUT = ".mandu/client/globals.css"; const SERVER_CSS_PATH = "/.mandu/client/globals.css"; const CSS_REBUILD_DEBOUNCE = 150; // ms // ========== Detection ========== /** * Tailwind v4 프로젝트인지 감지 * app/globals.css에 @import "tailwindcss" 포함 여부 확인 */ export async function isTailwindProject(rootDir: string): Promise { const cssPath = path.join(rootDir, DEFAULT_INPUT); try { const content = await fs.readFile(cssPath, "utf-8"); return ( content.includes('@import "tailwindcss"') || content.includes("@import 'tailwindcss'") || content.includes("@tailwind base") ); } catch { return false; } } /** * CSS 입력 파일 존재 여부 확인 */ export async function hasCSSEntry(rootDir: string, input?: string): Promise { const cssPath = path.join(rootDir, input || DEFAULT_INPUT); try { await fs.access(cssPath); return true; } catch { return false; } } // ========== Build ========== /** * CSS 단발 빌드 (--watch 없이) * Tailwind CLI --watch가 Bun.spawn에서 hang되므로 (#152) 단발 빌드만 사용 */ async function runCSSBuild( rootDir: string, inputPath: string, outputPath: string, minify: boolean, ): Promise { const startTime = performance.now(); const args = ["@tailwindcss/cli", "-i", inputPath, "-o", outputPath]; if (minify) args.push("--minify"); try { const proc = spawn(getTailwindCommand(args), { cwd: rootDir, stdout: "pipe", stderr: "pipe", }); const exitCode = await proc.exited; if (exitCode !== 0) { const rawStderr = await new Response(proc.stderr).text(); // ANSI escape + 환경 경고 필터링 const stderr = rawStderr .replace(/\u001b\[[0-9;]*m/g, "") .split("\n") .filter((l) => l.trim() && !l.includes(".bash_profile") && !l.includes("$'\\377")) .join("\n") .trim(); return { success: false, outputPath, error: stderr || `Tailwind CLI exited with code ${exitCode}`, }; } return { success: true, outputPath, buildTime: performance.now() - startTime, }; } catch (error) { return { success: false, outputPath, error: error instanceof Error ? error.message : String(error), }; } } /** * CSS 일회성 빌드 (production용) */ export async function buildCSS(options: CSSBuildOptions): Promise { return withPerf("bundler:css", async () => { const { rootDir, input = DEFAULT_INPUT, output = DEFAULT_OUTPUT, minify = true, } = options; const inputPath = path.join(rootDir, input); const outputPath = path.join(rootDir, output); await fs.mkdir(path.dirname(outputPath), { recursive: true }); return runCSSBuild(rootDir, inputPath, outputPath, minify); }); } // ========== Watch ========== /** * CSS 감시 모드 시작 (development용) * * #152: Tailwind CLI --watch 모드가 Bun.spawn에서 파일을 생성하지 않는 문제로 인해 * 자체 파일 감시 + 단발 빌드 반복 방식을 사용한다. * * 동작: * 1. 초기 단발 빌드 실행 (await — 서버 시작 전 CSS 준비 보장) * 2. app/, src/ 디렉토리 및 입력 CSS 파일 감시 * 3. 관련 파일 변경 시 debounce 후 단발 빌드 재실행 */ export async function startCSSWatch(options: CSSBuildOptions): Promise { const { rootDir, input = DEFAULT_INPUT, output = DEFAULT_OUTPUT, minify = false, onBuild, onError, } = options; const inputPath = path.join(rootDir, input); const outputPath = path.join(rootDir, output); // 출력 디렉토리 생성 try { await fs.mkdir(path.dirname(outputPath), { recursive: true }); } catch (error) { const err = new Error(`CSS 출력 디렉토리 생성 실패: ${error instanceof Error ? error.message : error}`); console.error(`❌ ${err.message}`); onError?.(err); throw err; } console.log(`🎨 Tailwind CSS v4 빌드 시작...`); console.log(` 입력: ${input}`); console.log(` 출력: ${output}`); // 1. 초기 빌드 (await — 서버 시작 전 CSS 준비 보장) const initialResult = await runCSSBuild(rootDir, inputPath, outputPath, minify); if (initialResult.success) { console.log(` ✅ CSS built (${Math.round(initialResult.buildTime ?? 0)}ms)`); } else { console.error(` ❌ CSS build failed: ${initialResult.error}`); onError?.(new Error(initialResult.error)); } // 2. 파일 감시 설정 (CSS 소스, app/, src/ 디렉토리) const watchers: FSWatcher[] = []; let debounceTimer: ReturnType | null = null; let isBuilding = false; let pendingRebuild = false; const triggerRebuild = () => { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { if (isBuilding) { pendingRebuild = true; return; } isBuilding = true; try { const result = await runCSSBuild(rootDir, inputPath, outputPath, minify); if (result.success) { console.log(` ✅ CSS rebuilt (${Math.round(result.buildTime ?? 0)}ms)`); onBuild?.(result); } else { console.error(` ❌ CSS rebuild failed: ${result.error}`); onError?.(new Error(result.error)); } } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); console.error(` ❌ CSS rebuild error: ${error.message}`); onError?.(error); } finally { isBuilding = false; if (pendingRebuild) { pendingRebuild = false; triggerRebuild(); } } }, CSS_REBUILD_DEBOUNCE); }; // CSS/TSX/HTML 파일 변경 시 리빌드 트리거 const isRelevantChange = (filename: string | null): boolean => { if (!filename) return false; const ext = path.extname(filename).toLowerCase(); return [".css", ".tsx", ".ts", ".jsx", ".js", ".html"].includes(ext); }; // 감시 대상 디렉토리 const watchTargets = ["app", "src"]; for (const dir of watchTargets) { const absDir = path.join(rootDir, dir); try { await fs.access(absDir); const watcher = fsWatch(absDir, { recursive: true }, (_event, filename) => { if (isRelevantChange(filename)) { triggerRebuild(); } }); watchers.push(watcher); } catch { // 디렉토리 없으면 무시 } } // 입력 CSS 파일 직접 감시 (app/ 외부에 있을 수도 있으므로) try { const cssWatcher = fsWatch(inputPath, () => triggerRebuild()); watchers.push(cssWatcher); } catch { // 무시 } return { outputPath, serverPath: SERVER_CSS_PATH, close: () => { if (debounceTimer) clearTimeout(debounceTimer); for (const w of watchers) w.close(); }, }; } /** * CSS 서버 경로 반환 */ export function getCSSServerPath(): string { return SERVER_CSS_PATH; } /** * CSS 링크 태그 생성 */ export function generateCSSLinkTag(isDev: boolean = false): string { const cacheBust = isDev ? `?t=${Date.now()}` : ""; return ``; } export const __private = { getTailwindCommand, isBunExecutable, resolveBunExecutable, };