feat: implement modular theme system with file-based theme organization
Transform theme system from large inline dictionaries to maintainable YAML files:
**Architecture:**
- File-based themes organized by scope: mode/, ui/, document/, branding/
- Dynamic theme loading with automatic discovery
- Hybrid system maintaining 100% backward compatibility
- Rich metadata support with theme documentation
**Implementation:**
- Created markitect/themes/ directory with organized structure
- Added ThemeRegistry for dynamic YAML theme loading
- Extracted ChatGPT and Substack themes to separate files
- Added mode themes (light.yaml, dark.yaml) as examples
- Integrated with existing LAYERED_THEMES system seamlessly
**Benefits:**
- Improved maintainability: each theme is a separate file
- Better collaboration: multiple contributors can work simultaneously
- Enhanced discoverability: clear organization shows available themes
- Rich documentation: each theme file includes design notes and metadata
- Schema validation potential with YAML format
**Quality Assurance:**
- Comprehensive 12-test suite for modular system (12/12 passing)
- Backward compatibility verified with existing 15 theme tests (15/15 passing)
- CLI integration tested and working with file-based themes
- Theme combination and scoping functionality preserved
**Files Created:**
- markitect/themes/__init__.py - Theme registry and dynamic loader
- markitect/themes/README.md - Complete documentation and usage guide
- markitect/themes/document/{chatgpt,substack}.yaml - Modular theme files
- markitect/themes/mode/{light,dark}.yaml - Mode theme examples
- tests/test_modular_theme_system.py - Comprehensive test coverage
Addresses maintainability concerns while preserving all existing functionality.
No breaking changes - all existing code, CLI commands, and API calls work unchanged.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
146
markitect/themes/__init__.py
Normal file
146
markitect/themes/__init__.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Modular Theme System for Markitect
|
||||
|
||||
This module provides a dynamic theme loading system that loads themes from
|
||||
separate YAML files organized by scope (mode, ui, document, branding).
|
||||
|
||||
Architecture:
|
||||
themes/
|
||||
mode/ # Light/dark color schemes
|
||||
ui/ # Interface styling
|
||||
document/ # Document formatting
|
||||
branding/ # Company/personal styling
|
||||
|
||||
Each theme file is a YAML file with metadata and properties.
|
||||
"""
|
||||
|
||||
import os
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ThemeRegistry:
|
||||
"""Registry for dynamically loaded themes."""
|
||||
|
||||
def __init__(self):
|
||||
self._themes = {}
|
||||
self._loaded = False
|
||||
|
||||
def load_themes(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Load all themes from YAML files."""
|
||||
if self._loaded:
|
||||
return self._themes
|
||||
|
||||
themes_dir = Path(__file__).parent
|
||||
|
||||
# Scan all scope directories
|
||||
for scope_dir in themes_dir.iterdir():
|
||||
if scope_dir.is_dir() and not scope_dir.name.startswith('__'):
|
||||
scope = scope_dir.name
|
||||
self._load_scope_themes(scope_dir, scope)
|
||||
|
||||
self._loaded = True
|
||||
return self._themes
|
||||
|
||||
def _load_scope_themes(self, scope_dir: Path, scope: str) -> None:
|
||||
"""Load themes from a specific scope directory."""
|
||||
for theme_file in scope_dir.glob('*.yaml'):
|
||||
theme_name = theme_file.stem
|
||||
try:
|
||||
with open(theme_file, 'r', encoding='utf-8') as f:
|
||||
theme_data = yaml.safe_load(f)
|
||||
|
||||
# Validate theme structure
|
||||
if not self._validate_theme(theme_data, theme_name, theme_file):
|
||||
continue
|
||||
|
||||
# Store theme in registry
|
||||
self._themes[theme_name] = {
|
||||
'scope': theme_data.get('scope', scope),
|
||||
'properties': theme_data.get('properties', {}),
|
||||
'metadata': {
|
||||
'name': theme_data.get('name', theme_name),
|
||||
'description': theme_data.get('description', ''),
|
||||
'author': theme_data.get('author', ''),
|
||||
'version': theme_data.get('version', '1.0.0'),
|
||||
'file': str(theme_file)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(f"Loaded theme '{theme_name}' from {theme_file}")
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
logger.error(f"Failed to parse YAML in {theme_file}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load theme from {theme_file}: {e}")
|
||||
|
||||
def _validate_theme(self, theme_data: Dict[str, Any], theme_name: str, theme_file: Path) -> bool:
|
||||
"""Validate theme structure."""
|
||||
if not isinstance(theme_data, dict):
|
||||
logger.error(f"Theme {theme_file} must be a dictionary")
|
||||
return False
|
||||
|
||||
if 'properties' not in theme_data:
|
||||
logger.error(f"Theme {theme_file} missing 'properties' section")
|
||||
return False
|
||||
|
||||
if not isinstance(theme_data['properties'], dict):
|
||||
logger.error(f"Theme {theme_file} 'properties' must be a dictionary")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_theme(self, name: str) -> Dict[str, Any]:
|
||||
"""Get a specific theme by name."""
|
||||
if not self._loaded:
|
||||
self.load_themes()
|
||||
return self._themes.get(name)
|
||||
|
||||
def list_themes(self, scope: str = None) -> List[str]:
|
||||
"""List available theme names, optionally filtered by scope."""
|
||||
if not self._loaded:
|
||||
self.load_themes()
|
||||
|
||||
if scope:
|
||||
return [name for name, data in self._themes.items()
|
||||
if data.get('scope') == scope]
|
||||
return list(self._themes.keys())
|
||||
|
||||
def get_themes_by_scope(self, scope: str) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get all themes for a specific scope."""
|
||||
if not self._loaded:
|
||||
self.load_themes()
|
||||
|
||||
return {name: data for name, data in self._themes.items()
|
||||
if data.get('scope') == scope}
|
||||
|
||||
# Global theme registry instance
|
||||
theme_registry = ThemeRegistry()
|
||||
|
||||
def get_layered_themes() -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get all themes in the format expected by the existing system.
|
||||
|
||||
Returns a dictionary compatible with the old LAYERED_THEMES structure.
|
||||
"""
|
||||
return theme_registry.load_themes()
|
||||
|
||||
def get_theme(name: str) -> Dict[str, Any]:
|
||||
"""Get a specific theme by name."""
|
||||
return theme_registry.get_theme(name)
|
||||
|
||||
def list_themes(scope: str = None) -> List[str]:
|
||||
"""List available theme names."""
|
||||
return theme_registry.list_themes(scope)
|
||||
|
||||
def reload_themes() -> None:
|
||||
"""Force reload of all themes."""
|
||||
theme_registry._loaded = False
|
||||
theme_registry._themes.clear()
|
||||
theme_registry.load_themes()
|
||||
|
||||
# Export main functions
|
||||
__all__ = ['get_layered_themes', 'get_theme', 'list_themes', 'reload_themes', 'theme_registry']
|
||||
Reference in New Issue
Block a user