Coverage for src/local_deep_research/web/themes/loader.py: 90%
86 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +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 LIGHT_THEME_REQUIRED_VARIABLES,
14 REQUIRED_RGB_VARIANTS,
15 REQUIRED_VARIABLES,
16 ThemeMetadata,
17)
19# Pattern to match TOML frontmatter in CSS comments
20# Matches: /*--- ... ---*/
21FRONTMATTER_PATTERN = re.compile(r"/\*---\s*(.*?)\s*---\*/", re.DOTALL)
23# Pattern to extract theme ID from CSS selector
24THEME_ID_PATTERN = re.compile(r'\[data-theme="([^"]+)"\]')
26# Matches CSS comments, including the TOML frontmatter block, so declaration
27# scanning can ignore anything that is only mentioned in a comment.
28CSS_COMMENT_PATTERN = re.compile(r"/\*.*?\*/", re.DOTALL)
30# Matches an actual custom-property *declaration* (`--name: value;`) at the
31# start of a line or right after a `{` / `;` (so single-line and minified
32# rules count too), as opposed to a `var(--name)` usage elsewhere in the
33# value, or a longer name that merely starts with the same characters
34# (e.g. `--control-border-hover` must not satisfy `--control-border`).
35CUSTOM_PROPERTY_DECLARATION_PATTERN = re.compile(
36 r"(?:^|[{;])\s*(--[\w-]+)\s*:", re.MULTILINE
37)
40class ThemeLoader:
41 """Loads and parses theme files from a directory."""
43 def __init__(self, themes_dir: Path):
44 """Initialize loader with themes directory path."""
45 self.themes_dir = themes_dir
46 self._cache: dict[str, ThemeMetadata] = {}
48 def parse_frontmatter(self, css_content: str) -> dict:
49 """Extract and parse TOML frontmatter from CSS file.
51 Args:
52 css_content: The CSS file content
54 Returns:
55 Parsed TOML as dictionary, or empty dict if no frontmatter
56 """
57 match = FRONTMATTER_PATTERN.search(css_content)
58 if not match:
59 return {}
60 try:
61 return tomllib.loads(match.group(1))
62 except tomllib.TOMLDecodeError:
63 logger.warning("Failed to parse theme frontmatter")
64 return {}
66 def extract_theme_id(self, css_content: str) -> str | None:
67 """Extract theme ID from CSS selector.
69 Args:
70 css_content: The CSS file content
72 Returns:
73 Theme ID string or None if not found
74 """
75 match = THEME_ID_PATTERN.search(css_content)
76 return match.group(1) if match else None
78 def declared_custom_properties(self, css_content: str) -> set[str]:
79 """Custom property names actually *declared* in the CSS.
81 Comments are stripped first so a variable only mentioned in a
82 comment (or referenced via `var(--name)`) isn't mistaken for a
83 declaration, and matching is on the exact property name so
84 `--control-border` doesn't match `--control-border-hover`.
86 Args:
87 css_content: The CSS file content
89 Returns:
90 Set of declared custom property names, e.g. {"--text-primary"}
91 """
92 without_comments = CSS_COMMENT_PATTERN.sub("", css_content)
93 return {
94 match.group(1)
95 for match in CUSTOM_PROPERTY_DECLARATION_PATTERN.finditer(
96 without_comments
97 )
98 }
100 def validate_css_variables(
101 self, css_content: str, theme_id: str
102 ) -> tuple[list[str], list[str]]:
103 """Validate that all required CSS variables are defined.
105 Args:
106 css_content: The CSS file content
107 theme_id: Theme identifier for logging
109 Returns:
110 Tuple of (missing_base_vars, missing_rgb_vars)
111 """
112 missing_base = [
113 var for var in REQUIRED_VARIABLES if var not in css_content
114 ]
115 missing_rgb = [
116 var for var in REQUIRED_RGB_VARIANTS if var not in css_content
117 ]
118 return missing_base, missing_rgb
120 def load_theme(self, css_path: Path) -> ThemeMetadata | None:
121 """Load a single theme from a CSS file.
123 Args:
124 css_path: Path to the CSS file
126 Returns:
127 ThemeMetadata object or None if loading failed
128 """
129 try:
130 content = css_path.read_text(encoding="utf-8")
131 meta = self.parse_frontmatter(content)
133 # Get theme ID from frontmatter or extract from CSS
134 theme_id = (
135 meta.get("id")
136 or self.extract_theme_id(content)
137 or css_path.stem
138 )
140 # Determine group from parent directory name
141 parent_name = css_path.parent.name
142 default_group = (
143 parent_name
144 if parent_name in {"core", "nature", "dev", "research"}
145 else "other"
146 )
148 # Auto-generate metadata if not provided
149 if not meta:
150 meta = {
151 "id": theme_id,
152 "label": theme_id.replace("-", " ").title(),
153 "icon": "fa-palette",
154 "group": default_group,
155 }
156 logger.debug(f"Auto-generated metadata for theme: {theme_id}")
158 # Validate CSS variables
159 missing_base, missing_rgb = self.validate_css_variables(
160 content, theme_id
161 )
162 if missing_base:
163 logger.warning(
164 f"Theme {theme_id} missing base variables: {missing_base}"
165 )
166 if missing_rgb:
167 logger.debug(
168 f"Theme {theme_id} missing RGB variants: {missing_rgb}"
169 )
171 # Determine theme type (dark/light)
172 theme_type = meta.get("type", "dark")
173 if theme_type not in ("dark", "light"):
174 theme_type = "dark"
175 if theme_type == "light":
176 declared_vars = self.declared_custom_properties(content)
177 missing_light = [
178 var
179 for var in LIGHT_THEME_REQUIRED_VARIABLES
180 if var not in declared_vars
181 ]
182 if missing_light: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true
183 logger.warning(
184 f"Light theme {theme_id} missing variables: "
185 f"{missing_light}"
186 )
188 return ThemeMetadata(
189 id=theme_id,
190 label=meta.get(
191 "label",
192 meta.get("name", theme_id.replace("-", " ").title()),
193 ),
194 icon=meta.get("icon", "fa-palette"),
195 group=meta.get("group", default_group),
196 type=theme_type,
197 description=meta.get("description", ""),
198 author=meta.get("author", ""),
199 css_path=css_path,
200 )
201 except Exception:
202 logger.exception(f"Failed to load theme: {css_path}")
203 return None
205 def get_css_content(self, theme: ThemeMetadata) -> str:
206 """Get CSS content for a theme, stripping frontmatter.
208 Args:
209 theme: ThemeMetadata object
211 Returns:
212 CSS content without frontmatter
213 """
214 if not theme.css_path or not theme.css_path.exists(): 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true
215 return ""
216 content = theme.css_path.read_text(encoding="utf-8")
217 # Strip frontmatter
218 return FRONTMATTER_PATTERN.sub("", content).strip()
220 @lru_cache(maxsize=1)
221 def load_all_themes(self) -> dict[str, ThemeMetadata]:
222 """Load all themes from the themes directory.
224 Scans subdirectories (core/, nature/, dev/, research/) for .css files.
226 Returns:
227 Dictionary mapping theme IDs to ThemeMetadata objects
228 """
229 themes: dict[str, ThemeMetadata] = {}
231 # Add virtual "system" theme (no CSS file)
232 themes["system"] = ThemeMetadata(
233 id="system",
234 label="System",
235 icon="fa-desktop",
236 group="system",
237 type="dark", # Resolves at runtime based on OS preference
238 description="Follow system color scheme preference",
239 css_path=None,
240 )
242 # Scan subdirectories for theme files
243 subdirs = ["core", "nature", "dev", "research"]
244 for subdir in subdirs:
245 dir_path = self.themes_dir / subdir
246 if dir_path.exists():
247 for css_file in sorted(dir_path.glob("*.css")):
248 theme = self.load_theme(css_file)
249 if theme: 249 ↛ 247line 249 didn't jump to line 247 because the condition on line 249 was always true
250 themes[theme.id] = theme
252 # Also scan root themes directory for any loose theme files
253 for css_file in sorted(self.themes_dir.glob("*.css")): 253 ↛ 254line 253 didn't jump to line 254 because the loop on line 253 never started
254 theme = self.load_theme(css_file)
255 if theme and theme.id not in themes:
256 themes[theme.id] = theme
258 logger.info(f"Loaded {len(themes)} themes from {self.themes_dir}")
259 return themes
261 def clear_cache(self) -> None:
262 """Clear the theme cache (useful for development/hot-reload)."""
263 self._cache.clear()
264 self.load_all_themes.cache_clear()