Coverage for fastblocks/adapters/fonts/google.py: 89%

98 statements  

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

1"""Google Fonts adapter implementation.""" 

2 

3from contextlib import suppress 

4from urllib.parse import quote_plus 

5from uuid import UUID 

6 

7from acb.config import Settings # type: ignore[attr-defined] 

8from acb.depends import depends 

9 

10from ._base import FontsBase 

11 

12 

13class GoogleFontsSettings(Settings): # type: ignore[misc] 

14 """Google Fonts-specific settings.""" 

15 

16 api_key: str | None = None # Optional API key for advanced features 

17 families: list[str] = ["Roboto", "Open Sans"] 

18 weights: list[str] = ["400", "700"] 

19 subsets: list[str] = ["latin"] 

20 display: str = "swap" # font-display CSS property 

21 preconnect: bool = True # Add preconnect link for performance 

22 

23 

24class GoogleFontsAdapter(FontsBase): 

25 """Google Fonts adapter implementation.""" 

26 

27 # Required ACB 0.19.0+ metadata 

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

29 MODULE_STATUS = "stable" 

30 

31 # Common Google Fonts families with fallbacks 

32 FONT_FALLBACKS = { 

33 "Roboto": "Roboto, -apple-system, BlinkMacSystemFont, sans-serif", 

34 "Open Sans": "'Open Sans', -apple-system, BlinkMacSystemFont, sans-serif", 

35 "Lato": "Lato, -apple-system, BlinkMacSystemFont, sans-serif", 

36 "Montserrat": "Montserrat, -apple-system, BlinkMacSystemFont, sans-serif", 

37 "Source Sans Pro": "'Source Sans Pro', -apple-system, BlinkMacSystemFont, sans-serif", 

38 "Inter": "Inter, -apple-system, BlinkMacSystemFont, sans-serif", 

39 "Poppins": "Poppins, -apple-system, BlinkMacSystemFont, sans-serif", 

40 "Nunito": "Nunito, -apple-system, BlinkMacSystemFont, sans-serif", 

41 "Playfair Display": "'Playfair Display', Georgia, serif", 

42 "Merriweather": "Merriweather, Georgia, serif", 

43 "Lora": "Lora, Georgia, serif", 

44 "Source Serif Pro": "'Source Serif Pro', Georgia, serif", 

45 "Crimson Text": "'Crimson Text', Georgia, serif", 

46 "PT Serif": "'PT Serif', Georgia, serif", 

47 "Fira Code": "'Fira Code', 'Source Code Pro', monospace", 

48 "Source Code Pro": "'Source Code Pro', 'Courier New', monospace", 

49 "JetBrains Mono": "'JetBrains Mono', 'Source Code Pro', monospace", 

50 "Inconsolata": "Inconsolata, 'Courier New', monospace", 

51 } 

52 

53 def __init__(self) -> None: 

54 """Initialize Google Fonts adapter.""" 

55 super().__init__() 

56 self.settings = GoogleFontsSettings() 

57 

58 # Register with ACB dependency system 

59 with suppress(Exception): 

60 depends.set(self) 

61 

62 async def get_font_import(self) -> str: 

63 """Generate Google Fonts import statements.""" 

64 # Build font families parameter 

65 families_param = self._build_families_param() 

66 

67 # Build query parameters 

68 params = [f"family={families_param}"] 

69 

70 if self.settings.subsets: 

71 subsets = "&".join(self.settings.subsets) 

72 params.extend((f"subset={subsets}", f"display={self.settings.display}")) 

73 

74 query_string = "&".join(params) 

75 url = f"https://fonts.googleapis.com/css2?{query_string}" 

76 

77 # Generate link tags 

78 links = [] 

79 

80 # Add preconnect for performance 

81 if self.settings.preconnect: 

82 links.extend( 

83 ( 

84 '<link rel="preconnect" href="https://fonts.googleapis.com">', 

85 '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>', 

86 ) 

87 ) 

88 

89 # Add the main stylesheet link 

90 links.append(f'<link rel="stylesheet" href="{url}">') 

91 

92 return "\n".join(links) 

93 

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

95 """Get font family CSS values with fallbacks.""" 

96 # Map font types to families 

97 if font_type == "primary" and self.settings.families: 

98 primary_font = self.settings.families[0] 

99 return self.FONT_FALLBACKS.get( 

100 primary_font, f"'{primary_font}', sans-serif" 

101 ) 

102 elif font_type == "secondary" and len(self.settings.families) > 1: 

103 secondary_font = self.settings.families[1] 

104 return self.FONT_FALLBACKS.get(secondary_font, f"'{secondary_font}', serif") 

105 if font_type in self.FONT_FALLBACKS: 

106 return self.FONT_FALLBACKS[font_type] 

