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)
}
|