UNPKG

hextaui

Version:

Build stunning websites effortlessly. HextaUI CLI tool to easily install components for Next.js, Vite, and Astro projects.

673 lines (596 loc) 22.7 kB
import * as p from "@clack/prompts"; import pc from "picocolors"; import fs from "fs-extra"; import { join } from "path"; import { findProjectRoot } from "../utils/project.js"; import { installDependencies, checkDependencyManager, } from "../utils/dependencies.js"; import { createInitMarker } from "../utils/init-check.js"; import { getFrameworkConfig, getComponentsDir, getUtilsDir, } from "../config/frameworks.js"; const HEXTAUI_CSS_VARIABLES = ` @theme { --radius-ele: var(--radius); --radius-card: var(--card-radius); --color-background: hsl(var(--hu-background)); --color-foreground: hsl(var(--hu-foreground)); --color-card: hsl(var(--hu-card)); --color-card-foreground: hsl(var(--hu-card-foreground)); --color-primary: hsl(var(--hu-primary)); --color-primary-foreground: hsl(var(--hu-primary-foreground)); --color-accent: hsl(var(--hu-accent)); --color-accent-foreground: hsl(var(--hu-accent-foreground)); --color-secondary: hsl(var(--hu-secondary)); --color-secondary-foreground: hsl(var(--hu-secondary-foreground)); --color-muted: hsl(var(--hu-muted)); --color-muted-foreground: hsl(var(--hu-muted-foreground)); --color-destructive: hsl(var(--hu-destructive)); --color-destructive-foreground: hsl(var(--hu-destructive-foreground)); --color-border: hsl(var(--hu-border)); --color-input: hsl(var(--hu-input)); --color-ring: hsl(var(--hu-ring)); } :root { --hu-font-geist: var(--font-geist); --hu-font-jetbrains: var(--font-jetbrains-mono); --radius: 0.8rem; --card-radius: 1rem; --hu-background: 0, 0%, 100%; --hu-foreground: 0, 0%, 14%; --hu-card: 0, 0%, 99%; --hu-card-foreground: 0, 0%, 14%; --hu-primary: 235, 100%, 60%; --hu-primary-foreground: 0, 0%, 98%; --hu-secondary: 0, 0%, 97%; --hu-secondary-foreground: 0, 0%, 20%; --hu-muted: 0, 0%, 97%; --hu-muted-foreground: 0, 0%, 56%; --hu-accent: 0, 0%, 96%; --hu-accent-foreground: 0, 0%, 20%; --hu-destructive: 9, 96%, 47%; --hu-destructive-foreground: 0, 0%, 98%; --hu-border: 0, 0%, 92%; --hu-input: 0, 0%, 100%; --hu-ring: 0, 0%, 71%; --color-fd-background: hsl(var(--hu-background)); --color-fd-card: hsl(var(--hu-background)); } .dark { --hu-background: 0, 0%, 7%; --hu-foreground: 0, 0%, 100%; --hu-card: 0, 0%, 9%; --hu-card-foreground: 0, 0%, 100%; --hu-primary: 235, 100%, 60%; --hu-primary-foreground: 0, 0%, 98%; --hu-secondary: 0, 0%, 15%; --hu-secondary-foreground: 0, 0%, 100%; --hu-muted: 0, 0%, 15%; --hu-muted-foreground: 0, 0%, 71%; --hu-accent: 0, 0%, 15%; --hu-accent-foreground: 0, 0%, 100%; --hu-destructive: 0, 84%, 50%; --hu-destructive-foreground: 0, 0%, 98%; --hu-border: 0, 0%, 100%, 10%; --hu-input: 0, 0%, 100%, 5%; --hu-ring: 0, 0%, 56%; --color-fd-background: hsl(var(--hu-background)); --color-fd-card: hsl(var(--hu-background)); }`; async function findGlobalCssFile(projectRoot, framework) { const possiblePaths = (() => { switch (framework) { case "nextjs": return [ "src/app/globals.css", "app/globals.css", "src/styles/globals.css", "styles/globals.css", "src/global.css", "global.css", ]; case "vite": return [ "src/index.css", "src/App.css", "src/main.css", "src/styles/index.css", "src/styles/global.css", ]; case "astro": return [ "src/styles/global.css", "src/styles/globals.css", "src/global.css", "src/layouts/Layout.astro", ]; default: return [ "src/index.css", "src/styles/index.css", "src/global.css", "src/app.css", ]; } })(); for (const possiblePath of possiblePaths) { const fullPath = join(projectRoot, possiblePath); if (await fs.pathExists(fullPath)) { return fullPath; } } return null; } function removeExistingHextaUIVariables(content) { const lines = content.split("\n"); const cleanedLines = []; let insideHextaUIBlock = false; let braceCount = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!line) continue; const trimmedLine = line.trim(); // Detect start of potential HextaUI block if (trimmedLine === ":root {" || trimmedLine === ".dark {") { // Look ahead to see if this contains HextaUI variables let containsHextaUIVars = false; for (let j = i + 1; j < Math.min(i + 10, lines.length); j++) { const lookAheadLine = lines[j]; if (!lookAheadLine) continue; if (lookAheadLine.includes("--hu-") || lookAheadLine.includes("--radius:") || lookAheadLine.includes("--color-fd-")) { containsHextaUIVars = true; break; } if (lookAheadLine.trim() === "}") break; } if (containsHextaUIVars) { insideHextaUIBlock = true; braceCount = 1; continue; } } if (insideHextaUIBlock) { // Count braces to know when block ends braceCount += (line.match(/{/g) || []).length; braceCount -= (line.match(/}/g) || []).length; if (braceCount === 0) { insideHextaUIBlock = false; } continue; } // Skip individual HextaUI variable lines if (trimmedLine.startsWith("--hu-") || trimmedLine.startsWith("--radius:") || trimmedLine.startsWith("--color-fd-")) { continue; } cleanedLines.push(line); } return cleanedLines.join("\n").replace(/\n{3,}/g, "\n\n"); } async function injectCssVariables(projectRoot, framework) { const globalCssPath = await findGlobalCssFile(projectRoot, framework); if (!globalCssPath) { p.log.warn("⚠️ Could not find global.css file automatically."); p.note(`Please create a global.css file and add these variables:\n\n${HEXTAUI_CSS_VARIABLES}`, "CSS Variables (Manual Setup)"); return; } const relativePath = globalCssPath .replace(projectRoot, "") .replace(/^[\/\\]/, ""); // Read existing content const existingContent = await fs.readFile(globalCssPath, "utf-8"); // Check if HextaUI variables already exist if (existingContent.includes("--hu-background") || existingContent.includes("--hu-foreground")) { const shouldReplace = await p.confirm({ message: `HextaUI CSS variables already exist in ${relativePath}. Replace them?`, initialValue: false, }); if (p.isCancel(shouldReplace) || !shouldReplace) { p.log.step("Skipping CSS variables injection."); return; } // Remove existing HextaUI variables const cleanedContent = removeExistingHextaUIVariables(existingContent); const newContent = cleanedContent.trimEnd() + "\n\n" + HEXTAUI_CSS_VARIABLES + "\n"; await fs.writeFile(globalCssPath, newContent); p.log.success(`✓ Updated HextaUI CSS variables in ${pc.cyan(relativePath)}`); } else { // Check if there are existing CSS variables that might conflict const hasExistingVars = existingContent.includes(":root") || existingContent.includes("--"); if (hasExistingVars) { p.log.warn(`⚠️ Existing CSS variables found in ${relativePath}.\n` + ` Please review and remove any conflicting variables.`); const shouldContinue = await p.confirm({ message: "Continue adding HextaUI CSS variables?", initialValue: true, }); if (p.isCancel(shouldContinue) || !shouldContinue) { p.log.step("Skipping CSS variables injection."); return; } } // Add HextaUI variables to the end of the file const newContent = existingContent.trimEnd() + "\n\n" + HEXTAUI_CSS_VARIABLES + "\n"; await fs.writeFile(globalCssPath, newContent); p.log.success(`✓ Added HextaUI CSS variables to ${pc.cyan(relativePath)}`); } // Show important note about variable usage p.note(`🎨 HextaUI CSS variables have been added to your global stylesheet.\n\n` + `These variables provide:\n` + `• Light and dark theme support\n` + `• Consistent color scheme across components\n` + `• Easy customization of the design system\n\n` + `${pc.yellow("Important:")} Remove any conflicting CSS variables to avoid styling issues.`, "CSS Variables Added"); } function getFrameworkSpecificInstructions(framework) { switch (framework) { case "vite": return `1. Add alias configuration to your vite.config.js/ts: ${pc.cyan(`resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, },`)} 2. Update your tsconfig.json baseUrl and paths: ${pc.cyan(`{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }`)} 3. Make sure Tailwind CSS is properly configured`; case "astro": return `1. Ensure you have the required integrations in astro.config.js/ts: ${pc.cyan(`import { defineConfig } from 'astro/config'; import react from '@astrojs/react'; import tailwind from '@astrojs/tailwind'; export default defineConfig({ integrations: [ react(), tailwind({ applyBaseStyles: false, }), ], });`)} 2. Update your tsconfig.json paths: ${pc.cyan(`{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }`)} 3. Install required dependencies: ${pc.cyan("npm install @astrojs/react @astrojs/tailwind")}`; default: return ""; } } export async function initCommand() { console.clear(); p.intro(pc.bgCyan(pc.black(" HextaUI Init "))); // Find project root and detect framework const projectInfo = await findProjectRoot(); if (!projectInfo) { p.cancel("Could not find a supported project. Please run this command in a Next.js, Vite, or Astro project."); process.exit(1); } const { root: projectRoot, framework } = projectInfo; const config = getFrameworkConfig(framework); p.log.info(`Detected ${pc.cyan(config.displayName)} project`); const s = p.spinner(); s.start("Initializing HextaUI..."); try { // Create components/ui directory const componentsDir = getComponentsDir(framework, projectRoot); await fs.ensureDir(componentsDir); // Create utils directory and files const libDir = getUtilsDir(framework, projectRoot); await fs.ensureDir(libDir); const utilsContent = `import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }`; const colorUtilsContent = `"use client"; /** * Enhanced color format utilities for the ColorPicker component * Provides conversion between different color formats including OKLCH and LAB */ import { Color, parseColor } from "react-aria-components"; export type ColorFormat = "hex" | "rgb" | "hsl" | "hsv" | "oklch" | "lab"; export const formatLabels: Record<ColorFormat, string> = { hex: "HEX", rgb: "RGB", hsl: "HSL", hsv: "HSV", oklch: "OKLCH", lab: "LAB", }; /** * Converts RGB values (0-1) to XYZ color space */ function rgbToXyz(r: number, g: number, b: number): [number, number, number] { // Convert sRGB to linear RGB const toLinear = (c: number) => { return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; const rLinear = toLinear(r); const gLinear = toLinear(g); const bLinear = toLinear(b); // Convert to XYZ using sRGB matrix const x = rLinear * 0.4124564 + gLinear * 0.3575761 + bLinear * 0.1804375; const y = rLinear * 0.2126729 + gLinear * 0.7151522 + bLinear * 0.072175; const z = rLinear * 0.0193339 + gLinear * 0.119192 + bLinear * 0.9503041; return [x, y, z]; } /** * Converts XYZ to LAB color space */ function xyzToLab(x: number, y: number, z: number): [number, number, number] { // Reference white point D65 const xn = 0.95047; const yn = 1.0; const zn = 1.08883; const fx = x / xn; const fy = y / yn; const fz = z / zn; const transform = (t: number) => { return t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116; }; const fxT = transform(fx); const fyT = transform(fy); const fzT = transform(fz); const L = 116 * fyT - 16; const a = 500 * (fxT - fyT); const b = 200 * (fyT - fzT); return [L, a, b]; } /** * Converts XYZ to OKLCH color space (simplified conversion) */ function xyzToOklch(x: number, y: number, z: number): [number, number, number] { // Simplified conversion to OKLCH // In practice, you'd want to use a proper color library like colorjs.io // Convert to OKLab first (simplified) const l = Math.cbrt(0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z); const m = Math.cbrt(0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z); const s = Math.cbrt(0.0482003018 * x + 0.2643662691 * y + 0.633851707 * z); const okL = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; const okA = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; const okB = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; // Convert to LCH const L_oklch = okL; const C = Math.sqrt(okA * okA + okB * okB); const H = (Math.atan2(okB, okA) * 180) / Math.PI; return [L_oklch, C, H < 0 ? H + 360 : H]; } /** * Formats a color value according to the specified format */ export function formatColorValue(color: Color, format: ColorFormat): string { switch (format) { case "hex": return color.toString("hex"); case "rgb": { const rgb = color.toFormat("rgb"); const r = Math.round(rgb.getChannelValue("red")); const g = Math.round(rgb.getChannelValue("green")); const b = Math.round(rgb.getChannelValue("blue")); const alpha = rgb.getChannelValue("alpha"); if (alpha < 1) { return \`rgba(\${r}, \${g}, \${b}, \${alpha.toFixed(2)})\`; } return \`rgb(\${r}, \${g}, \${b})\`; } case "hsl": { const hsl = color.toFormat("hsl"); const h = Math.round(hsl.getChannelValue("hue")); const s = Math.round(hsl.getChannelValue("saturation")); const l = Math.round(hsl.getChannelValue("lightness")); const alpha = hsl.getChannelValue("alpha"); if (alpha < 1) { return \`hsla(\${h}, \${s}%, \${l}%, \${alpha.toFixed(2)})\`; } return \`hsl(\${h}, \${s}%, \${l}%)\`; } case "hsv": { const hsv = color.toFormat("hsb"); // HSB is HSV in react-aria-components const h = Math.round(hsv.getChannelValue("hue")); const s = Math.round(hsv.getChannelValue("saturation")); const v = Math.round(hsv.getChannelValue("brightness")); const alpha = hsv.getChannelValue("alpha"); if (alpha < 1) { return \`hsva(\${h}, \${s}%, \${v}%, \${alpha.toFixed(2)})\`; } return \`hsv(\${h}, \${s}%, \${v}%)\`; } case "oklch": { const rgb = color.toFormat("rgb"); const r = rgb.getChannelValue("red") / 255; const g = rgb.getChannelValue("green") / 255; const b = rgb.getChannelValue("blue") / 255; const alpha = rgb.getChannelValue("alpha"); const [x, y, z] = rgbToXyz(r, g, b); const [L, C, H] = xyzToOklch(x, y, z); if (alpha < 1) { return \`oklch(\${(L * 100).toFixed(1)}% \${C.toFixed(3)} \${H.toFixed( 1, )} / \${alpha.toFixed(2)})\`; } return \`oklch(\${(L * 100).toFixed(1)}% \${C.toFixed(3)} \${H.toFixed(1)})\`; } case "lab": { const rgb = color.toFormat("rgb"); const r = rgb.getChannelValue("red") / 255; const g = rgb.getChannelValue("green") / 255; const b = rgb.getChannelValue("blue") / 255; const alpha = rgb.getChannelValue("alpha"); const [x, y, z] = rgbToXyz(r, g, b); const [L, a, b_lab] = xyzToLab(x, y, z); if (alpha < 1) { return \`lab(\${L.toFixed(1)}% \${a.toFixed(1)} \${b_lab.toFixed( 1, )} / \${alpha.toFixed(2)})\`; } return \`lab(\${L.toFixed(1)}% \${a.toFixed(1)} \${b_lab.toFixed(1)})\`; } default: return color.toString("hex"); } } /** * Parses a color string in the specified format */ export function parseColorFromFormat( value: string, format: ColorFormat, ): Color | null { try { // For formats that react-aria-components supports directly if (format === "hex" || format === "rgb" || format === "hsl") { return parseColor(value); } if (format === "hsv") { // Try to parse HSV/HSB format const hsvMatch = value.match(/hsva?\\(([^)]+)\\)/); if (hsvMatch) { const parts = hsvMatch[1].split(",").map((p) => p.trim()); const h = parseFloat(parts[0]) || 0; const s = parseFloat(parts[1]) || 0; const v = parseFloat(parts[2]) || 0; const a = parts[3] ? parseFloat(parts[3]) : 1; // Convert HSV to HSL for react-aria-components const hslL = (v * (2 - s / 100)) / 2; const hslS = (v * s) / (100 - Math.abs(2 * hslL - 100)); return parseColor(\`hsla(\${h}, \${hslS || 0}%, \${hslL}%, \${a})\`); } } if (format === "oklch") { // Parse OKLCH and convert to HSL as approximation const oklchMatch = value.match(/oklch\\(([^)]+)\\)/); if (oklchMatch) { const parts = oklchMatch[1].split(/[\\s\\/]+/); const L = parseFloat(parts[0]) || 50; const C = parseFloat(parts[1]) || 0; const H = parseFloat(parts[2]) || 0; const alpha = parts[3] ? parseFloat(parts[3]) : 1; // Simplified conversion back to HSL return parseColor( \`hsla(\${H}, \${Math.min(C * 100, 100)}%, \${L}%, \${alpha})\`, ); } } if (format === "lab") { // Parse LAB and convert to HSL as approximation const labMatch = value.match(/lab\\(([^)]+)\\)/); if (labMatch) { const parts = labMatch[1].split(/[\\s\\/]+/); const L = parseFloat(parts[0]) || 50; const a = parseFloat(parts[1]) || 0; const b = parseFloat(parts[2]) || 0; const alpha = parts[3] ? parseFloat(parts[3]) : 1; // Simplified conversion back to HSL const chroma = Math.sqrt(a * a + b * b); const hue = (Math.atan2(b, a) * 180) / Math.PI; return parseColor( \`hsla(\${hue < 0 ? hue + 360 : hue}, \${Math.min( chroma, 100, )}%, \${L}%, \${alpha})\`, ); } } // Fallback: try to parse as any supported format return parseColor(value); } catch { return null; } } /** * Validates if a color string is valid for the given format */ export function isValidColorFormat( value: string, format: ColorFormat, ): boolean { const parsed = parseColorFromFormat(value, format); return parsed !== null; } /** * Gets format-specific input placeholder text */ export function getFormatPlaceholder(format: ColorFormat): string { switch (format) { case "hex": return "#3b82f6"; case "rgb": return "rgb(59, 130, 246)"; case "hsl": return "hsl(220, 91%, 64%)"; case "hsv": return "hsv(220, 76%, 96%)"; case "oklch": return "oklch(65% 0.15 230)"; case "lab": return "lab(55% -10 40)"; default: return ""; } }`; // Write utility files await fs.writeFile(join(libDir, "utils.ts"), utilsContent); await fs.writeFile(join(libDir, "color-utils.ts"), colorUtilsContent); s.stop("HextaUI initialized successfully"); // Install required dependencies const packageManager = await checkDependencyManager(projectRoot); s.start(`Installing required dependencies with ${packageManager}...`); try { await installDependencies(config.requiredDependencies, projectRoot); s.stop("Dependencies installed successfully"); } catch (error) { s.stop("Failed to install some dependencies"); p.log.warn(`Please manually install: ${pc.yellow(config.requiredDependencies.join(" "))}`); p.log.info(`Run: ${pc.cyan(`${packageManager} ${packageManager === "npm" ? "install" : "add"} ${config.requiredDependencies.join(" ")}`)}`); } // Create initialization marker await createInitMarker(projectRoot); // Add CSS variables injection step const shouldInjectCss = await p.confirm({ message: "Add HextaUI CSS variables to your global stylesheet?", initialValue: true, }); if (!p.isCancel(shouldInjectCss) && shouldInjectCss) { await injectCssVariables(projectRoot, framework); } else { p.note(`You can manually add the CSS variables later:\n\n${HEXTAUI_CSS_VARIABLES}`, "CSS Variables (Manual Setup)"); } // Show framework-specific setup instructions if (framework === "vite" || framework === "astro") { p.note(`${pc.yellow("Framework-specific setup required:")}\n\n` + getFrameworkSpecificInstructions(framework), `${config.displayName} Setup`); } p.note(`Created:\n` + `${pc.cyan(config.componentsPath + "/")} - Components directory\n` + `${pc.cyan(config.utilsPath + "/utils.ts")} - Utility functions\n` + `${pc.cyan(config.utilsPath + "/color-utils.ts")} - Color utility functions\n\n` + `${pc.green("✓")} Base dependencies installed\n` + `${pc.green("✓")} CSS variables ${shouldInjectCss ? "added" : "skipped"}`, "Files created"); p.outro(pc.green('HextaUI is ready! Run "npx hextaui add" to add components.')); } catch (error) { s.stop("Failed to initialize HextaUI"); p.cancel(`Error: ${error}`); process.exit(1); } }