use crate::Renderer;
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap};
pub trait Attr {
fn write_attr(&self, name: &str, r: &mut dyn Renderer);
}
pub fn is_attr_name_safe(name: &str) -> bool {
!name.is_empty()
&& !name.chars().any(|c| {
c.is_control()
|| c.is_whitespace()
|| matches!(c, '"' | '\'' | '>' | '<' | '/' | '=' | '&')
})
}
fn write_pair(name: &str, value: &dyn core::fmt::Display, r: &mut dyn Renderer) {
r.write_raw(" ");
r.write_raw(name);
r.write_raw("=\"");
r.write_escaped(value);
r.write_raw("\"");
}
impl Attr for bool {
fn write_attr(&self, name: &str, r: &mut dyn Renderer) {
if *self {
r.write_raw(" ");
r.write_raw(name);
}
}
}
impl<T: Attr> Attr for Option<T> {
fn write_attr(&self, name: &str, r: &mut dyn Renderer) {
if let Some(value) = self {
value.write_attr(name, r);
}
}
}
impl<T: Attr + ?Sized> Attr for &T {
fn write_attr(&self, name: &str, r: &mut dyn Renderer) {
(**self).write_attr(name, r);
}
}
impl Attr for str {
fn write_attr(&self, name: &str, r: &mut dyn Renderer) {
write_pair(name, &self, r);
}
}
impl Attr for String {
fn write_attr(&self, name: &str, r: &mut dyn Renderer) {
write_pair(name, &self.as_str(), r);
}
}
impl Attr for Cow<'_, str> {
fn write_attr(&self, name: &str, r: &mut dyn Renderer) {
write_pair(name, &self.as_ref(), r);
}
}
macro_rules! attr_via_display {
($($t:ty),* $(,)?) => {$(
impl Attr for $t {
fn write_attr(&self, name: &str, r: &mut dyn Renderer) {
write_pair(name, self, r);
}
}
)*};
}
attr_via_display!(
char, u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize, f32, f64
);
pub trait AttrSpread {
fn write_attrs(&self, r: &mut dyn Renderer);
fn write_attrs_except(&self, taken: &[&str], r: &mut dyn Renderer) {
let _ = taken;
self.write_attrs(r);
}
}
impl<T: AttrSpread + ?Sized> AttrSpread for &T {
fn write_attrs(&self, r: &mut dyn Renderer) {
(**self).write_attrs(r);
}
fn write_attrs_except(&self, taken: &[&str], r: &mut dyn Renderer) {
(**self).write_attrs_except(taken, r);
}
}
impl AttrSpread for &'static str {
fn write_attrs(&self, r: &mut dyn Renderer) {
if !self.is_empty() {
r.write_raw(" ");
r.write_raw(self);
}
}
}
impl<T: AttrSpread> AttrSpread for Option<T> {
fn write_attrs(&self, r: &mut dyn Renderer) {
if let Some(inner) = self {
inner.write_attrs(r);
}
}
fn write_attrs_except(&self, taken: &[&str], r: &mut dyn Renderer) {
if let Some(inner) = self {
inner.write_attrs_except(taken, r);
}
}
}
impl<K: AsRef<str>, V: AsRef<str>> AttrSpread for [(K, V)] {
fn write_attrs(&self, r: &mut dyn Renderer) {
self.write_attrs_except(&[], r);
}
fn write_attrs_except(&self, taken: &[&str], r: &mut dyn Renderer) {
for (key, value) in self {
let key = key.as_ref();
if !is_attr_name_safe(key) {
debug_assert!(false, "`{key}` is not usable as an attribute name");
continue;
}
if taken.contains(&key) {
continue;
}
r.write_raw(" ");
r.write_escaped(&key);
r.write_raw("=\"");
r.write_escaped(&value.as_ref());
r.write_raw("\"");
}
}
}
impl<K: AsRef<str>, V: AsRef<str>> AttrSpread for Vec<(K, V)> {
fn write_attrs(&self, r: &mut dyn Renderer) {
self.as_slice().write_attrs(r);
}
fn write_attrs_except(&self, taken: &[&str], r: &mut dyn Renderer) {
self.as_slice().write_attrs_except(taken, r);
}
}
#[derive(Debug, Default, Clone)]
pub struct TokenList {
names: Vec<String>,
}
impl TokenList {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, text: &str) {
for name in text.split_whitespace() {
if !self.names.iter().any(|n| n == name) {
self.names.push(name.to_string());
}
}
}
pub fn set(&mut self, text: &str, on: bool) {
if on {
self.add(text);
} else {
for name in text.split_whitespace() {
self.names.retain(|n| n != name);
}
}
}
pub fn is_empty(&self) -> bool {
self.names.is_empty()
}
pub fn to_value(&self) -> String {
self.names.join(" ")
}
pub fn into_value(self) -> Option<String> {
match self.names.is_empty() {
true => None,
false => Some(self.names.join(" ")),
}
}
pub fn write_attr(&self, name: &str, r: &mut dyn Renderer) {
if !self.is_empty() {
write_pair(name, &self.to_value(), r);
}
}
}
pub trait TokenItem {
fn add_to(&self, list: &mut TokenList);
}
impl<T: TokenItem + ?Sized> TokenItem for &T {
fn add_to(&self, list: &mut TokenList) {
(**self).add_to(list);
}
}
impl<T: TokenItem> TokenItem for Option<T> {
fn add_to(&self, list: &mut TokenList) {
if let Some(item) = self {
item.add_to(list);
}
}
}
impl TokenItem for str {
fn add_to(&self, list: &mut TokenList) {
list.add(self);
}
}
impl TokenItem for String {
fn add_to(&self, list: &mut TokenList) {
list.add(self);
}
}
impl TokenItem for Cow<'_, str> {
fn add_to(&self, list: &mut TokenList) {
list.add(self.as_ref());
}
}
impl<T: TokenItem> TokenItem for [T] {
fn add_to(&self, list: &mut TokenList) {
for item in self {
item.add_to(list);
}
}
}
impl<T: TokenItem, const N: usize> TokenItem for [T; N] {
fn add_to(&self, list: &mut TokenList) {
self.as_slice().add_to(list);
}
}
impl<T: TokenItem> TokenItem for Vec<T> {
fn add_to(&self, list: &mut TokenList) {
self.as_slice().add_to(list);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttrValue {
Absent,
Bare,
Value(Cow<'static, str>),
}
pub trait IntoAttrValue {
fn into_attr_value(self) -> AttrValue;
}
impl IntoAttrValue for AttrValue {
fn into_attr_value(self) -> AttrValue {
self
}
}
impl IntoAttrValue for bool {
fn into_attr_value(self) -> AttrValue {
match self {
true => AttrValue::Bare,
false => AttrValue::Absent,
}
}
}
impl<T: IntoAttrValue> IntoAttrValue for Option<T> {
fn into_attr_value(self) -> AttrValue {
match self {
Some(value) => value.into_attr_value(),
None => AttrValue::Absent,
}
}
}
impl IntoAttrValue for String {
fn into_attr_value(self) -> AttrValue {
AttrValue::Value(Cow::Owned(self))
}
}
impl IntoAttrValue for Cow<'static, str> {
fn into_attr_value(self) -> AttrValue {
AttrValue::Value(self)
}
}
impl IntoAttrValue for &str {
fn into_attr_value(self) -> AttrValue {
AttrValue::Value(Cow::Owned(self.to_string()))
}
}
impl IntoAttrValue for &String {
fn into_attr_value(self) -> AttrValue {
AttrValue::Value(Cow::Owned(self.clone()))
}
}
macro_rules! attr_value_via_display {
($($ty:ty),* $(,)?) => {$(
impl IntoAttrValue for $ty {
fn into_attr_value(self) -> AttrValue {
AttrValue::Value(Cow::Owned(self.to_string()))
}
}
)*};
}
attr_value_via_display!(
char, u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize, f32, f64
);
#[derive(Debug, Default, Clone)]
pub struct Attrs {
entries: Vec<(Cow<'static, str>, Option<Cow<'static, str>>)>,
}
impl Attrs {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, name: impl Into<Cow<'static, str>>, value: impl IntoAttrValue) {
let name = name.into();
match value.into_attr_value() {
AttrValue::Absent => self.remove(&name),
AttrValue::Bare => self.put(name, None),
AttrValue::Value(value) => self.put(name, Some(value)),
}
}
pub fn insert_static(&mut self, name: &'static str, value: &'static str) {
self.put(Cow::Borrowed(name), Some(Cow::Borrowed(value)));
}
pub fn insert_bare(&mut self, name: impl Into<Cow<'static, str>>) {
self.put(name.into(), None);
}
pub fn remove(&mut self, name: &str) {
self.entries.retain(|(seen, _)| seen != name);
}
pub fn get(&self, name: &str) -> Option<&str> {
self.entries
.iter()
.find(|(seen, _)| seen == name)
.and_then(|(_, value)| value.as_deref())
}
pub fn contains(&self, name: &str) -> bool {
self.entries.iter().any(|(seen, _)| seen == name)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, Option<&str>)> {
self.entries
.iter()
.map(|(name, value)| (name.as_ref(), value.as_deref()))
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn merge(&mut self, other: &(impl AttrSet + ?Sized)) {
other.add_attrs(self);
}
pub fn write_attrs_prefixed(&self, prefix: &str, r: &mut dyn Renderer) {
for (name, value) in &self.entries {
r.write_raw(" ");
r.write_escaped(&prefix);
r.write_raw("-");
r.write_escaped(&name.as_ref());
if let Some(value) = value {
r.write_raw("=\"");
r.write_escaped(&value.as_ref());
r.write_raw("\"");
}
}
}
fn put(&mut self, name: Cow<'static, str>, value: Option<Cow<'static, str>>) {
if !is_attr_name_safe(&name) {
debug_assert!(false, "`{name}` is not usable as an attribute name");
return;
}
match self.entries.iter_mut().find(|(seen, _)| *seen == name) {
Some(entry) => entry.1 = value,
None => self.entries.push((name, value)),
}
}
}
impl AttrSpread for Attrs {
fn write_attrs(&self, r: &mut dyn Renderer) {
self.write_attrs_except(&[], r);
}
fn write_attrs_except(&self, taken: &[&str], r: &mut dyn Renderer) {
for (name, value) in &self.entries {
if taken.contains(&name.as_ref()) {
continue;
}
r.write_raw(" ");
r.write_escaped(&name.as_ref());
if let Some(value) = value {
r.write_raw("=\"");
r.write_escaped(&value.as_ref());
r.write_raw("\"");
}
}
}
}
#[diagnostic::on_unimplemented(
message = "`{Self}` is not a set of attributes",
label = "expected `Attrs`, a list of name/value pairs, or an `Option` of either",
note = "attributes written as raw markup are no longer accepted — write them at the call site as attributes: `<Hidden data-controller=\"signup\"/>`"
)]
pub trait AttrSet {
fn add_attrs(&self, attrs: &mut Attrs);
}
impl<T: AttrSet + ?Sized> AttrSet for &T {
fn add_attrs(&self, attrs: &mut Attrs) {
(**self).add_attrs(attrs);
}
}
impl<T: AttrSet> AttrSet for Option<T> {
fn add_attrs(&self, attrs: &mut Attrs) {
if let Some(set) = self {
set.add_attrs(attrs);
}
}
}
impl AttrSet for Attrs {
fn add_attrs(&self, attrs: &mut Attrs) {
for (name, value) in &self.entries {
attrs.put(name.clone(), value.clone());
}
}
}
impl<K: AsRef<str>, V: IntoAttrValue + Clone> AttrSet for [(K, V)] {
fn add_attrs(&self, attrs: &mut Attrs) {
for (name, value) in self {
attrs.insert(name.as_ref().to_string(), value.clone());
}
}
}
impl<K: AsRef<str>, V: IntoAttrValue + Clone, const N: usize> AttrSet for [(K, V); N] {
fn add_attrs(&self, attrs: &mut Attrs) {
self.as_slice().add_attrs(attrs);
}
}
impl<K: AsRef<str>, V: IntoAttrValue + Clone> AttrSet for Vec<(K, V)> {
fn add_attrs(&self, attrs: &mut Attrs) {
self.as_slice().add_attrs(attrs);
}
}
impl<K: AsRef<str>, V: IntoAttrValue + Clone> AttrSet for BTreeMap<K, V> {
fn add_attrs(&self, attrs: &mut Attrs) {
for (name, value) in self {
attrs.insert(name.as_ref().to_string(), value.clone());
}
}
}
impl<K: AsRef<str>, V: IntoAttrValue + Clone, S> AttrSet for HashMap<K, V, S> {
fn add_attrs(&self, attrs: &mut Attrs) {
let mut pairs: Vec<(&str, &V)> = self.iter().map(|(k, v)| (k.as_ref(), v)).collect();
pairs.sort_by_key(|(name, _)| *name);
for (name, value) in pairs {
attrs.insert(name.to_string(), value.clone());
}
}
}
impl<K: Into<Cow<'static, str>>, V: IntoAttrValue> FromIterator<(K, V)> for Attrs {
fn from_iter<I: IntoIterator<Item = (K, V)>>(pairs: I) -> Self {
let mut attrs = Attrs::new();
for (name, value) in pairs {
attrs.insert(name, value);
}
attrs
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::renderers::{StringRenderer, escape_html};
fn write(f: impl FnOnce(&mut dyn Renderer)) -> String {
let mut r = StringRenderer::with_escape(escape_html);
f(&mut r);
Box::new(r).finish()
}
fn rendered(item: &dyn AttrSet) -> String {
let mut set = Attrs::new();
item.add_attrs(&mut set);
write(|r| set.write_attrs_prefixed("data", r))
}
#[test]
fn pairs_and_maps_reach_the_same_attributes() {
let want = r#" data-controller="modal" data-index="3""#;
assert_eq!(rendered(&[("controller", "modal"), ("index", "3")]), want);
assert_eq!(
rendered(&vec![("controller", "modal"), ("index", "3")]),
want
);
let tree: BTreeMap<&str, &str> = [("controller", "modal"), ("index", "3")]
.into_iter()
.collect();
assert_eq!(rendered(&tree), want);
}
#[test]
fn a_hash_map_renders_in_key_order() {
let map: HashMap<&str, &str> = [("b", "2"), ("a", "1"), ("c", "3")].into_iter().collect();
assert_eq!(rendered(&map), r#" data-a="1" data-b="2" data-c="3""#);
}
#[test]
fn a_value_may_decline_to_appear() {
assert_eq!(rendered(&[("open", true)]), " data-open");
assert_eq!(rendered(&[("open", false)]), "");
assert_eq!(rendered(&[("note", Some("hi"))]), r#" data-note="hi""#);
assert_eq!(rendered(&[("note", None::<&str>)]), "");
assert_eq!(rendered(&None::<[(&str, &str); 1]>), "");
}
#[test]
fn numbers_and_chars_carry_their_display() {
assert_eq!(rendered(&[("count", 42u32)]), r#" data-count="42""#);
assert_eq!(rendered(&[("sep", ',')]), r#" data-sep=",""#);
}
#[test]
fn a_repeated_key_keeps_its_place_and_takes_the_last_value() {
let mut set = Attrs::new();
[("a", "1"), ("b", "2")].add_attrs(&mut set);
[("a", "9")].add_attrs(&mut set);
assert_eq!(
write(|r| set.write_attrs_prefixed("data", r)),
r#" data-a="9" data-b="2""#
);
}
#[test]
fn keys_are_written_verbatim_and_values_escaped() {
assert_eq!(
rendered(&[("user_id", "a\"b<c")]),
r#" data-user_id="a"b<c""#
);
}
#[test]
fn a_set_can_be_spread_and_folded_into_another() {
let mut base = Attrs::new();
base.insert("controller", "modal");
base.insert_bare("open");
assert_eq!(
write(|r| base.write_attrs_prefixed("data", r)),
r#" data-controller="modal" data-open"#
);
let mut set = Attrs::new();
base.add_attrs(&mut set);
set.remove("open");
assert_eq!(
write(|r| set.write_attrs_prefixed("data", r)),
r#" data-controller="modal""#
);
}
#[test]
fn a_name_that_could_break_out_of_its_attribute_is_refused() {
for bad in ["", "x onclick", "x=y", "a\"b", "a>b", "a/b", "a&b", "a\tb"] {
assert!(!is_attr_name_safe(bad), "`{bad}` must not be usable");
}
for good in ["controller", "user_id", "modal-open", "x:y", "п"] {
assert!(is_attr_name_safe(good), "`{good}` must be usable");
}
}
#[test]
#[cfg(not(debug_assertions))]
fn a_spread_drops_a_name_it_cannot_write() {
let pairs = [("safe", "1"), ("x onclick", "alert(1)")];
assert_eq!(write(|r| pairs.write_attrs(r)), r#" safe="1""#);
}
}