Files
knowledge_base/docs/superpowers/specs/2026-05-10-article-i18n-design.md
T
Yutaka Kurosaki 01fb8b9fcf 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>
2026-05-10 09:46:29 +09:00

15 KiB
Raw Blame History

記事の多言語対応 + デフォルト言語フォールバック

  • Date: 2026-05-10
  • Branch: feature/article-i18nmain から分岐)
  • 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(): HasManyDocumentTranslation
    • defaultTranslation(): HasOnewhere('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 モデル(新規)

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(改修)

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_noticedocuments.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、新規なら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() の戻り値内 namepath 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 deleterenderMarkdown()
tests/Unit/Services/WikiLinkResolverTest.php 解決順序5段階すべて/同名タイトル衝突時の決定論性/slug fallbacknullケース
tests/Unit/Services/DocumentServiceTest.php createDocument がtranslationを1件生成/updateDocument がdefault_locale時のみpath再生成/addTranslationdeleteTranslationdefault_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-i18nmain から分岐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言語サポート