Coverage for fastblocks/adapters/fonts/squirrel.py: 86%

159 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-09 03:52 -0700

1"""Font Squirrel adapter implementation for self-hosted fonts.""" 

2 

3import typing as t 

4from contextlib import suppress 

5from pathlib import Path 

6from uuid import UUID 

7 

8from acb.config import Settings 

9from acb.depends import depends 

10 

11from ._base import FontsBase 

12 

13 

14class FontSquirrelSettings(Settings): # type: ignore[misc] 

15 """Font Squirrel-specific settings.""" 

16 

17 fonts_dir: str = "/static/fonts" 

18 fonts: list[dict[str, t.Any]] = [] 

19 preload_critical: bool = True 

20 display: str = "swap" 

21 

22 

23class FontSquirrelAdapter(FontsBase): 

24 """Font Squirrel adapter for self-hosted fonts.""" 

25 

26 # Required ACB 0.19.0+ metadata 

27 MODULE_ID: UUID = UUID("01937d86-4f2a-7b3c-8d9e-f3b4d3c2e1a2") # Static UUID7 

28 MODULE_STATUS = "stable" 

29 

30 # Common font format priorities (most modern first) 

31 FORMAT_PRIORITIES = ["woff2", "woff", "ttf", "otf", "eot"] 

32 

33 def __init__(self) -> None: 

34 """Initialize Font Squirrel adapter.""" 

35 super().__init__() 

36 self.settings = FontSquirrelSettings() 

37 

38 # Register with ACB dependency system 

39 with suppress(Exception): 

40 depends.set(self) 

41 

42 async def get_font_import(self) -> str: 

43 """Generate @font-face declarations for self-hosted fonts.""" 

44 if not self.settings.fonts: 

45 return "<!-- No self-hosted fonts configured -->" 

46 

47 font_faces = [] 

48 

49 for font_config in self.settings.fonts: 

50 font_face = self._generate_font_face(font_config) 

51 if font_face: 

52 font_faces.append(font_face) 

53 

54 if font_faces: 

55 return f"<style>\n{chr(10).join(font_faces)}\n</style>" 

56 return "" 

57 

58 def get_font_family(self, font_type: str) -> str: 

59 """Get font family CSS values for configured fonts.""" 

60 # Look for a font with the specified type 

61 for font_config in self.settings.fonts: 

62 if font_config.get("type") == font_type: 

63 family_name = font_config.get("family", font_config.get("name", "")) 

64 fallback = font_config.get( 

65 "fallback", self._get_default_fallback(font_type) 

66 ) 

67 return f"'{family_name}', {fallback}" if family_name else fallback 

68 

69 # Return default fallbacks if no specific font found 

70 return self._get_default_fallback(font_type) 

71 

72 def _generate_font_face(self, font_config: dict[str, t.Any]) -> str: 

73 """Generate a single @font-face declaration.""" 

74 family = font_config.get("family") or font_config.get("name") 

75 if not family: 

76 return "" 

77 

78 # Build font-face properties 

79 properties = [ 

80 f" font-family: '{family}';", 

81 f" font-display: {self.settings.display};", 

82 ] 

83 

84 # Add font style 

85 style = font_config.get("style", "normal") 

86 properties.append(f" font-style: {style};") 

87 

88 # Add font weight 

89 weight = font_config.get("weight", "400") 

90 properties.append(f" font-weight: {weight};") 

91 

92 # Build src declaration 

93 src_parts = self._build_src_declaration(font_config) 

94 if src_parts: 

95 properties.append(f" src: {src_parts};") 

96 

97 return "" # No valid sources found 

98 

99 # Add unicode-range if specified 

100 if "unicode_range" in font_config: 

101 properties.append(f" unicode-range: {font_config['unicode_range']};") 

102 

103 return f"@font-face {{\n{chr(10).join(properties)}\n}}" 

104 

105 def _build_src_declaration(self, font_config: dict[str, t.Any]) -> str: 

106 """Build the src property for @font-face.""" 

107 src_parts = [] 

108 

109 # Handle single file path 

110 if "path" in font_config: 

111 file_path = font_config["path"] 

