First commit

This commit is contained in:
Kevin Adametz 2025-10-20 17:50:35 +02:00
commit 7cf3558ba7
12933 changed files with 1180047 additions and 0 deletions

View file

@ -0,0 +1,56 @@
{
"name": "flux-cms/core",
"description": "Flux CMS Core Package - Multi-domain, component-first CMS for Laravel",
"type": "library",
"keywords": [
"laravel",
"cms",
"livewire",
"multi-domain",
"components"
],
"license": "MIT",
"authors": [
{
"name": "Flux CMS Contributors",
"email": "contributors@flux-cms.com"
}
],
"require": {
"php": "^8.2",
"laravel/framework": "^11.0|^12.0",
"spatie/laravel-translatable": "^6.0",
"spatie/laravel-medialibrary": "^11.0",
"spatie/laravel-tags": "^4.0"
},
"require-dev": {
"orchestra/testbench": "^9.0",
"pestphp/pest": "^3.8",
"pestphp/pest-plugin-laravel": "^3.2"
},
"autoload": {
"psr-4": {
"FluxCms\\Core\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"FluxCms\\Core\\Tests\\": "tests/"
}
},
"extra": {
"laravel": {
"providers": [
"FluxCms\\Core\\FluxCmsServiceProvider"
]
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"config": {
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true
}
}
}

10274
packages/flux-cms/core/composer.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,305 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Flux CMS Configuration
|--------------------------------------------------------------------------
|
| This file contains the configuration options for Flux CMS.
|
*/
/*
|--------------------------------------------------------------------------
| Default Locale
|--------------------------------------------------------------------------
|
| The default locale for the CMS content.
|
*/
'default_locale' => env('FLUX_CMS_DEFAULT_LOCALE', 'de'),
/*
|--------------------------------------------------------------------------
| Available Locales
|--------------------------------------------------------------------------
|
| The available locales for the CMS content.
|
*/
'locales' => [
'de' => 'Deutsch',
'en' => 'English',
],
/*
|--------------------------------------------------------------------------
| Component Paths
|--------------------------------------------------------------------------
|
| The namespaces where the CMS will scan for components.
|
*/
'component_paths' => [
'App\\Livewire\\Components',
'FluxCms\\StarterComponents\\Components',
],
/*
|--------------------------------------------------------------------------
| Cache Configuration
|--------------------------------------------------------------------------
|
| Configuration for caching component registry and other CMS data.
|
*/
'cache' => [
'enabled' => env('FLUX_CMS_CACHE_ENABLED', true),
'ttl' => env('FLUX_CMS_CACHE_TTL', 3600), // 1 hour
'key_prefix' => 'flux_cms',
'store' => env('FLUX_CMS_CACHE_STORE', null), // null = default cache store
],
/*
|--------------------------------------------------------------------------
| Database Configuration
|--------------------------------------------------------------------------
|
| Database table configuration for Flux CMS.
|
*/
'database' => [
'table_prefix' => 'flux_cms_',
'connection' => env('FLUX_CMS_DB_CONNECTION', null), // null = default connection
],
/*
|--------------------------------------------------------------------------
| Authentication & Authorization
|--------------------------------------------------------------------------
|
| Configuration for user authentication and authorization.
|
*/
'auth' => [
'guard' => env('FLUX_CMS_AUTH_GUARD', 'web'),
'default_access' => env('FLUX_CMS_DEFAULT_ACCESS', false),
'super_admin_role' => 'admin',
'cms_role' => 'flux-cms',
'permissions' => [
'view' => 'flux-cms.view',
'edit' => 'flux-cms.edit',
'publish' => 'flux-cms.publish',
'delete' => 'flux-cms.delete',
'admin' => 'flux-cms.admin',
],
],
/*
|--------------------------------------------------------------------------
| Routes Configuration
|--------------------------------------------------------------------------
|
| Configuration for CMS routes.
|
*/
'routes' => [
'enabled' => env('FLUX_CMS_ROUTES_ENABLED', true),
'prefix' => env('FLUX_CMS_ROUTE_PREFIX', ''),
'middleware' => ['web'],
'admin_prefix' => env('FLUX_CMS_ADMIN_PREFIX', 'admin/cms'),
'admin_middleware' => ['web', 'auth'],
],
/*
|--------------------------------------------------------------------------
| SEO Configuration
|--------------------------------------------------------------------------
|
| SEO-related configuration options.
|
*/
'seo' => [
'site_name' => env('FLUX_CMS_SITE_NAME', config('app.name')),
'separator' => env('FLUX_CMS_SEO_SEPARATOR', ' - '),
'meta_keywords_limit' => 10,
'meta_description_limit' => 160,
'auto_sitemap' => true,
'auto_robots' => true,
'canonical_urls' => true,
'og_image_default' => null,
],
/*
|--------------------------------------------------------------------------
| Media Configuration
|--------------------------------------------------------------------------
|
| Configuration for media handling.
|
*/
'media' => [
'disk' => env('FLUX_CMS_MEDIA_DISK', 'public'),
'max_file_size' => env('FLUX_CMS_MAX_FILE_SIZE', 10240), // 10MB in KB
'allowed_extensions' => [
'images' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
'documents' => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv'],
'videos' => ['mp4', 'webm', 'ogg', 'avi', 'mov'],
'audio' => ['mp3', 'wav', 'ogg', 'flac'],
],
'conversions' => [
'thumb' => [
'width' => 300,
'height' => 300,
'fit' => 'crop',
],
'medium' => [
'width' => 800,
'height' => 600,
'fit' => 'contain',
],
'large' => [
'width' => 1200,
'height' => 900,
'fit' => 'contain',
],
],
],
/*
|--------------------------------------------------------------------------
| Editor Configuration
|--------------------------------------------------------------------------
|
| Configuration for WYSIWYG editors.
|
*/
'editor' => [
'default' => env('FLUX_CMS_EDITOR', 'tiptap'), // tiptap, tinymce, quill
'upload_images' => true,
'max_image_size' => 2048, // 2MB in KB
'toolbar_presets' => [
'basic' => ['bold', 'italic'],
'standard' => ['bold', 'italic', 'link', 'bulletList', 'orderedList'],
'full' => [
'bold', 'italic', 'underline', 'strike',
'heading1', 'heading2', 'heading3',
'bulletList', 'orderedList',
'link', 'image', 'table',
'code', 'codeBlock',
'quote', 'rule'
],
],
],
/*
|--------------------------------------------------------------------------
| Versioning Configuration
|--------------------------------------------------------------------------
|
| Configuration for content versioning.
|
*/
'versioning' => [
'enabled' => env('FLUX_CMS_VERSIONING_ENABLED', true),
'auto_version' => env('FLUX_CMS_AUTO_VERSION', true),
'max_versions' => env('FLUX_CMS_MAX_VERSIONS', 50),
'cleanup_old_versions' => env('FLUX_CMS_CLEANUP_VERSIONS', true),
'cleanup_after_days' => env('FLUX_CMS_CLEANUP_AFTER_DAYS', 365),
],
/*
|--------------------------------------------------------------------------
| Performance Configuration
|--------------------------------------------------------------------------
|
| Performance-related configuration options.
|
*/
'performance' => [
'eager_load_relations' => ['components', 'media'],
'pagination_size' => 20,
'component_registry_cache' => true,
'query_cache_ttl' => 300, // 5 minutes
],
/*
|--------------------------------------------------------------------------
| Development Configuration
|--------------------------------------------------------------------------
|
| Configuration options for development mode.
|
*/
'development' => [
'debug_mode' => env('FLUX_CMS_DEBUG', false),
'show_component_info' => env('FLUX_CMS_SHOW_COMPONENT_INFO', false),
'log_queries' => env('FLUX_CMS_LOG_QUERIES', false),
'hot_reload_components' => env('FLUX_CMS_HOT_RELOAD', false),
],
/*
|--------------------------------------------------------------------------
| Frontend Configuration
|--------------------------------------------------------------------------
|
| Configuration for frontend rendering.
|
*/
'frontend' => [
'layout' => env('FLUX_CMS_LAYOUT', 'layouts.app'),
'theme' => env('FLUX_CMS_THEME', 'default'),
'css_framework' => env('FLUX_CMS_CSS_FRAMEWORK', 'tailwind'), // tailwind, bootstrap
'component_wrapper' => env('FLUX_CMS_COMPONENT_WRAPPER', true),
'preview_mode' => env('FLUX_CMS_PREVIEW_MODE', true),
],
/*
|--------------------------------------------------------------------------
| API Configuration
|--------------------------------------------------------------------------
|
| Configuration for CMS API endpoints.
|
*/
'api' => [
'enabled' => env('FLUX_CMS_API_ENABLED', false),
'prefix' => env('FLUX_CMS_API_PREFIX', 'api/cms'),
'middleware' => ['api', 'auth:sanctum'],
'rate_limiting' => env('FLUX_CMS_API_RATE_LIMIT', '60,1'),
],
/*
|--------------------------------------------------------------------------
| Backup Configuration
|--------------------------------------------------------------------------
|
| Configuration for content backups.
|
*/
'backup' => [
'enabled' => env('FLUX_CMS_BACKUP_ENABLED', true),
'disk' => env('FLUX_CMS_BACKUP_DISK', 'local'),
'auto_backup' => env('FLUX_CMS_AUTO_BACKUP', true),
'backup_schedule' => env('FLUX_CMS_BACKUP_SCHEDULE', 'daily'),
'keep_backups' => env('FLUX_CMS_KEEP_BACKUPS', 30),
],
/*
|--------------------------------------------------------------------------
| Domain Configuration
|--------------------------------------------------------------------------
|
| Multi-domain support configuration.
|
*/
'domains' => [
'enabled' => env('FLUX_CMS_MULTI_DOMAIN', true),
'config_source' => 'domains', // 'domains' config key or 'database'
'default_domain' => env('FLUX_CMS_DEFAULT_DOMAIN', 'default'),
'auto_detect' => env('FLUX_CMS_AUTO_DETECT_DOMAIN', true),
],
];

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_pages', function (Blueprint $table) {
$table->id();
$table->string('domain_key')->index();
$table->json('title'); // Übersetzbarer Seitentitel
$table->json('meta_description')->nullable(); // SEO Meta Description
$table->json('meta_keywords')->nullable(); // SEO Keywords
$table->json('og_image')->nullable(); // Open Graph Image (Media Library Reference)
$table->string('canonical_url')->nullable(); // SEO Canonical URL
$table->json('settings')->nullable(); // Zusätzliche Einstellungen
$table->boolean('is_published')->default(true);
$table->timestamp('published_at')->nullable();
$table->timestamps();
// Composite indexes for performance
$table->index(['domain_key', 'is_published']);
$table->index(['domain_key', 'is_published', 'published_at']);
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_pages');
}
};

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_page_components', function (Blueprint $table) {
$table->id();
$table->foreignId('page_id')->constrained('flux_cms_pages')->onDelete('cascade');
$table->string('component_class'); // Full namespace of the component class
$table->integer('order')->default(0);
$table->json('content'); // Alle übersetzten Inhalte der Komponente
$table->json('settings')->nullable(); // Komponenten-spezifische Einstellungen
$table->boolean('is_active')->default(true);
$table->timestamps();
// Indexes for performance
$table->index(['page_id', 'order']);
$table->index(['page_id', 'is_active']);
$table->index(['component_class']);
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_page_components');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_page_versions', function (Blueprint $table) {
$table->id();
$table->foreignId('page_id')->constrained('flux_cms_pages')->onDelete('cascade');
$table->json('page_data'); // Snapshot der kompletten Seite
$table->json('components_data'); // Snapshot aller Komponenten
$table->string('version_name')->nullable(); // Benutzerdefinierter Name
$table->text('change_description')->nullable(); // Was wurde geändert
$table->string('created_by_type')->nullable(); // Polymorphic relation type
$table->unsignedBigInteger('created_by_id')->nullable(); // Polymorphic relation id
$table->timestamps();
// Indexes
$table->index('page_id');
$table->index(['page_id', 'created_at']);
$table->index(['created_by_type', 'created_by_id']);
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_page_versions');
}
};

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_navigations', function (Blueprint $table) {
$table->id();
$table->string('domain_key')->index();
$table->string('name'); // z.B. 'main', 'footer', 'sidebar'
$table->json('display_name'); // Übersetzbarer Anzeigename
$table->json('settings')->nullable(); // Navigation-spezifische Einstellungen
$table->boolean('is_active')->default(true);
$table->timestamps();
// Unique constraint
$table->unique(['domain_key', 'name']);
$table->index(['domain_key', 'is_active']);
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_navigations');
}
};

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_navigation_items', function (Blueprint $table) {
$table->id();
$table->foreignId('navigation_id')->constrained('flux_cms_navigations')->onDelete('cascade');
$table->foreignId('page_id')->nullable()->constrained('flux_cms_pages')->onDelete('cascade');
$table->string('external_url')->nullable();
$table->json('label'); // Übersetzbarer Menütext
$table->integer('order')->default(0);
$table->foreignId('parent_id')->nullable()->constrained('flux_cms_navigation_items')->onDelete('cascade');
$table->json('settings')->nullable(); // Item-spezifische Einstellungen
$table->boolean('is_active')->default(true);
$table->boolean('opens_in_new_tab')->default(false);
$table->timestamps();
// Indexes
$table->index(['navigation_id', 'order']);
$table->index(['navigation_id', 'parent_id']);
$table->index(['navigation_id', 'is_active']);
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_navigation_items');
}
};

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_blog_posts', function (Blueprint $table) {
$table->id();
$table->string('domain_key')->index();
$table->json('title'); // Übersetzbarer Titel
$table->json('excerpt')->nullable(); // Übersetzbarer Kurztext
$table->json('content'); // Übersetzbarer Haupttext
$table->json('meta_description')->nullable(); // SEO
$table->json('meta_keywords')->nullable(); // SEO
$table->json('og_image')->nullable(); // Open Graph Image
$table->json('settings')->nullable(); // Post-spezifische Einstellungen
$table->boolean('is_published')->default(false);
$table->boolean('is_featured')->default(false);
$table->timestamp('published_at')->nullable();
$table->string('author_type')->nullable(); // Polymorphic relation type
$table->unsignedBigInteger('author_id')->nullable(); // Polymorphic relation id
$table->timestamps();
// Indexes
$table->index(['domain_key', 'is_published']);
$table->index(['domain_key', 'is_published', 'published_at']);
$table->index(['domain_key', 'is_featured']);
$table->index(['author_type', 'author_id']);
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_blog_posts');
}
};

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->json('name');
$table->json('slug');
$table->string('type')->nullable();
$table->integer('order_column')->nullable();
$table->timestamps();
});
Schema::create('taggables', function (Blueprint $table) {
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->morphs('taggable');
$table->unique(['tag_id', 'taggable_id', 'taggable_type']);
});
}
public function down()
{
Schema::dropIfExists('taggables');
Schema::dropIfExists('tags');
}
};

View file

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('flux_cms_slugs', function (Blueprint $table) {
$table->id();
$table->morphs('model');
$table->string('locale')->index();
$table->string('slug');
$table->timestamps();
$table->unique(['locale', 'slug']);
});
}
public function down(): void
{
Schema::dropIfExists('flux_cms_slugs');
}
};

View file

@ -0,0 +1,175 @@
<?php
namespace FluxCms\Core\Database\Seeders;
use Illuminate\Database\Seeder;
use FluxCms\Core\Models\Page;
use FluxCms\Core\Models\BlogPost;
class CmsContentSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$this->createSamplePages();
$this->createSampleBlogPosts();
}
/**
* Create sample pages
*/
protected function createSamplePages(): void
{
// Homepage
$homepage = Page::create([
'domain_key' => 'default',
'title' => [
'de' => 'Willkommen auf unserer Website',
'en' => 'Welcome to our Website'
],
'slug' => [
'de' => '/',
'en' => '/'
],
'meta_description' => [
'de' => 'Willkommen auf unserer modernen Website, erstellt mit Flux CMS.',
'en' => 'Welcome to our modern website, built with Flux CMS.'
],
'meta_keywords' => [
'de' => 'Website, CMS, Flux CMS, Laravel',
'en' => 'Website, CMS, Flux CMS, Laravel'
],
'is_published' => true,
'published_at' => now(),
]);
// About page
$aboutPage = Page::create([
'domain_key' => 'default',
'title' => [
'de' => 'Über uns',
'en' => 'About us'
],
'slug' => [
'de' => '/ueber-uns',
'en' => '/about'
],
'meta_description' => [
'de' => 'Erfahren Sie mehr über unser Unternehmen und unsere Mission.',
'en' => 'Learn more about our company and our mission.'
],
'is_published' => true,
'published_at' => now(),
]);
// Contact page
$contactPage = Page::create([
'domain_key' => 'default',
'title' => [
'de' => 'Kontakt',
'en' => 'Contact'
],
'slug' => [
'de' => '/kontakt',
'en' => '/contact'
],
'meta_description' => [
'de' => 'Kontaktieren Sie uns für weitere Informationen.',
'en' => 'Contact us for more information.'
],
'is_published' => true,
'published_at' => now(),
]);
$this->command->info('Sample pages created successfully!');
}
/**
* Create sample blog posts
*/
protected function createSampleBlogPosts(): void
{
$posts = [
[
'title' => [
'de' => 'Willkommen bei Flux CMS',
'en' => 'Welcome to Flux CMS'
],
'slug' => [
'de' => 'willkommen-bei-flux-cms',
'en' => 'welcome-to-flux-cms'
],
'excerpt' => [
'de' => 'Flux CMS ist ein modernes, komponentenbasiertes Content Management System für Laravel.',
'en' => 'Flux CMS is a modern, component-based Content Management System for Laravel.'
],
'content' => [
'de' => '<p>Flux CMS revolutioniert die Art, wie Sie Inhalte verwalten. Mit seiner einzigartigen "Code-as-Schema" Philosophie definieren Sie Inhaltsstrukturen direkt in PHP-Komponenten.</p><p>Dies bietet beispiellose Flexibilität und eine hervorragende Entwicklererfahrung.</p>',
'en' => '<p>Flux CMS revolutionizes the way you manage content. With its unique "Code-as-Schema" philosophy, you define content structures directly in PHP components.</p><p>This offers unprecedented flexibility and an excellent developer experience.</p>'
],
'category' => 'News',
'tags' => ['CMS', 'Laravel', 'Flux'],
'is_published' => true,
'is_featured' => true,
'published_at' => now()->subDays(1),
],
[
'title' => [
'de' => 'Multi-Domain Support',
'en' => 'Multi-Domain Support'
],
'slug' => [
'de' => 'multi-domain-support',
'en' => 'multi-domain-support'
],
'excerpt' => [
'de' => 'Verwalten Sie mehrere Websites von einer Installation aus.',
'en' => 'Manage multiple websites from one installation.'
],
'content' => [
'de' => '<p>Mit Flux CMS können Sie mehrere Domains von einer einzigen Installation aus verwalten. Jede Domain kann ihre eigenen Inhalte, Designs und Einstellungen haben.</p>',
'en' => '<p>With Flux CMS, you can manage multiple domains from a single installation. Each domain can have its own content, designs, and settings.</p>'
],
'category' => 'Features',
'tags' => ['Multi-Domain', 'Features'],
'is_published' => true,
'is_featured' => false,
'published_at' => now()->subDays(2),
],
[
'title' => [
'de' => 'Komponenten-First Architektur',
'en' => 'Component-First Architecture'
],
'slug' => [
'de' => 'komponenten-first-architektur',
'en' => 'component-first-architecture'
],
'excerpt' => [
'de' => 'Bauen Sie Seiten aus wiederverwendbaren Livewire-Komponenten.',
'en' => 'Build pages from reusable Livewire components.'
],
'content' => [
'de' => '<p>Die Komponenten-First Architektur von Flux CMS ermöglicht es Ihnen, komplexe Seiten aus kleinen, wiederverwendbaren Komponenten zu erstellen.</p><p>Jede Komponente kann ihre eigenen Felder und Validierungsregeln definieren.</p>',
'en' => '<p>The component-first architecture of Flux CMS allows you to create complex pages from small, reusable components.</p><p>Each component can define its own fields and validation rules.</p>'
],
'category' => 'Architecture',
'tags' => ['Components', 'Livewire', 'Architecture'],
'is_published' => true,
'is_featured' => false,
'published_at' => now()->subDays(3),
],
];
foreach ($posts as $postData) {
BlogPost::create(array_merge($postData, [
'domain_key' => 'default',
'author_id' => 1, // Assuming user ID 1 exists
]));
}
$this->command->info('Sample blog posts created successfully!');
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace FluxCms\Core\Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class CmsPermissionSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$this->createPermissions();
$this->createRoles();
}
/**
* Create CMS permissions
*/
protected function createPermissions(): void
{
$permissions = [
'flux-cms.view' => 'View CMS content',
'flux-cms.edit' => 'Edit CMS content',
'flux-cms.publish' => 'Publish CMS content',
'flux-cms.delete' => 'Delete CMS content',
'flux-cms.admin' => 'Administer CMS',
];
foreach ($permissions as $name => $description) {
Permission::firstOrCreate(
['name' => $name],
['description' => $description]
);
}
$this->command->info('CMS permissions created successfully!');
}
/**
* Create CMS roles
*/
protected function createRoles(): void
{
// Create CMS Editor role
$editorRole = Role::firstOrCreate(['name' => 'flux-cms-editor']);
$editorRole->syncPermissions([
'flux-cms.view',
'flux-cms.edit',
]);
// Create CMS Publisher role
$publisherRole = Role::firstOrCreate(['name' => 'flux-cms-publisher']);
$publisherRole->syncPermissions([
'flux-cms.view',
'flux-cms.edit',
'flux-cms.publish',
]);
// Create CMS Admin role
$adminRole = Role::firstOrCreate(['name' => 'flux-cms-admin']);
$adminRole->syncPermissions([
'flux-cms.view',
'flux-cms.edit',
'flux-cms.publish',
'flux-cms.delete',
'flux-cms.admin',
]);
// Create general CMS role (for backward compatibility)
$cmsRole = Role::firstOrCreate(['name' => 'flux-cms']);
$cmsRole->syncPermissions([
'flux-cms.view',
'flux-cms.edit',
'flux-cms.publish',
'flux-cms.delete',
'flux-cms.admin',
]);
$this->command->info('CMS roles created successfully!');
$this->command->info('Available roles: flux-cms-editor, flux-cms-publisher, flux-cms-admin, flux-cms');
}
}

View file

@ -0,0 +1,8 @@
@extends('flux-cms::admin.layouts.app')
@section('content')
<div class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">Edit Blog Post</h1>
@livewire('flux-cms::blog-editor', ['post' => $post])
</div>
@endsection

View file

@ -0,0 +1,185 @@
@extends('flux-cms::admin.layouts.app')
@section('title', 'CMS Dashboard')
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">CMS Dashboard</h1>
<p class="mt-2 text-gray-600">Übersicht über Ihre Website-Inhalte</p>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Alle Seiten</dt>
<dd class="text-lg font-medium text-gray-900">{{ $stats['pages'] }}</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Veröffentlichte Seiten</dt>
<dd class="text-lg font-medium text-gray-900">{{ $stats['published_pages'] }}</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Entwürfe</dt>
<dd class="text-lg font-medium text-gray-900">{{ $stats['draft_pages'] }}</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Blog Posts</dt>
<dd class="text-lg font-medium text-gray-900">{{ $stats['blog_posts'] }}</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Veröffentlichte Posts</dt>
<dd class="text-lg font-medium text-gray-900">{{ $stats['published_posts'] }}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="bg-white shadow rounded-lg mb-8">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Schnellaktionen</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<a href="{{ route('admin.cms.pages.create') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Neue Seite erstellen
</a>
<a href="{{ route('admin.cms.blog') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
Blog verwalten
</a>
<a href="{{ route('admin.cms.components') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Komponenten
</a>
</div>
</div>
</div>
<!-- Recent Pages -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Zuletzt bearbeitete Seiten</h3>
@if($recentPages->count() > 0)
<div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Aktualisiert</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach($recentPages as $page)
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ $page->getTranslation('title', app()->getLocale()) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $page->domain_key }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if($page->is_published)
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
Veröffentlicht
</span>
@else
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
Entwurf
</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $page->updated_at->format('d.m.Y H:i') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<a href="{{ route('admin.cms.pages.edit', $page) }}" class="text-blue-600 hover:text-blue-900">Bearbeiten</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<p class="text-gray-500">Noch keine Seiten vorhanden.</p>
@endif
</div>
</div>
</div>
@endsection

