1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

RustのWeb開発でl10nの道を探る、第二章:FluentをTeraで使ってHTMLテンプレートをl10nする

Last updated at Posted at 2023-11-02

ハイサイ!オースティンやいびーん。ちょーや うちなー あみ ぬ ふっちょーいびーん。

概要

前回の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化します

内容は以下のようにします。

views/templates/base.html
<!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が入っています。contentheadscriptsなど、base.htmlを拡張できるようにします。

views/templates/login.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テンプレート内でアクセスできる変数を保管するオブジェクトです。ContextHashMap<&str, String>のようなもので、Stringserdeでシリアル化したJSONのようなものです。なので、Contextに入れるすべてのものは、serde::ser::Serializeを実装していないといけません。

Teraのテンプレート内でFluentを使えるように

前章で築いたLocalesHashMapを使って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か、それともエスケープされる必要があるのかを指定するためです。

callargsの引数のマップを使ったHTMLテンプレートに埋め込む文字列を出力するところです。

とても重要なところですが、tera::Functionは、Send + Syncが実装されていることを要求しています。前章でもFluentBundleはデフォルトだと!Send + !Syncなので、工夫するがあると紹介しました。FluentをTeraテンプレート内で使いたいなら、Send + Syncが実装されているようにconcurrent版を築く必要があったのはこれです。

今回は、Localizerというstructを作って、そこに前章で初期化したFluentBundleHashMapを持たせて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サーバーのリクエスト情報を元に動的にロケールを選定して引数をテンプレートに渡せます。さて、それでテンプレートを修正していきましょう。

views/templates/login.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>{{ 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が使われます。可読性のために名前を変えるべきだったでしょうか...?:sweat_smile:

これでテンプレートは完成ですが、翻訳ファイルに同キーを持ったメッセージを記載していきましょう。

.ftlファイルにメッセージを追加する

最後に、英語と日本語の翻訳文を追加していきたいのですが、今回は、Fluentの高度な機能を使わず、gettextのように一対一のつまらない訳文になるのです。

日本語

locales/ja/login.ftl
# 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 = 送信

英語

locales/en/login.ftl
# 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の引数をSomethingにしましょう:wink:

もう一度struct Localizertera::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::FunctionContextも同時にアクセスできたらもっと綺麗に書けたのにという思いもありますが"lang"=langで回避できるからまあいいでしょう。

次の投稿では、TeraをAxumに組み込んで、URLからロケールコードをtower::Serviceを実装しているサービスの中で抽出してAxumのハンドラー関数で使う方法を紹介します。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?