112 format_hint = self._get_format_from_path(file_path) 

113 url = self._normalize_font_url(file_path) 

114 src_parts.append(f"url('{url}') format('{format_hint}')") 

115 

116 # Handle multiple file paths with formats 

117 elif "files" in font_config: 

118 files = font_config["files"] 

119 

120 # Sort files by format priority 

121 sorted_files = sorted( 

122 files, 

123 key=lambda f: self.FORMAT_PRIORITIES.index(f.get("format", "ttf")) 

124 if f.get("format") in self.FORMAT_PRIORITIES 

125 else 999, 

126 ) 

127 

128 for file_info in sorted_files: 

129 file_path = file_info.get("path") 

130 format_hint = file_info.get("format") or self._get_format_from_path( 

131 file_path 

132 ) 

133 

134 if file_path and format_hint: 

135 url = self._normalize_font_url(file_path) 

136 src_parts.append(f"url('{url}') format('{format_hint}')") 

137 

138 # Handle directory-based discovery 

139 elif "directory" in font_config: 

140 directory = font_config["directory"] 

141 family = font_config.get("family") or font_config.get("name", "") 

142 weight = font_config.get("weight", "400") 

143 style = font_config.get("style", "normal") 

144 

145 # Look for font files in directory 

146 if family: # Only proceed if family name is available 

147 discovered_files = self._discover_font_files( 

148 directory, family, weight, style 

149 ) 

150 for file_path, format_hint in discovered_files: 

151 url = self._normalize_font_url(file_path) 

152 src_parts.append(f"url('{url}') format('{format_hint}')") 

153 

154 return ", ".join(src_parts) 

155 

156 def _get_format_from_path(self, file_path: str) -> str: 

157 """Determine font format from file extension.""" 

158 path = Path(file_path) 

159 extension = path.suffix.lower() 

160 

161 format_map = { 

162 ".woff2": "woff2", 

163 ".woff": "woff", 

164 ".ttf": "truetype", 

165 ".otf": "opentype", 

166 ".eot": "embedded-opentype", 

167 ".svg": "svg", 

168 } 

169 

170 return format_map.get(extension, "truetype") 

171 

172 def _normalize_font_url(self, file_path: str) -> str: 

173 """Normalize font file path to URL.""" 

174 # If already a full URL, return as-is 

175 if file_path.startswith(("http://", "https://", "//")): 

176 return file_path 

177 

178 # If relative path, prepend fonts directory 

179 if not file_path.startswith("/"): 

180 return f"{self.settings.fonts_dir.rstrip('/')}/{file_path}" 

181 

182 return file_path 

183 

184 def _discover_font_files( 

185 self, directory: str, family: str, weight: str, style: str 

186 ) -> list[tuple[str, str]]: 

187 """Discover font files in a directory based on naming patterns.""" 

188 discovered = [] 

189 

190 # Common naming patterns for font files 

191 patterns = [ 

192 f"{family.lower().replace(' ', '-')}-{weight}-{style}", 

193 f"{family.lower().replace(' ', '')}{weight}{style}", 

194 f"{family.replace(' ', '')}-{weight}", 

195 f"{family.lower()}-{style}", 

196 family.lower().replace(" ", "-"), 

197 ] 

198 

199 for pattern in patterns: 

200 for ext in (".woff2", ".woff", ".ttf", ".otf"): 

201 file_path = f"{directory.rstrip('/')}/{pattern}{ext}" 

202 format_hint = self._get_format_from_path(file_path) 

203 discovered.append((file_path, format_hint)) 

204 

205 return discovered 

206 

207 def _get_default_fallback(self, font_type: str) -> str: 

208 """Get default fallback fonts for different types.""" 

209 fallbacks = { 

210 "primary": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", 

211 "secondary": "Georgia, 'Times New Roman', serif", 

212 "heading": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", 

213 "body": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", 

214 "monospace": "'Courier New', monospace", 

215 "display": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", 

216 "sans-serif": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", 

217 "serif": "Georgia, 'Times New Roman', serif", 

218 } 

219 return fallbacks.get(font_type, "inherit") 

220 

221 def _get_default_critical_fonts(self) -> list[str]: 