View file

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'Flux CMS') - {{ config('app.name', 'Laravel') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Styles -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
<!-- Tailwind CSS (optional, remove if Tailwind already built via Vite) -->
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="font-sans antialiased bg-gray-100">
<div class="min-h-screen">
<!-- Navigation -->
<nav class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Logo -->
<div class="flex-shrink-0 flex items-center">
<a href="{{ route('admin.cms.index') }}" class="text-xl font-bold text-gray-900">
Flux CMS
</a>
</div>
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
<a href="{{ route('admin.cms.index') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm {{ request()->routeIs('admin.cms.index') ? 'border-blue-500 text-gray-900' : '' }}">
Dashboard
</a>
<a href="{{ route('admin.cms.pages') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm {{ request()->routeIs('admin.cms.pages*') ? 'border-blue-500 text-gray-900' : '' }}">
Seiten
</a>
<a href="{{ route('admin.cms.blog') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm {{ request()->routeIs('admin.cms.blog*') ? 'border-blue-500 text-gray-900' : '' }}">
Blog
</a>
<a href="{{ route('admin.cms.media') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm {{ request()->routeIs('admin.cms.media*') ? 'border-blue-500 text-gray-900' : '' }}">
Medien
</a>
<a href="{{ route('admin.cms.components') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm {{ request()->routeIs('admin.cms.components*') ? 'border-blue-500 text-gray-900' : '' }}">
Komponenten
</a>
</div>
</div>
<!-- Right side -->
<div class="hidden sm:ml-6 sm:flex sm:items-center">
<!-- User dropdown -->
<div class="ml-3 relative" x-data="{ open: false }">
<div>
<button @click="open = !open" class="bg-white flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<span class="sr-only">Open user menu</span>
<div class="h-8 w-8 rounded-full bg-gray-300 flex items-center justify-center">
<span class="text-sm font-medium text-gray-700">
{{ substr(auth()->user()->name ?? 'U', 0, 1) }}
</span>
</div>
</button>
</div>
<div x-show="open" @click.away="open = false" class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
<a href="{{ route('admin.cms.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Dashboard</a>
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Einstellungen</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
Abmelden
</button>
</form>
</div>
</div>
</div>
<!-- Mobile menu button -->
<div class="-mr-2 flex items-center sm:hidden">
<button @click="mobileMenuOpen = !mobileMenuOpen" class="bg-white inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500">
<span class="sr-only">Open main menu</span>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile menu -->
<div x-show="mobileMenuOpen" class="sm:hidden" x-data="{ mobileMenuOpen: false }">
<div class="pt-2 pb-3 space-y-1">
<a href="{{ route('admin.cms.index') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('admin.cms.index') ? 'border-blue-500 text-gray-900' : '' }}">
Dashboard
</a>
<a href="{{ route('admin.cms.pages') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('admin.cms.pages*') ? 'border-blue-500 text-gray-900' : '' }}">
Seiten
</a>
<a href="{{ route('admin.cms.blog') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('admin.cms.blog*') ? 'border-blue-500 text-gray-900' : '' }}">
Blog
</a>
<a href="{{ route('admin.cms.media') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('admin.cms.media*') ? 'border-blue-500 text-gray-900' : '' }}">
Medien
</a>
<a href="{{ route('admin.cms.components') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 block pl-3 pr-4 py-2 border-l-4 text-base font-medium {{ request()->routeIs('admin.cms.components*') ? 'border-blue-500 text-gray-900' : '' }}">
Komponenten
</a>
</div>
</div>
</nav>
<!-- Page Content -->
<main>
@yield('content')
</main>
</div>
<!-- Flash Messages -->
@if(session('success'))
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 5000)" class="fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50">
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 5000)" class="fixed top-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50">
{{ session('error') }}
</div>
@endif
<!-- Scripts -->
@stack('scripts')
</body>
</html>

View file

@ -0,0 +1,34 @@
<div class="flux-cms-field {{ $field->getCssClasses($hasError) }}">
<label for="{{ $fieldId }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ $field->getLabel() }}
@if($field->isRequired())
<span class="text-red-500">*</span>
@endif
@if($field->isTranslatable())
<span class="text-xs text-gray-500">({{ $locale }})</span>
@endif
</label>
<input
type="{{ $field->getInputType() }}"
id="{{ $fieldId }}"
name="{{ $field->getKey() }}"
wire:model="{{ $wireModel }}"
value="{{ $value }}"
@if($field->isRequired()) required @endif
@if($field->getMaxLength() > 0) maxlength="{{ $field->getMaxLength() }}" @endif
@if($field->getMinLength() > 0) minlength="{{ $field->getMinLength() }}" @endif
@if($field->getPattern()) pattern="{{ $field->getPattern() }}" @endif
@if($field->getPlaceholder()) placeholder="{{ $field->getPlaceholder() }}" @endif
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm @if($hasError) border-red-300 @endif"
{!! $field->getAttributes() ? ' ' . implode(' ', array_map(fn($k, $v) => "{$k}=\"{$v}\"", array_keys($field->getAttributes()), $field->getAttributes())) : '' !!}
>
@if($field->getHelpText())
<p class="mt-1 text-sm text-gray-500">{{ $field->getHelpText() }}</p>
@endif
@if($hasError)
<p class="mt-1 text-sm text-red-600">{{ $error }}</p>
@endif
</div>

View file

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $page->getSeoTitle() }}</title>
<meta name="description" content="{{ $page->getSeoDescription() }}">
<meta name="keywords" content="{{ $page->getTranslation('meta_keywords', app()->getLocale()) }}">
<!-- Open Graph -->
<meta property="og:title" content="{{ $page->getTranslation('title', app()->getLocale()) }}">
<meta property="og:description" content="{{ $page->getSeoDescription() }}">
<meta property="og:url" content="{{ request()->url() }}">
<meta property="og:type" content="website">
@if($page->getTranslation('og_image', app()->getLocale()))
<meta property="og:image" content="{{ $page->getTranslation('og_image', app()->getLocale()) }}">
@endif
<!-- Canonical URL -->
<link rel="canonical" href="{{ $page->getCanonicalUrl() }}">
<!-- Styles -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
<!-- Tailwind CSS (optional, remove if Tailwind already built via Vite) -->
<script src="https://cdn.tailwindcss.com"></script>
@stack('head')
</head>
<body class="font-sans antialiased">
<main class="cms-page">
@foreach($components as $component)
@if($component->canRender())
<div class="cms-component" data-component="{{ class_basename($component->component_class) }}">
@livewire($component->component_class, [
'content' => $component->getTranslations('content')
], key('component-' . $component->id))
</div>
@endif
@endforeach
</main>
<!-- Scripts -->
@stack('scripts')
</body>
</html>

View file

@ -0,0 +1,14 @@
User-agent: *
Allow: /
@if(config('flux-cms.seo.auto_sitemap', true))
Sitemap: {{ $baseUrl }}/sitemap.xml
@endif
# Disallow admin areas
Disallow: /admin/
Disallow: /preview/
# Disallow CMS specific paths
Disallow: /flux-cms/
Disallow: /vendor/

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@foreach($pages as $page)
@foreach(config('flux-cms.locales', ['de']) as $locale => $localeName)
<url>
<loc>{{ $page->getUrl($locale) }}</loc>
<lastmod>{{ $page->updated_at->format('Y-m-d') }}</lastmod>
<changefreq>weekly</changefreq>
<priority>{{ $page->getTranslation('slug', $locale) === '/' ? '1.0' : '0.8' }}</priority>
</url>
@endforeach
@endforeach
@foreach($blogPosts as $post)
@foreach(config('flux-cms.locales', ['de']) as $locale => $localeName)
<url>
<loc>{{ $post->getUrl($locale) }}</loc>
<lastmod>{{ $post->updated_at->format('Y-m-d') }}</lastmod>
<changefreq>monthly</changefreq>
<priority>{{ $post->is_featured ? '0.9' : '0.7' }}</priority>
</url>
@endforeach
@endforeach
</urlset>

View file

@ -0,0 +1,61 @@
<?php
use Illuminate\Support\Facades\Route;
use FluxCms\Core\Http\Controllers\Admin\DashboardController;
use FluxCms\Core\Http\Controllers\Admin\PageController as AdminPageController;
use FluxCms\Core\Http\Controllers\Admin\BlogController;
use FluxCms\Core\Http\Controllers\Admin\MediaController;
use FluxCms\Core\Http\Controllers\Admin\NavigationController;
use FluxCms\Core\Http\Controllers\Admin\ComponentController;
use FluxCms\Core\Http\Controllers\PageController;
/*
|--------------------------------------------------------------------------
| Flux CMS Admin Routes
|--------------------------------------------------------------------------
|
| These routes are for the CMS admin interface. They are protected by
| authentication and authorization middleware.
|
*/
Route::middleware(['web', 'auth', 'flux-cms:cms-access'])
->prefix(config('flux-cms.routes.admin_prefix', 'admin/cms'))
->name('admin.cms.')
->group(function () {
// Dashboard
Route::get('/', DashboardController::class)->name('index');
// Pages Management
Route::resource('pages', AdminPageController::class)->except(['show']);
// Blog Management
Route::get('blog', [BlogController::class, 'index'])->name('blog.index');
Route::get('blog/{blogPost}/edit', [BlogController::class, 'edit'])->name('blog.edit');
// Media Management
Route::get('media', MediaController::class)->name('media.index');
// Navigation Management
Route::get('navigation', NavigationController::class)->name('navigation.index');
// Component Library
Route::get('components', ComponentController::class)->name('components.index');
});
/*
|--------------------------------------------------------------------------
| Flux CMS Preview Routes
|--------------------------------------------------------------------------
|
| These routes allow authenticated users to preview unpublished content.
|
*/
Route::middleware(['web', 'auth', 'flux-cms:cms-access'])
->prefix('preview')
->name('cms.preview.')
->group(function () {
Route::get('/pages/{page}', [PageController::class, 'preview'])->name('page');
});

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Support\Facades\Route;
use FluxCms\Core\Http\Controllers\PageController;
/*
|--------------------------------------------------------------------------
| Flux CMS Frontend Routes
|--------------------------------------------------------------------------
|
| These routes handle the frontend display of CMS content. They should be
| placed at the END of your routes/web.php file to avoid conflicts.
|
*/
// SEO Routes
Route::get('/sitemap.xml', [PageController::class, 'sitemap'])->name('sitemap');
Route::get('/robots.txt', [PageController::class, 'robots'])->name('robots');
// Blog Routes (if using blog functionality)
Route::prefix('blog')->name('blog.')->group(function () {
Route::get('/', [PageController::class, 'blogIndex'])->name('index');
Route::get('/{slug}', [PageController::class, 'blogPost'])->name('show');
});
// CMS Pages - MUST BE LAST!
// This catch-all route should be placed at the very end of your routes file
Route::get('/{slug?}', [PageController::class, 'show'])
->where('slug', '.*')
->name('cms.page');

View file

@ -0,0 +1,20 @@
<?php
namespace FluxCms\Core\Commands;
use Illuminate\Console\Command;
use FluxCms\Core\Services\ComponentRegistry;
class ClearCacheCommand extends Command
{
protected $signature = 'flux-cms:clear-cache';
protected $description = 'Clear the Flux CMS component registry cache';
public function handle(ComponentRegistry $registry): int
{
$this->info('Clearing Flux CMS component cache...');
$registry->clearCache();
$this->info('Flux CMS component cache cleared successfully!');
return self::SUCCESS;
}
}

View file

@ -0,0 +1,185 @@
<?php
namespace FluxCms\Core\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class InstallCommand extends Command
{
protected $signature = 'flux-cms:install
{--force : Overwrite existing files}
{--no-migrate : Skip running migrations}
{--no-publish : Skip publishing assets}';
protected $description = 'Install Flux CMS';
public function handle(): int
{
$this->info('🚀 Installing Flux CMS...');
// Check requirements
if (!$this->checkRequirements()) {
return 1;
}
// Publish configuration
if (!$this->option('no-publish')) {
$this->publishAssets();
}
// Run migrations
if (!$this->option('no-migrate')) {
$this->runMigrations();
}
// Create storage link if needed
$this->createStorageLink();
// Create sample content
$this->createSampleContent();
// Set permissions
$this->setPermissions();
$this->info('✅ Flux CMS installed successfully!');
$this->showNextSteps();
return 0;
}
protected function checkRequirements(): bool
{
$this->info('🔍 Checking requirements...');
$requirements = [
'PHP 8.2+' => version_compare(PHP_VERSION, '8.2.0', '>='),
'Laravel 11+' => $this->checkLaravelVersion(),
'Livewire 3+' => $this->checkLivewireVersion(),
'Spatie Translatable' => class_exists(\Spatie\Translatable\HasTranslations::class),
'Spatie Media Library' => class_exists(\Spatie\MediaLibrary\HasMedia::class),
];
$allPassed = true;
foreach ($requirements as $requirement => $passed) {
if ($passed) {
$this->line("{$requirement}");
} else {
$this->error("{$requirement}");
$allPassed = false;
}
}
if (!$allPassed) {
$this->error('❌ Some requirements are not met. Please install missing dependencies.');
$this->line('Run: composer require spatie/laravel-translatable spatie/laravel-medialibrary');
}
return $allPassed;
}
protected function checkLaravelVersion(): bool
{
return version_compare(app()->version(), '11.0.0', '>=');
}
protected function checkLivewireVersion(): bool
{
if (!class_exists(\Livewire\Livewire::class)) {
return false;
}
$version = \Livewire\Livewire::VERSION ?? '2.0.0';
return version_compare($version, '3.0.0', '>=');
}
protected function publishAssets(): void
{
$this->info('📦 Publishing configuration and assets...');
// Publish config
$this->call('vendor:publish', [
'--tag' => 'flux-cms-config',
'--force' => $this->option('force'),
]);
// Publish migrations
$this->call('vendor:publish', [
'--tag' => 'flux-cms-migrations',
'--force' => $this->option('force'),
]);
// Publish views
$this->call('vendor:publish', [
'--tag' => 'flux-cms-views',
'--force' => $this->option('force'),
]);
$this->line('✅ Assets published');
}
protected function runMigrations(): void
{
$this->info('🗃️ Running migrations...');
$this->call('migrate');
$this->line('✅ Migrations completed');
}
protected function createStorageLink(): void
{
if (!File::exists(public_path('storage'))) {
$this->info('🔗 Creating storage link...');
$this->call('storage:link');
$this->line('✅ Storage link created');
}
}
protected function createSampleContent(): void
{
if ($this->confirm('Would you like to create sample content?', true)) {
$this->info('📝 Creating sample content...');
// Run CMS content seeder
$this->call('db:seed', ['--class' => 'FluxCms\\Core\\Database\\Seeders\\CmsContentSeeder']);
$this->line('✅ Sample content created');
}
}
protected function setPermissions(): void
{
if ($this->confirm('Would you like to create CMS permissions?', true)) {
$this->info('🔐 Setting up permissions...');
// Check if Spatie Permission is available
if (class_exists(\Spatie\Permission\Models\Permission::class)) {
$this->createCmsPermissions();
$this->line('✅ Permissions created');
} else {
$this->warn('⚠️ Spatie Permission package not found. Skipping permission setup.');
$this->line('Install with: composer require spatie/laravel-permission');
}
}
}
protected function createCmsPermissions(): void
{
// Run CMS permission seeder
$this->call('db:seed', ['--class' => 'FluxCms\\Core\\Database\\Seeders\\CmsPermissionSeeder']);
}
protected function showNextSteps(): void
{
$this->newLine();
$this->info('🎉 Next steps:');
$this->line('1. Assign the "flux-cms" role to users who should access the CMS');
$this->line('2. Create your first page: php artisan flux-cms:make:page');
$this->line('3. Create custom components: php artisan flux-cms:make:component');
$this->line('4. Visit /admin/cms to start editing');
$this->newLine();
$this->info('📚 Documentation: https://flux-cms.com/docs');
$this->info('💬 Support: https://github.com/flux-cms/flux-cms/discussions');
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace FluxCms\Core\Commands;
use Illuminate\Console\GeneratorCommand;
use Symfony\Component\Console\Input\InputArgument;
class MakeComponentCommand extends GeneratorCommand
{
protected $name = 'flux-cms:make-component';
protected $description = 'Create a new Flux CMS component';
protected $type = 'Flux CMS Component';
protected function getStub()
{
return __DIR__ . '/stubs/component.stub';
}
protected function getDefaultNamespace($rootNamespace)
{
return $rootNamespace . '\Livewire\Web\Components';
}
protected function buildClass($name)
{
$stub = parent::buildClass($name);
return str_replace('{{ componentName }}', $this->argument('name'), $stub);
}
protected function getArguments()
{
return [
['name', InputArgument::REQUIRED, 'The name of the component'],
];
}
public function handle()
{
parent::handle();
$this->createView();
}
protected function createView()
{
$name = $this->argument('name');
$viewPath = resource_path('views/livewire/web/components/' . strtolower($name) . '.blade.php');
if (! is_dir(dirname($viewPath))) {
mkdir(dirname($viewPath), 0777, true);
}
if (file_exists($viewPath)) {
$this->error('View already exists!');
return;
}
$stub = $this->files->get(__DIR__ . '/stubs/view.stub');
$this->files->put($viewPath, $stub);
$this->info('View created successfully.');
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace FluxCms\Core\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class PublishCommand extends Command
{
protected $signature = 'flux-cms:publish
{--force : Overwrite existing files}
{--tag= : Specific tag to publish}';
protected $description = 'Publish Flux CMS assets and configuration';
public function handle(): int
{
$this->info('📦 Publishing Flux CMS assets...');
$tag = $this->option('tag');
$force = $this->option('force');
if ($tag) {
$this->publishTag($tag, $force);
} else {
$this->publishAll($force);
}
$this->info('✅ Flux CMS assets published successfully!');
return 0;
}
protected function publishAll(bool $force): void
{
$this->info('Publishing all Flux CMS assets...');
// Publish config
$this->call('vendor:publish', [
'--tag' => 'flux-cms-config',
'--force' => $force,
]);
// Publish migrations
$this->call('vendor:publish', [
'--tag' => 'flux-cms-migrations',
'--force' => $force,
]);
// Publish views
$this->call('vendor:publish', [
'--tag' => 'flux-cms-views',
'--force' => $force,
]);
// Publish assets
$this->call('vendor:publish', [
'--tag' => 'flux-cms-assets',
'--force' => $force,
]);
// Publish translations
$this->call('vendor:publish', [
'--tag' => 'flux-cms-translations',
'--force' => $force,
]);
}
protected function publishTag(string $tag, bool $force): void
{
$this->info("Publishing tag: {$tag}");
$this->call('vendor:publish', [
'--tag' => $tag,
'--force' => $force,
]);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace {{ namespace }};
use Livewire\Component;
use FluxCms\Core\FieldTypes\TextField;
class {{ class }} extends Component
{
public array $content = [];
public function mount(array $content = [])
{
$this->content = $content;
}
public static function getCmsName(): string
{
return '{{ componentName }}';
}
public static function getCmsFields(): array
{
return [
TextField::make('headline', 'Headline')->translatable(),
];
}
public function render()
{
return view('livewire.web.components.{{ componentName }}');
}
}

View file

@ -0,0 +1,4 @@
<div>
{{-- Remember that you should never use @section('content') in a component. --}}
<h1>{{ $content['headline'] ?? 'Headline' }}</h1>
</div>

View file

@ -0,0 +1,327 @@
<?php
namespace FluxCms\Core\FieldTypes;
abstract class BaseField
{
protected string $key;
protected string $label;
protected bool $translatable = false;
protected bool $required = false;
protected mixed $default = null;
protected array $rules = [];
protected array $attributes = [];
protected ?string $helpText = null;
protected ?string $placeholder = null;
public function __construct(string $key, string $label)
{
$this->key = $key;
$this->label = $label;
}
public static function make(string $key, string $label): static
{
return new static($key, $label);
}
/**
* Configuration Methods
*/
public function translatable(bool $translatable = true): static
{
$this->translatable = $translatable;
return $this;
}
public function required(bool $required = true): static
{
$this->required = $required;
return $this;
}
public function default(mixed $default): static
{
$this->default = $default;
return $this;
}
public function rules(array|string $rules): static
{
$this->rules = is_array($rules) ? $rules : [$rules];
return $this;
}
public function helpText(string $helpText): static
{
$this->helpText = $helpText;
return $this;
}
public function placeholder(string $placeholder): static
{
$this->placeholder = $placeholder;
return $this;
}
public function attributes(array $attributes): static
{
$this->attributes = array_merge($this->attributes, $attributes);
return $this;
}
public function attribute(string $key, mixed $value): static
{
$this->attributes[$key] = $value;
return $this;
}
/**
* Getters
*/
public function getKey(): string
{
return $this->key;
}
public function getLabel(): string
{
return $this->label;
}
public function isTranslatable(): bool
{
return $this->translatable;
}
public function isRequired(): bool
{
return $this->required;
}
public function getDefault(): mixed
{
return $this->default;
}
public function getRules(): array
{
$rules = $this->rules;
if ($this->required) {
$rules[] = 'required';
}
return $rules;
}
public function getHelpText(): ?string
{
return $this->helpText;
}
public function getPlaceholder(): ?string
{
return $this->placeholder;
}
public function getAttributes(): array
{
return $this->attributes;
}
public function getAttribute(string $key, mixed $default = null): mixed
{
return $this->attributes[$key] ?? $default;
}
/**
* Abstract Methods
*/
abstract public function getType(): string;
abstract public function getValidationRules(): array;
abstract public function toArray(): array;
/**
* Value Handling
*/
public function getValue(array $content, string $locale = null): mixed
{
if ($this->translatable && $locale) {
return $content[$this->key][$locale] ?? $this->default;
}
return $content[$this->key] ?? $this->default;
}
public function setValue(array &$content, mixed $value, string $locale = null): void
{
if ($this->translatable && $locale) {
$content[$this->key][$locale] = $value;
} else {
$content[$this->key] = $value;
}
}
/**
* Validation
*/
public function validate(mixed $value, string $locale = null): array
{
$rules = $this->getValidationRules();
if (empty($rules)) {
return [];
}
// Für übersetzbare Felder Locale zu Regeln hinzufügen
$fieldKey = $this->key;
if ($locale && $this->translatable) {
$fieldKey .= '.' . $locale;
}
try {
$validator = validator(
[$fieldKey => $value],
[$fieldKey => $rules],
$this->getValidationMessages(),
$this->getValidationAttributes()
);
return $validator->fails() ? $validator->errors()->get($fieldKey) : [];
} catch (\Exception $e) {
return ['Validation error: ' . $e->getMessage()];
}
}
/**
* Validation Messages
*/
protected function getValidationMessages(): array
{
return [
'required' => 'The :attribute field is required.',
'string' => 'The :attribute field must be a string.',
'integer' => 'The :attribute field must be an integer.',
'numeric' => 'The :attribute field must be a number.',
'boolean' => 'The :attribute field must be true or false.',
'array' => 'The :attribute field must be an array.',
'email' => 'The :attribute field must be a valid email address.',
'url' => 'The :attribute field must be a valid URL.',
'max' => 'The :attribute field must not be greater than :max.',
'min' => 'The :attribute field must be at least :min.',
];
}
/**
* Validation Attributes
*/
protected function getValidationAttributes(): array
{
return [
$this->key => $this->label,
];
}
/**
* Rendering
*/
public function render(array $content = [], string $locale = null): string
{
$value = $this->getValue($content, $locale);
$viewData = [
'field' => $this,
'value' => $value,
'locale' => $locale,
'content' => $content,
'wireModel' => $this->getWireModel($locale),
'fieldId' => $this->getFieldId($locale),
'hasError' => false, // Will be set by parent component
];
$viewName = "flux-cms::fields.{$this->getType()}";
if (!view()->exists($viewName)) {
$viewName = "flux-cms::fields.fallback";
}
return view($viewName, $viewData)->render();
}
/**
* Wire Model für Livewire
*/
public function getWireModel(string $locale = null): string
{
if ($this->translatable && $locale) {
return "content.{$this->key}.{$locale}";
}
return "content.{$this->key}";
}
/**
* Field ID für Labels
*/
public function getFieldId(string $locale = null): string
{
$id = 'field_' . $this->key;
if ($this->translatable && $locale) {
$id .= '_' . $locale;
}
return $id;
}
/**
* CSS Classes
*/
public function getCssClasses(bool $hasError = false): string
{
$classes = ['flux-cms-field', 'flux-cms-field--' . $this->getType()];
if ($this->required) {
$classes[] = 'flux-cms-field--required';
}
if ($hasError) {
$classes[] = 'flux-cms-field--error';
}
if ($this->translatable) {
$classes[] = 'flux-cms-field--translatable';
}
return implode(' ', $classes);
}
/**
* Sanitize Input
*/
public function sanitizeValue(mixed $value): mixed
{
if (is_string($value)) {
return trim($value);
}
return $value;
}
/**
* Transform für Frontend-Ausgabe
*/
public function transformForDisplay(mixed $value): mixed
{
return $value;
}
/**
* Transform für Backend-Bearbeitung
*/
public function transformForEdit(mixed $value): mixed
{
return $value;
}
}

View file

@ -0,0 +1,115 @@
<?php
namespace FluxCms\Core\FieldTypes;
class BooleanField extends BaseField
{
protected string $trueLabel = 'Yes';
protected string $falseLabel = 'No';
protected string $displayType = 'checkbox'; // checkbox, toggle, radio
public function labels(string $trueLabel, string $falseLabel): static
{
$this->trueLabel = $trueLabel;
$this->falseLabel = $falseLabel;
return $this;
}
public function displayType(string $type): static
{
$this->displayType = $type;
return $this;
}
public function toggle(): static
{
$this->displayType = 'toggle';
return $this;
}
public function radio(): static
{
$this->displayType = 'radio';
return $this;
}
public function checkbox(): static
{
$this->displayType = 'checkbox';
return $this;
}
public function getType(): string
{
return 'boolean';
}
public function getTrueLabel(): string
{
return $this->trueLabel;
}
public function getFalseLabel(): string
{
return $this->falseLabel;
}
public function getDisplayType(): string
{
return $this->displayType;
}
public function getValidationRules(): array
{
$rules = ['boolean'];
if ($this->required) {
$rules[] = 'required';
}
return array_merge($rules, $this->rules);
}
public function sanitizeValue(mixed $value): mixed
{
if ($value === null || $value === '') {
return false;
}
// Convert various truthy values to boolean
if (is_string($value)) {
$value = strtolower($value);
return in_array($value, ['1', 'true', 'yes', 'on'], true);
}
return (bool) $value;
}
public function getValue(array $content, string $locale = null): mixed
{
$value = parent::getValue($content, $locale);
return $this->sanitizeValue($value);
}
public function transformForDisplay(mixed $value): mixed
{
return $this->sanitizeValue($value) ? $this->trueLabel : $this->falseLabel;
}
public function toArray(): array
{
return [
'type' => $this->getType(),
'key' => $this->key,
'label' => $this->label,
'translatable' => $this->translatable,
'required' => $this->required,
'default' => $this->default,
'trueLabel' => $this->trueLabel,
'falseLabel' => $this->falseLabel,
'displayType' => $this->displayType,
'helpText' => $this->helpText,
'attributes' => $this->attributes,
];
}
}

View file

@ -0,0 +1,233 @@
<?php
namespace FluxCms\Core\FieldTypes;
class MediaField extends BaseField
{
protected array $acceptedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
protected bool $multiple = false;
protected int $maxFiles = 1;
protected ?string $collection = null;
protected array $conversions = [];
protected int $maxFileSize = 10240; // 10MB in KB
protected bool $showPreview = true;
public function acceptedMimeTypes(array $mimeTypes): static
{
$this->acceptedMimeTypes = $mimeTypes;
return $this;
}
public function multiple(bool $multiple = true, int $maxFiles = 10): static
{
$this->multiple = $multiple;
$this->maxFiles = $maxFiles;
return $this;
}
public function collection(string $collection): static
{
$this->collection = $collection;
return $this;
}
public function conversions(array $conversions): static
{
$this->conversions = $conversions;
return $this;
}
public function maxFileSize(int $sizeInKb): static
{
$this->maxFileSize = $sizeInKb;
return $this;
}
public function showPreview(bool $show = true): static
{
$this->showPreview = $show;
return $this;
}
public function images(): static
{
$this->acceptedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'];
$this->collection = 'images';
return $this;
}
public function documents(): static
{
$this->acceptedMimeTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'text/csv'
];
$this->collection = 'documents';
$this->showPreview = false;
return $this;
}
public function videos(): static
{
$this->acceptedMimeTypes = ['video/mp4', 'video/webm', 'video/ogg'];
$this->collection = 'videos';
return $this;
}
public function audio(): static
{
$this->acceptedMimeTypes = ['audio/mpeg', 'audio/wav', 'audio/ogg'];
$this->collection = 'audio';
$this->showPreview = false;
return $this;
}
public function getType(): string
{
return 'media';
}
public function getAcceptedMimeTypes(): array
{
return $this->acceptedMimeTypes;
}
public function isMultiple(): bool
{
return $this->multiple;
}
public function getMaxFiles(): int
{
return $this->maxFiles;
}
public function getCollection(): ?string
{
return $this->collection;
}
public function getConversions(): array
{
return $this->conversions;
}
public function getMaxFileSize(): int
{
return $this->maxFileSize;
}
public function shouldShowPreview(): bool
{
return $this->showPreview;
}
public function getValidationRules(): array
{
$rules = [];
if ($this->required) {
$rules[] = 'required';
}
if ($this->multiple) {
$rules[] = 'array';
$rules[] = "max:{$this->maxFiles}";
// Each item should be integer (media ID)
$rules[] = 'array';
foreach (range(0, $this->maxFiles - 1) as $index) {
$rules["*.{$index}"] = 'integer|exists:media,id';
}
} else {
$rules[] = 'integer';
$rules[] = 'exists:media,id';
}
return array_merge($rules, $this->rules);
}
public function getAcceptAttribute(): string
{
return implode(',', $this->acceptedMimeTypes);
}
public function isImageType(): bool
{
return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'image/')));
}
public function isVideoType(): bool
{
return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'video/')));
}
public function isAudioType(): bool
{
return !empty(array_filter($this->acceptedMimeTypes, fn($type) => str_starts_with($type, 'audio/')));
}
public function isDocumentType(): bool
{
return !empty(array_filter($this->acceptedMimeTypes, fn($type) =>
str_starts_with($type, 'application/') || str_starts_with($type, 'text/')
));
}
public function getValue(array $content, string $locale = null): mixed
{
$value = parent::getValue($content, $locale);
// Ensure array for multiple fields
if ($this->multiple && !is_array($value)) {
return $value ? [$value] : [];
}
// Ensure integer for single fields
if (!$this->multiple && is_array($value)) {
return !empty($value) ? (int) $value[0] : null;
}
return $value;
}
public function getMediaItems(mixed $value): \Illuminate\Support\Collection
{
if (empty($value)) {
return collect();
}
$mediaIds = is_array($value) ? $value : [$value];
$mediaIds = array_filter($mediaIds);
if (empty($mediaIds)) {
return collect();
}
return \Spatie\MediaLibrary\MediaCollections\Models\Media::whereIn('id', $mediaIds)->get();
}
public function toArray(): array
{
return [
'type' => $this->getType(),
'key' => $this->key,
'label' => $this->label,
'translatable' => $this->translatable,
'required' => $this->required,
'default' => $this->default,
'acceptedMimeTypes' => $this->acceptedMimeTypes,
'multiple' => $this->multiple,
'maxFiles' => $this->maxFiles,
'collection' => $this->collection,
'conversions' => $this->conversions,
'maxFileSize' => $this->maxFileSize,
'showPreview' => $this->showPreview,
'helpText' => $this->helpText,
'attributes' => $this->attributes,
];
}
}

