diff --git a/src/Support/Autodiscovery/ArtisanPlugin.php b/src/Support/Autodiscovery/ArtisanPlugin.php new file mode 100644 index 0000000..84dcaf6 --- /dev/null +++ b/src/Support/Autodiscovery/ArtisanPlugin.php @@ -0,0 +1,62 @@ +commandFileFinder() + ->withModuleInfo() + ->values() + ->map(fn(ModuleFileInfo $file) => $file->fullyQualifiedClassName()) + ->filter($this->isInstantiableCommand(...)); + } + + public function handle(Collection $data): void + { + $data->each(fn(string $fqcn) => $this->artisan->resolve($fqcn)); + + $this->registerNamespacesInTinker(); + } + + protected function registerNamespacesInTinker(): void + { + if (! class_exists('Laravel\\Tinker\\TinkerServiceProvider')) { + return; + } + + $namespaces = $this->registry + ->modules() + ->flatMap(fn(ModuleConfig $config) => $config->namespaces) + ->reject(fn($ns) => Str::endsWith($ns, ['Tests\\', 'Database\\Factories\\', 'Database\\Seeders\\'])) + ->values() + ->all(); + + Config::set('tinker.alias', array_merge($namespaces, Config::get('tinker.alias', []))); + } + + protected function isInstantiableCommand($command): bool + { + return is_subclass_of($command, Command::class) + && ! (new ReflectionClass($command))->isAbstract(); + } +} diff --git a/src/Support/Autodiscovery/Attributes/AfterResolving.php b/src/Support/Autodiscovery/Attributes/AfterResolving.php new file mode 100644 index 0000000..3b22306 --- /dev/null +++ b/src/Support/Autodiscovery/Attributes/AfterResolving.php @@ -0,0 +1,14 @@ +bladeComponentFileFinder() + ->withModuleInfo() + ->values() + ->map(fn(ModuleFileInfo $component) => [ + 'prefix' => $component->module()->name, + 'fqcn' => $component->fullyQualifiedClassName(), + ]); + } + + public function handle(Collection $data) + { + $data->each(fn(array $row) => $this->blade->component($row['fqcn'], null, $row['prefix'])); + } +} diff --git a/src/Support/Autodiscovery/EventsPlugin.php b/src/Support/Autodiscovery/EventsPlugin.php new file mode 100644 index 0000000..312898b --- /dev/null +++ b/src/Support/Autodiscovery/EventsPlugin.php @@ -0,0 +1,61 @@ +shouldDiscoverEvents()) { + return []; + } + + return $finders + ->listenerDirectoryFinder() + ->withModuleInfo() + ->reduce(fn(array $discovered, ModuleFileInfo $file) => array_merge_recursive( + $discovered, + DiscoverEvents::within($file->getPathname(), $file->module()->path('src')) + ), []); + } + + public function handle(Collection $data): void + { + $data->each(function(array $listeners, string $event) { + foreach (array_unique($listeners, SORT_REGULAR) as $listener) { + $this->events->listen($event, $listener); + } + }); + } + + protected function shouldDiscoverEvents(): bool + { + return $this->config->get('app-modules.should_discover_events') ?? $this->appIsConfiguredToDiscoverEvents(); + } + + protected function appIsConfiguredToDiscoverEvents(): bool + { + return collect($this->app->getProviders(EventServiceProvider::class)) + ->filter(fn(EventServiceProvider $provider) => $provider::class === EventServiceProvider::class + || str_starts_with(get_class($provider), $this->app->getNamespace())) + ->contains(fn(EventServiceProvider $provider) => $provider->shouldDiscoverEvents()); + } +} diff --git a/src/Support/Autodiscovery/GatePlugin.php b/src/Support/Autodiscovery/GatePlugin.php new file mode 100644 index 0000000..a50bba4 --- /dev/null +++ b/src/Support/Autodiscovery/GatePlugin.php @@ -0,0 +1,53 @@ +modelFileFinder() + ->withModuleInfo() + ->values() + ->map(function(ModuleFileInfo $file) { + $fqcn = $file->fullyQualifiedClassName(); + $namespace = rtrim($file->module()->namespaces->first(), '\\'); + + $candidates = [ + $namespace.'\\Policies\\'.Str::after($fqcn, 'Models\\').'Policy', // Policies/Foo/BarPolicy + $namespace.'\\Policies\\'.Str::afterLast($fqcn, '\\').'Policy', // Policies/BarPolicy + ]; + + foreach ($candidates as $candidate) { + if (class_exists($candidate)) { + return [ + 'fqcn' => $fqcn, + 'policy' => $candidate, + ]; + } + } + + return null; + }) + ->filter(); + } + + public function handle(Collection $data): void + { + $data->each(fn(array $row) => $this->gate->policy($row['fqcn'], $row['policy'])); + } +} diff --git a/src/Support/Autodiscovery/LivewirePlugin.php b/src/Support/Autodiscovery/LivewirePlugin.php new file mode 100644 index 0000000..89d4f59 --- /dev/null +++ b/src/Support/Autodiscovery/LivewirePlugin.php @@ -0,0 +1,43 @@ +livewireComponentFileFinder() + ->withModuleInfo() + ->values() + ->map(fn(ModuleFileInfo $file) => [ + 'name' => sprintf( + '%s::%s', + $file->module()->name, + Str::of($file->getRelativePath()) + ->explode('/') + ->filter() + ->push($file->getBasename('.php')) + ->map([Str::class, 'kebab']) + ->implode('.') + ), + 'fqcn' => $file->fullyQualifiedClassName(), + ]); + } + + public function handle(Collection $data): void + { + $data->each(fn(array $d) => $this->livewire->component($d['name'], $d['fqcn'])); + } +} diff --git a/src/Support/Autodiscovery/MigratorPlugin.php b/src/Support/Autodiscovery/MigratorPlugin.php new file mode 100644 index 0000000..3e221b2 --- /dev/null +++ b/src/Support/Autodiscovery/MigratorPlugin.php @@ -0,0 +1,31 @@ +migrationDirectoryFinder() + ->values() + ->map(fn(SplFileInfo $file) => $file->getRealPath()); + } + + public function handle(Collection $data): void + { + $data->each(fn(string $path) => $this->migrator->path($path)); + } +} diff --git a/src/Support/Autodiscovery/Plugin.php b/src/Support/Autodiscovery/Plugin.php new file mode 100644 index 0000000..fd1528d --- /dev/null +++ b/src/Support/Autodiscovery/Plugin.php @@ -0,0 +1,13 @@ +has(static::class)) { + $container->instance(static::class, new self()); + } + + return $container->make(static::class); + } + + /** @param class-string<\InterNACHI\Modular\Support\Autodiscovery\Plugin> ...$class */ + public static function register(string ...$class): void + { + static::instance()->add(...$class); + } + + /** @param class-string<\InterNACHI\Modular\Support\Autodiscovery\Plugin> ...$class */ + public function add(string ...$class): static + { + foreach ($class as $fqcn) { + $this->plugins[] = $fqcn; + } + + return $this; + } + + /** @return class-string<\InterNACHI\Modular\Support\Autodiscovery\Plugin>[] */ + public function all(): array + { + return $this->plugins; + } +} diff --git a/src/Support/Autodiscovery/RoutesPlugin.php b/src/Support/Autodiscovery/RoutesPlugin.php new file mode 100644 index 0000000..5de3ecc --- /dev/null +++ b/src/Support/Autodiscovery/RoutesPlugin.php @@ -0,0 +1,23 @@ +routeFileFinder() + ->values() + ->map(fn(SplFileInfo $file) => $file->getRealPath()); + } + + public function handle(Collection $data): void + { + $data->each(fn(string $filename) => require $filename); + } +} diff --git a/src/Support/Autodiscovery/TranslatorPlugin.php b/src/Support/Autodiscovery/TranslatorPlugin.php new file mode 100644 index 0000000..19c6599 --- /dev/null +++ b/src/Support/Autodiscovery/TranslatorPlugin.php @@ -0,0 +1,38 @@ +langDirectoryFinder() + ->withModuleInfo() + ->values() + ->map(fn(ModuleFileInfo $dir) => [ + 'namespace' => $dir->module()->name, + 'path' => $dir->getRealPath(), + ]); + } + + public function handle(Collection $data): void + { + $data->each(function(array $row) { + $this->translator->addNamespace($row['namespace'], $row['path']); + $this->translator->addJsonPath($row['path']); + }); + } +} diff --git a/src/Support/Autodiscovery/ViewPlugin.php b/src/Support/Autodiscovery/ViewPlugin.php new file mode 100644 index 0000000..a91fd2d --- /dev/null +++ b/src/Support/Autodiscovery/ViewPlugin.php @@ -0,0 +1,35 @@ +viewDirectoryFinder() + ->withModuleInfo() + ->values() + ->map(fn(ModuleFileInfo $dir) => [ + 'namespace' => $dir->module()->name, + 'path' => $dir->getRealPath(), + ]); + } + + public function handle(Collection $data) + { + $data->each(fn(array $d) => $this->factory->addNamespace($d['namespace'], $d['path'])); + } +} diff --git a/src/Support/AutodiscoveryHelper.php b/src/Support/AutodiscoveryHelper.php index ccc9884..870b004 100644 --- a/src/Support/AutodiscoveryHelper.php +++ b/src/Support/AutodiscoveryHelper.php @@ -6,16 +6,15 @@ use Illuminate\Console\Application as Artisan; use Illuminate\Console\Command; use Illuminate\Contracts\Auth\Access\Gate; -use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Migrations\Migrator; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Collection; use Illuminate\Support\Str; -use Illuminate\Translation\Translator; -use Illuminate\View\Compilers\BladeCompiler; -use Illuminate\View\Factory as ViewFactory; +use InterNACHI\Modular\Support\Autodiscovery\Attributes\AfterResolving; +use InterNACHI\Modular\Support\Autodiscovery\Attributes\OnBoot; +use InterNACHI\Modular\Support\Autodiscovery\Plugin; use Livewire\LivewireManager; use ReflectionClass; use RuntimeException; @@ -26,32 +25,20 @@ class AutodiscoveryHelper { protected ?array $data = null; + protected array $plugins = []; + public function __construct( protected FinderFactory $finders, protected Filesystem $fs, + protected Container $app, protected string $cache_path, ) { } public function writeCache(Container $app): void { - $helpers = [ - $this->modules(...), - $this->routes(...), - $this->views(...), - $this->blade(...), - $this->translations(...), - $this->migrations(...), - $this->commands(...), - $this->policies(...), - $this->livewire(...), - ]; - - foreach ($helpers as $helper) { - try { - $app->call($helper); - } catch (BindingResolutionException) { - } + foreach ($this->plugins as $plugin) { + $this->discover($plugin); } $cache = Collection::make($this->data)->toArray(); @@ -78,6 +65,60 @@ public function clearCache(): void } } + /** @param class-string $plugin */ + public function register(string $plugin): static + { + $this->plugins[$plugin] ??= null; + + return $this; + } + + public function bootPlugins(): void + { + foreach ($this->plugins as $class => $_) { + $attributes = (new ReflectionClass($class))->getAttributes(); + foreach ($attributes as $attribute) { + if (AfterResolving::class === $attribute->getName()) { + $abstract = $attribute->getArguments()[0]; + $this->app->afterResolving($abstract, fn() => $this->handle($class)); + if ($this->app->resolved($abstract)) { + $this->handle($class); + } + return; + } + + if (OnBoot::class === $attribute->getName()) { + $this->handle($class); + return; + } + } + } + } + + /** @param class-string $name */ + public function discover(string $name): Collection + { + $this->data ??= $this->readData(); + $this->data[$name] ??= $this->plugin($name)->discover($this->finders); + + return collect($this->data[$name]); + } + + /** @param class-string $name */ + public function handle(string $name): mixed + { + return $this->plugin($name)->handle($this->discover($name)); + } + + public function handleIf(string $name, bool $condition): mixed + { + if ($condition) { + return $this->handle($name); + } + + return null; + } + /** @return Collection */ public function modules(bool $reload = false): Collection { @@ -111,96 +152,6 @@ public function modules(bool $reload = false): Collection ->map(fn(array $d) => new ModuleConfig($d['name'], $d['base_path'], new Collection($d['namespaces']))); } - public function routes(): void - { - $this->withCache( - key: 'route_files', - default: fn() => $this->finders - ->routeFileFinder() - ->values() - ->map(fn(SplFileInfo $file) => $file->getRealPath()), - each: fn(string $filename) => require $filename - ); - } - - public function views(ViewFactory $factory): void - { - $this->withCache( - key: 'view_namespaces', - default: fn() => $this->finders - ->viewDirectoryFinder() - ->withModuleInfo() - ->values() - ->map(fn(ModuleFileInfo $dir) => [ - 'namespace' => $dir->module()->name, - 'path' => $dir->getRealPath(), - ]), - each: fn(array $row) => $factory->addNamespace($row['namespace'], $row['path']), - ); - } - - public function blade(BladeCompiler $blade): void - { - // Handle individual Blade components (old syntax: ``) - $this->withCache( - key: 'blade_component_files', - default: fn() => $this->finders - ->bladeComponentFileFinder() - ->withModuleInfo() - ->values() - ->map(fn(ModuleFileInfo $component) => [ - 'prefix' => $component->module()->name, - 'fqcn' => $component->fullyQualifiedClassName(), - ]), - each: fn(array $row) => $blade->component($row['fqcn'], null, $row['prefix']), - ); - - // Handle Blade component namespaces (new syntax: ``) - $this->withCache( - key: 'blade_component_dirs', - default: fn() => $this->finders - ->bladeComponentDirectoryFinder() - ->withModuleInfo() - ->values() - ->map(fn(ModuleFileInfo $component) => [ - 'prefix' => $component->module()->name, - 'namespace' => $component->module()->qualify('View\\Components'), - ]), - each: fn(array $row) => $blade->componentNamespace($row['namespace'], $row['prefix']), - ); - } - - public function translations(Translator $translator): void - { - $this->withCache( - key: 'translation_files', - default: fn() => $this->finders - ->langDirectoryFinder() - ->withModuleInfo() - ->values() - ->map(fn(ModuleFileInfo $dir) => [ - 'namespace' => $dir->module()->name, - 'path' => $dir->getRealPath(), - ]), - each: function(array $row) use ($translator) { - $translator->addNamespace($row['namespace'], $row['path']); - $translator->addJsonPath($row['path']); - }, - ); - } - - public function migrations(Migrator $migrator): void - { - $this->withCache( - key: 'migration_files', - default: fn() => $this->finders - ->migrationDirectoryFinder() - ->values() - ->map(fn(SplFileInfo $file) => $file->getRealPath()), - each: fn(string $path) => $migrator->path($path), - ); - } - public function commands(Artisan $artisan): void { $this->withCache( @@ -215,39 +166,6 @@ public function commands(Artisan $artisan): void ); } - public function policies(Gate $gate): void - { - $this->withCache( - key: 'model_policy_files', - default: fn() => $this->finders - ->modelFileFinder() - ->withModuleInfo() - ->values() - ->map(function(ModuleFileInfo $file) use ($gate) { - $fqcn = $file->fullyQualifiedClassName(); - $namespace = rtrim($file->module()->namespaces->first(), '\\'); - - $candidates = [ - $namespace.'\\Policies\\'.Str::after($fqcn, 'Models\\').'Policy', // Policies/Foo/BarPolicy - $namespace.'\\Policies\\'.Str::afterLast($fqcn, '\\').'Policy', // Policies/BarPolicy - ]; - - foreach ($candidates as $candidate) { - if (class_exists($candidate)) { - return [ - 'fqcn' => $fqcn, - 'policy' => $candidate, - ]; - } - } - - return null; - }) - ->filter(), - each: fn(array $row) => $gate->policy($row['fqcn'], $row['policy']), - ); - } - public function events(Dispatcher $events, bool $autodiscover = true): void { $this->withCache( @@ -296,6 +214,16 @@ public function livewire(LivewireManager $livewire): void ); } + /** + * @template TPlugin of Plugin + * @param class-string $plugin + * @return TPlugin + */ + public function plugin(string $plugin): Plugin + { + return $this->plugins[$plugin] ??= $this->app->make($plugin); + } + protected function withCache( string $key, Closure $default, diff --git a/src/Support/ModularServiceProvider.php b/src/Support/ModularServiceProvider.php index 34341ab..f4235e8 100644 --- a/src/Support/ModularServiceProvider.php +++ b/src/Support/ModularServiceProvider.php @@ -3,27 +3,27 @@ namespace InterNACHI\Modular\Support; use Illuminate\Console\Application as Artisan; -use Illuminate\Contracts\Auth\Access\Gate; -use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Foundation\Application; -use Illuminate\Contracts\Translation\Translator as TranslatorContract; use Illuminate\Database\Console\Migrations\MigrateMakeCommand; use Illuminate\Database\Eloquent\Factories\Factory as EloquentFactory; -use Illuminate\Database\Migrations\Migrator; use Illuminate\Filesystem\Filesystem; -use Illuminate\Foundation\Support\Providers\EventServiceProvider; -use Illuminate\Support\Facades\Config; use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Str; -use Illuminate\Translation\Translator; -use Illuminate\View\Compilers\BladeCompiler; -use Illuminate\View\Factory as ViewFactory; use InterNACHI\Modular\Console\Commands\Make\MakeMigration; use InterNACHI\Modular\Console\Commands\Make\MakeModule; use InterNACHI\Modular\Console\Commands\ModulesCache; use InterNACHI\Modular\Console\Commands\ModulesClear; use InterNACHI\Modular\Console\Commands\ModulesList; use InterNACHI\Modular\Console\Commands\ModulesSync; +use InterNACHI\Modular\Support\Autodiscovery\ArtisanPlugin; +use InterNACHI\Modular\Support\Autodiscovery\BladePlugin; +use InterNACHI\Modular\Support\Autodiscovery\EventsPlugin; +use InterNACHI\Modular\Support\Autodiscovery\GatePlugin; +use InterNACHI\Modular\Support\Autodiscovery\LivewirePlugin; +use InterNACHI\Modular\Support\Autodiscovery\MigratorPlugin; +use InterNACHI\Modular\Support\Autodiscovery\PluginRegistry; +use InterNACHI\Modular\Support\Autodiscovery\RoutesPlugin; +use InterNACHI\Modular\Support\Autodiscovery\TranslatorPlugin; +use InterNACHI\Modular\Support\Autodiscovery\ViewPlugin; use Livewire\LivewireManager; class ModularServiceProvider extends ServiceProvider @@ -62,6 +62,7 @@ public function register(): void return new AutodiscoveryHelper( $app->make(FinderFactory::class), $app->make(Filesystem::class), + $app, $this->app->bootstrapPath('cache/app-modules.php') ); }); @@ -72,13 +73,27 @@ public function register(): void $this->registerEloquentFactories(); - $this->app->resolving(Migrator::class, fn(Migrator $migrator) => $this->autodiscover()->migrations($migrator)); - $this->app->resolving(Gate::class, fn(Gate $gate) => $this->autodiscover()->policies($gate)); + PluginRegistry::register( + RoutesPlugin::class, + TranslatorPlugin::class, + ViewPlugin::class, + BladePlugin::class, + EventsPlugin::class, + MigratorPlugin::class, + GatePlugin::class, + ); - Artisan::starting(function(Artisan $artisan) { - $this->autodiscover()->commands($artisan); - $this->registerNamespacesInTinker(); + $this->app->booting(function() { + $plugins = PluginRegistry::instance()->all(); + + foreach ($plugins as $class) { + $this->autodiscover()->plugin($class); + } + + $this->autodiscover()->bootPlugins(); }); + + Artisan::starting(fn() => $this->autodiscover()->handle(ArtisanPlugin::class)); } public function boot(): void @@ -86,12 +101,10 @@ public function boot(): void $this->publishVendorFiles(); $this->bootPackageCommands(); - $this->bootRoutes(); - $this->bootViews(); - $this->bootBladeComponents(); - $this->bootTranslations(); - $this->bootEvents(); - $this->bootLivewireComponents(); + $this->autodiscover()->handleIf(RoutesPlugin::class, condition: ! $this->app->routesAreCached()); + $this->autodiscover()->handleIf(LivewirePlugin::class, condition: class_exists(LivewireManager::class)); + + $this->autodiscover()->bootPlugins(); } protected function registry(): ModuleRegistry @@ -124,50 +137,6 @@ protected function bootPackageCommands(): void } } - protected function bootRoutes(): void - { - if (! $this->app->routesAreCached()) { - $this->autodiscover()->routes(); - } - } - - protected function bootViews(): void - { - $this->callAfterResolving('view', function(ViewFactory $factory) { - $this->autodiscover()->views($factory); - }); - } - - protected function bootBladeComponents(): void - { - $this->callAfterResolving(BladeCompiler::class, function(BladeCompiler $blade) { - $this->autodiscover()->blade($blade); - }); - } - - protected function bootTranslations(): void - { - $this->callAfterResolving('translator', function(TranslatorContract $translator) { - if ($translator instanceof Translator) { - $this->autodiscover()->translations($translator); - } - }); - } - - protected function bootEvents(): void - { - $this->callAfterResolving(Dispatcher::class, function(Dispatcher $events) { - $this->autodiscover()->events($events, $this->shouldDiscoverEvents()); - }); - } - - protected function bootLivewireComponents(): void - { - if (class_exists(LivewireManager::class)) { - $this->autodiscover()->livewire($this->app->make(LivewireManager::class)); - } - } - protected function registerEloquentFactories(): void { $helper = new DatabaseFactoryHelper($this->registry()); @@ -176,22 +145,6 @@ protected function registerEloquentFactories(): void EloquentFactory::guessFactoryNamesUsing($helper->factoryNameResolver()); } - protected function registerNamespacesInTinker(): void - { - if (! class_exists('Laravel\\Tinker\\TinkerServiceProvider')) { - return; - } - - $namespaces = $this->registry() - ->modules() - ->flatMap(fn(ModuleConfig $config) => $config->namespaces) - ->reject(fn($ns) => Str::endsWith($ns, ['Tests\\', 'Database\\Factories\\', 'Database\\Seeders\\'])) - ->values() - ->all(); - - Config::set('tinker.alias', array_merge($namespaces, Config::get('tinker.alias', []))); - } - protected function getModulesBasePath(): string { if (null === $this->modules_path) { @@ -202,17 +155,4 @@ protected function getModulesBasePath(): string return $this->modules_path; } - protected function shouldDiscoverEvents(): bool - { - return $this->app->make('config') - ->get('app-modules.should_discover_events') ?? $this->appIsConfiguredToDiscoverEvents(); - } - - protected function appIsConfiguredToDiscoverEvents(): bool - { - return collect($this->app->getProviders(EventServiceProvider::class)) - ->filter(fn(EventServiceProvider $provider) => $provider::class === EventServiceProvider::class - || str_starts_with(get_class($provider), $this->app->getNamespace())) - ->contains(fn(EventServiceProvider $provider) => $provider->shouldDiscoverEvents()); - } }