sqzass.git

sqzass

~ static site generator in Rust
git clone https://github.com/debuman/sqzass.git
commit cdb8b01b7803dbc13c30985f1ae632e644c9a0b2
parent f321d666af9fb164718fac2303ac5b4c340e8d91
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 22:39:26 +0900

Move UI strings out of the templates and into i18n/

The templates carried thirteen `{{ "이 페이지" if ko else "On this page" }}`
conditionals. Each one is a place where a third language means editing markup,
and a place where a translation lives somewhere nobody translating would think
to look.

They are now keys in `i18n/en.toml` and `i18n/ko.toml`, read by `t("…")`.

`t` takes the language from the page being rendered rather than from an
argument. A template that has to be told which language it is in is a template
someone will eventually forget to tell, and that page alone comes out in the
wrong language — with nothing failing.

A key missing from one language is an error, and the message names the file it
is missing from and the languages that do have it. Falling back to the default
would put English labels inside a Korean page: visible to every Korean reader
and invisible to whoever maintains the site. That is the same reasoning that
hides untranslated pages instead of duplicating them.

The `page.translations` loops were renamed from `t` to `lang`. They did not
call the function, so nothing was broken — but a loop variable shadowing `t`
means the first person to add a label inside that block gets a confusing error
for a reason that is nowhere near where they are looking.

Sites without an i18n/ directory are unaffected. The catalogue is only read by
templates that call `t`.

Diffstat:
Mdocs/content/content/languages.ko.md | 36++++++++++++++++++++++++++++++++++++
Mdocs/content/content/languages.md | 40++++++++++++++++++++++++++++++++++++++++
Adocs/i18n/en.toml | 16++++++++++++++++
Adocs/i18n/ko.toml | 16++++++++++++++++
Mdocs/templates/base.html | 32+++++++++++++++++---------------
Mdocs/templates/page.html | 7+++----
Asrc/i18n.rs | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 5++++-
Msrc/render.rs | 19+++++++++++++++++++
9 files changed, 280 insertion(+), 20 deletion(-)

diff --git a/docs/content/content/languages.ko.md b/docs/content/content/languages.ko.md
@@ -72,6 +72,42 @@ translation_key = "install"
 +++
 ```
 
+## UI 문자열
+
+페이지의 글은 `content/`에 있습니다. **템플릿이** 넣는 말 — "본문으로 건너뛰기",
+"이 페이지", "이전" — 은 `i18n/<코드>.toml`에 둡니다.
+
+```toml
+# i18n/en.toml
+home         = "Home"
+on_this_page = "On this page"
+```
+
+```toml
+# i18n/ko.toml
+home         = "홈"
+on_this_page = "이 페이지"
+```
+
+```html
+<a href="{{ home }}">{{ t("home") }}</a>
+```
+
+`t`는 지금 렌더 중인 페이지에서 언어를 읽습니다. 그래서 템플릿은 자기가 어느
+언어인지 묻지 않아도 되고, 알려 주는 걸 깜빡할 수 있는 자리도 생기지 않습니다.
+
+**어느 한 언어에 키가 없으면 에러**이고, 어느 언어에는 있는지도 알려 줍니다.
+
+```
+번역 키 'next' 가 i18n/ko.toml 에 없습니다 (en 에는 있습니다) (in page.html:22)
+```
+
+기본 언어로 떨어뜨리면 한국어 페이지 안에 영어 라벨이 들어갑니다. 한국어 독자에게는
+전부 보이고 사이트를 관리하는 사람에게는 보이지 않는데, 미번역 페이지를 복제하지
+않고 감추는 것과 정확히 같은 이유입니다.
+
+`i18n/` 디렉터리가 없는 사이트도 잘 돕니다. `t`는 그걸 부르는 템플릿에만 필요합니다.
+
 ## 한국어에서 알아 둘 것
 
 알아서 처리되지만 알고는 있어야 하는 게 둘 있습니다. 둘 다 어긋났을 때 조용하기
diff --git a/docs/content/content/languages.md b/docs/content/content/languages.md
@@ -74,6 +74,46 @@ translation_key = "install"
 +++
 ```
 
