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
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-19 12:49 -0800
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- Kelp 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_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
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_sync("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_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
146 return image_id
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 = {}
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
162 return img_attrs, transform_only
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.
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
181 # Extract img attributes from transform params
182 img_attrs, transform_only = _separate_image_attributes(transform_params)
184 image_url = twicpics.get_image_url(image_id, **transform_only)
186 attr_parts = [f'src="{image_url}"']
187 if "alt" not in img_attrs:
188 attr_parts.append(f'alt="{image_id}"')
190 for key, value in img_attrs.items():
191 attr_parts.append(f'{key}="{value}"')
193 return f"<img {' '.join(attr_parts)}>"
195 return f'<img src="{image_id}" alt="{image_id}" width="{width}" height="{height}">'
198# WebAwesome Icon Filters
199def wa_icon(icon_name: str, **attributes: t.Any) -> str:
200 """Generate WebAwesome icon.
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}]"
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>'
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.
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 )
236 icon = wa_icon(icon_name, **attributes)
237 if position == "right":
238 return f"{text} {icon}"
240 return f"{icon} {text}"
243# Kelp Component Filters
244def kelp_component(component_type: str, content: str = "", **attributes: t.Any) -> str:
245 """Generate Kelp component.
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 )
260 css_class = f"kelp-{component_type}"
261 variant = attributes.get("variant", "")
262 size = attributes.get("size", "")
264 if variant:
265 css_class += f" kelp-{component_type}--{variant}"
266 if size:
267 css_class += f" kelp-{component_type}--{size}"
269 if "class" in attributes:
270 css_class += f" {attributes['class']}"
272 if component_type == "button":
273 return f'<button class="{css_class}">{content}</button>'
275 return f'<div class="{css_class}">{content}</div>'
278def kelp_card(title: str = "", content: str = "", **attributes: t.Any) -> str:
279 """Generate Kelp card component.
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 )
294 return _build_fallback_card(title, content, **attributes)
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", "")
302 if variant:
303 css_class += f" kelp-card--{variant}"
304 if "class" in attributes:
305 css_class += f" {attributes['class']}"
307 card_html = [f'<div class="{css_class}">']
309 if title:
310 card_html.append(
311 f'<div class="kelp-card__header"><h3 class="kelp-card__title">{title}</h3></div>'
312 )
314 if content:
315 card_html.extend((f'<div class="kelp-card__content">{content}</div>', "</div>"))
317 return "".join(card_html)
320# Phosphor Icons Filters
321def phosphor_icon(icon_name: str, weight: str = "regular", **attributes: t.Any) -> str:
322 """Generate Phosphor icon.
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}]"
333 css_class = f"ph ph-{icon_name}"
334 if weight != "regular":
335 css_class += f" ph-{weight}"
337 if "class" in attributes:
338 css_class += f" {attributes['class']}"
340 size = attributes.get("size", "16")
341 return f'<i class="{css_class}" style="font-size: {size}px;"></i>'
344# Heroicons Filters
345def heroicon(icon_name: str, style: str = "outline", **attributes: t.Any) -> str:
346 """Generate Heroicon.
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}]"
357 css_class = attributes.get("class", "")
358 size = attributes.get("size", "24")
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>'''
368# Remix Icons Filters
369def remix_icon(icon_name: str, **attributes: t.Any) -> str:
370 """Generate Remix icon.
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}]"
381 css_class = f"ri-{icon_name}"
382 if "class" in attributes:
383 css_class += f" {attributes['class']}"
385 size = attributes.get("size", "16")
386 return f'<i class="{css_class}" style="font-size: {size}px;"></i>'
389# Material Icons Filters
390def material_icon(icon_name: str, variant: str = "filled", **attributes: t.Any) -> str:
391 """Generate Material Design icon.
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}]"
402 css_class = "material-icons"
403 if variant != "filled":
404 css_class += f"-{variant}"
406 if "class" in attributes:
407 css_class += f" {attributes['class']}"
409 size = attributes.get("size", "24")
410 return f'<span class="{css_class}" style="font-size: {size}px;">{icon_name}</span>'
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.
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 ""
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 )
437 return "\n".join(html_parts)
440def font_face_declaration(
441 font_name: str, font_files: dict[str, str], **attributes: t.Any
442) -> str:
443 """Generate @font-face CSS declaration.
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 ""
459 src_parts = []
460 format_map = {
461 "woff2": "woff2",
462 "woff": "woff",
463 "ttf": "truetype",
464 "otf": "opentype",
465 }
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}")')
471 css_parts = [
472 "@font-face {",
473 f' font-family: "{font_name}";',
474 f" src: {', '.join(src_parts)};",
475 ]
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};", "}"))
482 return "\n".join(css_parts)
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.
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 )
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}'
519 return content
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.
527 Usage in templates:
528 [[ htmx_turbo_frame('user-profile', '/users/123/profile', loading='eager') ]]
529 """
530 attrs_list = [f'id="{frame_id}"']
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 )
541 for key, value in attributes.items():
542 if key.startswith("hx-") or key in ("class", "style"):
543 attrs_list.append(f'{key}="{value}"')
545 attrs_str = " ".join(attrs_list)
547 placeholder = "Loading..." if loading == "eager" else "Click to load"
548 return f"<div {attrs_str}>{placeholder}</div>"
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.
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>'''
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}
594# Async filters
595ENHANCED_ASYNC_FILTERS = {
596 "async_optimized_font_loading": async_optimized_font_loading,
597}
600MODULE_ID = UUID("01937d8a-1234-7890-abcd-1234567890ab")
601MODULE_STATUS = AdapterStatus.STABLE