use std::borrow::Cow;
use http::Method;
#[cfg(feature = "maud")]
fn is_valid_attr_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(first) if first.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ':')
}
#[cfg(feature = "maud")]
fn push_escaped_text(out: &mut String, text: &str) {
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
other => out.push(other),
}
}
}
#[cfg(feature = "maud")]
fn push_escaped_attr_value(out: &mut String, text: &str) {
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
}
#[cfg(feature = "maud")]
fn push_attr(out: &mut String, name: &str, value: &str) {
assert!(
is_valid_attr_name(name),
"invalid HTML attribute name {name:?} passed to link_to/button_to"
);
out.push(' ');
out.push_str(name);
out.push_str("=\"");
push_escaped_attr_value(out, value);
out.push('"');
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, Default)]
pub struct LinkToOptions<'a> {
class: Option<&'a str>,
target: Option<&'a str>,
rel: Option<&'a str>,
attrs: &'a [(&'a str, &'a str)],
}
#[cfg(feature = "maud")]
impl<'a> LinkToOptions<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
class: None,
target: None,
rel: None,
attrs: &[],
}
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
#[must_use]
pub const fn target(mut self, target: &'a str) -> Self {
self.target = Some(target);
self
}
#[must_use]
pub const fn rel(mut self, rel: &'a str) -> Self {
self.rel = Some(rel);
self
}
#[must_use]
pub const fn attrs(mut self, attrs: &'a [(&'a str, &'a str)]) -> Self {
self.attrs = attrs;
self
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, Default)]
pub struct ButtonToOptions<'a> {
class: Option<&'a str>,
form_class: Option<&'a str>,
csrf_field: Option<&'a str>,
attrs: &'a [(&'a str, &'a str)],
}
#[cfg(feature = "maud")]
impl<'a> ButtonToOptions<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
class: None,
form_class: None,
csrf_field: None,
attrs: &[],
}
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
#[must_use]
pub const fn form_class(mut self, form_class: &'a str) -> Self {
self.form_class = Some(form_class);
self
}
#[must_use]
pub const fn csrf_field(mut self, csrf_field: &'a str) -> Self {
self.csrf_field = Some(csrf_field);
self
}
#[must_use]
pub const fn attrs(mut self, attrs: &'a [(&'a str, &'a str)]) -> Self {
self.attrs = attrs;
self
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn link_to(label: &str, href: &str) -> maud::Markup {
link_to_with(label, href, &LinkToOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn link_to_with(label: &str, href: &str, options: &LinkToOptions<'_>) -> maud::Markup {
let rel = effective_rel(options.target, options.rel);
let mut out = String::with_capacity(64 + href.len() + label.len());
out.push_str("<a");
push_attr(&mut out, "href", href);
if let Some(class) = options.class {
push_attr(&mut out, "class", class);
}
if let Some(target) = options.target {
push_attr(&mut out, "target", target);
}
if let Some(rel) = rel.as_deref() {
push_attr(&mut out, "rel", rel);
}
for (name, value) in options.attrs {
let is_reserved = name.eq_ignore_ascii_case("href")
|| name.eq_ignore_ascii_case("class")
|| name.eq_ignore_ascii_case("target")
|| name.eq_ignore_ascii_case("rel");
assert!(
!is_reserved,
"attrs cannot override the built-in {name:?} attribute on link_to; use the dedicated option instead"
);
push_attr(&mut out, name, value);
}
out.push('>');
push_escaped_text(&mut out, label);
out.push_str("</a>");
maud::PreEscaped(out)
}
#[cfg(feature = "maud")]
fn effective_rel<'a>(target: Option<&str>, rel: Option<&'a str>) -> Option<Cow<'a, str>> {
if !target.is_some_and(|t| t.eq_ignore_ascii_case("_blank")) {
return rel.map(Cow::Borrowed);
}
match rel {
Some(rel)
if rel
.split_whitespace()
.any(|token| token.eq_ignore_ascii_case("noopener")) =>
{
Some(Cow::Borrowed(rel))
}
Some(rel) => Some(Cow::Owned(format!("{rel} noopener"))),
None => Some(Cow::Borrowed("noopener")),
}
}
#[cfg(feature = "maud")]
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn button_to(label: &str, href: &str, method: Method, csrf_token: &str) -> maud::Markup {
button_to_with(label, href, method, csrf_token, &ButtonToOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn button_to_with(
label: &str,
href: &str,
method: Method,
csrf_token: &str,
options: &ButtonToOptions<'_>,
) -> maud::Markup {
assert!(
method == Method::GET
|| method == Method::POST
|| method == Method::PUT
|| method == Method::PATCH
|| method == Method::DELETE,
"button_to only supports GET, POST, PUT, PATCH, or DELETE (got {method}); \
a <form> cannot represent any other HTTP method"
);
let mut button = String::with_capacity(64 + label.len());
button.push_str("<button type=\"submit\"");
if let Some(class) = options.class {
push_attr(&mut button, "class", class);
}
for (name, value) in options.attrs {
let is_reserved = name.eq_ignore_ascii_case("type")
|| name.eq_ignore_ascii_case("class")
|| name.eq_ignore_ascii_case("form")
|| name.eq_ignore_ascii_case("formaction")
|| name.eq_ignore_ascii_case("formmethod")
|| name.eq_ignore_ascii_case("formenctype")
|| name.eq_ignore_ascii_case("formnovalidate")
|| name.eq_ignore_ascii_case("formtarget");
assert!(
!is_reserved,
"attrs cannot override the built-in {name:?} attribute on button_to; use the dedicated option instead"
);
push_attr(&mut button, name, value);
}
button.push('>');
push_escaped_text(&mut button, label);
button.push_str("</button>");
let button = maud::PreEscaped(button);
let csrf_field = options.csrf_field.unwrap_or("_csrf");
if method == Method::GET {
maud::html! {
form action=(href) method="get" class=[options.form_class] {
(button)
}
}
} else {
maud::html! {
form action=(href) method="post" class=[options.form_class] {
(crate::form::method_input(method.as_str()))
input type="hidden" name=(csrf_field) value=(csrf_token);
(button)
}
}
}
}
#[cfg(all(test, feature = "maud"))]
mod tests {
use super::*;
#[test]
fn link_to_renders_basic_anchor() {
let html = link_to("Posts", "/posts").into_string();
assert_eq!(html, r#"<a href="/posts">Posts</a>"#);
}
#[test]
fn link_to_escapes_label() {
let html = link_to("<script>alert(1)</script>", "/posts").into_string();
assert!(html.contains("<script>"), "{html}");
assert!(!html.contains("<script>"), "{html}");
}
#[test]
fn link_to_escapes_href() {
let html = link_to("x", r#"/x?a=1&b="><script>"#).into_string();
assert!(html.contains("&"), "{html}");
assert!(html.contains("""), "{html}");
assert!(!html.contains("<script>"), "{html}");
assert!(!html.contains(r#"b=">"#), "{html}");
}
#[test]
fn link_to_with_class_target_rel() {
let options = LinkToOptions::new()
.class("btn btn-link")
.target("_top")
.rel("nofollow");
let html = link_to_with("Docs", "/docs", &options).into_string();
assert!(html.contains(r#"class="btn btn-link""#), "{html}");
assert!(html.contains(r#"target="_top""#), "{html}");
assert!(html.contains(r#"rel="nofollow""#), "{html}");
}
#[test]
fn link_to_target_blank_adds_noopener() {
let options = LinkToOptions::new().target("_blank");
let html = link_to_with("Ext", "https://example.com", &options).into_string();
assert!(html.contains(r#"rel="noopener""#), "{html}");
}
#[test]
fn link_to_target_blank_merges_rel() {
let options = LinkToOptions::new().target("_blank").rel("nofollow");
let html = link_to_with("Ext", "https://example.com", &options).into_string();
assert!(html.contains(r#"rel="nofollow noopener""#), "{html}");
}
#[test]
fn link_to_target_blank_no_duplicate_noopener() {
let options = LinkToOptions::new()
.target("_blank")
.rel("noopener external");
let html = link_to_with("Ext", "https://example.com", &options).into_string();
assert_eq!(html.matches("noopener").count(), 1, "{html}");
assert!(html.contains(r#"rel="noopener external""#), "{html}");
}
#[test]
fn link_to_no_target_no_auto_rel() {
let html = link_to("Posts", "/posts").into_string();
assert!(!html.contains("rel="), "{html}");
}
#[test]
fn link_to_target_blank_case_insensitive() {
let options = LinkToOptions::new().target("_BLANK");
let html = link_to_with("Ext", "https://example.com", &options).into_string();
assert!(html.contains(r#"rel="noopener""#), "{html}");
}
#[test]
fn link_to_target_blank_rel_dedup_case_insensitive() {
let options = LinkToOptions::new().target("_blank").rel("NOOPENER");
let html = link_to_with("Ext", "https://example.com", &options).into_string();
assert_eq!(html.to_lowercase().matches("noopener").count(), 1, "{html}");
}
#[test]
fn link_to_extra_hx_attrs() {
let options =
LinkToOptions::new().attrs(&[("hx-get", "/posts/1"), ("hx-target", "#detail")]);
let html = link_to_with("Show", "/posts/1", &options).into_string();
assert!(html.contains(r#"hx-get="/posts/1""#), "{html}");
assert!(html.contains(r##"hx-target="#detail""##), "{html}");
}
#[test]
fn link_to_extra_attr_value_escaped() {
let options = LinkToOptions::new().attrs(&[("data-note", r#"x" onmouseover="evil()"#)]);
let html = link_to_with("x", "/x", &options).into_string();
assert!(html.contains("""), "{html}");
assert!(!html.contains(r#"" onmouseover"#), "{html}");
}
#[test]
#[should_panic(expected = "invalid HTML attribute name")]
fn link_to_invalid_attr_name_panics() {
let options = LinkToOptions::new().attrs(&[("on click", "evil()")]);
let _ = link_to_with("x", "/x", &options);
}
#[test]
#[should_panic(expected = "attrs cannot override the built-in \"href\" attribute")]
fn link_to_attrs_cannot_override_href_panics() {
let options = LinkToOptions::new().attrs(&[("href", "/evil")]);
let _ = link_to_with("x", "/x", &options);
}
#[test]
#[should_panic(expected = "attrs cannot override the built-in \"class\" attribute")]
fn link_to_attrs_cannot_override_class_panics() {
let options = LinkToOptions::new()
.class("btn")
.attrs(&[("class", "evil")]);
let _ = link_to_with("x", "/x", &options);
}
#[test]
#[should_panic(expected = "attrs cannot override the built-in \"class\" attribute")]
fn link_to_attrs_cannot_override_class_without_option_set_panics() {
let options = LinkToOptions::new().attrs(&[("class", "evil")]);
let _ = link_to_with("x", "/x", &options);
}
#[test]
#[should_panic(expected = "attrs cannot override the built-in \"target\" attribute")]
fn link_to_attrs_cannot_override_target_without_option_set_panics() {
let options = LinkToOptions::new().attrs(&[("target", "_blank")]);
let _ = link_to_with("x", "/x", &options);
}
#[test]
#[should_panic(expected = "attrs cannot override the built-in \"rel\" attribute")]
fn link_to_attrs_cannot_override_rel_without_option_set_panics() {
let options = LinkToOptions::new().attrs(&[("rel", "nofollow")]);
let _ = link_to_with("x", "/x", &options);
}
#[test]
fn button_to_post_renders_form_csrf_and_button() {
let html = button_to("Log out", "/logout", Method::POST, "tok123").into_string();
assert!(
html.contains(r#"<form action="/logout" method="post""#),
"{html}"
);
assert!(
html.contains(r#"input type="hidden" name="_csrf" value="tok123""#),
"{html}"
);
assert!(
html.contains(r#"<button type="submit">Log out</button>"#),
"{html}"
);
assert!(!html.contains("_method"), "{html}");
}
#[test]
fn button_to_delete_adds_method_override() {
let html = button_to("Delete", "/posts/42", Method::DELETE, "tok123").into_string();
assert!(html.contains(r#"method="post""#), "{html}");
assert!(html.contains(r#"name="_method" value="DELETE""#), "{html}");
assert!(html.contains(r#"name="_csrf" value="tok123""#), "{html}");
}
#[test]
fn button_to_put_and_patch_overrides() {
for (method, value) in [(Method::PUT, "PUT"), (Method::PATCH, "PATCH")] {
let html = button_to("Save", "/posts/42", method, "tok123").into_string();
assert!(html.contains(r#"method="post""#), "{html}");
assert!(
html.contains(&format!(r#"name="_method" value="{value}""#)),
"{html}"
);
assert!(html.contains(r#"name="_csrf""#), "{html}");
}
}
#[test]
fn button_to_get_renders_plain_get_form() {
let html = button_to("Search", "/search", Method::GET, "tok123").into_string();
assert!(html.contains(r#"method="get""#), "{html}");
assert!(!html.contains("_csrf"), "{html}");
assert!(!html.contains("tok123"), "{html}");
assert!(!html.contains("_method"), "{html}");
assert!(
html.contains(r#"<button type="submit">Search</button>"#),
"{html}"
);
}
#[test]
fn button_to_escapes_label_and_action() {
let html = button_to(
"<b>Delete</b>",
r#"/x?a=1&b="><script>"#,
Method::DELETE,
"tok",
)
.into_string();
assert!(html.contains("<b>"), "{html}");
assert!(!html.contains("<b>"), "{html}");
assert!(html.contains("&"), "{html}");
assert!(!html.contains("<script>"), "{html}");
}
#[test]
fn button_to_custom_csrf_field() {
let options = ButtonToOptions::new().csrf_field("csrf_tok");
let html =
button_to_with("Delete", "/posts/42", Method::DELETE, "tok123", &options).into_string();
assert!(html.contains(r#"name="csrf_tok" value="tok123""#), "{html}");
assert!(!html.contains(r#"name="_csrf""#), "{html}");
}
#[test]
fn button_to_button_and_form_classes() {
let options = ButtonToOptions::new()
.class("btn btn-danger")
.form_class("inline-form");
let html =
button_to_with("Delete", "/posts/42", Method::DELETE, "tok123", &options).into_string();
assert!(html.contains(r#"class="inline-form""#), "{html}");
assert!(html.contains(r#"class="btn btn-danger""#), "{html}");
}
#[test]
fn button_to_extra_attrs_on_button() {
let options = ButtonToOptions::new().class("btn btn-danger").attrs(&[
("hx-delete", "/admin/widgets/42"),
("hx-confirm", "Are you sure you want to delete this Widget?"),
("hx-target", "body"),
]);
let html = button_to_with(
"Delete",
"/admin/widgets/42",
Method::DELETE,
"tok123",
&options,
)
.into_string();
assert!(html.contains(r#"hx-delete="/admin/widgets/42""#), "{html}");
assert!(html.contains(r#"hx-target="body""#), "{html}");
assert!(
html.contains("hx-confirm=\"Are you sure you want to delete this Widget?\""),
"{html}"
);
assert!(html.contains(r#"name="_method" value="DELETE""#), "{html}");
assert!(html.contains(r#"name="_csrf" value="tok123""#), "{html}");
}
#[test]
#[should_panic(expected = "invalid HTML attribute name")]
fn button_to_invalid_attr_name_panics() {
let options = ButtonToOptions::new().attrs(&[("foo=bar", "x")]);
let _ = button_to_with("x", "/x", Method::POST, "tok", &options);
}
#[test]
#[should_panic(expected = "attrs cannot override the built-in \"type\" attribute")]
fn button_to_attrs_cannot_override_type_panics() {
let options = ButtonToOptions::new().attrs(&[("type", "reset")]);
let _ = button_to_with("x", "/x", Method::POST, "tok", &options);
}
#[test]
#[should_panic(expected = "attrs cannot override the built-in \"class\" attribute")]
fn button_to_attrs_cannot_override_class_panics() {
let options = ButtonToOptions::new()
.class("btn")
.attrs(&[("class", "evil")]);
let _ = button_to_with("x", "/x", Method::POST, "tok", &options);
}
#[test]
#[should_panic(expected = "attrs cannot override the built-in \"class\" attribute")]
fn button_to_attrs_cannot_override_class_without_option_set_panics() {
let options = ButtonToOptions::new().attrs(&[("class", "evil")]);
let _ = button_to_with("x", "/x", Method::POST, "tok", &options);
}
#[test]
#[should_panic(expected = "attrs cannot override the built-in \"formmethod\" attribute")]
fn button_to_attrs_cannot_override_formmethod_panics() {
let options = ButtonToOptions::new().attrs(&[("formmethod", "get")]);
let _ = button_to_with("x", "/x", Method::DELETE, "tok", &options);
}
#[test]
#[should_panic(expected = "attrs cannot override the built-in \"formaction\" attribute")]
fn button_to_attrs_cannot_override_formaction_panics() {
let options = ButtonToOptions::new().attrs(&[("formaction", "/evil")]);
let _ = button_to_with("x", "/x", Method::DELETE, "tok", &options);
}
#[test]
#[should_panic(expected = "attrs cannot override the built-in \"form\" attribute")]
fn button_to_attrs_cannot_override_form_panics() {
let options = ButtonToOptions::new().attrs(&[("form", "other-form")]);
let _ = button_to_with("x", "/x", Method::DELETE, "tok", &options);
}
#[test]
#[should_panic(expected = "button_to only supports GET, POST, PUT, PATCH, or DELETE")]
fn button_to_rejects_unsupported_method_panics() {
let _ = button_to("x", "/x", Method::HEAD, "tok");
}
}