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);
}
impl<T: AttrSpread + ?Sized> AttrSpread for &T {
fn write_attrs(&self, r: &mut dyn Renderer) {
(**self).write_attrs(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);
}
}
}
impl<K: AsRef<str>, V: AsRef<str>> AttrSpread for [(K, V)] {
fn write_attrs(&self, 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;
}
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);
}
}
#[derive(Debug, Default, Clone)]
pub struct ClassList {
names: Vec<String>,
}
impl ClassList {
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 write_attr(&self, name: &str, r: &mut dyn Renderer) {
if !self.is_empty() {
write_pair(name, &self.to_value(), r);
}
}
}
pub trait ClassItem {
fn add_to(&self, list: &mut ClassList);
}
impl<T: ClassItem + ?Sized> ClassItem for &T {
fn add_to(&self, list: &mut ClassList) {
(**self).add_to(list);
}
}
impl<T: ClassItem> ClassItem for Option<T> {
fn add_to(&self, list: &mut ClassList) {
if let Some(item) = self {
item.add_to(list);
}
}
}
impl ClassItem for str {
fn add_to(&self, list: &mut ClassList) {
list.add(self);
}
}
impl ClassItem for String {
fn add_to(&self, list: &mut ClassList) {
list.add(self);
}
}
impl ClassItem for Cow<'_, str> {
fn add_to(&self, list: &mut ClassList) {
list.add(self.as_ref());
}
}
#[derive(Debug, Default, Clone)]
pub struct DataSet {
entries: Vec<(String, Option<String>)>,
}
impl DataSet {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, key: &str, value: impl Into<String>) {
self.put(key, Some(value.into()));
}
pub fn insert_bare(&mut self, key: &str) {
self.put(key, None);
}
pub fn remove(&mut self, key: &str) {
self.entries.retain(|(seen, _)| seen != key);
}
fn put(&mut self, key: &str, value: Option<String>) {
if !is_attr_name_safe(key) {
debug_assert!(false, "`{key}` is not usable as a data attribute name");
return;
}
match self.entries.iter_mut().find(|(seen, _)| seen == key) {
Some(entry) => entry.1 = value,
None => self.entries.push((key.to_string(), value)),
}
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn write_attrs(&self, r: &mut dyn Renderer) {
for (key, value) in &self.entries {
r.write_raw(" data-");
r.write_escaped(&key.as_str());
if let Some(value) = value {
r.write_raw("=\"");
r.write_escaped(&value.as_str());
r.write_raw("\"");
}
}
}
}
impl AttrSpread for DataSet {
fn write_attrs(&self, r: &mut dyn Renderer) {
DataSet::write_attrs(self, r);
}
}
pub trait DataItem {
fn add_to(&self, set: &mut DataSet);
}
impl<T: DataItem + ?Sized> DataItem for &T {
fn add_to(&self, set: &mut DataSet) {
(**self).add_to(set);
}
}
impl<T: DataItem> DataItem for Option<T> {
fn add_to(&self, set: &mut DataSet) {
if let Some(item) = self {
item.add_to(set);
}
}
}
impl DataItem for DataSet {
fn add_to(&self, set: &mut DataSet) {
for (key, value) in &self.entries {
set.put(key, value.clone());
}
}
}
impl<K: AsRef<str>, V: DataValue> DataItem for [(K, V)] {
fn add_to(&self, set: &mut DataSet) {
for (key, value) in self {
value.add_to(key.as_ref(), set);
}
}
}
impl<K: AsRef<str>, V: DataValue, const N: usize> DataItem for [(K, V); N] {
fn add_to(&self, set: &mut DataSet) {
self.as_slice().add_to(set);
}
}
impl<K: AsRef<str>, V: DataValue> DataItem for Vec<(K, V)> {
fn add_to(&self, set: &mut DataSet) {
self.as_slice().add_to(set);
}
}
impl<K: AsRef<str>, V: DataValue> DataItem for BTreeMap<K, V> {
fn add_to(&self, set: &mut DataSet) {
for (key, value) in self {
value.add_to(key.as_ref(), set);
}
}
}
impl<K: AsRef<str>, V: DataValue, S> DataItem for HashMap<K, V, S> {
fn add_to(&self, set: &mut DataSet) {
let mut pairs: Vec<(&str, &V)> = self.iter().map(|(k, v)| (k.as_ref(), v)).collect();
pairs.sort_by_key(|(key, _)| *key);
for (key, value) in pairs {
value.add_to(key, set);
}
}
}
pub trait DataValue {
fn add_to(&self, key: &str, set: &mut DataSet);
}
impl<T: DataValue + ?Sized> DataValue for &T {
fn add_to(&self, key: &str, set: &mut DataSet) {
(**self).add_to(key, set);
}
}
impl<T: DataValue> DataValue for Option<T> {
fn add_to(&self, key: &str, set: &mut DataSet) {
if let Some(value) = self {
value.add_to(key, set);
}
}
}
impl DataValue for bool {
fn add_to(&self, key: &str, set: &mut DataSet) {
if *self {
set.insert_bare(key);
}
}
}
impl DataValue for str {
fn add_to(&self, key: &str, set: &mut DataSet) {
set.insert(key, self);
}
}
impl DataValue for String {
fn add_to(&self, key: &str, set: &mut DataSet) {
set.insert(key, self.as_str());
}
}
impl DataValue for Cow<'_, str> {
fn add_to(&self, key: &str, set: &mut DataSet) {
set.insert(key, self.as_ref());
}
}
macro_rules! data_value_via_display {
($($t:ty),* $(,)?) => {$(
impl DataValue for $t {
fn add_to(&self, key: &str, set: &mut DataSet) {
set.insert(key, self.to_string());
}
}
)*};
}
data_value_via_display!(
char, u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize, f32, f64
);
#[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 DataItem) -> String {
let mut set = DataSet::new();
item.add_to(&mut set);
write(|r| set.write_attrs(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 = DataSet::new();
[("a", "1"), ("b", "2")].add_to(&mut set);
[("a", "9")].add_to(&mut set);
assert_eq!(write(|r| set.write_attrs(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 = DataSet::new();
base.insert("controller", "modal");
base.insert_bare("open");
assert_eq!(
write(|r| AttrSpread::write_attrs(&base, r)),
r#" data-controller="modal" data-open"#
);
let mut set = DataSet::new();
base.add_to(&mut set);
set.remove("open");
assert_eq!(write(|r| set.write_attrs(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""#);
}
}