acton_htmx/template/helpers.rs
1//! Template helper functions for HTMX applications
2//!
3//! Provides utility functions that can be used within Askama templates
4//! for common HTMX patterns.
5//!
6//! # HTMX Attribute Helpers
7//!
8//! These helpers generate proper HTMX attributes for common operations:
9//!
10//! ```rust
11//! use acton_htmx::template::helpers::*;
12//!
13//! // Generate HTMX POST form attributes
14//! let attrs = hx_post("/api/items", "#item-list", "innerHTML");
15//! // Result: hx-post="/api/items" hx-target="#item-list" hx-swap="innerHTML"
16//! ```
17
18use crate::auth::session::FlashMessage;
19use crate::template::FrameworkTemplates;
20use std::sync::OnceLock;
21
22/// Get or initialize the framework templates (lazy singleton)
23fn templates() -> &'static FrameworkTemplates {
24 static TEMPLATES: OnceLock<FrameworkTemplates> = OnceLock::new();
25 TEMPLATES.get_or_init(|| FrameworkTemplates::new().expect("Failed to initialize templates"))
26}
27
28/// Generate CSRF token input field
29///
30/// **DEPRECATED**: Use `csrf_token_with(token)` instead, passing the token from your template context.
31///
32/// This function returns a placeholder value and should not be used in production.
33/// Extract the CSRF token in your handler using the `CsrfToken` extractor and pass it
34/// to your template context, then use `csrf_token_with(token)` in your template.
35///
36/// # Example
37///
38/// ```rust,ignore
39/// use acton_htmx::middleware::CsrfToken;
40/// use acton_htmx::template::helpers::csrf_token_with;
41///
42/// async fn handler(CsrfToken(token): CsrfToken) -> impl IntoResponse {
43/// // Pass token to template context
44/// MyTemplate { csrf_token: token }
45/// }
46/// ```
47///
48/// In your template:
49/// ```html
50/// {{ csrf_token_with(csrf_token) }}
51/// ```
52#[deprecated(since = "1.1.0", note = "Use csrf_token_with(token) instead, passing token from CsrfToken extractor")]
53#[must_use]
54pub fn csrf_token() -> String {
55 r#"<input type="hidden" name="_csrf_token" value="placeholder">"#.to_string()
56}
57
58/// Generate CSRF token input field with a specific token value
59///
60/// Used when the token is provided from the session.
61///
62/// # Panics
63///
64/// Panics if the CSRF template cannot be rendered. Ensure templates are
65/// initialized via `acton-htmx templates init` before using this function.
66#[must_use]
67pub fn csrf_token_with(token: &str) -> String {
68 templates()
69 .render("forms/csrf-input.html", minijinja::context! { token => token })
70 .expect("Failed to render CSRF token template - run `acton-htmx templates init`")
71}
72
73/// Render flash messages as HTML
74///
75/// Renders a collection of flash messages with appropriate styling and ARIA attributes.
76/// Each message is rendered with its level-specific CSS class and can include an optional title.
77///
78/// The generated HTML includes:
79/// - Container div with class `flash-messages`
80/// - Individual message divs with level-specific classes (`flash-success`, `flash-info`, etc.)
81/// - ARIA role and live region attributes for accessibility
82/// - Optional title in a `<strong>` tag
83/// - Message text in a `<span>` tag
84///
85/// # Examples
86///
87/// ```rust
88/// use acton_htmx::auth::session::FlashMessage;
89/// use acton_htmx::template::helpers::flash_messages;
90///
91/// let messages = vec![
92/// FlashMessage::success("Profile updated successfully"),
93/// FlashMessage::error("Invalid email address"),
94/// ];
95///
96/// let html = flash_messages(&messages);
97/// assert!(html.contains("flash-success"));
98/// assert!(html.contains("Profile updated"));
99/// ```
100///
101/// In your Askama templates:
102/// ```html
103/// <!-- Extract flash messages in handler -->
104/// {{ flash_messages(messages) }}
105/// ```
106///
107/// # Usage with `FlashExtractor`
108///
109/// ```rust,ignore
110/// use acton_htmx::extractors::FlashExtractor;
111/// use acton_htmx::template::helpers::flash_messages;
112///
113/// async fn handler(FlashExtractor(messages): FlashExtractor) -> impl IntoResponse {
114/// MyTemplate { flash_html: flash_messages(&messages) }
115/// }
116/// ```
117///
118/// # Panics
119///
120/// Panics if the flash messages template cannot be rendered. Ensure templates are
121/// initialized via `acton-htmx templates init` before using this function.
122#[must_use]
123pub fn flash_messages(messages: &[FlashMessage]) -> String {
124 if messages.is_empty() {
125 return String::new();
126 }
127
128 // Convert to serializable format for template
129 let msgs: Vec<_> = messages
130 .iter()
131 .map(|m| {
132 minijinja::context! {
133 css_class => m.css_class(),
134 title => m.title.as_deref(),
135 message => &m.message,
136 }
137 })
138 .collect();
139
140 templates()
141 .render(
142 "flash/container.html",
143 minijinja::context! {
144 container_class => "flash-messages",
145 messages => msgs,
146 },
147 )
148 .expect("Failed to render flash messages template - run `acton-htmx templates init`")
149}
150
151// Note: The route() helper has been removed as named routes are not currently implemented.
152// Use hardcoded paths in your templates instead:
153// href="/posts/{{ post.id }}"
154// If named routes are needed in the future, they can be implemented in Phase 3.
155
156/// Generate asset URL with cache busting
157///
158/// **Note**: Currently returns the path as-is without cache busting.
159/// Cache busting implementation is deferred to Phase 3.
160///
161/// # Current Behavior
162///
163/// Simply returns the provided path unchanged:
164/// ```rust
165/// use acton_htmx::template::helpers::asset;
166///
167/// assert_eq!(asset("/css/styles.css"), "/css/styles.css");
168/// ```
169///
170/// # Recommended Production Approach
171///
172/// Until cache busting is implemented, use one of these strategies:
173///
174/// 1. **CDN with query string versioning**: Append a version parameter to assets
175/// ```html
176/// <link rel="stylesheet" href="{{ asset("/css/styles.css") }}?v=1.2.3">
177/// ```
178///
179/// 2. **Filename-based versioning**: Include version in filename during build
180/// ```html
181/// <link rel="stylesheet" href="/css/styles.v1.2.3.css">
182/// ```
183///
184/// 3. **HTTP Cache-Control headers**: Configure your static file server with proper caching headers
185/// ```
186/// Cache-Control: public, max-age=31536000, immutable
187/// ```
188///
189/// # Future Implementation (Phase 3)
190///
191/// When implemented, this helper will:
192/// - Read a manifest file (e.g., `mix-manifest.json`) generated during build
193/// - Map logical paths to versioned paths (e.g., `/css/app.css` → `/css/app.abc123.css`)
194/// - Support both filename hashing and query string approaches
195///
196/// Usage in templates: `{{ asset("/css/styles.css") }}`
197#[must_use]
198pub fn asset(path: &str) -> String {
199 path.to_string()
200}
201
202// =============================================================================
203// HTMX Attribute Helpers
204// =============================================================================
205
206/// Generate hx-post attribute with optional target and swap
207///
208/// # Examples
209///
210/// ```rust
211/// use acton_htmx::template::helpers::hx_post;
212///
213/// let attrs = hx_post("/api/items", "#list", "innerHTML");
214/// assert!(attrs.contains(r#"hx-post="/api/items""#));
215/// ```
216#[must_use]
217pub fn hx_post(url: &str, target: &str, swap: &str) -> String {
218 format!(r#"hx-post="{url}" hx-target="{target}" hx-swap="{swap}""#)
219}
220
221/// Generate hx-get attribute with optional target and swap
222#[must_use]
223pub fn hx_get(url: &str, target: &str, swap: &str) -> String {
224 format!(r#"hx-get="{url}" hx-target="{target}" hx-swap="{swap}""#)
225}
226
227/// Generate hx-put attribute with optional target and swap
228#[must_use]
229pub fn hx_put(url: &str, target: &str, swap: &str) -> String {
230 format!(r#"hx-put="{url}" hx-target="{target}" hx-swap="{swap}""#)
231}
232
233/// Generate hx-delete attribute with optional target and swap
234#[must_use]
235pub fn hx_delete(url: &str, target: &str, swap: &str) -> String {
236 format!(r#"hx-delete="{url}" hx-target="{target}" hx-swap="{swap}""#)
237}
238
239/// Generate hx-patch attribute with optional target and swap
240#[must_use]
241pub fn hx_patch(url: &str, target: &str, swap: &str) -> String {
242 format!(r#"hx-patch="{url}" hx-target="{target}" hx-swap="{swap}""#)
243}
244
245/// Generate hx-trigger attribute
246///
247/// # Examples
248///
249/// ```rust
250/// use acton_htmx::template::helpers::hx_trigger;
251///
252/// let attr = hx_trigger("click");
253/// assert_eq!(attr, r#"hx-trigger="click""#);
254///
255/// let attr = hx_trigger("keyup changed delay:500ms");
256/// assert!(attr.contains("keyup"));
257/// ```
258#[must_use]
259pub fn hx_trigger(trigger: &str) -> String {
260 format!(r#"hx-trigger="{trigger}""#)
261}
262
263/// Generate hx-swap attribute
264#[must_use]
265pub fn hx_swap(strategy: &str) -> String {
266 format!(r#"hx-swap="{strategy}""#)
267}
268
269/// Generate hx-target attribute
270#[must_use]
271pub fn hx_target(selector: &str) -> String {
272 format!(r#"hx-target="{selector}""#)
273}
274
275/// Generate hx-indicator attribute for loading state
276#[must_use]
277pub fn hx_indicator(selector: &str) -> String {
278 format!(r#"hx-indicator="{selector}""#)
279}
280
281/// Generate hx-confirm attribute for confirmation dialogs
282#[must_use]
283pub fn hx_confirm(message: &str) -> String {
284 format!(r#"hx-confirm="{message}""#)
285}
286
287/// Generate hx-vals attribute for additional values
288///
289/// # Examples
290///
291/// ```rust
292/// use acton_htmx::template::helpers::hx_vals;
293///
294/// let attr = hx_vals(r#"{"key": "value"}"#);
295/// assert!(attr.contains("hx-vals"));
296/// ```
297#[must_use]
298pub fn hx_vals(json: &str) -> String {
299 format!(r"hx-vals='{json}'")
300}
301
302/// Generate hx-headers attribute for additional headers
303#[must_use]
304pub fn hx_headers(json: &str) -> String {
305 format!(r"hx-headers='{json}'")
306}
307
308/// Generate hx-push-url attribute
309#[must_use]
310pub fn hx_push_url(url: &str) -> String {
311 format!(r#"hx-push-url="{url}""#)
312}
313
314/// Generate hx-select attribute for partial selection
315#[must_use]
316pub fn hx_select(selector: &str) -> String {
317 format!(r#"hx-select="{selector}""#)
318}
319
320/// Generate hx-select-oob attribute for out-of-band selection
321#[must_use]
322pub fn hx_select_oob(selector: &str) -> String {
323 format!(r#"hx-select-oob="{selector}""#)
324}
325
326/// Generate hx-boost="true" for progressively enhanced links
327#[must_use]
328pub const fn hx_boost() -> &'static str {
329 r#"hx-boost="true""#
330}
331
332/// Generate hx-disabled-elt attribute for disabling elements during request
333#[must_use]
334pub fn hx_disabled_elt(selector: &str) -> String {
335 format!(r#"hx-disabled-elt="{selector}""#)
336}
337
338// =============================================================================
339// HTML Safe Output
340// =============================================================================
341
342/// HTML-safe string wrapper
343///
344/// Marks a string as safe for direct HTML output (already escaped).
345/// Use this when you have pre-escaped HTML that shouldn't be double-escaped.
346#[derive(Debug, Clone)]
347pub struct SafeString(pub String);
348
349impl SafeString {
350 /// Create a new `SafeString`
351 #[must_use]
352 pub fn new(s: impl Into<String>) -> Self {
353 Self(s.into())
354 }
355}
356
357impl std::fmt::Display for SafeString {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 write!(f, "{}", self.0)
360 }
361}
362
363impl From<String> for SafeString {
364 fn from(s: String) -> Self {
365 Self(s)
366 }
367}
368
369impl From<&str> for SafeString {
370 fn from(s: &str) -> Self {
371 Self(s.to_string())
372 }
373}
374
375// =============================================================================
376// HTML Escaping Utilities
377// =============================================================================
378
379/// Escape a string for safe use in HTML content
380///
381/// Escapes special HTML characters to prevent XSS attacks.
382/// This is used internally by helpers that generate HTML.
383///
384/// # Examples
385///
386/// ```rust
387/// use acton_htmx::template::helpers::escape_html;
388///
389/// assert_eq!(escape_html("<script>alert('xss')</script>"),
390/// "<script>alert('xss')</script>");
391/// assert_eq!(escape_html("Hello & goodbye"), "Hello & goodbye");
392/// ```
393#[must_use]
394pub fn escape_html(s: &str) -> String {
395 s.replace('&', "&")
396 .replace('<', "<")
397 .replace('>', ">")
398}
399
400// =============================================================================
401// Validation Error Helpers
402// =============================================================================
403
404/// Render validation errors for a specific field
405///
406/// Returns HTML markup for displaying validation errors next to form fields.
407///
408/// # Examples
409///
410/// ```rust
411/// use acton_htmx::template::helpers::validation_errors_for;
412/// use validator::ValidationErrors;
413///
414/// let errors = ValidationErrors::new();
415/// let html = validation_errors_for(&errors, "email");
416/// ```
417///
418/// # Panics
419///
420/// Panics if the field errors template cannot be rendered. Ensure templates are
421/// initialized via `acton-htmx templates init` before using this function.
422#[must_use]
423pub fn validation_errors_for(errors: &validator::ValidationErrors, field: &str) -> String {
424 errors.field_errors().get(field).map_or_else(String::new, |field_errors| {
425 let error_messages: Vec<String> = field_errors
426 .iter()
427 .map(|error| {
428 error.message.as_ref().map_or_else(
429 || format!("{field}: {}", error.code),
430 ToString::to_string,
431 )
432 })
433 .collect();
434
435 templates()
436 .render(
437 "validation/field-errors.html",
438 minijinja::context! {
439 container_class => "field-errors",
440 error_class => "error",
441 errors => error_messages,
442 },
443 )
444 .expect("Failed to render field errors template - run `acton-htmx templates init`")
445 })
446}
447
448/// Check if a field has validation errors
449///
450/// Useful for conditionally applying error classes in templates.
451///
452/// # Examples
453///
454/// ```rust
455/// use acton_htmx::template::helpers::has_error;
456/// use validator::ValidationErrors;
457///
458/// let errors = ValidationErrors::new();
459/// let has_err = has_error(&errors, "email");
460/// assert!(!has_err);
461/// ```
462#[must_use]
463pub fn has_error(errors: &validator::ValidationErrors, field: &str) -> bool {
464 errors.field_errors().contains_key(field)
465}
466
467/// Get error class string if field has errors
468///
469/// Returns " error" or " is-invalid" if the field has errors, empty string otherwise.
470/// Useful for conditionally applying CSS classes.
471///
472/// # Examples
473///
474/// ```rust
475/// use acton_htmx::template::helpers::error_class;
476/// use validator::ValidationErrors;
477///
478/// let mut errors = ValidationErrors::new();
479/// errors.add("email", validator::ValidationError::new("email"));
480///
481/// let class = error_class(&errors, "email");
482/// assert_eq!(class, " error");
483/// ```
484#[must_use]
485pub fn error_class(errors: &validator::ValidationErrors, field: &str) -> &'static str {
486 if has_error(errors, field) {
487 " error"
488 } else {
489 ""
490 }
491}
492
493/// Render all validation errors as an unordered list
494///
495/// Useful for displaying all errors at the top of a form.
496///
497/// # Examples
498///
499/// ```rust
500/// use acton_htmx::template::helpers::validation_errors_list;
501/// use validator::ValidationErrors;
502///
503/// let errors = ValidationErrors::new();
504/// let html = validation_errors_list(&errors);
505/// ```
506///
507/// # Panics
508///
509/// Panics if the validation summary template cannot be rendered. Ensure templates are
510/// initialized via `acton-htmx templates init` before using this function.
511#[must_use]
512pub fn validation_errors_list(errors: &validator::ValidationErrors) -> String {
513 if errors.is_empty() {
514 return String::new();
515 }
516
517 let error_messages: Vec<String> = errors
518 .field_errors()
519 .iter()
520 .flat_map(|(field, field_errors)| {
521 field_errors.iter().map(move |error| {
522 error.message.as_ref().map_or_else(
523 || format!("{field}: {}", error.code),
524 ToString::to_string,
525 )
526 })
527 })
528 .collect();
529
530 templates()
531 .render(
532 "validation/validation-summary.html",
533 minijinja::context! {
534 container_class => "validation-errors",
535 errors => error_messages,
536 },
537 )
538 .expect("Failed to render validation summary template - run `acton-htmx templates init`")
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 #[allow(deprecated)]
547 fn test_csrf_token() {
548 let token = csrf_token();
549 assert!(token.contains("_csrf_token"));
550 assert!(token.contains("hidden"));
551 }
552
553 #[test]
554 fn test_csrf_token_with_value() {
555 let token = csrf_token_with("abc123");
556 assert!(token.contains(r#"value="abc123""#));
557 }
558
559 #[test]
560 fn test_asset() {
561 let path = asset("/css/styles.css");
562 assert_eq!(path, "/css/styles.css");
563 }
564
565 #[test]
566 fn test_hx_post() {
567 let attrs = hx_post("/api/items", "#list", "innerHTML");
568 assert!(attrs.contains("hx-post=\"/api/items\""));
569 assert!(attrs.contains("hx-target=\"#list\""));
570 assert!(attrs.contains("hx-swap=\"innerHTML\""));
571 }
572
573 #[test]
574 fn test_hx_get() {
575 let attrs = hx_get("/search", "#results", "outerHTML");
576 assert!(attrs.contains(r#"hx-get="/search""#));
577 }
578
579 #[test]
580 fn test_hx_trigger() {
581 let attr = hx_trigger("click");
582 assert_eq!(attr, r#"hx-trigger="click""#);
583 }
584
585 #[test]
586 fn test_hx_confirm() {
587 let attr = hx_confirm("Are you sure?");
588 assert!(attr.contains("Are you sure?"));
589 }
590
591 #[test]
592 fn test_hx_boost() {
593 assert_eq!(hx_boost(), r#"hx-boost="true""#);
594 }
595
596 #[test]
597 fn test_safe_string() {
598 let safe = SafeString::new("<p>Hello</p>");
599 assert_eq!(format!("{safe}"), "<p>Hello</p>");
600 }
601
602 #[test]
603 fn test_safe_string_from() {
604 let safe: SafeString = "test".into();
605 assert_eq!(safe.0, "test");
606 }
607
608 #[test]
609 fn test_validation_errors_for() {
610 let mut errors = validator::ValidationErrors::new();
611 errors.add(
612 "email",
613 validator::ValidationError::new("email")
614 .with_message(std::borrow::Cow::Borrowed("Invalid email")),
615 );
616
617 let html = validation_errors_for(&errors, "email");
618 assert!(html.contains("Invalid email"));
619 assert!(html.contains("field-errors"));
620 }
621
622 #[test]
623 fn test_validation_errors_for_no_errors() {
624 let errors = validator::ValidationErrors::new();
625 let html = validation_errors_for(&errors, "email");
626 assert!(html.is_empty());
627 }
628
629 #[test]
630 fn test_has_error() {
631 let mut errors = validator::ValidationErrors::new();
632 errors.add("email", validator::ValidationError::new("email"));
633
634 assert!(has_error(&errors, "email"));
635 assert!(!has_error(&errors, "password"));
636 }
637
638 #[test]
639 fn test_error_class() {
640 let mut errors = validator::ValidationErrors::new();
641 errors.add("email", validator::ValidationError::new("email"));
642
643 assert_eq!(error_class(&errors, "email"), " error");
644 assert_eq!(error_class(&errors, "password"), "");
645 }
646
647 #[test]
648 fn test_validation_errors_list() {
649 let mut errors = validator::ValidationErrors::new();
650 errors.add(
651 "email",
652 validator::ValidationError::new("email")
653 .with_message(std::borrow::Cow::Borrowed("Invalid email")),
654 );
655 errors.add(
656 "password",
657 validator::ValidationError::new("length")
658 .with_message(std::borrow::Cow::Borrowed("Too short")),
659 );
660
661 let html = validation_errors_list(&errors);
662 assert!(html.contains("Invalid email"));
663 assert!(html.contains("Too short"));
664 assert!(html.contains("<ul>"));
665 }
666
667 #[test]
668 fn test_validation_errors_list_empty() {
669 let errors = validator::ValidationErrors::new();
670 let html = validation_errors_list(&errors);
671 assert!(html.is_empty());
672 }
673
674 #[test]
675 fn test_flash_messages_empty() {
676 let messages: Vec<FlashMessage> = vec![];
677 let html = flash_messages(&messages);
678 assert!(html.is_empty());
679 }
680
681 #[test]
682 fn test_flash_messages_single() {
683 use crate::auth::session::FlashMessage;
684
685 let messages = vec![FlashMessage::success("Operation successful")];
686 let html = flash_messages(&messages);
687
688 assert!(html.contains("flash-messages"));
689 assert!(html.contains("flash-success"));
690 assert!(html.contains("Operation successful"));
691 assert!(html.contains("role=\"alert\""));
692 assert!(html.contains("role=\"status\""));
693 }
694
695 #[test]
696 fn test_flash_messages_multiple_levels() {
697 use crate::auth::session::FlashMessage;
698
699 let messages = vec![
700 FlashMessage::success("Success message"),
701 FlashMessage::error("Error message"),
702 FlashMessage::warning("Warning message"),
703 FlashMessage::info("Info message"),
704 ];
705 let html = flash_messages(&messages);
706
707 assert!(html.contains("flash-success"));
708 assert!(html.contains("flash-error"));
709 assert!(html.contains("flash-warning"));
710 assert!(html.contains("flash-info"));
711 assert!(html.contains("Success message"));
712 assert!(html.contains("Error message"));
713 assert!(html.contains("Warning message"));
714 assert!(html.contains("Info message"));
715 }
716
717 #[test]
718 fn test_flash_messages_with_title() {
719 use crate::auth::session::FlashMessage;
720
721 let messages = vec![
722 FlashMessage::success("Message text").with_title("Success!"),
723 ];
724 let html = flash_messages(&messages);
725
726 assert!(html.contains("<strong>Success!</strong>"));
727 assert!(html.contains("Message text"));
728 }
729
730 #[test]
731 fn test_flash_messages_xss_protection() {
732 use crate::auth::session::FlashMessage;
733
734 let messages = vec![
735 FlashMessage::error("<script>alert('xss')</script>"),
736 ];
737 let html = flash_messages(&messages);
738
739 // Should be escaped
740 assert!(html.contains("<script>"));
741 assert!(!html.contains("<script>"));
742 }
743
744 #[test]
745 fn test_escape_html() {
746 assert_eq!(escape_html("Hello, world!"), "Hello, world!");
747 assert_eq!(escape_html("<script>"), "<script>");
748 assert_eq!(escape_html("A & B"), "A & B");
749 assert_eq!(escape_html("<div>content</div>"), "<div>content</div>");
750 assert_eq!(
751 escape_html("<script>alert('xss')</script>"),
752 "<script>alert('xss')</script>"
753 );
754 }
755
756 #[test]
757 fn test_escape_html_preserves_safe_chars() {
758 assert_eq!(escape_html("Hello 123 !@#$%^*()_+-=[]{}|;:',./? "),
759 "Hello 123 !@#$%^*()_+-=[]{}|;:',./? ");
760 }
761}