diff --git a/Modules/Developer/Config/config.php b/Modules/Developer/Config/config.php new file mode 100644 index 0000000..f3bc1fc --- /dev/null +++ b/Modules/Developer/Config/config.php @@ -0,0 +1,8 @@ + 'Modules', + 'modules_path' => base_path('Modules'), + 'themes_path' => resource_path('themes'), + 'overwrite' => false, +]; diff --git a/Modules/Developer/Console/Commands/InspectModulesCommand.php b/Modules/Developer/Console/Commands/InspectModulesCommand.php new file mode 100644 index 0000000..7d95618 --- /dev/null +++ b/Modules/Developer/Console/Commands/InspectModulesCommand.php @@ -0,0 +1,32 @@ +argument('name'); + $rows = []; + foreach ($files as $file) { + $manifest = json_decode((string) file_get_contents($file), true); + if (! is_array($manifest)) { $this->error("Invalid JSON: {$file}"); return self::FAILURE; } + if ($name && strcasecmp((string) ($manifest['name'] ?? ''), (string) $name) !== 0) { continue; } + $rows[] = [ + 'name' => $manifest['name'] ?? basename(dirname($file)), + 'version' => $manifest['version'] ?? 'unknown', + 'requires' => implode(', ', $manifest['requires'] ?? []), + 'providers' => count($manifest['providers'] ?? []), + ]; + } + if ($this->option('json')) { $this->line(json_encode($rows, JSON_PRETTY_PRINT)); return self::SUCCESS; } + $this->table(['Module', 'Version', 'Requires', 'Providers'], $rows); + return self::SUCCESS; + } +} diff --git a/Modules/Developer/Console/Commands/MakeCrudCommand.php b/Modules/Developer/Console/Commands/MakeCrudCommand.php new file mode 100644 index 0000000..8c83989 --- /dev/null +++ b/Modules/Developer/Console/Commands/MakeCrudCommand.php @@ -0,0 +1,28 @@ +option('module') ?: $this->choice('Select the target module', $this->modules()); + try { $files = $generator->generate((string) $module, $this->argument('model'), (bool) $this->option('force')); } + catch (Throwable $e) { $this->error($e->getMessage()); return self::FAILURE; } + foreach ($files as $file) { $this->line("created {$file}"); } + $this->warn('Register the generated controller routes in the target module Routes/web.php file.'); + return self::SUCCESS; + } + + private function modules(): array + { + return collect(glob(base_path('Modules/*/module.json')) ?: [])->map(fn (string $file) => basename(dirname($file)))->sort()->values()->all(); + } +} diff --git a/Modules/Developer/Console/Commands/MakeModuleCommand.php b/Modules/Developer/Console/Commands/MakeModuleCommand.php new file mode 100644 index 0000000..53e48d9 --- /dev/null +++ b/Modules/Developer/Console/Commands/MakeModuleCommand.php @@ -0,0 +1,22 @@ +generate($this->argument('name'), (bool) $this->option('force')); } + catch (Throwable $e) { $this->error($e->getMessage()); return self::FAILURE; } + foreach ($files as $file) { $this->line("created {$file}"); } + $this->newLine(); $this->info('Enable the module in modules_statuses.json, then run composer dump-autoload.'); + return self::SUCCESS; + } +} diff --git a/Modules/Developer/Console/Commands/MakeThemeCommand.php b/Modules/Developer/Console/Commands/MakeThemeCommand.php new file mode 100644 index 0000000..50c803b --- /dev/null +++ b/Modules/Developer/Console/Commands/MakeThemeCommand.php @@ -0,0 +1,21 @@ +generate($this->argument('name'), (bool) $this->option('force')); } + catch (Throwable $e) { $this->error($e->getMessage()); return self::FAILURE; } + foreach ($files as $file) { $this->line("created {$file}"); } + return self::SUCCESS; + } +} diff --git a/Modules/Developer/Http/Controllers/DeveloperController.php b/Modules/Developer/Http/Controllers/DeveloperController.php new file mode 100644 index 0000000..d921e99 --- /dev/null +++ b/Modules/Developer/Http/Controllers/DeveloperController.php @@ -0,0 +1,27 @@ +map(function (string $file): array { + $manifest = json_decode((string) file_get_contents($file), true) ?: []; + + return [ + 'name' => $manifest['name'] ?? basename(dirname($file)), + 'version' => $manifest['version'] ?? 'unknown', + 'description' => $manifest['description'] ?? '', + 'requires' => $manifest['requires'] ?? [], + ]; + }) + ->sortBy('name') + ->values(); + + return view('developer::admin.index', compact('modules')); + } +} diff --git a/Modules/Developer/Providers/DeveloperServiceProvider.php b/Modules/Developer/Providers/DeveloperServiceProvider.php new file mode 100644 index 0000000..a5f09a9 --- /dev/null +++ b/Modules/Developer/Providers/DeveloperServiceProvider.php @@ -0,0 +1,32 @@ +app->register(RouteServiceProvider::class); + $this->mergeConfigFrom(module_path('Developer', 'Config/config.php'), 'developer'); + } + + public function boot(): void + { + $this->loadViewsFrom(module_path('Developer', 'Resources/views'), 'developer'); + + if ($this->app->runningInConsole()) { + $this->commands([ + MakeModuleCommand::class, + MakeCrudCommand::class, + MakeThemeCommand::class, + InspectModulesCommand::class, + ]); + } + } +} diff --git a/Modules/Developer/Providers/RouteServiceProvider.php b/Modules/Developer/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..50cbdde --- /dev/null +++ b/Modules/Developer/Providers/RouteServiceProvider.php @@ -0,0 +1,14 @@ +group(module_path('Developer', 'Routes/web.php')); + } +} diff --git a/Modules/Developer/Resources/views/admin/index.blade.php b/Modules/Developer/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..ca9fe9f --- /dev/null +++ b/Modules/Developer/Resources/views/admin/index.blade.php @@ -0,0 +1,26 @@ + +
+
+

