Skip to main content

ssg/util/
html_rewriter.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Thin wrapper around Cloudflare's [`lol_html`] streaming HTML rewriter.
5//!
6//! Replaces the previous fragile `str::find` / `str::rfind` rewriting
7//! that lived in the image, search, CSP, and html-fix plugins (issue
8//! #525). Compared to the old approach `lol_html`:
9//!
10//! - **Skips HTML comments.** `<!-- <img …> -->` is byte-identical in
11//!   the output rather than being half-rewritten.
12//! - **Preserves character entities.** `alt="Café &amp; bar"` round-trips
13//!   verbatim — no double-encoding, no entity decoding inside attribute
14//!   values.
15//! - **Streams.** Peak RSS stays flat regardless of input size; there is
16//!   no full-document DOM materialised in memory.
17//!
18//! ## API
19//!
20//! The crate-private functions [`rewrite_html`] and
21//! [`extract_text_with_filter`] cover every call site inside SSG.
22//! Plugins pass closure-based [`ElementContentHandlers`] or write to a
23//! `&mut String` aggregator and let the wrapper deal with feeding
24//! chunks through `lol_html::HtmlRewriter`.
25
26use crate::error::SsgError;
27use lol_html::html_content::TextChunk;
28use lol_html::{
29    element, end_tag, rewrite_str, ElementContentHandlers, RewriteStrSettings,
30    Selector,
31};
32use std::borrow::Cow;
33
34/// Run `lol_html` over `html` with the supplied
35/// `(selector, handlers)` pairs and return the rewritten document.
36///
37/// Maps any `lol_html` failure (selector parse error, encoder failure,
38/// memory-limit overflow) onto [`SsgError::Io`] with a synthetic
39/// `<lol_html>` path so callers don't have to deal with `lol_html`'s
40/// error types directly.
41///
42/// # Examples
43///
44/// ```rust
45/// use ssg::util::html_rewriter::rewrite_html;
46///
47/// // With no handlers the output is byte-identical to the input.
48/// let out = rewrite_html("<p>hi</p>", Vec::new()).unwrap();
49/// assert_eq!(out, "<p>hi</p>");
50/// ```
51///
52/// # Errors
53///
54/// Returns [`SsgError::Io`] if `lol_html` rejects any selector or
55/// fails while writing output chunks.
56pub fn rewrite_html<'h>(
57    html: &str,
58    handlers: Vec<(Cow<'h, Selector>, ElementContentHandlers<'h>)>,
59) -> Result<String, SsgError> {
60    let mut settings = RewriteStrSettings::new();
61    for h in handlers {
62        settings = settings.append_element_content_handler(h);
63    }
64    rewrite_str(html, settings).map_err(|e| {
65        SsgError::io(
66            std::io::Error::other(format!("lol_html rewrite failed: {e}")),
67            "<lol_html>",
68        )
69    })
70}
71
72/// Rewrites every element with its attributes in name order.
73///
74/// Attribute order carries no meaning in HTML, but it does change the
75/// bytes, so a golden-file suite sees a reordering as a diff even though
76/// nothing observable changed. Sorting first makes those comparisons
77/// stable — #466 asks for exactly this.
78///
79/// Real parsing via `lol_html` rather than scanning for `<` and quotes:
80/// attribute values legitimately contain both, and hand-rolled tag
81/// surgery over arbitrary HTML is the failure mode that put escaped
82/// `<span>` tags on a published page in v0.0.58.
83///
84/// # Examples
85///
86/// ```rust
87/// use ssg::util::html_rewriter::sort_attributes;
88///
89/// let out = sort_attributes(r#"<img src="a.png" alt="x" id="i">"#).unwrap();
90/// assert_eq!(out, r#"<img alt="x" id="i" src="a.png">"#);
91/// ```
92///
93/// # Errors
94///
95/// Appends `payload` immediately before the document's `</body>` end tag.
96///
97/// The counterpart to [`crate::util::head_dom::inject_before_head_close`],
98/// and added for the same reason: five call sites across `islands`,
99/// `view_transitions` and `search` were splicing at `html.rfind("</body>")`.
100/// A byte search cannot tell the document's end tag from the characters
101/// `</body>` sitting inside a comment, a script string or a `<pre>` block,
102/// and when it picks the wrong one the payload lands somewhere inert with
103/// nothing reporting it (ssg#570).
104///
105/// Only the first `</body>` in document order is used, so a page carrying a
106/// nested document — the generator wraps an already-complete document in a
107/// layout — gets one copy, in its own body, rather than one per match.
108///
109/// Returns the input unchanged when the document has no `<body>` or when the
110/// rewrite fails, so callers keep whatever fallback they had.
111///
112/// # Examples
113///
114/// ```
115/// use ssg::util::html_rewriter::inject_before_body_close;
116/// let html = "<html><body><p>hi</p></body></html>";
117/// let out = inject_before_body_close(html, "<script src=\"x.js\"></script>");
118/// assert!(out.contains("</script></body>"));
119/// ```
120#[must_use]
121pub fn inject_before_body_close(html: &str, payload: &str) -> String {
122    if payload.is_empty() {
123        return html.to_string();
124    }
125
126    let payload_owned = payload.to_string();
127    let injected = std::rc::Rc::new(std::cell::Cell::new(false));
128    let injected_cb = std::rc::Rc::clone(&injected);
129    // Nesting depth at the moment each `<body>` opens. The outermost is the
130    // document's own, and it is the one that must carry the payload.
131    //
132    // This is where `<body>` differs from `<head>`, and the difference is
133    // easy to get backwards: an outer `<head>` closes *before* a nested
134    // document begins, so "first end tag" is the document's own. An outer
135    // `<body>` closes *after* the nested one, so "first end tag" is the
136    // nested document's — injecting there puts the payload inside the
137    // embedded page instead of the real one.
138    let depth = std::rc::Rc::new(std::cell::Cell::new(0usize));
139    let depth_cb = std::rc::Rc::clone(&depth);
140
141    let handler = element!("body", move |el| {
142        let pl = payload_owned.clone();
143        let cb = std::rc::Rc::clone(&injected_cb);
144        let d = std::rc::Rc::clone(&depth_cb);
145        let opened_at = d.get();
146        d.set(opened_at + 1);
147        let _ = el.on_end_tag(end_tag!(move |end| {
148            d.set(d.get().saturating_sub(1));
149            if opened_at == 0 && !cb.get() {
150                end.before(&pl, lol_html::html_content::ContentType::Html);
151                cb.set(true);
152            }
153            Ok(())
154        }));
155        Ok(())
156    });
157
158    let out =
159        rewrite_html(html, vec![handler]).unwrap_or_else(|_| html.to_string());
160
161    if injected.get() {
162        out
163    } else {
164        html.to_string()
165    }
166}
167
168/// Injects `payload` before `</body>`, appending it when there is none.
169///
170/// Six call sites across `islands`, `view_transitions` and `search` each
171/// wrote this by hand as `rfind("</body>")` with an append fallback. The
172/// fallback matters — HTML allows the end tag to be omitted, and the parser
173/// fires no handler then — so it is stated once here rather than six times.
174///
175/// # Examples
176///
177/// ```
178/// use ssg::util::html_rewriter::inject_before_body_close_or_append;
179/// let out = inject_before_body_close_or_append("<html><body><p>x", "<i>y</i>");
180/// assert!(out.ends_with("<i>y</i>"), "no </body>, so appended: {out}");
181/// ```
182#[must_use]
183pub fn inject_before_body_close_or_append(html: &str, payload: &str) -> String {
184    let injected = inject_before_body_close(html, payload);
185    if injected == html {
186        format!("{html}{payload}")
187    } else {
188        injected
189    }
190}
191
192/// Returns [`SsgError::Io`] if `lol_html` fails to parse or rewrite.
193pub fn sort_attributes(html: &str) -> Result<String, SsgError> {
194    use lol_html::html_content::ContentType;
195
196    rewrite_html(
197        html,
198        vec![lol_html::element!("*", |el| {
199            let mut attrs: Vec<(String, String)> = el
200                .attributes()
201                .iter()
202                .map(|a| (a.name(), a.value()))
203                .collect();
204            if attrs.is_empty() {
205                return Ok(());
206            }
207            // Re-serialise even a single attribute. Minified HTML writes
208            // `lang=en` where unminified writes `lang="en"`; going back
209            // through lol_html quotes both the same way, so the output is
210            // canonical rather than merely sorted.
211            attrs.sort_by(|a, b| a.0.cmp(&b.0));
212
213            // Remove then re-add: lol_html has no reorder primitive, and
214            // set_attribute on an existing name overwrites in place
215            // rather than moving it.
216            let names: Vec<String> =
217                attrs.iter().map(|(n, _)| n.clone()).collect();
218            for n in &names {
219                el.remove_attribute(n);
220            }
221            for (n, v) in &attrs {
222                el.set_attribute(n, v).map_err(|e| {
223                    SsgError::io(
224                        std::io::Error::other(format!(
225                            "set_attribute({n}): {e}"
226                        )),
227                        "<lol_html>",
228                    )
229                })?;
230            }
231            let _ = ContentType::Html;
232            Ok(())
233        })],
234    )
235}
236
237/// Extract concatenated text content from every element matching
238/// `selector`, separating successive elements with a single space.
239///
240/// Returns the **decoded** text — `&amp;` becomes `&`, `&lt;` becomes
241/// `<`, and so on — matching the on-screen rendering rather than the
242/// raw HTML bytes. This is the right behaviour for search-index
243/// extraction (issue #525 AC4).
244///
245/// # Examples
246///
247/// ```rust
248/// use ssg::util::html_rewriter::extract_text_with_filter;
249///
250/// let html = "<h1>Hello</h1><h1>World</h1>";
251/// let texts = extract_text_with_filter(html, "h1").unwrap();
252/// assert_eq!(texts, vec!["Hello".to_string(), "World".to_string()]);
253/// ```
254///
255/// # Errors
256///
257/// Returns [`SsgError::Io`] if `lol_html` rejects the selector or
258/// fails while parsing the input.
259pub fn extract_text_with_filter(
260    html: &str,
261    selector: &str,
262) -> Result<Vec<String>, SsgError> {
263    use std::cell::RefCell;
264    use std::rc::Rc;
265
266    let parsed: Selector = selector.parse().map_err(|e| {
267        SsgError::io(
268            std::io::Error::other(format!(
269                "invalid selector {selector:?}: {e}"
270            )),
271            "<lol_html>",
272        )
273    })?;
274
275    // `Rc<RefCell<...>>` because `lol_html` handlers are owned closures
276    // that outlive any local borrow scope.
277    let buf: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
278    let scratch: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
279
280    let scratch_for_text = Rc::clone(&scratch);
281    let scratch_for_end = Rc::clone(&scratch);
282    let buf_for_end = Rc::clone(&buf);
283
284    let text_handler = move |t: &mut TextChunk<'_>| {
285        scratch_for_text.borrow_mut().push_str(t.as_str());
286        Ok(())
287    };
288
289    let element_handler =
290        move |el: &mut lol_html::html_content::Element<'_, '_>| {
291            let buf_for_handler = Rc::clone(&buf_for_end);
292            let scratch_for_handler = Rc::clone(&scratch_for_end);
293            let _ = el.on_end_tag(Box::new(move |_end| {
294                let mut s = scratch_for_handler.borrow_mut();
295                let decoded = decode_html_entities(s.trim());
296                let collapsed = collapse_whitespace(&decoded);
297                if !collapsed.is_empty() {
298                    buf_for_handler.borrow_mut().push(collapsed);
299                }
300                s.clear();
301                Ok(())
302            }));
303            Ok(())
304        };
305
306    let handlers = vec![
307        (
308            Cow::Owned(parsed.clone()),
309            ElementContentHandlers::default().text(text_handler),
310        ),
311        (
312            Cow::Owned(parsed),
313            ElementContentHandlers::default().element(element_handler),
314        ),
315    ];
316
317    let _ = rewrite_html(html, handlers)?;
318    let result = buf.borrow().clone();
319    Ok(result)
320}
321
322/// Minimal HTML entity decoder for text-extraction call sites.
323///
324/// Handles the five XML/HTML named references (`&amp;`, `&lt;`, `&gt;`,
325/// `&quot;`, `&apos;`), decimal (`&#39;`) and hex (`&#x27;`) numeric
326/// character references, plus `&nbsp;`. Unknown references pass through
327/// verbatim so the output for non-canonical input is the least
328/// surprising.
329///
330/// The set is deliberately narrow — search-index titles / headings /
331/// body text only ever contain these forms in practice, and a full
332/// HTML5-spec named-reference table would balloon binary size for no
333/// observable benefit.
334///
335/// # Examples
336///
337/// ```rust
338/// use ssg::util::html_rewriter::decode_html_entities;
339///
340/// assert_eq!(decode_html_entities("a &amp; b"), "a & b");
341/// assert_eq!(decode_html_entities("&#65;"), "A");
342/// assert_eq!(decode_html_entities("&unknown;"), "&unknown;");
343/// ```
344#[must_use]
345pub fn decode_html_entities(s: &str) -> String {
346    let mut out = String::with_capacity(s.len());
347    let bytes = s.as_bytes();
348    let mut i = 0;
349    while i < bytes.len() {
350        if bytes[i] == b'&' {
351            // Try to match a named or numeric reference up to the next ';'
352            if let Some(rel_semi) = s[i..].find(';') {
353                let entity = &s[i..=i + rel_semi];
354                if let Some(decoded) = decode_one_entity(entity) {
355                    out.push_str(&decoded);
356                    i += rel_semi + 1;
357                    continue;
358                }
359            }
360        }
361        // Walk a single UTF-8 scalar; the byte index `i` always sits on
362        // a char boundary because we never split inside a multi-byte
363        // sequence (we only advance past `&...;` runs which are ASCII).
364        // `next()` always yields `Some` because the while-loop bound
365        // (`i < bytes.len()`) guarantees at least one more byte — this
366        // is a true invariant, not a defensive/fallible branch, so it
367        // is asserted rather than handled as a distinct code path.
368        let Some(ch) = s[i..].chars().next() else {
369            // Unreachable per the invariant above; `unreachable!()`
370            // (not `.expect()`) keeps this out of `clippy::expect_used`,
371            // which this crate denies via `-D warnings` in CI.
372            unreachable!("i < bytes.len() guarantees a next char")
373        };
374        out.push(ch);
375        i += ch.len_utf8();
376    }
377    out
378}
379
380fn decode_one_entity(entity: &str) -> Option<String> {
381    // `entity` is `&...;` inclusive.
382    let inner = entity.strip_prefix('&')?.strip_suffix(';')?;
383    let decoded = match inner {
384        "amp" => '&',
385        "lt" => '<',
386        "gt" => '>',
387        "quot" => '"',
388        "apos" => '\'',
389        "nbsp" => '\u{00A0}',
390        n if n.starts_with("#x") || n.starts_with("#X") => {
391            let cp = u32::from_str_radix(&n[2..], 16).ok()?;
392            char::from_u32(cp)?
393        }
394        n if n.starts_with('#') => {
395            let cp = n[1..].parse::<u32>().ok()?;
396            char::from_u32(cp)?
397        }
398        _ => return None,
399    };
400    Some(decoded.to_string())
401}
402
403/// Collapses runs of ASCII whitespace into a single space and trims.
404///
405/// Matches the historical `strip_tags` behaviour in
406/// `src/plugins/search.rs` so the search index stays byte-identical
407/// across the port.
408///
409/// # Examples
410///
411/// ```rust
412/// use ssg::util::html_rewriter::collapse_whitespace;
413///
414/// assert_eq!(collapse_whitespace("  hello  world  "), "hello world");
415/// assert_eq!(collapse_whitespace("a\tb\nc"), "a b c");
416/// ```
417#[must_use]
418pub fn collapse_whitespace(s: &str) -> String {
419    let mut out = String::with_capacity(s.len());
420    let mut prev_space = false;
421    for ch in s.chars() {
422        if ch.is_whitespace() {
423            if !prev_space {
424                out.push(' ');
425                prev_space = true;
426            }
427        } else {
428            out.push(ch);
429            prev_space = false;
430        }
431    }
432    out.trim().to_string()
433}
434
435#[cfg(test)]
436mod tests {
437
438    /// HTML allows `</body>` to be omitted. The parser fires no end-tag
439    /// handler then, so the helper returns its input and the caller's
440    /// fallback still runs — which is what makes converting the `rfind`
441    /// call sites safe rather than a silent behaviour change.
442    #[test]
443    fn body_injection_returns_input_when_the_end_tag_is_omitted() {
444        let html = "<html><body><p>x</p>";
445        assert_eq!(inject_before_body_close(html, "<i>p</i>"), html);
446    }
447
448    /// `rfind("</body>")` cannot tell the document's end tag from those
449    /// characters inside a script string, and the payload lands inside the
450    /// script where it is a syntax error rather than markup.
451    #[test]
452    fn body_injection_ignores_a_body_close_inside_a_script() {
453        let html = concat!(
454            "<html><body><p>hi</p>",
455            "<script>var s = \"</body>\";</script>",
456            "</body></html>"
457        );
458        let out = inject_before_body_close(html, "<span id=\"p\"></span>");
459
460        let payload = out.find("<span id=\"p\">").expect("payload present");
461        let script_end = out.find("</script>").expect("script survives");
462        assert!(
463            payload > script_end,
464            "payload was injected inside the script:\n{out}"
465        );
466        assert_eq!(out.matches("<span id=\"p\">").count(), 1);
467    }
468
469    /// A page can carry a nested document; only its own body takes the
470    /// payload.
471    #[test]
472    fn body_injection_targets_the_outermost_body() {
473        let html = concat!(
474            "<html><body><main>",
475            "<html><body>nested</body></html>",
476            "</main></body></html>"
477        );
478        let out = inject_before_body_close(html, "<i>x</i>");
479        assert_eq!(out.matches("<i>x</i>").count(), 1, "{out}");
480        // It must land in the page's own body, after the embedded document
481        // has closed — not inside it. The nested `</body>` comes first in
482        // document order, so "first end tag" would be the wrong one.
483        let payload = out.find("<i>x</i>").expect("payload");
484        let nested_close = out.find("nested</body>").expect("nested body");
485        assert!(
486            payload > nested_close,
487            "payload landed inside the embedded document:\n{out}"
488        );
489    }
490
491    /// No `<body>` at all: the caller keeps whatever fallback it had.
492    #[test]
493    fn body_injection_returns_input_when_there_is_no_body() {
494        let html = "<html><head><title>T</title></head></html>";
495        assert_eq!(inject_before_body_close(html, "<i>x</i>"), html);
496    }
497    /// Attribute values may legitimately contain `<`, `>` and quotes.
498    /// A scanner looking for those characters mangles them; a parser
499    /// does not. This is the case that makes the difference.
500    #[test]
501    fn sorting_preserves_values_containing_markup_characters() {
502        let html = r#"<a title="1 < 2 &amp; 3 > 0" href="/x" id="q">t</a>"#;
503        let out = sort_attributes(html).expect("sort");
504        // `<` is left raw inside an attribute value: that is valid HTML
505        // and lol_html does not gratuitously re-escape it. What matters
506        // is that the value survives intact and is not treated as markup.
507        assert!(out.contains(r#"title="1 < 2 &amp; 3 > 0""#), "{out}");
508        assert!(
509            out.find("href").unwrap() < out.find("id").unwrap()
510                && out.find("id").unwrap() < out.find("title").unwrap(),
511            "attributes not in name order: {out}"
512        );
513    }
514
515    /// Multi-byte text must survive untouched.
516    #[test]
517    fn sorting_preserves_multibyte_text() {
518        let html = r#"<p class="b" id="a">Ünïcödé — 日本語 ✓</p>"#;
519        let out = sort_attributes(html).expect("sort");
520        assert!(out.contains("Ünïcödé — 日本語 ✓"), "{out}");
521        // class < id already, so ordering is unchanged; the point of this
522        // test is that the multi-byte body survives the rewrite.
523        assert!(out.contains(r#"<p class="b" id="a">"#), "{out}");
524    }
525
526    /// A single attribute, or none, is left exactly as it was.
527    #[test]
528    fn sorting_is_a_no_op_below_two_attributes() {
529        for html in ["<p>x</p>", r#"<p id="a">x</p>"#] {
530            assert_eq!(sort_attributes(html).expect("sort"), html);
531        }
532    }
533
534    /// Sorting twice equals sorting once.
535    #[test]
536    fn sorting_attributes_is_idempotent() {
537        let html = r#"<div data-z="1" alt="a" class="c" id="i">x</div>"#;
538        let once = sort_attributes(html).expect("sort");
539        let twice = sort_attributes(&once).expect("sort");
540        assert_eq!(once, twice);
541    }
542
543    use super::*;
544    use lol_html::element;
545
546    #[test]
547    fn rewrite_html_noop_returns_input() {
548        let html = "<p>hello</p>";
549        let out = rewrite_html(html, Vec::new()).unwrap();
550        assert_eq!(out, html);
551    }
552
553    #[test]
554    fn rewrite_html_replace_text_via_handler() {
555        let html = "<p>hello <span>world</span></p>";
556        let out = rewrite_html(
557            html,
558            vec![element!("span", |el| {
559                el.set_inner_content(
560                    "rust",
561                    lol_html::html_content::ContentType::Text,
562                );
563                Ok(())
564            })],
565        )
566        .unwrap();
567        assert!(out.contains("rust"), "out={out}");
568    }
569
570    #[test]
571    fn extract_text_with_filter_decodes_entities() {
572        let html = "<title>My &amp; Title</title>";
573        let texts = extract_text_with_filter(html, "title").unwrap();
574        assert_eq!(texts, vec!["My & Title".to_string()]);
575    }
576
577    #[test]
578    fn extract_text_with_filter_collapses_whitespace() {
579        let html = "<h1>  hello   world  </h1>";
580        let texts = extract_text_with_filter(html, "h1").unwrap();
581        assert_eq!(texts, vec!["hello world".to_string()]);
582    }
583
584    #[test]
585    fn extract_text_with_filter_skips_empty_elements() {
586        let html = "<h2></h2><h2>real</h2>";
587        let texts = extract_text_with_filter(html, "h2").unwrap();
588        assert_eq!(texts, vec!["real".to_string()]);
589    }
590
591    #[test]
592    fn extract_text_with_filter_invalid_selector_errors() {
593        let err = extract_text_with_filter("<p></p>", ":::").unwrap_err();
594        assert!(
595            err.to_string().contains("invalid selector"),
596            "selector parse failure should surface as an Io error \
597             mentioning the selector, got: {err}"
598        );
599    }
600
601    #[test]
602    fn rewrite_html_maps_handler_failure_to_io_error() {
603        // A content handler that fails aborts the rewrite; the wrapper
604        // must map the `lol_html` error onto `SsgError::Io`.
605        let err = rewrite_html(
606            "<p>x</p>",
607            vec![element!("p", |_el| Err("boom".into()))],
608        )
609        .unwrap_err();
610        assert!(
611            err.to_string().contains("lol_html rewrite failed"),
612            "handler failure should be wrapped, got: {err}"
613        );
614    }
615
616    #[test]
617    fn extract_text_with_filter_propagates_parser_ambiguity_error() {
618        // `<xmp>` inside `<select>` is a documented lol_html parsing
619        // ambiguity — the streaming parser cannot decide whether the
620        // following bytes are raw text, so the rewrite fails and the
621        // error must propagate through `extract_text_with_filter`.
622        let html = "<h1>t</h1><select><xmp>a</xmp></select>";
623        let err = extract_text_with_filter(html, "h1").unwrap_err();
624        assert!(
625            err.to_string().contains("lol_html rewrite failed"),
626            "ambiguity should surface as a wrapped rewrite error, got: {err}"
627        );
628    }
629
630    #[test]
631    fn collapse_whitespace_basic() {
632        assert_eq!(collapse_whitespace("  a  b  "), "a b");
633        assert_eq!(collapse_whitespace(""), "");
634    }
635
636    // ── decode_html_entities ────────────────────────────────────────
637
638    #[test]
639    fn decode_html_entities_named_set() {
640        assert_eq!(decode_html_entities("&amp;"), "&");
641        assert_eq!(decode_html_entities("&lt;&gt;"), "<>");
642        assert_eq!(decode_html_entities("&quot;"), "\"");
643        assert_eq!(decode_html_entities("&apos;"), "'");
644        assert_eq!(decode_html_entities("&nbsp;"), "\u{00A0}");
645    }
646
647    #[test]
648    fn decode_html_entities_decimal_numeric_reference() {
649        assert_eq!(decode_html_entities("&#39;"), "'");
650        assert_eq!(decode_html_entities("&#65;"), "A");
651    }
652
653    #[test]
654    fn decode_html_entities_hex_numeric_reference() {
655        assert_eq!(decode_html_entities("&#x27;"), "'");
656        assert_eq!(decode_html_entities("&#X41;"), "A");
657    }
658
659    #[test]
660    fn decode_html_entities_unknown_named_passes_through() {
661        // Unrecognised named reference must be preserved verbatim so
662        // non-canonical input is left alone.
663        let s = "&foo;";
664        assert_eq!(decode_html_entities(s), s);
665    }
666
667    #[test]
668    fn decode_html_entities_invalid_numeric_passes_through() {
669        // u32 overflow / non-digit hex / out-of-range codepoint all
670        // fall through.
671        assert_eq!(decode_html_entities("&#xZZZZ;"), "&#xZZZZ;");
672        assert_eq!(decode_html_entities("&#99999999999;"), "&#99999999999;");
673        // 0xD800 is a surrogate — char::from_u32 returns None.
674        assert_eq!(decode_html_entities("&#xD800;"), "&#xD800;");
675        // Same surrogate rejection on the *decimal* reference path.
676        assert_eq!(decode_html_entities("&#55296;"), "&#55296;");
677    }
678
679    #[test]
680    fn decode_one_entity_rejects_malformed_delimiters() {
681        // Missing leading `&` and missing trailing `;` hit the two
682        // `strip_prefix`/`strip_suffix` early-outs directly.
683        assert_eq!(decode_one_entity("amp;"), None);
684        assert_eq!(decode_one_entity("&amp"), None);
685    }
686
687    #[test]
688    fn decode_html_entities_amp_without_semicolon() {
689        // A lone `&` with no matching `;` is treated as a literal.
690        assert_eq!(decode_html_entities("a & b"), "a & b");
691    }
692
693    #[test]
694    fn decode_html_entities_handles_multibyte_chars() {
695        // Make sure the UTF-8 walker doesn't choke on multi-byte input.
696        assert_eq!(decode_html_entities("café &amp; bar"), "café & bar");
697    }
698
699    #[test]
700    fn decode_html_entities_empty_string() {
701        assert_eq!(decode_html_entities(""), "");
702    }
703
704    // ── extract_text_with_filter additional coverage ────────────────
705
706    #[test]
707    fn extract_text_with_filter_handles_nested_elements() {
708        let html = "<div><p>outer <span>inner</span> tail</p></div>";
709        let texts = extract_text_with_filter(html, "p").unwrap();
710        assert_eq!(texts.len(), 1);
711        assert!(texts[0].contains("outer"));
712        assert!(texts[0].contains("inner"));
713    }
714
715    #[test]
716    fn extract_text_with_filter_multiple_matches_returns_separate_strings() {
717        let html = "<h2>one</h2><h2>two</h2><h2>three</h2>";
718        let texts = extract_text_with_filter(html, "h2").unwrap();
719        assert_eq!(
720            texts,
721            vec!["one".to_string(), "two".to_string(), "three".to_string(),]
722        );
723    }
724
725    #[test]
726    fn extract_text_with_filter_no_match_returns_empty() {
727        let html = "<div>not a heading</div>";
728        let texts = extract_text_with_filter(html, "h1").unwrap();
729        assert!(texts.is_empty());
730    }
731
732    // ── collapse_whitespace edge cases ──────────────────────────────
733
734    #[test]
735    fn collapse_whitespace_tabs_and_newlines() {
736        assert_eq!(collapse_whitespace("a\tb\nc\r\nd"), "a b c d");
737    }
738
739    #[test]
740    fn collapse_whitespace_only_whitespace_returns_empty() {
741        assert_eq!(collapse_whitespace("   \t\n   "), "");
742    }
743}