语言与本地化

使用 glory-i18n 实现国际化。

将翻译放入 Fluent 资源,为每个应用显式选择语言。glory-i18n 将不可变词典连接到静态值、Cage 数据源和 Bond 计算,响应式消息的生命周期由其 Owner 管理。

启用可选集成

启用 facade 的 i18n 特性,通过 glory::i18n 导入 API。本指南固定到包含该特性的语义架构 Git 提交;较旧的 crates.io 版本可能尚未提供此特性。服务器与浏览器的组合示例还选择 ssr 和 hydrate,后者包含 web。生成 /app.js 时需使用与锁定依赖一致的 wasm-bindgen CLI 版本。将下面的示例模块放入 src/examples/,在 mod.rs 中声明,并从库中导出该模块。i18n_server 仅在原生目标编译,i18n_browser 仅在 wasm32 编译。

Cargo.toml

1 2 3 4 5 6 7 8 9
[lib] crate-type = ["cdylib", "rlib"] [dependencies] glory = { git = "https://github.com/glory-rs/glory", rev = "0a46e2bd2f301807571b67f4a91776402eda8aeb", default-features = false, features = ["i18n", "ssr", "hydrate"] } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "=0.2.129" web-sys = { version = "=0.3.106", features = ["Window", "Document", "Element", "Location"] }

src/lib.rs

1
pub mod examples;

src/examples/mod.rs

1 2 3 4 5 6 7 8
#[cfg(target_arch = "wasm32")] pub mod i18n_browser; pub mod i18n_catalog; #[cfg(not(target_arch = "wasm32"))] pub mod i18n_server; pub mod i18n_shared; pub mod sapid_action;

在 Fluent 中编写参数与复数形式

将这两个文件放在 crate 根目录的 locales/ 中。各语言使用相同的消息 ID 和变量名,语序与复数分支可以不同。这些演示资源与官网自身的导航及文章词典分开管理。

locales/demo.en.ftl

1 2 3 4 5 6 7
greeting = Hello, { $name }! inbox = { $count -> [one] You have one message. *[other] You have { $count } messages. } add = Add a message

locales/demo.zh-CN.ftl

1 2 3 4
greeting = 你好,{ $name }! inbox = 你有 { $count } 条消息。 add = 增加一条消息

将 count 作为数字传入,不要预先格式化成 String。Fluent 根据资源的语言为英文选择 one 或 other 类别;简体中文可对所有数量使用同一个模式。带星号的 other 分支是默认分支。请测试 0、1、2 等代表值,不要假设所有语言都具有英文的复数类别。

解析词典并格式化参数

Catalog::new 一次性解析自有资源,拒绝语法错误和重复条目。在应用中共享生成的 Rc<Catalog>,避免在每次响应式计算中重新解析。下面的初始化代码选择在内嵌资源无效时立即失败。greeting 演示普通的严格查词,使用借用的 name 参数,并将格式化错误返回给调用者。

src/examples/i18n_catalog.rs

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
use glory::i18n::{Args, Catalog, Error, Locale}; use std::rc::Rc; pub fn catalog() -> Rc<Catalog> { let catalog = Catalog::new( "en", [ ("en", include_str!("../../locales/demo.en.ftl")), ("zh-CN", include_str!("../../locales/demo.zh-CN.ftl")), ], ) .expect("valid embedded Fluent resources"); assert!(catalog.validate_complete().is_empty()); Rc::new(catalog) } pub fn greeting(catalog: &Catalog, locale: &Locale, name: &str) -> Result<String, Error> { let mut args = Args::new(); args.set("name", name); catalog.format_exact(locale, "greeting", Some(&args)) }

一次性创建词典,将 "en" 或 "zh-CN" 解析为 glory::i18n::Locale,然后调用 greeting(&catalog, &locale, "Ada")。Args::set 接受文本和数字。Catalog::format_exact 不会回退。I18n::format 会立即读取语言源;构造组件时的普通读取不会建立订阅。

使用有所有权的 Cage 切换语言

下面的共享组件拥有一个语言 Cage 和一个消息数量。I18n::new 也接受静态 Locale 或从应用状态派生的 Bond<Locale>。message 显式地在指定 Owner 下创建 Bond<Result<String, Error>>,同时追踪语言源和参数闭包中的读取。标签 Bond 应用本演示对已验证资源的错误处理策略。在事件处理器中修改 language 后,运行时会在宿主正常的事件或帧边界调度受影响的绑定。

src/examples/i18n_shared.rs

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
use super::i18n_catalog::catalog; use glory::{ i18n::{Args, I18n, Locale}, prelude::*, }; // Both the server and browser call this before constructing the widget. pub fn locale_for_path(path: &str) -> Locale { let path = path.split(['?', '#']).next().unwrap_or(path); let tag = if path == "/zh" || path.starts_with("/zh/") { "zh-CN" } else { "en" }; tag.parse().expect("static language tag") } pub fn language_demo(initial: Locale) -> impl Widget { widget(move |scope| { let language = scope.cage(initial.clone()); let count = scope.cage(1_i32); let i18n = I18n::new(catalog(), language); let message = i18n .message(scope.owner(), "inbox", move || { let mut args = Args::new(); args.set("count", count.get()); args }) .expect("live message owner"); let add = i18n.message(scope.owner(), "add", Args::new).expect("live message owner"); // The application chooses its error policy. These resources are tested. let label = scope.bond(move || message.get().expect("valid inbox translation")); let add_label = scope.bond(move || add.get().expect("valid action translation")); col() .class("i18n-demo") .fill(( text().class("i18n-demo-message").content(label), row().class("i18n-demo-actions").fill(( button() .class("counter-button") .label("English") .on(activate, move |_| language.revise(|id| *id = "en".parse().unwrap())), button().class("counter-button").label("简体中文").on(activate, move |_| { language.revise(|id| *id = "zh-CN".parse().unwrap()) }), button() .class("counter-button counter-primary") .label(add_label) .on(activate, move |_| count.revise(|count| *count += 1)), )), )) .show_in(scope) }) }

