コンテンツにスキップ

ページの翻訳

テンプレートに書いた文字列は1つの言語のものです。2つ目を足そうとすると、たいていは ロケール値を全コンポーネントと全レイアウトに引き回すことになり、そのうえ「どこかで 文字列を出し忘れていないか」を人間が保証する羽目になります。

Popcorn Web はその両方をずらします。ロケールはパラメータでないままテンプレートに 届き、訳文が欠けているメッセージは本番で空欄になる前に pw generate を落とします。

ただし、2つ目の言語を宣言するまでこの仕組みは存在しません。単一言語のプロジェクトは テンプレートに素の文字列を書き、カタログを開くことがない。実際に出す言語が増えた ときに手を出すもので、備えて先に導入するものではありません。

言語と、その文字列の置き場所を宣言します。

[i18n]
locales = ["ja", "en"]
default_locale = "ja"
path_routes = ["/"]
[i18n.label]
ja = "日本語"
en = "English"

文字列は messages/shop.yaml に書きます。スコープ1つがファイル1つです。

title:
locales:
ja: "お店"
en: "Shop"
greeting:
params: ["name string"]
locales:
ja: "ようこそ、{name}さん"
en: "Welcome, {name}!"

参照はこう書きます。

package pages
messages shop
export component Page(name: string): html {
<section>
<h1>{t title}</h1>
<p>{t greeting, name: name}</p>
<a href="/{lang}/about">{t title}</a>
</section>
}

pw generate がカタログを型付きの Go パッケージに変換し、参照をそれと突き合わせます。 ID の綴り違い、引数の過不足、訳文を出していない言語があれば、そこでビルドが止まります。

ファイル先頭の messages shop は、このテンプレートの参照がどのカタログファイルに 解決するかを決めます。だから {t title}shop.title です。1ファイル1行で、 参照があるのに宣言がないファイルはエラーになります。パスから推測はしません — それを許すと、コンポーネントを移動しただけで表示される文字列が黙って変わります。

同じスコープを宣言した2つのテンプレートはメッセージを共有します。「保存」「キャンセル」 のための common スコープはそれで足ります。両方のファイルが宣言し、1つのエントリを 読む。別のスコープのものは {t common.save} と修飾して書けば届きます。

リンクとアセットにはロケールを明示的に書く

Section titled “リンクとアセットにはロケールを明示的に書く”

テンプレートには2つの名前が入っていて、これは入れ替えが利きません。

{lang}URL のセグメントです。パス方式ではタグ、それ以外では空文字になり、 空のときは直前のスラッシュごと消えます。だから1つのテンプレートがどの構成でも通ります。

<a href="/{lang}/about"> <!-- /ja/about、パスに言語が出ない構成では /about -->

{langtag}タグそのもので、決して空になりません。文書の言語属性と、 ロケール別アセットのパスが欲しいのはこちらです。

<html lang="{langtag}">
<img src="/assets/{langtag}/hero.png">
<img src="/assets/logo.png"> <!-- 言語非依存: どちらも書かない -->

2つに分かれているのは、画像も翻訳されうるからです。フレームワークが勝手にリンクを 書き換えるなら、/assets/hero.png がルートなのかファイルなのかを判別する必要があり、 それを教えてくれる属性はありません。<a> が PDF を指すこともあれば、<img> が ルートを指すこともある。両方を正しく扱える唯一の方法が、書く人が名前を選ぶことです。

いくつの形が要るかはメッセージではなく言語の事実です。日本語は1つ、英語は2つ、 ロシア語は3つ。言語ごとに宣言します。

item-count:
params: ["n int"]
plural: n
locales:
ja: "カートに{n}件"
en:
one: "{n} item in your cart"
other: "{n} items in your cart"
<p>{t item-count, n: count}</p>

その言語の規則が要求する形を欠いている場合はビルドが落ちるので、思いつかなかった ケースが間違って表示される前に報告されます。数値はロケールに合わせて桁区切りされます — 日本語と英語なら 1,234、ドイツ語なら 1.234

1文を3つのメッセージに割ると翻訳できなくなります。語順が動くからです。ブロック形で 書いて、リンクがどこに来るかは訳文に決めさせます。

{t agree}<a href="/{lang}/start"></a>{/t}
agree:
rich: true
locales:
ja: "利用規約に同意の上、<a>開始</a>してください"
en: "Please <a>get started</a> after agreeing to the terms"

訳文の <a> はマークアップではなく名前です。テンプレートが書いた <a> を指して いるので、翻訳者は文中で位置を動かせる一方、要素も属性もスクリプトも足せません。 テンプレート側の <a> は中身が空で、タグの間に入る文字列は訳文から来ます。

このページがどの言語で読めるかは1回の呼び出しで分かります。切り替え UI と hreflang の代替リンクが同じデータから出るので、両者がズレません。

func Header(w http.ResponseWriter, r *http.Request) {
for _, choice := range pw.LocaleChoices(r) {
// choice.Label はその言語自身での表記: 日本語, English
// choice.URL は同じページのその言語版
// choice.Current がいま読まれているもの
}
}

パス方式では各エントリが URL を持つので、UI はリンクの並びになります。URL に言語が 出ない構成では指す先がないので、切り替えは pw.SetLocale を呼んで元のページに戻る アクションへの POST です。Cookie とその検証はフレームワークが持ち、マークアップは アプリケーションのもの — テーマ切り替えと同じ線引きです。

文字列に印を付けて、あとは任せます。

<h1 i18n>ようこそ</h1>
<input placeholder="お名前" i18n="placeholder">
Terminal window
pw i18n extract

各文字列に ID を提案し、原文を記録したカタログエントリを書き、印の付いた文字列を 参照に置き換え、スコープ宣言を足します。決めたことは全部出力されます。ツールが 選んだ名前は、このあと何年も読むものだからです。

漢字を含む文字列からは slug を作れないので、位置由来の名前が付いて「改名してください」 と言われます。ID を人が付けるコストは変数名を付けるコストと同じで、辞書を同梱する 理由にはなりませんでした。

マークアップを含む文には手を触れず、理由を出して見送ります。それは上のブロック形で、 ツールが発明した穴の名前は翻訳者が検証できません。手で直すのは数分の作業で、 名前を正しくする唯一の方法です。

pw i18n check は、ビルドが構造上聞けない質問に答えます — 宣言したメッセージが まだどこかから使われているか。

{t}{t x} は別物です。 {t}t という名前のパラメータの補間で、 今まで通りに動きます。メッセージ参照になるのは識別子の直後に識別子が来た場合だけなので、 t time.Time を取るコンポーネントは壊れません。

リッチテキストのブロックは子要素が置ける場所にしか書けません。 構造を作るからです。 通常の参照は文字列なので、属性値を含め式が書ける場所ならどこでも通ります。

原文を書き換えると全訳文が stale になります。 それが狙いです。記録しておいた スナップショットと比較して、どの言語が「翻訳した元の文」と食い違うようになったかを 報告します。

言語を足しても生成されるコードは増えません。 表に行が1つ増えるだけです。 ビルドが目に見えて大きくなったなら、原因は別のところにあります。

URL 方式、それぞれが何に Vary するか、Go からのメッセージ呼び出しは ロケールルーティングにあります。設定キーの 一覧は設定リファレンスです。