#[allow(dead_code)]
pub fn find_tag_end(html: &str, tag_start: usize) -> usize {
let bytes = html.as_bytes();
let mut i = tag_start;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
match quote {
Some(q) if b == q => quote = None,
Some(_) => {}
None => match b {
b'"' | b'\'' => quote = Some(b),
b'>' => return i + 1,
_ => {}
},
}
i += 1;
}
bytes.len()
}
pub fn hreflang_attr(tag: &str, name: &str) -> Option<String> {
let bytes = tag.as_bytes();
let mut i = usize::from(bytes.first() == Some(&b'<'));
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>'
{
i += 1;
}
while i < bytes.len() {
while i < bytes.len()
&& (bytes[i].is_ascii_whitespace() || bytes[i] == b'/')
{
i += 1;
}
if i >= bytes.len() || bytes[i] == b'>' {
return None;
}
let name_start = i;
while i < bytes.len()
&& !bytes[i].is_ascii_whitespace()
&& bytes[i] != b'='
&& bytes[i] != b'>'
&& bytes[i] != b'/'
{
i += 1;
}
let attr_name = &tag[name_start..i];
let mut j = i;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j >= bytes.len() || bytes[j] != b'=' {
if attr_name.eq_ignore_ascii_case(name) {
return Some(String::new());
}
continue;
}
let mut k = j + 1;
while k < bytes.len() && bytes[k].is_ascii_whitespace() {
k += 1;
}
let (value_start, value_end, resume) =
if k < bytes.len() && (bytes[k] == b'"' || bytes[k] == b'\'') {
let quote = bytes[k] as char;
let vstart = k + 1;
let close = vstart + tag[vstart..].find(quote)?;
(vstart, close, close + 1)
} else {
let mut vend = k;
while vend < bytes.len()
&& !bytes[vend].is_ascii_whitespace()
&& bytes[vend] != b'>'
{
vend += 1;
}
(k, vend, vend)
};
if attr_name.eq_ignore_ascii_case(name) {
return Some(tag[value_start..value_end].to_string());
}
i = resume;
}
None
}
#[must_use]
pub fn strip_script_and_style(html: &str) -> String {
let mut out = html.as_bytes().to_vec();
let lower = html.to_ascii_lowercase();
for (open, close) in &[("<script", "</script"), ("<style", "</style")] {
let mut cursor = 0;
while let Some(rel) = lower[cursor..].find(open) {
let abs = cursor + rel;
let content_start = find_tag_end(&lower, abs);
let content_end = lower[content_start..]
.find(close)
.map_or(lower.len(), |e| content_start + e);
for b in &mut out[content_start..content_end] {
if !b.is_ascii_whitespace() {
*b = b' ';
}
}
cursor = content_end.max(abs + open.len());
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn find_tag_end_handles_quoted_gt() {
let h = "<img src=\"data:>foo\" alt=\"x\">rest";
let end = find_tag_end(h, 0);
assert_eq!(&h[..end], "<img src=\"data:>foo\" alt=\"x\">");
}
#[test]
fn hreflang_attr_handles_quotes() {
let tag = r#"<link rel='alternate' hreflang="en" href=/en/>"#;
assert_eq!(hreflang_attr(tag, "hreflang"), Some("en".to_string()));
assert_eq!(hreflang_attr(tag, "href"), Some("/en/".to_string()));
}
#[test]
fn hreflang_attr_missing_returns_none() {
assert_eq!(hreflang_attr("<a>", "href"), None);
}
#[test]
fn find_tag_end_returns_len_when_unterminated() {
let h = "<img src=\"x\" alt=\"y\"";
assert_eq!(find_tag_end(h, 0), h.len());
}
#[test]
fn find_tag_end_skips_gt_inside_single_quotes() {
let h = "<a href='data:>foo'>rest";
let end = find_tag_end(h, 0);
assert_eq!(&h[..end], "<a href='data:>foo'>");
}
#[test]
fn find_tag_end_simple_unquoted() {
let h = "<br>x";
assert_eq!(find_tag_end(h, 0), 4);
}
#[test]
fn hreflang_attr_handles_single_quotes() {
let tag = r#"<a href='single-quoted'>"#;
assert_eq!(
hreflang_attr(tag, "href"),
Some("single-quoted".to_string())
);
}
#[test]
fn hreflang_attr_unquoted_value_terminated_by_space() {
let tag = "<a href=/path other=1>";
assert_eq!(hreflang_attr(tag, "href"), Some("/path".to_string()));
}
#[test]
fn hreflang_attr_unquoted_value_terminated_by_gt() {
let tag = "<a href=/end>";
assert_eq!(hreflang_attr(tag, "href"), Some("/end".to_string()));
}
#[test]
fn hreflang_attr_case_insensitive_match() {
let tag = r#"<A HREF="upper">"#;
assert_eq!(hreflang_attr(tag, "href"), Some("upper".to_string()));
}
#[test]
fn hreflang_attr_unterminated_double_quote_returns_none() {
let tag = r#"<a href="never-closed"#;
assert_eq!(hreflang_attr(tag, "href"), None);
}
#[test]
fn hreflang_attr_unterminated_single_quote_returns_none() {
let tag = r#"<a href='never-closed"#;
assert_eq!(hreflang_attr(tag, "href"), None);
}
#[test]
fn hreflang_attr_valueless_attribute_returns_empty_value() {
let tag = "<img alt height=33 role=presentation width=100>";
assert_eq!(hreflang_attr(tag, "alt"), Some(String::new()));
assert_eq!(hreflang_attr(tag, "height"), Some("33".to_string()));
assert_eq!(hreflang_attr(tag, "width"), Some("100".to_string()));
}
#[test]
fn hreflang_attr_rejects_substring_attribute_names() {
let tag = r#"<a data-href="/wrong" hreflang="en-GB">"#;
assert_eq!(hreflang_attr(tag, "href"), None);
assert_eq!(hreflang_attr(tag, "lang"), None);
assert_eq!(hreflang_attr(tag, "hreflang"), Some("en-GB".to_string()));
}
#[test]
fn hreflang_attr_unquoted_mixed_case_value_preserved() {
let tag = "<meta http-equiv=Content-Security-Policy content=x>";
assert_eq!(
hreflang_attr(tag, "HTTP-EQUIV"),
Some("Content-Security-Policy".to_string())
);
}
#[test]
fn hreflang_attr_tolerates_whitespace_around_equals() {
let tag = r#"<a href = "/spaced">"#;
assert_eq!(hreflang_attr(tag, "href"), Some("/spaced".to_string()));
}
#[test]
fn hreflang_attr_ignores_tag_name_prefix() {
assert_eq!(hreflang_attr("<content x=1>", "content"), None);
}
#[test]
fn hreflang_attr_unterminated_tag_after_last_attr_returns_none() {
assert_eq!(hreflang_attr("<a foo=bar", "href"), None);
assert_eq!(hreflang_attr(r#"<a foo="bar""#, "href"), None);
}
#[test]
fn strip_script_and_style_blanks_embedded_markup() {
let html = "<script>var h='<a href=\"/ghost\">x</a>';</script>\
<style>a[href=\"/styled\"]{}</style>\
<a href=\"/real\">r</a>";
let s = strip_script_and_style(html);
assert_eq!(s.len(), html.len(), "offsets must be preserved");
assert!(!s.contains("/ghost"));
assert!(!s.contains("/styled"));
assert!(s.contains("/real"));
}
#[test]
fn strip_script_and_style_preserves_newlines() {
let html = "<script>\nline1\nline2\n</script>\n<p>after</p>";
let s = strip_script_and_style(html);
assert_eq!(
s.matches('\n').count(),
html.matches('\n').count(),
"newlines inside stripped regions must survive"
);
assert!(s.contains("<p>after</p>"));
}
#[test]
fn strip_script_and_style_unclosed_script_blanks_to_eof() {
let html = "<p>keep</p><script>var x='<a href=\"/js\">';";
let s = strip_script_and_style(html);
assert!(s.contains("keep"));
assert!(!s.contains("/js"));
}
#[test]
fn strip_script_and_style_handles_multibyte_content() {
let html = "<script>var s='héllo — “quoted”';</script><p>café</p>";
let s = strip_script_and_style(html);
assert!(s.contains("café"));
assert!(!s.contains("héllo"));
}
}