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é & 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 — `&` becomes `&`, `<` 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 (`&`, `<`, `>`,
325/// `"`, `'`), decimal (`'`) and hex (`'`) numeric
326/// character references, plus ` `. 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 & b"), "a & b");
341/// assert_eq!(decode_html_entities("A"), "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 & 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 & 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 & 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("&"), "&");
641 assert_eq!(decode_html_entities("<>"), "<>");
642 assert_eq!(decode_html_entities("""), "\"");
643 assert_eq!(decode_html_entities("'"), "'");
644 assert_eq!(decode_html_entities(" "), "\u{00A0}");
645 }
646
647 #[test]
648 fn decode_html_entities_decimal_numeric_reference() {
649 assert_eq!(decode_html_entities("'"), "'");
650 assert_eq!(decode_html_entities("A"), "A");
651 }
652
653 #[test]
654 fn decode_html_entities_hex_numeric_reference() {
655 assert_eq!(decode_html_entities("'"), "'");
656 assert_eq!(decode_html_entities("A"), "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("�"), "�");
673 // 0xD800 is a surrogate — char::from_u32 returns None.
674 assert_eq!(decode_html_entities("�"), "�");
675 // Same surrogate rejection on the *decimal* reference path.
676 assert_eq!(decode_html_entities("�"), "�");
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("&"), 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é & 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}