use crate::auth::session::FlashMessage;
use crate::template::FrameworkTemplates;
use std::sync::OnceLock;
fn templates() -> &'static FrameworkTemplates {
static TEMPLATES: OnceLock<FrameworkTemplates> = OnceLock::new();
TEMPLATES.get_or_init(|| FrameworkTemplates::new().expect("Failed to initialize templates"))
}
#[deprecated(since = "1.1.0", note = "Use csrf_token_with(token) instead, passing token from CsrfToken extractor")]
#[must_use]
pub fn csrf_token() -> String {
r#"<input type="hidden" name="_csrf_token" value="placeholder">"#.to_string()
}
#[must_use]
pub fn csrf_token_with(token: &str) -> String {
templates()
.render("forms/csrf-input.html", minijinja::context! { token => token })
.expect("Failed to render CSRF token template - run `acton-htmx templates init`")
}
#[must_use]
pub fn flash_messages(messages: &[FlashMessage]) -> String {
if messages.is_empty() {
return String::new();
}
let msgs: Vec<_> = messages
.iter()
.map(|m| {
minijinja::context! {
css_class => m.css_class(),
title => m.title.as_deref(),
message => &m.message,
}
})
.collect();
templates()
.render(
"flash/container.html",
minijinja::context! {
container_class => "flash-messages",
messages => msgs,
},
)
.expect("Failed to render flash messages template - run `acton-htmx templates init`")
}
#[must_use]
pub fn asset(path: &str) -> String {
path.to_string()
}
#[must_use]
pub fn hx_post(url: &str, target: &str, swap: &str) -> String {
format!(r#"hx-post="{url}" hx-target="{target}" hx-swap="{swap}""#)
}
#[must_use]
pub fn hx_get(url: &str, target: &str, swap: &str) -> String {
format!(r#"hx-get="{url}" hx-target="{target}" hx-swap="{swap}""#)
}
#[must_use]
pub fn hx_put(url: &str, target: &str, swap: &str) -> String {
format!(r#"hx-put="{url}" hx-target="{target}" hx-swap="{swap}""#)
}
#[must_use]
pub fn hx_delete(url: &str, target: &str, swap: &str) -> String {
format!(r#"hx-delete="{url}" hx-target="{target}" hx-swap="{swap}""#)
}
#[must_use]
pub fn hx_patch(url: &str, target: &str, swap: &str) -> String {
format!(r#"hx-patch="{url}" hx-target="{target}" hx-swap="{swap}""#)
}
#[must_use]
pub fn hx_trigger(trigger: &str) -> String {
format!(r#"hx-trigger="{trigger}""#)
}
#[must_use]
pub fn hx_swap(strategy: &str) -> String {
format!(r#"hx-swap="{strategy}""#)
}
#[must_use]
pub fn hx_target(selector: &str) -> String {
format!(r#"hx-target="{selector}""#)
}
#[must_use]
pub fn hx_indicator(selector: &str) -> String {
format!(r#"hx-indicator="{selector}""#)
}
#[must_use]
pub fn hx_confirm(message: &str) -> String {
format!(r#"hx-confirm="{message}""#)
}
#[must_use]
pub fn hx_vals(json: &str) -> String {
format!(r"hx-vals='{json}'")
}
#[must_use]
pub fn hx_headers(json: &str) -> String {
format!(r"hx-headers='{json}'")
}
#[must_use]
pub fn hx_push_url(url: &str) -> String {
format!(r#"hx-push-url="{url}""#)
}
#[must_use]
pub fn hx_select(selector: &str) -> String {
format!(r#"hx-select="{selector}""#)
}
#[must_use]
pub fn hx_select_oob(selector: &str) -> String {
format!(r#"hx-select-oob="{selector}""#)
}
#[must_use]
pub const fn hx_boost() -> &'static str {
r#"hx-boost="true""#
}
#[must_use]
pub fn hx_disabled_elt(selector: &str) -> String {
format!(r#"hx-disabled-elt="{selector}""#)
}
#[derive(Debug, Clone)]
pub struct SafeString(pub String);
impl SafeString {
#[must_use]
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl std::fmt::Display for SafeString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for SafeString {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for SafeString {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
#[must_use]
pub fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[must_use]
pub fn validation_errors_for(errors: &validator::ValidationErrors, field: &str) -> String {
errors.field_errors().get(field).map_or_else(String::new, |field_errors| {
let error_messages: Vec<String> = field_errors
.iter()
.map(|error| {
error.message.as_ref().map_or_else(
|| format!("{field}: {}", error.code),
ToString::to_string,
)
})
.collect();
templates()
.render(
"validation/field-errors.html",
minijinja::context! {
container_class => "field-errors",
error_class => "error",
errors => error_messages,
},
)
.expect("Failed to render field errors template - run `acton-htmx templates init`")
})
}
#[must_use]
pub fn has_error(errors: &validator::ValidationErrors, field: &str) -> bool {
errors.field_errors().contains_key(field)
}
#[must_use]
pub fn error_class(errors: &validator::ValidationErrors, field: &str) -> &'static str {
if has_error(errors, field) {
" error"
} else {
""
}
}
#[must_use]
pub fn validation_errors_list(errors: &validator::ValidationErrors) -> String {
if errors.is_empty() {
return String::new();
}
let error_messages: Vec<String> = errors
.field_errors()
.iter()
.flat_map(|(field, field_errors)| {
field_errors.iter().map(move |error| {
error.message.as_ref().map_or_else(
|| format!("{field}: {}", error.code),
ToString::to_string,
)
})
})
.collect();
templates()
.render(
"validation/validation-summary.html",
minijinja::context! {
container_class => "validation-errors",
errors => error_messages,
},
)
.expect("Failed to render validation summary template - run `acton-htmx templates init`")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[allow(deprecated)]
fn test_csrf_token() {
let token = csrf_token();
assert!(token.contains("_csrf_token"));
assert!(token.contains("hidden"));
}
#[test]
fn test_csrf_token_with_value() {
let token = csrf_token_with("abc123");
assert!(token.contains(r#"value="abc123""#));
}
#[test]
fn test_asset() {
let path = asset("/css/styles.css");
assert_eq!(path, "/css/styles.css");
}
#[test]
fn test_hx_post() {
let attrs = hx_post("/api/items", "#list", "innerHTML");
assert!(attrs.contains("hx-post=\"/api/items\""));
assert!(attrs.contains("hx-target=\"#list\""));
assert!(attrs.contains("hx-swap=\"innerHTML\""));
}
#[test]
fn test_hx_get() {
let attrs = hx_get("/search", "#results", "outerHTML");
assert!(attrs.contains(r#"hx-get="/search""#));
}
#[test]
fn test_hx_trigger() {
let attr = hx_trigger("click");
assert_eq!(attr, r#"hx-trigger="click""#);
}
#[test]
fn test_hx_confirm() {
let attr = hx_confirm("Are you sure?");
assert!(attr.contains("Are you sure?"));
}
#[test]
fn test_hx_boost() {
assert_eq!(hx_boost(), r#"hx-boost="true""#);
}
#[test]
fn test_safe_string() {
let safe = SafeString::new("<p>Hello</p>");
assert_eq!(format!("{safe}"), "<p>Hello</p>");
}
#[test]
fn test_safe_string_from() {
let safe: SafeString = "test".into();
assert_eq!(safe.0, "test");
}
#[test]
fn test_validation_errors_for() {
let mut errors = validator::ValidationErrors::new();
errors.add(
"email",
validator::ValidationError::new("email")
.with_message(std::borrow::Cow::Borrowed("Invalid email")),
);
let html = validation_errors_for(&errors, "email");
assert!(html.contains("Invalid email"));
assert!(html.contains("field-errors"));
}
#[test]
fn test_validation_errors_for_no_errors() {
let errors = validator::ValidationErrors::new();
let html = validation_errors_for(&errors, "email");
assert!(html.is_empty());
}
#[test]
fn test_has_error() {
let mut errors = validator::ValidationErrors::new();
errors.add("email", validator::ValidationError::new("email"));
assert!(has_error(&errors, "email"));
assert!(!has_error(&errors, "password"));
}
#[test]
fn test_error_class() {
let mut errors = validator::ValidationErrors::new();
errors.add("email", validator::ValidationError::new("email"));
assert_eq!(error_class(&errors, "email"), " error");
assert_eq!(error_class(&errors, "password"), "");
}
#[test]
fn test_validation_errors_list() {
let mut errors = validator::ValidationErrors::new();
errors.add(
"email",
validator::ValidationError::new("email")
.with_message(std::borrow::Cow::Borrowed("Invalid email")),
);
errors.add(
"password",
validator::ValidationError::new("length")
.with_message(std::borrow::Cow::Borrowed("Too short")),
);
let html = validation_errors_list(&errors);
assert!(html.contains("Invalid email"));
assert!(html.contains("Too short"));
assert!(html.contains("<ul>"));
}
#[test]
fn test_validation_errors_list_empty() {
let errors = validator::ValidationErrors::new();
let html = validation_errors_list(&errors);
assert!(html.is_empty());
}
#[test]
fn test_flash_messages_empty() {
let messages: Vec<FlashMessage> = vec![];
let html = flash_messages(&messages);
assert!(html.is_empty());
}
#[test]
fn test_flash_messages_single() {
use crate::auth::session::FlashMessage;
let messages = vec![FlashMessage::success("Operation successful")];
let html = flash_messages(&messages);
assert!(html.contains("flash-messages"));
assert!(html.contains("flash-success"));
assert!(html.contains("Operation successful"));
assert!(html.contains("role=\"alert\""));
assert!(html.contains("role=\"status\""));
}
#[test]
fn test_flash_messages_multiple_levels() {
use crate::auth::session::FlashMessage;
let messages = vec![
FlashMessage::success("Success message"),
FlashMessage::error("Error message"),
FlashMessage::warning("Warning message"),
FlashMessage::info("Info message"),
];
let html = flash_messages(&messages);
assert!(html.contains("flash-success"));
assert!(html.contains("flash-error"));
assert!(html.contains("flash-warning"));
assert!(html.contains("flash-info"));
assert!(html.contains("Success message"));
assert!(html.contains("Error message"));
assert!(html.contains("Warning message"));
assert!(html.contains("Info message"));
}
#[test]
fn test_flash_messages_with_title() {
use crate::auth::session::FlashMessage;
let messages = vec![
FlashMessage::success("Message text").with_title("Success!"),
];
let html = flash_messages(&messages);
assert!(html.contains("<strong>Success!</strong>"));
assert!(html.contains("Message text"));
}
#[test]
fn test_flash_messages_xss_protection() {
use crate::auth::session::FlashMessage;
let messages = vec![
FlashMessage::error("<script>alert('xss')</script>"),
];
let html = flash_messages(&messages);
assert!(html.contains("<script>"));
assert!(!html.contains("<script>"));
}
#[test]
fn test_escape_html() {
assert_eq!(escape_html("Hello, world!"), "Hello, world!");
assert_eq!(escape_html("<script>"), "<script>");
assert_eq!(escape_html("A & B"), "A & B");
assert_eq!(escape_html("<div>content</div>"), "<div>content</div>");
assert_eq!(
escape_html("<script>alert('xss')</script>"),
"<script>alert('xss')</script>"
);
}
#[test]
fn test_escape_html_preserves_safe_chars() {
assert_eq!(escape_html("Hello 123 !@#$%^*()_+-=[]{}|;:',./? "),
"Hello 123 !@#$%^*()_+-=[]{}|;:',./? ");
}
}