#![allow(clippy::redundant_pub_crate)]
use super::helpers::extract_html_lang;
use crate::plugin::PluginContext;
use std::fs;
use std::path::Path;
pub(crate) const DEFAULT_PAGE_LANG: &str = "en";
#[must_use]
pub(crate) fn resolve_page_lang(
html: &str,
path: &Path,
ctx: &PluginContext,
) -> String {
let rel_path = site_relative(path, ctx);
if let Some(meta) = read_page_sidecar(path, ctx, &rel_path) {
for key in ["language", "hreflang"] {
if let Some(lang) = meta
.get(key)
.and_then(serde_json::Value::as_str)
.and_then(normalize_bcp47)
{
return lang;
}
}
}
if let Some(lang) = locale_from_path(&rel_path, ctx) {
return lang;
}
if let Some(lang) = normalize_bcp47(&extract_html_lang(html)) {
return lang;
}
if let Some(lang) = ctx
.config
.as_ref()
.and_then(|cfg| normalize_bcp47(&cfg.language))
{
return lang;
}
DEFAULT_PAGE_LANG.to_string()
}
fn site_relative(path: &Path, ctx: &PluginContext) -> String {
path.strip_prefix(&ctx.site_dir)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
pub(crate) fn read_page_sidecar(
path: &Path,
ctx: &PluginContext,
rel_path: &str,
) -> Option<serde_json::Value> {
let sidecar_dir = ctx.build_dir.join(".meta");
let candidate = sidecar_dir.join(rel_path).with_extension("meta.json");
let raw = if candidate.exists() {
fs::read_to_string(&candidate).ok()?
} else {
let alt = sidecar_dir.join(rel_path.trim_end_matches(".html"));
let alt = alt.with_extension("md.meta.json");
if alt.exists() {
fs::read_to_string(&alt).ok()?
} else {
let inline = path.with_extension("meta.json");
if inline.exists() {
fs::read_to_string(&inline).ok()?
} else {
return None;
}
}
};
serde_json::from_str(&raw).ok()
}
fn locale_from_path(rel_path: &str, ctx: &PluginContext) -> Option<String> {
let (first, _) = rel_path.trim_start_matches('/').split_once('/')?;
let candidate = normalize_bcp47(first)?;
if let Some(i18n) = ctx.config.as_ref().and_then(|cfg| cfg.i18n.as_ref()) {
return i18n
.locales
.iter()
.chain(std::iter::once(&i18n.default_locale))
.filter_map(|loc| normalize_bcp47(loc))
.find(|loc| *loc == candidate);
}
if is_plausible_locale_shape(first) {
Some(candidate)
} else {
None
}
}
fn is_plausible_locale_shape(s: &str) -> bool {
let normalized = s.replace('_', "-");
let is_alpha2 = |part: &str| {
part.len() == 2 && part.bytes().all(|b| b.is_ascii_alphabetic())
};
match normalized.split_once('-') {
None => is_alpha2(&normalized),
Some((primary, region)) => is_alpha2(primary) && is_alpha2(region),
}
}
fn normalize_bcp47(raw: &str) -> Option<String> {
crate::core_group::lang::normalize_bcp47(raw)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::cmd::SsgConfig;
use crate::i18n::I18nConfig;
use std::path::PathBuf;
use tempfile::tempdir;
fn ctx_with(
dir: &Path,
language: Option<&str>,
locales: Option<&[&str]>,
) -> PluginContext {
let site = dir.join("site");
let build = dir.join("build");
let mut ctx = PluginContext::new(
Path::new("content"),
&build,
&site,
Path::new("templates"),
);
if language.is_some() || locales.is_some() {
ctx.config = Some(SsgConfig {
language: language.unwrap_or("").to_string(),
i18n: locales.map(|set| I18nConfig {
default_locale: set.first().map_or_else(
|| "en".to_string(),
|loc| (*loc).to_string(),
),
locales: set.iter().map(|loc| (*loc).to_string()).collect(),
url_prefix: Default::default(),
}),
..SsgConfig::default()
});
}
ctx
}
fn write_sidecar(dir: &Path, rel: &str, json: &str) {
let sidecar = dir
.join("build")
.join(".meta")
.join(rel)
.with_extension("meta.json");
fs::create_dir_all(sidecar.parent().unwrap()).unwrap();
fs::write(sidecar, json).unwrap();
}
fn page(dir: &Path, rel: &str) -> PathBuf {
dir.join("site").join(rel)
}
#[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 frontmatter_language_wins_over_everything() {
let dir = tempdir().unwrap();
write_sidecar(
dir.path(),
"hi/post/index.html",
r#"{"language":"fr","hreflang":"de"}"#,
);
let ctx = ctx_with(dir.path(), Some("en-GB"), Some(&["en", "hi"]));
let html = r#"<html lang="en-GB"><head></head></html>"#;
let lang = resolve_page_lang(
html,
&page(dir.path(), "hi/post/index.html"),
&ctx,
);
assert_eq!(lang, "fr");
}
#[test]
fn frontmatter_hreflang_used_when_language_absent() {
let dir = tempdir().unwrap();
write_sidecar(
dir.path(),
"hi/post/index.html",
r#"{"hreflang":"en_gb"}"#,
);
let ctx = ctx_with(dir.path(), Some("en"), Some(&["en", "hi"]));
let lang = resolve_page_lang(
"<html><head></head></html>",
&page(dir.path(), "hi/post/index.html"),
&ctx,
);
assert_eq!(lang, "en-GB", "hreflang should be normalised to BCP-47");
}
#[test]
fn invalid_frontmatter_language_falls_through_to_path() {
let dir = tempdir().unwrap();
write_sidecar(
dir.path(),
"hi/post/index.html",
r#"{"language":"not a lang"}"#,
);
let ctx = ctx_with(dir.path(), Some("en-GB"), Some(&["en", "hi"]));
let lang = resolve_page_lang(
"<html><head></head></html>",
&page(dir.path(), "hi/post/index.html"),
&ctx,
);
assert_eq!(lang, "hi");
}
#[test]
fn declared_locale_path_prefix_beats_html_lang_and_default() {
let dir = tempdir().unwrap();
let ctx = ctx_with(dir.path(), Some("en-GB"), Some(&["en", "hi"]));
let html = r#"<html lang="en-GB"><head></head></html>"#;
let lang = resolve_page_lang(
html,
&page(dir.path(), "hi/2026-06-01-post/index.html"),
&ctx,
);
assert_eq!(lang, "hi");
}
#[test]
fn undeclared_prefix_is_not_a_locale_when_i18n_configured() {
let dir = tempdir().unwrap();
let ctx = ctx_with(dir.path(), Some("en-GB"), Some(&["en", "hi"]));
let lang = resolve_page_lang(
"<html><head></head></html>",
&page(dir.path(), "de/page/index.html"),
&ctx,
);
assert_eq!(lang, "en-GB", "should fall through to site default");
}
#[test]
fn shape_heuristic_accepts_xx_and_xx_region_without_i18n_config() {
let dir = tempdir().unwrap();
let ctx = ctx_with(dir.path(), None, None);
assert_eq!(
resolve_page_lang(
"",
&page(dir.path(), "fr/about/index.html"),
&ctx
),
"fr"
);
assert_eq!(
resolve_page_lang(
"",
&page(dir.path(), "pt-BR/about/index.html"),
&ctx
),
"pt-BR"
);
}
#[test]
fn shape_heuristic_rejects_ordinary_directories() {
let dir = tempdir().unwrap();
let ctx = ctx_with(dir.path(), None, None);
for rel in ["blog/post/index.html", "api/index.html", "index.html"] {
assert_eq!(
resolve_page_lang("", &page(dir.path(), rel), &ctx),
DEFAULT_PAGE_LANG,
"{rel} must not resolve a locale from its path"
);
}
}
#[test]
fn html_lang_used_when_no_frontmatter_or_locale_prefix() {
let dir = tempdir().unwrap();
let ctx = ctx_with(dir.path(), Some("en"), None);
let html = r#"<html lang="fr-FR"><head></head></html>"#;
let lang = resolve_page_lang(
html,
&page(dir.path(), "about/index.html"),
&ctx,
);
assert_eq!(lang, "fr-FR");
}
#[test]
fn config_default_used_when_page_has_no_lang_signal() {
let dir = tempdir().unwrap();
let ctx = ctx_with(dir.path(), Some("en-GB"), None);
let lang = resolve_page_lang(
"<html><head></head></html>",
&page(dir.path(), "about/index.html"),
&ctx,
);
assert_eq!(lang, "en-GB");
}
#[test]
fn en_fallback_fires_only_when_nothing_else_resolves() {
let dir = tempdir().unwrap();
let ctx = ctx_with(dir.path(), None, None);
let lang = resolve_page_lang(
"<html><head></head></html>",
&page(dir.path(), "index.html"),
&ctx,
);
assert_eq!(lang, DEFAULT_PAGE_LANG);
let ctx = ctx_with(dir.path(), Some("hi"), None);
let lang = resolve_page_lang(
"<html><head></head></html>",
&page(dir.path(), "index.html"),
&ctx,
);
assert_eq!(lang, "hi");
}
#[test]
fn sidecar_md_convention_is_found() {
let dir = tempdir().unwrap();
let sidecar = dir
.path()
.join("build")
.join(".meta")
.join("post.md.meta.json");
fs::create_dir_all(sidecar.parent().unwrap()).unwrap();
fs::write(sidecar, r#"{"language":"hi"}"#).unwrap();
let ctx = ctx_with(dir.path(), Some("en"), None);
let lang = resolve_page_lang("", &page(dir.path(), "post.html"), &ctx);
assert_eq!(lang, "hi");
}
#[test]
fn unparseable_sidecar_falls_through() {
let dir = tempdir().unwrap();
write_sidecar(dir.path(), "p/index.html", "not json at all");
let ctx = ctx_with(dir.path(), Some("en-GB"), None);
let lang =
resolve_page_lang("", &page(dir.path(), "p/index.html"), &ctx);
assert_eq!(lang, "en-GB");
}
#[test]
fn unreadable_primary_sidecar_returns_none() {
let dir = tempdir().unwrap();
let sidecar = dir
.path()
.join("build")
.join(".meta")
.join("p")
.join("index.meta.json");
fs::create_dir_all(&sidecar).unwrap();
let ctx = ctx_with(dir.path(), None, None);
let got = read_page_sidecar(
&page(dir.path(), "p/index.html"),
&ctx,
"p/index.html",
);
assert!(got.is_none());
}
#[test]
fn unreadable_md_convention_sidecar_returns_none() {
let dir = tempdir().unwrap();
let alt = dir
.path()
.join("build")
.join(".meta")
.join("post.md.meta.json");
fs::create_dir_all(&alt).unwrap();
let ctx = ctx_with(dir.path(), None, None);
let got = read_page_sidecar(
&page(dir.path(), "post.html"),
&ctx,
"post.html",
);
assert!(got.is_none());
}
#[test]
fn inline_legacy_sidecar_next_to_html_is_found() {
let dir = tempdir().unwrap();
let page_dir = dir.path().join("site").join("p");
fs::create_dir_all(&page_dir).unwrap();
fs::write(page_dir.join("index.meta.json"), r#"{"language":"hi"}"#)
.unwrap();
let ctx = ctx_with(dir.path(), Some("en"), None);
let lang =
resolve_page_lang("", &page(dir.path(), "p/index.html"), &ctx);
assert_eq!(lang, "hi");
}
#[test]
fn unreadable_inline_sidecar_returns_none() {
let dir = tempdir().unwrap();
let inline = dir.path().join("site").join("p").join("index.meta.json");
fs::create_dir_all(&inline).unwrap();
let ctx = ctx_with(dir.path(), None, None);
let got = read_page_sidecar(
&page(dir.path(), "p/index.html"),
&ctx,
"p/index.html",
);
assert!(got.is_none());
}
#[test]
fn empty_declared_locale_set_defaults_to_en() {
let dir = tempdir().unwrap();
let ctx = ctx_with(dir.path(), None, Some(&[]));
let i18n = ctx.config.as_ref().unwrap().i18n.as_ref().unwrap();
assert_eq!(i18n.default_locale, "en");
assert!(i18n.locales.is_empty());
let lang = resolve_page_lang(
"",
&page(dir.path(), "fr/about/index.html"),
&ctx,
);
assert_eq!(lang, DEFAULT_PAGE_LANG, "fr/ is undeclared → no locale");
}
}