# Article i18n Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make article content (not just UI) language-switchable, with per-document fallback to a `default_locale` translation when the requested locale is missing. **Architecture:** 1 `documents` row + N `document_translations` rows. URL/path/slug stay locale-independent (driven by default_locale's title). Display title/content/rendered_html resolve to current locale → fallback to document's `default_locale`. Wiki-link resolution uses a deterministic 5-step order across all locales' titles. **Tech Stack:** Laravel 13, Livewire 3, Alpine.js, EasyMDE, MySQL (prod) / SQLite in-memory (test), PHPUnit 12, league/commonmark. **Spec:** `docs/superpowers/specs/2026-05-10-article-i18n-design.md` **Branch:** `feature/article-i18n` (already created from `main`) --- ## Conventions - All shell commands run inside the docker container: prefix with `docker compose exec php`. - Test command: `docker compose exec php php artisan test` (or `--filter=...` for one test). - Application source root is `src/`. All file paths below are repo-relative (i.e. include `src/`). - Tests use `RefreshDatabase` trait, SQLite in-memory (`DB_CONNECTION=sqlite`, `DB_DATABASE=:memory:`). - FULLTEXT statements are MySQL-only — guard with `if (DB::connection()->getDriverName() === 'mysql')`. - Locale list lives at `App\Http\Middleware\SetLocale::SUPPORTED_LOCALES` (16 keys: `en`, `ja`, `zh-CN`, `zh-TW`, `ko`, `hi`, `vi`, `tr`, `de`, `fr`, `es`, `pt-BR`, `ru`, `uk`, `it`, `pl`). - Each commit message body should mention what changed and why; co-author trailer not required for inline tasks (set per project convention). --- ## File Structure ### New files - `src/database/migrations/2026_05_10_000001_create_document_translations_and_migrate.php` — schema + data migration - `src/app/Models/DocumentTranslation.php` — translation model with `renderMarkdown()` - `src/database/factories/DocumentFactory.php` — Document factory (creates a document + a single default-locale translation) - `src/database/factories/DocumentTranslationFactory.php` - `src/app/Services/WikiLinkResolver.php` — 5-step deterministic resolver - `src/app/Http/Controllers/DocumentTranslationController.php` — store/destroy - `src/tests/Unit/Models/DocumentTest.php` - `src/tests/Unit/Models/DocumentTranslationTest.php` - `src/tests/Unit/Services/WikiLinkResolverTest.php` - `src/tests/Unit/Services/DocumentServiceTest.php` - `src/tests/Feature/DocumentI18nTest.php` - `src/tests/Feature/DocumentTranslationCrudTest.php` - `src/tests/Feature/DocumentMigrationTest.php` ### Modified files - `src/app/Models/Document.php` — drop title/content/rendered_html columns, add accessors + relations + `isFallback` - `src/app/Services/DocumentService.php` — locale-aware methods, search via translation table, `addTranslation`, `deleteTranslation`, `setDefaultLocale` - `src/app/Livewire/DocumentViewer.php` + `src/resources/views/livewire/document-viewer.blade.php` — fallback banner - `src/app/Livewire/DocumentEditor.php` + `src/resources/views/livewire/document-editor.blade.php` — locale tabs - `src/app/Livewire/QuickSwitcher.php` — search via translations table, distinct documents - `src/app/Livewire/SidebarTree.php` — display title via accessor (no code change required if kept as-is, but cache key gets locale) - `src/routes/web.php` — translation CRUD routes with `{locale}` constraint - `src/database/seeders/DocumentSeeder.php` — create via DocumentService instead of Document::create directly - `src/lang/{en,ja,zh-CN,zh-TW,ko,hi,vi,tr,de,fr,es,pt-BR,ru,uk,it,pl}/messages.php` — add `documents.fallback_notice`, `documents.add_translation`, `documents.translation_tabs`, `documents.set_as_default`, `documents.delete_translation_blocked` ### Untouched - `src/app/Models/DocumentLink.php` — `target_title` is locale-agnostic, no schema change - `src/app/Policies/DocumentPolicy.php` — `update` permission covers translation CRUD --- ## Task 1: Document & DocumentTranslation Factories (scaffold for later TDD) **Files:** - Create: `src/database/factories/DocumentFactory.php` - Create: `src/database/factories/DocumentTranslationFactory.php` - Modify: `src/app/Models/Document.php` — add `use HasFactory;` if missing (currently only `use SoftDeletes;`) Note: tasks 2+ build on these factories. We create them first (with placeholder definitions for fields that will exist after the migration in Task 2), then can write failing tests immediately in Task 2. - [ ] **Step 1: Add `HasFactory` trait to Document model** Edit `src/app/Models/Document.php` line 6 area: ```php use App\Helpers\SlugHelper; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; ``` And in the class body (line ~20): ```php class Document extends Model { use HasFactory, SoftDeletes; ``` - [ ] **Step 2: Create DocumentFactory** Create `src/database/factories/DocumentFactory.php`: ```php */ class DocumentFactory extends Factory { protected $model = Document::class; public function definition(): array { $title = fake()->unique()->sentence(3); return [ 'path' => $title . '.md', 'slug' => SlugHelper::generate($title), 'default_locale' => 'en', 'file_size' => 0, 'file_hash' => str_repeat('0', 64), 'file_modified_at' => now(), ]; } /** * After creating, attach a translation in the document's default_locale. * Pass an explicit ['title' => ..., 'content' => ...] via withTranslation() to override. */ public function configure(): static { return $this->afterCreating(function (Document $document) { if ($document->translations()->count() === 0) { DocumentTranslation::factory()->create([ 'document_id' => $document->id, 'locale' => $document->default_locale, ]); } }); } /** * Override the default_locale and create the corresponding translation. */ public function defaultLocale(string $locale): static { return $this->state(['default_locale' => $locale]); } /** * Suppress automatic translation creation (caller will create manually). */ public function withoutTranslations(): static { return $this->afterCreating(fn () => null); } } ``` - [ ] **Step 3: Create DocumentTranslationFactory** Create `src/database/factories/DocumentTranslationFactory.php`: ```php */ class DocumentTranslationFactory extends Factory { protected $model = DocumentTranslation::class; public function definition(): array { $title = fake()->sentence(3); $content = fake()->paragraphs(3, true); return [ 'document_id' => Document::factory()->withoutTranslations(), 'locale' => 'en', 'title' => $title, 'content' => $content, 'rendered_html' => '
' . e($content) . '
', ]; } } ``` - [ ] **Step 4: Commit** ```bash git add src/database/factories/DocumentFactory.php \ src/database/factories/DocumentTranslationFactory.php \ src/app/Models/Document.php git commit -m "Add Document and DocumentTranslation factories" ``` --- ## Task 2: Migration — create document_translations, migrate data, drop old columns **Files:** - Create: `src/database/migrations/2026_05_10_000001_create_document_translations_and_migrate.php` - Create: `src/tests/Feature/DocumentMigrationTest.php` The migration must (a) add `default_locale` to `documents`, (b) create `document_translations`, (c) copy existing `documents.{title,content,rendered_html,created_by,updated_by}` to a translation row, (d) drop the old documents FULLTEXT index, (e) drop the old columns. - [ ] **Step 1: Write the failing migration test** Create `src/tests/Feature/DocumentMigrationTest.php`: ```php assertTrue(Schema::hasColumn('documents', 'default_locale')); } public function test_documents_table_no_longer_has_translatable_columns(): void { $this->assertFalse(Schema::hasColumn('documents', 'title')); $this->assertFalse(Schema::hasColumn('documents', 'content')); $this->assertFalse(Schema::hasColumn('documents', 'rendered_html')); } public function test_document_translations_table_exists_with_required_columns(): void { $this->assertTrue(Schema::hasTable('document_translations')); foreach (['document_id', 'locale', 'title', 'content', 'rendered_html', 'created_by', 'updated_by', 'created_at', 'updated_at'] as $col) { $this->assertTrue( Schema::hasColumn('document_translations', $col), "document_translations missing column: $col" ); } } public function test_document_translations_unique_document_locale(): void { DB::table('documents')->insert([ 'path' => 'A.md', 'slug' => 'a', 'default_locale' => 'en', 'file_size' => 0, 'file_hash' => str_repeat('0', 64), 'file_modified_at' => now(), 'created_at' => now(), 'updated_at' => now(), ]); $docId = DB::table('documents')->where('slug', 'a')->value('id'); DB::table('document_translations')->insert([ 'document_id' => $docId, 'locale' => 'en', 'title' => 'A', 'content' => '...', 'rendered_html' => '...
', 'created_at' => now(), 'updated_at' => now(), ]); $this->expectException(\Illuminate\Database\QueryException::class); DB::table('document_translations')->insert([ 'document_id' => $docId, 'locale' => 'en', 'title' => 'duplicate', 'content' => '...', 'rendered_html' => '...
', 'created_at' => now(), 'updated_at' => now(), ]); } } ``` - [ ] **Step 2: Run the test to verify it fails** ```bash docker compose exec php php artisan test --filter=DocumentMigrationTest ``` Expected: FAIL — `default_locale` column missing, `document_translations` table missing. - [ ] **Step 3: Create the migration** Create `src/database/migrations/2026_05_10_000001_create_document_translations_and_migrate.php`: ```php string('default_locale', 10) ->default(config('app.locale', 'en')) ->after('slug'); }); // 2. Create document_translations Schema::create('document_translations', function (Blueprint $table) { $table->id(); $table->foreignId('document_id')->constrained('documents')->cascadeOnDelete(); $table->string('locale', 10); $table->string('title'); $table->text('content'); $table->text('rendered_html')->nullable(); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps(); $table->unique(['document_id', 'locale']); $table->index(['locale', 'title']); }); if (DB::connection()->getDriverName() === 'mysql') { DB::statement('ALTER TABLE document_translations ADD FULLTEXT INDEX document_translations_search_index (title, content) WITH PARSER ngram'); } // 3. Migrate existing data $defaultLocale = config('app.locale', 'en'); $now = now(); $rows = DB::table('documents')->get(); foreach ($rows as $row) { DB::table('document_translations')->insert([ 'document_id' => $row->id, 'locale' => $defaultLocale, 'title' => $row->title ?? '', 'content' => $row->content ?? '', 'rendered_html' => $row->rendered_html, 'created_by' => $row->created_by ?? null, 'updated_by' => $row->updated_by ?? null, 'created_at' => $row->created_at ?? $now, 'updated_at' => $row->updated_at ?? $now, ]); DB::table('documents')->where('id', $row->id)->update(['default_locale' => $defaultLocale]); } // 4. Drop the old FULLTEXT index on documents (MySQL only) if (DB::connection()->getDriverName() === 'mysql') { DB::statement('ALTER TABLE documents DROP INDEX documents_search_index'); } // 5. Drop translatable columns from documents. // SQLite requires explicit dropIndex on the title index before dropColumn. Schema::table('documents', function (Blueprint $table) { $table->dropIndex(['title']); $table->dropColumn(['title', 'content', 'rendered_html']); }); } public function down(): void { // Re-add columns Schema::table('documents', function (Blueprint $table) { $table->string('title')->nullable()->after('default_locale'); $table->text('content')->nullable()->after('title'); $table->text('rendered_html')->nullable()->after('content'); }); // Restore data from default_locale translation $rows = DB::table('document_translations as t') ->join('documents as d', 'd.id', '=', 't.document_id') ->whereColumn('t.locale', 'd.default_locale') ->select('t.document_id', 't.title', 't.content', 't.rendered_html') ->get(); foreach ($rows as $row) { DB::table('documents')->where('id', $row->document_id)->update([ 'title' => $row->title, 'content' => $row->content, 'rendered_html' => $row->rendered_html, ]); } // Restore FULLTEXT on documents (MySQL only) if (DB::connection()->getDriverName() === 'mysql') { DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram'); } Schema::dropIfExists('document_translations'); Schema::table('documents', function (Blueprint $table) { $table->dropColumn('default_locale'); }); } }; ``` - [ ] **Step 4: Run the test to verify it passes** ```bash docker compose exec php php artisan test --filter=DocumentMigrationTest ``` Expected: PASS (3 tests, all green). - [ ] **Step 5: Commit** ```bash git add src/database/migrations/2026_05_10_000001_create_document_translations_and_migrate.php \ src/tests/Feature/DocumentMigrationTest.php git commit -m "Add document_translations table and migrate existing data documents.{title,content,rendered_html} move to document_translations keyed by (document_id, locale). Existing rows are copied to a single translation in config('app.locale'). documents gains default_locale." ``` --- ## Task 3: DocumentTranslation model **Files:** - Create: `src/app/Models/DocumentTranslation.php` - Create: `src/tests/Unit/Models/DocumentTranslationTest.php` - [ ] **Step 1: Write failing tests** Create `src/tests/Unit/Models/DocumentTranslationTest.php`: ```php create(); $translation = $doc->translations()->first(); $this->assertInstanceOf(Document::class, $translation->document); $this->assertSame($doc->id, $translation->document->id); } public function test_unique_document_locale_constraint(): void { $doc = Document::factory()->create(['default_locale' => 'en']); $this->expectException(QueryException::class); DocumentTranslation::create([ 'document_id' => $doc->id, 'locale' => 'en', 'title' => 'Duplicate', 'content' => 'x', 'rendered_html' => 'x
', ]); } public function test_cascade_delete_when_document_deleted(): void { $doc = Document::factory()->create(); $translationId = $doc->translations()->first()->id; $doc->forceDelete(); $this->assertNull(DocumentTranslation::find($translationId)); } public function test_render_markdown_converts_basic_markdown(): void { $html = DocumentTranslation::renderMarkdown('# Hello'); $this->assertStringContainsString('English body
', ]); App::setLocale('ja'); $fresh = $doc->fresh(); $this->assertSame('English body', $fresh->content); $this->assertSame('English body
', $fresh->rendered_html); } public function test_is_fallback_returns_true_when_locale_missing(): void { $doc = Document::factory()->create(['default_locale' => 'en']); $this->assertTrue($doc->isFallback('ja')); $this->assertFalse($doc->isFallback('en')); } public function test_translation_for_returns_null_when_fallback_disabled(): void { $doc = Document::factory()->create(['default_locale' => 'en']); $this->assertNull($doc->translationFor('ja', fallback: false)); $this->assertNotNull($doc->translationFor('ja', fallback: true)); } public function test_available_locales_lists_existing_translations(): void { $doc = Document::factory()->create(['default_locale' => 'en']); DocumentTranslation::factory()->create(['document_id' => $doc->id, 'locale' => 'ja']); DocumentTranslation::factory()->create(['document_id' => $doc->id, 'locale' => 'fr']); $locales = $doc->fresh()->availableLocales(); sort($locales); $this->assertSame(['en', 'fr', 'ja'], $locales); } } ``` - [ ] **Step 2: Run the test to verify it fails** ```bash docker compose exec php php artisan test --filter=DocumentTest ``` Expected: FAIL — accessors not defined. - [ ] **Step 3: Refactor Document model** Replace `src/app/Models/Document.php` entirely: ```php 'array', 'file_modified_at' => 'datetime', ]; } public function getRouteKeyName(): string { return 'slug'; } public function resolveRouteBinding($value, $field = null) { $document = $this->where('slug', $value)->first(); if (!$document && is_numeric($value)) { $document = $this->where('id', $value)->first(); } return $document; } /** * Backward-compatible static delegate so existing callers and tests * (e.g. MediaEmbedExtensionTest) keep working. */ public static function renderMarkdown(string $markdown): string { return DocumentTranslation::renderMarkdown($markdown); } // ----- Relations ----- public function translations(): HasMany { return $this->hasMany(DocumentTranslation::class); } public function defaultTranslation(): HasOne { return $this->hasOne(DocumentTranslation::class) ->whereColumn('locale', 'documents.default_locale'); } public function creator(): BelongsTo { return $this->belongsTo(User::class, 'created_by'); } public function updater(): BelongsTo { return $this->belongsTo(User::class, 'updated_by'); } public function outgoingLinks(): HasMany { return $this->hasMany(DocumentLink::class, 'source_document_id'); } public function incomingLinks(): HasMany { return $this->hasMany(DocumentLink::class, 'target_document_id'); } public function recentByUsers(): HasManyThrough { return $this->hasManyThrough( User::class, RecentDocument::class, 'document_id', 'id', 'id', 'user_id' ); } // ----- Translation helpers ----- public function translationFor(string $locale, bool $fallback = true): ?DocumentTranslation { $translation = $this->translations->firstWhere('locale', $locale); if (!$translation && $fallback) { $translation = $this->translations->firstWhere('locale', $this->default_locale); } return $translation; } public function isFallback(string $requestedLocale): bool { return $this->translations->firstWhere('locale', $requestedLocale) === null; } /** * @return arraySee [[Target]].
', ]); $html = $source->fresh()->processLinks(); $this->assertStringContainsString('href="' . route('documents.show', 'target-doc') . '"', $html); $this->assertStringContainsString('>Target<', $html); // label preserved $this->assertStringContainsString('class="wiki-link"', $html); } public function test_process_links_marks_unresolved_links_as_new(): void { $source = Document::factory()->create(); $source->translations()->first()->update([ 'rendered_html' => 'Click [[Ghost]].
', ]); $html = $source->fresh()->processLinks(); $this->assertStringContainsString('wiki-link-new', $html); } ``` - [ ] **Step 2: Run the tests to verify they fail** ```bash docker compose exec php php artisan test --filter=DocumentTest ``` Expected: FAIL — `syncLinks()` is the stub from Task 6; `processLinks()` doesn't exist. - [ ] **Step 3: Implement syncLinks and processLinks** In `src/app/Models/Document.php`, replace the stub `syncLinks()` and add `processLinks()`: ```php /** * Extract [[wiki-links]] from the default-locale translation's content * and persist them via DocumentLink. */ public function syncLinks(): void { $this->outgoingLinks()->delete(); $translation = $this->translationFor($this->default_locale, fallback: false); if (!$translation || !$translation->content) { return; } preg_match_all('/\[\[([^\]]+)\]\]/', $translation->content, $matches); if (empty($matches[1])) { return; } $resolver = new \App\Services\WikiLinkResolver(); $position = 0; foreach ($matches[1] as $linkTitle) { $linkTitle = trim($linkTitle); $target = $resolver->resolve($linkTitle, $this->default_locale); DocumentLink::create([ 'source_document_id' => $this->id, 'target_document_id' => $target?->id, 'target_title' => $linkTitle, 'position' => $position++, ]); } } /** * Convert [[wiki-links]] in the current-locale rendered_html to anchor tags. * Link labels stay in the original language; the destination document is * resolved against the current locale (with fallback). */ public function processLinks(): string { $html = $this->rendered_html ?? ''; if ($html === '') { return ''; } $resolver = new \App\Services\WikiLinkResolver(); $currentLocale = \Illuminate\Support\Facades\App::getLocale(); return preg_replace_callback( '/\[\[([^\]]+)\]\]/', function ($matches) use ($resolver, $currentLocale) { $linkText = trim($matches[1]); $target = $resolver->resolve($linkText, $currentLocale); if ($target) { return '' . e($linkText) . ''; } return '' . e($linkText) . ''; }, $html ); } ``` - [ ] **Step 4: Run the tests to verify they pass** ```bash docker compose exec php php artisan test --filter=DocumentTest ``` Expected: PASS (10 tests now). - [ ] **Step 5: Commit** ```bash git add src/app/Models/Document.php src/tests/Unit/Models/DocumentTest.php git commit -m "Re-implement syncLinks and processLinks via WikiLinkResolver syncLinks parses the default-locale content; processLinks resolves each [[link]] against the current locale at render time. Link labels preserve original spelling; destination resolves to the same document in the current locale (with fallback)." ``` --- ## Task 8: Translation routes + DocumentTranslationController **Files:** - Create: `src/app/Http/Controllers/DocumentTranslationController.php` - Modify: `src/routes/web.php` - Create: `src/tests/Feature/DocumentTranslationCrudTest.php` - [ ] **Step 1: Write failing tests** Create `src/tests/Feature/DocumentTranslationCrudTest.php`: ```php create(); $doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]); $response = $this->actingAs($owner)->post( route('documents.translations.store', $doc), ['locale' => 'ja', 'title' => 'こんにちは', 'content' => '本文'] ); $response->assertRedirect(); $this->assertNotNull($doc->fresh()->translationFor('ja', false)); } public function test_non_owner_cannot_add_translation(): void { $owner = User::factory()->create(); $other = User::factory()->create(); $doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]); $response = $this->actingAs($other)->post( route('documents.translations.store', $doc), ['locale' => 'ja', 'title' => 'こんにちは', 'content' => '本文'] ); $response->assertForbidden(); } public function test_invalid_locale_is_rejected(): void { $owner = User::factory()->create(); $doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]); $response = $this->actingAs($owner)->post( route('documents.translations.store', $doc), ['locale' => 'xx', 'title' => 'X', 'content' => 'Y'] ); $response->assertSessionHasErrors('locale'); } public function test_duplicate_locale_returns_422(): void { $owner = User::factory()->create(); $doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]); $response = $this->actingAs($owner)->post( route('documents.translations.store', $doc), ['locale' => 'en', 'title' => 'X', 'content' => 'Y'] ); $response->assertStatus(422); } public function test_owner_can_delete_non_default_translation(): void { $owner = User::factory()->create(); $doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]); app(\App\Services\DocumentService::class)->addTranslation($doc, 'ja', 'JA', 'body', $owner->id); $response = $this->actingAs($owner)->delete( route('documents.translations.destroy', ['document' => $doc, 'locale' => 'ja']) ); $response->assertRedirect(); $this->assertNull($doc->fresh()->translationFor('ja', false)); } public function test_default_locale_translation_cannot_be_deleted(): void { $owner = User::factory()->create(); $doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]); $response = $this->actingAs($owner)->delete( route('documents.translations.destroy', ['document' => $doc, 'locale' => 'en']) ); $response->assertStatus(422); $this->assertNotNull($doc->fresh()->translationFor('en', false)); } } ``` - [ ] **Step 2: Run the test to verify it fails** ```bash docker compose exec php php artisan test --filter=DocumentTranslationCrudTest ``` Expected: FAIL — routes & controller missing. - [ ] **Step 3: Create the controller** Create `src/app/Http/Controllers/DocumentTranslationController.php`: ```php authorize('update', $document); $validated = $request->validate([ 'locale' => ['required', 'string', 'in:' . implode(',', array_keys(SetLocale::SUPPORTED_LOCALES))], 'title' => ['required', 'string', 'max:255'], 'content' => ['required', 'string'], ]); try { $this->service->addTranslation( $document, $validated['locale'], $validated['title'], $validated['content'], Auth::id(), ); } catch (\InvalidArgumentException $e) { return response()->json(['message' => $e->getMessage()], 422); } return redirect()->route('documents.show', $document) ->with('message', __('messages.documents.translation_added')); } public function destroy(Document $document, string $locale) { $this->authorize('update', $document); if (!array_key_exists($locale, SetLocale::SUPPORTED_LOCALES)) { abort(404); } try { $this->service->deleteTranslation($document, $locale); } catch (\InvalidArgumentException $e) { return response()->json(['message' => $e->getMessage()], 422); } return redirect()->route('documents.show', $document) ->with('message', __('messages.documents.translation_deleted')); } } ``` - [ ] **Step 4: Add the routes** Edit `src/routes/web.php`. Inside the existing `Route::prefix('documents')->name('documents.')->group(function () { ... })` block, there is an inner `Route::middleware('auth')->group(function () { ... })` containing the `create` and `edit` routes. Add the three new routes inside that same auth group, right after the existing `edit` route: ```php Route::post('/{document}/translations', [\App\Http\Controllers\DocumentTranslationController::class, 'store']) ->middleware('can:update,document') ->name('translations.store'); Route::delete('/{document}/translations/{locale}', [\App\Http\Controllers\DocumentTranslationController::class, 'destroy']) ->middleware('can:update,document') ->name('translations.destroy'); Route::get('/{document}/translations/{locale}/edit', \App\Livewire\DocumentEditor::class) ->middleware('can:update,document') ->name('translations.edit'); ``` These have 3-4 path segments so they will not collide with the public 1-segment `/{document}` show route registered later in the same prefix group. - [ ] **Step 5: Run the tests to verify they pass** ```bash docker compose exec php php artisan test --filter=DocumentTranslationCrudTest ``` Expected: PASS (6 tests). - [ ] **Step 6: Commit** ```bash git add src/app/Http/Controllers/DocumentTranslationController.php \ src/routes/web.php \ src/tests/Feature/DocumentTranslationCrudTest.php git commit -m "Add translation CRUD routes and controller POST/DELETE for translations gated by can:update,document policy. Locale validated against SUPPORTED_LOCALES. Default-locale deletion returns 422; duplicate-locale add returns 422." ``` --- ## Task 9: DocumentViewer fallback banner + lang strings **Files:** - Modify: `src/app/Livewire/DocumentViewer.php` - Modify: `src/resources/views/livewire/document-viewer.blade.php` - Modify: `src/lang/{en,ja,zh-CN,zh-TW,ko,hi,vi,tr,de,fr,es,pt-BR,ru,uk,it,pl}/messages.php` — add new keys - Create: `src/tests/Feature/DocumentI18nTest.php` - [ ] **Step 1: Write failing tests** Create `src/tests/Feature/DocumentI18nTest.php`: ```php create(['default_locale' => 'en', 'slug' => 'hello']); $doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'Hi', 'rendered_html' => 'Hi
']); DocumentTranslation::factory()->create([ 'document_id' => $doc->id, 'locale' => 'ja', 'title' => 'こんにちは', 'content' => 'やあ', 'rendered_html' => 'やあ
', ]); session()->put('locale', 'ja'); $response = $this->get(route('documents.show', $doc)); $response->assertOk(); $response->assertSee('こんにちは'); $response->assertSee('やあ', false); } public function test_viewer_falls_back_with_banner_when_locale_missing(): void { $doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'fb']); $doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'Hi', 'rendered_html' => 'Hi
']); session()->put('locale', 'ja'); $response = $this->get(route('documents.show', $doc)); $response->assertOk(); $response->assertSee('Hello'); // fallback content // banner present (use the JA translation key value) $response->assertSeeText(__('messages.documents.fallback_notice', [], 'ja')); } public function test_no_banner_when_translation_exists(): void { $doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'nb']); $doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'Hi', 'rendered_html' => 'Hi
']); DocumentTranslation::factory()->create([ 'document_id' => $doc->id, 'locale' => 'ja', 'title' => 'こんにちは', 'content' => 'やあ', 'rendered_html' => 'やあ
', ]); session()->put('locale', 'ja'); $response = $this->get(route('documents.show', $doc)); $response->assertOk(); $response->assertDontSeeText(__('messages.documents.fallback_notice', [], 'ja')); } } ``` - [ ] **Step 2: Run the test to verify it fails** ```bash docker compose exec php php artisan test --filter=DocumentI18nTest ``` Expected: FAIL — translation key missing, banner not in view. - [ ] **Step 3: Update DocumentViewer Livewire component** Replace `src/app/Livewire/DocumentViewer.php`: ```php document = $document->load('translations'); $current = App::getLocale(); $translation = $document->translationFor($current, fallback: true); $this->viewLocale = $translation?->locale ?? $document->default_locale; $this->isFallback = ($current !== $this->viewLocale); $this->renderedContent = $document->processLinks(); $this->backlinks = $documentService->getBacklinks($document); if (Auth::check()) { $documentService->recordDocumentAccess($document, Auth::id()); } } public function render() { return view('livewire.document-viewer') ->layout('layouts.knowledge-base', ['title' => $this->document->title]); } } ``` - [ ] **Step 4: Update the viewer Blade** Edit `src/resources/views/livewire/document-viewer.blade.php`. After the opening `{{ __('messages.documents.fallback_notice', ['locale' => __('messages.locale_names.' . $viewLocale)]) }}
@auth @can('update', $document) {{ __('messages.documents.add_translation') }} @endcan @endauth