Skip to main content

molt/core/
attribute.rs

1//! HTML attributes.
2//!
3//! Ports `Winged-Swift/Sources/WingedSwift/core/Attribute.swift`.
4
5use crate::core::escape::write_escaped_attribute;
6use crate::core::url_policy;
7
8/// A single HTML attribute.
9///
10/// Values are escaped when the attribute is built, not when it is rendered — see
11/// [`crate::core::escape`].
12///
13/// # Examples
14/// ```
15/// use molt::core::Attribute;
16/// assert_eq!(Attribute::new("href", "/a?x=1&y=2").to_string(), r#" href="/a?x=1&y=2""#);
17/// assert_eq!(Attribute::boolean("required").to_string(), " required");
18/// ```
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Attribute {
21    key: String,
22    value: String,
23    is_boolean: bool,
24    /// When true, a URL attribute is written as stored. The URL policy reads this flag.
25    is_trusted_url: bool,
26}
27
28impl Attribute {
29    /// Creates an attribute, escaping the value.
30    pub fn new(key: impl Into<String>, value: impl AsRef<str>) -> Self {
31        let raw = value.as_ref();
32        let mut escaped = String::with_capacity(raw.len());
33        write_escaped_attribute(&mut escaped, raw);
34        Self {
35            key: key.into(),
36            value: escaped,
37            is_boolean: false,
38            is_trusted_url: false,
39        }
40    }
41
42    /// A URL attribute rendered as given, even when the value is a script URL.
43    ///
44    /// `href`, `src`, `action` and the other URL attributes render as `#` when the value
45    /// would run script. Use this only for a URL you wrote yourself. The value is still
46    /// attribute-escaped.
47    ///
48    /// # Examples
49    /// ```
50    /// use molt::prelude::*;
51    /// let link = a().add_attribute(Attribute::trusted_url("href", "javascript:print()"));
52    /// assert_eq!(link.render(), "<a href=\"javascript:print()\"></a>");
53    /// ```
54    #[must_use]
55    pub fn trusted_url(key: impl Into<String>, value: impl AsRef<str>) -> Self {
56        let mut attribute = Self::new(key, value);
57        attribute.is_trusted_url = true;
58        attribute
59    }
60
61    /// Creates an attribute **without** escaping the value.
62    ///
63    /// Winged-Swift uses this for the keys it controls itself — `Meta`'s `name`,
64    /// `property`, `charset` and `http-equiv` are all inserted with `escape: false`.
65    /// Never pass user data here.
66    pub fn raw(key: impl Into<String>, value: impl Into<String>) -> Self {
67        Self {
68            key: key.into(),
69            value: value.into(),
70            is_boolean: false,
71            is_trusted_url: false,
72        }
73    }
74
75    /// Creates a boolean attribute, which renders as a bare key.
76    ///
77    /// `hidden`, `checked`, `required`, `open`, `controls`, `autoplay`, `muted`.
78    pub fn boolean(key: impl Into<String>) -> Self {
79        Self {
80            key: key.into(),
81            value: String::new(),
82            is_boolean: true,
83            is_trusted_url: false,
84        }
85    }
86
87    /// The attribute name.
88    #[must_use]
89    pub fn key(&self) -> &str {
90        &self.key
91    }
92
93    /// The escaped attribute value. Empty for boolean attributes.
94    #[must_use]
95    pub fn value(&self) -> &str {
96        &self.value
97    }
98
99    /// Whether this attribute renders as a bare key.
100    #[must_use]
101    pub fn is_boolean(&self) -> bool {
102        self.is_boolean
103    }
104
105    /// Appends ` key="value"`, or ` key` for a boolean attribute, to a buffer.
106    ///
107    /// A name that could break out of the attribute — space, quotes, `=`, `<`, `>`, `/`
108    /// or a control character, and the empty name — is omitted. URL attributes are then
109    /// checked by [`url_policy`](crate::core::url_policy); a blocked value is written as `#`. The check sees the
110    /// stored value, so [`Self::raw`] is covered and [`Self::trusted_url`] is not.
111    pub(crate) fn write_into(&self, out: &mut String) {
112        if !is_safe_attribute_name(&self.key) {
113            return;
114        }
115        out.push(' ');
116        out.push_str(&self.key);
117        if self.is_boolean {
118            return;
119        }
120        out.push_str("=\"");
121        if self.is_trusted_url || !url_policy::is_blocked(&self.key, &self.value) {
122            out.push_str(&self.value);
123        } else {
124            out.push_str(url_policy::REPLACEMENT);
125        }
126        out.push('"');
127    }
128}
129
130/// An attribute name is emitted only when it cannot close the quotes or start a new
131/// attribute. An empty name is omitted for the same reason: ` ="..."` is not a name.
132fn is_safe_attribute_name(key: &str) -> bool {
133    !key.is_empty()
134        && !key.chars().any(|scalar| {
135            scalar.is_control() || matches!(scalar, ' ' | '"' | '\'' | '=' | '<' | '>' | '/')
136        })
137}
138
139impl std::fmt::Display for Attribute {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        let mut out = String::new();
142        self.write_into(&mut out);
143        f.write_str(&out)
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn value_is_escaped_at_construction() {
153        let attr = Attribute::new("title", r#"a "quoted" & <tagged> value"#);
154        assert_eq!(attr.value(), "a &quot;quoted&quot; &amp; <tagged> value");
155    }
156
157    /// Ports the boolean-attribute cases in `HTML14FeaturesTests`.
158    #[test]
159    fn boolean_attributes_render_as_a_bare_key() {
160        assert_eq!(Attribute::boolean("required").to_string(), " required");
161        assert!(Attribute::boolean("open").is_boolean());
162    }
163
164    #[test]
165    fn raw_attributes_are_not_escaped() {
166        assert_eq!(Attribute::raw("property", "og:title").value(), "og:title");
167    }
168
169    #[test]
170    fn non_boolean_attributes_render_with_a_quoted_value() {
171        assert_eq!(
172            Attribute::new("class", "card p-4").to_string(),
173            r#" class="card p-4""#
174        );
175    }
176
177    #[test]
178    fn a_quote_in_the_value_cannot_break_out_of_the_attribute() {
179        let attr = Attribute::new("class", r#"a" onload="alert(1)"#);
180        assert!(!attr.value().contains('"'));
181    }
182}