use bytes::Bytes;
use serde::Serialize;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::sync::Arc;
pub const ROOT_SCOPE: &str = "root";
#[derive(Debug, Clone, Serialize)]
pub struct Locale {
pub code: String,
pub iso: String,
pub name: String,
pub dir: String,
pub seo: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub og: Option<String>,
}
impl Locale {
pub fn new(code: impl Into<String>) -> Self {
let code = code.into();
Self {
iso: code.clone(),
name: code.clone(),
dir: "ltr".into(),
seo: true,
og: None,
code,
}
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
pub fn with_iso(mut self, iso: impl Into<String>) -> Self {
self.iso = iso.into();
self
}
pub fn with_dir(mut self, dir: impl Into<String>) -> Self {
self.dir = dir.into();
self
}
pub fn with_seo(mut self, seo: bool) -> Self {
self.seo = seo;
self
}
pub fn with_og(mut self, og: impl Into<String>) -> Self {
self.og = Some(og.into());
self
}
pub fn og_locale(&self) -> String {
self.og
.clone()
.unwrap_or_else(|| self.iso.replace('-', "_"))
}
}
#[derive(Debug, Clone)]
pub struct Scope {
pub flat: HashMap<Box<str>, Box<str>>,
pub payload: Bytes,
pub etag: Box<str>,
pub tree: Value,
}
#[derive(Debug, Clone)]
pub struct Store {
pub scopes: HashMap<(Box<str>, Box<str>), Arc<Scope>>,
pub locales: Vec<Locale>,
pub version: Box<str>,
}
impl Store {
pub fn get(&self, locale: &str, scope: &str) -> Option<Arc<Scope>> {
self.scopes
.get(&(locale.into(), scope.into()))
.map(Arc::clone)
}
pub fn lookup_flat(&self, locale: &str, scope: &str, key: &str) -> Option<String> {
self.get(locale, scope)
.and_then(|s| s.flat.get(key).map(|v| v.to_string()))
}
}
pub fn load_store(dir: &Path, locales: &[Locale]) -> sova_core::Result<Store> {
let mut roots: HashMap<String, Value> = HashMap::new();
let mut pages: HashMap<(String, String), Value> = HashMap::new();
for loc in locales {
let root_path = dir.join(format!("{}.json", loc.code));
let root = read_json_object(&root_path)?;
roots.insert(loc.code.clone(), root);
let pages_dir = dir.join("pages");
if pages_dir.is_dir() {
for entry in std::fs::read_dir(&pages_dir).map_err(sova_core::Error::from)? {
let entry = entry.map_err(sova_core::Error::from)?;
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
let page = entry.file_name().to_string_lossy().into_owned();
let page_file = entry.path().join(format!("{}.json", loc.code));
if page_file.is_file() {
let v = read_json_object(&page_file)?;
pages.insert((loc.code.clone(), page), v);
}
}
}
}
let mut scopes = HashMap::new();
let mut version_hasher = std::collections::hash_map::DefaultHasher::new();
for loc in locales {
let root_tree = roots.get(&loc.code).cloned().unwrap_or_else(|| Value::Object(Map::new()));
let root_scope = build_scope(&root_tree)?;
root_scope.payload.hash(&mut version_hasher);
scopes.insert(
(loc.code.clone().into_boxed_str(), ROOT_SCOPE.into()),
Arc::new(root_scope),
);
for ((lang, page), page_tree) in &pages {
if lang != &loc.code {
continue;
}
let merged = deep_merge(&root_tree, page_tree);
let scope = build_scope(&merged)?;
scope.payload.hash(&mut version_hasher);
scopes.insert(
(lang.clone().into_boxed_str(), page.clone().into_boxed_str()),
Arc::new(scope),
);
}
}
let version = format!("{:016x}", version_hasher.finish()).into_boxed_str();
Ok(Store {
scopes,
locales: locales.to_vec(),
version,
})
}
fn read_json_object(path: &Path) -> sova_core::Result<Value> {
if !path.is_file() {
return Ok(Value::Object(Map::new()));
}
let raw = std::fs::read_to_string(path).map_err(sova_core::Error::from)?;
let v: Value = serde_json::from_str(&raw)
.map_err(|e| sova_core::Error::Internal(format!("i18n {}: {e}", path.display())))?;
if !v.is_object() {
return Err(sova_core::Error::Internal(format!(
"i18n {}: root must be a JSON object",
path.display()
)));
}
Ok(v)
}
fn build_scope(tree: &Value) -> sova_core::Result<Scope> {
let mut flat = HashMap::new();
flatten(tree, "", &mut flat);
let payload = Bytes::from(serde_json::to_vec(tree).map_err(|e| {
sova_core::Error::Internal(format!("i18n serialize: {e}"))
})?);
let etag = etag_for(&payload);
Ok(Scope {
flat,
payload,
etag,
tree: tree.clone(),
})
}
fn etag_for(payload: &Bytes) -> Box<str> {
let mut h = std::collections::hash_map::DefaultHasher::new();
payload.hash(&mut h);
format!("\"{:016x}\"", h.finish()).into_boxed_str()
}
pub fn deep_merge(base: &Value, overlay: &Value) -> Value {
match (base, overlay) {
(Value::Object(a), Value::Object(b)) => {
let mut out = a.clone();
for (k, v) in b {
match out.get(k) {
Some(existing) => {
out.insert(k.clone(), deep_merge(existing, v));
}
None => {
out.insert(k.clone(), v.clone());
}
}
}
Value::Object(out)
}
(_, over) => over.clone(),
}
}
pub fn flatten(value: &Value, prefix: &str, out: &mut HashMap<Box<str>, Box<str>>) {
match value {
Value::Object(map) => {
for (k, v) in map {
let key = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
match v {
Value::String(s) => {
out.insert(key.into_boxed_str(), s.clone().into_boxed_str());
}
Value::Object(_) => flatten(v, &key, out),
other => {
out.insert(
key.into_boxed_str(),
other.to_string().into_boxed_str(),
);
}
}
}
}
Value::String(s) if !prefix.is_empty() => {
out.insert(prefix.into(), s.clone().into_boxed_str());
}
_ => {}
}
}
pub fn get_by_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
if let Some(obj) = value.as_object() {
if let Some(v) = obj.get(path) {
return Some(v);
}
}
let mut cur = value;
for part in path.split('.') {
cur = cur.as_object()?.get(part)?;
}
Some(cur)
}
pub fn select_tree(tree: &Value, prefix: Option<&str>, keys: Option<&[&str]>) -> Value {
if let Some(keys) = keys {
let mut out = Map::new();
for key in keys {
if let Some(v) = get_by_path(tree, key) {
insert_path(&mut out, key, v.clone());
}
}
return Value::Object(out);
}
if let Some(prefix) = prefix {
return match get_by_path(tree, prefix) {
Some(v) => v.clone(),
None => Value::Object(Map::new()),
};
}
tree.clone()
}
fn insert_path(out: &mut Map<String, Value>, path: &str, value: Value) {
if !path.contains('.') {
out.insert(path.to_string(), value);
return;
}
let mut parts = path.split('.').peekable();
let first = parts.next().unwrap();
let entry = out
.entry(first.to_string())
.or_insert_with(|| Value::Object(Map::new()));
let mut cur = entry;
while let Some(part) = parts.next() {
if parts.peek().is_none() {
if let Value::Object(map) = cur {
map.insert(part.to_string(), value);
}
return;
}
if !cur.is_object() {
*cur = Value::Object(Map::new());
}
let map = cur.as_object_mut().unwrap();
cur = map
.entry(part.to_string())
.or_insert_with(|| Value::Object(Map::new()));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_temp(files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for (rel, body) in files {
let path = dir.path().join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, body).unwrap();
}
dir
}
#[test]
fn flatten_nested_and_dotted_top_level() {
let v: Value = serde_json::json!({
"nav": { "about": "About" },
"a.b": "literal"
});
let mut flat = HashMap::new();
flatten(&v, "", &mut flat);
assert_eq!(flat.get("nav.about").map(|s| s.as_ref()), Some("About"));
assert_eq!(flat.get("a.b").map(|s| s.as_ref()), Some("literal"));
}
#[test]
fn get_by_path_prefers_exact_key() {
let v: Value = serde_json::json!({
"a.b": "exact",
"a": { "b": "nested" }
});
assert_eq!(
get_by_path(&v, "a.b").and_then(|x| x.as_str()),
Some("exact")
);
}
#[test]
fn page_merges_root_and_overrides() {
let locales = vec![Locale::new("en")];
let dir = write_temp(&[
("en.json", r#"{"shared":"root","nav":{"home":"Home"}}"#),
("pages/blog/en.json", r#"{"shared":"blog","title":"Post"}"#),
]);
let store = load_store(dir.path(), &locales).unwrap();
let root = store.get("en", "root").unwrap();
assert_eq!(root.flat.get("shared").map(|s| s.as_ref()), Some("root"));
assert!(!root.flat.contains_key("title"));
let blog = store.get("en", "blog").unwrap();
assert_eq!(blog.flat.get("shared").map(|s| s.as_ref()), Some("blog"));
assert_eq!(blog.flat.get("nav.home").map(|s| s.as_ref()), Some("Home"));
assert_eq!(blog.flat.get("title").map(|s| s.as_ref()), Some("Post"));
}
#[test]
fn etag_stable_and_changes() {
let locales = vec![Locale::new("en")];
let dir = write_temp(&[("en.json", r#"{"a":"1"}"#)]);
let s1 = load_store(dir.path(), &locales).unwrap();
let s2 = load_store(dir.path(), &locales).unwrap();
assert_eq!(
s1.get("en", "root").unwrap().etag,
s2.get("en", "root").unwrap().etag
);
std::fs::write(dir.path().join("en.json"), r#"{"a":"2"}"#).unwrap();
let s3 = load_store(dir.path(), &locales).unwrap();
assert_ne!(
s1.get("en", "root").unwrap().etag,
s3.get("en", "root").unwrap().etag
);
}
}