Coverage for src / local_deep_research / web / themes / loader.py: 91%
76 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-25 01:07 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-25 01:07 +0000
1"""
2Theme Loader - Parse and load theme files with TOML frontmatter.
3"""
5import re
6import tomllib
7from functools import lru_cache
8from pathlib import Path
10from loguru import logger
12from .schema import (
13 REQUIRED_RGB_VARIANTS,
14 REQUIRED_VARIABLES,
15 ThemeMetadata,
16)
18# Pattern to match TOML frontmatter in CSS comments
19# Matches: /*--- ... ---*/
20FRONTMATTER_PATTERN = re.compile(r"/\*---\s*(.*?)\s*---\*/", re.DOTALL)
22# Pattern to extract theme ID from CSS selector
23THEME_ID_PATTERN = re.compile(r'\[data-theme="([^"]+)"\]')
26class ThemeLoader:
27 """Loads and parses theme files from a directory."""
29 def __init__(self, themes_dir: Path):
30 """Initialize loader with themes directory path."""
31 self.themes_dir = themes_dir
32 self._cache: dict[str, ThemeMetadata] = {}
34 def parse_frontmatter(self, css_content: str) -> dict:
35 """Extract and parse TOML frontmatter from CSS file.
37 Args:
38 css_content: The CSS file content
40 Returns:
41 Parsed TOML as dictionary, or empty dict if no frontmatter
42 """
43 match = FRONTMATTER_PATTERN.search(css_content)
44 if not match:
45 return {}
46 try:
47 return tomllib.loads(match.group(1))
48 except tomllib.TOMLDecodeError as e:
49 logger.warning(f"Failed to parse theme frontmatter: {e}")
50 return {}
52 def extract_theme_id(self, css_content: str) -> str | None:
53 """Extract theme ID from CSS selector.
55 Args:
56 css_content: The CSS file content
58 Returns:
59 Theme ID string or None if not found
60 """
61 match = THEME_ID_PATTERN.search(css_content)
62 return match.group(1) if match else None
64 def validate_css_variables(
65 self, css_content: str, theme_id: str
66 ) -> tuple[list[str], list[str]]:
67 """Validate that all required CSS variables are defined.
69 Args:
70 css_content: The CSS file content
71 theme_id: Theme identifier for logging
73 Returns:
74 Tuple of (missing_base_vars, missing_rgb_vars)
75 """
76 missing_base = [
77 var for var in REQUIRED_VARIABLES if var not in css_content
78 ]
79 missing_rgb = [
80 var for var in REQUIRED_RGB_VARIANTS if var not in css_content
81 ]
82 return missing_base, missing_rgb
84 def load_theme(self, css_path: Path) -> ThemeMetadata | None:
85 """Load a single theme from a CSS file.
87 Args:
88 css_path: Path to the CSS file
90 Returns:
91 ThemeMetadata object or None if loading failed
92 """
93 try:
94 content = css_path.read_text(encoding="utf-8")
95 meta = self.parse_frontmatter(content)
97 # Get theme ID from frontmatter or extract from CSS
98 theme_id = (
99 meta.get("id")
100 or self.extract_theme_id(content)
101 or css_path.stem
102 )
104 # Determine group from parent directory name
105 parent_name = css_path.parent.name
106 default_group = (
107 parent_name
108 if parent_name in {"core", "nature", "dev", "research"}
109 else "other"
110 )
112 # Auto-generate metadata if not provided
113 if not meta:
114 meta = {
115 "id": theme_id,
116 "label": theme_id.replace("-", " ").title(),
117 "icon": "fa-palette",
118 "group": default_group,
119 }
120 logger.debug(f"Auto-generated metadata for theme: {theme_id}")
122 # Validate CSS variables
123 missing_base, missing_rgb = self.validate_css_variables(
124 content, theme_id
125 )
126 if missing_base:
127 logger.warning(
128 f"Theme {theme_id} missing base variables: {missing_base}"
129 )
130 if missing_rgb:
131 logger.debug(
132 f"Theme {theme_id} missing RGB variants: {missing_rgb}"
133 )
135 # Determine theme type (dark/light)
136 theme_type = meta.get("type", "dark")
137 if theme_type not in ("dark", "light"):
138 theme_type = "dark"
140 return ThemeMetadata(
141 id=theme_id,
142 label=meta.get(
143 "label",
144 meta.get("name", theme_id.replace("-", " ").title()),
145 ),
146 icon=meta.get("icon", "fa-palette"),
147 group=meta.get("group", default_group),
148 type=theme_type,
149 description=meta.get("description", ""),
150 author=meta.get("author", ""),
151 css_path=css_path,
152 )
153 except Exception:
154 logger.exception(f"Failed to load theme: {css_path}")
155 return None
157 def get_css_content(self, theme: ThemeMetadata) -> str:
158 """Get CSS content for a theme, stripping frontmatter.
160 Args:
161 theme: ThemeMetadata object
163 Returns:
164 CSS content without frontmatter
165 """
166 if not theme.css_path or not theme.css_path.exists(): 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 return ""
168 content = theme.css_path.read_text(encoding="utf-8")
169 # Strip frontmatter
170 return FRONTMATTER_PATTERN.sub("", content).strip()
172 @lru_cache(maxsize=1)
173 def load_all_themes(self) -> dict[str, ThemeMetadata]:
174 """Load all themes from the themes directory.
176 Scans subdirectories (core/, nature/, dev/, research/) for .css files.
178 Returns:
179 Dictionary mapping theme IDs to ThemeMetadata objects
180 """
181 themes: dict[str, ThemeMetadata] = {}
183 # Add virtual "system" theme (no CSS file)
184 themes["system"] = ThemeMetadata(
185 id="system",
186 label="System",
187 icon="fa-desktop",
188 group="system",
189 type="dark", # Resolves at runtime based on OS preference
190 description="Follow system color scheme preference",
191 css_path=None,
192 )
194 # Scan subdirectories for theme files
195 subdirs = ["core", "nature", "dev", "research"]
196 for subdir in subdirs:
197 dir_path = self.themes_dir / subdir
198 if dir_path.exists():
199 for css_file in sorted(dir_path.glob("*.css")):
200 theme = self.load_theme(css_file)
201 if theme: 201 ↛ 199line 201 didn't jump to line 199 because the condition on line 201 was always true
202 themes[theme.id] = theme
204 # Also scan root themes directory for any loose theme files
205 for css_file in sorted(self.themes_dir.glob("*.css")): 205 ↛ 206line 205 didn't jump to line 206 because the loop on line 205 never started
206 theme = self.load_theme(css_file)
207 if theme and theme.id not in themes:
208 themes[theme.id] = theme
210 logger.info(f"Loaded {len(themes)} themes from {self.themes_dir}")
211 return themes
213 def clear_cache(self) -> None:
214 """Clear the theme cache (useful for development/hot-reload)."""
215 self._cache.clear()
216 self.load_all_themes.cache_clear()