Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b924564c22 | |||
| def78d4754 | |||
| 81efac4a53 | |||
| f26b930b5f | |||
| 6ee4dcfc21 | |||
| 9486d97c73 | |||
| 6debaf93bc | |||
| 5b6e344ee9 | |||
| bb9843fd47 | |||
| 7e445eb2fe | |||
| 6daa001388 | |||
| 1563aff964 | |||
| 692f4d5492 | |||
| 01a11328ec | |||
| 85f67871fa | |||
| 80deff661d | |||
| 8ea8b3f6b6 | |||
| d52968e697 | |||
| bed7137e43 | |||
| 028e0b11c7 | |||
| 5bf43abab9 | |||
| f96ad4d14f | |||
| a4aff43091 | |||
| 1e20982e00 | |||
| ec7aaf44a9 | |||
| 00a5951654 | |||
| 8dba510a6c | |||
| e66ece71e3 | |||
| b96012f598 | |||
| e50ed261e1 | |||
| 79a09430aa | |||
| 33fef93ce0 | |||
| e14cc5dd43 |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,331 @@
|
||||
# Media Embed Design
|
||||
|
||||
**Date:** 2026-05-09
|
||||
**Status:** Approved
|
||||
**Scope:** Add support for embedding video files, audio files, YouTube, and Vimeo in Markdown documents using the standard image syntax ``.
|
||||
|
||||
## Background
|
||||
|
||||
The knowledge base currently renders Markdown via `League\CommonMark` with `html_input => 'strip'`, which removes raw HTML. This is a deliberate safety choice: the project is published as OSS and may be deployed in environments with multiple authors or untrusted input, so raw HTML passthrough is undesirable.
|
||||
|
||||
To migrate fixed pages from a previous WordPress site (which used `<video>` tags and YouTube/Vimeo embeds), Markdown needs a safe mechanism to express media embeds. The chosen approach extends the existing image syntax: when an `` URL points to a media resource, the rendered output becomes `<video>`, `<audio>`, or `<iframe>` instead of `<img>`.
|
||||
|
||||
## Goals
|
||||
|
||||
- Support embedding local video and audio files via `` syntax
|
||||
- Support YouTube and Vimeo embeds via the same syntax
|
||||
- Use privacy-enhanced embed modes (`youtube-nocookie.com`, Vimeo `?dnt=1`)
|
||||
- Preserve existing image rendering and Wiki link behavior unchanged
|
||||
- Maintain `html_input => 'strip'` for safety
|
||||
- Provide unit-test coverage for URL parsing and rendering
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Custom attributes (width, autoplay, poster) — sizing handled via CSS only
|
||||
- Other embed providers (Twitch, SoundCloud, Spotify, etc.)
|
||||
- `og:video` OGP tags
|
||||
- VTT subtitles / `<track>` elements
|
||||
- Download cards for zip/binary files (a separate future task)
|
||||
- Rerendering existing documents (a separate Artisan command may be added later)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Markdown input
|
||||
│
|
||||
▼
|
||||
CommonMarkParser
|
||||
│ (after parse)
|
||||
▼
|
||||
DocumentParsedEvent ───► MediaEmbedExtension listener
|
||||
│
|
||||
│ Walk Image nodes, classify URL:
|
||||
│ ├─ video extension → <video>
|
||||
│ ├─ audio extension → <audio>
|
||||
│ ├─ YouTube URL → <iframe> (nocookie)
|
||||
│ ├─ Vimeo URL → <iframe> (dnt)
|
||||
│ └─ other → leave unchanged (renders as <img>)
|
||||
│
|
||||
│ Replace matching node with HtmlInline
|
||||
▼
|
||||
HTML output (existing render flow unchanged)
|
||||
```
|
||||
|
||||
The extension lives entirely in CommonMark's event-based AST modification layer. No changes are required to the existing Wiki link, GFM, or image rendering logic.
|
||||
|
||||
### Boundary Summary
|
||||
|
||||
- **Input:** Markdown string (unchanged)
|
||||
- **Output:** HTML string — some `` produce `<video>`, `<audio>`, or `<iframe>` instead of `<img>`
|
||||
- **Untouched:** Wiki links, GFM extension, default image rendering, `html_input => 'strip'` policy
|
||||
|
||||
## Components
|
||||
|
||||
### New files
|
||||
|
||||
#### `src/app/Markdown/MediaEmbedExtension.php`
|
||||
|
||||
CommonMark `ExtensionInterface` implementation. Sole responsibility: register the listener.
|
||||
|
||||
- Public API: `register(EnvironmentBuilderInterface $env): void`
|
||||
- Wires `DocumentParsedEvent` to `MediaEmbedListener::handle`
|
||||
|
||||
#### `src/app/Markdown/MediaUrlResolver.php`
|
||||
|
||||
Pure URL classification class with no external dependencies. Highly testable.
|
||||
|
||||
- Public API: `resolve(string $url): ?string`
|
||||
- Returns the replacement HTML string if URL is a recognized media resource
|
||||
- Returns `null` if URL should fall through to default image rendering
|
||||
- Internal helpers:
|
||||
- `detectVideo(string $url): ?string`
|
||||
- `detectAudio(string $url): ?string`
|
||||
- `detectYouTube(string $url): ?string`
|
||||
- `detectVimeo(string $url): ?string`
|
||||
- Order: video → audio → YouTube → Vimeo → null
|
||||
|
||||
#### `src/app/Markdown/MediaEmbedListener.php`
|
||||
|
||||
Thin glue layer. Receives `DocumentParsedEvent`, walks the AST, and delegates URL classification to `MediaUrlResolver`.
|
||||
|
||||
- Public API: `handle(DocumentParsedEvent $event): void`
|
||||
- For each `Image` node: call resolver; if non-null, replace node with a `MediaEmbedNode`
|
||||
|
||||
#### `src/app/Markdown/MediaEmbedNode.php`
|
||||
|
||||
Custom AST node that carries the pre-rendered embed HTML string.
|
||||
|
||||
- Extends `AbstractStringContainer`
|
||||
- Does NOT implement `RawMarkupContainerInterface` — this is intentional so the node is not subject to `HtmlFilter`
|
||||
- Holds its literal content (the HTML string) for direct output by its renderer
|
||||
|
||||
#### `src/app/Markdown/MediaEmbedNodeRenderer.php`
|
||||
|
||||
Dedicated renderer for `MediaEmbedNode`.
|
||||
|
||||
- Implements `NodeRendererInterface`
|
||||
- Returns the node's literal content directly, without invoking any HTML filter
|
||||
- This is the mechanism that allows trusted embed HTML to survive the `html_input => 'strip'` policy
|
||||
|
||||
### Modified files
|
||||
|
||||
#### `src/app/Models/Document.php` — `renderMarkdown()`
|
||||
|
||||
Add a single line:
|
||||
|
||||
```php
|
||||
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
|
||||
```
|
||||
|
||||
No other changes.
|
||||
|
||||
### File-split rationale
|
||||
|
||||
Separating `MediaUrlResolver` from `MediaEmbedListener` isolates "URL parsing / HTML generation" from "AST manipulation." The former is pure and exhaustively testable; the latter is a thin glue layer. This keeps each unit single-purpose and easier to reason about.
|
||||
|
||||
## Data Flow Specification
|
||||
|
||||
### Input → Output reference
|
||||
|
||||
| Markdown input | Output HTML (key parts) |
|
||||
|---|---|
|
||||
| `` | `<img src="/foo.png" alt="alt">` *(default, unchanged)* |
|
||||
| `` | `<video src="/demo.mp4" controls class="kb-video"></video>` |
|
||||
| `` | `<audio src="/audio.mp3" controls class="kb-audio"></audio>` |
|
||||
| `` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-" ...></iframe>` |
|
||||
| `` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-?start=30" ...></iframe>` |
|
||||
| `` | `<iframe src="https://www.youtube-nocookie.com/embed/abc123XYZ_-" ...></iframe>` |
|
||||
| `` | `<iframe src="https://player.vimeo.com/video/123456789?dnt=1" ...></iframe>` |
|
||||
| `` | `<iframe src="https://player.vimeo.com/video/123456789?dnt=1#t=30s" ...></iframe>` |
|
||||
|
||||
### Extension matching (case-insensitive)
|
||||
|
||||
- Video: `mp4`, `webm`, `ogv`, `mov`, `m4v`
|
||||
- Audio: `mp3`, `wav`, `ogg`, `m4a`
|
||||
|
||||
Matching is performed on the URL **path** only (after stripping `?query` and `#fragment`) so signed CDN URLs with `?token=...` are not misclassified.
|
||||
|
||||
### YouTube URL recognition
|
||||
|
||||
The video ID is the strict pattern `[A-Za-z0-9_-]{11}`. Recognized URL forms:
|
||||
|
||||
| Pattern | Example |
|
||||
|---|---|
|
||||
| `youtu.be/{id}` | `https://youtu.be/abc123XYZ_-` |
|
||||
| `youtube.com/watch?v={id}` | `https://www.youtube.com/watch?v=abc123XYZ_-` |
|
||||
| `youtube.com/shorts/{id}` | `https://www.youtube.com/shorts/abc123XYZ_-` |
|
||||
| `youtube.com/embed/{id}` | `https://www.youtube.com/embed/abc123XYZ_-` |
|
||||
| `m.youtube.com/...` | mobile variant of the above |
|
||||
|
||||
Timestamp normalization (first match wins; `t` preferred over `start`):
|
||||
|
||||
- `?t=30s` / `?t=30` / `&t=1m20s` → seconds → `?start=N`
|
||||
- `?start=N` → preserved
|
||||
- No timestamp → no `?start` parameter
|
||||
|
||||
### Vimeo URL recognition
|
||||
|
||||
| Pattern | Example |
|
||||
|---|---|
|
||||
| `vimeo.com/{id}` | `https://vimeo.com/123456789` |
|
||||
| `player.vimeo.com/video/{id}` | `https://player.vimeo.com/video/123456789` |
|
||||
|
||||
ID is digits only.
|
||||
|
||||
Timestamp:
|
||||
- `#t=30s` → preserved as `#t=30s` (Vimeo convention)
|
||||
- `?t=30s` → preserved as `#t=30s`
|
||||
|
||||
### iframe attribute template
|
||||
|
||||
```html
|
||||
<iframe src="..."
|
||||
width="560" height="315"
|
||||
loading="lazy"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="autoplay; encrypted-media; picture-in-picture"
|
||||
allowfullscreen
|
||||
frameborder="0"
|
||||
class="kb-embed kb-embed-{provider}">
|
||||
</iframe>
|
||||
```
|
||||
|
||||
`{provider}` is `youtube` or `vimeo`. Class hooks let CSS introduce aspect-ratio control later.
|
||||
|
||||
### Resolution order
|
||||
|
||||
1. Video extension → emit `<video>`, return
|
||||
2. Audio extension → emit `<audio>`, return
|
||||
3. YouTube → emit `<iframe>`, return
|
||||
4. Vimeo → emit `<iframe>`, return
|
||||
5. None match → return `null`; node renders as default `<img>`
|
||||
|
||||
## Error Handling and Edge Cases
|
||||
|
||||
| Case | Behavior | Reason |
|
||||
|---|---|---|
|
||||
| `parse_url` failure | return `null` → default `<img>` | Fall back to CommonMark default |
|
||||
| URL with no extension | return `null` → default `<img>` | Extension matching is path-suffix based |
|
||||
| YouTube ID does not match `[A-Za-z0-9_-]{11}` | return `null` → default `<img>` | Strict matching avoids false positives |
|
||||
| Vimeo ID is not digits | return `null` → default `<img>` | Same |
|
||||
| Empty URL | return `null` | `parse_url` returns empty path |
|
||||
|
||||
**Principle:** Unrecognized URLs are not transformed. Exceptions are not thrown. Default CommonMark rendering handles the fallback.
|
||||
|
||||
### XSS hardening
|
||||
|
||||
All output URLs are passed through `htmlspecialchars($url, ENT_QUOTES, 'UTF-8')` before being embedded in HTML strings. Attack-vector analysis:
|
||||
|
||||
- `)` — does not match a media extension → `null` → CommonMark's `allow_unsafe_links => false` blocks `<img src="javascript:...">`
|
||||
- `` — strict ID regex `[A-Za-z0-9_-]{11}` cannot extract from a URL containing `"` or `>` → `null` → default rendering, where CommonMark also escapes
|
||||
- `` — trailing quote breaks extension matching at the path-cleaning step; even if it passed, `htmlspecialchars` would escape the output
|
||||
|
||||
### Relation to `html_input => 'strip'`
|
||||
|
||||
The `'strip'` setting is preserved. All `HtmlInline` nodes — whether written by the user in Markdown source or inserted programmatically by an extension — go through `HtmlFilter::filter()`, which strips their content under `'strip'` mode. To emit the embed HTML safely without bypassing this policy, the extension introduces a custom node type:
|
||||
|
||||
- `MediaEmbedNode` extends `AbstractStringContainer` and deliberately does NOT implement `RawMarkupContainerInterface`
|
||||
- `MediaEmbedNodeRenderer` returns the node's literal content directly, without invoking any HTML filter
|
||||
|
||||
Therefore:
|
||||
- User-written `<script>` in Markdown source → produces `HtmlInline` → still stripped
|
||||
- `<video>` / `<audio>` / `<iframe>` inserted by `MediaEmbedExtension` → produces `MediaEmbedNode` → output as intended
|
||||
- The security boundary is "only the explicitly trusted node type bypasses filtering," and that node type is reachable only through `MediaEmbedListener` after `MediaUrlResolver` has classified the URL as a known media pattern.
|
||||
|
||||
### `alt` and `title`
|
||||
|
||||
Markdown image syntax allows ``.
|
||||
|
||||
- `<video>` / `<audio>` have no `alt` attribute → ignored
|
||||
- `title` is preserved on `<video>` / `<audio>` as `title="..."` (optional)
|
||||
- iframes ignore both (the YouTube/Vimeo player surfaces its own title)
|
||||
|
||||
VTT subtitles / `<track>` elements are out of scope.
|
||||
|
||||
### Multiple media in one paragraph
|
||||
|
||||
```markdown
|
||||
 and 
|
||||
```
|
||||
|
||||
Two `<video>` elements appear within the same `<p>`. `<video>` is phrasing content per the HTML spec, so this is valid. CSS can apply `display: block` if needed.
|
||||
|
||||
### Existing documents
|
||||
|
||||
Existing rows in `documents.rendered_html` may be stale after this change. Mitigation is left to the implementation phase — most likely a `docs:rerender` Artisan command (or a one-off `tinker` invocation) that re-saves each `Document` to trigger the existing render hook. This is **not part of the design scope** and should be tracked separately during implementation planning.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### `tests/Unit/Markdown/MediaUrlResolverTest.php`
|
||||
|
||||
Pure-unit tests against `MediaUrlResolver::resolve`.
|
||||
|
||||
**Video extensions** (one case per extension):
|
||||
- `/demo.mp4`, `/demo.webm`, `/demo.ogv`, `/demo.mov`, `/demo.m4v` → `<video>` output
|
||||
- `/demo.MP4` (uppercase) → recognized
|
||||
- `https://example.com/path/demo.mp4?token=abc` → query stripped, recognized
|
||||
|
||||
**Audio extensions** (one case per extension):
|
||||
- `/clip.mp3`, `/clip.wav`, `/clip.ogg`, `/clip.m4a` → `<audio>` output
|
||||
|
||||
**YouTube** (full URL pattern coverage):
|
||||
- `https://youtu.be/dQw4w9WgXcQ`
|
||||
- `https://www.youtube.com/watch?v=dQw4w9WgXcQ`
|
||||
- `https://www.youtube.com/shorts/dQw4w9WgXcQ`
|
||||
- `https://www.youtube.com/embed/dQw4w9WgXcQ`
|
||||
- `https://m.youtube.com/watch?v=dQw4w9WgXcQ`
|
||||
- Timestamps: `?t=30s`, `?t=90`, `?t=1m20s`, `?start=30`
|
||||
- Output contains `youtube-nocookie.com`
|
||||
|
||||
**Vimeo:**
|
||||
- `https://vimeo.com/123456789`
|
||||
- `https://player.vimeo.com/video/123456789`
|
||||
- Timestamps: `#t=30s`, `?t=30s`
|
||||
- Output contains `?dnt=1`
|
||||
|
||||
**Fallback (returns `null`):**
|
||||
- Normal images: `/photo.jpg`, `/icon.svg`
|
||||
- No extension: `/foo`
|
||||
- Invalid URL: empty string, `javascript:alert(1)`, `http://`
|
||||
- Negative-match candidates: `https://example.com/youtu.be-fake/abc` (host mismatch)
|
||||
- Invalid YouTube ID: `https://youtu.be/short` (less than 11 chars), special characters
|
||||
|
||||
**XSS resilience:**
|
||||
- `https://youtu.be/abc"><script>` → `null` (strict ID extraction fails)
|
||||
- Video URL containing `"` produces escaped output
|
||||
|
||||
### `tests/Unit/Markdown/MediaEmbedExtensionTest.php`
|
||||
|
||||
Integrated unit tests through `Document::renderMarkdown()`:
|
||||
|
||||
- Default image survives unchanged: `` → `<img>`
|
||||
- Video embed succeeds: `` → `<video>`, no `<img>`
|
||||
- Mixed Markdown: image, video, YouTube, Vimeo coexist correctly
|
||||
- Wiki link coexistence: `[[other-doc]]` is unaffected
|
||||
- Multiple media in one paragraph: ` ` → two `<video>`
|
||||
- List item: `- ` → `<video>` inside `<li>`
|
||||
|
||||
### Test data convention
|
||||
|
||||
No fixture files. Test inputs are inline string literals so they remain greppable.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
docker compose exec php php artisan test --filter=MediaUrlResolverTest
|
||||
docker compose exec php php artisan test --filter=MediaEmbedExtensionTest
|
||||
```
|
||||
|
||||
`composer test` (full suite) must remain green.
|
||||
|
||||
### Coverage target
|
||||
|
||||
No formal coverage measurement. The bar is: **every URL pattern listed in the Data Flow Specification has at least one corresponding test case.**
|
||||
|
||||
## Open Items for Implementation Phase
|
||||
|
||||
These are deliberately deferred to the planning phase, not the design:
|
||||
|
||||
- Whether to add a `docs:rerender` Artisan command for existing rows
|
||||
- CSS additions for `.kb-video`, `.kb-audio`, `.kb-embed-*` (likely a future task)
|
||||
- Updating `CLAUDE.md` to document the new media-embed convention
|
||||
@@ -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
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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!');
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Markdown;
|
||||
|
||||
use League\CommonMark\Environment\EnvironmentBuilderInterface;
|
||||
use League\CommonMark\Event\DocumentParsedEvent;
|
||||
use League\CommonMark\Extension\ExtensionInterface;
|
||||
|
||||
class MediaEmbedExtension implements ExtensionInterface
|
||||
{
|
||||
public function register(EnvironmentBuilderInterface $environment): void
|
||||
{
|
||||
$listener = new MediaEmbedListener(new MediaUrlResolver());
|
||||
$environment->addEventListener(DocumentParsedEvent::class, [$listener, 'handle']);
|
||||
$environment->addRenderer(MediaEmbedNode::class, new MediaEmbedNodeRenderer());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Markdown;
|
||||
|
||||
use League\CommonMark\Event\DocumentParsedEvent;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
|
||||
|
||||
class MediaEmbedListener
|
||||
{
|
||||
public function __construct(private readonly MediaUrlResolver $resolver)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(DocumentParsedEvent $event): void
|
||||
{
|
||||
$imagesToReplace = [];
|
||||
foreach ($event->getDocument()->iterator() as $node) {
|
||||
if ($node instanceof Image) {
|
||||
$imagesToReplace[] = $node;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($imagesToReplace as $image) {
|
||||
$html = $this->resolver->resolve($image->getUrl());
|
||||
if ($html !== null) {
|
||||
$image->replaceWith(new MediaEmbedNode($html));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Markdown;
|
||||
|
||||
use League\CommonMark\Node\Inline\AbstractStringContainer;
|
||||
|
||||
/**
|
||||
* A custom inline node for programmatically generated media embeds.
|
||||
*
|
||||
* Unlike HtmlInline, this node does NOT implement RawMarkupContainerInterface,
|
||||
* so its renderer bypasses the html_input filter entirely, allowing us to emit
|
||||
* safe, programmatically constructed HTML even when html_input is set to 'strip'.
|
||||
*/
|
||||
class MediaEmbedNode extends AbstractStringContainer
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Markdown;
|
||||
|
||||
use League\CommonMark\Node\Node;
|
||||
use League\CommonMark\Renderer\ChildNodeRendererInterface;
|
||||
use League\CommonMark\Renderer\NodeRendererInterface;
|
||||
|
||||
/**
|
||||
* Renders a MediaEmbedNode by emitting its literal content directly,
|
||||
* without going through any html_input filtering.
|
||||
*/
|
||||
class MediaEmbedNodeRenderer implements NodeRendererInterface
|
||||
{
|
||||
/**
|
||||
* @param MediaEmbedNode $node
|
||||
*
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @psalm-suppress MoreSpecificImplementedParamType
|
||||
*/
|
||||
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
|
||||
{
|
||||
MediaEmbedNode::assertInstanceOf($node);
|
||||
|
||||
return $node->getLiteral();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Markdown;
|
||||
|
||||
class MediaUrlResolver
|
||||
{
|
||||
private const VIDEO_EXT = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
|
||||
|
||||
private const AUDIO_EXT = ['mp3', 'wav', 'ogg', 'm4a'];
|
||||
|
||||
public function resolve(string $url): ?string
|
||||
{
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
return $this->detectVideo($url)
|
||||
?? $this->detectAudio($url)
|
||||
?? $this->detectYouTube($url)
|
||||
?? $this->detectVimeo($url);
|
||||
}
|
||||
|
||||
private function detectVideo(string $url): ?string
|
||||
{
|
||||
if (!in_array($this->getPathExtension($url), self::VIDEO_EXT, true)) {
|
||||
return null;
|
||||
}
|
||||
$safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
|
||||
return "<video src=\"{$safe}\" controls class=\"kb-video\"></video>";
|
||||
}
|
||||
|
||||
private function detectAudio(string $url): ?string
|
||||
{
|
||||
if (!in_array($this->getPathExtension($url), self::AUDIO_EXT, true)) {
|
||||
return null;
|
||||
}
|
||||
$safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
|
||||
return "<audio src=\"{$safe}\" controls class=\"kb-audio\"></audio>";
|
||||
}
|
||||
|
||||
private function detectYouTube(string $url): ?string
|
||||
{
|
||||
$patterns = [
|
||||
'~^https?://youtu\.be/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
|
||||
'~^https?://(?:www\.|m\.)?youtube\.com/watch\?(?:[^#]*&)?v=([A-Za-z0-9_-]{11})(?:[&#]|$)~',
|
||||
'~^https?://(?:www\.|m\.)?youtube\.com/shorts/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
|
||||
'~^https?://(?:www\.|m\.)?youtube\.com/embed/([A-Za-z0-9_-]{11})(?:[/?#]|$)~',
|
||||
];
|
||||
$videoId = null;
|
||||
foreach ($patterns as $p) {
|
||||
if (preg_match($p, $url, $m)) {
|
||||
$videoId = $m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($videoId === null) {
|
||||
return null;
|
||||
}
|
||||
$src = "https://www.youtube-nocookie.com/embed/{$videoId}";
|
||||
$start = $this->extractYouTubeStart($url);
|
||||
if ($start !== null) {
|
||||
$src .= "?start={$start}";
|
||||
}
|
||||
return $this->iframeHtml($src, 'youtube');
|
||||
}
|
||||
|
||||
private function extractYouTubeStart(string $url): ?int
|
||||
{
|
||||
if (preg_match('/[?&]t=([^&#]+)/', $url, $m)) {
|
||||
$seconds = $this->parseTimestamp($m[1]);
|
||||
if ($seconds !== null) {
|
||||
return $seconds;
|
||||
}
|
||||
}
|
||||
if (preg_match('/[?&]start=(\d+)/', $url, $m)) {
|
||||
return (int) $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function parseTimestamp(string $t): ?int
|
||||
{
|
||||
if (ctype_digit($t)) {
|
||||
return (int) $t;
|
||||
}
|
||||
$total = 0;
|
||||
$matched = false;
|
||||
if (preg_match('/(\d+)h/', $t, $m)) {
|
||||
$total += (int) $m[1] * 3600;
|
||||
$matched = true;
|
||||
}
|
||||
if (preg_match('/(\d+)m/', $t, $m)) {
|
||||
$total += (int) $m[1] * 60;
|
||||
$matched = true;
|
||||
}
|
||||
if (preg_match('/(\d+)s/', $t, $m)) {
|
||||
$total += (int) $m[1];
|
||||
$matched = true;
|
||||
}
|
||||
return $matched ? $total : null;
|
||||
}
|
||||
|
||||
private function detectVimeo(string $url): ?string
|
||||
{
|
||||
if (!preg_match('~^https?://(?:www\.|player\.)?vimeo\.com/(?:video/)?(\d+)(?![A-Za-z0-9])~', $url, $m)) {
|
||||
return null;
|
||||
}
|
||||
$videoId = $m[1];
|
||||
$src = "https://player.vimeo.com/video/{$videoId}?dnt=1";
|
||||
$hash = $this->extractVimeoHash($url);
|
||||
if ($hash !== null) {
|
||||
$src .= '#' . $hash;
|
||||
}
|
||||
return $this->iframeHtml($src, 'vimeo');
|
||||
}
|
||||
|
||||
private function extractVimeoHash(string $url): ?string
|
||||
{
|
||||
if (preg_match('/#(t=[^&]+)/', $url, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
if (preg_match('/[?&](t=[^&#]+)/', $url, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function iframeHtml(string $src, string $provider): string
|
||||
{
|
||||
$safe = htmlspecialchars($src, ENT_QUOTES, 'UTF-8');
|
||||
return '<iframe src="' . $safe . '" '
|
||||
. 'width="560" height="315" '
|
||||
. 'loading="lazy" '
|
||||
. 'referrerpolicy="strict-origin-when-cross-origin" '
|
||||
. 'allow="autoplay; encrypted-media; picture-in-picture" '
|
||||
. 'allowfullscreen frameborder="0" '
|
||||
. 'class="kb-embed kb-embed-' . $provider . '"></iframe>';
|
||||
}
|
||||
|
||||
private function getPathExtension(string $url): string
|
||||
{
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
if ($path === null || $path === false) {
|
||||
return '';
|
||||
}
|
||||
return strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -6,10 +6,10 @@
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.3",
|
||||
"cocur/slugify": "^4.7",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/tinker": "^3.0",
|
||||
"league/commonmark": "^2.8",
|
||||
"livewire/livewire": "^3.7"
|
||||
},
|
||||
@@ -21,7 +21,7 @@
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
"phpunit/phpunit": "^12.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
Generated
+864
-862
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -65,7 +65,7 @@
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
'timezone' => env('APP_TIMEZONE', 'Asia/Tokyo'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
Generated
+276
-227
@@ -4,6 +4,7 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "html",
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"easymde": "^2.20.0"
|
||||
@@ -34,9 +35,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -51,9 +52,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -68,9 +69,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -85,9 +86,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -102,9 +103,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -119,9 +120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -136,9 +137,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -153,9 +154,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -170,9 +171,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -187,9 +188,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -204,9 +205,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -221,9 +222,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -238,9 +239,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -255,9 +256,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -272,9 +273,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -289,9 +290,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -306,9 +307,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -323,9 +324,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -340,9 +341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -357,9 +358,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -374,9 +375,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -391,9 +392,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -408,9 +409,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -425,9 +426,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -442,9 +443,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -459,9 +460,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -557,9 +558,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
|
||||
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
|
||||
"integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -571,9 +572,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
|
||||
"integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -585,9 +586,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
|
||||
"integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -599,9 +600,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
|
||||
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
|
||||
"integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -613,9 +614,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
|
||||
"integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -627,9 +628,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
|
||||
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
|
||||
"integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -641,9 +642,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
|
||||
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
|
||||
"integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -655,9 +656,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
|
||||
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
|
||||
"integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -669,9 +670,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -683,9 +684,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -697,9 +698,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -711,9 +726,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -725,9 +754,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -739,9 +768,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -753,9 +782,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -767,9 +796,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -781,9 +810,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
|
||||
"integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -794,10 +823,24 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
|
||||
"integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
|
||||
"integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -809,9 +852,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
|
||||
"integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -823,9 +866,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
|
||||
"integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -837,9 +880,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
|
||||
"integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -851,9 +894,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
|
||||
"integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1329,15 +1372,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
@@ -1765,9 +1808,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -1778,32 +1821,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
@@ -1866,9 +1909,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2621,9 +2664,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -2651,9 +2694,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -2820,11 +2863,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
@@ -2908,9 +2954,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
|
||||
"integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2924,28 +2970,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.53.3",
|
||||
"@rollup/rollup-android-arm64": "4.53.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.53.3",
|
||||
"@rollup/rollup-darwin-x64": "4.53.3",
|
||||
"@rollup/rollup-freebsd-arm64": "4.53.3",
|
||||
"@rollup/rollup-freebsd-x64": "4.53.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.53.3",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.53.3",
|
||||
"@rollup/rollup-openharmony-arm64": "4.53.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.53.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.53.3",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.3",
|
||||
"@rollup/rollup-android-arm64": "4.60.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.3",
|
||||
"@rollup/rollup-darwin-x64": "4.60.3",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.3",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.3",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.3",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.3",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.3",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.3",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.3",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -3210,9 +3259,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -3300,13 +3349,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
|
||||
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
|
||||
"version": "7.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
|
||||
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -3404,9 +3453,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -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 = ``;
|
||||
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>
|
||||
|
||||
@@ -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
@@ -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('');
|
||||
$this->assertStringContainsString('<img', $html);
|
||||
$this->assertStringContainsString('src="/photo.png"', $html);
|
||||
}
|
||||
|
||||
public function test_video_url_renders_as_video_tag(): void
|
||||
{
|
||||
$html = Document::renderMarkdown('');
|
||||
$this->assertStringContainsString('<video', $html);
|
||||
$this->assertStringContainsString('src="/demo.mp4"', $html);
|
||||
$this->assertStringNotContainsString('<img', $html);
|
||||
}
|
||||
|
||||
public function test_youtube_url_renders_as_iframe(): void
|
||||
{
|
||||
$html = Document::renderMarkdown('');
|
||||
$this->assertStringContainsString('<iframe', $html);
|
||||
$this->assertStringContainsString('youtube-nocookie.com', $html);
|
||||
}
|
||||
|
||||
public function test_vimeo_url_renders_as_iframe(): void
|
||||
{
|
||||
$html = Document::renderMarkdown('');
|
||||
$this->assertStringContainsString('<iframe', $html);
|
||||
$this->assertStringContainsString('player.vimeo.com', $html);
|
||||
}
|
||||
|
||||
public function test_image_and_video_coexist_in_same_document(): void
|
||||
{
|
||||
$md = "\n\n";
|
||||
$html = Document::renderMarkdown($md);
|
||||
$this->assertStringContainsString('<img', $html);
|
||||
$this->assertStringContainsString('<video', $html);
|
||||
}
|
||||
|
||||
public function test_multiple_media_in_same_paragraph(): void
|
||||
{
|
||||
$html = Document::renderMarkdown(' and ');
|
||||
$this->assertSame(2, substr_count($html, '<video'));
|
||||
}
|
||||
|
||||
public function test_video_inside_list_item(): void
|
||||
{
|
||||
$html = Document::renderMarkdown("- ");
|
||||
$this->assertStringContainsString('<li>', $html);
|
||||
$this->assertStringContainsString('<video', $html);
|
||||
}
|
||||
|
||||
public function test_wiki_link_unaffected_alongside_media(): void
|
||||
{
|
||||
$html = Document::renderMarkdown("\n\n[[Other Doc]]");
|
||||
$this->assertStringContainsString('<video', $html);
|
||||
$this->assertStringContainsString('[[Other Doc]]', $html);
|
||||
}
|
||||
|
||||
public function test_youtube_with_timestamp_in_document(): void
|
||||
{
|
||||
$html = Document::renderMarkdown('');
|
||||
$this->assertStringContainsString('?start=30', $html);
|
||||
}
|
||||
|
||||
public function test_audio_url_renders_as_audio_tag(): void
|
||||
{
|
||||
$html = Document::renderMarkdown('');
|
||||
$this->assertStringContainsString('<audio', $html);
|
||||
$this->assertStringContainsString('src="/clip.mp3"', $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Markdown;
|
||||
|
||||
use App\Markdown\MediaUrlResolver;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MediaUrlResolverTest extends TestCase
|
||||
{
|
||||
private MediaUrlResolver $resolver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->resolver = new MediaUrlResolver();
|
||||
}
|
||||
|
||||
#[DataProvider('nonMediaUrls')]
|
||||
public function test_returns_null_for_non_media_urls(string $url): void
|
||||
{
|
||||
$this->assertNull($this->resolver->resolve($url));
|
||||
}
|
||||
|
||||
public static function nonMediaUrls(): array
|
||||
{
|
||||
return [
|
||||
'normal image' => ['/photo.jpg'],
|
||||
'svg' => ['/icon.svg'],
|
||||
'png' => ['/avatar.png'],
|
||||
'no extension' => ['/foo'],
|
||||
'empty string' => [''],
|
||||
'javascript scheme' => ['javascript:alert(1)'],
|
||||
'host-only' => ['http://'],
|
||||
'youtu.be lookalike host' => ['https://example.com/youtu.be-fake/abc'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('videoUrls')]
|
||||
public function test_video_urls_produce_video_tag(string $url): void
|
||||
{
|
||||
$html = $this->resolver->resolve($url);
|
||||
$this->assertNotNull($html);
|
||||
$this->assertStringStartsWith('<video', $html);
|
||||
$this->assertStringContainsString('controls', $html);
|
||||
$this->assertStringContainsString('class="kb-video"', $html);
|
||||
}
|
||||
|
||||
public static function videoUrls(): array
|
||||
{
|
||||
return [
|
||||
'mp4' => ['/demo.mp4'],
|
||||
'webm' => ['/demo.webm'],
|
||||
'ogv' => ['/demo.ogv'],
|
||||
'mov' => ['/demo.mov'],
|
||||
'm4v' => ['/demo.m4v'],
|
||||
'uppercase extension' => ['/demo.MP4'],
|
||||
'with query string' => ['https://example.com/path/demo.mp4?token=abc'],
|
||||
'absolute http' => ['https://example.com/demo.mp4'],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_video_url_is_html_escaped(): void
|
||||
{
|
||||
$html = $this->resolver->resolve('/path/with"quote.mp4');
|
||||
$this->assertNotNull($html);
|
||||
$this->assertStringNotContainsString('"quote.mp4"', $html);
|
||||
$this->assertStringContainsString('"', $html);
|
||||
}
|
||||
|
||||
#[DataProvider('audioUrls')]
|
||||
public function test_audio_urls_produce_audio_tag(string $url): void
|
||||
{
|
||||
$html = $this->resolver->resolve($url);
|
||||
$this->assertNotNull($html);
|
||||
$this->assertStringStartsWith('<audio', $html);
|
||||
$this->assertStringContainsString('controls', $html);
|
||||
$this->assertStringContainsString('class="kb-audio"', $html);
|
||||
}
|
||||
|
||||
public static function audioUrls(): array
|
||||
{
|
||||
return [
|
||||
'mp3' => ['/clip.mp3'],
|
||||
'wav' => ['/clip.wav'],
|
||||
'ogg' => ['/clip.ogg'],
|
||||
'm4a' => ['/clip.m4a'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('youtubeUrls')]
|
||||
public function test_youtube_urls_produce_iframe(string $url): void
|
||||
{
|
||||
$html = $this->resolver->resolve($url);
|
||||
$this->assertNotNull($html);
|
||||
$this->assertStringStartsWith('<iframe', $html);
|
||||
$this->assertStringContainsString('youtube-nocookie.com/embed/dQw4w9WgXcQ', $html);
|
||||
$this->assertStringContainsString('class="kb-embed kb-embed-youtube"', $html);
|
||||
$this->assertStringContainsString('loading="lazy"', $html);
|
||||
$this->assertStringContainsString('allowfullscreen', $html);
|
||||
}
|
||||
|
||||
public static function youtubeUrls(): array
|
||||
{
|
||||
return [
|
||||
'short youtu.be' => ['https://youtu.be/dQw4w9WgXcQ'],
|
||||
'watch v=' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ'],
|
||||
'shorts' => ['https://www.youtube.com/shorts/dQw4w9WgXcQ'],
|
||||
'embed' => ['https://www.youtube.com/embed/dQw4w9WgXcQ'],
|
||||
'mobile' => ['https://m.youtube.com/watch?v=dQw4w9WgXcQ'],
|
||||
'no www watch' => ['https://youtube.com/watch?v=dQw4w9WgXcQ'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidYoutubeUrls')]
|
||||
public function test_invalid_youtube_urls_return_null(string $url): void
|
||||
{
|
||||
$this->assertNull($this->resolver->resolve($url));
|
||||
}
|
||||
|
||||
public static function invalidYoutubeUrls(): array
|
||||
{
|
||||
return [
|
||||
'too short id' => ['https://youtu.be/short'],
|
||||
'host mismatch' => ['https://example.com/watch?v=dQw4w9WgXcQ'],
|
||||
'XSS attempt in id' => ['https://youtu.be/abc"><script>'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('youtubeTimestampUrls')]
|
||||
public function test_youtube_timestamp_normalizes_to_start(string $url, int $expectedStart): void
|
||||
{
|
||||
$html = $this->resolver->resolve($url);
|
||||
$this->assertNotNull($html);
|
||||
$this->assertStringContainsString("?start={$expectedStart}", $html);
|
||||
}
|
||||
|
||||
public static function youtubeTimestampUrls(): array
|
||||
{
|
||||
return [
|
||||
't=30s' => ['https://youtu.be/dQw4w9WgXcQ?t=30s', 30],
|
||||
't=30 (no suffix)' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30', 30],
|
||||
't=1m20s' => ['https://youtu.be/dQw4w9WgXcQ?t=1m20s', 80],
|
||||
't=1h2m3s' => ['https://youtu.be/dQw4w9WgXcQ?t=1h2m3s', 3723],
|
||||
'start=45' => ['https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=45', 45],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('vimeoUrls')]
|
||||
public function test_vimeo_urls_produce_iframe(string $url): void
|
||||
{
|
||||
$html = $this->resolver->resolve($url);
|
||||
$this->assertNotNull($html);
|
||||
$this->assertStringStartsWith('<iframe', $html);
|
||||
$this->assertStringContainsString('player.vimeo.com/video/123456789', $html);
|
||||
$this->assertStringContainsString('dnt=1', $html);
|
||||
$this->assertStringContainsString('class="kb-embed kb-embed-vimeo"', $html);
|
||||
}
|
||||
|
||||
public static function vimeoUrls(): array
|
||||
{
|
||||
return [
|
||||
'vimeo.com' => ['https://vimeo.com/123456789'],
|
||||
'www.vimeo.com' => ['https://www.vimeo.com/123456789'],
|
||||
'player.vimeo.com' => ['https://player.vimeo.com/video/123456789'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('vimeoTimestampUrls')]
|
||||
public function test_vimeo_timestamp_preserved_as_hash(string $url): void
|
||||
{
|
||||
$html = $this->resolver->resolve($url);
|
||||
$this->assertNotNull($html);
|
||||
$this->assertStringContainsString('#t=30s', $html);
|
||||
}
|
||||
|
||||
public static function vimeoTimestampUrls(): array
|
||||
{
|
||||
return [
|
||||
'hash form' => ['https://vimeo.com/123456789#t=30s'],
|
||||
'query form' => ['https://vimeo.com/123456789?t=30s'],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_vimeo_invalid_id_returns_null(): void
|
||||
{
|
||||
$this->assertNull($this->resolver->resolve('https://vimeo.com/notanumber'));
|
||||
}
|
||||
|
||||
#[DataProvider('vimeoFalsePositives')]
|
||||
public function test_vimeo_false_positives_return_null(string $url): void
|
||||
{
|
||||
$this->assertNull($this->resolver->resolve($url));
|
||||
}
|
||||
|
||||
public static function vimeoFalsePositives(): array
|
||||
{
|
||||
return [
|
||||
'digits then letter' => ['https://vimeo.com/123abc'],
|
||||
'digits then x' => ['https://vimeo.com/123x'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user