rumdl_lib/utils/
html_elements.rs1pub const VOID_ELEMENTS: &[&str] = &[
9 "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr",
10];
11
12pub fn is_void_element(name: &str) -> bool {
14 VOID_ELEMENTS.binary_search(&name).is_ok()
15}
16
17pub fn is_html_whitespace(byte: &u8) -> bool {
19 matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'\x0c')
20}
21
22pub 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
71pub 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 i += 1;
94 continue;
95 }
96 let name_start = i;
97 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 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}