{{ __('Developer Tools') }}

+

{{ __('Generate modules, CRUD resources and themes using the platform conventions.') }}

+
+ +
+

{{ __('Module Generator') }}

php artisan module:make Products
+

{{ __('CRUD Generator') }}

php artisan crud:make Product --module=Products
+

{{ __('Theme Generator') }}

php artisan theme:make Corporate
+
+ +
+
{{ __('Installed modules') }}
+
+ @foreach ($modules as $module) +
+
{{ $module['name'] }}
{{ $module['description'] }}
+
v{{ $module['version'] }}
{{ implode(', ', $module['requires']) }}
+
+ @endforeach +
+
+
+
diff --git a/Modules/Developer/Routes/web.php b/Modules/Developer/Routes/web.php new file mode 100644 index 0000000..5a8712a --- /dev/null +++ b/Modules/Developer/Routes/web.php @@ -0,0 +1,11 @@ +prefix('administration/developer') + ->name('administration.developer.') + ->group(function (): void { + Route::get('/', DeveloperController::class)->name('index'); + }); diff --git a/Modules/Developer/Support/CrudGenerator.php b/Modules/Developer/Support/CrudGenerator.php new file mode 100644 index 0000000..c1b7c07 --- /dev/null +++ b/Modules/Developer/Support/CrudGenerator.php @@ -0,0 +1,68 @@ +studly($module); + $model = $this->studly($model); + $table = str($model)->plural()->snake()->toString(); + $route = str($model)->plural()->kebab()->toString(); + $root = rtrim(config('developer.modules_path', base_path('Modules')), '/').'/'.$module; + $timestamp = now()->format('Y_m_d_His'); + + $files = [ + "$root/Models/{$model}.php" => $this->model($module, $model), + "$root/Http/Controllers/{$model}Controller.php" => $this->controller($module, $model), + "$root/Http/Requests/Store{$model}Request.php" => $this->request($module, $model), + "$root/Database/Migrations/{$timestamp}_create_{$table}_table.php" => $this->migration($table), + "$root/Resources/views/admin/{$route}/index.blade.php" => $this->indexView($model, $route), + "$root/Resources/views/admin/{$route}/form.blade.php" => $this->formView($model), + "$root/Tests/Feature/{$model}CrudTest.php" => $this->crudTest($module, $model), + ]; + + foreach ($files as $path => $contents) { + $this->write($path, $contents, $force); + } + + return array_keys($files); + } + + private function model(string $module, string $model): string + { + return " 'boolean'];\n }\n}\n"; + } + + private function controller(string $module, string $model): string + { + $plural = str($model)->plural()->kebab(); + return "snake()."::admin.{$plural}.index', ['records' => {$model}::query()->latest()->paginate()]);\n }\n\n public function store(Store{$model}Request \$request): RedirectResponse\n {\n {$model}::query()->create(\$request->validated());\n return back()->with('status', '{$model} created.');\n }\n}\n"; + } + + private function request(string $module, string $model): string + { + return "user() !== null; }\n public function rules(): array { return ['name' => ['required', 'string', 'max:255'], 'is_active' => ['sometimes', 'boolean']]; }\n}\n"; + } + + private function migration(string $table): string + { + return "id(); \$table->string('name'); \$table->boolean('is_active')->default(true); \$table->timestamps(); }); }\n public function down(): void { Schema::dropIfExists('{$table}'); }\n};\n"; + } + + private function indexView(string $model, string $route): string + { + return "\n

{$model}

@foreach (\$records as \$record)
{{ \$record->name }}
@endforeach{{ \$records->links() }}
\n
\n"; + } + + private function formView(string $model): string + { + return "
@csrf{{ __('Save {$model}') }}\n"; + } + + private function crudTest(string $module, string $model): string + { + return "create(['name' => 'Example']))->toBeInstanceOf({$model}::class); });\n"; + } +} diff --git a/Modules/Developer/Support/Generator.php b/Modules/Developer/Support/Generator.php new file mode 100644 index 0000000..a8c8bf8 --- /dev/null +++ b/Modules/Developer/Support/Generator.php @@ -0,0 +1,43 @@ +files->isDirectory($path)) { + $this->files->makeDirectory($path, 0755, true); + } + } + + protected function write(string $path, string $contents, bool $force = false): void + { + if ($this->files->exists($path) && ! $force) { + throw new RuntimeException("File already exists: {$path}"); + } + + $this->createDirectory(dirname($path)); + $this->files->put($path, $contents); + } + + protected function studly(string $value): string + { + return str($value)->studly()->toString(); + } + + protected function snake(string $value): string + { + return str($value)->snake()->toString(); + } + + protected function kebab(string $value): string + { + return str($value)->kebab()->toString(); + } +} diff --git a/Modules/Developer/Support/ModuleGenerator.php b/Modules/Developer/Support/ModuleGenerator.php new file mode 100644 index 0000000..a9668e3 --- /dev/null +++ b/Modules/Developer/Support/ModuleGenerator.php @@ -0,0 +1,55 @@ +studly($name); + $alias = $this->kebab($name); + $root = rtrim(config('developer.modules_path', base_path('Modules')), '/').'/'.$name; + $provider = "Modules\\{$name}\\Providers\\{$name}ServiceProvider"; + + $files = [ + "$root/module.json" => json_encode([ + 'name' => $name, + 'alias' => $alias, + 'version' => '1.0.0', + 'description' => "{$name} module.", + 'keywords' => [$alias], + 'priority' => 100, + 'providers' => [$provider], + 'aliases' => (object) [], + 'files' => [], + 'requires' => ['Core', 'Administration'], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL, + "$root/Config/config.php" => " '{$name}'];\n", + "$root/Providers/{$name}ServiceProvider.php" => $this->provider($name), + "$root/Providers/RouteServiceProvider.php" => $this->routeProvider($name), + "$root/Routes/web.php" => "prefix('administration/{$alias}')->name('administration.{$alias}.')->group(function (): void {\n // Register module routes here.\n});\n", + "$root/Tests/Feature/{$name}ModuleBootTest.php" => $this->test($name), + ]; + + foreach ($files as $path => $contents) { + $this->write($path, $contents, $force); + } + + return array_keys($files); + } + + private function provider(string $name): string + { + return "app->register(RouteServiceProvider::class);\n \$this->mergeConfigFrom(module_path('{$name}', 'Config/config.php'), '".str($name)->snake()."');\n }\n\n public function boot(): void\n {\n \$this->loadMigrationsFrom(module_path('{$name}', 'Database/Migrations'));\n \$this->loadViewsFrom(module_path('{$name}', 'Resources/views'), '".str($name)->snake()."');\n }\n}\n"; + } + + private function routeProvider(string $name): string + { + return "group(module_path('{$name}', 'Routes/web.php'));\n }\n}\n"; + } + + private function test(string $name): string + { + return "providerIsLoaded(Modules\\{$name}\\Providers\\{$name}ServiceProvider::class))->toBeTrue();\n});\n"; + } +} diff --git a/Modules/Developer/Support/ThemeGenerator.php b/Modules/Developer/Support/ThemeGenerator.php new file mode 100644 index 0000000..e1ed4b7 --- /dev/null +++ b/Modules/Developer/Support/ThemeGenerator.php @@ -0,0 +1,21 @@ +kebab($name); + $root = rtrim(config('developer.themes_path', resource_path('themes')), '/').'/'.$name; + $files = [ + "$root/theme.json" => json_encode(['name' => str($name)->headline()->toString(), 'slug' => $name, 'version' => '1.0.0', 'parent' => 'common'], JSON_PRETTY_PRINT).PHP_EOL, + "$root/views/layouts/app.blade.php" => "getLocale()) }}\">{{ \$title ?? config('app.name') }}@vite(['resources/css/app.css', 'resources/js/app.js']){{ \$slot }}\n", + "$root/views/home.blade.php" => "

{{ config('app.name') }}

\n", + "$root/assets/css/theme.css" => ":root { --theme-radius: 0.5rem; }\n", + "$root/README.md" => "# ".str($name)->headline()." Theme\n\nGenerated by `php artisan theme:make {$name}`.\n", + ]; + foreach ($files as $path => $contents) { $this->write($path, $contents, $force); } + return array_keys($files); + } +} diff --git a/Modules/Developer/Tests/Feature/DeveloperModuleBootTest.php b/Modules/Developer/Tests/Feature/DeveloperModuleBootTest.php new file mode 100644 index 0000000..80f181c --- /dev/null +++ b/Modules/Developer/Tests/Feature/DeveloperModuleBootTest.php @@ -0,0 +1,11 @@ +providerIsLoaded(DeveloperServiceProvider::class))->toBeTrue(); +}); + +it('registers developer commands', function (): void { + $this->artisan('module:inspect')->assertSuccessful(); +}); diff --git a/Modules/Developer/module.json b/Modules/Developer/module.json new file mode 100644 index 0000000..7df5745 --- /dev/null +++ b/Modules/Developer/module.json @@ -0,0 +1,12 @@ +{ + "name": "Developer", + "alias": "developer", + "version": "1.0.0", + "description": "Developer generators, module inspection and scaffolding tools.", + "keywords": ["developer", "generator", "scaffold"], + "priority": 80, + "providers": ["Modules\\Developer\\Providers\\DeveloperServiceProvider"], + "aliases": {}, + "files": [], + "requires": ["Core", "Administration"] +} diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php index 0e0e0ef..ed3ad40 100644 --- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php +++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php @@ -58,6 +58,7 @@ final class RolePermissionSeeder extends Seeder 'sites.view', 'sites.manage', 'sites.assign', + 'developer.access', ]; foreach ($permissions as $permission) { diff --git a/modules_statuses.json b/modules_statuses.json index d30050b..32b21e4 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -33,5 +33,6 @@ "Locations": true, "Workflow": true, "Localization": true, - "Sites": true + "Sites": true, + "Developer": true } diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index 883cdff..dcd1009 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -32,6 +32,7 @@ {{ __('Testimonials') }} {{ __('Sites') }}{{ __('Dashboard') }}{{ __('Administration') }} +{{ __('Developer Tools') }} {{ __('Review Queue') }} {{ __('Users') }}{{ __('Roles & Permissions') }}{{ __('Media') }}{{ __('Search') }} {{ __('Localisation') }}{{ __('SEO') }}{{ __('Settings') }}{{ __('Modules') }}{{ __('Audit Log') }}