+## UI strings
+
+Page text lives in `content/`. The words the *template* supplies — "Skip to
+content", "On this page", "Previous" — live in `i18n/<code>.toml`.
+
+```toml
+# i18n/en.toml
+home         = "Home"
+on_this_page = "On this page"
+```
+
+```toml
+# i18n/ko.toml
+home         = "홈"
+on_this_page = "이 페이지"
+```
+
+```html
+<a href="{{ home }}">{{ t("home") }}</a>
+```
+
+`t` reads the language from the page being rendered, so a template never asks
+which language it is in. It never has to be told, and there is no line where
+someone can forget to tell it.
+
+**A key missing from one language is an error**, and the message says which
+languages do have it:
+
+```
+번역 키 'next' 가 i18n/ko.toml 에 없습니다 (en 에는 있습니다) (in page.html:22)
+```
+
+Falling back to the default language would put English labels inside a Korean
+page — visible to every Korean reader and invisible to whoever is maintaining
+the site, which is the same reason untranslated pages are hidden rather than
+duplicated.
+
+Sites with no `i18n/` directory work fine. `t` is only needed by templates that
+call it.
+
 ## Korean specifics
 
 Two things are handled for you and are worth knowing about, because both are
diff --git a/docs/i18n/en.toml b/docs/i18n/en.toml
@@ -0,0 +1,16 @@
+# UI strings. A key missing from any one language is a build error — falling
+# back would mean shipping English labels inside a Korean page.
+
+skip_to_content = "Skip to content"
+sections        = "Sections"
+documentation   = "Documentation"
+breadcrumb      = "Breadcrumb"
+home            = "Home"
+on_this_page    = "On this page"
+search          = "Search"
+search_the_docs = "Search the documentation"
+theme           = "Theme"
+built_with      = "Built with sqzass"
+pagination      = "Pagination"
+previous        = "Previous"
+next            = "Next"
diff --git a/docs/i18n/ko.toml b/docs/i18n/ko.toml
@@ -0,0 +1,16 @@
+# UI 문자열. 어느 한 언어에 키가 없으면 빌드 에러다 — 기본 언어로 떨어뜨리면
+# 한국어 페이지에 영어 라벨이 섞여 나간다.
+
+skip_to_content = "본문으로 건너뛰기"
+sections        = "문서 갈래"
+documentation   = "문서"
+breadcrumb      = "현재 위치"
+home            = "홈"
+on_this_page    = "이 페이지"
+search          = "검색"
+search_the_docs = "문서 검색"
+theme           = "테마"
+built_with      = "sqzass로 빌드됨"
+pagination      = "이어 보기"
+previous        = "이전"
+next            = "다음"
diff --git a/docs/templates/base.html b/docs/templates/base.html
@@ -14,8 +14,10 @@
 <title>{% if page.url == home %}{{ site.title }} — {{ site.description }}{% else %}{{ page.title }} · {{ site.title }}{% endif %}</title>
 {% if page.description %}<meta name="description" content="{{ page.description }}">{% endif %}
 <link rel="canonical" href="{{ page.permalink }}">
