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 "quoted" & <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}