From 6d71f5fecf8010100341be4e7c64de7ec357390b Mon Sep 17 00:00:00 2001 From: Yutaka Kurosaki <> Date: Sun, 10 May 2026 12:25:19 +0900 Subject: [PATCH] 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). --- src/app/Models/Document.php | 60 ++++++++++++++++++++++- src/tests/Unit/Models/DocumentTest.php | 66 ++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/app/Models/Document.php b/src/app/Models/Document.php index a9dae22..9e8bf94 100644 --- a/src/app/Models/Document.php +++ b/src/app/Models/Document.php @@ -184,10 +184,66 @@ public function scopeInDirectory(Builder $query, string $directory): Builder } /** - * Sync wiki-links for this document. Implementation in Task 7. + * Extract [[wiki-links]] from the default-locale translation's content + * and persist them via DocumentLink. */ public function syncLinks(): void { - // Replaced in Task 7 + $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 = 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 + ); } } diff --git a/src/tests/Unit/Models/DocumentTest.php b/src/tests/Unit/Models/DocumentTest.php index ba36287..72cbf74 100644 --- a/src/tests/Unit/Models/DocumentTest.php +++ b/src/tests/Unit/Models/DocumentTest.php @@ -76,4 +76,70 @@ public function test_available_locales_lists_existing_translations(): void sort($locales); $this->assertSame(['en', 'fr', 'ja'], $locales); } + + 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); + } }