1pub const TYPE_1_BLOCK_ELEMENTS: &[&str] = &["pre", "script", "style", "textarea"];
12
13pub const BLOCK_ELEMENTS: &[&str] = &[
16 "address",
17 "article",
18 "aside",
19 "audio",
20 "base",
21 "basefont",
22 "blockquote",
23 "body",
24 "canvas",
25 "caption",
26 "center",
27 "col",
28 "colgroup",
29 "dd",
30 "details",
31 "dialog",
32 "dir",
33 "div",
34 "dl",
35 "dt",
36 "embed",
37 "fieldset",
38 "figcaption",
39 "figure",
40 "footer",
41 "form",
42 "frame",
43 "frameset",
44 "h1",
45 "h2",
46 "h3",
47 "h4",
48 "h5",
49 "h6",
50 "head",
51 "header",
52 "hr",
53 "html",
54 "iframe",
55 "legend",
56 "li",
57 "link",
58 "main",
59 "menu",
60 "menuitem",
61 "nav",
62 "noframes",
63 "noscript",
64 "object",
65 "ol",
66 "optgroup",
67 "option",
68 "p",
69 "param",
70 "picture",
71 "pre",
72 "script",
73 "search",
74 "section",
75 "source",
76 "style",
77 "summary",
78 "svg",
79 "table",
80 "tbody",
81 "td",
82 "template",
83 "textarea",
84 "tfoot",
85 "th",
86 "thead",
87 "title",
88 "tr",
89 "track",
90 "ul",
91 "video",
92];
93
94pub const NON_SPEC_BLOCK_ELEMENTS: &[&str] = &[
100 "audio", "canvas", "embed", "noscript", "object", "picture", "source", "svg", "template", "video",
101];
102
103pub fn parse_html_block_start(trimmed: &str) -> Option<(String, bool)> {
112 let after_bracket = trimmed.strip_prefix('<')?;
113 if after_bracket.is_empty() {
114 return None;
115 }
116 let is_closing = after_bracket.starts_with('/');
117 let tag_start = if is_closing { &after_bracket[1..] } else { after_bracket };
118
119 let tag_name = tag_start
120 .chars()
121 .take_while(|c| c.is_ascii_alphabetic() || *c == '-' || c.is_ascii_digit())
122 .collect::<String>()
123 .to_lowercase();
124
125 let rest = &tag_start[tag_name.len()..];
126 let terminated =
127 rest.is_empty() || rest.starts_with(|c: char| c.is_ascii_whitespace() || c == '>') || rest.starts_with("/>");
128
129 if terminated && !tag_name.is_empty() && BLOCK_ELEMENTS.contains(&tag_name.as_str()) {
130 Some((tag_name, is_closing))
131 } else {
132 None
133 }
134}
135
136pub fn opens_untagged_html_block(trimmed: &str) -> bool {
143 let Some(after_bracket) = trimmed.strip_prefix('<') else {
144 return false;
145 };
146 after_bracket.starts_with("!--")
147 || after_bracket.starts_with('?')
148 || after_bracket.starts_with("![CDATA[")
149 || after_bracket
150 .strip_prefix('!')
151 .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_alphabetic()))
152}
153
154pub fn complete_tag_len(text: &str) -> Option<usize> {
161 let bytes = text.as_bytes();
162 let mut i = 1;
163 if bytes.first() != Some(&b'<') {
164 return None;
165 }
166 let closing = bytes.get(1) == Some(&b'/');
167 if closing {
168 i += 1;
169 }
170 if !bytes.get(i).is_some_and(u8::is_ascii_alphabetic) {
171 return None;
172 }
173 while bytes.get(i).is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'-') {
174 i += 1;
175 }
176 let is_space = |b: Option<&u8>| matches!(b, Some(b' ' | b'\t'));
177 let skip_space = |mut i: usize| {
178 while is_space(bytes.get(i)) {
179 i += 1;
180 }
181 i
182 };
183 if closing {
184 i = skip_space(i);
185 return (bytes.get(i) == Some(&b'>')).then_some(i + 1);
186 }
187 loop {
188 let after_space = skip_space(i);
189 match bytes.get(after_space) {
190 Some(b'>') => return Some(after_space + 1),
191 Some(b'/') => return (bytes.get(after_space + 1) == Some(&b'>')).then_some(after_space + 2),
192 _ => {}
193 }
194 if after_space == i {
196 return None;
197 }
198 i = after_space;
199 if !bytes
200 .get(i)
201 .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_' || *b == b':')
202 {
203 return None;
204 }
205 while bytes
206 .get(i)
207 .is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b':' | b'-'))
208 {
209 i += 1;
210 }
211 let before_value = skip_space(i);
212 if bytes.get(before_value) != Some(&b'=') {
213 continue;
214 }
215 i = skip_space(before_value + 1);
216 match bytes.get(i) {
217 Some("e @ (b'"' | b'\'')) => {
218 let close = bytes[i + 1..].iter().position(|&b| b == quote)?;
219 i += close + 2;
220 }
221 Some(_) => {
222 let start = i;
223 while bytes
224 .get(i)
225 .is_some_and(|b| !b.is_ascii_whitespace() && !matches!(b, b'"' | b'\'' | b'=' | b'<' | b'>' | b'`'))
226 {
227 i += 1;
228 }
229 if i == start {
230 return None;
231 }
232 }
233 None => return None,
234 }
235 }
236}
237
238pub fn opens_tag_line_html_block(trimmed: &str) -> bool {
245 let Some(len) = complete_tag_len(trimmed) else {
246 return false;
247 };
248 let name_start = if trimmed.starts_with("</") { 2 } else { 1 };
249 let name: String = trimmed[name_start..]
250 .chars()
251 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
252 .collect::<String>()
253 .to_ascii_lowercase();
254 !TYPE_1_BLOCK_ELEMENTS.contains(&name.as_str()) && trimmed[len..].trim_matches([' ', '\t']).is_empty()
255}
256
257#[cfg(test)]
258mod tests {
259 use super::{complete_tag_len, opens_tag_line_html_block, opens_untagged_html_block, parse_html_block_start};
260
261 #[test]
262 fn untagged_html_block_openers_are_the_spec_start_conditions() {
263 for (line, expected) in [
264 ("<!-- note -->", true),
265 ("<!--", true),
266 ("<?php echo 1; ?>", true),
267 ("<!DOCTYPE html>", true),
268 ("<![CDATA[x]]>", true),
269 ("<!>", false),
270 ("<!1>", false),
271 ("<![cdata[x]]>", false),
272 ("<div>", false),
273 ("text <!-- note -->", false),
274 ("", false),
275 ] {
276 assert_eq!(opens_untagged_html_block(line), expected, "{line:?}");
277 }
278 }
279
280 #[test]
281 fn a_block_tag_name_needs_a_terminator() {
282 for (line, expected) in [
283 ("<div>", Some(("div".to_string(), false))),
284 ("<div class=\"x\">", Some(("div".to_string(), false))),
285 ("<div", Some(("div".to_string(), false))),
286 ("<div/>", Some(("div".to_string(), false))),
287 ("<DIV\tid=x>", Some(("div".to_string(), false))),
288 ("</div>", Some(("div".to_string(), true))),
289 ("</div", Some(("div".to_string(), true))),
290 ("<pre>", Some(("pre".to_string(), false))),
291 ("<h1>", Some(("h1".to_string(), false))),
292 ("<div.class>", None),
293 ("<p,", None),
294 ("<div/x>", None),
295 ("<div=1>", None),
296 ("<span>", None),
297 ("<div-custom>", None),
298 ("<h1foo>", None),
299 ("<", None),
300 ("</", None),
301 ("text <div>", None),
302 ] {
303 assert_eq!(parse_html_block_start(line), expected, "{line:?}");
304 }
305 }
306
307 #[test]
308 fn a_complete_tag_is_measured_to_its_closing_bracket() {
309 for (text, expected) in [
310 ("<img />", Some(7)),
311 ("<img/>", Some(6)),
312 ("<a href=\"x y\">text", Some(14)),
313 ("<a href='x'>", Some(12)),
314 ("<a href=x>", Some(10)),
315 ("<a href = x title>", Some(18)),
316 ("<a\thidden>", Some(10)),
317 ("</span >", Some(8)),
318 ("<custom-el data-x=\"1\">", Some(22)),
319 ("<a href=\"x>", None),
320 ("<a href=>", None),
321 ("<a href=\"x\"title=\"y\">", None),
322 ("</span x>", None),
323 ("<1a>", None),
324 ("<a", None),
325 ("< a>", None),
326 ("text", None),
327 ] {
328 assert_eq!(complete_tag_len(text), expected, "{text:?}");
329 }
330 }
331
332 #[test]
333 fn a_complete_tag_alone_on_the_line_opens_a_type_seven_block() {
334 for (line, expected) in [
335 ("<img alt=\"a b c\" src=\"x\" />", true),
336 ("<span> ", true),
337 ("</span>", true),
338 ("<div>", true),
339 ("<img src=\"x\" /> and text", false),
340 ("<img alt=\"a b", false),
341 ("<pre>", false),
342 ("<SCRIPT>", false),
343 ("</textarea>", false),
344 ("<http://example.com>", false),
345 ("text <span>", false),
346 ] {
347 assert_eq!(opens_tag_line_html_block(line), expected, "{line:?}");
348 }
349 }
350}