diff --git a/src/Phaseolies/Console/Commands/FrontendInstallCommand.php b/src/Phaseolies/Console/Commands/FrontendInstallCommand.php index bed3439..2519573 100644 --- a/src/Phaseolies/Console/Commands/FrontendInstallCommand.php +++ b/src/Phaseolies/Console/Commands/FrontendInstallCommand.php @@ -55,6 +55,11 @@ public function handle(): int in_array($framework, ['react', 'vue', 'svelte'], true) ); + $htmx = $this->confirm( + 'Do you want HTMX support?', + false + ); + $installDependencies = (bool) ($this->option('install') ?: $this->confirm( 'Do you want to install frontend dependencies now?', false @@ -68,6 +73,7 @@ public function handle(): int 'cssStack' => $cssStack, 'framework' => $framework, 'typescript' => $typescript, + 'htmx' => $htmx, 'installDependencies' => $installDependencies, 'packageManager' => $packageManager, 'force' => (bool) $this->option('force'), @@ -118,13 +124,15 @@ protected function renderInstallationWelcome(): void */ protected function ensureClientDirectories(): void { - foreach ([ - client_path(), - client_path('css'), - client_path('js'), - resource_path('views/layouts'), - storage_path('framework'), - ] as $directory) { + foreach ( + [ + client_path(), + client_path('css'), + client_path('js'), + resource_path('views/layouts'), + storage_path('framework'), + ] as $directory + ) { if (!is_dir($directory)) { mkdir($directory, 0777, true); } @@ -141,14 +149,15 @@ protected function writeFrontendFiles(array $options, array &$state): void { $framework = $options['framework']; $typescript = $options['typescript']; + $htmx = $options['htmx']; $cssStack = $options['cssStack']; $force = $options['force']; $files = [ - base_path('package.json') => $this->packageJson($framework, $cssStack, $typescript), + base_path('package.json') => $this->packageJson($framework, $cssStack, $typescript, $htmx), base_path('vite.config.js') => $this->viteConfig($framework, $typescript), client_path('css/app.css') => $this->clientCss($cssStack), - client_path('js/' . $this->bootstrapFilename($typescript)) => $this->bootstrapFile($cssStack, $typescript), + client_path('js/' . $this->bootstrapFilename($typescript)) => $this->bootstrapFile($cssStack, $typescript, $htmx), client_path('js/' . $this->entryFilename($framework, $typescript)) => $this->entryFile($framework, $cssStack, $typescript), base_path('resources/views/layouts/app.odo.php') => $this->appLayoutView($framework, $typescript), base_path('resources/views/welcome.odo.php') => $this->welcomeView($framework, $typescript), @@ -335,7 +344,7 @@ protected function entryFilename(string $framework, bool $typescript): string * @param bool $typescript * @return string */ - protected function packageJson(string $framework, string $cssStack, bool $typescript): string + protected function packageJson(string $framework, string $cssStack, bool $typescript, bool $htmx): string { $dependencies = []; $devDependencies = [ @@ -362,6 +371,10 @@ protected function packageJson(string $framework, string $cssStack, bool $typesc $dependencies['bootstrap'] = '^5.3.3'; } + if ($htmx) { + $dependencies['htmx.org'] = '^2.0.9'; + } + if ($cssStack === 'tailwind') { $devDependencies['postcss'] = '^8.4.49'; $devDependencies['tailwindcss'] = '^4.0.0'; @@ -458,7 +471,7 @@ protected function frameworkComponentFiles(string $framework, bool $typescript): return match ($framework) { 'react' => [ client_path('js/' . ($typescript ? 'App.tsx' : 'App.jsx')) => - $this->getFrontendStubContent('components/' . ($typescript ? 'react.tsx.stub' : 'react.jsx.stub')), + $this->getFrontendStubContent('components/' . ($typescript ? 'react.tsx.stub' : 'react.jsx.stub')), ], 'vue' => [ client_path('js/App.vue') => $this->getFrontendStubContent('components/vue.stub'), @@ -477,16 +490,20 @@ protected function frameworkComponentFiles(string $framework, bool $typescript): * @param bool $typescript * @return string */ - protected function bootstrapFile(string $cssStack, bool $typescript): string + protected function bootstrapFile(string $cssStack, bool $typescript, bool $htmx): string { $bootstrapVendorImport = $cssStack === 'bootstrap' ? "import 'bootstrap/dist/js/bootstrap.bundle.min.js';\n" : ''; + $htmxImport = $htmx + ? "import 'htmx.org';\n" + : ''; return $this->renderFrontendStub( 'entries/' . ($typescript ? 'bootstrap.ts.stub' : 'bootstrap.js.stub'), [ 'bootstrapVendorImport' => $bootstrapVendorImport, + 'htmxImport' => $htmxImport, ] ); } diff --git a/src/Phaseolies/Console/Commands/FrontendUninstallCommand.php b/src/Phaseolies/Console/Commands/FrontendUninstallCommand.php index b348549..a51f98b 100644 --- a/src/Phaseolies/Console/Commands/FrontendUninstallCommand.php +++ b/src/Phaseolies/Console/Commands/FrontendUninstallCommand.php @@ -87,9 +87,9 @@ protected function performLegacyFrontendCleanup(bool $removeNodeModules = false) && $this->isGeneratedPackageJson((string) file_get_contents($packageJsonPath)); $generatedRootFiles = [ - base_path('vite.config.js') => fn (string $contents): bool => $this->isGeneratedViteConfig($contents), - base_path('tsconfig.json') => fn (string $contents): bool => $this->isGeneratedTsConfig($contents), - base_path('postcss.config.js') => fn (string $contents): bool => $this->isGeneratedPostcssConfig($contents), + base_path('vite.config.js') => fn(string $contents): bool => $this->isGeneratedViteConfig($contents), + base_path('tsconfig.json') => fn(string $contents): bool => $this->isGeneratedTsConfig($contents), + base_path('postcss.config.js') => fn(string $contents): bool => $this->isGeneratedPostcssConfig($contents), ]; foreach ($generatedRootFiles as $path => $detector) { @@ -320,6 +320,7 @@ protected function isGeneratedPackageJson(string $contents): bool 'vue', 'svelte', 'bootstrap', + 'htmx.org', 'postcss', 'tailwindcss', '@tailwindcss/postcss', diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.js.stub b/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.js.stub index df24346..851c639 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.js.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.js.stub @@ -1,4 +1,4 @@ -{{ bootstrapVendorImport }}const csrfToken = document +{{ bootstrapVendorImport }}{{ htmxImport }}const csrfToken = document .querySelector('meta[name="csrf-token"]') ?.getAttribute('content'); diff --git a/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.ts.stub b/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.ts.stub index ab834fc..244ffb3 100644 --- a/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.ts.stub +++ b/src/Phaseolies/Console/Commands/stubs/frontend/entries/bootstrap.ts.stub @@ -1,4 +1,4 @@ -{{ bootstrapVendorImport }}const csrfToken = document +{{ bootstrapVendorImport }}{{ htmxImport }}const csrfToken = document .querySelector('meta[name="csrf-token"]') ?.getAttribute('content'); diff --git a/tests/Console/FrontendInstallCommandTest.php b/tests/Console/FrontendInstallCommandTest.php index e7ce21a..5ebf6db 100644 --- a/tests/Console/FrontendInstallCommandTest.php +++ b/tests/Console/FrontendInstallCommandTest.php @@ -97,7 +97,7 @@ public function testClientBootstrapExposesCsrfHeaderFromMetaToken(): void $command = new FrontendInstallCommand(); $method = new \ReflectionMethod($command, 'bootstrapFile'); - $bootstrap = $method->invoke($command, 'bootstrap', true); + $bootstrap = $method->invoke($command, 'bootstrap', true, false); $this->assertStringContainsString("meta[name=\"csrf-token\"]", $bootstrap); $this->assertStringContainsString("headers: csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}", $bootstrap); @@ -105,6 +105,7 @@ public function testClientBootstrapExposesCsrfHeaderFromMetaToken(): void $this->assertStringNotContainsString('XMLHttpRequest.prototype', $bootstrap); $this->assertStringNotContainsString('X-Requested-With', $bootstrap); $this->assertStringContainsString("import 'bootstrap/dist/js/bootstrap.bundle.min.js';", $bootstrap); + $this->assertStringNotContainsString("import 'htmx.org';", $bootstrap); $this->assertStringContainsString('declare global', $bootstrap); } @@ -113,7 +114,7 @@ public function testJavascriptBootstrapStubAvoidsTypescriptOnlySyntax(): void $command = new FrontendInstallCommand(); $method = new \ReflectionMethod($command, 'bootstrapFile'); - $bootstrap = $method->invoke($command, 'bootstrap', false); + $bootstrap = $method->invoke($command, 'bootstrap', false, false); $this->assertStringContainsString("meta[name=\"csrf-token\"]", $bootstrap); $this->assertStringContainsString("headers: csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}", $bootstrap); @@ -121,17 +122,37 @@ public function testJavascriptBootstrapStubAvoidsTypescriptOnlySyntax(): void $this->assertStringNotContainsString('declare global', $bootstrap); } + public function testBootstrapStubCanImportHtmxSupport(): void + { + $command = new FrontendInstallCommand(); + $method = new \ReflectionMethod($command, 'bootstrapFile'); + + $bootstrap = $method->invoke($command, 'none', false, true); + + $this->assertStringContainsString("import 'htmx.org';", $bootstrap); + } + public function testVuePackageJsonUsesViteSevenCompatiblePluginVersion(): void { $command = new FrontendInstallCommand(); $method = new \ReflectionMethod($command, 'packageJson'); - $packageJson = $method->invoke($command, 'vue', 'tailwind', true); + $packageJson = $method->invoke($command, 'vue', 'tailwind', true, false); $this->assertStringContainsString('"vite": "^7.0.0"', $packageJson); $this->assertStringContainsString('"@vitejs/plugin-vue": "^6.0.0"', $packageJson); } + public function testPackageJsonCanIncludeHtmxDependency(): void + { + $command = new FrontendInstallCommand(); + $method = new \ReflectionMethod($command, 'packageJson'); + + $packageJson = $method->invoke($command, 'vanilla', 'none', false, true); + + $this->assertStringContainsString('"htmx.org": "^2.0.9"', $packageJson); + } + public function testTailwindCssIncludesExplicitSourceDirectives(): void { $command = new FrontendInstallCommand(); @@ -248,6 +269,33 @@ public function testFrontendUninstallDetectsGeneratedPackageJsonTemplate(): void $this->assertFalse($method->invoke($command, $custom)); } + public function testFrontendUninstallDetectsGeneratedPackageJsonTemplateWithHtmx(): void + { + $command = new FrontendUninstallCommand(); + $method = new \ReflectionMethod($command, 'isGeneratedPackageJson'); + + $generated = <<<'JSON' +{ + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "bootstrap": "^5.3.3", + "htmx.org": "^2.0.9" + }, + "devDependencies": { + "vite": "^7.0.0" + } +} +JSON; + + $this->assertTrue($method->invoke($command, $generated)); + } + private function normalizePath(string $path): string { return str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path);