diff --git a/server-documentation/LICENSE b/server-documentation/LICENSE new file mode 100644 index 0000000..5bf8a81 --- /dev/null +++ b/server-documentation/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Gavin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/server-documentation/README.md b/server-documentation/README.md new file mode 100644 index 0000000..9c2943a --- /dev/null +++ b/server-documentation/README.md @@ -0,0 +1,362 @@ +# Server Documentation Plugin for Pelican Panel + +A documentation management plugin for [Pelican Panel](https://pelican.dev) that allows administrators to create, organize, and distribute documentation to server users with granular permission-based visibility. + +## Features + +- **Rich Text Editor** - Full WYSIWYG editing with formatting, lists, code blocks, tables, and more +- **4-Tier Permission System** - Control who sees what documentation based on their role +- **Global & Server-Specific Docs** - Create documentation that appears on all servers or only specific ones +- **Server Assignment During Creation** - Assign documents to servers with egg-based filtering when creating +- **Version History** - Track changes with automatic versioning, rate-limited to prevent spam +- **Markdown Import/Export** - Import `.md` files or export documents for backup/migration +- **Server Panel Integration** - Documents appear in the player's server sidebar with search +- **Admin Panel Integration** - Full CRUD management with filtering, search, and bulk actions +- **Drag-and-Drop Reordering** - Easily reorder documents in relation managers +- **Audit Logging** - All document operations are logged for accountability + +## Screenshots + +### Admin Panel - Document List +![Admin Documents List](docs/images/admin-documents-list.png) +*Full document management with Import Markdown action, type badges, and global indicators* + +### Admin Panel - Create Document +![Admin Create Document](docs/images/admin-create-document.png) +*Permission type selector with all 4 tiers visible in dropdown* + +### Admin Panel - Edit Document with Linked Servers +![Admin Edit Document](docs/images/admin-edit-document.png) +*Rich text editor with Servers relation manager showing linked servers* + +### Server Panel - Server Admin View +![Server Admin View](docs/images/server-admin-view.png) +*Server admins see "Server Admin", "Server Mod", and "Player" documents (4 docs)* + +### Server Panel - Server Mod View +![Server Mod View](docs/images/server-mod-view.png) +*Server mods see "Server Mod" and "Player" documents, including the Moderator Handbook (3 docs)* + +### Server Panel - Player View +![Player View](docs/images/player-view.png) +*Players only see documents marked as "Player" type (2 docs)* + +### Version History +![Version History](docs/images/version-history.png) +*Version table with change summaries showing character diff (e.g., "+2 chars")* + +### Version Preview +![Version Preview](docs/images/version-history-preview.png) +*Preview modal showing full content of a previous version* + +### Version Restore +![Version Restore](docs/images/version-history-restore.png) +*Confirmation dialog before restoring a previous version* + +### After Restore +![After Restore](docs/images/version-history-restored.png) +*New version created with "Restored from version X" summary* + +## The 4-Tier Permission System + +### Why Custom Tiers? + +Pelican Panel has two built-in permission contexts: +1. **Admin Panel** - Root admins and users with admin roles +2. **Server Panel** - Server owners and subusers with granular permissions + +However, for documentation, we needed more nuance. A game server host typically has: +- **Infrastructure documentation** - Only for the hosting team (network configs, security policies) +- **Server administration guides** - For server owners renting/managing servers +- **Moderator handbooks** - For trusted users helping manage game servers +- **Player-facing docs** - Rules, guides, and welcome messages for everyone + +Pelican's native permissions don't map cleanly to these roles, so we created a **virtual permission tier system** that infers user roles from their existing Pelican permissions. + +### Permission Tiers + +| Tier | Badge | Who Can See | How It's Determined | +|------|-------|-------------|---------------------| +| **Host Admin** | πŸ”΄ Red | Root Admins only | `user.isRootAdmin()` | +| **Server Admin** | 🟠 Orange | Server owners + admins with Server Update/Create | Server ownership OR admin panel server permissions | +| **Server Mod** | πŸ”΅ Blue | Subusers with control permissions | Has `control.*` subuser permissions (start/stop/restart/console) | +| **Player** | 🟒 Green | Anyone with server access | Default - anyone who can view the server | + +### Hierarchy + +Higher tiers see all documents at their level **and below**: +- **Host Admin** sees: Host Admin, Server Admin, Server Mod, Player +- **Server Admin** sees: Server Admin, Server Mod, Player +- **Server Mod** sees: Server Mod, Player +- **Player** sees: Player only + +### Example Use Cases + +| Document | Type | Global | Use Case | +|----------|------|--------|----------| +| Infrastructure Security Policy | Host Admin | Yes | Internal security guidelines for your hosting team | +| Server Administration Guide | Server Admin | Yes | SOPs for anyone managing a server | +| Moderator Handbook | Server Mod | Yes | Guidelines for trusted helpers with console access | +| Welcome to Our Servers! | Player | Yes | Community rules visible to all players | +| Minecraft Server Info | Player | No | Server-specific information for one server only | + +## Installation + +### Requirements +- Pelican Panel v1.0+ +- PHP 8.2+ + +### Install via Admin Panel + +1. Download the plugin zip or clone to your plugins directory +2. Navigate to **Admin Panel β†’ Plugins** +3. Click **Install** next to "Server Documentation" +4. Run migrations when prompted + +### Manual Installation + +```bash +# Copy plugin to plugins directory +cp -r server-documentation /var/www/html/plugins/ + +# Run migrations +php /var/www/html/artisan migrate +``` + +> **Note**: This plugin has no external composer dependencies - it uses Pelican's bundled packages only. + +## Usage + +### Creating Documents + +1. Go to **Admin Panel β†’ Documents** +2. Click **New Document** +3. Fill in: + - **Title** - Display name for the document + - **Slug** - URL-friendly identifier (auto-generated from title) + - **Type** - Permission tier (Host Admin, Server Admin, Server Mod, Player) + - **All Servers** - Toggle to show on every server + - **Published** - Toggle to hide from non-admins while drafting + - **Sort Order** - Lower numbers appear first in lists +4. **Server Assignment** (if "All Servers" is disabled): + - Optionally filter servers by **Egg** (game type) + - Select servers using checkboxes + - Use "Select All" / "Deselect All" for bulk selection +5. Write your content using the rich text editor +6. Click **Save** + +### Attaching to Servers (After Creation) + +You can also attach documents to servers after creation: + +1. Edit the document +2. Scroll to the **Servers** relation manager +3. Click **Attach** and select servers +4. Use drag-and-drop to reorder documents + +Or from the server side: +1. Go to **Admin Panel β†’ Servers β†’ [Server] β†’ Documents tab** +2. Click **Attach** and select documents +3. Use drag-and-drop to reorder + +### Importing Markdown + +1. Go to **Admin Panel β†’ Documents** +2. Click **Import Markdown** +3. Upload a `.md` file +4. Optionally enable "Use YAML Frontmatter" to extract metadata: + +```yaml +--- +title: My Document +slug: my-document +type: player +is_global: true +is_published: true +sort_order: 10 +--- + +# Document Content + +Your markdown content here... +``` + +### Exporting Documents + +1. Edit any document +2. Click the **Download** icon in the header +3. Document downloads as `.md` with YAML frontmatter + +### Version History + +1. Edit any document +2. Click the **History** icon (shows badge with version count) +3. View previous versions with timestamps and editors +4. Click **Preview** to see old content +5. Click **Restore** to revert to a previous version + +## Configuration + +### Environment Variables + +All settings can be configured via environment variables or by publishing the config file: + +```bash +# Cache Settings +SERVER_DOCS_CACHE_TTL=300 # Cache TTL for document queries (seconds, 0 to disable) +SERVER_DOCS_BADGE_CACHE_TTL=60 # Cache TTL for navigation badge count (seconds) + +# Version History +SERVER_DOCS_VERSIONS_TO_KEEP=50 # Max versions per document (0 = unlimited) +SERVER_DOCS_AUTO_PRUNE=false # Auto-prune old versions on save + +# Import Settings +SERVER_DOCS_MAX_IMPORT_SIZE=512 # Max markdown import file size (KB) +SERVER_DOCS_ALLOW_HTML_IMPORT=false # Allow raw HTML in imports (security risk) + +# Permissions +SERVER_DOCS_EXPLICIT_PERMISSIONS=false # Require explicit document permissions + +# Audit Logging +SERVER_DOCS_AUDIT_LOG_CHANNEL=single # Log channel for audit events +``` + +### Admin Permissions + +By default, users with server management permissions (`update server` or `create server`) can manage documents. Set `SERVER_DOCS_EXPLICIT_PERMISSIONS=true` to require explicit document permissions instead. + +The plugin registers these Gates: + +- `viewList document` +- `view document` +- `create document` +- `update document` +- `delete document` + +To extend access to other admin roles, modify the `registerDocumentPermissions()` method in the ServiceProvider. + +### Customization + +The plugin uses Pelican's standard extensibility patterns: + +```php +// In another plugin or service provider +use Starter\ServerDocumentation\Filament\Admin\Resources\DocumentResource; + +// Modify the form +DocumentResource::modifyForm(function (Form $form) { + return $form->schema([ + // Add custom fields + ]); +}); + +// Modify the table +DocumentResource::modifyTable(function (Table $table) { + return $table->columns([ + // Add custom columns + ]); +}); +``` + +## File Structure + +```text +server-documentation/ +β”œβ”€β”€ composer.json # PSR-4 autoloading (no external deps) +β”œβ”€β”€ config/server-documentation.php # Configuration options +β”œβ”€β”€ plugin.json # Plugin metadata +β”œβ”€β”€ database/ +β”‚ β”œβ”€β”€ factories/ # Model factories for testing +β”‚ └── migrations/ # Database schema +β”œβ”€β”€ lang/en/strings.php # Translations (i18n ready) +β”œβ”€β”€ resources/ +β”‚ β”œβ”€β”€ css/ # Document content styling +β”‚ └── views/filament/ # Blade templates +β”œβ”€β”€ tests/ +β”‚ └── Unit/ # Unit tests (Pest) +β”‚ β”œβ”€β”€ Enums/ +β”‚ β”œβ”€β”€ Models/ +β”‚ β”œβ”€β”€ Policies/ +β”‚ └── Services/ +└── src/ + β”œβ”€β”€ Enums/ # DocumentType enum + β”œβ”€β”€ Models/ # Document, DocumentVersion + β”œβ”€β”€ Policies/ # Permission logic + β”œβ”€β”€ Providers/ # Service provider + β”œβ”€β”€ Services/ # DocumentService, MarkdownConverter + └── Filament/ + β”œβ”€β”€ Admin/ # Admin panel resources + β”œβ”€β”€ Concerns/ # Shared traits (HasDocumentTableColumns) + └── Server/ # Server panel pages +``` + +## Database Schema + +```text +documents +β”œβ”€β”€ id, uuid +β”œβ”€β”€ title, slug (unique) +β”œβ”€β”€ content (HTML from rich editor) +β”œβ”€β”€ type (host_admin, server_admin, server_mod, player) +β”œβ”€β”€ is_global, is_published +β”œβ”€β”€ sort_order +β”œβ”€β”€ author_id, last_edited_by +β”œβ”€β”€ timestamps, soft_deletes + +document_versions +β”œβ”€β”€ id, document_id +β”œβ”€β”€ title, content (snapshot) +β”œβ”€β”€ version_number +β”œβ”€β”€ edited_by, change_summary +β”œβ”€β”€ created_at + +document_server (pivot) +β”œβ”€β”€ document_id, server_id +β”œβ”€β”€ sort_order +β”œβ”€β”€ timestamps +``` + +## Testing + +The plugin includes comprehensive unit tests using Pest PHP: + +```bash +# Run all tests +cd /path/to/pelican-panel +php artisan test --filter=ServerDocumentation + +# Run specific test file +php artisan test plugins/server-documentation/tests/Unit/Services/DocumentServiceTest.php + +# Run with coverage +php artisan test --filter=ServerDocumentation --coverage +``` + +### Test Coverage + +- **DocumentService** - Version creation, caching, permission checks +- **MarkdownConverter** - HTML↔Markdown conversion, sanitization, frontmatter +- **DocumentType Enum** - Hierarchy levels, visibility, options +- **DocumentPolicy** - Authorization for admin and server panel +- **Document Model** - Events, scopes, relationships + +## Contributing + +This plugin was developed for [Pelican Panel](https://pelican.dev). Contributions welcome! + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests: `php artisan test --filter=ServerDocumentation` +5. Submit a pull request + +## License + +MIT License - see [LICENSE](LICENSE) for details. + +## Credits + +- Built for [Pelican Panel](https://pelican.dev) +- Uses Pelican's bundled [League CommonMark](https://commonmark.thephpleague.com/) for Markdownβ†’HTML parsing +- Built-in HTMLβ†’Markdown converter for exports (no external dependencies) + diff --git a/server-documentation/composer.json b/server-documentation/composer.json new file mode 100644 index 0000000..4b605c8 --- /dev/null +++ b/server-documentation/composer.json @@ -0,0 +1,27 @@ +{ + "name": "starter/server-documentation", + "description": "Attach markdown documentation to servers with versioning and role-based access", + "type": "pelican-plugin", + "license": "MIT", + "authors": [ + { + "name": "Gavin", + "email": "gavin@nerdz.cloud" + } + ], + "require": { + "php": "^8.2" + }, + "autoload": { + "psr-4": { + "Starter\\ServerDocumentation\\": "src/" + } + }, + "extra": { + "pelican": { + "providers": [ + "Starter\\ServerDocumentation\\Providers\\ServerDocumentationServiceProvider" + ] + } + } +} diff --git a/server-documentation/config/server-documentation.php b/server-documentation/config/server-documentation.php new file mode 100644 index 0000000..684151b --- /dev/null +++ b/server-documentation/config/server-documentation.php @@ -0,0 +1,72 @@ + env('SERVER_DOCS_CACHE_TTL', 300), + + // Cache TTL for navigation badge count (in seconds) + 'badge_cache_ttl' => env('SERVER_DOCS_BADGE_CACHE_TTL', 60), + + /* + |-------------------------------------------------------------------------- + | Version History Settings + |-------------------------------------------------------------------------- + | + | Configure document version history behavior. + | + */ + + // Number of versions to keep per document (0 = unlimited) + 'versions_to_keep' => env('SERVER_DOCS_VERSIONS_TO_KEEP', 50), + + // Automatically prune old versions on save + 'auto_prune_versions' => env('SERVER_DOCS_AUTO_PRUNE', false), + + /* + |-------------------------------------------------------------------------- + | Import Settings + |-------------------------------------------------------------------------- + | + | Configure markdown import behavior. + | + */ + + // Maximum file size for markdown imports (in KB) + 'max_import_size' => env('SERVER_DOCS_MAX_IMPORT_SIZE', 512), + + // Allow raw HTML in markdown imports (security risk if enabled) + 'allow_html_import' => env('SERVER_DOCS_ALLOW_HTML_IMPORT', false), + + /* + |-------------------------------------------------------------------------- + | Permissions Settings + |-------------------------------------------------------------------------- + | + | Configure permission behavior. + | + */ + + // Require explicit document permissions instead of inheriting from server permissions + 'explicit_permissions' => env('SERVER_DOCS_EXPLICIT_PERMISSIONS', false), + + /* + |-------------------------------------------------------------------------- + | Audit Logging + |-------------------------------------------------------------------------- + | + | Configure audit logging for document operations. + | + */ + + // Log channel for audit events (use 'single', 'daily', or a custom channel) + 'audit_log_channel' => env('SERVER_DOCS_AUDIT_LOG_CHANNEL', 'single'), +]; diff --git a/server-documentation/database/factories/DocumentFactory.php b/server-documentation/database/factories/DocumentFactory.php new file mode 100644 index 0000000..1c6f0f4 --- /dev/null +++ b/server-documentation/database/factories/DocumentFactory.php @@ -0,0 +1,98 @@ + + */ +class DocumentFactory extends Factory +{ + protected $model = Document::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'uuid' => fake()->uuid(), + 'title' => fake()->sentence(4), + 'slug' => fake()->unique()->slug(3), + 'content' => '

