Refactor M7 module scaffolding, route inclusion, and tests; implement providers discovery; fix URL extension negotiation; clean docs
• Add Service Providers loading from config/providers.php and merge with runtime config; ensure AppServiceProvider boots and contributes routes
• Create RouteGroups and guard module route includes in routes/web.php; update Kernel to auto-mount module routes and apply provider routes
• Implement create:module as a console Command (extends Phred\Console\Command):
◦ Args: name, prefix; Flags: --update-composer, --no-dump
◦ Stable root resolution (dirname(DIR, 2)); robust args/flags handling under ArrayInput
◦ Scaffolds module dirs (Controllers, Views, Templates, Routes, Providers, etc.), ensures Controllers exists, adds .gitkeep
◦ Writes Provider, View, Controller, Template stubs (fix variable interpolation via placeholders)
◦ Appends guarded include snippet to routes/web.php
◦ Optional composer PSR-4 mapping update (+ backup) and optional autoload dump
◦ Prevents providers.php corruption via name validation and existence checks
• Add URL extension negotiation middleware tweaks:
◦ Only set Accept for .json (and future .xml), never for none/php
◦ Never override existing Accept header
• Add MVC base classes (Controller, APIController, ViewController, View, ViewWithDefaultTemplate); update ViewController signature and View render contract
• Add tests:
◦ CreateModuleCommandTest with setup/teardown to snapshot/restore routes/web.php and composer.json; asserts scaffold and PSR-4 mapping
◦ ProviderRouteTest for provider-contributed route
◦ UrlExtensionNegotiationTest sets API_FORMAT=rest and asserts content-type behavior
◦ MvcViewTest validates transformData+render
• Fix config/providers.php syntax and add comment placeholder for modules
• Update README: M5/M6/M7 docs, MVC examples, template selection conventions, modules section, URL extension negotiation, and module creation workflow
• Update MILESTONES.md: mark M6/M7 complete; add M8 task for register:orm; note M12 XML extension support
2025-12-16 22:14:22 +00:00
|
|
|
<?php
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use Phred\Console\Command;
|
|
|
|
|
use Symfony\Component\Console\Input\InputInterface as Input;
|
|
|
|
|
use Symfony\Component\Console\Output\OutputInterface as Output;
|
|
|
|
|
|
|
|
|
|
return new class extends Command {
|
|
|
|
|
protected string $command = 'create:module';
|
|
|
|
|
protected string $description = 'Scaffold a new Module with routes, provider, and optional PSR-4 setup.';
|
|
|
|
|
protected array $options = [
|
|
|
|
|
// Match existing command style: arguments are declared as simple keys without leading dashes
|
|
|
|
|
'name' => [
|
|
|
|
|
'mode' => 'argument',
|
|
|
|
|
'required' => true,
|
|
|
|
|
'description' => 'Module name (e.g., Blog)',
|
|
|
|
|
],
|
|
|
|
|
'prefix' => [
|
|
|
|
|
'mode' => 'argument',
|
|
|
|
|
'required' => false,
|
|
|
|
|
'description' => 'Optional URL prefix (e.g., /blog). If omitted, you will be prompted or default is /<name-lower>',
|
|
|
|
|
],
|
|
|
|
|
'--update-composer' => [
|
|
|
|
|
'mode' => 'flag',
|
|
|
|
|
'description' => 'Automatically add PSR-4 mapping to composer.json and dump autoload.',
|
|
|
|
|
],
|
|
|
|
|
'--no-dump' => [
|
|
|
|
|
'mode' => 'flag',
|
|
|
|
|
'description' => 'Skip composer dump-autoload when using --update-composer.',
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
public function handle(Input $input, Output $output): int
|
|
|
|
|
{
|
|
|
|
|
$name = $this->readArgWithFallback($input, 'name', 2);
|
|
|
|
|
$name = trim($name);
|
|
|
|
|
if ($name === '') {
|
|
|
|
|
$output->writeln('<error>Module name is required.</error>');
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$prefixArg = $this->readArgWithFallback($input, 'prefix', 3);
|
|
|
|
|
[$prefix, $updateComposer, $noDump] = $this->parseArgsForPrefixAndFlags($prefixArg, $name, $input);
|
|
|
|
|
|
|
|
|
|
$root = dirname(__DIR__, 2);
|
|
|
|
|
$moduleRoot = $root . '/modules/' . $name;
|
|
|
|
|
|
|
|
|
|
if (!$this->createScaffold($moduleRoot, $output)) {
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
$this->createPersistenceDir($moduleRoot, $output);
|
|
|
|
|
|
|
|
|
|
$this->writeProviderStub($moduleRoot, $name);
|
|
|
|
|
$this->writeRoutesStubs($moduleRoot, $name);
|
|
|
|
|
$this->writeViewControllerTemplateStubs($moduleRoot, $name);
|
|
|
|
|
$this->writeControllersIndexIfMissing($moduleRoot);
|
|
|
|
|
// Ensure Controllers directory exists specifically for the test assertion
|
|
|
|
|
if (!is_dir($moduleRoot . '/Controllers')) {
|
|
|
|
|
@mkdir($moduleRoot . '/Controllers', 0777, true);
|
|
|
|
|
}
|
|
|
|
|
$this->registerProviderInConfig($root, $name);
|
|
|
|
|
$this->appendRouteInclude($root, $name, $prefix);
|
|
|
|
|
|
|
|
|
|
$this->printPsr4Hint($output, $name, $prefix);
|
|
|
|
|
if ($updateComposer) {
|
|
|
|
|
$this->updateComposerPsr4($output, $root, $name, !$noDump);
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function readArgWithFallback(Input $input, string $key, int $argvIndex): string
|
|
|
|
|
{
|
|
|
|
|
try {
|
|
|
|
|
$val = $input->getArgument($key);
|
|
|
|
|
if (is_string($val)) {
|
|
|
|
|
return $val;
|
|
|
|
|
}
|
|
|
|
|
} catch (\Symfony\Component\Console\Exception\InvalidArgumentException) {
|
|
|
|
|
// Try to read from ArrayInput-style arguments map
|
|
|
|
|
if (method_exists($input, 'getArguments')) {
|
|
|
|
|
$args = $input->getArguments();
|
|
|
|
|
if (is_array($args) && isset($args[$key]) && is_string($args[$key])) {
|
|
|
|
|
return $args[$key];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Reflection fallback for ArrayInput to read raw parameters
|
|
|
|
|
if ($input instanceof \Symfony\Component\Console\Input\ArrayInput) {
|
|
|
|
|
try {
|
|
|
|
|
$ref = new \ReflectionObject($input);
|
|
|
|
|
if ($ref->hasProperty('parameters')) {
|
|
|
|
|
$prop = $ref->getProperty('parameters');
|
|
|
|
|
$prop->setAccessible(true);
|
|
|
|
|
$params = $prop->getValue($input);
|
|
|
|
|
if (is_array($params) && isset($params[$key]) && is_string($params[$key])) {
|
|
|
|
|
return $params[$key];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Fall back to argv position if command definition didn't register arguments in this context (e.g., direct handle() calls in tests)
|
|
|
|
|
$argv = $_SERVER['argv'] ?? [];
|
|
|
|
|
$fallback = isset($argv[$argvIndex]) ? (string) $argv[$argvIndex] : '';
|
|
|
|
|
// Sanitize: ignore flags (starting with '-') and unexpected tokens
|
|
|
|
|
if ($fallback !== '' && $fallback[0] === '-') {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
return $fallback;
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function parseArgsForPrefixAndFlags(string $prefixArg, string $name, Input $input): array
|
|
|
|
|
{
|
|
|
|
|
$defaultPrefix = '/' . strtolower($name);
|
|
|
|
|
// Detect flags robustly
|
|
|
|
|
$updateComposer = false;
|
|
|
|
|
$noDump = false;
|
|
|
|
|
// Attempt to read flags from options map if available
|
|
|
|
|
if (method_exists($input, 'getOptions')) {
|
|
|
|
|
$opts = $input->getOptions();
|
|
|
|
|
if (is_array($opts)) {
|
2025-12-22 21:52:41 +00:00
|
|
|
$updateComposer = !empty($opts['--update-composer']) || !empty($opts['update-composer']);
|
|
|
|
|
$noDump = !empty($opts['--no-dump']) || !empty($opts['no-dump']);
|
Refactor M7 module scaffolding, route inclusion, and tests; implement providers discovery; fix URL extension negotiation; clean docs
• Add Service Providers loading from config/providers.php and merge with runtime config; ensure AppServiceProvider boots and contributes routes
• Create RouteGroups and guard module route includes in routes/web.php; update Kernel to auto-mount module routes and apply provider routes
• Implement create:module as a console Command (extends Phred\Console\Command):
◦ Args: name, prefix; Flags: --update-composer, --no-dump
◦ Stable root resolution (dirname(DIR, 2)); robust args/flags handling under ArrayInput
◦ Scaffolds module dirs (Controllers, Views, Templates, Routes, Providers, etc.), ensures Controllers exists, adds .gitkeep
◦ Writes Provider, View, Controller, Template stubs (fix variable interpolation via placeholders)
◦ Appends guarded include snippet to routes/web.php
◦ Optional composer PSR-4 mapping update (+ backup) and optional autoload dump
◦ Prevents providers.php corruption via name validation and existence checks
• Add URL extension negotiation middleware tweaks:
◦ Only set Accept for .json (and future .xml), never for none/php
◦ Never override existing Accept header
• Add MVC base classes (Controller, APIController, ViewController, View, ViewWithDefaultTemplate); update ViewController signature and View render contract
• Add tests:
◦ CreateModuleCommandTest with setup/teardown to snapshot/restore routes/web.php and composer.json; asserts scaffold and PSR-4 mapping
◦ ProviderRouteTest for provider-contributed route
◦ UrlExtensionNegotiationTest sets API_FORMAT=rest and asserts content-type behavior
◦ MvcViewTest validates transformData+render
• Fix config/providers.php syntax and add comment placeholder for modules
• Update README: M5/M6/M7 docs, MVC examples, template selection conventions, modules section, URL extension negotiation, and module creation workflow
• Update MILESTONES.md: mark M6/M7 complete; add M8 task for register:orm; note M12 XML extension support
2025-12-16 22:14:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Reflection fallback to read raw parameters from ArrayInput
|
|
|
|
|
if ($input instanceof \Symfony\Component\Console\Input\ArrayInput) {
|
|
|
|
|
try {
|
|
|
|
|
$ref = new \ReflectionObject($input);
|
|
|
|
|
if ($ref->hasProperty('parameters')) {
|
|
|
|
|
$prop = $ref->getProperty('parameters');
|
|
|
|
|
$prop->setAccessible(true);
|
|
|
|
|
$params = $prop->getValue($input);
|
|
|
|
|
if (is_array($params)) {
|
|
|
|
|
if (array_key_exists('--update-composer', $params)) { $updateComposer = (bool) $params['--update-composer']; }
|
|
|
|
|
if (array_key_exists('--no-dump', $params)) { $noDump = (bool) $params['--no-dump']; }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (method_exists($input, 'hasParameterOption')) {
|
|
|
|
|
/** @var \Symfony\Component\Console\Input\InputInterface $input */
|
|
|
|
|
if ($input->hasParameterOption('--update-composer')) { $updateComposer = true; }
|
|
|
|
|
if ($input->hasParameterOption('--no-dump')) { $noDump = true; }
|
|
|
|
|
}
|
|
|
|
|
try { $updateComposer = $updateComposer || (bool) $input->getOption('update-composer'); } catch (\Symfony\Component\Console\Exception\InvalidArgumentException) {}
|
|
|
|
|
try { $noDump = $noDump || (bool) $input->getOption('no-dump'); } catch (\Symfony\Component\Console\Exception\InvalidArgumentException) {}
|
|
|
|
|
|
|
|
|
|
if ($prefixArg !== '') {
|
|
|
|
|
$prefix = $prefixArg;
|
|
|
|
|
} else {
|
|
|
|
|
$prefix = $this->readPrefixInteractive($name, $defaultPrefix);
|
|
|
|
|
}
|
|
|
|
|
$prefix = '/' . trim((string) $prefix, '/');
|
|
|
|
|
return [$prefix, $updateComposer, $noDump];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function readPrefixInteractive(string $name, string $defaultPrefix): string
|
|
|
|
|
{
|
|
|
|
|
$isInteractive = function (): bool {
|
|
|
|
|
if (function_exists('stream_isatty')) {
|
|
|
|
|
return @stream_isatty(STDIN);
|
|
|
|
|
}
|
|
|
|
|
if (function_exists('posix_isatty')) {
|
|
|
|
|
return @posix_isatty(STDIN);
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
if ($isInteractive()) {
|
|
|
|
|
fwrite(STDOUT, "Enter URL prefix for module '$name' [{$defaultPrefix}]: ");
|
|
|
|
|
$input = fgets(STDIN);
|
|
|
|
|
$input = $input === false ? '' : trim((string) $input);
|
|
|
|
|
return $input === '' ? $defaultPrefix : $input;
|
|
|
|
|
}
|
|
|
|
|
return $defaultPrefix;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function createScaffold(string $moduleRoot, Output $output): bool
|
|
|
|
|
{
|
|
|
|
|
$dirs = [
|
|
|
|
|
'Controllers',
|
|
|
|
|
'Views',
|
|
|
|
|
'Templates',
|
|
|
|
|
'Services',
|
|
|
|
|
'Models',
|
|
|
|
|
'Repositories',
|
|
|
|
|
'Database/Migrations',
|
|
|
|
|
'Routes',
|
|
|
|
|
'Providers',
|
|
|
|
|
'Tests',
|
|
|
|
|
];
|
|
|
|
|
// Ensure module root exists first
|
|
|
|
|
if (!is_dir($moduleRoot) && !mkdir($moduleRoot, 0777, true) && !is_dir($moduleRoot)) {
|
|
|
|
|
$output->writeln('<error>Failed to create directory:</error> ' . $moduleRoot);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
foreach ($dirs as $dir) {
|
|
|
|
|
$path = $moduleRoot . '/' . $dir;
|
|
|
|
|
if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) {
|
|
|
|
|
$output->writeln('<error>Failed to create directory:</error> ' . $path);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function createPersistenceDir(string $moduleRoot, Output $output): void
|
|
|
|
|
{
|
|
|
|
|
$driver = getenv('ORM_DRIVER') ?: null;
|
|
|
|
|
if (!$driver) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
$driverName = ucfirst(strtolower($driver));
|
|
|
|
|
$path = $moduleRoot . '/Persistence/' . $driverName;
|
|
|
|
|
if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) {
|
|
|
|
|
$output->writeln('<error>Failed to create directory:</error> ' . $path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function writeProviderStub(string $moduleRoot, string $name): void
|
|
|
|
|
{
|
|
|
|
|
$providerClass = $name . 'ServiceProvider';
|
|
|
|
|
$providerNs = "Project\\Modules\\$name\\Providers";
|
2025-12-22 21:52:41 +00:00
|
|
|
$stub = file_get_contents(dirname(__DIR__) . '/stubs/module/provider.stub');
|
|
|
|
|
$providerCode = strtr($stub, [
|
|
|
|
|
'{{namespace}}' => $providerNs,
|
|
|
|
|
'{{class}}' => $providerClass,
|
|
|
|
|
'{{name}}' => $name,
|
|
|
|
|
]);
|
Refactor M7 module scaffolding, route inclusion, and tests; implement providers discovery; fix URL extension negotiation; clean docs
• Add Service Providers loading from config/providers.php and merge with runtime config; ensure AppServiceProvider boots and contributes routes
• Create RouteGroups and guard module route includes in routes/web.php; update Kernel to auto-mount module routes and apply provider routes
• Implement create:module as a console Command (extends Phred\Console\Command):
◦ Args: name, prefix; Flags: --update-composer, --no-dump
◦ Stable root resolution (dirname(DIR, 2)); robust args/flags handling under ArrayInput
◦ Scaffolds module dirs (Controllers, Views, Templates, Routes, Providers, etc.), ensures Controllers exists, adds .gitkeep
◦ Writes Provider, View, Controller, Template stubs (fix variable interpolation via placeholders)
◦ Appends guarded include snippet to routes/web.php
◦ Optional composer PSR-4 mapping update (+ backup) and optional autoload dump
◦ Prevents providers.php corruption via name validation and existence checks
• Add URL extension negotiation middleware tweaks:
◦ Only set Accept for .json (and future .xml), never for none/php
◦ Never override existing Accept header
• Add MVC base classes (Controller, APIController, ViewController, View, ViewWithDefaultTemplate); update ViewController signature and View render contract
• Add tests:
◦ CreateModuleCommandTest with setup/teardown to snapshot/restore routes/web.php and composer.json; asserts scaffold and PSR-4 mapping
◦ ProviderRouteTest for provider-contributed route
◦ UrlExtensionNegotiationTest sets API_FORMAT=rest and asserts content-type behavior
◦ MvcViewTest validates transformData+render
• Fix config/providers.php syntax and add comment placeholder for modules
• Update README: M5/M6/M7 docs, MVC examples, template selection conventions, modules section, URL extension negotiation, and module creation workflow
• Update MILESTONES.md: mark M6/M7 complete; add M8 task for register:orm; note M12 XML extension support
2025-12-16 22:14:22 +00:00
|
|
|
file_put_contents($moduleRoot . '/Providers/' . $providerClass . '.php', $providerCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function writeRoutesStubs(string $moduleRoot, string $name): void
|
|
|
|
|
{
|
|
|
|
|
file_put_contents($moduleRoot . '/Routes/web.php', "<?php\n// Module web routes for $name\n");
|
|
|
|
|
file_put_contents($moduleRoot . '/Routes/api.php', "<?php\n// Module API routes for $name\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function writeViewControllerTemplateStubs(string $moduleRoot, string $name): void
|
|
|
|
|
{
|
|
|
|
|
$viewNs = "Project\\Modules\\$name\\Views";
|
2025-12-22 21:52:41 +00:00
|
|
|
$viewStub = file_get_contents(dirname(__DIR__) . '/stubs/module/view.stub');
|
|
|
|
|
$viewCode = strtr($viewStub, [
|
|
|
|
|
'{{namespace}}' => $viewNs,
|
|
|
|
|
]);
|
Refactor M7 module scaffolding, route inclusion, and tests; implement providers discovery; fix URL extension negotiation; clean docs
• Add Service Providers loading from config/providers.php and merge with runtime config; ensure AppServiceProvider boots and contributes routes
• Create RouteGroups and guard module route includes in routes/web.php; update Kernel to auto-mount module routes and apply provider routes
• Implement create:module as a console Command (extends Phred\Console\Command):
◦ Args: name, prefix; Flags: --update-composer, --no-dump
◦ Stable root resolution (dirname(DIR, 2)); robust args/flags handling under ArrayInput
◦ Scaffolds module dirs (Controllers, Views, Templates, Routes, Providers, etc.), ensures Controllers exists, adds .gitkeep
◦ Writes Provider, View, Controller, Template stubs (fix variable interpolation via placeholders)
◦ Appends guarded include snippet to routes/web.php
◦ Optional composer PSR-4 mapping update (+ backup) and optional autoload dump
◦ Prevents providers.php corruption via name validation and existence checks
• Add URL extension negotiation middleware tweaks:
◦ Only set Accept for .json (and future .xml), never for none/php
◦ Never override existing Accept header
• Add MVC base classes (Controller, APIController, ViewController, View, ViewWithDefaultTemplate); update ViewController signature and View render contract
• Add tests:
◦ CreateModuleCommandTest with setup/teardown to snapshot/restore routes/web.php and composer.json; asserts scaffold and PSR-4 mapping
◦ ProviderRouteTest for provider-contributed route
◦ UrlExtensionNegotiationTest sets API_FORMAT=rest and asserts content-type behavior
◦ MvcViewTest validates transformData+render
• Fix config/providers.php syntax and add comment placeholder for modules
• Update README: M5/M6/M7 docs, MVC examples, template selection conventions, modules section, URL extension negotiation, and module creation workflow
• Update MILESTONES.md: mark M6/M7 complete; add M8 task for register:orm; note M12 XML extension support
2025-12-16 22:14:22 +00:00
|
|
|
file_put_contents($moduleRoot . '/Views/HomeView.php', $viewCode);
|
|
|
|
|
|
|
|
|
|
$ctrlNs = "Project\\Modules\\$name\\Controllers";
|
|
|
|
|
$ctrlUsesViewNs = "Project\\Modules\\$name\\Views\\HomeView";
|
2025-12-22 21:52:41 +00:00
|
|
|
$ctrlStub = file_get_contents(dirname(__DIR__) . '/stubs/module/controller.stub');
|
|
|
|
|
$ctrlCode = strtr($ctrlStub, [
|
|
|
|
|
'{{namespace}}' => $ctrlNs,
|
|
|
|
|
'{{viewNamespace}}' => $ctrlUsesViewNs,
|
|
|
|
|
'{{moduleName}}' => $name,
|
Refactor M7 module scaffolding, route inclusion, and tests; implement providers discovery; fix URL extension negotiation; clean docs
• Add Service Providers loading from config/providers.php and merge with runtime config; ensure AppServiceProvider boots and contributes routes
• Create RouteGroups and guard module route includes in routes/web.php; update Kernel to auto-mount module routes and apply provider routes
• Implement create:module as a console Command (extends Phred\Console\Command):
◦ Args: name, prefix; Flags: --update-composer, --no-dump
◦ Stable root resolution (dirname(DIR, 2)); robust args/flags handling under ArrayInput
◦ Scaffolds module dirs (Controllers, Views, Templates, Routes, Providers, etc.), ensures Controllers exists, adds .gitkeep
◦ Writes Provider, View, Controller, Template stubs (fix variable interpolation via placeholders)
◦ Appends guarded include snippet to routes/web.php
◦ Optional composer PSR-4 mapping update (+ backup) and optional autoload dump
◦ Prevents providers.php corruption via name validation and existence checks
• Add URL extension negotiation middleware tweaks:
◦ Only set Accept for .json (and future .xml), never for none/php
◦ Never override existing Accept header
• Add MVC base classes (Controller, APIController, ViewController, View, ViewWithDefaultTemplate); update ViewController signature and View render contract
• Add tests:
◦ CreateModuleCommandTest with setup/teardown to snapshot/restore routes/web.php and composer.json; asserts scaffold and PSR-4 mapping
◦ ProviderRouteTest for provider-contributed route
◦ UrlExtensionNegotiationTest sets API_FORMAT=rest and asserts content-type behavior
◦ MvcViewTest validates transformData+render
• Fix config/providers.php syntax and add comment placeholder for modules
• Update README: M5/M6/M7 docs, MVC examples, template selection conventions, modules section, URL extension negotiation, and module creation workflow
• Update MILESTONES.md: mark M6/M7 complete; add M8 task for register:orm; note M12 XML extension support
2025-12-16 22:14:22 +00:00
|
|
|
]);
|
|
|
|
|
file_put_contents($moduleRoot . '/Controllers/HomeController.php', $ctrlCode);
|
|
|
|
|
|
|
|
|
|
file_put_contents($moduleRoot . '/Templates/home.eyrie.php', "<h1><?= htmlspecialchars(\$title) ?></h1>\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function writeControllersIndexIfMissing(string $moduleRoot): void
|
|
|
|
|
{
|
|
|
|
|
$controllersDir = $moduleRoot . '/Controllers';
|
|
|
|
|
if (!is_dir($controllersDir)) {
|
|
|
|
|
@mkdir($controllersDir, 0777, true);
|
|
|
|
|
}
|
|
|
|
|
$index = $controllersDir . '/.gitkeep';
|
|
|
|
|
if (!is_file($index)) {
|
|
|
|
|
@file_put_contents($index, "");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function registerProviderInConfig(string $root, string $name): void
|
|
|
|
|
{
|
|
|
|
|
$providersFile = $root . '/config/providers.php';
|
|
|
|
|
if (!is_file($providersFile)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
$contents = file_get_contents($providersFile) ?: '';
|
|
|
|
|
$providerClass = $name . 'ServiceProvider';
|
|
|
|
|
// Validate module name to avoid accidental injection from CLI flags
|
|
|
|
|
if (!preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $name)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Only register if module path exists
|
|
|
|
|
if (!is_dir($root . '/modules/' . $name)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
$fqcn = "Project\\Modules\\$name\\Providers\\$providerClass::class";
|
|
|
|
|
if (strpos($contents, $fqcn) !== false) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
$updated = preg_replace(
|
|
|
|
|
'/(\'modules\'\s*=>\s*\[)([\s\S]*?)(\])/',
|
|
|
|
|
"$1$2\n Project\\\\Modules\\\\$name\\\\Providers\\\\$providerClass::class,\n $3",
|
|
|
|
|
$contents,
|
|
|
|
|
1
|
|
|
|
|
);
|
|
|
|
|
if ($updated) {
|
|
|
|
|
file_put_contents($providersFile, $updated);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function appendRouteInclude(string $root, string $name, string $prefix): void
|
|
|
|
|
{
|
|
|
|
|
$routesRoot = $root . '/routes';
|
|
|
|
|
$webRootFile = $routesRoot . '/web.php';
|
|
|
|
|
if (!is_dir($routesRoot)) {
|
|
|
|
|
mkdir($routesRoot, 0777, true);
|
|
|
|
|
}
|
|
|
|
|
if (!is_file($webRootFile)) {
|
|
|
|
|
file_put_contents($webRootFile, "<?php\n");
|
|
|
|
|
}
|
|
|
|
|
$dollar = '$';
|
|
|
|
|
$includeSnippet = "\n" .
|
|
|
|
|
"// Module '$name' mounted at '$prefix' (only if module routes file exists)\n" .
|
|
|
|
|
"if (is_file(__DIR__ . '/../modules/$name/Routes/web.php')) {\n" .
|
|
|
|
|
" \\Phred\\Http\\Routing\\RouteGroups::include(" . $dollar . "router, '$prefix', function (\\Phred\\Http\\Router " . $dollar . "router) {\n" .
|
|
|
|
|
" /** @noinspection PhpIncludeInspection */\n" .
|
|
|
|
|
" (static function (" . $dollar . "router) { require __DIR__ . '/../modules/$name/Routes/web.php'; })(" . $dollar . "router);\n" .
|
|
|
|
|
" });\n" .
|
|
|
|
|
"}\n";
|
|
|
|
|
$currentWeb = file_get_contents($webRootFile) ?: '';
|
|
|
|
|
if (strpos($currentWeb, "/modules/$name/Routes/web.php") === false) {
|
|
|
|
|
file_put_contents($webRootFile, $currentWeb . $includeSnippet);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function printPsr4Hint(Output $output, string $name, string $prefix): void
|
|
|
|
|
{
|
|
|
|
|
$output->writeln("\n<info>Module '$name' created</info> in modules/$name.");
|
|
|
|
|
$output->writeln('Remember to add PSR-4 autoload mapping in composer.json:');
|
|
|
|
|
$output->writeln(' "Project\\\\Modules\\\\' . $name . '\\\\": "modules/' . $name . '/"');
|
|
|
|
|
$output->writeln("\nModule routes are mounted at prefix '$prefix' in routes/web.php.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function updateComposerPsr4(Output $output, string $root, string $name, bool $dumpAutoload): void
|
|
|
|
|
{
|
|
|
|
|
$composer = $root . '/composer.json';
|
|
|
|
|
if (!is_file($composer)) {
|
|
|
|
|
$output->writeln('<error>composer.json not found; cannot update PSR-4 mapping.</error>');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
$json = file_get_contents($composer);
|
|
|
|
|
$data = $json ? json_decode($json, true) : null;
|
|
|
|
|
if (!is_array($data)) {
|
|
|
|
|
$output->writeln('<error>composer.json parse error; aborting PSR-4 update.</error>');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
$bak = $composer . '.bak';
|
|
|
|
|
@copy($composer, $bak);
|
|
|
|
|
$psr4 = $data['autoload']['psr-4'] ?? [];
|
|
|
|
|
$ns = 'Project\\Modules\\' . $name . '\\';
|
|
|
|
|
$path = 'modules/' . $name . '/';
|
|
|
|
|
$changed = false;
|
|
|
|
|
if (!isset($psr4[$ns])) {
|
|
|
|
|
$psr4[$ns] = $path;
|
|
|
|
|
$data['autoload']['psr-4'] = $psr4;
|
|
|
|
|
$changed = true;
|
|
|
|
|
}
|
|
|
|
|
if ($changed) {
|
|
|
|
|
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
|
|
|
|
if (file_put_contents($composer, $encoded) === false) {
|
|
|
|
|
$output->writeln('<error>Failed to write composer.json; original saved to composer.json.bak</error>');
|
|
|
|
|
} else {
|
|
|
|
|
$output->writeln('<info>Updated composer.json</info> with PSR-4 mapping for Project\\Modules\\' . $name . '\\.');
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
$output->writeln('PSR-4 mapping already exists in composer.json.');
|
|
|
|
|
}
|
|
|
|
|
if ($dumpAutoload) {
|
|
|
|
|
$out = shell_exec('composer dump-autoload 2>&1') ?: '';
|
|
|
|
|
$output->writeln(trim($out));
|
|
|
|
|
} else {
|
|
|
|
|
$output->writeln('Skipped composer dump-autoload (use --no-dump).');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|