Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:48:52 +08:00
commit 6ec3196ecc
434 changed files with 125248 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
# UI Styling Skill Dependencies
# Python 3.10+ required
# No Python package dependencies - uses only standard library
# Testing dependencies (dev)
pytest>=8.0.0
pytest-cov>=4.1.0
pytest-mock>=3.12.0
# Note: This skill works with shadcn/ui and Tailwind CSS
# Requires Node.js and package managers:
# - Node.js 18+: https://nodejs.org/
# - npm (comes with Node.js)
#
# shadcn/ui CLI is installed per-project:
# npx shadcn-ui@latest init

View File

@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""
shadcn/ui Component Installer
Add shadcn/ui components to project with automatic dependency handling.
Wraps shadcn CLI for programmatic component installation.
"""
import argparse
import json
import subprocess
import sys
from pathlib import Path
from typing import List, Optional
class ShadcnInstaller:
"""Handle shadcn/ui component installation."""
def __init__(self, project_root: Optional[Path] = None, dry_run: bool = False):
"""
Initialize installer.
Args:
project_root: Project root directory (default: current directory)
dry_run: If True, show actions without executing
"""
self.project_root = project_root or Path.cwd()
self.dry_run = dry_run
self.components_json = self.project_root / "components.json"
def check_shadcn_config(self) -> bool:
"""
Check if shadcn is initialized in project.
Returns:
True if components.json exists
"""
return self.components_json.exists()
def get_installed_components(self) -> List[str]:
"""
Get list of already installed components.
Returns:
List of installed component names
"""
if not self.check_shadcn_config():
return []
try:
with open(self.components_json) as f:
config = json.load(f)
components_dir = self.project_root / config.get("aliases", {}).get(
"components", "components"
).replace("@/", "")
ui_dir = components_dir / "ui"
if not ui_dir.exists():
return []
return [f.stem for f in ui_dir.glob("*.tsx") if f.is_file()]
except (json.JSONDecodeError, KeyError, OSError):
return []
def add_components(
self, components: List[str], overwrite: bool = False
) -> tuple[bool, str]:
"""
Add shadcn/ui components.
Args:
components: List of component names to add
overwrite: If True, overwrite existing components
Returns:
Tuple of (success, message)
"""
if not components:
return False, "No components specified"
if not self.check_shadcn_config():
return (
False,
"shadcn not initialized. Run 'npx shadcn@latest init' first",
)
# Check which components already exist
installed = self.get_installed_components()
already_installed = [c for c in components if c in installed]
if already_installed and not overwrite:
return (
False,
f"Components already installed: {', '.join(already_installed)}. "
"Use --overwrite to reinstall",
)
# Build command
cmd = ["npx", "shadcn@latest", "add"] + components
if overwrite:
cmd.append("--overwrite")
if self.dry_run:
return True, f"Would run: {' '.join(cmd)}"
# Execute command
try:
result = subprocess.run(
cmd,
cwd=self.project_root,
capture_output=True,
text=True,
check=True,
)
success_msg = f"Successfully added components: {', '.join(components)}"
if result.stdout:
success_msg += f"\n\nOutput:\n{result.stdout}"
return True, success_msg
except subprocess.CalledProcessError as e:
error_msg = f"Failed to add components: {e.stderr or e.stdout or str(e)}"
return False, error_msg
except FileNotFoundError:
return False, "npx not found. Ensure Node.js is installed"
def add_all_components(self, overwrite: bool = False) -> tuple[bool, str]:
"""
Add all available shadcn/ui components.
Args:
overwrite: If True, overwrite existing components
Returns:
Tuple of (success, message)
"""
if not self.check_shadcn_config():
return (
False,
"shadcn not initialized. Run 'npx shadcn@latest init' first",
)
cmd = ["npx", "shadcn@latest", "add", "--all"]
if overwrite:
cmd.append("--overwrite")
if self.dry_run:
return True, f"Would run: {' '.join(cmd)}"
try:
result = subprocess.run(
cmd,
cwd=self.project_root,
capture_output=True,
text=True,
check=True,
)
success_msg = "Successfully added all components"
if result.stdout:
success_msg += f"\n\nOutput:\n{result.stdout}"
return True, success_msg
except subprocess.CalledProcessError as e:
error_msg = f"Failed to add all components: {e.stderr or e.stdout or str(e)}"
return False, error_msg
except FileNotFoundError:
return False, "npx not found. Ensure Node.js is installed"
def list_installed(self) -> tuple[bool, str]:
"""
List installed components.
Returns:
Tuple of (success, message with component list)
"""
if not self.check_shadcn_config():
return False, "shadcn not initialized"
installed = self.get_installed_components()
if not installed:
return True, "No components installed"
return True, f"Installed components:\n" + "\n".join(f" - {c}" for c in sorted(installed))
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(
description="Add shadcn/ui components to your project",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Add single component
python shadcn_add.py button
# Add multiple components
python shadcn_add.py button card dialog
# Add all components
python shadcn_add.py --all
# Overwrite existing components
python shadcn_add.py button --overwrite
# Dry run (show what would be done)
python shadcn_add.py button card --dry-run
# List installed components
python shadcn_add.py --list
""",
)
parser.add_argument(
"components",
nargs="*",
help="Component names to add (e.g., button, card, dialog)",
)
parser.add_argument(
"--all",
action="store_true",
help="Add all available components",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite existing components",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be done without executing",
)
parser.add_argument(
"--list",
action="store_true",
help="List installed components",
)
parser.add_argument(
"--project-root",
type=Path,
help="Project root directory (default: current directory)",
)
args = parser.parse_args()
# Initialize installer
installer = ShadcnInstaller(
project_root=args.project_root,
dry_run=args.dry_run,
)
# Handle list command
if args.list:
success, message = installer.list_installed()
print(message)
sys.exit(0 if success else 1)
# Handle add all command
if args.all:
success, message = installer.add_all_components(overwrite=args.overwrite)
print(message)
sys.exit(0 if success else 1)
# Handle add specific components
if not args.components:
parser.print_help()
sys.exit(1)
success, message = installer.add_components(
args.components,
overwrite=args.overwrite,
)
print(message)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,456 @@
#!/usr/bin/env python3
"""
Tailwind CSS Configuration Generator
Generate tailwind.config.js/ts with custom theme configuration.
Supports colors, fonts, spacing, breakpoints, and plugin recommendations.
"""
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
class TailwindConfigGenerator:
"""Generate Tailwind CSS configuration files."""
def __init__(
self,
typescript: bool = True,
framework: str = "react",
output_path: Optional[Path] = None,
):
"""
Initialize generator.
Args:
typescript: If True, generate .ts config, else .js
framework: Framework name (react, vue, svelte, nextjs)
output_path: Output file path (default: auto-detect)
"""
self.typescript = typescript
self.framework = framework
self.output_path = output_path or self._default_output_path()
self.config: Dict[str, Any] = self._base_config()
def _default_output_path(self) -> Path:
"""Determine default output path."""
ext = "ts" if self.typescript else "js"
return Path.cwd() / f"tailwind.config.{ext}"
def _base_config(self) -> Dict[str, Any]:
"""Create base configuration structure."""
return {
"darkMode": ["class"],
"content": self._default_content_paths(),
"theme": {
"extend": {}
},
"plugins": []
}
def _default_content_paths(self) -> List[str]:
"""Get default content paths for framework."""
paths = {
"react": [
"./src/**/*.{js,jsx,ts,tsx}",
"./index.html",
],
"vue": [
"./src/**/*.{vue,js,ts,jsx,tsx}",
"./index.html",
],
"svelte": [
"./src/**/*.{svelte,js,ts}",
"./src/app.html",
],
"nextjs": [
"./app/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
}
return paths.get(self.framework, paths["react"])
def add_colors(self, colors: Dict[str, str]) -> None:
"""
Add custom colors to theme.
Args:
colors: Dict of color_name: color_value
Value can be hex (#3b82f6) or variable (hsl(var(--primary)))
"""
if "colors" not in self.config["theme"]["extend"]:
self.config["theme"]["extend"]["colors"] = {}
self.config["theme"]["extend"]["colors"].update(colors)
def add_color_palette(self, name: str, base_color: str) -> None:
"""
Add full color palette (50-950 shades) for a base color.
Args:
name: Color name (e.g., 'brand', 'primary')
base_color: Base color in oklch format or hex
"""
# For simplicity, use CSS variable approach
if "colors" not in self.config["theme"]["extend"]:
self.config["theme"]["extend"]["colors"] = {}
self.config["theme"]["extend"]["colors"][name] = {
"50": f"var(--color-{name}-50)",
"100": f"var(--color-{name}-100)",
"200": f"var(--color-{name}-200)",
"300": f"var(--color-{name}-300)",
"400": f"var(--color-{name}-400)",
"500": f"var(--color-{name}-500)",
"600": f"var(--color-{name}-600)",
"700": f"var(--color-{name}-700)",
"800": f"var(--color-{name}-800)",
"900": f"var(--color-{name}-900)",
"950": f"var(--color-{name}-950)",
}
def add_fonts(self, fonts: Dict[str, List[str]]) -> None:
"""
Add custom font families.
Args:
fonts: Dict of font_type: [font_names]
e.g., {'sans': ['Inter', 'system-ui', 'sans-serif']}
"""
if "fontFamily" not in self.config["theme"]["extend"]:
self.config["theme"]["extend"]["fontFamily"] = {}
self.config["theme"]["extend"]["fontFamily"].update(fonts)
def add_spacing(self, spacing: Dict[str, str]) -> None:
"""
Add custom spacing values.
Args:
spacing: Dict of name: value
e.g., {'18': '4.5rem', 'navbar': '4rem'}
"""
if "spacing" not in self.config["theme"]["extend"]:
self.config["theme"]["extend"]["spacing"] = {}
self.config["theme"]["extend"]["spacing"].update(spacing)
def add_breakpoints(self, breakpoints: Dict[str, str]) -> None:
"""
Add custom breakpoints.
Args:
breakpoints: Dict of name: width
e.g., {'3xl': '1920px', 'tablet': '768px'}
"""
if "screens" not in self.config["theme"]["extend"]:
self.config["theme"]["extend"]["screens"] = {}
self.config["theme"]["extend"]["screens"].update(breakpoints)
def add_plugins(self, plugins: List[str]) -> None:
"""
Add plugin requirements.
Args:
plugins: List of plugin names
e.g., ['@tailwindcss/typography', '@tailwindcss/forms']
"""
for plugin in plugins:
if plugin not in self.config["plugins"]:
self.config["plugins"].append(plugin)
def recommend_plugins(self) -> List[str]:
"""
Get plugin recommendations based on configuration.
Returns:
List of recommended plugin package names
"""
recommendations = []
# Always recommend animation plugin
recommendations.append("tailwindcss-animate")
# Framework-specific recommendations
if self.framework == "nextjs":
recommendations.append("@tailwindcss/typography")
return recommendations
def generate_config_string(self) -> str:
"""
Generate configuration file content.
Returns:
Configuration file as string
"""
if self.typescript:
return self._generate_typescript()
return self._generate_javascript()
def _generate_typescript(self) -> str:
"""Generate TypeScript configuration."""
plugins_str = self._format_plugins()
config_json = json.dumps(self.config, indent=2)
# Remove plugin array from JSON (we'll add it with require())
config_obj = self.config.copy()
config_obj.pop("plugins", None)
config_json = json.dumps(config_obj, indent=2)
return f"""import type {{ Config }} from 'tailwindcss'
const config: Config = {{
{self._indent_json(config_json, 1)}
plugins: [{plugins_str}],
}}
export default config
"""
def _generate_javascript(self) -> str:
"""Generate JavaScript configuration."""
plugins_str = self._format_plugins()
config_obj = self.config.copy()
config_obj.pop("plugins", None)
config_json = json.dumps(config_obj, indent=2)
return f"""/** @type {{import('tailwindcss').Config}} */
module.exports = {{
{self._indent_json(config_json, 1)}
plugins: [{plugins_str}],
}}
"""
def _format_plugins(self) -> str:
"""Format plugins array for config."""
if not self.config["plugins"]:
return ""
plugin_requires = [
f"require('{plugin}')" for plugin in self.config["plugins"]
]
return ", ".join(plugin_requires)
def _indent_json(self, json_str: str, level: int) -> str:
"""Add indentation to JSON string."""
indent = " " * level
lines = json_str.split("\n")
# Skip first and last lines (braces)
indented = [indent + line for line in lines[1:-1]]
return "\n".join(indented)
def write_config(self) -> tuple[bool, str]:
"""
Write configuration to file.
Returns:
Tuple of (success, message)
"""
try:
config_content = self.generate_config_string()
self.output_path.write_text(config_content)
return True, f"Configuration written to {self.output_path}"
except OSError as e:
return False, f"Failed to write config: {e}"
def validate_config(self) -> tuple[bool, str]:
"""
Validate configuration.
Returns:
Tuple of (valid, message)
"""
# Check content paths exist
if not self.config["content"]:
return False, "No content paths specified"
# Check if extending empty theme
if not self.config["theme"]["extend"]:
return True, "Warning: No theme extensions defined"
return True, "Configuration valid"
def main():
"""CLI entry point."""
parser = argparse.ArgumentParser(
description="Generate Tailwind CSS configuration",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Generate TypeScript config for Next.js
python tailwind_config_gen.py --framework nextjs
# Generate JavaScript config with custom colors
python tailwind_config_gen.py --js --colors brand:#3b82f6 accent:#8b5cf6
# Add custom fonts
python tailwind_config_gen.py --fonts display:"Playfair Display,serif"
# Add custom spacing and breakpoints
python tailwind_config_gen.py --spacing navbar:4rem --breakpoints 3xl:1920px
# Add recommended plugins
python tailwind_config_gen.py --plugins
""",
)
parser.add_argument(
"--framework",
choices=["react", "vue", "svelte", "nextjs"],
default="react",
help="Target framework (default: react)",
)
parser.add_argument(
"--js",
action="store_true",
help="Generate JavaScript config instead of TypeScript",
)
parser.add_argument(
"--output",
type=Path,
help="Output file path",
)
parser.add_argument(
"--colors",
nargs="*",
metavar="NAME:VALUE",
help="Custom colors (e.g., brand:#3b82f6)",
)
parser.add_argument(
"--fonts",
nargs="*",
metavar="TYPE:FAMILY",
help="Custom fonts (e.g., sans:'Inter,system-ui')",
)
parser.add_argument(
"--spacing",
nargs="*",
metavar="NAME:VALUE",
help="Custom spacing (e.g., navbar:4rem)",
)
parser.add_argument(
"--breakpoints",
nargs="*",
metavar="NAME:WIDTH",
help="Custom breakpoints (e.g., 3xl:1920px)",
)
parser.add_argument(
"--plugins",
action="store_true",
help="Add recommended plugins",
)
parser.add_argument(
"--validate-only",
action="store_true",
help="Validate config without writing file",
)
args = parser.parse_args()
# Initialize generator
generator = TailwindConfigGenerator(
typescript=not args.js,
framework=args.framework,
output_path=args.output,
)
# Add custom colors
if args.colors:
colors = {}
for color_spec in args.colors:
try:
name, value = color_spec.split(":", 1)
colors[name] = value
except ValueError:
print(f"Invalid color spec: {color_spec}", file=sys.stderr)
sys.exit(1)
generator.add_colors(colors)
# Add custom fonts
if args.fonts:
fonts = {}
for font_spec in args.fonts:
try:
font_type, family = font_spec.split(":", 1)
fonts[font_type] = [f.strip().strip("'\"") for f in family.split(",")]
except ValueError:
print(f"Invalid font spec: {font_spec}", file=sys.stderr)
sys.exit(1)
generator.add_fonts(fonts)
# Add custom spacing
if args.spacing:
spacing = {}
for spacing_spec in args.spacing:
try:
name, value = spacing_spec.split(":", 1)
spacing[name] = value
except ValueError:
print(f"Invalid spacing spec: {spacing_spec}", file=sys.stderr)
sys.exit(1)
generator.add_spacing(spacing)
# Add custom breakpoints
if args.breakpoints:
breakpoints = {}
for bp_spec in args.breakpoints:
try:
name, width = bp_spec.split(":", 1)
breakpoints[name] = width
except ValueError:
print(f"Invalid breakpoint spec: {bp_spec}", file=sys.stderr)
sys.exit(1)
generator.add_breakpoints(breakpoints)
# Add recommended plugins
if args.plugins:
recommended = generator.recommend_plugins()
generator.add_plugins(recommended)
print(f"Added recommended plugins: {', '.join(recommended)}")
print("\nInstall with:")
print(f" npm install -D {' '.join(recommended)}")
# Validate
valid, message = generator.validate_config()
if not valid:
print(f"Validation failed: {message}", file=sys.stderr)
sys.exit(1)
if message.startswith("Warning"):
print(message)
# Validate only mode
if args.validate_only:
print("Configuration valid")
print("\nGenerated config:")
print(generator.generate_config_string())
sys.exit(0)
# Write config
success, message = generator.write_config()
print(message)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
pytest>=7.4.0
pytest-cov>=4.1.0
pytest-mock>=3.11.1

View File

@@ -0,0 +1,266 @@
"""Tests for shadcn_add.py"""
import json
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch
import pytest
# Add parent directory to path for imports
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from shadcn_add import ShadcnInstaller
class TestShadcnInstaller:
"""Test ShadcnInstaller class."""
@pytest.fixture
def temp_project(self, tmp_path):
"""Create temporary project structure."""
project_root = tmp_path / "test-project"
project_root.mkdir()
# Create components.json
components_json = project_root / "components.json"
components_json.write_text(
json.dumps({
"style": "new-york",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
})
)
# Create components directory
ui_dir = project_root / "components" / "ui"
ui_dir.mkdir(parents=True)
return project_root
def test_init_default_project_root(self):
"""Test initialization with default project root."""
installer = ShadcnInstaller()
assert installer.project_root == Path.cwd()
assert installer.dry_run is False
def test_init_custom_project_root(self, tmp_path):
"""Test initialization with custom project root."""
installer = ShadcnInstaller(project_root=tmp_path)
assert installer.project_root == tmp_path
def test_init_dry_run(self):
"""Test initialization with dry run mode."""
installer = ShadcnInstaller(dry_run=True)
assert installer.dry_run is True
def test_check_shadcn_config_exists(self, temp_project):
"""Test checking for existing shadcn config."""
installer = ShadcnInstaller(project_root=temp_project)
assert installer.check_shadcn_config() is True
def test_check_shadcn_config_not_exists(self, tmp_path):
"""Test checking for non-existent shadcn config."""
installer = ShadcnInstaller(project_root=tmp_path)
assert installer.check_shadcn_config() is False
def test_get_installed_components_empty(self, temp_project):
"""Test getting installed components when none exist."""
installer = ShadcnInstaller(project_root=temp_project)
installed = installer.get_installed_components()
assert installed == []
def test_get_installed_components_with_files(self, temp_project):
"""Test getting installed components when files exist."""
ui_dir = temp_project / "components" / "ui"
# Create component files
(ui_dir / "button.tsx").write_text("export const Button = () => {}")
(ui_dir / "card.tsx").write_text("export const Card = () => {}")
installer = ShadcnInstaller(project_root=temp_project)
installed = installer.get_installed_components()
assert sorted(installed) == ["button", "card"]
def test_get_installed_components_no_config(self, tmp_path):
"""Test getting installed components without config."""
installer = ShadcnInstaller(project_root=tmp_path)
installed = installer.get_installed_components()
assert installed == []
def test_add_components_no_components(self, temp_project):
"""Test adding components with empty list."""
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.add_components([])
assert success is False
assert "No components specified" in message
def test_add_components_no_config(self, tmp_path):
"""Test adding components without shadcn config."""
installer = ShadcnInstaller(project_root=tmp_path)
success, message = installer.add_components(["button"])
assert success is False
assert "not initialized" in message
def test_add_components_already_installed(self, temp_project):
"""Test adding components that are already installed."""
ui_dir = temp_project / "components" / "ui"
(ui_dir / "button.tsx").write_text("export const Button = () => {}")
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.add_components(["button"])
assert success is False
assert "already installed" in message
assert "button" in message
def test_add_components_with_overwrite(self, temp_project):
"""Test adding components with overwrite flag."""
ui_dir = temp_project / "components" / "ui"
(ui_dir / "button.tsx").write_text("export const Button = () => {}")
installer = ShadcnInstaller(project_root=temp_project)
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
stdout="Component added successfully",
returncode=0
)
success, message = installer.add_components(["button"], overwrite=True)
assert success is True
assert "Successfully added" in message
mock_run.assert_called_once()
# Verify --overwrite flag was passed
call_args = mock_run.call_args[0][0]
assert "--overwrite" in call_args
def test_add_components_dry_run(self, temp_project):
"""Test adding components in dry run mode."""
installer = ShadcnInstaller(project_root=temp_project, dry_run=True)
success, message = installer.add_components(["button", "card"])
assert success is True
assert "Would run:" in message
assert "button" in message
assert "card" in message
@patch("subprocess.run")
def test_add_components_success(self, mock_run, temp_project):
"""Test successful component addition."""
mock_run.return_value = MagicMock(
stdout="Components added successfully",
stderr="",
returncode=0
)
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.add_components(["button", "card"])
assert success is True
assert "Successfully added" in message
assert "button" in message
assert "card" in message
# Verify correct command was called
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0]
assert call_args[:3] == ["npx", "shadcn@latest", "add"]
assert "button" in call_args
assert "card" in call_args
@patch("subprocess.run")
def test_add_components_subprocess_error(self, mock_run, temp_project):
"""Test component addition with subprocess error."""
mock_run.side_effect = subprocess.CalledProcessError(
1, "cmd", stderr="Error occurred"
)
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.add_components(["button"])
assert success is False
assert "Failed to add" in message
@patch("subprocess.run")
def test_add_components_npx_not_found(self, mock_run, temp_project):
"""Test component addition when npx is not found."""
mock_run.side_effect = FileNotFoundError()
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.add_components(["button"])
assert success is False
assert "npx not found" in message
def test_add_all_components_no_config(self, tmp_path):
"""Test adding all components without config."""
installer = ShadcnInstaller(project_root=tmp_path)
success, message = installer.add_all_components()
assert success is False
assert "not initialized" in message
def test_add_all_components_dry_run(self, temp_project):
"""Test adding all components in dry run mode."""
installer = ShadcnInstaller(project_root=temp_project, dry_run=True)
success, message = installer.add_all_components()
assert success is True
assert "Would run:" in message
assert "--all" in message
@patch("subprocess.run")
def test_add_all_components_success(self, mock_run, temp_project):
"""Test successful addition of all components."""
mock_run.return_value = MagicMock(
stdout="All components added",
returncode=0
)
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.add_all_components()
assert success is True
assert "Successfully added all" in message
# Verify --all flag was passed
call_args = mock_run.call_args[0][0]
assert "--all" in call_args
def test_list_installed_no_config(self, tmp_path):
"""Test listing installed components without config."""
installer = ShadcnInstaller(project_root=tmp_path)
success, message = installer.list_installed()
assert success is False
assert "not initialized" in message
def test_list_installed_empty(self, temp_project):
"""Test listing installed components when none exist."""
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.list_installed()
assert success is True
assert "No components installed" in message
def test_list_installed_with_components(self, temp_project):
"""Test listing installed components when they exist."""
ui_dir = temp_project / "components" / "ui"
(ui_dir / "button.tsx").write_text("export const Button = () => {}")
(ui_dir / "card.tsx").write_text("export const Card = () => {}")
installer = ShadcnInstaller(project_root=temp_project)
success, message = installer.list_installed()
assert success is True
assert "button" in message
assert "card" in message

View File

@@ -0,0 +1,336 @@
"""Tests for tailwind_config_gen.py"""
from pathlib import Path
import pytest
# Add parent directory to path for imports
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from tailwind_config_gen import TailwindConfigGenerator
class TestTailwindConfigGenerator:
"""Test TailwindConfigGenerator class."""
def test_init_default_typescript(self):
"""Test initialization with default settings."""
generator = TailwindConfigGenerator()
assert generator.typescript is True
assert generator.framework == "react"
def test_init_javascript(self):
"""Test initialization for JavaScript config."""
generator = TailwindConfigGenerator(typescript=False)
assert generator.typescript is False
def test_init_framework(self):
"""Test initialization with different frameworks."""
for framework in ["react", "vue", "svelte", "nextjs"]:
generator = TailwindConfigGenerator(framework=framework)
assert generator.framework == framework
def test_default_output_path_typescript(self):
"""Test default output path for TypeScript."""
generator = TailwindConfigGenerator(typescript=True)
assert generator.output_path.name == "tailwind.config.ts"
def test_default_output_path_javascript(self):
"""Test default output path for JavaScript."""
generator = TailwindConfigGenerator(typescript=False)
assert generator.output_path.name == "tailwind.config.js"
def test_custom_output_path(self, tmp_path):
"""Test custom output path."""
custom_path = tmp_path / "custom-config.ts"
generator = TailwindConfigGenerator(output_path=custom_path)
assert generator.output_path == custom_path
def test_base_config_structure(self):
"""Test base configuration structure."""
generator = TailwindConfigGenerator()
config = generator.config
assert "darkMode" in config
assert "content" in config
assert "theme" in config
assert "plugins" in config
assert "extend" in config["theme"]
def test_default_content_paths_react(self):
"""Test default content paths for React."""
generator = TailwindConfigGenerator(framework="react")
paths = generator.config["content"]
assert any("src/**/*.{js,jsx,ts,tsx}" in p for p in paths)
assert any("index.html" in p for p in paths)
def test_default_content_paths_nextjs(self):
"""Test default content paths for Next.js."""
generator = TailwindConfigGenerator(framework="nextjs")
paths = generator.config["content"]
assert any("app/**" in p for p in paths)
assert any("pages/**" in p for p in paths)
assert any("components/**" in p for p in paths)
def test_default_content_paths_vue(self):
"""Test default content paths for Vue."""
generator = TailwindConfigGenerator(framework="vue")
paths = generator.config["content"]
assert any("vue" in p for p in paths)
def test_add_colors(self):
"""Test adding custom colors."""
generator = TailwindConfigGenerator()
colors = {
"brand": "#3b82f6",
"accent": "#8b5cf6"
}
generator.add_colors(colors)
assert "colors" in generator.config["theme"]["extend"]
assert generator.config["theme"]["extend"]["colors"]["brand"] == "#3b82f6"
assert generator.config["theme"]["extend"]["colors"]["accent"] == "#8b5cf6"
def test_add_colors_multiple_times(self):
"""Test adding colors multiple times."""
generator = TailwindConfigGenerator()
generator.add_colors({"brand": "#3b82f6"})
generator.add_colors({"accent": "#8b5cf6"})
colors = generator.config["theme"]["extend"]["colors"]
assert "brand" in colors
assert "accent" in colors
def test_add_color_palette(self):
"""Test adding full color palette."""
generator = TailwindConfigGenerator()
generator.add_color_palette("brand", "#3b82f6")
brand = generator.config["theme"]["extend"]["colors"]["brand"]
assert isinstance(brand, dict)
assert "50" in brand
assert "500" in brand
assert "950" in brand
assert "var(--color-brand" in brand["500"]
def test_add_fonts(self):
"""Test adding custom fonts."""
generator = TailwindConfigGenerator()
fonts = {
"sans": ["Inter", "system-ui", "sans-serif"],
"display": ["Playfair Display", "serif"]
}
generator.add_fonts(fonts)
font_family = generator.config["theme"]["extend"]["fontFamily"]
assert font_family["sans"] == ["Inter", "system-ui", "sans-serif"]
assert font_family["display"] == ["Playfair Display", "serif"]
def test_add_spacing(self):
"""Test adding custom spacing."""
generator = TailwindConfigGenerator()
spacing = {
"18": "4.5rem",
"navbar": "4rem"
}
generator.add_spacing(spacing)
spacing_config = generator.config["theme"]["extend"]["spacing"]
assert spacing_config["18"] == "4.5rem"
assert spacing_config["navbar"] == "4rem"
def test_add_breakpoints(self):
"""Test adding custom breakpoints."""
generator = TailwindConfigGenerator()
breakpoints = {
"3xl": "1920px",
"tablet": "768px"
}
generator.add_breakpoints(breakpoints)
screens = generator.config["theme"]["extend"]["screens"]
assert screens["3xl"] == "1920px"
assert screens["tablet"] == "768px"
def test_add_plugins(self):
"""Test adding plugins."""
generator = TailwindConfigGenerator()
plugins = ["@tailwindcss/typography", "@tailwindcss/forms"]
generator.add_plugins(plugins)
assert "@tailwindcss/typography" in generator.config["plugins"]
assert "@tailwindcss/forms" in generator.config["plugins"]
def test_add_plugins_no_duplicates(self):
"""Test that adding same plugin twice doesn't duplicate."""
generator = TailwindConfigGenerator()
generator.add_plugins(["@tailwindcss/typography"])
generator.add_plugins(["@tailwindcss/typography"])
count = generator.config["plugins"].count("@tailwindcss/typography")
assert count == 1
def test_recommend_plugins(self):
"""Test plugin recommendations."""
generator = TailwindConfigGenerator()
recommendations = generator.recommend_plugins()
assert isinstance(recommendations, list)
assert "tailwindcss-animate" in recommendations
def test_recommend_plugins_nextjs(self):
"""Test plugin recommendations for Next.js."""
generator = TailwindConfigGenerator(framework="nextjs")
recommendations = generator.recommend_plugins()
assert "@tailwindcss/typography" in recommendations
def test_generate_typescript_config(self):
"""Test generating TypeScript configuration."""
generator = TailwindConfigGenerator(typescript=True)
config = generator.generate_config_string()
assert "import type { Config } from 'tailwindcss'" in config
assert "const config: Config" in config
assert "export default config" in config
def test_generate_javascript_config(self):
"""Test generating JavaScript configuration."""
generator = TailwindConfigGenerator(typescript=False)
config = generator.generate_config_string()
assert "module.exports" in config
assert "@type" in config
def test_generate_config_with_colors(self):
"""Test generating config with custom colors."""
generator = TailwindConfigGenerator()
generator.add_colors({"brand": "#3b82f6"})
config = generator.generate_config_string()
assert "colors" in config
assert "brand" in config
def test_generate_config_with_plugins(self):
"""Test generating config with plugins."""
generator = TailwindConfigGenerator()
generator.add_plugins(["tailwindcss-animate"])
config = generator.generate_config_string()
assert "plugins:" in config
assert "require('tailwindcss-animate')" in config
def test_validate_config_valid(self):
"""Test validating valid configuration."""
generator = TailwindConfigGenerator()
valid, message = generator.validate_config()
assert valid is True
def test_validate_config_no_content(self):
"""Test validating config with no content paths."""
generator = TailwindConfigGenerator()
generator.config["content"] = []
valid, message = generator.validate_config()
assert valid is False
assert "No content paths" in message
def test_validate_config_empty_theme(self):
"""Test validating config with empty theme extensions."""
generator = TailwindConfigGenerator()
# Default has empty theme.extend
valid, message = generator.validate_config()
assert valid is True
assert "Warning" in message
def test_write_config(self, tmp_path):
"""Test writing configuration to file."""
output_path = tmp_path / "tailwind.config.ts"
generator = TailwindConfigGenerator(output_path=output_path)
success, message = generator.write_config()
assert success is True
assert output_path.exists()
assert "written to" in message
def test_write_config_creates_content(self, tmp_path):
"""Test that written config contains expected content."""
output_path = tmp_path / "tailwind.config.ts"
generator = TailwindConfigGenerator(output_path=output_path)
generator.add_colors({"brand": "#3b82f6"})
generator.write_config()
content = output_path.read_text()
assert "import type { Config }" in content
assert "brand" in content
def test_write_config_invalid_path(self):
"""Test writing config to invalid path."""
generator = TailwindConfigGenerator(output_path=Path("/invalid/path/config.ts"))
success, message = generator.write_config()
assert success is False
assert "Failed to write" in message
def test_full_configuration_typescript(self, tmp_path):
"""Test generating complete TypeScript configuration."""
output_path = tmp_path / "tailwind.config.ts"
generator = TailwindConfigGenerator(
typescript=True,
framework="nextjs",
output_path=output_path
)
# Add various customizations
generator.add_colors({"brand": "#3b82f6", "accent": "#8b5cf6"})
generator.add_fonts({"sans": ["Inter", "sans-serif"]})
generator.add_spacing({"navbar": "4rem"})
generator.add_breakpoints({"3xl": "1920px"})
generator.add_plugins(["tailwindcss-animate"])
success, _ = generator.write_config()
assert success is True
content = output_path.read_text()
# Verify all customizations are present
assert "brand" in content
assert "accent" in content
assert "Inter" in content
assert "navbar" in content
assert "3xl" in content
assert "tailwindcss-animate" in content
def test_full_configuration_javascript(self, tmp_path):
"""Test generating complete JavaScript configuration."""
output_path = tmp_path / "tailwind.config.js"
generator = TailwindConfigGenerator(
typescript=False,
framework="react",
output_path=output_path
)
generator.add_colors({"primary": "#3b82f6"})
generator.add_plugins(["@tailwindcss/forms"])
success, _ = generator.write_config()
assert success is True
content = output_path.read_text()
assert "module.exports" in content
assert "primary" in content
assert "@tailwindcss/forms" in content