こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。
このサイトには日本語の記事のほかに英語の記事があり、英語の記事を読む画面(英語版)を持っています。英語版の記事は日本語の記事の翻訳ではなく、別のページとして書いたものです。したがって、記事の本文は URL で切り替わります。
問題になったのは本文の外側です。ヘッダのナビゲーション、難易度を選ぶタブ、ハンバーガーメニューの中身、フッター、ページの見出し、パンくず、読み上げソフト向けのラベルは、どのページにもある共通の部品です。英語版を読んでいる読者にはこれらも英語で見せたいのですが、静的書き出しのサイトなので配る HTML は読者全員で同じです。
結論を先に書きます。
- 英語モードは「ページが自分は英語版だと知らせる値」ではなく、「読者が選んだサイト全体の状態」として保存し、
<html>の属性に載せます。 - 文言は日本語と英語の 2 つを静的 HTML に両方書き出し、CSS で片方だけを表示します。
- CSS で切り替えられないものだけを、別の方法で切り替えます。
- ただし「いまどこに居るか」を示す部品だけは、保存した状態ではなく、そのページの版に従わせます。
以下、この 4 つに至った経緯と、切り替え漏れを見つけるために使った確かめ方を書きます。
最初の形: ページごとに「英語版かどうか」を渡していた
最初は、英語版のページだけが共通部品に english という値を渡し、受け取った部品が文字を英語にする形でした。React の props として上から渡す、ごく普通の書き方です。
この形では 2 つの問題が出ました。
1 つ目は、渡し忘れです。英語版の天気のページで値を渡しておらず、そのページだけヘッダが日本語のままになっていました。共通部品を使うページが増えるたびに、渡す場所も増えていきます。
2 つ目は、渡しようが無いページがあることです。トピックスの一覧や検索のページには英語版がありませんでした。英語版のページからそこへ移ると、ページは英語版ではないので値を渡さず、ヘッダは日本語に戻ります。読者から見ると、英語を選んだことをサイトが忘れたように見えます。
2 つ目の問題は、渡し忘れを無くしても解決しません。値の出どころが「このページは英語版か」である限り、英語版の無いページでは英語になりようが無いためです。
英語モードを読者の設定として保存する
そこで、英語モードを読者の設定として保存することにしました。
- 英語のタブを押すか、英語版の URL を開いたら on にします。
- ふつう・やさしい(このサイトが日本語の記事に持っている 2 つの難易度)のタブを押したら off にします。
- 保存先は localStorage です。難易度の保存値とは別の値として持ち、英語モードの切り替えでは難易度を書き換えません。
保存した値は、<head> の中で同期実行する小さなスクリプトが、最初の描画の前に <html> の data 属性として付けます。英語モードなら data-english="on" になります。読者の表示設定を最初の描画の前に <html> へ載せる方法そのものは、文字サイズや配色の設定と同じです。
これで、どのページも「自分が英語版か」を知らせなくてよくなりました。共通部品は <html> の属性だけを見るので、英語版の無いページへ移っても英語のままです。ページごとの english の値は、ヘッダ・ナビゲーション・難易度タブの全部から外しました。
却下した案
保存の形を決めるときに、2 つの案を退けています。
- 英語を難易度の 3 つ目の値として保存する。難易度ごとに値を持つ型(難易度をキーにした対応表)に英語が乗らないうえ、英語版の無いページでは日本語の難易度が別に必要になります。英語モードと難易度を 2 つの設定として持つほうが、変更が小さく済みます。
- 英語モードのときは、日本語の URL を英語版へリダイレクトする。同じ URL が読者ごとに違う内容を返すことになります。このサイトでは「難易度は URL が決め、保存値はリンクの行き先を覚えるだけ」と決めているので、英語モードの保存値も同じ役割にとどめました。
文言は両方書き出し、CSS で片方だけ出す
共通部品の文字は、日本語と英語の 2 つの要素を両方 HTML に書き出し、CSS で片方を隠します。規則は 2 つです。
html:not([data-english="on"]) .label-en { display: none; }
html[data-english="on"] .label-ja { display: none; }
英語の要素 .label-en は英語モードのときだけ表示します。英語の要素を持つ日本語側には .label-ja を付け、英語モードでは隠します。
1 行目を否定形にしてあるのは、属性が付いていないとき(JavaScript が動かない環境)に日本語のほうへ倒すためです。
ここで決めておいた約束が 1 つあります。英語の要素を持たない部品には .label-ja を付けません。日本語しか無い部品に .label-ja を付けると、英語モードで何も表示されなくなります。.label-ja は「英語の対がある日本語」にだけ付ける目印です。
この 2 つの規則に、ヘッダのナビゲーション、ハンバーガーメニューの見出しと選択肢、フッター、ページの見出し(h1)、検索ページのパンくずを順に乗せていきました。部品の側でやることは、日本語と英語を並べて書き、それぞれにクラスを付けることだけです。
リンクの行き先が英語モードで変わるもの(ナビゲーションの「News」は英語版のトップへ、「Weather」は英語版の天気へ、など)は、href を CSS で変えられないので、リンクごと 2 つ書き出して片方を隠します。
英語の要素を書き出すかどうかは、データで決める
英語の記事が 1 本も無いビルドでは、英語の要素を書き出しません。その場合、日本語のページの DOM は英語版を作る前と同じです。
書き出すかどうかの条件は、環境変数のような「英語を出すかどうか」のスイッチではなく、「公開してよいと人が確認した英語の記事が 1 本でもあるか」です。最初は英語版を開発環境だけに出すスイッチを持っていましたが、本番に公開するときにスイッチごと消しました。範囲を絞る仕組みを別に持つと、それがスイッチの別名になるためです。記事を 1 本ずつ人が確認してから出す運用で、出す範囲はすでに絞れています。
見出しの言い方を 2 系統に分ける
h1 を英語にするときに、言い方を 2 つの系統に分けました。
- 日本語の記事が載るページの h1 は「Latest news」「Topics」「Weather」のように、ページの中身を英語で言い換えたものにします。英語モードでもページの中身は日本語の記事なので、「English news」とは呼びません。
- 英語の記事が載るページの h1 は「English news」で始めます。こちらは 2 つの要素を持たず、最初から英語だけを書きます。
混ぜると、日本語の記事が並ぶページを「English news」と呼んでしまいます。英語モードは「メニューの文字が英語になる」だけで、ページの中身は変えないということを、見出しの言い方で守っています。
CSS で切り替えられないもの
2 つの規則で済まない場所が 2 種類ありました。
読み上げ名
aria-label のような、画面に表示されない文字です。表示されないので display: none で選ぶことができません。
これはさらに 2 つに分けて扱います。
- ページから版が分かるもの(英語版のページのパンくずや、前後の日へのナビゲーション)は、ページから値を渡します。静的 HTML の時点で英語になり、部品はサーバー側で描画するコンポーネントのままにできます。
- ページをまたいで保つもの(ヘッダ、メニュー、フッターのナビゲーション、共有ボタン)は、
<html>の属性を React の state として読む hook で書きます。
hook は次の形です。
export function useEnglishMode(): boolean {
const [on, setOn] = useState(false);
useEffect(() => {
const sync = () => setOn(document.documentElement.dataset.english === 'on');
sync();
window.addEventListener('english-change', sync);
return () => window.removeEventListener('english-change', sync);
}, []);
return on;
}
初期値を必ず false にしてあるのが要点です。静的 HTML は日本語で書き出してあるので、hydration の最初の描画も日本語にしないと、サーバーの HTML とクライアントの描画が食い違います。属性は useEffect の中で読み、英語モードならその後で英語に変わります。英語モードを切り替えたときは属性を書き換えるのと同時に window へイベントを投げ、hook はそれを受けて読み直します。
フッターはサーバー側で描画するコンポーネントだったので、hook を持たせるとフッター全体がクライアントのコンポーネントになります。そこで、ナビゲーションの nav 要素だけをクライアントのコンポーネントに切り出しました。読み上げ名のためにページ全体をクライアント側へ移すことはしていません。
ブラウザで組み立てる文言
検索ページは、検索結果も件数もブラウザで組み立てます。静的 HTML に 2 つの文言を書き出しても、書き出す時点では結果がありません。そこで、検索ページの文言(入力欄の placeholder、案内、結果の件数の行など)は、描画するときに日本語と英語のどちらかを選びます。
同じ検索ページでも、h1 とパンくずはサーバー側で書き出すので、ほかのページと同じ CSS の出し分けのままです。1 つのページの中に 2 つの方法が混ざりますが、分ける基準は「その文字が静的 HTML の時点で決まるか」です。基準がはっきりしていれば、どちらに寄せるかで迷いません。
外れた考え: 全部の部品が保存した状態に従えばよい
ここまでの方針は「共通部品は保存した英語モードに従う」でした。難易度タブもその 1 つとして、英語モードなら「Normal / Easy / English」、そうでなければ「ふつう / やさしい / 英語」と表示していました。
これに対して、読者から報告がありました。トップページを開いた最初の表示で、「ふつう」が選択されているのにタブの文字が英語になっている、というものです。
保存した英語モードが on のまま日本語のトップページを開くと、こうなります。ページは日本語の「ふつう」なので、タブは Normal を選択状態にします。文字は英語モードに従うので英語です。仕組みとしては設計どおりの動作ですが、読者には食い違いにしか見えません。
原因は、難易度タブが他の共通部品と役割の違う部品だったことです。ナビゲーションやフッターは「次にどこへ行くか」を示す部品で、英語モードを覚えておく意味があります。難易度タブは「いまどの版を読んでいるか」を aria-selected で示す部品です。選択状態はページの版から決まるのに、文字だけが保存値から決まると、1 つの部品の中で 2 つの情報源が矛盾します。
そこで、難易度タブの文字と読み上げ名だけは、保存した英語モードを見ず、そのページの版だけで決めるように変えました。
- 英語版のページでは、3 つとも英語(Normal / Easy / English)にします。
- 日本語のページでは、3 つとも日本語にします。
ナビゲーション、メニュー、フッター、見出し、パンくず、共有ボタンは、今までどおり保存した英語モードに従います。押したときの保存も変えていないので、ふつう・やさしいのタブを押せば英語モードから抜けられます。
一般化すると、「現在地を示す部品はページから、行き先を示す部品は保存値から」という分け方になります。全部を保存値に従わせると、現在地を示す部品で、選択状態と文字の間に食い違いが出ます。
切り替え漏れの見つけ方
部品が多いので、英語にし忘れた場所や、英語が出てはいけない場所に出たものを、目で見て探すのは無理があります。使った確かめ方を 2 つ書きます。
e2e では見えている文字だけを比べる
h1 を 2 つの要素で書き出すようにしてから、e2e で見出しの文字を比べるときは textContent ではなく innerText を使うことにしました。
textContent は display: none の要素の文字も含めて返します。英語モードで見出しが英語になっているかを確かめたいのに、隠れている日本語の要素も拾うので、英語の要素が表示されていなくても文字の一致が成り立ちます。通ってはいけないテストが通ります。innerText は描画されている文字だけを返すので、読者に見えているものと同じものを比べられます。
この「両方を書き出して CSS で隠す」形を使う限り、DOM には常に両方の文字があります。DOM の中身ではなく見えている文字を比べる、という点は、この形を採るときに一緒に決めておく必要があります。
英語を出さないビルドの出力を数える
英語の記事を sitemap と RSS / Atom に載せる変更をしたときに、英語版のページを書き出さないビルドで確かめました。英語のページが無いのですから、出力のどこにも英語が無いはずです。
実際には、1 回目のビルドで sitemap に英語の URL が 80 件、News sitemap に英語の言語指定が 11 件入っていました。HTML のほうには英語のページが 1 つも無いので、存在しない URL を sitemap が指している状態です。
これはコードを読んで見つけたのではなく、ビルドの出力を数えて見つけたものです。sitemap、News sitemap、RSS、Atom、書き出した HTML のディレクトリのそれぞれについて英語の件数を数え、全部が 0 になることを確かめました。英語版を書き出すかどうかの条件は 1 つでも、その条件が効くべき出力は HTML だけではありません。条件を読む場所が増えるたびに、出力の側を 1 つずつ数えるのが確実です。このときは修正と一緒に、「英語を出さないビルドでは sitemap とフィードの項目も空になる」というテストを足しました。
まとめ
- 英語モードはページから渡す値ではなく、読者の設定として保存し
<html>の属性に載せる - 文言は日本語と英語の両方を静的 HTML に書き出し、CSS の 2 つの規則で片方だけ表示する
- 英語の要素を書き出すかどうかはスイッチではなく、公開できる英語の記事があるかで決める
- 読み上げ名は、ページから版が分かるものは値で渡し、ページをまたぐものは hook で書く(初期値は日本語にそろえる)
- ブラウザで組み立てる文言だけは描画のときに選ぶ
- 現在地を示す部品はページの版に、行き先を示す部品は保存した状態に従わせる
- e2e は
innerTextで見えている文字を比べ、英語を出さないビルドでは出力ごとに英語の件数を数える