33 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:21:10 +09:00
Yutaka Kurosaki 85f67871fa Update dependencies to fix security vulnerabilities
- npm audit fix: resolves axios, postcss, vite, rollup, picomatch, follow-redirects advisories
- composer update: refreshes PHP dependencies

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:18:38 +09:00
y963admin 80deff661d Add image upload support to document editor
- Create ImageUploadController to handle image uploads
- Store images in storage/app/public/images with UUID filenames
- Integrate with EasyMDE editor for drag-drop, paste, and toolbar upload
- Use original filename as alt text in markdown

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 20:08:40 +09:00
y963admin 8ea8b3f6b6 Improve current document highlighting with better URL matching
- Add removal of previous highlighting before applying new one
- Support multiple URL matching strategies (exact, full URL, ends with)
- Add debug logging to troubleshoot highlighting issues
- Reset all links before applying highlight to ensure clean state
2025-12-04 02:45:24 +09:00
y963admin d52968e697 Add current document highlighting in sidebar navigation
- Highlight active document with indigo background and bold font
- Change icon color to indigo for active document
- Use JavaScript to match current URL path with sidebar links
- Update highlighting on page load and Alpine navigation events
- Active document is visually distinct from other items
2025-12-04 02:42:20 +09:00
y963admin bed7137e43 Remove debug console logs from sidebar scroll preservation
- Remove all console.log statements
- Clean up code for production use
- Preserve scroll position functionality without debug output
2025-12-04 02:37:07 +09:00
y963admin 028e0b11c7 Fix sidebar scroll preservation by correcting link selector
- Change selector from 'a[x-navigate]' to 'a' (no x-navigate attribute)
- Remove sessionStorage.removeItem to prevent clearing saved scroll position
- Add '0' check to prevent restoring scroll position to top
- Add debug logging for troubleshooting
- Now works correctly in all browsers including Chrome
2025-12-04 02:35:29 +09:00
y963admin 5bf43abab9 Remove x-navigate directive, use native HTML links with scroll preservation
- Remove x-navigate.preserve-scroll from sidebar links
- Use standard <a> tags for navigation
- Implement manual JavaScript-based scroll position management
- Save scroll position to sessionStorage before navigation
- Restore scroll position after page load
- Works consistently across all browsers including Chrome
2025-12-04 02:30:48 +09:00
y963admin f96ad4d14f Use manual scroll position management instead of x-navigate.preserve-scroll
- Add JavaScript to save sidebar scroll position before navigation
- Restore scroll position after page load using sessionStorage
- Works consistently in Chrome and other browsers
- Handles both DOMContentLoaded and window load events
- Compatible with Alpine navigate and standard navigation
2025-12-04 02:24:00 +09:00
y963admin a4aff43091 Add preserve-scroll modifier to x-navigate directive
- Use x-navigate.preserve-scroll to maintain sidebar scroll position during navigation
- Prevents page from scrolling to top after clicking sidebar links
- Alpine navigate automatically saves and restores scroll position
2025-12-04 02:17:13 +09:00
y963admin 1e20982e00 Simplify sidebar scroll preservation using only x-navigate directive
- Remove custom sessionStorage scroll management logic
- Rely solely on x-navigate directive from Alpine for scroll preservation
- x-navigate handles automatic scroll position saving and restoring
- Cleaner and simpler implementation
- Keep x-navigate directive on all sidebar links
2025-12-04 02:13:28 +09:00
y963admin ec7aaf44a9 Fix sidebar scroll preservation per page with x-navigate directive
- Add x-navigate directive to all sidebar document links for Alpine navigation
- Store scroll position per page using URL path as key in sessionStorage
- Each page now maintains its own scroll position in the sidebar
- Save scroll position before navigation and restore after navigation
- Scroll position is preserved when clicking links in the sidebar
- Works correctly with Alpine navigate events triggered by x-navigate directive
2025-12-04 02:11:06 +09:00
y963admin 00a5951654 Improve sidebar scroll position preservation with sessionStorage fallback
- Replace localStorage with sessionStorage for session-based scroll restoration
- Add console logging for debugging scroll behavior
- Support both Livewire and Alpine navigate events
- Intercept sidebar link clicks to ensure scroll position is saved before navigation
- Use setTimeout for smoother DOM restoration timing
- Restore scroll position on page load and window load events
- Sidebar now maintains scroll position consistently across navigation
2025-12-04 01:55:59 +09:00
y963admin 8dba510a6c Fix sidebar scroll position preservation during page navigation
- Replace unstable x-navigate:scroll directive with custom Alpine event handlers
- Use alpine:navigating event to save sidebar scroll position to localStorage
- Use alpine:navigated event to restore sidebar scroll position after navigation
- Sidebar now maintains scroll position when clicking document links
- Fixed 'Element not found' error that was preventing scroll restoration
- Uses requestAnimationFrame for smooth DOM restoration
2025-12-04 01:47:51 +09:00
y963admin e66ece71e3 Preserve sidebar scroll position when navigating between documents
- Add x-navigate:scroll directive to sidebar container to maintain scroll position
- Add x-navigate:scroll to all document links in sidebar (tree-item.blade.php)
- Add x-navigate:scroll to 'New Document' button
- When clicking a link in sidebar, the sidebar scroll position is now preserved during page navigation
- Fixes issue where sidebar would scroll to top after loading a document
2025-12-04 01:37:19 +09:00
y963admin b96012f598 Add resizable sidebar feature and increase default width to 320px
- Add resizable handle on the right edge of sidebar in desktop view (lg and above)
- Allow dragging to adjust sidebar width between 200px and 600px
- Persist resize settings in localStorage across page reloads
- Keep mobile mode unchanged with fixed 256px width
- Increase default width from 256px to 320px
- Implement visual feedback with color change on mouse hover
2025-12-04 01:24:47 +09:00
y963admin e50ed261e1 Show timezone information 2025-11-30 13:58:03 +09:00
y963admin 79a09430aa Fix timezone issue: Set default timezone to Asia/Tokyo
- Update config/app.php to use APP_TIMEZONE from .env with Asia/Tokyo as default
- Add APP_TIMEZONE to .env.example
- Fixes issue where timestamps were displayed 9 hours behind (UTC vs JST)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 13:46:16 +09:00
y963admin 33fef93ce0 Update README with comprehensive project documentation
- Add detailed feature overview (wiki-links, folders, multi-language, responsive)
- Include complete installation and setup instructions
- Document project structure and key concepts
- Add development workflow and common commands
- Include troubleshooting section
- Update technology stack and credits

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 13:19:43 +09:00
y963admin e14cc5dd43 Implement responsive design for mobile and tablet devices
- Add hamburger menu for mobile sidebar with slide-out animation
- Make header navigation responsive with icon-only buttons on mobile
- Adjust document viewer, editor, and quick switcher layouts for smaller screens
- Preserve all existing functionality including localStorage folder state persistence
- Use Tailwind responsive utilities (sm:, md:, lg:) for progressive enhancement

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 13:17:50 +09:00
25 changed files with 3721 additions and 1203 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,331 @@
# Media Embed Design
**Date:** 2026-05-09
**Status:** Approved
**Scope:** Add support for embedding video files, audio files, YouTube, and Vimeo in Markdown documents using the standard image syntax `![](url)`.
## Background
The knowledge base currently renders Markdown via `League\CommonMark` with `html_input => 'strip'`, which removes raw HTML. This is a deliberate safety choice: the project is published as OSS and may be deployed in environments with multiple authors or untrusted input, so raw HTML passthrough is undesirable.
To migrate fixed pages from a previous WordPress site (which used `<video>` tags and YouTube/Vimeo embeds), Markdown needs a safe mechanism to express media embeds. The chosen approach extends the existing image syntax: when an `![](url)` URL points to a media resource, the rendered output becomes `<video>`, `<audio>`, or `<iframe>` instead of `<img>`.
## Goals
- Support embedding local video and audio files via `![](url)` syntax
- Support YouTube and Vimeo embeds via the same syntax
- Use privacy-enhanced embed modes (`youtube-nocookie.com`, Vimeo `?dnt=1`)
- Preserve existing image rendering and Wiki link behavior unchanged
- Maintain `html_input => 'strip'` for safety
- Provide unit-test coverage for URL parsing and rendering
## Non-Goals
- Custom attributes (width, autoplay, poster) — sizing handled via CSS only
- Other embed providers (Twitch, SoundCloud, Spotify, etc.)
- `og:video` OGP tags
- VTT subtitles / `<track>` elements
- Download cards for zip/binary files (a separate future task)
- Rerendering existing documents (a separate Artisan command may be added later)
## Architecture
```
Markdown input
CommonMarkParser
│ (after parse)
DocumentParsedEvent ───► MediaEmbedExtension listener
│ Walk Image nodes, classify URL:
│ ├─ video extension → <video>
│ ├─ audio extension → <audio>
│ ├─ YouTube URL → <iframe> (nocookie)
│ ├─ Vimeo URL → <iframe> (dnt)
│ └─ other → leave unchanged (renders as <img>)
│ Replace matching node with HtmlInline
HTML output (existing render flow unchanged)
```
The extension lives entirely in CommonMark's event-based AST modification layer. No changes are required to the existing Wiki link, GFM, or image rendering logic.
### Boundary Summary
- **Input:** Markdown string (unchanged)
- **Output:** HTML string — some `![](...)` produce `<video>`, `<audio>`, or `<iframe>` instead of `<img>`
- **Untouched:** Wiki links, GFM extension, default image rendering, `html_input => 'strip'` policy
## Components
### New files
#### `src/app/Markdown/MediaEmbedExtension.php`
CommonMark `ExtensionInterface` implementation. Sole responsibility: register the listener.
- Public API: `register(EnvironmentBuilderInterface $env): void`
- Wires `DocumentParsedEvent` to `MediaEmbedListener::handle`
#### `src/app/Markdown/MediaUrlResolver.php`
Pure URL classification class with no external dependencies. Highly testable.
- Public API: `resolve(string $url): ?string`
- Returns the replacement HTML string if URL is a recognized media resource
- Returns `null` if URL should fall through to default image rendering
- Internal helpers:
- `detectVideo(string $url): ?string`
- `detectAudio(string $url): ?string`
- `detectYouTube(string $url): ?string`
- `detectVimeo(string $url): ?string`
- Order: video → audio → YouTube → Vimeo → null
#### `src/app/Markdown/MediaEmbedListener.php`
Thin glue layer. Receives `DocumentParsedEvent`, walks the AST, and delegates URL classification to `MediaUrlResolver`.
- Public API: `handle(DocumentParsedEvent $event): void`
- For each `Image` node: call resolver; if non-null, replace node with a `MediaEmbedNode`
#### `src/app/Markdown/MediaEmbedNode.php`
Custom AST node that carries the pre-rendered embed HTML string.
- Extends `AbstractStringContainer`
- Does NOT implement `RawMarkupContainerInterface` — this is intentional so the node is not subject to `HtmlFilter`
- Holds its literal content (the HTML string) for direct output by its renderer
#### `src/app/Markdown/MediaEmbedNodeRenderer.php`
Dedicated renderer for `MediaEmbedNode`.
- Implements `NodeRendererInterface`
- Returns the node's literal content directly, without invoking any HTML filter
- This is the mechanism that allows trusted embed HTML to survive the `html_input => 'strip'` policy
### Modified files
#### `src/app/Models/Document.php` — `renderMarkdown()`
Add a single line:
```php
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
```
No other changes.
### File-split rationale
Separating `MediaUrlResolver` from `MediaEmbedListener` isolates "URL parsing / HTML generation" from "AST manipulation." The former is pure and exhaustively testable; the latter is a thin glue layer. This keeps each unit single-purpose and easier to reason about.
## Data Flow Specification
### Input → Output reference
| Markdown input | Output HTML (key parts) |
|---|---|
| `![alt](/foo.png)` | `<img src="/foo.png" alt="alt">` *(default, unchanged)* |
| `![](/demo.mp4)` | `<video src="/demo.mp4" controls class="kb-video"></video>` |
| `![](/audio.mp3)` | `<audio src="/audio.mp3" controls class="kb-audio"></audio>` |
| `![](https://youtu.be/abc123XYZ_-)` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-" ...></iframe>` |
| `![](https://www.youtube.com/watch?v=abc123XYZ_-&t=30s)` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-?start=30" ...></iframe>` |
| `![](https://www.youtube.com/shorts/abc123XYZ_-)` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-" ...></iframe>` |
| `![](https://vimeo.com/123456789)` | `<iframe src="https://player.vimeo.com/video/123456789?dnt=1" ...></iframe>` |
| `![](https://vimeo.com/123456789#t=30s)` | `<iframe src="https://player.vimeo.com/video/123456789?dnt=1#t=30s" ...></iframe>` |
### Extension matching (case-insensitive)
- Video: `mp4`, `webm`, `ogv`, `mov`, `m4v`
- Audio: `mp3`, `wav`, `ogg`, `m4a`
Matching is performed on the URL **path** only (after stripping `?query` and `#fragment`) so signed CDN URLs with `?token=...` are not misclassified.
### YouTube URL recognition
The video ID is the strict pattern `[A-Za-z0-9_-]{11}`. Recognized URL forms:
| Pattern | Example |
|---|---|
| `youtu.be/{id}` | `https://youtu.be/abc123XYZ_-` |
| `youtube.com/watch?v={id}` | `https://www.youtube.com/watch?v=abc123XYZ_-` |
| `youtube.com/shorts/{id}` | `https://www.youtube.com/shorts/abc123XYZ_-` |
| `youtube.com/embed/{id}` | `https://www.youtube.com/embed/abc123XYZ_-` |
| `m.youtube.com/...` | mobile variant of the above |
Timestamp normalization (first match wins; `t` preferred over `start`):
- `?t=30s` / `?t=30` / `&t=1m20s` → seconds → `?start=N`
- `?start=N` → preserved
- No timestamp → no `?start` parameter
### Vimeo URL recognition
| Pattern | Example |
|---|---|
| `vimeo.com/{id}` | `https://vimeo.com/123456789` |
| `player.vimeo.com/video/{id}` | `https://player.vimeo.com/video/123456789` |
ID is digits only.
Timestamp:
- `#t=30s` → preserved as `#t=30s` (Vimeo convention)
- `?t=30s` → preserved as `#t=30s`
### iframe attribute template
```html
<iframe src="..."
width="560" height="315"
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
frameborder="0"
class="kb-embed kb-embed-{provider}">
</iframe>
```
`{provider}` is `youtube` or `vimeo`. Class hooks let CSS introduce aspect-ratio control later.
### Resolution order
1. Video extension → emit `<video>`, return
2. Audio extension → emit `<audio>`, return
3. YouTube → emit `<iframe>`, return
4. Vimeo → emit `<iframe>`, return
5. None match → return `null`; node renders as default `<img>`
## Error Handling and Edge Cases
| Case | Behavior | Reason |
|---|---|---|
| `parse_url` failure | return `null` → default `<img>` | Fall back to CommonMark default |
| URL with no extension | return `null` → default `<img>` | Extension matching is path-suffix based |
| YouTube ID does not match `[A-Za-z0-9_-]{11}` | return `null` → default `<img>` | Strict matching avoids false positives |
| Vimeo ID is not digits | return `null` → default `<img>` | Same |
| Empty URL | return `null` | `parse_url` returns empty path |
**Principle:** Unrecognized URLs are not transformed. Exceptions are not thrown. Default CommonMark rendering handles the fallback.
### XSS hardening
All output URLs are passed through `htmlspecialchars($url, ENT_QUOTES, 'UTF-8')` before being embedded in HTML strings. Attack-vector analysis:
- `![](javascript:alert(1))` — does not match a media extension → `null` → CommonMark's `allow_unsafe_links => false` blocks `<img src="javascript:...">`
- `![](https://youtu.be/"><script>...)` — strict ID regex `[A-Za-z0-9_-]{11}` cannot extract from a URL containing `"` or `>``null` → default rendering, where CommonMark also escapes
- `![](/foo.mp4")` — trailing quote breaks extension matching at the path-cleaning step; even if it passed, `htmlspecialchars` would escape the output
### Relation to `html_input => 'strip'`
The `'strip'` setting is preserved. All `HtmlInline` nodes — whether written by the user in Markdown source or inserted programmatically by an extension — go through `HtmlFilter::filter()`, which strips their content under `'strip'` mode. To emit the embed HTML safely without bypassing this policy, the extension introduces a custom node type:
- `MediaEmbedNode` extends `AbstractStringContainer` and deliberately does NOT implement `RawMarkupContainerInterface`
- `MediaEmbedNodeRenderer` returns the node's literal content directly, without invoking any HTML filter
Therefore:
- User-written `<script>` in Markdown source → produces `HtmlInline` → still stripped
- `<video>` / `<audio>` / `<iframe>` inserted by `MediaEmbedExtension` → produces `MediaEmbedNode` → output as intended
- The security boundary is "only the explicitly trusted node type bypasses filtering," and that node type is reachable only through `MediaEmbedListener` after `MediaUrlResolver` has classified the URL as a known media pattern.
### `alt` and `title`
Markdown image syntax allows `![alt](url "title")`.
- `<video>` / `<audio>` have no `alt` attribute → ignored
- `title` is preserved on `<video>` / `<audio>` as `title="..."` (optional)
- iframes ignore both (the YouTube/Vimeo player surfaces its own title)
VTT subtitles / `<track>` elements are out of scope.
### Multiple media in one paragraph
```markdown
![](/a.mp4) and ![](/b.mp4)
```
Two `<video>` elements appear within the same `<p>`. `<video>` is phrasing content per the HTML spec, so this is valid. CSS can apply `display: block` if needed.
### Existing documents
Existing rows in `documents.rendered_html` may be stale after this change. Mitigation is left to the implementation phase — most likely a `docs:rerender` Artisan command (or a one-off `tinker` invocation) that re-saves each `Document` to trigger the existing render hook. This is **not part of the design scope** and should be tracked separately during implementation planning.
## Testing Strategy
### `tests/Unit/Markdown/MediaUrlResolverTest.php`
Pure-unit tests against `MediaUrlResolver::resolve`.
**Video extensions** (one case per extension):
- `/demo.mp4`, `/demo.webm`, `/demo.ogv`, `/demo.mov`, `/demo.m4v``<video>` output
- `/demo.MP4` (uppercase) → recognized
- `https://example.com/path/demo.mp4?token=abc` → query stripped, recognized
**Audio extensions** (one case per extension):
- `/clip.mp3`, `/clip.wav`, `/clip.ogg`, `/clip.m4a``<audio>` output
**YouTube** (full URL pattern coverage):
- `https://youtu.be/dQw4w9WgXcQ`
- `https://www.youtube.com/watch?v=dQw4w9WgXcQ`
- `https://www.youtube.com/shorts/dQw4w9WgXcQ`
- `https://www.youtube.com/embed/dQw4w9WgXcQ`
- `https://m.youtube.com/watch?v=dQw4w9WgXcQ`
- Timestamps: `?t=30s`, `?t=90`, `?t=1m20s`, `?start=30`
- Output contains `youtube-nocookie.com`
**Vimeo:**
- `https://vimeo.com/123456789`
- `https://player.vimeo.com/video/123456789`
- Timestamps: `#t=30s`, `?t=30s`
- Output contains `?dnt=1`
**Fallback (returns `null`):**
- Normal images: `/photo.jpg`, `/icon.svg`
- No extension: `/foo`
- Invalid URL: empty string, `javascript:alert(1)`, `http://`
- Negative-match candidates: `https://example.com/youtu.be-fake/abc` (host mismatch)
- Invalid YouTube ID: `https://youtu.be/short` (less than 11 chars), special characters
**XSS resilience:**
- `https://youtu.be/abc"><script>``null` (strict ID extraction fails)
- Video URL containing `"` produces escaped output
### `tests/Unit/Markdown/MediaEmbedExtensionTest.php`
Integrated unit tests through `Document::renderMarkdown()`:
- Default image survives unchanged: `![alt](/foo.png)``<img>`
- Video embed succeeds: `![](/foo.mp4)``<video>`, no `<img>`
- Mixed Markdown: image, video, YouTube, Vimeo coexist correctly
- Wiki link coexistence: `[[other-doc]]` is unaffected
- Multiple media in one paragraph: `![](/a.mp4) ![](/b.mp4)` → two `<video>`
- List item: `- ![](/a.mp4)``<video>` inside `<li>`
### Test data convention
No fixture files. Test inputs are inline string literals so they remain greppable.
### Running
```bash
docker compose exec php php artisan test --filter=MediaUrlResolverTest
docker compose exec php php artisan test --filter=MediaEmbedExtensionTest
```
`composer test` (full suite) must remain green.
### Coverage target
No formal coverage measurement. The bar is: **every URL pattern listed in the Data Flow Specification has at least one corresponding test case.**
## Open Items for Implementation Phase
These are deliberately deferred to the planning phase, not the design:
- Whether to add a `docs:rerender` Artisan command for existing rows
- CSS additions for `.kb-video`, `.kb-audio`, `.kb-embed-*` (likely a future task)
- Updating `CLAUDE.md` to document the new media-embed convention
+1
View File
@@ -7,6 +7,7 @@ APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_TIMEZONE=Asia/Tokyo
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
+314 -41
View File
@@ -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">
<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>
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.
## 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).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
### Multi-Language Support
Interface available in **16 languages**:
- English, 日本語 (Japanese)
- 简体中文, 繁體中文 (Simplified/Traditional Chinese)
- 한국어 (Korean)
- हिन्दी (Hindi), Tiếng Việt (Vietnamese), Türkçe (Turkish)
- 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)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Installation
### 1. Clone the repository
```bash
git clone <repository-url>
cd knowledge-base
```
### 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
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
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).
## 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.
Contributions are welcome! Please ensure:
- Code follows Laravel and PSR-12 conventions
- All existing tests pass
- New features include tests
- UI changes maintain responsive design
## 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 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,
],
]);
}
}
+6
View File
@@ -18,6 +18,8 @@ class DocumentEditor extends Component
public function mount(?Document $document = null)
{
if ($document) {
$this->authorize('update', $document);
$this->document = $document;
$this->title = $document->title;
$this->content = $document->content;
@@ -40,6 +42,8 @@ public function save(DocumentService $documentService)
try {
if ($this->isEditMode && $this->document) {
$this->authorize('update', $this->document);
$this->document = $documentService->updateDocument(
$this->document,
$this->title,
@@ -71,6 +75,8 @@ public function delete(DocumentService $documentService)
return;
}
$this->authorize('delete', $this->document);
try {
$documentService->deleteDocument($this->document);
session()->flash('message', 'Document deleted successfully!');
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Markdown;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\ExtensionInterface;
class MediaEmbedExtension implements ExtensionInterface
{
public function register(EnvironmentBuilderInterface $environment): void
{
$listener = new MediaEmbedListener(new MediaUrlResolver());
$environment->addEventListener(DocumentParsedEvent::class, [$listener, 'handle']);
$environment->addRenderer(MediaEmbedNode::class, new MediaEmbedNodeRenderer());
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Markdown;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
class MediaEmbedListener
{
public function __construct(private readonly MediaUrlResolver $resolver)
{
}
public function handle(DocumentParsedEvent $event): void
{
$imagesToReplace = [];
foreach ($event->getDocument()->iterator() as $node) {
if ($node instanceof Image) {
$imagesToReplace[] = $node;
}
}
foreach ($imagesToReplace as $image) {
$html = $this->resolver->resolve($image->getUrl());
if ($html !== null) {
$image->replaceWith(new MediaEmbedNode($html));
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Markdown;
use League\CommonMark\Node\Inline\AbstractStringContainer;
/**
* A custom inline node for programmatically generated media embeds.
*
* Unlike HtmlInline, this node does NOT implement RawMarkupContainerInterface,
* so its renderer bypasses the html_input filter entirely, allowing us to emit
* safe, programmatically constructed HTML even when html_input is set to 'strip'.
*/
class MediaEmbedNode extends AbstractStringContainer
{
}
@@ -0,0 +1,28 @@
<?php
namespace App\Markdown;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
/**
* Renders a MediaEmbedNode by emitting its literal content directly,
* without going through any html_input filtering.
*/
class MediaEmbedNodeRenderer implements NodeRendererInterface
{
/**
* @param MediaEmbedNode $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
{
MediaEmbedNode::assertInstanceOf($node);
return $node->getLiteral();
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace App\Markdown;
class MediaUrlResolver
{
private const VIDEO_EXT = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
private const AUDIO_EXT = ['mp3', 'wav', 'ogg', 'm4a'];
public function resolve(string $url): ?string
{
if ($url === '') {
return null;
}
return $this->detectVideo($url)
?? $this->detectAudio($url)
?? $this->detectYouTube($url)
?? $this->detectVimeo($url);
}
private function detectVideo(string $url): ?string
{
if (!in_array($this->getPathExtension($url), self::VIDEO_EXT, true)) {
return null;
}
$safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
return "<video src=\"{$safe}\" controls class=\"kb-video\"></video>";
}
private function detectAudio(string $url): ?string
{
if (!in_array($this->getPathExtension($url), self::AUDIO_EXT, true)) {
return null;
}
$safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
return "<audio src=\"{$safe}\" controls class=\"kb-audio\"></audio>";
}
private function detectYouTube(string $url): ?string
{
$patterns = [
'~^https?://youtu\.be/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
'~^https?://(?:www\.|m\.)?youtube\.com/watch\?(?:[^#]*&)?v=([A-Za-z0-9_-]{11})(?:[&#]|$)~',
'~^https?://(?:www\.|m\.)?youtube\.com/shorts/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
'~^https?://(?:www\.|m\.)?youtube\.com/embed/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
];
$videoId = null;
foreach ($patterns as $p) {
if (preg_match($p, $url, $m)) {
$videoId = $m[1];
break;
}
}
if ($videoId === null) {
return null;
}
$src = "https://www.youtube-nocookie.com/embed/{$videoId}";
$start = $this->extractYouTubeStart($url);
if ($start !== null) {
$src .= "?start={$start}";
}
return $this->iframeHtml($src, 'youtube');
}
private function extractYouTubeStart(string $url): ?int
{
if (preg_match('/[?&]t=([^&#]+)/', $url, $m)) {
$seconds = $this->parseTimestamp($m[1]);
if ($seconds !== null) {
return $seconds;
}
}
if (preg_match('/[?&]start=(\d+)/', $url, $m)) {
return (int) $m[1];
}
return null;
}
private function parseTimestamp(string $t): ?int
{
if (ctype_digit($t)) {
return (int) $t;
}
$total = 0;
$matched = false;
if (preg_match('/(\d+)h/', $t, $m)) {
$total += (int) $m[1] * 3600;
$matched = true;
}
if (preg_match('/(\d+)m/', $t, $m)) {
$total += (int) $m[1] * 60;
$matched = true;
}
if (preg_match('/(\d+)s/', $t, $m)) {
$total += (int) $m[1];
$matched = true;
}
return $matched ? $total : null;
}
private function detectVimeo(string $url): ?string
{
if (!preg_match('~^https?://(?:www\.|player\.)?vimeo\.com/(?:video/)?(\d+)(?![A-Za-z0-9])~', $url, $m)) {
return null;
}
$videoId = $m[1];
$src = "https://player.vimeo.com/video/{$videoId}?dnt=1";
$hash = $this->extractVimeoHash($url);
if ($hash !== null) {
$src .= '#' . $hash;
}
return $this->iframeHtml($src, 'vimeo');
}
private function extractVimeoHash(string $url): ?string
{
if (preg_match('/#(t=[^&]+)/', $url, $m)) {
return $m[1];
}
if (preg_match('/[?&](t=[^&#]+)/', $url, $m)) {
return $m[1];
}
return null;
}
private function iframeHtml(string $src, string $provider): string
{
$safe = htmlspecialchars($src, ENT_QUOTES, 'UTF-8');
return '<iframe src="' . $safe . '" '
. 'width="560" height="315" '
. 'loading="lazy" '
. 'referrerpolicy="strict-origin-when-cross-origin" '
. 'allow="autoplay; encrypted-media; picture-in-picture" '
. 'allowfullscreen frameborder="0" '
. 'class="kb-embed kb-embed-' . $provider . '"></iframe>';
}
private function getPathExtension(string $url): string
{
$path = parse_url($url, PHP_URL_PATH);
if ($path === null || $path === false) {
return '';
}
return strtolower(pathinfo($path, PATHINFO_EXTENSION));
}
}
+1
View File
@@ -127,6 +127,7 @@ public static function renderMarkdown(string $markdown): string
]);
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
return $converter->convert($markdown)->getContent();
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Policies;
use App\Models\Document;
use App\Models\User;
class DocumentPolicy
{
public function before(User $user): ?bool
{
return $user->isAdmin() ? true : null;
}
public function view(User $user, Document $document): bool
{
return true;
}
public function create(User $user): bool
{
return true;
}
public function update(User $user, Document $document): bool
{
return $document->created_by === $user->id;
}
public function delete(User $user, Document $document): bool
{
return $document->created_by === $user->id;
}
}
+4 -4
View File
@@ -6,10 +6,10 @@
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"php": "^8.3",
"cocur/slugify": "^4.7",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"laravel/framework": "^13.0",
"laravel/tinker": "^3.0",
"league/commonmark": "^2.8",
"livewire/livewire": "^3.7"
},
@@ -21,7 +21,7 @@
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3"
"phpunit/phpunit": "^12.0"
},
"autoload": {
"psr-4": {
+867 -865
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -65,7 +65,7 @@
|
*/
'timezone' => 'UTC',
'timezone' => env('APP_TIMEZONE', 'Asia/Tokyo'),
/*
|--------------------------------------------------------------------------
+276 -227
View File
@@ -4,6 +4,7 @@
"requires": true,
"packages": {
"": {
"name": "html",
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"easymde": "^2.20.0"
@@ -34,9 +35,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
@@ -51,9 +52,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
@@ -68,9 +69,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
@@ -85,9 +86,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
@@ -102,9 +103,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
@@ -119,9 +120,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
@@ -136,9 +137,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
@@ -153,9 +154,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
@@ -170,9 +171,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
@@ -187,9 +188,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
@@ -204,9 +205,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
@@ -221,9 +222,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
@@ -238,9 +239,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
@@ -255,9 +256,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
@@ -272,9 +273,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
@@ -289,9 +290,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
@@ -306,9 +307,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
@@ -323,9 +324,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
@@ -340,9 +341,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
@@ -357,9 +358,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
@@ -374,9 +375,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
@@ -391,9 +392,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
@@ -408,9 +409,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
@@ -425,9 +426,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
@@ -442,9 +443,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
@@ -459,9 +460,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
@@ -557,9 +558,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
"integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
"cpu": [
"arm"
],
@@ -571,9 +572,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
"integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
"cpu": [
"arm64"
],
@@ -585,9 +586,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
"integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
"cpu": [
"arm64"
],
@@ -599,9 +600,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
"integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
"cpu": [
"x64"
],
@@ -613,9 +614,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
"integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
"cpu": [
"arm64"
],
@@ -627,9 +628,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
"integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
"cpu": [
"x64"
],
@@ -641,9 +642,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
"integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
"cpu": [
"arm"
],
@@ -655,9 +656,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
"integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
"cpu": [
"arm"
],
@@ -669,9 +670,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
"integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
"cpu": [
"arm64"
],
@@ -683,9 +684,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
"integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
"cpu": [
"arm64"
],
@@ -697,9 +698,23 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
"integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
"integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
"cpu": [
"loong64"
],
@@ -711,9 +726,23 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
"integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
"integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
"cpu": [
"ppc64"
],
@@ -725,9 +754,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
"integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
"cpu": [
"riscv64"
],
@@ -739,9 +768,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
"integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
"cpu": [
"riscv64"
],
@@ -753,9 +782,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
"integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
"cpu": [
"s390x"
],
@@ -767,9 +796,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
"integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
"cpu": [
"x64"
],
@@ -781,9 +810,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
"integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
"cpu": [
"x64"
],
@@ -794,10 +823,24 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
"integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
"integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
"cpu": [
"arm64"
],
@@ -809,9 +852,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
"integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
"cpu": [
"arm64"
],
@@ -823,9 +866,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
"integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
"cpu": [
"ia32"
],
@@ -837,9 +880,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
"integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
"cpu": [
"x64"
],
@@ -851,9 +894,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
"integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
"cpu": [
"x64"
],
@@ -1329,15 +1372,15 @@
}
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/baseline-browser-mapping": {
@@ -1765,9 +1808,9 @@
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1778,32 +1821,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/escalade": {
@@ -1866,9 +1909,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"dev": true,
"funding": [
{
@@ -2621,9 +2664,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -2651,9 +2694,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [
{
"type": "opencollective",
@@ -2820,11 +2863,14 @@
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
@@ -2908,9 +2954,9 @@
}
},
"node_modules/rollup": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
"integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2924,28 +2970,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.53.3",
"@rollup/rollup-android-arm64": "4.53.3",
"@rollup/rollup-darwin-arm64": "4.53.3",
"@rollup/rollup-darwin-x64": "4.53.3",
"@rollup/rollup-freebsd-arm64": "4.53.3",
"@rollup/rollup-freebsd-x64": "4.53.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
"@rollup/rollup-linux-arm64-musl": "4.53.3",
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
"@rollup/rollup-linux-x64-gnu": "4.53.3",
"@rollup/rollup-linux-x64-musl": "4.53.3",
"@rollup/rollup-openharmony-arm64": "4.53.3",
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
"@rollup/rollup-win32-x64-gnu": "4.53.3",
"@rollup/rollup-win32-x64-msvc": "4.53.3",
"@rollup/rollup-android-arm-eabi": "4.60.3",
"@rollup/rollup-android-arm64": "4.60.3",
"@rollup/rollup-darwin-arm64": "4.60.3",
"@rollup/rollup-darwin-x64": "4.60.3",
"@rollup/rollup-freebsd-arm64": "4.60.3",
"@rollup/rollup-freebsd-x64": "4.60.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
"@rollup/rollup-linux-arm-musleabihf": "4.60.3",
"@rollup/rollup-linux-arm64-gnu": "4.60.3",
"@rollup/rollup-linux-arm64-musl": "4.60.3",
"@rollup/rollup-linux-loong64-gnu": "4.60.3",
"@rollup/rollup-linux-loong64-musl": "4.60.3",
"@rollup/rollup-linux-ppc64-gnu": "4.60.3",
"@rollup/rollup-linux-ppc64-musl": "4.60.3",
"@rollup/rollup-linux-riscv64-gnu": "4.60.3",
"@rollup/rollup-linux-riscv64-musl": "4.60.3",
"@rollup/rollup-linux-s390x-gnu": "4.60.3",
"@rollup/rollup-linux-x64-gnu": "4.60.3",
"@rollup/rollup-linux-x64-musl": "4.60.3",
"@rollup/rollup-openbsd-x64": "4.60.3",
"@rollup/rollup-openharmony-arm64": "4.60.3",
"@rollup/rollup-win32-arm64-msvc": "4.60.3",
"@rollup/rollup-win32-ia32-msvc": "4.60.3",
"@rollup/rollup-win32-x64-gnu": "4.60.3",
"@rollup/rollup-win32-x64-msvc": "4.60.3",
"fsevents": "~2.3.2"
}
},
@@ -3210,9 +3259,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -3300,13 +3349,13 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@@ -3404,9 +3453,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -27,34 +27,69 @@
@livewireStyles
@stack('styles')
</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">
<!-- 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="flex justify-between h-16">
<div class="flex items-center space-x-3">
<a href="{{ url('/') }}" class="flex items-center space-x-3">
<x-application-logo class="block h-8 w-auto fill-current text-gray-800" />
<h1 class="text-xl font-semibold text-gray-900">
<!-- Mobile Menu Toggle -->
<button
@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') }}
</h1>
</a>
</div>
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-2 sm:space-x-4">
<!-- Quick Switcher Trigger -->
<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
@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>
</svg>
{{ __('messages.quick_switcher.title') }}
<kbd class="ml-2 px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded">
<span class="hidden sm:inline">{{ __('messages.quick_switcher.title') }}</span>
<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
</kbd>
</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">
<button
@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') }}"
>
<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>
</svg>
@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;
@endphp
<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>
</svg>
</button>
@@ -82,7 +117,7 @@ class="flex items-center px-3 py-2 text-sm font-medium text-gray-700 hover:text-
<div
x-show="open"
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)
<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"
class="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900 focus:outline-none"
>
{{ Auth::user()->name }}
<svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<span class="hidden md:inline">{{ Auth::user()->name }}</span>
<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>
</svg>
</button>
@@ -120,7 +158,7 @@ class="flex items-center text-sm font-medium text-gray-700 hover:text-gray-900 f
<div
x-show="open"
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">
{{ __('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>
@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') }}
</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
</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 -->
<div class="flex h-[calc(100vh-4rem)]">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-gray-200 overflow-y-auto">
<!-- Sidebar - Desktop -->
<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')
</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 -->
<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) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
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 -->
@if (session()->has('message'))
<div class="mb-4 p-4 bg-green-100 border border-green-400 text-green-700 rounded">
@@ -13,16 +13,16 @@
@endif
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold text-gray-900">
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">
{{ $isEditMode ? __('messages.documents.edit_document') : __('messages.documents.new_document') }}
</h1>
<div class="flex space-x-3">
<div class="flex flex-wrap gap-2 sm:gap-3">
@if($isEditMode && $document)
<a
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') }}
</a>
@@ -30,14 +30,14 @@ class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text
<button
wire:click="delete"
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') }}
</button>
@else
<a
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') }}
</a>
@@ -45,12 +45,13 @@ class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text
<button
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>
</svg>
{{ __('messages.documents.save') }}
<span class="hidden sm:inline">{{ __('messages.documents.save') }}</span>
<span class="sm:hidden">{{ __('messages.documents.save') }}</span>
</button>
</div>
</div>
@@ -146,6 +147,42 @@ class="w-full"
'guide'
],
status: ['lines', 'words', 'cursor'],
// Image upload configuration
uploadImage: true,
imageMaxSize: 2 * 1024 * 1024, // 2MB
imageAccept: 'image/png, image/jpeg, image/gif, image/webp',
imageUploadFunction: (file, onSuccess, onError) => {
const formData = new FormData();
formData.append('image', file);
fetch('{{ route("images.upload") }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: formData,
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.message || 'Upload failed');
});
}
return response.json();
})
.then(data => {
// Insert markdown with alt text directly
const cm = this.editor.codemirror;
const altText = data.data.altText || 'image';
const url = data.data.filePath;
const markdown = `![${altText}](${url})`;
cm.replaceSelection(markdown);
})
.catch(error => {
onError(error.message || 'Failed to upload image');
});
},
});
this.editor.codemirror.on('change', () => {
@@ -1,27 +1,27 @@
<div class="max-w-4xl mx-auto p-8">
<div class="max-w-4xl mx-auto p-4 sm:p-6 lg:p-8">
<!-- Document Header -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<h1 class="text-4xl font-bold text-gray-900">
<div class="mb-6 sm:mb-8">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-4">
<h1 class="text-2xl sm:text-3xl lg:text-4xl font-bold text-gray-900 break-words">
{{ $document->title }}
</h1>
@auth
@can('update', $document)
<a
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">
<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>
{{ __('messages.documents.edit') }}
</a>
@endauth
@endcan
</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>
{{ __('messages.documents.updated') }} {{ $document->updated_at->diffForHumans() }}
{{ __('messages.documents.updated') }} {{ $document->updated_at->diffForHumans() }}{{ config('app.timezone') }}
</span>
@if($document->updated_by && $document->updater)
@@ -37,7 +37,7 @@ class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-
</div>
<!-- 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 !!}
</div>
@@ -69,15 +69,15 @@ class="block p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition"
@endif
<!-- Document Metadata -->
<div class="mt-12 pt-8 border-t border-gray-200">
<div class="grid grid-cols-2 gap-4 text-sm text-gray-500">
<div>
<div class="mt-8 sm:mt-12 pt-6 sm:pt-8 border-t border-gray-200">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs sm:text-sm text-gray-500">
<div class="break-all">
<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>
</div>
<div>
<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>
@@ -32,7 +32,7 @@ class="fixed inset-0 z-50 overflow-y-auto"
style="display: none;"
@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
class="w-full max-w-2xl bg-white rounded-lg shadow-2xl"
@click.stop
@@ -41,16 +41,16 @@ class="w-full max-w-2xl bg-white rounded-lg shadow-2xl"
wire:keydown.enter.prevent="selectDocument"
>
<!-- 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">
<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>
</svg>
<input
x-ref="searchInput"
type="text"
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') }}"
autocomplete="off"
>
@@ -58,7 +58,7 @@ class="w-full pl-10 pr-4 py-3 border-0 focus:ring-0 text-lg"
</div>
<!-- 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))
<div class="p-8 text-center text-gray-500">
{{ __('messages.quick_switcher.no_results') }}
@@ -102,21 +102,21 @@ class="block px-4 py-3 hover:bg-gray-50 transition {{ $index === $selectedIndex
</div>
<!-- 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 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">
<kbd class="px-2 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>
{{ __('messages.quick_switcher.navigate') }}
<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-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>
<span class="hidden sm:inline">{{ __('messages.quick_switcher.navigate') }}</span>
</span>
<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>
{{ __('messages.quick_switcher.select') }}
<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>
<span class="hidden sm:inline">{{ __('messages.quick_switcher.select') }}</span>
</span>
<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>
{{ __('messages.quick_switcher.close') }}
<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>
<span class="hidden sm:inline">{{ __('messages.quick_switcher.close') }}</span>
</span>
</div>
</div>
-6
View File
@@ -7,16 +7,10 @@
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
Route::get('register', [RegisteredUserController::class, 'create'])
->name('register');
Route::post('register', [RegisteredUserController::class, 'store']);
Route::get('login', [AuthenticatedSessionController::class, 'create'])
->name('login');
+7 -1
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\LocaleController;
use App\Http\Controllers\ImageUploadController;
use App\Http\Controllers\Admin\UserController as AdminUserController;
use App\Livewire\DocumentViewer;
use App\Livewire\DocumentEditor;
@@ -29,6 +30,9 @@
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
// Image upload for editor
Route::post('/images/upload', [ImageUploadController::class, 'upload'])->name('images.upload');
});
// Admin routes
@@ -40,7 +44,9 @@
// 認証が必要なルート(より具体的なルートを先に定義)
Route::middleware('auth')->group(function () {
Route::get('/create', DocumentEditor::class)->name('create');
Route::get('/{document}/edit', DocumentEditor::class)->name('edit');
Route::get('/{document}/edit', DocumentEditor::class)
->middleware('can:update,document')
->name('edit');
});
// 公開ルート(動的ルートは最後に)
@@ -0,0 +1,79 @@
<?php
namespace Tests\Unit\Markdown;
use App\Models\Document;
use Tests\TestCase;
class MediaEmbedExtensionTest extends TestCase
{
public function test_normal_image_still_renders_as_img(): void
{
$html = Document::renderMarkdown('![alt](/photo.png)');
$this->assertStringContainsString('<img', $html);
$this->assertStringContainsString('src="/photo.png"', $html);
}
public function test_video_url_renders_as_video_tag(): void
{
$html = Document::renderMarkdown('![](/demo.mp4)');
$this->assertStringContainsString('<video', $html);
$this->assertStringContainsString('src="/demo.mp4"', $html);
$this->assertStringNotContainsString('<img', $html);
}
public function test_youtube_url_renders_as_iframe(): void
{
$html = Document::renderMarkdown('![](https://youtu.be/dQw4w9WgXcQ)');
$this->assertStringContainsString('<iframe', $html);
$this->assertStringContainsString('youtube-nocookie.com', $html);
}
public function test_vimeo_url_renders_as_iframe(): void
{
$html = Document::renderMarkdown('![](https://vimeo.com/123456789)');
$this->assertStringContainsString('<iframe', $html);
$this->assertStringContainsString('player.vimeo.com', $html);
}
public function test_image_and_video_coexist_in_same_document(): void
{
$md = "![photo](/photo.png)\n\n![](/demo.mp4)";
$html = Document::renderMarkdown($md);
$this->assertStringContainsString('<img', $html);
$this->assertStringContainsString('<video', $html);
}
public function test_multiple_media_in_same_paragraph(): void
{
$html = Document::renderMarkdown('![](/a.mp4) and ![](/b.mp4)');
$this->assertSame(2, substr_count($html, '<video'));
}
public function test_video_inside_list_item(): void
{
$html = Document::renderMarkdown("- ![](/demo.mp4)");
$this->assertStringContainsString('<li>', $html);
$this->assertStringContainsString('<video', $html);
}
public function test_wiki_link_unaffected_alongside_media(): void
{
$html = Document::renderMarkdown("![](/demo.mp4)\n\n[[Other Doc]]");
$this->assertStringContainsString('<video', $html);
$this->assertStringContainsString('[[Other Doc]]', $html);
}
public function test_youtube_with_timestamp_in_document(): void
{
$html = Document::renderMarkdown('![](https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30s)');
$this->assertStringContainsString('?start=30', $html);
}
public function test_audio_url_renders_as_audio_tag(): void
{
$html = Document::renderMarkdown('![](/clip.mp3)');
$this->assertStringContainsString('<audio', $html);
$this->assertStringContainsString('src="/clip.mp3"', $html);
}
}
@@ -0,0 +1,203 @@
<?php
namespace Tests\Unit\Markdown;
use App\Markdown\MediaUrlResolver;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class MediaUrlResolverTest extends TestCase
{
private MediaUrlResolver $resolver;
protected function setUp(): void
{
parent::setUp();
$this->resolver = new MediaUrlResolver();
}
#[DataProvider('nonMediaUrls')]
public function test_returns_null_for_non_media_urls(string $url): void
{
$this->assertNull($this->resolver->resolve($url));
}
public static function nonMediaUrls(): array
{
return [
'normal image' => ['/photo.jpg'],
'svg' => ['/icon.svg'],
'png' => ['/avatar.png'],
'no extension' => ['/foo'],
'empty string' => [''],
'javascript scheme' => ['javascript:alert(1)'],
'host-only' => ['http://'],
'youtu.be lookalike host' => ['https://example.com/youtu.be-fake/abc'],
];
}
#[DataProvider('videoUrls')]
public function test_video_urls_produce_video_tag(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringStartsWith('<video', $html);
$this->assertStringContainsString('controls', $html);
$this->assertStringContainsString('class="kb-video"', $html);
}
public static function videoUrls(): array
{
return [
'mp4' => ['/demo.mp4'],
'webm' => ['/demo.webm'],
'ogv' => ['/demo.ogv'],
'mov' => ['/demo.mov'],
'm4v' => ['/demo.m4v'],
'uppercase extension' => ['/demo.MP4'],
'with query string' => ['https://example.com/path/demo.mp4?token=abc'],
'absolute http' => ['https://example.com/demo.mp4'],
];
}
public function test_video_url_is_html_escaped(): void
{
$html = $this->resolver->resolve('/path/with"quote.mp4');
$this->assertNotNull($html);
$this->assertStringNotContainsString('"quote.mp4"', $html);
$this->assertStringContainsString('&quot;', $html);
}
#[DataProvider('audioUrls')]
public function test_audio_urls_produce_audio_tag(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringStartsWith('<audio', $html);
$this->assertStringContainsString('controls', $html);
$this->assertStringContainsString('class="kb-audio"', $html);
}
public static function audioUrls(): array
{
return [
'mp3' => ['/clip.mp3'],
'wav' => ['/clip.wav'],
'ogg' => ['/clip.ogg'],
'm4a' => ['/clip.m4a'],
];
}
#[DataProvider('youtubeUrls')]
public function test_youtube_urls_produce_iframe(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringStartsWith('<iframe', $html);
$this->assertStringContainsString('youtube-nocookie.com/embed/dQw4w9WgXcQ', $html);
$this->assertStringContainsString('class="kb-embed kb-embed-youtube"', $html);
$this->assertStringContainsString('loading="lazy"', $html);
$this->assertStringContainsString('allowfullscreen', $html);
}
public static function youtubeUrls(): array
{
return [
'short youtu.be' => ['https://youtu.be/dQw4w9WgXcQ'],
'watch v=' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ'],
'shorts' => ['https://www.youtube.com/shorts/dQw4w9WgXcQ'],
'embed' => ['https://www.youtube.com/embed/dQw4w9WgXcQ'],
'mobile' => ['https://m.youtube.com/watch?v=dQw4w9WgXcQ'],
'no www watch' => ['https://youtube.com/watch?v=dQw4w9WgXcQ'],
];
}
#[DataProvider('invalidYoutubeUrls')]
public function test_invalid_youtube_urls_return_null(string $url): void
{
$this->assertNull($this->resolver->resolve($url));
}
public static function invalidYoutubeUrls(): array
{
return [
'too short id' => ['https://youtu.be/short'],
'host mismatch' => ['https://example.com/watch?v=dQw4w9WgXcQ'],
'XSS attempt in id' => ['https://youtu.be/abc"><script>'],
];
}
#[DataProvider('youtubeTimestampUrls')]
public function test_youtube_timestamp_normalizes_to_start(string $url, int $expectedStart): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringContainsString("?start={$expectedStart}", $html);
}
public static function youtubeTimestampUrls(): array
{
return [
't=30s' => ['https://youtu.be/dQw4w9WgXcQ?t=30s', 30],
't=30 (no suffix)' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30', 30],
't=1m20s' => ['https://youtu.be/dQw4w9WgXcQ?t=1m20s', 80],
't=1h2m3s' => ['https://youtu.be/dQw4w9WgXcQ?t=1h2m3s', 3723],
'start=45' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=45', 45],
];
}
#[DataProvider('vimeoUrls')]
public function test_vimeo_urls_produce_iframe(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringStartsWith('<iframe', $html);
$this->assertStringContainsString('player.vimeo.com/video/123456789', $html);
$this->assertStringContainsString('dnt=1', $html);
$this->assertStringContainsString('class="kb-embed kb-embed-vimeo"', $html);
}
public static function vimeoUrls(): array
{
return [
'vimeo.com' => ['https://vimeo.com/123456789'],
'www.vimeo.com' => ['https://www.vimeo.com/123456789'],
'player.vimeo.com' => ['https://player.vimeo.com/video/123456789'],
];
}
#[DataProvider('vimeoTimestampUrls')]
public function test_vimeo_timestamp_preserved_as_hash(string $url): void
{
$html = $this->resolver->resolve($url);
$this->assertNotNull($html);
$this->assertStringContainsString('#t=30s', $html);
}
public static function vimeoTimestampUrls(): array
{
return [
'hash form' => ['https://vimeo.com/123456789#t=30s'],
'query form' => ['https://vimeo.com/123456789?t=30s'],
];
}
public function test_vimeo_invalid_id_returns_null(): void
{
$this->assertNull($this->resolver->resolve('https://vimeo.com/notanumber'));
}
#[DataProvider('vimeoFalsePositives')]
public function test_vimeo_false_positives_return_null(string $url): void
{
$this->assertNull($this->resolver->resolve($url));
}
public static function vimeoFalsePositives(): array
{
return [
'digits then letter' => ['https://vimeo.com/123abc'],
'digits then x' => ['https://vimeo.com/123x'],
];
}
}