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 }