Coverage for fastblocks/adapters/templates/filters.py: 37%

164 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-28 18:13 -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 style_class(component: str, **modifiers: Any) -> str: 

56 """Get style framework class for component. 

57 

58 Usage in templates: 

59 [[ style_class('button', variant='primary', size='large') ]] 

60 """ 

61 styles = depends.get("styles") 

62 if styles: 

63 base_class = styles.get_component_class(component) 

64 base_class = ( 

65 str(base_class) if base_class is not None else component.replace("_", "-") 

66 ) 

67 

68 # Apply modifiers if the adapter supports it 

69 if hasattr(styles, "get_utility_classes"): 

70 utilities = styles.get_utility_classes() 

71 if utilities: 

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 # Fallback to semantic class name 

82 return component.replace("_", "-") 

83 

84 

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

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

87 

88 Usage in templates: 

89 [[ icon_tag('home', class='nav-icon', size='24') ]] 

90 """ 

91 icons = depends.get("icons") 

92 if icons: 

93 result = icons.get_icon_tag(icon_name, **attributes) 

94 return str(result) if result is not None else f"[{icon_name}]" 

95 

96 # Fallback to text placeholder 

97 return f"[{icon_name}]" 

98 

99 

100def icon_with_text( 

101 icon_name: str, text: str, position: str = "left", **attributes: Any 

102) -> str: 

103 """Generate icon with text using configured icon adapter. 

104 

105 Usage in templates: 

106 [[ icon_with_text('save', 'Save Changes', position='left') ]] 

107 """ 

108 icons = depends.get("icons") 

109 if icons and hasattr(icons, "get_icon_with_text"): 

110 result = icons.get_icon_with_text(icon_name, text, position, **attributes) 

111 return ( 

112 str(result) 

113 if result is not None 

114 else f"{icon_tag(icon_name, **attributes)} {text}" 

115 ) 

116 

117 # Fallback implementation 

118 icon = icon_tag(icon_name, **attributes) 

119 if position == "right": 

120 return f"{text} {icon}" 

121 else: 

122 return f"{icon} {text}" 

123 

124 

125def font_import() -> str: 

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

127 

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

129 

130 Usage in templates: 

131 [% block head %] 

132 [[ font_import() ]] 

133 [[ await async_font_import() ]] # async version for full functionality 

134 [% endblock %] 

135 """ 

136 fonts = depends.get("fonts") 

137 if fonts and hasattr(fonts, "get_sync_font_import"): 

138 # Some adapters may provide sync methods for basic imports 

139 result = fonts.get_sync_font_import() 

140 return str(result) if result is not None else "" 

141 elif fonts: 

142 # Return basic stylesheet links if available 

143 if hasattr(fonts, "get_stylesheet_links"): 

144 links = fonts.get_stylesheet_links() 

145 return "\n".join(links) if links else "" 

146 

147 # Fallback - no custom fonts 

148 return "" 

149 

150 

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

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

153 

154 Usage in templates: 

155 <style> 

156 body { font-family: [[ font_family('primary') ]]; } 

157 h1 { font-family: [[ font_family('heading') ]]; } 

158 </style> 

159 """ 

160 fonts = depends.get("fonts") 

161 if fonts: 

162 result = fonts.get_font_family(font_type) 

163 return str(result) if result is not None else "inherit" 

164 

165 # Fallback fonts 

166 fallbacks = { 

167 "primary": "-apple-system, BlinkMacSystemFont, sans-serif", 

168 "secondary": "Georgia, serif", 

169 "heading": "-apple-system, BlinkMacSystemFont, sans-serif", 

170 "body": "-apple-system, BlinkMacSystemFont, sans-serif", 

171 "monospace": "'Courier New', monospace", 

172 } 

173 return fallbacks.get(font_type, "inherit") 

174 

175 

176def stylesheet_links() -> str: 

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

178 

179 Usage in templates: 

180 [% block head %] 

181 [[ stylesheet_links() ]] 

182 [% endblock %] 

183 """ 

184 links = [] 

185 

186 # Get style framework links 

187 styles = depends.get("styles") 

188 if styles: 

189 links.extend(styles.get_stylesheet_links()) 

190 

191 # Get icon framework links 

192 icons = depends.get("icons") 

193 if icons and hasattr(icons, "get_stylesheet_links"): 

194 links.extend(icons.get_stylesheet_links()) 

195 

196 return "\n".join(links) 

197 

198 

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

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

201 

202 Usage in templates: 

203 [[ component_html('button', 'Click Me', variant='primary', class='my-btn') ]] 

204 """ 

205 styles = depends.get("styles") 

206 if styles and hasattr(styles, "build_component_html"): 

207 result = styles.build_component_html(component, content, **attributes) 

208 return ( 

209 str(result) 

210 if result is not None 

211 else f'<div class="{component}">{content}</div>' 

212 ) 

213 

214 # Fallback to basic HTML 

215 css_class = style_class(component) 

216 if "class" in attributes: 

217 css_class = f"{css_class} {attributes.pop('class')}" 

218 

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

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

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

222 

223 attrs_str = " ".join(attr_parts) 

224 

225 if component.startswith("button"): 

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

227 else: 

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

229 

230 

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

232 """Generate HTMX attributes for enhanced interactivity. 

