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

164 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-26 03:30 -0800

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.depends import depends 

9 

10from ._base import FontsBase, FontsBaseSettings 

11 

12 

13class FontSquirrelFontsSettings(FontsBaseSettings): 

14 """Font Squirrel-specific settings.""" 

15 

16 fonts_dir: str = "/static/fonts" 

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

18 preload_critical: bool = True 

19 display: str = "swap" 

20 

21 

22class FontSquirrelFonts(FontsBase): 

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

24 

25 # Required ACB 0.19.0+ metadata 

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

27 MODULE_STATUS = "stable" 

28 

29 # Common font format priorities (most modern first) 

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

31 

32 def __init__(self) -> None: 

33 """Initialize Font Squirrel adapter.""" 

34 super().__init__() 

35 self.settings = FontSquirrelFontsSettings() 

36 

37 # Register with ACB dependency system 

38 with suppress(Exception): 

39 depends.set(self) 

40 

41 async def get_font_import(self) -> str: 

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

43 if not self.settings.fonts: 

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

45 

46 font_faces = [] 

47 

48 for font_config in self.settings.fonts: 

49 font_face = self._generate_font_face(font_config) 

50 if font_face: 

51 font_faces.append(font_face) 

52 

53 if font_faces: 

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

55 return "" 

56 

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

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

59 # Look for a font with the specified type 

60 for font_config in self.settings.fonts: 

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

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

63 fallback = font_config.get( 

64 "fallback", self._get_default_fallback(font_type) 

65 ) 

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

67 

68 # Return default fallbacks if no specific font found 

69 return self._get_default_fallback(font_type) 

70 

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

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

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

74 if not family: 

75 return "" 

76 

77 # Build font-face properties 

78 properties = [ 

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

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

81 ] 

82 

83 # Add font style 

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

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

86 

87 # Add font weight 

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

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

90 

91 # Build src declaration 

92 src_parts = self._build_src_declaration(font_config) 

93 if not src_parts: 

94 return "" # No valid sources found 

95 

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

97 

98 # Add unicode-range if specified 

99 if "unicode_range" in font_config: 

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

101 

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

103 

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

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

106 src_parts = [] 

107 

108 # Handle single file path 

109 if "path" in font_config: 

110 file_path = font_config["path"] 

111 format_hint = self._get_format_from_path(file_path) 

112 url = self._normalize_font_url(file_path) 

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

114 

115 # Handle multiple file paths with formats 

116 elif "files" in font_config: 

117 files = font_config["files"] 

118 

119 # Sort files by format priority 

120 sorted_files = sorted( 

121 files, 

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

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

124 else 999, 

125 ) 

126 

127 for file_info in sorted_files: 

128 file_path = file_info.get("path") 

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

130 file_path 

131 ) 

132 

133 if file_path and format_hint: 

134 url = self._normalize_font_url(file_path) 

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

136 

137 # Handle directory-based discovery 

138 elif "directory" in font_config: 

139 directory = font_config["directory"] 

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

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

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

143 

144 # Look for font files in directory 

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

146 discovered_files = self._discover_font_files( 

147 directory, family, weight, style 

148 ) 

149 for file_path, format_hint in discovered_files: 

150 url = self._normalize_font_url(file_path) 

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

152 

153 return ", ".join(src_parts) 

154 

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

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

157 path = Path(file_path) 

158 extension = path.suffix.lower() 

159 

160 format_map = { 

161 ".woff2": "woff2", 

162 ".woff": "woff", 

163 ".ttf": "truetype", 

164 ".otf": "opentype", 

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

166 ".svg": "svg", 

167 } 

168 

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

170 

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

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

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

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

175 return file_path 

176 

177 # If relative path, prepend fonts directory 

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

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

180 

181 return file_path 

182 

183 def _discover_font_files( 

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

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

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

187 discovered = [] 

188 

189 # Common naming patterns for font files 

190 patterns = [ 

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

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

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

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

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

196 ] 

197 

198 for pattern in patterns: 

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

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

201 format_hint = self._get_format_from_path(file_path) 

202 discovered.append((file_path, format_hint)) 

203 

204 return discovered 

205 

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

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

208 fallbacks = { 

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

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

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

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

213 "monospace": "'Courier New', monospace", 

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

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

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

217 } 

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

219 

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

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

222 

223 Returns: 

224 List of font family names to preload 

225 """ 

226 fonts_to_preload = [] 

227 seen_types = set() 

228 

229 for font_config in self.settings.fonts: 

230 font_type = font_config.get("type") 

231 if font_type and font_type not in seen_types: 

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

233 if font_family: 

234 fonts_to_preload.append(font_family) 

235 seen_types.add(font_type) 

236 

237 return fonts_to_preload 

238 

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

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

241 

242 Args: 

243 font_families: List of font family names 

244 

245 Returns: 

246 List of preload link HTML strings 

247 """ 

248 preload_links: list[str] = [] 

249 

250 for font_family in font_families: 

251 for font_config in self.settings.fonts: 

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

253 if config_family == font_family: 

254 preload_link = self._generate_preload_link(font_config) 

255 if preload_link: 

256 preload_links.append(preload_link) 

257 break 

258 

259 return preload_links 

260 

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

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

263 if not self.settings.preload_critical: 

264 return "" 

265 

266 # Determine which fonts to preload 

267 fonts_to_preload = critical_fonts or self._get_default_critical_fonts() 

268 

269 # Generate preload links 

270 preload_links = self._generate_preload_links_for_fonts(fonts_to_preload) 

271 

272 return "\n".join(preload_links) 

273 

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

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

276 

277 Args: 

278 font_config: Font configuration dictionary 

279 

280 Returns: 

281 Path to best font file or None 

282 """ 

283 if "path" in font_config: 

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

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

286 

287 if "files" not in font_config: 

288 return None 

289 

290 # Search for woff2 first 

291 for file_info in font_config["files"]: 

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

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

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

295 

296 # Fall back to woff 

297 for file_info in font_config["files"]: 

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

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

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

301 

302 return None 

303 

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

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

306 best_file = self._find_best_font_file(font_config) 

307 

308 if best_file: 

309 url = self._normalize_font_url(best_file) 

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

311 

312 return "" 

313 

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

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

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

317 "valid": [], 

318 "invalid": [], 

319 "warnings": [], 

320 } 

321 

322 for font_config in self.settings.fonts: 

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

324 

325 if "path" in font_config: 

326 # Single file validation would go here 

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

328 elif "files" in font_config: 

329 # Multiple files validation would go here 

330 for file_info in font_config["files"]: 

331 validation_results["valid"].append( 

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

333 ) 

334 else: 

335 validation_results["warnings"].append( 

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

337 ) 

338 

339 return validation_results 

340 

341 

342FontsSettings = FontSquirrelFontsSettings 

343Fonts = FontSquirrelFonts 

344 

345depends.set(Fonts, "squirrel")