diff --git a/docs/superpowers/specs/2026-05-10-article-i18n-design.md b/docs/superpowers/specs/2026-05-10-article-i18n-design.md new file mode 100644 index 0000000..033a7b5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-article-i18n-design.md @@ -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言語サポート