Phase 3 sub-commit 2: make:bridge:window + make:bridge:command makers

Two new makers complete the trio the todo POC needs:

`make:bridge:window <Name>`:
  - emits {qml_path}/<Name>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 <Name>`:
  - emits src/Controller/<Name>Controller.php mounted at
    POST /api/<kebab-name>. 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 15:15:37 +02:00
parent d4343977e1
commit 9c97984bc9
4 changed files with 287 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Maker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
/**
* `make:bridge:command <Name>` — 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"
." • <info>src/Controller/<Name>Controller.php</info> — POST handler at <info>/api/<kebab-name></info>\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('/(?<!^)[A-Z]/', '-$0', $singular) ?? $singular);
$route = '/api/'.$kebab;
$controllerFqcn = $generator->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 <info>rest.post(\"{$route}\")</info>.",
]);
}
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');
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
/**
* `make:bridge:window <Name>` — emits a top-level QML Window that
* wraps `AppShell` and a content slot. Application code opens it via
* `Qt.createComponent("<Name>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"
." • <info>{qml_path}/<Name>Window.qml</info> — 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 <info>{$target}</info>.");
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
// Pure-QML output — no PHP runtime deps.
}
}

View File

@@ -0,0 +1,34 @@
<?= "<?php\n" ?>
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 <?= $controller_short ?>
{
public function __construct(
private readonly EntityManagerInterface $em,
) {
}
#[Route('<?= $route ?>', name: '<?= $kebab ?>', methods: ['POST'])]
public function __invoke(Request $request): JsonResponse
{
// TODO: implement <?= $singular ?>.
// Read inputs from $request->getPayload() / json_decode($request->getContent(), true).
// Mutate entities, then $this->em->flush().
return new JsonResponse(['ok' => true]);
}
}

View File

@@ -0,0 +1,36 @@
// Auto-generated by `bin/console make:bridge:window <?= $singular ?>`.
// 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: <?= $resource ?>Window
width: 720
height: 520
visible: true
title: "<?= $singular ?>"
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: "<?= $singular ?>"
font.pixelSize: 18
font.bold: true
}
// TODO: window content here.
Item { Layout.fillWidth: true; Layout.fillHeight: true }
}
}
}