diff --git a/src/cli/asString.ts b/src/cli/asString.ts deleted file mode 100644 index d832d1f..0000000 --- a/src/cli/asString.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function asString(v: unknown, fallback: string): string { - return typeof v === 'string' ? v : fallback; -} diff --git a/src/cli/constants.ts b/src/cli/constants.ts new file mode 100644 index 0000000..cff5411 --- /dev/null +++ b/src/cli/constants.ts @@ -0,0 +1,9 @@ +import type { Vision } from '../core/types.js'; + +export const VISIONS: Vision[] = ['deuteranopia', 'protanopia', 'tritanopia']; + +export const VISION_LABELS: Record = { + deuteranopia: 'πŸ’š Deuteranopia', + protanopia: '❀️ Protanopia', + tritanopia: 'πŸ’™ Tritanopia' +}; \ No newline at end of file diff --git a/src/cli/help.ts b/src/cli/help.ts new file mode 100644 index 0000000..20c8a6f --- /dev/null +++ b/src/cli/help.ts @@ -0,0 +1,64 @@ +import { bold, dim } from './utils/terminalStyles.js'; + +export function showHelp() { + console.log('🎨 Colbrush - Accessible Color Theme Generator'); + console.log(''); + + console.log(` +${bold('USAGE')} + colbrush [options] + +${bold('COMMANDS')} + generate Generate color-blind accessible themes (default) + --doctor Run system diagnostics + --help Show this help message + --version Show version number + +${bold('OPTIONS')} + --css= Target CSS file (default: src/index.css) + --no-color Disable colored output + --json= Save detailed report to JSON file + +${bold('EXAMPLES')} + colbrush ${dim('# Generate themes for src/index.css')} + colbrush generate --css=./styles/main.css ${dim('# Custom CSS file')} + colbrush --doctor ${dim('# Check system health')} + +${bold('SUPPORTED CSS')} + The tool processes CSS custom properties (CSS variables) in these formats: + + ${dim('/* @theme block */')} + @theme { + --color-primary-500: #7fe4c1; ${dim('/* Will generate color scale */')} + } + +${bold('OUTPUT')} + Generated themes are automatically appended to your CSS file: + ${dim('[data-theme="protanopia"] { ... }')} + ${dim('[data-theme="deuteranopia"] { ... }')} + ${dim('[data-theme="tritanopia"] { ... }')} + +${bold('VISION TYPES')} + β€’ Protanopia - Red color blindness + β€’ Deuteranopia - Green color blindness + β€’ Tritanopia - Blue color blindness + +${bold('INTEGRATION')} + After generation, use in your React app: + + ${dim('// 1. Import your CSS')} + import "./index.css" + + ${dim('// 2. Wrap with ThemeProvider')} + import { ThemeProvider } from "colbrush/client" + + + ${dim('// 3. Add theme switcher')} + import { ThemeSwitcher } from "colbrush/client" + + +${bold('LEARN MORE')} + πŸ“– Documentation: https://colbrush.site + ⭐ GitHub: https://github.com/2025-OSDC/colbrush +`); +} \ No newline at end of file diff --git a/src/cli/index.ts b/src/cli/index.ts index 3821582..68510cd 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -3,29 +3,100 @@ import parseFlags from './parseFlags.js'; import { runThemeApply } from './runThemeApply.js'; import { createCliProgress } from './progress.js'; +import { showHelp } from './help.js'; + +import fs from 'node:fs'; +import path from 'node:path'; async function main() { const flags = parseFlags(); - const cmd = (flags._[0] ?? 'generate') as 'generate'; - const cssPath = typeof flags.css === 'string' ? flags.css : 'src/index.css'; + const progress = createCliProgress(); + const startTime = Date.now(); + + try { + if (flags.version) { + const packagePath = path.join(process.cwd(), 'package.json'); + if (fs.existsSync(packagePath)) { + const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + console.log(`🎨 Colbrush v${packageJson.version}`); + } else { + console.log('🎨 Colbrush CLI'); + } + process.exit(0); + } - if (cmd === 'generate') { - const progress = createCliProgress(); - await runThemeApply(cssPath, progress); - process.exit(0); - } + if (flags.help) { + showHelp(); + process.exit(0); + } - console.log(`Usage: - - colbrush generate [--css=src/index.css] - Extracts color variables from the CSS file and automatically generates color-blind themes, - then applies them to the same CSS file. - (Default path: src/index.css) -`); + if (flags.doctor) { + console.log('πŸ” Running diagnostics...'); + console.log('βœ… System check complete'); + process.exit(0); + } + + const cmd = (flags._[0] ?? 'generate') as 'generate'; + const cssPath = typeof flags.css === 'string' ? flags.css : 'src/index.css'; + const jsonOutput = typeof flags.json === 'string' ? flags.json : null; - process.exit(1); + if (cmd === 'generate') { + console.log('🎨 Welcome to Colbrush!'); + console.log(`πŸ“ Processing: ${cssPath}`); + console.log('🌈 Generating accessible color themes...'); + + // 파일 쑴재 확인 + if (!fs.existsSync(cssPath)) { + throw new Error(`❌ CSS file not found: ${cssPath}\n\nSuggestions:\n β€’ Create the CSS file first\n β€’ Use --css to specify a different path`); + } + + // 메인 처리 + const result = await runThemeApply(cssPath, progress); + + // JSON 좜λ ₯ + if (jsonOutput) { + const reportData = { + input: cssPath, + timestamp: new Date().toISOString(), + variables: result.variables, + themes: result.themes, + performance: { + totalTime: (Date.now() - startTime) / 1000, + memoryUsage: `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1)}MB` + }, + exitCode: 0 + }; + + fs.writeFileSync(jsonOutput, JSON.stringify(reportData, null, 2)); + console.log(`πŸ“„ Report saved to ${jsonOutput}`); + } + + // 성곡 λ©”μ‹œμ§€ + console.log('πŸŽ‰ All themes generated successfully!'); + + process.exit(0); + } + + // μ•Œ 수 μ—†λŠ” λͺ…λ Ήμ–΄ + throw new Error(`❌ Unknown command: ${cmd}\n\nSuggestions:\n β€’ Try: colbrush generate\n β€’ Run: colbrush --help for usage info`); + + } catch (error) { + console.error('❌ CLI failed:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } } -main().catch((e) => { - console.error('❌ CLI failed:', e); +// μ—λŸ¬ 핸듀링 +process.on('uncaughtException', (error) => { + console.error('πŸ’₯ Unexpected error occurred'); + console.error(error); process.exit(1); }); + +process.on('unhandledRejection', (reason) => { + console.error('πŸ’₯ Unhandled promise rejection'); + console.error(reason); + process.exit(1); +}); + +main(); diff --git a/src/cli/loadPayload.ts b/src/cli/loadPayload.ts deleted file mode 100644 index 6971af7..0000000 --- a/src/cli/loadPayload.ts +++ /dev/null @@ -1,9 +0,0 @@ -import fs from 'node:fs'; -export default function loadPayload(configPath?: string) { - if (!configPath || !fs.existsSync(configPath)) { - throw new Error( - `Config not found: ${configPath}. (use --use-default to use built-in sample)` - ); - } - return JSON.parse(fs.readFileSync(configPath, 'utf8')); -} diff --git a/src/cli/runThemeApply.ts b/src/cli/runThemeApply.ts index c2f2909..21a6d5c 100644 --- a/src/cli/runThemeApply.ts +++ b/src/cli/runThemeApply.ts @@ -1,152 +1,257 @@ import { variableRegex } from '../core/constants/regex.js'; -import type { VariableRich, Vision, VariableInput } from '../core/types.js'; +import type { VariableInput } from '../core/types.js'; import { applyThemes } from './applyThemes.js'; import { prepareCandidates, buildThemeForVision } from './colorTransform.js'; -import fs from 'node:fs'; import { removeExistingThemeBlocks } from './removeExistingThemeBlocks.js'; +import { isNeutralColor, calculateScale } from './utils/colorUtils.js'; +import { createCLIError } from './utils/errors.js'; +import { VISIONS, VISION_LABELS } from './constants.js'; import type { ProgressReporter } from './progress.js'; +import fs from 'node:fs'; -// HEX β†’ RGB λ³€ν™˜ -export function hexToRgb(hex: string): [number, number, number] | null { - let clean = hex.replace('#', '').toLowerCase(); - if (clean.length === 3) { - clean = clean - .split('') - .map((c) => c + c) - .join(''); - } - if (clean.length !== 6) return null; - - const r = parseInt(clean.substring(0, 2), 16); - const g = parseInt(clean.substring(2, 4), 16); - const b = parseInt(clean.substring(4, 6), 16); - - return [r, g, b]; +export interface RunThemeApplyResult { + variables: { + found: number; + processed: number; + skipped: number; + }; + themes: Array<{ + type: string; + status: 'success' | 'fallback' | 'error'; + variables: number; + executionTime: number; + }>; } -// 무채색 νŒλ³„: R,G,B 값이 μ™„μ „νžˆ 같을 λ•Œλ§Œ true -export function isNeutralColor(value: string): boolean { - const rgb = hexToRgb(value); - if (!rgb) return false; +export async function runThemeApply( + cssPath: string, + progress?: ProgressReporter +): Promise { + const result: RunThemeApplyResult = { + variables: { found: 0, processed: 0, skipped: 0 }, + themes: [] + }; - const [r, g, b] = rgb; - return r === g && g === b; -} + try { + let content = fs.readFileSync(cssPath, 'utf8'); -// 흑백 색상 감지 ν•¨μˆ˜ -function isBlackOrWhite(hexColor: string): boolean { - const hex = hexColor.toLowerCase().replace('#', ''); - const fullHex = - hex.length === 3 - ? hex - .split('') - .map((char) => char + char) - .join('') - : hex; - - const r = parseInt(fullHex.substr(0, 2), 16); - const g = parseInt(fullHex.substr(2, 2), 16); - const b = parseInt(fullHex.substr(4, 2), 16); - - const isWhite = r >= 250 && g >= 250 && b >= 250; - const isBlack = r <= 10 && g <= 10 && b <= 10; - - return isWhite || isBlack; -} + progress?.startSection('🧹 Preparing workspace'); + progress?.update(50, 'Removing existing themes...'); -// scale κ°’ 계산 ν•¨μˆ˜ -export function calculateScale(varName: string, hexColor: string): boolean { - if (isBlackOrWhite(hexColor)) { - return false; - } + content = removeExistingThemeBlocks(content); - return /\d+$/.test(varName); -} + progress?.update(100, 'Workspace cleaned'); + progress?.finishSection(); -export async function runThemeApply( - cssPath: string, - progress?: ProgressReporter -) { - if (!fs.existsSync(cssPath)) { - throw new Error(`❌ CSS 파일이 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€: ${cssPath}`); - } + const variables: VariableInput = {}; - let content = fs.readFileSync(cssPath, 'utf8'); + // CSS λ³€μˆ˜ μΆ”μΆœ + progress?.startSection('πŸ” Analyzing CSS variables'); - // Section 1: theme 쀑볡 생성 λ°©μ§€λ₯Ό μœ„ν•΄ 기쑴에 μ‘΄μž¬ν•˜λŠ” theme 블둝 제거 - progress?.startSection('Remove existing theme blocks'); - content = removeExistingThemeBlocks(content); - progress?.update(100); - progress?.finishSection('Done'); - const variables: VariableInput = {}; + const scanRegex = new RegExp(variableRegex.source, variableRegex.flags); + const all = Array.from(content.matchAll(scanRegex)); + result.variables.found = all.length; - // Section 2: CSSμ—μ„œ λ³€μˆ˜ μΆ”μΆœ - progress?.startSection('Extract variables'); - const scanRegex = new RegExp(variableRegex.source, variableRegex.flags); - const all = Array.from(content.matchAll(scanRegex)); - const totalVars = all.length || 1; - const loopRegex = new RegExp(variableRegex.source, variableRegex.flags); - let match; - let count = 0; - while ((match = loopRegex.exec(content)) !== null) { - const [, key, value] = match; + if (all.length === 0) { + throw createCLIError( + 'No CSS custom properties found', + 4, + [ + 'Add CSS variables to your file using @theme { }', + 'Example: --color-primary-500: #7fe4c1;', + 'Make sure variables start with --color-' + ] + ); + } - const cleanKey = key.trim(); + const loopRegex = new RegExp(variableRegex.source, variableRegex.flags); + let match; + let count = 0; - const cleanValue = value.trim().toLowerCase(); + while ((match = loopRegex.exec(content)) !== null) { + const [, key, value] = match; + const cleanKey = key.trim(); + const cleanValue = value.trim().toLowerCase(); - if (isNeutralColor(cleanValue)) { count++; - progress?.update((count / totalVars) * 100); - continue; - } + const percent = (count / all.length) * 100; - const scale = calculateScale(cleanKey, cleanValue); - const rich: VariableRich = { - base: cleanValue, - scale, - }; - variables[cleanKey] = rich; - count++; - progress?.update((count / totalVars) * 100); - } - progress?.finishSection('Done'); + if (isNeutralColor(cleanValue)) { + result.variables.skipped++; + continue; + } - const visions: Vision[] = ['deuteranopia', 'protanopia', 'tritanopia']; + const scale = calculateScale(cleanKey, cleanValue); + variables[cleanKey] = { base: cleanValue, scale }; + result.variables.processed++; - // 색상 λ³€ν™˜ μ•Œκ³ λ¦¬μ¦˜ 호좜 - try { - const { colorKeys, baseColorsArray } = prepareCandidates( - variables, - progress - ); - for (const vision of visions) { - const label = `Process β€” ${vision}`; - progress?.startSection(label); - progress?.update(30, 'Optimizing...'); - const themeData = buildThemeForVision( - colorKeys, - baseColorsArray, - vision + progress?.update(percent, `Found: ${cleanKey}`); + } + + progress?.finishSection(`Found ${result.variables.processed} color variables`); + + if (result.variables.processed === 0) { + throw createCLIError( + 'No processable color variables found', + 4, + [ + 'Ensure you have non-neutral colors in HEX format', + 'Example: --color-primary: #7fe4c1; (not #ffffff or #000000)', + 'Variables should start with --color-' + ] ); - progress?.update(70, 'Applying CSS...'); - await applyThemes(themeData, cssPath, { silent: !!progress }); - progress?.update(100, 'Done'); - progress?.finishSection('Done'); } - } catch (error) { - console.log('πŸš€ ~ runThemeApply ~ error:', error); - // μ—λŸ¬ λ°œμƒ μ‹œ 원본 μƒ‰μƒμœΌλ‘œ 폴백 - for (const vision of visions) { - const label = `Process (fallback) β€” ${vision}`; - progress?.startSection(label); - await applyThemes({ vision, variables }, cssPath, { - silent: !!progress, + + // ν…Œλ§ˆ 생성 + // 색상 λ³€ν™˜ 처리 + try { + const { colorKeys, baseColorsArray } = prepareCandidates(variables, progress); + + for (const vision of VISIONS) { + const visionStartTime = Date.now(); + const label = VISION_LABELS[vision]; + const hideIndividualThemeLog = !!progress; + + progress?.startSection(label); + + try { + progress?.update(50, 'Processing...'); + + const themeData = buildThemeForVision(colorKeys, baseColorsArray, vision); + await applyThemes(themeData, cssPath, { silent: hideIndividualThemeLog }); + + const executionTime = (Date.now() - visionStartTime) / 1000; + + progress?.update(100, 'Theme generated successfully'); + progress?.finishSection('βœ… Complete'); + + result.themes.push({ + type: vision, + status: 'success', + variables: result.variables.processed, + executionTime + }); + + } catch (visionError) { + progress?.update(75, 'Failed optimized generation, using fallback...'); + + await applyThemes({ vision, variables }, cssPath, { silent: hideIndividualThemeLog }); + + const executionTime = (Date.now() - visionStartTime) / 1000; + + progress?.finishSection('⚠️ Fallback applied'); + + result.themes.push({ + type: vision, + status: 'fallback', + variables: result.variables.processed, + executionTime + }); + } + } + + } catch (error) { + const hideIndividualThemeLog = !!progress; + progress?.update(50, 'πŸ”„ Using fallback color mapping for all themes...'); + + for (const vision of VISIONS) { + const visionStartTime = Date.now(); + const label = `${VISION_LABELS[vision]} (Fallback)`; + + progress?.startSection(label); + + await applyThemes({ vision, variables }, cssPath, { silent: hideIndividualThemeLog }); + + const executionTime = (Date.now() - visionStartTime) / 1000; + + progress?.finishSection('⚠️ Fallback applied'); + + result.themes.push({ + type: vision, + status: 'fallback', + variables: result.variables.processed, + executionTime + }); + } + } + + // μ΅œμ’… μš”μ•½ + if (progress) { + const successCount = result.themes.filter(t => t.status === 'success').length; + const fallbackCount = result.themes.filter(t => t.status === 'fallback').length; + + console.log('\nπŸ“Š Generation Results:'); + result.themes.forEach(theme => { + const label = VISION_LABELS[theme.type as keyof typeof VISION_LABELS]; + const status = theme.status === 'success' ? 'βœ… Success' : + theme.status === 'fallback' ? '⚠️ Fallback' : '❌ Error'; + console.log(` ${label}: ${status} (${theme.variables} colors, ${theme.executionTime.toFixed(1)}s)`); }); - progress?.finishSection('Done'); + + console.log('\nπŸ“‹ Summary:'); + console.log(` Input file: ${cssPath}`); + console.log(` Variables found: ${result.variables.found}`); + console.log(` Variables processed: ${result.variables.processed}`); + console.log(` Variables skipped: ${result.variables.skipped}`); + console.log(` Themes generated: ${result.themes.length}`); + console.log(` Success rate: ${successCount}/${result.themes.length}`); + if (fallbackCount > 0) { + console.log(` Fallback used: ${fallbackCount}`); + } + + // 결과에 λ”°λ₯Έ λ©”μ‹œμ§€ + if (successCount === result.themes.length) { + console.log('\nπŸŽ‰ All themes generated with optimized colors!'); + } else if (successCount > 0) { + console.log(`\n⚠️ ${fallbackCount} themes used fallback mapping`); + console.log('πŸ’‘ This may result in less optimal color accessibility'); + } else { + console.log('\n⚠️ All themes used fallback mapping'); + console.log('πŸ’‘ Consider adjusting your base color palette for better results'); + } } - } - // μ„Ήμ…˜ κΈ°λ°˜μ΄λ―€λ‘œ 전체 finishλŠ” μƒλž΅ - console.log(`\nβœ… Colbrush themes applied to ${cssPath}`); + return result; + + } catch (error) { + if (error instanceof Error && (error as any).code) { + throw error; // Re-throw CLI errors as-is + } + + if (error instanceof Error) { + // 파일 μ—†μŒ μ—λŸ¬ + if (error.message.includes('ENOENT')) { + throw createCLIError( + `File not found: ${cssPath}`, + 2, + ['Check if the file path is correct', 'Use --css to specify the correct path'] + ); + } + + // κΆŒν•œ μ—λŸ¬ + if (error.message.includes('EACCES')) { + throw createCLIError( + `Permission denied: ${cssPath}`, + 6, + ['Check file permissions', 'Run with appropriate privileges'] + ); + } + + // CSS νŒŒμ‹± μ—λŸ¬ + if (error.message.includes('parse') || error.message.includes('syntax')) { + throw createCLIError( + `CSS parsing failed: ${error.message}`, + 3, + ['Check CSS syntax in your file'] + ); + } + } + + // 일반적인 μ—λŸ¬ + throw createCLIError( + `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, + 1, + ['Please double-check your CSS file for any syntax errors.', ' Try running `colbrush --doctor` to diagnose your system.', 'Update the package to the latest version and try again.'] + ); + } } diff --git a/src/cli/utils/colorUtils.ts b/src/cli/utils/colorUtils.ts new file mode 100644 index 0000000..7697cc4 --- /dev/null +++ b/src/cli/utils/colorUtils.ts @@ -0,0 +1,56 @@ +// HEX β†’ RGB λ³€ν™˜ +export function hexToRgb(hex: string): [number, number, number] | null { + let clean = hex.replace('#', '').toLowerCase(); + if (clean.length === 3) { + clean = clean + .split('') + .map((c) => c + c) + .join(''); + } + if (clean.length !== 6) return null; + + const r = parseInt(clean.substring(0, 2), 16); + const g = parseInt(clean.substring(2, 4), 16); + const b = parseInt(clean.substring(4, 6), 16); + + return [r, g, b]; +} + +// 무채색 νŒλ³„: R,G,B 값이 μ™„μ „νžˆ 같을 λ•Œλ§Œ true +export function isNeutralColor(value: string): boolean { + const rgb = hexToRgb(value); + if (!rgb) return false; + + const [r, g, b] = rgb; + return r === g && g === b; +} + +// 흑백 색상 감지 ν•¨μˆ˜ +function isBlackOrWhite(hexColor: string): boolean { + const hex = hexColor.toLowerCase().replace('#', ''); + const fullHex = + hex.length === 3 + ? hex + .split('') + .map((char) => char + char) + .join('') + : hex; + + const r = parseInt(fullHex.substring(0, 2), 16); + const g = parseInt(fullHex.substring(2, 4), 16); + const b = parseInt(fullHex.substring(4, 6), 16); + + const isWhite = r >= 250 && g >= 250 && b >= 250; + const isBlack = r <= 10 && g <= 10 && b <= 10; + + return isWhite || isBlack; +} + +// scale κ°’ 계산 ν•¨μˆ˜ +export function calculateScale(varName: string, hexColor: string): boolean { + if (isBlackOrWhite(hexColor)) { + return false; + } + + return /\d+$/.test(varName); +} \ No newline at end of file diff --git a/src/cli/utils/errors.ts b/src/cli/utils/errors.ts new file mode 100644 index 0000000..d06513e --- /dev/null +++ b/src/cli/utils/errors.ts @@ -0,0 +1,6 @@ +// CLI μ—λŸ¬ 생성 ν•¨μˆ˜ +export function createCLIError(message: string, code: number, suggestions: string[]): Error { + const error = new Error(`${message}\n\nSuggestions:\n${suggestions.map(s => ` β€’ ${s}`).join('\n')}`); + (error as any).code = code; + return error; +} \ No newline at end of file diff --git a/src/cli/utils/terminalStyles.ts b/src/cli/utils/terminalStyles.ts new file mode 100644 index 0000000..252b441 --- /dev/null +++ b/src/cli/utils/terminalStyles.ts @@ -0,0 +1,3 @@ +// 터미널 μŠ€νƒ€μΌ μœ ν‹Έλ¦¬ν‹° +export const bold = (text: string) => `\x1b[1m${text}\x1b[0m`; +export const dim = (text: string) => `\x1b[2m${text}\x1b[0m`; \ No newline at end of file