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

1"""Jinja2 custom filters for FastBlocks adapter integration.""" 

2 

3from typing import Any 

4 

5from acb.depends import depends 

6 

7 

8def img_tag(image_id: str, alt: str, **attributes: Any) -> str: 

9 """Generate image tag using configured image adapter. 

10 

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 ) 

20 

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}"') 

26 

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

28 

29 

30def image_url(image_id: str, **transformations: Any) -> str: 

31 """Generate image URL with transformations using configured image adapter. 

32 

33 Note: For full functionality with transformations, use async_image_url in async templates. 

34 

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 

50 

51 # Fallback to basic URL 

52 return image_id 

53 

54 

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("_", "-") 

59 

60 

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 

67 

68 utilities = styles.get_utility_classes() 

69 if not utilities: 

70 return base_class 

71 

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}" 

78 

79 return base_class 

80 

81 

82def style_class(component: str, **modifiers: Any) -> str: 

83 """Get style framework class for component. 

84 

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("_", "-") 

92 

93 base_class = _get_base_component_class(styles, component) 

94 return _apply_utility_modifiers(base_class, styles, modifiers) 

95 

96 

97def icon_tag(icon_name: str, **attributes: Any) -> str: 

98 """Generate icon tag using configured icon adapter. 

99 

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}]" 

107 

108 # Fallback to text placeholder 

109 return f"[{icon_name}]" 

110 

111 

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. 

116 

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 ) 

128 

129 # Fallback implementation 

130 icon = icon_tag(icon_name, **attributes) 

131 if position == "right": 

132 return f"{text} {icon}" 

133 

134 return f"{icon} {text}" 

135 

136 

137def font_import() -> str: 

138 """Generate font import statements using configured font adapter. 

139 

140 Note: For full functionality, use async_font_import in async templates. 

141 

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 "" 

158 

159 # Fallback - no custom fonts 

160 return "" 

161 

162 

163def font_family(font_type: str = "primary") -> str: 

164 """Get font family CSS value using configured font adapter. 

165 

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" 

176 

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") 

186 

187 

188def stylesheet_links() -> str: 

189 """Generate all stylesheet links for configured adapters. 

190 

191 Usage in templates: 

192 [% block head %] 

193 [[ stylesheet_links() ]] 

194 [% endblock %] 

195 """ 

196 links = [] 

197 

198 # Get style framework links 

199 styles = depends.get("styles") 

200 if styles: 

201 links.extend(styles.get_stylesheet_links()) 

202 

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()) 

207 

208 return "\n".join(links) 

209 

210 

211def component_html(component: str, content: str = "", **attributes: Any) -> str: 

212 """Generate complete HTML component using style adapter. 

213 

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 ) 

225 

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')}" 

230 

231 attr_parts = [f'class="{css_class}"'] 

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

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

234 

235 attrs_str = " ".join(attr_parts) 

236 

237 if component.startswith("button"): 

238 return f"<button {attrs_str}>{content}</button>" 

239 

240 return f"<div {attrs_str}>{content}</div>" 

241 

242 

243def htmx_attrs(**htmx_attributes: Any) -> str: 

244 """Generate HTMX attributes for enhanced interactivity. 

245 

246 Usage in templates: 

247 <button [[ htmx_attrs(get='/api/data', target='#content', swap='innerHTML') ]]> 

248 Load Data 

249 </button> 

250 """ 

251 attr_parts = [] 

252 

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 } 

281 

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}"') 

285 

286 return " ".join(attr_parts) 

287 

288 

289def htmx_component(component_type: str, **attributes: Any) -> str: 

290 """Generate HTMX-enabled components with adapter integration. 

291 

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 = {} 

301 

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 

333 

334 # Get component styling from style adapter 

335 css_class = style_class(component_type, **component_attrs) 

336 

337 # Add HTMX attributes if any 

338 htmx_str = htmx_attrs(**htmx_attrs_dict) if htmx_attrs_dict else "" 

339 

340 # Build complete attribute string 

341 attr_parts = [f'class="{css_class}"'] 

342 if htmx_str: 

343 attr_parts.append(htmx_str) 

344 

345 return " ".join(attr_parts) 

346 

347 

348def htmx_form(action: str, **attributes: Any) -> str: 

349 """Generate HTMX-enabled forms with validation and feedback. 

350 

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 

363 

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}"}}' 

368 

369 return htmx_attrs(**form_attrs) 

370 

371 

372def htmx_lazy_load(url: str, placeholder: str = "Loading...", **attributes: Any) -> str: 

373 """Create lazy-loading containers with intersection observers. 

374 

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 

385 

386 attrs_str = htmx_attrs(**lazy_attrs) 

387 return f'{attrs_str} data-placeholder="{placeholder}"' 

388 

389 

390def htmx_infinite_scroll( 

391 next_url: str, container: str = "#infinite-container", **attributes: Any 

392) -> str: 

393 """Generate infinite scroll triggers. 

394 

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 

406 

407 return htmx_attrs(**scroll_attrs) 

408 

409 

410def htmx_search(endpoint: str, debounce: int = 300, **attributes: Any) -> str: 

411 """Generate debounced search inputs. 

412 

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 

423 

424 return htmx_attrs(**search_attrs) 

425 

426 

427def htmx_modal(content_url: str, **attributes: Any) -> str: 

428 """Create modal dialog triggers. 

429 

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 

440 

441 return htmx_attrs(**modal_attrs) 

442 

443 

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. 

448 

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) 

456 

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 

473 

474 return htmx_attrs(**swap_attrs) 

475 

476 

477def htmx_icon_toggle(icon_on: str, icon_off: str, **attributes: Any) -> str: 

478 """Icon state toggles for interactive elements. 

479 

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 

487 

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}"' 

491 

492 

493def htmx_ws_connect(endpoint: str, **attributes: Any) -> str: 

494 """Generate WebSocket connection attributes for real-time features. 

495 

496 Usage in templates: 

497 <div [[ htmx_ws_connect('/ws/notifications', 

498 listen='notification-received') ]]> 

499 </div> 

500 """ 

501 ws_attrs = {"ext": "ws"} | attributes 

502 

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}"' 

511 

512 

513def htmx_validation_feedback(field_name: str, **attributes: Any) -> str: 

514 """Generate real-time validation feedback containers. 

515 

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}") 

522 

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 

529 

530 return htmx_attrs(**validation_attrs) 

531 

532 

533def htmx_error_container(container_id: str = "htmx-errors") -> str: 

534 """Generate error display containers for HTMX responses. 

535 

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"' 

540 

541 

542def htmx_retry_trigger(max_retries: int = 3, backoff: str = "exponential") -> str: 

543 """Generate retry mechanisms for failed HTMX requests. 

544 

545 Usage in templates: 

546 <div [[ htmx_retry_trigger(3, 'exponential') ]]> 

547 """ 

548 return f'data-max-retries="{max_retries}" data-backoff="{backoff}"' 

549 

550 

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}