From ab846b71b212c108ba20af49395ddc0108ef8f66 Mon Sep 17 00:00:00 2001 From: Yutaka Kurosaki <> Date: Sun, 10 May 2026 10:10:42 +0900 Subject: [PATCH] Add implementation plan for article-level i18n 12 TDD tasks: factories, migration with data move, DocumentTranslation model, Document refactor with locale-aware accessors, WikiLinkResolver, DocumentService rewrite, syncLinks/processLinks via resolver, translation CRUD routes/controller, viewer fallback banner, editor locale tabs, QuickSwitcher cross-locale search, and seeder cleanup. Each task includes exact file paths, failing tests, minimal implementation, and a commit step. --- .../plans/2026-05-10-article-i18n.md | 2803 +++++++++++++++++ 1 file changed, 2803 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-10-article-i18n.md 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('

', $html); + $this->assertStringContainsString('Hello', $html); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +docker compose exec php php artisan test --filter=DocumentTranslationTest +``` + +Expected: FAIL — `DocumentTranslation` class missing. + +- [ ] **Step 3: Create the model** + +Create `src/app/Models/DocumentTranslation.php`: + +```php +belongsTo(Document::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + /** + * Full-text search scope. Falls back to LIKE on non-MySQL drivers + * (notably SQLite in tests, which lacks FULLTEXT). + */ + public function scopeSearch(Builder $query, string $term): Builder + { + if ($query->getConnection()->getDriverName() === 'mysql') { + return $query->whereRaw( + 'MATCH(title, content) AGAINST(? IN BOOLEAN MODE)', + [$term] + ); + } + + return $query->where(function (Builder $q) use ($term) { + $like = '%' . $term . '%'; + $q->where('title', 'like', $like)->orWhere('content', 'like', $like); + }); + } + + public static function renderMarkdown(string $markdown): string + { + $converter = new CommonMarkConverter([ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + ]); + + $converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension()); + $converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension()); + + return $converter->convert($markdown)->getContent(); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +docker compose exec php php artisan test --filter=DocumentTranslationTest +``` + +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/app/Models/DocumentTranslation.php \ + src/tests/Unit/Models/DocumentTranslationTest.php +git commit -m "Add DocumentTranslation model with renderMarkdown and search scope" +``` + +--- + +## Task 4: Document model — accessors, relations, fallback helpers + +**Files:** +- Modify: `src/app/Models/Document.php` +- Create: `src/tests/Unit/Models/DocumentTest.php` + +- [ ] **Step 1: Write failing tests** + +Create `src/tests/Unit/Models/DocumentTest.php`: + +```php +create(['default_locale' => 'en']); + $doc->translations()->where('locale', 'en')->update(['title' => 'Hello']); + DocumentTranslation::factory()->create([ + 'document_id' => $doc->id, + 'locale' => 'ja', + 'title' => 'こんにちは', + ]); + + App::setLocale('ja'); + $this->assertSame('こんにちは', $doc->fresh()->title); + + App::setLocale('en'); + $this->assertSame('Hello', $doc->fresh()->title); + } + + public function test_title_accessor_falls_back_to_default_locale(): void + { + $doc = Document::factory()->create(['default_locale' => 'en']); + $doc->translations()->where('locale', 'en')->update(['title' => 'Hello']); + + App::setLocale('ja'); + $this->assertSame('Hello', $doc->fresh()->title); + } + + public function test_content_and_rendered_html_accessors_fall_back(): void + { + $doc = Document::factory()->create(['default_locale' => 'en']); + $doc->translations()->where('locale', 'en')->update([ + 'content' => 'English body', + 'rendered_html' => '

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 array + */ + public function availableLocales(): array + { + return $this->translations->pluck('locale')->all(); + } + + // ----- Accessors (current-locale → fallback) ----- + + public function getTitleAttribute(): string + { + return $this->translationFor(App::getLocale())?->title ?? ''; + } + + public function getContentAttribute(): string + { + return $this->translationFor(App::getLocale())?->content ?? ''; + } + + public function getRenderedHtmlAttribute(): ?string + { + return $this->translationFor(App::getLocale())?->rendered_html; + } + + // ----- Path helpers ----- + + public function getDirectoryAttribute(): string + { + return dirname($this->path); + } + + public function getFilenameAttribute(): string + { + return basename($this->path); + } + + public function getAbsolutePathAttribute(): string + { + return Storage::disk('markdown')->path($this->path); + } + + // ----- Search scope (delegates to translations) ----- + + public function scopeSearch(Builder $query, string $term): Builder + { + return $query->whereHas('translations', function (Builder $q) use ($term) { + DocumentTranslation::scopeSearch($q, $term); + }); + } + + public function scopeInDirectory(Builder $query, string $directory): Builder + { + $directory = rtrim($directory, '/') . '/'; + return $query->where('path', 'like', $directory . '%'); + } +} +``` + +(Note: `syncLinks()` and `processLinks()` removed — re-added via `WikiLinkResolver` in Task 6/7.) + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +docker compose exec php php artisan test --filter=DocumentTest +``` + +Expected: PASS (6 tests). + +- [ ] **Step 5: Run the existing media test to ensure backward compatibility** + +```bash +docker compose exec php php artisan test --filter=MediaEmbedExtensionTest +``` + +Expected: PASS — `Document::renderMarkdown()` delegate still works. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/Models/Document.php src/tests/Unit/Models/DocumentTest.php +git commit -m "Refactor Document to read title/content via translations + +Adds translations/defaultTranslation relations, current-locale accessors +with fallback to default_locale, isFallback/availableLocales helpers, +and search scope that delegates to DocumentTranslation." +``` + +--- + +## Task 5: WikiLinkResolver service + +**Files:** +- Create: `src/app/Services/WikiLinkResolver.php` +- Create: `src/tests/Unit/Services/WikiLinkResolverTest.php` + +- [ ] **Step 1: Write failing tests** + +Create `src/tests/Unit/Services/WikiLinkResolverTest.php`: + +```php +resolver = new WikiLinkResolver(); + } + + public function test_resolves_via_current_locale_title(): void + { + $doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'getting-started']); + $doc->translations()->where('locale', 'en')->update(['title' => 'Getting Started']); + DocumentTranslation::factory()->create([ + 'document_id' => $doc->id, + 'locale' => 'ja', + 'title' => 'はじめに', + ]); + + $resolved = $this->resolver->resolve('はじめに', 'ja'); + $this->assertSame($doc->id, $resolved->id); + } + + public function test_resolves_via_default_locale_when_current_locale_missing(): void + { + $doc = Document::factory()->create(['default_locale' => 'en']); + $doc->translations()->where('locale', 'en')->update(['title' => 'Getting Started']); + + $resolved = $this->resolver->resolve('Getting Started', 'ja'); + $this->assertSame($doc->id, $resolved->id); + } + + public function test_resolves_via_any_locale_deterministically(): void + { + // Two documents both have a 'fr' translation titled "Bonjour", neither is current/default + $docA = Document::factory()->create(['default_locale' => 'en']); + DocumentTranslation::factory()->create(['document_id' => $docA->id, 'locale' => 'fr', 'title' => 'Bonjour']); + + $docB = Document::factory()->create(['default_locale' => 'en']); + DocumentTranslation::factory()->create(['document_id' => $docB->id, 'locale' => 'fr', 'title' => 'Bonjour']); + + $resolved = $this->resolver->resolve('Bonjour', 'ja'); + // Lower id wins (deterministic) + $this->assertSame($docA->id, $resolved->id); + } + + public function test_resolves_via_slug_when_no_title_match(): void + { + $doc = Document::factory()->create(['slug' => 'unique-slug', 'default_locale' => 'en']); + $doc->translations()->where('locale', 'en')->update(['title' => 'Whatever']); + + $resolved = $this->resolver->resolve('unique-slug', 'ja'); + $this->assertSame($doc->id, $resolved->id); + } + + public function test_returns_null_when_nothing_matches(): void + { + $this->assertNull($this->resolver->resolve('Nonexistent', 'en')); + } + + public function test_current_locale_wins_over_default(): void + { + // Doc A has en title "Setup"; Doc B has ja title "Setup" + $docA = Document::factory()->create(['default_locale' => 'en']); + $docA->translations()->where('locale', 'en')->update(['title' => 'Setup']); + + $docB = Document::factory()->create(['default_locale' => 'en']); + $docB->translations()->where('locale', 'en')->update(['title' => 'Different']); + DocumentTranslation::factory()->create(['document_id' => $docB->id, 'locale' => 'ja', 'title' => 'Setup']); + + // Browsing in ja: ja-locale match (Doc B) should win over default-locale match (Doc A) + $resolved = $this->resolver->resolve('Setup', 'ja'); + $this->assertSame($docB->id, $resolved->id); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +docker compose exec php php artisan test --filter=WikiLinkResolverTest +``` + +Expected: FAIL — `WikiLinkResolver` class missing. + +- [ ] **Step 3: Create the service** + +Create `src/app/Services/WikiLinkResolver.php`: + +```php +where('title', $linkText) + ->orderBy('document_id') + ->first(); + if ($byCurrent) { + return $byCurrent->document; + } + + // 2. Document's default-locale title match + $byDefault = DocumentTranslation::query() + ->join('documents', 'documents.id', '=', 'document_translations.document_id') + ->whereColumn('document_translations.locale', 'documents.default_locale') + ->where('document_translations.title', $linkText) + ->orderBy('document_translations.document_id') + ->select('document_translations.*') + ->first(); + if ($byDefault) { + return $byDefault->document; + } + + // 3. Any-locale title match (lowest document_id wins) + $byAny = DocumentTranslation::where('title', $linkText) + ->orderBy('document_id') + ->first(); + if ($byAny) { + return $byAny->document; + } + + // 4. Slug match (legacy) + $slug = SlugHelper::generate($linkText); + $byslug = Document::where('slug', $slug)->first(); + if ($byslug) { + return $byslug; + } + + // 5. Nothing + return null; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +docker compose exec php php artisan test --filter=WikiLinkResolverTest +``` + +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/app/Services/WikiLinkResolver.php \ + src/tests/Unit/Services/WikiLinkResolverTest.php +git commit -m "Add WikiLinkResolver with deterministic 5-step resolution + +Prefers current locale, then document default_locale, then any locale +(lowest document_id), then slug match (legacy)." +``` + +--- + +## Task 6: DocumentService refactor (locale-aware) + +**Files:** +- Modify: `src/app/Services/DocumentService.php` +- Create: `src/tests/Unit/Services/DocumentServiceTest.php` + +- [ ] **Step 1: Write failing tests** + +Create `src/tests/Unit/Services/DocumentServiceTest.php`: + +```php +service = new DocumentService(); + } + + public function test_create_document_creates_one_translation_in_given_locale(): void + { + App::setLocale('en'); // ensure deterministic + $user = User::factory()->create(); + + $doc = $this->service->createDocument('Hello', '# Hi', $user->id, 'en'); + + $this->assertSame('en', $doc->default_locale); + $this->assertSame('Hello.md', $doc->path); + $this->assertSame('hello', $doc->slug); + $this->assertCount(1, $doc->translations); + $this->assertSame('Hello', $doc->translations->first()->title); + $this->assertSame('# Hi', $doc->translations->first()->content); + $this->assertStringContainsString('

', $doc->translations->first()->rendered_html); + } + + public function test_update_document_in_default_locale_regenerates_path_and_slug(): void + { + $doc = $this->service->createDocument('Old', 'body', null, 'en'); + + $updated = $this->service->updateDocument($doc, 'New Title', 'body2', null, 'en'); + + $this->assertSame('New Title.md', $updated->path); + $this->assertSame('new-title', $updated->slug); + } + + public function test_update_document_in_non_default_locale_does_not_change_path(): void + { + $doc = $this->service->createDocument('English', 'body', null, 'en'); + $originalPath = $doc->path; + $originalSlug = $doc->slug; + + $updated = $this->service->updateDocument($doc, '日本語タイトル', '本文', null, 'ja'); + + $this->assertSame($originalPath, $updated->path); + $this->assertSame($originalSlug, $updated->slug); + $this->assertSame('日本語タイトル', $updated->translationFor('ja', false)->title); + } + + public function test_add_translation_creates_new_locale_row(): void + { + $doc = $this->service->createDocument('Hello', 'body', null, 'en'); + + $this->service->addTranslation($doc, 'ja', 'こんにちは', 'やあ', null); + + $this->assertCount(2, $doc->fresh()->translations); + $this->assertSame('こんにちは', $doc->fresh()->translationFor('ja', false)->title); + } + + public function test_add_translation_throws_on_duplicate_locale(): void + { + $doc = $this->service->createDocument('Hello', 'body', null, 'en'); + + $this->expectException(\InvalidArgumentException::class); + $this->service->addTranslation($doc, 'en', 'X', 'Y', null); + } + + public function test_delete_translation_removes_non_default(): void + { + $doc = $this->service->createDocument('Hello', 'body', null, 'en'); + $this->service->addTranslation($doc, 'ja', 'こんにちは', 'やあ', null); + + $this->service->deleteTranslation($doc, 'ja'); + + $this->assertNull($doc->fresh()->translationFor('ja', false)); + } + + public function test_delete_translation_refuses_default_locale(): void + { + $doc = $this->service->createDocument('Hello', 'body', null, 'en'); + + $this->expectException(\InvalidArgumentException::class); + $this->service->deleteTranslation($doc, 'en'); + } + + public function test_set_default_locale_requires_existing_translation(): void + { + $doc = $this->service->createDocument('Hello', 'body', null, 'en'); + + $this->expectException(\InvalidArgumentException::class); + $this->service->setDefaultLocale($doc, 'ja'); + } + + public function test_set_default_locale_regenerates_path_from_new_locale_title(): void + { + $doc = $this->service->createDocument('Hello', 'body', null, 'en'); + $this->service->addTranslation($doc, 'ja', 'こんにちは', 'やあ', null); + + $updated = $this->service->setDefaultLocale($doc, 'ja'); + + $this->assertSame('ja', $updated->default_locale); + $this->assertSame('こんにちは.md', $updated->path); + } + + public function test_search_returns_distinct_documents_across_locales(): void + { + $doc = $this->service->createDocument('Searchword', 'body', null, 'en'); + $this->service->addTranslation($doc, 'ja', 'Searchword JA', 'Searchword body', null); + + $results = $this->service->search('Searchword'); + + $this->assertCount(1, $results); // distinct, even though 2 translations match + $this->assertSame($doc->id, $results->first()->id); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +docker compose exec php php artisan test --filter=DocumentServiceTest +``` + +Expected: FAIL — new methods don't exist; old `createDocument` signature mismatches. + +- [ ] **Step 3: Refactor DocumentService** + +Replace `src/app/Services/DocumentService.php` entirely: + +```php +generatePathAndSlug($title); + + return DB::transaction(function () use ($title, $content, $userId, $locale, $path, $slug) { + $document = Document::create([ + 'path' => $path, + 'slug' => $slug, + 'default_locale' => $locale, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + DocumentTranslation::create([ + 'document_id' => $document->id, + 'locale' => $locale, + 'title' => $title, + 'content' => $content, + 'rendered_html' => DocumentTranslation::renderMarkdown($content), + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + $document->load('translations'); + $document->syncLinks(); + + return $document; + }); + } + + public function updateDocument( + Document $document, + string $title, + string $content, + ?int $userId = null, + ?string $locale = null, + ): Document { + $locale = $locale ?: App::getLocale(); + + return DB::transaction(function () use ($document, $title, $content, $userId, $locale) { + $translation = $document->translations()->firstOrNew(['locale' => $locale]); + $translation->title = $title; + $translation->content = $content; + $translation->rendered_html = DocumentTranslation::renderMarkdown($content); + $translation->updated_by = $userId; + if (!$translation->exists) { + $translation->created_by = $userId; + } + $translation->save(); + + $document->updated_by = $userId; + + // Path/slug regenerate only when editing the default-locale translation + if ($locale === $document->default_locale) { + [$path, $slug] = $this->generatePathAndSlug($title, $document->id); + $document->path = $path; + $document->slug = $slug; + } + + $document->save(); + $document->load('translations'); + $document->syncLinks(); + + return $document; + }); + } + + public function addTranslation( + Document $document, + string $locale, + string $title, + string $content, + ?int $userId = null, + ): DocumentTranslation { + if ($document->translations()->where('locale', $locale)->exists()) { + throw new \InvalidArgumentException("Translation for locale '$locale' already exists"); + } + + return DocumentTranslation::create([ + 'document_id' => $document->id, + 'locale' => $locale, + 'title' => $title, + 'content' => $content, + 'rendered_html' => DocumentTranslation::renderMarkdown($content), + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + } + + public function deleteTranslation(Document $document, string $locale): void + { + if ($locale === $document->default_locale) { + throw new \InvalidArgumentException("Cannot delete default-locale translation '$locale'"); + } + $document->translations()->where('locale', $locale)->delete(); + } + + public function setDefaultLocale(Document $document, string $locale): Document + { + $translation = $document->translations()->where('locale', $locale)->first(); + if (!$translation) { + throw new \InvalidArgumentException("Cannot set default to '$locale': translation does not exist"); + } + + return DB::transaction(function () use ($document, $locale, $translation) { + $document->default_locale = $locale; + [$path, $slug] = $this->generatePathAndSlug($translation->title, $document->id); + $document->path = $path; + $document->slug = $slug; + $document->save(); + + return $document->fresh('translations'); + }); + } + + public function deleteDocument(Document $document): bool + { + return $document->delete(); + } + + /** + * Locale-agnostic full-text search; returns distinct documents. + */ + public function search(string $query, int $limit = 20) + { + $documentIds = DocumentTranslation::query() + ->search($query) + ->limit($limit * 5) // overscan to allow distinct collapse + ->pluck('document_id') + ->unique() + ->values() + ->take($limit); + + if ($documentIds->isEmpty()) { + return Document::query()->whereRaw('1 = 0')->get(); + } + + return Document::with('translations') + ->whereIn('id', $documentIds) + ->get() + ->sortBy(fn ($d) => $documentIds->search($d->id)) + ->values(); + } + + public function findByTitle(string $title, ?string $locale = null): ?Document + { + return (new WikiLinkResolver())->resolve($title, $locale ?: App::getLocale()); + } + + public function getDirectoryTree(): array + { + $documents = Document::with('translations')->orderBy('path')->get(); + + $tree = []; + foreach ($documents as $document) { + $parts = explode('/', $document->path); + $current = &$tree; + foreach ($parts as $index => $part) { + $isFile = ($index === count($parts) - 1); + if ($isFile) { + $current['_files'][] = [ + 'name' => $part, + 'document' => $document, + ]; + } else { + if (!isset($current[$part])) { + $current[$part] = []; + } + $current = &$current[$part]; + } + } + } + return $tree; + } + + public function getRecentDocuments(int $userId, int $limit = 10) + { + return RecentDocument::getRecentForUser($userId, $limit); + } + + public function recordDocumentAccess(Document $document, int $userId): void + { + RecentDocument::recordAccess($userId, $document->id); + } + + public function getBacklinks(Document $document) + { + return $document->incomingLinks() + ->with('sourceDocument.translations') + ->get() + ->pluck('sourceDocument') + ->filter(); + } + + public function getBrokenLinks() + { + return DB::table('document_links') + ->whereNull('target_document_id') + ->select('target_title', DB::raw('COUNT(*) as count')) + ->groupBy('target_title') + ->orderByDesc('count') + ->get(); + } + + private function generatePathAndSlug(string $title, ?int $excludeDocumentId = null): array + { + $basePath = $title . '.md'; + $baseSlug = SlugHelper::generate(basename($title)); + return $this->ensureUniquePath($basePath, $baseSlug, $excludeDocumentId); + } + + private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excludeDocumentId = null): array + { + $path = $basePath; + $slug = $baseSlug; + $counter = 1; + + while (true) { + $query = Document::withTrashed() + ->where(function ($q) use ($path, $slug) { + $q->where('path', $path)->orWhere('slug', $slug); + }); + if ($excludeDocumentId) { + $query->where('id', '!=', $excludeDocumentId); + } + if (!$query->exists()) { + break; + } + $counter++; + $path = preg_replace('/\.md$/', "-{$counter}.md", $basePath); + $slug = $baseSlug . '-' . $counter; + } + + return [$path, $slug]; + } +} +``` + +(Note: `createInitialDocuments()` removed — `DocumentSeeder` will be updated in Task 12 to use `createDocument()` directly. `Document::syncLinks()` will be added in Task 7.) + +- [ ] **Step 4: Add a stub `syncLinks()` to Document so the service compiles** + +In `src/app/Models/Document.php`, just before the closing `}` of the class, add: + +```php + /** + * Sync wiki-links for this document. Implementation in Task 7. + */ + public function syncLinks(): void + { + // Replaced in Task 7 + } +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +docker compose exec php php artisan test --filter=DocumentServiceTest +``` + +Expected: PASS (10 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/app/Services/DocumentService.php \ + src/app/Models/Document.php \ + src/tests/Unit/Services/DocumentServiceTest.php +git commit -m "Make DocumentService locale-aware + +createDocument/updateDocument now accept a \$locale parameter and +write to document_translations. Adds addTranslation, deleteTranslation, +setDefaultLocale (with path/slug regen), distinct-document search, +and findByTitle that delegates to WikiLinkResolver." +``` + +--- + +## Task 7: Document::syncLinks and processLinks via WikiLinkResolver + +**Files:** +- Modify: `src/app/Models/Document.php` — replace stub `syncLinks()` with real implementation, add `processLinks()` +- Modify: `src/tests/Unit/Models/DocumentTest.php` — add tests + +- [ ] **Step 1: Write failing tests** + +Append to `src/tests/Unit/Models/DocumentTest.php` (inside the class, before the closing `}`): + +```php + public function test_sync_links_creates_outgoing_links_with_resolved_targets(): void + { + $target = Document::factory()->create(['default_locale' => 'en']); + $target->translations()->where('locale', 'en')->update(['title' => 'Target']); + + $source = Document::factory()->create(['default_locale' => 'en']); + $source->translations()->where('locale', 'en')->update([ + 'content' => 'See [[Target]] for details.', + ]); + + $source->fresh('translations')->syncLinks(); + + $links = $source->fresh()->outgoingLinks; + $this->assertCount(1, $links); + $this->assertSame($target->id, $links->first()->target_document_id); + $this->assertSame('Target', $links->first()->target_title); + } + + public function test_sync_links_records_unresolved_links_with_null_target(): void + { + $source = Document::factory()->create(); + $source->translations()->first()->update([ + 'content' => 'Goes to [[NoSuchPage]].', + ]); + + $source->fresh('translations')->syncLinks(); + + $links = $source->fresh()->outgoingLinks; + $this->assertCount(1, $links); + $this->assertNull($links->first()->target_document_id); + } + + public function test_process_links_replaces_wiki_link_with_anchor_keeping_label(): void + { + $target = Document::factory()->create(['default_locale' => 'en', 'slug' => 'target-doc']); + $target->translations()->where('locale', 'en')->update(['title' => 'Target']); + \Illuminate\Support\Facades\App::setLocale('ja'); + \App\Models\DocumentTranslation::factory()->create([ + 'document_id' => $target->id, + 'locale' => 'ja', + 'title' => 'ターゲット', + ]); + + $source = Document::factory()->create(); + $source->translations()->first()->update([ + 'rendered_html' => '

See [[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 `
` and before the `` block, insert: + +```blade + @if($isFallback) +
+

+ {{ __('messages.documents.fallback_notice', ['locale' => __('messages.locale_names.' . $viewLocale)]) }} +

+ @auth + @can('update', $document) + + {{ __('messages.documents.add_translation') }} + + @endcan + @endauth +
+ @endif + +``` + +- [ ] **Step 5: Add lang keys to ALL 16 locales** + +For each of these locale files (`src/lang/{en,ja,zh-CN,zh-TW,ko,hi,vi,tr,de,fr,es,pt-BR,ru,uk,it,pl}/messages.php`), add the following keys to the existing `'documents' => [ ... ]` array (insert before the closing `],` of the documents block): + +For `en/messages.php`: +```php + 'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.', + 'add_translation' => 'Add translation', + 'translation_added' => 'Translation added.', + 'translation_deleted' => 'Translation deleted.', + 'set_as_default' => 'Set as default', + 'delete_translation_blocked' => 'The default-language translation cannot be deleted.', + 'translation_tabs_label' => 'Languages', +``` + +And in the same file, add a top-level entry just before the closing `];`: +```php + 'locale_names' => [ + 'en' => 'English', + 'ja' => 'Japanese', + 'zh-CN' => 'Simplified Chinese', + 'zh-TW' => 'Traditional Chinese', + 'ko' => 'Korean', + 'hi' => 'Hindi', + 'vi' => 'Vietnamese', + 'tr' => 'Turkish', + 'de' => 'German', + 'fr' => 'French', + 'es' => 'Spanish', + 'pt-BR' => 'Portuguese (Brazil)', + 'ru' => 'Russian', + 'uk' => 'Ukrainian', + 'it' => 'Italian', + 'pl' => 'Polish', + ], +``` + +For `ja/messages.php` use these translations: +```php + 'fallback_notice' => 'この記事には選択した言語の翻訳がありません。:locale 版を表示しています。', + 'add_translation' => '翻訳を追加', + 'translation_added' => '翻訳を追加しました。', + 'translation_deleted' => '翻訳を削除しました。', + 'set_as_default' => 'デフォルトに設定', + 'delete_translation_blocked' => 'デフォルト言語の翻訳は削除できません。', + 'translation_tabs_label' => '言語', +``` +And `locale_names` (Japanese): +```php + 'locale_names' => [ + 'en' => '英語', 'ja' => '日本語', + 'zh-CN' => '簡体字中国語', 'zh-TW' => '繁体字中国語', + 'ko' => '韓国語', 'hi' => 'ヒンディー語', 'vi' => 'ベトナム語', 'tr' => 'トルコ語', + 'de' => 'ドイツ語', 'fr' => 'フランス語', 'es' => 'スペイン語', 'pt-BR' => 'ポルトガル語(ブラジル)', + 'ru' => 'ロシア語', 'uk' => 'ウクライナ語', 'it' => 'イタリア語', 'pl' => 'ポーランド語', + ], +``` + +For the other 14 locales (`zh-CN`, `zh-TW`, `ko`, `hi`, `vi`, `tr`, `de`, `fr`, `es`, `pt-BR`, `ru`, `uk`, `it`, `pl`), copy the **English** values verbatim into the same structure. Translation polish for those locales is out of scope for this PR — listed as a follow-up item in the spec's "Scope Out" section. (Rationale: 14 lang files × 7 keys × accurate translation = 98 strings of human-quality translation, which is its own task.) + +- [ ] **Step 6: Run the tests to verify they pass** + +```bash +docker compose exec php php artisan test --filter=DocumentI18nTest +``` + +Expected: PASS (3 tests). + +- [ ] **Step 7: Commit** + +```bash +git add src/app/Livewire/DocumentViewer.php \ + src/resources/views/livewire/document-viewer.blade.php \ + src/lang/ \ + src/tests/Feature/DocumentI18nTest.php +git commit -m "Show fallback banner when current-locale translation is missing + +DocumentViewer computes viewLocale and isFallback at mount; banner +links authenticated owners to the editor for the current UI locale. +Adds documents.fallback_notice + locale_names to all 16 lang files +(en+ja human-translated, others mirror en for now)." +``` + +--- + +## Task 10: DocumentEditor with locale tabs + +**Files:** +- Modify: `src/app/Livewire/DocumentEditor.php` +- Modify: `src/resources/views/livewire/document-editor.blade.php` +- Modify: `src/routes/web.php` — confirm `documents.translations.edit` route binds correctly (added in Task 8) + +- [ ] **Step 1: Add a feature test for the editor tab flow** + +Append to `src/tests/Feature/DocumentI18nTest.php` (inside the class): + +```php + public function test_editor_loads_existing_translation_for_locale(): void + { + $owner = \App\Models\User::factory()->create(); + $doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id, 'slug' => 'editor']); + $doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'EN body']); + DocumentTranslation::factory()->create([ + 'document_id' => $doc->id, + 'locale' => 'ja', + 'title' => 'こんにちは', + 'content' => 'JA body', + ]); + + $response = $this->actingAs($owner)->get(route('documents.translations.edit', [ + 'document' => $doc, + 'locale' => 'ja', + ])); + + $response->assertOk(); + $response->assertSee('こんにちは'); + $response->assertSee('JA body'); + } + + public function test_editor_for_missing_locale_shows_empty_form_with_new_locale_state(): void + { + $owner = \App\Models\User::factory()->create(); + $doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id, 'slug' => 'editor2']); + + $response = $this->actingAs($owner)->get(route('documents.translations.edit', [ + 'document' => $doc, + 'locale' => 'ja', + ])); + + $response->assertOk(); + // The blade should render a tab marked active for ja with empty inputs + $response->assertSeeText(__('messages.locale_names.ja', [], 'en')); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +docker compose exec php php artisan test --filter=DocumentI18nTest +``` + +Expected: FAIL — editor doesn't yet handle `editingLocale` from URL. + +- [ ] **Step 3: Update DocumentEditor component** + +Replace `src/app/Livewire/DocumentEditor.php`: + +```php +authorize('update', $document); + $this->document = $document->load('translations'); + $this->isEditMode = true; + $this->availableLocales = $document->availableLocales(); + $this->editingLocale = $locale ?: ($document->default_locale ?? App::getLocale()); + + $translation = $document->translations->firstWhere('locale', $this->editingLocale); + if ($translation) { + $this->title = $translation->title; + $this->content = $translation->content; + $this->isNewLocale = false; + } else { + $this->title = ''; + $this->content = ''; + $this->isNewLocale = true; + } + } else { + $this->editingLocale = App::getLocale(); + $titleParam = request()->query('title'); + if ($titleParam) { + $this->title = $titleParam; + } + } + } + + public function save(DocumentService $documentService) + { + $validated = $this->validate([ + 'title' => 'required|string|max:255', + 'content' => 'required|string', + 'editingLocale' => ['required', 'string', 'in:' . implode(',', array_keys(SetLocale::SUPPORTED_LOCALES))], + ]); + + try { + if ($this->isEditMode && $this->document) { + $this->authorize('update', $this->document); + + if ($this->isNewLocale) { + $documentService->addTranslation( + $this->document, + $this->editingLocale, + $this->title, + $this->content, + Auth::id(), + ); + $this->document->refresh()->load('translations'); + } else { + $this->document = $documentService->updateDocument( + $this->document, + $this->title, + $this->content, + Auth::id(), + $this->editingLocale, + ); + } + + session()->flash('message', __('messages.documents.update_success')); + return $this->redirect(route('documents.show', $this->document)); + } else { + $this->document = $documentService->createDocument( + $this->title, + $this->content, + Auth::id(), + $this->editingLocale, + ); + session()->flash('message', __('messages.documents.create_success')); + return $this->redirect(route('documents.show', $this->document)); + } + } catch (\InvalidArgumentException $e) { + session()->flash('error', $e->getMessage()); + } catch (\Exception $e) { + session()->flash('error', 'Error saving document: ' . $e->getMessage()); + } + } + + public function deleteTranslation(DocumentService $documentService) + { + if (!$this->isEditMode || !$this->document || $this->isNewLocale) { + return; + } + $this->authorize('update', $this->document); + + try { + $documentService->deleteTranslation($this->document, $this->editingLocale); + session()->flash('message', __('messages.documents.translation_deleted')); + return $this->redirect(route('documents.show', $this->document)); + } catch (\InvalidArgumentException $e) { + session()->flash('error', $e->getMessage()); + } + } + + public function setAsDefault(DocumentService $documentService) + { + if (!$this->isEditMode || !$this->document) { + return; + } + $this->authorize('update', $this->document); + + try { + $this->document = $documentService->setDefaultLocale($this->document, $this->editingLocale); + session()->flash('message', __('messages.documents.update_success')); + return $this->redirect(route('documents.show', $this->document)); + } catch (\InvalidArgumentException $e) { + session()->flash('error', $e->getMessage()); + } + } + + public function delete(DocumentService $documentService) + { + if (!$this->isEditMode || !$this->document) { + return; + } + $this->authorize('delete', $this->document); + + try { + $documentService->deleteDocument($this->document); + session()->flash('message', __('messages.documents.delete_success')); + $homeDocument = Document::where('slug', 'home')->first(); + if ($homeDocument) { + return redirect()->route('documents.show', $homeDocument); + } + return redirect(url('/')); + } catch (\Exception $e) { + session()->flash('error', 'Error deleting document: ' . $e->getMessage()); + } + } + + public function render() + { + return view('livewire.document-editor') + ->layout('layouts.knowledge-base', [ + 'title' => $this->isEditMode + ? __('messages.documents.edit_document') . ': ' . $this->title + : __('messages.documents.new_document'), + ]); + } +} +``` + +- [ ] **Step 4: Update the editor Blade with locale tabs** + +In `src/resources/views/livewire/document-editor.blade.php`, just after the closing `
` of the existing header block (the `
` block, around line 57), insert the tab bar: + +```blade + @if($isEditMode && $document) +
+ + + @if($editingLocale !== $document->default_locale && !$isNewLocale) +
+ + +
+ @endif +
+ @endif +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +docker compose exec php php artisan test --filter=DocumentI18nTest +``` + +Expected: PASS (5 tests now in DocumentI18nTest). + +- [ ] **Step 6: Run the full test suite to check for regressions** + +```bash +docker compose exec php php artisan test +``` + +Expected: All green. + +- [ ] **Step 7: Commit** + +```bash +git add src/app/Livewire/DocumentEditor.php \ + src/resources/views/livewire/document-editor.blade.php \ + src/tests/Feature/DocumentI18nTest.php +git commit -m "Add locale tabs to DocumentEditor + +Editor accepts a locale URL parameter, loads the corresponding +translation (or empty form for new locales), and exposes +addTranslation/setDefaultLocale/deleteTranslation actions. Tab bar +shows existing locales with default-locale star and a + dropdown +for missing locales." +``` + +--- + +## Task 11: SidebarTree + QuickSwitcher locale awareness + +**Files:** +- Modify: `src/app/Livewire/QuickSwitcher.php` +- Modify: `src/app/Livewire/SidebarTree.php` (only if it caches; otherwise no code change needed since it relies on `$document->title` accessor) + +- [ ] **Step 1: Write failing test for QuickSwitcher cross-locale search** + +Append to `src/tests/Feature/DocumentI18nTest.php`: + +```php + public function test_quick_switcher_finds_documents_by_any_locale_title(): void + { + $doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'qs']); + $doc->translations()->where('locale', 'en')->update(['title' => 'Getting Started', 'content' => 'EN body']); + DocumentTranslation::factory()->create([ + 'document_id' => $doc->id, + 'locale' => 'ja', + 'title' => 'はじめに', + 'content' => '本文', + ]); + + $component = \Livewire\Livewire::test(\App\Livewire\QuickSwitcher::class) + ->set('search', 'はじめに'); + + $results = $component->get('results'); + $this->assertCount(1, $results); + $this->assertSame($doc->id, $results[0]['id']); + + $component2 = \Livewire\Livewire::test(\App\Livewire\QuickSwitcher::class) + ->set('search', 'Getting'); + $results2 = $component2->get('results'); + $this->assertCount(1, $results2); + $this->assertSame($doc->id, $results2[0]['id']); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +docker compose exec php php artisan test --filter=DocumentI18nTest::test_quick_switcher_finds_documents_by_any_locale_title +``` + +Expected: FAIL — QuickSwitcher still queries `documents.title`/`documents.content`, which no longer exist. + +- [ ] **Step 3: Update QuickSwitcher** + +Replace `src/app/Livewire/QuickSwitcher.php`: + +```php +search)) { + $documents = Document::with('translations') + ->orderBy('updated_at', 'desc') + ->limit(10) + ->get(); + } else { + $documents = app(DocumentService::class)->search($this->search, 10); + } + + return $documents->map(fn ($doc) => [ + 'id' => $doc->id, + 'title' => $doc->title, + 'slug' => $doc->slug, + 'directory' => dirname($doc->path), + ])->values()->toArray(); + } + + public function updated($propertyName) + { + if ($propertyName === 'search') { + $this->selectedIndex = 0; + } + } + + public function selectNext() + { + $results = $this->results; + if ($this->selectedIndex < count($results) - 1) { + $this->selectedIndex++; + } + } + + public function selectPrevious() + { + if ($this->selectedIndex > 0) { + $this->selectedIndex--; + } + } + + public function selectDocument() + { + $results = $this->results; + if (isset($results[$this->selectedIndex])) { + $document = $results[$this->selectedIndex]; + if (!empty($document['slug'])) { + return $this->redirect(route('documents.show', $document['slug'])); + } + } + } + + public function render() + { + return view('livewire.quick-switcher'); + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +docker compose exec php php artisan test --filter=DocumentI18nTest +``` + +Expected: PASS. + +- [ ] **Step 5: Verify SidebarTree still works** + +`SidebarTree` only calls `DocumentService::getDirectoryTree()`, and the Blade reads `$document->title` (accessor). Run a smoke test by booting up Tinker and confirming the tree: + +```bash +docker compose exec php php artisan tinker --execute="echo json_encode(array_keys(app(\\App\\Services\\DocumentService::class)->getDirectoryTree()));" +``` + +Expected: prints the top-level path entries (e.g., `["Home.md","Getting Started.md",...]` depending on seed state). No errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/Livewire/QuickSwitcher.php src/tests/Feature/DocumentI18nTest.php +git commit -m "Make QuickSwitcher search across all locales + +Delegates to DocumentService::search which queries DocumentTranslation +and collapses to distinct documents. Display titles use the Document +title accessor (current locale + fallback)." +``` + +--- + +## Task 12: DocumentSeeder cleanup, full-suite verification, manual check + +**Files:** +- Modify: `src/database/seeders/DocumentSeeder.php` + +- [ ] **Step 1: Update DocumentSeeder to use DocumentService** + +Replace the body of `run()` in `src/database/seeders/DocumentSeeder.php` (lines 13-55): + +```php + public function run(): void + { + if (\App\Models\Document::count() > 0) { + $this->command->info('Documents already exist. Skipping...'); + return; + } + + $service = app(\App\Services\DocumentService::class); + $defaultLocale = config('app.locale', 'en'); + + $docs = [ + ['title' => 'Home', 'content' => $this->getHomeContent()], + ['title' => 'Getting Started', 'content' => $this->getGettingStartedContent()], + ['title' => 'Markdown Guide', 'content' => $this->getMarkdownGuideContent()], + ]; + + foreach ($docs as $d) { + $service->createDocument($d['title'], $d['content'], null, $defaultLocale); + $this->command->info("Created: {$d['title']}"); + } + + $this->command->info('Initial documents created successfully!'); + } +``` + +(Leave the three `getXxxContent()` private methods untouched.) + +- [ ] **Step 2: Run the full test suite** + +```bash +docker compose exec php php artisan test +``` + +Expected: All green. If any pre-existing test fails (e.g. it referenced `Document::create([...title => ...])` directly), update that test to use the factory. Commit any such fixes as their own step. + +- [ ] **Step 3: Manual smoke test in the browser** + +```bash +docker compose exec php php artisan migrate:fresh --seed +``` + +Then in a browser: + +1. Visit `http://localhost:9700/` — should redirect to the Home document +2. Use the language switcher to change UI to `日本語` +3. Confirm Home shows the fallback banner ("選択した言語の翻訳がありません" or similar) +4. Click "翻訳を追加" → editor opens with `ja` tab active and empty fields +5. Enter title `ホーム` and some content → Save +6. Return to Home — banner should be gone, title shows `ホーム` +7. Switch UI back to English — title shows `Home`, content shows the original +8. In editor, switch to the `JA` tab — confirm Japanese content reloads +9. QuickSwitcher (Ctrl+K): search `ホーム` and `Home` — both should find the same document +10. Add a `[[Getting Started]]` link in EN content; render and confirm the anchor goes to `/documents/getting-started` + +Document any issues found and address before commit. + +- [ ] **Step 4: Final commit** + +```bash +git add src/database/seeders/DocumentSeeder.php +git commit -m "Update DocumentSeeder to use DocumentService::createDocument + +Removes direct Document::create() calls that referenced the dropped +title/content/rendered_html columns. Initial seed now creates the +default-locale translation through the service." +``` + +- [ ] **Step 5: Push the branch** + +```bash +git push -u origin feature/article-i18n +``` + +--- + +## Verification Summary + +When all tasks complete, you should have: + +1. New schema: `documents` (no title/content/rendered_html, +default_locale) + `document_translations` (id, document_id, locale, title, content, rendered_html, …) +2. New tests covering: migration, models, WikiLinkResolver, DocumentService, viewer i18n + fallback banner, editor tab flow, translation CRUD, QuickSwitcher cross-locale. +3. New routes: `documents.translations.{store,destroy,edit}`. +4. UI: fallback banner with "Add translation" CTA; editor with locale tab bar + new-locale dropdown + set-as-default + delete-translation buttons. +5. Backward compat: `Document::renderMarkdown()` still works (delegates), `Document::title`/`content`/`rendered_html` still readable as accessors. + +Run once more to be sure: +```bash +docker compose exec php php artisan test +```