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

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 REQUIRED_RGB_VARIANTS, 

14 REQUIRED_VARIABLES, 

15 ThemeMetadata, 

16) 

17 

18# Pattern to match TOML frontmatter in CSS comments 

19# Matches: /*--- ... ---*/ 

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

21 

22# Pattern to extract theme ID from CSS selector 

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

24 

25 

26class ThemeLoader: 

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

28 

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

33 

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

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

36 

37 Args: 

38 css_content: The CSS file content 

39 

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

51 

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

53 """Extract theme ID from CSS selector. 

54 

55 Args: 

56 css_content: The CSS file content 

57 

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 

63 

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. 

68 

69 Args: 

70 css_content: The CSS file content 

71 theme_id: Theme identifier for logging 

72 

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 

83 

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

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

86 

87 Args: 

88 css_path: Path to the CSS file 

89 

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) 

96 

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 ) 

103 

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 ) 

111 

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

121 

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 ) 

134 

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" 

139 

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 

156 

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

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

159 

160 Args: 

161 theme: ThemeMetadata object 

162 

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

171 

172 @lru_cache(maxsize=1) 

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

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

175 

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

177 

178 Returns: 

179 Dictionary mapping theme IDs to ThemeMetadata objects 

180 """ 

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

182 

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 ) 

193 

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 

203 

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 

209 

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

211 return themes 

212 

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