0
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?

静的サイトの英語モードを全ページに通す — ページに渡さず、サイトの状態として持つ

0
Posted at

こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。

このサイトには日本語の記事のほかに英語の記事があり、英語の記事を読む画面(英語版)を持っています。英語版の記事は日本語の記事の翻訳ではなく、別のページとして書いたものです。したがって、記事の本文は 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 で見えている文字を比べ、英語を出さないビルドでは出力ごとに英語の件数を数える
0
0
1

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
0
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?