1use crate::util::html_rewriter::rewrite_html;
30use lol_html::html_content::ContentType;
31use lol_html::{element, end_tag, text};
32use std::cell::{Cell, RefCell};
33use std::rc::Rc;
34
35#[derive(Debug, Default, Clone, PartialEq, Eq)]
43pub struct HeadMeta {
44 pub title: String,
47 pub lang: String,
49 pub canonical: String,
51}
52
53#[must_use]
73pub fn inject_before_head_close(html: &str, payload: &str) -> String {
74 if payload.is_empty() {
75 return html.to_string();
76 }
77
78 let payload_owned = payload.to_string();
79 let injected = Rc::new(Cell::new(false));
80 let injected_cb = Rc::clone(&injected);
81
82 let handler = element!("head", move |el| {
83 let pl = payload_owned.clone();
84 let cb = Rc::clone(&injected_cb);
85 let _ = el.on_end_tag(end_tag!(move |end| {
86 if !cb.get() {
94 end.before(&pl, ContentType::Html);
95 cb.set(true);
96 }
97 Ok(())
98 }));
99 Ok(())
100 });
101
102 let out =
103 rewrite_html(html, vec![handler]).unwrap_or_else(|_| html.to_string());
104
105 if injected.get() {
106 out
107 } else {
108 html.to_string()
109 }
110}
111
112fn decode_entities(s: &str) -> String {
131 if !s.contains('&') {
132 return s.to_string();
133 }
134 s.replace("<", "<")
135 .replace(">", ">")
136 .replace(""", "\"")
137 .replace("'", "'")
138 .replace("'", "'")
139 .replace("&", "&")
140}
141
142#[must_use]
169pub fn extract_head_meta(html: &str) -> HeadMeta {
170 let title_buf: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
171 let title_done: Rc<Cell<bool>> = Rc::new(Cell::new(false));
172 let lang: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
173 let canonical: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
174
175 let title_buf_text = Rc::clone(&title_buf);
176 let title_done_text = Rc::clone(&title_done);
177 let title_text_handler = text!("title", move |t| {
178 if !title_done_text.get() {
179 title_buf_text.borrow_mut().push_str(t.as_str());
180 if t.last_in_text_node() {
181 title_done_text.set(true);
182 }
183 }
184 Ok(())
185 });
186
187 let lang_cb = Rc::clone(&lang);
188 let html_handler = element!("html", move |el| {
189 if let Some(value) = el.get_attribute("lang") {
190 *lang_cb.borrow_mut() = value;
191 }
192 Ok(())
193 });
194
195 let canonical_cb = Rc::clone(&canonical);
196 let canonical_handler = element!("link[rel~=\"canonical\" i]", move |el| {
197 if canonical_cb.borrow().is_empty() {
198 if let Some(href) = el.get_attribute("href") {
199 *canonical_cb.borrow_mut() = href;
200 }
201 }
202 Ok(())
203 });
204
205 let _ = rewrite_html(
206 html,
207 vec![title_text_handler, html_handler, canonical_handler],
208 );
209
210 let raw_title = title_buf.borrow().clone();
211 let title =
212 decode_entities(&collapse_ws(strip_tags(raw_title.trim()).trim()));
213 let lang_val = lang.borrow().clone();
214 let canonical_val = canonical.borrow().clone();
215
216 HeadMeta {
217 title,
218 lang: lang_val,
219 canonical: canonical_val,
220 }
221}
222
223#[must_use]
241pub fn remove_canonical_links(html: &str) -> String {
242 let handler = element!("link[rel~=\"canonical\" i]", |el| {
243 el.remove();
244 Ok(())
245 });
246 rewrite_html(html, vec![handler]).unwrap_or_else(|_| html.to_string())
247}
248
249#[must_use]
269pub fn replace_canonical_link(html: &str, payload: &str) -> String {
270 let payload_owned = payload.to_string();
271 let injected = Rc::new(Cell::new(false));
272 let injected_cb = Rc::clone(&injected);
273
274 let canonical_handler = element!("link[rel~=\"canonical\" i]", |el| {
275 el.remove();
276 Ok(())
277 });
278
279 let head_handler = element!("head", move |el| {
280 let pl = payload_owned.clone();
281 let cb = Rc::clone(&injected_cb);
282 let _ = el.on_end_tag(end_tag!(move |end| {
283 end.before(&pl, ContentType::Html);
284 cb.set(true);
285 Ok(())
286 }));
287 Ok(())
288 });
289
290 let out = rewrite_html(html, vec![canonical_handler, head_handler])
291 .unwrap_or_else(|_| html.to_string());
292
293 if injected.get() {
294 out
295 } else {
296 html.to_string()
297 }
298}
299
300fn strip_tags(s: &str) -> String {
301 let mut out = String::with_capacity(s.len());
302 let mut in_tag = false;
303 for ch in s.chars() {
304 match ch {
305 '<' => in_tag = true,
306 '>' => {
307 in_tag = false;
308 out.push(' ');
309 }
310 _ if !in_tag => out.push(ch),
311 _ => {}
312 }
313 }
314 out
315}
316
317fn collapse_ws(s: &str) -> String {
318 let mut out = String::with_capacity(s.len());
319 let mut prev_space = false;
320 for ch in s.chars() {
321 if ch.is_whitespace() {
322 if !prev_space {
323 out.push(' ');
324 prev_space = true;
325 }
326 } else {
327 out.push(ch);
328 prev_space = false;
329 }
330 }
331 out.trim().to_string()
332}
333
334#[cfg(test)]
335mod entity_decode_tests {
336 use super::*;
337
338 #[test]
346 fn extracted_title_is_plain_text_not_escaped_markup() {
347 let html = "<html><head><title>AI, Payments & Post-Quantum</title></head></html>";
348 assert_eq!(
349 extract_head_meta(html).title,
350 "AI, Payments & Post-Quantum"
351 );
352 }
353
354 #[test]
358 fn decoding_does_not_resurrect_escaped_markup() {
359 let html =
360 "<html><head><title>Tricky &lt;script&gt; name</title></head></html>";
361 let title = extract_head_meta(html).title;
362 assert_eq!(title, "Tricky <script> name");
363 assert!(
364 !title.contains("<script>"),
365 "decoding must not turn an escaped tag back into markup: {title:?}"
366 );
367 }
368
369 #[test]
370 fn all_escaper_entities_round_trip() {
371 let html = "<html><head><title>a <b> "c" 'd' & e</title></head></html>";
372 assert_eq!(extract_head_meta(html).title, r#"a <b> "c" 'd' & e"#);
373 }
374
375 #[test]
376 fn titles_without_entities_are_untouched() {
377 let html = "<html><head><title>Plain Title</title></head></html>";
378 assert_eq!(extract_head_meta(html).title, "Plain Title");
379 }
380}
381
382#[cfg(test)]
383mod tests {
384
385 #[test]
396 fn injection_targets_the_document_head_not_every_head() {
397 let html = concat!(
398 "<html><head><title>Outer</title></head><body><main>",
399 "<html><head><title>Nested</title></head><body>x</body></html>",
400 "</main></body></html>"
401 );
402 let out = inject_before_head_close(html, "<link rel=\"x\">");
403
404 assert_eq!(
405 out.matches("<link rel=\"x\">").count(),
406 1,
407 "payload was injected more than once:\n{out}"
408 );
409 let body_at = out.find("<body").expect("a body");
410 assert!(
411 out.find("<link rel=\"x\">").expect("the payload") < body_at,
412 "payload landed inside <body>:\n{out}"
413 );
414 }
415
416 #[test]
421 fn injection_ignores_a_head_close_inside_a_comment() {
422 let html = concat!(
423 "<html><head>",
424 "<!-- </head> -->",
425 "<title>T</title>",
426 "</head><body></body></html>"
427 );
428 let out = inject_before_head_close(html, "<meta name=\"x\">");
429
430 let naive = html.find("</head>").unwrap();
431 let real = out.find("<meta name=\"x\">").unwrap();
432 assert!(
433 real > naive,
434 "payload landed at the commented-out tag, not the real one: {out}"
435 );
436 assert!(
437 out.contains("<meta name=\"x\"></head>"),
438 "payload should sit immediately before the real close: {out}"
439 );
440 }
441
442 #[test]
444 fn injection_ignores_a_head_close_inside_a_script() {
445 let html = concat!(
446 "<html><head>",
447 "<script>var s = \"</head>\";</script>",
448 "</head><body></body></html>"
449 );
450 let out = inject_before_head_close(html, "<meta name=\"y\">");
451 assert!(
452 out.contains("<meta name=\"y\"></head>"),
453 "payload should sit before the real close: {out}"
454 );
455 }
456
457 use super::*;
458
459 #[test]
462 fn inject_at_real_head_close() {
463 let html = "<html><head><title>T</title></head><body></body></html>";
464 let out = inject_before_head_close(html, "<meta name=\"x\">");
465 assert!(out.contains("<meta name=\"x\"></head>"));
466 assert_eq!(out.matches("<meta name=\"x\">").count(), 1);
467 }
468
469 #[test]
470 fn inject_skips_pre_block_literal() {
471 let html = "<html><head><title>T</title></head>\
472 <body><pre></head></pre></body></html>";
473 let out = inject_before_head_close(html, "<meta name=\"x\">");
474 assert_eq!(out.matches("<meta name=\"x\">").count(), 1);
475 assert!(out.contains("<pre></head></pre>"));
476 }
477
478 #[test]
479 fn inject_skips_comment_literal() {
480 let html =
481 "<html><head><title>T</title></head><body><!-- </head> --></body></html>";
482 let out = inject_before_head_close(html, "<meta name=\"x\">");
483 assert_eq!(out.matches("<meta name=\"x\">").count(), 1);
484 assert!(out.contains("<!-- </head> -->"));
485 }
486
487 #[test]
488 fn inject_returns_input_when_no_head() {
489 let html = "<html><body>no head</body></html>";
490 let out = inject_before_head_close(html, "<meta>");
491 assert_eq!(out, html);
492 }
493
494 #[test]
495 fn inject_empty_payload_returns_input() {
496 let html = "<html><head></head></html>";
497 let out = inject_before_head_close(html, "");
498 assert_eq!(out, html);
499 }
500
501 #[test]
504 fn extract_title_from_real_title_not_comment() {
505 let html = "<html><head><!-- <title>Old</title> --><title>Real</title></head></html>";
506 let meta = extract_head_meta(html);
507 assert_eq!(meta.title, "Real");
508 }
509
510 #[test]
511 fn extract_lang_from_html_not_pre() {
512 let html = "<html lang=\"en-GB\"><head></head>\
513 <body><pre><html lang=\"fr\"></pre></body></html>";
514 let meta = extract_head_meta(html);
515 assert_eq!(meta.lang, "en-GB");
516 }
517
518 #[test]
519 fn extract_canonical_returns_href() {
520 let html = r#"<html><head><link rel="canonical" href="https://x"></head></html>"#;
521 let meta = extract_head_meta(html);
522 assert_eq!(meta.canonical, "https://x");
523 }
524
525 #[test]
526 fn extract_returns_defaults_when_absent() {
527 let html = "<html><head></head><body></body></html>";
528 let meta = extract_head_meta(html);
529 assert!(meta.title.is_empty());
530 assert!(meta.lang.is_empty());
531 assert!(meta.canonical.is_empty());
532 }
533
534 #[test]
535 fn extract_collapses_title_whitespace() {
536 let html = "<html><head><title> Hello World </title></head></html>";
537 let meta = extract_head_meta(html);
538 assert_eq!(meta.title, "Hello World");
539 }
540
541 #[test]
544 fn remove_double_quoted_canonical() {
545 let html = r#"<head><link rel="canonical" href="/old"><title>x</title></head>"#;
546 let out = remove_canonical_links(html);
547 assert!(!out.contains("rel=\"canonical\""));
548 assert!(out.contains("<title>x</title>"));
549 }
550
551 #[test]
552 fn remove_single_quoted_canonical() {
553 let html = "<head><link rel='canonical' href='/old'></head>";
554 let out = remove_canonical_links(html);
555 assert!(!out.contains("canonical"));
556 }
557
558 #[test]
559 fn remove_unquoted_canonical() {
560 let html = "<head><link rel=canonical href=/old></head>";
561 let out = remove_canonical_links(html);
562 assert!(!out.contains("canonical"));
563 }
564
565 #[test]
566 fn remove_keeps_non_canonical_link() {
567 let html = r#"<head><link rel="stylesheet" href="/x.css"></head>"#;
568 let out = remove_canonical_links(html);
569 assert_eq!(out, html);
570 }
571
572 #[test]
573 fn remove_multiple_canonicals() {
574 let html = r#"<head><link rel="canonical" href="/a"><link rel="canonical" href="/b"></head>"#;
575 let out = remove_canonical_links(html);
576 assert!(!out.contains("canonical"));
577 }
578
579 #[test]
580 fn remove_leaves_pre_literal_untouched() {
581 let html = "<html><head></head>\
582 <body><pre><link rel=\"canonical\"></pre></body></html>";
583 let out = remove_canonical_links(html);
584 assert!(out.contains("<pre><link rel=\"canonical\"></pre>"));
585 }
586
587 #[test]
590 fn replace_canonical_removes_old_and_injects_new() {
591 let html = r#"<html><head><title>T</title><link rel="canonical" href="/old"></head><body></body></html>"#;
592 let payload = r#"<link rel="canonical" href="/new">"#;
593 let out = replace_canonical_link(html, payload);
594 assert!(out.contains("href=\"/new\""));
595 assert!(!out.contains("href=\"/old\""));
596 assert_eq!(out.matches("rel=\"canonical\"").count(), 1);
598 }
599
600 #[test]
601 fn replace_canonical_injects_when_none_existed() {
602 let html = "<html><head><title>T</title></head><body></body></html>";
603 let payload = r#"<link rel="canonical" href="/new">"#;
604 let out = replace_canonical_link(html, payload);
605 assert!(out.contains("href=\"/new\""));
606 assert!(out.contains("href=\"/new\"></head>"));
608 }
609
610 #[test]
611 fn replace_canonical_returns_input_when_no_head() {
612 let html = "<html><body>nothing here</body></html>";
615 let payload = r#"<link rel="canonical" href="/new">"#;
616 let out = replace_canonical_link(html, payload);
617 assert_eq!(out, html);
618 }
619
620 #[test]
621 fn replace_canonical_is_idempotent() {
622 let html = r#"<html><head><title>T</title><link rel="canonical" href="/a"></head></html>"#;
623 let payload = r#"<link rel="canonical" href="/a">"#;
624 let once = replace_canonical_link(html, payload);
625 let twice = replace_canonical_link(&once, payload);
626 assert_eq!(twice.matches("rel=\"canonical\"").count(), 1);
628 assert!(twice.contains("href=\"/a\""));
629 }
630
631 #[test]
634 fn inject_handles_head_with_existing_children() {
635 let html = "<html><head><meta charset=\"utf-8\"><title>X</title>\
639 <link rel=\"stylesheet\" href=\"/a.css\"></head><body>b</body></html>";
640 let out =
641 inject_before_head_close(html, "<script src=\"/x.js\"></script>");
642 assert!(out.contains("<meta charset=\"utf-8\">"));
643 assert!(out.contains("<title>X</title>"));
644 assert!(out.contains("<link rel=\"stylesheet\" href=\"/a.css\">"));
645 assert!(out.contains("<script src=\"/x.js\"></script></head>"));
646 assert_eq!(out.matches("<script src=\"/x.js\">").count(), 1);
647 }
648
649 #[test]
650 fn remove_canonical_with_mixed_case_rel_value() {
651 let html = r#"<head><link rel="Canonical" href="/x"></head>"#;
654 let out = remove_canonical_links(html);
655 assert!(!out.contains("Canonical"));
656 }
657
658 const AMBIGUOUS: &str = "<html><head><title>T</title></head>\
665 <body><select><xmp>x</xmp></select></body></html>";
666
667 #[test]
668 fn inject_returns_input_when_rewrite_fails() {
669 let out = inject_before_head_close(AMBIGUOUS, "<meta name=\"x\">");
670 assert_eq!(out, AMBIGUOUS);
671 }
672
673 #[test]
674 fn remove_canonical_returns_input_when_rewrite_fails() {
675 let html = "<head><link rel=\"canonical\" href=\"/old\"></head>\
676 <select><xmp>x</xmp></select>";
677 let out = remove_canonical_links(html);
678 assert_eq!(out, html);
679 }
680
681 #[test]
682 fn replace_canonical_returns_input_when_rewrite_fails() {
683 let out = replace_canonical_link(
684 AMBIGUOUS,
685 "<link rel=\"canonical\" href=\"/new\">",
686 );
687 assert_eq!(out, AMBIGUOUS);
688 }
689
690 #[test]
693 fn extract_title_uses_first_title_element_only() {
694 let html = "<head><title>First</title><title>Second</title></head>";
697 let meta = extract_head_meta(html);
698 assert_eq!(meta.title, "First");
699 }
700
701 #[test]
702 fn extract_canonical_skips_link_without_href() {
703 let html = "<head><link rel=\"canonical\">\
706 <link rel=\"canonical\" href=\"/real\"></head>";
707 let meta = extract_head_meta(html);
708 assert_eq!(meta.canonical, "/real");
709 }
710
711 #[test]
712 fn extract_canonical_keeps_first_of_multiple_hrefs() {
713 let html = "<head><link rel=\"canonical\" href=\"/first\">\
714 <link rel=\"canonical\" href=\"/second\"></head>";
715 let meta = extract_head_meta(html);
716 assert_eq!(meta.canonical, "/first");
717 }
718}