Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c338e3ae5 | |||
| b90e3534ce | |||
| 85a3a5a422 | |||
| 1ce1fa23a4 | |||
| 0c13ad1e64 | |||
| c9586612f5 | |||
| 0100a0afb4 | |||
| 97171960bd | |||
| 187349521d | |||
| 6d71f5fecf | |||
| 7909c33074 | |||
| d7522f592d | |||
| 0c399c9f0f | |||
| b7a70f74e5 | |||
| 4a8622c385 | |||
| f2bdb6a069 | |||
| e83bd6981d | |||
| 7f2f8a2248 | |||
| ab846b71b2 | |||
| 01fb8b9fcf | |||
| ba25b544f5 | |||
| 3c185fac37 | |||
| b924564c22 | |||
| def78d4754 | |||
| 81efac4a53 | |||
| f26b930b5f | |||
| 6ee4dcfc21 | |||
| 9486d97c73 | |||
| 6debaf93bc | |||
| 5b6e344ee9 | |||
| bb9843fd47 | |||
| 7e445eb2fe | |||
| 6daa001388 | |||
| 1563aff964 | |||
| 692f4d5492 | |||
| 01a11328ec | |||
| 85f67871fa | |||
| 80deff661d | |||
| 8ea8b3f6b6 | |||
| d52968e697 |
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 ``.
|
||||
|
||||
## 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 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 `` 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) |
|
||||
|---|---|
|
||||
| `` | `<img src="/foo.png" alt="alt">` *(default, unchanged)* |
|
||||
| `` | `<video src="/demo.mp4" controls class="kb-video"></video>` |
|
||||
| `` | `<audio src="/audio.mp3" controls class="kb-audio"></audio>` |
|
||||
| `` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-" ...></iframe>` |
|
||||
| `` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-?start=30" ...></iframe>` |
|
||||
| `` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-" ...></iframe>` |
|
||||
| `` | `<iframe src="https://player.vimeo.com/video/123456789?dnt=1" ...></iframe>` |
|
||||
| `` | `<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:
|
||||
|
||||
- `)` — does not match a media extension → `null` → CommonMark's `allow_unsafe_links => false` blocks `<img src="javascript:...">`
|
||||
- `` — strict ID regex `[A-Za-z0-9_-]{11}` cannot extract from a URL containing `"` or `>` → `null` → default rendering, where CommonMark also escapes
|
||||
- `` — 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 ``.
|
||||
|
||||
- `<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
|
||||
 and 
|
||||
```
|
||||
|
||||
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: `` → `<img>`
|
||||
- Video embed succeeds: `` → `<video>`, no `<img>`
|
||||
- Mixed Markdown: image, video, YouTube, Vimeo coexist correctly
|
||||
- Wiki link coexistence: `[[other-doc]]` is unaffected
|
||||
- Multiple media in one paragraph: ` ` → two `<video>`
|
||||
- List item: `- ` → `<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: `` or ``.
|
||||
- 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 `` 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:
|
||||
- `` → image extension, no embed rewrite, `<img src="/storage/media/{uuid}.png" alt="alt">`.
|
||||
- `` → 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>`.
|
||||
- `` → 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 `` 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` | `` 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)` unique/cascade delete/`renderMarkdown()` |
|
||||
| `tests/Unit/Services/WikiLinkResolverTest.php` | 解決順序5段階すべて/同名タイトル衝突時の決定論性/slug fallback/nullケース |
|
||||
| `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/削除DELETE(default_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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ImageUploadController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle image upload from EasyMDE editor
|
||||
*/
|
||||
public function upload(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'image' => [
|
||||
'required',
|
||||
'file',
|
||||
'mimes:jpeg,jpg,png,gif,webp',
|
||||
'max:2048', // 2MB
|
||||
],
|
||||
]);
|
||||
|
||||
$file = $request->file('image');
|
||||
|
||||
// Get original filename without extension for alt text
|
||||
$originalName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||
|
||||
// Generate unique filename: YYYY/MM/uuid.extension
|
||||
$year = date('Y');
|
||||
$month = date('m');
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$filename = Str::uuid() . '.' . $extension;
|
||||
$path = "images/{$year}/{$month}/{$filename}";
|
||||
|
||||
// Store to public disk
|
||||
Storage::disk('public')->putFileAs(
|
||||
"images/{$year}/{$month}",
|
||||
$file,
|
||||
$filename
|
||||
);
|
||||
|
||||
// Return URL for EasyMDE (use APP_URL)
|
||||
$url = asset('storage/' . $path);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'filePath' => $url,
|
||||
'altText' => $originalName,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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']));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Generated
+864
-862
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) {
|
||||
|
||||
+115
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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!');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'प्रोफ़ाइल',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'プロフィール',
|
||||
|
||||
@@ -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' => '프로필',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'Профиль',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => 'Профіль',
|
||||
|
||||
@@ -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ơ',
|
||||
|
||||
@@ -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' => '个人资料',
|
||||
|
||||
@@ -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' => '個人資料',
|
||||
|
||||
Generated
+276
-227
@@ -4,6 +4,7 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "html",
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"easymde": "^2.20.0"
|
||||
@@ -34,9 +35,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -51,9 +52,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -68,9 +69,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -85,9 +86,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -102,9 +103,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -119,9 +120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -136,9 +137,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -153,9 +154,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -170,9 +171,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -187,9 +188,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -204,9 +205,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -221,9 +222,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -238,9 +239,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -255,9 +256,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -272,9 +273,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -289,9 +290,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -306,9 +307,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -323,9 +324,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -340,9 +341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -357,9 +358,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -374,9 +375,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -391,9 +392,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -408,9 +409,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -425,9 +426,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -442,9 +443,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -459,9 +460,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -557,9 +558,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
|
||||
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
|
||||
"integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -571,9 +572,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
|
||||
"integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -585,9 +586,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
|
||||
"integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -599,9 +600,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
|
||||
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
|
||||
"integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -613,9 +614,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
|
||||
"integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -627,9 +628,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
|
||||
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
|
||||
"integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -641,9 +642,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
|
||||
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
|
||||
"integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -655,9 +656,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
|
||||
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
|
||||
"integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -669,9 +670,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -683,9 +684,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -697,9 +698,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -711,9 +726,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -725,9 +754,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -739,9 +768,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -753,9 +782,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -767,9 +796,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -781,9 +810,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -794,10 +823,24 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
|
||||
"integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
|
||||
"integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -809,9 +852,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
|
||||
"integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -823,9 +866,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
|
||||
"integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -837,9 +880,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -851,9 +894,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
|
||||
"integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1329,15 +1372,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
@@ -1765,9 +1808,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -1778,32 +1821,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
@@ -1866,9 +1909,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2621,9 +2664,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -2651,9 +2694,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -2820,11 +2863,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
@@ -2908,9 +2954,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
|
||||
"integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2924,28 +2970,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.53.3",
|
||||
"@rollup/rollup-android-arm64": "4.53.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.53.3",
|
||||
"@rollup/rollup-darwin-x64": "4.53.3",
|
||||
"@rollup/rollup-freebsd-arm64": "4.53.3",
|
||||
"@rollup/rollup-freebsd-x64": "4.53.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.53.3",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.53.3",
|
||||
"@rollup/rollup-openharmony-arm64": "4.53.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.53.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.53.3",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.3",
|
||||
"@rollup/rollup-android-arm64": "4.60.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.3",
|
||||
"@rollup/rollup-darwin-x64": "4.60.3",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.3",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.3",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.3",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.3",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.3",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.3",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.3",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -3210,9 +3259,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -3300,13 +3349,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
|
||||
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
|
||||
"version": "7.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
|
||||
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -3404,9 +3453,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -282,15 +282,67 @@ function restoreSidebarScroll() {
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight current document in sidebar
|
||||
function highlightCurrentDocument() {
|
||||
const sidebar = document.getElementById('kb-sidebar');
|
||||
if (!sidebar) {
|
||||
console.log('Sidebar not found for highlighting');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const links = sidebar.querySelectorAll('a');
|
||||
|
||||
console.log('Current path:', currentPath);
|
||||
console.log('Found links in sidebar:', links.length);
|
||||
|
||||
links.forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
|
||||
// Remove previous highlighting
|
||||
link.classList.remove('bg-indigo-50', 'text-indigo-700', 'font-semibold');
|
||||
link.classList.add('text-gray-700');
|
||||
|
||||
const icon = link.querySelector('svg');
|
||||
if (icon) {
|
||||
icon.classList.remove('text-indigo-600');
|
||||
icon.classList.add('text-gray-400', 'group-hover:text-gray-600');
|
||||
}
|
||||
|
||||
// Check if this is the current page
|
||||
if (href === currentPath || href === window.location.href ||
|
||||
(href && currentPath && href.endsWith(currentPath))) {
|
||||
console.log('Matched link:', href, 'with current path:', currentPath);
|
||||
link.classList.add('bg-indigo-50', 'text-indigo-700', 'font-semibold');
|
||||
link.classList.remove('text-gray-700');
|
||||
|
||||
if (icon) {
|
||||
icon.classList.remove('text-gray-400', 'group-hover:text-gray-600');
|
||||
icon.classList.add('text-indigo-600');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Restore on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', restoreSidebarScroll);
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
restoreSidebarScroll();
|
||||
highlightCurrentDocument();
|
||||
});
|
||||
} else {
|
||||
restoreSidebarScroll();
|
||||
highlightCurrentDocument();
|
||||
}
|
||||
|
||||
// Also restore on window load (for safety)
|
||||
window.addEventListener('load', restoreSidebarScroll);
|
||||
window.addEventListener('load', () => {
|
||||
restoreSidebarScroll();
|
||||
highlightCurrentDocument();
|
||||
});
|
||||
|
||||
// Update highlight after Alpine navigation
|
||||
document.addEventListener('alpine:navigated', highlightCurrentDocument);
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
|
||||
@@ -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 -->
|
||||
@@ -147,6 +206,42 @@ class="w-full"
|
||||
'guide'
|
||||
],
|
||||
status: ['lines', 'words', 'cursor'],
|
||||
// Image upload configuration
|
||||
uploadImage: true,
|
||||
imageMaxSize: 2 * 1024 * 1024, // 2MB
|
||||
imageAccept: 'image/png, image/jpeg, image/gif, image/webp',
|
||||
imageUploadFunction: (file, onSuccess, onError) => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
fetch('{{ route("images.upload") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(data => {
|
||||
throw new Error(data.message || 'Upload failed');
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Insert markdown with alt text directly
|
||||
const cm = this.editor.codemirror;
|
||||
const altText = data.data.altText || 'image';
|
||||
const url = data.data.filePath;
|
||||
const markdown = ``;
|
||||
cm.replaceSelection(markdown);
|
||||
})
|
||||
.catch(error => {
|
||||
onError(error.message || 'Failed to upload image');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
this.editor.codemirror.on('change', () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
+16
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\LocaleController;
|
||||
use App\Http\Controllers\ImageUploadController;
|
||||
use App\Http\Controllers\Admin\UserController as AdminUserController;
|
||||
use App\Livewire\DocumentViewer;
|
||||
use App\Livewire\DocumentEditor;
|
||||
@@ -29,6 +30,9 @@
|
||||
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
||||
|
||||
// Image upload for editor
|
||||
Route::post('/images/upload', [ImageUploadController::class, 'upload'])->name('images.upload');
|
||||
});
|
||||
|
||||
// Admin routes
|
||||
@@ -40,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');
|
||||
});
|
||||
|
||||
// 公開ルート(動的ルートは最後に)
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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('');
|
||||
$this->assertStringContainsString('<img', $html);
|
||||
$this->assertStringContainsString('src="/photo.png"', $html);
|
||||
}
|
||||
|
||||
public function test_video_url_renders_as_video_tag(): void
|
||||
{
|
||||
$html = Document::renderMarkdown('');
|
||||
$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('');
|
||||
$this->assertStringContainsString('<iframe', $html);
|
||||
$this->assertStringContainsString('youtube-nocookie.com', $html);
|
||||
}
|
||||
|
||||
public function test_vimeo_url_renders_as_iframe(): void
|
||||
{
|
||||
$html = Document::renderMarkdown('');
|
||||
$this->assertStringContainsString('<iframe', $html);
|
||||
$this->assertStringContainsString('player.vimeo.com', $html);
|
||||
}
|
||||
|
||||
public function test_image_and_video_coexist_in_same_document(): void
|
||||
{
|
||||
$md = "\n\n";
|
||||
$html = Document::renderMarkdown($md);
|
||||
$this->assertStringContainsString('<img', $html);
|
||||
$this->assertStringContainsString('<video', $html);
|
||||
}
|
||||
|
||||
public function test_multiple_media_in_same_paragraph(): void
|
||||
{
|
||||
$html = Document::renderMarkdown(' and ');
|
||||
$this->assertSame(2, substr_count($html, '<video'));
|
||||
}
|
||||
|
||||
public function test_video_inside_list_item(): void
|
||||
{
|
||||
$html = Document::renderMarkdown("- ");
|
||||
$this->assertStringContainsString('<li>', $html);
|
||||
$this->assertStringContainsString('<video', $html);
|
||||
}
|
||||
|
||||
public function test_wiki_link_unaffected_alongside_media(): void
|
||||
{
|
||||
$html = Document::renderMarkdown("\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('');
|
||||
$this->assertStringContainsString('?start=30', $html);
|
||||
}
|
||||
|
||||
public function test_audio_url_renders_as_audio_tag(): void
|
||||
{
|
||||
$html = Document::renderMarkdown('');
|
||||
$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('"', $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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user