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///            "&lt;script&gt;alert('xss')&lt;/script&gt;");
391/// assert_eq!(escape_html("Hello & goodbye"), "Hello &amp; goodbye");
392/// ```
393#[must_use]
394pub fn escape_html(s: &str) -> String {
395    s.replace('&', "&amp;")
396        .replace('<', "&lt;")
397        .replace('>', "&gt;")
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("&lt;script&gt;"));
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>"), "&lt;script&gt;");
748        assert_eq!(escape_html("A & B"), "A &amp; B");
749        assert_eq!(escape_html("<div>content</div>"), "&lt;div&gt;content&lt;/div&gt;");
750        assert_eq!(
751            escape_html("<script>alert('xss')</script>"),
752            "&lt;script&gt;alert('xss')&lt;/script&gt;"
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}