View file

@ -0,0 +1,144 @@
<?php
namespace FluxCms\Core\FieldTypes;
class NumberField extends BaseField
{
protected ?float $min = null;
protected ?float $max = null;
protected float $step = 1;
protected bool $decimal = false;
public function min(float $min): static
{
$this->min = $min;
return $this;
}
public function max(float $max): static
{
$this->max = $max;
return $this;
}
public function step(float $step): static
{
$this->step = $step;
return $this;
}
public function decimal(bool $decimal = true): static
{
$this->decimal = $decimal;
if ($decimal && $this->step === 1) {
$this->step = 0.01;
}
return $this;
}
public function currency(): static
{
$this->decimal(true);
$this->step(0.01);
$this->min(0);
return $this;
}
public function percentage(): static
{
$this->decimal(true);
$this->min(0);
$this->max(100);
$this->step(0.1);
return $this;
}
public function getType(): string
{
return 'number';
}
public function getMin(): ?float
{
return $this->min;
}
public function getMax(): ?float
{
return $this->max;
}
public function getStep(): float
{
return $this->step;
}
public function isDecimal(): bool
{
return $this->decimal;
}
public function getValidationRules(): array
{
$rules = [$this->decimal ? 'numeric' : 'integer'];
if ($this->required) {
$rules[] = 'required';
}
if ($this->min !== null) {
$rules[] = "min:{$this->min}";
}
if ($this->max !== null) {
$rules[] = "max:{$this->max}";
}
return array_merge($rules, $this->rules);
}
public function sanitizeValue(mixed $value): mixed
{
if ($value === null || $value === '') {
return null;
}
if ($this->decimal) {
return (float) $value;
}
return (int) $value;
}
public function transformForDisplay(mixed $value): mixed
{
if ($value === null) {
return '';
}
if ($this->decimal) {
return number_format((float) $value, 2);
}
return (string) $value;
}
public function toArray(): array
{
return [
'type' => $this->getType(),
'key' => $this->key,
'label' => $this->label,
'translatable' => $this->translatable,
'required' => $this->required,
'default' => $this->default,
'min' => $this->min,
'max' => $this->max,
'step' => $this->step,
'decimal' => $this->decimal,
'helpText' => $this->helpText,
'placeholder' => $this->placeholder,
'attributes' => $this->attributes,
];
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace FluxCms\Core\FieldTypes;
class SelectField extends BaseField
{
protected array $options = [];
protected bool $multiple = false;
protected bool $searchable = false;
protected ?string $emptyOption = null;
public function options(array $options): static
{
$this->options = $options;
return $this;
}
public function multiple(bool $multiple = true): static
{
$this->multiple = $multiple;
return $this;
}
public function searchable(bool $searchable = true): static
{
$this->searchable = $searchable;
return $this;
}
public function emptyOption(string $text): static
{
$this->emptyOption = $text;
return $this;
}
public function getType(): string
{
return 'select';
}
public function getOptions(): array
{
return $this->options;
}
public function isMultiple(): bool
{
return $this->multiple;
}
public function isSearchable(): bool
{
return $this->searchable;
}
public function getEmptyOption(): ?string
{
return $this->emptyOption;
}
public function getValidationRules(): array
{
$rules = [];
if ($this->required) {
$rules[] = 'required';
}
if ($this->multiple) {
$rules[] = 'array';
} else {
$rules[] = 'string';
}
if (!empty($this->options)) {
$validValues = array_keys($this->options);
if ($this->multiple) {
$rules[] = 'array';
// Each value must be in the valid options
foreach ($validValues as $value) {
$rules[] = "array";
}
} else {
$rules[] = "in:" . implode(',', $validValues);
}
}
return array_merge($rules, $this->rules);
}
public function getValue(array $content, string $locale = null): mixed
{
$value = parent::getValue($content, $locale);
// Ensure array for multiple selects
if ($this->multiple && !is_array($value)) {
return $value ? [$value] : [];
}
// Ensure string for single selects
if (!$this->multiple && is_array($value)) {
return !empty($value) ? (string) $value[0] : '';
}
return $value;
}
public function toArray(): array
{
return [
'type' => $this->getType(),
'key' => $this->key,
'label' => $this->label,
'translatable' => $this->translatable,
'required' => $this->required,
'default' => $this->default,
'options' => $this->options,
'multiple' => $this->multiple,
'searchable' => $this->searchable,
'emptyOption' => $this->emptyOption,
'helpText' => $this->helpText,
'attributes' => $this->attributes,
];
}
}

View file

@ -0,0 +1,166 @@
<?php
namespace FluxCms\Core\FieldTypes;
class TextField extends BaseField
{
protected int $maxLength = 255;
protected int $minLength = 0;
protected ?string $pattern = null;
protected string $inputType = 'text';
public function maxLength(int $maxLength): static
{
$this->maxLength = $maxLength;
return $this;
}
public function minLength(int $minLength): static
{
$this->minLength = $minLength;
return $this;
}
public function pattern(string $pattern): static
{
$this->pattern = $pattern;
return $this;
}
public function email(): static
{
$this->inputType = 'email';
$this->rules(['email']);
return $this;
}
public function url(): static
{
$this->inputType = 'url';
$this->rules(['url']);
return $this;
}
public function password(): static
{
$this->inputType = 'password';
return $this;
}
public function tel(): static
{
$this->inputType = 'tel';
return $this;
}
public function search(): static
{
$this->inputType = 'search';
return $this;
}
public function getType(): string
{
return 'text';
}
public function getInputType(): string
{
return $this->inputType;
}
public function getMaxLength(): int
{
return $this->maxLength;
}
public function getMinLength(): int
{
return $this->minLength;
}
public function getPattern(): ?string
{
return $this->pattern;
}
public function getValidationRules(): array
{
$rules = ['string'];
if ($this->required) {
$rules[] = 'required';
}
if ($this->maxLength > 0) {
$rules[] = "max:{$this->maxLength}";
}
if ($this->minLength > 0) {
$rules[] = "min:{$this->minLength}";
}
// Input type specific rules
if ($this->inputType === 'email') {
$rules[] = 'email';
} elseif ($this->inputType === 'url') {
$rules[] = 'url';
}
// Pattern validation
if ($this->pattern) {
$rules[] = "regex:{$this->pattern}";
}
return array_merge($rules, $this->rules);
}
public function sanitizeValue(mixed $value): mixed
{
if (!is_string($value)) {
return $value;
}
$value = trim($value);
// Input type specific sanitization
if ($this->inputType === 'email') {
$value = strtolower($value);
} elseif ($this->inputType === 'url') {
// Ensure URL has protocol
if ($value && !preg_match('/^https?:\/\//', $value)) {
$value = 'https://' . $value;
}
}
return $value;
}
public function toArray(): array
{
return [
'type' => $this->getType(),
'key' => $this->key,
'label' => $this->label,
'translatable' => $this->translatable,
'required' => $this->required,
'default' => $this->default,
'maxLength' => $this->maxLength,
'minLength' => $this->minLength,
'pattern' => $this->pattern,
'inputType' => $this->inputType,
'placeholder' => $this->placeholder,
'helpText' => $this->helpText,
'attributes' => $this->attributes,
];
}
protected function getValidationMessages(): array
{
return array_merge(parent::getValidationMessages(), [
'email' => 'The :attribute field must be a valid email address.',
'url' => 'The :attribute field must be a valid URL.',
'regex' => 'The :attribute field format is invalid.',
]);
}
}

View file

@ -0,0 +1,179 @@
<?php
namespace FluxCms\Core\FieldTypes;
class WysiwygField extends BaseField
{
protected array $toolbar = ['bold', 'italic', 'link', 'bulletList', 'orderedList'];
protected int $minHeight = 200;
protected bool $allowImages = true;
protected bool $allowTables = false;
protected bool $allowCode = true;
protected string $editor = 'tiptap'; // tiptap, tinymce, quill
public function toolbar(array $toolbar): static
{
$this->toolbar = $toolbar;
return $this;
}
public function minHeight(int $minHeight): static
{
$this->minHeight = $minHeight;
return $this;
}
public function allowImages(bool $allowImages = true): static
{
$this->allowImages = $allowImages;
return $this;
}
public function allowTables(bool $allowTables = true): static
{
$this->allowTables = $allowTables;
return $this;
}
public function allowCode(bool $allowCode = true): static
{
$this->allowCode = $allowCode;
return $this;
}
public function editor(string $editor): static
{
$this->editor = $editor;
return $this;
}
public function basic(): static
{
$this->toolbar = ['bold', 'italic'];
$this->allowImages = false;
$this->allowTables = false;
$this->allowCode = false;
return $this;
}
public function full(): static
{
$this->toolbar = [
'bold', 'italic', 'underline', 'strike',
'heading1', 'heading2', 'heading3',
'bulletList', 'orderedList',
'link', 'image', 'table',
'code', 'codeBlock',
'quote', 'rule'
];
$this->allowImages = true;
$this->allowTables = true;
$this->allowCode = true;
return $this;
}
public function getType(): string
{
return 'wysiwyg';
}
public function getToolbar(): array
{
return $this->toolbar;
}
public function getMinHeight(): int
{
return $this->minHeight;
}
public function getAllowImages(): bool
{
return $this->allowImages;
}
public function getAllowTables(): bool
{
return $this->allowTables;
}
public function getAllowCode(): bool
{
return $this->allowCode;
}
public function getEditor(): string
{
return $this->editor;
}
public function getValidationRules(): array
{
$rules = ['string'];
if ($this->required) {
$rules[] = 'required';
}
return array_merge($rules, $this->rules);
}
public function sanitizeValue(mixed $value): mixed
{
if (!is_string($value)) {
return $value;
}
// Basic HTML sanitization
$value = trim($value);
// Remove dangerous tags
$dangerousTags = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input'];
foreach ($dangerousTags as $tag) {
$value = preg_replace('/<' . $tag . '[^>]*>.*?<\/' . $tag . '>/is', '', $value);
$value = preg_replace('/<' . $tag . '[^>]*\/?>/is', '', $value);
}
// Remove javascript: links
$value = preg_replace('/javascript:/i', '', $value);
// Remove on* attributes
$value = preg_replace('/\s*on\w+\s*=\s*["\'][^"\']*["\']/i', '', $value);
return $value;
}
public function transformForDisplay(mixed $value): mixed
{
if (!is_string($value)) {
return $value;
}
// Convert relative URLs to absolute
$value = preg_replace_callback('/src="(\/[^"]*)"/', function ($matches) {
return 'src="' . url($matches[1]) . '"';
}, $value);
return $value;
}
public function toArray(): array
{
return [
'type' => $this->getType(),
'key' => $this->key,
'label' => $this->label,
'translatable' => $this->translatable,
'required' => $this->required,
'default' => $this->default,
'toolbar' => $this->toolbar,
'minHeight' => $this->minHeight,
'allowImages' => $this->allowImages,
'allowTables' => $this->allowTables,
'allowCode' => $this->allowCode,
'editor' => $this->editor,
'helpText' => $this->helpText,
'attributes' => $this->attributes,
];
}
}

View file

@ -0,0 +1,222 @@
<?php
namespace FluxCms\Core;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Gate;
use FluxCms\Core\Services\ComponentRegistry;
use FluxCms\Core\Commands\InstallCommand;
use FluxCms\Core\Commands\PublishCommand;
use FluxCms\Core\Commands\ClearCacheCommand;
use FluxCms\Core\Commands\MakeComponentCommand;
class FluxCmsServiceProvider extends ServiceProvider
{
/**
* Register services
*/
public function register(): void
{
// Merge config
$this->mergeConfigFrom(__DIR__ . '/../config/flux-cms.php', 'flux-cms');
// Register services
$this->app->singleton(ComponentRegistry::class, function ($app) {
return new ComponentRegistry();
});
// Register aliases
$this->app->alias(ComponentRegistry::class, 'flux-cms.registry');
}
/**
* Bootstrap services
*/
public function boot(): void
{
$this->bootPublishing();
$this->bootMigrations();
$this->bootViews();
$this->bootCommands();
$this->bootRoutes();
$this->bootMiddleware();
$this->bootGates();
$this->bootTranslations();
}
/**
* Boot publishing
*/
protected function bootPublishing(): void
{
if ($this->app->runningInConsole()) {
// Publish config
$this->publishes([
__DIR__ . '/../config/flux-cms.php' => config_path('flux-cms.php'),
], 'flux-cms-config');
// Publish migrations
$this->publishes([
__DIR__ . '/../database/migrations' => database_path('migrations'),
], 'flux-cms-migrations');
// Publish views
$this->publishes([
__DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms'),
], 'flux-cms-views');
// Publish all
$this->publishes([
__DIR__ . '/../config/flux-cms.php' => config_path('flux-cms.php'),
__DIR__ . '/../database/migrations' => database_path('migrations'),
__DIR__ . '/../resources/views' => resource_path('views/vendor/flux-cms'),
], 'flux-cms');
}
}
/**
* Boot migrations
*/
protected function bootMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
}
/**
* Boot views
*/
protected function bootViews(): void
{
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'flux-cms');
}
/**
* Boot commands
*/
protected function bootCommands(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
InstallCommand::class,
PublishCommand::class,
ClearCacheCommand::class,
MakeComponentCommand::class,
]);
}
}
/**
* Boot routes
*/
protected function bootRoutes(): void
{
if (config('flux-cms.routes.enabled', true)) {
$this->loadRoutesFromDirectory(__DIR__ . '/../routes');
}
}
/**
* Boot middleware
*/
protected function bootMiddleware(): void
{
$router = $this->app['router'];
// Register middleware aliases
$router->aliasMiddleware('flux-cms:cms-access', \FluxCms\Core\Http\Middleware\CmsAccess::class);
$router->aliasMiddleware('flux-cms:domain-detection', \FluxCms\Core\Http\Middleware\DomainDetection::class);
$router->aliasMiddleware('flux-cms:preview-mode', \FluxCms\Core\Http\Middleware\PreviewMode::class);
}
/**
* Load routes from directory
*/
protected function loadRoutesFromDirectory(string $directory): void
{
if (!is_dir($directory)) {
return;
}
$files = glob($directory . '/*.php');
foreach ($files as $file) {
$this->loadRoutesFrom($file);
}
}
/**
* Boot authorization gates
*/
protected function bootGates(): void
{
Gate::define('flux-cms.view', function ($user) {
return $this->userHasCmsPermission($user, 'view');
});
Gate::define('flux-cms.edit', function ($user) {
return $this->userHasCmsPermission($user, 'edit');
});
Gate::define('flux-cms.publish', function ($user) {
return $this->userHasCmsPermission($user, 'publish');
});
Gate::define('flux-cms.delete', function ($user) {
return $this->userHasCmsPermission($user, 'delete');
});
Gate::define('flux-cms.admin', function ($user) {
return $this->userHasCmsPermission($user, 'admin');
});
}
/**
* Check if user has CMS permission
*/
protected function userHasCmsPermission($user, string $permission): bool
{
// If no user, deny access
if (!$user) {
return false;
}
// Check for Spatie Permission package
if (method_exists($user, 'can')) {
return $user->can("flux-cms.{$permission}") || $user->hasRole('flux-cms') || $user->hasRole('admin');
}
// Fallback: Check if user has admin role property
if (isset($user->is_admin)) {
return $user->is_admin;
}
// Default: Allow access for authenticated users (can be overridden in config)
return config('flux-cms.auth.default_access', false);
}
/**
* Boot translations
*/
protected function bootTranslations(): void
{
$this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'flux-cms');
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../resources/lang' => resource_path('lang/vendor/flux-cms'),
], 'flux-cms-translations');
}
}
/**
* Get the services provided by the provider
*/
public function provides(): array
{
return [
ComponentRegistry::class,
'flux-cms.registry',
];
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace FluxCms\Core\Http\Controllers\Admin;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use FluxCms\Core\Models\BlogPost;
class BlogController extends Controller
{
public function __construct()
{
$this->middleware('can:flux-cms.view');
}
public function index(Request $request)
{
$query = BlogPost::with(['author']);
if ($request->has('status')) {
switch ($request->status) {
case 'published':
$query->published();
break;
case 'draft':
$query->where('is_published', false);
break;
}
}
$posts = $query->orderBy('updated_at', 'desc')->paginate(20);
return view('flux-cms::admin.blog.index', compact('posts'));
}
public function edit(BlogPost $blogPost)
{
$this->authorize('flux-cms.edit');
return view('flux-cms::admin.blog.edit', ['post' => $blogPost]);
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace FluxCms\Core\Http\Controllers\Admin;
use Illuminate\Routing\Controller;
use FluxCms\Core\Services\ComponentRegistry;
class ComponentController extends Controller
{
public function __construct()
{
$this->middleware('can:flux-cms.view');
}
public function __invoke()
{
$registry = app(ComponentRegistry::class);
$components = $registry->getComponentsByCategory();
$stats = $registry->getComponentStats();
return view('flux-cms::admin.components.index', compact('components', 'stats'));
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace FluxCms\Core\Http\Controllers\Admin;
use Illuminate\Routing\Controller;
use FluxCms\Core\Models\Page;
use FluxCms\Core\Models\BlogPost;
class DashboardController extends Controller
{
public function __invoke()
{
$stats = [
'pages' => Page::count(),
'published_pages' => Page::published()->count(),
'draft_pages' => Page::where('is_published', false)->count(),
'blog_posts' => BlogPost::count(),
'published_posts' => BlogPost::published()->count(),
];
$recentPages = Page::with(['components'])
->orderBy('updated_at', 'desc')
->limit(5)
->get();
return view('flux-cms::admin.dashboard', compact('stats', 'recentPages'));
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace FluxCms\Core\Http\Controllers\Admin;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class MediaController extends Controller
{
public function __construct()
{
$this->middleware('can:flux-cms.view');
}
public function __invoke(Request $request)
{
// This would integrate with Spatie Media Library
return view('flux-cms::admin.media.index');
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace FluxCms\Core\Http\Controllers\Admin;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class NavigationController extends Controller
{
public function __construct()
{
$this->middleware('can:flux-cms.view');
}
public function __invoke(Request $request)
{
return view('flux-cms::admin.navigation.index');
}
}

View file

@ -0,0 +1,131 @@
<?php
namespace FluxCms\Core\Http\Controllers\Admin;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use FluxCms\Core\Models\Page;
class PageController extends Controller
{
public function __construct()
{
$this->middleware('can:flux-cms.view');
}
public function index(Request $request)
{
$query = Page::with(['components']);
if ($request->has('domain') && $request->domain) {
$query->forDomain($request->domain);
}
if ($request->has('status')) {
switch ($request->status) {
case 'published':
$query->published();
break;
case 'draft':
$query->where('is_published', false);
break;
}
}
$pages = $query->orderBy('updated_at', 'desc')->paginate(20);
return view('flux-cms::admin.pages.index', compact('pages'));
}
public function create()
{
$this->authorize('flux-cms.edit');
$domains = $this->getAvailableDomains();
$locales = config('flux-cms.locales');
return view('flux-cms::admin.pages.create', compact('domains', 'locales'));
}
public function store(Request $request)
{
$this->authorize('flux-cms.edit');
$validated = $request->validate([
'domain_key' => 'required|string',
'title' => 'required|array',
'title.*' => 'required|string|max:255',
'slugs' => 'required|array',
'slugs.*' => 'required|string',
]);
$page = Page::create([
'domain_key' => $validated['domain_key'],
'title' => $validated['title'],
'is_published' => $request->boolean('is_published'),
'published_at' => $request->boolean('is_published') ? now() : null,
]);
foreach ($validated['slugs'] as $locale => $slug) {
$page->slugs()->create(['locale' => $locale, 'slug' => $slug]);
}
return redirect()->route('admin.cms.pages.edit', $page)
->with('success', 'Page created successfully!');
}
public function edit(Page $page)
{
$this->authorize('flux-cms.edit');
return view('flux-cms::admin.pages.edit', compact('page'));
}
public function update(Request $request, Page $page)
{
$this->authorize('flux-cms.edit');
$validated = $request->validate([
'title' => 'required|array',
'title.*' => 'required|string|max:255',
'slugs' => 'required|array',
'slugs.*' => 'required|string',
]);
$page->update([
'title' => $validated['title'],
'is_published' => $request->boolean('is_published'),
'published_at' => $request->boolean('is_published') && !$page->published_at ? now() : $page->published_at,
]);
foreach ($validated['slugs'] as $locale => $slug) {
$page->slugs()->updateOrCreate(
['locale' => $locale],
['slug' => $slug]
);
}
return redirect()->route('admin.cms.pages.edit', $page)
->with('success', 'Page updated successfully!');
}
public function destroy(Page $page)
{
$this->authorize('flux-cms.delete');
$page->delete();
return redirect()->route('admin.cms.pages.index')->with('success', 'Page deleted successfully!');
}
private function getAvailableDomains(): array
{
if (!config('flux-cms.domains.enabled')) {
return ['default' => 'Default'];
}
$domains = config('domains.domains', []);
$result = [];
foreach ($domains as $key => $config) {
$result[$key] = $config['name'] ?? $key;
}
return $result;
}
}

View file

@ -0,0 +1,153 @@
<?php
namespace FluxCms\Core\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use FluxCms\Core\Models\Page;
use FluxCms\Core\Models\BlogPost;
class PageController extends Controller
{
/**
* Display a CMS page
*/
public function show(Request $request, string $slug = '/')
{
$domainKey = $this->getCurrentDomainKey($request);
$page = Page::forDomain($domainKey)
->published()
->bySlugWithFallback($slug)
->with(['components'])
->firstOrFail();
// Load active components
$components = $page->components()->get();
return view('flux-cms::pages.show', compact('page', 'components'));
}
/**
* Blog index page
*/
public function blogIndex(Request $request)
{
$domainKey = $this->getCurrentDomainKey($request);
$locale = app()->getLocale();
$posts = BlogPost::forDomain($domainKey)
->published()
->orderBy('published_at', 'desc')
->paginate(10);
return view('flux-cms::blog.index', compact('posts'));
}
/**
* Blog post page
*/
public function blogPost(Request $request, string $slug)
{
$domainKey = $this->getCurrentDomainKey($request);
$post = BlogPost::forDomain($domainKey)
->published()
->bySlugWithFallback($slug)
->firstOrFail();
// Get related posts
$relatedPosts = BlogPost::forDomain($domainKey)
->published()
->where('id', '!=', $post->id)
->where('category', $post->category)
->limit(3)
->get();
return view('flux-cms::blog.show', compact('post', 'relatedPosts'));
}
/**
* Generate sitemap
*/
public function sitemap(Request $request)
{
$domainKey = $this->getCurrentDomainKey($request);
$locale = app()->getLocale();
$pages = Page::forDomain($domainKey)
->published()
->get();
$blogPosts = BlogPost::forDomain($domainKey)
->published()
->get();
return response()->view('flux-cms::sitemap', compact('pages', 'blogPosts'))
->header('Content-Type', 'application/xml');
}
/**
* Generate robots.txt
*/
public function robots(Request $request)
{
$domainKey = $this->getCurrentDomainKey($request);
$baseUrl = $this->getBaseUrl($request);
return response()->view('flux-cms::robots', compact('baseUrl'))
->header('Content-Type', 'text/plain');
}
/**
* Preview page (for authenticated users)
*/
public function preview(Request $request, Page $page)
{
$this->middleware('auth');
$this->middleware('can:flux-cms.view');
$components = $page->allComponents()->get();
return view('flux-cms::pages.preview', compact('page', 'components'));
}
/**
* Get current domain key
*/
protected function getCurrentDomainKey(Request $request): string
{
if (!config('flux-cms.domains.enabled')) {
return config('flux-cms.domains.default_domain', 'default');
}
$host = $request->getHost();
$domains = config('domains.domains', []);
foreach ($domains as $key => $config) {
if (isset($config['url'])) {
$domainHost = parse_url($config['url'], PHP_URL_HOST);
if ($domainHost === $host) {
return $key;
}
}
}
return config('flux-cms.domains.default_domain', 'default');
}
/**
* Get base URL for current domain
*/
protected function getBaseUrl(Request $request): string
{
$domainKey = $this->getCurrentDomainKey($request);
$domains = config('domains.domains', []);
if (isset($domains[$domainKey]['url'])) {
return $domains[$domainKey]['url'];
}
return $request->getSchemeAndHttpHost();
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace FluxCms\Core\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class CmsAccess
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next, string $permission = 'view'): Response
{
$user = Auth::user();
if (!$user) {
return redirect()->route('login');
}
// Check if user has CMS permission
if (!$this->userHasCmsPermission($user, $permission)) {
abort(403, 'Access denied. You do not have permission to access the CMS.');
}
return $next($request);
}
/**
* Check if user has CMS permission
*/
protected function userHasCmsPermission($user, string $permission): bool
{
// Check for Spatie Permission package
if (method_exists($user, 'can')) {
return $user->can("flux-cms.{$permission}") ||
$user->hasRole('flux-cms') ||
$user->hasRole('admin');
}
// Fallback: Check if user has admin role property
if (isset($user->is_admin)) {
return $user->is_admin;
}
// Default: Allow access for authenticated users (can be overridden in config)
return config('flux-cms.auth.default_access', false);
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace FluxCms\Core\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class DomainDetection
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
if (!config('flux-cms.domains.enabled')) {
return $next($request);
}
$domainKey = $this->detectDomainKey($request);
// Set domain key in request for later use
$request->attributes->set('flux_cms_domain_key', $domainKey);
// Set locale based on domain if configured
$this->setLocaleFromDomain($request, $domainKey);
return $next($request);
}
/**
* Detect domain key from request
*/
protected function detectDomainKey(Request $request): string
{
$host = $request->getHost();
$domains = config('domains.domains', []);
foreach ($domains as $key => $config) {
if (isset($config['url'])) {
$domainHost = parse_url($config['url'], PHP_URL_HOST);
if ($domainHost === $host) {
return $key;
}
}
}
return config('flux-cms.domains.default_domain', 'default');
}
/**
* Set locale based on domain configuration
*/
protected function setLocaleFromDomain(Request $request, string $domainKey): void
{
$domains = config('domains.domains', []);
if (isset($domains[$domainKey]['locale'])) {
$locale = $domains[$domainKey]['locale'];
app()->setLocale($locale);
}
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace FluxCms\Core\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class PreviewMode
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
// Check if preview mode is enabled via query parameter
if ($request->has('preview') && $request->boolean('preview')) {
// Verify user has preview permission
if (!$this->userCanPreview($request)) {
abort(403, 'Preview access denied');
}
// Set preview mode in request
$request->attributes->set('flux_cms_preview_mode', true);
}
return $next($request);
}
/**
* Check if user can preview content
*/
protected function userCanPreview(Request $request): bool
{
$user = $request->user();
if (!$user) {
return false;
}
// Check for Spatie Permission package
if (method_exists($user, 'can')) {
return $user->can('flux-cms.view') ||
$user->hasRole('flux-cms') ||
$user->hasRole('admin');
}
// Fallback: Check if user has admin role property
if (isset($user->is_admin)) {
return $user->is_admin;
}
return false;
}
}

View file

@ -0,0 +1,257 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\Translatable\HasTranslations;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\Tags\HasTags;
class BlogPost extends Model implements HasMedia
{
use HasTranslations, InteractsWithMedia, HasTags;
public function getTable()
{
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'blog_posts';
}
protected $fillable = [
'domain_key',
'title',
'excerpt',
'content',
'meta_title',
'meta_description',
'is_published',
'is_featured',
'published_at',
'author_id',
'category',
'settings',
];
protected $translatable = [
'title',
'excerpt',
'content',
'meta_title',
'meta_description'
];
protected $casts = [
'title' => 'array',
'excerpt' => 'array',
'content' => 'array',
'meta_title' => 'array',
'meta_description' => 'array',
'settings' => 'array',
'is_published' => 'boolean',
'is_featured' => 'boolean',
'published_at' => 'datetime',
];
public function author()
{
return $this->morphTo();
}
/**
* Slug relationship
*/
public function slugs()
{
return $this->morphMany(Slug::class, 'model');
}
/**
* Scopes
*/
public function scopeForDomain($query, string $domainKey)
{
return $query->where('domain_key', $domainKey);
}
public function scopePublished($query)
{
return $query->where('is_published', true)
->where('published_at', '<=', now());
}
public function scopeFeatured($query)
{
return $query->where('is_featured', true);
}
public function scopeByCategory($query, string $category)
{
return $query->where('category', $category);
}
public function scopeByTag($query, string $tag)
{
return $query->withAnyTags([$tag]);
}
public function scopeRecent($query, int $limit = 10)
{
return $query->orderBy('published_at', 'desc')->limit($limit);
}
public function scopeBySlug($query, string $slug, string $locale = null)
{
$locale = $locale ?? app()->getLocale();
return $query->whereHas('slugs', function ($q) use ($slug, $locale) {
$q->where('slug', $slug)->where('locale', $locale);
});
}
public function scopeBySlugWithFallback($query, string $slug, string $locale = null)
{
$locale = $locale ?? app()->getLocale();
$fallbackLocale = config('app.fallback_locale');
return $query->where(function ($q) use ($slug, $locale, $fallbackLocale) {
$q->whereHas('slugs', function ($sq) use ($slug, $locale) {
$sq->where('slug', $slug)->where('locale', $locale);
})->orWhereHas('slugs', function ($sq) use ($slug, $fallbackLocale) {
$sq->where('slug', $slug)->where('locale', $fallbackLocale);
});
});
}
/**
* Get the URL for this blog post
*/
public function getUrl(string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
$slug = $this->slugs()->where('locale', $locale)->first();
return route('blog.post', ['slug' => $slug->slug ?? '']);
}
/**
* Get the reading time estimate in minutes
*/
public function getReadingTimeAttribute(): int
{
$content = strip_tags($this->getTranslation('content', app()->getLocale()));
$wordCount = str_word_count($content);
return max(1, ceil($wordCount / 200)); // Assuming 200 words per minute
}
/**
* Get excerpt with fallback to content
*/
public function getExcerptAttribute($value): string
{
$locale = app()->getLocale();
$excerpt = $this->getTranslation('excerpt', $locale);
if (empty($excerpt)) {
$content = strip_tags($this->getTranslation('content', $locale));
$excerpt = str_limit($content, 160);
}
return $excerpt;
}
/**
* Check if post is published
*/
public function isPublished(): bool
{
return $this->is_published &&
$this->published_at &&
$this->published_at <= now();
}
/**
* Get SEO title with fallback to title
*/
public function getSeoTitle(string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
return $this->getTranslation('meta_title', $locale)
?? $this->getTranslation('title', $locale);
}
/**
* Get SEO description with fallback to excerpt
*/
public function getSeoDescription(string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
return $this->getTranslation('meta_description', $locale)
?? $this->getTranslation('excerpt', $locale);
}
/**
* Media collections
*/
public function registerMediaCollections(): void
{
$this->addMediaCollection('featured_image')
->singleFile()
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']);
$this->addMediaCollection('gallery')
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']);
}
public function registerMediaConversions(Media $media = null): void
{
$this->addMediaConversion('thumb')
->width(300)
->height(200)
->sharpen(10);
$this->addMediaConversion('hero')
->width(1200)
->height(600)
->optimize();
}
/**
* Get featured image
*/
public function getFeaturedImage(): ?Media
{
return $this->getFirstMedia('featured_image');
}
/**
* Get featured image URL
*/
public function getFeaturedImageUrl(string $conversion = ''): ?string
{
$media = $this->getFeaturedImage();
if (!$media) {
return null;
}
return $conversion ? $media->getUrl($conversion) : $media->getUrl();
}
/**
* Settings management
*/
public function getSetting(string $key, mixed $default = null): mixed
{
return data_get($this->settings, $key, $default);
}
public function setSetting(string $key, mixed $value): void
{
$settings = $this->settings ?? [];
data_set($settings, $key, $value);
$this->update(['settings' => $settings]);
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Translatable\HasTranslations;
class Navigation extends Model
{
use HasTranslations;
public function getTable()
{
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'navigations';
}
protected $fillable = [
'domain_key',
'name',
'display_name',
'settings',
'is_active',
];
protected $translatable = [
'display_name'
];
protected $casts = [
'display_name' => 'array',
'settings' => 'array',
'is_active' => 'boolean',
];
public function items(): HasMany
{
return $this->hasMany(NavigationItem::class, 'navigation_id')
->where('is_active', true)
->whereNull('parent_id')
->orderBy('order');
}
public function allItems(): HasMany
{
return $this->hasMany(NavigationItem::class, 'navigation_id')
->orderBy('order');
}
/**
* Scopes
*/
public function scopeForDomain($query, string $domainKey)
{
return $query->where('domain_key', $domainKey);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeByName($query, string $name, string $domainKey)
{
return $query->where('name', $name)->where('domain_key', $domainKey);
}
/**
* Get hierarchical navigation structure
*/
public function getHierarchicalItems(): \Illuminate\Support\Collection
{
return $this->items()->with(['children' => function ($query) {
$query->where('is_active', true)->orderBy('order');
}, 'page'])->get();
}
/**
* Settings management
*/
public function getSetting(string $key, mixed $default = null): mixed
{
return data_get($this->settings, $key, $default);
}
public function setSetting(string $key, mixed $value): void
{
$settings = $this->settings ?? [];
data_set($settings, $key, $value);
$this->update(['settings' => $settings]);
}
}

View file

@ -0,0 +1,140 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Translatable\HasTranslations;
class NavigationItem extends Model
{
use HasTranslations;
public function getTable()
{
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'navigation_items';
}
protected $fillable = [
'navigation_id',
'parent_id',
'page_id',
'title',
'url',
'target',
'order',
'is_active',
'settings',
];
protected $translatable = [
'title'
];
protected $casts = [
'title' => 'array',
'settings' => 'array',
'is_active' => 'boolean',
'order' => 'integer',
];
public function navigation(): BelongsTo
{
return $this->belongsTo(Navigation::class, 'navigation_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(NavigationItem::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(NavigationItem::class, 'parent_id')
->where('is_active', true)
->orderBy('order');
}
public function allChildren(): HasMany
{
return $this->hasMany(NavigationItem::class, 'parent_id')
->orderBy('order');
}
public function page(): BelongsTo
{
return $this->belongsTo(Page::class, 'page_id');
}
/**
* Scopes
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeTopLevel($query)
{
return $query->whereNull('parent_id');
}
public function scopeForNavigation($query, int $navigationId)
{
return $query->where('navigation_id', $navigationId);
}
/**
* Get the effective URL for this navigation item
*/
public function getEffectiveUrl(): string
{
if ($this->page_id && $this->page) {
return $this->page->getUrl();
}
return $this->url ?? '#';
}
/**
* Check if this item has children
*/
public function hasChildren(): bool
{
return $this->children()->count() > 0;
}
/**
* Get all descendant IDs (children, grandchildren, etc.)
*/
public function getDescendantIds(): array
{
$ids = [];
$this->collectDescendantIds($ids);
return $ids;
}
protected function collectDescendantIds(array &$ids): void
{
foreach ($this->allChildren as $child) {
$ids[] = $child->id;
$child->collectDescendantIds($ids);
}
}
/**
* Settings management
*/
public function getSetting(string $key, mixed $default = null): mixed
{
return data_get($this->settings, $key, $default);
}
public function setSetting(string $key, mixed $value): void
{
$settings = $this->settings ?? [];
data_set($settings, $key, $value);
$this->update(['settings' => $settings]);
}
}

View file

@ -0,0 +1,241 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Translatable\HasTranslations;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class Page extends Model implements HasMedia
{
use HasTranslations, InteractsWithMedia;
public function getTable()
{
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'pages';
}
protected $fillable = [
'domain_key',
'title',
'meta_description',
'meta_keywords',
'og_image',
'canonical_url',
'is_published',
'published_at',
'settings',
];
protected $translatable = [
'title',
'meta_description',
'meta_keywords',
'og_image'
];
protected $casts = [
'title' => 'array',
'meta_description' => 'array',
'meta_keywords' => 'array',
'og_image' => 'array',
'settings' => 'array',
'is_published' => 'boolean',
'published_at' => 'datetime',
];
public function components(): HasMany
{
return $this->hasMany(PageComponent::class, 'page_id')
->where('is_active', true)
->orderBy('order');
}
public function allComponents(): HasMany
{
return $this->hasMany(PageComponent::class, 'page_id')
->orderBy('order');
}
public function versions(): HasMany
{
return $this->hasMany(PageVersion::class, 'page_id')
->orderBy('created_at', 'desc');
}
public function navigationItems(): HasMany
{
return $this->hasMany(NavigationItem::class, 'page_id');
}
/**
* Media Collections für SEO Images
*/
public function registerMediaCollections(): void
{
$this->addMediaCollection('og_images')
->singleFile()
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']);
$this->addMediaCollection('page_assets')
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']);
}
public function registerMediaConversions(Media $media = null): void
{
$this->addMediaConversion('og_thumb')
->width(1200)
->height(630)
->sharpen(10);
$this->addMediaConversion('preview')
->width(400)
->height(300)
->sharpen(10);
}
/**
* Scopes
*/
public function scopeForDomain($query, string $domainKey)
{
return $query->where('domain_key', $domainKey);
}
public function scopePublished($query)
{
return $query->where('is_published', true)
->where(function ($q) {
$q->whereNull('published_at')
->orWhere('published_at', '<=', now());
});
}
public function scopeBySlug($query, string $slug, string $locale = null)
{
$locale = $locale ?? app()->getLocale();
return $query->whereHas('slugs', function ($q) use ($slug, $locale) {
$q->where('slug', $slug)->where('locale', $locale);
});
}
public function scopeBySlugWithFallback($query, string $slug, string $locale = null)
{
$locale = $locale ?? app()->getLocale();
$fallbackLocale = config('app.fallback_locale');
return $query->where(function ($q) use ($slug, $locale, $fallbackLocale) {
$q->whereHas('slugs', function ($sq) use ($slug, $locale) {
$sq->where('slug', $slug)->where('locale', $locale);
})->orWhereHas('slugs', function ($sq) use ($slug, $fallbackLocale) {
$sq->where('slug', $slug)->where('locale', $fallbackLocale);
});
});
}
/**
* Slug relationship
*/
public function slugs()
{
return $this->morphMany(Slug::class, 'model');
}
/**
* Version Management
*/
public function createVersion(string $changeDescription = null, ?Model $user = null): PageVersion
{
$version = $this->versions()->make([
'page_data' => $this->toArray(),
'components_data' => $this->allComponents()->with('media')->get()->toArray(),
'change_description' => $changeDescription,
'version_name' => $this->generateVersionName(),
]);
if ($user) {
$version->createdBy()->associate($user);
}
$version->save();
return $version;
}
protected function generateVersionName(): string
{
$count = $this->versions()->count();
return "Version " . ($count + 1) . " - " . now()->format('Y-m-d H:i');
}
/**
* Publishing Methods
*/
public function publish(): void
{
$this->update([
'is_published' => true,
'published_at' => $this->published_at ?? now(),
]);
}
public function unpublish(): void
{
$this->update(['is_published' => false]);
}
/**
* SEO Methods
*/
public function getSeoTitle(string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
$title = $this->getTranslation('title', $locale);
$siteName = config('flux-cms.seo.site_name', config('app.name'));
return $title ? "{$title} - {$siteName}" : $siteName;
}
public function getSeoDescription(string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
return $this->getTranslation('meta_description', $locale) ?? '';
}
public function getCanonicalUrl(): string
{
return $this->canonical_url ?: request()->url();
}
/**
* URL Generation
*/
public function getUrl(string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
$slug = $this->slugs()->where('locale', $locale)->first();
if (!$slug || $slug->slug === '/') {
return url('/');
}
return url(ltrim($slug->slug, '/'));
}
/**
* Settings Management
*/
public function getSetting(string $key, mixed $default = null): mixed
{
return data_get($this->settings, $key, $default);
}
public function setSetting(string $key, mixed $value): void
{
$settings = $this->settings ?? [];
data_set($settings, $key, $value);
$this->update(['settings' => $settings]);
}
}

View file

@ -0,0 +1,217 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\Translatable\HasTranslations;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use FluxCms\Core\Services\ComponentRegistry;
class PageComponent extends Model implements HasMedia
{
use HasTranslations, InteractsWithMedia;
public function getTable()
{
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'page_components';
}
protected $fillable = [
'page_id',
'component_class',
'order',
'content',
'is_active',
'settings',
];
protected $translatable = [
'content'
];
protected $casts = [
'content' => 'array',
'settings' => 'array',
'is_active' => 'boolean',
];
public function page(): BelongsTo
{
return $this->belongsTo(Page::class, 'page_id');
}
/**
* Media Collections für Component Assets
*/
public function registerMediaCollections(): void
{
$this->addMediaCollection('component_images')
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']);
$this->addMediaCollection('component_files')
->acceptsMimeTypes([
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain'
]);
$this->addMediaCollection('component_videos')
->acceptsMimeTypes(['video/mp4', 'video/webm', 'video/ogg']);
}
public function registerMediaConversions(Media $media = null): void
{
$this->addMediaConversion('thumb')
->width(300)
->height(300)
->sharpen(10);
$this->addMediaConversion('medium')
->width(800)
->height(600)
->sharpen(10);
$this->addMediaConversion('large')
->width(1200)
->height(900)
->sharpen(10);
}
/**
* Component Configuration
*/
public function getComponentConfig(): array
{
if (!class_exists($this->component_class)) {
return [
'name' => 'Unknown Component',
'fields' => [],
'category' => 'Unknown',
'error' => 'Component class not found: ' . $this->component_class
];
}
$registry = app(ComponentRegistry::class);
return $registry->getComponentConfig($this->component_class);
}
/**
* Content Validation
*/
public function validateContent(): array
{
if (!class_exists($this->component_class)) {
return ['component_class' => 'Component class not found'];
}
$registry = app(ComponentRegistry::class);
return $registry->validateComponentContent($this->component_class, $this->content ?? []);
}
/**
* Scopes
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeOrdered($query)
{
return $query->orderBy('order');
}
public function scopeByComponentClass($query, string $componentClass)
{
return $query->where('component_class', $componentClass);
}
/**
* Content Management
*/
public function getTranslatedContent(string $locale = null): array
{
$locale = $locale ?? app()->getLocale();
$content = $this->getTranslations('content');
return $content[$locale] ?? $content[config('app.fallback_locale')] ?? [];
}
public function setTranslatedContent(array $content, string $locale = null): void
{
$locale = $locale ?? app()->getLocale();
$translations = $this->getTranslations('content');
$translations[$locale] = $content;
$this->content = $translations;
$this->save();
}
public function getContentValue(string $key, string $locale = null): mixed
{
$content = $this->getTranslatedContent($locale);
return data_get($content, $key);
}
public function setContentValue(string $key, mixed $value, string $locale = null): void
{
$content = $this->getTranslatedContent($locale);
data_set($content, $key, $value);
$this->setTranslatedContent($content, $locale);
}
/**
* Component Rendering
*/
public function canRender(): bool
{
return class_exists($this->component_class) && $this->is_active;
}
public function getComponentName(): string
{
$config = $this->getComponentConfig();
return $config['name'] ?? class_basename($this->component_class);
}
public function getComponentCategory(): string
{
$config = $this->getComponentConfig();
return $config['category'] ?? 'General';
}
/**
* Duplication
*/
public function duplicate(int $newOrder = null): self
{
$duplicate = $this->replicate();
$duplicate->order = $newOrder ?? ($this->page->allComponents()->max('order') + 1);
$duplicate->save();
// Copy media if any
foreach ($this->getMedia() as $media) {
$media->copy($duplicate);
}
return $duplicate;
}
/**
* Settings Management
*/
public function getSetting(string $key, mixed $default = null): mixed
{
return data_get($this->settings, $key, $default);
}
public function setSetting(string $key, mixed $value): void
{
$settings = $this->settings ?? [];
data_set($settings, $key, $value);
$this->update(['settings' => $settings]);
}
}

View file

@ -0,0 +1,101 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PageVersion extends Model
{
public function getTable()
{
return config('flux-cms.database.table_prefix', 'flux_cms_') . 'page_versions';
}
protected $fillable = [
'page_id',
'page_data',
'components_data',
'version_name',
'change_description',
'created_by_type',
'created_by_id',
];
protected $casts = [
'page_data' => 'array',
'components_data' => 'array',
];
public function page(): BelongsTo
{
return $this->belongsTo(Page::class, 'page_id');
}
public function createdBy()
{
return $this->morphTo('created_by');
}
/**
* Restore this version
*/
public function restore(): void
{
$page = $this->page;
// Create backup of current version
$page->createVersion('Backup before restoration');
// Restore page data
$pageData = $this->page_data;
unset($pageData['id'], $pageData['created_at'], $pageData['updated_at']);
$page->update($pageData);
// Delete current components and restore from version
$page->allComponents()->delete();
foreach ($this->components_data as $componentData) {
unset($componentData['id'], $componentData['page_id'], $componentData['created_at'], $componentData['updated_at']);
$page->allComponents()->create($componentData);
}
}
/**
* Get differences compared to current version
*/
public function getDifferences(): array
{
$currentPage = $this->page->fresh();
$currentComponents = $currentPage->allComponents()->get();
return [
'page_changes' => $this->arrayDiff($this->page_data, $currentPage->toArray()),
'components_changes' => $this->arrayDiff($this->components_data, $currentComponents->toArray()),
];
}
private function arrayDiff(array $old, array $new): array
{
$changes = [];
foreach ($old as $key => $value) {
if (!isset($new[$key])) {
$changes['removed'][$key] = $value;
} elseif ($new[$key] !== $value) {
$changes['changed'][$key] = [
'old' => $value,
'new' => $new[$key]
];
}
}
foreach ($new as $key => $value) {
if (!isset($old[$key])) {
$changes['added'][$key] = $value;
}
}
return $changes;
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace FluxCms\Core\Models;
use Illuminate\Database\Eloquent\Model;
class Slug extends Model
{
protected $table = 'flux_cms_slugs';
protected $fillable = [
'model_type',
'model_id',
'locale',
'slug',
];
public function model()
{
return $this->morphTo();
}
}

View file

@ -0,0 +1,470 @@
<?php
namespace FluxCms\Core\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use ReflectionClass;
use ReflectionException;
use FluxCms\Core\FieldTypes\BaseField;
class ComponentRegistry
{
protected array $componentPaths = [];
protected string $cacheKey = 'flux_cms.component.registry';
protected int $cacheTtl = 3600; // 1 Stunde
protected bool $cacheEnabled = true;
public function __construct()
{
$this->componentPaths = config('flux-cms.component_paths', [
'App\\Livewire\\Components',
'FluxCms\\StarterComponents\\Components',
]);
$this->cacheEnabled = config('flux-cms.cache.components', true);
$this->cacheTtl = config('flux-cms.cache.ttl', 3600);
}
/**
* Hole alle verfügbaren CMS-Komponenten
*/
public function getAvailableComponents(): array
{
if (!$this->cacheEnabled) {
return $this->scanComponents();
}
return Cache::remember($this->cacheKey, $this->cacheTtl, function () {
return $this->scanComponents();
});
}
/**
* Hole eine spezifische Komponente
*/
public function getComponent(string $className): ?array
{
$components = $this->getAvailableComponents();
return $components[$className] ?? null;
}
/**
* Prüfe ob eine Klasse eine gültige CMS-Komponente ist
*/
public function isValidComponent(string $className): bool
{
if (!class_exists($className)) {
return false;
}
try {
$reflection = new ReflectionClass($className);
// Muss getCmsFields Methode haben
if (!$reflection->hasMethod('getCmsFields')) {
return false;
}
// getCmsFields muss static sein
$method = $reflection->getMethod('getCmsFields');
if (!$method->isStatic() || !$method->isPublic()) {
return false;
}
// Muss von Livewire\Component erben
if (!$reflection->isSubclassOf(\Livewire\Component::class)) {
return false;
}
return true;
} catch (ReflectionException) {
return false;
}
}
/**
* Hole die CMS-Konfiguration einer Komponente
*/
public function getComponentConfig(string $className): array
{
if (!$this->isValidComponent($className)) {
return [
'class' => $className,
'name' => 'Invalid Component',
'fields' => [],
'category' => 'Error',
'error' => 'Component is not valid or does not exist'
];
}
try {
$config = [
'class' => $className,
'name' => $this->getComponentName($className),
'fields' => $className::getCmsFields(),
'category' => $this->getComponentCategory($className),
'description' => $this->getComponentDescription($className),
'preview' => $this->getComponentPreview($className),
'icon' => $this->getComponentIcon($className),
'tags' => $this->getComponentTags($className),
'version' => $this->getComponentVersion($className),
];
// Validiere Felder
$config['fields'] = $this->validateFields($config['fields']);
return $config;
} catch (\Exception $e) {
\Log::error("Error getting component config for {$className}: " . $e->getMessage());
return [
'class' => $className,
'name' => 'Error Component',
'fields' => [],
'category' => 'Error',
'error' => $e->getMessage()
];
}
}
/**
* Scanne alle Komponenten-Verzeichnisse
*/
protected function scanComponents(): array
{
$components = [];
foreach ($this->componentPaths as $namespace) {
$path = $this->namespaceToPath($namespace);
if (!is_dir($path)) {
continue;
}
$components = array_merge($components, $this->scanDirectory($path, $namespace));
}
// Sortiere nach Kategorie und Name
uasort($components, function ($a, $b) {
$categoryCompare = strcmp($a['category'], $b['category']);
if ($categoryCompare !== 0) {
return $categoryCompare;
}
return strcmp($a['name'], $b['name']);
});
return $components;
}
/**
* Scanne ein Verzeichnis nach Komponenten
*/
protected function scanDirectory(string $path, string $namespace): array
{
$components = [];
if (!is_dir($path)) {
return $components;
}
$files = File::allFiles($path);
foreach ($files as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$className = $this->fileToClassName($file, $namespace, $path);
if ($this->isValidComponent($className)) {
$config = $this->getComponentConfig($className);
if (!isset($config['error'])) {
$components[$className] = $config;
}
}
}
return $components;
}
/**
* Konvertiere Namespace zu Dateipfad
*/
protected function namespaceToPath(string $namespace): string
{
// Handle App namespace
if (str_starts_with($namespace, 'App\\')) {
$path = str_replace('App\\', 'app/', $namespace);
$path = str_replace('\\', '/', $path);
return base_path($path);
}
// Handle package namespaces
if (str_starts_with($namespace, 'FluxCms\\')) {
$path = str_replace('FluxCms\\', 'packages/flux-cms/', $namespace);
$path = str_replace('\\', '/', $path);
$path = strtolower($path);
return base_path($path . '/src');
}
// Fallback
$path = str_replace('\\', '/', $namespace);
return base_path('vendor/' . strtolower($path));
}
/**
* Konvertiere Datei zu Klassenname
*/
protected function fileToClassName(\SplFileInfo $file, string $namespace, string $basePath): string
{
$relativePath = str_replace($basePath . '/', '', $file->getPathname());
$relativePath = str_replace('.php', '', $relativePath);
$relativePath = str_replace('/', '\\', $relativePath);
return $namespace . '\\' . $relativePath;
}
/**
* Component Metadaten-Methoden
*/
protected function getComponentName(string $className): string
{
if (method_exists($className, 'getCmsName')) {
return $className::getCmsName();
}
return $this->classNameToReadable($className);
}
protected function getComponentCategory(string $className): string
{
if (method_exists($className, 'getCmsCategory')) {
return $className::getCmsCategory();
}
return 'General';
}
protected function getComponentDescription(string $className): ?string
{
if (method_exists($className, 'getCmsDescription')) {
return $className::getCmsDescription();
}
return null;
}
protected function getComponentPreview(string $className): ?string
{
if (method_exists($className, 'getCmsPreview')) {
return $className::getCmsPreview();
}
return null;
}
protected function getComponentIcon(string $className): string
{
if (method_exists($className, 'getCmsIcon')) {
return $className::getCmsIcon();
}
return 'puzzle-piece';
}
protected function getComponentTags(string $className): array
{
if (method_exists($className, 'getCmsTags')) {
return $className::getCmsTags();
}
return [];
}
protected function getComponentVersion(string $className): string
{
if (method_exists($className, 'getCmsVersion')) {
return $className::getCmsVersion();
}
return '1.0.0';
}
/**
* Validiere Feld-Definitionen
*/
protected function validateFields(array $fields): array
{
$validatedFields = [];
foreach ($fields as $field) {
if ($field instanceof BaseField) {
$validatedFields[] = $field;
} else {
\Log::warning('Invalid field type in component fields', [
'field' => $field,
'type' => gettype($field)
]);
}
}
return $validatedFields;
}
/**
* Konvertiere Klassenname zu lesbarem Namen
*/
protected function classNameToReadable(string $className): string
{
$baseName = class_basename($className);
return preg_replace('/([a-z])([A-Z])/', '$1 $2', $baseName);
}
/**
* Validiere Komponenten-Content
*/
public function validateComponentContent(string $className, array $content): array
{
if (!$this->isValidComponent($className)) {
return ['component' => ['Invalid component class: ' . $className]];
}
// Custom Validation der Komponente
if (method_exists($className, 'validateContent')) {
$customErrors = $className::validateContent($content);
if (!empty($customErrors)) {
return $customErrors;
}
}
// Standard-Validation basierend auf Feldern
return $this->validateContentByFields($className, $content);
}
/**
* Validiere Content anhand der Feld-Definitionen
*/
protected function validateContentByFields(string $className, array $content): array
{
$errors = [];
$fields = $className::getCmsFields();
$availableLocales = config('flux-cms.locales', ['de', 'en']);
foreach ($fields as $field) {
if (!$field instanceof BaseField) {
continue;
}
if ($field->isTranslatable()) {
// Validiere für alle verfügbaren Sprachen
foreach ($availableLocales as $locale) {
$value = $content[$field->getKey()][$locale] ?? null;
$fieldErrors = $field->validate($value, $locale);
if (!empty($fieldErrors)) {
$errors["{$field->getKey()}.{$locale}"] = $fieldErrors;
}
}
} else {
$value = $content[$field->getKey()] ?? null;
$fieldErrors = $field->validate($value);
if (!empty($fieldErrors)) {
$errors[$field->getKey()] = $fieldErrors;
}
}
}
return $errors;
}
/**
* Cache Management
*/
public function clearCache(): void
{
Cache::forget($this->cacheKey);
}
public function refreshCache(): array
{
$this->clearCache();
return $this->getAvailableComponents();
}
/**
* Component Path Management
*/
public function addComponentPath(string $namespace): void
{
if (!in_array($namespace, $this->componentPaths)) {
$this->componentPaths[] = $namespace;
$this->clearCache();
}
}
public function removeComponentPath(string $namespace): void
{
$this->componentPaths = array_filter($this->componentPaths, fn($path) => $path !== $namespace);
$this->clearCache();
}
public function getComponentPaths(): array
{
return $this->componentPaths;
}
/**
* Hole Komponenten nach Kategorie
*/
public function getComponentsByCategory(): array
{
$components = $this->getAvailableComponents();
$categorized = [];
foreach ($components as $component) {
$category = $component['category'] ?? 'General';
$categorized[$category][] = $component;
}
return $categorized;
}
/**
* Suche Komponenten
*/
public function searchComponents(string $query): array
{
$components = $this->getAvailableComponents();
$query = strtolower($query);
return array_filter($components, function ($component) use ($query) {
$searchFields = [
strtolower($component['name']),
strtolower($component['category']),
strtolower($component['description'] ?? ''),
strtolower(implode(' ', $component['tags'] ?? []))
];
foreach ($searchFields as $field) {
if (str_contains($field, $query)) {
return true;
}
}
return false;
});
}
/**
* Hole Komponenten-Statistiken
*/
public function getComponentStats(): array
{
$components = $this->getAvailableComponents();
$stats = [
'total' => count($components),
'categories' => [],
'most_used' => [],
];
foreach ($components as $component) {
$category = $component['category'] ?? 'General';
$stats['categories'][$category] = ($stats['categories'][$category] ?? 0) + 1;
}
return $stats;
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace FluxCms\Core\Tests\Browser;
use FluxCms\Core\Models\User;
use FluxCms\Core\Tests\DuskTestCase;
use Laravel\Dusk\Browser;
class LoginTest extends DuskTestCase
{
/**
* A basic browser test example.
*
* @return void
*/
public function test_admin_can_login_successfully()
{
$admin = User::factory()->create([
'email' => 'admin@flux-cms.com',
'password' => bcrypt('password'),
'is_admin' => true,
]);
$this->browse(function (Browser $browser) use ($admin) {
$browser->visit('/login')
->type('email', $admin->email)
->type('password', 'password')
->press('Login')
->assertPathIs('/admin/cms');
});
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace FluxCms\Core\Tests;
use Laravel\Dusk\TestCase as BaseTestCase;
use Orchestra\Testbench\Concerns\CreatesApplication;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities;
abstract class DuskTestCase extends BaseTestCase
{
use CreatesApplication;
/**
* Prepare for Dusk test execution.
*
* @beforeClass
* @return void
*/
public static function prepare()
{
if (! static::runningInSail()) {
static::startChromeDriver();
}
}
/**
* Create the RemoteWebDriver instance.
*
* @return \Facebook\WebDriver\Remote\RemoteWebDriver
*/
protected function driver()
{
$options = (new ChromeOptions)->addArguments([
'--disable-gpu',
'--headless',
'--window-size=1920,1080',
]);
return RemoteWebDriver::create(
$_ENV['DUSK_DRIVER_URL'] ?? 'http://localhost:9515',
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY,
$options
)
);
}
}

View file

@ -0,0 +1,197 @@
<?php
namespace FluxCms\Core\Tests\Feature;
use FluxCms\Core\Models\Page;
use FluxCms\Core\Models\PageComponent;
use Orchestra\Testbench\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PageManagementTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->loadLaravelMigrations();
$this->artisan('migrate');
}
public function test_can_create_page()
{
$page = Page::create([
'domain_key' => 'test',
'title' => ['de' => 'Test Seite', 'en' => 'Test Page'],
'slug' => ['de' => '/test-seite', 'en' => '/test-page'],
'is_published' => true,
]);
$this->assertDatabaseHas('flux_cms_pages', [
'id' => $page->id,
'domain_key' => 'test',
]);
$this->assertEquals('Test Seite', $page->getTranslation('title', 'de'));
$this->assertEquals('Test Page', $page->getTranslation('title', 'en'));
}
public function test_can_add_components_to_page()
{
$page = Page::create([
'domain_key' => 'test',
'title' => ['de' => 'Test Seite'],
'slug' => ['de' => '/test'],
'is_published' => true,
]);
$component = $page->allComponents()->create([
'component_class' => 'TestComponent',
'order' => 1,
'content' => [
'title' => ['de' => 'Komponenten Titel'],
'text' => ['de' => 'Komponenten Text']
],
'is_active' => true,
]);
$this->assertDatabaseHas('flux_cms_page_components', [
'page_id' => $page->id,
'component_class' => 'TestComponent',
'order' => 1,
]);
$this->assertEquals(1, $page->allComponents()->count());
$this->assertEquals('Komponenten Titel', $component->getTranslatedContent('de')['title']);
}
public function test_can_publish_and_unpublish_page()
{
$page = Page::create([
'domain_key' => 'test',
'title' => ['de' => 'Test Seite'],
'slug' => ['de' => '/test'],
'is_published' => false,
]);
$this->assertFalse($page->is_published);
$page->publish();
$this->assertTrue($page->fresh()->is_published);
$this->assertNotNull($page->fresh()->published_at);
$page->unpublish();
$this->assertFalse($page->fresh()->is_published);
}
public function test_can_create_page_version()
{
$page = Page::create([
'domain_key' => 'test',
'title' => ['de' => 'Test Seite'],
'slug' => ['de' => '/test'],
'is_published' => true,
]);
$page->allComponents()->create([
'component_class' => 'TestComponent',
'order' => 1,
'content' => ['title' => ['de' => 'Original']],
]);
$version = $page->createVersion('Initial version');
$this->assertDatabaseHas('flux_cms_page_versions', [
'page_id' => $page->id,
'change_description' => 'Initial version',
]);
$this->assertNotNull($version->page_data);
$this->assertNotNull($version->components_data);
$this->assertEquals(1, $page->versions()->count());
}
public function test_page_scope_by_domain()
{
Page::create([
'domain_key' => 'domain1',
'title' => ['de' => 'Domain 1 Seite'],
'slug' => ['de' => '/domain1'],
]);
Page::create([
'domain_key' => 'domain2',
'title' => ['de' => 'Domain 2 Seite'],
'slug' => ['de' => '/domain2'],
]);
$domain1Pages = Page::forDomain('domain1')->get();
$domain2Pages = Page::forDomain('domain2')->get();
$this->assertEquals(1, $domain1Pages->count());
$this->assertEquals(1, $domain2Pages->count());
$this->assertEquals('Domain 1 Seite', $domain1Pages->first()->getTranslation('title', 'de'));
}
public function test_page_scope_by_slug()
{
$page = Page::create([
'domain_key' => 'test',
'title' => ['de' => 'Test Seite'],
'slug' => ['de' => '/test-seite', 'en' => '/test-page'],
]);
$foundPage = Page::bySlug('/test-seite', 'de')->first();
$this->assertEquals($page->id, $foundPage->id);
$foundPage = Page::bySlug('/test-page', 'en')->first();
$this->assertEquals($page->id, $foundPage->id);
$notFound = Page::bySlug('/not-found', 'de')->first();
$this->assertNull($notFound);
}
public function test_component_can_be_duplicated()
{
$page = Page::create([
'domain_key' => 'test',
'title' => ['de' => 'Test Seite'],
'slug' => ['de' => '/test'],
]);
$originalComponent = $page->allComponents()->create([
'component_class' => 'TestComponent',
'order' => 1,
'content' => ['title' => ['de' => 'Original']],
]);
$duplicate = $originalComponent->duplicate();
$this->assertEquals(2, $page->allComponents()->count());
$this->assertEquals($originalComponent->content, $duplicate->content);
$this->assertEquals(2, $duplicate->order);
$this->assertNotEquals($originalComponent->id, $duplicate->id);
}
protected function getPackageProviders($app)
{
return [
\FluxCms\Core\FluxCmsServiceProvider::class,
];
}
protected function getEnvironmentSetUp($app)
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
$app['config']->set('flux-cms.locales', [
'de' => 'Deutsch',
'en' => 'English',
]);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace FluxCms\Core\Tests\Unit\Admin;
use FluxCms\Core\Models\User;
use FluxCms\Core\Models\BlogPost;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase;
class BlogControllerTest extends TestCase
{
use RefreshDatabase;
protected User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = User::factory()->create(['is_admin' => true]);
$this->actingAs($this->adminUser);
}
public function test_can_display_blog_posts_index()
{
BlogPost::factory()->count(3)->create();
$response = $this->get(route('admin.cms.blog.index'));
$response->assertOk();
$response->assertViewHas('posts');
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace FluxCms\Core\Tests\Unit\Admin;
use FluxCms\Core\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase;
class DashboardControllerTest extends TestCase
{
use RefreshDatabase;
protected User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = User::factory()->create(['is_admin' => true]);
$this->actingAs($this->adminUser);
}
public function test_admin_can_see_dashboard()
{
$response = $this->get(route('admin.cms.index'));
$response->assertOk();
$response->assertViewHas('stats');
$response->assertViewHas('recentPages');
}
}

View file

@ -0,0 +1,123 @@
<?php
namespace FluxCms\Core\Tests\Unit\Admin;
use FluxCms\Core\Models\User;
use FluxCms\Core\Models\Page;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase;
class PageControllerTest extends TestCase
{
use RefreshDatabase;
protected User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = User::factory()->create(['is_admin' => true]);
$this->actingAs($this->adminUser);
}
public function test_can_display_pages_index()
{
Page::factory()->count(3)->create();
$response = $this->get(route('admin.cms.pages.index'));
$response->assertOk();
$response->assertViewHas('pages');
}
public function test_can_show_create_page_form()
{
$response = $this->get(route('admin.cms.pages.create'));
$response->assertOk();
}
public function test_can_store_a_new_page()
{
$pageData = [
'domain_key' => 'default',
'title' => ['en' => 'New Page'],
'slugs' => ['en' => '/new-page'],
'is_published' => true,
];
$response = $this->post(route('admin.cms.pages.store'), $pageData);
$this->assertDatabaseHas('flux_cms_pages', ['title' => '{"en":"New Page"}']);
$this->assertDatabaseHas('flux_cms_slugs', ['slug' => '/new-page']);
$response->assertRedirect();
}
public function test_store_fails_with_invalid_data()
{
$response = $this->post(route('admin.cms.pages.store'), [
'title' => ['en' => ''], // Invalid: title is required
]);
$response->assertSessionHasErrors('title.en');
$response->assertStatus(302); // Should redirect back
}
public function test_unauthorized_user_cannot_create_page()
{
$user = User::factory()->create(); // Non-admin user
$this->actingAs($user);
$response = $this->get(route('admin.cms.pages.create'));
$response->assertStatus(403);
$response = $this->post(route('admin.cms.pages.store'), []);
$response->assertStatus(403);
}
public function test_can_show_edit_page_form()
{
$page = Page::factory()->create();
$response = $this->get(route('admin.cms.pages.edit', $page));
$response->assertOk();
$response->assertViewHas('page', $page);
}
public function test_can_update_a_page()
{
$page = Page::factory()->create();
$page->slugs()->create(['locale' => 'en', 'slug' => '/old-slug']);
$updateData = [
'title' => ['en' => 'Updated Title'],
'slugs' => ['en' => '/updated-slug'],
];
$response = $this->put(route('admin.cms.pages.update', $page), $updateData);
$this->assertDatabaseHas('flux_cms_pages', ['id' => $page->id, 'title' => '{"en":"Updated Title"}']);
$this->assertDatabaseHas('flux_cms_slugs', ['slug' => '/updated-slug']);
$response->assertRedirect();
}
public function test_unauthorized_user_cannot_edit_or_delete_page()
{
$user = User::factory()->create(); // Non-admin user
$page = Page::factory()->create();
$this->actingAs($user);
$response = $this->get(route('admin.cms.pages.edit', $page));
$response->assertStatus(403);
$response = $this->put(route('admin.cms.pages.update', $page), []);
$response->assertStatus(403);
$response = $this->delete(route('admin.cms.pages.destroy', $page));
$response->assertStatus(403);
}
public function test_can_delete_a_page()
{
$page = Page::factory()->create();
$response = $this->delete(route('admin.cms.pages.destroy', $page));
$this->assertModelMissing($page);
$response->assertRedirect(route('admin.cms.pages.index'));
}
}

View file

@ -0,0 +1,165 @@
<?php
namespace FluxCms\Core\Tests\Unit;
use FluxCms\Core\Services\ComponentRegistry;
use FluxCms\Core\FieldTypes\TextField;
use FluxCms\Core\FieldTypes\MediaField;
use Orchestra\Testbench\TestCase;
use Livewire\Component;
use Mockery;
class ComponentRegistryTest extends TestCase
{
protected ComponentRegistry $registry;
protected function setUp(): void
{
parent::setUp();
$this->registry = new ComponentRegistry();
}
public function test_can_detect_valid_component()
{
$componentClass = TestComponent::class;
$this->assertTrue($this->registry->isValidComponent($componentClass));
}
public function test_can_get_component_config()
{
$componentClass = TestComponent::class;
$config = $this->registry->getComponentConfig($componentClass);
$this->assertIsArray($config);
$this->assertEquals('Test Component', $config['name']);
$this->assertEquals('Testing', $config['category']);
$this->assertCount(2, $config['fields']);
}
public function test_can_validate_component_content()
{
$componentClass = TestComponent::class;
// Valid content
$validContent = [
'title' => ['de' => 'Test Titel', 'en' => 'Test Title'],
'image' => 123
];
$errors = $this->registry->validateComponentContent($componentClass, $validContent);
$this->assertEmpty($errors);
// Invalid content (missing required field)
$invalidContent = [
'image' => 123
];
$errors = $this->registry->validateComponentContent($componentClass, $invalidContent);
$this->assertNotEmpty($errors);
}
public function test_can_search_components()
{
// Mock some components in registry
$components = [
TestComponent::class => [
'name' => 'Test Component',
'category' => 'Testing',
'description' => 'A test component',
'tags' => ['test', 'example']
]
];
$registry = Mockery::mock(ComponentRegistry::class)->makePartial();
$registry->shouldReceive('getAvailableComponents')->andReturn($components);
$results = $registry->searchComponents('test');
$this->assertCount(1, $results);
$this->assertArrayHasKey(TestComponent::class, $results);
}
public function test_can_get_components_by_category()
{
$components = [
TestComponent::class => [
'name' => 'Test Component',
'category' => 'Testing',
],
AnotherTestComponent::class => [
'name' => 'Another Component',
'category' => 'Layout',
]
];
$registry = Mockery::mock(ComponentRegistry::class)->makePartial();
$registry->shouldReceive('getAvailableComponents')->andReturn($components);
$categorized = $registry->getComponentsByCategory();
$this->assertArrayHasKey('Testing', $categorized);
$this->assertArrayHasKey('Layout', $categorized);
$this->assertCount(1, $categorized['Testing']);
$this->assertCount(1, $categorized['Layout']);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}
// Test component classes
class TestComponent extends Component
{
public static function getCmsName(): string
{
return 'Test Component';
}
public static function getCmsCategory(): string
{
return 'Testing';
}
public static function getCmsFields(): array
{
return [
TextField::make('title', 'Title')->translatable()->required(),
MediaField::make('image', 'Image')->images(),
];
}
public function render()
{
return '<div>Test Component</div>';
}
}
class AnotherTestComponent extends Component
{
public static function getCmsName(): string
{
return 'Another Component';
}
public static function getCmsCategory(): string
{
return 'Layout';
}
public static function getCmsFields(): array
{
return [
TextField::make('content', 'Content'),
];
}
public function render()
{
return '<div>Another Component</div>';
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace FluxCms\Core\Tests\Unit\Models;
use FluxCms\Core\Models\BlogPost;
use FluxCms\Core\Models\User;
use Spatie\Tags\Tag;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase;
class BlogPostTest extends TestCase
{
use RefreshDatabase;
public function test_blog_post_has_author_relationship()
{
$user = User::factory()->create();
$post = BlogPost::factory()->create([
'author_id' => $user->id,
'author_type' => User::class
]);
$this->assertInstanceOf(User::class, $post->author);
}
public function test_can_attach_and_retrieve_tags()
{
$post = BlogPost::factory()->create();
$post->attachTag('Laravel');
$this->assertCount(1, $post->tags);
$this->assertEquals('Laravel', $post->tags->first()->name);
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace FluxCms\Core\Tests\Unit\Models;
use FluxCms\Core\Models\Page;
use FluxCms\Core\Models\Slug;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase;
class PageTest extends TestCase
{
use RefreshDatabase;
public function test_page_has_slugs_relationship()
{
$page = Page::factory()->create();
Slug::factory()->create([
'model_id' => $page->id,
'model_type' => Page::class
]);
$this->assertInstanceOf(Slug::class, $page->slugs->first());
}
public function test_published_scope_returns_only_published_pages()
{
Page::factory()->create(['is_published' => true]);
Page::factory()->create(['is_published' => false]);
$this->assertEquals(1, Page::published()->count());
}
public function test_for_domain_scope_returns_pages_for_correct_domain()
{
Page::factory()->create(['domain_key' => 'domain-a']);
Page::factory()->create(['domain_key' => 'domain-b']);
$this->assertEquals(1, Page::forDomain('domain-a')->count());
}
public function test_by_slug_with_fallback_scope_finds_page_by_slug()
{
$page = Page::factory()->create();
$page->slugs()->create(['locale' => 'en', 'slug' => 'test-slug']);
$foundPage = Page::bySlugWithFallback('test-slug', 'en')->first();
$this->assertNotNull($foundPage);
$this->assertEquals($page->id, $foundPage->id);
}
}

View file

@ -0,0 +1,22 @@
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInit3b19c1bf4f41f1c6ad6362da08433039::getLoader();

119
packages/flux-cms/core/vendor/bin/canvas vendored Executable file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../orchestra/canvas/canvas)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/orchestra/canvas/canvas');
}
}
return include __DIR__ . '/..'.'/orchestra/canvas/canvas';

119
packages/flux-cms/core/vendor/bin/carbon vendored Executable file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../nesbot/carbon/bin/carbon)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/nesbot/carbon/bin/carbon');
}
}
return include __DIR__ . '/..'.'/nesbot/carbon/bin/carbon';

119
packages/flux-cms/core/vendor/bin/paratest vendored Executable file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../brianium/paratest/bin/paratest)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/brianium/paratest/bin/paratest');
}
}
return include __DIR__ . '/..'.'/brianium/paratest/bin/paratest';

View file

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../brianium/paratest/bin/paratest_for_phpstorm)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/brianium/paratest/bin/paratest_for_phpstorm');
}
}
return include __DIR__ . '/..'.'/brianium/paratest/bin/paratest_for_phpstorm';

View file

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../symfony/error-handler/Resources/bin/patch-type-declarations)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/symfony/error-handler/Resources/bin/patch-type-declarations');
}
}
return include __DIR__ . '/..'.'/symfony/error-handler/Resources/bin/patch-type-declarations';

119
packages/flux-cms/core/vendor/bin/pest vendored Executable file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../pestphp/pest/bin/pest)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/pestphp/pest/bin/pest');
}
}
return include __DIR__ . '/..'.'/pestphp/pest/bin/pest';

119
packages/flux-cms/core/vendor/bin/php-parse vendored Executable file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../nikic/php-parser/bin/php-parse)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/nikic/php-parser/bin/php-parse');
}
}
return include __DIR__ . '/..'.'/nikic/php-parser/bin/php-parse';

