use std::collections::HashMap;
use tera::{Tera, Value, Error as TeraError};
fn filter_slugify(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value.as_str().ok_or_else(|| TeraError::msg("slugify: expected string"))?;
let slug = s
.chars()
.map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect::<String>()
.split('-')
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
.join("-");
Ok(Value::String(slug))
}
fn filter_date_fmt(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value.as_str().ok_or_else(|| TeraError::msg("date_fmt: expected string"))?;
let fmt = args
.get("format")
.and_then(Value::as_str)
.unwrap_or("%B %d, %Y");
if s.len() >= 10 {
let parts: Vec<&str> = s[..10].split('-').collect();
if parts.len() == 3 {
if let (Ok(y), Ok(m), Ok(d)) = (
parts[0].parse::<i32>(),
parts[1].parse::<u8>(),
parts[2].parse::<u8>(),
) {
let formatted = fmt
.replace("%Y", &format!("{:04}", y))
.replace("%m", &format!("{:02}", m))
.replace("%d", &format!("{:02}", d))
.replace("%B", month_name(m));
return Ok(Value::String(formatted));
}
}
}
Ok(Value::String(s.to_owned()))
}
fn month_name(m: u8) -> &'static str {
match m {
1 => "January", 2 => "February", 3 => "March", 4 => "April",
5 => "May", 6 => "June", 7 => "July", 8 => "August",
9 => "September", 10 => "October", 11 => "November", 12 => "December",
_ => "",
}
}
fn filter_safe_html(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value.as_str().ok_or_else(|| TeraError::msg("safe_html: expected string"))?;
let escaped = s
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'");
Ok(Value::String(escaped))
}
pub fn register_filters(tera: &mut Tera) {
tera.register_filter("slugify", filter_slugify);
tera.register_filter("date_fmt", filter_date_fmt);
tera.register_filter("safe_html", filter_safe_html);
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn run(filter: fn(&Value, &HashMap<String, Value>) -> tera::Result<Value>, val: Value, args: HashMap<String, Value>) -> String {
filter(&val, &args).unwrap().as_str().unwrap().to_owned()
}
#[test]
fn slugify_basic() {
assert_eq!(run(filter_slugify, json!("Hello World"), HashMap::new()), "hello-world");
}
#[test]
fn slugify_special_chars() {
assert_eq!(run(filter_slugify, json!("Rust & Typst (2026)"), HashMap::new()), "rust-typst-2026");
}
#[test]
fn date_fmt_default() {
let result = run(filter_date_fmt, json!("2026-06-07"), HashMap::new());
assert_eq!(result, "June 07, 2026");
}
#[test]
fn date_fmt_custom() {
let mut args = HashMap::new();
args.insert("format".into(), json!("%Y-%m-%d"));
let result = run(filter_date_fmt, json!("2026-06-07"), args);
assert_eq!(result, "2026-06-07");
}
#[test]
fn date_fmt_passthrough_on_invalid() {
let result = run(filter_date_fmt, json!("not-a-date"), HashMap::new());
assert_eq!(result, "not-a-date");
}
#[test]
fn safe_html_escapes() {
let result = run(filter_safe_html, json!("<script>alert(\"xss\")</script>"), HashMap::new());
assert_eq!(result, "<script>alert("xss")</script>");
}
}