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

1""" 

2Theme Loader - Parse and load theme files with TOML frontmatter. 

3""" 

4 

5import re 

6import tomllib 

7from functools import lru_cache 

8from pathlib import Path 

9 

10from loguru import logger 

11 

12from .schema import ( 

13 LIGHT_THEME_REQUIRED_VARIABLES, 

14 REQUIRED_RGB_VARIANTS, 

15 REQUIRED_VARIABLES, 

16 ThemeMetadata, 

17) 

18 

19# Pattern to match TOML frontmatter in CSS comments 

20# Matches: /*--- ... ---*/ 

21FRONTMATTER_PATTERN = re.compile(r"/\*---\s*(.*?)\s*---\*/", re.DOTALL) 

22 

23# Pattern to extract theme ID from CSS selector 

24THEME_ID_PATTERN = re.compile(r'\[data-theme="([^"]+)"\]') 

25 

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) 

29 

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) 

38 

39 

40class ThemeLoader: 

41 """Loads and parses theme files from a directory.""" 

42 

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] = {} 

47 

48 def parse_frontmatter(self, css_content: str) -> dict: 

49 """Extract and parse TOML frontmatter from CSS file. 

50 

51 Args: 

52 css_content: The CSS file content 

53 

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 {} 

65 

66 def extract_theme_id(self, css_content: str) -> str | None: 

67 """Extract theme ID from CSS selector. 

68 

69 Args: 

70 css_content: The CSS file content 

71 

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 

77 

78 def declared_custom_properties(self, css_content: str) -> set[str]: 

79 """Custom property names actually *declared* in the CSS. 

80 

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`. 

85 

86 Args: 

87 css_content: The CSS file content 

88 

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 } 

99 

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. 

104 

105 Args: 

106 css_content: The CSS file content 

107 theme_id: Theme identifier for logging 

108 

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 

119 

120 def load_theme(self, css_path: Path) -> ThemeMetadata | None: 

121 """Load a single theme from a CSS file. 

122 

123 Args: 

124 css_path: Path to the CSS file 

125 

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) 

132 

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 ) 

139 

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 ) 

147 

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}") 

157 

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 ) 

170 

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 ) 

187 

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 

204 

205 def get_css_content(self, theme: ThemeMetadata) -> str: 

206 """Get CSS content for a theme, stripping frontmatter. 

207 

208 Args: 

209 theme: ThemeMetadata object 

210 

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() 

219 

220 @lru_cache(maxsize=1) 

221 def load_all_themes(self) -> dict[str, ThemeMetadata]: 

222 """Load all themes from the themes directory. 

223 

224 Scans subdirectories (core/, nature/, dev/, research/) for .css files. 

225 

226 Returns: 

227 Dictionary mapping theme IDs to ThemeMetadata objects 

228 """ 

229 themes: dict[str, ThemeMetadata] = {} 

230 

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 ) 

241 

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 

251 

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 

257 

258 logger.info(f"Loaded {len(themes)} themes from {self.themes_dir}") 

259 return themes 

260 

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()