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
« 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."""
3import typing as t
4from contextlib import suppress
5from pathlib import Path
6from uuid import UUID
8from acb.config import Settings
9from acb.depends import depends
11from ._base import FontsBase
14class FontSquirrelSettings(Settings): # type: ignore[misc]
15 """Font Squirrel-specific settings."""
17 fonts_dir: str = "/static/fonts"
18 fonts: list[dict[str, t.Any]] = []
19 preload_critical: bool = True
20 display: str = "swap"
23class FontSquirrelAdapter(FontsBase):
24 """Font Squirrel adapter for self-hosted fonts."""
26 # Required ACB 0.19.0+ metadata
27 MODULE_ID: UUID = UUID("01937d86-4f2a-7b3c-8d9e-f3b4d3c2e1a2") # Static UUID7
28 MODULE_STATUS = "stable"
30 # Common font format priorities (most modern first)
31 FORMAT_PRIORITIES = ["woff2", "woff", "ttf", "otf", "eot"]
33 def __init__(self) -> None:
34 """Initialize Font Squirrel adapter."""
35 super().__init__()
36 self.settings = FontSquirrelSettings()
38 # Register with ACB dependency system
39 with suppress(Exception):
40 depends.set(self)
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 -->"
47 font_faces = []
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)
54 if font_faces:
55 return f"<style>\n{chr(10).join(font_faces)}\n</style>"
56 return ""
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
69 # Return default fallbacks if no specific font found
70 return self._get_default_fallback(font_type)
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 ""
78 # Build font-face properties
79 properties = [
80 f" font-family: '{family}';",
81 f" font-display: {self.settings.display};",
82 ]
84 # Add font style
85 style = font_config.get("style", "normal")
86 properties.append(f" font-style: {style};")
88 # Add font weight
89 weight = font_config.get("weight", "400")
90 properties.append(f" font-weight: {weight};")
92 # Build src declaration
93 src_parts = self._build_src_declaration(font_config)
94 if src_parts:
95 properties.append(f" src: {src_parts};")
97 return "" # No valid sources found
99 # Add unicode-range if specified
100 if "unicode_range" in font_config:
101 properties.append(f" unicode-range: {font_config['unicode_range']};")
103 return f"@font-face {{\n{chr(10).join(properties)}\n}}"
105 def _build_src_declaration(self, font_config: dict[str, t.Any]) -> str:
106 """Build the src property for @font-face."""
107 src_parts = []
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}')")
116 # Handle multiple file paths with formats
117 elif "files" in font_config:
118 files = font_config["files"]
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 )
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 )
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}')")
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")
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}')")
154 return ", ".join(src_parts)
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()
161 format_map = {
162 ".woff2": "woff2",
163 ".woff": "woff",
164 ".ttf": "truetype",
165 ".otf": "opentype",
166 ".eot": "embedded-opentype",
167 ".svg": "svg",
168 }
170 return format_map.get(extension, "truetype")
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
178 # If relative path, prepend fonts directory
179 if not file_path.startswith("/"):
180 return f"{self.settings.fonts_dir.rstrip('/')}/{file_path}"
182 return file_path
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 = []
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 ]
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))
205 return discovered
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")
221 def _get_default_critical_fonts(self) -> list[str]:
222 """Get default critical fonts (first font of each type).
224 Returns:
225 List of font family names to preload
226 """
227 fonts_to_preload = []
228 seen_types = set()
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)
238 return fonts_to_preload
240 def _generate_preload_links_for_fonts(self, font_families: list[str]) -> list[str]:
241 """Generate preload links for specified font families.
243 Args:
244 font_families: List of font family names
246 Returns:
247 List of preload link HTML strings
248 """
249 preload_links: list[str] = []
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
260 return preload_links
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 ""
267 # Determine which fonts to preload
268 fonts_to_preload = critical_fonts or self._get_default_critical_fonts()
270 # Generate preload links
271 preload_links = self._generate_preload_links_for_fonts(fonts_to_preload)
273 return "\n".join(preload_links)
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).
278 Args:
279 font_config: Font configuration dictionary
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"])
288 if "files" not in font_config:
289 return None
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"))
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"))
303 return None
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)
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>'
313 return ""
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 }
323 for font_config in self.settings.fonts:
324 family = font_config.get("family") or font_config.get("name", "Unknown")
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 )
340 return validation_results