Skip to main content

ssg/plugins/seo/
helpers.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Internal helper functions for SEO plugins.
5
6use crate::error::SsgError;
7use crate::util::head_dom::extract_head_meta;
8use std::path::{Path, PathBuf};
9
10/// Extract the page title from the `<title>` tag.
11///
12/// Backed by [`extract_head_meta`] so a comment containing
13/// `<!-- <title>…</title> -->` no longer fools the extractor and quoting
14/// / attribute-order variants on `<title>` (extremely rare but legal)
15/// are handled by the parser.
16///
17/// # Examples
18///
19/// ```rust
20/// use ssg::seo::helpers::extract_title;
21///
22/// let html = "<html><head><title>Hello</title></head></html>";
23/// assert_eq!(extract_title(html), "Hello");
24/// ```
25pub fn extract_title(html: &str) -> String {
26    extract_head_meta(html).title
27}
28
29/// Extract plain text from the page content, strip tags, and truncate to
30/// `max_len` characters.
31///
32/// Prefers `<main>` content if present. Falls back to `<body>` with nav,
33/// header, footer, script, and style blocks removed.
34pub(super) fn extract_description(html: &str, max_len: usize) -> String {
35    let content = extract_main_content(html);
36
37    let clean = strip_inline_tags(&content, &["script", "style"]);
38
39    let text = strip_tags(&clean);
40    let trimmed = text.trim();
41    truncate_at_word_boundary(trimmed, max_len)
42}
43
44/// Extracts the inner content of `<main>`, or falls back to `<body>` with
45/// non-content elements removed.
46fn extract_main_content(html: &str) -> String {
47    if let Some(inner) = extract_tag_inner(html, "main") {
48        return inner;
49    }
50
51    let body =
52        extract_tag_inner(html, "body").unwrap_or_else(|| html.to_string());
53    strip_inline_tags(&body, &["script", "style", "nav", "header", "footer"])
54}
55
56/// Extracts the inner HTML of the first occurrence of `<tag_name>...</tag_name>`.
57fn extract_tag_inner(html: &str, tag_name: &str) -> Option<String> {
58    let open = format!("<{tag_name}");
59    let close = format!("</{tag_name}>");
60    let start = html.find(&open)?;
61    let after = &html[start..];
62    let gt = after.find('>')?;
63    let inner = &after[gt + 1..];
64    if let Some(end) = inner.find(&close) {
65        Some(inner[..end].to_string())
66    } else {
67        Some(inner.to_string())
68    }
69}
70
71/// Removes matched `<tag>...</tag>` blocks for each tag name in `tags`.
72fn strip_inline_tags(html: &str, tags: &[&str]) -> String {
73    let mut clean = html.to_string();
74    for tag in tags {
75        let open = format!("<{tag}");
76        let close = format!("</{tag}>");
77        while let Some(start) = clean.find(&open) {
78            if let Some(end) = clean[start..].find(&close) {
79                clean.replace_range(start..start + end + close.len(), " ");
80            } else {
81                break;
82            }
83        }
84    }
85    clean
86}
87
88/// Truncates text to `max_len` at a word boundary.
89fn truncate_at_word_boundary(text: &str, max_len: usize) -> String {
90    if text.len() <= max_len {
91        return text.to_string();
92    }
93    let mut end = max_len;
94    while end > 0 && !text.is_char_boundary(end) {
95        end -= 1;
96    }
97    let truncated = &text[..end];
98    if let Some(last_space) = truncated.rfind(' ') {
99        truncated[..last_space].to_string()
100    } else {
101        truncated.to_string()
102    }
103}
104
105/// Remove all HTML tags and collapse whitespace.
106pub(super) fn strip_tags(html: &str) -> String {
107    let mut result = String::with_capacity(html.len());
108    let mut in_tag = false;
109    for ch in html.chars() {
110        match ch {
111            '<' => in_tag = true,
112            '>' => {
113                in_tag = false;
114                result.push(' ');
115            }
116            _ if !in_tag => result.push(ch),
117            _ => {}
118        }
119    }
120    // Collapse whitespace
121    let mut collapsed = String::with_capacity(result.len());
122    let mut prev_space = false;
123    for ch in result.chars() {
124        if ch.is_whitespace() {
125            if !prev_space {
126                collapsed.push(' ');
127                prev_space = true;
128            }
129        } else {
130            collapsed.push(ch);
131            prev_space = false;
132        }
133    }
134    collapsed.trim().to_string()
135}
136
137/// Collect all `.html` files under `dir` (delegates to `crate::walk`).
138#[allow(dead_code)] // used only by tests in seo::mod
139pub(super) fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
140    crate::walk::walk_files(dir, "html")
141}
142
143/// Longest HTML5 named reference (`CounterClockwiseContourIntegral`) is 31
144/// characters, so a well-formed `&name;` never exceeds 32 bytes past the `&`.
145const MAX_ENTITY_BYTES: usize = 32;
146
147/// If `bytes[start]` (a `&`) begins a well-formed character reference,
148/// return the index just past its `;`.
149///
150/// Accepts `&name;`, `&#NN;` and `&#xNN;`. The cap bounds the scan so a
151/// stray `&` in prose cannot walk the rest of the attribute.
152fn scan_existing_entity(bytes: &[u8], start: usize) -> Option<usize> {
153    let limit = bytes.len().min(start + MAX_ENTITY_BYTES);
154    let mut i = start + 1;
155    if i >= limit {
156        return None;
157    }
158    let numeric = bytes[i] == b'#';
159    if numeric {
160        i += 1;
161        if i < limit && (bytes[i] == b'x' || bytes[i] == b'X') {
162            i += 1;
163        }
164    }
165    let value_start = i;
166    while i < limit {
167        match bytes[i] {
168            b';' if i > value_start => return Some(i + 1),
169            b'0'..=b'9' => i += 1,
170            b'a'..=b'z' | b'A'..=b'Z' if !numeric => i += 1,
171            b'a'..=b'f' | b'A'..=b'F' if numeric => i += 1,
172            _ => return None,
173        }
174    }
175    None
176}
177
178/// Escape a string for safe inclusion in an HTML attribute value.
179///
180/// **Idempotent**: an already-formed character reference (`&amp;`, `&#39;`,
181/// `&#x27;`) is preserved verbatim rather than having its leading `&`
182/// re-escaped. This mirrors the contract `staticweaver` adopted for
183/// [ssg#589]; without it the two passes compose into `&amp;amp;`, because
184/// this function runs over values the template layer has already escaped.
185///
186/// That is not hypothetical — it shipped. `og:title` and `twitter:title` are
187/// built from already-escaped values, so a title containing `&` reached the
188/// page as `&amp;amp;` in 0.0.52 and 0.0.53 (see #706). The naive version
189/// looked correct in isolation and was covered by a test that only ever fed
190/// it raw characters.
191///
192/// [ssg#589]: https://github.com/sebastienrousseau/static-site-generator/issues/589
193pub(super) fn escape_attr(s: &str) -> String {
194    let bytes = s.as_bytes();
195    let mut out = String::with_capacity(s.len());
196    let mut i = 0;
197    let mut start = 0;
198    while i < bytes.len() {
199        let replacement = match bytes[i] {
200            b'&' => {
201                if let Some(end) = scan_existing_entity(bytes, i) {
202                    // Already a character reference — copy it through.
203                    i = end;
204                    continue;
205                }
206                "&amp;"
207            }
208            b'"' => "&quot;",
209            b'<' => "&lt;",
210            b'>' => "&gt;",
211            _ => {
212                i += 1;
213                continue;
214            }
215        };
216        if start < i {
217            out.push_str(&s[start..i]);
218        }
219        out.push_str(replacement);
220        i += 1;
221        start = i;
222    }
223    if start < s.len() {
224        out.push_str(&s[start..]);
225    }
226    out
227}
228
229/// Check for an actual `<meta` tag (not just an HTML comment marker).
230///
231/// Staticdatagen may emit empty comment blocks like:
232/// ```html
233/// <!-- # Start Open Graph / Facebook Meta Tags -->
234/// <!-- # End Open Graph / Facebook Meta Tags -->
235/// ```
236/// These should NOT count as "tag present" — only real `<meta` tags do.
237///
238/// # Examples
239///
240/// ```rust
241/// use ssg::seo::helpers::has_meta_tag;
242///
243/// let html = r#"<meta property="og:title" content="x">"#;
244/// assert!(has_meta_tag(html, "og:title"));
245/// assert!(!has_meta_tag("<!-- og:image -->", "og:image"));
246/// ```
247pub fn has_meta_tag(html: &str, attr: &str) -> bool {
248    html.contains(&format!("<meta property=\"{attr}\""))
249        || html.contains(&format!("<meta property='{attr}'"))
250        || html.contains(&format!("<meta name=\"{attr}\""))
251        || html.contains(&format!("<meta name='{attr}'"))
252}
253
254/// Extract the canonical URL from a `<link rel="canonical">` tag.
255///
256/// Backed by [`extract_head_meta`] so the result no longer depends on
257/// attribute order or quoting, and `<pre>` content containing a literal
258/// canonical link does not leak into the result.
259pub(super) fn extract_canonical(html: &str) -> String {
260    extract_head_meta(html).canonical
261}
262
263/// Extract the content of a specific meta tag by name or property.
264///
265/// Attribute-based matching: tolerant of attribute order, quoting style
266/// (double, single, unquoted), and case — minified HTML emits forms
267/// like `<meta content=x name=twitter:image>` that literal prefix
268/// matching misses.
269pub(super) fn extract_existing_meta(html: &str, attr: &str) -> String {
270    use std::cell::RefCell;
271    use std::rc::Rc;
272
273    use lol_html::element;
274
275    use crate::util::html_rewriter::rewrite_html;
276
277    // A real parser, not a `<meta` scan. The scan matched the literal bytes
278    // wherever they appeared — including inside an HTML comment, so a
279    // commented-out `<meta name="twitter:image">` left over from an edit
280    // won the lookup over the live tag that followed it. `lol_html` never
281    // reports comment contents as elements, so the class goes away rather
282    // than being special-cased.
283    let found: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
284    let sink = Rc::clone(&found);
285    let want = attr.to_ascii_lowercase();
286
287    let handlers = vec![element!("meta", move |el| {
288        if !sink.borrow().is_empty() {
289            return Ok(());
290        }
291        // Either `name` or `property` may carry the key; both are matched
292        // case-insensitively because the parser preserves author casing.
293        let keyed = ["name", "property"].iter().any(|k| {
294            el.get_attribute(k)
295                .is_some_and(|v| v.eq_ignore_ascii_case(&want))
296        });
297        if keyed {
298            if let Some(content) = el.get_attribute("content") {
299                let value = content.trim();
300                if !value.is_empty() {
301                    sink.borrow_mut().push_str(value);
302                }
303            }
304        }
305        Ok(())
306    })];
307
308    let _ = rewrite_html(html, handlers);
309
310    let out = found.borrow().clone();
311    out
312}
313
314/// Extract the `lang` attribute from the `<html>` tag.
315///
316/// Backed by [`extract_head_meta`] so a `<pre>` block containing
317/// `<html lang="…">` markup no longer confuses the extractor.
318pub(super) fn extract_html_lang(html: &str) -> String {
319    extract_head_meta(html).lang
320}
321
322/// Extract the first image URL from `<main>` or `<article>` content.
323pub(super) fn extract_first_content_image(html: &str) -> String {
324    // Look in <main> or <article> first
325    let search_region = if let Some(start) = html.find("<main") {
326        &html[start..]
327    } else if let Some(start) = html.find("<article") {
328        &html[start..]
329    } else {
330        return String::new();
331    };
332
333    if let Some(img_pos) = search_region.find("<img") {
334        let after_img = &search_region[img_pos..];
335        // Scan at most 500 bytes of the tag, stepping back to a char
336        // boundary: text in the tag may be multi-byte (a Tamil `alt`).
337        let mut tag_end = after_img.find('>').unwrap_or(500).min(500);
338        while !after_img.is_char_boundary(tag_end) {
339            tag_end -= 1;
340        }
341        let img_tag = &after_img[..tag_end];
342        if let Some(src_pos) = img_tag.find("src=\"") {
343            let after_src = &img_tag[src_pos + 5..];
344            if let Some(end) = after_src.find('"') {
345                return after_src[..end].to_string();
346            }
347        }
348    }
349    String::new()
350}
351
352/// Extract the author name from `<meta name="author">` or byline markup.
353pub(super) fn extract_meta_author(html: &str) -> String {
354    // Try meta tag first
355    let from_meta = extract_existing_meta(html, "author");
356    if !from_meta.is_empty() {
357        return from_meta;
358    }
359    // Try <span class="author"> or similar byline patterns
360    for pattern in &["class=\"author\">", "class='author'>", "rel=\"author\">"]
361    {
362        if let Some(pos) = html.find(pattern) {
363            let after = &html[pos + pattern.len()..];
364            if let Some(end) = after.find('<') {
365                let name = after[..end].trim();
366                // Strip "by " prefix
367                let name = name.strip_prefix("by ").unwrap_or(name).trim();
368                if !name.is_empty() {
369                    return name.to_string();
370                }
371            }
372        }
373    }
374    String::new()
375}
376
377/// Extract a date from an existing JSON-LD block in the HTML.
378pub(super) fn extract_date_from_html(
379    html: &str,
380    field: &str,
381) -> Option<String> {
382    let pattern = format!("\"{field}\":\"");
383    if let Some(pos) = html.find(&pattern) {
384        let after = &html[pos + pattern.len()..];
385        if let Some(end) = after.find('"') {
386            let date = &after[..end];
387            if !date.is_empty() {
388                return Some(date.to_string());
389            }
390        }
391    }
392    None
393}
394
395/// Extract a date from `<time datetime="...">` or `<meta property="article:published_time">`.
396pub(super) fn extract_meta_date(html: &str) -> Option<String> {
397    // Try article:published_time meta
398    let meta = extract_existing_meta(html, "article:published_time");
399    if !meta.is_empty() {
400        return Some(meta);
401    }
402    // Try first <time datetime="..."> in the page
403    if let Some(pos) = html.find("datetime=\"") {
404        let after = &html[pos + 10..];
405        if let Some(end) = after.find('"') {
406            let date = &after[..end];
407            if !date.is_empty() {
408                return Some(date.to_string());
409            }
410        }
411    }
412    None
413}
414
415/// Recursively collects HTML files (delegates to `crate::walk`).
416#[allow(dead_code)] // used only by tests in seo::mod
417pub(super) fn collect_html_files_recursive(
418    dir: &Path,
419) -> Result<Vec<PathBuf>, SsgError> {
420    crate::walk::walk_files(dir, "html")
421}
422
423#[cfg(test)]
424mod tests {
425
426    // ---- ssg#539 acceptance criteria -----------------------------------
427
428    /// AC1: a `<title>` inside an HTML comment must not win.
429    #[test]
430    fn ac1_title_ignores_commented_title() {
431        let html = "<html><head><!-- <title>Old</title> --><title>Real</title></head><body></body></html>";
432        assert_eq!(extract_title(html), "Real");
433    }
434
435    /// AC3: canonical is read from the real `<link rel=canonical>`.
436    #[test]
437    fn ac3_canonical_is_detected() {
438        let html = r#"<html><head><link rel="canonical" href="https://x"></head><body></body></html>"#;
439        assert_eq!(extract_canonical(html), "https://x");
440    }
441
442    /// A `<meta>` inside a comment must not be read as a real meta tag.
443    #[test]
444    fn ac_meta_ignores_commented_meta() {
445        let html = concat!(
446            "<html><head>",
447            "<!-- <meta name=\"twitter:image\" content=\"COMMENTED\"> -->",
448            "<meta name=\"twitter:image\" content=\"REAL\">",
449            "</head><body></body></html>"
450        );
451        assert_eq!(extract_existing_meta(html, "twitter:image"), "REAL");
452    }
453
454    /// A `<meta>` shown as escaped example text inside `<pre>` must not win.
455    #[test]
456    fn ac_meta_ignores_meta_in_pre_block() {
457        let html = concat!(
458            "<html><head><meta name=\"description\" content=\"REAL\"></head>",
459            "<body><pre>&lt;meta name=\"description\" content=\"EXAMPLE\"&gt;</pre></body></html>"
460        );
461        assert_eq!(extract_existing_meta(html, "description"), "REAL");
462    }
463
464    use super::*;
465    use std::fs;
466    use tempfile::tempdir;
467
468    #[test]
469    fn extract_title_from_html() {
470        let html = "<html><head><title>Test Page</title></head></html>";
471        assert_eq!(extract_title(html), "Test Page");
472    }
473
474    #[test]
475    fn extract_existing_meta_minified_unquoted_and_reordered() {
476        // Regression: minified HTML emits unquoted values with
477        // `content` before `name`/`property`.
478        let html = "<head><meta content=https://ex.test/img.png \
479                    name=twitter:image></head>";
480        assert_eq!(
481            extract_existing_meta(html, "twitter:image"),
482            "https://ex.test/img.png"
483        );
484        let html2 =
485            "<head><meta content=\"https://ex.test/og.png\" property=og:image></head>";
486        assert_eq!(
487            extract_existing_meta(html2, "og:image"),
488            "https://ex.test/og.png"
489        );
490    }
491
492    #[test]
493    fn extract_existing_meta_quoted_forms_still_work() {
494        let html = r#"<meta name="author" content="Alice">"#;
495        assert_eq!(extract_existing_meta(html, "author"), "Alice");
496        let html2 = r#"<meta property='og:image' content='/x.png'>"#;
497        assert_eq!(extract_existing_meta(html2, "og:image"), "/x.png");
498    }
499
500    #[test]
501    fn extract_existing_meta_absent_returns_empty() {
502        let html = "<head><meta name=viewport content=width=device-width>\
503                    </head>";
504        assert_eq!(extract_existing_meta(html, "og:image"), "");
505    }
506
507    #[test]
508    fn extract_existing_meta_skips_empty_content_and_keeps_scanning() {
509        let html = "<meta name=author content=\"\">\
510                    <meta name=author content=\"Bea\">";
511        assert_eq!(extract_existing_meta(html, "author"), "Bea");
512    }
513
514    #[test]
515    fn extract_title_empty_no_tag() {
516        let html = "<html><head></head><body>Hello</body></html>";
517        assert_eq!(extract_title(html), "");
518    }
519
520    #[test]
521    fn extract_title_empty_tag() {
522        let html = "<html><head><title></title></head></html>";
523        assert_eq!(extract_title(html), "");
524    }
525
526    #[test]
527    fn extract_title_nested_tags() {
528        let html = "<title><span>Inner</span></title>";
529        // strip_tags removes the inner span, leaving "Inner"
530        assert_eq!(extract_title(html), "Inner");
531    }
532
533    #[test]
534    fn extract_description_from_body() {
535        let html = "<html><body><main><p>Short description here.</p></main></body></html>";
536        let desc = extract_description(html, 200);
537        assert!(desc.contains("Short description here"));
538    }
539
540    #[test]
541    fn extract_description_truncation() {
542        let long_text = "word ".repeat(100);
543        let html = format!("<main><p>{long_text}</p></main>");
544        let desc = extract_description(&html, 50);
545        assert!(desc.len() <= 50);
546    }
547
548    #[test]
549    fn strip_tags_basic() {
550        assert_eq!(strip_tags("<p>Hello <b>world</b></p>"), "Hello world");
551    }
552
553    #[test]
554    fn strip_tags_empty() {
555        assert_eq!(strip_tags(""), "");
556    }
557
558    #[test]
559    fn strip_tags_no_tags() {
560        assert_eq!(strip_tags("plain text"), "plain text");
561    }
562
563    #[test]
564    fn strip_tags_self_closing() {
565        let result = strip_tags("<img src=\"x\"/>text");
566        assert!(result.contains("text"));
567        assert!(!result.contains("img"));
568    }
569
570    #[test]
571    fn truncate_short_text_unchanged() {
572        assert_eq!(truncate_at_word_boundary("short", 100), "short");
573    }
574
575    #[test]
576    fn truncate_long_text_at_word() {
577        let text = "one two three four five six";
578        let result = truncate_at_word_boundary(text, 15);
579        assert!(result.len() <= 15);
580        // Should cut at a space
581        assert!(!result.ends_with(' '));
582        assert_eq!(result, "one two three");
583    }
584
585    #[test]
586    fn truncate_unicode() {
587        let text = "日本語 テスト データ";
588        let result = truncate_at_word_boundary(text, 15);
589        // Must not panic on multi-byte boundaries
590        assert!(result.len() <= 15);
591    }
592
593    #[test]
594    fn collect_html_files_finds_files() {
595        let tmp = tempdir().unwrap();
596        let sub = tmp.path().join("sub");
597        fs::create_dir_all(&sub).unwrap();
598        fs::write(tmp.path().join("index.html"), "<html></html>").unwrap();
599        fs::write(sub.join("page.html"), "<html></html>").unwrap();
600
601        let files = collect_html_files(tmp.path()).unwrap();
602        assert_eq!(files.len(), 2);
603    }
604
605    #[test]
606    fn collect_html_files_recursive_finds_files() {
607        let tmp = tempdir().unwrap();
608        let sub = tmp.path().join("sub");
609        fs::create_dir_all(&sub).unwrap();
610        fs::write(tmp.path().join("index.html"), "<html></html>").unwrap();
611        fs::write(sub.join("page.html"), "<html></html>").unwrap();
612        fs::write(sub.join("style.css"), "body{}").unwrap();
613
614        let files = collect_html_files_recursive(tmp.path()).unwrap();
615        assert_eq!(files.len(), 2);
616        assert!(files.iter().all(|p| p.extension().unwrap() == "html"));
617    }
618
619    #[test]
620    fn collect_html_files_recursive_empty_dir() {
621        let tmp = tempdir().unwrap();
622        let files = collect_html_files_recursive(tmp.path()).unwrap();
623        assert!(files.is_empty(), "{:?}", files);
624    }
625
626    #[test]
627    fn escape_attr_special_chars() {
628        assert_eq!(escape_attr("a&b<c>d\"e"), "a&amp;b&lt;c&gt;d&quot;e");
629    }
630
631    /// #706: `og:title` / `twitter:title` are built from values the template
632    /// layer already escaped. A naive pass re-escapes the `&` of `&amp;` and
633    /// ships `&amp;amp;` to the page — 1,494 pages on one real corpus.
634    #[test]
635    fn escape_attr_preserves_existing_entities() {
636        assert_eq!(escape_attr("A &amp; B"), "A &amp; B");
637        assert_eq!(escape_attr("A &lt; B"), "A &lt; B");
638        assert_eq!(escape_attr("A &#39; B"), "A &#39; B");
639        assert_eq!(escape_attr("A &#x27; B"), "A &#x27; B");
640        assert_eq!(escape_attr("&nbsp;"), "&nbsp;");
641    }
642
643    /// The property the old test could not see, because it only ever fed
644    /// `escape_attr` raw characters: escaping twice must equal escaping once.
645    #[test]
646    fn escape_attr_is_idempotent() {
647        for input in [
648            "AI, Payments & Post-Quantum Cryptography",
649            "a&b<c>d\"e",
650            "Tom & Jerry's <b>show</b>",
651            "already &amp; escaped",
652            "mixed & already &amp; both",
653            "",
654            "no metacharacters at all",
655        ] {
656            let once = escape_attr(input);
657            let twice = escape_attr(&once);
658            assert_eq!(once, twice, "not idempotent for {input:?}");
659        }
660    }
661
662    /// A bare `&` in prose is still escaped — the entity scan must not treat
663    /// arbitrary following text as a reference, or it would silently emit
664    /// invalid markup.
665    #[test]
666    fn escape_attr_still_escapes_bare_ampersands() {
667        assert_eq!(escape_attr("Tom & Jerry"), "Tom &amp; Jerry");
668        assert_eq!(escape_attr("a & b & c"), "a &amp; b &amp; c");
669        // Unterminated / malformed references are not references.
670        assert_eq!(escape_attr("&amp"), "&amp;amp");
671        assert_eq!(escape_attr("&;"), "&amp;;");
672        assert_eq!(escape_attr("&#;"), "&amp;#;");
673        // Longer than the 32-byte cap: not a reference.
674        let long = format!("&{};", "a".repeat(40));
675        assert!(escape_attr(&long).starts_with("&amp;"));
676    }
677
678    #[test]
679    fn has_meta_tag_present() {
680        let html = r#"<meta property="og:title" content="Hi">"#;
681        assert!(has_meta_tag(html, "og:title"));
682    }
683
684    #[test]
685    fn has_meta_tag_absent() {
686        let html = "<html><head></head></html>";
687        assert!(!has_meta_tag(html, "og:title"));
688    }
689
690    #[test]
691    fn extract_canonical_found() {
692        let html = r#"<link rel="canonical" href="https://example.com/page">"#;
693        assert_eq!(extract_canonical(html), "https://example.com/page");
694    }
695
696    #[test]
697    fn extract_canonical_missing() {
698        let html = "<html><head></head></html>";
699        assert_eq!(extract_canonical(html), "");
700    }
701
702    #[test]
703    fn extract_existing_meta_by_name() {
704        let html = r#"<meta name="author" content="Alice">"#;
705        assert_eq!(extract_existing_meta(html, "author"), "Alice");
706    }
707
708    #[test]
709    fn extract_html_lang_found() {
710        let html = r#"<html lang="fr"><head></head></html>"#;
711        assert_eq!(extract_html_lang(html), "fr");
712    }
713
714    #[test]
715    fn extract_html_lang_missing() {
716        let html = "<html><head></head></html>";
717        assert_eq!(extract_html_lang(html), "");
718    }
719
720    #[test]
721    fn extract_date_from_html_found() {
722        let html = r#"{"datePublished":"2025-01-15"}"#;
723        assert_eq!(
724            extract_date_from_html(html, "datePublished"),
725            Some("2025-01-15".to_string())
726        );
727    }
728
729    #[test]
730    fn extract_date_from_html_missing() {
731        assert_eq!(
732            extract_date_from_html("<html></html>", "datePublished"),
733            None
734        );
735    }
736
737    #[test]
738    fn extract_existing_meta_skips_tag_without_content_attribute() {
739        // A matching meta tag that carries no `content` attribute at
740        // all must be skipped (scanning continues past it).
741        let html = "<meta name=\"author\">\
742                    <meta name=\"author\" content=\"Cid\">";
743        assert_eq!(extract_existing_meta(html, "author"), "Cid");
744    }
745
746    #[test]
747    fn extract_existing_meta_no_content_anywhere_returns_empty() {
748        let html = "<meta name=\"author\">";
749        assert_eq!(extract_existing_meta(html, "author"), "");
750    }
751
752    #[test]
753    fn extract_first_content_image_src_without_closing_quote() {
754        // `src="` opened but never closed before the tag ends — the
755        // extractor must fall through to the empty result.
756        let html = "<main><img src=\"broken.png</main>";
757        assert_eq!(extract_first_content_image(html), "");
758    }
759
760    #[test]
761    fn extract_first_content_image_img_without_src_attribute() {
762        let html = "<main><img alt=\"decorative\"></main>";
763        assert_eq!(extract_first_content_image(html), "");
764    }
765
766    #[test]
767    fn extract_meta_author_byline_with_empty_name_returns_empty() {
768        // `class="author">` immediately followed by a closing tag —
769        // the byline text is empty and must not be returned.
770        let html = "<html><body><span class=\"author\"></span></body></html>";
771        assert_eq!(extract_meta_author(html), "");
772    }
773
774    #[test]
775    fn extract_meta_author_byline_without_following_tag_returns_empty() {
776        // Pattern matches at the very end of the document, so there is
777        // no `<` terminating the byline text.
778        let html = "<span class=\"author\">Dana";
779        assert_eq!(extract_meta_author(html), "");
780    }
781
782    #[test]
783    fn extract_date_from_html_empty_date_returns_none() {
784        let html = r#"{"datePublished":""}"#;
785        assert_eq!(extract_date_from_html(html, "datePublished"), None);
786    }
787
788    #[test]
789    fn extract_date_from_html_unterminated_value_returns_none() {
790        // Opening quote never closed — no closing `"` after the value.
791        let html = r#"{"datePublished":"2026-01-01"#;
792        assert_eq!(extract_date_from_html(html, "datePublished"), None);
793    }
794
795    #[test]
796    fn extract_meta_date_empty_datetime_returns_none() {
797        let html = r#"<time datetime="">x</time>"#;
798        assert_eq!(extract_meta_date(html), None);
799    }
800
801    #[test]
802    fn extract_meta_date_unterminated_datetime_returns_none() {
803        let html = r#"<time datetime="2026-01-01"#;
804        assert_eq!(extract_meta_date(html), None);
805    }
806
807    // ---- multi-byte text in an <img> tag -------------------------------
808    //
809    // The tag scan is capped at 500 bytes. With no `>` inside that window,
810    // byte 500 can fall inside a multi-byte character, and slicing there
811    // panicked (`end byte index 500 is not a char boundary`). Found
812    // building a Tamil page, where each character is three bytes.
813
814    /// `src` comes first and the long Tamil `alt` runs past the cap.
815    #[test]
816    fn first_content_image_src_before_long_multibyte_alt() {
817        let html = format!(
818            "<main><img src=\"/ta.png\" alt=\"{}\"></main>",
819            "ட".repeat(200)
820        );
821        assert_eq!(extract_first_content_image(&html), "/ta.png");
822    }
823
824    /// `src` sits beyond the cap: no image, and no panic.
825    #[test]
826    fn first_content_image_src_past_cap_inside_multibyte_run() {
827        // `<img alt="` is 10 bytes; 10 + 3k never equals 500.
828        let html = format!(
829            "<main><img alt=\"{}\" src=\"/late.png\"></main>",
830            "ட".repeat(200)
831        );
832        assert_eq!(extract_first_content_image(&html), "");
833    }
834
835    /// A short, unterminated tag: no `>` and fewer than 500 bytes left,
836    /// so the cap itself lies past the end of the string.
837    #[test]
838    fn first_content_image_unterminated_short_tag() {
839        assert_eq!(
840            extract_first_content_image("<main><img src=\"/a.png\""),
841            "/a.png"
842        );
843    }
844}