22 Commits

Author SHA1 Message Date
Yutaka Kurosaki 5c338e3ae5 Native locale_names for 14 locales + drop forced-en in editor tabs
Translates the 16-language locale_names block in zh-CN, zh-TW, ko, hi,
vi, tr, de, fr, es, pt-BR, ru, uk, it, pl to the target locale's own
language (e.g. de file: 'en' => 'Englisch', 'ja' => 'Japanisch').

DocumentEditor blade no longer hardcodes 'en' as the locale_names
lookup — falls back to the current UI locale. Test still passes
because tests run with default app locale 'en' and the en file
maps to "Japanese" / "English" etc.

A user editing in ja now sees [English] [日本語 ★] tabs instead of
[English] [Japanese ★].
2026-05-10 13:13:25 +09:00
Yutaka Kurosaki b90e3534ce Native UI translations for translation/edit-flow keys (14 locales)
Translates 7 newly-added documents.* keys (translation_added,
translation_deleted, add_translation, set_as_default, delete_translation,
delete_translation_blocked, translation_tabs_label) from English mirror
to native equivalents for zh-CN, zh-TW, ko, hi, vi, tr, de, fr, es,
pt-BR, ru, uk, it, pl. en/ja already had natives.

locale_names labels still mirror English for those 14 — separate follow-up
since the editor tab labels currently force English lookup for test reasons.
2026-05-10 13:10:03 +09:00
Yutaka Kurosaki 85a3a5a422 Translate fallback_notice to native for 14 remaining locales
Replaces the English mirror with native-language equivalents of the
ja string ("この記事には選択した言語の翻訳がありません。元の言語版を表示しています。")
for zh-CN, zh-TW, ko, hi, vi, tr, de, fr, es, pt-BR, ru, uk, it, pl.

Matches the no-:locale-placeholder style already used in ja so the
banner reads naturally in each UI language. en still uses the
parameterized ":locale" version since it's the master template.
2026-05-10 13:06:59 +09:00
Yutaka Kurosaki 1ce1fa23a4 Add documents.delete_translation lang key for editor button
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.
2026-05-10 12:50:57 +09:00
Yutaka Kurosaki 0c13ad1e64 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.
2026-05-10 12:45:06 +09:00
Yutaka Kurosaki c9586612f5 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).
2026-05-10 12:43:01 +09:00
Yutaka Kurosaki 0100a0afb4 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.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:40:54 +09:00
Yutaka Kurosaki 97171960bd 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).
2026-05-10 12:36:25 +09:00
Yutaka Kurosaki 187349521d Add translation CRUD routes and controller
POST/DELETE for translations gated by can:update,document middleware.
Locale validated against SUPPORTED_LOCALES. Default-locale deletion
returns 422; duplicate-locale add returns 422. Flash messages added
to en/ja lang files (other locales updated in Task 9).
2026-05-10 12:28:25 +09:00
Yutaka Kurosaki 6d71f5fecf 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).
2026-05-10 12:25:19 +09:00
Yutaka Kurosaki 7909c33074 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.
2026-05-10 12:23:14 +09:00
Yutaka Kurosaki d7522f592d Add WikiLinkResolver with deterministic 5-step resolution
Prefers current locale, then document default_locale, then any locale
(lowest document_id), then slug match (legacy).
2026-05-10 12:19:57 +09:00
Yutaka Kurosaki 0c399c9f0f 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.
2026-05-10 12:17:31 +09:00
Yutaka Kurosaki b7a70f74e5 Add DocumentTranslation model with renderMarkdown and search scope
Includes a minimal Document::translations() HasMany relation so that
DocumentFactory's afterCreating callback (which calls
$document->translations()->count()) works. The full Document model
refactor (accessors, fallback helpers, default-translation accessor)
lands in Task 4.
2026-05-10 12:14:24 +09:00
Yutaka Kurosaki 4a8622c385 Harden migration: transaction, chunking, lossy-down doc, data-preservation test
- Wrap the data copy in DB::transaction (FULLTEXT ALTER stays outside)
- Switch to chunkById(500) so the migration scales
- Document down() as irreversible for non-default-locale translations
- Add test_existing_documents_data_is_copied_to_translations to cover
  the data copy itself (the only previously-untested behavior)
- Drop unused Migrator import in DocumentMigrationTest
- Also restore title index in down() so up() can be re-run cleanly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:11:31 +09:00
Yutaka Kurosaki f2bdb6a069 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.

Also guard the original FULLTEXT ALTER TABLE with a MySQL driver check
so that the SQLite test environment can run all migrations cleanly.
2026-05-10 12:04:05 +09:00
Yutaka Kurosaki e83bd6981d Fix DocumentFactory withoutTranslations + path trailing period
afterCreating appends rather than replaces, so a no-op closure does not
override configure(). Use withoutAfterCreating() to actually clear the
translation-creation callback (otherwise DocumentTranslation::factory()
recurses through Document::factory()->withoutTranslations()).

