The editor's delete-translation button used `__('messages.documents.delete_translation') ?? __('messages.documents.delete')`, but `__()` returns the key string (not null) on miss so the `??` fallback never fires — the button rendered the literal key. Adds the missing key to all 16 locales (en+ja human-translated, others mirror en) and simplifies the blade to a single `__()` call.
Plan doc also reflects the SQLite dropIndex requirement found during Task 2.
94 KiB
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. includesrc/). - Tests use
RefreshDatabasetrait, 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 migrationsrc/app/Models/DocumentTranslation.php— translation model withrenderMarkdown()src/database/factories/DocumentFactory.php— Document factory (creates a document + a single default-locale translation)src/database/factories/DocumentTranslationFactory.phpsrc/app/Services/WikiLinkResolver.php— 5-step deterministic resolversrc/app/Http/Controllers/DocumentTranslationController.php— store/destroysrc/tests/Unit/Models/DocumentTest.phpsrc/tests/Unit/Models/DocumentTranslationTest.phpsrc/tests/Unit/Services/WikiLinkResolverTest.phpsrc/tests/Unit/Services/DocumentServiceTest.phpsrc/tests/Feature/DocumentI18nTest.phpsrc/tests/Feature/DocumentTranslationCrudTest.phpsrc/tests/Feature/DocumentMigrationTest.php
Modified files
src/app/Models/Document.php— drop title/content/rendered_html columns, add accessors + relations +isFallbacksrc/app/Services/DocumentService.php— locale-aware methods, search via translation table,addTranslation,deleteTranslation,setDefaultLocalesrc/app/Livewire/DocumentViewer.php+src/resources/views/livewire/document-viewer.blade.php— fallback bannersrc/app/Livewire/DocumentEditor.php+src/resources/views/livewire/document-editor.blade.php— locale tabssrc/app/Livewire/QuickSwitcher.php— search via translations table, distinct documentssrc/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}constraintsrc/database/seeders/DocumentSeeder.php— create via DocumentService instead of Document::create directlysrc/lang/{en,ja,zh-CN,zh-TW,ko,hi,vi,tr,de,fr,es,pt-BR,ru,uk,it,pl}/messages.php— adddocuments.fallback_notice,documents.add_translation,documents.translation_tabs,documents.set_as_default,documents.delete_translation_blocked
Untouched
src/app/Models/DocumentLink.php—target_titleis locale-agnostic, no schema changesrc/app/Policies/DocumentPolicy.php—updatepermission 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— adduse HasFactory;if missing (currently onlyuse 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
HasFactorytrait to Document model
Edit src/app/Models/Document.php line 6 area:
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):
class Document extends Model
{
use HasFactory, SoftDeletes;
- Step 2: Create DocumentFactory
Create src/database/factories/DocumentFactory.php:
<?php
namespace Database\Factories;
use App\Helpers\SlugHelper;
use App\Models\Document;
use App\Models\DocumentTranslation;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Document>
*/
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
namespace Database\Factories;
use App\Models\Document;
use App\Models\DocumentTranslation;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<DocumentTranslation>
*/
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' => '<p>' . e($content) . '</p>',
];
}
}
- Step 4: Commit
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
namespace Tests\Feature;
use Illuminate\Database\Migrations\Migrator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class DocumentMigrationTest extends TestCase
{
use RefreshDatabase;
public function test_documents_table_has_default_locale_after_migration(): void
{
$this->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' => '<p>...</p>',
'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' => '<p>...</p>',
'created_at' => now(),
'updated_at' => now(),
]);
}
}
- Step 2: Run the test to verify it fails
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
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// 1. Add default_locale to documents
Schema::table('documents', function (Blueprint $table) {
$table->string('default_locale', 10)
->default(config('app.locale', 'en'))
->after('slug');
});
// 2. Create document_translations
Schema::create('document_translations', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained('documents')->cascadeOnDelete();
$table->string('locale', 10);
$table->string('title');
$table->text('content');
$table->text('rendered_html')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['document_id', 'locale']);
$table->index(['locale', 'title']);
});
if (DB::connection()->getDriverName() === 'mysql') {
DB::statement('ALTER TABLE document_translations ADD FULLTEXT INDEX document_translations_search_index (title, content) WITH PARSER ngram');
}
// 3. Migrate existing data
$defaultLocale = config('app.locale', 'en');
$now = now();
$rows = DB::table('documents')->get();
foreach ($rows as $row) {
DB::table('document_translations')->insert([
'document_id' => $row->id,
'locale' => $defaultLocale,
'title' => $row->title ?? '',
'content' => $row->content ?? '',
'rendered_html' => $row->rendered_html,
'created_by' => $row->created_by ?? null,
'updated_by' => $row->updated_by ?? null,
'created_at' => $row->created_at ?? $now,
'updated_at' => $row->updated_at ?? $now,
]);
DB::table('documents')->where('id', $row->id)->update(['default_locale' => $defaultLocale]);
}
// 4. Drop the old FULLTEXT index on documents (MySQL only)
if (DB::connection()->getDriverName() === 'mysql') {
DB::statement('ALTER TABLE documents DROP INDEX documents_search_index');
}
// 5. Drop translatable columns from documents.
// SQLite requires explicit dropIndex on the title index before dropColumn.
Schema::table('documents', function (Blueprint $table) {
$table->dropIndex(['title']);
$table->dropColumn(['title', 'content', 'rendered_html']);
});
}
public function down(): void
{
// Re-add columns
Schema::table('documents', function (Blueprint $table) {
$table->string('title')->nullable()->after('default_locale');
$table->text('content')->nullable()->after('title');
$table->text('rendered_html')->nullable()->after('content');
});
// Restore data from default_locale translation
$rows = DB::table('document_translations as t')
->join('documents as d', 'd.id', '=', 't.document_id')
->whereColumn('t.locale', 'd.default_locale')
->select('t.document_id', 't.title', 't.content', 't.rendered_html')
->get();
foreach ($rows as $row) {
DB::table('documents')->where('id', $row->document_id)->update([
'title' => $row->title,
'content' => $row->content,
'rendered_html' => $row->rendered_html,
]);
}
// Restore FULLTEXT on documents (MySQL only)
if (DB::connection()->getDriverName() === 'mysql') {
DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram');
}
Schema::dropIfExists('document_translations');
Schema::table('documents', function (Blueprint $table) {
$table->dropColumn('default_locale');
});
}
};
- Step 4: Run the test to verify it passes
docker compose exec php php artisan test --filter=DocumentMigrationTest
Expected: PASS (3 tests, all green).
- Step 5: Commit
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
namespace Tests\Unit\Models;
use App\Models\Document;
use App\Models\DocumentTranslation;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DocumentTranslationTest extends TestCase
{
use RefreshDatabase;
public function test_belongs_to_a_document(): void
{
$doc = Document::factory()->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' => '<p>x</p>',
]);
}
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('<h1>', $html);
$this->assertStringContainsString('Hello', $html);
}
}
- Step 2: Run the test to verify it fails
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
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
class DocumentTranslation extends Model
{
use HasFactory;
protected $fillable = [
'document_id',
'locale',
'title',
'content',
'rendered_html',
'created_by',
'updated_by',
];
public function document(): BelongsTo
{
return $this->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
docker compose exec php php artisan test --filter=DocumentTranslationTest
Expected: PASS (4 tests).
- Step 5: Commit
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
namespace Tests\Unit\Models;
use App\Models\Document;
use App\Models\DocumentTranslation;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Tests\TestCase;
class DocumentTest extends TestCase
{
use RefreshDatabase;
public function test_title_accessor_returns_current_locale_translation(): void
{
$doc = Document::factory()->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' => '<p>English body</p>',
]);
App::setLocale('ja');
$fresh = $doc->fresh();
$this->assertSame('English body', $fresh->content);
$this->assertSame('<p>English body</p>', $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
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
namespace App\Models;
use App\Helpers\SlugHelper;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Storage;
class Document extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'path',
'slug',
'default_locale',
'frontmatter',
'file_size',
'file_hash',
'file_modified_at',
'created_by',
'updated_by',
];
protected function casts(): array
{
return [
'frontmatter' => '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<int, string>
*/
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
docker compose exec php php artisan test --filter=DocumentTest
Expected: PASS (6 tests).
- Step 5: Run the existing media test to ensure backward compatibility
docker compose exec php php artisan test --filter=MediaEmbedExtensionTest
Expected: PASS — Document::renderMarkdown() delegate still works.
- Step 6: Commit
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
namespace Tests\Unit\Services;
use App\Models\Document;
use App\Models\DocumentTranslation;
use App\Services\WikiLinkResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WikiLinkResolverTest extends TestCase
{
use RefreshDatabase;
private WikiLinkResolver $resolver;
protected function setUp(): void
{
parent::setUp();
$this->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
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
namespace App\Services;
use App\Helpers\SlugHelper;
use App\Models\Document;
use App\Models\DocumentTranslation;
class WikiLinkResolver
{
/**
* Resolve [[wiki-link]] text to a Document, preferring the current locale.
*
* Resolution order:
* 1. translations WHERE locale = $currentLocale AND title = $linkText
* 2. translations WHERE locale = document.default_locale AND title = $linkText
* 3. translations WHERE title = $linkText (lowest document_id wins)
* 4. documents WHERE slug = SlugHelper::generate($linkText)
* 5. null
*/
public function resolve(string $linkText, string $currentLocale): ?Document
{
$linkText = trim($linkText);
// 1. Current-locale exact title match
$byCurrent = DocumentTranslation::where('locale', $currentLocale)
->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
docker compose exec php php artisan test --filter=WikiLinkResolverTest
Expected: PASS (6 tests).
- Step 5: Commit
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
namespace Tests\Unit\Services;
use App\Models\Document;
use App\Models\DocumentTranslation;
use App\Models\User;
use App\Services\DocumentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Tests\TestCase;
class DocumentServiceTest extends TestCase
{
use RefreshDatabase;
private DocumentService $service;
protected function setUp(): void
{
parent::setUp();
$this->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('<h1>', $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
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
namespace App\Services;
use App\Helpers\SlugHelper;
use App\Models\Document;
use App\Models\DocumentTranslation;
use App\Models\RecentDocument;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
class DocumentService
{
public function createDocument(
string $title,
string $content,
?int $userId = null,
?string $locale = null,
): Document {
$locale = $locale ?: App::getLocale();
[$path, $slug] = $this->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:
/**
* 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
docker compose exec php php artisan test --filter=DocumentServiceTest
Expected: PASS (10 tests).
- Step 6: Commit
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 stubsyncLinks()with real implementation, addprocessLinks() -
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 }):
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' => '<p>See [[Target]].</p>',
]);
$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' => '<p>Click [[Ghost]].</p>',
]);
$html = $source->fresh()->processLinks();
$this->assertStringContainsString('wiki-link-new', $html);
}
- Step 2: Run the tests to verify they fail
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():
/**
* 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 '<a href="' . route('documents.show', $target->slug) . '" class="wiki-link">' . e($linkText) . '</a>';
}
return '<a href="' . route('documents.create') . '?title=' . urlencode($linkText) . '" class="wiki-link wiki-link-new">' . e($linkText) . '</a>';
},
$html
);
}
- Step 4: Run the tests to verify they pass
docker compose exec php php artisan test --filter=DocumentTest
Expected: PASS (10 tests now).
- Step 5: Commit
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
namespace Tests\Feature;
use App\Models\Document;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DocumentTranslationCrudTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_add_a_translation(): 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' => '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
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
namespace App\Http\Controllers;
use App\Http\Middleware\SetLocale;
use App\Models\Document;
use App\Services\DocumentService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class DocumentTranslationController extends Controller
{
public function __construct(private DocumentService $service) {}
public function store(Request $request, Document $document)
{
$this->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:
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
docker compose exec php php artisan test --filter=DocumentTranslationCrudTest
Expected: PASS (6 tests).
- Step 6: Commit
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
namespace Tests\Feature;
use App\Models\Document;
use App\Models\DocumentTranslation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Tests\TestCase;
class DocumentI18nTest extends TestCase
{
use RefreshDatabase;
public function test_viewer_shows_current_locale_translation(): void
{
$doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'hello']);
$doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'Hi', 'rendered_html' => '<p>Hi</p>']);
DocumentTranslation::factory()->create([
'document_id' => $doc->id,
'locale' => 'ja',
'title' => 'こんにちは',
'content' => 'やあ',
'rendered_html' => '<p>やあ</p>',
]);
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' => '<p>Hi</p>']);
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' => '<p>Hi</p>']);
DocumentTranslation::factory()->create([
'document_id' => $doc->id,
'locale' => 'ja',
'title' => 'こんにちは',
'content' => 'やあ',
'rendered_html' => '<p>やあ</p>',
]);
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
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
namespace App\Livewire;
use App\Models\Document;
use App\Services\DocumentService;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class DocumentViewer extends Component
{
public Document $document;
public $backlinks = [];
public string $renderedContent = '';
public string $viewLocale = '';
public bool $isFallback = false;
public function mount(Document $document, DocumentService $documentService)
{
$this->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 <div class="max-w-4xl mx-auto p-4 sm:p-6 lg:p-8"> and before the <!-- Document Header --> block, insert:
@if($isFallback)
<div class="mb-4 p-4 bg-amber-50 border border-amber-300 rounded-md flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<p class="text-sm text-amber-800">
{{ __('messages.documents.fallback_notice', ['locale' => __('messages.locale_names.' . $viewLocale)]) }}
</p>
@auth
@can('update', $document)
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => app()->getLocale()]) }}"
class="inline-flex items-center justify-center px-3 py-2 bg-amber-600 text-white text-sm font-medium rounded-md hover:bg-amber-700">
{{ __('messages.documents.add_translation') }}
</a>
@endcan
@endauth
</div>
@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:
'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 ];:
'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:
'fallback_notice' => 'この記事には選択した言語の翻訳がありません。:locale 版を表示しています。',
'add_translation' => '翻訳を追加',
'translation_added' => '翻訳を追加しました。',
'translation_deleted' => '翻訳を削除しました。',
'set_as_default' => 'デフォルトに設定',
'delete_translation_blocked' => 'デフォルト言語の翻訳は削除できません。',
'translation_tabs_label' => '言語',
And locale_names (Japanese):
'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
docker compose exec php php artisan test --filter=DocumentI18nTest
Expected: PASS (3 tests).
- Step 7: Commit
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— confirmdocuments.translations.editroute 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):
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
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
namespace App\Livewire;
use App\Http\Middleware\SetLocale;
use App\Models\Document;
use App\Services\DocumentService;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class DocumentEditor extends Component
{
public ?Document $document = null;
public string $title = '';
public string $content = '';
public string $editingLocale = '';
public bool $isEditMode = false;
public bool $isNewLocale = false;
public array $availableLocales = [];
public function mount(?Document $document = null, ?string $locale = null)
{
if ($document) {
$this->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 </div> of the existing header block (the <div class="mb-6 flex flex-col sm:flex-row ..."> block, around line 57), insert the tab bar:
@if($isEditMode && $document)
<div class="mb-4 border-b border-gray-200" role="tablist" aria-label="{{ __('messages.documents.translation_tabs_label') }}">
<nav class="-mb-px flex flex-wrap gap-x-2">
@php $allLocales = \App\Http\Middleware\SetLocale::SUPPORTED_LOCALES; @endphp
@foreach($availableLocales as $loc)
@php $isActive = ($loc === $editingLocale); @endphp
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => $loc]) }}"
class="px-3 py-2 text-sm font-medium border-b-2 {{ $isActive ? 'border-indigo-500 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
{{ $allLocales[$loc] ?? $loc }}
@if($loc === $document->default_locale)
<span class="ml-1 text-xs text-gray-400">★</span>
@endif
</a>
@endforeach
@php $missingLocales = array_diff(array_keys($allLocales), $availableLocales); @endphp
@if(!empty($missingLocales))
<div x-data="{ open: false }" class="relative">
<button type="button" @click="open = !open"
class="px-3 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
+ {{ __('messages.documents.add_translation') }}
</button>
<div x-show="open" @click.outside="open = false" x-cloak
class="absolute right-0 z-10 mt-2 w-48 max-h-64 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
@foreach($missingLocales as $loc)
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => $loc]) }}"
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
{{ $allLocales[$loc] }}
</a>
@endforeach
</div>
</div>
@endif
</nav>
@if($editingLocale !== $document->default_locale && !$isNewLocale)
<div class="mt-2 flex gap-2">
<button wire:click="setAsDefault" type="button"
class="px-2 py-1 text-xs text-indigo-600 border border-indigo-300 rounded hover:bg-indigo-50">
{{ __('messages.documents.set_as_default') }}
</button>
<button wire:click="deleteTranslation"
wire:confirm="{{ __('messages.documents.delete_confirm') }}"
type="button"
class="px-2 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50">
{{ __('messages.documents.delete_translation') ?? __('messages.documents.delete') }}
</button>
</div>
@endif
</div>
@endif
- Step 5: Run the tests to verify they pass
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
docker compose exec php php artisan test
Expected: All green.
- Step 7: Commit
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->titleaccessor) -
Step 1: Write failing test for QuickSwitcher cross-locale search
Append to src/tests/Feature/DocumentI18nTest.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
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
namespace App\Livewire;
use App\Models\Document;
use App\Services\DocumentService;
use Livewire\Attributes\Computed;
use Livewire\Component;
class QuickSwitcher extends Component
{
public string $search = '';
public int $selectedIndex = 0;
#[Computed]
public function results()
{
if (empty($this->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
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:
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
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):
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
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
docker compose exec php php artisan migrate:fresh --seed
Then in a browser:
- Visit
http://localhost:9700/— should redirect to the Home document - Use the language switcher to change UI to
日本語 - Confirm Home shows the fallback banner ("選択した言語の翻訳がありません" or similar)
- Click "翻訳を追加" → editor opens with
jatab active and empty fields - Enter title
ホームand some content → Save - Return to Home — banner should be gone, title shows
ホーム - Switch UI back to English — title shows
Home, content shows the original - In editor, switch to the
JAtab — confirm Japanese content reloads - QuickSwitcher (Ctrl+K): search
ホームandHome— both should find the same document - 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
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
git push -u origin feature/article-i18n
Verification Summary
When all tasks complete, you should have:
- New schema:
documents(no title/content/rendered_html, +default_locale) +document_translations(id, document_id, locale, title, content, rendered_html, …) - New tests covering: migration, models, WikiLinkResolver, DocumentService, viewer i18n + fallback banner, editor tab flow, translation CRUD, QuickSwitcher cross-locale.
- New routes:
documents.translations.{store,destroy,edit}. - UI: fallback banner with "Add translation" CTA; editor with locale tab bar + new-locale dropdown + set-as-default + delete-translation buttons.
- Backward compat:
Document::renderMarkdown()still works (delegates),Document::title/content/rendered_htmlstill readable as accessors.
Run once more to be sure:
docker compose exec php php artisan test