use std::fmt::Write as _;
use std::time::Duration;
use autumn_web::{Markup, PreEscaped};
pub type Attributes = Vec<(&'static str, String)>;
pub(crate) const TIMER_MAX: u32 = 2_147_483_647;
pub(crate) fn millis(duration: Duration) -> u32 {
if duration.is_zero() {
return 0;
}
u32::try_from(duration.as_millis()).map_or(TIMER_MAX, |ms| ms.clamp(1, TIMER_MAX))
}
pub(crate) fn element(
tag: &'static str,
attributes: &[(&'static str, String)],
content: Markup,
) -> Markup {
let mut html = String::with_capacity(64 + content.0.len());
html.push('<');
html.push_str(tag);
for (name, value) in attributes {
html.push(' ');
html.push_str(name);
html.push_str("=\"");
let _ = write!(maud::Escaper::new(&mut html), "{value}");
html.push('"');
}
html.push('>');
html.push_str(&content.into_string());
html.push_str("</");
html.push_str(tag);
html.push('>');
PreEscaped(html)
}
pub(crate) fn valid_extra_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
let mut chars = name.chars();
let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic());
let rest_ok = chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':'));
first_ok
&& rest_ok
&& !lower.starts_with("on")
&& lower != "style"
&& lower != "type"
&& !lower.starts_with("data-vanilla")
}
macro_rules! common {
($ty:ty) => {
impl $ty {
pub fn attr(mut self, name: &'static str, value: impl Into<String>) -> Self {
if $crate::markup::valid_extra_name(name) {
self.extra.push((name, value.into()));
} else {
autumn_web::reexports::tracing::warn!(
attribute = name,
"autumn-plugin-vanilla: extra attribute name refused"
);
}
self
}
}
};
($ty:ty, wrap) => {
$crate::markup::common!($ty);
impl $ty {
#[must_use]
pub fn wrap(&self, content: autumn_web::Markup) -> autumn_web::Markup {
$crate::markup::element("div", &self.attributes(), content)
}
}
};
}
pub(crate) use common;
#[cfg(test)]
mod tests {
use super::*;
use autumn_web::html;
#[test]
fn millis_converts_and_clamps() {
assert_eq!(millis(Duration::ZERO), 0);
assert_eq!(millis(Duration::from_nanos(1)), 1);
assert_eq!(millis(Duration::from_micros(500)), 1);
assert_eq!(millis(Duration::from_micros(1999)), 1);
assert_eq!(millis(Duration::from_secs(5)), 5000);
assert_eq!(
millis(Duration::from_millis(u64::from(TIMER_MAX) + 1)),
TIMER_MAX
);
assert_eq!(millis(Duration::MAX), TIMER_MAX);
}
#[test]
fn element_escapes_values_and_keeps_content() {
let html = element(
"p",
&[("title", r#"a"b<c>&"#.to_owned())],
html! { b { "x" } },
);
assert_eq!(
html.into_string(),
r#"<p title="a"b<c>&"><b>x</b></p>"#
);
}
#[test]
fn element_with_no_attributes() {
assert_eq!(element("div", &[], html! {}).into_string(), "<div></div>");
}
#[test]
fn extra_names_are_validated() {
for ok in ["id", "class", "aria-label", "hx-get", "xml:lang", "data-x"] {
assert!(valid_extra_name(ok), "{ok}");
}
for bad in [
"",
"onclick",
"OnClick",
"style",
"type",
"data-vanilla",
"data-vanilla-copy",
"1a",
"-a",
"a b",
"a\"",
"a=b",
"a>",
] {
assert!(!valid_extra_name(bad), "{bad:?}");
}
}
}