222 """Get default critical fonts (first font of each type). 

223 

224 Returns: 

225 List of font family names to preload 

226 """ 

227 fonts_to_preload = [] 

228 seen_types = set() 

229 

230 for font_config in self.settings.fonts: 

231 font_type = font_config.get("type") 

232 if font_type and font_type not in seen_types: 

233 font_family = font_config.get("family") or font_config.get("name") 

234 if font_family: 

235 fonts_to_preload.append(font_family) 

236 seen_types.add(font_type) 

237 

238 return fonts_to_preload 

239 

240 def _generate_preload_links_for_fonts(self, font_families: list[str]) -> list[str]: 

241 """Generate preload links for specified font families. 

242 

243 Args: 

244 font_families: List of font family names 

245 

246 Returns: 

247 List of preload link HTML strings 

248 """ 

249 preload_links: list[str] = [] 

250 

251 for font_family in font_families: 

252 for font_config in self.settings.fonts: 

253 config_family = font_config.get("family") or font_config.get("name") 

254 if config_family == font_family: 

255 preload_link = self._generate_preload_link(font_config) 

256 if preload_link: 

257 preload_links.append(preload_link) 

258 break 

259 

260 return preload_links 

261 

262 def get_preload_links(self, critical_fonts: list[str] | None = None) -> str: 

263 """Generate preload links for critical fonts.""" 

264 if not self.settings.preload_critical: 

265 return "" 

266 

267 # Determine which fonts to preload 

268 fonts_to_preload = critical_fonts or self._get_default_critical_fonts() 

269 

270 # Generate preload links 

271 preload_links = self._generate_preload_links_for_fonts(fonts_to_preload) 

272 

273 return "\n".join(preload_links) 

274 

275 def _find_best_font_file(self, font_config: dict[str, t.Any]) -> str | None: 

276 """Find the best format file (woff2 preferred, then woff). 

277 

278 Args: 

279 font_config: Font configuration dictionary 

280 

281 Returns: 

282 Path to best font file or None 

283 """ 

284 if "path" in font_config: 

285 # Dictionary access returns Any, so we cast to the expected type 

286 return t.cast(str | None, font_config["path"]) 

287 

288 if "files" not in font_config: 

289 return None 

290 

291 # Search for woff2 first 

292 for file_info in font_config["files"]: 

293 if file_info.get("format") == "woff2": 

294 # Dictionary.get() returns Any, so we cast to the expected type 

295 return t.cast(str | None, file_info.get("path")) 

296 

297 # Fall back to woff 

298 for file_info in font_config["files"]: 

299 if file_info.get("format") == "woff": 

300 # Dictionary.get() returns Any, so we cast to the expected type 

301 return t.cast(str | None, file_info.get("path")) 

302 

303 return None 

304 

305 def _generate_preload_link(self, font_config: dict[str, t.Any]) -> str: 

306 """Generate a preload link for a specific font.""" 

307 best_file = self._find_best_font_file(font_config) 

308 

309 if best_file: 

310 url = self._normalize_font_url(best_file) 

311 return f'<link rel="preload" as="font" type="font/woff2" href="{url}" crossorigin>' 

312 

313 return "" 

314 

315 def validate_font_files(self) -> dict[str, list[str]]: 

316 """Validate that configured font files exist and are accessible.""" 

317 validation_results: dict[str, list[str]] = { 

318 "valid": [], 

319 "invalid": [], 

320 "warnings": [], 

321 } 

322 

323 for font_config in self.settings.fonts: 

324 family = font_config.get("family") or font_config.get("name", "Unknown") 

325 

326 if "path" in font_config: 

327 # Single file validation would go here 

328 validation_results["valid"].append(f"{family}: {font_config['path']}") 

329 elif "files" in font_config: 

330 # Multiple files validation would go here 

331 for file_info in font_config["files"]: 

332 validation_results["valid"].append( 

333 f"{family}: {file_info.get('path', 'Unknown path')}" 

334 ) 

335 else: 

336 validation_results["warnings"].append( 

337 f"{family}: No font files specified" 

338 ) 

339 

340 return validation_results