Coverage for fastblocks / adapters / fonts / squirrel.py: 85%
176 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-19 12:49 -0800
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-19 12:49 -0800
1"""Font Squirrel adapter implementation for self-hosted fonts."""
3import typing as t
4from contextlib import suppress
5from pathlib import Path
6from uuid import UUID
8from acb.depends import depends
10from ._base import FontsBase, FontsBaseSettings
13class FontSquirrelFontsSettings(FontsBaseSettings):
14 """Font Squirrel-specific settings."""
16 fonts_dir: str = "/static/fonts"
17 fonts: list[dict[str, t.Any]] = []
18 preload_critical: bool = True
19 display: str = "swap"
22class FontSquirrelFonts(FontsBase):
23 """Font Squirrel adapter for self-hosted fonts."""
25 # Required ACB 0.19.0+ metadata
26 MODULE_ID: UUID = UUID("01937d86-4f2a-7b3c-8d9e-f3b4d3c2e1a2") # Static UUID7
27 MODULE_STATUS = "stable"
29 # Common font format priorities (most modern first)
30 FORMAT_PRIORITIES = ["woff2", "woff", "ttf", "otf", "eot"]
32 def __init__(self) -> None:
33 """Initialize Font Squirrel adapter."""
34 super().__init__()
35 self.settings = FontSquirrelFontsSettings()
37 # Register with ACB dependency system
38 with suppress(Exception):
39 depends.set(self)
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 -->"
46 font_faces = []
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)
53 if font_faces:
54 return f"<style>\n{chr(10).join(font_faces)}\n</style>"
55 return ""
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
68 # Return default fallbacks if no specific font found
69 return self._get_default_fallback(font_type)
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 ""
77 # Build font-face properties
78 properties = [
79 f" font-family: '{family}';",
80 f" font-display: {self.settings.display};",
81 ]
83 # Add font style
84 style = font_config.get("style", "normal")
85 properties.append(f" font-style: {style};")
87 # Add font weight
88 weight = font_config.get("weight", "400")
89 properties.append(f" font-weight: {weight};")
91 # Build src declaration
92 src_parts = self._build_src_declaration(font_config)
93 if not src_parts:
94 return "" # No valid sources found
96 properties.append(f" src: {src_parts};")
98 # Add unicode-range if specified
99 if "unicode_range" in font_config:
100 properties.append(f" unicode-range: {font_config['unicode_range']};")
102 return f"@font-face {{\n{chr(10).join(properties)}\n}}"
104 def _handle_single_file_path(self, font_config: dict[str, t.Any]) -> list[str]:
105 """Handle single file path configuration."""
106 src_parts = []
107 file_path = font_config["path"]
108 format_hint = self._get_format_from_path(file_path)
109 url = self._normalize_font_url(file_path)
110 src_parts.append(f"url('{url}') format('{format_hint}')")
111 return src_parts
113 def _handle_multiple_file_paths(self, font_config: dict[str, t.Any]) -> list[str]:
114 """Handle multiple file paths with formats."""
115 src_parts = []
116 files = font_config["files"]
118 # Sort files by format priority
119 sorted_files = sorted(
120 files,
121 key=lambda f: self.FORMAT_PRIORITIES.index(f.get("format", "ttf"))
122 if f.get("format") in self.FORMAT_PRIORITIES
123 else 999,
124 )
126 for file_info in sorted_files:
127 file_path = file_info.get("path")
128 format_hint = file_info.get("format") or self._get_format_from_path(
129 file_path
130 )
132 if file_path and format_hint:
133 url = self._normalize_font_url(file_path)
134 src_parts.append(f"url('{url}') format('{format_hint}')")
136 return src_parts
138 def _handle_directory_discovery(self, font_config: dict[str, t.Any]) -> list[str]:
139 """Handle directory-based font discovery."""
140 src_parts = []
141 directory = font_config["directory"]
142 family = font_config.get("family") or font_config.get("name", "")
143 weight = font_config.get("weight", "400")
144 style = font_config.get("style", "normal")
146 # Look for font files in directory
147 if family: # Only proceed if family name is available
148 discovered_files = self._discover_font_files(
149 directory, family, weight, style
150 )
151 for file_path, format_hint in discovered_files:
152 url = self._normalize_font_url(file_path)
153 src_parts.append(f"url('{url}') format('{format_hint}')")
155 return src_parts
157 def _build_src_declaration(self, font_config: dict[str, t.Any]) -> str:
158 """Build the src property for @font-face."""
159 src_parts = []
161 # Handle single file path
162 if "path" in font_config:
163 src_parts.extend(self._handle_single_file_path(font_config))
165 # Handle multiple file paths with formats
166 elif "files" in font_config:
167 src_parts.extend(self._handle_multiple_file_paths(font_config))
169 # Handle directory-based discovery
170 elif "directory" in font_config:
171 src_parts.extend(self._handle_directory_discovery(font_config))
173 return ", ".join(src_parts)
175 def _get_format_from_path(self, file_path: str) -> str:
176 """Determine font format from file extension."""
177 path = Path(file_path)
178 extension = path.suffix.lower()
180 format_map = {
181 ".woff2": "woff2",
182 ".woff": "woff",
183 ".ttf": "truetype",
184 ".otf": "opentype",
185 ".eot": "embedded-opentype",
186 ".svg": "svg",
187 }
189 return format_map.get(extension, "truetype")
191 def _normalize_font_url(self, file_path: str) -> str:
192 """Normalize font file path to URL."""
193 # If already a full URL, return as-is
194 if file_path.startswith(("http://", "https://", "//")):
195 return file_path
197 # If relative path, prepend fonts directory
198 if not file_path.startswith("/"):
199 return f"{self.settings.fonts_dir.rstrip('/')}/{file_path}"
201 return file_path
203 def _discover_font_files(
204 self, directory: str, family: str, weight: str, style: str
205 ) -> list[tuple[str, str]]:
206 """Discover font files in a directory based on naming patterns."""
207 discovered = []
209 # Common naming patterns for font files
210 patterns = [
211 f"{family.lower().replace(' ', '-')}-{weight}-{style}",
212 f"{family.lower().replace(' ', '')}{weight}{style}",
213 f"{family.replace(' ', '')}-{weight}",
214 f"{family.lower()}-{style}",
215 family.lower().replace(" ", "-"),
216 ]
218 for pattern in patterns:
219 for ext in (".woff2", ".woff", ".ttf", ".otf"):
220 file_path = f"{directory.rstrip('/')}/{pattern}{ext}"
221 format_hint = self._get_format_from_path(file_path)
222 discovered.append((file_path, format_hint))
224 return discovered
226 def _get_default_fallback(self, font_type: str) -> str:
227 """Get default fallback fonts for different types."""
228 fallbacks = {
229 "primary": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
230 "secondary": "Georgia, 'Times New Roman', serif",
231 "heading": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
232 "body": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
233 "monospace": "'Courier New', monospace",
234 "display": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
235 "sans-serif": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
236 "serif": "Georgia, 'Times New Roman', serif",
237 }
238 return fallbacks.get(font_type, "inherit")
240 def _get_default_critical_fonts(self) -> list[str]:
241 """Get default critical fonts (first font of each type).
243 Returns:
244 List of font family names to preload
245 """
246 fonts_to_preload = []
247 seen_types = set()
249 for font_config in self.settings.fonts:
250 font_type = font_config.get("type")
251 if font_type and font_type not in seen_types:
252 font_family = font_config.get("family") or font_config.get("name")
253 if font_family:
254 fonts_to_preload.append(font_family)
255 seen_types.add(font_type)
257 return fonts_to_preload
259 def _generate_preload_links_for_fonts(self, font_families: list[str]) -> list[str]:
260 """Generate preload links for specified font families.
262 Args:
263 font_families: List of font family names
265 Returns:
266 List of preload link HTML strings
267 """
268 preload_links: list[str] = []
270 for font_family in font_families:
271 for font_config in self.settings.fonts:
272 config_family = font_config.get("family") or font_config.get("name")
273 if config_family == font_family:
274 preload_link = self._generate_preload_link(font_config)
275 if preload_link:
276 preload_links.append(preload_link)
277 break
279 return preload_links
281 def get_preload_links(self, critical_fonts: list[str] | None = None) -> str:
282 """Generate preload links for critical fonts."""
283 if not self.settings.preload_critical:
284 return ""
286 # Determine which fonts to preload
287 fonts_to_preload = critical_fonts or self._get_default_critical_fonts()
289 # Generate preload links
290 preload_links = self._generate_preload_links_for_fonts(fonts_to_preload)
292 return "\n".join(preload_links)
294 def _find_best_font_file(self, font_config: dict[str, t.Any]) -> str | None:
295 """Find the best format file (woff2 preferred, then woff).
297 Args:
298 font_config: Font configuration dictionary
300 Returns:
301 Path to best font file or None
302 """
303 if "path" in font_config:
304 # Dictionary access returns Any, so we cast to the expected type
305 return t.cast(str | None, font_config["path"])
307 if "files" not in font_config:
308 return None
310 # Search for woff2 first
311 for file_info in font_config["files"]:
312 if file_info.get("format") == "woff2":
313 # Dictionary.get() returns Any, so we cast to the expected type
314 return t.cast(str | None, file_info.get("path"))
316 # Fall back to woff
317 for file_info in font_config["files"]:
318 if file_info.get("format") == "woff":
319 # Dictionary.get() returns Any, so we cast to the expected type
320 return t.cast(str | None, file_info.get("path"))
322 return None
324 def _generate_preload_link(self, font_config: dict[str, t.Any]) -> str:
325 """Generate a preload link for a specific font."""
326 best_file = self._find_best_font_file(font_config)
328 if best_file:
329 url = self._normalize_font_url(best_file)
330 return f'<link rel="preload" as="font" type="font/woff2" href="{url}" crossorigin>'
332 return ""
334 def validate_font_files(self) -> dict[str, list[str]]:
335 """Validate that configured font files exist and are accessible."""
336 validation_results: dict[str, list[str]] = {
337 "valid": [],
338 "invalid": [],
339 "warnings": [],
340 }
342 for font_config in self.settings.fonts:
343 family = font_config.get("family") or font_config.get("name", "Unknown")
345 if "path" in font_config:
346 # Single file validation would go here
347 validation_results["valid"].append(f"{family}: {font_config['path']}")
348 elif "files" in font_config:
349 # Multiple files validation would go here
350 for file_info in font_config["files"]:
351 validation_results["valid"].append(
352 f"{family}: {file_info.get('path', 'Unknown path')}"
353 )
354 else:
355 validation_results["warnings"].append(
356 f"{family}: No font files specified"
357 )
359 return validation_results
362FontsSettings = FontSquirrelFontsSettings
363Fonts = FontSquirrelFonts
365depends.set(Fonts, "squirrel")