この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 12 回(全 19 回)です。
実際に動いている公開リポジトリ kai-kou/gem-hunter(MIT)を1本まるごと読み解く連載です。架空のサンプルではなく実ファイルを引用し、掲載しているコマンド結果はすべて実際に動かして採取しています。
全体を通しで読みたい方へ: 同じ内容を 1 冊にまとめた Zenn Book を無料で公開しています。
シリーズ全体の目次
- 第 1 回 動いているリポジトリを読むという学び方
- 第 2 回 clone からテストが緑になるまで
- 第 3 回 要件IDとADRで、何を解くアプリなのかを地図にする
- 第 4 回 App Router の地図。フォルダがそのまま URL になる
- 第 5 回 'use client' が6ファイルしかないアプリの境界の引き方
- 第 6 回 「クリーンアーキテクチャだから」を理由にしない層の分け方
- 第 7 回 ブランド型で「検証済みの値」を型にする
- 第 8 回 DIコンテナを使わない依存性逆転
- 第 9 回 外部APIの語彙を持ち込ませない翻訳層の作り方
- 第 10 回 依存規則を440行のPythonで機械検査する
- 第 11 回 URLのクエリが画面に出るまでを5層ぶん追跡する
- 第 12 回 ライブラリなしのi18nと、画面の変化を目で見ていない人へ伝える実装(この記事)
- 第 13 回 どの層を何でテストするかを表で決めておく
- 第 14 回 失敗するテストを先に書いて Red→Green を1周する
- 第 15 回 「実ネットワークに出ない」を設定1行で保証する
- 第 16 回 外部APIに依存しないE2Eの組み方
- 第 17 回 赤くなったテストをどう判定するか、axeの限界とflakyの正体
- 第 18 回 生IPを残さないレート制限と、第三者HTMLを安全に表示する
- 第 19 回 CIは道具ではなくゲートの集合、そして自分のプロジェクトへの持ち帰り方
TL;DR
対象読者は、多言語対応やアクセシビリティを「余裕があったらやるもの」として後回しにしてきたエンジニアです。どちらも設計の一部として組み込まれた実例を見ます。
- i18n は ライブラリなし・19 行。日本語の JSON を型の正本にして、英語の翻訳漏れをコンパイルエラーにします
- 「翻訳するもの」と「原文のまま出すもの」の線引き が要件として決まっています
- 画面の変化を読み上げソフトへ伝える実装が、Next.js の穴を埋める形 で入っています
i18n の実装が 19 行
import en from '@/messages/en.json'
import ja from '@/messages/ja.json'
/**
* `src/shared/` は domain を含むどの層にも依存してはならない(ARCH-7)。
* `domain/model/locale.ts` の `Locale`('ja' | 'en' のブランド型)はここでは import せず、
* 代わりにその部分型であるプレーンなリテラル型を持つ(ブランド型の値はそのまま渡せる)。
*/
type MessageLocale = 'ja' | 'en'
const dictionaries = { ja, en } satisfies Record<MessageLocale, typeof ja>
export type Messages = typeof ja
/** ロケールに対応するメッセージカタログを返す(依存を増やさない自前実装)。 */
export function getMessages(locale: MessageLocale): Messages {
return dictionaries[locale]
}
これで全部です。 next-intl も react-i18next も入っていません。
型の正本を日本語にする
export type Messages = typeof ja
ja.json の構造そのものを型にしています。そして satisfies Record<MessageLocale, typeof ja> により、en.json に足りないキーがあるとコンパイルエラー になります。
翻訳漏れは「実行してみたら英語画面に日本語が出た」で気づくのが普通ですが、これなら型チェックの時点で止まります。追加のツールもテストも要りません。
層のルールを守るための工夫
コメントの前半が面白いところです。
src/shared/ はどの層にも依存できません(第 6 回の ARCH-7)。だから domain/ にある Locale 型を import できない。そこで 同じ形の型をここで再定義しています。
type MessageLocale = 'ja' | 'en'
Locale(ブランド型)の値は MessageLocale として渡せるので、実用上は困りません。規則を守りつつ実利も取る、という小さな工夫です。
翻訳するもの、しないもの
多言語対応で意外と決まっていないのが、どこまで訳すか です。gem-hunter では要件として決まっています。
| 対象 | 扱い |
|---|---|
| UI のラベル・ボタン・エラー文言 | 翻訳する |
| GitHub から来たリポジトリの説明文 | 原文のまま(機械翻訳しない) |
| リポジトリ名・オーナー名 | 原文のまま |
| 日付・数値の書式 | ロケールに合わせる |
リポジトリの説明文を機械翻訳しない理由は明快です。技術用語の機械翻訳は原文より分かりにくくなることが多い からです。
この線引きは E2E テストでも固定されています(第 16 回)。
// 検索結果の説明文(GitHub 由来データ)。機械翻訳しない対象であることの前提確認。
await expect(
page.getByText('Reusable UI widgets for octostub demos.', { exact: true }),
).toBeVisible()
英語に切り替えても、この説明文はそのまま表示される。「訳さない」ことがテストで守られています。
エラー文言に「次の行動」を入れる
エラー表示の型がこうなっています。
export type ErrorPresentation = {
/** 利用者向けの本文(プレースホルダー補間済み)。 */
message: string
/**
* 一次レート制限かつ未ログインのときだけ入る、ログイン導線の説明文。
* ここが `undefined` のときは呼び出し側もログインリンクを出さない。
*/
loginHint?: string
}
レート制限に当たったとき、gem-hunter は「ログインすると自分の枠が使えます」と案内します。ただし 未ログインのときだけ です。すでにログインしている人に同じ案内を出しても意味がないので、型の上で「入ることも入らないこともある」と表現されています。
タイムゾーンの落とし穴
/**
* 復帰時刻の表示書式。日付をまたぐ可能性があるため月日も出す。
* タイムゾーンは一覧の更新日と揃えて `Asia/Tokyo` に固定する。
*
* 🔴 `timeZoneName: 'short'`(ja: `JST` / en: `GMT+9`)で基準を必ず併記する。表示が JST 固定なのに
* 基準が出ないと、日本国外の利用者が自分のローカル時刻と誤読し、まだ復帰していない時刻に
* 再試行して再び失敗する。
*/
function formatResetAt(retryAfter: Date, locale: Locale): string {
return new Intl.DateTimeFormat(toIntlLocaleTag(locale), {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
timeZone: 'Asia/Tokyo',
timeZoneName: 'short',
}).format(retryAfter)
}
「14:30 に再試行できます」とだけ書くと、海外の利用者は自分の時計の 14:30 だと思います。まだ復帰していない時刻に再試行して、また失敗する。
timeZoneName: 'short' を足すと「08/22 14:30 JST」と出ます。文字数にして 4 文字の差ですが、失敗の再発を防ぎます。
画面の変化を、目で見ていない人へ伝える
ここからアクセシビリティです。gem-hunter では NFR-16〜NFR-18 として要件になっています。
ライブリージョンは「空で常設」する
検索結果の件数を読み上げソフトへ伝える部分です。
<section
id="search-status"
role="status"
aria-live="polite"
className="text-muted-foreground mt-6 text-sm"
>
{hasKeyword ? (
<Suspense key={suspenseKey} fallback={<LoadingIndicator label={messages.common.loading} />}>
<SearchStatusText statePromise={statePromise} locale={locale} messages={messages} />
</Suspense>
) : null}
</section>
aria-live="polite" は「中身が変わったら読み上げてね」という指定です。読みどころは <section> 自体は条件分岐の外にある ことです。
{/* ❌ こう書くと読み上げられない */}
{hasKeyword && <section aria-live="polite">{count}件</section>}
要素そのものが後から現れると、読み上げソフトは変化として認識しません。 要素は最初から空で置いておき、中身だけを入れ替える必要があります。
これは知らないと必ず踏む落とし穴で、しかも 目で見ている限り完全に正常に動いて見えます。
polite と assertive を入れ子にしない
もう 1 つ、role="alert"(緊急の通知)を aria-live="polite" の中に入れない、という注意もコメントに書かれています。入れ子にすると読み上げが二重になったり、順序が崩れたりします。
Next.js の穴を埋める
/**
* Next.js の route announcer は `document.title` の変化だけを見て読み上げを判断するが、
* 本アプリの `document.title` は 'gem-hunter' 固定でロケール非依存のため、
* 言語切替では一切アナウンスされない。
*/
export function LocaleSwitchAnnouncer({
currentLocale,
announcedLabel,
}: {
currentLocale: string
announcedLabel: string
}) {
const liveRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
const changed = previousLocale !== undefined && previousLocale !== currentLocale
previousLocale = currentLocale
if (changed && liveRef.current) {
liveRef.current.textContent = announcedLabel
}
}, [currentLocale, announcedLabel])
return <span ref={liveRef} role="status" aria-live="polite" className="sr-only" />
}
Next.js は「ページが変わったこと」を読み上げソフトへ伝える仕組みを持っていますが、判定材料が document.title の変化だけ です。このアプリはタイトルが固定なので、言語を切り替えても何も読み上げられません。
そこで自前で「Switched to English.」とアナウンスします。細かいのは 「実際にロケールが変わったときだけ」 という条件です。同じ言語のリンクを踏んだときに毎回喋ると、うるさいだけです。
フォーカスを移す
第 5 回で見た FocusOnNavigate がここで効きます。検索して結果が変わったとき、フォーカスを結果の見出しへ移します。
これがないと、キーボードだけで操作している人は検索のたびに Tab を何度も押して結果まで移動することになります。
移動先の見出しには tabIndex={-1} が付いています。「クリックやスクリプトからはフォーカスできるが、Tab の順路には入らない」という指定です。
まとめ
- i18n は ライブラリなし・19 行 でも成立する。型の正本を 1 言語に置けば翻訳漏れがコンパイルエラーになる
- 「翻訳するもの/しないもの」を 要件として決め、テストで固定 する
- エラー文言には 次の行動 と、時刻には 基準(タイムゾーン) を入れる
- ライブリージョンは 空で常設 して中身だけ差し替える(後から要素を出すと読み上げられない)
- フレームワークの機能で足りない部分は、穴を特定して自前で埋める
シリーズの前後の記事
- ⬅️ 前の記事: 第 11 回 URLのクエリが画面に出るまでを5層ぶん追跡する
- ➡️ 次の記事: 第 13 回 どの層を何でテストするかを表で決めておく
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。