Coverage for fastblocks/adapters/templates/enhanced_filters.py: 11%
237 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-29 00:51 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-29 00:51 -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 uuid import UUID
22from acb.adapters import AdapterStatus
23from acb.depends import depends
26# Cloudflare Images Filters
27def cf_image_url(image_id: str, **transformations: t.Any) -> str:
28 """Generate Cloudflare Images URL with transformations.
30 Usage in templates:
31 [[ cf_image_url('hero.jpg', width=800, quality=85, format='webp') ]]
32 """
33 try:
34 cloudflare = depends.get("cloudflare_images")
35 if cloudflare:
36 result = cloudflare.get_image_url(image_id, **transformations)
37 return str(result) if result is not None else image_id
38 except Exception:
39 pass
41 # Fallback
42 return image_id
45def cf_responsive_image(
46 image_id: str, alt: str, sizes: dict[str, dict[str, t.Any]], **attributes: t.Any
47) -> str:
48 """Generate responsive Cloudflare Images with srcset.
50 Usage in templates:
51 [[ cf_responsive_image('hero.jpg', 'Hero Image', {
52 'mobile': {'width': 400, 'quality': 75},
53 'tablet': {'width': 800, 'quality': 80},
54 'desktop': {'width': 1200, 'quality': 85}
55 }) ]]
56 """
57 try:
58 cloudflare = depends.get("cloudflare_images")
59 if not cloudflare:
60 return f'<img src="{image_id}" alt="{alt}">'
62 srcset_parts = []
63 src_url = image_id
65 for size_name, size_params in sizes.items():
66 width = size_params.get("width", 400)
67 size_url = cloudflare.get_image_url(image_id, **size_params)
68 srcset_parts.append(f"{size_url} {width}w")
70 # Use largest as default src
71 if width > 800:
72 src_url = size_url
74 attr_parts = [
75 f'src="{src_url}"',
76 f'alt="{alt}"',
77 f'srcset="{", ".join(srcset_parts)}"',
78 ]
80 # Add default sizes if not provided
81 if "sizes" not in attributes:
82 attr_parts.append(
83 'sizes="(max-width: 480px) 400px, (max-width: 768px) 800px, 1200px"'
84 )
86 for key, value in attributes.items():
87 if key in ["width", "height", "class", "id", "style", "loading", "sizes"]:
88 attr_parts.append(f'{key}="{value}"')
90 return f"<img {' '.join(attr_parts)}>"
92 except Exception:
93 return f'<img src="{image_id}" alt="{alt}">'
96# TwicPics Filters
97def twicpics_image(image_id: str, **transformations: t.Any) -> str:
98 """Generate TwicPics image URL with smart transformations.
100 Usage in templates:
101 [[ twicpics_image('product.jpg', resize='400x300', focus='auto') ]]
102 """
103 try:
104 twicpics = depends.get("twicpics")
105 if twicpics:
106 result = twicpics.get_image_url(image_id, **transformations)
107 return str(result) if result is not None else image_id
108 except Exception:
109 pass
111 return image_id
114def twicpics_smart_crop(
115 image_id: str, width: int, height: int, focus: str = "auto", **attributes: t.Any
116) -> str:
117 """Generate TwicPics image with smart cropping.
119 Usage in templates:
120 [[ twicpics_smart_crop('landscape.jpg', 400, 300, 'face', class='hero-img') ]]
121 """
122 try:
123 twicpics = depends.get("twicpics")
124 if twicpics:
125 transform_params = {
126 "resize": f"{width}x{height}",
127 "focus": focus,
128 **attributes,
129 }
131 # Extract img attributes from transform params
132 img_attrs = {}
133 transform_only = {}
135 for key, value in transform_params.items():
136 if key in ["class", "id", "style", "loading", "alt"]:
137 img_attrs[key] = value
138 else:
139 transform_only[key] = value
141 image_url = twicpics.get_image_url(image_id, **transform_only)
143 attr_parts = [f'src="{image_url}"']
144 if "alt" not in img_attrs:
145 attr_parts.append(f'alt="{image_id}"')
147 for key, value in img_attrs.items():
148 attr_parts.append(f'{key}="{value}"')
150 return f"<img {' '.join(attr_parts)}>"
152 except Exception:
153 pass
155 return f'<img src="{image_id}" alt="{image_id}" width="{width}" height="{height}">'
158# WebAwesome Icon Filters
159def wa_icon(icon_name: str, **attributes: t.Any) -> str:
160 """Generate WebAwesome icon.
162 Usage in templates:
163 [[ wa_icon('home', size='24', class='nav-icon') ]]
164 """
165 try:
166 webawesome = depends.get("webawesome")
167 if webawesome:
168 result = webawesome.get_icon_tag(icon_name, **attributes)
169 return str(result) if result is not None else f"[{icon_name}]"
170 except Exception:
171 pass
173 # Fallback
174 css_class = attributes.get("class", "")
175 size = attributes.get("size", "16")
176 return f'<i class="wa wa-{icon_name} {css_class}" style="font-size: {size}px;"></i>'
179def wa_icon_with_text(
180 icon_name: str, text: str, position: str = "left", **attributes: t.Any
181) -> str:
182 """Generate WebAwesome icon with text.
184 Usage in templates:
185 [[ wa_icon_with_text('save', 'Save Changes', 'left', class='btn-icon') ]]
186 """
187 try:
188 webawesome = depends.get("webawesome")
189 if webawesome and hasattr(webawesome, "get_icon_with_text"):
190 result = webawesome.get_icon_with_text(
191 icon_name, text, position, **attributes
192 )
193 return (
194 str(result)
195 if result is not None
196 else f"{wa_icon(icon_name, **attributes)} {text}"
197 )
198 except Exception:
199 pass
201 # Fallback
202 icon = wa_icon(icon_name, **attributes)
203 if position == "right":
204 return f"{text} {icon}"
205 else:
206 return f"{icon} {text}"
209# KelpUI Component Filters
210def kelp_component(component_type: str, content: str = "", **attributes: t.Any) -> str:
211 """Generate KelpUI component.
213 Usage in templates:
214 [[ kelp_component('button', 'Click Me', variant='primary', size='large') ]]
215 """
216 try:
217 kelpui = depends.get("kelpui")
218 if kelpui:
219 result = kelpui.build_component(component_type, content, **attributes)
220 return (
221 str(result)
222 if result is not None
223 else f'<div class="kelp-{component_type}">{content}</div>'
224 )
225 except Exception:
226 pass
228 # Fallback
229 css_class = f"kelp-{component_type}"
230 variant = attributes.get("variant", "")
231 size = attributes.get("size", "")
233 if variant:
234 css_class += f" kelp-{component_type}--{variant}"
235 if size:
236 css_class += f" kelp-{component_type}--{size}"
238 if "class" in attributes:
239 css_class += f" {attributes['class']}"
241 if component_type == "button":
242 return f'<button class="{css_class}">{content}</button>'
243 else:
244 return f'<div class="{css_class}">{content}</div>'
247def kelp_card(title: str = "", content: str = "", **attributes: t.Any) -> str:
248 """Generate KelpUI card component.
250 Usage in templates:
251 [[ kelp_card('Card Title', '<p>Card content here</p>', variant='elevated') ]]
252 """
253 try:
254 kelpui = depends.get("kelpui")
255 if kelpui and hasattr(kelpui, "build_card"):
256 result = kelpui.build_card(title, content, **attributes)
257 return (
258 str(result)
259 if result is not None
260 else _build_fallback_card(title, content, **attributes)
261 )
262 except Exception:
263 pass
265 return _build_fallback_card(title, content, **attributes)
268def _build_fallback_card(title: str, content: str, **attributes: t.Any) -> str:
269 """Build fallback card HTML."""
270 css_class = "kelp-card"
271 variant = attributes.get("variant", "")
273 if variant:
274 css_class += f" kelp-card--{variant}"
275 if "class" in attributes:
276 css_class += f" {attributes['class']}"
278 card_html = [f'<div class="{css_class}">']
280 if title:
281 card_html.append(
282 f'<div class="kelp-card__header"><h3 class="kelp-card__title">{title}</h3></div>'
283 )
285 if content:
286 card_html.append(f'<div class="kelp-card__content">{content}</div>')
288 card_html.append("</div>")
290 return "".join(card_html)
293# Phosphor Icons Filters
294def phosphor_icon(icon_name: str, weight: str = "regular", **attributes: t.Any) -> str:
295 """Generate Phosphor icon.
297 Usage in templates:
298 [[ phosphor_icon('house', 'bold', size='24', class='nav-icon') ]]
299 """
300 try:
301 phosphor = depends.get("phosphor")
302 if phosphor:
303 result = phosphor.get_icon_tag(icon_name, weight=weight, **attributes)
304 return str(result) if result is not None else f"[{icon_name}]"
305 except Exception:
306 pass
308 # Fallback
309 css_class = f"ph ph-{icon_name}"
310 if weight != "regular":
311 css_class += f" ph-{weight}"
313 if "class" in attributes:
314 css_class += f" {attributes['class']}"
316 size = attributes.get("size", "16")
317 return f'<i class="{css_class}" style="font-size: {size}px;"></i>'
320# Heroicons Filters
321def heroicon(icon_name: str, style: str = "outline", **attributes: t.Any) -> str:
322 """Generate Heroicon.
324 Usage in templates:
325 [[ heroicon('home', 'solid', size='24', class='nav-icon') ]]
326 """
327 try:
328 heroicons = depends.get("heroicons")
329 if heroicons:
330 result = heroicons.get_icon_tag(icon_name, style=style, **attributes)
331 return str(result) if result is not None else f"[{icon_name}]"
332 except Exception:
333 pass
335 # Fallback SVG approach
336 css_class = attributes.get("class", "")
337 size = attributes.get("size", "24")
339 return f'''<svg class="heroicon heroicon-{icon_name} {css_class}"
340 width="{size}" height="{size}"
341 fill="{style == "solid" and "currentColor" or "none"}"
342 stroke="currentColor" stroke-width="1.5">
343 <use href="#heroicon-{icon_name}-{style}"></use>
344 </svg>'''
347# Remix Icons Filters
348def remix_icon(icon_name: str, **attributes: t.Any) -> str:
349 """Generate Remix icon.
351 Usage in templates:
352 [[ remix_icon('home-line', size='24', class='nav-icon') ]]
353 """
354 try:
355 remix = depends.get("remix_icons")
356 if remix:
357 result = remix.get_icon_tag(icon_name, **attributes)
358 return str(result) if result is not None else f"[{icon_name}]"
359 except Exception:
360 pass
362 # Fallback
363 css_class = f"ri-{icon_name}"
364 if "class" in attributes:
365 css_class += f" {attributes['class']}"
367 size = attributes.get("size", "16")
368 return f'<i class="{css_class}" style="font-size: {size}px;"></i>'
371# Material Icons Filters
372def material_icon(icon_name: str, variant: str = "filled", **attributes: t.Any) -> str:
373 """Generate Material Design icon.
375 Usage in templates:
376 [[ material_icon('home', 'outlined', size='24', class='nav-icon') ]]
377 """
378 try:
379 material = depends.get("material_icons")
380 if material:
381 result = material.get_icon_tag(icon_name, variant=variant, **attributes)
382 return str(result) if result is not None else f"[{icon_name}]"
383 except Exception:
384 pass
386 # Fallback
387 css_class = "material-icons"
388 if variant != "filled":
389 css_class += f"-{variant}"
391 if "class" in attributes:
392 css_class += f" {attributes['class']}"
394 size = attributes.get("size", "24")
395 return f'<span class="{css_class}" style="font-size: {size}px;">{icon_name}</span>'
398# Advanced Font Filters
399async def async_optimized_font_loading(fonts: list[str], critical: bool = True) -> str:
400 """Generate optimized font loading with preload hints.
402 Usage in templates:
403 [[ await async_optimized_font_loading(['Inter', 'Roboto Mono'], critical=True) ]]
404 """
405 try:
406 font_adapter = depends.get("fonts")
407 if font_adapter and hasattr(font_adapter, "get_optimized_loading"):
408 result = await font_adapter.get_optimized_loading(fonts, critical=critical)
409 return str(result) if result is not None else ""
410 except Exception:
411 pass
413 # Fallback
414 html_parts = []
415 for font in fonts:
416 font_family = font.replace(" ", "+")
417 if critical:
418 html_parts.append(
419 f'<link rel="preload" href="https://fonts.googleapis.com/css2?family={font_family}&display=swap" as="style">'
420 )
421 html_parts.append(
422 f'<link href="https://fonts.googleapis.com/css2?family={font_family}&display=swap" rel="stylesheet">'
423 )
425 return "\n".join(html_parts)
428def font_face_declaration(
429 font_name: str, font_files: dict[str, str], **attributes: t.Any
430) -> str:
431 """Generate @font-face CSS declaration.
433 Usage in templates:
434 [[ font_face_declaration('CustomFont', {
435 'woff2': '/fonts/custom.woff2',
436 'woff': '/fonts/custom.woff'
437 }, weight='400', style='normal') ]]
438 """
439 try:
440 font_adapter = depends.get("fonts")
441 if font_adapter and hasattr(font_adapter, "generate_font_face"):
442 result = font_adapter.generate_font_face(
443 font_name, font_files, **attributes
444 )
445 return str(result) if result is not None else ""
446 except Exception:
447 pass
449 # Fallback
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.append(f" {css_key}: {value};")
473 css_parts.append("}")
475 return "\n".join(css_parts)
478# Advanced HTMX Integration Filters
479def htmx_progressive_enhancement(
480 content: str, htmx_attrs: dict[str, str], fallback_action: str = ""
481) -> str:
482 """Create progressively enhanced element with HTMX.
484 Usage in templates:
485 [[ htmx_progressive_enhancement('<button>Save</button>', {
486 'hx-post': '/api/save',
487 'hx-target': '#result'
488 }, fallback_action='/save') ]]
489 """
490 # Add fallback action if provided
491 if fallback_action:
492 if "<form" in content:
493 content = content.replace(
494 "<form", f'<form action="{fallback_action}" method="post"'
495 )
496 elif "<button" in content and "onclick" not in content:
497 content = content.replace(
498 "<button",
499 f"<button onclick=\"window.location.href='{fallback_action}'\"",
500 )
502 # Add HTMX attributes
503 for attr_name, attr_value in htmx_attrs.items():
504 # Find the main element and add attributes
505 if "<" in content:
506 first_tag_end = content.find(">")
507 if first_tag_end != -1:
508 before_close = content[:first_tag_end]
509 after_close = content[first_tag_end:]
510 content = f'{before_close} {attr_name}="{attr_value}"{after_close}'
512 return content
515def htmx_turbo_frame(
516 frame_id: str, src: str = "", loading: str = "lazy", **attributes: t.Any
517) -> str:
518 """Create Turbo Frame-like behavior with HTMX.
520 Usage in templates:
521 [[ htmx_turbo_frame('user-profile', '/users/123/profile', loading='eager') ]]
522 """
523 attrs_list = [f'id="{frame_id}"']
525 if src:
526 attrs_list.extend(
527 [
528 f'hx-get="{src}"',
529 f'hx-trigger="{"load" if loading == "eager" else "revealed"}"',
530 'hx-swap="innerHTML"',
531 ]
532 )
534 for key, value in attributes.items():
535 if key.startswith("hx-") or key in ["class", "style"]:
536 attrs_list.append(f'{key}="{value}"')
538 attrs_str = " ".join(attrs_list)
540 placeholder = "Loading..." if loading == "eager" else "Click to load"
541 return f"<div {attrs_str}>{placeholder}</div>"
544def htmx_infinite_scroll_sentinel(
545 next_url: str, container: str = "#content", threshold: str = "0px"
546) -> str:
547 """Create intersection observer sentinel for infinite scroll.
549 Usage in templates:
550 [[ htmx_infinite_scroll_sentinel('/api/posts?page=2', '#posts', '100px') ]]
551 """
552 return f'''<div hx-get="{next_url}"
553 hx-trigger="revealed"
554 hx-target="{container}"
555 hx-swap="beforeend"
556 style="height: 1px; margin-bottom: {threshold};">
557 </div>'''
560# Filter registration mapping
561ENHANCED_FILTERS = {
562 # Cloudflare Images
563 "cf_image_url": cf_image_url,
564 "cf_responsive_image": cf_responsive_image,
565 # TwicPics
566 "twicpics_image": twicpics_image,
567 "twicpics_smart_crop": twicpics_smart_crop,
568 # WebAwesome
569 "wa_icon": wa_icon,
570 "wa_icon_with_text": wa_icon_with_text,
571 # KelpUI
572 "kelp_component": kelp_component,
573 "kelp_card": kelp_card,
574 # Icon Libraries
575 "phosphor_icon": phosphor_icon,
576 "heroicon": heroicon,
577 "remix_icon": remix_icon,
578 "material_icon": material_icon,
579 # Font Management
580 "font_face_declaration": font_face_declaration,
581 # HTMX Advanced
582 "htmx_progressive_enhancement": htmx_progressive_enhancement,
583 "htmx_turbo_frame": htmx_turbo_frame,
584 "htmx_infinite_scroll_sentinel": htmx_infinite_scroll_sentinel,
585}
587# Async filters
588ENHANCED_ASYNC_FILTERS = {
589 "async_optimized_font_loading": async_optimized_font_loading,
590}
593MODULE_ID = UUID("01937d8a-1234-7890-abcd-1234567890ab")
594MODULE_STATUS = AdapterStatus.STABLE