107 # Default fallbacks 

108 return { 

109 "primary": "-apple-system, BlinkMacSystemFont, sans-serif", 

110 "secondary": "Georgia, serif", 

111 "monospace": "'Source Code Pro', monospace", 

112 "heading": "-apple-system, BlinkMacSystemFont, sans-serif", 

113 "body": "-apple-system, BlinkMacSystemFont, sans-serif", 

114 }.get(font_type, "inherit") 

115 

116 def _build_families_param(self) -> str: 

117 """Build the families parameter for Google Fonts URL.""" 

118 family_strings = [] 

119 

120 for family in self.settings.families: 

121 # Encode family name 

122 encoded_family = quote_plus(family) 

123 

124 # Add weights if specified 

125 if self.settings.weights: 

126 weights_str = ";".join( 

127 [f"wght@{weight}" for weight in self.settings.weights] 

128 ) 

129 family_strings.append(f"{encoded_family}:ital,{weights_str}") 

130 else: 

131 family_strings.append(encoded_family) 

132 

133 return "&family=".join(family_strings) 

134 

135 def get_css_variables(self) -> str: 

136 """Generate CSS custom properties for fonts.""" 

137 variables = [] 

138 

139 if self.settings.families: 

140 primary_font = self.get_font_family("primary") 

141 variables.append(f" --font-primary: {primary_font};") 

142 

143 if len(self.settings.families) > 1: 

144 secondary_font = self.get_font_family("secondary") 

145 variables.append(f" --font-secondary: {secondary_font};") 

146 

147 # Add weight variables 

148 if self.settings.weights: 

149 for weight in self.settings.weights: 

150 var_name = ( 

151 "normal" 

152 if weight == "400" 

153 else ("bold" if weight == "700" else f"weight-{weight}") 

154 ) 

155 variables.append(f" --font-weight-{var_name}: {weight};") 

156 

157 if variables: 

158 return ":root {\n" + "\n".join(variables) + "\n}" 

159 return "" 

160 

161 def get_font_preload(self, font_family: str, weight: str = "400") -> str: 

162 """Generate font preload link for critical fonts.""" 

163 # This would need actual font file URLs, which require API access 

164 # For now, return a basic structure 

165 encoded_family = quote_plus(font_family) 

166 return f'<link rel="preload" as="font" type="font/woff2" href="https://fonts.gstatic.com/s/{encoded_family.lower()}/..." crossorigin>' 

167 

168 def get_font_face_declarations(self) -> str: 

169 """Generate @font-face declarations for local hosting (if API key available).""" 

170 if not self.settings.api_key: 

171 return "<!-- API key required for local font hosting -->" 

172 

173 # This would integrate with Google Fonts API to get actual font file URLs 

174 # For now, return a placeholder 

175 declarations = [] 

176 for family in self.settings.families: 

177 for weight in self.settings.weights: 

178 declarations.append(f""" 

179@font-face {{ 

180 font-family: '{family}'; 

181 font-style: normal; 

182 font-weight: {weight}; 

183 font-display: {self.settings.display}; 

184 src: url('...') format('woff2'); 

185}}""") 

186 

187 return "\n".join(declarations) 

188 

189 def validate_font_availability(self, font_family: str) -> bool: 

190 """Check if a font family is available in Google Fonts.""" 

191 # This would require API integration 

192 # For now, return True for common fonts 

193 common_fonts = { 

194 "Roboto", 

195 "Open Sans", 

196 "Lato", 

197 "Montserrat", 

198 "Source Sans Pro", 

199 "Inter", 

200 "Poppins", 

201 "Nunito", 

202 "Playfair Display", 

203 "Merriweather", 

204 "Lora", 

205 "Source Serif Pro", 

206 "Fira Code", 

207 "Source Code Pro", 

208 } 

209 return font_family in common_fonts 

210 

211 async def get_optimized_import( 

212 self, critical_fonts: list[str] | None = None 

213 ) -> str: 

214 """Generate optimized font import with critical font prioritization.""" 

215 if critical_fonts: 

216 # Prioritize critical fonts in the import order 

217 prioritized_families = [] 

218 remaining_families = self.settings.families.copy() 

219 

220 for critical in critical_fonts: 

221 if critical in remaining_families: 

222 prioritized_families.append(critical) 

223 remaining_families.remove(critical) 

224 

225 # Add remaining fonts 

226 prioritized_families.extend(remaining_families) 

227 

228 # Temporarily override families for this import 

229 original_families = self.settings.families 

230 self.settings.families = prioritized_families 

231 

232 import_html = await self.get_font_import() 

233 

234 # Restore original families 

235 self.settings.families = original_families 

236 

237 return import_html 

238 

239 return await self.get_font_import()