diff --git a/docs/superpowers/plans/2026-05-10-article-i18n.md b/docs/superpowers/plans/2026-05-10-article-i18n.md new file mode 100644 index 0000000..38e3f27 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-article-i18n.md @@ -0,0 +1,2803 @@ +# 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 + Schema::table('documents', function (Blueprint $table) { + $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 +