use axum::{
http::{HeaderName, HeaderValue},
response::{Html, IntoResponse, IntoResponseParts, Response, ResponseParts},
};
#[derive(Debug, Clone)]
pub struct HtmlFragment<T>(pub T);
impl<T: AsRef<str>> IntoResponse for HtmlFragment<T> {
fn into_response(self) -> Response {
Html(self.0.as_ref().to_string()).into_response()
}
}
#[derive(Debug, Clone, Default)]
pub struct HxTriggerEvents {
events: Vec<HxEventEntry>,
timing: TriggerTiming,
}
#[derive(Debug, Clone)]
enum HxEventEntry {
Simple(String),
WithData(String, serde_json::Value),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TriggerTiming {
#[default]
Immediate,
AfterSettle,
AfterSwap,
}
impl HxTriggerEvents {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn timing(mut self, timing: TriggerTiming) -> Self {
self.timing = timing;
self
}
#[must_use]
pub fn event(mut self, name: impl Into<String>) -> Self {
self.events.push(HxEventEntry::Simple(name.into()));
self
}
#[must_use]
pub fn event_with_data(mut self, name: impl Into<String>, data: serde_json::Value) -> Self {
self.events.push(HxEventEntry::WithData(name.into(), data));
self
}
fn header_name(&self) -> HeaderName {
match self.timing {
TriggerTiming::Immediate => HeaderName::from_static("hx-trigger"),
TriggerTiming::AfterSettle => HeaderName::from_static("hx-trigger-after-settle"),
TriggerTiming::AfterSwap => HeaderName::from_static("hx-trigger-after-swap"),
}
}
}
impl IntoResponseParts for HxTriggerEvents {
type Error = std::convert::Infallible;
fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
if self.events.is_empty() {
return Ok(res);
}
let header_name = self.header_name();
let all_simple = self
.events
.iter()
.all(|e| matches!(e, HxEventEntry::Simple(_)));
let header_value = if all_simple {
self.events
.iter()
.filter_map(|e| match e {
HxEventEntry::Simple(name) => Some(name.as_str()),
HxEventEntry::WithData(_, _) => None,
})
.collect::<Vec<_>>()
.join(", ")
} else {
let mut map = serde_json::Map::new();
for event in self.events {
match event {
HxEventEntry::Simple(name) => {
map.insert(name, serde_json::Value::Null);
}
HxEventEntry::WithData(name, data) => {
map.insert(name, data);
}
}
}
serde_json::to_string(&serde_json::Value::Object(map)).unwrap_or_default()
};
if let Ok(value) = HeaderValue::from_str(&header_value) {
res.headers_mut().insert(header_name, value);
}
Ok(res)
}
}
#[derive(Debug, Clone)]
pub struct OutOfBandSwap {
target_id: String,
content: String,
swap_style: String,
}
impl OutOfBandSwap {
#[must_use]
pub fn new(target_id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
target_id: target_id.into(),
content: content.into(),
swap_style: "true".to_string(),
}
}
#[must_use]
pub fn with_style(mut self, style: impl Into<String>) -> Self {
self.swap_style = style.into();
self
}
#[must_use]
pub fn replace(target_id: impl Into<String>, content: impl Into<String>) -> Self {
Self::new(target_id, content).with_style("outerHTML")
}
#[must_use]
pub fn append(target_id: impl Into<String>, content: impl Into<String>) -> Self {
Self::new(target_id, content).with_style("beforeend")
}
#[must_use]
pub fn prepend(target_id: impl Into<String>, content: impl Into<String>) -> Self {
Self::new(target_id, content).with_style("afterbegin")
}
#[must_use]
pub fn delete(target_id: impl Into<String>) -> Self {
Self {
target_id: target_id.into(),
content: String::new(),
swap_style: "delete".to_string(),
}
}
}
impl IntoResponse for OutOfBandSwap {
fn into_response(self) -> Response {
let html = format!(
r#"<div id="{}" hx-swap-oob="{}">{}</div>"#,
html_escape(&self.target_id),
html_escape(&self.swap_style),
self.content
);
Html(html).into_response()
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_html_fragment() {
let fragment = HtmlFragment("<div>Test</div>");
let response = fragment.into_response();
assert_eq!(response.status(), axum::http::StatusCode::OK);
}
#[test]
fn test_oob_swap() {
let swap = OutOfBandSwap::new("test-id", "<p>Content</p>");
let response = swap.into_response();
assert_eq!(response.status(), axum::http::StatusCode::OK);
}
#[test]
fn test_oob_swap_delete() {
let swap = OutOfBandSwap::delete("test-id");
assert_eq!(swap.swap_style, "delete");
assert!(swap.content.is_empty());
}
#[test]
fn test_html_escape() {
assert_eq!(html_escape("test"), "test");
assert_eq!(html_escape("<script>"), "<script>");
assert_eq!(html_escape("\"hello\""), ""hello"");
assert_eq!(html_escape("foo & bar"), "foo & bar");
}
#[test]
fn test_trigger_events_simple() {
let events = HxTriggerEvents::new().event("event1").event("event2");
assert_eq!(events.events.len(), 2);
}
}