Also use words() instead of sentence() to avoid Faker's trailing period
producing paths like "Foo bar..md".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:58:41 +09:00
Yutaka Kurosaki 7f2f8a2248 Add Document and DocumentTranslation factories 2026-05-10 11:53:33 +09:00
Yutaka Kurosaki ab846b71b2 Add implementation plan for article-level i18n
12 TDD tasks: factories, migration with data move, DocumentTranslation
model, Document refactor with locale-aware accessors, WikiLinkResolver,
DocumentService rewrite, syncLinks/processLinks via resolver, translation
CRUD routes/controller, viewer fallback banner, editor locale tabs,
QuickSwitcher cross-locale search, and seeder cleanup. Each task includes
exact file paths, failing tests, minimal implementation, and a commit step.
2026-05-10 10:10:42 +09:00
Yutaka Kurosaki 01fb8b9fcf Add design spec for article-level i18n with default-locale fallback
Documents the data model (1 doc + document_translations table), service
layer changes, routing/editor UX, wiki-link resolution order, search,
and migration plan. Article URLs stay locale-independent; display falls
back to each document's default_locale when the requested locale lacks
a translation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:46:29 +09:00
Yutaka Kurosaki ba25b544f5 Add implementation plan for admin media manager
Eleven TDD tasks from migration through editor integration: model,
service (store/rename/delete + auto-suffix), admin CRUD controller,
real Blade UI, auth-gated download, listener Phase-1 logical-path
rewrite for Image+Link nodes, and ImageUploadController rewiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:54:54 +09:00
Yutaka Kurosaki 3c185fac37 Add design spec for admin media manager
Defines logical/physical filename split, MD reference resolution policy,
component structure, data flow, and test coverage for the new
upload/rename/download/delete flows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:42:18 +09:00
43 changed files with 7170 additions and 516 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,192 @@
# Media Manager — Design Spec
Date: 2026-05-09
## Goal
Add a simple media manager to the admin panel so users can upload, rename, download, and delete arbitrary files (images, video, audio, PDF, ZIP) and reference them from Markdown documents by a stable logical filename or path.
## Non-Goals
- Folder management UI (explicit folder create/move/rename). Folders are expressed implicitly via slashes in the logical path.
- Versioning or revision history of media files.
- Automatic rewriting of existing Markdown when a media file is renamed or deleted (broken-link warning is left to manual operation).
- Migration of existing `storage/app/public/images/` files into the new media table.
## Core Design
### Logical / Physical Separation
- **Logical path** (DB-managed): user-facing identifier used in Markdown, e.g., `report.png` or `2026/spec.pdf`. UNIQUE.
- **Physical path** (filesystem): `media/{uuid}.{ext}` under the `public` disk. Filename is a UUID; extension preserved from the original upload.
- Renaming changes only the logical path; the physical file is never moved.
- Deletion removes the DB row and the physical file.
### Markdown Reference Resolution
- Markdown reference syntax stays standard: `![alt](report.png)` or `![alt](2026/spec.pdf)`.
- Resolution policy: **exact match only** on `logical_path`. No basename fallback, no fuzzy matching.
- Only schema-less, non-absolute paths are looked up. `https://…`, `http://…`, and paths starting with `/` are passed through to the existing pipeline (YouTube/Vimeo/video/audio handlers).
### Editor Drag-and-Drop Integration
- The existing `POST /images/upload` endpoint (used by EasyMDE) is rerouted through the same `MediaService`.
- The endpoint returns the **logical path** (e.g., `report.png`) as `data.filePath`, so the inserted Markdown becomes `![alt](report.png)` rather than an absolute UUID URL.
- Logical-path collisions during editor uploads are auto-resolved server-side by appending `-2`, `-3`, … to the basename (max 100 attempts before erroring). Collisions in the manual admin upload form are surfaced as validation errors instead.
## Data Model
### Table `media_files`
| Column | Type | Notes |
|--|--|--|
| `id` | bigint, pk | |
| `logical_path` | varchar(512), UNIQUE | Validated: no leading/trailing `/`, no `..`, no empty segments, no consecutive `/`, non-empty |
| `physical_path` | varchar(255) | `media/{uuid}.{ext}` (relative to the public disk) |
| `original_name` | varchar(255) | Original filename at upload time. Used as the Content-Disposition filename on download |
| `mime_type` | varchar(127) | Detected at upload |
| `size` | unsigned bigint | Bytes |
| `uploaded_by` | foreign key → `users.id`, nullable | ON DELETE SET NULL |
| `created_at` / `updated_at` | timestamp | |
Indexes: `UNIQUE(logical_path)`, plain index on `uploaded_by`.
### Eloquent Model `App\Models\MediaFile`
- `publicUrl()`: returns `Storage::disk('public')->url($this->physical_path)`.
- Override `delete()` to remove the physical file before deleting the DB row. If the physical delete fails, the DB row is **kept** and the failure is logged so it can be retried.
## Components
### Backend
| Role | File | Responsibility |
|--|--|--|
| Routes | `routes/web.php` (additions) | Admin resource routes for media; standalone `auth`-only download route |
| Admin controller | `app/Http/Controllers/Admin/MediaController.php` | index / store / edit / update / destroy. Inline `validate()`; no FormRequest |
| Service | `app/Services/MediaService.php` | `store(UploadedFile, ?string $logicalPath, bool $autoSuffixOnConflict = false)`, `rename(MediaFile, string $newLogicalPath)`, `delete(MediaFile)`. Owns logical-path normalization, collision detection, and physical I/O |
| Listener update | `app/Markdown/MediaEmbedListener.php` | Add a Phase-1 logical-path rewrite that walks `Image` and `Link` nodes and replaces matching `logical_path` URLs with the physical public URL before the existing video/audio/YouTube/Vimeo detection runs |
| Editor integration | `app/Http/Controllers/ImageUploadController.php` | Rewritten to call `MediaService::store(..., autoSuffixOnConflict: true)` and return `data.filePath = <logical_path>` |
### Frontend
| View | Content |
|--|--|
| `resources/views/admin/media/index.blade.php` | Search box, inline upload form, paginated table (logical path / type icon or thumbnail / size / updated_at / actions). Actions: rename, download, delete |
| `resources/views/admin/media/edit.blade.php` | Rename-only form (edits `logical_path`) |
Navigation entry added to `resources/views/layouts/navigation.blade.php` under the admin dropdown.
### Routing
```php
Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () {
Route::resource('users', AdminUserController::class)->except(['show']);
Route::resource('media', AdminMediaController::class)->except(['show', 'create']);
});
Route::middleware('auth')->group(function () {
// existing routes...
Route::get('/media/{media}/download', [AdminMediaController::class, 'download'])->name('media.download');
});
```
Rationale: CRUD is admin-only; download is reachable by any authenticated user (so document readers can grab attached PDFs/ZIPs). The physical file under `/storage/media/{uuid}.ext` is already publicly readable via the public disk — the `download` route only adds the `original_name` Content-Disposition.
## Data Flow
### Upload (admin form)
1. `POST /admin/media` (multipart) → `MediaController::store`
2. Validate: `file` required; allowed MIME (image jpeg/png/gif/webp/svg, video mp4/webm, audio mp3/m4a/ogg/wav, application/pdf, application/zip); `max:102400` (100 MB); `logical_path` optional.
3. `MediaService::store($file, $logicalPath, autoSuffixOnConflict: false)`:
- Normalize logical path (trim, reject `..`, reject leading/trailing `/`, reject empty/consecutive segments, default to `original_name` if blank).
- Check `logical_path` UNIQUE; on conflict throw a validation exception.
- Save physical file to `media/{uuid}.{ext}` via `Storage::disk('public')->putFileAs`.
- Create the `media_files` row with `uploaded_by = auth()->id()`.
4. Redirect to `admin.media.index` with a success flash.
### Rename
1. `PUT /admin/media/{media}``MediaController::update`
2. Validate the new `logical_path` (same normalization + UNIQUE excluding self).
3. Update the DB row only. Physical file is untouched.
4. **Side effect**: any existing Markdown referring to the old logical path becomes a broken link. No automatic rewrite in this iteration.
### Download
1. `GET /media/{media}/download``MediaController::download` (auth-only).
2. Returns `Storage::disk('public')->download($physical_path, $original_name)`.
### Delete
1. `DELETE /admin/media/{media}``MediaController::destroy`
2. `MediaService::delete($media)`: try to delete the physical file; on success delete the DB row. On physical-delete failure, log it and surface a warning flash; the DB row is preserved so the operation can be retried.
3. Existing Markdown references to the deleted file become broken links. No warning surfaced in this iteration.
### Markdown Render-Time Resolution
The `MediaEmbedListener` walks both `Image` and `Link` nodes during `DocumentParsedEvent` in two ordered phases:
**Phase 1 — Logical-path rewrite** (new):
For each `Image` or `Link` node, take its URL.
- If the URL has a scheme (`http(s)://`, etc.) or starts with `/`, skip it.
- Otherwise look up `MediaFile::where('logical_path', $url)->first()`.
- On hit, replace the node's URL with `$media->publicUrl()` (e.g., `/storage/media/{uuid}.png`). The node type is unchanged.
**Phase 2 — Embed detection** (existing, unchanged):
The existing `MediaEmbedListener` logic runs against the (possibly rewritten) URL. `Image` nodes whose URL ends in a video/audio extension or matches YouTube/Vimeo get replaced with a `MediaEmbedNode` rendered as `<video>` / `<audio>` / iframe. Everything else falls through to CommonMark's default rendering.
Net behavior:
- `![alt](report.png)` → image extension, no embed rewrite, `<img src="/storage/media/{uuid}.png" alt="alt">`.
- `![](demo.mp4)` → URL rewritten to physical, then video extension detected, replaced with `<video>` tag.
- `[manual](manual.pdf)` → Link node, URL rewritten to physical, CommonMark renders `<a href="/storage/media/{uuid}.pdf">manual</a>`.
- `![](https://example.com/foo.png)` → has scheme, Phase 1 skips; Phase 2 also has nothing to do; CommonMark renders default `<img>`.
`MediaUrlResolver` itself stays focused on URL→embed-HTML mapping for video/audio/YouTube/Vimeo (i.e., its public surface does not change). The logical-path rewrite is a separate concern handled in the listener.
### Editor Drag-and-Drop
1. EasyMDE → `POST /images/upload` (existing endpoint).
2. `ImageUploadController` calls `MediaService::store($file, null, autoSuffixOnConflict: true)`.
3. Response: `{ data: { filePath: <logical_path>, altText: <basename without ext> } }`.
4. EasyMDE inserts `![<basename>](<logical_path>)` into the Markdown.
## Validation & Error Handling
| Case | Behavior |
|--|--|
| Invalid logical path (`..`, leading/trailing `/`, empty/consecutive segments, > 512 chars, empty after trim) | Validation error |
| Logical-path collision (admin form) | Validation error: "同じパスのファイルが既に存在します" |
| Logical-path collision (editor D&D) | Auto-suffix `-2`, `-3`, … up to 100 attempts; on exhaustion return a 422 |
| Disallowed MIME / extension | Validation error |
| File over 100 MB | Validation error. `php.ini` `upload_max_filesize` / `post_max_size` must be ≥ 100 MB; document this as an environment requirement |
| Physical-delete failure | Log + warning flash; DB row kept |
| Deleted/renamed media still referenced in MD | No warning; the resolver misses, the link renders as a broken link |
| Markdown URL is absolute (`http(s)://`, leading `/`) | Resolver passes through to existing video/audio/YouTube/Vimeo logic |
| Authorization | All CRUD under `auth + admin` middleware; download under `auth` only |
## Testing
PHPUnit feature tests using a fake `public` disk so real file I/O is exercised in temp storage.
| Test | Coverage |
|--|--|
| `MediaServiceTest::store` | with explicit logical path, with default (original name), conflict rejection, invalid-path rejection, `uploaded_by` recorded |
| `MediaServiceTest::rename` | DB updated, physical file unchanged, conflict rejection |
| `MediaServiceTest::delete` | physical + DB removed; on physical-fail DB row kept |
| `MediaServiceTest::auto_suffix` | editor path: `report.png` collision → `report-2.png` |
| `Admin\MediaControllerTest` | non-admin → 403 on index/store/update/destroy |
| `MediaDownloadTest` | unauth → redirect/401, auth → file with `original_name` |
| `MediaEmbedListenerTest` (extended) | logical-path hit rewrites Image/Link URL to physical; miss leaves URL untouched; absolute URL (with scheme or leading `/`) skipped |
| `DocumentRenderIntegrationTest` | `![](report.png)` rendered to `<img src="/storage/media/{uuid}.png">` after a `MediaFile` row exists |
Coverage target: the rows above. Not aiming for 100%.
## Out-of-Scope (explicit)
- Bulk operations (multi-select delete, bulk rename).
- Quota/usage tracking per user.
- CDN / signed URLs.
- Migration of existing `images/` content into `media_files`.
- Rewriting existing Markdown when a logical path is renamed or deleted.
@@ -0,0 +1,275 @@
# 記事の多言語対応 + デフォルト言語フォールバック
- **Date:** 2026-05-10
- **Branch:** `feature/article-i18n``main` から分岐)
- **Status:** Design approved, ready for implementation plan
## 背景
UI言語切り替えは16言語対応済み(`SetLocale` ミドルウェア + `users.locale` カラム + セッション/Cookie)。
一方、記事本体(`documents` テーブル)は単一言語のまま:1ドキュメント = 1行で `title`/`content`/`rendered_html` を直接保持し、locale非対応。
本設計は、記事自体を多言語化し、ユーザーのUI言語に応じた翻訳を提示する。翻訳が無ければ「その記事の原語版」へフォールバックする。
## 決定事項サマリ
| 論点 | 決定 |
|---|---|
| 翻訳のデータモデル | 1記事 + `document_translations` 子テーブル |
| デフォルト言語 | 記事ごとに `documents.default_locale` を持つ |
| URL | デフォルト言語のslug固定(言語非依存) |
| 表示title | 現在locale → default_locale でフォールバック |
| 編集UX | 1記事の編集画面に言語タブ |
| `[[wiki-link]]` 解決 | 全言語のtitleを対象、現在locale優先の決定論的順序 |
| 検索 | 全言語のtitle/contentを対象、表示titleは現在locale |
| マイグレーション | 既存記事は `config('app.locale')``default_locale` として移行 |
| フォールバック表示 | 上部バナー + 「翻訳を追加」ボタン |
## Section 1: データモデル
### `documents` テーブル(変更)
| 操作 | カラム | 備考 |
|---|---|---|
| 追加 | `default_locale` VARCHAR(10) NOT NULL | フォールバック先となる原語 |
| 削除 | `title` | `document_translations` へ移管 |
| 削除 | `content` | 同上 |
| 削除 | `rendered_html` | 同上 |
| 維持 | `path`, `slug`, `frontmatter`, `file_size`, `file_hash`, `file_modified_at`, `created_by`, `updated_by`, timestamps, `softDeletes` | |
`path`/`slug` は引き続き「default_locale のtitle」から生成。タイトル変更で再生成される既存ロジックも default_locale のtitleに連動。
### `document_translations` テーブル(新規)
```
id BIGINT PK
document_id FK → documents (cascade delete)
locale VARCHAR(10)
title VARCHAR(255)
content TEXT
rendered_html TEXT NULLABLE
created_by FK → users (nullOnDelete)
updated_by FK → users (nullOnDelete)
created_at, updated_at
UNIQUE (document_id, locale)
INDEX (locale, title) -- wiki-link / QuickSwitcher 用
FULLTEXT (title, content) WITH PARSER ngram -- MySQL のみ(既存と同条件)
```
- `(locale, title)` には**unique制約は付けない**。既存 `documents.title` も unique でないため挙動互換性を保つ。代わりに wiki-link 解決順序を決定論にする。
- SQLite(テスト環境)では FULLTEXT インデックス作成をスキップ(既存マイグレーションと同じ条件分岐)。
### マイグレーション手順
1. `documents.default_locale` カラム追加(デフォルト値 `config('app.locale')`、nullable=false
2. `document_translations` テーブル作成
3. データ移行:既存各 `documents` 行から `(title, content, rendered_html, created_by, updated_by)``document_translations` に複製、`locale = config('app.locale')` をセット
4. `documents` テーブルの旧FULLTEXTインデックス(`documents_search_index`)を削除
5. `documents.title`, `documents.content`, `documents.rendered_html` カラムを削除
`down()` は対称的に逆順で復元するが、データ復元は best-effort(複数translation存在時はdefault_localeのものを採用)。
## Section 2: モデル & サービス層
### `Document` モデル
- `title`/`content`/`rendered_html` をfillable/直接プロパティから削除
- 新リレーション
- `translations(): HasMany``DocumentTranslation`
- `defaultTranslation(): HasOne``where('locale', $this->default_locale)`
- アクセサ(**現在 `App::getLocale()` のtranslation → 無ければdefault_localeのtranslation**
- `getTitleAttribute(): string`
- `getContentAttribute(): string`
- `getRenderedHtmlAttribute(): ?string`
- 新メソッド
- `translationFor(string $locale, bool $fallback = true): ?DocumentTranslation`
- `isFallback(string $requestedLocale): bool` — バナー判定用
- `availableLocales(): array` — タブ生成用、translation存在のlocale一覧
### `DocumentTranslation` モデル(新規)
```php
fillable: [document_id, locale, title, content, rendered_html, created_by, updated_by]
casts: timestamps
relations: document(): BelongsTo, creator(), updater()
scope: scopeSearch(Builder, string) -- MATCH(title, content) AGAINST(?)
static: renderMarkdown(string): string -- Document から移管(純粋関数)
```
### `DocumentService` 改修
| メソッド | 変更内容 |
|---|---|
| `createDocument($title, $content, $userId, $locale = null)` | `$locale` 未指定なら `App::getLocale()`。document を作成し `default_locale = $locale`、translationを1件作成。path/slug は title から生成 |
| `updateDocument(Document $doc, $title, $content, $userId, $locale)` | 指定 `$locale` のtranslationをupsert。`$locale === default_locale` のときだけ path/slug 再生成 |
| `addTranslation(Document $doc, $locale, $title, $content, $userId): DocumentTranslation` | 新言語のtranslation追加。重複locale時は422 |
| `deleteTranslation(Document $doc, string $locale): void` | default_locale の翻訳は削除拒否(例外) |
| `setDefaultLocale(Document $doc, string $locale): Document` | `default_locale` 切り替え。新しいdefault_localeのtranslationが存在しない場合422。切替後 path/slug を新default_localeのtitleから再生成 |
| `findByTitle($title, $locale = null): ?Document` | locale指定優先。後述の解決順序を実装 |
| `search(string $query, int $limit = 20)` | `DocumentTranslation::search()` ベース、結果documentをdistinct化 |
| `getDirectoryTree()` | path/階層構造はdocumentレベルのまま、表示titleはアクセサ経由で現在locale + フォールバック |
### `WikiLinkResolver`(新規 `app/Services/WikiLinkResolver.php`
`processLinks()``syncLinks()` から共通利用される解決ロジック:
```
resolve(string $linkText, string $currentLocale): ?Document
1. translations WHERE locale = $currentLocale AND title = $linkText
2. translations WHERE locale = document.default_locale AND title = $linkText
3. translations WHERE title = $linkText
ORDER BY document_id ASC LIMIT 1
4. documents WHERE slug = SlugHelper::generate($linkText)
5. null
```
- `Document::syncLinks()``Document::processLinks()` はResolver利用に書き換え
- `processLinks()` のリンク**表示ラベル**は原文のまま(例: 英語本文中の `[[Getting Started]]` はja表示時もラベル「Getting Started」)。クリック後の遷移先記事は現在localeで表示される
- 既存の `DocumentLink` テーブルはスキーマ無変更(`target_title` を保存しているだけなのでlocale非依存で運用可能)
## Section 3: ルーティング & コントローラ層
### 既存ルート(変更なし)
- `GET /documents/{document}` → slug一致でdocumentバインド
- `GET /documents/{document}/edit`
- `GET /documents/create`
URLは言語非依存。表示言語は `SetLocale` ミドルウェアが解決した `App::getLocale()` に従う。
### `Document::resolveRouteBinding()`
slugは `documents` テーブル直下のため既存ロジックそのまま。
翻訳title(例: `はじめに`)でURLを直接叩いた場合は404のまま(検索/QuickSwitcher経由で見つける想定)。
### 新規ルート: 翻訳の管理
| メソッド | URL | 名前 | 機能 |
|---|---|---|---|
| GET | `/documents/{document}/translations/{locale}/edit` | `documents.translations.edit` | 既存翻訳の編集(`DocumentEditor` を再利用) |
| POST | `/documents/{document}/translations` | `documents.translations.store` | 新言語追加 |
| DELETE | `/documents/{document}/translations/{locale}` | `documents.translations.destroy` | 翻訳削除(default_locale 不可) |
- `{locale}``SetLocale::SUPPORTED_LOCALES` のキーで route constraint
- すべて `auth` + `can:update,document` ミドルウェア配下
### `/` ルート
`slug = home` の document を探してリダイレクト(既存通り、slugは言語非依存のため変更不要)。
## Section 4: Livewire コンポーネント & ビュー
### `DocumentViewer`(改修)
```php
public Document $document;
public string $viewLocale; // 実際に表示中のlocale(フォールバック後)
public bool $isFallback; // バナー表示判定
public string $renderedContent; // wiki-link処理済みHTML
public $backlinks;
```
`mount()` フロー:
1. `$current = App::getLocale()`
2. `$translation = $document->translationFor($current, fallback: true)`
3. `$this->viewLocale = $translation->locale`
4. `$this->isFallback = ($current !== $translation->locale)`
5. `WikiLinkResolver``$translation->rendered_html` 内のwiki-linkを現在locale基準で再解決
ビュー `livewire/document-viewer.blade.php`:
- 上部にフォールバックバナー(`$isFallback === true` のみ)
- 文言は `lang/{locale}/messages.php` に追加(例: `documents.fallback_notice``documents.add_translation`
- 「翻訳を追加」ボタン → `documents.translations.store` への小フォーム(locale = 現在UI locale
- バックリンク表示titleは `$backlink->title` アクセサ経由(自動でフォールバック)
### `DocumentEditor`(改修)
```php
public ?Document $document; // 既存編集時、新規作成時はnull
public string $editingLocale; // 現在編集中のタブ
public string $title; // editingLocale の title
public string $content; // editingLocale の content
public array $availableLocales; // この記事で既に翻訳がある locale 一覧
public bool $isNewLocale; // 既存翻訳の編集 or 新規追加
```
メソッド:
- `switchTab(string $locale)` — 未保存変更があれば確認ダイアログ、translation取得 or 空フォーム
- `save()` — 既存ならupdate、新規ならaddTranslation
- `delete()` — 翻訳削除(default_locale以外)
- `setAsDefault(string $locale)` — default_locale変更(path/slug再生成)
ビュー `livewire/document-editor.blade.php`:
- 上部にタブバー: `[JA*] [EN] [zh-CN] [+ 翻訳を追加 ▾]`
- `*` は default_locale 印
- `+` ドロップダウンで未追加の `SUPPORTED_LOCALES` を選択可能
- 既存のEasyMDE設定はそのまま流用、`wire:model` の単一入力構造を維持
- default_locale変更UI(タブの隣にメニュー or 「この言語をデフォルトにする」ボタン)
### `SidebarTree`(軽微改修)
- `getDirectoryTree()` の戻り値内 `name`path basename)はpathベースのまま
- ファイル名表示は `$document->title`(アクセサ経由)に切り替え
- treeのキャッシュキー(もしあれば)に `App::getLocale()` を含めて、locale切替で再構築
### `QuickSwitcher`(改修)
- 検索クエリを `DocumentTranslation::search()` に投げ、結果documentをdistinct化
- 表示行は `$doc->title`(アクセサ)+ ヒットしたtranslationが現在locale以外なら小さなlocaleバッジ
## Section 5: テスト計画 & 受け入れ基準
### ユニットテスト(追加)
| ファイル | 検証内容 |
|---|---|
| `tests/Unit/Models/DocumentTest.php` | `title`/`content`/`rendered_html` アクセサが現在localeを返す/無ければdefault_localeにフォールバック/`isFallback()` 真偽/`availableLocales()` |
| `tests/Unit/Models/DocumentTranslationTest.php` | `(document_id, locale)` uniquecascade delete`renderMarkdown()` |
| `tests/Unit/Services/WikiLinkResolverTest.php` | 解決順序5段階すべて/同名タイトル衝突時の決定論性/slug fallbacknullケース |
| `tests/Unit/Services/DocumentServiceTest.php` | `createDocument` がtranslationを1件生成/`updateDocument` がdefault_locale時のみpath再生成/`addTranslation``deleteTranslation`default_locale削除拒否)/`setDefaultLocale`(未存在translation時422 + path再生成)/`search` のdistinct化 |
### フィーチャーテスト(追加)
| ファイル | 検証内容 |
|---|---|
| `tests/Feature/DocumentI18nTest.php` | 現在locale=ja で英語のみの記事を表示→英語版が表示されバナーが出る/ja版を追加後はバナーなし/QuickSwitcherで日本語クエリ・英語クエリどちらも同記事ヒット |
| `tests/Feature/DocumentTranslationCrudTest.php` | 翻訳追加POST/編集GET/PATCH/削除DELETEdefault_localeは422)/非権限ユーザは403 |
| `tests/Feature/DocumentMigrationTest.php` | マイグレーション前の擬似データ→migrate実行→translationsへ正しく移行/documentsの旧カラムが消去 |
### 既存テストの修正範囲
- `DocumentService` を呼ぶ既存テストはfactoryで `default_locale` 指定が必要
- `Document::factory()``DocumentTranslationFactory` と連動
- 既存の `processLinks` / `syncLinks` テストは `WikiLinkResolver` 経由に書き換え
### 受け入れ基準(DoD
1. UI言語をjaにして英語のみ記事を開く → 英語コンテンツが表示され、上部に日本語バナー+「翻訳を追加」ボタンが出る
2. 「翻訳を追加」→ 編集画面でja版を作成・保存 → 同URLを再表示 → ja版がバナーなしで表示
3. `[[Getting Started]]``[[はじめに]]` の両方が同一記事へリンクする
4. QuickSwitcherで「はじめに」「Getting Started」どちらでも同記事がヒットする
5. 編集画面の言語タブを切り替えて、各言語のtitle/contentを独立に編集・保存できる
6. default_locale設定の翻訳は削除できない(UIで削除ボタン非表示+バックエンドで422)
7. 既存の `composer test` がすべて緑
8. マイグレーション後、既存記事すべてが `default_locale = config('app.locale')` で正しく翻訳化されている
## ブランチ & コミット計画
- ブランチ: `feature/article-i18n`**`main` から分岐**`feature/media-manager` ではない)
- コミット粒度の目安(1 PR想定で7コミット):
1. マイグレーション(`document_translations` 作成 + 既存データ移行 + 旧カラム削除)
2. `DocumentTranslation` モデル + factory
3. `Document` モデルアクセサ/リレーション改修
4. `WikiLinkResolver` + `DocumentService` 改修
5. ルート + 翻訳CRUDコントローラ
6. `DocumentViewer` + バナー + lang追加
7. `DocumentEditor` タブUI + `SidebarTree`/`QuickSwitcher` 調整
## スコープ外(YAGNI
- 翻訳の自動生成(DeepL等の外部API連携)
- 翻訳の進捗ダッシュボード(カバレッジ表示)
- 言語ごとの別URL`/ja/documents/...`
- title翻訳に応じたslugの言語別生成
- RTL言語サポート
@@ -0,0 +1,54 @@
<?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)
{
$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)
{
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'));
}
}
+86 -27
View File
@@ -2,30 +2,44 @@
namespace App\Livewire; namespace App\Livewire;
use App\Http\Middleware\SetLocale;
use App\Models\Document; use App\Models\Document;
use App\Services\DocumentService; use App\Services\DocumentService;
use Livewire\Component; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class DocumentEditor extends Component class DocumentEditor extends Component
{ {
public ?Document $document = null; public ?Document $document = null;
public $title = ''; public string $title = '';
public $content = ''; public string $content = '';
public $directory = ''; public string $editingLocale = '';
public $isEditMode = false; public bool $isEditMode = false;
public bool $isNewLocale = false;
public array $availableLocales = [];
public function mount(?Document $document = null) public function mount(?Document $document = null, ?string $locale = null)
{ {
if ($document) { if ($document) {
$this->authorize('update', $document); $this->authorize('update', $document);
$this->document = $document->load('translations');
$this->document = $document;
$this->title = $document->title;
$this->content = $document->content;
$this->directory = $document->directory;
$this->isEditMode = true; $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 { } else {
$this->editingLocale = App::getLocale();
$titleParam = request()->query('title'); $titleParam = request()->query('title');
if ($titleParam) { if ($titleParam) {
$this->title = $titleParam; $this->title = $titleParam;
@@ -35,53 +49,96 @@ public function mount(?Document $document = null)
public function save(DocumentService $documentService) public function save(DocumentService $documentService)
{ {
$this->validate([ $validated = $this->validate([
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'content' => 'required|string', 'content' => 'required|string',
'editingLocale' => ['required', 'string', 'in:' . implode(',', array_keys(SetLocale::SUPPORTED_LOCALES))],
]); ]);
try { try {
if ($this->isEditMode && $this->document) { if ($this->isEditMode && $this->document) {
$this->authorize('update', $this->document); $this->authorize('update', $this->document);
$this->document = $documentService->updateDocument( if ($this->isNewLocale) {
$this->document, $documentService->addTranslation(
$this->title, $this->document,
$this->content, $this->editingLocale,
Auth::id() $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', 'Document updated successfully!'); session()->flash('message', __('messages.documents.update_success'));
return $this->redirect(route('documents.show', $this->document)); return $this->redirect(route('documents.show', $this->document));
} else { } else {
$this->document = $documentService->createDocument( $this->document = $documentService->createDocument(
$this->title, $this->title,
$this->content, $this->content,
Auth::id(), Auth::id(),
$this->directory ?: null $this->editingLocale,
); );
session()->flash('message', __('messages.documents.create_success'));
session()->flash('message', 'Document created successfully!');
return $this->redirect(route('documents.show', $this->document)); return $this->redirect(route('documents.show', $this->document));
} }
} catch (\InvalidArgumentException $e) {
session()->flash('error', $e->getMessage());
} catch (\Exception $e) { } catch (\Exception $e) {
session()->flash('error', 'Error saving document: ' . $e->getMessage()); 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) public function delete(DocumentService $documentService)
{ {
if (!$this->isEditMode || !$this->document) { if (!$this->isEditMode || !$this->document) {
return; return;
} }
$this->authorize('delete', $this->document); $this->authorize('delete', $this->document);
try { try {
$documentService->deleteDocument($this->document); $documentService->deleteDocument($this->document);
session()->flash('message', 'Document deleted successfully!'); session()->flash('message', __('messages.documents.delete_success'));
// Try to redirect to home document, or root if not found
$homeDocument = Document::where('slug', 'home')->first(); $homeDocument = Document::where('slug', 'home')->first();
if ($homeDocument) { if ($homeDocument) {
return redirect()->route('documents.show', $homeDocument); return redirect()->route('documents.show', $homeDocument);
@@ -96,7 +153,9 @@ public function render()
{ {
return view('livewire.document-editor') return view('livewire.document-editor')
->layout('layouts.knowledge-base', [ ->layout('layouts.knowledge-base', [
'title' => $this->isEditMode ? 'Edit: ' . $this->title : 'New Document' 'title' => $this->isEditMode
? __('messages.documents.edit_document') . ': ' . $this->title
: __('messages.documents.new_document'),
]); ]);
} }
} }
+13 -6
View File
@@ -4,25 +4,32 @@
use App\Models\Document; use App\Models\Document;
use App\Services\DocumentService; use App\Services\DocumentService;
use Livewire\Component; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class DocumentViewer extends Component class DocumentViewer extends Component
{ {
public Document $document; public Document $document;
public $backlinks = []; public $backlinks = [];
public $renderedContent = ''; public string $renderedContent = '';
public string $viewLocale = '';
public bool $isFallback = false;
public function mount(Document $document, DocumentService $documentService) public function mount(Document $document, DocumentService $documentService)
{ {
$this->document = $document; $this->document = $document->load('translations');
$this->renderedContent = $this->document->processLinks(); $current = App::getLocale();
$translation = $document->translationFor($current, fallback: true);
$this->backlinks = $documentService->getBacklinks($this->document); $this->viewLocale = $translation?->locale ?? $document->default_locale;
$this->isFallback = ($current !== $this->viewLocale);
$this->renderedContent = $document->processLinks();
$this->backlinks = $documentService->getBacklinks($document);
if (Auth::check()) { if (Auth::check()) {
$documentService->recordDocumentAccess($this->document, Auth::id()); $documentService->recordDocumentAccess($document, Auth::id());
} }
} }
+13 -48
View File
@@ -4,65 +4,32 @@
use App\Models\Document; use App\Models\Document;
use App\Services\DocumentService; use App\Services\DocumentService;
use Livewire\Component;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Component;
class QuickSwitcher extends Component class QuickSwitcher extends Component
{ {
public $search = ''; public string $search = '';
public $selectedIndex = 0; public int $selectedIndex = 0;
#[Computed] #[Computed]
public function results() public function results()
{ {
if (empty($this->search)) { if (empty($this->search)) {
return Document::select('id', 'title', 'slug', 'path', 'updated_at') $documents = Document::with('translations')
->orderBy('updated_at', 'desc') ->orderBy('updated_at', 'desc')
->limit(10) ->limit(10)
->get() ->get();
->map(fn($doc) => [ } else {
'id' => $doc->id, $documents = app(DocumentService::class)->search($this->search, 10);
'title' => $doc->title,
'slug' => $doc->slug,
'directory' => dirname($doc->path),
])
->toArray();
} }
// FULLTEXT検索を使用(日本語対応) return $documents->map(fn ($doc) => [
$results = Document::select('id', 'title', 'slug', 'path', 'updated_at') 'id' => $doc->id,
->whereRaw('MATCH(title, content) AGAINST(? IN BOOLEAN MODE)', [$this->search]) 'title' => $doc->title,
->orderBy('updated_at', 'desc') 'slug' => $doc->slug,
->limit(10) 'directory' => dirname($doc->path),
->get() ])->values()->toArray();
->map(fn($doc) => [
'id' => $doc->id,
'title' => $doc->title,
'slug' => $doc->slug,
'directory' => dirname($doc->path),
])
->toArray();
// FULLTEXT検索で結果がない場合は LIKE 検索にフォールバック
if (empty($results)) {
$results = Document::select('id', 'title', 'slug', 'path', 'updated_at')
->where(function($query) {
$query->where('title', 'like', '%' . $this->search . '%')
->orWhere('content', 'like', '%' . $this->search . '%');
})
->orderBy('updated_at', 'desc')
->limit(10)
->get()
->map(fn($doc) => [
'id' => $doc->id,
'title' => $doc->title,
'slug' => $doc->slug,
'directory' => dirname($doc->path),
])
->toArray();
}
return $results;
} }
public function updated($propertyName) public function updated($propertyName)
@@ -92,8 +59,6 @@ public function selectDocument()
$results = $this->results; $results = $this->results;
if (isset($results[$this->selectedIndex])) { if (isset($results[$this->selectedIndex])) {
$document = $results[$this->selectedIndex]; $document = $results[$this->selectedIndex];
// slug が存在することを確認
if (!empty($document['slug'])) { if (!empty($document['slug'])) {
return $this->redirect(route('documents.show', $document['slug'])); return $this->redirect(route('documents.show', $document['slug']));
} }
+144 -222
View File
@@ -3,64 +3,25 @@
namespace App\Models; namespace App\Models;
use App\Helpers\SlugHelper; use App\Helpers\SlugHelper;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; 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\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 class Document extends Model
{ {
use SoftDeletes; 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 = [ protected $fillable = [
'path', 'path',
'title',
'slug', 'slug',
'content', 'default_locale',
'rendered_html',
'frontmatter', 'frontmatter',
'file_size', 'file_size',
'file_hash', 'file_hash',
@@ -69,11 +30,6 @@ public function resolveRouteBinding($value, $field = null)
'updated_by', 'updated_by',
]; ];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array protected function casts(): array
{ {
return [ return [
@@ -82,192 +38,64 @@ protected function casts(): array
]; ];
} }
/** public function getRouteKeyName(): string
* Frontmatterをパース(互換性のため残す)
*
* @param string $content
* @return array{frontmatter: array, content: string}
*/
protected static function parseFrontmatter(string $content): array
{ {
$frontmatter = []; return 'slug';
$bodyContent = $content; }
// Frontmatterの検出(--- で囲まれた部分) public function resolveRouteBinding($value, $field = null)
if (preg_match('/^---\s*\n(.*?)\n---\s*\n(.*)/s', $content, $matches)) { {
$frontmatterText = $matches[1]; $document = $this->where('slug', $value)->first();
$bodyContent = $matches[2];
// 簡易的なYAMLパース(key: value形式のみ) if (!$document && is_numeric($value)) {
$lines = explode("\n", $frontmatterText); $document = $this->where('id', $value)->first();
foreach ($lines as $line) {
if (preg_match('/^([^:]+):\s*(.*)$/', $line, $lineMatches)) {
$frontmatter[trim($lineMatches[1])] = trim($lineMatches[2]);
}
}
} }
return [ return $document;
'frontmatter' => $frontmatter,
'content' => trim($bodyContent),
];
} }
/** /**
* Markdownをレンダリング * Backward-compatible static delegate so existing callers and tests
* * (e.g. MediaEmbedExtensionTest) keep working.
* @param string $markdown
* @return string
*/ */
public static function renderMarkdown(string $markdown): string public static function renderMarkdown(string $markdown): string
{ {
$converter = new CommonMarkConverter([ return DocumentTranslation::renderMarkdown($markdown);
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
return $converter->convert($markdown)->getContent();
} }
/** // ----- Relations -----
* [[wiki-link]]を抽出してリンクテーブルに同期
* public function translations(): HasMany
* @return void
*/
public function syncLinks(): void
{ {
// 既存のリンクを削除 return $this->hasMany(DocumentTranslation::class);
$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++,
]);
}
} }
/** public function defaultTranslation(): HasOne
* [[wiki-link]]をHTMLリンクに変換
*
* @return string
*/
public function processLinks(): string
{ {
return preg_replace_callback( return $this->hasOne(DocumentTranslation::class)
'/\[\[([^\]]+)\]\]/', ->whereColumn('locale', 'documents.default_locale');
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
);
} }
/**
* 全文検索スコープ
*
* @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 public function creator(): BelongsTo
{ {
return $this->belongsTo(User::class, 'created_by'); return $this->belongsTo(User::class, 'created_by');
} }
/**
* 更新者リレーション
*
* @return BelongsTo
*/
public function updater(): BelongsTo public function updater(): BelongsTo
{ {
return $this->belongsTo(User::class, 'updated_by'); return $this->belongsTo(User::class, 'updated_by');
} }
/**
* 発リンク(このドキュメントから他へのリンク)
*
* @return HasMany
*/
public function outgoingLinks(): HasMany public function outgoingLinks(): HasMany
{ {
return $this->hasMany(DocumentLink::class, 'source_document_id'); return $this->hasMany(DocumentLink::class, 'source_document_id');
} }
/**
* 被リンク(他のドキュメントからこのドキュメントへのリンク)
*
* @return HasMany
*/
public function incomingLinks(): HasMany public function incomingLinks(): HasMany
{ {
return $this->hasMany(DocumentLink::class, 'target_document_id'); return $this->hasMany(DocumentLink::class, 'target_document_id');
} }
/**
* このドキュメントを最近閲覧したユーザー
*
* @return HasManyThrough
*/
public function recentByUsers(): HasManyThrough public function recentByUsers(): HasManyThrough
{ {
return $this->hasManyThrough( return $this->hasManyThrough(
@@ -280,48 +108,142 @@ public function recentByUsers(): HasManyThrough
); );
} }
// ----- 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>
*
* @return 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 public function getDirectoryAttribute(): string
{ {
return dirname($this->path); return dirname($this->path);
} }
/**
* ファイル名を取得
*
* @return string
*/
public function getFilenameAttribute(): string public function getFilenameAttribute(): string
{ {
return basename($this->path); return basename($this->path);
} }
/**
* 絶対パスを取得
*
* @return string
*/
public function getAbsolutePathAttribute(): string public function getAbsolutePathAttribute(): string
{ {
return Storage::disk('markdown')->path($this->path); return Storage::disk('markdown')->path($this->path);
} }
/** // ----- Search scope (delegates to translations) -----
* タイトルセット時にslugも自動生成
*
* @param string $value
* @return void
*/
public function setTitleAttribute(string $value): void
{
$this->attributes['title'] = $value;
if (empty($this->attributes['slug'])) { public function scopeSearch(Builder $query, string $term): Builder
$this->attributes['slug'] = SlugHelper::generate($value); {
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 . '%');
}
/**
* 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 = 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
);
}
} }
+72
View File
@@ -0,0 +1,72 @@
<?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();
}
}
+128 -180
View File
@@ -2,143 +2,185 @@
namespace App\Services; namespace App\Services;
use App\Models\Document;
use App\Models\RecentDocument;
use App\Helpers\SlugHelper; use App\Helpers\SlugHelper;
use Illuminate\Support\Facades\Storage; use App\Models\Document;
use App\Models\DocumentTranslation;
use App\Models\RecentDocument;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class DocumentService class DocumentService
{ {
/**
* 新しいドキュメントを作成
*
* @param string $title
* @param string $content
* @param int|null $userId
* @param string|null $directory (deprecated - path is now auto-generated from title)
* @return Document
*/
public function createDocument( public function createDocument(
string $title, string $title,
string $content, string $content,
?int $userId = null, ?int $userId = null,
?string $directory = null ?string $locale = null,
): Document { ): Document {
// タイトルからパスとスラッグを自動生成 $locale = $locale ?: App::getLocale();
// 例: "Laravel/Livewire/Components" → path="Laravel/Livewire/Components.md", slug="components"
[$path, $slug] = $this->generatePathAndSlug($title); [$path, $slug] = $this->generatePathAndSlug($title);
// ドキュメントをDBに作成 return DB::transaction(function () use ($title, $content, $userId, $locale, $path, $slug) {
$document = Document::create([ $document = Document::create([
'path' => $path, 'path' => $path,
'title' => $title, 'slug' => $slug,
'slug' => $slug, 'default_locale' => $locale,
'content' => $content, 'created_by' => $userId,
'rendered_html' => Document::renderMarkdown($content), 'updated_by' => $userId,
'created_by' => $userId, ]);
'updated_by' => $userId,
]);
// リンクを同期 DocumentTranslation::create([
$document->syncLinks(); 'document_id' => $document->id,
'locale' => $locale,
'title' => $title,
'content' => $content,
'rendered_html' => DocumentTranslation::renderMarkdown($content),
'created_by' => $userId,
'updated_by' => $userId,
]);
return $document; $document->load('translations');
$document->syncLinks();
return $document;
});
} }
/**
* ドキュメントを更新
*
* @param Document $document
* @param string $title
* @param string $content
* @param int|null $userId
* @return Document
*/
public function updateDocument( public function updateDocument(
Document $document, Document $document,
string $title, string $title,
string $content, string $content,
?int $userId = null ?int $userId = null,
?string $locale = null,
): Document { ): Document {
// タイトルが変更された場合はパスとスラッグを再生成 $locale = $locale ?: App::getLocale();
if ($document->title !== $title) {
[$path, $slug] = $this->generatePathAndSlug($title, $document->id);
$document->path = $path;
$document->slug = $slug;
}
$document->title = $title; return DB::transaction(function () use ($document, $title, $content, $userId, $locale) {
$document->content = $content; $translation = $document->translations()->firstOrNew(['locale' => $locale]);
$document->rendered_html = Document::renderMarkdown($content); $translation->title = $title;
$document->updated_by = $userId; $translation->content = $content;
$translation->rendered_html = DocumentTranslation::renderMarkdown($content);
$translation->updated_by = $userId;
if (!$translation->exists) {
$translation->created_by = $userId;
}
$translation->save();
// DBに保存 $document->updated_by = $userId;
$document->save();
// リンクを再同期 // Path/slug regenerate only when editing the default-locale translation
$document->syncLinks(); if ($locale === $document->default_locale) {
[$path, $slug] = $this->generatePathAndSlug($title, $document->id);
$document->path = $path;
$document->slug = $slug;
}
return $document; $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');
});
} }
/**
* ドキュメントを削除
*
* @param Document $document
* @return bool
*/
public function deleteDocument(Document $document): bool public function deleteDocument(Document $document): bool
{ {
// DBから削除(ソフトデリート)
return $document->delete(); return $document->delete();
} }
/** /**
* 全文検索 * Locale-agnostic full-text search; returns distinct documents.
*
* @param string $query
* @param int $limit
* @return \Illuminate\Database\Eloquent\Collection
*/ */
public function search(string $query, int $limit = 20) public function search(string $query, int $limit = 20)
{ {
return Document::search($query) $documentIds = DocumentTranslation::query()
->limit($limit) ->search($query)
->get(); ->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());
} }
/**
* ディレクトリツリーを生成
*
* @return array
*/
public function getDirectoryTree(): array public function getDirectoryTree(): array
{ {
$documents = Document::orderBy('path')->get(); $documents = Document::with('translations')->orderBy('path')->get();
$tree = []; $tree = [];
foreach ($documents as $document) { foreach ($documents as $document) {
$parts = explode('/', $document->path); $parts = explode('/', $document->path);
$current = &$tree; $current = &$tree;
foreach ($parts as $index => $part) { foreach ($parts as $index => $part) {
$isFile = ($index === count($parts) - 1); $isFile = ($index === count($parts) - 1);
if ($isFile) { if ($isFile) {
// ファイル
if (!isset($current['_files'])) {
$current['_files'] = [];
}
$current['_files'][] = [ $current['_files'][] = [
'name' => $part, 'name' => $part,
'document' => $document, 'document' => $document,
]; ];
} else { } else {
// ディレクトリ
if (!isset($current[$part])) { if (!isset($current[$part])) {
$current[$part] = []; $current[$part] = [];
} }
@@ -146,67 +188,28 @@ public function getDirectoryTree(): array
} }
} }
} }
return $tree; return $tree;
} }
/**
* ユーザーの最近閲覧したドキュメントを取得
*
* @param int $userId
* @param int $limit
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getRecentDocuments(int $userId, int $limit = 10) public function getRecentDocuments(int $userId, int $limit = 10)
{ {
return RecentDocument::getRecentForUser($userId, $limit); return RecentDocument::getRecentForUser($userId, $limit);
} }
/**
* ドキュメント閲覧を記録
*
* @param Document $document
* @param int $userId
* @return void
*/
public function recordDocumentAccess(Document $document, int $userId): void public function recordDocumentAccess(Document $document, int $userId): void
{ {
RecentDocument::recordAccess($userId, $document->id); RecentDocument::recordAccess($userId, $document->id);
} }
/**
* 指定タイトルのドキュメントを検索
*
* @param string $title
* @return Document|null
*/
public function findByTitle(string $title): ?Document
{
return Document::where('title', $title)
->orWhere('slug', SlugHelper::generate($title))
->first();
}
/**
* 被リンク(バックリンク)を取得
*
* @param Document $document
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getBacklinks(Document $document) public function getBacklinks(Document $document)
{ {
return $document->incomingLinks() return $document->incomingLinks()
->with('sourceDocument') ->with('sourceDocument.translations')
->get() ->get()
->pluck('sourceDocument') ->pluck('sourceDocument')
->filter(); ->filter();
} }
/**
* 壊れたリンク(未作成ページへのリンク)を取得
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getBrokenLinks() public function getBrokenLinks()
{ {
return DB::table('document_links') return DB::table('document_links')
@@ -217,39 +220,13 @@ public function getBrokenLinks()
->get(); ->get();
} }
/**
* タイトルからパスとスラッグを生成
* タイトルに含まれる / をディレクトリ区切りとして扱う
*
* : "Laravel/Livewire/Components"
* path = "Laravel/Livewire/Components.md"
* slug = "components" (最後のコンポーネントから生成)
*
* @param string $title
* @param int|null $excludeDocumentId 更新時に除外するドキュメントID
* @return array [path, slug]
*/
private function generatePathAndSlug(string $title, ?int $excludeDocumentId = null): array private function generatePathAndSlug(string $title, ?int $excludeDocumentId = null): array
{ {
// タイトルをそのままパスとして使用(.md拡張子を追加)
$basePath = $title . '.md'; $basePath = $title . '.md';
$baseSlug = SlugHelper::generate(basename($title));
// 最後のパスコンポーネント(スラッシュで区切られた最後の部分)からスラッグを生成
$lastComponent = basename($title);
$baseSlug = SlugHelper::generate($lastComponent);
// ユニークなパスとスラッグを生成
return $this->ensureUniquePath($basePath, $baseSlug, $excludeDocumentId); return $this->ensureUniquePath($basePath, $baseSlug, $excludeDocumentId);
} }
/**
* パスとスラッグがユニークになるように調整
*
* @param string $basePath
* @param string $baseSlug
* @param int|null $excludeDocumentId
* @return array [path, slug]
*/
private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excludeDocumentId = null): array private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excludeDocumentId = null): array
{ {
$path = $basePath; $path = $basePath;
@@ -261,46 +238,17 @@ private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excl
->where(function ($q) use ($path, $slug) { ->where(function ($q) use ($path, $slug) {
$q->where('path', $path)->orWhere('slug', $slug); $q->where('path', $path)->orWhere('slug', $slug);
}); });
if ($excludeDocumentId) { if ($excludeDocumentId) {
$query->where('id', '!=', $excludeDocumentId); $query->where('id', '!=', $excludeDocumentId);
} }
if (!$query->exists()) { if (!$query->exists()) {
break; break;
} }
$counter++; $counter++;
// パス: "title.md" → "title-2.md"
$path = preg_replace('/\.md$/', "-{$counter}.md", $basePath); $path = preg_replace('/\.md$/', "-{$counter}.md", $basePath);
// スラッグ: "title" → "title-2"
$slug = $baseSlug . '-' . $counter; $slug = $baseSlug . '-' . $counter;
} }
return [$path, $slug]; return [$path, $slug];
} }
/**
* 初期ドキュメントを作成
*
* @return void
*/
public function createInitialDocuments(): void
{
// ホームページ
$this->createDocument(
'Home',
"# Welcome to Knowledge Base\n\nThis is your personal knowledge base powered by Markdown.\n\n## Getting Started\n\n- Create new documents using [[wiki-links]]\n- Use Ctrl+K for quick switching\n- Full-text search is available\n\n## Example Links\n\n- [[Getting Started]]\n- [[Documentation]]\n- [[Notes]]",
null,
null
);
// Getting Startedページ
$this->createDocument(
'Getting Started',
"# Getting Started\n\nLearn how to use this knowledge base.\n\n## Creating Documents\n\nClick on any [[wiki-link]] to create a new document.\n\n## Editing\n\nClick the edit button to modify content.\n\nBack to [[Home]]",
null,
null
);
}
} }
+64
View File
@@ -0,0 +1,64 @@
<?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;
}
}
@@ -0,0 +1,65 @@
<?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 = rtrim(fake()->unique()->words(3, true), '.');
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
* (skipped if a translation was already created via state, or if the
* caller used withoutTranslations()).
*/
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 (auto-translation will be created in this locale).
*/
public function defaultLocale(string $locale): static
{
return $this->state(['default_locale' => $locale]);
}
/**
* Suppress automatic translation creation. Uses Laravel's built-in
* withoutAfterCreating() to clear callbacks rather than appending a no-op
* (afterCreating appends, so a no-op closure does NOT override the configure() callback).
*/
public function withoutTranslations(): static
{
return $this->withoutAfterCreating();
}
}
@@ -0,0 +1,29 @@
<?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>',
];
}
}
@@ -2,6 +2,7 @@
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
return new class extends Migration return new class extends Migration
@@ -27,7 +28,9 @@ public function up(): void
// FULLTEXT検索インデックス(MySQL 5.7以降) // FULLTEXT検索インデックス(MySQL 5.7以降)
// ngramトークナイザーは日本語対応に必要だが、設定が必要 // ngramトークナイザーは日本語対応に必要だが、設定が必要
DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram'); if (DB::connection()->getDriverName() === 'mysql') {
DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram');
}
// 検索パフォーマンス向上用インデックス // 検索パフォーマンス向上用インデックス
Schema::table('documents', function (Blueprint $table) { Schema::table('documents', function (Blueprint $table) {
@@ -0,0 +1,115 @@
<?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
// Note: step 4 (ALTER TABLE … DROP INDEX) must remain OUTSIDE this
// transaction because MySQL's ALTER TABLE causes an implicit commit.
$defaultLocale = config('app.locale', 'en');
$now = now();
DB::transaction(function () use ($defaultLocale, $now) {
DB::table('documents')->orderBy('id')->chunkById(500, function ($rows) use ($defaultLocale, $now) {
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
// Drop the title index first (SQLite requires this before dropping the column)
Schema::table('documents', function (Blueprint $table) {
$table->dropIndex(['title']);
$table->dropColumn(['title', 'content', 'rendered_html']);
});
}
public function down(): void
{
// IRREVERSIBLE for non-default-locale translations: only the row matching
// each document's default_locale is restored to the legacy columns; any
// other-locale translations are dropped along with document_translations.
// Re-add columns (with the title index that up() expects to drop)
Schema::table('documents', function (Blueprint $table) {
$table->string('title')->nullable()->index()->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');
});
}
};
+11 -32
View File
@@ -2,7 +2,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Document;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class DocumentSeeder extends Seeder class DocumentSeeder extends Seeder
@@ -12,43 +11,23 @@ class DocumentSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// 既存のドキュメントがある場合はスキップ if (\App\Models\Document::count() > 0) {
if (Document::count() > 0) {
$this->command->info('Documents already exist. Skipping...'); $this->command->info('Documents already exist. Skipping...');
return; return;
} }
$documents = [ $service = app(\App\Services\DocumentService::class);
[ $defaultLocale = config('app.locale', 'en');
'title' => 'Home',
'path' => 'Home.md', $docs = [
'slug' => 'home', ['title' => 'Home', 'content' => $this->getHomeContent()],
'content' => $this->getHomeContent(), ['title' => 'Getting Started', 'content' => $this->getGettingStartedContent()],
], ['title' => 'Markdown Guide', 'content' => $this->getMarkdownGuideContent()],
[
'title' => 'Getting Started',
'path' => 'Getting Started.md',
'slug' => 'getting-started',
'content' => $this->getGettingStartedContent(),
],
[
'title' => 'Markdown Guide',
'path' => 'Markdown Guide.md',
'slug' => 'markdown-guide',
'content' => $this->getMarkdownGuideContent(),
],
]; ];
foreach ($documents as $doc) { foreach ($docs as $d) {
Document::create([ $service->createDocument($d['title'], $d['content'], null, $defaultLocale);
'title' => $doc['title'], $this->command->info("Created: {$d['title']}");
'path' => $doc['path'],
'slug' => $doc['slug'],
'content' => $doc['content'],
'rendered_html' => Document::renderMarkdown($doc['content']),
]);
$this->command->info("Created: {$doc['title']}");
} }
$this->command->info('Initial documents created successfully!'); $this->command->info('Initial documents created successfully!');
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Inhalt', 'content_label' => 'Inhalt',
'content_placeholder' => 'Schreiben Sie hier Ihren Markdown...', 'content_placeholder' => 'Schreiben Sie hier Ihren Markdown...',
'saving' => 'Speichern...', 'saving' => 'Speichern...',
'fallback_notice' => 'Eine Übersetzung dieses Artikels in der von Ihnen gewählten Sprache ist nicht verfügbar. Die Originalsprachversion wird angezeigt.',
'add_translation' => 'Übersetzung hinzufügen',
'translation_added' => 'Übersetzung hinzugefügt.',
'translation_deleted' => 'Übersetzung gelöscht.',
'set_as_default' => 'Als Standard festlegen',
'delete_translation' => 'Übersetzung löschen',
'delete_translation_blocked' => 'Die Übersetzung der Standardsprache kann nicht gelöscht werden.',
'translation_tabs_label' => 'Sprachen',
], ],
// Quick Switcher // Quick Switcher
@@ -123,6 +131,25 @@
'back_to_home' => 'Zurück zur Startseite', 'back_to_home' => 'Zurück zur Startseite',
], ],
'locale_names' => [
'en' => 'Englisch',
'ja' => 'Japanisch',
'zh-CN' => 'Chinesisch (vereinfacht)',
'zh-TW' => 'Chinesisch (traditionell)',
'ko' => 'Koreanisch',
'hi' => 'Hindi',
'vi' => 'Vietnamesisch',
'tr' => 'Türkisch',
'de' => 'Deutsch',
'fr' => 'Französisch',
'es' => 'Spanisch',
'pt-BR' => 'Portugiesisch (Brasilien)',
'ru' => 'Russisch',
'uk' => 'Ukrainisch',
'it' => 'Italienisch',
'pl' => 'Polnisch',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Profil', 'title' => 'Profil',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Content', 'content_label' => 'Content',
'content_placeholder' => 'Write your markdown here...', 'content_placeholder' => 'Write your markdown here...',
'saving' => 'Saving...', 'saving' => 'Saving...',
'translation_added' => 'Translation added.',
'translation_deleted' => 'Translation deleted.',
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
'add_translation' => 'Add translation',
'set_as_default' => 'Set as default',
'delete_translation' => 'Delete translation',
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
'translation_tabs_label' => 'Languages',
], ],
// Quick Switcher // Quick Switcher
@@ -123,6 +131,25 @@
'back_to_home' => 'Back to Home', 'back_to_home' => 'Back to Home',
], ],
'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',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Profile', 'title' => 'Profile',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Contenido', 'content_label' => 'Contenido',
'content_placeholder' => 'Escriba su markdown aquí...', 'content_placeholder' => 'Escriba su markdown aquí...',
'saving' => 'Guardando...', 'saving' => 'Guardando...',
'fallback_notice' => 'Este artículo no está traducido al idioma seleccionado. Se muestra la versión en el idioma original.',
'add_translation' => 'Añadir traducción',
'translation_added' => 'Traducción añadida.',
'translation_deleted' => 'Traducción eliminada.',
'set_as_default' => 'Establecer como predeterminado',
'delete_translation' => 'Eliminar traducción',
'delete_translation_blocked' => 'No se puede eliminar la traducción del idioma predeterminado.',
'translation_tabs_label' => 'Idiomas',
], ],
// Quick Switcher // Quick Switcher
@@ -123,6 +131,25 @@
'back_to_home' => 'Volver al inicio', 'back_to_home' => 'Volver al inicio',
], ],
'locale_names' => [
'en' => 'Inglés',
'ja' => 'Japonés',
'zh-CN' => 'Chino (simplificado)',
'zh-TW' => 'Chino (tradicional)',
'ko' => 'Coreano',
'hi' => 'Hindi',
'vi' => 'Vietnamita',
'tr' => 'Turco',
'de' => 'Alemán',
'fr' => 'Francés',
'es' => 'Español',
'pt-BR' => 'Portugués (Brasil)',
'ru' => 'Ruso',
'uk' => 'Ucraniano',
'it' => 'Italiano',
'pl' => 'Polaco',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Perfil', 'title' => 'Perfil',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Contenu', 'content_label' => 'Contenu',
'content_placeholder' => 'Écrivez votre markdown ici...', 'content_placeholder' => 'Écrivez votre markdown ici...',
'saving' => 'Enregistrement...', 'saving' => 'Enregistrement...',
'fallback_notice' => 'Cet article n\'est pas traduit dans la langue sélectionnée. La version dans la langue d\'origine est affichée.',
'add_translation' => 'Ajouter une traduction',
'translation_added' => 'Traduction ajoutée.',
'translation_deleted' => 'Traduction supprimée.',
'set_as_default' => 'Définir par défaut',
'delete_translation' => 'Supprimer la traduction',
'delete_translation_blocked' => 'La traduction de la langue par défaut ne peut pas être supprimée.',
'translation_tabs_label' => 'Langues',
], ],
// Quick Switcher // Quick Switcher
@@ -123,6 +131,25 @@
'back_to_home' => 'Retour à l\'accueil', 'back_to_home' => 'Retour à l\'accueil',
], ],
'locale_names' => [
'en' => 'Anglais',
'ja' => 'Japonais',
'zh-CN' => 'Chinois (simplifié)',
'zh-TW' => 'Chinois (traditionnel)',
'ko' => 'Coréen',
'hi' => 'Hindi',
'vi' => 'Vietnamien',
'tr' => 'Turc',
'de' => 'Allemand',
'fr' => 'Français',
'es' => 'Espagnol',
'pt-BR' => 'Portugais (Brésil)',
'ru' => 'Russe',
'uk' => 'Ukrainien',
'it' => 'Italien',
'pl' => 'Polonais',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Profil', 'title' => 'Profil',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'सामग्री', 'content_label' => 'सामग्री',
'content_placeholder' => 'यहां अपना मार्कडाउन लिखें...', 'content_placeholder' => 'यहां अपना मार्कडाउन लिखें...',
'saving' => 'सहेजा जा रहा है...', 'saving' => 'सहेजा जा रहा है...',
'fallback_notice' => 'इस लेख का आपकी चुनी गई भाषा में अनुवाद उपलब्ध नहीं है। मूल भाषा का संस्करण दिखाया जा रहा है।',
'add_translation' => 'अनुवाद जोड़ें',
'translation_added' => 'अनुवाद जोड़ा गया।',
'translation_deleted' => 'अनुवाद हटाया गया।',
'set_as_default' => 'डिफ़ॉल्ट के रूप में सेट करें',
'delete_translation' => 'अनुवाद हटाएं',
'delete_translation_blocked' => 'डिफ़ॉल्ट भाषा का अनुवाद हटाया नहीं जा सकता।',
'translation_tabs_label' => 'भाषाएँ',
], ],
// Quick Switcher // Quick Switcher
@@ -124,6 +132,25 @@
'back_to_home' => 'होम पर वापस जाएं', 'back_to_home' => 'होम पर वापस जाएं',
], ],
'locale_names' => [
'en' => 'अंग्रेज़ी',
'ja' => 'जापानी',
'zh-CN' => 'चीनी (सरलीकृत)',
'zh-TW' => 'चीनी (पारंपरिक)',
'ko' => 'कोरियाई',
'hi' => 'हिन्दी',
'vi' => 'वियतनामी',
'tr' => 'तुर्की',
'de' => 'जर्मन',
'fr' => 'फ़्रेंच',
'es' => 'स्पेनिश',
'pt-BR' => 'पुर्तगाली (ब्राज़ील)',
'ru' => 'रूसी',
'uk' => 'यूक्रेनी',
'it' => 'इतालवी',
'pl' => 'पोलिश',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'प्रोफ़ाइल', 'title' => 'प्रोफ़ाइल',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Contenuto', 'content_label' => 'Contenuto',
'content_placeholder' => 'Scrivi il tuo markdown qui...', 'content_placeholder' => 'Scrivi il tuo markdown qui...',
'saving' => 'Salvataggio...', 'saving' => 'Salvataggio...',
'fallback_notice' => 'Questo articolo non è tradotto nella lingua selezionata. Viene mostrata la versione nella lingua originale.',
'add_translation' => 'Aggiungi traduzione',
'translation_added' => 'Traduzione aggiunta.',
'translation_deleted' => 'Traduzione eliminata.',
'set_as_default' => 'Imposta come predefinita',
'delete_translation' => 'Elimina traduzione',
'delete_translation_blocked' => 'La traduzione della lingua predefinita non può essere eliminata.',
'translation_tabs_label' => 'Lingue',
], ],
// Quick Switcher // Quick Switcher
@@ -124,6 +132,25 @@
'back_to_home' => 'Torna alla Home', 'back_to_home' => 'Torna alla Home',
], ],
'locale_names' => [
'en' => 'Inglese',
'ja' => 'Giapponese',
'zh-CN' => 'Cinese (semplificato)',
'zh-TW' => 'Cinese (tradizionale)',
'ko' => 'Coreano',
'hi' => 'Hindi',
'vi' => 'Vietnamita',
'tr' => 'Turco',
'de' => 'Tedesco',
'fr' => 'Francese',
'es' => 'Spagnolo',
'pt-BR' => 'Portoghese (Brasile)',
'ru' => 'Russo',
'uk' => 'Ucraino',
'it' => 'Italiano',
'pl' => 'Polacco',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Profilo', 'title' => 'Profilo',
+16
View File
@@ -39,6 +39,14 @@
'content_label' => '本文', 'content_label' => '本文',
'content_placeholder' => 'Markdownで記述してください...', 'content_placeholder' => 'Markdownで記述してください...',
'saving' => '保存中...', 'saving' => '保存中...',
'translation_added' => '翻訳を追加しました。',
'translation_deleted' => '翻訳を削除しました。',
'fallback_notice' => 'この記事には選択した言語の翻訳がありません。元の言語版を表示しています。',
'add_translation' => '翻訳を追加',
'set_as_default' => 'デフォルトに設定',
'delete_translation' => '翻訳を削除',
'delete_translation_blocked' => 'デフォルト言語の翻訳は削除できません。',
'translation_tabs_label' => '言語',
], ],
// Quick Switcher // Quick Switcher
@@ -123,6 +131,14 @@
'back_to_home' => 'ホームに戻る', 'back_to_home' => 'ホームに戻る',
], ],
'locale_names' => [
'en' => '英語', 'ja' => '日本語',
'zh-CN' => '簡体字中国語', 'zh-TW' => '繁体字中国語',
'ko' => '韓国語', 'hi' => 'ヒンディー語', 'vi' => 'ベトナム語', 'tr' => 'トルコ語',
'de' => 'ドイツ語', 'fr' => 'フランス語', 'es' => 'スペイン語', 'pt-BR' => 'ポルトガル語(ブラジル)',
'ru' => 'ロシア語', 'uk' => 'ウクライナ語', 'it' => 'イタリア語', 'pl' => 'ポーランド語',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'プロフィール', 'title' => 'プロフィール',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => '내용', 'content_label' => '내용',
'content_placeholder' => '여기에 마크다운을 작성하세요...', 'content_placeholder' => '여기에 마크다운을 작성하세요...',
'saving' => '저장 중...', 'saving' => '저장 중...',
'fallback_notice' => '이 문서에는 선택한 언어의 번역이 없습니다. 원본 언어 버전을 표시합니다.',
'add_translation' => '번역 추가',
'translation_added' => '번역이 추가되었습니다.',
'translation_deleted' => '번역이 삭제되었습니다.',
'set_as_default' => '기본값으로 설정',
'delete_translation' => '번역 삭제',
'delete_translation_blocked' => '기본 언어의 번역은 삭제할 수 없습니다.',
'translation_tabs_label' => '언어',
], ],
// Quick Switcher // Quick Switcher
@@ -123,6 +131,25 @@
'back_to_home' => '홈으로 돌아가기', 'back_to_home' => '홈으로 돌아가기',
], ],
'locale_names' => [
'en' => '영어',
'ja' => '일본어',
'zh-CN' => '중국어 간체',
'zh-TW' => '중국어 번체',
'ko' => '한국어',
'hi' => '힌디어',
'vi' => '베트남어',
'tr' => '터키어',
'de' => '독일어',
'fr' => '프랑스어',
'es' => '스페인어',
'pt-BR' => '포르투갈어 (브라질)',
'ru' => '러시아어',
'uk' => '우크라이나어',
'it' => '이탈리아어',
'pl' => '폴란드어',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => '프로필', 'title' => '프로필',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Treść', 'content_label' => 'Treść',
'content_placeholder' => 'Napisz swój markdown tutaj...', 'content_placeholder' => 'Napisz swój markdown tutaj...',
'saving' => 'Zapisywanie...', 'saving' => 'Zapisywanie...',
'fallback_notice' => 'Ten artykuł nie został przetłumaczony na wybrany język. Wyświetlana jest wersja w języku oryginału.',
'add_translation' => 'Dodaj tłumaczenie',
'translation_added' => 'Tłumaczenie dodane.',
'translation_deleted' => 'Tłumaczenie usunięte.',
'set_as_default' => 'Ustaw jako domyślny',
'delete_translation' => 'Usuń tłumaczenie',
'delete_translation_blocked' => 'Nie można usunąć tłumaczenia w języku domyślnym.',
'translation_tabs_label' => 'Języki',
], ],
// Quick Switcher // Quick Switcher
@@ -124,6 +132,25 @@
'back_to_home' => 'Wróć do strony głównej', 'back_to_home' => 'Wróć do strony głównej',
], ],
'locale_names' => [
'en' => 'Angielski',
'ja' => 'Japoński',
'zh-CN' => 'Chiński (uproszczony)',
'zh-TW' => 'Chiński (tradycyjny)',
'ko' => 'Koreański',
'hi' => 'Hindi',
'vi' => 'Wietnamski',
'tr' => 'Turecki',
'de' => 'Niemiecki',
'fr' => 'Francuski',
'es' => 'Hiszpański',
'pt-BR' => 'Portugalski (Brazylia)',
'ru' => 'Rosyjski',
'uk' => 'Ukraiński',
'it' => 'Włoski',
'pl' => 'Polski',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Profil', 'title' => 'Profil',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Conteúdo', 'content_label' => 'Conteúdo',
'content_placeholder' => 'Escreva seu markdown aqui...', 'content_placeholder' => 'Escreva seu markdown aqui...',
'saving' => 'Salvando...', 'saving' => 'Salvando...',
'fallback_notice' => 'Este artigo não possui tradução para o idioma selecionado. Exibindo a versão no idioma original.',
'add_translation' => 'Adicionar tradução',
'translation_added' => 'Tradução adicionada.',
'translation_deleted' => 'Tradução excluída.',
'set_as_default' => 'Definir como padrão',
'delete_translation' => 'Excluir tradução',
'delete_translation_blocked' => 'A tradução do idioma padrão não pode ser excluída.',
'translation_tabs_label' => 'Idiomas',
], ],
// Quick Switcher // Quick Switcher
@@ -124,6 +132,25 @@
'back_to_home' => 'Voltar para Início', 'back_to_home' => 'Voltar para Início',
], ],
'locale_names' => [
'en' => 'Inglês',
'ja' => 'Japonês',
'zh-CN' => 'Chinês (simplificado)',
'zh-TW' => 'Chinês (tradicional)',
'ko' => 'Coreano',
'hi' => 'Híndi',
'vi' => 'Vietnamita',
'tr' => 'Turco',
'de' => 'Alemão',
'fr' => 'Francês',
'es' => 'Espanhol',
'pt-BR' => 'Português (Brasil)',
'ru' => 'Russo',
'uk' => 'Ucraniano',
'it' => 'Italiano',
'pl' => 'Polonês',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Perfil', 'title' => 'Perfil',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Содержимое', 'content_label' => 'Содержимое',
'content_placeholder' => 'Напишите здесь ваш markdown...', 'content_placeholder' => 'Напишите здесь ваш markdown...',
'saving' => 'Сохранение...', 'saving' => 'Сохранение...',
'fallback_notice' => 'Эта статья не переведена на выбранный язык. Отображается версия на языке оригинала.',
'add_translation' => 'Добавить перевод',
'translation_added' => 'Перевод добавлен.',
'translation_deleted' => 'Перевод удалён.',
'set_as_default' => 'Установить по умолчанию',
'delete_translation' => 'Удалить перевод',
'delete_translation_blocked' => 'Перевод языка по умолчанию нельзя удалить.',
'translation_tabs_label' => 'Языки',
], ],
// Quick Switcher // Quick Switcher
@@ -124,6 +132,25 @@
'back_to_home' => 'Вернуться на главную', 'back_to_home' => 'Вернуться на главную',
], ],
'locale_names' => [
'en' => 'Английский',
'ja' => 'Японский',
'zh-CN' => 'Китайский (упрощённый)',
'zh-TW' => 'Китайский (традиционный)',
'ko' => 'Корейский',
'hi' => 'Хинди',
'vi' => 'Вьетнамский',
'tr' => 'Турецкий',
'de' => 'Немецкий',
'fr' => 'Французский',
'es' => 'Испанский',
'pt-BR' => 'Португальский (Бразилия)',
'ru' => 'Русский',
'uk' => 'Украинский',
'it' => 'Итальянский',
'pl' => 'Польский',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Профиль', 'title' => 'Профиль',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'İçerik', 'content_label' => 'İçerik',
'content_placeholder' => 'Markdown\'ınızı buraya yazın...', 'content_placeholder' => 'Markdown\'ınızı buraya yazın...',
'saving' => 'Kaydediliyor...', 'saving' => 'Kaydediliyor...',
'fallback_notice' => 'Bu makalenin seçtiğiniz dile çevirisi mevcut değil. Orijinal dil sürümü gösteriliyor.',
'add_translation' => 'Çeviri ekle',
'translation_added' => 'Çeviri eklendi.',
'translation_deleted' => 'Çeviri silindi.',
'set_as_default' => 'Varsayılan olarak ayarla',
'delete_translation' => 'Çeviriyi sil',
'delete_translation_blocked' => 'Varsayılan dilin çevirisi silinemez.',
'translation_tabs_label' => 'Diller',
], ],
// Quick Switcher // Quick Switcher
@@ -124,6 +132,25 @@
'back_to_home' => 'Ana Sayfaya Dön', 'back_to_home' => 'Ana Sayfaya Dön',
], ],
'locale_names' => [
'en' => 'İngilizce',
'ja' => 'Japonca',
'zh-CN' => 'Basitleştirilmiş Çince',
'zh-TW' => 'Geleneksel Çince',
'ko' => 'Korece',
'hi' => 'Hintçe',
'vi' => 'Vietnamca',
'tr' => 'Türkçe',
'de' => 'Almanca',
'fr' => 'Fransızca',
'es' => 'İspanyolca',
'pt-BR' => 'Portekizce (Brezilya)',
'ru' => 'Rusça',
'uk' => 'Ukraynaca',
'it' => 'İtalyanca',
'pl' => 'Lehçe',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Profil', 'title' => 'Profil',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Вміст', 'content_label' => 'Вміст',
'content_placeholder' => 'Напишіть тут ваш markdown...', 'content_placeholder' => 'Напишіть тут ваш markdown...',
'saving' => 'Збереження...', 'saving' => 'Збереження...',
'fallback_notice' => 'Цю статтю не перекладено вибраною мовою. Відображається версія мовою оригіналу.',
'add_translation' => 'Додати переклад',
'translation_added' => 'Переклад додано.',
'translation_deleted' => 'Переклад видалено.',
'set_as_default' => 'Встановити за замовчуванням',
'delete_translation' => 'Видалити переклад',
'delete_translation_blocked' => 'Переклад мови за замовчуванням не можна видалити.',
'translation_tabs_label' => 'Мови',
], ],
// Quick Switcher // Quick Switcher
@@ -124,6 +132,25 @@
'back_to_home' => 'Повернутися на головну', 'back_to_home' => 'Повернутися на головну',
], ],
'locale_names' => [
'en' => 'Англійська',
'ja' => 'Японська',
'zh-CN' => 'Китайська (спрощена)',
'zh-TW' => 'Китайська (традиційна)',
'ko' => 'Корейська',
'hi' => 'Гінді',
'vi' => 'В\'єтнамська',
'tr' => 'Турецька',
'de' => 'Німецька',
'fr' => 'Французька',
'es' => 'Іспанська',
'pt-BR' => 'Португальська (Бразилія)',
'ru' => 'Російська',
'uk' => 'Українська',
'it' => 'Італійська',
'pl' => 'Польська',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Профіль', 'title' => 'Профіль',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Nội dung', 'content_label' => 'Nội dung',
'content_placeholder' => 'Viết markdown của bạn ở đây...', 'content_placeholder' => 'Viết markdown của bạn ở đây...',
'saving' => 'Đang lưu...', 'saving' => 'Đang lưu...',
'fallback_notice' => 'Bài viết này không có bản dịch sang ngôn ngữ bạn đã chọn. Đang hiển thị phiên bản ngôn ngữ gốc.',
'add_translation' => 'Thêm bản dịch',
'translation_added' => 'Đã thêm bản dịch.',
'translation_deleted' => 'Đã xoá bản dịch.',
'set_as_default' => 'Đặt làm mặc định',
'delete_translation' => 'Xoá bản dịch',
'delete_translation_blocked' => 'Không thể xoá bản dịch của ngôn ngữ mặc định.',
'translation_tabs_label' => 'Ngôn ngữ',
], ],
// Quick Switcher // Quick Switcher
@@ -124,6 +132,25 @@
'back_to_home' => 'Quay lại trang chủ', 'back_to_home' => 'Quay lại trang chủ',
], ],
'locale_names' => [
'en' => 'Tiếng Anh',
'ja' => 'Tiếng Nhật',
'zh-CN' => 'Tiếng Trung (Giản thể)',
'zh-TW' => 'Tiếng Trung (Phồn thể)',
'ko' => 'Tiếng Hàn',
'hi' => 'Tiếng Hindi',
'vi' => 'Tiếng Việt',
'tr' => 'Tiếng Thổ Nhĩ Kỳ',
'de' => 'Tiếng Đức',
'fr' => 'Tiếng Pháp',
'es' => 'Tiếng Tây Ban Nha',
'pt-BR' => 'Tiếng Bồ Đào Nha (Brasil)',
'ru' => 'Tiếng Nga',
'uk' => 'Tiếng Ukraine',
'it' => 'Tiếng Ý',
'pl' => 'Tiếng Ba Lan',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => 'Hồ sơ', 'title' => 'Hồ sơ',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => '内容', 'content_label' => '内容',
'content_placeholder' => '在此输入Markdown内容...', 'content_placeholder' => '在此输入Markdown内容...',
'saving' => '保存中...', 'saving' => '保存中...',
'fallback_notice' => '此文章没有您所选语言的翻译。正在显示原语言版本。',
'add_translation' => '添加翻译',
'translation_added' => '翻译已添加。',
'translation_deleted' => '翻译已删除。',
'set_as_default' => '设为默认',
'delete_translation' => '删除翻译',
'delete_translation_blocked' => '无法删除默认语言的翻译。',
'translation_tabs_label' => '语言',
], ],
// Quick Switcher // Quick Switcher
@@ -123,6 +131,25 @@
'back_to_home' => '返回首页', 'back_to_home' => '返回首页',
], ],
'locale_names' => [
'en' => '英语',
'ja' => '日语',
'zh-CN' => '简体中文',
'zh-TW' => '繁体中文',
'ko' => '韩语',
'hi' => '印地语',
'vi' => '越南语',
'tr' => '土耳其语',
'de' => '德语',
'fr' => '法语',
'es' => '西班牙语',
'pt-BR' => '葡萄牙语(巴西)',
'ru' => '俄语',
'uk' => '乌克兰语',
'it' => '意大利语',
'pl' => '波兰语',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => '个人资料', 'title' => '个人资料',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => '內容', 'content_label' => '內容',
'content_placeholder' => '在此輸入Markdown內容...', 'content_placeholder' => '在此輸入Markdown內容...',
'saving' => '儲存中...', 'saving' => '儲存中...',
'fallback_notice' => '此文章沒有您所選語言的翻譯。正在顯示原語言版本。',
'add_translation' => '新增翻譯',
'translation_added' => '翻譯已新增。',
'translation_deleted' => '翻譯已刪除。',
'set_as_default' => '設為預設',
'delete_translation' => '刪除翻譯',
'delete_translation_blocked' => '無法刪除預設語言的翻譯。',
'translation_tabs_label' => '語言',
], ],
// Quick Switcher // Quick Switcher
@@ -123,6 +131,25 @@
'back_to_home' => '返回首頁', 'back_to_home' => '返回首頁',
], ],
'locale_names' => [
'en' => '英語',
'ja' => '日語',
'zh-CN' => '簡體中文',
'zh-TW' => '繁體中文',
'ko' => '韓語',
'hi' => '印地語',
'vi' => '越南語',
'tr' => '土耳其語',
'de' => '德語',
'fr' => '法語',
'es' => '西班牙語',
'pt-BR' => '葡萄牙語(巴西)',
'ru' => '俄語',
'uk' => '烏克蘭語',
'it' => '義大利語',
'pl' => '波蘭語',
],
// Profile // Profile
'profile' => [ 'profile' => [
'title' => '個人資料', 'title' => '個人資料',
@@ -56,6 +56,65 @@ class="inline-flex items-center justify-center px-3 sm:px-4 py-2 bg-indigo-600 b
</div> </div>
</div> </div>
@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' }}">
{{ __('messages.locale_names.' . $loc) }}
@if($loc === $document->default_locale)
<span class="ml-1 text-xs text-gray-400"></span>
@endif
</a>
@endforeach
@if($isNewLocale && $editingLocale)
<span class="px-3 py-2 text-sm font-medium border-b-2 border-indigo-500 text-indigo-600">
{{ __('messages.locale_names.' . $editingLocale) }}
<span class="ml-1 text-xs text-gray-400">({{ __('messages.documents.new_document') }})</span>
</span>
@endif
@php $missingLocales = array_diff(array_keys($allLocales), $availableLocales, $isNewLocale ? [$editingLocale] : []); @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') }}
</button>
</div>
@endif
</div>
@endif
<!-- Form --> <!-- Form -->
<form wire:submit.prevent="save" class="space-y-6"> <form wire:submit.prevent="save" class="space-y-6">
<!-- Title --> <!-- Title -->
@@ -1,4 +1,20 @@
<div class="max-w-4xl mx-auto p-4 sm:p-6 lg:p-8"> <div class="max-w-4xl mx-auto p-4 sm:p-6 lg:p-8">
@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
<!-- Document Header --> <!-- Document Header -->
<div class="mb-6 sm:mb-8"> <div class="mb-6 sm:mb-8">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-4"> <div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-4">
+9
View File
@@ -47,6 +47,15 @@
Route::get('/{document}/edit', DocumentEditor::class) Route::get('/{document}/edit', DocumentEditor::class)
->middleware('can:update,document') ->middleware('can:update,document')
->name('edit'); ->name('edit');
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');
}); });
// 公開ルート(動的ルートは最後に) // 公開ルート(動的ルートは最後に)
+130
View File
@@ -0,0 +1,130 @@
<?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'));
}
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'));
}
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']);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace Tests\Feature;
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(),
]);
}
public function test_existing_documents_data_is_copied_to_translations(): void
{
// Roll the new migration back so the legacy columns exist again
\Illuminate\Support\Facades\Artisan::call('migrate:rollback', ['--step' => 1]);
$this->assertTrue(\Illuminate\Support\Facades\Schema::hasColumn('documents', 'title'));
// Seed a legacy document row directly
\Illuminate\Support\Facades\DB::table('documents')->insert([
'path' => 'Legacy.md',
'title' => 'Legacy Title',
'slug' => 'legacy-title',
'content' => '# Legacy body',
'rendered_html' => '<h1>Legacy body</h1>',
'file_size' => 0,
'file_hash' => str_repeat('0', 64),
'file_modified_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$docId = \Illuminate\Support\Facades\DB::table('documents')->where('slug', 'legacy-title')->value('id');
// Re-run the migration
\Illuminate\Support\Facades\Artisan::call('migrate');
// Verify the data was copied to document_translations
$translation = \Illuminate\Support\Facades\DB::table('document_translations')
->where('document_id', $docId)
->first();
$this->assertNotNull($translation, 'Translation row should have been created from legacy data');
$this->assertSame('Legacy Title', $translation->title);
$this->assertSame('# Legacy body', $translation->content);
$this->assertSame('<h1>Legacy body</h1>', $translation->rendered_html);
$this->assertSame(config('app.locale', 'en'), $translation->locale);
// Verify documents.default_locale was set
$defaultLocale = \Illuminate\Support\Facades\DB::table('documents')->where('id', $docId)->value('default_locale');
$this->assertSame(config('app.locale', 'en'), $defaultLocale);
}
}
@@ -0,0 +1,94 @@
<?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));
}
}
+145
View File
@@ -0,0 +1,145 @@
<?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);
}
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);
}
}
@@ -0,0 +1,55 @@
<?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);
}
}
@@ -0,0 +1,129 @@
<?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);
}
}
@@ -0,0 +1,88 @@
<?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);
}
}