36 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
Yutaka Kurosaki b924564c22 Upgrade to Laravel 13
Bump laravel/framework ^12.0 → ^13.0, laravel/tinker ^2.10 → ^3.0,
phpunit/phpunit ^11.5 → ^12.0, and php ^8.2 → ^8.3 (Laravel 13
minimum). No app code changes required: codebase has no
VerifyCsrfToken, JobAttempted/QueueBusy listeners, custom
Manager::extend, custom queue drivers, or model boot()
instantiation that the v13 breaking changes touch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:12 +09:00
Yutaka Kurosaki def78d4754 Address final review: Vimeo regex boundary + spec accuracy
- Vimeo regex now rejects URLs like vimeo.com/123abc that were
  silently truncated to ID 123 and produced broken iframes. Negative
  lookahead (?![A-Za-z0-9]) ensures the captured digits are not
  followed by alphanumerics. Two false-positive test cases added.
- Spec corrected: HtmlInline nodes ARE filtered regardless of
  insertion path; the implementation uses a dedicated MediaEmbedNode
  + renderer to bypass the filter only for trusted programmatic embeds.
  Components list updated to include the two extra files.
- Plan Task 6 regex updated for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:18:26 +09:00
Yutaka Kurosaki 81efac4a53 Add integration tests for mixed media in Markdown rendering
Covers image+video coexistence, multiple videos in one paragraph,
videos inside list items, wiki link non-interference, YouTube
timestamps end-to-end, and audio rendering through renderMarkdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:08:06 +09:00
Yutaka Kurosaki f26b930b5f Wire MediaEmbedExtension into Document::renderMarkdown
The extension registers a DocumentParsedEvent listener that walks the
AST, finds Image nodes whose URLs match media patterns (via
MediaUrlResolver), and replaces them with MediaEmbedNode instances
containing the appropriate <video>/<audio>/<iframe> markup.

