Skip to main content

ssg/audit/gates/
hreflang.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Hreflang reciprocity gate.
5//!
6//! For every `<link rel="alternate" hreflang="X" href="Y">` on page
7//! `A`, verifies that page `Y` exists in the built site AND contains a
8//! corresponding `<link rel="alternate" hreflang="LANG_OF_A" href="A">`
9//! pointing back. Either direction missing is an error finding.
10
11use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
12use std::collections::HashMap;
13
14const NAME: &str = "hreflang";
15
16/// Hreflang reciprocity gate.
17///
18/// # Examples
19///
20/// ```
21/// use ssg::audit::AuditGate;
22/// use ssg::audit::gates::hreflang::HreflangGate;
23/// assert_eq!(HreflangGate.name(), "hreflang");
24/// ```
25#[derive(Debug, Clone, Copy)]
26pub struct HreflangGate;
27
28impl AuditGate for HreflangGate {
29    fn name(&self) -> &'static str {
30        NAME
31    }
32
33    fn explain(&self) -> &'static str {
34        "For every <link rel=\"alternate\" hreflang=\"X\" href=\"Y\"> \
35         the gate verifies that Y resolves to a page in the built site \
36         AND that Y links back with the originating page's hreflang. \
37         Either side missing is an error — Google's hreflang doc \
38         requires bidirectional links for the signal to be honoured."
39    }
40
41    fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
42        // Index: rel_path -> (hreflang -> rel_target)
43        let mut index: HashMap<String, HashMap<String, String>> =
44            HashMap::with_capacity(site.html_files.len());
45        let mut self_lang: HashMap<String, String> = HashMap::new();
46
47        for path in &site.html_files {
48            let Ok(html) = site.read(path) else { continue };
49            let rel = site.rel(path);
50            let alts = extract_alternates(&html);
51            if let Some(s) = alts.iter().find(|a| a.is_self) {
52                let _ = self_lang.insert(rel.clone(), s.lang.clone());
53            }
54            let mut m = HashMap::with_capacity(alts.len());
55            for a in alts {
56                let _ = m.insert(a.lang, a.href);
57            }
58            let _ = index.insert(rel, m);
59        }
60
61        let mut findings = Vec::new();
62
63        for (rel, alts) in &index {
64            let my_lang = self_lang.get(rel);
65            for (lang, href) in alts {
66                if lang == "x-default" || my_lang.is_some_and(|m| m == lang) {
67                    continue;
68                }
69                // resolve_href maps absolute URLs to their path part,
70                // so every href resolves to a site-relative candidate;
71                // nonexistent targets surface as TARGET-MISSING below.
72                let target_rel = resolve_href(href, &site.root);
73                let Some(reverse) = index.get(&target_rel) else {
74                    findings.push(
75                        Finding::new(
76                            NAME,
77                            Severity::Error,
78                            format!(
79                                "hreflang=\"{lang}\" target \"{target_rel}\" does not exist in the built site"
80                            ),
81                        )
82                        .with_code("HREFLANG-TARGET-MISSING")
83                        .with_path(rel.clone()),
84                    );
85                    continue;
86                };
87                let Some(my_lang) = my_lang else { continue };
88                if !reverse.contains_key(my_lang) {
89                    findings.push(
90                        Finding::new(
91                            NAME,
92                            Severity::Error,
93                            format!(
94                                "{target_rel} does not link back with hreflang=\"{my_lang}\""
95                            ),
96                        )
97                        .with_code("HREFLANG-NO-RECIPROCAL")
98                        .with_path(rel.clone()),
99                    );
100                }
101            }
102        }
103
104        findings
105    }
106}
107
108#[derive(Debug)]
109struct Alternate {
110    lang: String,
111    href: String,
112    is_self: bool,
113}
114
115fn extract_alternates(html: &str) -> Vec<Alternate> {
116    let mut out = Vec::new();
117    let lower = html.to_lowercase();
118    let mut cursor = 0;
119    while let Some(rel_open) = lower[cursor..].find("<link") {
120        let abs = cursor + rel_open;
121        let end = lower[abs..].find('>').map_or(lower.len(), |e| abs + e + 1);
122        let tag = &html[abs..end];
123        cursor = end;
124
125        let lower_tag = tag.to_lowercase();
126        if !lower_tag.contains("rel=\"alternate\"")
127            && !lower_tag.contains("rel='alternate'")
128        {
129            continue;
130        }
131        let Some(lang) = attr(tag, "hreflang") else {
132            continue;
133        };
134        let Some(href) = attr(tag, "href") else {
135            continue;
136        };
137        let is_self = lang.eq_ignore_ascii_case("self")
138            || lower_tag.contains("data-self=\"true\"");
139        out.push(Alternate {
140            lang,
141            href,
142            is_self,
143        });
144    }
145    out
146}
147
148use super::hreflang_attr as attr;
149
150fn resolve_href(href: &str, root: &std::path::Path) -> String {
151    let stripped = href.trim_start_matches('/');
152    // Strip absolute URL prefix if present
153    let path_part = if let Some(rest) = stripped.strip_prefix("http://") {
154        rest.split_once('/').map_or("", |(_, p)| p)
155    } else if let Some(rest) = stripped.strip_prefix("https://") {
156        rest.split_once('/').map_or("", |(_, p)| p)
157    } else {
158        stripped
159    };
160    let needs_index = path_part.is_empty()
161        || path_part.ends_with('/')
162        || !path_part.ends_with(".html");
163    // This is a URL path, not an OS filesystem path — build it with a
164    // literal `/` rather than `PathBuf::join`, which uses `\` on
165    // Windows and would silently break every comparison against the
166    // (always `/`-separated) page paths this gate matches against.
167    let with_index = if !needs_index {
168        path_part.to_string()
169    } else if path_part.is_empty() {
170        "index.html".to_string()
171    } else {
172        format!("{}/index.html", path_part.trim_end_matches('/'))
173    };
174    // Returned regardless of existence — callers report "target
175    // missing" against the resolved path so authors get an actionable
176    // message.
177    let _ = root;
178    with_index
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    fn site_with(pages: &[(&str, &str)]) -> Site {
186        let tmp = tempfile::tempdir().unwrap();
187        let root = tmp.path().to_path_buf();
188        let mut files = Vec::new();
189        for (rel, html) in pages {
190            let p = root.join(rel);
191            // root.join(rel) always has a parent directory.
192            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
193            std::fs::write(&p, html).unwrap();
194            files.push(p);
195        }
196        std::mem::forget(tmp);
197        Site {
198            root,
199            html_files: files,
200        }
201    }
202
203    #[test]
204    fn reciprocal_pair_has_no_findings() {
205        let en = r#"<html><head>
206            <link rel="alternate" hreflang="self" href="/en/index.html">
207            <link rel="alternate" hreflang="en" href="/en/index.html">
208            <link rel="alternate" hreflang="fr" href="/fr/index.html">
209        </head><body></body></html>"#;
210        let fr = r#"<html><head>
211            <link rel="alternate" hreflang="self" href="/fr/index.html">
212            <link rel="alternate" hreflang="fr" href="/fr/index.html">
213            <link rel="alternate" hreflang="en" href="/en/index.html">
214        </head><body></body></html>"#;
215        let s = site_with(&[("en/index.html", en), ("fr/index.html", fr)]);
216        // Set self_lang via the "self" alternate. Since the resolver
217        // strips that alternate, we set my_lang via a regular alternate
218        // pointing at self. Reformulate using the "en" + "fr" entries.
219        let en2 = r#"<html><head>
220            <link rel="alternate" hreflang="en" href="/en/index.html" data-self="true">
221            <link rel="alternate" hreflang="fr" href="/fr/index.html">
222        </head><body></body></html>"#;
223        let fr2 = r#"<html><head>
224            <link rel="alternate" hreflang="fr" href="/fr/index.html" data-self="true">
225            <link rel="alternate" hreflang="en" href="/en/index.html">
226        </head><body></body></html>"#;
227        let s2 = site_with(&[("en/index.html", en2), ("fr/index.html", fr2)]);
228        let f = HreflangGate.run(&s, &AuditOptions::default());
229        // s misuses "self" so won't be perfect; primary test is s2:
230        let f2 = HreflangGate.run(&s2, &AuditOptions::default());
231        assert!(
232            f2.is_empty(),
233            "expected reciprocal site to be clean, got {f2:?}"
234        );
235        let _ = f;
236    }
237
238    #[test]
239    fn missing_reciprocal_is_flagged() {
240        let en = r#"<html><head>
241            <link rel="alternate" hreflang="en" href="/en/index.html" data-self="true">
242            <link rel="alternate" hreflang="fr" href="/fr/index.html">
243        </head><body></body></html>"#;
244        // fr exists but does NOT link back to en.
245        let fr = r#"<html><head>
246            <link rel="alternate" hreflang="fr" href="/fr/index.html" data-self="true">
247        </head><body></body></html>"#;
248        let s = site_with(&[("en/index.html", en), ("fr/index.html", fr)]);
249        let f = HreflangGate.run(&s, &AuditOptions::default());
250        assert!(
251            f.iter()
252                .any(|x| x.code.as_deref() == Some("HREFLANG-NO-RECIPROCAL")),
253            "expected NO-RECIPROCAL finding, got {f:?}"
254        );
255    }
256
257    #[test]
258    fn missing_target_is_flagged() {
259        let en = r#"<html><head>
260            <link rel="alternate" hreflang="en" href="/en/index.html" data-self="true">
261            <link rel="alternate" hreflang="de" href="/de/index.html">
262        </head><body></body></html>"#;
263        let s = site_with(&[("en/index.html", en)]);
264        let f = HreflangGate.run(&s, &AuditOptions::default());
265        assert!(
266            f.iter()
267                .any(|x| x.code.as_deref() == Some("HREFLANG-TARGET-MISSING")),
268            "expected TARGET-MISSING, got {f:?}"
269        );
270    }
271
272    #[test]
273    fn x_default_skipped() {
274        let en = r#"<html><head>
275            <link rel="alternate" hreflang="en" href="/en/index.html" data-self="true">
276            <link rel="alternate" hreflang="x-default" href="/de/missing.html">
277        </head><body></body></html>"#;
278        let s = site_with(&[("en/index.html", en)]);
279        let f = HreflangGate.run(&s, &AuditOptions::default());
280        assert!(f.is_empty(), "x-default should be skipped, got {f:?}");
281    }
282
283    #[test]
284    fn self_alternate_lang_skipped() {
285        let en = r#"<html><head>
286            <link rel="alternate" hreflang="en" href="/en/index.html" data-self="true">
287        </head><body></body></html>"#;
288        let s = site_with(&[("en/index.html", en)]);
289        let f = HreflangGate.run(&s, &AuditOptions::default());
290        assert!(f.is_empty(), "self lang should be skipped, got {f:?}");
291    }
292
293    #[test]
294    fn single_quoted_alternate_recognised() {
295        let en = r#"<html><head>
296            <link rel='alternate' hreflang='en' href='/en/index.html' data-self="true">
297            <link rel='alternate' hreflang='fr' href='/fr/index.html'>
298        </head><body></body></html>"#;
299        let fr = r#"<html><head>
300            <link rel='alternate' hreflang='fr' href='/fr/index.html' data-self="true">
301            <link rel='alternate' hreflang='en' href='/en/index.html'>
302        </head><body></body></html>"#;
303        let s = site_with(&[("en/index.html", en), ("fr/index.html", fr)]);
304        let f = HreflangGate.run(&s, &AuditOptions::default());
305        assert!(
306            f.is_empty(),
307            "single-quoted alternates should be clean, got {f:?}"
308        );
309    }
310
311    #[test]
312    fn non_alternate_link_ignored() {
313        let html = r#"<html><head>
314            <link rel="stylesheet" href="/main.css" hreflang="en">
315            <link rel="alternate" hreflang="en" href="/en/index.html" data-self="true">
316        </head><body></body></html>"#;
317        let s = site_with(&[("en/index.html", html)]);
318        let f = HreflangGate.run(&s, &AuditOptions::default());
319        assert!(f.is_empty(), "stylesheet link should be ignored, got {f:?}");
320    }
321
322    #[test]
323    fn link_without_hreflang_skipped() {
324        let html = r#"<html><head>
325            <link rel="alternate" href="/en/index.html">
326        </head><body></body></html>"#;
327        let s = site_with(&[("en/index.html", html)]);
328        let f = HreflangGate.run(&s, &AuditOptions::default());
329        assert!(f.is_empty(), "no hreflang attr → ignored, got {f:?}");
330    }
331
332    #[test]
333    fn link_without_href_skipped() {
334        let html = r#"<html><head>
335            <link rel="alternate" hreflang="en">
336        </head><body></body></html>"#;
337        let s = site_with(&[("en/index.html", html)]);
338        let f = HreflangGate.run(&s, &AuditOptions::default());
339        assert!(f.is_empty(), "no href attr → ignored, got {f:?}");
340    }
341
342    #[test]
343    fn absolute_url_resolves_to_path() {
344        let en = r#"<html><head>
345            <link rel="alternate" hreflang="en" href="/en/index.html" data-self="true">
346            <link rel="alternate" hreflang="fr" href="https://example.com/fr/index.html">
347        </head><body></body></html>"#;
348        let fr = r#"<html><head>
349            <link rel="alternate" hreflang="fr" href="/fr/index.html" data-self="true">
350            <link rel="alternate" hreflang="en" href="/en/index.html">
351        </head><body></body></html>"#;
352        let s = site_with(&[("en/index.html", en), ("fr/index.html", fr)]);
353        let f = HreflangGate.run(&s, &AuditOptions::default());
354        assert!(f.is_empty(), "absolute URLs should resolve, got {f:?}");
355    }
356
357    #[test]
358    fn unreadable_html_skipped() {
359        let tmp = tempfile::tempdir().unwrap();
360        let root = tmp.path().to_path_buf();
361        let dir_as_file = root.join("page.html");
362        std::fs::create_dir_all(&dir_as_file).unwrap();
363        let s = Site {
364            root,
365            html_files: vec![dir_as_file],
366        };
367        let f = HreflangGate.run(&s, &AuditOptions::default());
368        assert!(f.is_empty());
369        std::mem::forget(tmp);
370    }
371
372    #[test]
373    fn metadata_methods_exposed() {
374        let g = HreflangGate;
375        assert_eq!(g.name(), "hreflang");
376        assert!(g.explain().to_lowercase().contains("hreflang"));
377        let _copy: HreflangGate = g;
378        let _clone = g;
379        let dbg = format!("{g:?}");
380        assert!(dbg.contains("HreflangGate"));
381    }
382
383    #[test]
384    fn page_without_self_lang_skips_reciprocity_check() {
385        // Neither page declares its own language (no data-self / self
386        // alternate), so my_lang is None and the reciprocity check is
387        // skipped after target resolution.
388        let en = r#"<html><head>
389            <link rel="alternate" hreflang="fr" href="/fr/index.html">
390        </head><body></body></html>"#;
391        let fr = r#"<html><head>
392            <link rel="alternate" hreflang="en" href="/en/index.html">
393        </head><body></body></html>"#;
394        let s = site_with(&[("en/index.html", en), ("fr/index.html", fr)]);
395        let f = HreflangGate.run(&s, &AuditOptions::default());
396        assert!(f.is_empty(), "no self-lang means no reciprocity: {f:?}");
397    }
398
399    #[test]
400    fn resolve_href_handles_scheme_and_index_variants() {
401        let root = std::path::Path::new("/unused");
402        assert_eq!(
403            resolve_href("http://example.com/fr/index.html", root),
404            "fr/index.html"
405        );
406        assert_eq!(resolve_href("http://example.com", root), "index.html");
407        assert_eq!(resolve_href("https://example.com", root), "index.html");
408        assert_eq!(resolve_href("/fr/", root), "fr/index.html");
409        assert_eq!(resolve_href("/fr", root), "fr/index.html");
410        assert_eq!(resolve_href("", root), "index.html");
411        assert_eq!(resolve_href("/de/page.html", root), "de/page.html");
412    }
413
414    #[test]
415    fn empty_site_returns_no_findings() {
416        let s = site_with(&[]);
417        let f = HreflangGate.run(&s, &AuditOptions::default());
418        assert!(f.is_empty());
419    }
420}