' . fake()->paragraphs(3, true) . '

', + 'type' => fake()->randomElement(DocumentType::cases())->value, + 'is_global' => fake()->boolean(20), + 'is_published' => fake()->boolean(80), + 'sort_order' => fake()->numberBetween(0, 100), + ]; + } + + /** + * Document with host_admin type (Root Admins only). + */ + public function hostAdmin(): static + { + return $this->state(['type' => DocumentType::HostAdmin->value]); + } + + /** + * Document with server_admin type. + */ + public function serverAdmin(): static + { + return $this->state(['type' => DocumentType::ServerAdmin->value]); + } + + /** + * Document with server_mod type. + */ + public function serverMod(): static + { + return $this->state(['type' => DocumentType::ServerMod->value]); + } + + /** + * Document with player type (visible to all). + */ + public function player(): static + { + return $this->state(['type' => DocumentType::Player->value]); + } + + /** + * Published document. + */ + public function published(): static + { + return $this->state(['is_published' => true]); + } + + /** + * Unpublished/draft document. + */ + public function unpublished(): static + { + return $this->state(['is_published' => false]); + } + + /** + * Global document (visible on all servers). + */ + public function global(): static + { + return $this->state(['is_global' => true]); + } + + /** + * Non-global document (must be attached to servers). + */ + public function notGlobal(): static + { + return $this->state(['is_global' => false]); + } +} diff --git a/server-documentation/database/factories/DocumentVersionFactory.php b/server-documentation/database/factories/DocumentVersionFactory.php new file mode 100644 index 0000000..fca2e2b --- /dev/null +++ b/server-documentation/database/factories/DocumentVersionFactory.php @@ -0,0 +1,47 @@ + + */ +class DocumentVersionFactory extends Factory +{ + protected $model = DocumentVersion::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'document_id' => Document::factory(), + 'title' => fake()->sentence(4), + 'content' => '

' . fake()->paragraphs(2, true) . '

