diff --git a/CHANGELOG.md b/CHANGELOG.md index ce75291b..b33a202b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0-beta.2...develop) +### Fixed +- A plugin with neither `app/` nor `src/` — one that only ships blocks, which need no class — was discovered from its root, `node_modules` included: 2 to 7 seconds on every request in debug mode, measured on a blocks-only plugin with a Vite build. Its other top-level directories are scanned instead, never `node_modules`, `vendor`, `bower_components`, `build`, `dist`, `public`, `resources`, `assets`, `languages`, `lang` or a hidden directory + ## [v13.34.0-beta.2](https://github.com/Pollora/framework/compare/v13.34.0-beta...v13.34.0-beta.2) - 2026-09-30 ### Added diff --git a/src/Plugin/Application/Services/PluginRegistrar.php b/src/Plugin/Application/Services/PluginRegistrar.php index fe61095d..c7ee2724 100644 --- a/src/Plugin/Application/Services/PluginRegistrar.php +++ b/src/Plugin/Application/Services/PluginRegistrar.php @@ -26,6 +26,26 @@ */ class PluginRegistrar { + /** + * Directories of a plugin root that never hold discoverable classes. + * + * Dependencies, build output, views and assets, translations, and hidden + * directories (`.git`, `.github`). Walking them costs the request and + * finds nothing that belongs to the plugin. + */ + private const array NON_DISCOVERABLE_DIRECTORIES = [ + 'node_modules', + 'vendor', + 'bower_components', + 'build', + 'dist', + 'public', + 'resources', + 'assets', + 'languages', + 'lang', + ]; + /** * Collection of registered plugins. * @@ -246,7 +266,9 @@ protected function discoverPluginStructures(PluginModuleInterface $plugin): void try { $discoveryService = $this->app->get(ModuleDiscoveryOrchestratorInterface::class); - $discoveryService->discover($this->getPluginDiscoveryPath($plugin)); + foreach ($this->getPluginDiscoveryPaths($plugin) as $path) { + $discoveryService->discover($path); + } } catch (\Exception $exception) { $this->logError(sprintf('Plugin discovery error for %s: ', $plugin->getName()).$exception->getMessage()); } @@ -269,20 +291,37 @@ protected function discoverPluginStructures(PluginModuleInterface $plugin): void * out. It also found ten PHP files shipped inside `@wordpress/style-engine` * and handed them to discovery, which is its own kind of wrong. * - * The root is still used when a plugin has neither directory, so a plugin - * that keeps its classes at the top level is discovered as before. + * A plugin with neither directory — one that only ships blocks, which + * need no class at all — is scanned in its other top-level directories, + * never in the ones listed in {@see NON_DISCOVERABLE_DIRECTORIES}. Its + * root was scanned whole before: 2 to 7 seconds per request, measured on + * a blocks-only plugin with a Vite build. + * + * @return list */ - protected function getPluginDiscoveryPath(PluginModuleInterface $plugin): string + protected function getPluginDiscoveryPaths(PluginModuleInterface $plugin): array { $basePath = rtrim($plugin->getPath(), '/'); foreach (['/app', '/src'] as $directory) { if (is_dir($basePath.$directory)) { - return $basePath.$directory; + return [$basePath.$directory]; + } + } + + $paths = []; + + foreach (scandir($basePath) ?: [] as $entry) { + if (str_starts_with($entry, '.') || in_array($entry, self::NON_DISCOVERABLE_DIRECTORIES, true)) { + continue; + } + + if (is_dir($basePath.'/'.$entry)) { + $paths[] = $basePath.'/'.$entry; } } - return $basePath; + return $paths; } /** diff --git a/tests/Unit/Plugin/PluginDiscoveryPathTest.php b/tests/Unit/Plugin/PluginDiscoveryPathTest.php index 0f207c01..74f783e9 100644 --- a/tests/Unit/Plugin/PluginDiscoveryPathTest.php +++ b/tests/Unit/Plugin/PluginDiscoveryPathTest.php @@ -21,9 +21,13 @@ function registrar(): object { return new class(new Container, new WordPressPluginParser) extends PluginRegistrar { - public function discoveryPathFor(PluginModuleInterface $plugin): string + /** @return list */ + public function discoveryPathsFor(PluginModuleInterface $plugin): array { - return $this->getPluginDiscoveryPath($plugin); + $paths = $this->getPluginDiscoveryPaths($plugin); + sort($paths); + + return $paths; } }; } @@ -58,26 +62,41 @@ function pluginAt(string $path): PluginModuleInterface it('scans app/ when the plugin has one', function (): void { mkdir($this->root.'/app'); - expect(registrar()->discoveryPathFor(pluginAt($this->root)))->toBe($this->root.'/app'); + expect(registrar()->discoveryPathsFor(pluginAt($this->root)))->toBe([$this->root.'/app']); }); it('falls back to src/', function (): void { mkdir($this->root.'/src'); - expect(registrar()->discoveryPathFor(pluginAt($this->root)))->toBe($this->root.'/src'); + expect(registrar()->discoveryPathsFor(pluginAt($this->root)))->toBe([$this->root.'/src']); }); it('prefers app/ over src/ when both are there', function (): void { mkdir($this->root.'/app'); mkdir($this->root.'/src'); - expect(registrar()->discoveryPathFor(pluginAt($this->root)))->toBe($this->root.'/app'); + expect(registrar()->discoveryPathsFor(pluginAt($this->root)))->toBe([$this->root.'/app']); + }); + + it('scans the other top-level directories of a plugin that has neither', function (): void { + // A plugin keeping its classes in, say, includes/ is still discovered. + mkdir($this->root.'/includes'); + mkdir($this->root.'/config'); + + expect(registrar()->discoveryPathsFor(pluginAt($this->root))) + ->toBe([$this->root.'/config', $this->root.'/includes']); }); - it('keeps scanning the root for a plugin that has neither', function (): void { - // A plugin holding its classes at the top level is discovered exactly - // as it was; nothing is narrowed away from it. - expect(registrar()->discoveryPathFor(pluginAt($this->root)))->toBe($this->root); + it('never scans a directory that cannot hold discoverable classes', function (): void { + // A blocks-only plugin: no class, a Vite build. Its root was scanned + // whole, node_modules included — 2 to 7 seconds on every request. + foreach (['node_modules', 'vendor', 'build', 'dist', 'public', 'resources', 'assets', 'languages', '.git', '.github'] as $directory) { + mkdir($this->root.'/'.$directory); + } + + touch($this->root.'/demo-plugin.php'); + + expect(registrar()->discoveryPathsFor(pluginAt($this->root)))->toBe([]); }); it('never hands back a directory holding node_modules when app/ exists', function (): void { @@ -85,15 +104,12 @@ function pluginAt(string $path): PluginModuleInterface mkdir($this->root.'/app'); mkdir($this->root.'/node_modules'); - $path = registrar()->discoveryPathFor(pluginAt($this->root)); - - expect($path)->toBe($this->root.'/app') - ->and(is_dir($path.'/node_modules'))->toBeFalse(); + expect(registrar()->discoveryPathsFor(pluginAt($this->root)))->toBe([$this->root.'/app']); }); it('does not care how the path was spelled', function (): void { mkdir($this->root.'/app'); - expect(registrar()->discoveryPathFor(pluginAt($this->root.'/')))->toBe($this->root.'/app'); + expect(registrar()->discoveryPathsFor(pluginAt($this->root.'/')))->toBe([$this->root.'/app']); }); });