#![allow(clippy::redundant_pub_crate)]
#[cfg(feature = "templates")]
use std::collections::HashMap;
#[cfg(feature = "templates")]
pub(crate) const DEFAULT_PAGE_LANG: &str = "en";
#[cfg(feature = "templates")]
pub(crate) fn resolve_render_lang(
frontmatter: &HashMap<String, serde_json::Value>,
site_language: Option<&str>,
) -> String {
for key in ["language", "hreflang"] {
if let Some(lang) = frontmatter
.get(key)
.and_then(serde_json::Value::as_str)
.and_then(normalize_bcp47)
{
return lang;
}
}
site_language
.and_then(normalize_bcp47)
.unwrap_or_else(|| DEFAULT_PAGE_LANG.to_string())
}
pub(crate) fn normalize_bcp47(raw: &str) -> Option<String> {
let cleaned = raw.trim().replace('_', "-");
let mut parts = cleaned.split('-');
let primary = parts.next().unwrap_or_default();
if !(2..=3).contains(&primary.len())
|| !primary.bytes().all(|b| b.is_ascii_alphabetic())
{
return None;
}
let mut out = primary.to_ascii_lowercase();
for sub in parts {
if sub.is_empty() || !sub.bytes().all(|b| b.is_ascii_alphanumeric()) {
return None;
}
out.push('-');
match sub.len() {
2 if sub.bytes().all(|b| b.is_ascii_alphabetic()) => {
out.push_str(&sub.to_ascii_uppercase());
}
4 if sub.bytes().all(|b| b.is_ascii_alphabetic()) => {
let (head, tail) = sub.split_at(1);
out.push_str(&head.to_ascii_uppercase());
out.push_str(&tail.to_ascii_lowercase());
}
_ => out.push_str(&sub.to_ascii_lowercase()),
}
}
Some(out)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn fm(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
pairs
.iter()
.map(|(k, v)| {
(
(*k).to_string(),
serde_json::Value::String((*v).to_string()),
)
})
.collect()
}
#[test]
fn normalize_canonicalises_case_and_separators() {
assert_eq!(normalize_bcp47("EN_gb").as_deref(), Some("en-GB"));
assert_eq!(normalize_bcp47("fr-fr").as_deref(), Some("fr-FR"));
assert_eq!(normalize_bcp47("hi").as_deref(), Some("hi"));
assert_eq!(normalize_bcp47("ZH-HANS").as_deref(), Some("zh-Hans"));
}
#[test]
fn normalize_rejects_empty_and_garbage() {
assert_eq!(normalize_bcp47(""), None);
assert_eq!(normalize_bcp47(" "), None);
assert_eq!(normalize_bcp47("english"), None);
assert_eq!(normalize_bcp47("e"), None);
assert_eq!(normalize_bcp47("en-"), None);
assert_eq!(normalize_bcp47("12"), None);
}
#[test]
fn normalize_keeps_numeric_and_variant_subtags_lowercase() {
assert_eq!(normalize_bcp47("ES-419").as_deref(), Some("es-419"));
assert_eq!(
normalize_bcp47("en-GB-OXENDICT").as_deref(),
Some("en-GB-oxendict")
);
assert_eq!(normalize_bcp47("en-a1").as_deref(), Some("en-a1"));
}
#[test]
fn frontmatter_language_beats_site_default() {
let lang =
resolve_render_lang(&fm(&[("language", "hi")]), Some("en-GB"));
assert_eq!(lang, "hi");
}
#[test]
fn frontmatter_hreflang_used_when_language_absent() {
let lang =
resolve_render_lang(&fm(&[("hreflang", "en_gb")]), Some("en"));
assert_eq!(lang, "en-GB");
}
#[test]
fn invalid_frontmatter_language_falls_through() {
let lang = resolve_render_lang(
&fm(&[("language", "not a lang")]),
Some("fr-FR"),
);
assert_eq!(lang, "fr-FR");
}
#[test]
fn site_default_used_when_no_frontmatter_signal() {
let lang = resolve_render_lang(&HashMap::new(), Some("en-GB"));
assert_eq!(lang, "en-GB");
}
#[test]
fn en_constant_when_nothing_resolves() {
assert_eq!(resolve_render_lang(&HashMap::new(), None), "en");
assert_eq!(
resolve_render_lang(&HashMap::new(), Some("")),
DEFAULT_PAGE_LANG
);
}
#[test]
fn non_string_frontmatter_values_are_ignored() {
let mut map = HashMap::new();
let _ = map.insert("language".to_string(), serde_json::json!(42));
assert_eq!(resolve_render_lang(&map, Some("de")), "de");
}
}