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
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
1"""Enhanced Template Filters for Secondary Adapters Integration.
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
12Requirements:
13- All secondary adapter packages as available
15Author: lesleslie <les@wedgwoodwebworks.com>
16Created: 2025-01-12
17"""
19import typing as t
20from contextlib import suppress
21from uuid import UUID
23from acb.adapters import AdapterStatus
24from acb.depends import depends
27# Cloudflare Images Filters
28def cf_image_url(image_id: str, **transformations: t.Any) -> str:
29 """Generate Cloudflare Images URL with transformations.
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
40 return image_id
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.
48 Args:
49 cloudflare: Cloudflare adapter instance
50 image_id: Image identifier
51 sizes: Size configurations
53 Returns:
54 Tuple of (srcset_parts, default_src_url)
55 """
56 srcset_parts = []
57 src_url = image_id
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")
64 # Use largest as default src
65 if width > 800:
66 src_url = size_url
68 return srcset_parts, src_url
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.
79 Args:
80 src_url: Source URL
81 alt: Alt text
82 srcset_parts: Srcset components
83 attributes: Additional HTML attributes
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 ]
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 )
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}"')
104 return attr_parts
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.
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}">'
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)
127 return f"<img {' '.join(attr_parts)}>"
129 except Exception:
130 return f'<img src="{image_id}" alt="{alt}">'
133# TwicPics Filters
134def twicpics_image(image_id: str, **transformations: t.Any) -> str:
135 """Generate TwicPics image URL with smart transformations.
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
146 return image_id
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.
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
165 # Extract img attributes from transform params
166 img_attrs = {}
167 transform_only = {}
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
175 image_url = twicpics.get_image_url(image_id, **transform_only)
177 attr_parts = [f'src="{image_url}"']
178 if "alt" not in img_attrs:
179 attr_parts.append(f'alt="{image_id}"')
181 for key, value in img_attrs.items():
182 attr_parts.append(f'{key}="{value}"')
184 return f"<img {' '.join(attr_parts)}>"
186 return f'<img src="{image_id}" alt="{image_id}" width="{width}" height="{height}">'
189# WebAwesome Icon Filters
190def wa_icon(icon_name: str, **attributes: t.Any) -> str:
191 """Generate WebAwesome icon.
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}]"
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>'
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.
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 )
227 icon = wa_icon(icon_name, **attributes)
228 if position == "right":
229 return f"{text} {icon}"
231 return f"{icon} {text}"
234# KelpUI Component Filters
235def kelp_component(component_type: str, content: str = "", **attributes: t.Any) -> str:
236 """Generate KelpUI component.
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 )
251 css_class = f"kelp-{component_type}"
252 variant = attributes.get("variant", "")
253 size = attributes.get("size", "")
255 if variant:
256 css_class += f" kelp-{component_type}--{variant}"
257 if size:
258 css_class += f" kelp-{component_type}--{size}"
260 if "class" in attributes:
261 css_class += f" {attributes['class']}"
263 if component_type == "button":
264 return f'<button class="{css_class}">{content}</button>'
266 return f'<div class="{css_class}">{content}</div>'
269def kelp_card(title: str = "", content: str = "", **attributes: t.Any) -> str:
270 """Generate KelpUI card component.
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 )
285 return _build_fallback_card(title, content, **attributes)
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", "")
293 if variant:
294 css_class += f" kelp-card--{variant}"
295 if "class" in attributes:
296 css_class += f" {attributes['class']}"
298 card_html = [f'<div class="{css_class}">']
300 if title:
301 card_html.append(
302 f'<div class="kelp-card__header"><h3 class="kelp-card__title">{title}</h3></div>'
303 )
305 if content:
306 card_html.extend((f'<div class="kelp-card__content">{content}</div>', "</div>"))
308 return "".join(card_html)
311# Phosphor Icons Filters
312def phosphor_icon(icon_name: str, weight: str = "regular", **attributes: t.Any) -> str:
313 """Generate Phosphor icon.
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}]"
324 css_class = f"ph ph-{icon_name}"
325 if weight != "regular":
326 css_class += f" ph-{weight}"
328 if "class" in attributes:
329 css_class += f" {attributes['class']}"
331 size = attributes.get("size", "16")
332 return f'<i class="{css_class}" style="font-size: {size}px;"></i>'
335# Heroicons Filters
336def heroicon(icon_name: str, style: str = "outline", **attributes: t.Any) -> str:
337 """Generate Heroicon.
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}]"
348 css_class = attributes.get("class", "")
349 size = attributes.get("size", "24")
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>'''
359# Remix Icons Filters
360def remix_icon(icon_name: str, **attributes: t.Any) -> str:
361 """Generate Remix icon.
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}]"
372 css_class = f"ri-{icon_name}"
373 if "class" in attributes:
374 css_class += f" {attributes['class']}"
376 size = attributes.get("size", "16")
377 return f'<i class="{css_class}" style="font-size: {size}px;"></i>'
380# Material Icons Filters
381def material_icon(icon_name: str, variant: str = "filled", **attributes: t.Any) -> str:
382 """Generate Material Design icon.
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}]"
393 css_class = "material-icons"
394 if variant != "filled":
395 css_class += f"-{variant}"
397 if "class" in attributes:
398 css_class += f" {attributes['class']}"
400 size = attributes.get("size", "24")
401 return f'<span class="{css_class}" style="font-size: {size}px;">{icon_name}</span>'
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.
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 ""
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 )
428 return "\n".join(html_parts)
431def font_face_declaration(
432 font_name: str, font_files: dict[str, str], **attributes: t.Any
433) -> str:
434 """Generate @font-face CSS declaration.
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 ""
450 src_parts = []
451 format_map = {
452 "woff2": "woff2",
453 "woff": "woff",
454 "ttf": "truetype",
455 "otf": "opentype",
456 }
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}")')
462 css_parts = [
463 "@font-face {",
464 f' font-family: "{font_name}";',
465 f" src: {', '.join(src_parts)};",
466 ]
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};", "}"))
473 return "\n".join(css_parts)
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.
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 )
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}'
510 return content
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.
518 Usage in templates:
519 [[ htmx_turbo_frame('user-profile', '/users/123/profile', loading='eager') ]]
520 """
521 attrs_list = [f'id="{frame_id}"']
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 )
532 for key, value in attributes.items():
533 if key.startswith("hx-") or key in ("class", "style"):
534 attrs_list.append(f'{key}="{value}"')
536 attrs_str = " ".join(attrs_list)
538 placeholder = "Loading..." if loading == "eager" else "Click to load"
539 return f"<div {attrs_str}>{placeholder}</div>"
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.
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>'''
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}
585# Async filters
586ENHANCED_ASYNC_FILTERS = {
587 "async_optimized_font_loading": async_optimized_font_loading,
588}
591MODULE_ID = UUID("01937d8a-1234-7890-abcd-1234567890ab")
592MODULE_STATUS = AdapterStatus.STABLE