在构建期间分配消息

在组件构建作用域中一次性创建上下文和消息 Bond,不要在纯派生计算中创建。参数闭包返回含有自有或静态值的 Args,可以读取 Cage,但不能写状态或创建新的响应式节点。释放 Owner 会使消息失效并释放捕获值。克隆 I18n 只共享资源和语言句柄,不会保留 Owner,也不会创建观察器。

试用语言切换与复数

从一条消息开始,再增加一条,并在 English 与简体中文之间切换。本地语言按钮会保留消息数量。官网顶部的语言选择器则会切换本地化 URL,并重新挂载当前页面;若业务状态需要跨页面保留,应将其放到该路由边界之上。

你有 ⁨1⁩ 条消息。

在服务端渲染前选择语言

两端采用相同的 locale_for_path 规则:/zh 和 /zh/... 选择 zh-CN,其他路径选择 en。每个请求都创建新的组件和状态。词典可以共享,当前语言必须属于请求或应用。snapshot 序列化版本化语义树及翻译后的初始值。将 Snapshot::html 原样插入独占挂载元素,加入 BASE_CSS,并设置外层 HTML 的语言。这个最小宿主示例仅提供演示组件;完整网站还应本地化标题、描述、canonical URL 和语言替代链接。

src/examples/i18n_server.rs

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
use super::i18n_shared::{language_demo, locale_for_path}; pub fn render_page(path: &str) -> Result<String, Box<dyn std::error::Error>> { let initial = locale_for_path(path); let snapshot = glory::ssr::snapshot(language_demo(initial.clone()))?; Ok(format!( r#"<!doctype html> <html lang="{initial}"><head><meta charset="utf-8"> <title>Glory i18n demo</title> <style data-gly-base="v1">{}</style> </head><body><main id="app">{}</main> <script type="module">import init from '/app.js'; await init();</script> </body></html>"#, glory::html::BASE_CSS, snapshot.html )) }

使用相同的初始语言水合

浏览器在构造组件前读取 URL,使用相同资源和初始数量 1,然后调用 hydrate_with。从应用自己的 wasm-bindgen 启动入口调用 start_demo,并像示例一样保留 WebApp 守卫。将生成的 JavaScript 以 /app.js 提供,与 WASM 文件放在一起;服务器需为请求的本地化路由返回 render_page。这里展示的代码块直接取自官网实际编译和测试的源文件。

src/examples/i18n_browser.rs

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
use super::i18n_shared::{language_demo, locale_for_path}; use std::cell::RefCell; use wasm_bindgen::JsValue; thread_local! { static APP: RefCell<Option<glory::web::WebApp>> = const { RefCell::new(None) }; } // Call this from your app's #[wasm_bindgen(start)] entry. pub fn start_demo() -> Result<(), JsValue> { let window = web_sys::window().ok_or_else(|| JsValue::from_str("Missing window"))?; let document = window.document().ok_or_else(|| JsValue::from_str("Missing document"))?; let root = document.get_element_by_id("app").ok_or_else(|| JsValue::from_str("Missing mount"))?; // Use the same URL policy before hydration; do not override it with navigator.language. let initial = locale_for_path(&window.location().pathname()?); let app = glory::web::hydrate_with(root, move |_| language_demo(initial)) .map_err(|error| JsValue::from_str(&error.to_string()))?; APP.with(|slot| *slot.borrow_mut() = Some(app)); Ok(()) }

首次语义树必须完全一致

不要在接管前使用 navigator.language 或存储的偏好覆盖中文 URL 的初始语言。服务器和浏览器必须使用一致的资源、语言、参数、结构和初始状态。普通 ssr::render 输出不是水合 Snapshot。应在成功水合后,通过明确导航或状态修改应用语言偏好。

选择回退与验证策略

Catalog::format 只在缺少请求语言、消息或属性时回退;缺失参数、无法解析的引用、循环引用等解析期求值错误仍会返回错误。I18n::format 和 message 使用这一回退策略。发布必须完整的翻译时使用 format_exact,并按应用需要处理 Error 结果。

validate_complete 对照回退语言,比较消息和术语的值、属性及直接引用的变量名,允许复数类别和语序不同。这个结构检查不能证明每个分支都能求值;还应测试参数化消息,并验证翻译后的 SSR 和客户端路径。

Fluent 默认启用双向文本隔离,因此参数周围可能存在不可见的隔离字符。展示时应保留这些字符。翻译内容应按文本处理,不要当作可信 HTML;链接和富文本结构使用普通语义组件表达。

glory-i18n 不负责探测浏览器偏好、持久化语言、URL 路由或更新文档元数据,这些由应用决定。官网使用英文路径和 /zh/ 路径,语言选择器保留章节、查询参数和锚点,并生成 canonical URL、hreflang 替代链接和双语 sitemap。

阅读完整的 SSR 与水合指南 →