pub const VOID_ELEMENTS: &[&str] = &[
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr",
];
pub fn is_void_element(name: &str) -> bool {
VOID_ELEMENTS.binary_search(&name).is_ok()
}
pub fn is_html_whitespace(byte: &u8) -> bool {
matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'\x0c')
}
pub fn start_tag_end(source: &str) -> Option<usize> {
let bytes = source.as_bytes();
let mut i = 1;
while i < bytes.len() && !is_html_whitespace(&bytes[i]) && !matches!(bytes[i], b'>' | b'/') {
i += 1;
}
while i < bytes.len() {
while bytes.get(i).is_some_and(is_html_whitespace) {
i += 1;
}
match bytes.get(i)? {
b'>' => return Some(i + 1),
b'/' => {
i += 1;
continue;
}
_ => {}
}
i += usize::from(bytes[i] == b'=');
while i < bytes.len() && !is_html_whitespace(&bytes[i]) && !matches!(bytes[i], b'=' | b'>' | b'/') {
i += 1;
}
while bytes.get(i).is_some_and(is_html_whitespace) {
i += 1;
}
if bytes.get(i) != Some(&b'=') {
continue;
}
i += 1;
while bytes.get(i).is_some_and(is_html_whitespace) {
i += 1;
}
let quote = bytes.get(i).copied().filter(|b| matches!(b, b'\'' | b'"'));
i += usize::from(quote.is_some());
while i < bytes.len()
&& match quote {
Some(quote) => bytes[i] != quote,
None => !is_html_whitespace(&bytes[i]) && bytes[i] != b'>',
}
{
i += 1;
}
i += usize::from(quote.is_some() && i < bytes.len());
}
None
}
pub fn extract_attribute_with_range(tag: &str, attr_name: &str) -> Option<(String, std::ops::Range<usize>)> {
let bytes = tag.as_bytes();
let mut i = usize::from(bytes.first() == Some(&b'<'));
while i < bytes.len() && !is_html_whitespace(&bytes[i]) && !matches!(bytes[i], b'>' | b'/') {
i += 1;
}
while i < bytes.len() {
while bytes.get(i).is_some_and(is_html_whitespace) {
i += 1;
}
if bytes.get(i).is_none_or(|b| *b == b'>') {
break;
}
if bytes[i] == b'/' {
if bytes.get(i + 1) == Some(&b'>') {
break;
}
i += 1;
continue;
}
let name_start = i;
i += usize::from(bytes[i] == b'=');
while i < bytes.len() && !is_html_whitespace(&bytes[i]) && !matches!(bytes[i], b'=' | b'>' | b'/') {
i += 1;
}
if name_start == i {
break;
}
let matches = tag[name_start..i].eq_ignore_ascii_case(attr_name);
while bytes.get(i).is_some_and(is_html_whitespace) {
i += 1;
}
if bytes.get(i) != Some(&b'=') {
if matches {
return Some((String::new(), i..i));
}
continue;
}
i += 1;
while bytes.get(i).is_some_and(is_html_whitespace) {
i += 1;
}
let quote = bytes.get(i).copied().filter(|b| matches!(b, b'\'' | b'"'));
i += usize::from(quote.is_some());
let start = i;
while i < bytes.len()
&& match quote {
Some(quote) => bytes[i] != quote,
None => !is_html_whitespace(&bytes[i]) && bytes[i] != b'>',
}
{
i += 1;
}
if matches {
return Some((tag[start..i].to_string(), start..i));
}
i += usize::from(quote.is_some() && i < bytes.len());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vertical_tab_belongs_to_an_attribute_name() {
let tag = "<a href\u{000B} href=\"https://еxample.com\">";
let (value, range) = extract_attribute_with_range(tag, "href").unwrap();
assert_eq!(value, "https://еxample.com");
assert_eq!(&tag[range], value);
assert!(!is_html_whitespace(&b'\x0b'));
for byte in [b' ', b'\t', b'\r', b'\n', b'\x0c'] {
assert!(is_html_whitespace(&byte));
}
}
#[test]
fn void_elements_are_sorted_for_binary_search() {
assert!(VOID_ELEMENTS.is_sorted(), "{VOID_ELEMENTS:?}");
}
#[test]
fn param_is_void_as_browsers_parse_it() {
assert!(is_void_element("param"));
assert!(is_void_element("br"));
assert!(!is_void_element("span"));
}
#[test]
fn attribute_range_selects_value_after_unicode_and_duplicate_text() {
for tag in [
"<a title=\"https://еxample.com\" href=\"https://еxample.com\">",
"<a title='é' href=https://еxample.com>",
"<a HREF = 'https://еxample.com' />",
] {
let (value, range) = extract_attribute_with_range(tag, "href").unwrap();
assert_eq!(value, "https://еxample.com");
assert_eq!(&tag[range.clone()], value);
assert_eq!(range.start, tag.rfind("https://").unwrap());
}
}
#[test]
fn unquoted_attribute_preserves_slashes_including_before_tag_end() {
for tag in [
"<a href=https://example.com/docs>",
"<a href=https://example.com/>",
"<a href=https://example.com/ >",
] {
let (value, range) = extract_attribute_with_range(tag, "href").unwrap();
assert_eq!(&tag[range], value);
assert!(value.starts_with("https://"));
if !tag.contains("docs") {
assert!(value.ends_with('/'));
}
}
}
#[test]
fn first_duplicate_attribute_wins_including_boolean_attribute() {
assert_eq!(
extract_attribute_with_range("<a href=safe href=unsafe>", "href")
.unwrap()
.0,
"safe"
);
assert_eq!(
extract_attribute_with_range("<a href href=unsafe>", "href").unwrap().0,
""
);
}
#[test]
fn only_html_ascii_whitespace_separates_attributes() {
assert!(extract_attribute_with_range("<a title='é'\u{00a0}href='unsafe'>", "href").is_none());
}
#[test]
fn malformed_but_rendered_attributes_do_not_hide_href() {
for tag in [
"<a / href=\"https://еxample.com\">",
"<a title=\"x\"/href=\"https://еxample.com\">",
"<a =x href=\"https://еxample.com\">",
"<a title=\"x>y\" / href=\"https://еxample.com\">",
] {
let (value, range) = extract_attribute_with_range(tag, "href").unwrap();
assert_eq!(value, "https://еxample.com");
assert_eq!(&tag[range], value);
}
}
}