ハイサイ!オースティンやいびーん。ちょーや うちなー あみ ぬ ふっちょーいびーん。
概要
前回のRustのWeb開発でl10nの道を探る、第一章:Fluentで紹介したFluentとその組み込みロジックを使って、HTMLテンプレートエンジンのTeraを使ってi18n(国際化)およびl10n(地域化)をします。
紹介するソースコードは筆者が勉強用と家庭買い物用に使っているリスト管理アプリのものを使っています。
l10nシリーズの記事
Tera
RustでHTMLテンプレートをレンダーするレンプレートエンジンはいくつかありますが、主にはHandleBarsのRust実装とTeraがあります。筆者は前章でFluentを選定するのと同様に両方を天秤にかけましたが、やはり機能的にTeraの方が優れているので、Teraにしました。
TeraはPythonで有名なJinjaのテンプレートエンジンの構文を元に実装されており、Jinjaに馴染みがあるならTera一択です。筆者はJinjaよりHandlebarsとLaravelのBladeに慣れているのですが、ソースコードも含め、Teraの実装の品質が高いのでおすすめです。
また、Teraは、テンプレートに使われているフィルターと関数を慣用的に拡張できるように設計されているので、非常に使い勝手がよく、特に本稿の目的であるHTMLのl10nにはぴったりです。
TeraのHTMLテンプレートを読み込んで初期化する
以下のようなファイル構成でTeraのHTMLテンプレートを作成しておきます。
-- views
--- templates
---- base.html
---- login.html ### これをi18n化します
内容は以下のようにします。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale = 1.0, user-scalable = no" />
<title>Listo</title>
<link rel="stylesheet" href="/assets/css/style.css" />
<link rel="stylesheet" href="/assets/css/variables.css" />
<script type="importmap">
{
"imports": {
"lit": "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js",
"rxjs": "https://cdn.jsdelivr.net/npm/@esm-bundle/rxjs@7.8.1/+esm",
}
}
</script>
{% block head %} {% endblock head %}
</head>
<body>
{% block content %} {% endblock content %}
{% block scripts %} {% endblock scripts %}
</body>
</html>
base.html
は他のテンプレートでも使いまわされる基本的なHTMLが入っています。content
、 head
、scripts
など、base.html
を拡張できるようにします。
{% extends "base.html" %} {% block head %}
<link rel="stylesheet" href="/assets/css/registrations.css" />
<script src="https://www.google.com/recaptcha/api.js?render=6LcuLlsgAAAAADL_n_1hS7zeQMKX6xbi10jQYIYR"></script>
{% endblock head %} {% block content %}
<div class="card header">
<h1>TODO</h1>
<p>TODO</p>
</div>
<div class="card">
<listo-registration mode="login">
<div class="button-group">
<button class="button-left button inactive" id="login-button">
TODO
</button>
<button class="button button-right" id="register-button">
TODO
</button>
</div>
<form class="login-form" id="login-form">
<div class="form-group">
<label for="email">TODO</label>
<input id="email" name="email" type="email" required autocomplete="email" />
</div>
<div class="form-group">
<label for="password">TODO</label>
<input id="password" name="password" type="password" required autocomplete="current-password" />
</div>
<button id="submit" type="submit">
TODO
</button>
</form>
</listo-registration>
</div>
{% endblock content %} {% block scripts %}
<script type="module" src="/assets/js/listo-registration.js"></script>
{% endblock scripts %}
このlogin.html
にTODOがたくさんありますが、これはi18nの対象になる文章です。あとでここに戻って適切なコードを入れましょう。
<listo-registration>
はWeb Componentでログインに必要なJavaScriptのロジックがあります。本稿ではJavaScriptのFluentにおけるi18nを紹介しませんが、同じ考え方で.ftl
ファイルに入っているメッセージをJavaScriptでも使えるので、SSR用のテンプレートで作った翻訳文を共通化できます!
Teraの初期化
上記のHTMLテンプレートをTeraのインスタンスに読み込んで初期化するロジックを次に書きます。Teraはこのプロセスを非常に簡単にしてくれますので、Fluentみたいに深く考える必要はありません。
let mut tera = Tera::new("src/views/templates/**/*").expect("tera parsing error");
TeraのHTMLテンプレートに問題があったら起動時に失敗して欲しいので.expect
を使います。
これを使って上記のlogin.html
をTODOだらけのままレンダーするとしたら以下のようにできます。
let ctx = Context::new();
let html = state
.tera
.render("login.html", &ctx)
.expect("Template failed");
tera::Context
は、TeraのHTMLテンプレート内でアクセスできる変数を保管するオブジェクトです。Context
はHashMap<&str, String>
のようなもので、String
はserde
でシリアル化したJSONのようなものです。なので、Contextに入れるすべてのものは、serde::ser::Serialize
を実装していないといけません。
Teraのテンプレート内でFluentを使えるように
前章で築いたLocales
のHashMap
を使ってTeraの拡張関数を実装しましょう。まず前章の延長線でtera::Function
のトレートを実装します。
tera::Function
は以下のような定義があります。
/// The global function type definition
pub trait Function: Sync + Send {
/// The global function type definition
fn call(&self, args: &HashMap<String, Value>) -> Result<Value>;
/// Whether the current function's output should be treated as safe, defaults to `false`
fn is_safe(&self) -> bool {
false
}
}
引用:https://docs.rs/tera/latest/tera/trait.Function.html
is_safe
は、関数が出力する文字列は安全はHTMLか、それともエスケープされる必要があるのかを指定するためです。
call
はargs
の引数のマップを使ったHTMLテンプレートに埋め込む文字列を出力するところです。
とても重要なところですが、tera::Function
は、Send + Sync
が実装されていることを要求しています。前章でもFluentBundle
はデフォルトだと!Send + !Sync
なので、工夫するがあると紹介しました。FluentをTeraテンプレート内で使いたいなら、Send + Sync
が実装されているようにconcurrent
版を築く必要があったのはこれです。
今回は、Localizer
というstruct
を作って、そこに前章で初期化したFluentBundle
のHashMap
を持たせてtera::Function
を実装します。
use std::collections::HashMap;
use fluent::{bundle::FluentBundle, FluentResource};
use unic_langid::subtags::Language;
use unic_langid::{langid, LanguageIdentifier};
pub const ENGLISH: LanguageIdentifier = langid!("en");
pub const JAPANESE: LanguageIdentifier = langid!("ja");
pub type Locales =
HashMap<Language, FluentBundle<FluentResource, intl_memoizer::concurrent::IntlLangMemoizer>>;
fn init() -> Locales {
let mut locales = HashMap::new();
let en = create_bundle!(vec![ENGLISH], "locales/en/main.ftl", "locales/en/login.ftl");
locales.insert(ENGLISH.language, en);
let ja = create_bundle!(
vec![JAPANESE],
"locales/ja/main.ftl",
"locales/ja/login.ftl",
);
locales.insert(JAPANESE.language, ja);
locales
}
pub struct Localizer {
locales: Locales,
}
impl Localizer {
pub fn new() -> Self {
let locales = init();
Self { locales }
}
}
ここで定義したLocalizer
にさらにtera::Function
の実装を追加しましょう。
impl tera::Function for Localizer {
fn call(&self, args: &HashMap<String, serde_json::Value>) -> tera::Result<serde_json::Value> {
// ここでTeraテンプレート内で渡される`lang="..."`の引数を
// 開梱して、もしきちんとしたロケールコードになっていなければ
// エラーを返す
let lang_arg = args
.get("lang")
.and_then(|lang| lang.as_str())
.and_then(|str| str.parse::<LanguageIdentifier>().ok())
.ok_or(tera::Error::msg("missing lang param"))?;
// .ftlファイル内のキーを開梱する
// これもTeraテンプレート内では`key="..."`というふうに後々記載します。
let ftl_key = args
.get("key")
.and_then(|key| key.as_str())
.ok_or(tera::Error::msg("missing ftl key"))?;
let bundle = self
.locales
.get(&lang_arg.language)
.ok_or(tera::Error::msg("locale not registered"))?;
let msg = bundle
.get_message(ftl_key)
.ok_or(tera::Error::msg("FTL key not in locale"))?;
let pattern = msg
.value()
.ok_or(tera::Error::msg("No value in fluent message"))?;
// Fluent処理のエラーがあれば、ログに残したり、panic!などしたほうがいいが、
// 今はdbgのログのみ
let mut errs = Vec::new();
// Fluentの引数もありますが、今はとりあえず`None`にします
// Teraテンプレート内のこの関数の引数として渡せるように後々実装してみましょう。
let res = bundle.format_pattern(pattern, None, &mut errs);
if errs.len() > 0 {
dbg!(errs);
}
// TeraはJSONのStringが必要なので、serde_jsonで変換します。ただのStringですが。
Ok(serde_json::Value::String(res.into()))
}
fn is_safe(&self) -> bool {
// 翻訳メッセージにエスケープが必要なHTMLはないはずなので、Safeにしましょう。
true
}
}
TeraのFluentの変換関数を登録する
ここで実装したstruct Localizer
をTeraのインスタンスに追加して、テンプレート内で使えるようにしましょう。
let mut tera = Tera::new("src/views/templates/**/*").expect("tera parsing error");
let localizer = Localizer::new();
tera.register_function("fluent", localizer);
これで{{ fluent(key="hello-world", lang="en") }}
という風にテンプレート内で使えますので、もう一度login.html
に戻ってTODOを実装しましょう。
Teraテンプレートに翻訳対象となる文章にキーを付与する
TeraテンプレートでTODOにしていたところを、上記で追加した関数に置き換えていきます。
fluent
のTera関数にはロケールコードを持ったlang
の引数を渡す必要があるのですが、いちいちテンプレートに埋め込んでいたら本末転倒なので、tera::Context
にロケールコードを渡します。
let mut ctx = Context::new();
// 実際には動的にロケールコードを取得して渡すがここではJAに固定します。
ctx.insert("lang", "ja");
let html = state
.tera
.render("login.html", &ctx)
.expect("Template failed");
これでWebサーバーのリクエスト情報を元に動的にロケールを選定して引数をテンプレートに渡せます。さて、それでテンプレートを修正していきましょう。
{% extends "base.html" %} {% block head %}
<link rel="stylesheet" href="/assets/css/registrations.css" />
<script src="https://www.google.com/recaptcha/api.js?render=6LcuLlsgAAAAADL_n_1hS7zeQMKX6xbi10jQYIYR"></script>
{% endblock head %} {% block content %}
<div class="card header">
<h1>{{ fluent(key="login-header", lang=lang) }}</h1>
<p>{{ fluent(key="login-header-subtext", lang=lang) }}</p>
</div>
<div class="card">
<listo-registration mode="login">
<div class="button-group">
<button class="button-left button inactive" id="login-button">
{{ fluent(key="listo-registration-login-button", lang=lang) }}
</button>
<button class="button button-right" id="register-button">
{{ fluent(key="listo-registration-register-button", lang=lang) }}
</button>
</div>
<form class="login-form" id="login-form">
<div class="form-group">
<label for="email">{{ fluent(key="listo-registration-login-form-email-label", lang=lang) }}</label>
<input id="email" name="email" type="email" required autocomplete="email" />
</div>
<div class="form-group">
<label for="password">{{ fluent(key="listo-registration-login-form-password-label", lang=lang) }}</label>
<input id="password" name="password" type="password" required autocomplete="current-password" />
</div>
<button id="submit" type="submit">
{{ fluent(key="listo-registration-login-form-submit-label", lang=lang) }}
</button>
</form>
</listo-registration>
</div>
{% endblock content %} {% block scripts %}
<script type="module" src="/assets/js/listo-registration.js"></script>
{% endblock scripts %}
注意していただきたいのは、lang=lang
の引数です。lang="ja"
だったら、固定になりますが、lang=lang
だと、Context
に入れた変数のlang
が使われます。可読性のために名前を変えるべきだったでしょうか...?
これでテンプレートは完成ですが、翻訳ファイルに同キーを持ったメッセージを記載していきましょう。
.ftlファイルにメッセージを追加する
最後に、英語と日本語の翻訳文を追加していきたいのですが、今回は、Fluentの高度な機能を使わず、gettext
のように一対一のつまらない訳文になるのです。
日本語
# Login
login-header = Listo
login-header-subtext = 優雅にリスト管理を
## listo-registration WC
listo-registration-login-button = ログイン
listo-registration-register-button = 登録
listo-registration-login-form-email-label = メールアドレス
listo-registration-login-form-password-label = パスワード
listo-registration-login-form-submit-label = 送信
英語
# Login
login-header = Listo
login-header-subtext = An elegant List Manager
## listo-registration WC
listo-registration-login-button = Login
listo-registration-register-button = Register
listo-registration-login-form-email-label = Email
listo-registration-login-form-password-label = Password
listo-registration-login-form-submit-label = Submit
結果
結果として、login.html
が以下のようにレンダーされます。
英語
<html lang="en"><head><meta http-equiv="origin-trial" content="Az520Inasey3TAyqLyojQa8MnmCALSEU29yQFW8dePZ7xQTvSt73pHazLFTK5f7SyLUJSo2uKLesEtEa9aUYcgMAAACPeyJvcmlnaW4iOiJodHRwczovL2dvb2dsZS5jb206NDQzIiwiZmVhdHVyZSI6IkRpc2FibGVUaGlyZFBhcnR5U3RvcmFnZVBhcnRpdGlvbmluZyIsImV4cGlyeSI6MTcyNTQwNzk5OSwiaXNTdWJkb21haW4iOnRydWUsImlzVGhpcmRQYXJ0eSI6dHJ1ZX0=">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale = 1.0, user-scalable = no">
<title>Listo</title>
<link rel="stylesheet" href="/assets/css/style.css">
<link rel="stylesheet" href="/assets/css/variables.css">
<script type="text/javascript" async="" src="https://www.gstatic.com/recaptcha/releases/3sU2vDRVDmUU2E0Ro4VadvPr/recaptcha__en.js" crossorigin="anonymous" integrity="sha384-+EucQLX9FjQkBnp1US+TPO2CzxNKzs/l+WSpHcJEldNETFzQEp0i7r+cEaAMWcUk"></script><script type="importmap">
{
"imports": {
"lit": "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js",
"rxjs": "https://cdn.jsdelivr.net/npm/@esm-bundle/rxjs@7.8.1/+esm",
}
}
</script>
<link rel="stylesheet" href="/assets/css/registrations.css">
<script src="https://www.google.com/recaptcha/api.js?render=6LcuLlsgAAAAADL_n_1hS7zeQMKX6xbi10jQYIYR"></script>
</head>
<body>
<div class="card header">
<h1>Listo</h1>
<p>An elegant List Manager</p>
</div>
<div class="card">
<listo-registration mode="login">
<div class="button-group">
<button class="button-left button inactive" id="login-button">
Login
</button>
<button class="button button-right" id="register-button">
Register
</button>
</div>
<form class="login-form" id="login-form">
<div class="form-group">
<label for="email">Email</label>
<input id="email" name="email" type="email" required="" autocomplete="email">
</div>
<div class="form-group">
<label for="password">Password</label>
<input id="password" name="password" type="password" required="" autocomplete="current-password">
</div>
<button id="submit" type="submit">
Submit
</button>
</form>
</listo-registration>
</div>
<script type="module" src="/assets/js/listo-registration.js"></script>
</body></html>
日本語
<html lang="en"><head><meta http-equiv="origin-trial" content="Az520Inasey3TAyqLyojQa8MnmCALSEU29yQFW8dePZ7xQTvSt73pHazLFTK5f7SyLUJSo2uKLesEtEa9aUYcgMAAACPeyJvcmlnaW4iOiJodHRwczovL2dvb2dsZS5jb206NDQzIiwiZmVhdHVyZSI6IkRpc2FibGVUaGlyZFBhcnR5U3RvcmFnZVBhcnRpdGlvbmluZyIsImV4cGlyeSI6MTcyNTQwNzk5OSwiaXNTdWJkb21haW4iOnRydWUsImlzVGhpcmRQYXJ0eSI6dHJ1ZX0=">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale = 1.0, user-scalable = no">
<title>Listo</title>
<link rel="stylesheet" href="/assets/css/style.css">
<link rel="stylesheet" href="/assets/css/variables.css">
<script type="text/javascript" async="" src="https://www.gstatic.com/recaptcha/releases/3sU2vDRVDmUU2E0Ro4VadvPr/recaptcha__en.js" crossorigin="anonymous" integrity="sha384-+EucQLX9FjQkBnp1US+TPO2CzxNKzs/l+WSpHcJEldNETFzQEp0i7r+cEaAMWcUk"></script><script type="importmap">
{
"imports": {
"lit": "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js",
"rxjs": "https://cdn.jsdelivr.net/npm/@esm-bundle/rxjs@7.8.1/+esm",
}
}
</script>
<link rel="stylesheet" href="/assets/css/registrations.css">
<script src="https://www.google.com/recaptcha/api.js?render=6LcuLlsgAAAAADL_n_1hS7zeQMKX6xbi10jQYIYR"></script>
</head>
<body>
<div class="card header">
<h1>Listo</h1>
<p>優雅にリスト管理を</p>
</div>
<div class="card">
<listo-registration mode="login">
<div class="button-group">
<button class="button-left button inactive" id="login-button">
ログイン
</button>
<button class="button button-right" id="register-button">
登録
</button>
</div>
<form class="login-form" id="login-form">
<div class="form-group">
<label for="email">メールアドレス</label>
<input id="email" name="email" type="email" required="" autocomplete="email">
</div>
<div class="form-group">
<label for="password">パスワード</label>
<input id="password" name="password" type="password" required="" autocomplete="current-password">
</div>
<button id="submit" type="submit">
送信
</button>
</form>
</listo-registration>
</div>
<script type="module" src="/assets/js/listo-registration.js"></script>
</body></html>
Fluentの引数をTeraテンプレートから渡せるようにする
最後に、None
にしていたFluentの引数をSome
thingにしましょう
もう一度struct Localizer
のtera::Function
の実装に戻ります。そこでargs: &HashMap<String, serde_json::Value>
をちょっと変換して使わせてもらいます。
impl tera::Function for Localizer {
fn call(&self, args: &HashMap<String, serde_json::Value>) -> tera::Result<serde_json::Value> {
// 中略...
let fluent_args: FluentArgs = args
.iter()
// "lang"と"key"は毎度指定するし、被ったらいけないので含めません。
.filter(|(key, _)| key.as_str() != "lang" && key.as_str() != "key")
// &strに変換する必要がありますが、json_serde::Valueをstrに変換する
// メソッドはOptionを返すので
// filter_mapを使って有効なstrのみをFluentArgsに含める
.filter_map(|(key, val)| val.as_str().map(|val| (key, val)))
.collect();
dbg!(&fluent_args);
let mut errs = Vec::new();
let res = bundle.format_pattern(pattern, Some(&fluent_args), &mut errs);
if errs.len() > 0 {
dbg!(errs);
}
Ok(serde_json::Value::String(res.into()))
}
fn is_safe(&self) -> bool {
true
}
}
そしてlogin.html
で以下のように試しに<p>{{ fluent(key="login-header-subtext", lang=lang, name="Austin") }}</p>
を入れると以下のようにきちんとFluentArgsに渡されます。
fluent_args = FluentArgs(
[
(
"name",
String(
"Austin",
),
),
],
)
まとめ
ここまででTeraにFluentを組み込んで使う方法を紹介してきましたがいかがでしょうか?
Teraが拡張しやすいようにできているおかげですんなりできました。Teraのtera::Function
でContext
も同時にアクセスできたらもっと綺麗に書けたのにという思いもありますが"lang"=lang
で回避できるからまあいいでしょう。
次の投稿では、TeraをAxumに組み込んで、URLからロケールコードをtower::Service
を実装しているサービスの中で抽出してAxumのハンドラー関数で使う方法を紹介します。