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

231 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-19 12:49 -0800

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- Kelp 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_sync("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_sync("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_sync("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 _separate_image_attributes( 

150 transform_params: dict[str, t.Any], 

151) -> tuple[dict[str, t.Any], dict[str, t.Any]]: 

152 """Separate image attributes from transformation parameters.""" 

153 img_attrs = {} 

154 transform_only = {} 

155 

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

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

158 img_attrs[key] = value 

159 else: 

160 transform_only[key] = value 

161 

162 return img_attrs, transform_only 

163 

164 

165def twicpics_smart_crop( 

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

167) -> str: 

168 """Generate TwicPics image with smart cropping. 

169 

170 Usage in templates: 

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

172 """ 

173 with suppress(Exception): 

174 twicpics = depends.get_sync("twicpics") 

175 if twicpics: 

176 transform_params = { 

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

178 "focus": focus, 

179 } | attributes 

180 

181 # Extract img attributes from transform params 

182 img_attrs, transform_only = _separate_image_attributes(transform_params) 

183 

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

185 

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

187 if "alt" not in img_attrs: 

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

189 

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

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

192 

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

194 

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

196 

197 

198# WebAwesome Icon Filters 

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

200 """Generate WebAwesome icon. 

201 

202 Usage in templates: 

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

204 """ 

205 with suppress(Exception): # Fallback 

206 webawesome = depends.get_sync("webawesome") 

207 if webawesome: 

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

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

210 

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

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

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

214 

215 

216def wa_icon_with_text( 

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

218) -> str: 

219 """Generate WebAwesome icon with text. 

220 

221 Usage in templates: 

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

223 """ 

224 with suppress(Exception): # Fallback 

225 webawesome = depends.get_sync("webawesome") 

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

227 result = webawesome.get_icon_with_text( 

228 icon_name, text, position, **attributes 

229 ) 

230 return ( 

231 str(result) 

232 if result is not None 

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

234 ) 

235 

236 icon = wa_icon(icon_name, **attributes) 

237 if position == "right": 

238 return f"{text} {icon}" 

239 

240 return f"{icon} {text}" 

241 

242 

243# Kelp Component Filters 

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

245 """Generate Kelp component. 

246 

247 Usage in templates: 

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

249 """ 

250 with suppress(Exception): # Fallback 

251 kelp = depends.get_sync("kelp") 

252 if kelp: 

253 result = kelp.build_component(component_type, content, **attributes) 

254 return ( 

255 str(result) 

256 if result is not None 

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

258 ) 

259 

260 css_class = f"kelp-{component_type}" 

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

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

263 

264 if variant: 

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

266 if size: 

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

268 

269 if "class" in attributes: 

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

271 

272 if component_type == "button": 

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

274 

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

276 

277 

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

279 """Generate Kelp card component. 

280 

281 Usage in templates: 

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

283 """ 

284 with suppress(Exception): 

285 kelp = depends.get_sync("kelp") 

286 if kelp and hasattr(kelp, "build_card"): 

287 result = kelp.build_card(title, content, **attributes) 

288 return ( 

289 str(result) 

290 if result is not None 

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

292 ) 

293 

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

295 

296 

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

298 """Build fallback card HTML.""" 

299 css_class = "kelp-card" 

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

301 

302 if variant: 

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

304 if "class" in attributes: 

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

306 

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

308 

309 if title: 

310 card_html.append( 

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

312 ) 

313 

314 if content: 

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

316 

317 return "".join(card_html) 

318 

319 

320# Phosphor Icons Filters 

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

322 """Generate Phosphor icon. 

323 

324 Usage in templates: 

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

326 """ 

327 with suppress(Exception): # Fallback 

328 phosphor = depends.get_sync("phosphor") 

329 if phosphor: 

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

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

332 

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

334 if weight != "regular": 

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

336 

337 if "class" in attributes: 

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

339 

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

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

342 

343 

344# Heroicons Filters 

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

346 """Generate Heroicon. 

347 

348 Usage in templates: 

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

350 """ 

351 with suppress(Exception): # Fallback SVG approach 

352 heroicons = depends.get_sync("heroicons") 

353 if heroicons: 

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

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

356 

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

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

359 

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

361 width="{size}" height="{size}" 

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

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

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

365 </svg>''' 

366 

367 

368# Remix Icons Filters 

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

370 """Generate Remix icon. 

371 

372 Usage in templates: 

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

374 """ 

375 with suppress(Exception): # Fallback 

376 remix = depends.get_sync("remix_icons") 

377 if remix: 

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

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

380 

381 css_class = f"ri-{icon_name}" 

382 if "class" in attributes: 

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

384 

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

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

387 

388 

389# Material Icons Filters 

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

391 """Generate Material Design icon. 

392 

393 Usage in templates: 

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

395 """ 

396 with suppress(Exception): # Fallback 

397 material = depends.get_sync("material_icons") 

398 if material: 

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

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

401 

402 css_class = "material-icons" 

403 if variant != "filled": 

404 css_class += f"-{variant}" 

405 

406 if "class" in attributes: 

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

408 

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

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

411 

412 

413# Advanced Font Filters 

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

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

416 

417 Usage in templates: 

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

419 """ 

420 with suppress(Exception): # Fallback 

421 font_adapter = await depends.get("fonts") 

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

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

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

425 

426 html_parts = [] 

427 for font in fonts: 

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

429 if critical: 

430 html_parts.extend( 

431 ( 

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

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

434 ) 

435 ) 

436 

437 return "\n".join(html_parts) 

438 

439 

440def font_face_declaration( 

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

442) -> str: 

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

444 

445 Usage in templates: 

446 [[ font_face_declaration('CustomFont', { 

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

448 'woff': '/fonts/custom.woff' 

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

450 """ 

451 with suppress(Exception): # Fallback 

452 font_adapter = depends.get_sync("fonts") 

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

454 result = font_adapter.generate_font_face( 

455 font_name, font_files, **attributes 

456 ) 

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

458 

459 src_parts = [] 

460 format_map = { 

461 "woff2": "woff2", 

462 "woff": "woff", 

463 "ttf": "truetype", 

464 "otf": "opentype", 

465 } 

466 

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

468 format_name = format_map.get(ext, ext) 

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

470 

471 css_parts = [ 

472 "@font-face {", 

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

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

475 ] 

476 

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

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

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

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

481 

482 return "\n".join(css_parts) 

483 

484 

485# Advanced HTMX Integration Filters 

486def htmx_progressive_enhancement( 

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

488) -> str: 

489 """Create progressively enhanced element with HTMX. 

490 

491 Usage in templates: 

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

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

494 'hx-target': '#result' 

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

496 """ 

497 # Add fallback action if provided 

498 if fallback_action: 

499 if "<form" in content: 

500 content = content.replace( 

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

502 ) 

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

504 content = content.replace( 

505 "<button", 

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

507 ) 

508 

509 # Add HTMX attributes 

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

511 # Find the main element and add attributes 

512 if "<" in content: 

513 first_tag_end = content.find(">") 

514 if first_tag_end != -1: 

515 before_close = content[:first_tag_end] 

516 after_close = content[first_tag_end:] 

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

518 

519 return content 

520 

521 

522def htmx_turbo_frame( 

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

524) -> str: 

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

526 

527 Usage in templates: 

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

529 """ 

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

531 

532 if src: 

533 attrs_list.extend( 

534 [ 

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

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

537 'hx-swap="innerHTML"', 

538 ] 

539 ) 

540 

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

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

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

544 

545 attrs_str = " ".join(attrs_list) 

546 

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

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

549 

550 

551def htmx_infinite_scroll_sentinel( 

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

553) -> str: 

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

555 

556 Usage in templates: 

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

558 """ 

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

560 hx-trigger="revealed" 

561 hx-target="{container}" 

562 hx-swap="beforeend" 

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

564 </div>''' 

565 

566 

567# Filter registration mapping 

568ENHANCED_FILTERS = { 

569 # Cloudflare Images 

570 "cf_image_url": cf_image_url, 

571 "cf_responsive_image": cf_responsive_image, 

572 # TwicPics 

573 "twicpics_image": twicpics_image, 

574 "twicpics_smart_crop": twicpics_smart_crop, 

575 # WebAwesome 

576 "wa_icon": wa_icon, 

577 "wa_icon_with_text": wa_icon_with_text, 

578 # Kelp 

579 "kelp_component": kelp_component, 

580 "kelp_card": kelp_card, 

581 # Icon Libraries 

582 "phosphor_icon": phosphor_icon, 

583 "heroicon": heroicon, 

584 "remix_icon": remix_icon, 

585 "material_icon": material_icon, 

586 # Font Management 

587 "font_face_declaration": font_face_declaration, 

588 # HTMX Advanced 

589 "htmx_progressive_enhancement": htmx_progressive_enhancement, 

590 "htmx_turbo_frame": htmx_turbo_frame, 

591 "htmx_infinite_scroll_sentinel": htmx_infinite_scroll_sentinel, 

592} 

593 

594# Async filters 

595ENHANCED_ASYNC_FILTERS = { 

596 "async_optimized_font_loading": async_optimized_font_loading, 

597} 

598 

599 

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

601MODULE_STATUS = AdapterStatus.STABLE