-{%- for t in page.translations %}
-<link rel="alternate" hreflang="{{ t.code }}" href="{{ site.base_url }}{{ t.url }}">
+{#- `lang`이지 `t`가 아니다. `t`는 번역 함수이고, 루프 변수가 그 이름을 가리면
+    이 블록 안에서 라벨을 하나 넣는 순간 조용히 깨진다. #}
+{%- for lang in page.translations %}
+<link rel="alternate" hreflang="{{ lang.code }}" href="{{ site.base_url }}{{ lang.url }}">
 {%- endfor %}
 <link rel="stylesheet" href="{{ asset("css/main.css") }}">
 {%- if site.highlight_css %}
@@ -29,7 +31,7 @@
 </script>
 </head>
 <body>
-<a class="skip" href="#content">{{ "본문으로 건너뛰기" if ko else "Skip to content" }}</a>
+<a class="skip" href="#content">{{ t("skip_to_content") }}</a>
 
 <header class="masthead">
   <a class="brand" href="{{ home }}">{{ site.title }}</a>
@@ -37,7 +39,7 @@
   {#- The top nav is the section list. It is the same data the sidebar draws,
       one level up, so a reader can move between parts of the documentation
       without going back to the index. #}
-  <nav class="sections" aria-label="{{ "문서 갈래" if ko else "Sections" }}">
+  <nav class="sections" aria-label="{{ t("sections") }}">
     {%- for s in site.sections %}
     <a href="{{ s.url }}"{% if page.section and page.section.url == s.url or page.url == s.url %} aria-current="true"{% endif %}>{{ s.title }}</a>
     {%- endfor %}
@@ -48,28 +50,28 @@
             data-index="/search-{{ page.language }}.json">
       {#- The label is replaced with the reader's own modifier by search.js;
           this is the value someone with JavaScript off sees. #}
-      <span>{{ "검색" if ko else "Search" }}</span><kbd>Ctrl /</kbd>
+      <span>{{ t("search") }}</span><kbd>Ctrl /</kbd>
     </button>
-    {%- for t in page.translations %}
-    <a href="{{ t.url }}" hreflang="{{ t.code }}">{{ t.name }}</a>
+    {%- for lang in page.translations %}
+    <a href="{{ lang.url }}" hreflang="{{ lang.code }}">{{ lang.name }}</a>
     {%- endfor %}
     <a href="https://github.com/sqzer-x/sqzass">GitHub</a>
     {#- Text, not a glyph: ◐/☾ and friends are missing from enough fallback
         stacks that they render as a box. #}
-    <button class="theme-toggle" type="button">{{ "테마" if ko else "Theme" }}</button>
+    <button class="theme-toggle" type="button">{{ t("theme") }}</button>
   </div>
 </header>
 
 <div class="layout">
-  <aside class="sidebar" aria-label="{{ "문서" if ko else "Documentation" }}">
+  <aside class="sidebar" aria-label="{{ t("documentation") }}">
     {{ nav(site.sections, page.url) }}
   </aside>
 
   <main id="content">
     <div class="title-block">
       {%- if page.section %}
-      <nav class="crumbs" aria-label="{{ "현재 위치" if ko else "Breadcrumb" }}">
-        <a href="{{ home }}">{{ "홈" if ko else "Home" }}</a>
+      <nav class="crumbs" aria-label="{{ t("breadcrumb") }}">
+        <a href="{{ home }}">{{ t("home") }}</a>
         <a href="{{ page.section.url }}">{{ page.section.title }}</a>
       </nav>
       {%- endif %}
@@ -87,21 +89,21 @@
   </main>
 
   {%- if page.toc and page.toc_entries %}
-  <aside class="toc" data-label="{{ "이 페이지" if ko else "On this page" }}" aria-label="{{ "이 페이지" if ko else "On this page" }}">
+  <aside class="toc" data-label="{{ t("on_this_page") }}" aria-label="{{ t("on_this_page") }}">
     {{ toc_list(page.toc_entries) }}
   </aside>
   {%- endif %}
 </div>
 
 <footer class="colophon">
-  <span>{{ "sqzass로 빌드됨" if ko else "Built with sqzass" }}</span>
+  <span>{{ t("built_with") }}</span>
   <a href="https://github.com/sqzer-x/sqzass">Source</a>
   <span>MIT</span>
 </footer>
 
-<dialog id="search-dialog" class="search-dialog" aria-label="{{ "문서 검색" if ko else "Search the documentation" }}">
+<dialog id="search-dialog" class="search-dialog" aria-label="{{ t("search_the_docs") }}">
   <div class="search-panel">
-    <input type="search" autocomplete="off" spellcheck="false" aria-label="{{ "문서 검색" if ko else "Search the documentation" }}">
+    <input type="search" autocomplete="off" spellcheck="false" aria-label="{{ t("search_the_docs") }}">
     <ul class="search-results"></ul>
     <div class="search-foot">
       <span class="search-status"></span>
diff --git a/docs/templates/page.html b/docs/templates/page.html
@@ -1,5 +1,4 @@
 {% extends "base.html" %}
-{%- set ko = page.language == "ko" %}
 
 {% block content %}
 <div class="prose">
@@ -11,16 +10,16 @@
     across the boundary into an unrelated one pretends the sections are a single
     ordered book. #}
 {%- if page.prev or page.next %}
-<nav class="pager" aria-label="{{ "이어 보기" if ko else "Pagination" }}">
+<nav class="pager" aria-label="{{ t("pagination") }}">
   {%- if page.prev %}
   <a class="pager-prev" href="{{ page.prev.url }}" rel="prev">
-    <span>{{ "이전" if ko else "Previous" }}</span>
+    <span>{{ t("previous") }}</span>
     <strong>{{ page.prev.title }}</strong>
   </a>
   {%- endif %}
   {%- if page.next %}
   <a class="pager-next" href="{{ page.next.url }}" rel="next">
-    <span>{{ "다음" if ko else "Next" }}</span>
+    <span>{{ t("next") }}</span>
     <strong>{{ page.next.title }}</strong>
   </a>
   {%- endif %}
diff --git a/src/i18n.rs b/src/i18n.rs
@@ -0,0 +1,129 @@
+//! UI 문자열 번역 — `i18n/<언어>.toml`.
+//!
+//! 템플릿의 `{{ t("skip_to_content") }}` 가 지금 렌더 중인 페이지의 언어로 찾는다.
+//!
+//! **없는 키도, 그 언어에 없는 번역도 에러다.** 기본 언어로 조용히 떨어뜨리면
+//! 한국어 페이지에 영어 라벨이 섞여 나가고, 그건 아무도 보고하지 않는 종류의
+//! 버그다 — 미번역 페이지를 복제하지 않고 감추는 것과 같은 이유다.
+
+use anyhow::{Context, Result};
+use std::collections::BTreeMap;
+use std::path::Path;
+
+pub const I18N_DIR: &str = "i18n";
+
+/// 언어 코드 → (키 → 문자열)
+pub type Catalog = BTreeMap<String, BTreeMap<String, String>>;
+
+/// `<root>/i18n/*.toml`을 읽는다. 디렉터리가 없으면 빈 카탈로그다 —
+/// `t`를 쓰지 않는 사이트는 이 기능의 존재를 알 필요가 없다.
+pub fn load(root: &Path) -> Result<Catalog> {
+    let dir = root.join(I18N_DIR);
+    if !dir.is_dir() {
+        return Ok(Catalog::new());
+    }
+
+    let mut out = Catalog::new();
+    let mut entries: Vec<_> = std::fs::read_dir(&dir)
+        .with_context(|| format!("{}을(를) 읽을 수 없습니다", dir.display()))?
+        .filter_map(std::result::Result::ok)
+        .map(|e| e.path())
+        .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("toml"))
+        .collect();
+    // 파일 순서가 빌드 결과에 영향을 주지 않도록 정렬한다.
+    entries.sort();
+
+    for path in entries {
+        let Some(lang) = path.file_stem().and_then(|s| s.to_str()) else {
+            continue;
+        };
+        let raw = std::fs::read_to_string(&path)
+            .with_context(|| format!("{}을(를) 읽을 수 없습니다", path.display()))?;
+        let table: BTreeMap<String, String> = toml::from_str(&raw).with_context(|| {
+            format!(
+                "{} 파싱 실패 (값은 전부 문자열이어야 합니다)",
+                path.display()
+            )
+        })?;
+        out.insert(lang.to_string(), table);
+    }
+
+    Ok(out)
+}
+
+/// 키를 찾는다. 실패 이유를 사람이 고칠 수 있는 문장으로 돌려준다.
+pub fn lookup(catalog: &Catalog, language: &str, key: &str) -> std::result::Result<String, String> {
+    let Some(table) = catalog.get(language) else {
+        return Err(format!(
+            "'{language}' 번역 파일이 없습니다. {I18N_DIR}/{language}.toml 을 만드세요."
+        ));
+    };
+    table.get(key).cloned().ok_or_else(|| {
+        // 다른 언어에는 있는데 이 언어에만 없는 경우가 압도적으로 흔하다.
+        // 그때는 "파일을 만들라"가 아니라 "이 줄을 더하라"가 맞는 조언이다.
+        let elsewhere: Vec<&str> = catalog
+            .iter()
+            .filter(|(l, t)| l.as_str() != language && t.contains_key(key))
+            .map(|(l, _)| l.as_str())
+            .collect();
+        if elsewhere.is_empty() {
+            format!("번역 키 '{key}' 가 어느 언어에도 없습니다")
+        } else {
+            format!(
+                "번역 키 '{key}' 가 {I18N_DIR}/{language}.toml 에 없습니다 \
+                 ({} 에는 있습니다)",
+                elsewhere.join(", ")
+            )
+        }
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn catalog() -> Catalog {
+        Catalog::from([
+            (
+                "en".to_string(),
+                BTreeMap::from([
+                    ("home".to_string(), "Home".to_string()),
+                    ("only_en".to_string(), "Only".to_string()),
+                ]),
+            ),
+            (
+                "ko".to_string(),
+                BTreeMap::from([("home".to_string(), "홈".to_string())]),
+            ),
+        ])
+    }
+
+    #[test]
+    fn looks_up_in_the_page_language() {
+        let c = catalog();
+        assert_eq!(lookup(&c, "ko", "home").unwrap(), "홈");
+        assert_eq!(lookup(&c, "en", "home").unwrap(), "Home");
+    }
+
+    #[test]
+    fn a_key_missing_in_one_language_says_where_it_does_exist() {
+        // 기본 언어로 떨어뜨리면 한국어 페이지에 영어가 섞여 나가고,
+        // 그건 아무도 보고하지 않는다. 그래서 에러다.
+        let err = lookup(&catalog(), "ko", "only_en").unwrap_err();
+        assert!(err.contains("only_en"), "실제: {err}");
+        assert!(err.contains("ko.toml"), "실제: {err}");
+        assert!(err.contains("en"), "어디에 있는지 알려줘야 한다: {err}");
+    }
+
+    #[test]
+    fn an_unknown_key_is_not_a_missing_translation() {
+        let err = lookup(&catalog(), "ko", "typo").unwrap_err();
+        assert!(err.contains("어느 언어에도 없습니다"), "실제: {err}");
+    }
+
+    #[test]
+    fn a_language_with_no_file_says_so() {
+        let err = lookup(&catalog(), "ja", "home").unwrap_err();
+        assert!(err.contains("i18n/ja.toml"), "실제: {err}");
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -4,6 +4,7 @@ pub mod assets;
 pub mod config;
 pub mod content;
 pub mod highlight;
+pub mod i18n;
 pub mod init;
 pub mod links;
 pub mod markdown;
@@ -100,7 +101,9 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
     };
 
     assets.write_manifest();
-    let templates = Templates::load(root)?.with_assets(assets.manifest.clone());
+    let templates = Templates::load(root)?
+        .with_assets(assets.manifest.clone())
+        .with_i18n(i18n::load(root)?);
 
     let mut out = BuildOutput::default();
     out.files.extend(assets.files);
diff --git a/src/render.rs b/src/render.rs
@@ -117,6 +117,25 @@ impl Templates {
         self
     }
 
+    /// 템플릿에서 `{{ t("skip_to_content") }}`로 UI 문자열을 얻게 한다.
+    ///
+    /// 언어는 인자로 받지 않고 렌더 중인 컨텍스트의 `page.language`에서 읽는다.
+    /// 호출하는 쪽이 매번 언어를 넘겨야 하면 언젠가 한 군데를 빠뜨리고, 그 페이지만
+    /// 다른 언어로 나간다.
+    pub fn with_i18n(mut self, catalog: crate::i18n::Catalog) -> Self {
+        self.env.add_function("t", move |state: &State, key: &str| {
+            let language = state
+                .lookup("page")
+                .and_then(|p| p.get_attr("language").ok())
+                .map(|v| v.to_string())
+                .unwrap_or_default();
+
+            crate::i18n::lookup(&catalog, &language, key)
+                .map_err(|msg| minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, msg))
+        });
+        self
+    }
+
     pub fn has(&self, name: &str) -> bool {
         self.names.iter().any(|n| n == name)
     }