A custom MediaEmbedNode + MediaEmbedNodeRenderer pair bypasses the
html_input filter (which would strip raw HTML when set to 'strip'),
allowing programmatically generated embed HTML to pass through safely
while user-authored raw HTML remains stripped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:03:09 +09:00
Yutaka Kurosaki 6ee4dcfc21 Detect Vimeo URLs and emit iframe with dnt=1
Recognizes vimeo.com/{id} and player.vimeo.com/video/{id}. Preserves
timestamps from #t=30s and ?t=30s as #t=30s on the embed URL (Vimeo
convention).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:57:05 +09:00
Yutaka Kurosaki 9486d97c73 Normalize YouTube timestamp parameters to ?start=N
Accepts ?t=30s, ?t=30, ?t=1m20s, ?t=1h2m3s, and ?start=N. Converts to
seconds and emits as ?start=N on the embed URL. ?t= takes priority over
?start= when both are present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:52:49 +09:00
Yutaka Kurosaki 6debaf93bc Fix regex delimiter in plan Task 5
Task 4 implementer discovered that # delimiter conflicts with literal #
inside [/?#] and [&#] character classes (PHP PCRE terminates the regex
early). Same patterns repeat in Task 5; pre-update so a re-execution
does not hit the same bug. Vimeo regex in Task 6 is unaffected (no
literal # in pattern body).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:51:48 +09:00
Yutaka Kurosaki 5b6e344ee9 Detect YouTube URLs and emit privacy-enhanced iframe
Recognizes youtu.be, watch?v=, shorts, embed, and mobile variants.
Emits an iframe pointing to youtube-nocookie.com with lazy loading,
strict-origin referrer policy, and allowfullscreen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:47:46 +09:00
Yutaka Kurosaki bb9843fd47 Detect local audio URLs in MediaUrlResolver
Recognizes mp3/wav/ogg/m4a and emits <audio controls class="kb-audio">.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:44:35 +09:00
Yutaka Kurosaki 7e445eb2fe Detect local video URLs in MediaUrlResolver
Recognizes mp4/webm/ogv/mov/m4v on URL path (case-insensitive, ignoring
query strings) and emits <video controls class="kb-video">.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:41:29 +09:00
Yutaka Kurosaki 6daa001388 Scaffold MediaUrlResolver with null fallback
Initial skeleton returning null for any non-media URL. Subsequent commits
add detection for video, audio, YouTube, and Vimeo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:38:19 +09:00
Yutaka Kurosaki 1563aff964 Add implementation plan for Markdown media embed
Plan breaks the work into 9 TDD tasks: scaffold resolver, video
detection, audio detection, YouTube URL detection, YouTube timestamps,
Vimeo detection, listener+extension wiring, integration tests, full
test suite verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:25:08 +09:00
Yutaka Kurosaki 692f4d5492 Restrict document edit/delete to owners and close public registration
Adds DocumentPolicy gating update/delete to the creator (admins bypass via
before()), invokes $this->authorize() in DocumentEditor mount/save/delete,
applies can:update,document on the edit route, hides the edit button for
non-owners, and removes the open /register routes so accounts must be
provisioned via the admin panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:22:18 +09:00
Yutaka Kurosaki 01a11328ec Add design spec for Markdown media embed extension
Approved design for extending image syntax `![](url)` to render videos,
audio, YouTube, and Vimeo embeds. Preserves html_input=>strip safety and
existing image/Wiki-link behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:21:10 +09:00
56 changed files with 9491 additions and 935 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,331 @@
# Media Embed Design
**Date:** 2026-05-09
**Status:** Approved
**Scope:** Add support for embedding video files, audio files, YouTube, and Vimeo in Markdown documents using the standard image syntax `![](url)`.
## Background
The knowledge base currently renders Markdown via `League\CommonMark` with `html_input => 'strip'`, which removes raw HTML. This is a deliberate safety choice: the project is published as OSS and may be deployed in environments with multiple authors or untrusted input, so raw HTML passthrough is undesirable.
To migrate fixed pages from a previous WordPress site (which used `<video>` tags and YouTube/Vimeo embeds), Markdown needs a safe mechanism to express media embeds. The chosen approach extends the existing image syntax: when an `![](url)` URL points to a media resource, the rendered output becomes `<video>`, `<audio>`, or `<iframe>` instead of `<img>`.
## Goals
- Support embedding local video and audio files via `![](url)` syntax
- Support YouTube and Vimeo embeds via the same syntax
- Use privacy-enhanced embed modes (`youtube-nocookie.com`, Vimeo `?dnt=1`)
- Preserve existing image rendering and Wiki link behavior unchanged
- Maintain `html_input => 'strip'` for safety
- Provide unit-test coverage for URL parsing and rendering
## Non-Goals
- Custom attributes (width, autoplay, poster) — sizing handled via CSS only
- Other embed providers (Twitch, SoundCloud, Spotify, etc.)
- `og:video` OGP tags
- VTT subtitles / `<track>` elements
- Download cards for zip/binary files (a separate future task)
- Rerendering existing documents (a separate Artisan command may be added later)
## Architecture
```
Markdown input
CommonMarkParser
│ (after parse)
DocumentParsedEvent ───► MediaEmbedExtension listener
│ Walk Image nodes, classify URL:
│ ├─ video extension → <video>
│ ├─ audio extension → <audio>
│ ├─ YouTube URL → <iframe> (nocookie)
│ ├─ Vimeo URL → <iframe> (dnt)
│ └─ other → leave unchanged (renders as <img>)
│ Replace matching node with HtmlInline
HTML output (existing render flow unchanged)
```
The extension lives entirely in CommonMark's event-based AST modification layer. No changes are required to the existing Wiki link, GFM, or image rendering logic.
### Boundary Summary
- **Input:** Markdown string (unchanged)
- **Output:** HTML string — some `![](...)` produce `<video>`, `<audio>`, or `<iframe>` instead of `<img>`
- **Untouched:** Wiki links, GFM extension, default image rendering, `html_input => 'strip'` policy
## Components
### New files
#### `src/app/Markdown/MediaEmbedExtension.php`
CommonMark `ExtensionInterface` implementation. Sole responsibility: register the listener.
- Public API: `register(EnvironmentBuilderInterface $env): void`
- Wires `DocumentParsedEvent` to `MediaEmbedListener::handle`
#### `src/app/Markdown/MediaUrlResolver.php`
Pure URL classification class with no external dependencies. Highly testable.
- Public API: `resolve(string $url): ?string`
- Returns the replacement HTML string if URL is a recognized media resource
- Returns `null` if URL should fall through to default image rendering
- Internal helpers:
- `detectVideo(string $url): ?string`
- `detectAudio(string $url): ?string`
- `detectYouTube(string $url): ?string`
- `detectVimeo(string $url): ?string`
- Order: video → audio → YouTube → Vimeo → null
#### `src/app/Markdown/MediaEmbedListener.php`
Thin glue layer. Receives `DocumentParsedEvent`, walks the AST, and delegates URL classification to `MediaUrlResolver`.
- Public API: `handle(DocumentParsedEvent $event): void`
- For each `Image` node: call resolver; if non-null, replace node with a `MediaEmbedNode`
#### `src/app/Markdown/MediaEmbedNode.php`
Custom AST node that carries the pre-rendered embed HTML string.
- Extends `AbstractStringContainer`
- Does NOT implement `RawMarkupContainerInterface` — this is intentional so the node is not subject to `HtmlFilter`
- Holds its literal content (the HTML string) for direct output by its renderer
#### `src/app/Markdown/MediaEmbedNodeRenderer.php`
Dedicated renderer for `MediaEmbedNode`.
- Implements `NodeRendererInterface`
- Returns the node's literal content directly, without invoking any HTML filter
- This is the mechanism that allows trusted embed HTML to survive the `html_input => 'strip'` policy
### Modified files
#### `src/app/Models/Document.php` — `renderMarkdown()`
Add a single line:
```php
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
```
No other changes.
### File-split rationale
Separating `MediaUrlResolver` from `MediaEmbedListener` isolates "URL parsing / HTML generation" from "AST manipulation." The former is pure and exhaustively testable; the latter is a thin glue layer. This keeps each unit single-purpose and easier to reason about.
## Data Flow Specification
### Input → Output reference
| Markdown input | Output HTML (key parts) |
|---|---|
| `![alt](/foo.png)` | `<img src="/foo.png" alt="alt">` *(default, unchanged)* |
| `![](/demo.mp4)` | `<video src="/demo.mp4" controls class="kb-video"></video>` |
| `![](/audio.mp3)` | `<audio src="/audio.mp3" controls class="kb-audio"></audio>` |
| `![](https://youtu.be/abc123XYZ_-)` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-" ...></iframe>` |
| `![](https://www.youtube.com/watch?v=abc123XYZ_-&t=30s)` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-?start=30" ...></iframe>` |
| `![](https://www.youtube.com/shorts/abc123XYZ_-)` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-" ...></iframe>` |
| `![](https://vimeo.com/123456789)` | `<iframe src="https://player.vimeo.com/video/123456789?dnt=1" ...></iframe>` |
| `![](https://vimeo.com/123456789#t=30s)` | `<iframe src="https://player.vimeo.com/video/123456789?dnt=1#t=30s" ...></iframe>` |
### Extension matching (case-insensitive)
- Video: `mp4`, `webm`, `ogv`, `mov`, `m4v`
- Audio: `mp3`, `wav`, `ogg`, `m4a`
Matching is performed on the URL **path** only (after stripping `?query` and `#fragment`) so signed CDN URLs with `?token=...` are not misclassified.
### YouTube URL recognition
The video ID is the strict pattern `[A-Za-z0-9_-]{11}`. Recognized URL forms:
| Pattern | Example |
|---|---|
| `youtu.be/{id}` | `https://youtu.be/abc123XYZ_-` |
| `youtube.com/watch?v={id}` | `https://www.youtube.com/watch?v=abc123XYZ_-` |
| `youtube.com/shorts/{id}` | `https://www.youtube.com/shorts/abc123XYZ_-` |
| `youtube.com/embed/{id}` | `https://www.youtube.com/embed/abc123XYZ_-` |
| `m.youtube.com/...` | mobile variant of the above |
Timestamp normalization (first match wins; `t` preferred over `start`):
- `?t=30s` / `?t=30` / `&t=1m20s` → seconds → `?start=N`
- `?start=N` → preserved
- No timestamp → no `?start` parameter
### Vimeo URL recognition
| Pattern | Example |
|---|---|
| `vimeo.com/{id}` | `https://vimeo.com/123456789` |
| `player.vimeo.com/video/{id}` | `https://player.vimeo.com/video/123456789` |
ID is digits only.
Timestamp:
- `#t=30s` → preserved as `#t=30s` (Vimeo convention)
- `?t=30s` → preserved as `#t=30s`
### iframe attribute template
```html
<iframe src="..."
width="560" height="315"
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
frameborder="0"
class="kb-embed kb-embed-{provider}">
</iframe>
```
`{provider}` is `youtube` or `vimeo`. Class hooks let CSS introduce aspect-ratio control later.
### Resolution order
1. Video extension → emit `<video>`, return
2. Audio extension → emit `<audio>`, return
3. YouTube → emit `<iframe>`, return
4. Vimeo → emit `<iframe>`, return
5. None match → return `null`; node renders as default `<img>`
## Error Handling and Edge Cases
| Case | Behavior | Reason |
|---|---|---|
| `parse_url` failure | return `null` → default `<img>` | Fall back to CommonMark default |
| URL with no extension | return `null` → default `<img>` | Extension matching is path-suffix based |
| YouTube ID does not match `[A-Za-z0-9_-]{11}` | return `null` → default `<img>` | Strict matching avoids false positives |
| Vimeo ID is not digits | return `null` → default `<img>` | Same |
| Empty URL | return `null` | `parse_url` returns empty path |
**Principle:** Unrecognized URLs are not transformed. Exceptions are not thrown. Default CommonMark rendering handles the fallback.
### XSS hardening
All output URLs are passed through `htmlspecialchars($url, ENT_QUOTES, 'UTF-8')` before being embedded in HTML strings. Attack-vector analysis:
- `![](javascript:alert(1))` — does not match a media extension → `null` → CommonMark's `allow_unsafe_links => false` blocks `<img src="javascript:...">`
- `![](https://youtu.be/"><script>...)` — strict ID regex `[A-Za-z0-9_-]{11}` cannot extract from a URL containing `"` or `>``null` → default rendering, where CommonMark also escapes
- `![](/foo.mp4")` — trailing quote breaks extension matching at the path-cleaning step; even if it passed, `htmlspecialchars` would escape the output
### Relation to `html_input => 'strip'`
The `'strip'` setting is preserved. All `HtmlInline` nodes — whether written by the user in Markdown source or inserted programmatically by an extension — go through `HtmlFilter::filter()`, which strips their content under `'strip'` mode. To emit the embed HTML safely without bypassing this policy, the extension introduces a custom node type:
- `MediaEmbedNode` extends `AbstractStringContainer` and deliberately does NOT implement `RawMarkupContainerInterface`
- `MediaEmbedNodeRenderer` returns the node's literal content directly, without invoking any HTML filter
Therefore:
- User-written `<script>` in Markdown source → produces `HtmlInline` → still stripped
- `<video>` / `<audio>` / `<iframe>` inserted by `MediaEmbedExtension` → produces `MediaEmbedNode` → output as intended
- The security boundary is "only the explicitly trusted node type bypasses filtering," and that node type is reachable only through `MediaEmbedListener` after `MediaUrlResolver` has classified the URL as a known media pattern.
### `alt` and `title`
Markdown image syntax allows `![alt](url "title")`.
- `<video>` / `<audio>` have no `alt` attribute → ignored
- `title` is preserved on `<video>` / `<audio>` as `title="..."` (optional)
- iframes ignore both (the YouTube/Vimeo player surfaces its own title)
VTT subtitles / `<track>` elements are out of scope.
### Multiple media in one paragraph
```markdown
![](/a.mp4) and ![](/b.mp4)
```
Two `<video>` elements appear within the same `<p>`. `<video>` is phrasing content per the HTML spec, so this is valid. CSS can apply `display: block` if needed.
### Existing documents
Existing rows in `documents.rendered_html` may be stale after this change. Mitigation is left to the implementation phase — most likely a `docs:rerender` Artisan command (or a one-off `tinker` invocation) that re-saves each `Document` to trigger the existing render hook. This is **not part of the design scope** and should be tracked separately during implementation planning.
## Testing Strategy
### `tests/Unit/Markdown/MediaUrlResolverTest.php`
Pure-unit tests against `MediaUrlResolver::resolve`.
**Video extensions** (one case per extension):
- `/demo.mp4`, `/demo.webm`, `/demo.ogv`, `/demo.mov`, `/demo.m4v``<video>` output
- `/demo.MP4` (uppercase) → recognized
- `https://example.com/path/demo.mp4?token=abc` → query stripped, recognized
**Audio extensions** (one case per extension):
- `/clip.mp3`, `/clip.wav`, `/clip.ogg`, `/clip.m4a``<audio>` output
**YouTube** (full URL pattern coverage):
- `https://youtu.be/dQw4w9WgXcQ`
- `https://www.youtube.com/watch?v=dQw4w9WgXcQ`
- `https://www.youtube.com/shorts/dQw4w9WgXcQ`
- `https://www.youtube.com/embed/dQw4w9WgXcQ`
- `https://m.youtube.com/watch?v=dQw4w9WgXcQ`
- Timestamps: `?t=30s`, `?t=90`, `?t=1m20s`, `?start=30`
- Output contains `youtube-nocookie.com`
**Vimeo:**
- `https://vimeo.com/123456789`
- `https://player.vimeo.com/video/123456789`
- Timestamps: `#t=30s`, `?t=30s`
- Output contains `?dnt=1`
**Fallback (returns `null`):**
- Normal images: `/photo.jpg`, `/icon.svg`
- No extension: `/foo`
- Invalid URL: empty string, `javascript:alert(1)`, `http://`
- Negative-match candidates: `https://example.com/youtu.be-fake/abc` (host mismatch)
- Invalid YouTube ID: `https://youtu.be/short` (less than 11 chars), special characters
**XSS resilience:**
- `https://youtu.be/abc"><script>``null` (strict ID extraction fails)
- Video URL containing `"` produces escaped output
### `tests/Unit/Markdown/MediaEmbedExtensionTest.php`
Integrated unit tests through `Document::renderMarkdown()`:
- Default image survives unchanged: `![alt](/foo.png)``<img>`
- Video embed succeeds: `![](/foo.mp4)``<video>`, no `<img>`
- Mixed Markdown: image, video, YouTube, Vimeo coexist correctly
- Wiki link coexistence: `[[other-doc]]` is unaffected
- Multiple media in one paragraph: `![](/a.mp4) ![](/b.mp4)` → two `<video>`
- List item: `- ![](/a.mp4)``<video>` inside `<li>`
### Test data convention
No fixture files. Test inputs are inline string literals so they remain greppable.
### Running
```bash
docker compose exec php php artisan test --filter=MediaUrlResolverTest
docker compose exec php php artisan test --filter=MediaEmbedExtensionTest
```
`composer test` (full suite) must remain green.
### Coverage target
No formal coverage measurement. The bar is: **every URL pattern listed in the Data Flow Specification has at least one corresponding test case.**
## Open Items for Implementation Phase
These are deliberately deferred to the planning phase, not the design:
- Whether to add a `docs:rerender` Artisan command for existing rows
- CSS additions for `.kb-video`, `.kb-audio`, `.kb-embed-*` (likely a future task)
- Updating `CLAUDE.md` to document the new media-embed convention
@@ -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'));
}
}
+85 -20
View File
@@ -2,28 +2,44 @@
namespace App\Livewire;
use App\Http\Middleware\SetLocale;
use App\Models\Document;
use App\Services\DocumentService;
use Livewire\Component;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class DocumentEditor extends Component
{
public ?Document $document = null;
public $title = '';
public $content = '';
public $directory = '';
public $isEditMode = false;
public string $title = '';
public string $content = '';
public string $editingLocale = '';
public bool $isEditMode = false;
public bool $isNewLocale = false;
public array $availableLocales = [];
public function mount(?Document $document = null)
public function mount(?Document $document = null, ?string $locale = null)
{
if ($document) {
$this->document = $document;
$this->title = $document->title;
$this->content = $document->content;
$this->directory = $document->directory;
$this->authorize('update', $document);
$this->document = $document->load('translations');
$this->isEditMode = true;
$this->availableLocales = $document->availableLocales();
$this->editingLocale = $locale ?: ($document->default_locale ?? App::getLocale());
$translation = $document->translations->firstWhere('locale', $this->editingLocale);
if ($translation) {
$this->title = $translation->title;
$this->content = $translation->content;
$this->isNewLocale = false;
} else {
$this->title = '';
$this->content = '';
$this->isNewLocale = true;
}
} else {
$this->editingLocale = App::getLocale();
$titleParam = request()->query('title');
if ($titleParam) {
$this->title = $titleParam;
@@ -33,49 +49,96 @@ public function mount(?Document $document = null)
public function save(DocumentService $documentService)
{
$this->validate([
$validated = $this->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'editingLocale' => ['required', 'string', 'in:' . implode(',', array_keys(SetLocale::SUPPORTED_LOCALES))],
]);
try {
if ($this->isEditMode && $this->document) {
$this->authorize('update', $this->document);
if ($this->isNewLocale) {
$documentService->addTranslation(
$this->document,
$this->editingLocale,
$this->title,
$this->content,
Auth::id(),
);
$this->document->refresh()->load('translations');
} else {
$this->document = $documentService->updateDocument(
$this->document,
$this->title,
$this->content,
Auth::id()
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));
} else {
$this->document = $documentService->createDocument(
$this->title,
$this->content,
Auth::id(),
$this->directory ?: null
$this->editingLocale,
);
session()->flash('message', 'Document created successfully!');
session()->flash('message', __('messages.documents.create_success'));
return $this->redirect(route('documents.show', $this->document));
}
} catch (\InvalidArgumentException $e) {
session()->flash('error', $e->getMessage());
} catch (\Exception $e) {
session()->flash('error', 'Error saving document: ' . $e->getMessage());
}
}
public function deleteTranslation(DocumentService $documentService)
{
if (!$this->isEditMode || !$this->document || $this->isNewLocale) {
return;
}
$this->authorize('update', $this->document);
try {
$documentService->deleteTranslation($this->document, $this->editingLocale);
session()->flash('message', __('messages.documents.translation_deleted'));
return $this->redirect(route('documents.show', $this->document));
} catch (\InvalidArgumentException $e) {
session()->flash('error', $e->getMessage());
}
}
public function setAsDefault(DocumentService $documentService)
{
if (!$this->isEditMode || !$this->document) {
return;
}
$this->authorize('update', $this->document);
try {
$this->document = $documentService->setDefaultLocale($this->document, $this->editingLocale);
session()->flash('message', __('messages.documents.update_success'));
return $this->redirect(route('documents.show', $this->document));
} catch (\InvalidArgumentException $e) {
session()->flash('error', $e->getMessage());
}
}
public function delete(DocumentService $documentService)
{
if (!$this->isEditMode || !$this->document) {
return;
}
$this->authorize('delete', $this->document);
try {
$documentService->deleteDocument($this->document);
session()->flash('message', 'Document deleted successfully!');
// Try to redirect to home document, or root if not found
session()->flash('message', __('messages.documents.delete_success'));
$homeDocument = Document::where('slug', 'home')->first();
if ($homeDocument) {
return redirect()->route('documents.show', $homeDocument);
@@ -90,7 +153,9 @@ public function render()
{
return view('livewire.document-editor')
->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\Services\DocumentService;
use Livewire\Component;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class DocumentViewer extends Component
{
public Document $document;
public $backlinks = [];
public $renderedContent = '';
public string $renderedContent = '';
public string $viewLocale = '';
public bool $isFallback = false;
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()) {
$documentService->recordDocumentAccess($this->document, Auth::id());
$documentService->recordDocumentAccess($document, Auth::id());
}
}
+9 -44
View File
@@ -4,65 +4,32 @@
use App\Models\Document;
use App\Services\DocumentService;
use Livewire\Component;
use Livewire\Attributes\Computed;
use Livewire\Component;
class QuickSwitcher extends Component
{
public $search = '';
public $selectedIndex = 0;
public string $search = '';
public int $selectedIndex = 0;
#[Computed]
public function results()
{
if (empty($this->search)) {
return Document::select('id', 'title', 'slug', 'path', 'updated_at')
$documents = Document::with('translations')
->orderBy('updated_at', 'desc')
->limit(10)
->get()
->map(fn($doc) => [
'id' => $doc->id,
'title' => $doc->title,
'slug' => $doc->slug,
'directory' => dirname($doc->path),
])
->toArray();
->get();
} else {
$documents = app(DocumentService::class)->search($this->search, 10);
}
// FULLTEXT検索を使用(日本語対応)
$results = Document::select('id', 'title', 'slug', 'path', 'updated_at')
->whereRaw('MATCH(title, content) AGAINST(? IN BOOLEAN MODE)', [$this->search])
->orderBy('updated_at', 'desc')
->limit(10)
->get()
->map(fn($doc) => [
return $documents->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;
])->values()->toArray();
}
public function updated($propertyName)
@@ -92,8 +59,6 @@ public function selectDocument()
$results = $this->results;
if (isset($results[$this->selectedIndex])) {
$document = $results[$this->selectedIndex];
// slug が存在することを確認
if (!empty($document['slug'])) {
return $this->redirect(route('documents.show', $document['slug']));
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Markdown;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\ExtensionInterface;
class MediaEmbedExtension implements ExtensionInterface
{
public function register(EnvironmentBuilderInterface $environment): void
{
$listener = new MediaEmbedListener(new MediaUrlResolver());
$environment->addEventListener(DocumentParsedEvent::class, [$listener, 'handle']);
$environment->addRenderer(MediaEmbedNode::class, new MediaEmbedNodeRenderer());
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Markdown;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
class MediaEmbedListener
{
public function __construct(private readonly MediaUrlResolver $resolver)
{
}
public function handle(DocumentParsedEvent $event): void
{
$imagesToReplace = [];
foreach ($event->getDocument()->iterator() as $node) {
if ($node instanceof Image) {
$imagesToReplace[] = $node;
}
}
foreach ($imagesToReplace as $image) {
$html = $this->resolver->resolve($image->getUrl());
if ($html !== null) {
$image->replaceWith(new MediaEmbedNode($html));
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Markdown;
use League\CommonMark\Node\Inline\AbstractStringContainer;
/**
* A custom inline node for programmatically generated media embeds.
*
* Unlike HtmlInline, this node does NOT implement RawMarkupContainerInterface,
* so its renderer bypasses the html_input filter entirely, allowing us to emit
* safe, programmatically constructed HTML even when html_input is set to 'strip'.
*/
class MediaEmbedNode extends AbstractStringContainer
{
}
@@ -0,0 +1,28 @@
<?php
namespace App\Markdown;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
/**
* Renders a MediaEmbedNode by emitting its literal content directly,
* without going through any html_input filtering.
*/
class MediaEmbedNodeRenderer implements NodeRendererInterface
{
/**
* @param MediaEmbedNode $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
{
MediaEmbedNode::assertInstanceOf($node);
return $node->getLiteral();
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace App\Markdown;
class MediaUrlResolver
{
private const VIDEO_EXT = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
private const AUDIO_EXT = ['mp3', 'wav', 'ogg', 'm4a'];
public function resolve(string $url): ?string
{
if ($url === '') {
return null;
}
return $this->detectVideo($url)
?? $this->detectAudio($url)
?? $this->detectYouTube($url)
?? $this->detectVimeo($url);
}
private function detectVideo(string $url): ?string
{
if (!in_array($this->getPathExtension($url), self::VIDEO_EXT, true)) {
return null;
}
$safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
return "<video src=\"{$safe}\" controls class=\"kb-video\"></video>";
}
private function detectAudio(string $url): ?string
{
if (!in_array($this->getPathExtension($url), self::AUDIO_EXT, true)) {
return null;
}
$safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
return "<audio src=\"{$safe}\" controls class=\"kb-audio\"></audio>";
}
private function detectYouTube(string $url): ?string
{
$patterns = [
'~^https?://youtu\.be/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
'~^https?://(?:www\.|m\.)?youtube\.com/watch\?(?:[^#]*&)?v=([A-Za-z0-9_-]{11})(?:[&#]|$)~',
'~^https?://(?:www\.|m\.)?youtube\.com/shorts/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
'~^https?://(?:www\.|m\.)?youtube\.com/embed/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
];
$videoId = null;
foreach ($patterns as $p) {
if (preg_match($p, $url, $m)) {
$videoId = $m[1];
break;
}
}
if ($videoId === null) {
return null;
}
$src = "https://www.youtube-nocookie.com/embed/{$videoId}";
$start = $this->extractYouTubeStart($url);
if ($start !== null) {
$src .= "?start={$start}";
}
return $this->iframeHtml($src, 'youtube');
}
private function extractYouTubeStart(string $url): ?int
{
if (preg_match('/[?&]t=([^&#]+)/', $url, $m)) {
$seconds = $this->parseTimestamp($m[1]);
if ($seconds !== null) {
return $seconds;
}
}
if (preg_match('/[?&]start=(\d+)/', $url, $m)) {
return (int) $m[1];
}
return null;
}
private function parseTimestamp(string $t): ?int
{
if (ctype_digit($t)) {
return (int) $t;
}
$total = 0;
$matched = false;
if (preg_match('/(\d+)h/', $t, $m)) {
$total += (int) $m[1] * 3600;
$matched = true;
}
if (preg_match('/(\d+)m/', $t, $m)) {
$total += (int) $m[1] * 60;
$matched = true;
}
if (preg_match('/(\d+)s/', $t, $m)) {
$total += (int) $m[1];
$matched = true;
}
return $matched ? $total : null;
}
private function detectVimeo(string $url): ?string
{
if (!preg_match('~^https?://(?:www\.|player\.)?vimeo\.com/(?:video/)?(\d+)(?![A-Za-z0-9])~', $url, $m)) {
return null;
}
$videoId = $m[1];
$src = "https://player.vimeo.com/video/{$videoId}?dnt=1";
$hash = $this->extractVimeoHash($url);
if ($hash !== null) {
$src .= '#' . $hash;
}
return $this->iframeHtml($src, 'vimeo');
}
private function extractVimeoHash(string $url): ?string
{
if (preg_match('/#(t=[^&]+)/', $url, $m)) {
return $m[1];
}
if (preg_match('/[?&](t=[^&#]+)/', $url, $m)) {
return $m[1];
}
return null;
}
private function iframeHtml(string $src, string $provider): string
{
$safe = htmlspecialchars($src, ENT_QUOTES, 'UTF-8');
return '<iframe src="' . $safe . '" '
. 'width="560" height="315" '
. 'loading="lazy" '
. 'referrerpolicy="strict-origin-when-cross-origin" '
. 'allow="autoplay; encrypted-media; picture-in-picture" '
. 'allowfullscreen frameborder="0" '
. 'class="kb-embed kb-embed-' . $provider . '"></iframe>';
}
private function getPathExtension(string $url): string
{
$path = parse_url($url, PHP_URL_PATH);
if ($path === null || $path === false) {
return '';
}
return strtolower(pathinfo($path, PATHINFO_EXTENSION));
}
}
+146 -223
View File
@@ -3,64 +3,25 @@
namespace App\Models;
use App\Helpers\SlugHelper;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
class Document extends Model
{
use 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 = [
'path',
'title',
'slug',
'content',
'rendered_html',
'default_locale',
'frontmatter',
'file_size',
'file_hash',
@@ -69,11 +30,6 @@ public function resolveRouteBinding($value, $field = null)
'updated_by',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
@@ -82,191 +38,64 @@ protected function casts(): array
];
}
/**
* Frontmatterをパース(互換性のため残す)
*
* @param string $content
* @return array{frontmatter: array, content: string}
*/
protected static function parseFrontmatter(string $content): array
public function getRouteKeyName(): string
{
$frontmatter = [];
$bodyContent = $content;
// Frontmatterの検出(--- で囲まれた部分)
if (preg_match('/^---\s*\n(.*?)\n---\s*\n(.*)/s', $content, $matches)) {
$frontmatterText = $matches[1];
$bodyContent = $matches[2];
// 簡易的なYAMLパース(key: value形式のみ)
$lines = explode("\n", $frontmatterText);
foreach ($lines as $line) {
if (preg_match('/^([^:]+):\s*(.*)$/', $line, $lineMatches)) {
$frontmatter[trim($lineMatches[1])] = trim($lineMatches[2]);
}
}
return 'slug';
}
return [
'frontmatter' => $frontmatter,
'content' => trim($bodyContent),
];
public function resolveRouteBinding($value, $field = null)
{
$document = $this->where('slug', $value)->first();
if (!$document && is_numeric($value)) {
$document = $this->where('id', $value)->first();
}
return $document;
}
/**
* Markdownをレンダリング
*
* @param string $markdown
* @return string
* Backward-compatible static delegate so existing callers and tests
* (e.g. MediaEmbedExtensionTest) keep working.
*/
public static function renderMarkdown(string $markdown): string
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
return $converter->convert($markdown)->getContent();
return DocumentTranslation::renderMarkdown($markdown);
}
/**
* [[wiki-link]]を抽出してリンクテーブルに同期
*
* @return void
*/
public function syncLinks(): void
// ----- Relations -----
public function translations(): HasMany
{
// 既存のリンクを削除
$this->outgoingLinks()->delete();
// [[wiki-link]]を抽出
preg_match_all('/\[\[([^\]]+)\]\]/', $this->content, $matches);
if (empty($matches[1])) {
return;
return $this->hasMany(DocumentTranslation::class);
}
$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++,
]);
}
}
/**
* [[wiki-link]]をHTMLリンクに変換
*
* @return string
*/
public function processLinks(): string
public function defaultTranslation(): HasOne
{
return preg_replace_callback(
'/\[\[([^\]]+)\]\]/',
function ($matches) {
$linkTitle = trim($matches[1]);
$slug = SlugHelper::generate($linkTitle);
// リンク先のドキュメントを検索
$targetDocument = static::where('title', $linkTitle)
->orWhere('slug', $slug)
->first();
if ($targetDocument) {
return '<a href="' . route('documents.show', $targetDocument->slug) . '" class="wiki-link">' . e($linkTitle) . '</a>';
} else {
return '<a href="' . route('documents.create') . '?title=' . urlencode($linkTitle) . '" class="wiki-link wiki-link-new">' . e($linkTitle) . '</a>';
}
},
$this->rendered_html
);
return $this->hasOne(DocumentTranslation::class)
->whereColumn('locale', 'documents.default_locale');
}
/**
* 全文検索スコープ
*
* @param Builder $query
* @param string $searchTerm
* @return Builder
*/
public function scopeSearch(Builder $query, string $searchTerm): Builder
{
return $query->whereRaw(
'MATCH(title, content) AGAINST(? IN BOOLEAN MODE)',
[$searchTerm]
);
}
/**
* ディレクトリ内検索スコープ
*
* @param Builder $query
* @param string $directory
* @return Builder
*/
public function scopeInDirectory(Builder $query, string $directory): Builder
{
$directory = rtrim($directory, '/') . '/';
return $query->where('path', 'like', $directory . '%');
}
/**
* 作成者リレーション
*
* @return BelongsTo
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 更新者リレーション
*
* @return BelongsTo
*/
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
/**
* 発リンク(このドキュメントから他へのリンク)
*
* @return HasMany
*/
public function outgoingLinks(): HasMany
{
return $this->hasMany(DocumentLink::class, 'source_document_id');
}
/**
* 被リンク(他のドキュメントからこのドキュメントへのリンク)
*
* @return HasMany
*/
public function incomingLinks(): HasMany
{
return $this->hasMany(DocumentLink::class, 'target_document_id');
}
/**
* このドキュメントを最近閲覧したユーザー
*
* @return HasManyThrough
*/
public function recentByUsers(): HasManyThrough
{
return $this->hasManyThrough(
@@ -279,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 string
* @return array<int, string>
*/
public function availableLocales(): array
{
return $this->translations->pluck('locale')->all();
}
// ----- Accessors (current-locale → fallback) -----
public function getTitleAttribute(): string
{
return $this->translationFor(App::getLocale())?->title ?? '';
}
public function getContentAttribute(): string
{
return $this->translationFor(App::getLocale())?->content ?? '';
}
public function getRenderedHtmlAttribute(): ?string
{
return $this->translationFor(App::getLocale())?->rendered_html;
}
// ----- Path helpers -----
public function getDirectoryAttribute(): string
{
return dirname($this->path);
}
/**
* ファイル名を取得
*
* @return string
*/
public function getFilenameAttribute(): string
{
return basename($this->path);
}
/**
* 絶対パスを取得
*
* @return string
*/
public function getAbsolutePathAttribute(): string
{
return Storage::disk('markdown')->path($this->path);
}
/**
* タイトルセット時にslugも自動生成
*
* @param string $value
* @return void
*/
public function setTitleAttribute(string $value): void
{
$this->attributes['title'] = $value;
// ----- Search scope (delegates to translations) -----
if (empty($this->attributes['slug'])) {
$this->attributes['slug'] = SlugHelper::generate($value);
public function scopeSearch(Builder $query, string $term): Builder
{
return $query->whereHas('translations', function (Builder $q) use ($term) {
DocumentTranslation::scopeSearch($q, $term);
});
}
public function scopeInDirectory(Builder $query, string $directory): Builder
{
$directory = rtrim($directory, '/') . '/';
return $query->where('path', 'like', $directory . '%');
}
/**
* 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();
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Policies;
use App\Models\Document;
use App\Models\User;
class DocumentPolicy
{
public function before(User $user): ?bool
{
return $user->isAdmin() ? true : null;
}
public function view(User $user, Document $document): bool
{
return true;
}
public function create(User $user): bool
{
return true;
}
public function update(User $user, Document $document): bool
{
return $document->created_by === $user->id;
}
public function delete(User $user, Document $document): bool
{
return $document->created_by === $user->id;
}
}
+115 -167
View File
@@ -2,143 +2,185 @@
namespace App\Services;
use App\Models\Document;
use App\Models\RecentDocument;
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\Str;
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(
string $title,
string $content,
?int $userId = null,
?string $directory = null
?string $locale = null,
): Document {
// タイトルからパスとスラッグを自動生成
// 例: "Laravel/Livewire/Components" → path="Laravel/Livewire/Components.md", slug="components"
$locale = $locale ?: App::getLocale();
[$path, $slug] = $this->generatePathAndSlug($title);
// ドキュメントをDBに作成
return DB::transaction(function () use ($title, $content, $userId, $locale, $path, $slug) {
$document = Document::create([
'path' => $path,
'title' => $title,
'slug' => $slug,
'content' => $content,
'rendered_html' => Document::renderMarkdown($content),
'default_locale' => $locale,
'created_by' => $userId,
'updated_by' => $userId,
]);
// リンクを同期
DocumentTranslation::create([
'document_id' => $document->id,
'locale' => $locale,
'title' => $title,
'content' => $content,
'rendered_html' => DocumentTranslation::renderMarkdown($content),
'created_by' => $userId,
'updated_by' => $userId,
]);
$document->load('translations');
$document->syncLinks();
return $document;
});
}
/**
* ドキュメントを更新
*
* @param Document $document
* @param string $title
* @param string $content
* @param int|null $userId
* @return Document
*/
public function updateDocument(
Document $document,
string $title,
string $content,
?int $userId = null
?int $userId = null,
?string $locale = null,
): Document {
// タイトルが変更された場合はパスとスラッグを再生成
if ($document->title !== $title) {
$locale = $locale ?: App::getLocale();
return DB::transaction(function () use ($document, $title, $content, $userId, $locale) {
$translation = $document->translations()->firstOrNew(['locale' => $locale]);
$translation->title = $title;
$translation->content = $content;
$translation->rendered_html = DocumentTranslation::renderMarkdown($content);
$translation->updated_by = $userId;
if (!$translation->exists) {
$translation->created_by = $userId;
}
$translation->save();
$document->updated_by = $userId;
// Path/slug regenerate only when editing the default-locale translation
if ($locale === $document->default_locale) {
[$path, $slug] = $this->generatePathAndSlug($title, $document->id);
$document->path = $path;
$document->slug = $slug;
}
$document->title = $title;
$document->content = $content;
$document->rendered_html = Document::renderMarkdown($content);
$document->updated_by = $userId;
// DBに保存
$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
{
// DBから削除(ソフトデリート)
return $document->delete();
}
/**
* 全文検索
*
* @param string $query
* @param int $limit
* @return \Illuminate\Database\Eloquent\Collection
* Locale-agnostic full-text search; returns distinct documents.
*/
public function search(string $query, int $limit = 20)
{
return Document::search($query)
->limit($limit)
->get();
$documentIds = DocumentTranslation::query()
->search($query)
->limit($limit * 5) // overscan to allow distinct collapse
->pluck('document_id')
->unique()
->values()
->take($limit);
if ($documentIds->isEmpty()) {
return Document::query()->whereRaw('1 = 0')->get();
}
return Document::with('translations')
->whereIn('id', $documentIds)
->get()
->sortBy(fn ($d) => $documentIds->search($d->id))
->values();
}
public function findByTitle(string $title, ?string $locale = null): ?Document
{
return (new WikiLinkResolver())->resolve($title, $locale ?: App::getLocale());
}
/**
* ディレクトリツリーを生成
*
* @return array
*/
public function getDirectoryTree(): array
{
$documents = Document::orderBy('path')->get();
$documents = Document::with('translations')->orderBy('path')->get();
$tree = [];
foreach ($documents as $document) {
$parts = explode('/', $document->path);
$current = &$tree;
foreach ($parts as $index => $part) {
$isFile = ($index === count($parts) - 1);
if ($isFile) {
// ファイル
if (!isset($current['_files'])) {
$current['_files'] = [];
}
$current['_files'][] = [
'name' => $part,
'document' => $document,
];
} else {
// ディレクトリ
if (!isset($current[$part])) {
$current[$part] = [];
}
@@ -146,67 +188,28 @@ public function getDirectoryTree(): array
}
}
}
return $tree;
}
/**
* ユーザーの最近閲覧したドキュメントを取得
*
* @param int $userId
* @param int $limit
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getRecentDocuments(int $userId, int $limit = 10)
{
return RecentDocument::getRecentForUser($userId, $limit);
}
/**
* ドキュメント閲覧を記録
*
* @param Document $document
* @param int $userId
* @return void
*/
public function recordDocumentAccess(Document $document, int $userId): void
{
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)
{
return $document->incomingLinks()
->with('sourceDocument')
->with('sourceDocument.translations')
->get()
->pluck('sourceDocument')
->filter();
}
/**
* 壊れたリンク(未作成ページへのリンク)を取得
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getBrokenLinks()
{
return DB::table('document_links')
@@ -217,39 +220,13 @@ public function getBrokenLinks()
->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
{
// タイトルをそのままパスとして使用(.md拡張子を追加)
$basePath = $title . '.md';
// 最後のパスコンポーネント(スラッシュで区切られた最後の部分)からスラッグを生成
$lastComponent = basename($title);
$baseSlug = SlugHelper::generate($lastComponent);
// ユニークなパスとスラッグを生成
$baseSlug = SlugHelper::generate(basename($title));
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
{
$path = $basePath;
@@ -261,46 +238,17 @@ private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excl
->where(function ($q) use ($path, $slug) {
$q->where('path', $path)->orWhere('slug', $slug);
});
if ($excludeDocumentId) {
$query->where('id', '!=', $excludeDocumentId);
}
if (!$query->exists()) {
break;
}
$counter++;
// パス: "title.md" → "title-2.md"
$path = preg_replace('/\.md$/', "-{$counter}.md", $basePath);
// スラッグ: "title" → "title-2"
$slug = $baseSlug . '-' . $counter;
}
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;
}
}
+4 -4
View File
@@ -6,10 +6,10 @@
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"php": "^8.3",
"cocur/slugify": "^4.7",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"laravel/framework": "^13.0",
"laravel/tinker": "^3.0",
"league/commonmark": "^2.8",
"livewire/livewire": "^3.7"
},
@@ -21,7 +21,7 @@
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3"
"phpunit/phpunit": "^12.0"
},
"autoload": {
"psr-4": {
+380 -406
View File
File diff suppressed because it is too large Load Diff
@@ -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\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
@@ -27,7 +28,9 @@ public function up(): void
// FULLTEXT検索インデックス(MySQL 5.7以降)
// 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) {
@@ -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;
use App\Models\Document;
use Illuminate\Database\Seeder;
class DocumentSeeder extends Seeder
@@ -12,43 +11,23 @@ class DocumentSeeder extends Seeder
*/
public function run(): void
{
// 既存のドキュメントがある場合はスキップ
if (Document::count() > 0) {
if (\App\Models\Document::count() > 0) {
$this->command->info('Documents already exist. Skipping...');
return;
}
$documents = [
[
'title' => 'Home',
'path' => 'Home.md',
'slug' => 'home',
'content' => $this->getHomeContent(),
],
[
'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(),
],
$service = app(\App\Services\DocumentService::class);
$defaultLocale = config('app.locale', 'en');
$docs = [
['title' => 'Home', 'content' => $this->getHomeContent()],
['title' => 'Getting Started', 'content' => $this->getGettingStartedContent()],
['title' => 'Markdown Guide', 'content' => $this->getMarkdownGuideContent()],
];
foreach ($documents as $doc) {
Document::create([
'title' => $doc['title'],
'path' => $doc['path'],
'slug' => $doc['slug'],
'content' => $doc['content'],
'rendered_html' => Document::renderMarkdown($doc['content']),
]);
$this->command->info("Created: {$doc['title']}");
foreach ($docs as $d) {
$service->createDocument($d['title'], $d['content'], null, $defaultLocale);
$this->command->info("Created: {$d['title']}");
}
$this->command->info('Initial documents created successfully!');
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Inhalt',
'content_placeholder' => 'Schreiben Sie hier Ihren Markdown...',
'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
@@ -123,6 +131,25 @@
'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' => [
'title' => 'Profil',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Content',
'content_placeholder' => 'Write your markdown here...',
'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
@@ -123,6 +131,25 @@
'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' => [
'title' => 'Profile',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Contenido',
'content_placeholder' => 'Escriba su markdown aquí...',
'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
@@ -123,6 +131,25 @@
'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' => [
'title' => 'Perfil',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Contenu',
'content_placeholder' => 'Écrivez votre markdown ici...',
'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
@@ -123,6 +131,25 @@
'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' => [
'title' => 'Profil',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'सामग्री',
'content_placeholder' => 'यहां अपना मार्कडाउन लिखें...',
'saving' => 'सहेजा जा रहा है...',
'fallback_notice' => 'इस लेख का आपकी चुनी गई भाषा में अनुवाद उपलब्ध नहीं है। मूल भाषा का संस्करण दिखाया जा रहा है।',
'add_translation' => 'अनुवाद जोड़ें',
'translation_added' => 'अनुवाद जोड़ा गया।',
'translation_deleted' => 'अनुवाद हटाया गया।',
'set_as_default' => 'डिफ़ॉल्ट के रूप में सेट करें',
'delete_translation' => 'अनुवाद हटाएं',
'delete_translation_blocked' => 'डिफ़ॉल्ट भाषा का अनुवाद हटाया नहीं जा सकता।',
'translation_tabs_label' => 'भाषाएँ',
],
// Quick Switcher
@@ -124,6 +132,25 @@
'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' => [
'title' => 'प्रोफ़ाइल',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Contenuto',
'content_placeholder' => 'Scrivi il tuo markdown qui...',
'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
@@ -124,6 +132,25 @@
'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' => [
'title' => 'Profilo',
+16
View File
@@ -39,6 +39,14 @@
'content_label' => '本文',
'content_placeholder' => 'Markdownで記述してください...',
'saving' => '保存中...',
'translation_added' => '翻訳を追加しました。',
'translation_deleted' => '翻訳を削除しました。',
'fallback_notice' => 'この記事には選択した言語の翻訳がありません。元の言語版を表示しています。',
'add_translation' => '翻訳を追加',
'set_as_default' => 'デフォルトに設定',
'delete_translation' => '翻訳を削除',
'delete_translation_blocked' => 'デフォルト言語の翻訳は削除できません。',
'translation_tabs_label' => '言語',
],
// Quick Switcher
@@ -123,6 +131,14 @@
'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' => [
'title' => 'プロフィール',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => '내용',
'content_placeholder' => '여기에 마크다운을 작성하세요...',
'saving' => '저장 중...',
'fallback_notice' => '이 문서에는 선택한 언어의 번역이 없습니다. 원본 언어 버전을 표시합니다.',
'add_translation' => '번역 추가',
'translation_added' => '번역이 추가되었습니다.',
'translation_deleted' => '번역이 삭제되었습니다.',
'set_as_default' => '기본값으로 설정',
'delete_translation' => '번역 삭제',
'delete_translation_blocked' => '기본 언어의 번역은 삭제할 수 없습니다.',
'translation_tabs_label' => '언어',
],
// Quick Switcher
@@ -123,6 +131,25 @@
'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' => [
'title' => '프로필',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Treść',
'content_placeholder' => 'Napisz swój markdown tutaj...',
'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
@@ -124,6 +132,25 @@
'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' => [
'title' => 'Profil',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Conteúdo',
'content_placeholder' => 'Escreva seu markdown aqui...',
'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
@@ -124,6 +132,25 @@
'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' => [
'title' => 'Perfil',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Содержимое',
'content_placeholder' => 'Напишите здесь ваш markdown...',
'saving' => 'Сохранение...',
'fallback_notice' => 'Эта статья не переведена на выбранный язык. Отображается версия на языке оригинала.',
'add_translation' => 'Добавить перевод',
'translation_added' => 'Перевод добавлен.',
'translation_deleted' => 'Перевод удалён.',
'set_as_default' => 'Установить по умолчанию',
'delete_translation' => 'Удалить перевод',
'delete_translation_blocked' => 'Перевод языка по умолчанию нельзя удалить.',
'translation_tabs_label' => 'Языки',
],
// Quick Switcher
@@ -124,6 +132,25 @@
'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' => [
'title' => 'Профиль',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'İçerik',
'content_placeholder' => 'Markdown\'ınızı buraya yazın...',
'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
@@ -124,6 +132,25 @@
'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' => [
'title' => 'Profil',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Вміст',
'content_placeholder' => 'Напишіть тут ваш markdown...',
'saving' => 'Збереження...',
'fallback_notice' => 'Цю статтю не перекладено вибраною мовою. Відображається версія мовою оригіналу.',
'add_translation' => 'Додати переклад',
'translation_added' => 'Переклад додано.',
'translation_deleted' => 'Переклад видалено.',
'set_as_default' => 'Встановити за замовчуванням',
'delete_translation' => 'Видалити переклад',
'delete_translation_blocked' => 'Переклад мови за замовчуванням не можна видалити.',
'translation_tabs_label' => 'Мови',
],
// Quick Switcher
@@ -124,6 +132,25 @@
'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' => [
'title' => 'Профіль',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => 'Nội dung',
'content_placeholder' => 'Viết markdown của bạn ở đây...',
'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
@@ -124,6 +132,25 @@
'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' => [
'title' => 'Hồ sơ',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => '内容',
'content_placeholder' => '在此输入Markdown内容...',
'saving' => '保存中...',
'fallback_notice' => '此文章没有您所选语言的翻译。正在显示原语言版本。',
'add_translation' => '添加翻译',
'translation_added' => '翻译已添加。',
'translation_deleted' => '翻译已删除。',
'set_as_default' => '设为默认',
'delete_translation' => '删除翻译',
'delete_translation_blocked' => '无法删除默认语言的翻译。',
'translation_tabs_label' => '语言',
],
// Quick Switcher
@@ -123,6 +131,25 @@
'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' => [
'title' => '个人资料',
+27
View File
@@ -39,6 +39,14 @@
'content_label' => '內容',
'content_placeholder' => '在此輸入Markdown內容...',
'saving' => '儲存中...',
'fallback_notice' => '此文章沒有您所選語言的翻譯。正在顯示原語言版本。',
'add_translation' => '新增翻譯',
'translation_added' => '翻譯已新增。',
'translation_deleted' => '翻譯已刪除。',
'set_as_default' => '設為預設',
'delete_translation' => '刪除翻譯',
'delete_translation_blocked' => '無法刪除預設語言的翻譯。',
'translation_tabs_label' => '語言',
],
// Quick Switcher
@@ -123,6 +131,25 @@
'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' => [
'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>
@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 wire:submit.prevent="save" class="space-y-6">
<!-- Title -->
@@ -1,4 +1,20 @@
<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 -->
<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">
@@ -6,7 +22,7 @@
{{ $document->title }}
</h1>
@auth
@can('update', $document)
<a
href="{{ route('documents.edit', $document) }}"
class="inline-flex items-center justify-center px-3 sm:px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 whitespace-nowrap"
@@ -16,7 +32,7 @@ class="inline-flex items-center justify-center px-3 sm:px-4 py-2 bg-indigo-600 t
</svg>
{{ __('messages.documents.edit') }}
</a>
@endauth
@endcan
</div>
<div class="flex flex-col sm:flex-row sm:items-center text-xs sm:text-sm text-gray-500 gap-2 sm:gap-4">
-6
View File
@@ -7,16 +7,10 @@
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
Route::get('register', [RegisteredUserController::class, 'create'])
->name('register');
Route::post('register', [RegisteredUserController::class, 'store']);
Route::get('login', [AuthenticatedSessionController::class, 'create'])
->name('login');
+12 -1
View File
@@ -44,7 +44,18 @@
// 認証が必要なルート(より具体的なルートを先に定義)
Route::middleware('auth')->group(function () {
Route::get('/create', DocumentEditor::class)->name('create');
Route::get('/{document}/edit', DocumentEditor::class)->name('edit');
Route::get('/{document}/edit', DocumentEditor::class)
->middleware('can:update,document')
->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));
}
}
@@ -0,0 +1,79 @@
<?php
namespace Tests\Unit\Markdown;
use App\Models\Document;
use Tests\TestCase;
class MediaEmbedExtensionTest extends TestCase
{
public function test_normal_image_still_renders_as_img(): void
{
$html = Document::renderMarkdown('![alt](/photo.png)');
$this->assertStringContainsString('<img', $html);
$this->assertStringContainsString('src="/photo.png"', $html);
}
public function test_video_url_renders_as_video_tag(): void
{
$html = Document::renderMarkdown('![](/demo.mp4)');
$this->assertStringContainsString('<video', $html);
$this->assertStringContainsString('src="/demo.mp4"', $html);
$this->assertStringNotContainsString('<img', $html);
}
public function test_youtube_url_renders_as_iframe(): void
{
$html = Document::renderMarkdown('![](https://youtu.be/dQw4w9WgXcQ)');
$this->assertStringContainsString('<iframe', $html);
$this->assertStringContainsString('youtube-nocookie.com', $html);
}
public function test_vimeo_url_renders_as_iframe(): void
{
$html = Document::renderMarkdown('![](https://vimeo.com/123456789)');
$this->assertStringContainsString('<iframe', $html);
$this->assertStringContainsString('player.vimeo.com', $html);
}
public function test_image_and_video_coexist_in_same_document(): void
{
$md = "![photo](/photo.png)\n\n![](/demo.mp4)";
$html = Document::renderMarkdown($md);
$this->assertStringContainsString('<img', $html);
$this->assertStringContainsString('<video', $html);
}
public function test_multiple_media_in_same_paragraph(): void
{
$html = Document::renderMarkdown('![](/a.mp4) and ![](/b.mp4)');
$this->assertSame(2, substr_count($html, '<video'));
}
public function test_video_inside_list_item(): void
{
$html = Document::renderMarkdown("- ![](/demo.mp4)");
$this->assertStringContainsString('<li>', $html);
$this->assertStringContainsString('<video', $html);
}
public function test_wiki_link_unaffected_alongside_media(): void
{
$html = Document::renderMarkdown("![](/demo.mp4)\n\n[[Other Doc]]");
$this->assertStringContainsString('<video', $html);
$this->assertStringContainsString('[[Other Doc]]', $html);
}
public function test_youtube_with_timestamp_in_document(): void
{
$html = Document::renderMarkdown('![](https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30s)');
$this->assertStringContainsString('?start=30', $html);
}
public function test_audio_url_renders_as_audio_tag(): void
{
$html = Document::renderMarkdown('![](/clip.mp3)');
$this->assertStringContainsString('<audio', $html);
$this->assertStringContainsString('src="/clip.mp3"', $html);
}
}
@@ -0,0 +1,203 @@
<?php
namespace Tests\Unit\Markdown;
use App\Markdown\MediaUrlResolver;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class MediaUrlResolverTest extends TestCase
{
private MediaUrlResolver $resolver;
protected function setUp(): void
{
parent::setUp();
$this->resolver = new MediaUrlResolver();
}
#[DataProvider('nonMediaUrls')]
public function test_returns_null_for_non_media_urls(string $url): void
{
$this->assertNull($this->resolver->resolve($url));
}
public static function nonMediaUrls(): array
{
return [
'normal image' => ['/photo.jpg'],
'svg' => ['/icon.svg'],
'png' => ['/avatar.png'],
'no extension' => ['/foo'],
'empty string' => [''],
'javascript scheme' => ['javascript:alert(1)'],
'host-only' => ['http://'],
'youtu.be lookalike host' => ['https://example.com/youtu.be-fake/abc'],
];
}
#[DataProvider('videoUrls')]
public function test_video_urls_produce_video_tag(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringStartsWith('<video', $html);
$this->assertStringContainsString('controls', $html);
$this->assertStringContainsString('class="kb-video"', $html);
}
public static function videoUrls(): array
{
return [
'mp4' => ['/demo.mp4'],
'webm' => ['/demo.webm'],
'ogv' => ['/demo.ogv'],
'mov' => ['/demo.mov'],
'm4v' => ['/demo.m4v'],
'uppercase extension' => ['/demo.MP4'],
'with query string' => ['https://example.com/path/demo.mp4?token=abc'],
'absolute http' => ['https://example.com/demo.mp4'],
];
}
public function test_video_url_is_html_escaped(): void
{
$html = $this->resolver->resolve('/path/with"quote.mp4');
$this->assertNotNull($html);
$this->assertStringNotContainsString('"quote.mp4"', $html);
$this->assertStringContainsString('&quot;', $html);
}
#[DataProvider('audioUrls')]
public function test_audio_urls_produce_audio_tag(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringStartsWith('<audio', $html);
$this->assertStringContainsString('controls', $html);
$this->assertStringContainsString('class="kb-audio"', $html);
}
public static function audioUrls(): array
{
return [
'mp3' => ['/clip.mp3'],
'wav' => ['/clip.wav'],
'ogg' => ['/clip.ogg'],
'm4a' => ['/clip.m4a'],
];
}
#[DataProvider('youtubeUrls')]
public function test_youtube_urls_produce_iframe(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringStartsWith('<iframe', $html);
$this->assertStringContainsString('youtube-nocookie.com/embed/dQw4w9WgXcQ', $html);
$this->assertStringContainsString('class="kb-embed kb-embed-youtube"', $html);
$this->assertStringContainsString('loading="lazy"', $html);
$this->assertStringContainsString('allowfullscreen', $html);
}
public static function youtubeUrls(): array
{
return [
'short youtu.be' => ['https://youtu.be/dQw4w9WgXcQ'],
'watch v=' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ'],
'shorts' => ['https://www.youtube.com/shorts/dQw4w9WgXcQ'],
'embed' => ['https://www.youtube.com/embed/dQw4w9WgXcQ'],
'mobile' => ['https://m.youtube.com/watch?v=dQw4w9WgXcQ'],
'no www watch' => ['https://youtube.com/watch?v=dQw4w9WgXcQ'],
];
}
#[DataProvider('invalidYoutubeUrls')]
public function test_invalid_youtube_urls_return_null(string $url): void
{
$this->assertNull($this->resolver->resolve($url));
}
public static function invalidYoutubeUrls(): array
{
return [
'too short id' => ['https://youtu.be/short'],
'host mismatch' => ['https://example.com/watch?v=dQw4w9WgXcQ'],
'XSS attempt in id' => ['https://youtu.be/abc"><script>'],
];
}
#[DataProvider('youtubeTimestampUrls')]
public function test_youtube_timestamp_normalizes_to_start(string $url, int $expectedStart): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringContainsString("?start={$expectedStart}", $html);
}
public static function youtubeTimestampUrls(): array
{
return [
't=30s' => ['https://youtu.be/dQw4w9WgXcQ?t=30s', 30],
't=30 (no suffix)' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30', 30],
't=1m20s' => ['https://youtu.be/dQw4w9WgXcQ?t=1m20s', 80],
't=1h2m3s' => ['https://youtu.be/dQw4w9WgXcQ?t=1h2m3s', 3723],
'start=45' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=45', 45],
];
}
#[DataProvider('vimeoUrls')]
public function test_vimeo_urls_produce_iframe(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringStartsWith('<iframe', $html);
$this->assertStringContainsString('player.vimeo.com/video/123456789', $html);
$this->assertStringContainsString('dnt=1', $html);
$this->assertStringContainsString('class="kb-embed kb-embed-vimeo"', $html);
}
public static function vimeoUrls(): array
{
return [
'vimeo.com' => ['https://vimeo.com/123456789'],
'www.vimeo.com' => ['https://www.vimeo.com/123456789'],
'player.vimeo.com' => ['https://player.vimeo.com/video/123456789'],
];
}
#[DataProvider('vimeoTimestampUrls')]
public function test_vimeo_timestamp_preserved_as_hash(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringContainsString('#t=30s', $html);
}
public static function vimeoTimestampUrls(): array
{
return [
'hash form' => ['https://vimeo.com/123456789#t=30s'],
'query form' => ['https://vimeo.com/123456789?t=30s'],
];
}
public function test_vimeo_invalid_id_returns_null(): void
{
$this->assertNull($this->resolver->resolve('https://vimeo.com/notanumber'));
}
#[DataProvider('vimeoFalsePositives')]
public function test_vimeo_false_positives_return_null(string $url): void
{
$this->assertNull($this->resolver->resolve($url));
}
public static function vimeoFalsePositives(): array
{
return [
'digits then letter' => ['https://vimeo.com/123abc'],
'digits then x' => ['https://vimeo.com/123x'],
];
}
}
+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);
}
}