Documents the data model (1 doc + document_translations table), service layer changes, routing/editor UX, wiki-link resolution order, search, and migration plan. Article URLs stay locale-independent; display falls back to each document's default_locale when the requested locale lacks a translation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 KiB
記事の多言語対応 + デフォルト言語フォールバック
- Date: 2026-05-10
- Branch:
feature/article-i18n(mainから分岐) - Status: Design approved, ready for implementation plan
背景
UI言語切り替えは16言語対応済み(SetLocale ミドルウェア + users.locale カラム + セッション/Cookie)。
一方、記事本体(documents テーブル)は単一言語のまま:1ドキュメント = 1行で title/content/rendered_html を直接保持し、locale非対応。
本設計は、記事自体を多言語化し、ユーザーのUI言語に応じた翻訳を提示する。翻訳が無ければ「その記事の原語版」へフォールバックする。
決定事項サマリ
| 論点 | 決定 |
|---|---|
| 翻訳のデータモデル | 1記事 + document_translations 子テーブル |
| デフォルト言語 | 記事ごとに documents.default_locale を持つ |
| URL | デフォルト言語のslug固定(言語非依存) |
| 表示title | 現在locale → default_locale でフォールバック |
| 編集UX | 1記事の編集画面に言語タブ |
[[wiki-link]] 解決 |
全言語のtitleを対象、現在locale優先の決定論的順序 |
| 検索 | 全言語のtitle/contentを対象、表示titleは現在locale |
| マイグレーション | 既存記事は config('app.locale') を default_locale として移行 |
| フォールバック表示 | 上部バナー + 「翻訳を追加」ボタン |
Section 1: データモデル
documents テーブル(変更)
| 操作 | カラム | 備考 |
|---|---|---|
| 追加 | default_locale VARCHAR(10) NOT NULL |
フォールバック先となる原語 |
| 削除 | title |
document_translations へ移管 |
| 削除 | content |
同上 |
| 削除 | rendered_html |
同上 |
| 維持 | path, slug, frontmatter, file_size, file_hash, file_modified_at, created_by, updated_by, timestamps, softDeletes |
path/slug は引き続き「default_locale のtitle」から生成。タイトル変更で再生成される既存ロジックも default_locale のtitleに連動。
document_translations テーブル(新規)
id BIGINT PK
document_id FK → documents (cascade delete)
locale VARCHAR(10)
title VARCHAR(255)
content TEXT
rendered_html TEXT NULLABLE
created_by FK → users (nullOnDelete)
updated_by FK → users (nullOnDelete)
created_at, updated_at
UNIQUE (document_id, locale)
INDEX (locale, title) -- wiki-link / QuickSwitcher 用
FULLTEXT (title, content) WITH PARSER ngram -- MySQL のみ(既存と同条件)
(locale, title)にはunique制約は付けない。既存documents.titleも unique でないため挙動互換性を保つ。代わりに wiki-link 解決順序を決定論にする。- SQLite(テスト環境)では FULLTEXT インデックス作成をスキップ(既存マイグレーションと同じ条件分岐)。
マイグレーション手順
documents.default_localeカラム追加(デフォルト値config('app.locale')、nullable=false)document_translationsテーブル作成- データ移行:既存各
documents行から(title, content, rendered_html, created_by, updated_by)をdocument_translationsに複製、locale = config('app.locale')をセット documentsテーブルの旧FULLTEXTインデックス(documents_search_index)を削除documents.title,documents.content,documents.rendered_htmlカラムを削除
down() は対称的に逆順で復元するが、データ復元は best-effort(複数translation存在時はdefault_localeのものを採用)。
Section 2: モデル & サービス層
Document モデル
title/content/rendered_htmlをfillable/直接プロパティから削除- 新リレーション
translations(): HasMany→DocumentTranslationdefaultTranslation(): HasOne→where('locale', $this->default_locale)
- アクセサ(現在
App::getLocale()のtranslation → 無ければdefault_localeのtranslation)getTitleAttribute(): stringgetContentAttribute(): stringgetRenderedHtmlAttribute(): ?string
- 新メソッド
translationFor(string $locale, bool $fallback = true): ?DocumentTranslationisFallback(string $requestedLocale): bool— バナー判定用availableLocales(): array— タブ生成用、translation存在のlocale一覧
DocumentTranslation モデル(新規)
fillable: [document_id, locale, title, content, rendered_html, created_by, updated_by]
casts: timestamps
relations: document(): BelongsTo, creator(), updater()
scope: scopeSearch(Builder, string) -- MATCH(title, content) AGAINST(?)
static: renderMarkdown(string): string -- Document から移管(純粋関数)
DocumentService 改修
| メソッド | 変更内容 |
|---|---|
createDocument($title, $content, $userId, $locale = null) |
$locale 未指定なら App::getLocale()。document を作成し default_locale = $locale、translationを1件作成。path/slug は title から生成 |
updateDocument(Document $doc, $title, $content, $userId, $locale) |
指定 $locale のtranslationをupsert。$locale === default_locale のときだけ path/slug 再生成 |
addTranslation(Document $doc, $locale, $title, $content, $userId): DocumentTranslation |
新言語のtranslation追加。重複locale時は422 |
deleteTranslation(Document $doc, string $locale): void |
default_locale の翻訳は削除拒否(例外) |
setDefaultLocale(Document $doc, string $locale): Document |
default_locale 切り替え。新しいdefault_localeのtranslationが存在しない場合422。切替後 path/slug を新default_localeのtitleから再生成 |
findByTitle($title, $locale = null): ?Document |
locale指定優先。後述の解決順序を実装 |
search(string $query, int $limit = 20) |
DocumentTranslation::search() ベース、結果documentをdistinct化 |
getDirectoryTree() |
path/階層構造はdocumentレベルのまま、表示titleはアクセサ経由で現在locale + フォールバック |
WikiLinkResolver(新規 app/Services/WikiLinkResolver.php)
processLinks() と syncLinks() から共通利用される解決ロジック:
resolve(string $linkText, string $currentLocale): ?Document
1. translations WHERE locale = $currentLocale AND title = $linkText
2. translations WHERE locale = document.default_locale AND title = $linkText
3. translations WHERE title = $linkText
ORDER BY document_id ASC LIMIT 1
4. documents WHERE slug = SlugHelper::generate($linkText)
5. null
Document::syncLinks()とDocument::processLinks()はResolver利用に書き換えprocessLinks()のリンク表示ラベルは原文のまま(例: 英語本文中の[[Getting Started]]はja表示時もラベル「Getting Started」)。クリック後の遷移先記事は現在localeで表示される- 既存の
DocumentLinkテーブルはスキーマ無変更(target_titleを保存しているだけなのでlocale非依存で運用可能)
Section 3: ルーティング & コントローラ層
既存ルート(変更なし)
GET /documents/{document}→ slug一致でdocumentバインドGET /documents/{document}/editGET /documents/create
URLは言語非依存。表示言語は SetLocale ミドルウェアが解決した App::getLocale() に従う。
Document::resolveRouteBinding()
slugは documents テーブル直下のため既存ロジックそのまま。
翻訳title(例: はじめに)でURLを直接叩いた場合は404のまま(検索/QuickSwitcher経由で見つける想定)。
新規ルート: 翻訳の管理
| メソッド | URL | 名前 | 機能 |
|---|---|---|---|
| GET | /documents/{document}/translations/{locale}/edit |
documents.translations.edit |
既存翻訳の編集(DocumentEditor を再利用) |
| POST | /documents/{document}/translations |
documents.translations.store |
新言語追加 |
| DELETE | /documents/{document}/translations/{locale} |
documents.translations.destroy |
翻訳削除(default_locale 不可) |
{locale}はSetLocale::SUPPORTED_LOCALESのキーで route constraint- すべて
auth+can:update,documentミドルウェア配下
/ ルート
slug = home の document を探してリダイレクト(既存通り、slugは言語非依存のため変更不要)。
Section 4: Livewire コンポーネント & ビュー
DocumentViewer(改修)
public Document $document;
public string $viewLocale; // 実際に表示中のlocale(フォールバック後)
public bool $isFallback; // バナー表示判定
public string $renderedContent; // wiki-link処理済みHTML
public $backlinks;
mount() フロー:
$current = App::getLocale()$translation = $document->translationFor($current, fallback: true)$this->viewLocale = $translation->locale$this->isFallback = ($current !== $translation->locale)WikiLinkResolverで$translation->rendered_html内のwiki-linkを現在locale基準で再解決
ビュー livewire/document-viewer.blade.php:
- 上部にフォールバックバナー(
$isFallback === trueのみ)- 文言は
lang/{locale}/messages.phpに追加(例:documents.fallback_notice、documents.add_translation) - 「翻訳を追加」ボタン →
documents.translations.storeへの小フォーム(locale = 現在UI locale)
- 文言は
- バックリンク表示titleは
$backlink->titleアクセサ経由(自動でフォールバック)
DocumentEditor(改修)
public ?Document $document; // 既存編集時、新規作成時はnull
public string $editingLocale; // 現在編集中のタブ
public string $title; // editingLocale の title
public string $content; // editingLocale の content
public array $availableLocales; // この記事で既に翻訳がある locale 一覧
public bool $isNewLocale; // 既存翻訳の編集 or 新規追加
メソッド:
switchTab(string $locale)— 未保存変更があれば確認ダイアログ、translation取得 or 空フォームsave()— 既存ならupdate、新規ならaddTranslationdelete()— 翻訳削除(default_locale以外)setAsDefault(string $locale)— default_locale変更(path/slug再生成)
ビュー livewire/document-editor.blade.php:
- 上部にタブバー:
[JA*] [EN] [zh-CN] [+ 翻訳を追加 ▾]*は default_locale 印+ドロップダウンで未追加のSUPPORTED_LOCALESを選択可能
- 既存のEasyMDE設定はそのまま流用、
wire:modelの単一入力構造を維持 - default_locale変更UI(タブの隣にメニュー or 「この言語をデフォルトにする」ボタン)
SidebarTree(軽微改修)
getDirectoryTree()の戻り値内name(path basename)はpathベースのまま- ファイル名表示は
$document->title(アクセサ経由)に切り替え - treeのキャッシュキー(もしあれば)に
App::getLocale()を含めて、locale切替で再構築
QuickSwitcher(改修)
- 検索クエリを
DocumentTranslation::search()に投げ、結果documentをdistinct化 - 表示行は
$doc->title(アクセサ)+ ヒットしたtranslationが現在locale以外なら小さなlocaleバッジ
Section 5: テスト計画 & 受け入れ基準
ユニットテスト(追加)
| ファイル | 検証内容 |
|---|---|
tests/Unit/Models/DocumentTest.php |
title/content/rendered_html アクセサが現在localeを返す/無ければdefault_localeにフォールバック/isFallback() 真偽/availableLocales() |
tests/Unit/Models/DocumentTranslationTest.php |
(document_id, locale) unique/cascade delete/renderMarkdown() |
tests/Unit/Services/WikiLinkResolverTest.php |
解決順序5段階すべて/同名タイトル衝突時の決定論性/slug fallback/nullケース |
tests/Unit/Services/DocumentServiceTest.php |
createDocument がtranslationを1件生成/updateDocument がdefault_locale時のみpath再生成/addTranslation/deleteTranslation(default_locale削除拒否)/setDefaultLocale(未存在translation時422 + path再生成)/search のdistinct化 |
フィーチャーテスト(追加)
| ファイル | 検証内容 |
|---|---|
tests/Feature/DocumentI18nTest.php |
現在locale=ja で英語のみの記事を表示→英語版が表示されバナーが出る/ja版を追加後はバナーなし/QuickSwitcherで日本語クエリ・英語クエリどちらも同記事ヒット |
tests/Feature/DocumentTranslationCrudTest.php |
翻訳追加POST/編集GET/PATCH/削除DELETE(default_localeは422)/非権限ユーザは403 |
tests/Feature/DocumentMigrationTest.php |
マイグレーション前の擬似データ→migrate実行→translationsへ正しく移行/documentsの旧カラムが消去 |
既存テストの修正範囲
DocumentServiceを呼ぶ既存テストはfactoryでdefault_locale指定が必要Document::factory()はDocumentTranslationFactoryと連動- 既存の
processLinks/syncLinksテストはWikiLinkResolver経由に書き換え
受け入れ基準(DoD)
- UI言語をjaにして英語のみ記事を開く → 英語コンテンツが表示され、上部に日本語バナー+「翻訳を追加」ボタンが出る
- 「翻訳を追加」→ 編集画面でja版を作成・保存 → 同URLを再表示 → ja版がバナーなしで表示
[[Getting Started]]と[[はじめに]]の両方が同一記事へリンクする- QuickSwitcherで「はじめに」「Getting Started」どちらでも同記事がヒットする
- 編集画面の言語タブを切り替えて、各言語のtitle/contentを独立に編集・保存できる
- default_locale設定の翻訳は削除できない(UIで削除ボタン非表示+バックエンドで422)
- 既存の
composer testがすべて緑 - マイグレーション後、既存記事すべてが
default_locale = config('app.locale')で正しく翻訳化されている
ブランチ & コミット計画
- ブランチ:
feature/article-i18nをmainから分岐(feature/media-managerではない) - コミット粒度の目安(1 PR想定で7コミット):
- マイグレーション(
document_translations作成 + 既存データ移行 + 旧カラム削除) DocumentTranslationモデル + factoryDocumentモデルアクセサ/リレーション改修WikiLinkResolver+DocumentService改修- ルート + 翻訳CRUDコントローラ
DocumentViewer+ バナー + lang追加DocumentEditorタブUI +SidebarTree/QuickSwitcher調整
- マイグレーション(
スコープ外(YAGNI)
- 翻訳の自動生成(DeepL等の外部API連携)
- 翻訳の進捗ダッシュボード(カバレッジ表示)
- 言語ごとの別URL(
/ja/documents/...) - title翻訳に応じたslugの言語別生成
- RTL言語サポート