# 記事の多言語対応 + デフォルト言語フォールバック - **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 インデックス作成をスキップ(既存マイグレーションと同じ条件分岐)。 ### マイグレーション手順 1. `documents.default_locale` カラム追加(デフォルト値 `config('app.locale')`、nullable=false) 2. `document_translations` テーブル作成 3. データ移行:既存各 `documents` 行から `(title, content, rendered_html, created_by, updated_by)` を `document_translations` に複製、`locale = config('app.locale')` をセット 4. `documents` テーブルの旧FULLTEXTインデックス(`documents_search_index`)を削除 5. `documents.title`, `documents.content`, `documents.rendered_html` カラムを削除 `down()` は対称的に逆順で復元するが、データ復元は best-effort(複数translation存在時はdefault_localeのものを採用)。 ## Section 2: モデル & サービス層 ### `Document` モデル - `title`/`content`/`rendered_html` をfillable/直接プロパティから削除 - 新リレーション - `translations(): HasMany` → `DocumentTranslation` - `defaultTranslation(): HasOne` → `where('locale', $this->default_locale)` - アクセサ(**現在 `App::getLocale()` のtranslation → 無ければdefault_localeのtranslation**) - `getTitleAttribute(): string` - `getContentAttribute(): string` - `getRenderedHtmlAttribute(): ?string` - 新メソッド - `translationFor(string $locale, bool $fallback = true): ?DocumentTranslation` - `isFallback(string $requestedLocale): bool` — バナー判定用 - `availableLocales(): array` — タブ生成用、translation存在のlocale一覧 ### `DocumentTranslation` モデル(新規) ```php 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}/edit` - `GET /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`(改修) ```php public Document $document; public string $viewLocale; // 実際に表示中のlocale(フォールバック後) public bool $isFallback; // バナー表示判定 public string $renderedContent; // wiki-link処理済みHTML public $backlinks; ``` `mount()` フロー: 1. `$current = App::getLocale()` 2. `$translation = $document->translationFor($current, fallback: true)` 3. `$this->viewLocale = $translation->locale` 4. `$this->isFallback = ($current !== $translation->locale)` 5. `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`(改修) ```php 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、新規ならaddTranslation - `delete()` — 翻訳削除(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) 1. UI言語をjaにして英語のみ記事を開く → 英語コンテンツが表示され、上部に日本語バナー+「翻訳を追加」ボタンが出る 2. 「翻訳を追加」→ 編集画面でja版を作成・保存 → 同URLを再表示 → ja版がバナーなしで表示 3. `[[Getting Started]]` と `[[はじめに]]` の両方が同一記事へリンクする 4. QuickSwitcherで「はじめに」「Getting Started」どちらでも同記事がヒットする 5. 編集画面の言語タブを切り替えて、各言語のtitle/contentを独立に編集・保存できる 6. default_locale設定の翻訳は削除できない(UIで削除ボタン非表示+バックエンドで422) 7. 既存の `composer test` がすべて緑 8. マイグレーション後、既存記事すべてが `default_locale = config('app.locale')` で正しく翻訳化されている ## ブランチ & コミット計画 - ブランチ: `feature/article-i18n` を **`main` から分岐**(`feature/media-manager` ではない) - コミット粒度の目安(1 PR想定で7コミット): 1. マイグレーション(`document_translations` 作成 + 既存データ移行 + 旧カラム削除) 2. `DocumentTranslation` モデル + factory 3. `Document` モデルアクセサ/リレーション改修 4. `WikiLinkResolver` + `DocumentService` 改修 5. ルート + 翻訳CRUDコントローラ 6. `DocumentViewer` + バナー + lang追加 7. `DocumentEditor` タブUI + `SidebarTree`/`QuickSwitcher` 調整 ## スコープ外(YAGNI) - 翻訳の自動生成(DeepL等の外部API連携) - 翻訳の進捗ダッシュボード(カバレッジ表示) - 言語ごとの別URL(`/ja/documents/...`) - title翻訳に応じたslugの言語別生成 - RTL言語サポート