Coverage for fastblocks/adapters/templates/_enhanced_filters.py: 13%

228 statements  

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

1"""Enhanced Template Filters for Secondary Adapters Integration. 

2 

3This module provides comprehensive template filters for all FastBlocks secondary adapters: 

4- Cloudflare Images integration with transformations 

5- TwicPics integration with smart cropping 

6- WebAwesome icon integration 

7- KelpUI component integration 

8- Phosphor, Heroicons, Remix, Material Icons support 

9- Font loading and optimization 

10- Advanced HTMX integrations 

11 

12Requirements: 

13- All secondary adapter packages as available 

14 

15Author: lesleslie <les@wedgwoodwebworks.com> 

16Created: 2025-01-12 

17""" 

18 

19import typing as t 

20from contextlib import suppress 

21from uuid import UUID 

22 

23from acb.adapters import AdapterStatus 

24from acb.depends import depends 

25 

26 

27# Cloudflare Images Filters 

28def cf_image_url(image_id: str, **transformations: t.Any) -> str: 

29 """Generate Cloudflare Images URL with transformations. 

30 

31 Usage in templates: 

32 [[ cf_image_url('hero.jpg', width=800, quality=85, format='webp') ]] 

33 """ 

34 with suppress(Exception): # Fallback 

35 cloudflare = depends.get("cloudflare_images") 

36 if cloudflare: 

37 result = cloudflare.get_image_url(image_id, **transformations) 

38 return str(result) if result is not None else image_id 

39 

40 return image_id 

41 

42 

