From 9c97984bc93c76159836e2f5d5071a75ee34a682 Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 2 May 2026 15:15:37 +0200 Subject: [PATCH] Phase 3 sub-commit 2: make:bridge:window + make:bridge:command makers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new makers complete the trio the todo POC needs: `make:bridge:window `: - emits {qml_path}/Window.qml — an ApplicationWindow wrapping AppShell with a content slot to fill in. Apps open it via Qt.createComponent() / a Component { } block to get extra instances for the multi-window test (PLAN.md §13 Phase 3). - pure-QML output, no PHP runtime deps. `make:bridge:command `: - emits src/Controller/Controller.php mounted at POST /api/. The body is a TODO stub that fills in domain logic and flushes via the injected EntityManager — Doctrine listeners pick up the changes and publish to Mercure automatically. Synchronous by design (no Messenger plumbing for a POC); apps that need async dispatch can add Messenger and refactor. Templates excluded from PHPStan / cs-fixer the same way the resource maker's are. Smoke-tested both makers against `MarkAllDone` and `AboutDialog` — output is correct PHP / QML and re-running them reproduces byte-for-byte. composer quality stays green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../php/src/Maker/BridgeCommandMaker.php | 119 ++++++++++++++++++ framework/php/src/Maker/BridgeWindowMaker.php | 98 +++++++++++++++ .../php/src/Maker/templates/Command.tpl.php | 34 +++++ .../php/src/Maker/templates/Window.tpl.php | 36 ++++++ 4 files changed, 287 insertions(+) create mode 100644 framework/php/src/Maker/BridgeCommandMaker.php create mode 100644 framework/php/src/Maker/BridgeWindowMaker.php create mode 100644 framework/php/src/Maker/templates/Command.tpl.php create mode 100644 framework/php/src/Maker/templates/Window.tpl.php diff --git a/framework/php/src/Maker/BridgeCommandMaker.php b/framework/php/src/Maker/BridgeCommandMaker.php new file mode 100644 index 0000000..8044568 --- /dev/null +++ b/framework/php/src/Maker/BridgeCommandMaker.php @@ -0,0 +1,119 @@ +` — emits a non-CRUD HTTP endpoint that + * carries a domain action. + * + * For example, `make:bridge:command MarkAllDone` generates + * `src/Controller/MarkAllDoneController.php` mounted at + * `POST /api/mark-all-done`. The generated body is a TODO stub the + * developer fills in. + * + * Phase 3 keeps this synchronous — Messenger integration for async + * dispatch is a follow-up. Apps that need it can add Messenger + * separately and refactor the generated controller to dispatch via the + * bus. + */ +final class BridgeCommandMaker extends AbstractMaker +{ + public static function getCommandName(): string + { + return 'make:bridge:command'; + } + + public static function getCommandDescription(): string + { + return 'Generate a non-CRUD HTTP command endpoint (POST /api/{kebab-name}).'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command + ->addArgument( + 'name', + InputArgument::OPTIONAL, + 'Singular CamelCase name of the command (e.g. MarkAllDone).', + ) + ->setHelp( + "Creates one file:\n\n" + ." • src/Controller/Controller.php — POST handler at /api/\n\n" + ."The body is a TODO stub. Fill in the domain logic and persist via the EntityManager.\n" + ); + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + if (null === $input->getArgument('name')) { + $name = $io->ask('Command name (CamelCase)?', null, static function (?string $v): string { + if (null === $v || '' === trim($v)) { + throw new \RuntimeException('Command name cannot be empty.'); + } + + return ucfirst(trim($v)); + }); + $input->setArgument('name', $name); + } + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + $rawName = (string) $input->getArgument('name'); + $singular = ucfirst(Str::asCamelCase($rawName)); + $kebab = strtolower(preg_replace('/(?createClassNameDetails( + $singular, + 'Controller\\', + 'Controller', + ); + + $vars = [ + 'singular' => $singular, + 'route' => $route, + 'kebab' => $kebab, + 'controller_fqcn' => $controllerFqcn->getFullName(), + 'controller_short' => $controllerFqcn->getShortName(), + ]; + + $generator->generateFile( + 'src/Controller/'.$controllerFqcn->getShortName().'.php', + __DIR__.'/templates/Command.tpl.php', + $vars, + ); + + $generator->writeChanges(); + $this->writeSuccessMessage($io); + $io->text([ + 'Next:', + " • Fill in the body of {$controllerFqcn->getShortName()}::__invoke().", + " • From QML, call rest.post(\"{$route}\").", + ]); + } + + public function configureDependencies(DependencyBuilder $dependencies): void + { + $dependencies->addClassDependency(Route::class, 'symfony/routing'); + $dependencies->addClassDependency(JsonResponse::class, 'symfony/http-foundation'); + $dependencies->addClassDependency(Request::class, 'symfony/http-foundation'); + $dependencies->addClassDependency(EntityManagerInterface::class, 'doctrine/orm'); + } +} diff --git a/framework/php/src/Maker/BridgeWindowMaker.php b/framework/php/src/Maker/BridgeWindowMaker.php new file mode 100644 index 0000000..571c407 --- /dev/null +++ b/framework/php/src/Maker/BridgeWindowMaker.php @@ -0,0 +1,98 @@ +` — emits a top-level QML Window that + * wraps `AppShell` and a content slot. Application code opens it via + * `Qt.createComponent("Window.qml")` (or by importing it) for + * the first window and as many extra instances as it wants for the + * multi-window test from PLAN.md §9 / §13 Phase 3. + * + * Generated file goes to `qml_path` (default: `../qml/`). + */ +final class BridgeWindowMaker extends AbstractMaker +{ + public function __construct( + private readonly string $qmlPath = '../qml/', + ) { + } + + public static function getCommandName(): string + { + return 'make:bridge:window'; + } + + public static function getCommandDescription(): string + { + return 'Generate a top-level QML Window scaffold (AppShell + content slot).'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command + ->addArgument( + 'name', + InputArgument::OPTIONAL, + 'Singular name of the window (e.g. Todo, Settings).', + ) + ->setHelp( + "Creates one file:\n\n" + ." • {qml_path}/Window.qml — Window subclass with AppShell\n" + ); + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + if (null === $input->getArgument('name')) { + $name = $io->ask('Window name?', null, static function (?string $v): string { + if (null === $v || '' === trim($v)) { + throw new \RuntimeException('Window name cannot be empty.'); + } + + return ucfirst(trim($v)); + }); + $input->setArgument('name', $name); + } + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + $rawName = (string) $input->getArgument('name'); + $singular = ucfirst(Str::asCamelCase($rawName)); + $resource = strtolower($singular); + + $vars = [ + 'singular' => $singular, + 'resource' => $resource, + ]; + + $target = rtrim($this->qmlPath, '/').'/'.$singular.'Window.qml'; + $generator->generateFile( + $target, + __DIR__.'/templates/Window.tpl.php', + $vars, + ); + + $generator->writeChanges(); + $this->writeSuccessMessage($io); + $io->text("Window scaffold at {$target}."); + } + + public function configureDependencies(DependencyBuilder $dependencies): void + { + // Pure-QML output — no PHP runtime deps. + } +} diff --git a/framework/php/src/Maker/templates/Command.tpl.php b/framework/php/src/Maker/templates/Command.tpl.php new file mode 100644 index 0000000..c267242 --- /dev/null +++ b/framework/php/src/Maker/templates/Command.tpl.php @@ -0,0 +1,34 @@ + + +declare(strict_types=1); + +namespace App\Controller; + +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Attribute\Route; + +/** + * Auto-generated domain command endpoint. Fill in the body and persist + * via $this->em->flush(). Doctrine entities tagged with #[BridgeResource] + * publish their changes to Mercure automatically. + */ +final class + +{ + public function __construct( + private readonly EntityManagerInterface $em, + ) { + } + + #[Route('', name: '', methods: ['POST'])] + public function __invoke(Request $request): JsonResponse + { + // TODO: implement . + // Read inputs from $request->getPayload() / json_decode($request->getContent(), true). + // Mutate entities, then $this->em->flush(). + + return new JsonResponse(['ok' => true]); + } +} diff --git a/framework/php/src/Maker/templates/Window.tpl.php b/framework/php/src/Maker/templates/Window.tpl.php new file mode 100644 index 0000000..f8f2e36 --- /dev/null +++ b/framework/php/src/Maker/templates/Window.tpl.php @@ -0,0 +1,36 @@ +// Auto-generated by `bin/console make:bridge:window `. +// Edit freely; re-running the maker won't overwrite this file. + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import PhpQml.Bridge + +ApplicationWindow { + id: Window + width: 720 + height: 520 + visible: true + title: "" + + AppShell { + anchors.fill: parent + + // Replace this with the window's content. The AppShell parent + // takes care of Reconnecting / Offline UI overlays. + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + + Label { + text: "" + font.pixelSize: 18 + font.bold: true + } + + // TODO: window content here. + Item { Layout.fillWidth: true; Layout.fillHeight: true } + } + } +}