122
packages/flux-cms/core/vendor/bin/phpunit vendored Executable file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../phpunit/phpunit/phpunit)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
$GLOBALS['__PHPUNIT_ISOLATION_EXCLUDE_LIST'] = $GLOBALS['__PHPUNIT_ISOLATION_BLACKLIST'] = array(realpath(__DIR__ . '/..'.'/phpunit/phpunit/phpunit'));
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = 'phpvfscomposer://'.$this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$data = str_replace('__DIR__', var_export(dirname($this->realpath), true), $data);
$data = str_replace('__FILE__', var_export($this->realpath, true), $data);
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/phpunit/phpunit/phpunit');
}
}
return include __DIR__ . '/..'.'/phpunit/phpunit/phpunit';

119
packages/flux-cms/core/vendor/bin/psysh vendored Executable file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../psy/psysh/bin/psysh)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/psy/psysh/bin/psysh');
}
}
return include __DIR__ . '/..'.'/psy/psysh/bin/psysh';

119
packages/flux-cms/core/vendor/bin/testbench vendored Executable file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../orchestra/testbench-core/testbench)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/orchestra/testbench-core/testbench');
}
}
return include __DIR__ . '/..'.'/orchestra/testbench-core/testbench';

View file

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../symfony/var-dumper/Resources/bin/var-dump-server)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/symfony/var-dumper/Resources/bin/var-dump-server');
}
}
return include __DIR__ . '/..'.'/symfony/var-dumper/Resources/bin/var-dump-server';