', + 'version_number' => 1, + 'change_summary' => fake()->optional()->sentence(), + ]; + } + + /** + * Create a version with a specific version number. + */ + public function versionNumber(int $number): static + { + return $this->state(['version_number' => $number]); + } + + /** + * Create a version for a specific document. + */ + public function forDocument(Document $document): static + { + return $this->state(['document_id' => $document->id]); + } +} diff --git a/server-documentation/database/migrations/2024_01_01_000001_create_documents_table.php b/server-documentation/database/migrations/2024_01_01_000001_create_documents_table.php new file mode 100644 index 0000000..dcece03 --- /dev/null +++ b/server-documentation/database/migrations/2024_01_01_000001_create_documents_table.php @@ -0,0 +1,35 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('title'); + $table->string('slug'); + $table->longText('content'); + $table->enum('type', ['admin', 'player'])->default('player'); + $table->boolean('is_global')->default(false); + $table->boolean('is_published')->default(true); + $table->unsignedInteger('author_id')->nullable(); + $table->unsignedInteger('last_edited_by')->nullable(); + $table->integer('sort_order')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('author_id')->references('id')->on('users')->nullOnDelete(); + $table->foreign('last_edited_by')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('documents'); + } +}; diff --git a/server-documentation/database/migrations/2024_01_01_000002_create_document_versions_table.php b/server-documentation/database/migrations/2024_01_01_000002_create_document_versions_table.php new file mode 100644 index 0000000..8a2f589 --- /dev/null +++ b/server-documentation/database/migrations/2024_01_01_000002_create_document_versions_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('document_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->longText('content'); + $table->unsignedInteger('version_number'); + $table->foreignId('edited_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('change_summary')->nullable(); + $table->timestamps(); + $table->index(['document_id', 'version_number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('document_versions'); + } +}; diff --git a/server-documentation/database/migrations/2024_01_01_000003_create_document_server_table.php b/server-documentation/database/migrations/2024_01_01_000003_create_document_server_table.php new file mode 100644 index 0000000..6b95b09 --- /dev/null +++ b/server-documentation/database/migrations/2024_01_01_000003_create_document_server_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('document_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('server_id'); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->foreign('server_id')->references('id')->on('servers')->cascadeOnDelete(); + $table->unique(['document_id', 'server_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('document_server'); + } +}; diff --git a/server-documentation/database/migrations/2024_01_01_000004_update_documents_type_column.php b/server-documentation/database/migrations/2024_01_01_000004_update_documents_type_column.php new file mode 100644 index 0000000..94d1c22 --- /dev/null +++ b/server-documentation/database/migrations/2024_01_01_000004_update_documents_type_column.php @@ -0,0 +1,42 @@ +getDriverName(); + + // Change enum to string for flexibility with new document types + if ($driver === 'sqlite') { + // SQLite doesn't support ALTER COLUMN, but it also doesn't enforce enum types + // The column will accept any value, so we just need to update the data + } elseif ($driver === 'mysql' || $driver === 'mariadb') { + DB::statement('ALTER TABLE documents MODIFY COLUMN type VARCHAR(50) NOT NULL DEFAULT \'player\''); + } elseif ($driver === 'pgsql') { + // PostgreSQL: drop the enum constraint and change to varchar + DB::statement('ALTER TABLE documents ALTER COLUMN type TYPE VARCHAR(50)'); + DB::statement('ALTER TABLE documents ALTER COLUMN type SET DEFAULT \'player\''); + } + + // Migrate old 'admin' type to 'server_admin' for all drivers + DB::table('documents')->where('type', 'admin')->update(['type' => 'server_admin']); + } + + public function down(): void + { + $driver = Schema::getConnection()->getDriverName(); + + // Migrate back to old types + DB::table('documents')->where('type', 'server_admin')->update(['type' => 'admin']); + DB::table('documents')->whereIn('type', ['host_admin', 'server_mod'])->update(['type' => 'admin']); + + // Change back to enum (MySQL only - other drivers will just have varchar) + if ($driver === 'mysql' || $driver === 'mariadb') { + DB::statement('ALTER TABLE documents MODIFY COLUMN type ENUM(\'admin\', \'player\') NOT NULL DEFAULT \'player\''); + } + } +}; diff --git a/server-documentation/database/migrations/2024_01_01_000005_add_unique_constraint_to_documents_slug.php b/server-documentation/database/migrations/2024_01_01_000005_add_unique_constraint_to_documents_slug.php new file mode 100644 index 0000000..d290d30 --- /dev/null +++ b/server-documentation/database/migrations/2024_01_01_000005_add_unique_constraint_to_documents_slug.php @@ -0,0 +1,22 @@ +unique('slug'); + }); + } + + public function down(): void + { + Schema::table('documents', function (Blueprint $table) { + $table->dropUnique(['slug']); + }); + } +}; diff --git a/server-documentation/database/migrations/2024_01_01_000006_add_performance_indexes_and_fix_slug_constraint.php b/server-documentation/database/migrations/2024_01_01_000006_add_performance_indexes_and_fix_slug_constraint.php new file mode 100644 index 0000000..0e4a385 --- /dev/null +++ b/server-documentation/database/migrations/2024_01_01_000006_add_performance_indexes_and_fix_slug_constraint.php @@ -0,0 +1,87 @@ +index(['is_published', 'type'], 'idx_documents_published_type'); + $table->index(['is_global', 'is_published'], 'idx_documents_global_published'); + $table->index('sort_order', 'idx_documents_sort'); + }); + + // Fix slug uniqueness to allow reuse after soft delete + // This requires database-specific handling + $driver = DB::getDriverName(); + + Schema::table('documents', function (Blueprint $table) { + // First, drop the existing unique constraint + $table->dropUnique(['slug']); + }); + + if ($driver === 'mysql' || $driver === 'mariadb') { + // MySQL/MariaDB: Use a partial unique index workaround + // Create a generated column that's null when deleted + DB::statement('ALTER TABLE documents ADD COLUMN slug_unique VARCHAR(255) GENERATED ALWAYS AS (IF(deleted_at IS NULL, slug, NULL)) STORED'); + DB::statement('CREATE UNIQUE INDEX idx_documents_slug_active ON documents(slug_unique)'); + } elseif ($driver === 'pgsql') { + // PostgreSQL: Use a partial unique index + DB::statement('CREATE UNIQUE INDEX idx_documents_slug_active ON documents(slug) WHERE deleted_at IS NULL'); + } elseif ($driver === 'sqlite') { + // SQLite 3.9+: Use a partial unique index + DB::statement('CREATE UNIQUE INDEX idx_documents_slug_active ON documents(slug) WHERE deleted_at IS NULL'); + } else { + // Fallback for unsupported drivers: regular unique (slug reuse after soft delete won't work) + Schema::table('documents', function (Blueprint $table) { + $table->unique('slug'); + }); + } + + // Add unique constraint on document versions to prevent race condition duplicates + Schema::table('document_versions', function (Blueprint $table) { + $table->unique(['document_id', 'version_number'], 'idx_document_versions_unique'); + }); + } + + public function down(): void + { + $driver = DB::getDriverName(); + + // Remove version unique constraint + Schema::table('document_versions', function (Blueprint $table) { + $table->dropUnique('idx_document_versions_unique'); + }); + + // Remove slug constraint based on driver + if ($driver === 'mysql' || $driver === 'mariadb') { + DB::statement('DROP INDEX idx_documents_slug_active ON documents'); + DB::statement('ALTER TABLE documents DROP COLUMN slug_unique'); + } elseif ($driver === 'pgsql') { + DB::statement('DROP INDEX idx_documents_slug_active'); + } elseif ($driver === 'sqlite') { + DB::statement('DROP INDEX idx_documents_slug_active'); + } else { + Schema::table('documents', function (Blueprint $table) { + $table->dropUnique(['slug']); + }); + } + + // Restore original unique constraint + Schema::table('documents', function (Blueprint $table) { + $table->unique('slug'); + }); + + // Remove performance indexes + Schema::table('documents', function (Blueprint $table) { + $table->dropIndex('idx_documents_published_type'); + $table->dropIndex('idx_documents_global_published'); + $table->dropIndex('idx_documents_sort'); + }); + } +}; diff --git a/server-documentation/docs/images/admin-create-document.png b/server-documentation/docs/images/admin-create-document.png new file mode 100755 index 0000000..ae66b05 Binary files /dev/null and b/server-documentation/docs/images/admin-create-document.png differ diff --git a/server-documentation/docs/images/admin-documents-list.png b/server-documentation/docs/images/admin-documents-list.png new file mode 100755 index 0000000..4696df9 Binary files /dev/null and b/server-documentation/docs/images/admin-documents-list.png differ diff --git a/server-documentation/docs/images/admin-edit-document.png b/server-documentation/docs/images/admin-edit-document.png new file mode 100755 index 0000000..d664e47 Binary files /dev/null and b/server-documentation/docs/images/admin-edit-document.png differ diff --git a/server-documentation/docs/images/player-view.png b/server-documentation/docs/images/player-view.png new file mode 100755 index 0000000..d0db87e Binary files /dev/null and b/server-documentation/docs/images/player-view.png differ diff --git a/server-documentation/docs/images/server-admin-view.png b/server-documentation/docs/images/server-admin-view.png new file mode 100755 index 0000000..96270bb Binary files /dev/null and b/server-documentation/docs/images/server-admin-view.png differ diff --git a/server-documentation/docs/images/server-mod-view.png b/server-documentation/docs/images/server-mod-view.png new file mode 100755 index 0000000..20d3023 Binary files /dev/null and b/server-documentation/docs/images/server-mod-view.png differ diff --git a/server-documentation/docs/images/version-history-preview.png b/server-documentation/docs/images/version-history-preview.png new file mode 100755 index 0000000..e2c9331 Binary files /dev/null and b/server-documentation/docs/images/version-history-preview.png differ diff --git a/server-documentation/docs/images/version-history-restore.png b/server-documentation/docs/images/version-history-restore.png new file mode 100755 index 0000000..493607e Binary files /dev/null and b/server-documentation/docs/images/version-history-restore.png differ diff --git a/server-documentation/docs/images/version-history-restored.png b/server-documentation/docs/images/version-history-restored.png new file mode 100755 index 0000000..65ed782 Binary files /dev/null and b/server-documentation/docs/images/version-history-restored.png differ diff --git a/server-documentation/docs/images/version-history.png b/server-documentation/docs/images/version-history.png new file mode 100755 index 0000000..b600526 Binary files /dev/null and b/server-documentation/docs/images/version-history.png differ diff --git a/server-documentation/lang/en/strings.php b/server-documentation/lang/en/strings.php new file mode 100644 index 0000000..ad49cd0 --- /dev/null +++ b/server-documentation/lang/en/strings.php @@ -0,0 +1,153 @@ + [ + 'documents' => 'Documents', + 'group' => 'Content', + ], + + 'document' => [ + 'singular' => 'Document', + 'plural' => 'Documents', + 'title' => 'Title', + 'slug' => 'Slug', + 'content' => 'Content', + 'type' => 'Type', + 'is_global' => 'Global', + 'is_published' => 'Published', + 'sort_order' => 'Sort Order', + 'author' => 'Author', + 'last_edited_by' => 'Last Edited By', + 'version' => 'Version', + ], + + 'types' => [ + 'host_admin' => 'Host Admin', + 'host_admin_description' => 'Root Admins only', + 'server_admin' => 'Server Admin', + 'server_admin_description' => 'Server owners + admins with Server Update/Create', + 'server_mod' => 'Server Mod', + 'server_mod_description' => 'Subusers with control permissions', + 'player' => 'Player', + 'player_description' => 'Anyone with server access', + ], + + 'labels' => [ + 'all_servers' => 'All Servers', + 'all_servers_helper' => 'Show on all servers (otherwise attach to specific servers below)', + 'published_helper' => 'Unpublished documents are only visible to admins', + 'sort_order_helper' => 'Lower numbers appear first', + ], + + 'form' => [ + 'details_section' => 'Document Details', + 'server_assignment' => 'Server Assignment', + 'server_assignment_description' => 'Select which servers should display this document', + 'filter_by_egg' => 'Filter by Egg', + 'all_eggs' => 'All Eggs', + 'assign_to_servers' => 'Assign to Servers', + 'assign_servers_helper' => 'Select servers that should display this document. Leave empty if using "All Servers" toggle above.', + ], + + 'server' => [ + 'node' => 'Node', + 'owner' => 'Owner', + ], + + 'table' => [ + 'servers' => 'Servers', + 'updated_at' => 'Updated', + 'empty_heading' => 'No documents yet', + 'empty_description' => 'Create your first document to get started.', + ], + + 'permission_guide' => [ + 'title' => 'Permission Guide', + 'modal_heading' => 'Document Permission Guide', + 'description' => 'Understanding document visibility', + 'type_controls' => 'controls who can see the document.', + 'all_servers_controls' => 'controls where it appears.', + 'who_can_see' => 'Who Can See', + 'hierarchy_note' => 'Higher tiers can see all docs at their level and below (e.g., Server Admin sees Server Admin, Server Mod, and Player docs).', + 'toggle_title' => 'All Servers Toggle:', + 'toggle_on' => 'On', + 'toggle_on_desc' => 'Document appears on every server', + 'toggle_off' => 'Off', + 'toggle_off_desc' => 'Must attach to specific servers', + 'examples_title' => 'Examples:', + 'example_player_all' => 'Player + All Servers', + 'example_player_all_desc' => 'Welcome guide everyone sees everywhere', + 'example_player_specific' => 'Player + Specific Server', + 'example_player_specific_desc' => 'Rules for one server only', + 'example_admin_all' => 'Server Admin + All Servers', + 'example_admin_all_desc' => 'Company-wide admin procedures', + 'example_mod_specific' => 'Server Mod + Specific Server', + 'example_mod_specific_desc' => 'Mod notes for one server', + ], + + 'messages' => [ + 'version_restored' => 'Version :version restored successfully.', + 'no_documents' => 'No documents available.', + 'no_versions' => 'No versions yet.', + ], + + 'versions' => [ + 'title' => 'Version History', + 'current_document' => 'Current Document', + 'current_version' => 'Current Version', + 'last_updated' => 'Last Updated', + 'last_edited_by' => 'Last Edited By', + 'version_number' => 'Version', + 'edited_by' => 'Edited By', + 'date' => 'Date', + 'change_summary' => 'Change Summary', + 'preview' => 'Preview', + 'restore' => 'Restore', + 'restore_confirm' => 'Are you sure you want to restore this version? This will create a new version with the restored content.', + 'restored' => 'Version restored successfully.', + ], + + 'server_panel' => [ + 'title' => 'Server Documents', + 'no_documents' => 'No documents available', + 'no_documents_description' => 'There are no documents for this server yet.', + 'select_document' => 'Select a document', + 'select_document_description' => 'Choose a document from the list to view its contents.', + 'last_updated' => 'Last updated :time', + 'global' => 'Global', + ], + + 'actions' => [ + 'export' => 'Export as Markdown', + 'import' => 'Import Markdown', + 'back_to_document' => 'Back to Document', + 'close' => 'Close', + ], + + 'import' => [ + 'file_label' => 'Markdown File', + 'file_helper' => 'Upload a .md file to create a new document', + 'use_frontmatter' => 'Use YAML Frontmatter', + 'use_frontmatter_helper' => 'Extract title, type, and settings from YAML frontmatter if present', + 'success' => 'Document Imported', + 'success_body' => 'Successfully created document ":title"', + 'error' => 'Import Failed', + 'file_too_large' => 'The uploaded file exceeds the maximum allowed size.', + 'file_read_error' => 'Could not read the uploaded file.', + 'invalid_type' => 'Invalid document type in frontmatter, defaulting to Player.', + ], + + 'export' => [ + 'success' => 'Document Exported', + 'success_body' => 'Document has been downloaded as Markdown', + ], + + 'relation_managers' => [ + 'linked_servers' => 'Linked Servers', + 'no_servers_linked' => 'No servers linked', + 'attach_servers_description' => 'Attach servers to make this document visible on those servers.', + 'no_documents_linked' => 'No documents linked', + 'attach_documents_description' => 'Attach documents to make them visible on this server.', + 'sort_order_helper' => 'Order this document appears for this server', + ], +]; diff --git a/server-documentation/plugin.json b/server-documentation/plugin.json new file mode 100644 index 0000000..3ca8bdf --- /dev/null +++ b/server-documentation/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "server-documentation", + "name": "Server Documentation", + "version": "1.0.0", + "description": "Attach markdown documentation to servers with versioning and role-based access", + "author": "Gavin", + "category": "plugin", + "namespace": "Starter\\ServerDocumentation", + "class": "ServerDocumentationPlugin" +} diff --git a/server-documentation/resources/css/document-content.css b/server-documentation/resources/css/document-content.css new file mode 100644 index 0000000..1caa58d --- /dev/null +++ b/server-documentation/resources/css/document-content.css @@ -0,0 +1,150 @@ +/** + * Document content styling for server-documentation plugin + * These styles are applied to rendered document content in the server panel + */ + +.document-content h1 { + font-size: 1.875rem; + font-weight: 700; + margin-top: 1.5rem; + margin-bottom: 1rem; + color: rgb(var(--gray-100)); +} + +.document-content h2 { + font-size: 1.5rem; + font-weight: 600; + margin-top: 1.5rem; + margin-bottom: 0.75rem; + color: rgb(var(--gray-100)); +} + +.document-content h3 { + font-size: 1.25rem; + font-weight: 600; + margin-top: 1.25rem; + margin-bottom: 0.5rem; + color: rgb(var(--gray-200)); +} + +.document-content h4 { + font-size: 1.125rem; + font-weight: 600; + margin-top: 1rem; + margin-bottom: 0.5rem; + color: rgb(var(--gray-200)); +} + +.document-content p { + margin-top: 0.75rem; + margin-bottom: 0.75rem; + color: rgb(var(--gray-300)); + line-height: 1.625; +} + +.document-content ul, +.document-content ol { + margin-top: 0.75rem; + margin-bottom: 0.75rem; + padding-left: 1.5rem; + color: rgb(var(--gray-300)); +} + +.document-content ul { + list-style-type: disc; +} + +.document-content ol { + list-style-type: decimal; +} + +.document-content li { + margin-top: 0.375rem; + margin-bottom: 0.375rem; +} + +.document-content li > ul, +.document-content li > ol { + margin-top: 0.375rem; + margin-bottom: 0.375rem; +} + +.document-content strong { + font-weight: 600; + color: rgb(var(--gray-100)); +} + +.document-content em { + font-style: italic; +} + +.document-content code { + background-color: rgb(var(--gray-800)); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.875rem; + color: rgb(var(--primary-400)); +} + +.document-content pre { + background-color: rgb(var(--gray-800)); + padding: 1rem; + border-radius: 0.5rem; + overflow-x: auto; + margin-top: 1rem; + margin-bottom: 1rem; +} + +.document-content pre code { + background: none; + padding: 0; +} + +.document-content a { + color: rgb(var(--primary-400)); + text-decoration: underline; +} + +.document-content a:hover { + color: rgb(var(--primary-300)); +} + +.document-content blockquote { + border-left: 4px solid rgb(var(--gray-600)); + padding-left: 1rem; + margin: 1rem 0; + color: rgb(var(--gray-400)); + font-style: italic; +} + +.document-content hr { + border-color: rgb(var(--gray-700)); + margin: 1.5rem 0; +} + +.document-content table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; +} + +.document-content th, +.document-content td { + border: 1px solid rgb(var(--gray-700)); + padding: 0.5rem 0.75rem; + text-align: left; +} + +.document-content th { + background-color: rgb(var(--gray-800)); + font-weight: 600; + color: rgb(var(--gray-100)); +} + +.document-content td { + color: rgb(var(--gray-300)); +} + +.document-content > *:first-child { + margin-top: 0; +} diff --git a/server-documentation/resources/views/filament/pages/document-versions.blade.php b/server-documentation/resources/views/filament/pages/document-versions.blade.php new file mode 100644 index 0000000..bf95c50 --- /dev/null +++ b/server-documentation/resources/views/filament/pages/document-versions.blade.php @@ -0,0 +1,30 @@ + +
+ + + {{ trans('server-documentation::strings.versions.current_document') }} + + +
+
+ {{ trans('server-documentation::strings.document.title') }}: + {{ $this->record->title }} +
+
+ {{ trans('server-documentation::strings.versions.current_version') }}: + v{{ $this->record->getCurrentVersionNumber() }} +
+
+ {{ trans('server-documentation::strings.versions.last_updated') }}: + {{ $this->record->updated_at?->diffForHumans() ?? 'Never' }} +
+
+ {{ trans('server-documentation::strings.versions.last_edited_by') }}: + {{ $this->record->lastEditor?->username ?? 'Unknown' }} +
+
+
+ + {{ $this->table }} +
+
diff --git a/server-documentation/resources/views/filament/pages/version-preview.blade.php b/server-documentation/resources/views/filament/pages/version-preview.blade.php new file mode 100644 index 0000000..525f0a9 --- /dev/null +++ b/server-documentation/resources/views/filament/pages/version-preview.blade.php @@ -0,0 +1,30 @@ +
+
+
+ Title: + {{ $version->title }} +
+
+ Version: + v{{ $version->version_number }} +
+
+ Edited By: + {{ $version->editor?->username ?? 'Unknown' }} +
+
+ Date: + {{ $version->created_at->format('M j, Y g:i A') }} +
+ @if($version->change_summary) +
+ Change Summary: + {{ $version->change_summary }} +
+ @endif +
+ +
+ {!! str($version->content)->sanitizeHtml() !!} +
+
diff --git a/server-documentation/resources/views/filament/partials/permission-guide.blade.php b/server-documentation/resources/views/filament/partials/permission-guide.blade.php new file mode 100644 index 0000000..ee2c0f0 --- /dev/null +++ b/server-documentation/resources/views/filament/partials/permission-guide.blade.php @@ -0,0 +1,61 @@ +@php + use Starter\ServerDocumentation\Enums\DocumentType; +@endphp + +
+

+ {{ trans('server-documentation::strings.document.type') }} + {{ trans('server-documentation::strings.permission_guide.type_controls') }} + {{ trans('server-documentation::strings.labels.all_servers') }} + {{ trans('server-documentation::strings.permission_guide.all_servers_controls') }} +