233 

234 Usage in templates: 

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

236 Load Data 

237 </button> 

238 """ 

239 attr_parts = [] 

240 

241 # Map common HTMX attributes with enhanced support 

242 attr_mapping = { 

243 "get": "hx-get", 

244 "post": "hx-post", 

245 "put": "hx-put", 

246 "delete": "hx-delete", 

247 "patch": "hx-patch", 

248 "target": "hx-target", 

249 "swap": "hx-swap", 

250 "trigger": "hx-trigger", 

251 "indicator": "hx-indicator", 

252 "confirm": "hx-confirm", 

253 "vals": "hx-vals", 

254 "headers": "hx-headers", 

255 "include": "hx-include", 

256 "params": "hx-params", 

257 "boost": "hx-boost", 

258 "push_url": "hx-push-url", 

259 "replace_url": "hx-replace-url", 

260 "ext": "hx-ext", 

261 "select": "hx-select", 

262 "select_oob": "hx-select-oob", 

263 "sync": "hx-sync", 

264 "history": "hx-history", 

265 "disabled_elt": "hx-disabled-elt", 

266 "encoding": "hx-encoding", 

267 "preserve": "hx-preserve", 

268 } 

269 

270 for key, value in htmx_attributes.items(): 

271 htmx_attr = attr_mapping.get(key, f"hx-{key.replace('_', '-')}") 

272 attr_parts.append(f'{htmx_attr}="{value}"') 

273 

274 return " ".join(attr_parts) 

275 

276 

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

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

279 

280 Usage in templates: 

281 <div [[ htmx_component('card', get='/api/details/{id}', target='#details') ]]> 

282 [[ component_html('card-header', 'Title') ]] 

283 <div id="details"></div> 

284 </div> 

285 """ 

286 # Extract HTMX attributes 

287 htmx_attrs_dict = {} 

288 component_attrs = {} 

289 

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

291 if key in [ 

292 "get", 

293 "post", 

294 "put", 

295 "delete", 

296 "patch", 

297 "target", 

298 "swap", 

299 "trigger", 

300 "indicator", 

301 "confirm", 

302 "vals", 

303 "headers", 

304 "include", 

305 "params", 

306 "boost", 

307 "push_url", 

308 "replace_url", 

309 "ext", 

310 "select", 

311 "select_oob", 

312 "sync", 

313 "history", 

314 "disabled_elt", 

315 "encoding", 

316 "preserve", 

317 ]: 

318 htmx_attrs_dict[key] = value 

319 else: 

320 component_attrs[key] = value 

321 

322 # Get component styling from style adapter 

323 css_class = style_class(component_type, **component_attrs) 

324 

325 # Add HTMX attributes if any 

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

327 

328 # Build complete attribute string 

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

330 if htmx_str: 

331 attr_parts.append(htmx_str) 

332 

333 return " ".join(attr_parts) 

334 

335 

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

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

338 

339 Usage in templates: 

340 <form [[ htmx_form('/users/create', target='#form-container', 

341 validation_target='#form-errors') ]]> 

342 <!-- form fields --> 

343 </form> 

344 """ 

345 # Set default HTMX behavior for forms 

346 form_attrs = { 

347 "post": action, 

348 "swap": "outerHTML", 

349 "indicator": "#form-loading", 

350 **attributes, 

351 } 

352 

353 # Handle validation target if specified 

354 if "validation_target" in form_attrs: 

355 validation_target = form_attrs.pop("validation_target") 

356 form_attrs["headers"] = f'{{"HX-Error-Target": "{validation_target}"}}' 

357 

358 return htmx_attrs(**form_attrs) 

359 

360 

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

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

363 

364 Usage in templates: 

365 <div [[ htmx_lazy_load('/api/content', 'Loading content...', 

366 trigger='revealed once') ]]> 

367 </div> 

368 """ 

369 lazy_attrs = { 

370 "get": url, 

371 "trigger": "revealed once", 

372 "indicator": "this", 

373 **attributes, 

374 } 

375 

376 attrs_str = htmx_attrs(**lazy_attrs) 

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

378 

379 

380def htmx_infinite_scroll( 

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

382) -> str: 

383 """Generate infinite scroll triggers. 

384 

385 Usage in templates: 

386 <div [[ htmx_infinite_scroll('/api/posts?page=2', '#posts-container') ]]> 

387 Loading more posts... 

388 </div> 

389 """ 

390 scroll_attrs = { 

391 "get": next_url, 

392 "trigger": "revealed", 

393 "target": container, 

394 "swap": "afterend", 

395 **attributes, 

396 } 

397 

398 return htmx_attrs(**scroll_attrs) 

399 

400 

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

402 """Generate debounced search inputs. 

403 

404 Usage in templates: 

405 <input type="text" name="q" 

406 [[ htmx_search('/api/search', 500, target='#results') ]]> 

407 """ 

408 search_attrs = { 

409 "get": endpoint, 

410 "trigger": f"keyup changed delay:{debounce}ms", 

411 "target": "#search-results", 

412 "indicator": "#search-loading", 

413 **attributes, 

414 } 

