use crate::util::html_rewriter::rewrite_html;
use lol_html::html_content::ContentType;
use lol_html::{element, end_tag, text};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct HeadMeta {
pub title: String,
pub lang: String,
pub canonical: String,
}
#[must_use]
pub fn inject_before_head_close(html: &str, payload: &str) -> String {
if payload.is_empty() {
return html.to_string();
}
let payload_owned = payload.to_string();
let injected = Rc::new(Cell::new(false));
let injected_cb = Rc::clone(&injected);
let handler = element!("head", move |el| {
let pl = payload_owned.clone();
let cb = Rc::clone(&injected_cb);
let _ = el.on_end_tag(end_tag!(move |end| {
end.before(&pl, ContentType::Html);
cb.set(true);
Ok(())
}));
Ok(())
});
let out =
rewrite_html(html, vec![handler]).unwrap_or_else(|_| html.to_string());
if injected.get() {
out
} else {
html.to_string()
}
}
#[must_use]
pub fn extract_head_meta(html: &str) -> HeadMeta {
let title_buf: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
let title_done: Rc<Cell<bool>> = Rc::new(Cell::new(false));
let lang: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
let canonical: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
let title_buf_text = Rc::clone(&title_buf);
let title_done_text = Rc::clone(&title_done);
let title_text_handler = text!("title", move |t| {
if !title_done_text.get() {
title_buf_text.borrow_mut().push_str(t.as_str());
if t.last_in_text_node() {
title_done_text.set(true);
}
}
Ok(())
});
let lang_cb = Rc::clone(&lang);
let html_handler = element!("html", move |el| {
if let Some(value) = el.get_attribute("lang") {
*lang_cb.borrow_mut() = value;
}
Ok(())
});
let canonical_cb = Rc::clone(&canonical);
let canonical_handler = element!("link[rel~=\"canonical\" i]", move |el| {
if canonical_cb.borrow().is_empty() {
if let Some(href) = el.get_attribute("href") {
*canonical_cb.borrow_mut() = href;
}
}
Ok(())
});
let _ = rewrite_html(
html,
vec![title_text_handler, html_handler, canonical_handler],
);
let raw_title = title_buf.borrow().clone();
let title = collapse_ws(strip_tags(raw_title.trim()).trim());
let lang_val = lang.borrow().clone();
let canonical_val = canonical.borrow().clone();
HeadMeta {
title,
lang: lang_val,
canonical: canonical_val,
}
}
#[must_use]
pub fn remove_canonical_links(html: &str) -> String {
let handler = element!("link[rel~=\"canonical\" i]", |el| {
el.remove();
Ok(())
});
rewrite_html(html, vec![handler]).unwrap_or_else(|_| html.to_string())
}
#[must_use]
pub fn replace_canonical_link(html: &str, payload: &str) -> String {
let payload_owned = payload.to_string();
let injected = Rc::new(Cell::new(false));
let injected_cb = Rc::clone(&injected);
let canonical_handler = element!("link[rel~=\"canonical\" i]", |el| {
el.remove();
Ok(())
});
let head_handler = element!("head", move |el| {
let pl = payload_owned.clone();
let cb = Rc::clone(&injected_cb);
let _ = el.on_end_tag(end_tag!(move |end| {
end.before(&pl, ContentType::Html);
cb.set(true);
Ok(())
}));
Ok(())
});
let out = rewrite_html(html, vec![canonical_handler, head_handler])
.unwrap_or_else(|_| html.to_string());
if injected.get() {
out
} else {
html.to_string()
}
}
fn strip_tags(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_tag = false;
for ch in s.chars() {
match ch {
'<' => in_tag = true,
'>' => {
in_tag = false;
out.push(' ');
}
_ if !in_tag => out.push(ch),
_ => {}
}
}
out
}
fn collapse_ws(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_space = false;
for ch in s.chars() {
if ch.is_whitespace() {
if !prev_space {
out.push(' ');
prev_space = true;
}
} else {
out.push(ch);
prev_space = false;
}
}
out.trim().to_string()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn inject_at_real_head_close() {
let html = "<html><head><title>T</title></head><body></body></html>";
let out = inject_before_head_close(html, "<meta name=\"x\">");
assert!(out.contains("<meta name=\"x\"></head>"));
assert_eq!(out.matches("<meta name=\"x\">").count(), 1);
}
#[test]
fn inject_skips_pre_block_literal() {
let html = "<html><head><title>T</title></head>\
<body><pre></head></pre></body></html>";
let out = inject_before_head_close(html, "<meta name=\"x\">");
assert_eq!(out.matches("<meta name=\"x\">").count(), 1);
assert!(out.contains("<pre></head></pre>"));
}
#[test]
fn inject_skips_comment_literal() {
let html =
"<html><head><title>T</title></head><body><!-- </head> --></body></html>";
let out = inject_before_head_close(html, "<meta name=\"x\">");
assert_eq!(out.matches("<meta name=\"x\">").count(), 1);
assert!(out.contains("<!-- </head> -->"));
}
#[test]
fn inject_returns_input_when_no_head() {
let html = "<html><body>no head</body></html>";
let out = inject_before_head_close(html, "<meta>");
assert_eq!(out, html);
}
#[test]
fn inject_empty_payload_returns_input() {
let html = "<html><head></head></html>";
let out = inject_before_head_close(html, "");
assert_eq!(out, html);
}
#[test]
fn extract_title_from_real_title_not_comment() {
let html = "<html><head><!-- <title>Old</title> --><title>Real</title></head></html>";
let meta = extract_head_meta(html);
assert_eq!(meta.title, "Real");
}
#[test]
fn extract_lang_from_html_not_pre() {
let html = "<html lang=\"en-GB\"><head></head>\
<body><pre><html lang=\"fr\"></pre></body></html>";
let meta = extract_head_meta(html);
assert_eq!(meta.lang, "en-GB");
}
#[test]
fn extract_canonical_returns_href() {
let html = r#"<html><head><link rel="canonical" href="https://x"></head></html>"#;
let meta = extract_head_meta(html);
assert_eq!(meta.canonical, "https://x");
}
#[test]
fn extract_returns_defaults_when_absent() {
let html = "<html><head></head><body></body></html>";
let meta = extract_head_meta(html);
assert!(meta.title.is_empty());
assert!(meta.lang.is_empty());
assert!(meta.canonical.is_empty());
}
#[test]
fn extract_collapses_title_whitespace() {
let html = "<html><head><title> Hello World </title></head></html>";
let meta = extract_head_meta(html);
assert_eq!(meta.title, "Hello World");
}
#[test]
fn remove_double_quoted_canonical() {
let html = r#"<head><link rel="canonical" href="/old"><title>x</title></head>"#;
let out = remove_canonical_links(html);
assert!(!out.contains("rel=\"canonical\""));
assert!(out.contains("<title>x</title>"));
}
#[test]
fn remove_single_quoted_canonical() {
let html = "<head><link rel='canonical' href='/old'></head>";
let out = remove_canonical_links(html);
assert!(!out.contains("canonical"));
}
#[test]
fn remove_unquoted_canonical() {
let html = "<head><link rel=canonical href=/old></head>";
let out = remove_canonical_links(html);
assert!(!out.contains("canonical"));
}
#[test]
fn remove_keeps_non_canonical_link() {
let html = r#"<head><link rel="stylesheet" href="/x.css"></head>"#;
let out = remove_canonical_links(html);
assert_eq!(out, html);
}
#[test]
fn remove_multiple_canonicals() {
let html = r#"<head><link rel="canonical" href="/a"><link rel="canonical" href="/b"></head>"#;
let out = remove_canonical_links(html);
assert!(!out.contains("canonical"));
}
#[test]
fn remove_leaves_pre_literal_untouched() {
let html = "<html><head></head>\
<body><pre><link rel=\"canonical\"></pre></body></html>";
let out = remove_canonical_links(html);
assert!(out.contains("<pre><link rel=\"canonical\"></pre>"));
}
#[test]
fn replace_canonical_removes_old_and_injects_new() {
let html = r#"<html><head><title>T</title><link rel="canonical" href="/old"></head><body></body></html>"#;
let payload = r#"<link rel="canonical" href="/new">"#;
let out = replace_canonical_link(html, payload);
assert!(out.contains("href=\"/new\""));
assert!(!out.contains("href=\"/old\""));
assert_eq!(out.matches("rel=\"canonical\"").count(), 1);
}
#[test]
fn replace_canonical_injects_when_none_existed() {
let html = "<html><head><title>T</title></head><body></body></html>";
let payload = r#"<link rel="canonical" href="/new">"#;
let out = replace_canonical_link(html, payload);
assert!(out.contains("href=\"/new\""));
assert!(out.contains("href=\"/new\"></head>"));
}
#[test]
fn replace_canonical_returns_input_when_no_head() {
let html = "<html><body>nothing here</body></html>";
let payload = r#"<link rel="canonical" href="/new">"#;
let out = replace_canonical_link(html, payload);
assert_eq!(out, html);
}
#[test]
fn replace_canonical_is_idempotent() {
let html = r#"<html><head><title>T</title><link rel="canonical" href="/a"></head></html>"#;
let payload = r#"<link rel="canonical" href="/a">"#;
let once = replace_canonical_link(html, payload);
let twice = replace_canonical_link(&once, payload);
assert_eq!(twice.matches("rel=\"canonical\"").count(), 1);
assert!(twice.contains("href=\"/a\""));
}
#[test]
fn inject_handles_head_with_existing_children() {
let html = "<html><head><meta charset=\"utf-8\"><title>X</title>\
<link rel=\"stylesheet\" href=\"/a.css\"></head><body>b</body></html>";
let out =
inject_before_head_close(html, "<script src=\"/x.js\"></script>");
assert!(out.contains("<meta charset=\"utf-8\">"));
assert!(out.contains("<title>X</title>"));
assert!(out.contains("<link rel=\"stylesheet\" href=\"/a.css\">"));
assert!(out.contains("<script src=\"/x.js\"></script></head>"));
assert_eq!(out.matches("<script src=\"/x.js\">").count(), 1);
}
#[test]
fn remove_canonical_with_mixed_case_rel_value() {
let html = r#"<head><link rel="Canonical" href="/x"></head>"#;
let out = remove_canonical_links(html);
assert!(!out.contains("Canonical"));
}
}