43def _build_cf_srcset( 

44 cloudflare: t.Any, image_id: str, sizes: dict[str, dict[str, t.Any]] 

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

46 """Build Cloudflare Images srcset and determine default src URL. 

47 

48 Args: 

49 cloudflare: Cloudflare adapter instance 

50 image_id: Image identifier 

51 sizes: Size configurations 

52 

53 Returns: 

54 Tuple of (srcset_parts, default_src_url) 

55 """ 

56 srcset_parts = [] 

57 src_url = image_id 

58 

59 for size_params in sizes.values(): 

60 width = size_params.get("width", 400) 

61 size_url = cloudflare.get_image_url(image_id, **size_params) 

62 srcset_parts.append(f"{size_url} {width}w") 

63 

64 # Use largest as default src 

65 if width > 800: 

66 src_url = size_url 

67 

68 return srcset_parts, src_url 

69 

70 

71def _build_cf_img_attributes( 

72 src_url: str, 

73 alt: str, 

74 srcset_parts: list[str], 

75 attributes: dict[str, t.Any], 

76) -> list[str]: 

77 """Build HTML image attributes for Cloudflare Images. 

78 

79 Args: 

80 src_url: Source URL 

81 alt: Alt text 

82 srcset_parts: Srcset components 

83 attributes: Additional HTML attributes 

84 

85 Returns: 

86 List of formatted attribute strings 

87 """ 

88 attr_parts = [ 

89 f'src="{src_url}"', 

90 f'alt="{alt}"', 

91 f'srcset="{", ".join(srcset_parts)}"', 

92 ] 

93 

94 # Add default sizes if not provided 

95 if "sizes" not in attributes: 

96 attr_parts.append( 

97 'sizes="(max-width: 480px) 400px, (max-width: 768px) 800px, 1200px"' 

98 ) 

99 

100 for key, value in attributes.items(): 

101 if key in ("width", "height", "class", "id", "style", "loading", "sizes"): 

102 attr_parts.append(f'{key}="{value}"') 

103 

104 return attr_parts 

105 

106 

107def cf_responsive_image( 

108 image_id: str, alt: str, sizes: dict[str, dict[str, t.Any]], **attributes: t.Any 

109) -> str: 

110 """Generate responsive Cloudflare Images with srcset. 

111 

112 Usage in templates: 

113 [[ cf_responsive_image('hero.jpg', 'Hero Image', { 

114 'mobile': {'width': 400, 'quality': 75}, 

115 'tablet': {'width': 800, 'quality': 80}, 

116 'desktop': {'width': 1200, 'quality': 85} 

117 }) ]] 

118 """ 

119 try: 

120 cloudflare = depends.get("cloudflare_images") 

121 if not cloudflare: 

122 return f'<img src="{image_id}" alt="{alt}">' 

123 

124 srcset_parts, src_url = _build_cf_srcset(cloudflare, image_id, sizes) 

125 attr_parts = _build_cf_img_attributes(src_url, alt, srcset_parts, attributes) 

126 

127 return f"<img {' '.join(attr_parts)}>" 

128 

129 except Exception: 

130 return f'<img src="{image_id}" alt="{alt}">' 

131 

132 

133# TwicPics Filters 

134def twicpics_image(image_id: str, **transformations: t.Any) -> str: 

135 """Generate TwicPics image URL with smart transformations. 

136 

137 Usage in templates: 

138 [[ twicpics_image('product.jpg', resize='400x300', focus='auto') ]] 

139 """ 

140 with suppress(Exception): 

141 twicpics = depends.get("twicpics") 

142 if twicpics: 

143 result = twicpics.get_image_url(image_id, **transformations) 

144 return str(result) if result is not None else image_id 

145 

146 return image_id 

147 

148 

149def twicpics_smart_crop( 

150 image_id: str, width: int, height: int, focus: str = "auto", **attributes: t.Any 

151) -> str: 

152 """Generate TwicPics image with smart cropping. 

153 

154 Usage in templates: 

155 [[ twicpics_smart_crop('landscape.jpg', 400, 300, 'face', class='hero-img') ]] 

156 """ 

157 with suppress(Exception): 

158 twicpics = depends.get("twicpics") 

159 if twicpics: 

160 transform_params = { 

161 "resize": f"{width}x{height}", 

162 "focus": focus, 

163 } | attributes 

164 

165 # Extract img attributes from transform params 

166 img_attrs = {} 

167 transform_only = {} 

168 

169 for key, value in transform_params.items(): 

170 if key in ("class", "id", "style", "loading", "alt"): 

171 img_attrs[key] = value 

172 else: 

173 transform_only[key] = value 

174 

175 image_url = twicpics.get_image_url(image_id, **transform_only) 

176 

177 attr_parts = [f'src="{image_url}"'] 

178 if "alt" not in img_attrs: 

179 attr_parts.append(f'alt="{image_id}"') 

180 

181 for key, value in img_attrs.items(): 

182 attr_parts.append(f'{key}="{value}"') 

183 

184 return f"<img {' '.join(attr_parts)}>" 

185 

186 return f'<img src="{image_id}" alt="{image_id}" width="{width}" height="{height}">' 

187 

188 

189# WebAwesome Icon Filters 

190def wa_icon(icon_name: str, **attributes: t.Any) -> str: 

191 """Generate WebAwesome icon. 

192 

193 Usage in templates: 

194 [[ wa_icon('home', size='24', class='nav-icon') ]] 

195 """ 

196 with suppress(Exception): # Fallback 

197 webawesome = depends.get("webawesome") 

198 if webawesome: 

199 result = webawesome.get_icon_tag(icon_name, **attributes) 

200 return str(result) if result is not None else f"[{icon_name}]" 

201 

202 css_class = attributes.get("class", "") 

203 size = attributes.get("size", "16") 

204 return f'<i class="wa wa-{icon_name} {css_class}" style="font-size: {size}px;"></i>' 

205 

206 

207def wa_icon_with_text( 

208 icon_name: str, text: str, position: str = "left", **attributes: t.Any 

209) -> str: 

210 """Generate WebAwesome icon with text. 

211 

212 Usage in templates: 

213 [[ wa_icon_with_text('save', 'Save Changes', 'left', class='btn-icon') ]] 

214 """ 

215 with suppress(Exception): # Fallback 

216 webawesome = depends.get("webawesome") 

217 if webawesome and hasattr(webawesome, "get_icon_with_text"): 

218 result = webawesome.get_icon_with_text( 

219 icon_name, text, position, **attributes 

220 ) 

221 return ( 

222 str(result) 

223 if result is not None 

224 else f"{wa_icon(icon_name, **attributes)} {text}" 

225 ) 

226 

227 icon = wa_icon(icon_name, **attributes) 

228 if position == "right": 

229 return f"{text} {icon}" 

230 

231 return f"{icon} {text}" 

232 

233 

234# KelpUI Component Filters 

235def kelp_component(component_type: str, content: str = "", **attributes: t.Any) -> str: 

236 """Generate KelpUI component. 

237 

238 Usage in templates: 

239 [[ kelp_component('button', 'Click Me', variant='primary', size='large') ]] 

240 """ 

241 with suppress(Exception): # Fallback 

242 kelpui = depends.get("kelpui") 

243 if kelpui: 

244 result = kelpui.build_component(component_type, content, **attributes) 

245 return ( 

246 str(result) 

247 if result is not None 

248 else f'<div class="kelp-{component_type}">{content}</div>' 

249 ) 

250 

251 css_class = f"kelp-{component_type}" 

252 variant = attributes.get("variant", "") 

253 size = attributes.get("size", "") 

254 

255 if variant: 

256 css_class += f" kelp-{component_type}--{variant}" 

257 if size: 

258 css_class += f" kelp-{component_type}--{size}" 

259 

260 if "class" in attributes: 

261 css_class += f" {attributes['class']}" 

262 

263 if component_type == "button": 

264 return f'<button class="{css_class}">{content}</button>' 

265 

266 return f'<div class="{css_class}">{content}</div>' 

267 

268 

269def kelp_card(title: str = "", content: str = "", **attributes: t.Any) -> str: 

270 """Generate KelpUI card component. 

271 

272 Usage in templates: 

273 [[ kelp_card('Card Title', '<p>Card content here</p>', variant='elevated') ]] 

274 """ 

275 with suppress(Exception): 

276 kelpui = depends.get("kelpui") 

277 if kelpui and hasattr(kelpui, "build_card"): 

278 result = kelpui.build_card(title, content, **attributes) 

279 return ( 

280 str(result) 

281 if result is not None 

282 else _build_fallback_card(title, content, **attributes) 

283 ) 

284 

285 return _build_fallback_card(title, content, **attributes) 

286 

287 

288def _build_fallback_card(title: str, content: str, **attributes: t.Any) -> str: 

289 """Build fallback card HTML.""" 

290 css_class = "kelp-card" 

291 variant = attributes.get("variant", "") 

292 

293 if variant: 

294 css_class += f" kelp-card--{variant}" 

295 if "class" in attributes: 

296 css_class += f" {attributes['class']}" 

297 

298 card_html = [f'<div class="{css_class}">'] 

299 

300 if title: 

301 card_html.append( 

302 f'<div class="kelp-card__header"><h3 class="kelp-card__title">{title}</h3></div>' 

303 ) 

304 

305 if content: 

306 card_html.extend((f'<div class="kelp-card__content">{content}</div>', "</div>")) 

307 

308 return "".join(card_html) 

309 

310 

311# Phosphor Icons Filters 

312def phosphor_icon(icon_name: str, weight: str = "regular", **attributes: t.Any) -> str: 

313 """Generate Phosphor icon. 

314 

315 Usage in templates: 

316 [[ phosphor_icon('house', 'bold', size='24', class='nav-icon') ]] 

317 """ 

318 with suppress(Exception): # Fallback 

319 phosphor = depends.get("phosphor") 

320 if phosphor: 

321 result = phosphor.get_icon_tag(icon_name, weight=weight, **attributes) 

322 return str(result) if result is not None else f"[{icon_name}]" 

323 

324 css_class = f"ph ph-{icon_name}" 

325 if weight != "regular": 

326 css_class += f" ph-{weight}" 

327 

328 if "class" in attributes: 

329 css_class += f" {attributes['class']}" 

330 

331 size = attributes.get("size", "16") 

332 return f'<i class="{css_class}" style="font-size: {size}px;"></i>' 

333 

334 

335# Heroicons Filters 

336def heroicon(icon_name: str, style: str = "outline", **attributes: t.Any) -> str: 

337 """Generate Heroicon. 

338 

339 Usage in templates: 

340 [[ heroicon('home', 'solid', size='24', class='nav-icon') ]] 

341 """ 

342 with suppress(Exception): # Fallback SVG approach 

343 heroicons = depends.get("heroicons") 

344 if heroicons: 

345 result = heroicons.get_icon_tag(icon_name, style=style, **attributes) 

346 return str(result) if result is not None else f"[{icon_name}]" 

347 

348 css_class = attributes.get("class", "") 

349 size = attributes.get("size", "24") 

350 

351 return f'''<svg class="heroicon heroicon-{icon_name} {css_class}" 

352 width="{size}" height="{size}" 

353 fill="{style == "solid" and "currentColor" or "none"}" 

354 stroke="currentColor" stroke-width="1.5"> 

355 <use href="#heroicon-{icon_name}-{style}"></use> 

356 </svg>''' 

357 

358 

359# Remix Icons Filters 

360def remix_icon(icon_name: str, **attributes: t.Any) -> str: 

361 """Generate Remix icon. 

362 

363 Usage in templates: 

364 [[ remix_icon('home-line', size='24', class='nav-icon') ]] 

365 """ 

366 with suppress(Exception): # Fallback 

367 remix = depends.get("remix_icons") 

368 if remix: 

369 result = remix.get_icon_tag(icon_name, **attributes) 

370 return str(result) if result is not None else f"[{icon_name}]" 

371 

372 css_class = f"ri-{icon_name}" 

373 if "class" in attributes: 

374 css_class += f" {attributes['class']}" 

375 

376 size = attributes.get("size", "16") 

377 return f'<i class="{css_class}" style="font-size: {size}px;"></i>' 

378 

379 

380# Material Icons Filters 

381def material_icon(icon_name: str, variant: str = "filled", **attributes: t.Any) -> str: 

382 """Generate Material Design icon. 

383 

384 Usage in templates: 

385 [[ material_icon('home', 'outlined', size='24', class='nav-icon') ]] 

386 """ 

387 with suppress(Exception): # Fallback 

388 material = depends.get("material_icons") 

389 if material: 

390 result = material.get_icon_tag(icon_name, variant=variant, **attributes) 

391 return str(result) if result is not None else f"[{icon_name}]" 

392 

393 css_class = "material-icons" 

394 if variant != "filled": 

395 css_class += f"-{variant}" 

396 

397 if "class" in attributes: 

398 css_class += f" {attributes['class']}" 

399 

400 size = attributes.get("size", "24") 

401 return f'<span class="{css_class}" style="font-size: {size}px;">{icon_name}</span>' 

402 

403 

404# Advanced Font Filters 

405async def async_optimized_font_loading(fonts: list[str], critical: bool = True) -> str: 

406 """Generate optimized font loading with preload hints. 

407 

408 Usage in templates: 

409 [[ await async_optimized_font_loading(['Inter', 'Roboto Mono'], critical=True) ]] 

410 """ 

411 with suppress(Exception): # Fallback 

412 font_adapter = depends.get("fonts") 

413 if font_adapter and hasattr(font_adapter, "get_optimized_loading"): 

414 result = await font_adapter.get_optimized_loading(fonts, critical=critical) 

415 return str(result) if result is not None else "" 

416 

417 html_parts = [] 

418 for font in fonts: 

419 font_family = font.replace(" ", "+") 

420 if critical: 

421 html_parts.extend( 

422 ( 

423 f'<link rel="preload" href="https://fonts.googleapis.com/css2?family={font_family}&display=swap" as="style">', 

424 f'<link href="https://fonts.googleapis.com/css2?family={font_family}&display=swap" rel="stylesheet">', 

425 ) 

426 ) 

427 

428 return "\n".join(html_parts) 

429 

430 

431def font_face_declaration( 

432 font_name: str, font_files: dict[str, str], **attributes: t.Any 

433) -> str: 

434 """Generate @font-face CSS declaration. 

435 

436 Usage in templates: 

437 [[ font_face_declaration('CustomFont', { 

438 'woff2': '/fonts/custom.woff2', 

439 'woff': '/fonts/custom.woff' 

440 }, weight='400', style='normal') ]] 

441 """ 

442 with suppress(Exception): # Fallback 

443 font_adapter = depends.get("fonts") 

444 if font_adapter and hasattr(font_adapter, "generate_font_face"): 

445 result = font_adapter.generate_font_face( 

446 font_name, font_files, **attributes 

447 ) 

448 return str(result) if result is not None else "" 

449 

450 src_parts = [] 

451 format_map = { 

452 "woff2": "woff2", 

453 "woff": "woff", 

454 "ttf": "truetype", 

455 "otf": "opentype", 

456 } 

457 

458 for ext, url in font_files.items(): 

459 format_name = format_map.get(ext, ext) 

460 src_parts.append(f'url("{url}") format("{format_name}")') 

461 

462 css_parts = [ 

463 "@font-face {", 

464 f' font-family: "{font_name}";', 

465 f" src: {', '.join(src_parts)};", 

466 ] 

467 

468 for key, value in attributes.items(): 

469 if key in ("weight", "style", "display", "stretch"): 

470 css_key = f"font-{key}" if key in ("weight", "style", "stretch") else key 

471 css_parts.extend((f" {css_key}: {value};", "}")) 

472 

473 return "\n".join(css_parts) 

474 

475 

476# Advanced HTMX Integration Filters 

477def htmx_progressive_enhancement( 

478 content: str, htmx_attrs: dict[str, str], fallback_action: str = "" 

479) -> str: 

480 """Create progressively enhanced element with HTMX. 

481 

482 Usage in templates: 

483 [[ htmx_progressive_enhancement('<button>Save</button>', { 

484 'hx-post': '/api/save', 

485 'hx-target': '#result' 

486 }, fallback_action='/save') ]] 

487 """ 

488 # Add fallback action if provided 

489 if fallback_action: 

490 if "<form" in content: 

491 content = content.replace( 

492 "<form", f'<form action="{fallback_action}" method="post"' 

493 ) 

494 elif "<button" in content and "onclick" not in content: 

495 content = content.replace( 

496 "<button", 

497 f"<button onclick=\"window.location.href='{fallback_action}'\"", 

498 ) 

499 

500 # Add HTMX attributes 

501 for attr_name, attr_value in htmx_attrs.items(): 

502 # Find the main element and add attributes 

503 if "<" in content: 

504 first_tag_end = content.find(">") 

505 if first_tag_end != -1: 

506 before_close = content[:first_tag_end] 

507 after_close = content[first_tag_end:] 

508 content = f'{before_close} {attr_name}="{attr_value}"{after_close}' 

509 

510 return content 

511 

512 

513def htmx_turbo_frame( 

514 frame_id: str, src: str = "", loading: str = "lazy", **attributes: t.Any 

515) -> str: 

516 """Create Turbo Frame-like behavior with HTMX. 

517 

518 Usage in templates: 

519 [[ htmx_turbo_frame('user-profile', '/users/123/profile', loading='eager') ]] 

520 """ 

521 attrs_list = [f'id="{frame_id}"'] 

522 

523 if src: 

524 attrs_list.extend( 

525 [ 

526 f'hx-get="{src}"', 

527 f'hx-trigger="{"load" if loading == "eager" else "revealed"}"', 

528 'hx-swap="innerHTML"', 

529 ] 

530 ) 

531 

532 for key, value in attributes.items(): 

533 if key.startswith("hx-") or key in ("class", "style"): 

534 attrs_list.append(f'{key}="{value}"') 

535 

536 attrs_str = " ".join(attrs_list) 

537 

538 placeholder = "Loading..." if loading == "eager" else "Click to load" 

539 return f"<div {attrs_str}>{placeholder}</div>" 

540 

541 

542def htmx_infinite_scroll_sentinel( 

543 next_url: str, container: str = "#content", threshold: str = "0px" 

544) -> str: 

545 """Create intersection observer sentinel for infinite scroll. 

546 

547 Usage in templates: 

548 [[ htmx_infinite_scroll_sentinel('/api/posts?page=2', '#posts', '100px') ]] 

549 """ 

550 return f'''<div hx-get="{next_url}" 

551 hx-trigger="revealed" 

552 hx-target="{container}" 

553 hx-swap="beforeend" 

554 style="height: 1px; margin-bottom: {threshold};"> 

555 </div>''' 

556 

557 

558# Filter registration mapping 

559ENHANCED_FILTERS = { 

560 # Cloudflare Images 

561 "cf_image_url": cf_image_url, 

562 "cf_responsive_image": cf_responsive_image, 

563 # TwicPics 

564 "twicpics_image": twicpics_image, 

565 "twicpics_smart_crop": twicpics_smart_crop, 

566 # WebAwesome 

567 "wa_icon": wa_icon, 

568 "wa_icon_with_text": wa_icon_with_text, 

569 # KelpUI 

570 "kelp_component": kelp_component, 

571 "kelp_card": kelp_card, 

572 # Icon Libraries 

573 "phosphor_icon": phosphor_icon, 

574 "heroicon": heroicon, 

575 "remix_icon": remix_icon, 

576 "material_icon": material_icon, 

577 # Font Management 

578 "font_face_declaration": font_face_declaration, 

579 # HTMX Advanced 

580 "htmx_progressive_enhancement": htmx_progressive_enhancement, 

581 "htmx_turbo_frame": htmx_turbo_frame, 

582 "htmx_infinite_scroll_sentinel": htmx_infinite_scroll_sentinel, 

583} 

584 

585# Async filters 

586ENHANCED_ASYNC_FILTERS = { 

587 "async_optimized_font_loading": async_optimized_font_loading, 

588} 

589 

590 

591MODULE_ID = UUID("01937d8a-1234-7890-abcd-1234567890ab") 

592MODULE_STATUS = AdapterStatus.STABLE