diff --git a/src/Commands/MakePluginCommand.php b/src/Commands/MakePluginCommand.php index f3e2210..4319586 100644 --- a/src/Commands/MakePluginCommand.php +++ b/src/Commands/MakePluginCommand.php @@ -62,7 +62,7 @@ public function handle(): int $this->info("Creating plugin: {$name}..."); // Gather configuration options - $options = $this->gatherConfigurationOptions(); + $options = $this->gatherConfigurationOptions($name); // Generate the plugin using the service $config = $this->generator->prepareConfig( @@ -121,16 +121,15 @@ protected function getVendorName(): string ); } - protected function gatherConfigurationOptions(): array + protected function gatherConfigurationOptions(string $pluginName): array { if ($this->option('no-interaction')) { - return $this->getDefaultOptions(); + return $this->getDefaultOptions($pluginName); } $options = []; - // Get plugin name for auto-filling - $pluginName = $this->argument('name'); + // Use the resolved name (the argument is null when the name was entered at the prompt) $humanReadableName = trim(ucwords(str_replace(['-', '_'], ' ', Str::kebab($pluginName)))); $defaultTitle = $humanReadableName; $defaultDescription = trim($humanReadableName.' plugin for Laravilt'); @@ -147,7 +146,7 @@ protected function gatherConfigurationOptions(): array 'web_routes' => 'Web routes', 'api_routes' => 'API routes', 'css' => 'CSS assets (Tailwind v4)', - 'js' => 'JavaScript assets (Vue.js plugin + Vite)', + 'js' => 'JavaScript assets (Vue or React plugin + Vite, following the app stack)', 'arts' => 'Arts folder with cover photo (screenshot.jpg)', 'languages' => 'Language files (i18n)', 'github' => 'GitHub workflows and issue templates', @@ -242,10 +241,9 @@ protected function gatherConfigurationOptions(): array return $options; } - protected function getDefaultOptions(): array + protected function getDefaultOptions(string $pluginName): array { // Auto-fill title and description from plugin name - $pluginName = $this->argument('name'); $humanReadableName = trim(ucwords(str_replace(['-', '_'], ' ', Str::kebab($pluginName)))); return [ diff --git a/src/Features/InstallCommandFeature.php b/src/Features/InstallCommandFeature.php index 3e4c8c8..57887c7 100644 --- a/src/Features/InstallCommandFeature.php +++ b/src/Features/InstallCommandFeature.php @@ -82,6 +82,7 @@ public function generate(array $config): void 'class' => 'Install'.$config['studly_name'].'Command', 'command_name' => $config['kebab_name'].':install', 'plugin_name' => $config['studly_name'], + 'config' => $config['config_name'] ?? 'laravilt-'.$config['kebab_name'], 'signature_options' => ! empty($signatureOptions) ? "\n ".implode("\n ", $signatureOptions) : '', 'publish_assets_call' => in_array('$this->publishAssets();', $calls) ? '$this->publishAssets();' : '', 'run_migrations_block' => $this->findInArray($calls, 'runMigrations'), diff --git a/src/PluginProvider.php b/src/PluginProvider.php index 14153f1..a4d1da4 100644 --- a/src/PluginProvider.php +++ b/src/PluginProvider.php @@ -105,6 +105,24 @@ public function disable(): static return $this; } + /** + * Get the IDs of the plugins this plugin depends on. + * + * @return array + */ + public function getDependencies(): array + { + return []; + } + + /** + * Whether this plugin's dependencies are available. + */ + public function dependenciesSatisfied(): bool + { + return true; + } + /** * Register the plugin with a panel. */ diff --git a/src/Stubs/install-command.stub b/src/Stubs/install-command.stub index eba61f4..83efc13 100644 --- a/src/Stubs/install-command.stub +++ b/src/Stubs/install-command.stub @@ -4,6 +4,7 @@ namespace {{ namespace }}\Commands; use Illuminate\Console\Command; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Process; class {{ class }} extends Command { @@ -23,7 +24,7 @@ class {{ class }} extends Command */ public function handle(): int { - $this->info('Installing {{ name }} plugin...'); + $this->info('Installing {{ plugin_name }} plugin...'); $this->newLine(); // Publish config @@ -31,7 +32,7 @@ class {{ class }} extends Command {{ publish_assets_call }}{{ run_migrations_block }}{{ run_seeders_block }}{{ build_assets_block }} $this->newLine(); - $this->info('✅ {{ name }} plugin installed successfully!'); + $this->info('✅ {{ plugin_name }} plugin installed successfully!'); $this->newLine(); return self::SUCCESS; diff --git a/src/Stubs/plugin.stub b/src/Stubs/plugin.stub index b3b3f4e..0a30ff3 100644 --- a/src/Stubs/plugin.stub +++ b/src/Stubs/plugin.stub @@ -2,11 +2,10 @@ namespace {{ namespace }}; -use Filament\Contracts\Plugin; -use Filament\Panel; +use Laravilt\Panel\Panel; use Laravilt\Plugins\PluginProvider; -class {{ class }} extends PluginProvider implements Plugin +class {{ class }} extends PluginProvider { /** * The plugin ID (must be unique). @@ -34,10 +33,10 @@ class {{ class }} extends PluginProvider implements Plugin protected static string $author = '{{ author }}'; /** - * Register the plugin with a Filament panel. + * Register the plugin with a Laravilt panel. * * This is where you register resources, pages, widgets, and other - * Filament-specific components that should be available in the panel. + * panel components that should be available in the panel. */ public function register(Panel $panel): void { diff --git a/src/Stubs/vite.plugin.stub b/src/Stubs/vite.plugin.stub index 713c51a..00aa35d 100644 --- a/src/Stubs/vite.plugin.stub +++ b/src/Stubs/vite.plugin.stub @@ -1,7 +1,9 @@ -import { resolve } from 'path'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; export default function {{ plugin_name }}Plugin() { - const pluginPath = resolve(__dirname); + // package.json sets "type": "module", so __dirname is not defined here + const pluginPath = dirname(fileURLToPath(import.meta.url)); return { name: '{{ kebab_name }}-plugin', diff --git a/src/Support/PluginManager.php b/src/Support/PluginManager.php index 51d791b..d4fb47f 100644 --- a/src/Support/PluginManager.php +++ b/src/Support/PluginManager.php @@ -43,9 +43,9 @@ public function register(Plugin $plugin): void throw new \RuntimeException("Plugin '{$id}' is already registered."); } - // Check dependencies - if (! $plugin->dependenciesSatisfied()) { - $dependencies = implode(', ', $plugin->getDependencies()); + // Check dependencies (not part of the Plugin contract, so only when the plugin declares them) + if (method_exists($plugin, 'dependenciesSatisfied') && ! $plugin->dependenciesSatisfied()) { + $dependencies = implode(', ', $this->dependenciesOf($plugin)); throw new \RuntimeException( "Plugin '{$id}' dependencies not satisfied: {$dependencies}" ); @@ -70,13 +70,21 @@ public function boot(string $id): void } // Boot dependencies first - foreach ($plugin->getDependencies() as $dependency) { + foreach ($this->dependenciesOf($plugin) as $dependency) { $this->boot($dependency); } $this->booted->push($id); } + /** + * @return array + */ + protected function dependenciesOf(Plugin $plugin): array + { + return method_exists($plugin, 'getDependencies') ? $plugin->getDependencies() : []; + } + /** * Boot all plugins. */ diff --git a/src/Support/PluginManifest.php b/src/Support/PluginManifest.php index 9d1a147..1b1c996 100644 --- a/src/Support/PluginManifest.php +++ b/src/Support/PluginManifest.php @@ -26,14 +26,17 @@ public function __construct(Collection $plugins) public function toArray(): array { return $this->plugins->map(function (Plugin $plugin) { + // Only getId()/isEnabled() are on the Plugin contract; the rest come from PluginProvider + $call = fn (string $method, mixed $default) => method_exists($plugin, $method) ? $plugin->{$method}() : $default; + return [ 'id' => $plugin->getId(), - 'name' => $plugin->getName(), - 'version' => $plugin->getVersion(), - 'description' => $plugin->getDescription(), - 'author' => $plugin->getAuthor(), + 'name' => $call('getName', ''), + 'version' => $call('getVersion', ''), + 'description' => $call('getDescription', ''), + 'author' => $call('getAuthor', ''), 'enabled' => $plugin->isEnabled(), - 'dependencies' => $plugin->getDependencies(), + 'dependencies' => $call('getDependencies', []), ]; })->all(); } diff --git a/tests/Unit/Features/GeneratedStubsTest.php b/tests/Unit/Features/GeneratedStubsTest.php new file mode 100644 index 0000000..3bce449 --- /dev/null +++ b/tests/Unit/Features/GeneratedStubsTest.php @@ -0,0 +1,48 @@ +files = new Filesystem; + $this->basePath = sys_get_temp_dir().'/laravilt-stubs-'.uniqid(); + $this->processor = new StubProcessor($this->files); + $this->config = [ + 'base_path' => $this->basePath, + 'namespace' => 'Acme\\Blog', + 'studly_name' => 'Blog', + 'kebab_name' => 'blog', + 'vendor_lower' => 'acme', + 'config_name' => 'laravilt-blog', + 'author' => 'Jane', + ]; +}); + +afterEach(function () { + $this->files->deleteDirectory($this->basePath); +}); + +it('generates an install command with every placeholder replaced and Process imported', function () { + (new InstallCommandFeature($this->processor))->generate([...$this->config, 'generate_js' => true]); + + $command = $this->files->get($this->basePath.'/src/Commands/InstallBlogCommand.php'); + + expect($command)->not->toContain('{{') + ->and($command)->toContain("'--tag' => 'laravilt-blog-config'") + ->and($command)->toContain('Installing Blog plugin...') + ->and($command)->toContain('Process::path(') + ->and($command)->toContain('use Illuminate\\Support\\Facades\\Process;'); +}); + +it('generates a plugin class compatible with the Laravilt PluginProvider', function () { + (new PluginClassFeature($this->processor))->generate($this->config); + + $plugin = $this->files->get($this->basePath.'/src/BlogPlugin.php'); + + expect($plugin)->not->toContain('{{') + ->and($plugin)->not->toContain('Filament') + ->and($plugin)->toContain('use Laravilt\\Panel\\Panel;') + ->and($plugin)->toContain('class BlogPlugin extends PluginProvider'); +}); diff --git a/tests/Unit/Features/JsFeatureTest.php b/tests/Unit/Features/JsFeatureTest.php index 8de351f..eee1946 100644 --- a/tests/Unit/Features/JsFeatureTest.php +++ b/tests/Unit/Features/JsFeatureTest.php @@ -45,9 +45,15 @@ $this->feature->generate($config); $package = json_decode($this->files->get($this->basePath.'/package.json'), true); + $vite = $this->files->get($this->basePath.'/vite.plugin.js'); expect($this->feature->getDirectories($config))->toBe(['resources/js', 'dist']) ->and($this->files->exists($this->basePath.'/resources/js/app.js'))->toBeTrue() ->and($package['name'])->toBe('acme') - ->and($package['dependencies'])->toHaveKey('vue'); + ->and($package['type'])->toBe('module') + ->and($package['dependencies'])->toHaveKey('vue') + // "type": "module" means __dirname is undefined when Vite loads the plugin + ->and($vite)->toContain("resolve(pluginPath, 'resources/js/app.js')") + ->and($vite)->toContain('fileURLToPath(import.meta.url)') + ->and($vite)->not->toContain('__dirname)'); }); diff --git a/tests/Unit/PluginManagerTest.php b/tests/Unit/PluginManagerTest.php new file mode 100644 index 0000000..43a9dc1 --- /dev/null +++ b/tests/Unit/PluginManagerTest.php @@ -0,0 +1,91 @@ +manager = new PluginManager(Mockery::mock(Application::class)); +}); + +afterEach(function () { + Mockery::close(); +}); + +it('registers, boots and describes a PluginProvider plugin', function () { + $plugin = new class extends PluginProvider + { + protected static string $id = 'blog'; + + protected static string $name = 'Blog'; + + public function register(Panel $panel): void {} + }; + + $this->manager->register($plugin); + $this->manager->bootAll(); + + expect($this->manager->has('blog'))->toBeTrue() + ->and($this->manager->getManifest()->toArray()['blog'])->toMatchArray([ + 'id' => 'blog', + 'name' => 'Blog', + 'dependencies' => [], + ]); +}); + +it('handles plugins that only implement the Plugin contract', function () { + $plugin = new class implements Plugin + { + public function getId(): string + { + return 'bare'; + } + + public function register(Panel $panel): void {} + + public function boot(Panel $panel): void {} + + public function isEnabled(): bool + { + return true; + } + + public static function make(): static + { + return new self; + } + }; + + $this->manager->register($plugin); + $this->manager->bootAll(); + + expect($this->manager->getManifest()->toArray()['bare'])->toMatchArray([ + 'id' => 'bare', + 'enabled' => true, + 'dependencies' => [], + ]); +}); + +it('rejects a plugin whose dependencies are not satisfied', function () { + $plugin = new class extends PluginProvider + { + protected static string $id = 'needy'; + + public function getDependencies(): array + { + return ['missing']; + } + + public function dependenciesSatisfied(): bool + { + return false; + } + + public function register(Panel $panel): void {} + }; + + expect(fn () => $this->manager->register($plugin)) + ->toThrow(RuntimeException::class, "Plugin 'needy' dependencies not satisfied: missing"); +});