1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
12use std::collections::HashMap;
13
14const NAME: &str = "hreflang";
15
16#[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 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 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 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 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 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 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 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 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 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 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}