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.
This commit is contained in:
Yutaka Kurosaki
2026-05-10 12:17:31 +09:00
parent b7a70f74e5
commit 0c399c9f0f
2 changed files with 155 additions and 228 deletions
+76 -228
View File
@@ -3,65 +3,25 @@
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\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
class Document extends Model
{
use HasFactory, SoftDeletes;
/**
* Get the route key for the model.
*
* @return string
*/
public function getRouteKeyName(): string
{
return 'slug';
}
/**
* Retrieve the model for a bound value.
* Supports both slug and ID for backwards compatibility.
*
* @param mixed $value
* @param string|null $field
* @return \Illuminate\Database\Eloquent\Model|null
*/
public function resolveRouteBinding($value, $field = null)
{
// First try to find by slug
$document = $this->where('slug', $value)->first();
// If not found by slug, try by ID (for backwards compatibility)
if (!$document && is_numeric($value)) {
$document = $this->where('id', $value)->first();
}
return $document;
}
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'path',
'title',
'slug',
'content',
'rendered_html',
'default_locale',
'frontmatter',
'file_size',
'file_hash',
@@ -70,11 +30,6 @@ public function resolveRouteBinding($value, $field = null)
'updated_by',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
@@ -83,192 +38,64 @@ protected function casts(): array
];
}
/**
* Frontmatterをパース(互換性のため残す)
*
* @param string $content
* @return array{frontmatter: array, content: string}
*/
protected static function parseFrontmatter(string $content): array
public function getRouteKeyName(): string
{
$frontmatter = [];
$bodyContent = $content;
return 'slug';
}
// Frontmatterの検出(--- で囲まれた部分)
if (preg_match('/^---\s*\n(.*?)\n---\s*\n(.*)/s', $content, $matches)) {
$frontmatterText = $matches[1];
$bodyContent = $matches[2];
public function resolveRouteBinding($value, $field = null)
{
$document = $this->where('slug', $value)->first();
// 簡易的なYAMLパース(key: value形式のみ)
$lines = explode("\n", $frontmatterText);
foreach ($lines as $line) {
if (preg_match('/^([^:]+):\s*(.*)$/', $line, $lineMatches)) {
$frontmatter[trim($lineMatches[1])] = trim($lineMatches[2]);
}
}
if (!$document && is_numeric($value)) {
$document = $this->where('id', $value)->first();
}
return [
'frontmatter' => $frontmatter,
'content' => trim($bodyContent),
];
return $document;
}
/**
* Markdownをレンダリング
*
* @param string $markdown
* @return string
* Backward-compatible static delegate so existing callers and tests
* (e.g. MediaEmbedExtensionTest) keep working.
*/
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();
return DocumentTranslation::renderMarkdown($markdown);
}
/**
* [[wiki-link]]を抽出してリンクテーブルに同期
*
* @return void
*/
public function syncLinks(): void
// ----- Relations -----
public function translations(): HasMany
{
// 既存のリンクを削除
$this->outgoingLinks()->delete();
// [[wiki-link]]を抽出
preg_match_all('/\[\[([^\]]+)\]\]/', $this->content, $matches);
if (empty($matches[1])) {
return;
}
$position = 0;
foreach ($matches[1] as $linkTitle) {
$linkTitle = trim($linkTitle);
// リンク先のドキュメントを検索
$targetDocument = static::where('title', $linkTitle)
->orWhere('slug', SlugHelper::generate($linkTitle))
->first();
DocumentLink::create([
'source_document_id' => $this->id,
'target_document_id' => $targetDocument?->id,
'target_title' => $linkTitle,
'position' => $position++,
]);
}
return $this->hasMany(DocumentTranslation::class);
}
/**
* [[wiki-link]]をHTMLリンクに変換
*
* @return string
*/
public function processLinks(): string
public function defaultTranslation(): HasOne
{
return preg_replace_callback(
'/\[\[([^\]]+)\]\]/',
function ($matches) {
$linkTitle = trim($matches[1]);
$slug = SlugHelper::generate($linkTitle);
// リンク先のドキュメントを検索
$targetDocument = static::where('title', $linkTitle)
->orWhere('slug', $slug)
->first();
if ($targetDocument) {
return '<a href="' . route('documents.show', $targetDocument->slug) . '" class="wiki-link">' . e($linkTitle) . '</a>';
} else {
return '<a href="' . route('documents.create') . '?title=' . urlencode($linkTitle) . '" class="wiki-link wiki-link-new">' . e($linkTitle) . '</a>';
}
},
$this->rendered_html
);
return $this->hasOne(DocumentTranslation::class)
->whereColumn('locale', 'documents.default_locale');
}
/**
* 全文検索スコープ
*
* @param Builder $query
* @param string $searchTerm
* @return Builder
*/
public function scopeSearch(Builder $query, string $searchTerm): Builder
{
return $query->whereRaw(
'MATCH(title, content) AGAINST(? IN BOOLEAN MODE)',
[$searchTerm]
);
}
/**
* ディレクトリ内検索スコープ
*
* @param Builder $query
* @param string $directory
* @return Builder
*/
public function scopeInDirectory(Builder $query, string $directory): Builder
{
$directory = rtrim($directory, '/') . '/';
return $query->where('path', 'like', $directory . '%');
}
/**
* 作成者リレーション
*
* @return BelongsTo
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 更新者リレーション
*
* @return BelongsTo
*/
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
/**
* 発リンク(このドキュメントから他へのリンク)
*
* @return HasMany
*/
public function outgoingLinks(): HasMany
{
return $this->hasMany(DocumentLink::class, 'source_document_id');
}
/**
* 被リンク(他のドキュメントからこのドキュメントへのリンク)
*
* @return HasMany
*/
public function incomingLinks(): HasMany
{
return $this->hasMany(DocumentLink::class, 'target_document_id');
}
/**
* このドキュメントを最近閲覧したユーザー
*
* @return HasManyThrough
*/
public function recentByUsers(): HasManyThrough
{
return $this->hasManyThrough(
@@ -281,57 +108,78 @@ public function recentByUsers(): HasManyThrough
);
}
/**
* Translations of this document, one per locale.
* (Other relation/accessor refactor lands in Task 4.)
*/
public function translations(): \Illuminate\Database\Eloquent\Relations\HasMany
// ----- Translation helpers -----
public function translationFor(string $locale, bool $fallback = true): ?DocumentTranslation
{
return $this->hasMany(DocumentTranslation::class);
$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 string
* @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);
}
/**
* ファイル名を取得
*
* @return string
*/
public function getFilenameAttribute(): string
{
return basename($this->path);
}
/**
* 絶対パスを取得
*
* @return string
*/
public function getAbsolutePathAttribute(): string
{
return Storage::disk('markdown')->path($this->path);
}
/**
* タイトルセット時にslugも自動生成
*
* @param string $value
* @return void
*/
public function setTitleAttribute(string $value): void
{
$this->attributes['title'] = $value;
// ----- Search scope (delegates to translations) -----
if (empty($this->attributes['slug'])) {
$this->attributes['slug'] = SlugHelper::generate($value);
}
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 . '%');
}
}
+79
View File
@@ -0,0 +1,79 @@
<?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);
}
}