sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git

src/i18n.rs (4906B)

      1 //! UI 문자열 번역 — `i18n/<언어>.toml`.
      2 //!
      3 //! 템플릿의 `{{ t("skip_to_content") }}` 가 지금 렌더 중인 페이지의 언어로 찾는다.
      4 //!
      5 //! **없는 키도, 그 언어에 없는 번역도 에러다.** 기본 언어로 조용히 떨어뜨리면
      6 //! 한국어 페이지에 영어 라벨이 섞여 나가고, 그건 아무도 보고하지 않는 종류의
      7 //! 버그다 — 미번역 페이지를 복제하지 않고 감추는 것과 같은 이유다.
      8 
      9 use anyhow::{Context, Result};
     10 use std::collections::BTreeMap;
     11 use std::path::Path;
     12 
     13 pub const I18N_DIR: &str = "i18n";
     14 
     15 /// 언어 코드 → (키 → 문자열)
     16 pub type Catalog = BTreeMap<String, BTreeMap<String, String>>;
     17 
     18 /// `<root>/i18n/*.toml`을 읽는다. 디렉터리가 없으면 빈 카탈로그다 —
     19 /// `t`를 쓰지 않는 사이트는 이 기능의 존재를 알 필요가 없다.
     20 pub fn load(root: &Path) -> Result<Catalog> {
     21     let dir = root.join(I18N_DIR);
     22     if !dir.is_dir() {
     23         return Ok(Catalog::new());
     24     }
     25 
     26     let mut out = Catalog::new();
     27     let mut entries: Vec<_> = std::fs::read_dir(&dir)
     28         .with_context(|| format!("{}을(를) 읽을 수 없습니다", dir.display()))?
     29         .filter_map(std::result::Result::ok)
     30         .map(|e| e.path())
     31         .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("toml"))
     32         .collect();
     33     // 파일 순서가 빌드 결과에 영향을 주지 않도록 정렬한다.
     34     entries.sort();
     35 
     36     for path in entries {
     37         let Some(lang) = path.file_stem().and_then(|s| s.to_str()) else {
     38             continue;
     39         };
     40         let raw = std::fs::read_to_string(&path)
     41             .with_context(|| format!("{}을(를) 읽을 수 없습니다", path.display()))?;
     42         let table: BTreeMap<String, String> = toml::from_str(&raw).with_context(|| {
     43             format!(
     44                 "{} 파싱 실패 (값은 전부 문자열이어야 합니다)",
     45                 path.display()
     46             )
     47         })?;
     48         out.insert(lang.to_string(), table);
     49     }
     50 
     51     Ok(out)
     52 }
     53 
     54 /// 키를 찾는다. 실패 이유를 사람이 고칠 수 있는 문장으로 돌려준다.
     55 pub fn lookup(catalog: &Catalog, language: &str, key: &str) -> std::result::Result<String, String> {
     56     let Some(table) = catalog.get(language) else {
     57         return Err(format!(
     58             "'{language}' 번역 파일이 없습니다. {I18N_DIR}/{language}.toml 을 만드세요."
     59         ));
     60     };
     61     table.get(key).cloned().ok_or_else(|| {
     62         // 다른 언어에는 있는데 이 언어에만 없는 경우가 압도적으로 흔하다.
     63         // 그때는 "파일을 만들라"가 아니라 "이 줄을 더하라"가 맞는 조언이다.
     64         let elsewhere: Vec<&str> = catalog
     65             .iter()
     66             .filter(|(l, t)| l.as_str() != language && t.contains_key(key))
     67             .map(|(l, _)| l.as_str())
     68             .collect();
     69         if elsewhere.is_empty() {
     70             format!("번역 키 '{key}' 가 어느 언어에도 없습니다")
     71         } else {
     72             format!(
     73                 "번역 키 '{key}' 가 {I18N_DIR}/{language}.toml 에 없습니다 \
     74                  ({} 에는 있습니다)",
     75                 elsewhere.join(", ")
     76             )
     77         }
     78     })
     79 }
     80 
     81 #[cfg(test)]
     82 mod tests {
     83     use super::*;
     84 
     85     fn catalog() -> Catalog {
     86         Catalog::from([
     87             (
     88                 "en".to_string(),
     89                 BTreeMap::from([
     90                     ("home".to_string(), "Home".to_string()),
     91                     ("only_en".to_string(), "Only".to_string()),
     92                 ]),
     93             ),
     94             (
     95                 "ko".to_string(),
     96                 BTreeMap::from([("home".to_string(), "홈".to_string())]),
     97             ),
     98         ])
     99     }
    100 
    101     #[test]
    102     fn looks_up_in_the_page_language() {
    103         let c = catalog();
    104         assert_eq!(lookup(&c, "ko", "home").unwrap(), "홈");
    105         assert_eq!(lookup(&c, "en", "home").unwrap(), "Home");
    106     }
    107 
    108     #[test]
    109     fn a_key_missing_in_one_language_says_where_it_does_exist() {
    110         // 기본 언어로 떨어뜨리면 한국어 페이지에 영어가 섞여 나가고,
    111         // 그건 아무도 보고하지 않는다. 그래서 에러다.
    112         let err = lookup(&catalog(), "ko", "only_en").unwrap_err();
    113         assert!(err.contains("only_en"), "실제: {err}");
    114         assert!(err.contains("ko.toml"), "실제: {err}");
    115         assert!(err.contains("en"), "어디에 있는지 알려줘야 한다: {err}");
    116     }
    117 
    118     #[test]
    119     fn an_unknown_key_is_not_a_missing_translation() {
    120         let err = lookup(&catalog(), "ko", "typo").unwrap_err();
    121         assert!(err.contains("어느 언어에도 없습니다"), "실제: {err}");
    122     }
    123 
    124     #[test]
    125     fn a_language_with_no_file_says_so() {
    126         let err = lookup(&catalog(), "ja", "home").unwrap_err();
    127         assert!(err.contains("i18n/ja.toml"), "실제: {err}");
    128     }
    129 }