+ + + + + + + + + + @foreach(DocumentType::cases() as $type) + + + + + @endforeach + +
{{ trans('server-documentation::strings.document.type') }}{{ trans('server-documentation::strings.permission_guide.who_can_see') }}
+ $type === DocumentType::HostAdmin, + 'bg-warning-50 text-warning-700 dark:bg-warning-900/50 dark:text-warning-400' => $type === DocumentType::ServerAdmin, + 'bg-info-50 text-info-700 dark:bg-info-900/50 dark:text-info-400' => $type === DocumentType::ServerMod, + 'bg-success-50 text-success-700 dark:bg-success-900/50 dark:text-success-400' => $type === DocumentType::Player, + ])> + {{ $type->label() }} + + {{ $type->description() }}
+ + @if($showExamples ?? false) +

+ {{ trans('server-documentation::strings.permission_guide.toggle_title') }} +

+
    +
  • {{ trans('server-documentation::strings.permission_guide.toggle_on') }} β†’ {{ trans('server-documentation::strings.permission_guide.toggle_on_desc') }}
  • +
  • {{ trans('server-documentation::strings.permission_guide.toggle_off') }} β†’ {{ trans('server-documentation::strings.permission_guide.toggle_off_desc') }}
  • +
+ +

{{ trans('server-documentation::strings.permission_guide.examples_title') }}

+
    +
  • {{ trans('server-documentation::strings.permission_guide.example_player_all') }} β†’ {{ trans('server-documentation::strings.permission_guide.example_player_all_desc') }}
  • +
  • {{ trans('server-documentation::strings.permission_guide.example_player_specific') }} β†’ {{ trans('server-documentation::strings.permission_guide.example_player_specific_desc') }}
  • +
  • {{ trans('server-documentation::strings.permission_guide.example_admin_all') }} β†’ {{ trans('server-documentation::strings.permission_guide.example_admin_all_desc') }}
  • +
  • {{ trans('server-documentation::strings.permission_guide.example_mod_specific') }} β†’ {{ trans('server-documentation::strings.permission_guide.example_mod_specific_desc') }}
  • +
+ @endif + +

+ {{ trans('server-documentation::strings.permission_guide.hierarchy_note') }} +

+
diff --git a/server-documentation/resources/views/filament/server/pages/documents.blade.php b/server-documentation/resources/views/filament/server/pages/documents.blade.php new file mode 100644 index 0000000..206d0e7 --- /dev/null +++ b/server-documentation/resources/views/filament/server/pages/documents.blade.php @@ -0,0 +1,127 @@ + + @php + use Starter\ServerDocumentation\Enums\DocumentType; + $documents = $this->getDocuments(); + @endphp + + @once + @push('styles') + + @endpush + @endonce + + @if($documents->isEmpty()) +
+ +

+ {{ trans('server-documentation::strings.server_panel.no_documents') }} +

+

+ {{ trans('server-documentation::strings.server_panel.no_documents_description') }} +

+
+ @else +
+ {{-- Document list sidebar --}} +
+
+
+

{{ trans('server-documentation::strings.navigation.documents') }}

+
+ +
+
+ + {{-- Document content --}} +
+ @if($selectedDocument) + @php + $selectedDocType = DocumentType::tryFromLegacy($selectedDocument->type); + @endphp +
+
+
+

+ {{ $selectedDocument->title }} +

+
+ @if($selectedDocType && $selectedDocType !== DocumentType::Player) + $selectedDocType === DocumentType::HostAdmin, + 'bg-warning-50 text-warning-700 dark:bg-warning-900/50 dark:text-warning-400' => $selectedDocType === DocumentType::ServerAdmin, + 'bg-info-50 text-info-700 dark:bg-info-900/50 dark:text-info-400' => $selectedDocType === DocumentType::ServerMod, + ])> + + {{ $selectedDocType->label() }} + + @endif + @if($selectedDocument->is_global) + + + {{ trans('server-documentation::strings.server_panel.global') }} + + @endif +
+
+ @if($selectedDocument->updated_at) +

+ {{ trans('server-documentation::strings.server_panel.last_updated', ['time' => $selectedDocument->updated_at->diffForHumans()]) }} +

+ @endif +
+
+ {!! str($selectedDocument->content)->sanitizeHtml() !!} +
+
+ @else +
+ +

+ {{ trans('server-documentation::strings.server_panel.select_document') }} +

+

+ {{ trans('server-documentation::strings.server_panel.select_document_description') }} +

