Skip to main content

ssg/util/
head_dom.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Parser-driven helpers for HTML `<head>` manipulation.
5//!
6//! Replaces the previous `str::find` / `str::replace` patterns scattered
7//! across the SEO, `og_image`, `llm`, `ai`, `i18n`, `highlight`, `sbom`,
8//! `atom`, `json_feed`, and `jsonld` plugins (issues #538, #539, #540).
9//!
10//! Three operations are exposed:
11//!
12//! - [`inject_before_head_close`] — append a payload immediately before
13//!   the real `</head>` end-tag, never inside a `<pre>` / `<code>` /
14//!   comment / `<script>` template that happens to contain the literal
15//!   `</head>` string.
16//! - [`extract_head_meta`] — single `lol_html` walk that pulls the
17//!   document `<title>`, the `<html lang>` attribute, and the existing
18//!   `<link rel="canonical">` href in one pass.
19//! - [`remove_canonical_links`] — strip every
20//!   `<link rel~="canonical">` in `<head>` without disturbing
21//!   non-canonical `<link>` elements or matching literals embedded in
22//!   `<pre>` blocks.
23//!
24//! All three guard against the standard `str::find` failure modes:
25//! comments containing the literal markup, `<pre>` / `<code>` samples
26//! that quote the same tag, attribute order and quoting variants, and
27//! multiple matches in body content.
28
29use 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/// Metadata extracted from the document `<head>` in a single parser
36/// walk.
37///
38/// All fields default to the empty string when the corresponding markup
39/// is missing — matching the historical `str::find`-based helpers in
40/// `src/plugins/seo/helpers.rs` so call sites can swap in without
41/// branch-equivalence regressions.
42#[derive(Debug, Default, Clone, PartialEq, Eq)]
43pub struct HeadMeta {
44    /// Plain-text content of the first `<title>` element, with inner
45    /// tags stripped and whitespace collapsed. Empty if no `<title>`.
46    pub title: String,
47    /// Value of `<html lang="…">`. Empty if absent.
48    pub lang: String,
49    /// `href` of the first `<link rel="canonical">`. Empty if absent.
50    pub canonical: String,
51}
52
53/// Inserts `payload` immediately before the real `</head>` end-tag.
54///
55/// Returns the input unchanged when no `<head>` element exists or when
56/// the underlying `lol_html` rewrite fails (allocation exhaustion is the
57/// only documented failure mode). `payload` is treated as raw HTML so
58/// callers retain control over tag construction; callers that need
59/// escaping must do so themselves.
60///
61/// Idempotency is the **caller's** responsibility — this helper inserts
62/// unconditionally and will append a second payload on a second call.
63///
64/// # Examples
65///
66/// ```
67/// use ssg::util::head_dom::inject_before_head_close;
68/// let html = "<html><head><title>T</title></head><body></body></html>";
69/// let out = inject_before_head_close(html, "<meta name=\"x\">");
70/// assert!(out.contains("<meta name=\"x\"></head>"));
71/// ```
72#[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            // Only the first `</head>` in document order, which is the
87            // document's own. A page can carry a second, nested one: the
88            // generator wraps an already-complete document in a layout, so
89            // `<main>` holds a whole `<!DOCTYPE html>…<head>…` of its own.
90            // Injecting into every match put a copy of the payload inside
91            // `<body>` - a second canonical link, a second stylesheet, and
92            // on one example the only copy of the JSON-LD.
93            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
112/// Decodes the entities an HTML escaper produces, so `title` is genuinely the
113/// plain text this module documents it to be.
114///
115/// `<title>` arrives already escaped — it came out of rendered HTML. Every
116/// consumer treats the returned value as plain text and escapes it again on
117/// the way out, so without decoding here a title containing `&` round-trips
118/// to `&amp;amp;` and renders as a literal `&amp;`.
119///
120/// That is not hypothetical: it reached 1,494 of 6,856 pages on
121/// sebastienrousseau.com, in `og:title` and `twitter:title` — every social
122/// preview of a page whose title contains an ampersand. `og:description`
123/// escaped correctly on the same pages, because `extract_description` routes
124/// through `strip_tags`, which drops entities; only the title kept them. That
125/// asymmetry is what made it look like an escaping bug rather than a decoding
126/// one.
127///
128/// `&amp;` is decoded last. Doing it first would turn `&amp;lt;` into `&lt;`
129/// and then into `<`, re-introducing the injection the escaping prevented.
130fn decode_entities(s: &str) -> String {
131    if !s.contains('&') {
132        return s.to_string();
133    }
134    s.replace("&lt;", "<")
135        .replace("&gt;", ">")
136        .replace("&quot;", "\"")
137        .replace("&#39;", "'")
138        .replace("&apos;", "'")
139        .replace("&amp;", "&")
140}
141
142/// Extracts `{title, lang, canonical}` from `html` in a single
143/// `lol_html` pass.
144///
145/// - `title` is the text content of the first `<title>` element with
146///   inner tags stripped (matching the historical
147///   `extract_title` behaviour). Whitespace is collapsed and trimmed;
148///   returns the empty string when the element is missing or its text
149///   content is whitespace-only.
150/// - `lang` is the `lang` attribute on the root `<html>` element.
151///   `<html lang="…">` inside a `<pre>` block is ignored because
152///   `lol_html` only matches the real element.
153/// - `canonical` is the `href` attribute of the first
154///   `<link rel~="canonical">` in `<head>`. Selector matches the
155///   space-separated token set so `rel="canonical other-token"` is
156///   detected, while quoting style is irrelevant (the parser normalises
157///   it).
158///
159/// # Examples
160///
161/// ```
162/// use ssg::util::head_dom::extract_head_meta;
163/// let html = r#"<html lang="en"><head><title>Hi</title></head></html>"#;
164/// let meta = extract_head_meta(html);
165/// assert_eq!(meta.title, "Hi");
166/// assert_eq!(meta.lang, "en");
167/// ```
168#[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/// Removes every `<link rel~="canonical">` element from the document.
224///
225/// Uses the `link[rel~="canonical" i]` selector so any attribute order
226/// or quoting is handled by the parser, including `rel="canonical
227/// other-token"` (the space-separated token set match). Literal
228/// `<link rel="canonical">` text embedded inside a `<pre>` / `<code>`
229/// block or an HTML comment is left untouched because `lol_html` only
230/// dispatches on real elements.
231///
232/// # Examples
233///
234/// ```
235/// use ssg::util::head_dom::remove_canonical_links;
236/// let html = r#"<head><link rel="canonical" href="/old"></head>"#;
237/// let out = remove_canonical_links(html);
238/// assert!(!out.contains("canonical"));
239/// ```
240#[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/// Replaces every existing `<link rel~="canonical">` with a single
250/// `payload` injected just before `</head>`, in one parser pass.
251///
252/// Equivalent to `inject_before_head_close(&remove_canonical_links(html),
253/// payload)` but executed in a single `lol_html` walk so that the
254/// surrounding whitespace residue from the removal is consumed at the
255/// same time as the new link is inserted — keeping the canonical
256/// plugin's `transform_html` byte-stable across repeated invocations
257/// (idempotency requirement).
258///
259/// # Examples
260///
261/// ```
262/// use ssg::util::head_dom::replace_canonical_link;
263/// let html = r#"<html><head><link rel="canonical" href="/old"></head></html>"#;
264/// let out = replace_canonical_link(html, r#"<link rel="canonical" href="/new">"#);
265/// assert!(out.contains("href=\"/new\""));
266/// assert!(!out.contains("href=\"/old\""));
267/// ```
268#[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    /// The regression: a title with `&` must survive one escape, not two.
339    ///
340    /// `<title>` comes out of rendered HTML already escaped. Consumers treat
341    /// the extracted value as plain text and re-escape it, so leaving the
342    /// entity in place produced `&amp;amp;` — a literal `&amp;` on the page.
343    /// 1,494 of 6,856 pages on sebastienrousseau.com shipped that in
344    /// `og:title` and `twitter:title`.
345    #[test]
346    fn extracted_title_is_plain_text_not_escaped_markup() {
347        let html = "<html><head><title>AI, Payments &amp; Post-Quantum</title></head></html>";
348        assert_eq!(
349            extract_head_meta(html).title,
350            "AI, Payments & Post-Quantum"
351        );
352    }
353
354    /// Guards the decode order. `&amp;` must be decoded LAST: doing it first
355    /// turns `&amp;lt;` into `&lt;` and then into `<`, re-introducing exactly
356    /// the injection the escaping existed to prevent.
357    #[test]
358    fn decoding_does_not_resurrect_escaped_markup() {
359        let html =
360            "<html><head><title>Tricky &amp;lt;script&amp;gt; name</title></head></html>";
361        let title = extract_head_meta(html).title;
362        assert_eq!(title, "Tricky &lt;script&gt; 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 &lt;b&gt; &quot;c&quot; &#39;d&#39; &amp; 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    /// A page can contain a second, nested `<head>`: the generator wraps an
386    /// already-complete document inside a layout, so `<main>` holds a whole
387    /// `<!DOCTYPE html>…<head>…</head>…` of its own. Injecting into every
388    /// `<head>` puts a copy of the payload inside `<body>`.
389    ///
390    /// Measured on the plugins example, where twelve plugins use this helper:
391    /// two `/highlight.css` links, three canonical links, six alternate
392    /// links, ten Open Graph meta — with one of each pair, and the only copy
393    /// of the JSON-LD, landing in the body. Duplicate canonicals are worse
394    /// than useless; a search engine may honour neither.
395    #[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    /// ssg#540: a `</head>` that appears inside a comment or a script in the
417    /// head is not the head's end tag. A `find("</head>")` splice takes the
418    /// first byte match and injects *inside* that comment, where the payload
419    /// is inert — silently, because the output still looks like valid HTML.
420    #[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    /// The same hazard written as a script body rather than a comment.
443    #[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    // ── inject_before_head_close ────────────────────────────────────
460
461    #[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>&lt;/head&gt;</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>&lt;/head&gt;</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    // ── extract_head_meta ───────────────────────────────────────────
502
503    #[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>&lt;html lang=\"fr\"&gt;</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    // ── remove_canonical_links ──────────────────────────────────────
542
543    #[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>&lt;link rel=\"canonical\"&gt;</pre></body></html>";
583        let out = remove_canonical_links(html);
584        assert!(out.contains("<pre>&lt;link rel=\"canonical\"&gt;</pre>"));
585    }
586
587    // ── replace_canonical_link ───────────────────────────────────────
588
589    #[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        // Only one canonical present after replacement.
597        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        // Payload sits just before </head>.
607        assert!(out.contains("href=\"/new\"></head>"));
608    }
609
610    #[test]
611    fn replace_canonical_returns_input_when_no_head() {
612        // No <head> element => injector callback never fires => function
613        // returns the original string unchanged.
614        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        // After two passes, still exactly one canonical and same href.
627        assert_eq!(twice.matches("rel=\"canonical\"").count(), 1);
628        assert!(twice.contains("href=\"/a\""));
629    }
630
631    // ── inject_before_head_close: exercise multiple <head> rewrite ──
632
633    #[test]
634    fn inject_handles_head_with_existing_children() {
635        // Already-populated <head> with mixed children — inject must
636        // preserve every prior element AND append the payload exactly
637        // once just before </head>.
638        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        // Selector uses the case-insensitive flag — rel="CANONICAL" must
652        // still match.
653        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    // ── lol_html failure fallbacks ──────────────────────────────────
659    //
660    // `<xmp>` inside `<select>` is a documented lol_html parsing
661    // ambiguity: the rewrite fails and every helper must fall back to
662    // returning the input unchanged.
663
664    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    // ── extract_head_meta edge shapes ───────────────────────────────
691
692    #[test]
693    fn extract_title_uses_first_title_element_only() {
694        // A second <title> must not overwrite or append to the first —
695        // the `title_done` latch discards its text chunks.
696        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        // First canonical carries no href — the handler leaves the slot
704        // empty so the following canonical is captured instead.
705        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}