415 

416 return htmx_attrs(**search_attrs) 

417 

418 

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

420 """Create modal dialog triggers. 

421 

422 Usage in templates: 

423 <button [[ htmx_modal('/modal/user/{id}', target='#modal-container') ]]> 

424 View Details 

425 </button> 

426 """ 

427 modal_attrs = { 

428 "get": content_url, 

429 "target": "#modal-container", 

430 "swap": "innerHTML", 

431 **attributes, 

432 } 

433 

434 return htmx_attrs(**modal_attrs) 

435 

436 

437def htmx_img_swap( 

438 image_id: str, transformations: dict[str, Any] | None = None, **attributes: Any 

439) -> str: 

440 """Dynamic image swapping with transformations using image adapter. 

441 

442 Usage in templates: 

443 <img [[ htmx_img_swap('product.jpg', {'width': 300}, 

444 trigger='mouseenter once', target='this') ]]> 

445 """ 

446 images = depends.get("images") 

447 if not images: 

448 return htmx_attrs(**attributes) 

449 

450 # Build transformation URL 

451 if transformations: 

452 # This would typically be handled by the image adapter 

453 transform_url = f"/api/images/{image_id}/transform" 

454 swap_attrs = { 

455 "get": transform_url, 

456 "vals": str(transformations), 

457 "target": "this", 

458 "swap": "outerHTML", 

459 **attributes, 

460 } 

461 else: 

462 swap_attrs = { 

463 "get": f"/api/images/{image_id}", 

464 "target": "this", 

465 "swap": "outerHTML", 

466 **attributes, 

467 } 

468 

469 return htmx_attrs(**swap_attrs) 

470 

471 

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

473 """Icon state toggles for interactive elements. 

474 

475 Usage in templates: 

476 <button [[ htmx_icon_toggle('heart-filled', 'heart-outline', 

477 post='/favorites/toggle/{id}') ]]> 

478 [[ icon_tag('heart-outline') ]] 

479 </button> 

480 """ 

481 toggle_attrs = {"swap": "outerHTML", "target": "this", **attributes} 

482 

483 # Add data attributes for icon states 

484 attrs_str = htmx_attrs(**toggle_attrs) 

485 return f'{attrs_str} data-icon-on="{icon_on}" data-icon-off="{icon_off}"' 

486 

487 

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

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

490 

491 Usage in templates: 

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

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

494 </div> 

495 """ 

496 ws_attrs = {"ext": "ws", **attributes} 

497 

498 # Handle WebSocket-specific attributes 

499 if "listen" in ws_attrs: 

500 listen_event = ws_attrs.pop("listen") 

501 attrs_str = htmx_attrs(**ws_attrs) 

502 return f'{attrs_str} ws-connect="{endpoint}" sse-listen="{listen_event}"' 

503 else: 

504 attrs_str = htmx_attrs(**ws_attrs) 

505 return f'{attrs_str} ws-connect="{endpoint}"' 

506 

507 

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

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

510 

511 Usage in templates: 

512 <input name="email" 

513 [[ htmx_validation_feedback('email', 

514 validate_url='/validate/email') ]]> 

515 """ 

516 validate_url = attributes.pop("validate_url", f"/validate/{field_name}") 

517 

518 validation_attrs = { 

519 "get": validate_url, 

520 "trigger": "blur, keyup changed delay:500ms", 

521 "target": f"#{field_name}-feedback", 

522 "include": "this", 

523 **attributes, 

524 } 

525 

526 return htmx_attrs(**validation_attrs) 

527 

528 

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

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

531 

532 Usage in templates: 

533 <div [[ htmx_error_container('form-errors') ]]></div> 

534 """ 

535 return f'id="{container_id}" class="htmx-error-container" role="alert"' 

536 

537 

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

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

540 

541 Usage in templates: 

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

543 """ 

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

545 

546 

547# Filter registration mapping for template engines 

548FASTBLOCKS_FILTERS = { 

549 "img_tag": img_tag, 

550 "image_url": image_url, 

551 "style_class": style_class, 

552 "icon_tag": icon_tag, 

553 "icon_with_text": icon_with_text, 

554 "font_import": font_import, 

555 "font_family": font_family, 

556 "stylesheet_links": stylesheet_links, 

557 "component_html": component_html, 

558 "htmx_attrs": htmx_attrs, 

559 "htmx_component": htmx_component, 

560 "htmx_form": htmx_form, 

561 "htmx_lazy_load": htmx_lazy_load, 

562 "htmx_infinite_scroll": htmx_infinite_scroll, 

563 "htmx_search": htmx_search, 

564 "htmx_modal": htmx_modal, 

565 "htmx_img_swap": htmx_img_swap, 

566 "htmx_icon_toggle": htmx_icon_toggle, 

567 "htmx_ws_connect": htmx_ws_connect, 

568 "htmx_validation_feedback": htmx_validation_feedback, 

569 "htmx_error_container": htmx_error_container, 

570 "htmx_retry_trigger": htmx_retry_trigger, 

571}