119
packages/flux-cms/core/vendor/bin/yaml-lint vendored Executable file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env php
<?php
/**
* Proxy PHP file generated by Composer
*
* This file includes the referenced bin path (../symfony/yaml/Resources/bin/yaml-lint)
* using a stream wrapper to prevent the shebang from being output on PHP<8
*
* @generated
*/
namespace Composer;
$GLOBALS['_composer_bin_dir'] = __DIR__;
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
if (PHP_VERSION_ID < 80000) {
if (!class_exists('Composer\BinProxyWrapper')) {
/**
* @internal
*/
final class BinProxyWrapper
{
private $handle;
private $position;
private $realpath;
public function stream_open($path, $mode, $options, &$opened_path)
{
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
$opened_path = substr($path, 17);
$this->realpath = realpath($opened_path) ?: $opened_path;
$opened_path = $this->realpath;
$this->handle = fopen($this->realpath, $mode);
$this->position = 0;
return (bool) $this->handle;
}
public function stream_read($count)
{
$data = fread($this->handle, $count);
if ($this->position === 0) {
$data = preg_replace('{^#!.*\r?\n}', '', $data);
}
$this->position += strlen($data);
return $data;
}
public function stream_cast($castAs)
{
return $this->handle;
}
public function stream_close()
{
fclose($this->handle);
}
public function stream_lock($operation)
{
return $operation ? flock($this->handle, $operation) : true;
}
public function stream_seek($offset, $whence)
{
if (0 === fseek($this->handle, $offset, $whence)) {
$this->position = ftell($this->handle);
return true;
}
return false;
}
public function stream_tell()
{
return $this->position;
}
public function stream_eof()
{
return feof($this->handle);
}
public function stream_stat()
{
return array();
}
public function stream_set_option($option, $arg1, $arg2)
{
return true;
}
public function url_stat($path, $flags)
{
$path = substr($path, 17);
if (file_exists($path)) {
return stat($path);
}
return false;
}
}
}
if (
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
) {
return include("phpvfscomposer://" . __DIR__ . '/..'.'/symfony/yaml/Resources/bin/yaml-lint');
}
}
return include __DIR__ . '/..'.'/symfony/yaml/Resources/bin/yaml-lint';

