Skip to main content

winged_rust/core/
attribute.rs

1//! HTML attributes.
2//!
3//! Ports `Winged-Swift/Sources/WingedSwift/core/Attribute.swift`.
4
5use crate::core::escape::write_escaped_attribute;
6
7/// A single HTML attribute.
8///
9/// Values are escaped when the attribute is built, not when it is rendered — see
10/// [`crate::core::escape`].
11///
12/// # Examples
13/// ```
14/// use winged_rust::core::Attribute;
15/// assert_eq!(Attribute::new("href", "/a?x=1&y=2").to_string(), r#" href="/a?x=1&y=2""#);
16/// assert_eq!(Attribute::boolean("required").to_string(), " required");
17/// ```
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Attribute {
20    key: String,
21    value: String,
22    is_boolean: bool,
23}
24
25impl Attribute {
26    /// Creates an attribute, escaping the value.
27    pub fn new(key: impl Into<String>, value: impl AsRef<str>) -> Self {
28        let raw = value.as_ref();
29        let mut escaped = String::with_capacity(raw.len());
30        write_escaped_attribute(&mut escaped, raw);
31        Self {
32            key: key.into(),
33            value: escaped,
34            is_boolean: false,
35        }
36    }
37
38    /// Creates an attribute **without** escaping the value.
39    ///
40    /// Winged-Swift uses this for the keys it controls itself — `Meta`'s `name`,
41    /// `property`, `charset` and `http-equiv` are all inserted with `escape: false`.
42    /// Never pass user data here.
43    pub fn raw(key: impl Into<String>, value: impl Into<String>) -> Self {
44        Self {
45            key: key.into(),
46            value: value.into(),
47            is_boolean: false,
48        }
49    }
50
51    /// Creates a boolean attribute, which renders as a bare key.
52    ///
53    /// `hidden`, `checked`, `required`, `open`, `controls`, `autoplay`, `muted`.
54    pub fn boolean(key: impl Into<String>) -> Self {
55        Self {
56            key: key.into(),
57            value: String::new(),
58            is_boolean: true,
59        }
60    }
61
62    /// The attribute name.
63    #[must_use]
64    pub fn key(&self) -> &str {
65        &self.key
66    }
67
68    /// The escaped attribute value. Empty for boolean attributes.
69    #[must_use]
70    pub fn value(&self) -> &str {
71        &self.value
72    }
73
74    /// Whether this attribute renders as a bare key.
75    #[must_use]
76    pub fn is_boolean(&self) -> bool {
77        self.is_boolean
78    }
79
80    /// Appends ` key="value"`, or ` key` for a boolean attribute, to a buffer.
81    pub(crate) fn write_into(&self, out: &mut String) {
82        out.push(' ');
83        out.push_str(&self.key);
84        if !self.is_boolean {
85            out.push_str("=\"");
86            out.push_str(&self.value);
87            out.push('"');
88        }
89    }
90}
91
92impl std::fmt::Display for Attribute {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        let mut out = String::new();
95        self.write_into(&mut out);
96        f.write_str(&out)
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn value_is_escaped_at_construction() {
106        let attr = Attribute::new("title", r#"a "quoted" & <tagged> value"#);
107        assert_eq!(attr.value(), "a &quot;quoted&quot; &amp; <tagged> value");
108    }
109
110    /// Ports the boolean-attribute cases in `HTML14FeaturesTests`.
111    #[test]
112    fn boolean_attributes_render_as_a_bare_key() {
113        assert_eq!(Attribute::boolean("required").to_string(), " required");
114        assert!(Attribute::boolean("open").is_boolean());
115    }
116
117    #[test]
118    fn raw_attributes_are_not_escaped() {
119        assert_eq!(Attribute::raw("property", "og:title").value(), "og:title");
120    }
121
122    #[test]
123    fn non_boolean_attributes_render_with_a_quoted_value() {
124        assert_eq!(
125            Attribute::new("class", "card p-4").to_string(),
126            r#" class="card p-4""#
127        );
128    }
129
130    #[test]
131    fn a_quote_in_the_value_cannot_break_out_of_the_attribute() {
132        let attr = Attribute::new("class", r#"a" onload="alert(1)"#);
133        assert!(!attr.value().contains('"'));
134    }
135}