Skip to main content

rumdl_lib/utils/
html_elements.rs

1//! Facts about HTML elements shared by the rules that read inline HTML.
2
3/// The void elements of the HTML standard, plus `param`, which browsers still
4/// parse as one. Each is complete in its start tag: it holds no content, and no
5/// later closing tag belongs to it.
6///
7/// Sorted, so a lowercase tag name can be binary searched.
8pub const VOID_ELEMENTS: &[&str] = &[
9    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr",
10];
11
12/// Whether the lowercase tag `name` is a void element.
13pub fn is_void_element(name: &str) -> bool {
14    VOID_ELEMENTS.binary_search(&name).is_ok()
15}
16
17/// HTML's ASCII whitespace excludes vertical tab, unlike Rust's ASCII helper.
18pub fn is_html_whitespace(byte: &u8) -> bool {
19    matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'\x0c')
20}
21
22/// Find the end of a start tag without treating a quoted `>` as its end.
23/// `source` starts at the opening `<`; the returned offset includes `>`.
24pub fn start_tag_end(source: &str) -> Option<usize> {
25    let bytes = source.as_bytes();
26    let mut i = 1;
27    while i < bytes.len() && !is_html_whitespace(&bytes[i]) && !matches!(bytes[i], b'>' | b'/') {
28        i += 1;
29    }
30    while i < bytes.len() {
31        while bytes.get(i).is_some_and(is_html_whitespace) {
32            i += 1;
33        }
34        match bytes.get(i)? {
35            b'>' => return Some(i + 1),
36            b'/' => {
37                i += 1;
38                continue;
39            }
40            _ => {}
41        }
42        i += usize::from(bytes[i] == b'=');
43        while i < bytes.len() && !is_html_whitespace(&bytes[i]) && !matches!(bytes[i], b'=' | b'>' | b'/') {
44            i += 1;
45        }
46        while bytes.get(i).is_some_and(is_html_whitespace) {
47            i += 1;
48        }
49        if bytes.get(i) != Some(&b'=') {
50            continue;
51        }
52        i += 1;
53        while bytes.get(i).is_some_and(is_html_whitespace) {
54            i += 1;
55        }
56        let quote = bytes.get(i).copied().filter(|b| matches!(b, b'\'' | b'"'));
57        i += usize::from(quote.is_some());
58        while i < bytes.len()
59            && match quote {
60                Some(quote) => bytes[i] != quote,
61                None => !is_html_whitespace(&bytes[i]) && bytes[i] != b'>',
62            }
63        {
64            i += 1;
65        }
66        i += usize::from(quote.is_some() && i < bytes.len());
67    }
68    None
69}
70
71/// Read an HTML attribute with the exact source range of its value.
72/// Slashes belong to unquoted values, including a slash immediately before
73/// `>`; they mark a self-closing tag only outside an attribute value.
74pub fn extract_attribute_with_range(tag: &str, attr_name: &str) -> Option<(String, std::ops::Range<usize>)> {
75    let bytes = tag.as_bytes();
76    let mut i = usize::from(bytes.first() == Some(&b'<'));
77    while i < bytes.len() && !is_html_whitespace(&bytes[i]) && !matches!(bytes[i], b'>' | b'/') {
78        i += 1;
79    }
80    while i < bytes.len() {
81        while bytes.get(i).is_some_and(is_html_whitespace) {
82            i += 1;
83        }
84        if bytes.get(i).is_none_or(|b| *b == b'>') {
85            break;
86        }
87        if bytes[i] == b'/' {
88            if bytes.get(i + 1) == Some(&b'>') {
89                break;
90            }
91            // HTML reconsumes after an unexpected solidus rather than
92            // dropping the following attributes.
93            i += 1;
94            continue;
95        }
96        let name_start = i;
97        // A leading '=' is an HTML parse error but belongs to the name.
98        i += usize::from(bytes[i] == b'=');
99        while i < bytes.len() && !is_html_whitespace(&bytes[i]) && !matches!(bytes[i], b'=' | b'>' | b'/') {
100            i += 1;
101        }
102        if name_start == i {
103            break;
104        }
105        let matches = tag[name_start..i].eq_ignore_ascii_case(attr_name);
106        while bytes.get(i).is_some_and(is_html_whitespace) {
107            i += 1;
108        }
109        if bytes.get(i) != Some(&b'=') {
110            // The first duplicate wins, even when it has no explicit value.
111            if matches {
112                return Some((String::new(), i..i));
113            }
114            continue;
115        }
116        i += 1;
117        while bytes.get(i).is_some_and(is_html_whitespace) {
118            i += 1;
119        }
120        let quote = bytes.get(i).copied().filter(|b| matches!(b, b'\'' | b'"'));
121        i += usize::from(quote.is_some());
122        let start = i;
123        while i < bytes.len()
124            && match quote {
125                Some(quote) => bytes[i] != quote,
126                None => !is_html_whitespace(&bytes[i]) && bytes[i] != b'>',
127            }
128        {
129            i += 1;
130        }
131        if matches {
132            return Some((tag[start..i].to_string(), start..i));
133        }
134        i += usize::from(quote.is_some() && i < bytes.len());
135    }
136    None
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn vertical_tab_belongs_to_an_attribute_name() {
145        let tag = "<a href\u{000B} href=\"https://еxample.com\">";
146        let (value, range) = extract_attribute_with_range(tag, "href").unwrap();
147        assert_eq!(value, "https://еxample.com");
148        assert_eq!(&tag[range], value);
149        assert!(!is_html_whitespace(&b'\x0b'));
150        for byte in [b' ', b'\t', b'\r', b'\n', b'\x0c'] {
151            assert!(is_html_whitespace(&byte));
152        }
153    }
154
155    #[test]
156    fn void_elements_are_sorted_for_binary_search() {
157        assert!(VOID_ELEMENTS.is_sorted(), "{VOID_ELEMENTS:?}");
158    }
159
160    #[test]
161    fn param_is_void_as_browsers_parse_it() {
162        assert!(is_void_element("param"));
163        assert!(is_void_element("br"));
164        assert!(!is_void_element("span"));
165    }
166
167    #[test]
168    fn attribute_range_selects_value_after_unicode_and_duplicate_text() {
169        for tag in [
170            "<a title=\"https://еxample.com\" href=\"https://еxample.com\">",
171            "<a title='é' href=https://еxample.com>",
172            "<a HREF = 'https://еxample.com' />",
173        ] {
174            let (value, range) = extract_attribute_with_range(tag, "href").unwrap();
175            assert_eq!(value, "https://еxample.com");
176            assert_eq!(&tag[range.clone()], value);
177            assert_eq!(range.start, tag.rfind("https://").unwrap());
178        }
179    }
180
181    #[test]
182    fn unquoted_attribute_preserves_slashes_including_before_tag_end() {
183        for tag in [
184            "<a href=https://example.com/docs>",
185            "<a href=https://example.com/>",
186            "<a href=https://example.com/ >",
187        ] {
188            let (value, range) = extract_attribute_with_range(tag, "href").unwrap();
189            assert_eq!(&tag[range], value);
190            assert!(value.starts_with("https://"));
191            if !tag.contains("docs") {
192                assert!(value.ends_with('/'));
193            }
194        }
195    }
196
197    #[test]
198    fn first_duplicate_attribute_wins_including_boolean_attribute() {
199        assert_eq!(
200            extract_attribute_with_range("<a href=safe href=unsafe>", "href")
201                .unwrap()
202                .0,
203            "safe"
204        );
205        assert_eq!(
206            extract_attribute_with_range("<a href href=unsafe>", "href").unwrap().0,
207            ""
208        );
209    }
210
211    #[test]
212    fn only_html_ascii_whitespace_separates_attributes() {
213        assert!(extract_attribute_with_range("<a title='é'\u{00a0}href='unsafe'>", "href").is_none());
214    }
215    #[test]
216    fn malformed_but_rendered_attributes_do_not_hide_href() {
217        for tag in [
218            "<a / href=\"https://еxample.com\">",
219            "<a title=\"x\"/href=\"https://еxample.com\">",
220            "<a =x href=\"https://еxample.com\">",
221            "<a title=\"x>y\" / href=\"https://еxample.com\">",
222        ] {
223            let (value, range) = extract_attribute_with_range(tag, "href").unwrap();
224            assert_eq!(value, "https://еxample.com");
225            assert_eq!(&tag[range], value);
226        }
227    }
228}