1use serde::{Deserialize, Serialize};
28use std::collections::BTreeMap;
29
30#[cfg(doctest)]
33#[doc = include_str!("../README.md")]
34struct ReadmeDoctests;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42#[derive(Default)]
43#[non_exhaustive]
44pub enum UrlPrefixStrategy {
45 #[default]
47 SubPath,
48 SubDomain,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct I18nConfig {
64 pub default_locale: String,
66 pub locales: Vec<String>,
68 #[serde(default)]
70 pub url_prefix: UrlPrefixStrategy,
71}
72
73impl Default for I18nConfig {
74 fn default() -> Self {
75 Self {
76 default_locale: "en".to_string(),
77 locales: vec!["en".to_string()],
78 url_prefix: UrlPrefixStrategy::default(),
79 }
80 }
81}
82
83pub fn sidecar_candidates(site_rel: &str) -> Vec<String> {
88 let Some(stem) = site_rel.strip_suffix(".html") else {
89 return Vec::new();
90 };
91 let mut out = Vec::with_capacity(2);
92 if let Some(dir) = stem.strip_suffix("/index") {
95 out.push(format!("{dir}.meta.json"));
96 }
97 out.push(format!("{stem}.meta.json"));
98 out
99}
100
101pub const HREFLANG_MARKER: &str = "rel=\"alternate\" hreflang=";
103
104pub fn rewrite_ap_lang_items(
106 html: &str,
107 locale_map: &BTreeMap<String, String>,
108 base: &str,
109 strategy: &UrlPrefixStrategy,
110 root_locale: Option<&str>,
111) -> String {
112 if !html.contains("ap-lang-item") {
113 return html.to_string();
114 }
115
116 let mut result = String::with_capacity(html.len());
117 let mut remaining = html;
118
119 while let Some(start_idx) = remaining.find("<a ") {
120 result.push_str(&remaining[..start_idx]);
121 let tag_content = &remaining[start_idx..];
122
123 let Some(end_idx) = tag_content.find('>') else {
124 result.push_str(remaining);
125 return result;
126 };
127
128 let tag_inner = &tag_content[..end_idx + 1];
129 let mut rewritten_tag = tag_inner.to_string();
130
131 if tag_inner.contains("ap-lang-item") {
132 let mut data_lang = None;
133 for quote in ['"', '\''] {
134 let pattern = format!("data-lang={quote}");
135 if let Some(pos) = tag_inner.find(&pattern) {
136 let val_start = pos + pattern.len();
137 if let Some(val_end) = tag_inner[val_start..].find(quote) {
138 data_lang = Some(
139 tag_inner[val_start..val_start + val_end]
140 .trim()
141 .to_string(),
142 );
143 break;
144 }
145 }
146 }
147
148 if let Some(lang) = data_lang {
149 if let Some(rel_path) = locale_map.get(&lang) {
150 let full_url =
151 build_url(base, &lang, rel_path, strategy, root_locale);
152 let new_href = if full_url.starts_with("http://")
153 || full_url.starts_with("https://")
154 {
155 let after_scheme =
156 full_url.split("://").nth(1).unwrap_or("");
157 if let Some(slash_idx) = after_scheme.find('/') {
158 after_scheme[slash_idx..].to_string()
159 } else {
160 "/".to_string()
161 }
162 } else {
163 full_url
164 };
165
166 for quote in ['"', '\''] {
167 let href_pattern = format!("href={quote}");
168 if let Some(pos) = tag_inner.find(&href_pattern) {
169 let val_start = pos + href_pattern.len();
170 if let Some(val_end) =
171 tag_inner[val_start..].find(quote)
172 {
173 let before = &rewritten_tag[..val_start];
174 let after =
175 &rewritten_tag[val_start + val_end..];
176 rewritten_tag =
177 format!("{before}{new_href}{after}");
178 break;
179 }
180 }
181 }
182 }
183 }
184 }
185
186 result.push_str(&rewritten_tag);
187 remaining = &tag_content[end_idx + 1..];
188 }
189
190 result.push_str(remaining);
191 result
192}
193
194pub const LANG_SWITCHER_MARKER: &str = "<!-- ssg:lang-switcher -->";
203
204pub const LANG_SWITCHER_ATTR: &str = "data-ssg-lang-switcher";
208
209pub fn find_lang_switcher_element(html: &str) -> Option<(usize, usize)> {
215 let attr_at = html.find(LANG_SWITCHER_ATTR)?;
216 let start = html[..attr_at].rfind('<')?;
218 let name_start = start + 1;
219 let name_end = html[name_start..]
220 .find(|c: char| !c.is_ascii_alphanumeric())
221 .map(|i| name_start + i)?;
222 let name = &html[name_start..name_end];
223 if name.is_empty() {
224 return None;
225 }
226 let open_end = html[start..].find('>')? + start + 1;
228 if attr_at > open_end {
229 return None;
230 }
231 let close = format!("</{name}>");
232 let close_at = html[open_end..].find(&close)? + open_end;
233 if !html[open_end..close_at].trim().is_empty() {
235 return None;
236 }
237 Some((start, close_at + close.len()))
238}
239
240pub fn build_hreflang_links(
252 locale_map: &BTreeMap<String, String>,
253 labels: &BTreeMap<String, String>,
254 default_locale: &str,
255 base: &str,
256 strategy: &UrlPrefixStrategy,
257 root_locale: Option<&str>,
258) -> String {
259 let mut links = String::new();
260
261 for (locale, rel_path) in locale_map {
262 let href = build_url(base, locale, rel_path, strategy, root_locale);
263 let hreflang = labels.get(locale).unwrap_or(locale);
264 links.push_str(&format!(
265 " <link rel=\"alternate\" hreflang=\"{hreflang}\" href=\"{href}\" />\n"
266 ));
267 }
268
269 if let Some(default_rel) = locale_map.get(default_locale) {
270 let default_href =
271 build_url(base, default_locale, default_rel, strategy, root_locale);
272 links.push_str(&format!(
273 " <link rel=\"alternate\" hreflang=\"x-default\" href=\"{default_href}\" />\n"
274 ));
275 }
276
277 links
278}
279
280pub fn build_url(
286 base: &str,
287 locale: &str,
288 rel_path: &str,
289 strategy: &UrlPrefixStrategy,
290 root_locale: Option<&str>,
291) -> String {
292 if root_locale == Some(locale) {
293 return format!("{base}/{rel_path}");
294 }
295 match strategy {
296 UrlPrefixStrategy::SubPath => {
297 format!("{base}/{locale}/{rel_path}")
298 }
299 UrlPrefixStrategy::SubDomain => {
300 if let Some(idx) = base.find("://") {
302 let (scheme, rest) = base.split_at(idx + 3);
303 format!("{scheme}{locale}.{rest}/{rel_path}")
304 } else {
305 format!("{base}/{locale}/{rel_path}")
307 }
308 }
309 }
310}
311
312#[must_use]
328pub fn parse_accept_language(header: &str) -> Vec<String> {
329 if header.trim().is_empty() {
330 return Vec::new();
331 }
332
333 let mut entries: Vec<(String, f64)> = header
334 .split(',')
335 .filter_map(|part| {
336 let part = part.trim();
337 if part.is_empty() {
338 return None;
339 }
340 let mut segments = part.splitn(2, ';');
341 let locale = segments.next()?.trim().to_string();
342 if locale.is_empty() {
343 return None;
344 }
345 let quality = segments
346 .next()
347 .and_then(|q| {
348 let q = q.trim();
349 q.strip_prefix("q=")
350 .and_then(|v| v.trim().parse::<f64>().ok())
351 })
352 .unwrap_or(1.0);
353 Some((locale, quality))
354 })
355 .collect();
356
357 entries.sort_by(|a, b| {
359 b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
360 });
361
362 entries.into_iter().map(|(locale, _)| locale).collect()
363}
364
365#[must_use]
383pub fn negotiate_locale(
384 preferred: &[String],
385 available: &[String],
386 default_locale: &str,
387) -> String {
388 let available_lower: Vec<String> =
389 available.iter().map(|l| l.to_lowercase()).collect();
390
391 for pref in preferred {
392 if pref == "*" {
394 continue;
395 }
396 let pref_lower = pref.to_lowercase();
397
398 if let Some(idx) = available_lower.iter().position(|a| *a == pref_lower)
400 {
401 return available[idx].clone();
402 }
403
404 let prefix = pref_lower.split('-').next().unwrap_or(&pref_lower);
406 if let Some(idx) = available_lower.iter().position(|a| *a == prefix) {
407 return available[idx].clone();
408 }
409 }
410
411 default_locale.to_string()
412}
413
414#[must_use]
445pub fn generate_lang_switcher_html(
446 locales: &[String],
447 current_locale: &str,
448 current_path: &str,
449 base_url: &str,
450 strategy: &UrlPrefixStrategy,
451) -> String {
452 let locale_map: BTreeMap<String, String> = locales
455 .iter()
456 .map(|l| (l.clone(), current_path.to_string()))
457 .collect();
458 let labels: BTreeMap<String, String> =
459 locales.iter().map(|l| (l.clone(), l.clone())).collect();
460 generate_lang_switcher_html_with_self_lang(
461 &locale_map,
462 &labels,
463 current_locale,
464 base_url,
465 strategy,
466 None,
467 )
468}
469
470pub fn generate_lang_switcher_html_with_self_lang(
481 locale_map: &BTreeMap<String, String>,
482 labels: &BTreeMap<String, String>,
483 current_locale: &str,
484 base_url: &str,
485 strategy: &UrlPrefixStrategy,
486 root_locale: Option<&str>,
487) -> String {
488 let base = base_url.trim_end_matches('/');
489 let mut html = String::from(
490 "<nav class=\"lang-switcher\" aria-label=\"Language\">\n <ul>\n",
491 );
492
493 for (locale, rel_path) in locale_map {
494 let href = build_url(base, locale, rel_path, strategy, root_locale);
495 let lang_attr = labels.get(locale).unwrap_or(locale);
496 let aria = if locale == current_locale {
497 " aria-current=\"page\""
498 } else {
499 ""
500 };
501 html.push_str(&format!(
502 " <li><a href=\"{href}\" lang=\"{lang_attr}\" hreflang=\"{lang_attr}\"{aria}>{locale}</a></li>\n"
503 ));
504 }
505
506 html.push_str(" </ul>\n</nav>\n");
507 html
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
516 fn accept_language_is_ordered_by_quality() {
517 let got = parse_accept_language("en;q=0.5,fr-CA,de;q=0.8,*;q=0.1");
518 assert_eq!(got.first().map(String::as_str), Some("fr-CA"));
519 assert!(
520 got.iter().position(|l| l == "de")
521 < got.iter().position(|l| l == "en"),
522 "q=0.8 outranks q=0.5: {got:?}"
523 );
524 }
525
526 #[test]
527 fn negotiation_falls_back_to_the_default() {
528 let have = ["en".to_string(), "fr".to_string()];
529 assert_eq!(negotiate_locale(&["de".to_string()], &have, "en"), "en");
530 assert_eq!(negotiate_locale(&["fr".to_string()], &have, "en"), "fr");
531 }
532
533 #[test]
535 fn negotiation_matches_a_region_against_its_base_language() {
536 let have = ["en".to_string(), "fr".to_string()];
537 assert_eq!(negotiate_locale(&["fr-CA".to_string()], &have, "en"), "fr");
538 }
539
540 #[test]
541 fn sub_path_puts_the_locale_in_the_path() {
542 let url = build_url(
543 "https://example.com",
544 "fr",
545 "about/",
546 &UrlPrefixStrategy::SubPath,
547 None,
548 );
549 assert!(url.contains("/fr/"), "{url}");
550 }
551
552 #[test]
553 fn sub_domain_puts_the_locale_in_the_host() {
554 let url = build_url(
555 "https://example.com",
556 "fr",
557 "about/",
558 &UrlPrefixStrategy::SubDomain,
559 None,
560 );
561 assert!(url.contains("fr."), "{url}");
562 assert!(!url.contains("/fr/"), "not also in the path: {url}");
563 }
564
565 #[test]
567 fn the_root_locale_keeps_a_bare_path() {
568 let url = build_url(
569 "https://example.com",
570 "en",
571 "about/",
572 &UrlPrefixStrategy::SubPath,
573 Some("en"),
574 );
575 assert!(!url.contains("/en/"), "{url}");
576 }
577
578 #[test]
579 fn hreflang_links_cover_every_locale_and_x_default() {
580 let mut map = BTreeMap::new();
581 let _ = map.insert("en".to_string(), "about/".to_string());
582 let _ = map.insert("fr".to_string(), "a-propos/".to_string());
583 let links = build_hreflang_links(
584 &map,
585 &BTreeMap::new(),
586 "en",
587 "https://example.com",
588 &UrlPrefixStrategy::SubPath,
589 None,
590 );
591 assert!(links.contains("hreflang=\"en\""), "{links}");
592 assert!(links.contains("hreflang=\"fr\""), "{links}");
593 assert!(links.contains("x-default"), "{links}");
594 }
595
596 #[test]
597 fn the_switcher_lists_every_locale() {
598 let locales =
599 vec!["en".to_string(), "fr".to_string(), "de".to_string()];
600 let html = generate_lang_switcher_html(
601 &locales,
602 "en",
603 "about/",
604 "https://example.com",
605 &UrlPrefixStrategy::SubPath,
606 );
607 for locale in &locales {
608 assert!(html.contains(locale.as_str()), "{locale} missing: {html}");
609 }
610 }
611
612 #[test]
613 fn a_document_without_the_marker_has_no_switcher_element() {
614 assert!(find_lang_switcher_element("<p>no switcher here</p>").is_none());
615 }
616
617 #[test]
618 fn the_attribute_locates_the_whole_placeholder_element() {
619 let html = format!("<p>x</p><nav {LANG_SWITCHER_ATTR}></nav><p>y</p>");
620 let (start, end) = find_lang_switcher_element(&html)
621 .expect("an empty placeholder is replaceable");
622 assert_eq!(
623 &html[start..end],
624 format!("<nav {LANG_SWITCHER_ATTR}></nav>"),
625 "the span must cover the element and nothing else"
626 );
627 }
628
629 #[test]
632 fn a_non_empty_placeholder_is_left_alone() {
633 let html = format!("<nav {LANG_SWITCHER_ATTR}>hand-written</nav>");
634 assert!(find_lang_switcher_element(&html).is_none(), "{html}");
635 }
636
637 #[test]
638 fn sidecar_candidates_are_derived_from_the_page_path() {
639 let got = sidecar_candidates("about/index.html");
640 assert!(!got.is_empty(), "{got:?}");
641 assert!(
642 got.iter().any(|c| c.contains("about")),
643 "the page's own stem is a candidate: {got:?}"
644 );
645 }
646}