Add design spec for article-level i18n with default-locale fallback

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>
This commit is contained in:
Yutaka Kurosaki
2026-05-10 09:46:29 +09:00
parent ba25b544f5
commit 01fb8b9fcf
@@ -0,0 +1,275 @@
# 記事の多言語対応 + デフォルト言語フォールバック
- **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)` uniquecascade delete`renderMarkdown()` |
| `tests/Unit/Services/WikiLinkResolverTest.php` | 解決順序5段階すべて/同名タイトル衝突時の決定論性/slug fallbacknullケース |
| `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/削除DELETEdefault_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言語サポート