View file

@ -0,0 +1,19 @@
Copyright (c) 2013 Brian Scaturro
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.

View file

@ -0,0 +1,157 @@
ParaTest
========
[![Latest Stable Version](https://img.shields.io/packagist/v/brianium/paratest.svg)](https://packagist.org/packages/brianium/paratest)
[![Downloads](https://img.shields.io/packagist/dt/brianium/paratest.svg)](https://packagist.org/packages/brianium/paratest)
[![Integrate](https://github.com/paratestphp/paratest/workflows/CI/badge.svg)](https://github.com/paratestphp/paratest/actions)
[![Infection MSI](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fparatestphp%2Fparatest%2F7.x)](https://dashboard.stryker-mutator.io/reports/github.com/paratestphp/paratest/7.x)
The objective of ParaTest is to support parallel testing in PHPUnit. Provided you have well-written PHPUnit tests, you can drop `paratest` in your project and
start using it with no additional bootstrap or configurations!
Benefits:
* Zero configuration. After the installation, run with `vendor/bin/paratest` to parallelize by TestCase or `vendor/bin/paratest --functional` to parallelize by Test. That's it!
* Code Coverage report combining. Run your tests in N parallel processes and all the code coverage output will be combined into one report.
# Installation
To install with composer run the following command:
composer require --dev brianium/paratest
# Versions
Only the latest version of PHPUnit is supported, and thus only the latest version of ParaTest is actively maintained.
This is because of the following reasons:
1. To reduce bugs, code duplication and incompatibilities with PHPUnit, from version 5 ParaTest heavily relies on PHPUnit `@internal` classes
1. The fast pace both PHP and PHPUnit have taken recently adds too much maintenance burden, which we can only afford for the latest versions to stay up-to-date
# Usage
After installation, the binary can be found at `vendor/bin/paratest`. Run it
with `--help` option to see a complete list of the available options.
## Test token
The `TEST_TOKEN` environment variable is guaranteed to have a value that is different
from every other currently running test. This is useful to e.g. use a different database
for each test:
```php
if (getenv('TEST_TOKEN') !== false) { // Using ParaTest
$dbname = 'testdb_' . getenv('TEST_TOKEN');
} else {
$dbname = 'testdb';
}
```
A `UNIQUE_TEST_TOKEN` environment variable is also available and guaranteed to have a value that is unique both
per run and per process.
## Code coverage
The cache is always warmed up by ParaTest before executing the test suite.
### PCOV
If you have installed `pcov` but need to enable it only while running tests, you have to pass thru the needed PHP binary
option:
```
php -d pcov.enabled=1 vendor/bin/paratest --passthru-php="'-d' 'pcov.enabled=1'"
```
### xDebug
If you have `xDebug` installed, activating it by the environment variable is enough to have it running even in the subprocesses:
```
XDEBUG_MODE=coverage vendor/bin/paratest
```
## Initial setup for all tests
Because ParaTest runs multiple processes in parallel, each with their own instance of the PHP interpreter,
techniques used to perform an initialization step exactly once for each test work different from PHPUnit.
The following pattern will not work as expected - run the initialization exactly once - and instead run the
initialization once per process:
```php
private static bool $initialized = false;
public function setUp(): void
{
if (! self::$initialized) {
self::initialize();
self::$initialized = true;
}
}
```
This is because static variables persist during the execution of a single process.
In parallel testing each process has a separate instance of `$initialized`.
You can use the following pattern to ensure your initialization runs exactly once for the entire test invocation:
```php
static bool $initialized = false;
public function setUp(): void
{
if (! self::$initialized) {
// We utilize the filesystem as shared mutable state to coordinate between processes
touch('/tmp/test-initialization-lock-file');
$lockFile = fopen('/tmp/test-initialization-lock-file', 'r');
// Attempt to get an exclusive lock - first process wins
if (flock($lockFile, LOCK_EX | LOCK_NB)) {
// Since we are the single process that has an exclusive lock, we run the initialization
self::initialize();
} else {
// If no exclusive lock is available, block until the first process is done with initialization
flock($lockFile, LOCK_SH);
}
self::$initialized = true;
}
}
```
## Troubleshooting
If you run into problems with `paratest`, try to get more information about the issue by enabling debug output via
`--verbose --debug`.
When a sub-process fails, the originating command is given in the output and can then be copy-pasted in the terminal
to be run and debugged. All internal commands run with `--printer [...]\NullPhpunitPrinter` which silence the original
PHPUnit output: during a debugging run remove that option to restore the output and see what PHPUnit is doing.
## Caveats
1. Constants, static methods, static variables and everything exposed by test classes consumed by other test classes
(including Reflection) are not supported. This is due to a limitation of the current implementation of `WrapperRunner`
and how PHPUnit searches for classes. The fix is to put shared code into classes which are not tests _themselves_.
## Integration with PHPStorm
ParaTest provides a dedicated binary to work with PHPStorm; follow these steps to have ParaTest working within it:
1. Be sure you have PHPUnit already configured in PHPStorm: https://www.jetbrains.com/help/phpstorm/using-phpunit-framework.html#php_test_frameworks_phpunit_integrate
2. Go to `Run` -> `Edit configurations...`
3. Select `Add new Configuration`, select the `PHPUnit` type and name it `ParaTest`
4. In the `Command Line` -> `Interpreter options` add `./vendor/bin/paratest_for_phpstorm`
5. Any additional ParaTest options you want to pass to ParaTest should go within the `Test runner` -> `Test runner options` section
You should now have a `ParaTest` run within your configurations list.
It should natively work with the `Rerun failed tests` and `Toggle auto-test` buttons of the `Run` overlay.
### Run with Coverage
Coverage with one of the [available coverage engines](#code-coverage) must already be [configured in PHPStorm](https://www.jetbrains.com/help/phpstorm/code-coverage.html)
and working when running tests sequentially in order for the helper binary to correctly handle code coverage
# For Contributors: testing ParaTest itself
Before creating a Pull Request be sure to run all the necessary checks with `make` command.

View file

@ -0,0 +1,37 @@
#!/usr/bin/env php
<?php
$cwd = getcwd();
$files = array(
dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'autoload.php',
dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php',
dirname(__DIR__) . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php'
);
$found = false;
foreach ($files as $file) {
if (file_exists($file)) {
require $file;
$found = true;
break;
}
}
if (!$found) {
die(
'You need to set up the project dependencies using the following commands:' . PHP_EOL .
'curl -s http://getcomposer.org/installer | php' . PHP_EOL .
'php composer.phar install' . PHP_EOL
);
}
if (false === in_array(PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) {
echo PHP_EOL . 'ParaTest may only be invoked from a command line, got "' . PHP_SAPI . '"' . PHP_EOL;
exit(1);
}
assert(is_string($cwd) && '' !== $cwd);
ParaTest\ParaTestCommand::applicationFactory($cwd)->run();

View file

@ -0,0 +1,10 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use ParaTest\Util\PhpstormHelper;
require dirname(__DIR__, 1) . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'Util' . DIRECTORY_SEPARATOR . 'PhpstormHelper.php';
require PhpstormHelper::handleArgvFromPhpstorm($_SERVER['argv'], __DIR__ . '/paratest');

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
use ParaTest\WrapperRunner\WrapperWorker;
(static function (): void {
$getopt = getopt('', [
'status-file:',
'progress-file:',
'unexpected-output-file:',
'test-result-file:',
'result-cache-file:',
'teamcity-file:',
'testdox-file:',
'testdox-color',
'testdox-columns:',
'testdox-summary',
'phpunit-argv:',
]);
$composerAutoloadFiles = [
dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'autoload.php',
dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php',
dirname(__DIR__) . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php',
];
foreach ($composerAutoloadFiles as $file) {
if (file_exists($file)) {
define('PHPUNIT_COMPOSER_INSTALL', $file);
require_once $file;
break;
}
}
assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
$statusFile = fopen($getopt['status-file'], 'wb');
assert(is_resource($statusFile));
assert(isset($getopt['progress-file']) && is_string($getopt['progress-file']));
assert(isset($getopt['unexpected-output-file']) && is_string($getopt['unexpected-output-file']));
assert(isset($getopt['test-result-file']) && is_string($getopt['test-result-file']));
assert(!isset($getopt['result-cache-file']) || is_string($getopt['result-cache-file']));
assert(!isset($getopt['teamcity-file']) || is_string($getopt['teamcity-file']));
assert(!isset($getopt['testdox-file']) || is_string($getopt['testdox-file']));
assert(!isset($getopt['testdox-columns']) || $getopt['testdox-columns'] === (string) (int) $getopt['testdox-columns']);
assert(isset($getopt['phpunit-argv']) && is_string($getopt['phpunit-argv']));
$phpunitArgv = unserialize($getopt['phpunit-argv'], ['allowed_classes' => false]);
assert(is_array($phpunitArgv));
$application = new ApplicationForWrapperWorker(
$phpunitArgv,
$getopt['progress-file'],
$getopt['unexpected-output-file'],
$getopt['test-result-file'],
$getopt['result-cache-file'] ?? null,
$getopt['teamcity-file'] ?? null,
$getopt['testdox-file'] ?? null,
isset($getopt['testdox-color']),
isset($getopt['testdox-columns']) ? (int) $getopt['testdox-columns'] : null,
isset($getopt['testdox-summary']),
);
while (true) {
if (feof(STDIN)) {
$application->end();
exit;
}
$testPath = fgets(STDIN);
if ($testPath === false || $testPath === WrapperWorker::COMMAND_EXIT) {
$application->end();
exit;
}
// It must be a 1 byte string to ensure filesize() is equal to the number of tests executed
$exitCode = $application->runTest(trim($testPath, "\n"));
fwrite($statusFile, (string) $exitCode);
fflush($statusFile);
}
})();

View file

@ -0,0 +1,86 @@
{
"name": "brianium/paratest",
"description": "Parallel testing for PHP",
"license": "MIT",
"type": "library",
"keywords": [
"testing",
"PHPUnit",
"concurrent",
"parallel"
],
"authors": [
{
"name": "Brian Scaturro",
"email": "scaturrob@gmail.com",
"role": "Developer"
},
{
"name": "Filippo Tessarotto",
"email": "zoeslam@gmail.com",
"role": "Developer"
}
],
"homepage": "https://github.com/paratestphp/paratest",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/Slamdunk"
},
{
"type": "paypal",
"url": "https://paypal.me/filippotessarotto"
}
],
"require": {
"php": "~8.2.0 || ~8.3.0 || ~8.4.0",
"ext-dom": "*",
"ext-pcre": "*",
"ext-reflection": "*",
"ext-simplexml": "*",
"fidry/cpu-core-counter": "^1.2.0",
"jean85/pretty-package-versions": "^2.1.1",
"phpunit/php-code-coverage": "^11.0.10",
"phpunit/php-file-iterator": "^5.1.0",
"phpunit/php-timer": "^7.0.1",
"phpunit/phpunit": "^11.5.24",
"sebastian/environment": "^7.2.1",
"symfony/console": "^6.4.22 || ^7.3.0",
"symfony/process": "^6.4.20 || ^7.3.0"
},
"require-dev": {
"ext-pcov": "*",
"ext-posix": "*",
"doctrine/coding-standard": "^12.0.0",
"phpstan/phpstan": "^2.1.17",
"phpstan/phpstan-deprecation-rules": "^2.0.3",
"phpstan/phpstan-phpunit": "^2.0.6",
"phpstan/phpstan-strict-rules": "^2.0.4",
"squizlabs/php_codesniffer": "^3.13.2",
"symfony/filesystem": "^6.4.13 || ^7.3.0"
},
"autoload": {
"psr-4": {
"ParaTest\\": [
"src/"
]
}
},
"autoload-dev": {
"psr-4": {
"ParaTest\\Tests\\": "test/"
}
},
"bin": [
"bin/paratest",
"bin/paratest_for_phpstorm"
],
"config": {
"allow-plugins": {
"composer/package-versions-deprecated": true,
"dealerdirect/phpcodesniffer-composer-installer": true,
"infection/extension-installer": true
},
"sort-packages": true
}
}

View file

@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>Slamdunk/.github:renovate-config"
]
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace ParaTest\Coverage;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SplFileInfo;
use function assert;
/** @internal */
final readonly class CoverageMerger
{
public function __construct(
private CodeCoverage $coverage
) {
}
public function addCoverageFromFile(SplFileInfo $coverageFile): void
{
if (! $coverageFile->isFile() || $coverageFile->getSize() === 0) {
return;
}
/** @psalm-suppress UnresolvableInclude **/
$coverage = include $coverageFile->getPathname();
assert($coverage instanceof CodeCoverage);
$this->coverage->merge($coverage);
}
}

View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace ParaTest\JUnit;
use SplFileInfo;
use function array_merge;
use function assert;
use function ksort;
/**
* @internal
*
* @immutable
*/
final readonly class LogMerger
{
/** @param list<SplFileInfo> $junitFiles */
public function merge(array $junitFiles): ?TestSuite
{
$mainSuite = null;
foreach ($junitFiles as $junitFile) {
if (! $junitFile->isFile()) {
continue;
}
$otherSuite = TestSuite::fromFile($junitFile);
if ($mainSuite === null) {
$mainSuite = $otherSuite;
continue;
}
if ($mainSuite->name !== $otherSuite->name) {
if ($mainSuite->name !== '') {
$mainSuite = new TestSuite(
'',
$mainSuite->tests,
$mainSuite->assertions,
$mainSuite->failures,
$mainSuite->errors,
$mainSuite->skipped,
$mainSuite->time,
'',
[$mainSuite->name => $mainSuite],
[],
);
}
if ($otherSuite->name !== '') {
$otherSuite = new TestSuite(
'',
$otherSuite->tests,
$otherSuite->assertions,
$otherSuite->failures,
$otherSuite->errors,
$otherSuite->skipped,
$otherSuite->time,
'',
[$otherSuite->name => $otherSuite],
[],
);
}
}
$mainSuite = $this->mergeSuites($mainSuite, $otherSuite);
}
return $mainSuite;
}
private function mergeSuites(TestSuite $suite1, TestSuite $suite2): TestSuite
{
assert($suite1->name === $suite2->name);
$suites = $suite1->suites;
foreach ($suite2->suites as $suite2suiteName => $suite2suite) {
if (! isset($suites[$suite2suiteName])) {
$suites[$suite2suiteName] = $suite2suite;
continue;
}
$suites[$suite2suiteName] = $this->mergeSuites(
$suites[$suite2suiteName],
$suite2suite,
);
}
ksort($suites);
return new TestSuite(
$suite1->name,
$suite1->tests + $suite2->tests,
$suite1->assertions + $suite2->assertions,
$suite1->failures + $suite2->failures,
$suite1->errors + $suite2->errors,
$suite1->skipped + $suite2->skipped,
$suite1->time + $suite2->time,
$suite1->file,
$suites,
array_merge($suite1->cases, $suite2->cases),
);
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace ParaTest\JUnit;
/** @internal */
enum MessageType
{
case error;
case failure;
case skipped;
public function toString(): string
{
return match ($this) {
self::error => 'error',
self::failure => 'failure',
self::skipped => 'skipped',
};
}
}

View file

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace ParaTest\JUnit;
use SimpleXMLElement;
use function assert;
use function count;
use function current;
use function iterator_to_array;
use function sprintf;
/**
* @internal
*
* @immutable
*/
readonly class TestCase
{
public function __construct(
public string $name,
public string $class,
public string $file,
public int $line,
public int $assertions,
public float $time
) {
}
final public static function caseFromNode(SimpleXMLElement $node): self
{
$getFirstNode = static function (array $nodes): SimpleXMLElement {
assert(count($nodes) === 1);
$node = current($nodes);
assert($node instanceof SimpleXMLElement);
return $node;
};
$getType = static function (SimpleXMLElement $node): string {
$element = $node->attributes();
assert($element !== null);
$attributes = iterator_to_array($element);
assert($attributes !== []);
return (string) $attributes['type'];
};
if (($errors = $node->xpath('error')) !== []) {
$error = $getFirstNode($errors);
$type = $getType($error);
$text = (string) $error;
return new TestCaseWithMessage(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
$type,
$text,
MessageType::error,
);
}
if (($failures = $node->xpath('failure')) !== []) {
$failure = $getFirstNode($failures);
$type = $getType($failure);
$text = (string) $failure;
return new TestCaseWithMessage(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
$type,
$text,
MessageType::failure,
);
}
if ($node->xpath('skipped') !== []) {
$text = (string) $node['name'];
if ((string) $node['class'] !== '') {
$text = sprintf(
"%s::%s\n\n%s:%s",
$node['class'],
$node['name'],
$node['file'],
$node['line'],
);
}
return new TestCaseWithMessage(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
null,
$text,
MessageType::skipped,
);
}
return new self(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace ParaTest\JUnit;
/**
* @internal
*
* @immutable
*/
final readonly class TestCaseWithMessage extends TestCase
{
public function __construct(
string $name,
string $class,
string $file,
int $line,
int $assertions,
float $time,
public ?string $type,
public string $text,
public MessageType $xmlTagName
) {
parent::__construct($name, $class, $file, $line, $assertions, $time);
}
}

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace ParaTest\JUnit;
use SimpleXMLElement;
use SplFileInfo;
use function assert;
use function count;
use function file_get_contents;
/**
* @internal
*
* @immutable
*/
final readonly class TestSuite
{
/**
* @param array<string, TestSuite> $suites
* @param list<TestCase> $cases
*/
public function __construct(
public string $name,
public int $tests,
public int $assertions,
public int $failures,
public int $errors,
public int $skipped,
public float $time,
public string $file,
public array $suites,
public array $cases
) {
}
public static function fromFile(SplFileInfo $logFile): self
{
assert($logFile->isFile() && 0 < (int) $logFile->getSize());
$logFileContents = file_get_contents($logFile->getPathname());
assert($logFileContents !== false);
return self::parseTestSuite(
new SimpleXMLElement($logFileContents),
true,
);
}
private static function parseTestSuite(SimpleXMLElement $node, bool $isRootSuite): self
{
if ($isRootSuite) {
$tests = 0;
$assertions = 0;
$failures = 0;
$errors = 0;
$skipped = 0;
$time = 0;
} else {
$tests = (int) $node['tests'];
$assertions = (int) $node['assertions'];
$failures = (int) $node['failures'];
$errors = (int) $node['errors'];
$skipped = (int) $node['skipped'];
$time = (float) $node['time'];
}
$count = count($node->testsuite);
$suites = [];
foreach ($node->testsuite as $singleTestSuiteXml) {
$testSuite = self::parseTestSuite($singleTestSuiteXml, false);
if ($isRootSuite && $count === 1) {
return $testSuite;
}
$suites[$testSuite->name] = $testSuite;
if (! $isRootSuite) {
continue;
}
$tests += $testSuite->tests;
$assertions += $testSuite->assertions;
$failures += $testSuite->failures;
$errors += $testSuite->errors;
$skipped += $testSuite->skipped;
$time += $testSuite->time;
}
$cases = [];
foreach ($node->testcase as $singleTestCase) {
$cases[] = TestCase::caseFromNode($singleTestCase);
}
return new self(
(string) $node['name'],
$tests,
$assertions,
$failures,
$errors,
$skipped,
$time,
(string) $node['file'],
$suites,
$cases,
);
}
}

View file

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace ParaTest\JUnit;
use DOMDocument;
use DOMElement;
use function assert;
use function dirname;
use function file_put_contents;
use function htmlspecialchars;
use function is_dir;
use function is_int;
use function is_string;
use function mkdir;
use function sprintf;
use function str_replace;
use const ENT_XML1;
/** @internal */
final readonly class Writer
{
private const TESTSUITES_NAME = 'PHPUnit tests';
private DOMDocument $document;
public function __construct()
{
$this->document = new DOMDocument('1.0', 'UTF-8');
$this->document->formatOutput = true;
}
public function write(TestSuite $testSuite, string $path): void
{
$dir = dirname($path);
if (! is_dir($dir)) {
mkdir($dir, 0777, true);
}
$result = file_put_contents($path, $this->getXml($testSuite));
assert(is_int($result) && 0 < $result);
}
/** @return non-empty-string */
private function getXml(TestSuite $testSuite): string
{
$xmlTestsuites = $this->document->createElement('testsuites');
$xmlTestsuites->appendChild($this->createSuiteNode($testSuite));
$xmlTestsuites->setAttribute('name', self::TESTSUITES_NAME);
$this->document->appendChild($xmlTestsuites);
$xml = $this->document->saveXML();
assert(is_string($xml) && $xml !== '');
return $xml;
}
private function createSuiteNode(TestSuite $parentSuite): DOMElement
{
$suiteNode = $this->document->createElement('testsuite');
$suiteNode->setAttribute('name', $parentSuite->name !== self::TESTSUITES_NAME ? $parentSuite->name : '');
if ($parentSuite->file !== '') {
$suiteNode->setAttribute('file', $parentSuite->file);
}
$suiteNode->setAttribute('tests', (string) $parentSuite->tests);
$suiteNode->setAttribute('assertions', (string) $parentSuite->assertions);
$suiteNode->setAttribute('errors', (string) $parentSuite->errors);
$suiteNode->setAttribute('failures', (string) $parentSuite->failures);
$suiteNode->setAttribute('skipped', (string) $parentSuite->skipped);
$suiteNode->setAttribute('time', (string) $parentSuite->time);
foreach ($parentSuite->suites as $suite) {
$suiteNode->appendChild($this->createSuiteNode($suite));
}
foreach ($parentSuite->cases as $case) {
$suiteNode->appendChild($this->createCaseNode($case));
}
return $suiteNode;
}
private function createCaseNode(TestCase $case): DOMElement
{
$caseNode = $this->document->createElement('testcase');
$caseNode->setAttribute('name', $case->name);
$caseNode->setAttribute('class', $case->class);
$caseNode->setAttribute('classname', str_replace('\\', '.', $case->class));
$caseNode->setAttribute('file', $case->file);
$caseNode->setAttribute('line', (string) $case->line);
$caseNode->setAttribute('assertions', (string) $case->assertions);
$caseNode->setAttribute('time', sprintf('%F', $case->time));
if ($case instanceof TestCaseWithMessage) {
if ($case->xmlTagName === MessageType::skipped) {
$defectNode = $this->document->createElement($case->xmlTagName->toString());
} else {
$defectNode = $this->document->createElement($case->xmlTagName->toString(), htmlspecialchars($case->text, ENT_XML1));
$type = $case->type;
if ($type !== null) {
$defectNode->setAttribute('type', $type);
}
}
$caseNode->appendChild($defectNode);
}
return $caseNode;
}
}

View file

@ -0,0 +1,643 @@
<?php
declare(strict_types=1);
namespace ParaTest;
use Fidry\CpuCoreCounter\CpuCoreCounter;
use Fidry\CpuCoreCounter\NumberOfCpuCoreNotFound;
use PHPUnit\TextUI\Configuration\Builder;
use PHPUnit\TextUI\Configuration\Configuration;
use RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Process\Process;
use function array_filter;
use function array_intersect_key;
use function array_key_exists;
use function array_shift;
use function assert;
use function count;
use function dirname;
use function escapeshellarg;
use function file_exists;
use function is_array;
use function is_bool;
use function is_numeric;
use function is_string;
use function realpath;
use function sprintf;
use function str_starts_with;
use function strlen;
use function substr;
use function sys_get_temp_dir;
use function uniqid;
use function unserialize;
use const PHP_BINARY;
/**
* @internal
*
* @immutable
*/
final readonly class Options
{
public const ENV_KEY_TOKEN = 'TEST_TOKEN';
public const ENV_KEY_UNIQUE_TOKEN = 'UNIQUE_TEST_TOKEN';
private const OPTIONS_TO_KEEP_FOR_PHPUNIT_IN_WORKER = [
'bootstrap' => true,
'cache-directory' => true,
'configuration' => true,
'coverage-filter' => true,
'dont-report-useless-tests' => true,
'exclude-group' => true,
'fail-on-incomplete' => true,
'fail-on-risky' => true,
'fail-on-skipped' => true,
'fail-on-warning' => true,
'fail-on-deprecation' => true,
'filter' => true,
'group' => true,
'no-configuration' => true,
'order-by' => true,
'process-isolation' => true,
'random-order-seed' => true,
'stop-on-defect' => true,
'stop-on-error' => true,
'stop-on-warning' => true,
'stop-on-risky' => true,
'stop-on-skipped' => true,
'stop-on-incomplete' => true,
'strict-coverage' => true,
'strict-global-state' => true,
'disallow-test-output' => true,
];
public readonly bool $needsTeamcity;
/**
* @param non-empty-string $phpunit
* @param non-empty-string $cwd
* @param list<non-empty-string>|null $passthruPhp
* @param array<non-empty-string, non-empty-string|true|list<non-empty-string>> $phpunitOptions
* @param non-empty-string $runner
* @param non-empty-string $tmpDir
*/
public function __construct(
public Configuration $configuration,
public string $phpunit,
public string $cwd,
public int $maxBatchSize,
public bool $noTestTokens,
public ?array $passthruPhp,
public array $phpunitOptions,
public int $processes,
public string $runner,
public string $tmpDir,
public bool $verbose,
public bool $functional,
) {
$this->needsTeamcity = $configuration->outputIsTeamCity() || $configuration->hasLogfileTeamcity();
}
/** @param non-empty-string $cwd */
public static function fromConsoleInput(InputInterface $input, string $cwd): self
{
$options = $input->getOptions();
$maxBatchSize = (int) $options['max-batch-size'];
unset($options['max-batch-size']);
assert(is_bool($options['no-test-tokens']));
$noTestTokens = $options['no-test-tokens'];
unset($options['no-test-tokens']);
assert($options['passthru-php'] === null || is_string($options['passthru-php']));
$passthruPhp = self::parsePassthru($options['passthru-php']);
unset($options['passthru-php']);
assert(is_string($options['processes']));
$processes = is_numeric($options['processes'])
? (int) $options['processes']
: self::getNumberOfCPUCores();
unset($options['processes']);
assert(is_string($options['runner']) && $options['runner'] !== '');
$runner = $options['runner'];
unset($options['runner']);
assert(is_string($options['tmp-dir']) && $options['tmp-dir'] !== '');
$tmpDir = $options['tmp-dir'];
unset($options['tmp-dir']);
assert(is_bool($options['verbose']));
$verbose = $options['verbose'];
unset($options['verbose']);
assert(is_bool($options['functional']));
$functional = $options['functional'];
unset($options['functional']);
assert(array_key_exists('colors', $options));
if ($options['colors'] === Configuration::COLOR_DEFAULT) {
unset($options['colors']);
} elseif ($options['colors'] === null) {
$options['colors'] = Configuration::COLOR_AUTO;
}
assert(array_key_exists('coverage-text', $options));
if ($options['coverage-text'] === null) {
$options['coverage-text'] = 'php://stdout';
}
// Must be a static non-customizable reference because ParaTest code
// is strictly coupled with PHPUnit pinned version
$phpunit = self::getPhpunitBinary();
if (str_starts_with($phpunit, $cwd)) {
$phpunit = substr($phpunit, 1 + strlen($cwd));
}
$phpunitArgv = [$phpunit];
foreach ($options as $key => $value) {
if ($value === null || $value === false) {
continue;
}
if ($value === true) {
$phpunitArgv[] = "--{$key}";
continue;
}
if (! is_array($value)) {
$value = [$value];
}
foreach ($value as $innerValue) {
$phpunitArgv[] = "--{$key}={$innerValue}";
}
}
if (($path = $input->getArgument('path')) !== null) {
assert(is_string($path));
$phpunitArgv[] = '--';
$phpunitArgv[] = $path;
}
$phpunitOptions = array_intersect_key($options, self::OPTIONS_TO_KEEP_FOR_PHPUNIT_IN_WORKER);
$phpunitOptions = array_filter($phpunitOptions);
$configuration = (new Builder())->build($phpunitArgv);
return new self(
$configuration,
$phpunit,
$cwd,
$maxBatchSize,
$noTestTokens,
$passthruPhp,
$phpunitOptions,
$processes,
$runner,
$tmpDir,
$verbose,
$functional,
);
}
public static function setInputDefinition(InputDefinition $inputDefinition): void
{
$inputDefinition->setDefinition([
// Arguments
new InputArgument(
'path',
InputArgument::OPTIONAL,
'The path to a directory or file containing tests.',
),
// ParaTest options
new InputOption(
'functional',
null,
InputOption::VALUE_NONE,
'Whether to enable functional testing, for unit and dataset parallelization',
),
new InputOption(
'max-batch-size',
'm',
InputOption::VALUE_REQUIRED,
'Max batch size.',
'0',
),
new InputOption(
'no-test-tokens',
null,
InputOption::VALUE_NONE,
'Disable TEST_TOKEN environment variables.',
),
new InputOption(
'passthru-php',
null,
InputOption::VALUE_REQUIRED,
'Pass the given arguments verbatim to the underlying php process. Example: --passthru-php="\'-d\' ' .
'\'pcov.enabled=1\'"',
),
new InputOption(
'processes',
'p',
InputOption::VALUE_REQUIRED,
'The number of test processes to run.',
'auto',
),
new InputOption(
'runner',
null,
InputOption::VALUE_REQUIRED,
sprintf('A %s.', RunnerInterface::class),
'WrapperRunner',
),
new InputOption(
'tmp-dir',
null,
InputOption::VALUE_REQUIRED,
'Temporary directory for internal ParaTest files',
sys_get_temp_dir(),
),
new InputOption(
'verbose',
'v',
InputOption::VALUE_NONE,
'Output more verbose information',
),
// PHPUnit options
new InputOption(
'bootstrap',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter = 'Configuration',
),
new InputOption(
'configuration',
'c',
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'no-configuration',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'cache-directory',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'testsuite',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter = 'Selection',
),
new InputOption(
'exclude-testsuite',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'group',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'exclude-group',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'filter',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'process-isolation',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter = 'Execution',
),
new InputOption(
'strict-coverage',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'strict-global-state',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'disallow-test-output',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'dont-report-useless-tests',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-defect',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-error',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-failure',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-warning',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-risky',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-skipped',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-incomplete',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'fail-on-incomplete',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'fail-on-risky',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'fail-on-skipped',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'fail-on-warning',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'fail-on-deprecation',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'order-by',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'random-order-seed',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'colors',
null,
InputOption::VALUE_OPTIONAL,
'@see PHPUnit guide, chapter: ' . $chapter = 'Reporting',
Configuration::COLOR_DEFAULT,
),
new InputOption(
'no-progress',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'display-incomplete',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'display-skipped',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'display-deprecations',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'display-errors',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'display-notices',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'display-warnings',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'teamcity',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'testdox',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'log-junit',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter = 'Logging',
),
new InputOption(
'log-teamcity',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'coverage-clover',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter = 'Code Coverage',
),
new InputOption(
'coverage-cobertura',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'coverage-crap4j',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'coverage-html',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'coverage-php',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'coverage-text',
null,
InputOption::VALUE_OPTIONAL,
'@see PHPUnit guide, chapter: ' . $chapter,
false,
),
new InputOption(
'coverage-xml',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'coverage-filter',
null,
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'no-coverage',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
]);
}
/** @return non-empty-string $phpunit the path to phpunit */
private static function getPhpunitBinary(): string
{
$tryPaths = [
dirname(__DIR__, 3) . '/bin/phpunit',
dirname(__DIR__, 3) . '/phpunit/phpunit/phpunit',
dirname(__DIR__) . '/vendor/phpunit/phpunit/phpunit',
];
foreach ($tryPaths as $path) {
if (($realPath = realpath($path)) !== false && file_exists($realPath)) {
return $realPath;
}
}
throw new RuntimeException('PHPUnit not found'); // @codeCoverageIgnore
}
public static function getNumberOfCPUCores(): int
{
try {
return (new CpuCoreCounter())->getCount();
} catch (NumberOfCpuCoreNotFound) {
return 2;
}
}
/** @return list<non-empty-string>|null */
private static function parsePassthru(?string $param): ?array
{
if ($param === null) {
return null;
}
$stringToArgumentProcess = Process::fromShellCommandline(
sprintf(
'%s -r %s -- %s',
escapeshellarg(PHP_BINARY),
escapeshellarg('echo serialize($argv);'),
$param,
),
);
$stringToArgumentProcess->mustRun();
$passthruAsArguments = unserialize($stringToArgumentProcess->getOutput());
assert(is_array($passthruAsArguments));
array_shift($passthruAsArguments);
if (count($passthruAsArguments) === 0) {
return null;
}
return $passthruAsArguments;
}
/** @return array{PARATEST: int, TEST_TOKEN?: int, UNIQUE_TEST_TOKEN?: non-empty-string} */
public function fillEnvWithTokens(int $inc): array
{
$env = ['PARATEST' => 1];
if (! $this->noTestTokens) {
$env[self::ENV_KEY_TOKEN] = $inc;
$env[self::ENV_KEY_UNIQUE_TOKEN] = uniqid($inc . '_');
}
return $env;
}
}

View file

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace ParaTest;
use InvalidArgumentException;
use Jean85\PrettyVersions;
use ParaTest\WrapperRunner\WrapperRunner;
use PHPUnit\Runner\Version;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function assert;
use function class_exists;
use function is_string;
use function is_subclass_of;
use function sprintf;
/** @internal */
final class ParaTestCommand extends Command
{
public const COMMAND_NAME = 'paratest';
private const KNOWN_RUNNERS = [
'WrapperRunner' => WrapperRunner::class,
];
/** @param non-empty-string $cwd */
public function __construct(
private readonly string $cwd,
?string $name = null
) {
parent::__construct($name);
}
/** @param non-empty-string $cwd */
public static function applicationFactory(string $cwd): Application
{
$application = new Application();
$command = new self($cwd, self::COMMAND_NAME);
$application->setName('ParaTest');
$application->setVersion(PrettyVersions::getVersion('brianium/paratest')->getPrettyVersion());
$application->add($command);
$commandName = $command->getName();
assert($commandName !== null);
$application->setDefaultCommand($commandName, true);
return $application;
}
protected function configure(): void
{
Options::setInputDefinition($this->getDefinition());
}
/**
* {@inheritDoc}
*/
public function mergeApplicationDefinition($mergeArgs = true): void
{
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$application = $this->getApplication();
assert($application !== null);
$output->write(sprintf(
"%s upon %s\n\n",
$application->getLongVersion(),
Version::getVersionString(),
));
$options = Options::fromConsoleInput(
$input,
$this->cwd,
);
if (! $options->configuration->hasConfigurationFile() && ! $options->configuration->hasCliArguments()) {
return $this->displayHelp($output);
}
$runnerClass = $this->getRunnerClass($input);
return (new $runnerClass($options, $output))->run();
}
private function displayHelp(OutputInterface $output): int
{
$app = $this->getApplication();
assert($app !== null);
$help = $app->find('help');
$input = new ArrayInput(['command_name' => $this->getName()]);
return $help->run($input, $output);
}
/** @return class-string<RunnerInterface> */
private function getRunnerClass(InputInterface $input): string
{
$runnerClass = $input->getOption('runner');
assert(is_string($runnerClass));
$runnerClass = self::KNOWN_RUNNERS[$runnerClass] ?? $runnerClass;
if (! class_exists($runnerClass) || ! is_subclass_of($runnerClass, RunnerInterface::class)) {
throw new InvalidArgumentException(sprintf(
'Selected runner class "%s" does not exist or does not implement %s',
$runnerClass,
RunnerInterface::class,
));
}
return $runnerClass;
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace ParaTest;
interface RunnerInterface
{
public const SUCCESS_EXIT = 0;
public const FAILURE_EXIT = 1;
public const EXCEPTION_EXIT = 2;
public function run(): int;
}

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace ParaTest\Util;
use RuntimeException;
use function array_search;
use function array_unshift;
use function in_array;
use function str_ends_with;
/** @internal */
final readonly class PhpstormHelper
{
/** @param array<int, string> $argv */
public static function handleArgvFromPhpstorm(array &$argv, string $paratestBinary): string
{
$phpunitKey = self::getArgvKeyFor($argv, '/phpunit');
if (! in_array('--filter', $argv, true)) {
$coverageArgKey = self::getCoverageArgvKey($argv);
if ($coverageArgKey !== false) {
unset($argv[$coverageArgKey]);
}
unset($argv[$phpunitKey]);
return $paratestBinary;
}
unset($argv[self::getArgvKeyFor($argv, '/paratest_for_phpstorm')]);
$phpunitBinary = $argv[$phpunitKey];
foreach ($argv as $index => $value) {
if ($value === '--configuration' || $value === '--bootstrap') {
break;
}
unset($argv[$index]);
}
array_unshift($argv, $phpunitBinary);
return $phpunitBinary;
}
/** @param array<int, string> $argv */
private static function getArgvKeyFor(array $argv, string $searchFor): int
{
foreach ($argv as $key => $arg) {
if (str_ends_with($arg, $searchFor)) {
return $key;
}
}
throw new RuntimeException("Missing path to '$searchFor'");
}
/**
* @param array<int, string> $argv
*
* @return int|false
*/
private static function getCoverageArgvKey(array $argv)
{
$coverageOptions = [
'-dpcov.enabled=1',
'-dxdebug.mode=coverage',
];
foreach ($coverageOptions as $coverageOption) {
$key = array_search($coverageOption, $argv, true);
if ($key !== false) {
return $key;
}
}
return false;
}
}

View file

@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use ParaTest\RunnerInterface;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Event\TestSuite\TestSuiteBuilder;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Logging\JUnit\JunitXmlLogger;
use PHPUnit\Logging\TeamCity\TeamCityLogger;
use PHPUnit\Logging\TestDox\TestResultCollector;
use PHPUnit\Runner\Baseline\CannotLoadBaselineException;
use PHPUnit\Runner\Baseline\Reader;
use PHPUnit\Runner\CodeCoverage;
use PHPUnit\Runner\DeprecationCollector\Facade as DeprecationCollector;
use PHPUnit\Runner\ErrorHandler;
use PHPUnit\Runner\Extension\ExtensionBootstrapper;
use PHPUnit\Runner\Extension\Facade as ExtensionFacade;
use PHPUnit\Runner\Extension\PharLoader;
use PHPUnit\Runner\Filter\Factory;
use PHPUnit\Runner\ResultCache\DefaultResultCache;
use PHPUnit\Runner\ResultCache\ResultCacheHandler;
use PHPUnit\Runner\TestSuiteLoader;
use PHPUnit\Runner\TestSuiteSorter;
use PHPUnit\TestRunner\IssueFilter;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TextUI\Configuration\Builder;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\TextUI\Configuration\Configuration;
use PHPUnit\TextUI\Configuration\PhpHandler;
use PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter;
use PHPUnit\TextUI\Output\Default\UnexpectedOutputPrinter;
use PHPUnit\TextUI\Output\DefaultPrinter;
use PHPUnit\TextUI\Output\NullPrinter;
use PHPUnit\TextUI\Output\TestDox\ResultPrinter as TestDoxResultPrinter;
use PHPUnit\TextUI\TestSuiteFilterProcessor;
use PHPUnit\Util\ExcludeList;
use function assert;
use function file_put_contents;
use function is_file;
use function mt_srand;
use function serialize;
use function str_ends_with;
use function strpos;
use function substr;
/**
* @internal
*
* @codeCoverageIgnore
*/
final class ApplicationForWrapperWorker
{
private bool $hasBeenBootstrapped = false;
private Configuration $configuration;
private TestResultCollector $testdoxResultCollector;
/** @param list<string> $argv */
public function __construct(
private readonly array $argv,
private readonly string $progressFile,
private readonly string $unexpectedOutputFile,
private readonly string $testResultFile,
private readonly ?string $resultCacheFile,
private readonly ?string $teamcityFile,
private readonly ?string $testdoxFile,
private readonly bool $testdoxColor,
private readonly ?int $testdoxColumns,
) {
}
public function runTest(string $testPath): int
{
$null = strpos($testPath, "\0");
$filter = null;
if ($null !== false) {
$filter = new Factory();
$name = substr($testPath, $null + 1);
assert($name !== '');
$filter->addIncludeNameFilter($name);
$testPath = substr($testPath, 0, $null);
}
$this->bootstrap();
if (is_file($testPath) && str_ends_with($testPath, '.phpt')) {
$testSuite = TestSuite::empty($testPath);
$testSuite->addTestFile($testPath);
} else {
$testSuiteRefl = (new TestSuiteLoader())->load($testPath);
$testSuite = TestSuite::fromClassReflector($testSuiteRefl);
}
EventFacade::emitter()->testSuiteLoaded(
TestSuiteBuilder::from($testSuite),
);
(new TestSuiteFilterProcessor())->process($this->configuration, $testSuite);
if ($filter !== null) {
$testSuite->injectFilter($filter);
EventFacade::emitter()->testSuiteFiltered(
TestSuiteBuilder::from($testSuite),
);
}
EventFacade::emitter()->testRunnerExecutionStarted(
TestSuiteBuilder::from($testSuite),
);
$testSuite->run();
return TestResultFacade::result()->wasSuccessful()
? RunnerInterface::SUCCESS_EXIT
: RunnerInterface::FAILURE_EXIT;
}
private function bootstrap(): void
{
if ($this->hasBeenBootstrapped) {
return;
}
ExcludeList::addDirectory(__DIR__);
EventFacade::emitter()->applicationStarted();
$this->configuration = (new Builder())->build($this->argv);
(new PhpHandler())->handle($this->configuration->php());
if ($this->configuration->hasBootstrap()) {
$bootstrapFilename = $this->configuration->bootstrap();
include_once $bootstrapFilename;
EventFacade::emitter()->testRunnerBootstrapFinished($bootstrapFilename);
}
$extensionRequiresCodeCoverageCollection = false;
if (! $this->configuration->noExtensions()) {
if ($this->configuration->hasPharExtensionDirectory()) {
(new PharLoader())->loadPharExtensionsInDirectory(
$this->configuration->pharExtensionDirectory(),
);
}
$extensionFacade = new ExtensionFacade();
$extensionBootstrapper = new ExtensionBootstrapper(
$this->configuration,
$extensionFacade,
);
foreach ($this->configuration->extensionBootstrappers() as $bootstrapper) {
$extensionBootstrapper->bootstrap(
$bootstrapper['className'],
$bootstrapper['parameters'],
);
}
$extensionRequiresCodeCoverageCollection = $extensionFacade->requiresCodeCoverageCollection();
}
if ($this->configuration->hasLogfileJunit()) {
new JunitXmlLogger(
DefaultPrinter::from($this->configuration->logfileJunit()),
EventFacade::instance(),
);
}
$printer = new ProgressPrinterOutput(
DefaultPrinter::from($this->progressFile),
DefaultPrinter::from($this->unexpectedOutputFile),
);
new UnexpectedOutputPrinter($printer, EventFacade::instance());
new ProgressPrinter(
$printer,
EventFacade::instance(),
false,
99999,
$this->configuration->source(),
);
if (isset($this->teamcityFile)) {
new TeamCityLogger(
DefaultPrinter::from($this->teamcityFile),
EventFacade::instance(),
);
}
if (isset($this->testdoxFile)) {
$this->testdoxResultCollector = new TestResultCollector(
EventFacade::instance(),
new IssueFilter($this->configuration->source()),
);
}
TestResultFacade::init();
DeprecationCollector::init();
if (isset($this->resultCacheFile)) {
new ResultCacheHandler(
new DefaultResultCache($this->resultCacheFile),
EventFacade::instance(),
);
}
if ($this->configuration->source()->useBaseline()) {
$baselineFile = $this->configuration->source()->baseline();
$baseline = null;
try {
$baseline = (new Reader())->read($baselineFile);
} catch (CannotLoadBaselineException $e) {
EventFacade::emitter()->testRunnerTriggeredPhpunitWarning($e->getMessage());
}
if ($baseline !== null) {
ErrorHandler::instance()->useBaseline($baseline);
}
}
EventFacade::instance()->seal();
CodeCoverage::instance()->init(
$this->configuration,
CodeCoverageFilterRegistry::instance(),
$extensionRequiresCodeCoverageCollection,
);
EventFacade::emitter()->testRunnerStarted();
if ($this->configuration->executionOrder() === TestSuiteSorter::ORDER_RANDOMIZED) {
mt_srand($this->configuration->randomOrderSeed());
}
$this->hasBeenBootstrapped = true;
}
public function end(): void
{
if (! $this->hasBeenBootstrapped) {
return;
}
EventFacade::emitter()->testRunnerExecutionFinished();
EventFacade::emitter()->testRunnerFinished();
CodeCoverage::instance()->generateReports(new NullPrinter(), $this->configuration);
$result = TestResultFacade::result();
if (isset($this->testdoxResultCollector)) {
assert(isset($this->testdoxFile));
assert(isset($this->testdoxColumns));
(new TestDoxResultPrinter(DefaultPrinter::from($this->testdoxFile), $this->testdoxColor, $this->testdoxColumns, false))->print(
$result,
$this->testdoxResultCollector->testMethodsGroupedByClass(),
);
}
file_put_contents($this->testResultFile, serialize($result));
EventFacade::emitter()->applicationFinished(0);
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use PHPUnit\TextUI\Output\Printer;
use function preg_match;
/** @internal */
final readonly class ProgressPrinterOutput implements Printer
{
public function __construct(
private Printer $progressPrinter,
private Printer $outputPrinter,
) {
}
public function print(string $buffer): void
{
// Skip anything in \PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter::printProgress except $progress
if (
$buffer === "\n"
|| preg_match('/^ +$/', $buffer) === 1
|| preg_match('/^ \\d+ \\/ \\d+ \\(...%\\)$/', $buffer) === 1
) {
return;
}
match ($buffer) {
'E', 'F', 'I', 'N', 'D', 'R', 'W', 'S', '.' => $this->progressPrinter->print($buffer),
default => $this->outputPrinter->print($buffer),
};
}
public function flush(): void
{
$this->progressPrinter->flush();
$this->outputPrinter->flush();
}
}

View file

@ -0,0 +1,334 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use ParaTest\Options;
use PHPUnit\Runner\TestSuiteSorter;
use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Output\Default\ResultPrinter as DefaultResultPrinter;
use PHPUnit\TextUI\Output\Printer;
use PHPUnit\TextUI\Output\SummaryPrinter;
use PHPUnit\Util\Color;
use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\Timer\ResourceUsageFormatter;
use SplFileInfo;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Output\OutputInterface;
use function assert;
use function fclose;
use function feof;
use function floor;
use function fopen;
use function fread;
use function fseek;
use function ftell;
use function fwrite;
use function sprintf;
use function str_repeat;
use function strlen;
use const DIRECTORY_SEPARATOR;
use const PHP_EOL;
use const PHP_VERSION;
/** @internal */
final class ResultPrinter
{
public readonly Printer $printer;
private int $numTestsWidth = 0;
private int $maxColumn = 0;
private int $totalCases = 0;
private int $column = 0;
private int $casesProcessed = 0;
private int $numberOfColumns;
/** @var resource|null */
private $teamcityLogFileHandle;
/** @var array<non-empty-string, int> */
private array $tailPositions;
public function __construct(
private readonly OutputInterface $output,
private readonly Options $options
) {
$this->printer = new class ($this->output) implements Printer {
public function __construct(
private readonly OutputInterface $output,
) {
}
public function print(string $buffer): void
{
$this->output->write(OutputFormatter::escape($buffer));
}
public function flush(): void
{
}
};
$this->numberOfColumns = $this->options->configuration->columns();
if (! $this->options->configuration->hasLogfileTeamcity()) {
return;
}
$teamcityLogFileHandle = fopen($this->options->configuration->logfileTeamcity(), 'ab+');
assert($teamcityLogFileHandle !== false);
$this->teamcityLogFileHandle = $teamcityLogFileHandle;
}
public function setTestCount(int $testCount): void
{
$this->totalCases = $testCount;
}
public function start(): void
{
$this->numTestsWidth = strlen((string) $this->totalCases);
$this->maxColumn = $this->numberOfColumns
+ (DIRECTORY_SEPARATOR === '\\' ? -1 : 0) // fix windows blank lines
- strlen($this->getProgress());
// @see \PHPUnit\TextUI\TestRunner::writeMessage()
$output = $this->output;
$write = static function (string $type, string $message) use ($output): void {
$output->write(sprintf("%-15s%s\n", $type . ':', $message));
};
// @see \PHPUnit\TextUI\Application::writeRuntimeInformation()
$write('Processes', (string) $this->options->processes);
$runtime = 'PHP ' . PHP_VERSION;
if ($this->options->configuration->hasCoverageReport()) {
$filter = new Filter();
if ($this->options->configuration->pathCoverage()) {
$codeCoverageDriver = (new Selector())->forLineAndPathCoverage($filter); // @codeCoverageIgnore
} else {
$codeCoverageDriver = (new Selector())->forLineCoverage($filter);
}
$runtime .= ' with ' . $codeCoverageDriver->nameAndVersion();
}
$write('Runtime', $runtime);
if ($this->options->configuration->hasConfigurationFile()) {
$write('Configuration', $this->options->configuration->configurationFile());
}
if ($this->options->configuration->executionOrder() === TestSuiteSorter::ORDER_RANDOMIZED) {
$write('Random Seed', (string) $this->options->configuration->randomOrderSeed());
}
$output->write("\n");
}
public function printFeedback(
SplFileInfo $progressFile,
SplFileInfo $outputFile,
SplFileInfo|null $teamcityFile
): void {
if ($this->options->needsTeamcity && $teamcityFile !== null) {
$teamcityProgress = $this->tailMultiple([$teamcityFile]);
if ($this->teamcityLogFileHandle !== null) {
fwrite($this->teamcityLogFileHandle, $teamcityProgress);
}
}
if ($this->options->configuration->outputIsTeamCity()) {
assert(isset($teamcityProgress));
$this->output->write($teamcityProgress);
return;
}
if ($this->options->configuration->noProgress()) {
return;
}
$unexpectedOutput = $this->tail($outputFile);
if ($unexpectedOutput !== '') {
$this->output->write($unexpectedOutput);
}
$feedbackItems = $this->tail($progressFile);
if ($feedbackItems === '') {
return;
}
$actualTestCount = strlen($feedbackItems);
for ($index = 0; $index < $actualTestCount; ++$index) {
$this->printFeedbackItem($feedbackItems[$index]);
}
}
/**
* @param list<SplFileInfo> $teamcityFiles
* @param list<SplFileInfo> $testdoxFiles
*/
public function printResults(TestResult $testResult, array $teamcityFiles, array $testdoxFiles): void
{
if ($this->options->needsTeamcity) {
$teamcityProgress = $this->tailMultiple($teamcityFiles);
if ($this->teamcityLogFileHandle !== null) {
fwrite($this->teamcityLogFileHandle, $teamcityProgress);
$resource = $this->teamcityLogFileHandle;
$this->teamcityLogFileHandle = null;
fclose($resource);
}
}
if ($this->options->configuration->outputIsTeamCity()) {
assert(isset($teamcityProgress));
$this->output->write($teamcityProgress);
return;
}
$this->printer->print(PHP_EOL . (new ResourceUsageFormatter())->resourceUsageSinceStartOfRequest() . PHP_EOL . PHP_EOL);
$defaultResultPrinter = new DefaultResultPrinter(
$this->printer,
true,
true,
$this->options->configuration->displayDetailsOnPhpunitDeprecations(),
true,
true,
true,
$this->options->configuration->displayDetailsOnIncompleteTests(),
$this->options->configuration->displayDetailsOnSkippedTests(),
$this->options->configuration->displayDetailsOnTestsThatTriggerDeprecations(),
$this->options->configuration->displayDetailsOnTestsThatTriggerErrors(),
$this->options->configuration->displayDetailsOnTestsThatTriggerNotices(),
$this->options->configuration->displayDetailsOnTestsThatTriggerWarnings(),
false,
);
if ($this->options->configuration->outputIsTestDox()) {
$this->output->write($this->tailMultiple($testdoxFiles));
$defaultResultPrinter = new DefaultResultPrinter(
$this->printer,
true,
true,
$this->options->configuration->displayDetailsOnPhpunitDeprecations(),
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
);
}
$defaultResultPrinter->print($testResult);
(new SummaryPrinter(
$this->printer,
$this->options->configuration->colors(),
))->print($testResult);
}
private function printFeedbackItem(string $item): void
{
$this->printFeedbackItemColor($item);
++$this->column;
++$this->casesProcessed;
if ($this->column !== $this->maxColumn && $this->casesProcessed < $this->totalCases) {
return;
}
if (
$this->casesProcessed > 0
&& $this->casesProcessed === $this->totalCases
&& ($pad = $this->maxColumn - $this->column) > 0
) {
$this->output->write(str_repeat(' ', $pad));
}
$this->output->write($this->getProgress() . "\n");
$this->column = 0;
}
private function printFeedbackItemColor(string $item): void
{
$buffer = match ($item) {
'E' => $this->colorizeTextBox('fg-red, bold', $item),
'F' => $this->colorizeTextBox('bg-red, fg-white', $item),
'I', 'N', 'D', 'R', 'W' => $this->colorizeTextBox('fg-yellow, bold', $item),
'S' => $this->colorizeTextBox('fg-cyan, bold', $item),
'.' => $item,
};
$this->output->write($buffer);
}
private function getProgress(): string
{
return sprintf(
' %' . $this->numTestsWidth . 'd / %' . $this->numTestsWidth . 'd (%3s%%)',
$this->casesProcessed,
$this->totalCases,
floor(($this->totalCases > 0 ? $this->casesProcessed / $this->totalCases : 0) * 100),
);
}
private function colorizeTextBox(string $color, string $buffer): string
{
if (! $this->options->configuration->colors()) {
return $buffer;
}
return Color::colorizeTextBox($color, $buffer);
}
/** @param list<SplFileInfo> $files */
private function tailMultiple(array $files): string
{
$content = '';
foreach ($files as $file) {
if (! $file->isFile()) {
continue;
}
$content .= $this->tail($file);
}
return $content;
}
private function tail(SplFileInfo $file): string
{
$path = $file->getPathname();
assert($path !== '');
$handle = fopen($path, 'r');
assert($handle !== false);
$fseek = fseek($handle, $this->tailPositions[$path] ?? 0);
assert($fseek === 0);
$contents = '';
while (! feof($handle)) {
$fread = fread($handle, 8192);
assert($fread !== false);
$contents .= $fread;
}
$ftell = ftell($handle);
assert($ftell !== false);
$this->tailPositions[$path] = $ftell;
fclose($handle);
return $contents;
}
}

View file

@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use Generator;
use ParaTest\Options;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Runner\Extension\ExtensionBootstrapper;
use PHPUnit\Runner\Extension\Facade as ExtensionFacade;
use PHPUnit\Runner\Extension\PharLoader;
use PHPUnit\Runner\PhptTestCase;
use PHPUnit\Runner\ResultCache\DefaultResultCache;
use PHPUnit\Runner\ResultCache\NullResultCache;
use PHPUnit\Runner\TestSuiteSorter;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TextUI\Command\Result;
use PHPUnit\TextUI\Command\WarmCodeCoverageCacheCommand;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\TextUI\Configuration\PhpHandler;
use PHPUnit\TextUI\Configuration\TestSuiteBuilder;
use PHPUnit\TextUI\TestSuiteFilterProcessor;
use ReflectionClass;
use ReflectionProperty;
use Symfony\Component\Console\Output\OutputInterface;
use function array_keys;
use function assert;
use function count;
use function is_int;
use function is_string;
use function mt_srand;
use function ob_get_clean;
use function ob_start;
use function preg_quote;
use function sprintf;
use function str_starts_with;
use function strlen;
use function substr;
/** @internal */
final readonly class SuiteLoader
{
public int $testCount;
/** @var list<non-empty-string> */
public array $tests;
public function __construct(
private Options $options,
OutputInterface $output,
CodeCoverageFilterRegistry $codeCoverageFilterRegistry,
) {
(new PhpHandler())->handle($this->options->configuration->php());
if ($this->options->configuration->hasBootstrap()) {
$bootstrapFilename = $this->options->configuration->bootstrap();
include_once $bootstrapFilename;
EventFacade::emitter()->testRunnerBootstrapFinished($bootstrapFilename);
}
if (! $this->options->configuration->noExtensions()) {
if ($this->options->configuration->hasPharExtensionDirectory()) {
(new PharLoader())->loadPharExtensionsInDirectory(
$this->options->configuration->pharExtensionDirectory(),
);
}
$extensionFacade = new ExtensionFacade();
$extensionBootstrapper = new ExtensionBootstrapper(
$this->options->configuration,
$extensionFacade,
);
foreach ($this->options->configuration->extensionBootstrappers() as $bootstrapper) {
$extensionBootstrapper->bootstrap(
$bootstrapper['className'],
$bootstrapper['parameters'],
);
}
}
TestResultFacade::init();
EventFacade::instance()->seal();
$testSuite = (new TestSuiteBuilder())->build($this->options->configuration);
if ($this->options->configuration->executionOrder() === TestSuiteSorter::ORDER_RANDOMIZED) {
mt_srand($this->options->configuration->randomOrderSeed());
}
if (
$this->options->configuration->executionOrder() !== TestSuiteSorter::ORDER_DEFAULT ||
$this->options->configuration->executionOrderDefects() !== TestSuiteSorter::ORDER_DEFAULT ||
$this->options->configuration->resolveDependencies()
) {
$resultCache = new NullResultCache();
if ($this->options->configuration->cacheResult()) {
$resultCache = new DefaultResultCache($this->options->configuration->testResultCacheFile());
$resultCache->load();
}
(new TestSuiteSorter($resultCache))->reorderTestsInSuite(
$testSuite,
$this->options->configuration->executionOrder(),
$this->options->configuration->resolveDependencies(),
$this->options->configuration->executionOrderDefects(),
);
}
(new TestSuiteFilterProcessor())->process($this->options->configuration, $testSuite);
$this->testCount = count($testSuite);
$files = [];
$tests = [];
foreach ($this->loadFiles($testSuite) as $file => $test) {
$files[$file] = null;
if ($test instanceof PhptTestCase) {
$tests[] = $file;
} else {
$name = $test->name();
if ($test->providedData() !== []) {
$dataName = $test->dataName();
if ($this->options->functional) {
$name = sprintf('/%s%s$/', preg_quote($name, '/'), preg_quote($test->dataSetAsString(), '/'));
} else {
if (is_int($dataName)) {
$name .= '#' . $dataName;
} else {
$name .= '@' . $dataName;
}
}
} else {
$name = sprintf('/%s$/', $name);
}
$tests[] = "$file\0$name";
}
}
$this->tests = $this->options->functional
? $tests
: array_keys($files);
if (! $this->options->configuration->hasCoverageReport()) {
return;
}
ob_start();
$result = (new WarmCodeCoverageCacheCommand(
$this->options->configuration,
$codeCoverageFilterRegistry,
))->execute();
$ob_get_clean = ob_get_clean();
assert($ob_get_clean !== false);
$output->write($ob_get_clean);
$output->write($result->output());
if ($result->shellExitCode() !== Result::SUCCESS) {
exit($result->shellExitCode());
}
}
/** @return Generator<non-empty-string, (PhptTestCase|TestCase)> */
private function loadFiles(TestSuite $testSuite): Generator
{
foreach ($testSuite as $test) {
if ($test instanceof TestSuite) {
yield from $this->loadFiles($test);
continue;
}
if ($test instanceof PhptTestCase) {
$refProperty = new ReflectionProperty(PhptTestCase::class, 'filename');
$filename = $refProperty->getValue($test);
assert(is_string($filename) && $filename !== '');
$filename = $this->stripCwd($filename);
yield $filename => $test;
continue;
}
if ($test instanceof TestCase) {
$refClass = new ReflectionClass($test);
$filename = $refClass->getFileName();
assert(is_string($filename));
$filename = $this->stripCwd($filename);
yield $filename => $test;
continue;
}
}
}
/**
* @param non-empty-string $filename
*
* @return non-empty-string
*/
private function stripCwd(string $filename): string
{
if (! str_starts_with($filename, $this->options->cwd)) {
return $filename;
}
$substr = substr($filename, 1 + strlen($this->options->cwd));
assert($substr !== '');
return $substr;
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use RuntimeException;
use Symfony\Component\Process\Process;
use Throwable;
use function escapeshellarg;
use function sprintf;
/** @internal */
final class WorkerCrashedException extends RuntimeException
{
public static function fromProcess(Process $process, string $test, ?Throwable $previousException = null): self
{
$envs = '';
foreach ($process->getEnv() as $key => $value) {
$envs .= sprintf('%s=%s ', $key, escapeshellarg((string) $value));
}
$error = sprintf(
'The test "%s%s" failed.' . "\n\nExit Code: %s(%s)\n\nWorking directory: %s",
$envs,
$test,
(string) $process->getExitCode(),
(string) $process->getExitCodeText(),
(string) $process->getWorkingDirectory(),
);
if (! $process->isOutputDisabled()) {
$error .= sprintf(
"\n\nOutput:\n================\n%s\n\nError Output:\n================\n%s",
$process->getOutput(),
$process->getErrorOutput(),
);
}
return new self($error, 0, $previousException);
}
}

View file

@ -0,0 +1,380 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use ParaTest\Coverage\CoverageMerger;
use ParaTest\JUnit\LogMerger;
use ParaTest\JUnit\Writer;
use ParaTest\Options;
use ParaTest\RunnerInterface;
use PHPUnit\Runner\CodeCoverage;
use PHPUnit\Runner\ResultCache\DefaultResultCache;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\TextUI\ShellExitCodeCalculator;
use PHPUnit\Util\ExcludeList;
use SplFileInfo;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\PhpExecutableFinder;
use function array_merge;
use function array_merge_recursive;
use function array_shift;
use function assert;
use function count;
use function dirname;
use function file_get_contents;
use function max;
use function realpath;
use function unlink;
use function unserialize;
use function usleep;
use const DIRECTORY_SEPARATOR;
/** @internal */
final class WrapperRunner implements RunnerInterface
{
private const CYCLE_SLEEP = 10000;
private readonly ResultPrinter $printer;
/** @var list<non-empty-string> */
private array $pending = [];
private int $exitcode = -1;
/** @var array<positive-int,WrapperWorker> */
private array $workers = [];
/** @var array<int,int> */
private array $batches = [];
/** @var list<SplFileInfo> */
private array $statusFiles = [];
/** @var list<SplFileInfo> */
private array $progressFiles = [];
/** @var list<SplFileInfo> */
private array $unexpectedOutputFiles = [];
/** @var list<SplFileInfo> */
private array $resultCacheFiles = [];
/** @var list<SplFileInfo> */
private array $testResultFiles = [];
/** @var list<SplFileInfo> */
private array $coverageFiles = [];
/** @var list<SplFileInfo> */
private array $junitFiles = [];
/** @var list<SplFileInfo> */
private array $teamcityFiles = [];
/** @var list<SplFileInfo> */
private array $testdoxFiles = [];
/** @var non-empty-string[] */
private readonly array $parameters;
private CodeCoverageFilterRegistry $codeCoverageFilterRegistry;
public function __construct(
private readonly Options $options,
private readonly OutputInterface $output
) {
$this->printer = new ResultPrinter($output, $options);
$wrapper = realpath(
dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'phpunit-wrapper.php',
);
assert($wrapper !== false);
$phpFinder = new PhpExecutableFinder();
$phpBin = $phpFinder->find(false);
assert($phpBin !== false);
$parameters = [$phpBin];
$parameters = array_merge($parameters, $phpFinder->findArguments());
if ($options->passthruPhp !== null) {
$parameters = array_merge($parameters, $options->passthruPhp);
}
$parameters[] = $wrapper;
$this->parameters = $parameters;
$this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry();
}
public function run(): int
{
$directory = dirname(__DIR__);
assert($directory !== '');
ExcludeList::addDirectory($directory);
$suiteLoader = new SuiteLoader(
$this->options,
$this->output,
$this->codeCoverageFilterRegistry,
);
$result = TestResultFacade::result();
$this->pending = $suiteLoader->tests;
$this->printer->setTestCount($suiteLoader->testCount);
$this->printer->start();
$this->startWorkers();
$this->assignAllPendingTests();
$this->waitForAllToFinish();
return $this->complete($result);
}
private function startWorkers(): void
{
for ($token = 1; $token <= $this->options->processes; ++$token) {
$this->startWorker($token);
}
}
private function assignAllPendingTests(): void
{
$batchSize = $this->options->maxBatchSize;
while (count($this->pending) > 0 && count($this->workers) > 0) {
foreach ($this->workers as $token => $worker) {
if (! $worker->isRunning()) {
throw $worker->getWorkerCrashedException();
}
if (! $worker->isFree()) {
continue;
}
$this->flushWorker($worker);
if ($batchSize !== 0 && $this->batches[$token] === $batchSize) {
$this->destroyWorker($token);
$worker = $this->startWorker($token);
}
if (
$this->exitcode > 0
&& $this->options->configuration->stopOnFailure()
) {
$this->pending = [];
} elseif (($pending = array_shift($this->pending)) !== null) {
$worker->assign($pending);
$this->batches[$token]++;
}
}
usleep(self::CYCLE_SLEEP);
}
}
private function flushWorker(WrapperWorker $worker): void
{
$this->exitcode = max($this->exitcode, $worker->getExitCode());
$this->printer->printFeedback(
$worker->progressFile,
$worker->unexpectedOutputFile,
$worker->teamcityFile ?? null,
);
$worker->reset();
}
private function waitForAllToFinish(): void
{
$stopped = [];
while (count($this->workers) > 0) {
foreach ($this->workers as $index => $worker) {
if ($worker->isRunning()) {
if (! isset($stopped[$index]) && $worker->isFree()) {
$worker->stop();
$stopped[$index] = true;
}
continue;
}
if (! $worker->isFree()) {
throw $worker->getWorkerCrashedException();
}
$this->flushWorker($worker);
unset($this->workers[$index]);
}
usleep(self::CYCLE_SLEEP);
}
}
/** @param positive-int $token */
private function startWorker(int $token): WrapperWorker
{
$worker = new WrapperWorker(
$this->output,
$this->options,
$this->parameters,
$token,
);
$worker->start();
$this->batches[$token] = 0;
$this->statusFiles[] = $worker->statusFile;
$this->progressFiles[] = $worker->progressFile;
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
$this->testResultFiles[] = $worker->testResultFile;
if (isset($worker->resultCacheFile)) {
$this->resultCacheFiles[] = $worker->resultCacheFile;
}
if (isset($worker->junitFile)) {
$this->junitFiles[] = $worker->junitFile;
}
if (isset($worker->coverageFile)) {
$this->coverageFiles[] = $worker->coverageFile;
}
if (isset($worker->teamcityFile)) {
$this->teamcityFiles[] = $worker->teamcityFile;
}
if (isset($worker->testdoxFile)) {
$this->testdoxFiles[] = $worker->testdoxFile;
}
return $this->workers[$token] = $worker;
}
private function destroyWorker(int $token): void
{
$this->workers[$token]->stop();
// We need to wait for ApplicationForWrapperWorker::end to end
while ($this->workers[$token]->isRunning()) {
usleep(self::CYCLE_SLEEP);
}
unset($this->workers[$token]);
}
private function complete(TestResult $testResultSum): int
{
foreach ($this->testResultFiles as $testresultFile) {
if (! $testresultFile->isFile()) {
continue;
}
$contents = file_get_contents($testresultFile->getPathname());
assert($contents !== false);
$testResult = unserialize($contents);
assert($testResult instanceof TestResult);
$testResultSum = new TestResult(
(int) $testResultSum->hasTests() + (int) $testResult->hasTests(),
$testResultSum->numberOfTestsRun() + $testResult->numberOfTestsRun(),
$testResultSum->numberOfAssertions() + $testResult->numberOfAssertions(),
array_merge_recursive($testResultSum->testErroredEvents(), $testResult->testErroredEvents()),
array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents()),
array_merge_recursive($testResultSum->testConsideredRiskyEvents(), $testResult->testConsideredRiskyEvents()),
array_merge_recursive($testResultSum->testSuiteSkippedEvents(), $testResult->testSuiteSkippedEvents()),
array_merge_recursive($testResultSum->testSkippedEvents(), $testResult->testSkippedEvents()),
array_merge_recursive($testResultSum->testMarkedIncompleteEvents(), $testResult->testMarkedIncompleteEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitDeprecationEvents(), $testResult->testTriggeredPhpunitDeprecationEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitErrorEvents(), $testResult->testTriggeredPhpunitErrorEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitWarningEvents(), $testResult->testTriggeredPhpunitWarningEvents()),
array_merge_recursive($testResultSum->testRunnerTriggeredDeprecationEvents(), $testResult->testRunnerTriggeredDeprecationEvents()),
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
array_merge_recursive($testResultSum->errors(), $testResult->errors()),
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
array_merge_recursive($testResultSum->notices(), $testResult->notices()),
array_merge_recursive($testResultSum->warnings(), $testResult->warnings()),
array_merge_recursive($testResultSum->phpDeprecations(), $testResult->phpDeprecations()),
array_merge_recursive($testResultSum->phpNotices(), $testResult->phpNotices()),
array_merge_recursive($testResultSum->phpWarnings(), $testResult->phpWarnings()),
$testResultSum->numberOfIssuesIgnoredByBaseline() + $testResult->numberOfIssuesIgnoredByBaseline(),
);
}
if ($this->options->configuration->cacheResult()) {
$resultCacheSum = new DefaultResultCache($this->options->configuration->testResultCacheFile());
foreach ($this->resultCacheFiles as $resultCacheFile) {
$resultCache = new DefaultResultCache($resultCacheFile->getPathname());
$resultCache->load();
$resultCacheSum->mergeWith($resultCache);
}
$resultCacheSum->persist();
}
$this->printer->printResults(
$testResultSum,
$this->teamcityFiles,
$this->testdoxFiles,
);
$this->generateCodeCoverageReports();
$this->generateLogs();
$exitcode = (new ShellExitCodeCalculator())->calculate(
$this->options->configuration,
$testResultSum,
);
$this->clearFiles($this->statusFiles);
$this->clearFiles($this->progressFiles);
$this->clearFiles($this->unexpectedOutputFiles);
$this->clearFiles($this->testResultFiles);
$this->clearFiles($this->resultCacheFiles);
$this->clearFiles($this->coverageFiles);
$this->clearFiles($this->junitFiles);
$this->clearFiles($this->teamcityFiles);
$this->clearFiles($this->testdoxFiles);
return $exitcode;
}
protected function generateCodeCoverageReports(): void
{
if ($this->coverageFiles === []) {
return;
}
$coverageManager = new CodeCoverage();
$coverageManager->init(
$this->options->configuration,
$this->codeCoverageFilterRegistry,
false,
);
$coverageMerger = new CoverageMerger($coverageManager->codeCoverage());
foreach ($this->coverageFiles as $coverageFile) {
$coverageMerger->addCoverageFromFile($coverageFile);
}
$coverageManager->generateReports(
$this->printer->printer,
$this->options->configuration,
);
}
private function generateLogs(): void
{
if ($this->junitFiles === []) {
return;
}
$testSuite = (new LogMerger())->merge($this->junitFiles);
if ($testSuite === null) {
return;
}
(new Writer())->write(
$testSuite,
$this->options->configuration->logfileJunit(),
);
}
/** @param list<SplFileInfo> $files */
private function clearFiles(array $files): void
{
foreach ($files as $file) {
if (! $file->isFile()) {
continue;
}
unlink($file->getPathname());
}
}
}

Some files were not shown because too many files have changed in this diff Show more