+
+ @endif +
+
+ @endif +
diff --git a/server-documentation/src/Enums/DocumentType.php b/server-documentation/src/Enums/DocumentType.php new file mode 100644 index 0000000..f2d884d --- /dev/null +++ b/server-documentation/src/Enums/DocumentType.php @@ -0,0 +1,200 @@ + trans('server-documentation::strings.types.host_admin'), + self::ServerAdmin => trans('server-documentation::strings.types.server_admin'), + self::ServerMod => trans('server-documentation::strings.types.server_mod'), + self::Player => trans('server-documentation::strings.types.player'), + }; + } + + /** + * Get the description for this type. + */ + public function description(): string + { + return match ($this) { + self::HostAdmin => trans('server-documentation::strings.types.host_admin_description'), + self::ServerAdmin => trans('server-documentation::strings.types.server_admin_description'), + self::ServerMod => trans('server-documentation::strings.types.server_mod_description'), + self::Player => trans('server-documentation::strings.types.player_description'), + }; + } + + /** + * Get the Filament color for this type. + */ + public function color(): string + { + return match ($this) { + self::HostAdmin => 'danger', + self::ServerAdmin => 'warning', + self::ServerMod => 'info', + self::Player => 'success', + }; + } + + /** + * Get the icon for this type. + */ + public function icon(): string + { + return match ($this) { + self::HostAdmin => 'tabler-shield-lock', + self::ServerAdmin => 'tabler-lock', + self::ServerMod => 'tabler-user-shield', + self::Player => 'tabler-file-text', + }; + } + + /** + * Get the hierarchy level (higher = more privileged). + */ + public function hierarchyLevel(): int + { + return match ($this) { + self::HostAdmin => 4, + self::ServerAdmin => 3, + self::ServerMod => 2, + self::Player => 1, + }; + } + + /** + * Check if this type is visible to a given hierarchy level. + */ + public function isVisibleToLevel(int $level): bool + { + return $level >= $this->hierarchyLevel(); + } + + /** + * Get all types visible to a given hierarchy level. + * + * @return array + */ + public static function typesVisibleToLevel(int $level): array + { + $types = []; + foreach (self::cases() as $case) { + if ($case->isVisibleToLevel($level)) { + $types[] = $case->value; + } + } + + if ($level >= self::ServerAdmin->hierarchyLevel()) { + $types[] = self::LEGACY_ADMIN; + } + + return $types; + } + + /** + * Try to create from a string, handling legacy values. + */ + public static function tryFromLegacy(string $value): ?self + { + if ($value === self::LEGACY_ADMIN) { + return self::ServerAdmin; + } + + return self::tryFrom($value); + } + + /** + * Check if a string is a valid document type (including legacy). + */ + public static function isValid(string $value): bool + { + return self::tryFromLegacy($value) !== null; + } + + /** + * Get options array for form selects. + * + * @return array + */ + public static function options(): array + { + $options = []; + foreach (self::cases() as $case) { + $options[$case->value] = $case->label() . ' (' . $case->description() . ')'; + } + + return $options; + } + + /** + * Get options array with just labels (no descriptions). + * + * @return array + */ + public static function simpleOptions(): array + { + $options = []; + foreach (self::cases() as $case) { + $options[$case->value] = $case->label(); + } + + return $options; + } + + /** + * Format a type string for display, handling legacy values. + */ + public static function formatLabel(string $value): string + { + $type = self::tryFromLegacy($value); + + return $type?->label() ?? $value; + } + + /** + * Get color for a type string, handling legacy values. + */ + public static function formatColor(string $value): string + { + $type = self::tryFromLegacy($value); + + return $type?->color() ?? 'gray'; + } + + /** + * Get icon for a type string, handling legacy values. + */ + public static function formatIcon(string $value): string + { + $type = self::tryFromLegacy($value); + + return $type?->icon() ?? 'tabler-file-text'; + } +} diff --git a/server-documentation/src/Filament/Admin/RelationManagers/DocumentsRelationManager.php b/server-documentation/src/Filament/Admin/RelationManagers/DocumentsRelationManager.php new file mode 100644 index 0000000..3e0c21e --- /dev/null +++ b/server-documentation/src/Filament/Admin/RelationManagers/DocumentsRelationManager.php @@ -0,0 +1,82 @@ +recordTitleAttribute('title') + ->reorderable('pivot.sort_order') + ->columns([ + static::getDocumentTitleColumn(40), + static::getDocumentTypeColumn(), + static::getDocumentGlobalColumn(), + static::getDocumentPublishedColumn(), + + TextColumn::make('pivot.sort_order') + ->label(trans('server-documentation::strings.document.sort_order')) + ->sortable(), + + static::getDocumentUpdatedAtColumn(), + ]) + ->filters([ + static::getDocumentTypeFilter(), + ]) + ->headerActions([ + AttachAction::make() + ->preloadRecordSelect() + ->form(fn (AttachAction $action): array => [ + $action->getRecordSelect(), + TextInput::make('sort_order') + ->label(trans('server-documentation::strings.document.sort_order')) + ->numeric() + ->default(0) + ->helperText(trans('server-documentation::strings.relation_managers.sort_order_helper')), + ]), + CreateAction::make() + ->mutateFormDataUsing(fn (array $data): array => [ + ...$data, + 'author_id' => auth()->id(), + ]), + ]) + ->recordActions([ + ViewAction::make() + ->url(fn (Document $record) => DocumentResource::getUrl('edit', ['record' => $record])), + DetachAction::make(), + ]) + ->groupedBulkActions([ + DetachBulkAction::make(), + ]) + ->emptyStateHeading(trans('server-documentation::strings.relation_managers.no_documents_linked')) + ->emptyStateDescription(trans('server-documentation::strings.relation_managers.attach_documents_description')) + ->emptyStateIcon('tabler-file-off'); + } +} diff --git a/server-documentation/src/Filament/Admin/Resources/DocumentResource.php b/server-documentation/src/Filament/Admin/Resources/DocumentResource.php new file mode 100644 index 0000000..41a5a13 --- /dev/null +++ b/server-documentation/src/Filament/Admin/Resources/DocumentResource.php @@ -0,0 +1,246 @@ +check() && auth()->user()->can('viewList document'); + } + + public static function getNavigationLabel(): string + { + return trans('server-documentation::strings.navigation.documents'); + } + + public static function getModelLabel(): string + { + return trans('server-documentation::strings.document.singular', [], 'en') !== 'server-documentation::strings.document.singular' + ? trans('server-documentation::strings.document.singular') + : 'Document'; + } + + public static function getPluralModelLabel(): string + { + return trans('server-documentation::strings.document.plural', [], 'en') !== 'server-documentation::strings.document.plural' + ? trans('server-documentation::strings.document.plural') + : 'Documents'; + } + + public static function getNavigationGroup(): ?string + { + return trans('server-documentation::strings.navigation.group', [], 'en') !== 'server-documentation::strings.navigation.group' + ? trans('server-documentation::strings.navigation.group') + : 'Content'; + } + + public static function getNavigationBadge(): ?string + { + $count = app(DocumentService::class)->getDocumentCount(); + + return $count > 0 ? (string) $count : null; + } + + public static function defaultForm(Schema $schema): Schema + { + return $schema + ->columns(1) + ->components([ + Section::make(trans('server-documentation::strings.form.details_section'))->schema([ + TextInput::make('title') + ->label(trans('server-documentation::strings.document.title')) + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(fn ($state, $set, ?Document $record) => $record === null ? $set('slug', Str::slug($state)) : null + ), + + TextInput::make('slug') + ->label(trans('server-documentation::strings.document.slug')) + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true) + ->rules(['alpha_dash']), + + Select::make('type') + ->label(trans('server-documentation::strings.document.type')) + ->options(DocumentType::options()) + ->default(DocumentType::Player->value) + ->required() + ->native(false), + + Toggle::make('is_global') + ->label(trans('server-documentation::strings.labels.all_servers')) + ->helperText(trans('server-documentation::strings.labels.all_servers_helper')), + + Toggle::make('is_published') + ->label(trans('server-documentation::strings.document.is_published')) + ->default(true) + ->helperText(trans('server-documentation::strings.labels.published_helper')), + + TextInput::make('sort_order') + ->label(trans('server-documentation::strings.document.sort_order')) + ->numeric() + ->default(0) + ->helperText(trans('server-documentation::strings.labels.sort_order_helper')), + ])->columns(3)->columnSpanFull(), + + Section::make(trans('server-documentation::strings.document.content'))->schema([ + RichEditor::make('content') + ->label('') + ->required() + ->extraAttributes(['style' => 'min-height: 400px;']) + ->columnSpanFull(), + ])->columnSpanFull(), + + Section::make(trans('server-documentation::strings.form.server_assignment')) + ->description(trans('server-documentation::strings.form.server_assignment_description')) + ->collapsed(fn (?Document $record) => $record !== null) + ->visible(fn (?Document $record, string $operation) => $operation === 'create' || ($record && !$record->is_global)) + ->schema([ + Select::make('filter_egg') + ->label(trans('server-documentation::strings.form.filter_by_egg')) + ->options(fn () => Egg::pluck('name', 'id')) + ->placeholder(trans('server-documentation::strings.form.all_eggs')) + ->live() + ->afterStateUpdated(fn ($set) => $set('servers', [])), + + CheckboxList::make('servers') + ->label(trans('server-documentation::strings.form.assign_to_servers')) + ->relationship('servers', 'name') + ->options(function ($get) { + $query = Server::query()->orderBy('name'); + + if ($eggId = $get('filter_egg')) { + $query->where('egg_id', $eggId); + } + + return $query->pluck('name', 'id'); + }) + ->searchable() + ->bulkToggleable() + ->columns(2) + ->helperText(trans('server-documentation::strings.form.assign_servers_helper')) + ->visible(fn ($get) => !$get('is_global')), + ])->columnSpanFull(), + + Section::make(trans('server-documentation::strings.permission_guide.title')) + ->description(trans('server-documentation::strings.permission_guide.description')) + ->collapsed() + ->schema([ + Placeholder::make('help') + ->label('') + ->content(new HtmlString( + view('server-documentation::filament.partials.permission-guide', ['showExamples' => false])->render() // @phpstan-ignore argument.type + )), + ])->columnSpanFull(), + ]); + } + + public static function defaultTable(Table $table): Table + { + return $table + ->columns([ + static::getDocumentTitleColumn(), + static::getDocumentTypeColumn(), + static::getDocumentGlobalColumn(), + static::getDocumentPublishedColumn(), + + TextColumn::make('servers_count') + ->counts('servers') + ->label(trans('server-documentation::strings.table.servers')) + ->badge(), + + TextColumn::make('author.username') + ->label(trans('server-documentation::strings.document.author')) + ->toggleable(isToggledHiddenByDefault: true), + + static::getDocumentUpdatedAtColumn(), + ]) + ->filters([ + static::getDocumentTypeFilter(), + static::getDocumentGlobalFilter(), + static::getDocumentPublishedFilter(), + TrashedFilter::make(), + ]) + ->recordActions([ + EditAction::make(), + ]) + ->groupedBulkActions([ + DeleteBulkAction::make(), + ]) + ->defaultSort('sort_order') + ->emptyStateIcon('tabler-file-off') + ->emptyStateHeading(trans('server-documentation::strings.table.empty_heading')) + ->emptyStateDescription(trans('server-documentation::strings.table.empty_description')); + } + + /** @return class-string[] */ + public static function getRelations(): array + { + return [ + RelationManagers\ServersRelationManager::class, + ]; + } + + /** @return array */ + public static function getPages(): array + { + return [ + 'index' => Pages\ListDocuments::route('/'), + 'create' => Pages\CreateDocument::route('/create'), + 'edit' => Pages\EditDocument::route('/{record}/edit'), + 'versions' => Pages\ViewDocumentVersions::route('/{record}/versions'), + ]; + } +} diff --git a/server-documentation/src/Filament/Admin/Resources/DocumentResource/Pages/CreateDocument.php b/server-documentation/src/Filament/Admin/Resources/DocumentResource/Pages/CreateDocument.php new file mode 100644 index 0000000..b320a9c --- /dev/null +++ b/server-documentation/src/Filament/Admin/Resources/DocumentResource/Pages/CreateDocument.php @@ -0,0 +1,40 @@ + */ + protected function getHeaderActions(): array + { + return [ + $this->getCreateFormAction() + ->formId('form') + ->iconButton() + ->iconSize(IconSize::ExtraLarge) + ->icon('tabler-file-plus'), + ]; + } + + protected function getFormActions(): array + { + return []; + } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('index'); + } +} diff --git a/server-documentation/src/Filament/Admin/Resources/DocumentResource/Pages/EditDocument.php b/server-documentation/src/Filament/Admin/Resources/DocumentResource/Pages/EditDocument.php new file mode 100644 index 0000000..ce6620e --- /dev/null +++ b/server-documentation/src/Filament/Admin/Resources/DocumentResource/Pages/EditDocument.php @@ -0,0 +1,92 @@ +record; + } + + /** @return array */ + protected function getHeaderActions(): array + { + return [ + Action::make('export') + ->label(trans('server-documentation::strings.actions.export')) + ->icon('tabler-download') + ->iconButton() + ->iconSize(IconSize::ExtraLarge) + ->color('gray') + ->action(fn () => $this->exportAsMarkdown()), + Action::make('versions') + ->label(trans('server-documentation::strings.versions.title')) + ->icon('tabler-history') + ->iconButton() + ->iconSize(IconSize::ExtraLarge) + ->url(fn () => DocumentResource::getUrl('versions', ['record' => $this->getRecord()])) + ->badge(fn () => $this->getRecord()->versions()->count() ?: null), + $this->getSaveFormAction() + ->formId('form') + ->iconButton() + ->iconSize(IconSize::ExtraLarge) + ->icon('tabler-device-floppy'), + DeleteAction::make() + ->iconButton() + ->iconSize(IconSize::ExtraLarge), + ]; + } + + /** + * Export the current document as a Markdown file. + */ + public function exportAsMarkdown(): StreamedResponse + { + $converter = new MarkdownConverter(); + $document = $this->getRecord(); + + $markdown = $converter->toMarkdown($document->content); + $markdown = $converter->addFrontmatter($markdown, [ + 'title' => $document->title, + 'slug' => $document->slug, + 'type' => $document->type, + 'is_global' => $document->is_global, + 'is_published' => $document->is_published, + 'sort_order' => $document->sort_order, + ]); + + $filename = $converter->generateFilename($document->title, $document->slug); + + return response()->streamDownload(function () use ($markdown) { + echo $markdown; + }, $filename, [ + 'Content-Type' => 'text/markdown', + ]); + } + + protected function getFormActions(): array + { + return []; + } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('index'); + } +} diff --git a/server-documentation/src/Filament/Admin/Resources/DocumentResource/Pages/ListDocuments.php b/server-documentation/src/Filament/Admin/Resources/DocumentResource/Pages/ListDocuments.php new file mode 100644 index 0000000..5566f13 --- /dev/null +++ b/server-documentation/src/Filament/Admin/Resources/DocumentResource/Pages/ListDocuments.php @@ -0,0 +1,176 @@ +label(trans('server-documentation::strings.actions.import')) + ->icon('tabler-upload') + ->color('gray') + ->form([ + FileUpload::make('markdown_file') + ->label(trans('server-documentation::strings.import.file_label')) + ->helperText(trans('server-documentation::strings.import.file_helper') . " (max {$maxFileSize}KB)") + ->acceptedFileTypes(['text/markdown', 'text/plain', '.md']) + ->maxSize($maxFileSize) + ->required() + ->storeFiles(false), + Toggle::make('use_frontmatter') + ->label(trans('server-documentation::strings.import.use_frontmatter')) + ->helperText(trans('server-documentation::strings.import.use_frontmatter_helper')) + ->default(true), + ]) + ->action(function (array $data): void { + $this->importMarkdownFile($data); + }), + Action::make('help') + ->label(trans('server-documentation::strings.permission_guide.title')) + ->icon('tabler-help') + ->color('gray') + ->modalHeading(trans('server-documentation::strings.permission_guide.modal_heading')) + ->modalDescription(new HtmlString( + view('server-documentation::filament.partials.permission-guide', ['showExamples' => true])->render() // @phpstan-ignore argument.type + )) + ->modalSubmitAction(false) + ->modalCancelActionLabel(trans('server-documentation::strings.actions.close')), + CreateAction::make(), + ]; + } + + /** + * Import a Markdown file and create a new document. + * + * @phpstan-param array $data + */ + protected function importMarkdownFile(array $data): void + { + $converter = app(MarkdownConverter::class); + + /** @var TemporaryUploadedFile $file */ + $file = $data['markdown_file']; + + $maxSize = config('server-documentation.max_import_size', 512) * 1024; + if ($file->getSize() > $maxSize) { + Notification::make() + ->title(trans('server-documentation::strings.import.error')) + ->body(trans('server-documentation::strings.import.file_too_large')) + ->danger() + ->send(); + + return; + } + + $content = file_get_contents($file->getRealPath()); + if ($content === false) { + Notification::make() + ->title(trans('server-documentation::strings.import.error')) + ->body(trans('server-documentation::strings.import.file_read_error')) + ->danger() + ->send(); + + return; + } + + $useFrontmatter = $data['use_frontmatter'] ?? true; + $metadata = []; + $markdownContent = $content; + + if ($useFrontmatter) { + [$metadata, $markdownContent] = $converter->parseFrontmatter($content); + } + + $htmlContent = $converter->toHtml($markdownContent); + + $title = $metadata['title'] + ?? $this->extractTitleFromMarkdown($markdownContent) + ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME); + + $slug = $metadata['slug'] ?? Str::slug($title); + $originalSlug = $slug; + $counter = 1; + while (Document::where('slug', $slug)->exists()) { + $slug = $originalSlug . '-' . $counter++; + } + + $type = $this->normalizeDocumentType($metadata['type'] ?? null); + + $document = Document::create([ + 'title' => $title, + 'slug' => $slug, + 'content' => $htmlContent, + 'type' => $type, + 'is_global' => filter_var($metadata['is_global'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'is_published' => filter_var($metadata['is_published'] ?? true, FILTER_VALIDATE_BOOLEAN), + 'sort_order' => (int) ($metadata['sort_order'] ?? 0), + 'author_id' => auth()->id(), + 'last_edited_by' => auth()->id(), + ]); + + Notification::make() + ->title(trans('server-documentation::strings.import.success')) + ->body(trans('server-documentation::strings.import.success_body', ['title' => $document->title])) + ->success() + ->send(); + + $this->redirect(DocumentResource::getUrl('edit', ['record' => $document])); + } + + /** + * Extract title from first H1 heading in markdown. + */ + protected function extractTitleFromMarkdown(string $markdown): ?string + { + if (preg_match('/^#\s+(.+)$/m', $markdown, $matches)) { + return trim($matches[1]); + } + + return null; + } + + /** + * Normalize document type from import, validating against allowed types. + */ + protected function normalizeDocumentType(?string $type): string + { + if ($type === null) { + return DocumentType::Player->value; + } + + if (DocumentType::isValid($type)) { + $enumType = DocumentType::tryFromLegacy($type); + + return $enumType !== null ? $enumType->value : DocumentType::Player->value; + } + + logger()->warning('Invalid document type in import', [ + 'type' => $type, + 'defaulted_to' => DocumentType::Player->value, + ]); + + return DocumentType::Player->value; + } +} diff --git a/server-documentation/src/Filament/Admin/Resources/DocumentResource/Pages/ViewDocumentVersions.php b/server-documentation/src/Filament/Admin/Resources/DocumentResource/Pages/ViewDocumentVersions.php new file mode 100644 index 0000000..7d80393 --- /dev/null +++ b/server-documentation/src/Filament/Admin/Resources/DocumentResource/Pages/ViewDocumentVersions.php @@ -0,0 +1,123 @@ +resolveRecord($record); + $this->record = $resolved; + } + + public function getRecord(): Document + { + /** @var Document */ + return $this->record; + } + + public function getTitle(): string|Htmlable + { + return trans('server-documentation::strings.versions.title') . ': ' . $this->getRecord()->title; + } + + public static function getNavigationLabel(): string + { + return trans('server-documentation::strings.versions.title'); + } + + protected function getHeaderActions(): array + { + return [ + Action::make('back') + ->label(trans('server-documentation::strings.actions.back_to_document')) + ->icon('tabler-arrow-left') + ->url(fn () => DocumentResource::getUrl('edit', ['record' => $this->getRecord()])), + ]; + } + + public function table(Table $table): Table + { + return $table + ->query(fn (): Builder => DocumentVersion::query()->where('document_id', $this->getRecord()->id)) + ->columns([ + TextColumn::make('version_number') + ->label(trans('server-documentation::strings.versions.version_number')) + ->formatStateUsing(fn (int $state): string => 'v' . $state) + ->sortable(), + + TextColumn::make('title') + ->label(trans('server-documentation::strings.document.title')) + ->limit(40), + + TextColumn::make('editor.username') + ->label(trans('server-documentation::strings.versions.edited_by')) + ->placeholder('Unknown'), + + TextColumn::make('change_summary') + ->label(trans('server-documentation::strings.versions.change_summary')) + ->limit(50) + ->placeholder('-'), + + TextColumn::make('created_at') + ->label(trans('server-documentation::strings.versions.date')) + ->dateTime() + ->sortable(), + ]) + ->defaultSort('version_number', 'desc') + ->actions([ + Action::make('preview') + ->label(trans('server-documentation::strings.versions.preview')) + ->icon('tabler-eye') + ->modalHeading(fn (DocumentVersion $record): string => 'v' . $record->version_number . ': ' . $record->title) + ->modalContent(fn (DocumentVersion $record): HtmlString => new HtmlString( + view('server-documentation::filament.pages.version-preview', ['version' => $record])->render() // @phpstan-ignore argument.type + )) + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close'), + + Action::make('restore') + ->label(trans('server-documentation::strings.versions.restore')) + ->icon('tabler-restore') + ->color('warning') + ->requiresConfirmation() + ->modalHeading(trans('server-documentation::strings.versions.restore')) + ->modalDescription(trans('server-documentation::strings.versions.restore_confirm')) + ->action(function (DocumentVersion $record): void { + $this->getRecord()->restoreVersion($record); + + Notification::make() + ->title(trans('server-documentation::strings.versions.restored')) + ->success() + ->send(); + }), + ]) + ->emptyStateHeading(trans('server-documentation::strings.messages.no_versions')) + ->emptyStateIcon('tabler-history-off'); + } +} diff --git a/server-documentation/src/Filament/Admin/Resources/DocumentResource/RelationManagers/ServersRelationManager.php b/server-documentation/src/Filament/Admin/Resources/DocumentResource/RelationManagers/ServersRelationManager.php new file mode 100644 index 0000000..f9c009c --- /dev/null +++ b/server-documentation/src/Filament/Admin/Resources/DocumentResource/RelationManagers/ServersRelationManager.php @@ -0,0 +1,69 @@ +recordTitleAttribute('name') + ->reorderable('pivot.sort_order') + ->columns([ + TextColumn::make('name') + ->searchable() + ->sortable(), + + TextColumn::make('node.name') + ->label(trans('server-documentation::strings.server.node')) + ->sortable(), + + TextColumn::make('user.username') + ->label(trans('server-documentation::strings.server.owner')), + + TextColumn::make('pivot.sort_order') + ->label(trans('server-documentation::strings.document.sort_order')) + ->sortable(), + ]) + ->headerActions([ + AttachAction::make() + ->preloadRecordSelect() + ->recordSelectSearchColumns(['name', 'uuid', 'uuid_short']) + ->form(fn (AttachAction $action): array => [ + $action->getRecordSelect(), + TextInput::make('sort_order') + ->numeric() + ->default(0) + ->helperText(trans('server-documentation::strings.relation_managers.sort_order_helper')), + ]), + ]) + ->recordActions([ + DetachAction::make(), + ]) + ->groupedBulkActions([ + DetachBulkAction::make(), + ]) + ->emptyStateHeading(trans('server-documentation::strings.relation_managers.no_servers_linked')) + ->emptyStateDescription(trans('server-documentation::strings.relation_managers.attach_servers_description')) + ->emptyStateIcon('tabler-server-off'); + } +} diff --git a/server-documentation/src/Filament/Concerns/HasDocumentTableColumns.php b/server-documentation/src/Filament/Concerns/HasDocumentTableColumns.php new file mode 100644 index 0000000..19bb5ad --- /dev/null +++ b/server-documentation/src/Filament/Concerns/HasDocumentTableColumns.php @@ -0,0 +1,81 @@ +label(trans('server-documentation::strings.document.title')) + ->searchable() + ->sortable() + ->description(fn (Document $record) => Str::limit(strip_tags($record->content), $descriptionLimit)); + } + + protected static function getDocumentTypeColumn(): TextColumn + { + return TextColumn::make('type') + ->label(trans('server-documentation::strings.document.type')) + ->badge() + ->formatStateUsing(fn (string $state): string => DocumentType::formatLabel($state)) + ->color(fn (string $state): string => DocumentType::formatColor($state)); + } + + protected static function getDocumentGlobalColumn(): IconColumn + { + return IconColumn::make('is_global') + ->boolean() + ->label(trans('server-documentation::strings.document.is_global')) + ->trueIcon('tabler-world') + ->falseIcon('tabler-world-off'); + } + + protected static function getDocumentPublishedColumn(): IconColumn + { + return IconColumn::make('is_published') + ->boolean() + ->label(trans('server-documentation::strings.document.is_published')); + } + + protected static function getDocumentUpdatedAtColumn(): TextColumn + { + return TextColumn::make('updated_at') + ->label(trans('server-documentation::strings.table.updated_at')) + ->dateTime() + ->sortable() + ->toggleable(); + } + + protected static function getDocumentTypeFilter(): SelectFilter + { + return SelectFilter::make('type') + ->label(trans('server-documentation::strings.document.type')) + ->options(DocumentType::simpleOptions()); + } + + protected static function getDocumentGlobalFilter(): TernaryFilter + { + return TernaryFilter::make('is_global') + ->label(trans('server-documentation::strings.document.is_global')); + } + + protected static function getDocumentPublishedFilter(): TernaryFilter + { + return TernaryFilter::make('is_published') + ->label(trans('server-documentation::strings.document.is_published')); + } +} diff --git a/server-documentation/src/Filament/Server/Pages/Documents.php b/server-documentation/src/Filament/Server/Pages/Documents.php new file mode 100644 index 0000000..87e08dc --- /dev/null +++ b/server-documentation/src/Filament/Server/Pages/Documents.php @@ -0,0 +1,84 @@ +isNotEmpty(); + } + + public function mount(): void + { + $documents = $this->getDocuments(); + + if ($documents->isNotEmpty() && !$this->selectedDocument) { + $this->selectedDocument = $documents->first(); + } + } + + public function selectDocument(int $documentId): void + { + $document = $this->getDocuments()->firstWhere('id', $documentId); + + if ($document) { + /** @var Server $server */ + $server = Filament::getTenant(); + $user = user(); + + if ($user && $user->cannot('viewOnServer', [$document, $server])) { + $document = null; + } + } + + $this->selectedDocument = $document; + } + + public function getDocuments(): Collection + { + /** @var Server $server */ + $server = Filament::getTenant(); + + return static::getDocumentsForServer($server); + } + + protected static function getDocumentsForServer(Server $server): Collection + { + return app(DocumentService::class)->getDocumentsForServer($server, user()); + } +} diff --git a/server-documentation/src/Models/Document.php b/server-documentation/src/Models/Document.php new file mode 100644 index 0000000..a80d78e --- /dev/null +++ b/server-documentation/src/Models/Document.php @@ -0,0 +1,343 @@ + $servers + * @property-read \Illuminate\Database\Eloquent\Collection $versions + * + * @method static Builder|Document forServer(Server $server) + * @method static Builder|Document forTypes(array $types) + * @method static Builder|Document published() + * @method static Builder|Document global() + * @method static Builder|Document search(string $term) + * @method static Builder|Document visibleTo(?User $user, Server $server) + */ +class Document extends Model +{ + /** @use HasFactory */ + use HasFactory; + + use SoftDeletes; + + /** + * Resource name for API/permission references. + */ + public const RESOURCE_NAME = 'document'; + + /** + * Temporary storage for original values before update (for versioning). + * + * @var array{title?: string, content?: string, dirty_fields?: array} + */ + protected array $originalValuesForVersion = []; + + protected $table = 'documents'; + + protected $fillable = [ + 'uuid', + 'title', + 'slug', + 'content', + 'type', + 'is_global', + 'is_published', + 'author_id', + 'last_edited_by', + 'sort_order', + ]; + + /** + * Validation rules for the model. + * + * @var array> + */ + public static array $validationRules = [ + 'title' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'alpha_dash'], + 'content' => ['required', 'string'], + 'type' => ['required', 'string', 'in:host_admin,server_admin,server_mod,player'], + 'is_global' => ['boolean'], + 'is_published' => ['boolean'], + 'sort_order' => ['integer'], + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'is_global' => 'boolean', + 'is_published' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + protected static function newFactory(): DocumentFactory + { + return DocumentFactory::new(); + } + + protected static function booted(): void + { + static::creating(function (Document $document) { + $document->uuid ??= Str::uuid()->toString(); + if (empty($document->slug)) { + $document->slug = static::generateUniqueSlug($document->title); + } + if ($document->author_id === null && auth()->check()) { + $document->author_id = auth()->id(); + } + }); + + static::updating(function (Document $document) { + if ($document->isDirty(['title', 'content'])) { + $document->originalValuesForVersion = [ + 'title' => $document->getOriginal('title'), + 'content' => $document->getOriginal('content'), + 'dirty_fields' => array_keys($document->getDirty()), + ]; + + if (auth()->check()) { + $document->last_edited_by = auth()->id(); + } + } + }); + + static::updated(function (Document $document) { + if (!empty($document->originalValuesForVersion)) { + $changeSummary = app(DocumentService::class)->generateChangeSummary( + $document->originalValuesForVersion['dirty_fields'] ?? [], + $document->originalValuesForVersion['content'] ?? '', + $document->content + ); + + app(DocumentService::class)->createVersionFromOriginal( + $document, + $document->originalValuesForVersion['title'], + $document->originalValuesForVersion['content'], + $changeSummary + ); + + $document->originalValuesForVersion = []; + } + }); + + static::saved(function (Document $document) { + app(DocumentService::class)->clearDocumentCache($document); + app(DocumentService::class)->clearCountCache(); + + if (config('server-documentation.auto_prune_versions', false)) { + app(DocumentService::class)->pruneVersions($document); + } + }); + + static::deleted(function (Document $document) { + app(DocumentService::class)->clearCountCache(); + }); + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'author_id'); + } + + public function lastEditor(): BelongsTo + { + return $this->belongsTo(User::class, 'last_edited_by'); + } + + public function servers(): BelongsToMany + { + return $this->belongsToMany(Server::class, 'document_server') + ->withPivot('sort_order') + ->withTimestamps() + ->orderByPivot('sort_order'); + } + + public function versions(): HasMany + { + return $this->hasMany(DocumentVersion::class) + ->orderByDesc('version_number'); + } + + public function createVersion(?string $changeSummary = null): DocumentVersion + { + return app(DocumentService::class)->createVersion($this, $changeSummary); + } + + public function restoreVersion(DocumentVersion $version): void + { + app(DocumentService::class)->restoreVersion($this, $version); + } + + public function getCurrentVersionNumber(): int + { + return $this->versions()->max('version_number') ?? 1; + } + + public function scopeHostAdmin(Builder $query): Builder + { + return $query->where('type', DocumentType::HostAdmin->value); + } + + public function scopeServerAdmin(Builder $query): Builder + { + return $query->whereIn('type', [DocumentType::ServerAdmin->value, DocumentType::LEGACY_ADMIN]); + } + + public function scopeServerMod(Builder $query): Builder + { + return $query->where('type', DocumentType::ServerMod->value); + } + + public function scopePlayer(Builder $query): Builder + { + return $query->where('type', DocumentType::Player->value); + } + + public function scopeGlobal(Builder $query): Builder + { + return $query->where('is_global', true); + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('is_published', true); + } + + public function scopeForServer(Builder $query, Server $server): Builder + { + return $query->where(function (Builder $q) use ($server) { + $q->whereHas('servers', fn (Builder $sub) => $sub->where('servers.id', $server->id)) + ->orWhere('is_global', true); + }); + } + + /** @phpstan-param array $types */ + public function scopeForTypes(Builder $query, array $types): Builder + { + return $query->whereIn('type', $types); + } + + /** + * Search documents by title, slug, or content. + */ + public function scopeSearch(Builder $query, string $term): Builder + { + $term = trim($term); + if (empty($term)) { + return $query; + } + + return $query->where(function (Builder $q) use ($term) { + $q->where('title', 'like', "%{$term}%") + ->orWhere('slug', 'like', "%{$term}%") + ->orWhere('content', 'like', "%{$term}%"); + }); + } + + /** + * Scope to documents visible to a specific user on a server. + */ + public function scopeVisibleTo(Builder $query, ?User $user, Server $server): Builder + { + $allowedTypes = app(DocumentService::class)->getAllowedTypesForUser($user, $server); + + /** @phpstan-ignore-next-line Scope methods on Document model */ + return $query + ->forServer($server) + ->published() + ->forTypes($allowedTypes); + } + + public function getDocumentType(): ?DocumentType + { + return DocumentType::tryFromLegacy($this->type); + } + + public function isHostAdminOnly(): bool + { + return $this->getDocumentType() === DocumentType::HostAdmin; + } + + public function isServerAdminOnly(): bool + { + return $this->getDocumentType() === DocumentType::ServerAdmin; + } + + public function isServerModOnly(): bool + { + return $this->getDocumentType() === DocumentType::ServerMod; + } + + public function isPlayerVisible(): bool + { + return $this->getDocumentType() === DocumentType::Player; + } + + /** + * Get the minimum tier required to view this document. + */ + public function getRequiredTier(): int + { + return $this->getDocumentType()?->hierarchyLevel() ?? DocumentType::Player->hierarchyLevel(); + } + + /** + * Check if a user with the given tier can view this document. + */ + public function isVisibleToTier(int $tier): bool + { + return $tier >= $this->getRequiredTier(); + } + + /** + * Generate a unique slug from a title. + */ + protected static function generateUniqueSlug(string $title): string + { + $slug = Str::slug($title); + $originalSlug = $slug; + $counter = 1; + + while (static::withTrashed()->where('slug', $slug)->exists()) { + $slug = $originalSlug . '-' . $counter++; + } + + return $slug; + } +} diff --git a/server-documentation/src/Models/DocumentVersion.php b/server-documentation/src/Models/DocumentVersion.php new file mode 100644 index 0000000..febbbfe --- /dev/null +++ b/server-documentation/src/Models/DocumentVersion.php @@ -0,0 +1,72 @@ + */ + use HasFactory; + + protected static function newFactory(): DocumentVersionFactory + { + return DocumentVersionFactory::new(); + } + + protected $table = 'document_versions'; + + protected $fillable = [ + 'document_id', + 'title', + 'content', + 'version_number', + 'edited_by', + 'change_summary', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'version_number' => 'integer', + ]; + } + + public function document(): BelongsTo + { + return $this->belongsTo(Document::class); + } + + public function editor(): BelongsTo + { + return $this->belongsTo(User::class, 'edited_by'); + } + + public function getFormattedVersionAttribute(): string + { + return 'v' . $this->version_number; + } +} diff --git a/server-documentation/src/Policies/DocumentPolicy.php b/server-documentation/src/Policies/DocumentPolicy.php new file mode 100644 index 0000000..57665f0 --- /dev/null +++ b/server-documentation/src/Policies/DocumentPolicy.php @@ -0,0 +1,101 @@ +can('viewList document'); + } + + /** + * Admin panel: Can user view a specific document? + */ + public function view(User $user, Document $document): bool + { + return $user->can('view document'); + } + + /** + * Admin panel: Can user create documents? + */ + public function create(User $user): bool + { + return $user->can('create document'); + } + + /** + * Admin panel: Can user update documents? + */ + public function update(User $user, Document $document): bool + { + return $user->can('update document'); + } + + /** + * Admin panel: Can user delete documents? + */ + public function delete(User $user, Document $document): bool + { + return $user->can('delete document'); + } + + /** + * Admin panel: Can user restore soft-deleted documents? + */ + public function restore(User $user, Document $document): bool + { + return $user->can('delete document'); + } + + /** + * Admin panel: Can user permanently delete documents? + */ + public function forceDelete(User $user, Document $document): bool + { + return $user->can('delete document'); + } + + /** + * Server panel: Can user view this document on a specific server? + * Implements 4-tier permission hierarchy: + * - host_admin: Root Admin only + * - server_admin: Server owner OR admin with update/create server permission + * - server_mod: Subusers with control permissions + * - player: Anyone with server access + */ + public function viewOnServer(User $user, Document $document, Server $server): bool + { + if ($user->isRootAdmin()) { + if (!$document->is_global && !$document->servers()->where('servers.id', $server->id)->exists()) { + return false; + } + + return true; + } + + if (!$document->is_published) { + return false; + } + + if (!$document->is_global && !$document->servers()->where('servers.id', $server->id)->exists()) { + return false; + } + + $allowedTypes = app(DocumentService::class)->getAllowedTypesForUser($user, $server); + + return in_array($document->type, $allowedTypes); + } +} diff --git a/server-documentation/src/Providers/ServerDocumentationServiceProvider.php b/server-documentation/src/Providers/ServerDocumentationServiceProvider.php new file mode 100644 index 0000000..80613a5 --- /dev/null +++ b/server-documentation/src/Providers/ServerDocumentationServiceProvider.php @@ -0,0 +1,111 @@ +mergeConfigFrom( + plugin_path('server-documentation', 'config/server-documentation.php'), + 'server-documentation' + ); + + $this->app->singleton(DocumentService::class, function ($app) { + return new DocumentService(); + }); + + $this->app->singleton(MarkdownConverter::class, function ($app) { + return new MarkdownConverter(); + }); + } + + public function boot(): void + { + Gate::policy(Document::class, DocumentPolicy::class); + + $this->registerDocumentPermissions(); + + $this->loadMigrationsFrom( + plugin_path('server-documentation', 'database/migrations') + ); + + $this->loadViewsFrom( + plugin_path('server-documentation', 'resources/views'), + 'server-documentation' + ); + + $this->loadTranslationsFrom( + plugin_path('server-documentation', 'lang'), + 'server-documentation' + ); + + $this->publishes([ + plugin_path('server-documentation', 'config/server-documentation.php') => config_path('server-documentation.php'), + ], 'server-documentation-config'); + + $this->publishes([ + plugin_path('server-documentation', 'resources/css') => public_path('plugins/server-documentation/css'), + ], 'server-documentation-assets'); + + Server::resolveRelationUsing('documents', function (Server $server) { + return $server->belongsToMany( + Document::class, + 'document_server', + 'server_id', + 'document_id' + )->withPivot('sort_order')->withTimestamps()->orderByPivot('sort_order'); + }); + + ServerResource::registerCustomRelations(DocumentsRelationManager::class); + } + + /** + * Register document-related Gates for admin panel permissions. + * + * These gates control who can manage documents in the admin panel. + * Access is granted to: + * - Root Admins (full access) + * - Server Admins (users with server update/create permissions) + * + * Set config('server-documentation.explicit_permissions', true) to require + * explicit document permissions instead of inheriting from server permissions. + */ + protected function registerDocumentPermissions(): void + { + $permissions = [ + 'viewList document', + 'view document', + 'create document', + 'update document', + 'delete document', + ]; + + foreach ($permissions as $permission) { + Gate::define($permission, function (User $user) { + if ($user->isRootAdmin()) { + return true; + } + + if (config('server-documentation.explicit_permissions', false)) { + return false; + } + + return $user->can('update server') || $user->can('create server'); + }); + } + } +} diff --git a/server-documentation/src/ServerDocumentationPlugin.php b/server-documentation/src/ServerDocumentationPlugin.php new file mode 100644 index 0000000..21d4a77 --- /dev/null +++ b/server-documentation/src/ServerDocumentationPlugin.php @@ -0,0 +1,40 @@ +getId() === 'admin') { + $panel->resources([ + DocumentResource::class, + ]); + } + + if ($panel->getId() === 'server') { + $panel->pages([ + Documents::class, + ]); + } + } + + public function boot(Panel $panel): void {} +} diff --git a/server-documentation/src/Services/DocumentService.php b/server-documentation/src/Services/DocumentService.php new file mode 100644 index 0000000..48baad8 --- /dev/null +++ b/server-documentation/src/Services/DocumentService.php @@ -0,0 +1,417 @@ +cacheTtl = config('server-documentation.cache_ttl', self::DEFAULT_CACHE_TTL_SECONDS); + } + + /** + * Get documents visible to a user for a specific server. + */ + public function getDocumentsForServer(Server $server, ?User $user = null): Collection + { + $allowedTypes = $this->getAllowedTypesForUser($user, $server); + $cacheKey = $this->getServerDocumentsCacheKey($server, $allowedTypes); + + if ($this->cacheTtl > 0 && $this->cacheSupportsTagging()) { + return Cache::tags([self::CACHE_TAG_SERVER_DOCUMENTS, "server-{$server->id}"]) + ->remember($cacheKey, $this->cacheTtl, fn () => $this->queryDocumentsForServer($server, $allowedTypes)); + } + + if ($this->cacheTtl > 0) { + return Cache::remember($cacheKey, $this->cacheTtl, fn () => $this->queryDocumentsForServer($server, $allowedTypes)); + } + + return $this->queryDocumentsForServer($server, $allowedTypes); + } + + /** + * Query documents for a server without caching. + * + * @phpstan-param array $allowedTypes + */ + protected function queryDocumentsForServer(Server $server, array $allowedTypes): Collection + { + /** @phpstan-ignore-next-line Relationship added by plugin */ + $attachedDocs = $server->documents() + ->where('is_published', true) + ->whereIn('type', $allowedTypes) + ->orderByPivot('sort_order') + ->get(); + + $globalDocs = Document::query() + ->where('is_global', true) + ->where('is_published', true) + ->whereIn('type', $allowedTypes) + ->orderBy('sort_order') + ->get(); + + $attachedIds = $attachedDocs->pluck('id')->toArray(); + $globalDocs = $globalDocs->filter(fn ($doc) => !in_array($doc->id, $attachedIds)); + + return $attachedDocs->concat($globalDocs); + } + + /** + * Get the document types a user can view on a specific server. + * + * @return array + */ + public function getAllowedTypesForUser(?User $user, Server $server): array + { + if ($user === null) { + return [DocumentType::Player->value]; + } + + $level = $this->getUserHierarchyLevel($user, $server); + + return DocumentType::typesVisibleToLevel($level); + } + + /** + * Get the hierarchy level for a user on a specific server. + */ + public function getUserHierarchyLevel(User $user, Server $server): int + { + if ($user->isRootAdmin()) { + return DocumentType::HostAdmin->hierarchyLevel(); + } + + if ($this->isServerAdmin($user, $server)) { + return DocumentType::ServerAdmin->hierarchyLevel(); + } + + if ($this->isServerMod($user, $server)) { + return DocumentType::ServerMod->hierarchyLevel(); + } + + return DocumentType::Player->hierarchyLevel(); + } + + /** + * Check if user is a Server Admin (owner or has server management permissions). + */ + public function isServerAdmin(User $user, Server $server): bool + { + return $server->owner_id === $user->id || + $user->can('update server', $server) || + $user->can('create server', $server); + } + + /** + * Check if user is a Server Mod (has control permissions). + */ + public function isServerMod(User $user, Server $server): bool + { + return $user->can(\App\Enums\SubuserPermission::ControlConsole, $server) || + $user->can(\App\Enums\SubuserPermission::ControlStart, $server) || + $user->can(\App\Enums\SubuserPermission::ControlStop, $server) || + $user->can(\App\Enums\SubuserPermission::ControlRestart, $server); + } + + /** + * Generate a change summary for version history. + * + * @phpstan-param array $dirtyFields + */ + public function generateChangeSummary(array $dirtyFields, string $oldContent, string $newContent): string + { + $parts = []; + + if (in_array('title', $dirtyFields)) { + $parts[] = 'title'; + } + + if (in_array('content', $dirtyFields)) { + $oldLen = strlen(strip_tags($oldContent)); + $newLen = strlen(strip_tags($newContent)); + $diff = $newLen - $oldLen; + + $parts[] = match (true) { + $diff > 0 => "content (+{$diff} chars)", + $diff < 0 => "content ({$diff} chars)", + default => 'content (reformatted)', + }; + } + + return 'Updated ' . implode(', ', $parts); + } + + /** + * Create a version from pre-stored original values (called from model 'updated' event). + * Includes rate limiting to prevent spam. + */ + public function createVersionFromOriginal( + Document $document, + ?string $originalTitle, + ?string $originalContent, + ?string $changeSummary = null, + ?int $userId = null + ): DocumentVersion { + /** @var DocumentVersion */ + return DB::transaction(function () use ($document, $originalTitle, $originalContent, $changeSummary, $userId): DocumentVersion { + /** @var DocumentVersion|null $latestVersion */ + $latestVersion = $document->versions() + ->lockForUpdate() + ->latest() + ->first(); + + $latestVersionNumber = $latestVersion !== null ? $latestVersion->version_number : 0; + + if ($latestVersion !== null && $latestVersion->created_at->diffInSeconds(now()) < self::VERSION_DEBOUNCE_SECONDS) { + $latestVersion->update([ + 'title' => $originalTitle ?? $document->title, + 'content' => $originalContent ?? $document->content, + 'change_summary' => $changeSummary, + 'edited_by' => $userId ?? auth()->id(), + ]); + + $this->logAudit('version_updated', $document, [ + 'version_number' => $latestVersion->version_number, + 'reason' => 'rate_limited', + ]); + + return $latestVersion; + } + + /** @var DocumentVersion $version */ + $version = $document->versions()->create([ + 'title' => $originalTitle ?? $document->title, + 'content' => $originalContent ?? $document->content, + 'version_number' => $latestVersionNumber + 1, + 'edited_by' => $userId ?? auth()->id(), + 'change_summary' => $changeSummary, + ]); + + $this->logAudit('version_created', $document, [ + 'version_number' => $version->version_number, + ]); + + return $version; + }); + } + + /** + * Create a new version of a document within a transaction. + * + * @deprecated Use createVersionFromOriginal for model events + */ + public function createVersion(Document $document, ?string $changeSummary = null, ?int $userId = null): DocumentVersion + { + /** @var DocumentVersion */ + return DB::transaction(function () use ($document, $changeSummary, $userId): DocumentVersion { + $latestVersion = $document->versions() + ->lockForUpdate() + ->max('version_number') ?? 0; + + /** @var DocumentVersion */ + return $document->versions()->create([ + 'title' => $document->getOriginal('title') ?? $document->title, + 'content' => $document->getOriginal('content') ?? $document->content, + 'version_number' => $latestVersion + 1, + 'edited_by' => $userId ?? auth()->id(), + 'change_summary' => $changeSummary, + ]); + }); + } + + /** + * Restore a document to a previous version within a transaction. + */ + public function restoreVersion(Document $document, DocumentVersion $version, ?int $userId = null): void + { + $this->logAudit('version_restore_started', $document, [ + 'restoring_version' => $version->version_number, + 'current_title' => $document->title, + ]); + + DB::transaction(function () use ($document, $version, $userId) { + $document->updateQuietly([ + 'title' => $version->title, + 'content' => $version->content, + 'last_edited_by' => $userId ?? auth()->id(), + ]); + + $this->createVersionFromOriginal( + $document, + $document->title, + $document->content, + 'Restored from version ' . $version->version_number, + $userId + ); + }); + + $this->logAudit('version_restored', $document, [ + 'restored_version' => $version->version_number, + ]); + + $this->clearDocumentCache($document); + } + + /** + * Clear cache for a specific document. + */ + public function clearDocumentCache(Document $document): void + { + /** @var Server $server */ + foreach ($document->servers as $server) { + $this->clearServerDocumentsCache($server); + } + + if ($document->is_global && $this->cacheSupportsTagging()) { + Cache::tags([self::CACHE_TAG_SERVER_DOCUMENTS])->flush(); + } + } + + /** + * Clear document cache for a specific server. + */ + public function clearServerDocumentsCache(Server $server): void + { + if ($this->cacheSupportsTagging()) { + Cache::tags(["server-{$server->id}"])->flush(); + + return; + } + + foreach (DocumentType::cases() as $type) { + $allowedTypes = DocumentType::typesVisibleToLevel($type->hierarchyLevel()); + $cacheKey = $this->getServerDocumentsCacheKey($server, $allowedTypes); + Cache::forget($cacheKey); + } + } + + /** + * Check if the cache driver supports tagging. + */ + protected function cacheSupportsTagging(): bool + { + $driver = config('cache.default'); + + return in_array($driver, ['redis', 'memcached', 'dynamodb', 'array']); + } + + /** + * Generate cache key for server documents. + * + * @phpstan-param array $allowedTypes + */ + protected function getServerDocumentsCacheKey(Server $server, array $allowedTypes): string + { + sort($allowedTypes); + $typesHash = hash('xxh3', implode(',', $allowedTypes)); + + return "server-docs.{$server->id}.{$typesHash}"; + } + + /** + * Get document count (cached for navigation badge). + */ + public function getDocumentCount(): int + { + $cacheTtl = config('server-documentation.badge_cache_ttl', self::DEFAULT_BADGE_CACHE_TTL_SECONDS); + + if ($cacheTtl > 0) { + return Cache::remember('server-docs.count', $cacheTtl, fn () => Document::count()); + } + + return Document::count(); + } + + /** + * Clear the document count cache. + */ + public function clearCountCache(): void + { + Cache::forget('server-docs.count'); + } + + /** + * Prune old versions keeping only the specified number of recent versions. + */ + public function pruneVersions(Document $document, ?int $keepCount = null): int + { + $keepCount ??= config('server-documentation.versions_to_keep', 50); + + if ($keepCount <= 0) { + return 0; + } + + $versionsToKeep = $document->versions() + ->orderByDesc('version_number') + ->limit($keepCount) + ->pluck('id'); + + $deleted = $document->versions() + ->whereNotIn('id', $versionsToKeep) + ->delete(); + + if ($deleted > 0) { + $this->logAudit('versions_pruned', $document, [ + 'deleted_count' => $deleted, + 'kept_count' => $keepCount, + ]); + } + + return $deleted; + } + + /** + * Log an audit event for document operations. + * + * @phpstan-param array $context + */ + protected function logAudit(string $action, Document $document, array $context = []): void + { + $context = array_merge([ + 'document_id' => $document->id, + 'document_title' => $document->title, + 'user_id' => auth()->id(), + 'user' => auth()->user()?->username, + ], $context); + + Log::channel(config('server-documentation.audit_log_channel', 'single')) + ->info("Document {$action}", $context); + } +} diff --git a/server-documentation/src/Services/MarkdownConverter.php b/server-documentation/src/Services/MarkdownConverter.php new file mode 100644 index 0000000..ffd515b --- /dev/null +++ b/server-documentation/src/Services/MarkdownConverter.php @@ -0,0 +1,347 @@ + $htmlInput, + 'allow_unsafe_links' => false, + ]); + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new GithubFlavoredMarkdownExtension()); + + $this->markdownToHtml = new LeagueMarkdownConverter($environment); + } + + /** + * Convert HTML content to Markdown using built-in converter. + * Handles common HTML elements without external dependencies. + */ + public function toMarkdown(string $html): string + { + $html = $this->cleanHtml($html); + $markdown = $this->htmlToMarkdown($html); + $markdown = $this->cleanMarkdown($markdown); + + return $markdown; + } + + /** + * Convert Markdown content to HTML. + */ + public function toHtml(string $markdown, bool $sanitize = true): string + { + $html = $this->markdownToHtml->convert($markdown)->getContent(); + + if ($sanitize) { + $html = $this->sanitizeHtml($html); + } + + return $html; + } + + /** + * Sanitize HTML content to prevent XSS using Laravel's built-in sanitizer. + */ + public function sanitizeHtml(string $html): string + { + return (string) str($html)->sanitizeHtml(); + } + + /** + * Built-in HTML to Markdown converter. + * Handles common elements used in documentation. + */ + protected function htmlToMarkdown(string $html): string + { + $codeBlocks = []; + $html = preg_replace_callback('/]*>]*>(.*?)<\/code><\/pre>/is', function ($matches) use (&$codeBlocks) { + $placeholder = '{{CODE_BLOCK_' . count($codeBlocks) . '}}'; + $codeBlocks[$placeholder] = "```\n" . html_entity_decode(strip_tags($matches[1])) . "\n```"; + + return $placeholder; + }, $html) ?? $html; + + $html = preg_replace('/]*>(.*?)<\/code>/is', '`$1`', $html) ?? $html; + $html = preg_replace('/]*>(.*?)<\/h1>/is', "\n# $1\n", $html) ?? $html; + $html = preg_replace('/]*>(.*?)<\/h2>/is', "\n## $1\n", $html) ?? $html; + $html = preg_replace('/]*>(.*?)<\/h3>/is', "\n### $1\n", $html) ?? $html; + $html = preg_replace('/]*>(.*?)<\/h4>/is', "\n#### $1\n", $html) ?? $html; + $html = preg_replace('/]*>(.*?)<\/h5>/is', "\n##### $1\n", $html) ?? $html; + $html = preg_replace('/]*>(.*?)<\/h6>/is', "\n###### $1\n", $html) ?? $html; + $html = preg_replace('/]*>(.*?)<\/strong>/is', '**$1**', $html) ?? $html; + $html = preg_replace('/]*>(.*?)<\/b>/is', '**$1**', $html) ?? $html; + $html = preg_replace('/]*>(.*?)<\/em>/is', '*$1*', $html) ?? $html; + $html = preg_replace('/]*>(.*?)<\/i>/is', '*$1*', $html) ?? $html; + $html = preg_replace('/]*>(.*?)<\/del>/is', '~~$1~~', $html) ?? $html; + $html = preg_replace('/]*>(.*?)<\/s>/is', '~~$1~~', $html) ?? $html; + + $html = preg_replace_callback('/]+href=["\']([^"\']+)["\'][^>]*>(.*?)<\/a>/is', function ($matches) { + $url = $matches[1]; + $text = strip_tags($matches[2]); + + return "[$text]($url)"; + }, $html) ?? $html; + + $html = preg_replace_callback('/]+src=["\']([^"\']+)["\'][^>]*alt=["\']([^"\']*)["\'][^>]*\/?>/is', function ($matches) { + return "![{$matches[2]}]({$matches[1]})"; + }, $html) ?? $html; + $html = preg_replace_callback('/]+src=["\']([^"\']+)["\'][^>]*\/?>/is', function ($matches) { + return "![]({$matches[1]})"; + }, $html) ?? $html; + + $html = preg_replace_callback('/]*>(.*?)<\/blockquote>/is', function ($matches) { + $content = strip_tags($matches[1]); + $lines = explode("\n", trim($content)); + + return "\n" . implode("\n", array_map(fn ($line) => '> ' . trim($line), $lines)) . "\n"; + }, $html) ?? $html; + + $html = preg_replace_callback('/]*>(.*?)<\/ul>/is', function ($matches) { + return $this->convertList($matches[1], '-'); + }, $html) ?? $html; + + $html = preg_replace_callback('/]*>(.*?)<\/ol>/is', function ($matches) { + return $this->convertList($matches[1], '1.'); + }, $html) ?? $html; + + $html = preg_replace('/]*\/?>/is', "\n---\n", $html) ?? $html; + $html = preg_replace('/]*>(.*?)<\/p>/is', "\n$1\n", $html) ?? $html; + $html = preg_replace('/]*\/?>/is', " \n", $html) ?? $html; + + $html = preg_replace_callback('/]*>(.*?)<\/table>/is', function ($matches) { + return $this->convertTable($matches[1]); + }, $html) ?? $html; + + $html = strip_tags($html); + + foreach ($codeBlocks as $placeholder => $code) { + $html = str_replace($placeholder, $code, $html); + } + + $html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return $html; + } + + /** + * Convert HTML list to Markdown. + */ + protected function convertList(string $html, string $marker): string + { + $result = "\n"; + preg_match_all('/]*>(.*?)<\/li>/is', $html, $matches); + + $index = 1; + foreach ($matches[1] as $item) { + $item = trim(strip_tags($item)); + if ($marker === '1.') { + $result .= "{$index}. {$item}\n"; + $index++; + } else { + $result .= "{$marker} {$item}\n"; + } + } + + return $result; + } + + /** + * Convert HTML table to Markdown. + */ + protected function convertTable(string $html): string + { + $result = "\n"; + $headers = []; + $rows = []; + + if (preg_match('/]*>(.*?)<\/thead>/is', $html, $theadMatch)) { + preg_match_all('/]*>(.*?)<\/th>/is', $theadMatch[1], $thMatches); + $headers = array_map(fn ($h) => trim(strip_tags($h)), $thMatches[1]); + } + + if (preg_match('/]*>(.*?)<\/tbody>/is', $html, $tbodyMatch)) { + preg_match_all('/]*>(.*?)<\/tr>/is', $tbodyMatch[1], $trMatches); + foreach ($trMatches[1] as $tr) { + preg_match_all('/]*>(.*?)<\/td>/is', $tr, $tdMatches); + $rows[] = array_map(fn ($d) => trim(strip_tags($d)), $tdMatches[1]); + } + } else { + preg_match_all('/]*>(.*?)<\/tr>/is', $html, $trMatches); + $first = true; + foreach ($trMatches[1] as $tr) { + if ($first && empty($headers)) { + preg_match_all('/]*>(.*?)<\/t[hd]>/is', $tr, $cellMatches); + $headers = array_map(fn ($h) => trim(strip_tags($h)), $cellMatches[1]); + $first = false; + } else { + preg_match_all('/]*>(.*?)<\/td>/is', $tr, $tdMatches); + $rows[] = array_map(fn ($d) => trim(strip_tags($d)), $tdMatches[1]); + } + } + } + + if (empty($headers)) { + return $result; + } + + $result .= '| ' . implode(' | ', $headers) . " |\n"; + $result .= '| ' . implode(' | ', array_fill(0, count($headers), '---')) . " |\n"; + + foreach ($rows as $row) { + while (count($row) < count($headers)) { + $row[] = ''; + } + $result .= '| ' . implode(' | ', $row) . " |\n"; + } + + return $result; + } + + /** + * Clean HTML before markdown conversion. + */ + protected function cleanHtml(string $html): string + { + $html = preg_replace('/]*>.*?<\/style>/is', '', $html) ?? $html; + $html = preg_replace('/]*>.*?<\/script>/is', '', $html) ?? $html; + $html = preg_replace('//s', '', $html) ?? $html; + $html = preg_replace('/\s+/', ' ', $html) ?? $html; + + return trim($html); + } + + /** + * Clean up markdown output. + */ + protected function cleanMarkdown(string $markdown): string + { + $markdown = preg_replace('/\n{3,}/', "\n\n", $markdown) ?? $markdown; + $lines = explode("\n", $markdown); + $lines = array_map('rtrim', $lines); + $markdown = implode("\n", $lines); + + return trim($markdown); + } + + /** + * Generate a safe filename for a document. + */ + public function generateFilename(string $title, string $slug): string + { + $filename = !empty($slug) ? $slug : $this->sanitizeFilename($title); + + return $filename . '.md'; + } + + /** + * Sanitize a string for use as a filename. + */ + public function sanitizeFilename(string $name): string + { + $name = strtolower(trim($name)); + $name = preg_replace('/\s+/', '-', $name) ?? $name; + $name = preg_replace('/[^a-z0-9\-_]/', '', $name) ?? $name; + $name = preg_replace('/-+/', '-', $name) ?? $name; + + return $name ?: 'document'; + } + + /** + * Add YAML frontmatter to markdown content. + * + * @phpstan-param array $metadata + */ + public function addFrontmatter(string $markdown, array $metadata): string + { + $frontmatter = "---\n"; + foreach ($metadata as $key => $value) { + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } elseif (is_array($value)) { + $value = implode(', ', $value); + } elseif (is_string($value) && $this->needsYamlQuoting($value)) { + $escaped = addcslashes($value, '"\\'); + $escaped = str_replace(["\r\n", "\r", "\n", "\t"], ['\\n', '\\n', '\\n', '\\t'], $escaped); + $value = '"' . $escaped . '"'; + } + $frontmatter .= "{$key}: {$value}\n"; + } + $frontmatter .= "---\n\n"; + + return $frontmatter . $markdown; + } + + /** + * Check if a YAML value needs quoting due to special characters. + */ + protected function needsYamlQuoting(string $value): bool + { + return (bool) preg_match('/[:#\[\]{}|>&*!?,\'"\n\r\t]|^[@`]/', $value); + } + + /** + * Parse YAML frontmatter from markdown content. + * Returns [metadata, content] tuple. + * + * @return array{0: array, 1: string} + */ + public function parseFrontmatter(string $markdown): array + { + $pattern = '/^---\s*\n(.*?)\n---\s*\n(.*)$/s'; + + if (preg_match($pattern, $markdown, $matches)) { + $metadata = []; + $lines = explode("\n", trim($matches[1])); + + foreach ($lines as $line) { + if (str_contains($line, ':')) { + [$key, $value] = explode(':', $line, 2); + $key = trim($key); + $value = trim($value); + + if ($value === 'true') { + $value = true; + } elseif ($value === 'false') { + $value = false; + } else { + $value = $this->unquoteYamlValue($value); + } + + $metadata[$key] = $value; + } + } + + return [$metadata, trim($matches[2])]; + } + + return [[], $markdown]; + } + + /** + * Unquote a YAML value that was quoted by addFrontmatter. + */ + protected function unquoteYamlValue(string $value): string + { + if (strlen($value) >= 2 && $value[0] === '"' && $value[strlen($value) - 1] === '"') { + $value = substr($value, 1, -1); + $value = str_replace(['\\n', '\\t', '\\"', '\\\\'], ["\n", "\t", '"', '\\'], $value); + } + + return $value; + } +}