Coverage for fastblocks/adapters/templates/_filters.py: 36%
170 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"""Jinja2 custom filters for FastBlocks adapter integration."""
3from typing import Any
5from acb.depends import depends
8def img_tag(image_id: str, alt: str, **attributes: Any) -> str:
9 """Generate image tag using configured image adapter.
11 Usage in templates:
12 [[ img_tag('product.jpg', 'Product Image', width=300, class='responsive') ]]
13 """
14 images = depends.get("images")
15 if images:
16 result = images.get_img_tag(image_id, alt, **attributes)
17 return (
18 str(result) if result is not None else f'<img src="{image_id}" alt="{alt}">'
19 )
21 # Fallback to basic img tag
22 attr_parts = [f'src="{image_id}"', f'alt="{alt}"']
23 for key, value in attributes.items():
24 if key in ("width", "height", "class", "id", "style"):
25 attr_parts.append(f'{key}="{value}"')
27 return f"<img {' '.join(attr_parts)}>"
30def image_url(image_id: str, **transformations: Any) -> str:
31 """Generate image URL with transformations using configured image adapter.
33 Note: For full functionality with transformations, use async_image_url in async templates.
35 Usage in templates:
36 [[ image_url('product.jpg', width=300, height=200, crop='fill') ]]
37 [[ await async_image_url('product.jpg', width=300, height=200, crop='fill') ]] # async version
38 """
39 images = depends.get("images")
40 if images and hasattr(images, "get_sync_image_url"):
41 # Some adapters may provide sync methods for simple URLs
42 result = images.get_sync_image_url(image_id, **transformations)
43 return str(result) if result is not None else image_id
44 elif images:
45 # Return base URL with query parameters as fallback
46 if transformations:
47 params = "&".join([f"{k}={v}" for k, v in transformations.items()])
48 return f"{image_id}?{params}"
49 return image_id
51 # Fallback to basic URL
52 return image_id
55def _get_base_component_class(styles: Any, component: str) -> str:
56 """Get the base class for a component from styles adapter."""
57 base_class = styles.get_component_class(component)
58 return str(base_class) if base_class is not None else component.replace("_", "-")
61def _apply_utility_modifiers(
62 base_class: str, styles: Any, modifiers: dict[str, Any]
63) -> str:
64 """Apply utility class modifiers to base class if supported."""
65 if not hasattr(styles, "get_utility_classes"):
66 return base_class
68 utilities = styles.get_utility_classes()
69 if not utilities:
70 return base_class
72 for modifier, value in modifiers.items():
73 utility_key = f"{modifier}_{value}"
74 if utility_key in utilities:
75 utility_class = utilities[utility_key]
76 if utility_class:
77 base_class = f"{base_class} {utility_class}"
79 return base_class
82def style_class(component: str, **modifiers: Any) -> str:
83 """Get style framework class for component.
85 Usage in templates:
86 [[ style_class('button', variant='primary', size='large') ]]
87 """
88 styles = depends.get("styles")
89 if not styles:
90 # Fallback to semantic class name
91 return component.replace("_", "-")
93 base_class = _get_base_component_class(styles, component)
94 return _apply_utility_modifiers(base_class, styles, modifiers)
97def icon_tag(icon_name: str, **attributes: Any) -> str:
98 """Generate icon tag using configured icon adapter.
100 Usage in templates:
101 [[ icon_tag('home', class='nav-icon', size='24') ]]
102 """
103 icons = depends.get("icons")
104 if icons:
105 result = icons.get_icon_tag(icon_name, **attributes)
106 return str(result) if result is not None else f"[{icon_name}]"
108 # Fallback to text placeholder
109 return f"[{icon_name}]"
112def icon_with_text(
113 icon_name: str, text: str, position: str = "left", **attributes: Any
114) -> str:
115 """Generate icon with text using configured icon adapter.
117 Usage in templates:
118 [[ icon_with_text('save', 'Save Changes', position='left') ]]
119 """
120 icons = depends.get("icons")
121 if icons and hasattr(icons, "get_icon_with_text"):
122 result = icons.get_icon_with_text(icon_name, text, position, **attributes)
123 return (
124 str(result)
125 if result is not None
126 else f"{icon_tag(icon_name, **attributes)} {text}"
127 )
129 # Fallback implementation
130 icon = icon_tag(icon_name, **attributes)
131 if position == "right":
132 return f"{text} {icon}"
134 return f"{icon} {text}"
137def font_import() -> str:
138 """Generate font import statements using configured font adapter.
140 Note: For full functionality, use async_font_import in async templates.
142 Usage in templates:
143 [% block head %]
144 [[ font_import() ]]
145 [[ await async_font_import() ]] # async version for full functionality
146 [% endblock %]
147 """
148 fonts = depends.get("fonts")
149 if fonts and hasattr(fonts, "get_sync_font_import"):
150 # Some adapters may provide sync methods for basic imports
151 result = fonts.get_sync_font_import()
152 return str(result) if result is not None else ""
153 elif fonts:
154 # Return basic stylesheet links if available
155 if hasattr(fonts, "get_stylesheet_links"):
156 links = fonts.get_stylesheet_links()
157 return "\n".join(links) if links else ""
159 # Fallback - no custom fonts
160 return ""
163def font_family(font_type: str = "primary") -> str:
164 """Get font family CSS value using configured font adapter.
166 Usage in templates:
167 <style>
168 body { font-family: [[ font_family('primary') ]]; }
169 h1 { font-family: [[ font_family('heading') ]]; }
170 </style>
171 """
172 fonts = depends.get("fonts")
173 if fonts:
174 result = fonts.get_font_family(font_type)
175 return str(result) if result is not None else "inherit"
177 # Fallback fonts
178 fallbacks = {
179 "primary": "-apple-system, BlinkMacSystemFont, sans-serif",
180 "secondary": "Georgia, serif",
181 "heading": "-apple-system, BlinkMacSystemFont, sans-serif",
182 "body": "-apple-system, BlinkMacSystemFont, sans-serif",
183 "monospace": "'Courier New', monospace",
184 }
185 return fallbacks.get(font_type, "inherit")
188def stylesheet_links() -> str:
189 """Generate all stylesheet links for configured adapters.
191 Usage in templates:
192 [% block head %]
193 [[ stylesheet_links() ]]
194 [% endblock %]
195 """
196 links = []
198 # Get style framework links
199 styles = depends.get("styles")
200 if styles:
201 links.extend(styles.get_stylesheet_links())
203 # Get icon framework links
204 icons = depends.get("icons")
205 if icons and hasattr(icons, "get_stylesheet_links"):
206 links.extend(icons.get_stylesheet_links())
208 return "\n".join(links)
211def component_html(component: str, content: str = "", **attributes: Any) -> str:
212 """Generate complete HTML component using style adapter.
214 Usage in templates:
215 [[ component_html('button', 'Click Me', variant='primary', class='my-btn') ]]
216 """
217 styles = depends.get("styles")
218 if styles and hasattr(styles, "build_component_html"):
219 result = styles.build_component_html(component, content, **attributes)
220 return (
221 str(result)
222 if result is not None
223 else f'<div class="{component}">{content}</div>'
224 )
226 # Fallback to basic HTML
227 css_class = style_class(component)
228 if "class" in attributes:
229 css_class = f"{css_class} {attributes.pop('class')}"
231 attr_parts = [f'class="{css_class}"']
232 for key, value in attributes.items():
233 attr_parts.append(f'{key}="{value}"')
235 attrs_str = " ".join(attr_parts)
237 if component.startswith("button"):
238 return f"<button {attrs_str}>{content}</button>"
240 return f"<div {attrs_str}>{content}</div>"
243def htmx_attrs(**htmx_attributes: Any) -> str:
244 """Generate HTMX attributes for enhanced interactivity.
246 Usage in templates:
247 <button [[ htmx_attrs(get='/api/data', target='#content', swap='innerHTML') ]]>
248 Load Data
249 </button>
250 """
251 attr_parts = []
253 # Map common HTMX attributes with enhanced support
254 attr_mapping = {
255 "get": "hx-get",
256 "post": "hx-post",
257 "put": "hx-put",
258 "delete": "hx-delete",
259 "patch": "hx-patch",
260 "target": "hx-target",
261 "swap": "hx-swap",
262 "trigger": "hx-trigger",
263 "indicator": "hx-indicator",
264 "confirm": "hx-confirm",
265 "vals": "hx-vals",
266 "headers": "hx-headers",
267 "include": "hx-include",
268 "params": "hx-params",
269 "boost": "hx-boost",
270 "push_url": "hx-push-url",
271 "replace_url": "hx-replace-url",
272 "ext": "hx-ext",
273 "select": "hx-select",
274 "select_oob": "hx-select-oob",
275 "sync": "hx-sync",
276 "history": "hx-history",
277 "disabled_elt": "hx-disabled-elt",
278 "encoding": "hx-encoding",
279 "preserve": "hx-preserve",
280 }
282 for key, value in htmx_attributes.items():
283 htmx_attr = attr_mapping.get(key, f"hx-{key.replace('_', '-')}")
284 attr_parts.append(f'{htmx_attr}="{value}"')
286 return " ".join(attr_parts)
289def htmx_component(component_type: str, **attributes: Any) -> str:
290 """Generate HTMX-enabled components with adapter integration.
292 Usage in templates:
293 <div [[ htmx_component('card', get='/api/details/{id}', target='#details') ]]>
294 [[ component_html('card-header', 'Title') ]]
295 <div id="details"></div>
296 </div>
297 """
298 # Extract HTMX attributes
299 htmx_attrs_dict = {}
300 component_attrs = {}
302 for key, value in attributes.items():
303 if key in (
304 "get",
305 "post",
306 "put",
307 "delete",
308 "patch",
309 "target",
310 "swap",
311 "trigger",
312 "indicator",
313 "confirm",
314 "vals",
315 "headers",
316 "include",
317 "params",
318 "boost",
319 "push_url",
320 "replace_url",
321 "ext",
322 "select",
323 "select_oob",
324 "sync",
325 "history",
326 "disabled_elt",
327 "encoding",
328 "preserve",
329 ):
330 htmx_attrs_dict[key] = value
331 else:
332 component_attrs[key] = value
334 # Get component styling from style adapter
335 css_class = style_class(component_type, **component_attrs)
337 # Add HTMX attributes if any
338 htmx_str = htmx_attrs(**htmx_attrs_dict) if htmx_attrs_dict else ""
340 # Build complete attribute string
341 attr_parts = [f'class="{css_class}"']
342 if htmx_str:
343 attr_parts.append(htmx_str)
345 return " ".join(attr_parts)
348def htmx_form(action: str, **attributes: Any) -> str:
349 """Generate HTMX-enabled forms with validation and feedback.
351 Usage in templates:
352 <form [[ htmx_form('/users/create', target='#form-container',
353 validation_target='#form-errors') ]]>
354 <!-- form fields -->
355 </form>
356 """
357 # Set default HTMX behavior for forms
358 form_attrs = {
359 "post": action,
360 "swap": "outerHTML",
361 "indicator": "#form-loading",
362 } | attributes
364 # Handle validation target if specified
365 if "validation_target" in form_attrs:
366 validation_target = form_attrs.pop("validation_target")
367 form_attrs["headers"] = f'{{"HX-Error-Target": "{validation_target}"}}'
369 return htmx_attrs(**form_attrs)
372def htmx_lazy_load(url: str, placeholder: str = "Loading...", **attributes: Any) -> str:
373 """Create lazy-loading containers with intersection observers.
375 Usage in templates:
376 <div [[ htmx_lazy_load('/api/content', 'Loading content...',
377 trigger='revealed once') ]]>
378 </div>
379 """
380 lazy_attrs = {
381 "get": url,
382 "trigger": "revealed once",
383 "indicator": "this",
384 } | attributes
386 attrs_str = htmx_attrs(**lazy_attrs)
387 return f'{attrs_str} data-placeholder="{placeholder}"'
390def htmx_infinite_scroll(
391 next_url: str, container: str = "#infinite-container", **attributes: Any
392) -> str:
393 """Generate infinite scroll triggers.
395 Usage in templates:
396 <div [[ htmx_infinite_scroll('/api/posts?page=2', '#posts-container') ]]>
397 Loading more posts...
398 </div>
399 """
400 scroll_attrs = {
401 "get": next_url,
402 "trigger": "revealed",
403 "target": container,
404 "swap": "afterend",
405 } | attributes
407 return htmx_attrs(**scroll_attrs)
410def htmx_search(endpoint: str, debounce: int = 300, **attributes: Any) -> str:
411 """Generate debounced search inputs.
413 Usage in templates:
414 <input type="text" name="q"
415 [[ htmx_search('/api/search', 500, target='#results') ]]>
416 """
417 search_attrs = {
418 "get": endpoint,
419 "trigger": f"keyup changed delay:{debounce}ms",
420 "target": "#search-results",
421 "indicator": "#search-loading",
422 } | attributes
424 return htmx_attrs(**search_attrs)
427def htmx_modal(content_url: str, **attributes: Any) -> str:
428 """Create modal dialog triggers.
430 Usage in templates:
431 <button [[ htmx_modal('/modal/user/{id}', target='#modal-container') ]]>
432 View Details
433 </button>
434 """
435 modal_attrs = {
436 "get": content_url,
437 "target": "#modal-container",
438 "swap": "innerHTML",
439 } | attributes
441 return htmx_attrs(**modal_attrs)
444def htmx_img_swap(
445 image_id: str, transformations: dict[str, Any] | None = None, **attributes: Any
446) -> str:
447 """Dynamic image swapping with transformations using image adapter.
449 Usage in templates:
450 <img [[ htmx_img_swap('product.jpg', {'width': 300},
451 trigger='mouseenter once', target='this') ]]>
452 """
453 images = depends.get("images")
454 if not images:
455 return htmx_attrs(**attributes)
457 # Build transformation URL
458 if transformations:
459 # This would typically be handled by the image adapter
460 transform_url = f"/api/images/{image_id}/transform"
461 swap_attrs = {
462 "get": transform_url,
463 "vals": str(transformations),
464 "target": "this",
465 "swap": "outerHTML",
466 } | attributes
467 else:
468 swap_attrs = {
469 "get": f"/api/images/{image_id}",
470 "target": "this",
471 "swap": "outerHTML",
472 } | attributes
474 return htmx_attrs(**swap_attrs)
477def htmx_icon_toggle(icon_on: str, icon_off: str, **attributes: Any) -> str:
478 """Icon state toggles for interactive elements.
480 Usage in templates:
481 <button [[ htmx_icon_toggle('heart-filled', 'heart-outline',
482 post='/favorites/toggle/{id}') ]]>
483 [[ icon_tag('heart-outline') ]]
484 </button>
485 """
486 toggle_attrs = {"swap": "outerHTML", "target": "this"} | attributes
488 # Add data attributes for icon states
489 attrs_str = htmx_attrs(**toggle_attrs)
490 return f'{attrs_str} data-icon-on="{icon_on}" data-icon-off="{icon_off}"'
493def htmx_ws_connect(endpoint: str, **attributes: Any) -> str:
494 """Generate WebSocket connection attributes for real-time features.
496 Usage in templates:
497 <div [[ htmx_ws_connect('/ws/notifications',
498 listen='notification-received') ]]>
499 </div>
500 """
501 ws_attrs = {"ext": "ws"} | attributes
503 # Handle WebSocket-specific attributes
504 if "listen" in ws_attrs:
505 listen_event = ws_attrs.pop("listen")
506 attrs_str = htmx_attrs(**ws_attrs)
507 return f'{attrs_str} ws-connect="{endpoint}" sse-listen="{listen_event}"'
508 else:
509 attrs_str = htmx_attrs(**ws_attrs)
510 return f'{attrs_str} ws-connect="{endpoint}"'
513def htmx_validation_feedback(field_name: str, **attributes: Any) -> str:
514 """Generate real-time validation feedback containers.
516 Usage in templates:
517 <input name="email"
518 [[ htmx_validation_feedback('email',
519 validate_url='/validate/email') ]]>
520 """
521 validate_url = attributes.pop("validate_url", f"/validate/{field_name}")
523 validation_attrs = {
524 "get": validate_url,
525 "trigger": "blur, keyup changed delay:500ms",
526 "target": f"#{field_name}-feedback",
527 "include": "this",
528 } | attributes
530 return htmx_attrs(**validation_attrs)
533def htmx_error_container(container_id: str = "htmx-errors") -> str:
534 """Generate error display containers for HTMX responses.
536 Usage in templates:
537 <div [[ htmx_error_container('form-errors') ]]></div>
538 """
539 return f'id="{container_id}" class="htmx-error-container" role="alert"'
542def htmx_retry_trigger(max_retries: int = 3, backoff: str = "exponential") -> str:
543 """Generate retry mechanisms for failed HTMX requests.
545 Usage in templates:
546 <div [[ htmx_retry_trigger(3, 'exponential') ]]>
547 """
548 return f'data-max-retries="{max_retries}" data-backoff="{backoff}"'
551# Filter registration mapping for template engines
552FASTBLOCKS_FILTERS = {
553 "img_tag": img_tag,
554 "image_url": image_url,
555 "style_class": style_class,
556 "icon_tag": icon_tag,
557 "icon_with_text": icon_with_text,
558 "font_import": font_import,
559 "font_family": font_family,
560 "stylesheet_links": stylesheet_links,
561 "component_html": component_html,
562 "htmx_attrs": htmx_attrs,
563 "htmx_component": htmx_component,
564 "htmx_form": htmx_form,
565 "htmx_lazy_load": htmx_lazy_load,
566 "htmx_infinite_scroll": htmx_infinite_scroll,
567 "htmx_search": htmx_search,
568 "htmx_modal": htmx_modal,
569 "htmx_img_swap": htmx_img_swap,
570 "htmx_icon_toggle": htmx_icon_toggle,
571 "htmx_ws_connect": htmx_ws_connect,
572 "htmx_validation_feedback": htmx_validation_feedback,
573 "htmx_error_container": htmx_error_container,
574 "htmx_retry_trigger": htmx_retry_trigger,
575}