Compare commits
55 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 | |||
| bed7137e43 | |||
| 028e0b11c7 | |||
| 5bf43abab9 | |||
| f96ad4d14f | |||
| a4aff43091 | |||
| 1e20982e00 | |||
| ec7aaf44a9 | |||
| 00a5951654 | |||
| 8dba510a6c | |||
| e66ece71e3 | |||
| b96012f598 | |||
| e50ed261e1 | |||
| 79a09430aa | |||
| 33fef93ce0 | |||
| e14cc5dd43 |
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言語サポート
|
||||||
@@ -7,6 +7,7 @@ APP_URL=http://localhost
|
|||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
APP_TIMEZONE=Asia/Tokyo
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
APP_MAINTENANCE_DRIVER=file
|
||||||
# APP_MAINTENANCE_STORE=database
|
# APP_MAINTENANCE_STORE=database
|
||||||
|
|||||||
+314
-41
@@ -1,59 +1,332 @@
|
|||||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
# Knowledge Base System
|
||||||
|
|
||||||
<p align="center">
|
An Obsidian-like knowledge base system built with Laravel 12, Livewire v3, and Alpine.js. Create, organize, and link your documents with wiki-style references and a powerful search interface.
|
||||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## About Laravel
|
## Features
|
||||||
|
|
||||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
### Core Functionality
|
||||||
|
- **Markdown-based documents** with live preview using EasyMDE editor
|
||||||
|
- **Wiki-style linking** with `[[Document Title]]` syntax
|
||||||
|
- **Automatic backlinks** - see which documents reference the current page
|
||||||
|
- **Folder organization** - use `/` in titles to auto-organize into folders (e.g., `Laravel/Livewire/Components`)
|
||||||
|
- **Quick switcher** - Press `Ctrl+K` to instantly search and navigate
|
||||||
|
- **Full-text search** - MySQL FULLTEXT index with ngram tokenizer for multilingual support
|
||||||
|
- **ID-based routing** - Clean URLs with guaranteed uniqueness
|
||||||
|
|
||||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
### Multi-Language Support
|
||||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
Interface available in **16 languages**:
|
||||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
- English, 日本語 (Japanese)
|
||||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
- 简体中文, 繁體中文 (Simplified/Traditional Chinese)
|
||||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
- 한국어 (Korean)
|
||||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
- हिन्दी (Hindi), Tiếng Việt (Vietnamese), Türkçe (Turkish)
|
||||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
- Deutsch, Français, Español, Português (Brasil)
|
||||||
|
- Русский, Українська, Italiano, Polski
|
||||||
|
|
||||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
Language preferences persist for both authenticated and guest users via cookies.
|
||||||
|
|
||||||
## Learning Laravel
|
### Responsive Design
|
||||||
|
- **Mobile-first** interface with hamburger menu
|
||||||
|
- **Tablet and desktop** optimized layouts
|
||||||
|
- **Touch-friendly** navigation
|
||||||
|
- All features work seamlessly across devices
|
||||||
|
|
||||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
### User Management
|
||||||
|
- **Role-based access** - Admin and regular user roles
|
||||||
|
- **User authentication** - Laravel Breeze integration
|
||||||
|
- **Profile management** - Update name, email, password, and language preference
|
||||||
|
|
||||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
## Technology Stack
|
||||||
|
|
||||||
## Laravel Sponsors
|
- **Backend**: Laravel 12.0 (PHP 8.2+)
|
||||||
|
- **Frontend**: Livewire v3.7.0, Alpine.js, Tailwind CSS
|
||||||
|
- **Database**: MySQL 8.0 with FULLTEXT indexing
|
||||||
|
- **Markdown**: league/commonmark for rendering
|
||||||
|
- **Editor**: EasyMDE (markdown editor)
|
||||||
|
- **Docker**: Custom containerized environment
|
||||||
|
|
||||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
## Prerequisites
|
||||||
|
|
||||||
### Premium Partners
|
- Docker and Docker Compose
|
||||||
|
- Node.js 18+ (for asset compilation)
|
||||||
|
- Git
|
||||||
|
|
||||||
- **[Vehikl](https://vehikl.com)**
|
## Installation
|
||||||
- **[Tighten Co.](https://tighten.co)**
|
|
||||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
### 1. Clone the repository
|
||||||
- **[64 Robots](https://64robots.com)**
|
|
||||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
```bash
|
||||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
git clone <repository-url>
|
||||||
- **[Redberry](https://redberry.international/laravel-development)**
|
cd knowledge-base
|
||||||
- **[Active Logic](https://activelogic.com)**
|
```
|
||||||
|
|
||||||
|
### 2. Start Docker services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts:
|
||||||
|
- Nginx: `http://localhost:9700`
|
||||||
|
- phpMyAdmin: `http://localhost:9701`
|
||||||
|
- MySQL: `localhost:9702`
|
||||||
|
- MailHog: `http://localhost:9725`
|
||||||
|
|
||||||
|
### 3. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Inside the src/ directory
|
||||||
|
cd src
|
||||||
|
|
||||||
|
# Install PHP dependencies
|
||||||
|
docker compose exec php composer install
|
||||||
|
|
||||||
|
# Install Node dependencies
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Configure environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy environment file
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Generate application key
|
||||||
|
docker compose exec php php artisan key:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Set up database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run migrations
|
||||||
|
docker compose exec php php artisan migrate
|
||||||
|
|
||||||
|
# Seed initial user (admin@example.com / password)
|
||||||
|
docker compose exec php php artisan db:seed --class=UserSeeder
|
||||||
|
|
||||||
|
# Initialize sample documents (optional)
|
||||||
|
docker compose exec php php artisan docs:init
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Build frontend assets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development mode with hot reload
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Or production build
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Access the application
|
||||||
|
|
||||||
|
Open `http://localhost:9700` in your browser.
|
||||||
|
|
||||||
|
**Default credentials**:
|
||||||
|
- Email: `admin@example.com`
|
||||||
|
- Password: `password`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running the dev environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services (server, queue, logs, Vite)
|
||||||
|
docker compose exec php composer dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec php php artisan test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Access PHP container shell
|
||||||
|
docker compose exec php bash
|
||||||
|
|
||||||
|
# Clear caches
|
||||||
|
docker compose exec php php artisan config:clear
|
||||||
|
docker compose exec php php artisan cache:clear
|
||||||
|
docker compose exec php php artisan view:clear
|
||||||
|
|
||||||
|
# Publish Livewire assets (after updates)
|
||||||
|
docker compose exec php php artisan livewire:publish --assets
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── Http/
|
||||||
|
│ │ ├── Controllers/
|
||||||
|
│ │ │ └── LocaleController.php # Language switching
|
||||||
|
│ │ └── Middleware/
|
||||||
|
│ │ └── SetLocale.php # Multi-language support
|
||||||
|
│ ├── Livewire/
|
||||||
|
│ │ ├── DocumentEditor.php # Create/edit documents
|
||||||
|
│ │ ├── DocumentViewer.php # Display documents
|
||||||
|
│ │ ├── QuickSwitcher.php # Ctrl+K search modal
|
||||||
|
│ │ └── SidebarTree.php # Folder tree navigation
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ ├── Document.php # Document model
|
||||||
|
│ │ ├── DocumentLink.php # Wiki-style links
|
||||||
|
│ │ └── RecentDocument.php # Access history
|
||||||
|
│ └── Services/
|
||||||
|
│ └── DocumentService.php # Document business logic
|
||||||
|
├── database/
|
||||||
|
│ └── migrations/ # Database schema
|
||||||
|
├── lang/ # Translation files (16 languages)
|
||||||
|
├── resources/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── app.css # Tailwind + custom styles
|
||||||
|
│ ├── js/
|
||||||
|
│ │ └── app.js # Alpine.js initialization
|
||||||
|
│ └── views/
|
||||||
|
│ ├── layouts/
|
||||||
|
│ │ └── knowledge-base.blade.php # Main layout
|
||||||
|
│ └── livewire/ # Livewire component views
|
||||||
|
└── routes/
|
||||||
|
└── web.php # Application routes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
### Document Organization
|
||||||
|
|
||||||
|
Documents are organized using **virtual paths** derived from titles:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Title: "Laravel/Livewire/Components"
|
||||||
|
→ Path: "Laravel/Livewire/Components.md"
|
||||||
|
→ Slug: "components"
|
||||||
|
→ Sidebar: Nested under Laravel → Livewire → Components
|
||||||
|
```
|
||||||
|
|
||||||
|
No manual directory field needed - just use `/` in the title!
|
||||||
|
|
||||||
|
### Wiki-Style Links
|
||||||
|
|
||||||
|
Create links between documents using double brackets:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
See [[Getting Started]] for more information.
|
||||||
|
Links to [[Uncreated Pages]] appear in red.
|
||||||
|
```
|
||||||
|
|
||||||
|
Links are automatically:
|
||||||
|
- Extracted and stored in the `document_links` table
|
||||||
|
- Rendered as clickable HTML anchors
|
||||||
|
- Displayed as backlinks on target documents
|
||||||
|
|
||||||
|
### ID-Based Routing
|
||||||
|
|
||||||
|
URLs use document IDs instead of slugs:
|
||||||
|
|
||||||
|
```
|
||||||
|
/documents/123 (instead of /documents/my-document-slug)
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- Guaranteed uniqueness
|
||||||
|
- Title changes don't break URLs
|
||||||
|
- Simpler route model binding
|
||||||
|
|
||||||
|
### Folder State Persistence
|
||||||
|
|
||||||
|
Sidebar folder expanded/collapsed state is stored in `localStorage`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Managed by Alpine.js
|
||||||
|
localStorage.getItem('kb_expanded_folders')
|
||||||
|
// ["Laravel", "Laravel/Livewire", "Docker"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Survives page navigation and browser sessions.
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Adding new languages
|
||||||
|
|
||||||
|
1. Add to `SetLocale::SUPPORTED_LOCALES` in `app/Http/Middleware/SetLocale.php`
|
||||||
|
2. Create translation file at `lang/{code}/messages.php`
|
||||||
|
3. Copy structure from existing language file
|
||||||
|
|
||||||
|
### Changing default locale
|
||||||
|
|
||||||
|
Edit `config/app.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'locale' => 'en', // Change to your preferred language code
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customizing markdown styles
|
||||||
|
|
||||||
|
Edit `resources/css/app.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@layer components {
|
||||||
|
.prose .wiki-link {
|
||||||
|
@apply text-indigo-600 hover:text-indigo-800 underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Livewire assets not loading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec php php artisan livewire:publish --assets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend changes not reflecting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
docker compose exec php php artisan view:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database connection errors
|
||||||
|
|
||||||
|
Check `.env` file matches Docker Compose settings:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=kb_mysql
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=knowledge_base
|
||||||
|
DB_USERNAME=kb_user
|
||||||
|
DB_PASSWORD=kb_password
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alpine.js errors in console
|
||||||
|
|
||||||
|
Ensure scripts are loaded in correct order in `knowledge-base.blade.php`:
|
||||||
|
1. Livewire scripts first
|
||||||
|
2. Alpine.js initialization (via Vite)
|
||||||
|
3. Custom Alpine components
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
Contributions are welcome! Please ensure:
|
||||||
|
- Code follows Laravel and PSR-12 conventions
|
||||||
## Code of Conduct
|
- All existing tests pass
|
||||||
|
- New features include tests
|
||||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
- UI changes maintain responsive design
|
||||||
|
|
||||||
## Security Vulnerabilities
|
|
||||||
|
|
||||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
This project is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Built with:
|
||||||
|
- [Laravel](https://laravel.com) - The PHP Framework
|
||||||
|
- [Livewire](https://livewire.laravel.com) - Full-stack framework for Laravel
|
||||||
|
- [Alpine.js](https://alpinejs.dev) - Lightweight JavaScript framework
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com) - Utility-first CSS framework
|
||||||
|
- [EasyMDE](https://github.com/Ionaru/easy-markdown-editor) - Markdown editor
|
||||||
|
- [league/commonmark](https://commonmark.thephpleague.com) - Markdown parser
|
||||||
|
|||||||
@@ -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;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Http\Middleware\SetLocale;
|
||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
use App\Services\DocumentService;
|
use App\Services\DocumentService;
|
||||||
use Livewire\Component;
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
class DocumentEditor extends Component
|
class DocumentEditor extends Component
|
||||||
{
|
{
|
||||||
public ?Document $document = null;
|
public ?Document $document = null;
|
||||||
public $title = '';
|
public string $title = '';
|
||||||
public $content = '';
|
public string $content = '';
|
||||||
public $directory = '';
|
public string $editingLocale = '';
|
||||||
public $isEditMode = false;
|
public bool $isEditMode = false;
|
||||||
|
public bool $isNewLocale = false;
|
||||||
|
public array $availableLocales = [];
|
||||||
|
|
||||||
public function mount(?Document $document = null)
|
public function mount(?Document $document = null, ?string $locale = null)
|
||||||
{
|
{
|
||||||
if ($document) {
|
if ($document) {
|
||||||
$this->document = $document;
|
$this->authorize('update', $document);
|
||||||
$this->title = $document->title;
|
$this->document = $document->load('translations');
|
||||||
$this->content = $document->content;
|
|
||||||
$this->directory = $document->directory;
|
|
||||||
$this->isEditMode = true;
|
$this->isEditMode = true;
|
||||||
|
$this->availableLocales = $document->availableLocales();
|
||||||
|
$this->editingLocale = $locale ?: ($document->default_locale ?? App::getLocale());
|
||||||
|
|
||||||
|
$translation = $document->translations->firstWhere('locale', $this->editingLocale);
|
||||||
|
if ($translation) {
|
||||||
|
$this->title = $translation->title;
|
||||||
|
$this->content = $translation->content;
|
||||||
|
$this->isNewLocale = false;
|
||||||
} else {
|
} else {
|
||||||
|
$this->title = '';
|
||||||
|
$this->content = '';
|
||||||
|
$this->isNewLocale = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->editingLocale = App::getLocale();
|
||||||
$titleParam = request()->query('title');
|
$titleParam = request()->query('title');
|
||||||
if ($titleParam) {
|
if ($titleParam) {
|
||||||
$this->title = $titleParam;
|
$this->title = $titleParam;
|
||||||
@@ -33,49 +49,96 @@ public function mount(?Document $document = null)
|
|||||||
|
|
||||||
public function save(DocumentService $documentService)
|
public function save(DocumentService $documentService)
|
||||||
{
|
{
|
||||||
$this->validate([
|
$validated = $this->validate([
|
||||||
'title' => 'required|string|max:255',
|
'title' => 'required|string|max:255',
|
||||||
'content' => 'required|string',
|
'content' => 'required|string',
|
||||||
|
'editingLocale' => ['required', 'string', 'in:' . implode(',', array_keys(SetLocale::SUPPORTED_LOCALES))],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($this->isEditMode && $this->document) {
|
if ($this->isEditMode && $this->document) {
|
||||||
|
$this->authorize('update', $this->document);
|
||||||
|
|
||||||
|
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 = $documentService->updateDocument(
|
||||||
$this->document,
|
$this->document,
|
||||||
$this->title,
|
$this->title,
|
||||||
$this->content,
|
$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));
|
return $this->redirect(route('documents.show', $this->document));
|
||||||
} else {
|
} else {
|
||||||
$this->document = $documentService->createDocument(
|
$this->document = $documentService->createDocument(
|
||||||
$this->title,
|
$this->title,
|
||||||
$this->content,
|
$this->content,
|
||||||
Auth::id(),
|
Auth::id(),
|
||||||
$this->directory ?: null
|
$this->editingLocale,
|
||||||
);
|
);
|
||||||
|
session()->flash('message', __('messages.documents.create_success'));
|
||||||
session()->flash('message', 'Document created successfully!');
|
|
||||||
return $this->redirect(route('documents.show', $this->document));
|
return $this->redirect(route('documents.show', $this->document));
|
||||||
}
|
}
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
session()->flash('error', $e->getMessage());
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
session()->flash('error', 'Error saving document: ' . $e->getMessage());
|
session()->flash('error', 'Error saving document: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function deleteTranslation(DocumentService $documentService)
|
||||||
|
{
|
||||||
|
if (!$this->isEditMode || !$this->document || $this->isNewLocale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->authorize('update', $this->document);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$documentService->deleteTranslation($this->document, $this->editingLocale);
|
||||||
|
session()->flash('message', __('messages.documents.translation_deleted'));
|
||||||
|
return $this->redirect(route('documents.show', $this->document));
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
session()->flash('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAsDefault(DocumentService $documentService)
|
||||||
|
{
|
||||||
|
if (!$this->isEditMode || !$this->document) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->authorize('update', $this->document);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->document = $documentService->setDefaultLocale($this->document, $this->editingLocale);
|
||||||
|
session()->flash('message', __('messages.documents.update_success'));
|
||||||
|
return $this->redirect(route('documents.show', $this->document));
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
session()->flash('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function delete(DocumentService $documentService)
|
public function delete(DocumentService $documentService)
|
||||||
{
|
{
|
||||||
if (!$this->isEditMode || !$this->document) {
|
if (!$this->isEditMode || !$this->document) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$this->authorize('delete', $this->document);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$documentService->deleteDocument($this->document);
|
$documentService->deleteDocument($this->document);
|
||||||
session()->flash('message', 'Document deleted successfully!');
|
session()->flash('message', __('messages.documents.delete_success'));
|
||||||
|
|
||||||
// Try to redirect to home document, or root if not found
|
|
||||||
$homeDocument = Document::where('slug', 'home')->first();
|
$homeDocument = Document::where('slug', 'home')->first();
|
||||||
if ($homeDocument) {
|
if ($homeDocument) {
|
||||||
return redirect()->route('documents.show', $homeDocument);
|
return redirect()->route('documents.show', $homeDocument);
|
||||||
@@ -90,7 +153,9 @@ public function render()
|
|||||||
{
|
{
|
||||||
return view('livewire.document-editor')
|
return view('livewire.document-editor')
|
||||||
->layout('layouts.knowledge-base', [
|
->layout('layouts.knowledge-base', [
|
||||||
'title' => $this->isEditMode ? 'Edit: ' . $this->title : 'New Document'
|
'title' => $this->isEditMode
|
||||||
|
? __('messages.documents.edit_document') . ': ' . $this->title
|
||||||
|
: __('messages.documents.new_document'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,25 +4,32 @@
|
|||||||
|
|
||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
use App\Services\DocumentService;
|
use App\Services\DocumentService;
|
||||||
use Livewire\Component;
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
class DocumentViewer extends Component
|
class DocumentViewer extends Component
|
||||||
{
|
{
|
||||||
public Document $document;
|
public Document $document;
|
||||||
public $backlinks = [];
|
public $backlinks = [];
|
||||||
public $renderedContent = '';
|
public string $renderedContent = '';
|
||||||
|
public string $viewLocale = '';
|
||||||
|
public bool $isFallback = false;
|
||||||
|
|
||||||
public function mount(Document $document, DocumentService $documentService)
|
public function mount(Document $document, DocumentService $documentService)
|
||||||
{
|
{
|
||||||
$this->document = $document;
|
$this->document = $document->load('translations');
|
||||||
|
|
||||||
$this->renderedContent = $this->document->processLinks();
|
$current = App::getLocale();
|
||||||
|
$translation = $document->translationFor($current, fallback: true);
|
||||||
|
|
||||||
$this->backlinks = $documentService->getBacklinks($this->document);
|
$this->viewLocale = $translation?->locale ?? $document->default_locale;
|
||||||
|
$this->isFallback = ($current !== $this->viewLocale);
|
||||||
|
$this->renderedContent = $document->processLinks();
|
||||||
|
$this->backlinks = $documentService->getBacklinks($document);
|
||||||
|
|
||||||
if (Auth::check()) {
|
if (Auth::check()) {
|
||||||
$documentService->recordDocumentAccess($this->document, Auth::id());
|
$documentService->recordDocumentAccess($document, Auth::id());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,65 +4,32 @@
|
|||||||
|
|
||||||
use App\Models\Document;
|
use App\Models\Document;
|
||||||
use App\Services\DocumentService;
|
use App\Services\DocumentService;
|
||||||
use Livewire\Component;
|
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
class QuickSwitcher extends Component
|
class QuickSwitcher extends Component
|
||||||
{
|
{
|
||||||
public $search = '';
|
public string $search = '';
|
||||||
public $selectedIndex = 0;
|
public int $selectedIndex = 0;
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function results()
|
public function results()
|
||||||
{
|
{
|
||||||
if (empty($this->search)) {
|
if (empty($this->search)) {
|
||||||
return Document::select('id', 'title', 'slug', 'path', 'updated_at')
|
$documents = Document::with('translations')
|
||||||
->orderBy('updated_at', 'desc')
|
->orderBy('updated_at', 'desc')
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get()
|
->get();
|
||||||
->map(fn($doc) => [
|
} else {
|
||||||
'id' => $doc->id,
|
$documents = app(DocumentService::class)->search($this->search, 10);
|
||||||
'title' => $doc->title,
|
|
||||||
'slug' => $doc->slug,
|
|
||||||
'directory' => dirname($doc->path),
|
|
||||||
])
|
|
||||||
->toArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FULLTEXT検索を使用(日本語対応)
|
return $documents->map(fn ($doc) => [
|
||||||
$results = Document::select('id', 'title', 'slug', 'path', 'updated_at')
|
|
||||||
->whereRaw('MATCH(title, content) AGAINST(? IN BOOLEAN MODE)', [$this->search])
|
|
||||||
->orderBy('updated_at', 'desc')
|
|
||||||
->limit(10)
|
|
||||||
->get()
|
|
||||||
->map(fn($doc) => [
|
|
||||||
'id' => $doc->id,
|
'id' => $doc->id,
|
||||||
'title' => $doc->title,
|
'title' => $doc->title,
|
||||||
'slug' => $doc->slug,
|
'slug' => $doc->slug,
|
||||||
'directory' => dirname($doc->path),
|
'directory' => dirname($doc->path),
|
||||||
])
|
])->values()->toArray();
|
||||||
->toArray();
|
|
||||||
|
|
||||||
// FULLTEXT検索で結果がない場合は LIKE 検索にフォールバック
|
|
||||||
if (empty($results)) {
|
|
||||||
$results = Document::select('id', 'title', 'slug', 'path', 'updated_at')
|
|
||||||
->where(function($query) {
|
|
||||||
$query->where('title', 'like', '%' . $this->search . '%')
|
|
||||||
->orWhere('content', 'like', '%' . $this->search . '%');
|
|
||||||
})
|
|
||||||
->orderBy('updated_at', 'desc')
|
|
||||||
->limit(10)
|
|
||||||
->get()
|
|
||||||
->map(fn($doc) => [
|
|
||||||
'id' => $doc->id,
|
|
||||||
'title' => $doc->title,
|
|
||||||
'slug' => $doc->slug,
|
|
||||||
'directory' => dirname($doc->path),
|
|
||||||
])
|
|
||||||
->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updated($propertyName)
|
public function updated($propertyName)
|
||||||
@@ -92,8 +59,6 @@ public function selectDocument()
|
|||||||
$results = $this->results;
|
$results = $this->results;
|
||||||
if (isset($results[$this->selectedIndex])) {
|
if (isset($results[$this->selectedIndex])) {
|
||||||
$document = $results[$this->selectedIndex];
|
$document = $results[$this->selectedIndex];
|
||||||
|
|
||||||
// slug が存在することを確認
|
|
||||||
if (!empty($document['slug'])) {
|
if (!empty($document['slug'])) {
|
||||||
return $this->redirect(route('documents.show', $document['slug']));
|
return $this->redirect(route('documents.show', $document['slug']));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Helpers\SlugHelper;
|
use App\Helpers\SlugHelper;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use League\CommonMark\CommonMarkConverter;
|
|
||||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
|
|
||||||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
|
||||||
|
|
||||||
class Document extends Model
|
class Document extends Model
|
||||||
{
|
{
|
||||||
use SoftDeletes;
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the route key for the model.
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function getRouteKeyName(): string
|
|
||||||
{
|
|
||||||
return 'slug';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve the model for a bound value.
|
|
||||||
* Supports both slug and ID for backwards compatibility.
|
|
||||||
*
|
|
||||||
* @param mixed $value
|
|
||||||
* @param string|null $field
|
|
||||||
* @return \Illuminate\Database\Eloquent\Model|null
|
|
||||||
*/
|
|
||||||
public function resolveRouteBinding($value, $field = null)
|
|
||||||
{
|
|
||||||
// First try to find by slug
|
|
||||||
$document = $this->where('slug', $value)->first();
|
|
||||||
|
|
||||||
// If not found by slug, try by ID (for backwards compatibility)
|
|
||||||
if (!$document && is_numeric($value)) {
|
|
||||||
$document = $this->where('id', $value)->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $document;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that are mass assignable.
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'path',
|
'path',
|
||||||
'title',
|
|
||||||
'slug',
|
'slug',
|
||||||
'content',
|
'default_locale',
|
||||||
'rendered_html',
|
|
||||||
'frontmatter',
|
'frontmatter',
|
||||||
'file_size',
|
'file_size',
|
||||||
'file_hash',
|
'file_hash',
|
||||||
@@ -69,11 +30,6 @@ public function resolveRouteBinding($value, $field = null)
|
|||||||
'updated_by',
|
'updated_by',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the attributes that should be cast.
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -82,191 +38,64 @@ protected function casts(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function getRouteKeyName(): string
|
||||||
* Frontmatterをパース(互換性のため残す)
|
|
||||||
*
|
|
||||||
* @param string $content
|
|
||||||
* @return array{frontmatter: array, content: string}
|
|
||||||
*/
|
|
||||||
protected static function parseFrontmatter(string $content): array
|
|
||||||
{
|
{
|
||||||
$frontmatter = [];
|
return 'slug';
|
||||||
$bodyContent = $content;
|
|
||||||
|
|
||||||
// Frontmatterの検出(--- で囲まれた部分)
|
|
||||||
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 [
|
public function resolveRouteBinding($value, $field = null)
|
||||||
'frontmatter' => $frontmatter,
|
{
|
||||||
'content' => trim($bodyContent),
|
$document = $this->where('slug', $value)->first();
|
||||||
];
|
|
||||||
|
if (!$document && is_numeric($value)) {
|
||||||
|
$document = $this->where('id', $value)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $document;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Markdownをレンダリング
|
* Backward-compatible static delegate so existing callers and tests
|
||||||
*
|
* (e.g. MediaEmbedExtensionTest) keep working.
|
||||||
* @param string $markdown
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public static function renderMarkdown(string $markdown): string
|
public static function renderMarkdown(string $markdown): string
|
||||||
{
|
{
|
||||||
$converter = new CommonMarkConverter([
|
return DocumentTranslation::renderMarkdown($markdown);
|
||||||
'html_input' => 'strip',
|
|
||||||
'allow_unsafe_links' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
|
|
||||||
|
|
||||||
return $converter->convert($markdown)->getContent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ----- Relations -----
|
||||||
* [[wiki-link]]を抽出してリンクテーブルに同期
|
|
||||||
*
|
public function translations(): HasMany
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function syncLinks(): void
|
|
||||||
{
|
{
|
||||||
// 既存のリンクを削除
|
return $this->hasMany(DocumentTranslation::class);
|
||||||
$this->outgoingLinks()->delete();
|
|
||||||
|
|
||||||
// [[wiki-link]]を抽出
|
|
||||||
preg_match_all('/\[\[([^\]]+)\]\]/', $this->content, $matches);
|
|
||||||
|
|
||||||
if (empty($matches[1])) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$position = 0;
|
public function defaultTranslation(): HasOne
|
||||||
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
|
|
||||||
{
|
{
|
||||||
return preg_replace_callback(
|
return $this->hasOne(DocumentTranslation::class)
|
||||||
'/\[\[([^\]]+)\]\]/',
|
->whereColumn('locale', 'documents.default_locale');
|
||||||
function ($matches) {
|
|
||||||
$linkTitle = trim($matches[1]);
|
|
||||||
$slug = SlugHelper::generate($linkTitle);
|
|
||||||
|
|
||||||
// リンク先のドキュメントを検索
|
|
||||||
$targetDocument = static::where('title', $linkTitle)
|
|
||||||
->orWhere('slug', $slug)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($targetDocument) {
|
|
||||||
return '<a href="' . route('documents.show', $targetDocument->slug) . '" class="wiki-link">' . e($linkTitle) . '</a>';
|
|
||||||
} else {
|
|
||||||
return '<a href="' . route('documents.create') . '?title=' . urlencode($linkTitle) . '" class="wiki-link wiki-link-new">' . e($linkTitle) . '</a>';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
$this->rendered_html
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 全文検索スコープ
|
|
||||||
*
|
|
||||||
* @param Builder $query
|
|
||||||
* @param string $searchTerm
|
|
||||||
* @return Builder
|
|
||||||
*/
|
|
||||||
public function scopeSearch(Builder $query, string $searchTerm): Builder
|
|
||||||
{
|
|
||||||
return $query->whereRaw(
|
|
||||||
'MATCH(title, content) AGAINST(? IN BOOLEAN MODE)',
|
|
||||||
[$searchTerm]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ディレクトリ内検索スコープ
|
|
||||||
*
|
|
||||||
* @param Builder $query
|
|
||||||
* @param string $directory
|
|
||||||
* @return Builder
|
|
||||||
*/
|
|
||||||
public function scopeInDirectory(Builder $query, string $directory): Builder
|
|
||||||
{
|
|
||||||
$directory = rtrim($directory, '/') . '/';
|
|
||||||
return $query->where('path', 'like', $directory . '%');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 作成者リレーション
|
|
||||||
*
|
|
||||||
* @return BelongsTo
|
|
||||||
*/
|
|
||||||
public function creator(): BelongsTo
|
public function creator(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'created_by');
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新者リレーション
|
|
||||||
*
|
|
||||||
* @return BelongsTo
|
|
||||||
*/
|
|
||||||
public function updater(): BelongsTo
|
public function updater(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'updated_by');
|
return $this->belongsTo(User::class, 'updated_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 発リンク(このドキュメントから他へのリンク)
|
|
||||||
*
|
|
||||||
* @return HasMany
|
|
||||||
*/
|
|
||||||
public function outgoingLinks(): HasMany
|
public function outgoingLinks(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(DocumentLink::class, 'source_document_id');
|
return $this->hasMany(DocumentLink::class, 'source_document_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 被リンク(他のドキュメントからこのドキュメントへのリンク)
|
|
||||||
*
|
|
||||||
* @return HasMany
|
|
||||||
*/
|
|
||||||
public function incomingLinks(): HasMany
|
public function incomingLinks(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(DocumentLink::class, 'target_document_id');
|
return $this->hasMany(DocumentLink::class, 'target_document_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* このドキュメントを最近閲覧したユーザー
|
|
||||||
*
|
|
||||||
* @return HasManyThrough
|
|
||||||
*/
|
|
||||||
public function recentByUsers(): HasManyThrough
|
public function recentByUsers(): HasManyThrough
|
||||||
{
|
{
|
||||||
return $this->hasManyThrough(
|
return $this->hasManyThrough(
|
||||||
@@ -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 array<int, string>
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
|
public function availableLocales(): array
|
||||||
|
{
|
||||||
|
return $this->translations->pluck('locale')->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Accessors (current-locale → fallback) -----
|
||||||
|
|
||||||
|
public function getTitleAttribute(): string
|
||||||
|
{
|
||||||
|
return $this->translationFor(App::getLocale())?->title ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContentAttribute(): string
|
||||||
|
{
|
||||||
|
return $this->translationFor(App::getLocale())?->content ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRenderedHtmlAttribute(): ?string
|
||||||
|
{
|
||||||
|
return $this->translationFor(App::getLocale())?->rendered_html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Path helpers -----
|
||||||
|
|
||||||
public function getDirectoryAttribute(): string
|
public function getDirectoryAttribute(): string
|
||||||
{
|
{
|
||||||
return dirname($this->path);
|
return dirname($this->path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ファイル名を取得
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function getFilenameAttribute(): string
|
public function getFilenameAttribute(): string
|
||||||
{
|
{
|
||||||
return basename($this->path);
|
return basename($this->path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 絶対パスを取得
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function getAbsolutePathAttribute(): string
|
public function getAbsolutePathAttribute(): string
|
||||||
{
|
{
|
||||||
return Storage::disk('markdown')->path($this->path);
|
return Storage::disk('markdown')->path($this->path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ----- Search scope (delegates to translations) -----
|
||||||
* タイトルセット時にslugも自動生成
|
|
||||||
*
|
|
||||||
* @param string $value
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function setTitleAttribute(string $value): void
|
|
||||||
{
|
|
||||||
$this->attributes['title'] = $value;
|
|
||||||
|
|
||||||
if (empty($this->attributes['slug'])) {
|
public function scopeSearch(Builder $query, string $term): Builder
|
||||||
$this->attributes['slug'] = SlugHelper::generate($value);
|
{
|
||||||
|
return $query->whereHas('translations', function (Builder $q) use ($term) {
|
||||||
|
DocumentTranslation::scopeSearch($q, $term);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeInDirectory(Builder $query, string $directory): Builder
|
||||||
|
{
|
||||||
|
$directory = rtrim($directory, '/') . '/';
|
||||||
|
return $query->where('path', 'like', $directory . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract [[wiki-links]] from the default-locale translation's content
|
||||||
|
* and persist them via DocumentLink.
|
||||||
|
*/
|
||||||
|
public function syncLinks(): void
|
||||||
|
{
|
||||||
|
$this->outgoingLinks()->delete();
|
||||||
|
|
||||||
|
$translation = $this->translationFor($this->default_locale, fallback: false);
|
||||||
|
if (!$translation || !$translation->content) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
preg_match_all('/\[\[([^\]]+)\]\]/', $translation->content, $matches);
|
||||||
|
if (empty($matches[1])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = new \App\Services\WikiLinkResolver();
|
||||||
|
$position = 0;
|
||||||
|
foreach ($matches[1] as $linkTitle) {
|
||||||
|
$linkTitle = trim($linkTitle);
|
||||||
|
$target = $resolver->resolve($linkTitle, $this->default_locale);
|
||||||
|
|
||||||
|
DocumentLink::create([
|
||||||
|
'source_document_id' => $this->id,
|
||||||
|
'target_document_id' => $target?->id,
|
||||||
|
'target_title' => $linkTitle,
|
||||||
|
'position' => $position++,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert [[wiki-links]] in the current-locale rendered_html to anchor tags.
|
||||||
|
* Link labels stay in the original language; the destination document is
|
||||||
|
* resolved against the current locale (with fallback).
|
||||||
|
*/
|
||||||
|
public function processLinks(): string
|
||||||
|
{
|
||||||
|
$html = $this->rendered_html ?? '';
|
||||||
|
if ($html === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = new \App\Services\WikiLinkResolver();
|
||||||
|
$currentLocale = App::getLocale();
|
||||||
|
|
||||||
|
return preg_replace_callback(
|
||||||
|
'/\[\[([^\]]+)\]\]/',
|
||||||
|
function ($matches) use ($resolver, $currentLocale) {
|
||||||
|
$linkText = trim($matches[1]);
|
||||||
|
$target = $resolver->resolve($linkText, $currentLocale);
|
||||||
|
|
||||||
|
if ($target) {
|
||||||
|
return '<a href="' . route('documents.show', $target->slug) . '" class="wiki-link">' . e($linkText) . '</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<a href="' . route('documents.create') . '?title=' . urlencode($linkText) . '" class="wiki-link wiki-link-new">' . e($linkText) . '</a>';
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Document;
|
|
||||||
use App\Models\RecentDocument;
|
|
||||||
use App\Helpers\SlugHelper;
|
use App\Helpers\SlugHelper;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use App\Models\Document;
|
||||||
|
use App\Models\DocumentTranslation;
|
||||||
|
use App\Models\RecentDocument;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class DocumentService
|
class DocumentService
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
|
||||||
* 新しいドキュメントを作成
|
|
||||||
*
|
|
||||||
* @param string $title
|
|
||||||
* @param string $content
|
|
||||||
* @param int|null $userId
|
|
||||||
* @param string|null $directory (deprecated - path is now auto-generated from title)
|
|
||||||
* @return Document
|
|
||||||
*/
|
|
||||||
public function createDocument(
|
public function createDocument(
|
||||||
string $title,
|
string $title,
|
||||||
string $content,
|
string $content,
|
||||||
?int $userId = null,
|
?int $userId = null,
|
||||||
?string $directory = null
|
?string $locale = null,
|
||||||
): Document {
|
): Document {
|
||||||
// タイトルからパスとスラッグを自動生成
|
$locale = $locale ?: App::getLocale();
|
||||||
// 例: "Laravel/Livewire/Components" → path="Laravel/Livewire/Components.md", slug="components"
|
|
||||||
[$path, $slug] = $this->generatePathAndSlug($title);
|
[$path, $slug] = $this->generatePathAndSlug($title);
|
||||||
|
|
||||||
// ドキュメントをDBに作成
|
return DB::transaction(function () use ($title, $content, $userId, $locale, $path, $slug) {
|
||||||
$document = Document::create([
|
$document = Document::create([
|
||||||
'path' => $path,
|
'path' => $path,
|
||||||
'title' => $title,
|
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'content' => $content,
|
'default_locale' => $locale,
|
||||||
'rendered_html' => Document::renderMarkdown($content),
|
|
||||||
'created_by' => $userId,
|
'created_by' => $userId,
|
||||||
'updated_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();
|
$document->syncLinks();
|
||||||
|
|
||||||
return $document;
|
return $document;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ドキュメントを更新
|
|
||||||
*
|
|
||||||
* @param Document $document
|
|
||||||
* @param string $title
|
|
||||||
* @param string $content
|
|
||||||
* @param int|null $userId
|
|
||||||
* @return Document
|
|
||||||
*/
|
|
||||||
public function updateDocument(
|
public function updateDocument(
|
||||||
Document $document,
|
Document $document,
|
||||||
string $title,
|
string $title,
|
||||||
string $content,
|
string $content,
|
||||||
?int $userId = null
|
?int $userId = null,
|
||||||
|
?string $locale = null,
|
||||||
): Document {
|
): Document {
|
||||||
// タイトルが変更された場合はパスとスラッグを再生成
|
$locale = $locale ?: App::getLocale();
|
||||||
if ($document->title !== $title) {
|
|
||||||
|
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);
|
[$path, $slug] = $this->generatePathAndSlug($title, $document->id);
|
||||||
$document->path = $path;
|
$document->path = $path;
|
||||||
$document->slug = $slug;
|
$document->slug = $slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
$document->title = $title;
|
|
||||||
$document->content = $content;
|
|
||||||
$document->rendered_html = Document::renderMarkdown($content);
|
|
||||||
$document->updated_by = $userId;
|
|
||||||
|
|
||||||
// DBに保存
|
|
||||||
$document->save();
|
$document->save();
|
||||||
|
$document->load('translations');
|
||||||
// リンクを再同期
|
|
||||||
$document->syncLinks();
|
$document->syncLinks();
|
||||||
|
|
||||||
return $document;
|
return $document;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addTranslation(
|
||||||
|
Document $document,
|
||||||
|
string $locale,
|
||||||
|
string $title,
|
||||||
|
string $content,
|
||||||
|
?int $userId = null,
|
||||||
|
): DocumentTranslation {
|
||||||
|
if ($document->translations()->where('locale', $locale)->exists()) {
|
||||||
|
throw new \InvalidArgumentException("Translation for locale '$locale' already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
return DocumentTranslation::create([
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'locale' => $locale,
|
||||||
|
'title' => $title,
|
||||||
|
'content' => $content,
|
||||||
|
'rendered_html' => DocumentTranslation::renderMarkdown($content),
|
||||||
|
'created_by' => $userId,
|
||||||
|
'updated_by' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteTranslation(Document $document, string $locale): void
|
||||||
|
{
|
||||||
|
if ($locale === $document->default_locale) {
|
||||||
|
throw new \InvalidArgumentException("Cannot delete default-locale translation '$locale'");
|
||||||
|
}
|
||||||
|
$document->translations()->where('locale', $locale)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDefaultLocale(Document $document, string $locale): Document
|
||||||
|
{
|
||||||
|
$translation = $document->translations()->where('locale', $locale)->first();
|
||||||
|
if (!$translation) {
|
||||||
|
throw new \InvalidArgumentException("Cannot set default to '$locale': translation does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($document, $locale, $translation) {
|
||||||
|
$document->default_locale = $locale;
|
||||||
|
[$path, $slug] = $this->generatePathAndSlug($translation->title, $document->id);
|
||||||
|
$document->path = $path;
|
||||||
|
$document->slug = $slug;
|
||||||
|
$document->save();
|
||||||
|
|
||||||
|
return $document->fresh('translations');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ドキュメントを削除
|
|
||||||
*
|
|
||||||
* @param Document $document
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
public function deleteDocument(Document $document): bool
|
public function deleteDocument(Document $document): bool
|
||||||
{
|
{
|
||||||
// DBから削除(ソフトデリート)
|
|
||||||
return $document->delete();
|
return $document->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全文検索
|
* Locale-agnostic full-text search; returns distinct documents.
|
||||||
*
|
|
||||||
* @param string $query
|
|
||||||
* @param int $limit
|
|
||||||
* @return \Illuminate\Database\Eloquent\Collection
|
|
||||||
*/
|
*/
|
||||||
public function search(string $query, int $limit = 20)
|
public function search(string $query, int $limit = 20)
|
||||||
{
|
{
|
||||||
return Document::search($query)
|
$documentIds = DocumentTranslation::query()
|
||||||
->limit($limit)
|
->search($query)
|
||||||
->get();
|
->limit($limit * 5) // overscan to allow distinct collapse
|
||||||
|
->pluck('document_id')
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->take($limit);
|
||||||
|
|
||||||
|
if ($documentIds->isEmpty()) {
|
||||||
|
return Document::query()->whereRaw('1 = 0')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Document::with('translations')
|
||||||
|
->whereIn('id', $documentIds)
|
||||||
|
->get()
|
||||||
|
->sortBy(fn ($d) => $documentIds->search($d->id))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByTitle(string $title, ?string $locale = null): ?Document
|
||||||
|
{
|
||||||
|
return (new WikiLinkResolver())->resolve($title, $locale ?: App::getLocale());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ディレクトリツリーを生成
|
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function getDirectoryTree(): array
|
public function getDirectoryTree(): array
|
||||||
{
|
{
|
||||||
$documents = Document::orderBy('path')->get();
|
$documents = Document::with('translations')->orderBy('path')->get();
|
||||||
|
|
||||||
$tree = [];
|
$tree = [];
|
||||||
|
|
||||||
foreach ($documents as $document) {
|
foreach ($documents as $document) {
|
||||||
$parts = explode('/', $document->path);
|
$parts = explode('/', $document->path);
|
||||||
$current = &$tree;
|
$current = &$tree;
|
||||||
|
|
||||||
foreach ($parts as $index => $part) {
|
foreach ($parts as $index => $part) {
|
||||||
$isFile = ($index === count($parts) - 1);
|
$isFile = ($index === count($parts) - 1);
|
||||||
|
|
||||||
if ($isFile) {
|
if ($isFile) {
|
||||||
// ファイル
|
|
||||||
if (!isset($current['_files'])) {
|
|
||||||
$current['_files'] = [];
|
|
||||||
}
|
|
||||||
$current['_files'][] = [
|
$current['_files'][] = [
|
||||||
'name' => $part,
|
'name' => $part,
|
||||||
'document' => $document,
|
'document' => $document,
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
// ディレクトリ
|
|
||||||
if (!isset($current[$part])) {
|
if (!isset($current[$part])) {
|
||||||
$current[$part] = [];
|
$current[$part] = [];
|
||||||
}
|
}
|
||||||
@@ -146,67 +188,28 @@ public function getDirectoryTree(): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $tree;
|
return $tree;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ユーザーの最近閲覧したドキュメントを取得
|
|
||||||
*
|
|
||||||
* @param int $userId
|
|
||||||
* @param int $limit
|
|
||||||
* @return \Illuminate\Database\Eloquent\Collection
|
|
||||||
*/
|
|
||||||
public function getRecentDocuments(int $userId, int $limit = 10)
|
public function getRecentDocuments(int $userId, int $limit = 10)
|
||||||
{
|
{
|
||||||
return RecentDocument::getRecentForUser($userId, $limit);
|
return RecentDocument::getRecentForUser($userId, $limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ドキュメント閲覧を記録
|
|
||||||
*
|
|
||||||
* @param Document $document
|
|
||||||
* @param int $userId
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function recordDocumentAccess(Document $document, int $userId): void
|
public function recordDocumentAccess(Document $document, int $userId): void
|
||||||
{
|
{
|
||||||
RecentDocument::recordAccess($userId, $document->id);
|
RecentDocument::recordAccess($userId, $document->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 指定タイトルのドキュメントを検索
|
|
||||||
*
|
|
||||||
* @param string $title
|
|
||||||
* @return Document|null
|
|
||||||
*/
|
|
||||||
public function findByTitle(string $title): ?Document
|
|
||||||
{
|
|
||||||
return Document::where('title', $title)
|
|
||||||
->orWhere('slug', SlugHelper::generate($title))
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 被リンク(バックリンク)を取得
|
|
||||||
*
|
|
||||||
* @param Document $document
|
|
||||||
* @return \Illuminate\Database\Eloquent\Collection
|
|
||||||
*/
|
|
||||||
public function getBacklinks(Document $document)
|
public function getBacklinks(Document $document)
|
||||||
{
|
{
|
||||||
return $document->incomingLinks()
|
return $document->incomingLinks()
|
||||||
->with('sourceDocument')
|
->with('sourceDocument.translations')
|
||||||
->get()
|
->get()
|
||||||
->pluck('sourceDocument')
|
->pluck('sourceDocument')
|
||||||
->filter();
|
->filter();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 壊れたリンク(未作成ページへのリンク)を取得
|
|
||||||
*
|
|
||||||
* @return \Illuminate\Database\Eloquent\Collection
|
|
||||||
*/
|
|
||||||
public function getBrokenLinks()
|
public function getBrokenLinks()
|
||||||
{
|
{
|
||||||
return DB::table('document_links')
|
return DB::table('document_links')
|
||||||
@@ -217,39 +220,13 @@ public function getBrokenLinks()
|
|||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* タイトルからパスとスラッグを生成
|
|
||||||
* タイトルに含まれる / をディレクトリ区切りとして扱う
|
|
||||||
*
|
|
||||||
* 例: "Laravel/Livewire/Components"
|
|
||||||
* → path = "Laravel/Livewire/Components.md"
|
|
||||||
* → slug = "components" (最後のコンポーネントから生成)
|
|
||||||
*
|
|
||||||
* @param string $title
|
|
||||||
* @param int|null $excludeDocumentId 更新時に除外するドキュメントID
|
|
||||||
* @return array [path, slug]
|
|
||||||
*/
|
|
||||||
private function generatePathAndSlug(string $title, ?int $excludeDocumentId = null): array
|
private function generatePathAndSlug(string $title, ?int $excludeDocumentId = null): array
|
||||||
{
|
{
|
||||||
// タイトルをそのままパスとして使用(.md拡張子を追加)
|
|
||||||
$basePath = $title . '.md';
|
$basePath = $title . '.md';
|
||||||
|
$baseSlug = SlugHelper::generate(basename($title));
|
||||||
// 最後のパスコンポーネント(スラッシュで区切られた最後の部分)からスラッグを生成
|
|
||||||
$lastComponent = basename($title);
|
|
||||||
$baseSlug = SlugHelper::generate($lastComponent);
|
|
||||||
|
|
||||||
// ユニークなパスとスラッグを生成
|
|
||||||
return $this->ensureUniquePath($basePath, $baseSlug, $excludeDocumentId);
|
return $this->ensureUniquePath($basePath, $baseSlug, $excludeDocumentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* パスとスラッグがユニークになるように調整
|
|
||||||
*
|
|
||||||
* @param string $basePath
|
|
||||||
* @param string $baseSlug
|
|
||||||
* @param int|null $excludeDocumentId
|
|
||||||
* @return array [path, slug]
|
|
||||||
*/
|
|
||||||
private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excludeDocumentId = null): array
|
private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excludeDocumentId = null): array
|
||||||
{
|
{
|
||||||
$path = $basePath;
|
$path = $basePath;
|
||||||
@@ -261,46 +238,17 @@ private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excl
|
|||||||
->where(function ($q) use ($path, $slug) {
|
->where(function ($q) use ($path, $slug) {
|
||||||
$q->where('path', $path)->orWhere('slug', $slug);
|
$q->where('path', $path)->orWhere('slug', $slug);
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($excludeDocumentId) {
|
if ($excludeDocumentId) {
|
||||||
$query->where('id', '!=', $excludeDocumentId);
|
$query->where('id', '!=', $excludeDocumentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$query->exists()) {
|
if (!$query->exists()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$counter++;
|
$counter++;
|
||||||
// パス: "title.md" → "title-2.md"
|
|
||||||
$path = preg_replace('/\.md$/', "-{$counter}.md", $basePath);
|
$path = preg_replace('/\.md$/', "-{$counter}.md", $basePath);
|
||||||
// スラッグ: "title" → "title-2"
|
|
||||||
$slug = $baseSlug . '-' . $counter;
|
$slug = $baseSlug . '-' . $counter;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$path, $slug];
|
return [$path, $slug];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 初期ドキュメントを作成
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function createInitialDocuments(): void
|
|
||||||
{
|
|
||||||
// ホームページ
|
|
||||||
$this->createDocument(
|
|
||||||
'Home',
|
|
||||||
"# Welcome to Knowledge Base\n\nThis is your personal knowledge base powered by Markdown.\n\n## Getting Started\n\n- Create new documents using [[wiki-links]]\n- Use Ctrl+K for quick switching\n- Full-text search is available\n\n## Example Links\n\n- [[Getting Started]]\n- [[Documentation]]\n- [[Notes]]",
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
// Getting Startedページ
|
|
||||||
$this->createDocument(
|
|
||||||
'Getting Started',
|
|
||||||
"# Getting Started\n\nLearn how to use this knowledge base.\n\n## Creating Documents\n\nClick on any [[wiki-link]] to create a new document.\n\n## Editing\n\nClick the edit button to modify content.\n\nBack to [[Home]]",
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
"keywords": ["laravel", "framework"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.3",
|
||||||
"cocur/slugify": "^4.7",
|
"cocur/slugify": "^4.7",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^3.0",
|
||||||
"league/commonmark": "^2.8",
|
"league/commonmark": "^2.8",
|
||||||
"livewire/livewire": "^3.7"
|
"livewire/livewire": "^3.7"
|
||||||
},
|
},
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.41",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
"phpunit/phpunit": "^11.5.3"
|
"phpunit/phpunit": "^12.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|||||||
Generated
+864
-862
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -65,7 +65,7 @@
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'timezone' => 'UTC',
|
'timezone' => env('APP_TIMEZONE', 'Asia/Tokyo'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Helpers\SlugHelper;
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\DocumentTranslation;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Document>
|
||||||
|
*/
|
||||||
|
class DocumentFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Document::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$title = rtrim(fake()->unique()->words(3, true), '.');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => $title . '.md',
|
||||||
|
'slug' => SlugHelper::generate($title),
|
||||||
|
'default_locale' => 'en',
|
||||||
|
'file_size' => 0,
|
||||||
|
'file_hash' => str_repeat('0', 64),
|
||||||
|
'file_modified_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After creating, attach a translation in the document's default_locale
|
||||||
|
* (skipped if a translation was already created via state, or if the
|
||||||
|
* caller used withoutTranslations()).
|
||||||
|
*/
|
||||||
|
public function configure(): static
|
||||||
|
{
|
||||||
|
return $this->afterCreating(function (Document $document) {
|
||||||
|
if ($document->translations()->count() === 0) {
|
||||||
|
DocumentTranslation::factory()->create([
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'locale' => $document->default_locale,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override the default_locale (auto-translation will be created in this locale).
|
||||||
|
*/
|
||||||
|
public function defaultLocale(string $locale): static
|
||||||
|
{
|
||||||
|
return $this->state(['default_locale' => $locale]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppress automatic translation creation. Uses Laravel's built-in
|
||||||
|
* withoutAfterCreating() to clear callbacks rather than appending a no-op
|
||||||
|
* (afterCreating appends, so a no-op closure does NOT override the configure() callback).
|
||||||
|
*/
|
||||||
|
public function withoutTranslations(): static
|
||||||
|
{
|
||||||
|
return $this->withoutAfterCreating();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\DocumentTranslation;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<DocumentTranslation>
|
||||||
|
*/
|
||||||
|
class DocumentTranslationFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = DocumentTranslation::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$title = fake()->sentence(3);
|
||||||
|
$content = fake()->paragraphs(3, true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'document_id' => Document::factory()->withoutTranslations(),
|
||||||
|
'locale' => 'en',
|
||||||
|
'title' => $title,
|
||||||
|
'content' => $content,
|
||||||
|
'rendered_html' => '<p>' . e($content) . '</p>',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
return new class extends Migration
|
return new class extends Migration
|
||||||
@@ -27,7 +28,9 @@ public function up(): void
|
|||||||
|
|
||||||
// FULLTEXT検索インデックス(MySQL 5.7以降)
|
// FULLTEXT検索インデックス(MySQL 5.7以降)
|
||||||
// ngramトークナイザーは日本語対応に必要だが、設定が必要
|
// ngramトークナイザーは日本語対応に必要だが、設定が必要
|
||||||
|
if (DB::connection()->getDriverName() === 'mysql') {
|
||||||
DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram');
|
DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram');
|
||||||
|
}
|
||||||
|
|
||||||
// 検索パフォーマンス向上用インデックス
|
// 検索パフォーマンス向上用インデックス
|
||||||
Schema::table('documents', function (Blueprint $table) {
|
Schema::table('documents', function (Blueprint $table) {
|
||||||
|
|||||||
+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;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\Document;
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class DocumentSeeder extends Seeder
|
class DocumentSeeder extends Seeder
|
||||||
@@ -12,43 +11,23 @@ class DocumentSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// 既存のドキュメントがある場合はスキップ
|
if (\App\Models\Document::count() > 0) {
|
||||||
if (Document::count() > 0) {
|
|
||||||
$this->command->info('Documents already exist. Skipping...');
|
$this->command->info('Documents already exist. Skipping...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$documents = [
|
$service = app(\App\Services\DocumentService::class);
|
||||||
[
|
$defaultLocale = config('app.locale', 'en');
|
||||||
'title' => 'Home',
|
|
||||||
'path' => 'Home.md',
|
$docs = [
|
||||||
'slug' => 'home',
|
['title' => 'Home', 'content' => $this->getHomeContent()],
|
||||||
'content' => $this->getHomeContent(),
|
['title' => 'Getting Started', 'content' => $this->getGettingStartedContent()],
|
||||||
],
|
['title' => 'Markdown Guide', 'content' => $this->getMarkdownGuideContent()],
|
||||||
[
|
|
||||||
'title' => 'Getting Started',
|
|
||||||
'path' => 'Getting Started.md',
|
|
||||||
'slug' => 'getting-started',
|
|
||||||
'content' => $this->getGettingStartedContent(),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Markdown Guide',
|
|
||||||
'path' => 'Markdown Guide.md',
|
|
||||||
'slug' => 'markdown-guide',
|
|
||||||
'content' => $this->getMarkdownGuideContent(),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($documents as $doc) {
|
foreach ($docs as $d) {
|
||||||
Document::create([
|
$service->createDocument($d['title'], $d['content'], null, $defaultLocale);
|
||||||
'title' => $doc['title'],
|
$this->command->info("Created: {$d['title']}");
|
||||||
'path' => $doc['path'],
|
|
||||||
'slug' => $doc['slug'],
|
|
||||||
'content' => $doc['content'],
|
|
||||||
'rendered_html' => Document::renderMarkdown($doc['content']),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->command->info("Created: {$doc['title']}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->command->info('Initial documents created successfully!');
|
$this->command->info('Initial documents created successfully!');
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => 'Inhalt',
|
'content_label' => 'Inhalt',
|
||||||
'content_placeholder' => 'Schreiben Sie hier Ihren Markdown...',
|
'content_placeholder' => 'Schreiben Sie hier Ihren Markdown...',
|
||||||
'saving' => 'Speichern...',
|
'saving' => 'Speichern...',
|
||||||
|
'fallback_notice' => 'Eine Übersetzung dieses Artikels in der von Ihnen gewählten Sprache ist nicht verfügbar. Die Originalsprachversion wird angezeigt.',
|
||||||
|
'add_translation' => 'Übersetzung hinzufügen',
|
||||||
|
'translation_added' => 'Übersetzung hinzugefügt.',
|
||||||
|
'translation_deleted' => 'Übersetzung gelöscht.',
|
||||||
|
'set_as_default' => 'Als Standard festlegen',
|
||||||
|
'delete_translation' => 'Übersetzung löschen',
|
||||||
|
'delete_translation_blocked' => 'Die Übersetzung der Standardsprache kann nicht gelöscht werden.',
|
||||||
|
'translation_tabs_label' => 'Sprachen',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -123,6 +131,25 @@
|
|||||||
'back_to_home' => 'Zurück zur Startseite',
|
'back_to_home' => 'Zurück zur Startseite',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => 'Englisch',
|
||||||
|
'ja' => 'Japanisch',
|
||||||
|
'zh-CN' => 'Chinesisch (vereinfacht)',
|
||||||
|
'zh-TW' => 'Chinesisch (traditionell)',
|
||||||
|
'ko' => 'Koreanisch',
|
||||||
|
'hi' => 'Hindi',
|
||||||
|
'vi' => 'Vietnamesisch',
|
||||||
|
'tr' => 'Türkisch',
|
||||||
|
'de' => 'Deutsch',
|
||||||
|
'fr' => 'Französisch',
|
||||||
|
'es' => 'Spanisch',
|
||||||
|
'pt-BR' => 'Portugiesisch (Brasilien)',
|
||||||
|
'ru' => 'Russisch',
|
||||||
|
'uk' => 'Ukrainisch',
|
||||||
|
'it' => 'Italienisch',
|
||||||
|
'pl' => 'Polnisch',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => 'Profil',
|
'title' => 'Profil',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => 'Content',
|
'content_label' => 'Content',
|
||||||
'content_placeholder' => 'Write your markdown here...',
|
'content_placeholder' => 'Write your markdown here...',
|
||||||
'saving' => 'Saving...',
|
'saving' => 'Saving...',
|
||||||
|
'translation_added' => 'Translation added.',
|
||||||
|
'translation_deleted' => 'Translation deleted.',
|
||||||
|
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||||
|
'add_translation' => 'Add translation',
|
||||||
|
'set_as_default' => 'Set as default',
|
||||||
|
'delete_translation' => 'Delete translation',
|
||||||
|
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||||
|
'translation_tabs_label' => 'Languages',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -123,6 +131,25 @@
|
|||||||
'back_to_home' => 'Back to Home',
|
'back_to_home' => 'Back to Home',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => 'English',
|
||||||
|
'ja' => 'Japanese',
|
||||||
|
'zh-CN' => 'Simplified Chinese',
|
||||||
|
'zh-TW' => 'Traditional Chinese',
|
||||||
|
'ko' => 'Korean',
|
||||||
|
'hi' => 'Hindi',
|
||||||
|
'vi' => 'Vietnamese',
|
||||||
|
'tr' => 'Turkish',
|
||||||
|
'de' => 'German',
|
||||||
|
'fr' => 'French',
|
||||||
|
'es' => 'Spanish',
|
||||||
|
'pt-BR' => 'Portuguese (Brazil)',
|
||||||
|
'ru' => 'Russian',
|
||||||
|
'uk' => 'Ukrainian',
|
||||||
|
'it' => 'Italian',
|
||||||
|
'pl' => 'Polish',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => 'Profile',
|
'title' => 'Profile',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => 'Contenido',
|
'content_label' => 'Contenido',
|
||||||
'content_placeholder' => 'Escriba su markdown aquí...',
|
'content_placeholder' => 'Escriba su markdown aquí...',
|
||||||
'saving' => 'Guardando...',
|
'saving' => 'Guardando...',
|
||||||
|
'fallback_notice' => 'Este artículo no está traducido al idioma seleccionado. Se muestra la versión en el idioma original.',
|
||||||
|
'add_translation' => 'Añadir traducción',
|
||||||
|
'translation_added' => 'Traducción añadida.',
|
||||||
|
'translation_deleted' => 'Traducción eliminada.',
|
||||||
|
'set_as_default' => 'Establecer como predeterminado',
|
||||||
|
'delete_translation' => 'Eliminar traducción',
|
||||||
|
'delete_translation_blocked' => 'No se puede eliminar la traducción del idioma predeterminado.',
|
||||||
|
'translation_tabs_label' => 'Idiomas',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -123,6 +131,25 @@
|
|||||||
'back_to_home' => 'Volver al inicio',
|
'back_to_home' => 'Volver al inicio',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => 'Inglés',
|
||||||
|
'ja' => 'Japonés',
|
||||||
|
'zh-CN' => 'Chino (simplificado)',
|
||||||
|
'zh-TW' => 'Chino (tradicional)',
|
||||||
|
'ko' => 'Coreano',
|
||||||
|
'hi' => 'Hindi',
|
||||||
|
'vi' => 'Vietnamita',
|
||||||
|
'tr' => 'Turco',
|
||||||
|
'de' => 'Alemán',
|
||||||
|
'fr' => 'Francés',
|
||||||
|
'es' => 'Español',
|
||||||
|
'pt-BR' => 'Portugués (Brasil)',
|
||||||
|
'ru' => 'Ruso',
|
||||||
|
'uk' => 'Ucraniano',
|
||||||
|
'it' => 'Italiano',
|
||||||
|
'pl' => 'Polaco',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => 'Perfil',
|
'title' => 'Perfil',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => 'Contenu',
|
'content_label' => 'Contenu',
|
||||||
'content_placeholder' => 'Écrivez votre markdown ici...',
|
'content_placeholder' => 'Écrivez votre markdown ici...',
|
||||||
'saving' => 'Enregistrement...',
|
'saving' => 'Enregistrement...',
|
||||||
|
'fallback_notice' => 'Cet article n\'est pas traduit dans la langue sélectionnée. La version dans la langue d\'origine est affichée.',
|
||||||
|
'add_translation' => 'Ajouter une traduction',
|
||||||
|
'translation_added' => 'Traduction ajoutée.',
|
||||||
|
'translation_deleted' => 'Traduction supprimée.',
|
||||||
|
'set_as_default' => 'Définir par défaut',
|
||||||
|
'delete_translation' => 'Supprimer la traduction',
|
||||||
|
'delete_translation_blocked' => 'La traduction de la langue par défaut ne peut pas être supprimée.',
|
||||||
|
'translation_tabs_label' => 'Langues',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -123,6 +131,25 @@
|
|||||||
'back_to_home' => 'Retour à l\'accueil',
|
'back_to_home' => 'Retour à l\'accueil',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => 'Anglais',
|
||||||
|
'ja' => 'Japonais',
|
||||||
|
'zh-CN' => 'Chinois (simplifié)',
|
||||||
|
'zh-TW' => 'Chinois (traditionnel)',
|
||||||
|
'ko' => 'Coréen',
|
||||||
|
'hi' => 'Hindi',
|
||||||
|
'vi' => 'Vietnamien',
|
||||||
|
'tr' => 'Turc',
|
||||||
|
'de' => 'Allemand',
|
||||||
|
'fr' => 'Français',
|
||||||
|
'es' => 'Espagnol',
|
||||||
|
'pt-BR' => 'Portugais (Brésil)',
|
||||||
|
'ru' => 'Russe',
|
||||||
|
'uk' => 'Ukrainien',
|
||||||
|
'it' => 'Italien',
|
||||||
|
'pl' => 'Polonais',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => 'Profil',
|
'title' => 'Profil',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => 'सामग्री',
|
'content_label' => 'सामग्री',
|
||||||
'content_placeholder' => 'यहां अपना मार्कडाउन लिखें...',
|
'content_placeholder' => 'यहां अपना मार्कडाउन लिखें...',
|
||||||
'saving' => 'सहेजा जा रहा है...',
|
'saving' => 'सहेजा जा रहा है...',
|
||||||
|
'fallback_notice' => 'इस लेख का आपकी चुनी गई भाषा में अनुवाद उपलब्ध नहीं है। मूल भाषा का संस्करण दिखाया जा रहा है।',
|
||||||
|
'add_translation' => 'अनुवाद जोड़ें',
|
||||||
|
'translation_added' => 'अनुवाद जोड़ा गया।',
|
||||||
|
'translation_deleted' => 'अनुवाद हटाया गया।',
|
||||||
|
'set_as_default' => 'डिफ़ॉल्ट के रूप में सेट करें',
|
||||||
|
'delete_translation' => 'अनुवाद हटाएं',
|
||||||
|
'delete_translation_blocked' => 'डिफ़ॉल्ट भाषा का अनुवाद हटाया नहीं जा सकता।',
|
||||||
|
'translation_tabs_label' => 'भाषाएँ',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -124,6 +132,25 @@
|
|||||||
'back_to_home' => 'होम पर वापस जाएं',
|
'back_to_home' => 'होम पर वापस जाएं',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => 'अंग्रेज़ी',
|
||||||
|
'ja' => 'जापानी',
|
||||||
|
'zh-CN' => 'चीनी (सरलीकृत)',
|
||||||
|
'zh-TW' => 'चीनी (पारंपरिक)',
|
||||||
|
'ko' => 'कोरियाई',
|
||||||
|
'hi' => 'हिन्दी',
|
||||||
|
'vi' => 'वियतनामी',
|
||||||
|
'tr' => 'तुर्की',
|
||||||
|
'de' => 'जर्मन',
|
||||||
|
'fr' => 'फ़्रेंच',
|
||||||
|
'es' => 'स्पेनिश',
|
||||||
|
'pt-BR' => 'पुर्तगाली (ब्राज़ील)',
|
||||||
|
'ru' => 'रूसी',
|
||||||
|
'uk' => 'यूक्रेनी',
|
||||||
|
'it' => 'इतालवी',
|
||||||
|
'pl' => 'पोलिश',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => 'प्रोफ़ाइल',
|
'title' => 'प्रोफ़ाइल',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => 'Contenuto',
|
'content_label' => 'Contenuto',
|
||||||
'content_placeholder' => 'Scrivi il tuo markdown qui...',
|
'content_placeholder' => 'Scrivi il tuo markdown qui...',
|
||||||
'saving' => 'Salvataggio...',
|
'saving' => 'Salvataggio...',
|
||||||
|
'fallback_notice' => 'Questo articolo non è tradotto nella lingua selezionata. Viene mostrata la versione nella lingua originale.',
|
||||||
|
'add_translation' => 'Aggiungi traduzione',
|
||||||
|
'translation_added' => 'Traduzione aggiunta.',
|
||||||
|
'translation_deleted' => 'Traduzione eliminata.',
|
||||||
|
'set_as_default' => 'Imposta come predefinita',
|
||||||
|
'delete_translation' => 'Elimina traduzione',
|
||||||
|
'delete_translation_blocked' => 'La traduzione della lingua predefinita non può essere eliminata.',
|
||||||
|
'translation_tabs_label' => 'Lingue',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -124,6 +132,25 @@
|
|||||||
'back_to_home' => 'Torna alla Home',
|
'back_to_home' => 'Torna alla Home',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => 'Inglese',
|
||||||
|
'ja' => 'Giapponese',
|
||||||
|
'zh-CN' => 'Cinese (semplificato)',
|
||||||
|
'zh-TW' => 'Cinese (tradizionale)',
|
||||||
|
'ko' => 'Coreano',
|
||||||
|
'hi' => 'Hindi',
|
||||||
|
'vi' => 'Vietnamita',
|
||||||
|
'tr' => 'Turco',
|
||||||
|
'de' => 'Tedesco',
|
||||||
|
'fr' => 'Francese',
|
||||||
|
'es' => 'Spagnolo',
|
||||||
|
'pt-BR' => 'Portoghese (Brasile)',
|
||||||
|
'ru' => 'Russo',
|
||||||
|
'uk' => 'Ucraino',
|
||||||
|
'it' => 'Italiano',
|
||||||
|
'pl' => 'Polacco',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => 'Profilo',
|
'title' => 'Profilo',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => '本文',
|
'content_label' => '本文',
|
||||||
'content_placeholder' => 'Markdownで記述してください...',
|
'content_placeholder' => 'Markdownで記述してください...',
|
||||||
'saving' => '保存中...',
|
'saving' => '保存中...',
|
||||||
|
'translation_added' => '翻訳を追加しました。',
|
||||||
|
'translation_deleted' => '翻訳を削除しました。',
|
||||||
|
'fallback_notice' => 'この記事には選択した言語の翻訳がありません。元の言語版を表示しています。',
|
||||||
|
'add_translation' => '翻訳を追加',
|
||||||
|
'set_as_default' => 'デフォルトに設定',
|
||||||
|
'delete_translation' => '翻訳を削除',
|
||||||
|
'delete_translation_blocked' => 'デフォルト言語の翻訳は削除できません。',
|
||||||
|
'translation_tabs_label' => '言語',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -123,6 +131,14 @@
|
|||||||
'back_to_home' => 'ホームに戻る',
|
'back_to_home' => 'ホームに戻る',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => '英語', 'ja' => '日本語',
|
||||||
|
'zh-CN' => '簡体字中国語', 'zh-TW' => '繁体字中国語',
|
||||||
|
'ko' => '韓国語', 'hi' => 'ヒンディー語', 'vi' => 'ベトナム語', 'tr' => 'トルコ語',
|
||||||
|
'de' => 'ドイツ語', 'fr' => 'フランス語', 'es' => 'スペイン語', 'pt-BR' => 'ポルトガル語(ブラジル)',
|
||||||
|
'ru' => 'ロシア語', 'uk' => 'ウクライナ語', 'it' => 'イタリア語', 'pl' => 'ポーランド語',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => 'プロフィール',
|
'title' => 'プロフィール',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => '내용',
|
'content_label' => '내용',
|
||||||
'content_placeholder' => '여기에 마크다운을 작성하세요...',
|
'content_placeholder' => '여기에 마크다운을 작성하세요...',
|
||||||
'saving' => '저장 중...',
|
'saving' => '저장 중...',
|
||||||
|
'fallback_notice' => '이 문서에는 선택한 언어의 번역이 없습니다. 원본 언어 버전을 표시합니다.',
|
||||||
|
'add_translation' => '번역 추가',
|
||||||
|
'translation_added' => '번역이 추가되었습니다.',
|
||||||
|
'translation_deleted' => '번역이 삭제되었습니다.',
|
||||||
|
'set_as_default' => '기본값으로 설정',
|
||||||
|
'delete_translation' => '번역 삭제',
|
||||||
|
'delete_translation_blocked' => '기본 언어의 번역은 삭제할 수 없습니다.',
|
||||||
|
'translation_tabs_label' => '언어',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -123,6 +131,25 @@
|
|||||||
'back_to_home' => '홈으로 돌아가기',
|
'back_to_home' => '홈으로 돌아가기',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => '영어',
|
||||||
|
'ja' => '일본어',
|
||||||
|
'zh-CN' => '중국어 간체',
|
||||||
|
'zh-TW' => '중국어 번체',
|
||||||
|
'ko' => '한국어',
|
||||||
|
'hi' => '힌디어',
|
||||||
|
'vi' => '베트남어',
|
||||||
|
'tr' => '터키어',
|
||||||
|
'de' => '독일어',
|
||||||
|
'fr' => '프랑스어',
|
||||||
|
'es' => '스페인어',
|
||||||
|
'pt-BR' => '포르투갈어 (브라질)',
|
||||||
|
'ru' => '러시아어',
|
||||||
|
'uk' => '우크라이나어',
|
||||||
|
'it' => '이탈리아어',
|
||||||
|
'pl' => '폴란드어',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => '프로필',
|
'title' => '프로필',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => 'Treść',
|
'content_label' => 'Treść',
|
||||||
'content_placeholder' => 'Napisz swój markdown tutaj...',
|
'content_placeholder' => 'Napisz swój markdown tutaj...',
|
||||||
'saving' => 'Zapisywanie...',
|
'saving' => 'Zapisywanie...',
|
||||||
|
'fallback_notice' => 'Ten artykuł nie został przetłumaczony na wybrany język. Wyświetlana jest wersja w języku oryginału.',
|
||||||
|
'add_translation' => 'Dodaj tłumaczenie',
|
||||||
|
'translation_added' => 'Tłumaczenie dodane.',
|
||||||
|
'translation_deleted' => 'Tłumaczenie usunięte.',
|
||||||
|
'set_as_default' => 'Ustaw jako domyślny',
|
||||||
|
'delete_translation' => 'Usuń tłumaczenie',
|
||||||
|
'delete_translation_blocked' => 'Nie można usunąć tłumaczenia w języku domyślnym.',
|
||||||
|
'translation_tabs_label' => 'Języki',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -124,6 +132,25 @@
|
|||||||
'back_to_home' => 'Wróć do strony głównej',
|
'back_to_home' => 'Wróć do strony głównej',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => 'Angielski',
|
||||||
|
'ja' => 'Japoński',
|
||||||
|
'zh-CN' => 'Chiński (uproszczony)',
|
||||||
|
'zh-TW' => 'Chiński (tradycyjny)',
|
||||||
|
'ko' => 'Koreański',
|
||||||
|
'hi' => 'Hindi',
|
||||||
|
'vi' => 'Wietnamski',
|
||||||
|
'tr' => 'Turecki',
|
||||||
|
'de' => 'Niemiecki',
|
||||||
|
'fr' => 'Francuski',
|
||||||
|
'es' => 'Hiszpański',
|
||||||
|
'pt-BR' => 'Portugalski (Brazylia)',
|
||||||
|
'ru' => 'Rosyjski',
|
||||||
|
'uk' => 'Ukraiński',
|
||||||
|
'it' => 'Włoski',
|
||||||
|
'pl' => 'Polski',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => 'Profil',
|
'title' => 'Profil',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => 'Conteúdo',
|
'content_label' => 'Conteúdo',
|
||||||
'content_placeholder' => 'Escreva seu markdown aqui...',
|
'content_placeholder' => 'Escreva seu markdown aqui...',
|
||||||
'saving' => 'Salvando...',
|
'saving' => 'Salvando...',
|
||||||
|
'fallback_notice' => 'Este artigo não possui tradução para o idioma selecionado. Exibindo a versão no idioma original.',
|
||||||
|
'add_translation' => 'Adicionar tradução',
|
||||||
|
'translation_added' => 'Tradução adicionada.',
|
||||||
|
'translation_deleted' => 'Tradução excluída.',
|
||||||
|
'set_as_default' => 'Definir como padrão',
|
||||||
|
'delete_translation' => 'Excluir tradução',
|
||||||
|
'delete_translation_blocked' => 'A tradução do idioma padrão não pode ser excluída.',
|
||||||
|
'translation_tabs_label' => 'Idiomas',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -124,6 +132,25 @@
|
|||||||
'back_to_home' => 'Voltar para Início',
|
'back_to_home' => 'Voltar para Início',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => 'Inglês',
|
||||||
|
'ja' => 'Japonês',
|
||||||
|
'zh-CN' => 'Chinês (simplificado)',
|
||||||
|
'zh-TW' => 'Chinês (tradicional)',
|
||||||
|
'ko' => 'Coreano',
|
||||||
|
'hi' => 'Híndi',
|
||||||
|
'vi' => 'Vietnamita',
|
||||||
|
'tr' => 'Turco',
|
||||||
|
'de' => 'Alemão',
|
||||||
|
'fr' => 'Francês',
|
||||||
|
'es' => 'Espanhol',
|
||||||
|
'pt-BR' => 'Português (Brasil)',
|
||||||
|
'ru' => 'Russo',
|
||||||
|
'uk' => 'Ucraniano',
|
||||||
|
'it' => 'Italiano',
|
||||||
|
'pl' => 'Polonês',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => 'Perfil',
|
'title' => 'Perfil',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => 'Содержимое',
|
'content_label' => 'Содержимое',
|
||||||
'content_placeholder' => 'Напишите здесь ваш markdown...',
|
'content_placeholder' => 'Напишите здесь ваш markdown...',
|
||||||
'saving' => 'Сохранение...',
|
'saving' => 'Сохранение...',
|
||||||
|
'fallback_notice' => 'Эта статья не переведена на выбранный язык. Отображается версия на языке оригинала.',
|
||||||
|
'add_translation' => 'Добавить перевод',
|
||||||
|
'translation_added' => 'Перевод добавлен.',
|
||||||
|
'translation_deleted' => 'Перевод удалён.',
|
||||||
|
'set_as_default' => 'Установить по умолчанию',
|
||||||
|
'delete_translation' => 'Удалить перевод',
|
||||||
|
'delete_translation_blocked' => 'Перевод языка по умолчанию нельзя удалить.',
|
||||||
|
'translation_tabs_label' => 'Языки',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -124,6 +132,25 @@
|
|||||||
'back_to_home' => 'Вернуться на главную',
|
'back_to_home' => 'Вернуться на главную',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => 'Английский',
|
||||||
|
'ja' => 'Японский',
|
||||||
|
'zh-CN' => 'Китайский (упрощённый)',
|
||||||
|
'zh-TW' => 'Китайский (традиционный)',
|
||||||
|
'ko' => 'Корейский',
|
||||||
|
'hi' => 'Хинди',
|
||||||
|
'vi' => 'Вьетнамский',
|
||||||
|
'tr' => 'Турецкий',
|
||||||
|
'de' => 'Немецкий',
|
||||||
|
'fr' => 'Французский',
|
||||||
|
'es' => 'Испанский',
|
||||||
|
'pt-BR' => 'Португальский (Бразилия)',
|
||||||
|
'ru' => 'Русский',
|
||||||
|
'uk' => 'Украинский',
|
||||||
|
'it' => 'Итальянский',
|
||||||
|
'pl' => 'Польский',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => 'Профиль',
|
'title' => 'Профиль',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => 'İçerik',
|
'content_label' => 'İçerik',
|
||||||
'content_placeholder' => 'Markdown\'ınızı buraya yazın...',
|
'content_placeholder' => 'Markdown\'ınızı buraya yazın...',
|
||||||
'saving' => 'Kaydediliyor...',
|
'saving' => 'Kaydediliyor...',
|
||||||
|
'fallback_notice' => 'Bu makalenin seçtiğiniz dile çevirisi mevcut değil. Orijinal dil sürümü gösteriliyor.',
|
||||||
|
'add_translation' => 'Çeviri ekle',
|
||||||
|
'translation_added' => 'Çeviri eklendi.',
|
||||||
|
'translation_deleted' => 'Çeviri silindi.',
|
||||||
|
'set_as_default' => 'Varsayılan olarak ayarla',
|
||||||
|
'delete_translation' => 'Çeviriyi sil',
|
||||||
|
'delete_translation_blocked' => 'Varsayılan dilin çevirisi silinemez.',
|
||||||
|
'translation_tabs_label' => 'Diller',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -124,6 +132,25 @@
|
|||||||
'back_to_home' => 'Ana Sayfaya Dön',
|
'back_to_home' => 'Ana Sayfaya Dön',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => 'İngilizce',
|
||||||
|
'ja' => 'Japonca',
|
||||||
|
'zh-CN' => 'Basitleştirilmiş Çince',
|
||||||
|
'zh-TW' => 'Geleneksel Çince',
|
||||||
|
'ko' => 'Korece',
|
||||||
|
'hi' => 'Hintçe',
|
||||||
|
'vi' => 'Vietnamca',
|
||||||
|
'tr' => 'Türkçe',
|
||||||
|
'de' => 'Almanca',
|
||||||
|
'fr' => 'Fransızca',
|
||||||
|
'es' => 'İspanyolca',
|
||||||
|
'pt-BR' => 'Portekizce (Brezilya)',
|
||||||
|
'ru' => 'Rusça',
|
||||||
|
'uk' => 'Ukraynaca',
|
||||||
|
'it' => 'İtalyanca',
|
||||||
|
'pl' => 'Lehçe',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => 'Profil',
|
'title' => 'Profil',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => 'Вміст',
|
'content_label' => 'Вміст',
|
||||||
'content_placeholder' => 'Напишіть тут ваш markdown...',
|
'content_placeholder' => 'Напишіть тут ваш markdown...',
|
||||||
'saving' => 'Збереження...',
|
'saving' => 'Збереження...',
|
||||||
|
'fallback_notice' => 'Цю статтю не перекладено вибраною мовою. Відображається версія мовою оригіналу.',
|
||||||
|
'add_translation' => 'Додати переклад',
|
||||||
|
'translation_added' => 'Переклад додано.',
|
||||||
|
'translation_deleted' => 'Переклад видалено.',
|
||||||
|
'set_as_default' => 'Встановити за замовчуванням',
|
||||||
|
'delete_translation' => 'Видалити переклад',
|
||||||
|
'delete_translation_blocked' => 'Переклад мови за замовчуванням не можна видалити.',
|
||||||
|
'translation_tabs_label' => 'Мови',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -124,6 +132,25 @@
|
|||||||
'back_to_home' => 'Повернутися на головну',
|
'back_to_home' => 'Повернутися на головну',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => 'Англійська',
|
||||||
|
'ja' => 'Японська',
|
||||||
|
'zh-CN' => 'Китайська (спрощена)',
|
||||||
|
'zh-TW' => 'Китайська (традиційна)',
|
||||||
|
'ko' => 'Корейська',
|
||||||
|
'hi' => 'Гінді',
|
||||||
|
'vi' => 'В\'єтнамська',
|
||||||
|
'tr' => 'Турецька',
|
||||||
|
'de' => 'Німецька',
|
||||||
|
'fr' => 'Французька',
|
||||||
|
'es' => 'Іспанська',
|
||||||
|
'pt-BR' => 'Португальська (Бразилія)',
|
||||||
|
'ru' => 'Російська',
|
||||||
|
'uk' => 'Українська',
|
||||||
|
'it' => 'Італійська',
|
||||||
|
'pl' => 'Польська',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => 'Профіль',
|
'title' => 'Профіль',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => 'Nội dung',
|
'content_label' => 'Nội dung',
|
||||||
'content_placeholder' => 'Viết markdown của bạn ở đây...',
|
'content_placeholder' => 'Viết markdown của bạn ở đây...',
|
||||||
'saving' => 'Đang lưu...',
|
'saving' => 'Đang lưu...',
|
||||||
|
'fallback_notice' => 'Bài viết này không có bản dịch sang ngôn ngữ bạn đã chọn. Đang hiển thị phiên bản ngôn ngữ gốc.',
|
||||||
|
'add_translation' => 'Thêm bản dịch',
|
||||||
|
'translation_added' => 'Đã thêm bản dịch.',
|
||||||
|
'translation_deleted' => 'Đã xoá bản dịch.',
|
||||||
|
'set_as_default' => 'Đặt làm mặc định',
|
||||||
|
'delete_translation' => 'Xoá bản dịch',
|
||||||
|
'delete_translation_blocked' => 'Không thể xoá bản dịch của ngôn ngữ mặc định.',
|
||||||
|
'translation_tabs_label' => 'Ngôn ngữ',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -124,6 +132,25 @@
|
|||||||
'back_to_home' => 'Quay lại trang chủ',
|
'back_to_home' => 'Quay lại trang chủ',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => 'Tiếng Anh',
|
||||||
|
'ja' => 'Tiếng Nhật',
|
||||||
|
'zh-CN' => 'Tiếng Trung (Giản thể)',
|
||||||
|
'zh-TW' => 'Tiếng Trung (Phồn thể)',
|
||||||
|
'ko' => 'Tiếng Hàn',
|
||||||
|
'hi' => 'Tiếng Hindi',
|
||||||
|
'vi' => 'Tiếng Việt',
|
||||||
|
'tr' => 'Tiếng Thổ Nhĩ Kỳ',
|
||||||
|
'de' => 'Tiếng Đức',
|
||||||
|
'fr' => 'Tiếng Pháp',
|
||||||
|
'es' => 'Tiếng Tây Ban Nha',
|
||||||
|
'pt-BR' => 'Tiếng Bồ Đào Nha (Brasil)',
|
||||||
|
'ru' => 'Tiếng Nga',
|
||||||
|
'uk' => 'Tiếng Ukraine',
|
||||||
|
'it' => 'Tiếng Ý',
|
||||||
|
'pl' => 'Tiếng Ba Lan',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => 'Hồ sơ',
|
'title' => 'Hồ sơ',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => '内容',
|
'content_label' => '内容',
|
||||||
'content_placeholder' => '在此输入Markdown内容...',
|
'content_placeholder' => '在此输入Markdown内容...',
|
||||||
'saving' => '保存中...',
|
'saving' => '保存中...',
|
||||||
|
'fallback_notice' => '此文章没有您所选语言的翻译。正在显示原语言版本。',
|
||||||
|
'add_translation' => '添加翻译',
|
||||||
|
'translation_added' => '翻译已添加。',
|
||||||
|
'translation_deleted' => '翻译已删除。',
|
||||||
|
'set_as_default' => '设为默认',
|
||||||
|
'delete_translation' => '删除翻译',
|
||||||
|
'delete_translation_blocked' => '无法删除默认语言的翻译。',
|
||||||
|
'translation_tabs_label' => '语言',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -123,6 +131,25 @@
|
|||||||
'back_to_home' => '返回首页',
|
'back_to_home' => '返回首页',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => '英语',
|
||||||
|
'ja' => '日语',
|
||||||
|
'zh-CN' => '简体中文',
|
||||||
|
'zh-TW' => '繁体中文',
|
||||||
|
'ko' => '韩语',
|
||||||
|
'hi' => '印地语',
|
||||||
|
'vi' => '越南语',
|
||||||
|
'tr' => '土耳其语',
|
||||||
|
'de' => '德语',
|
||||||
|
'fr' => '法语',
|
||||||
|
'es' => '西班牙语',
|
||||||
|
'pt-BR' => '葡萄牙语(巴西)',
|
||||||
|
'ru' => '俄语',
|
||||||
|
'uk' => '乌克兰语',
|
||||||
|
'it' => '意大利语',
|
||||||
|
'pl' => '波兰语',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => '个人资料',
|
'title' => '个人资料',
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
'content_label' => '內容',
|
'content_label' => '內容',
|
||||||
'content_placeholder' => '在此輸入Markdown內容...',
|
'content_placeholder' => '在此輸入Markdown內容...',
|
||||||
'saving' => '儲存中...',
|
'saving' => '儲存中...',
|
||||||
|
'fallback_notice' => '此文章沒有您所選語言的翻譯。正在顯示原語言版本。',
|
||||||
|
'add_translation' => '新增翻譯',
|
||||||
|
'translation_added' => '翻譯已新增。',
|
||||||
|
'translation_deleted' => '翻譯已刪除。',
|
||||||
|
'set_as_default' => '設為預設',
|
||||||
|
'delete_translation' => '刪除翻譯',
|
||||||
|
'delete_translation_blocked' => '無法刪除預設語言的翻譯。',
|
||||||
|
'translation_tabs_label' => '語言',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quick Switcher
|
// Quick Switcher
|
||||||
@@ -123,6 +131,25 @@
|
|||||||
'back_to_home' => '返回首頁',
|
'back_to_home' => '返回首頁',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'locale_names' => [
|
||||||
|
'en' => '英語',
|
||||||
|
'ja' => '日語',
|
||||||
|
'zh-CN' => '簡體中文',
|
||||||
|
'zh-TW' => '繁體中文',
|
||||||
|
'ko' => '韓語',
|
||||||
|
'hi' => '印地語',
|
||||||
|
'vi' => '越南語',
|
||||||
|
'tr' => '土耳其語',
|
||||||
|
'de' => '德語',
|
||||||
|
'fr' => '法語',
|
||||||
|
'es' => '西班牙語',
|
||||||
|
'pt-BR' => '葡萄牙語(巴西)',
|
||||||
|
'ru' => '俄語',
|
||||||
|
'uk' => '烏克蘭語',
|
||||||
|
'it' => '義大利語',
|
||||||
|
'pl' => '波蘭語',
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'title' => '個人資料',
|
'title' => '個人資料',
|
||||||
|
|||||||
Generated
+276
-227
@@ -4,6 +4,7 @@
|
|||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"name": "html",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"easymde": "^2.20.0"
|
"easymde": "^2.20.0"
|
||||||
@@ -34,9 +35,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -51,9 +52,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -68,9 +69,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -85,9 +86,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -102,9 +103,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -119,9 +120,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -136,9 +137,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -153,9 +154,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -170,9 +171,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -187,9 +188,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -204,9 +205,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -221,9 +222,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -238,9 +239,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
@@ -255,9 +256,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -272,9 +273,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -289,9 +290,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -306,9 +307,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -323,9 +324,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -340,9 +341,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -357,9 +358,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -374,9 +375,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -391,9 +392,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -408,9 +409,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -425,9 +426,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -442,9 +443,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -459,9 +460,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -557,9 +558,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
|
||||||
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
|
"integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -571,9 +572,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
|
||||||
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
|
"integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -585,9 +586,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
|
||||||
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
|
"integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -599,9 +600,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
|
||||||
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
|
"integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -613,9 +614,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
|
||||||
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
|
"integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -627,9 +628,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
|
||||||
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
|
"integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -641,9 +642,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
|
||||||
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
|
"integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -655,9 +656,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
|
||||||
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
|
"integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -669,9 +670,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
|
||||||
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
|
"integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -683,9 +684,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
|
||||||
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
|
"integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -697,9 +698,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
|
||||||
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
|
"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": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -711,9 +726,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
|
||||||
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
|
"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": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -725,9 +754,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
|
||||||
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
|
"integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -739,9 +768,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
|
||||||
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
|
"integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -753,9 +782,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
|
||||||
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
|
"integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -767,9 +796,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
|
||||||
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
|
"integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -781,9 +810,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
|
||||||
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
|
"integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -794,10 +823,24 @@
|
|||||||
"linux"
|
"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": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
|
||||||
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
|
"integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -809,9 +852,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
|
||||||
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
|
"integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -823,9 +866,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
|
||||||
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
|
"integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -837,9 +880,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
|
||||||
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
|
"integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -851,9 +894,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
|
||||||
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
|
"integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1329,15 +1372,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.2",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.16.0",
|
||||||
"form-data": "^4.0.4",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
@@ -1765,9 +1808,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1778,32 +1821,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.25.12",
|
"@esbuild/aix-ppc64": "0.27.7",
|
||||||
"@esbuild/android-arm": "0.25.12",
|
"@esbuild/android-arm": "0.27.7",
|
||||||
"@esbuild/android-arm64": "0.25.12",
|
"@esbuild/android-arm64": "0.27.7",
|
||||||
"@esbuild/android-x64": "0.25.12",
|
"@esbuild/android-x64": "0.27.7",
|
||||||
"@esbuild/darwin-arm64": "0.25.12",
|
"@esbuild/darwin-arm64": "0.27.7",
|
||||||
"@esbuild/darwin-x64": "0.25.12",
|
"@esbuild/darwin-x64": "0.27.7",
|
||||||
"@esbuild/freebsd-arm64": "0.25.12",
|
"@esbuild/freebsd-arm64": "0.27.7",
|
||||||
"@esbuild/freebsd-x64": "0.25.12",
|
"@esbuild/freebsd-x64": "0.27.7",
|
||||||
"@esbuild/linux-arm": "0.25.12",
|
"@esbuild/linux-arm": "0.27.7",
|
||||||
"@esbuild/linux-arm64": "0.25.12",
|
"@esbuild/linux-arm64": "0.27.7",
|
||||||
"@esbuild/linux-ia32": "0.25.12",
|
"@esbuild/linux-ia32": "0.27.7",
|
||||||
"@esbuild/linux-loong64": "0.25.12",
|
"@esbuild/linux-loong64": "0.27.7",
|
||||||
"@esbuild/linux-mips64el": "0.25.12",
|
"@esbuild/linux-mips64el": "0.27.7",
|
||||||
"@esbuild/linux-ppc64": "0.25.12",
|
"@esbuild/linux-ppc64": "0.27.7",
|
||||||
"@esbuild/linux-riscv64": "0.25.12",
|
"@esbuild/linux-riscv64": "0.27.7",
|
||||||
"@esbuild/linux-s390x": "0.25.12",
|
"@esbuild/linux-s390x": "0.27.7",
|
||||||
"@esbuild/linux-x64": "0.25.12",
|
"@esbuild/linux-x64": "0.27.7",
|
||||||
"@esbuild/netbsd-arm64": "0.25.12",
|
"@esbuild/netbsd-arm64": "0.27.7",
|
||||||
"@esbuild/netbsd-x64": "0.25.12",
|
"@esbuild/netbsd-x64": "0.27.7",
|
||||||
"@esbuild/openbsd-arm64": "0.25.12",
|
"@esbuild/openbsd-arm64": "0.27.7",
|
||||||
"@esbuild/openbsd-x64": "0.25.12",
|
"@esbuild/openbsd-x64": "0.27.7",
|
||||||
"@esbuild/openharmony-arm64": "0.25.12",
|
"@esbuild/openharmony-arm64": "0.27.7",
|
||||||
"@esbuild/sunos-x64": "0.25.12",
|
"@esbuild/sunos-x64": "0.27.7",
|
||||||
"@esbuild/win32-arm64": "0.25.12",
|
"@esbuild/win32-arm64": "0.27.7",
|
||||||
"@esbuild/win32-ia32": "0.25.12",
|
"@esbuild/win32-ia32": "0.27.7",
|
||||||
"@esbuild/win32-x64": "0.25.12"
|
"@esbuild/win32-x64": "0.27.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
@@ -1866,9 +1909,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2621,9 +2664,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
@@ -2651,9 +2694,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -2820,11 +2863,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
@@ -2908,9 +2954,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.3",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
|
||||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
"integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2924,28 +2970,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.53.3",
|
"@rollup/rollup-android-arm-eabi": "4.60.3",
|
||||||
"@rollup/rollup-android-arm64": "4.53.3",
|
"@rollup/rollup-android-arm64": "4.60.3",
|
||||||
"@rollup/rollup-darwin-arm64": "4.53.3",
|
"@rollup/rollup-darwin-arm64": "4.60.3",
|
||||||
"@rollup/rollup-darwin-x64": "4.53.3",
|
"@rollup/rollup-darwin-x64": "4.60.3",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.53.3",
|
"@rollup/rollup-freebsd-arm64": "4.60.3",
|
||||||
"@rollup/rollup-freebsd-x64": "4.53.3",
|
"@rollup/rollup-freebsd-x64": "4.60.3",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
|
"@rollup/rollup-linux-arm-musleabihf": "4.60.3",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
|
"@rollup/rollup-linux-arm64-gnu": "4.60.3",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.53.3",
|
"@rollup/rollup-linux-arm64-musl": "4.60.3",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
|
"@rollup/rollup-linux-loong64-gnu": "4.60.3",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
|
"@rollup/rollup-linux-loong64-musl": "4.60.3",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
|
"@rollup/rollup-linux-ppc64-gnu": "4.60.3",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
|
"@rollup/rollup-linux-ppc64-musl": "4.60.3",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
|
"@rollup/rollup-linux-riscv64-gnu": "4.60.3",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.53.3",
|
"@rollup/rollup-linux-riscv64-musl": "4.60.3",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.53.3",
|
"@rollup/rollup-linux-s390x-gnu": "4.60.3",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.53.3",
|
"@rollup/rollup-linux-x64-gnu": "4.60.3",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
|
"@rollup/rollup-linux-x64-musl": "4.60.3",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
|
"@rollup/rollup-openbsd-x64": "4.60.3",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.53.3",
|
"@rollup/rollup-openharmony-arm64": "4.60.3",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.53.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"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3210,9 +3259,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -3300,13 +3349,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.2.4",
|
"version": "7.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
|
||||||
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
|
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
@@ -3404,9 +3453,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/picomatch": {
|
"node_modules/vite/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -27,34 +27,69 @@
|
|||||||
@livewireStyles
|
@livewireStyles
|
||||||
@stack('styles')
|
@stack('styles')
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans antialiased">
|
<body class="font-sans antialiased" x-data="{
|
||||||
|
mobileMenuOpen: false,
|
||||||
|
sidebarWidth: localStorage.getItem('kb_sidebar_width') || 320,
|
||||||
|
isResizing: false,
|
||||||
|
startResize(e) {
|
||||||
|
if (window.innerWidth < 1024) return; // lg breakpoint
|
||||||
|
this.isResizing = true;
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
},
|
||||||
|
resize(e) {
|
||||||
|
if (!this.isResizing) return;
|
||||||
|
const newWidth = Math.max(200, Math.min(600, e.clientX));
|
||||||
|
this.sidebarWidth = newWidth;
|
||||||
|
localStorage.setItem('kb_sidebar_width', newWidth);
|
||||||
|
},
|
||||||
|
stopResize() {
|
||||||
|
if (this.isResizing) {
|
||||||
|
this.isResizing = false;
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}" @mousemove.window="resize($event)" @mouseup.window="stopResize()">
|
||||||
<div class="min-h-screen bg-gray-50">
|
<div class="min-h-screen bg-gray-50">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="bg-white border-b border-gray-200 sticky top-0 z-10">
|
<header class="bg-white border-b border-gray-200 sticky top-0 z-20">
|
||||||
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between h-16">
|
<div class="flex justify-between h-16">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<a href="{{ url('/') }}" class="flex items-center space-x-3">
|
<!-- Mobile Menu Toggle -->
|
||||||
<x-application-logo class="block h-8 w-auto fill-current text-gray-800" />
|
<button
|
||||||
<h1 class="text-xl font-semibold text-gray-900">
|
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||||
|
class="lg:hidden p-2 rounded-md text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path x-show="!mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
<path x-show="mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="{{ url('/') }}" class="flex items-center space-x-2 sm:space-x-3">
|
||||||
|
<x-application-logo class="block h-6 sm:h-8 w-auto fill-current text-gray-800" />
|
||||||
|
<h1 class="text-lg sm:text-xl font-semibold text-gray-900 hidden xs:block">
|
||||||
{{ config('app.name', 'Knowledge Base') }}
|
{{ config('app.name', 'Knowledge Base') }}
|
||||||
</h1>
|
</h1>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-2 sm:space-x-4">
|
||||||
<!-- Quick Switcher Trigger -->
|
<!-- Quick Switcher Trigger -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
class="inline-flex items-center px-2 sm:px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
x-data
|
x-data
|
||||||
@click.prevent="$dispatch('open-quick-switcher')"
|
@click.prevent="$dispatch('open-quick-switcher')"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-4 w-4 sm:mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
{{ __('messages.quick_switcher.title') }}
|
<span class="hidden sm:inline">{{ __('messages.quick_switcher.title') }}</span>
|
||||||
<kbd class="ml-2 px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded">
|
<kbd class="hidden md:inline-flex ml-2 px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded">
|
||||||
Ctrl+K
|
Ctrl+K
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
@@ -63,10 +98,10 @@ class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-
|
|||||||
<div x-data="{ open: false }" @click.away="open = false" class="relative">
|
<div x-data="{ open: false }" @click.away="open = false" class="relative">
|
||||||
<button
|
<button
|
||||||
@click="open = !open"
|
@click="open = !open"
|
||||||
class="flex items-center px-3 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 focus:outline-none"
|
class="flex items-center px-2 sm:px-3 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 focus:outline-none"
|
||||||
title="{{ __('messages.settings.change_language') }}"
|
title="{{ __('messages.settings.change_language') }}"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 sm:w-5 sm:h-5 sm:mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@php
|
@php
|
||||||
@@ -74,7 +109,7 @@ class="flex items-center px-3 py-2 text-sm font-medium text-gray-700 hover:text-
|
|||||||
$locales = \App\Http\Middleware\SetLocale::SUPPORTED_LOCALES;
|
$locales = \App\Http\Middleware\SetLocale::SUPPORTED_LOCALES;
|
||||||
@endphp
|
@endphp
|
||||||
<span class="hidden sm:inline">{{ $locales[$currentLocale] ?? 'English' }}</span>
|
<span class="hidden sm:inline">{{ $locales[$currentLocale] ?? 'English' }}</span>
|
||||||
<svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="ml-1 h-4 w-4 hidden sm:block" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -82,7 +117,7 @@ class="flex items-center px-3 py-2 text-sm font-medium text-gray-700 hover:text-
|
|||||||
<div
|
<div
|
||||||
x-show="open"
|
x-show="open"
|
||||||
x-transition
|
x-transition
|
||||||
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring-black ring-opacity-5 max-h-96 overflow-y-auto"
|
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring-black ring-opacity-5 max-h-96 overflow-y-auto z-50"
|
||||||
>
|
>
|
||||||
@foreach($locales as $code => $name)
|
@foreach($locales as $code => $name)
|
||||||
<form method="POST" action="{{ route('locale.update') }}" class="inline-block w-full">
|
<form method="POST" action="{{ route('locale.update') }}" class="inline-block w-full">
|
||||||
@@ -111,8 +146,11 @@ class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 {{ $currentLocale ==
|
|||||||
@click="open = !open"
|
@click="open = !open"
|
||||||
class="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900 focus:outline-none"
|
class="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900 focus:outline-none"
|
||||||
>
|
>
|
||||||
{{ Auth::user()->name }}
|
<span class="hidden md:inline">{{ Auth::user()->name }}</span>
|
||||||
<svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
<span class="md:hidden w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center text-indigo-700 font-semibold">
|
||||||
|
{{ strtoupper(substr(Auth::user()->name, 0, 1)) }}
|
||||||
|
</span>
|
||||||
|
<svg class="ml-1 h-4 w-4 hidden md:block" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -120,7 +158,7 @@ class="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900 f
|
|||||||
<div
|
<div
|
||||||
x-show="open"
|
x-show="open"
|
||||||
x-transition
|
x-transition
|
||||||
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring-black ring-opacity-5"
|
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring-black ring-opacity-5 z-50"
|
||||||
>
|
>
|
||||||
<a href="{{ route('profile.edit') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
<a href="{{ route('profile.edit') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||||
{{ __('messages.nav.profile') }}
|
{{ __('messages.nav.profile') }}
|
||||||
@@ -144,9 +182,14 @@ class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<a href="{{ route('login') }}" class="text-sm text-gray-700 hover:text-gray-900">
|
<a href="{{ route('login') }}" class="text-sm text-gray-700 hover:text-gray-900 hidden sm:block">
|
||||||
{{ __('messages.nav.login') }}
|
{{ __('messages.nav.login') }}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('login') }}" class="sm:hidden p-2 text-gray-700 hover:bg-gray-100 rounded-md" title="{{ __('messages.nav.login') }}">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
@endauth
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,8 +198,50 @@ class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring
|
|||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="flex h-[calc(100vh-4rem)]">
|
<div class="flex h-[calc(100vh-4rem)]">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar - Desktop -->
|
||||||
<aside class="w-64 bg-white border-r border-gray-200 overflow-y-auto">
|
<aside
|
||||||
|
id="kb-sidebar"
|
||||||
|
class="hidden lg:block bg-white border-r border-gray-200 overflow-y-auto relative"
|
||||||
|
:style="'width: ' + sidebarWidth + 'px'"
|
||||||
|
>
|
||||||
|
@livewire('sidebar-tree')
|
||||||
|
|
||||||
|
<!-- Resize Handle -->
|
||||||
|
<div
|
||||||
|
@mousedown="startResize($event)"
|
||||||
|
class="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-indigo-500 transition-colors group"
|
||||||
|
title="ドラッグして幅を変更"
|
||||||
|
>
|
||||||
|
<div class="absolute top-1/2 right-0 transform translate-x-1/2 -translate-y-1/2 w-1.5 h-12 bg-gray-300 rounded-full group-hover:bg-indigo-500 transition-colors"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Sidebar - Mobile Overlay -->
|
||||||
|
<div
|
||||||
|
x-show="mobileMenuOpen"
|
||||||
|
@click="mobileMenuOpen = false"
|
||||||
|
x-transition:enter="transition-opacity ease-linear duration-300"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition-opacity ease-linear duration-300"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
class="fixed inset-0 bg-gray-600 bg-opacity-75 z-30 lg:hidden"
|
||||||
|
style="display: none;"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
x-show="mobileMenuOpen"
|
||||||
|
@click.away="mobileMenuOpen = false"
|
||||||
|
x-transition:enter="transition ease-in-out duration-300 transform"
|
||||||
|
x-transition:enter-start="-translate-x-full"
|
||||||
|
x-transition:enter-end="translate-x-0"
|
||||||
|
x-transition:leave="transition ease-in-out duration-300 transform"
|
||||||
|
x-transition:leave-start="translate-x-0"
|
||||||
|
x-transition:leave-end="-translate-x-full"
|
||||||
|
class="fixed inset-y-0 left-0 top-16 w-64 bg-white border-r border-gray-200 overflow-y-auto z-40 lg:hidden"
|
||||||
|
style="display: none;"
|
||||||
|
>
|
||||||
@livewire('sidebar-tree')
|
@livewire('sidebar-tree')
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -174,6 +259,91 @@ class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring
|
|||||||
|
|
||||||
<!-- Global Keyboard Shortcuts -->
|
<!-- Global Keyboard Shortcuts -->
|
||||||
<script>
|
<script>
|
||||||
|
// Preserve sidebar scroll position during navigation
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const sidebar = document.getElementById('kb-sidebar');
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
const link = e.target.closest('a');
|
||||||
|
if (link && sidebar.contains(link)) {
|
||||||
|
const scrollPos = sidebar.scrollTop;
|
||||||
|
sessionStorage.setItem('kb_sidebar_scroll', scrollPos);
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Restore scroll position after page load
|
||||||
|
function restoreSidebarScroll() {
|
||||||
|
const sidebar = document.getElementById('kb-sidebar');
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
const savedPos = sessionStorage.getItem('kb_sidebar_scroll');
|
||||||
|
if (savedPos !== null && savedPos !== '0') {
|
||||||
|
sidebar.scrollTop = parseInt(savedPos, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
highlightCurrentDocument();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
restoreSidebarScroll();
|
||||||
|
highlightCurrentDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also restore on window load (for safety)
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
restoreSidebarScroll();
|
||||||
|
highlightCurrentDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update highlight after Alpine navigation
|
||||||
|
document.addEventListener('alpine:navigated', highlightCurrentDocument);
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="max-w-5xl mx-auto p-8">
|
<div class="max-w-5xl mx-auto p-4 sm:p-6 lg:p-8">
|
||||||
<!-- Flash Messages -->
|
<!-- Flash Messages -->
|
||||||
@if (session()->has('message'))
|
@if (session()->has('message'))
|
||||||
<div class="mb-4 p-4 bg-green-100 border border-green-400 text-green-700 rounded">
|
<div class="mb-4 p-4 bg-green-100 border border-green-400 text-green-700 rounded">
|
||||||
@@ -13,16 +13,16 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">
|
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">
|
||||||
{{ $isEditMode ? __('messages.documents.edit_document') : __('messages.documents.new_document') }}
|
{{ $isEditMode ? __('messages.documents.edit_document') : __('messages.documents.new_document') }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="flex space-x-3">
|
<div class="flex flex-wrap gap-2 sm:gap-3">
|
||||||
@if($isEditMode && $document)
|
@if($isEditMode && $document)
|
||||||
<a
|
<a
|
||||||
href="{{ route('documents.show', $document) }}"
|
href="{{ route('documents.show', $document) }}"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
class="inline-flex items-center justify-center px-3 sm:px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 flex-1 sm:flex-none"
|
||||||
>
|
>
|
||||||
{{ __('messages.common.cancel') }}
|
{{ __('messages.common.cancel') }}
|
||||||
</a>
|
</a>
|
||||||
@@ -30,14 +30,14 @@ class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text
|
|||||||
<button
|
<button
|
||||||
wire:click="delete"
|
wire:click="delete"
|
||||||
wire:confirm="{{ __('messages.documents.delete_confirm') }}"
|
wire:confirm="{{ __('messages.documents.delete_confirm') }}"
|
||||||
class="inline-flex items-center px-4 py-2 border border-red-300 rounded-md text-sm font-medium text-red-700 bg-white hover:bg-red-50"
|
class="inline-flex items-center justify-center px-3 sm:px-4 py-2 border border-red-300 rounded-md text-sm font-medium text-red-700 bg-white hover:bg-red-50 flex-1 sm:flex-none"
|
||||||
>
|
>
|
||||||
{{ __('messages.documents.delete') }}
|
{{ __('messages.documents.delete') }}
|
||||||
</button>
|
</button>
|
||||||
@else
|
@else
|
||||||
<a
|
<a
|
||||||
href="{{ route('documents.show', 'home') }}"
|
href="{{ route('documents.show', 'home') }}"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
class="inline-flex items-center justify-center px-3 sm:px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 flex-1 sm:flex-none"
|
||||||
>
|
>
|
||||||
{{ __('messages.common.cancel') }}
|
{{ __('messages.common.cancel') }}
|
||||||
</a>
|
</a>
|
||||||
@@ -45,16 +45,76 @@ class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
wire:click="save"
|
wire:click="save"
|
||||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 border border-transparent rounded-md text-sm font-medium text-white hover:bg-indigo-700"
|
class="inline-flex items-center justify-center px-3 sm:px-4 py-2 bg-indigo-600 border border-transparent rounded-md text-sm font-medium text-white hover:bg-indigo-700 flex-1 sm:flex-none"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 sm:mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
{{ __('messages.documents.save') }}
|
<span class="hidden sm:inline">{{ __('messages.documents.save') }}</span>
|
||||||
|
<span class="sm:hidden">{{ __('messages.documents.save') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if($isEditMode && $document)
|
||||||
|
<div class="mb-4 border-b border-gray-200" role="tablist" aria-label="{{ __('messages.documents.translation_tabs_label') }}">
|
||||||
|
<nav class="-mb-px flex flex-wrap gap-x-2">
|
||||||
|
@php $allLocales = \App\Http\Middleware\SetLocale::SUPPORTED_LOCALES; @endphp
|
||||||
|
@foreach($availableLocales as $loc)
|
||||||
|
@php $isActive = ($loc === $editingLocale); @endphp
|
||||||
|
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => $loc]) }}"
|
||||||
|
class="px-3 py-2 text-sm font-medium border-b-2 {{ $isActive ? 'border-indigo-500 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
||||||
|
{{ __('messages.locale_names.' . $loc) }}
|
||||||
|
@if($loc === $document->default_locale)
|
||||||
|
<span class="ml-1 text-xs text-gray-400">★</span>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@if($isNewLocale && $editingLocale)
|
||||||
|
<span class="px-3 py-2 text-sm font-medium border-b-2 border-indigo-500 text-indigo-600">
|
||||||
|
{{ __('messages.locale_names.' . $editingLocale) }}
|
||||||
|
<span class="ml-1 text-xs text-gray-400">({{ __('messages.documents.new_document') }})</span>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@php $missingLocales = array_diff(array_keys($allLocales), $availableLocales, $isNewLocale ? [$editingLocale] : []); @endphp
|
||||||
|
@if(!empty($missingLocales))
|
||||||
|
<div x-data="{ open: false }" class="relative">
|
||||||
|
<button type="button" @click="open = !open"
|
||||||
|
class="px-3 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||||
|
+ {{ __('messages.documents.add_translation') }}
|
||||||
|
</button>
|
||||||
|
<div x-show="open" @click.outside="open = false" x-cloak
|
||||||
|
class="absolute right-0 z-10 mt-2 w-48 max-h-64 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
|
||||||
|
@foreach($missingLocales as $loc)
|
||||||
|
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => $loc]) }}"
|
||||||
|
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||||
|
{{ $allLocales[$loc] }}
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@if($editingLocale !== $document->default_locale && !$isNewLocale)
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<button wire:click="setAsDefault" type="button"
|
||||||
|
class="px-2 py-1 text-xs text-indigo-600 border border-indigo-300 rounded hover:bg-indigo-50">
|
||||||
|
{{ __('messages.documents.set_as_default') }}
|
||||||
|
</button>
|
||||||
|
<button wire:click="deleteTranslation"
|
||||||
|
wire:confirm="{{ __('messages.documents.delete_confirm') }}"
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50">
|
||||||
|
{{ __('messages.documents.delete_translation') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<!-- Form -->
|
<!-- Form -->
|
||||||
<form wire:submit.prevent="save" class="space-y-6">
|
<form wire:submit.prevent="save" class="space-y-6">
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
@@ -146,6 +206,42 @@ class="w-full"
|
|||||||
'guide'
|
'guide'
|
||||||
],
|
],
|
||||||
status: ['lines', 'words', 'cursor'],
|
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', () => {
|
this.editor.codemirror.on('change', () => {
|
||||||
|
|||||||
@@ -1,27 +1,43 @@
|
|||||||
<div class="max-w-4xl mx-auto p-8">
|
<div class="max-w-4xl mx-auto p-4 sm:p-6 lg:p-8">
|
||||||
|
@if($isFallback)
|
||||||
|
<div class="mb-4 p-4 bg-amber-50 border border-amber-300 rounded-md flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<p class="text-sm text-amber-800">
|
||||||
|
{{ __('messages.documents.fallback_notice', ['locale' => __('messages.locale_names.' . $viewLocale)]) }}
|
||||||
|
</p>
|
||||||
|
@auth
|
||||||
|
@can('update', $document)
|
||||||
|
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => app()->getLocale()]) }}"
|
||||||
|
class="inline-flex items-center justify-center px-3 py-2 bg-amber-600 text-white text-sm font-medium rounded-md hover:bg-amber-700">
|
||||||
|
{{ __('messages.documents.add_translation') }}
|
||||||
|
</a>
|
||||||
|
@endcan
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<!-- Document Header -->
|
<!-- Document Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-6 sm:mb-8">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-4">
|
||||||
<h1 class="text-4xl font-bold text-gray-900">
|
<h1 class="text-2xl sm:text-3xl lg:text-4xl font-bold text-gray-900 break-words">
|
||||||
{{ $document->title }}
|
{{ $document->title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@auth
|
@can('update', $document)
|
||||||
<a
|
<a
|
||||||
href="{{ route('documents.edit', $document) }}"
|
href="{{ route('documents.edit', $document) }}"
|
||||||
class="inline-flex items-center 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"
|
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"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
{{ __('messages.documents.edit') }}
|
{{ __('messages.documents.edit') }}
|
||||||
</a>
|
</a>
|
||||||
@endauth
|
@endcan
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center text-sm text-gray-500 space-x-4">
|
<div class="flex flex-col sm:flex-row sm:items-center text-xs sm:text-sm text-gray-500 gap-2 sm:gap-4">
|
||||||
<span>
|
<span>
|
||||||
{{ __('messages.documents.updated') }} {{ $document->updated_at->diffForHumans() }}
|
{{ __('messages.documents.updated') }} {{ $document->updated_at->diffForHumans() }}({{ config('app.timezone') }})
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@if($document->updated_by && $document->updater)
|
@if($document->updated_by && $document->updater)
|
||||||
@@ -37,7 +53,7 @@ class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Document Content -->
|
<!-- Document Content -->
|
||||||
<div class="prose prose-lg max-w-none mb-12">
|
<div class="prose prose-sm sm:prose-base lg:prose-lg max-w-none mb-8 sm:mb-12">
|
||||||
{!! $renderedContent !!}
|
{!! $renderedContent !!}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,15 +85,15 @@ class="block p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition"
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Document Metadata -->
|
<!-- Document Metadata -->
|
||||||
<div class="mt-12 pt-8 border-t border-gray-200">
|
<div class="mt-8 sm:mt-12 pt-6 sm:pt-8 border-t border-gray-200">
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm text-gray-500">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs sm:text-sm text-gray-500">
|
||||||
<div>
|
<div class="break-all">
|
||||||
<span class="font-medium">{{ __('messages.documents.path') }}:</span>
|
<span class="font-medium">{{ __('messages.documents.path') }}:</span>
|
||||||
<code class="ml-2 text-xs bg-gray-100 px-2 py-1 rounded">{{ $document->path }}</code>
|
<code class="ml-2 text-xs bg-gray-100 px-2 py-1 rounded">{{ $document->path }}</code>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium">{{ __('messages.documents.last_modified') }}:</span>
|
<span class="font-medium">{{ __('messages.documents.last_modified') }}:</span>
|
||||||
<span class="ml-2">{{ $document->updated_at->format('Y-m-d H:i:s') }}</span>
|
<span class="ml-2">{{ $document->updated_at->format('Y-m-d H:i:s') }}({{ config('app.timezone') }})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class="fixed inset-0 z-50 overflow-y-auto"
|
|||||||
style="display: none;"
|
style="display: none;"
|
||||||
@click="open = false"
|
@click="open = false"
|
||||||
>
|
>
|
||||||
<div class="flex min-h-full items-start justify-center p-4 pt-[10vh]">
|
<div class="flex min-h-full items-start justify-center p-2 sm:p-4 pt-[5vh] sm:pt-[10vh]">
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-2xl bg-white rounded-lg shadow-2xl"
|
class="w-full max-w-2xl bg-white rounded-lg shadow-2xl"
|
||||||
@click.stop
|
@click.stop
|
||||||
@@ -41,16 +41,16 @@ class="w-full max-w-2xl bg-white rounded-lg shadow-2xl"
|
|||||||
wire:keydown.enter.prevent="selectDocument"
|
wire:keydown.enter.prevent="selectDocument"
|
||||||
>
|
>
|
||||||
<!-- Search Input -->
|
<!-- Search Input -->
|
||||||
<div class="p-4 border-b border-gray-200">
|
<div class="p-3 sm:p-4 border-b border-gray-200">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<svg class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="absolute left-2 sm:left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 sm:h-5 sm:w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
x-ref="searchInput"
|
x-ref="searchInput"
|
||||||
type="text"
|
type="text"
|
||||||
wire:model.live="search"
|
wire:model.live="search"
|
||||||
class="w-full pl-10 pr-4 py-3 border-0 focus:ring-0 text-lg"
|
class="w-full pl-8 sm:pl-10 pr-4 py-2 sm:py-3 border-0 focus:ring-0 text-base sm:text-lg"
|
||||||
placeholder="{{ __('messages.quick_switcher.placeholder') }}"
|
placeholder="{{ __('messages.quick_switcher.placeholder') }}"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
>
|
>
|
||||||
@@ -58,7 +58,7 @@ class="w-full pl-10 pr-4 py-3 border-0 focus:ring-0 text-lg"
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
<div class="max-h-96 overflow-y-auto">
|
<div class="max-h-60 sm:max-h-96 overflow-y-auto">
|
||||||
@if(empty($this->results))
|
@if(empty($this->results))
|
||||||
<div class="p-8 text-center text-gray-500">
|
<div class="p-8 text-center text-gray-500">
|
||||||
{{ __('messages.quick_switcher.no_results') }}
|
{{ __('messages.quick_switcher.no_results') }}
|
||||||
@@ -102,21 +102,21 @@ class="block px-4 py-3 hover:bg-gray-50 transition {{ $index === $selectedIndex
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="px-4 py-3 bg-gray-50 border-t border-gray-200 text-xs text-gray-500">
|
<div class="px-3 sm:px-4 py-2 sm:py-3 bg-gray-50 border-t border-gray-200 text-xs text-gray-500">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-2 sm:space-x-4 flex-wrap gap-y-1">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<kbd class="px-2 py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-1">↑</kbd>
|
<kbd class="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-1">↑</kbd>
|
||||||
<kbd class="px-2 py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-2">↓</kbd>
|
<kbd class="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-1 sm:mr-2">↓</kbd>
|
||||||
{{ __('messages.quick_switcher.navigate') }}
|
<span class="hidden sm:inline">{{ __('messages.quick_switcher.navigate') }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<kbd class="px-2 py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-2">↵</kbd>
|
<kbd class="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-1 sm:mr-2">↵</kbd>
|
||||||
{{ __('messages.quick_switcher.select') }}
|
<span class="hidden sm:inline">{{ __('messages.quick_switcher.select') }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<kbd class="px-2 py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-2">esc</kbd>
|
<kbd class="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-white border border-gray-300 rounded text-xs font-semibold mr-1 sm:mr-2">esc</kbd>
|
||||||
{{ __('messages.quick_switcher.close') }}
|
<span class="hidden sm:inline">{{ __('messages.quick_switcher.close') }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,16 +7,10 @@
|
|||||||
use App\Http\Controllers\Auth\NewPasswordController;
|
use App\Http\Controllers\Auth\NewPasswordController;
|
||||||
use App\Http\Controllers\Auth\PasswordController;
|
use App\Http\Controllers\Auth\PasswordController;
|
||||||
use App\Http\Controllers\Auth\PasswordResetLinkController;
|
use App\Http\Controllers\Auth\PasswordResetLinkController;
|
||||||
use App\Http\Controllers\Auth\RegisteredUserController;
|
|
||||||
use App\Http\Controllers\Auth\VerifyEmailController;
|
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware('guest')->group(function () {
|
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'])
|
Route::get('login', [AuthenticatedSessionController::class, 'create'])
|
||||||
->name('login');
|
->name('login');
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
use App\Http\Controllers\LocaleController;
|
use App\Http\Controllers\LocaleController;
|
||||||
|
use App\Http\Controllers\ImageUploadController;
|
||||||
use App\Http\Controllers\Admin\UserController as AdminUserController;
|
use App\Http\Controllers\Admin\UserController as AdminUserController;
|
||||||
use App\Livewire\DocumentViewer;
|
use App\Livewire\DocumentViewer;
|
||||||
use App\Livewire\DocumentEditor;
|
use App\Livewire\DocumentEditor;
|
||||||
@@ -29,6 +30,9 @@
|
|||||||
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||||
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||||
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
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
|
// Admin routes
|
||||||
@@ -40,7 +44,18 @@
|
|||||||
// 認証が必要なルート(より具体的なルートを先に定義)
|
// 認証が必要なルート(より具体的なルートを先に定義)
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
Route::get('/create', DocumentEditor::class)->name('create');
|
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