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
« 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."""
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 _build_src_declaration(self, font_config: dict[str, t.Any]) -> str:
105 """Build the src property for @font-face."""
106 src_parts = []
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}')")
115 # Handle multiple file paths with formats
116 elif "files" in font_config:
117 files = font_config["files"]
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 )
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 )
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}')")
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")
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}')")
153 return ", ".join(src_parts)
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()
160 format_map = {
161 ".woff2": "woff2",
162 ".woff": "woff",
163 ".ttf": "truetype",
164 ".otf": "opentype",
165 ".eot": "embedded-opentype",
166 ".svg": "svg",
167 }
169 return format_map.get(extension, "truetype")
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
177 # If relative path, prepend fonts directory
178 if not file_path.startswith("/"):
179 return f"{self.settings.fonts_dir.rstrip('/')}/{file_path}"
181 return file_path
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 = []
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 ]
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))
204 return discovered
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")
220 def _get_default_critical_fonts(self) -> list[str]:
221 """Get default critical fonts (first font of each type).
223 Returns:
224 List of font family names to preload
225 """
226 fonts_to_preload = []
227 seen_types = set()
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)
237 return fonts_to_preload
239 def _generate_preload_links_for_fonts(self, font_families: list[str]) -> list[str]:
240 """Generate preload links for specified font families.
242 Args:
243 font_families: List of font family names
245 Returns:
246 List of preload link HTML strings
247 """
248 preload_links: list[str] = []
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
259 return preload_links
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 ""
266 # Determine which fonts to preload
267 fonts_to_preload = critical_fonts or self._get_default_critical_fonts()
269 # Generate preload links
270 preload_links = self._generate_preload_links_for_fonts(fonts_to_preload)
272 return "\n".join(preload_links)
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).
277 Args:
278 font_config: Font configuration dictionary
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"])
287 if "files" not in font_config:
288 return None
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"))
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"))
302 return None
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)
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>'
312 return ""
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 }
322 for font_config in self.settings.fonts:
323 family = font_config.get("family") or font_config.get("name", "Unknown")
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 )
339 return validation_results
342FontsSettings = FontSquirrelFontsSettings
343Fonts = FontSquirrelFonts
345depends.set(Fonts, "squirrel")