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:
@@ -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)` 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言語サポート
|
||||
Reference in New Issue
Block a user