Add howto-modul-erstellen.md covering the full module creation workflow: scaffold, manifest, container registration, first page, sync, validate, and test. Includes 9 extension point examples, merge checklist, and troubleshooting table. Documents the AuthorizationPolicy container registration requirement (policies with constructor params must be explicitly registered). Update make:module command output to reference the tutorial, manifest contract, and notifications as reference module. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
271 lines
9.0 KiB
PHP
271 lines
9.0 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Console\Commands\Make;
|
|
|
|
use MintyPHP\Console\Command;
|
|
|
|
/**
|
|
* Scaffolds a new module with manifest, registrar, policy, and directory structure.
|
|
*
|
|
* Usage: php bin/console make:module <id>
|
|
* Example: php bin/console make:module notifications
|
|
*
|
|
* Generates:
|
|
* modules/<id>/module.php
|
|
* modules/<id>/lib/Module/<Name>/<Name>ContainerRegistrar.php
|
|
* modules/<id>/lib/Module/<Name>/<Name>AuthorizationPolicy.php
|
|
* modules/<id>/pages/
|
|
* modules/<id>/templates/
|
|
* modules/<id>/web/css/
|
|
* modules/<id>/web/js/
|
|
* modules/<id>/tests/Module/<Name>/
|
|
*/
|
|
final class ModuleCommand extends Command
|
|
{
|
|
public function name(): string
|
|
{
|
|
return 'make:module';
|
|
}
|
|
|
|
public function description(): string
|
|
{
|
|
return 'Scaffold a new module with manifest and stub classes';
|
|
}
|
|
|
|
public function usage(): string
|
|
{
|
|
return <<<'USAGE'
|
|
Usage: php bin/console make:module <module-id>
|
|
|
|
Arguments:
|
|
module-id Lowercase identifier (e.g. notifications, activity-log)
|
|
Must match [a-z][a-z0-9-]* pattern.
|
|
|
|
Example:
|
|
php bin/console make:module notifications
|
|
|
|
Generates the full module directory structure with:
|
|
- module.php manifest (all fields, sensible defaults)
|
|
- ContainerRegistrar stub
|
|
- AuthorizationPolicy stub
|
|
- Empty pages/, templates/, web/css/, web/js/, tests/ directories
|
|
USAGE;
|
|
}
|
|
|
|
public function execute(array $args, array $options): int
|
|
{
|
|
$moduleId = trim($args[0] ?? '');
|
|
|
|
if ($moduleId === '') {
|
|
fwrite(STDERR, $this->usage() . "\n");
|
|
return 1;
|
|
}
|
|
|
|
if (!preg_match('/^[a-z][a-z0-9-]*$/', $moduleId)) {
|
|
fwrite(STDERR, "Error: module ID must match [a-z][a-z0-9-]* (got '{$moduleId}')\n");
|
|
return 1;
|
|
}
|
|
|
|
$modulesDir = $this->projectRoot() . '/modules';
|
|
$moduleDir = $modulesDir . '/' . $moduleId;
|
|
|
|
if (is_dir($moduleDir)) {
|
|
fwrite(STDERR, "Error: module directory already exists: modules/{$moduleId}/\n");
|
|
return 1;
|
|
}
|
|
|
|
$pascalName = self::toPascalCase($moduleId);
|
|
$snakeName = str_replace('-', '_', $moduleId);
|
|
|
|
// Create directories
|
|
$dirs = [
|
|
"lib/Module/{$pascalName}",
|
|
'pages',
|
|
'templates',
|
|
'web/css',
|
|
'web/js',
|
|
"tests/Module/{$pascalName}",
|
|
];
|
|
|
|
foreach ($dirs as $dir) {
|
|
$path = $moduleDir . '/' . $dir;
|
|
if (!mkdir($path, 0777, true) && !is_dir($path)) {
|
|
fwrite(STDERR, "Error: failed to create directory: {$dir}\n");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// Generate files
|
|
$files = [
|
|
'module.php' => self::manifestTemplate($moduleId, $pascalName, $snakeName),
|
|
"lib/Module/{$pascalName}/{$pascalName}ContainerRegistrar.php" => self::registrarTemplate($pascalName),
|
|
"lib/Module/{$pascalName}/{$pascalName}AuthorizationPolicy.php" => self::policyTemplate($pascalName, $snakeName),
|
|
];
|
|
|
|
foreach ($files as $relativePath => $content) {
|
|
$fullPath = $moduleDir . '/' . $relativePath;
|
|
if (file_put_contents($fullPath, $content) === false) {
|
|
fwrite(STDERR, "Error: failed to write file: {$relativePath}\n");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// Add .gitkeep to empty directories
|
|
foreach (['pages', 'templates', 'web/css', 'web/js', "tests/Module/{$pascalName}"] as $dir) {
|
|
touch($moduleDir . '/' . $dir . '/.gitkeep');
|
|
}
|
|
|
|
fwrite(STDOUT, "Module '{$moduleId}' scaffolded at modules/{$moduleId}/\n\n");
|
|
fwrite(STDOUT, "Created:\n");
|
|
fwrite(STDOUT, " modules/{$moduleId}/module.php\n");
|
|
fwrite(STDOUT, " modules/{$moduleId}/lib/Module/{$pascalName}/{$pascalName}ContainerRegistrar.php\n");
|
|
fwrite(STDOUT, " modules/{$moduleId}/lib/Module/{$pascalName}/{$pascalName}AuthorizationPolicy.php\n");
|
|
fwrite(STDOUT, " modules/{$moduleId}/pages/\n");
|
|
fwrite(STDOUT, " modules/{$moduleId}/templates/\n");
|
|
fwrite(STDOUT, " modules/{$moduleId}/web/css/\n");
|
|
fwrite(STDOUT, " modules/{$moduleId}/web/js/\n");
|
|
fwrite(STDOUT, " modules/{$moduleId}/tests/Module/{$pascalName}/\n");
|
|
fwrite(STDOUT, "\nNext steps:\n");
|
|
fwrite(STDOUT, " 1. Add '{$moduleId}' to config/modules.php enabled_modules\n");
|
|
fwrite(STDOUT, " 2. Register the AuthorizationPolicy in {$pascalName}ContainerRegistrar (required for UI slot visibility)\n");
|
|
fwrite(STDOUT, " 3. Edit module.php to define routes, permissions, and UI slots\n");
|
|
fwrite(STDOUT, " 4. Run: php bin/console module:sync\n");
|
|
fwrite(STDOUT, "\nGuides:\n");
|
|
fwrite(STDOUT, " Tutorial: docs/howto-modul-erstellen.md\n");
|
|
fwrite(STDOUT, " Manifest contract: docs/reference-module-manifest-contract.md\n");
|
|
fwrite(STDOUT, " Reference module: modules/notifications/ (full-featured example)\n");
|
|
|
|
return 0;
|
|
}
|
|
|
|
private static function toPascalCase(string $id): string
|
|
{
|
|
return str_replace(' ', '', ucwords(str_replace('-', ' ', $id)));
|
|
}
|
|
|
|
private static function manifestTemplate(string $id, string $pascalName, string $snakeName): string
|
|
{
|
|
$registrarClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}ContainerRegistrar::class";
|
|
$policyClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}AuthorizationPolicy::class";
|
|
|
|
return <<<PHP
|
|
<?php
|
|
|
|
/**
|
|
* {$pascalName} module manifest.
|
|
*/
|
|
return [
|
|
'id' => '{$id}',
|
|
'version' => '0.1.0',
|
|
'enabled_by_default' => false,
|
|
'load_order' => 100,
|
|
'requires' => [],
|
|
|
|
'routes' => [
|
|
// ['path' => '{$id}', 'target' => '{$id}'],
|
|
],
|
|
'public_paths' => [],
|
|
'container_registrars' => [
|
|
{$registrarClass},
|
|
],
|
|
|
|
'ui_slots' => [],
|
|
|
|
'authorization_policies' => [
|
|
{$policyClass},
|
|
],
|
|
|
|
'search_resources' => [],
|
|
'asset_groups' => [],
|
|
'scheduler_jobs' => [],
|
|
|
|
'layout_context_providers' => [],
|
|
'session_providers' => [],
|
|
|
|
'permissions' => [
|
|
// [
|
|
// 'key' => '{$snakeName}.view',
|
|
// 'description' => 'Can view {$id}',
|
|
// 'active' => true,
|
|
// 'is_system' => true,
|
|
// ],
|
|
],
|
|
|
|
'event_listeners' => [],
|
|
'deactivation_handler' => null,
|
|
'migrations_path' => null,
|
|
];
|
|
|
|
PHP;
|
|
}
|
|
|
|
private static function registrarTemplate(string $pascalName): string
|
|
{
|
|
return <<<PHP
|
|
<?php
|
|
|
|
namespace MintyPHP\\Module\\{$pascalName};
|
|
|
|
use MintyPHP\\App\\AppContainer;
|
|
use MintyPHP\\App\\Container\\ContainerRegistrar;
|
|
|
|
final class {$pascalName}ContainerRegistrar implements ContainerRegistrar
|
|
{
|
|
public function register(AppContainer \$container): void
|
|
{
|
|
// Register module services here.
|
|
// Example:
|
|
// \$container->set(MyService::class, static fn (AppContainer \$c): MyService => new MyService(
|
|
// \$c->get(SomeDependency::class)
|
|
// ));
|
|
}
|
|
}
|
|
|
|
PHP;
|
|
}
|
|
|
|
private static function policyTemplate(string $pascalName, string $snakeName): string
|
|
{
|
|
return <<<PHP
|
|
<?php
|
|
|
|
namespace MintyPHP\\Module\\{$pascalName};
|
|
|
|
use MintyPHP\\Service\\Access\\AuthorizationDecision;
|
|
use MintyPHP\\Service\\Access\\AuthorizationPolicyInterface;
|
|
use MintyPHP\\Service\\Access\\PermissionService;
|
|
|
|
final class {$pascalName}AuthorizationPolicy implements AuthorizationPolicyInterface
|
|
{
|
|
public const ABILITY_VIEW = '{$snakeName}.view';
|
|
public const PERMISSION_KEY = '{$snakeName}.view';
|
|
|
|
public function __construct(
|
|
private readonly PermissionService \$permissionService
|
|
) {
|
|
}
|
|
|
|
public function supports(string \$ability): bool
|
|
{
|
|
return \$ability === self::ABILITY_VIEW;
|
|
}
|
|
|
|
public function authorize(string \$ability, array \$context = []): AuthorizationDecision
|
|
{
|
|
\$actorUserId = (int) (\$context['actor_user_id'] ?? 0);
|
|
if (\$actorUserId <= 0) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
if (!\$this->permissionService->userHas(\$actorUserId, self::PERMISSION_KEY)) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
return AuthorizationDecision::allow();
|
|
}
|
|
}
|
|
|
|
PHP;
|
|
}
|
|
}
|