From adfbd2aa16c1b04ca824be33bb6c29dd93da3188 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Sat, 6 Sep 2025 16:50:05 +0200 Subject: [PATCH 1/2] Add VendorModuleService for Composer-based Webtrees modules --- app/Services/Composer/VendorModuleService.php | 237 ++++++++++++++++++ app/Services/ModuleService.php | 12 + 2 files changed, 249 insertions(+) create mode 100644 app/Services/Composer/VendorModuleService.php diff --git a/app/Services/Composer/VendorModuleService.php b/app/Services/Composer/VendorModuleService.php new file mode 100644 index 0000000000..56630ef6e2 --- /dev/null +++ b/app/Services/Composer/VendorModuleService.php @@ -0,0 +1,237 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Services\Composer; + +use Composer\InstalledVersions; +use Fisharebest\Webtrees\FlashMessages; +use Fisharebest\Webtrees\Module\ModuleCustomInterface; +use Fisharebest\Webtrees\Module\ModuleInterface; +use Illuminate\Support\Collection; +use Throwable; + +use function dirname; + +/** + * Service for loading Webtrees modules from the vendor directory using Composer's InstalledVersions API. + * + * This service provides seamless integration between Composer-managed packages and Webtrees' module system, + * enabling modules to be distributed and installed as standard Composer packages. It represents a modern + * approach to module management, similar to how major PHP frameworks like Symfony, Laravel, and TYPO3 + * handle their extension ecosystems. + * + * The service acts as a bridge between Composer's package management and Webtrees' module system by: + * - Discovering installed packages through Composer's InstalledVersions API + * - Identifying which packages are Webtrees modules based on their type + * - Loading and initializing modules from the vendor directory + * - Integrating vendor modules with Webtrees' existing module infrastructure + * + * @author Rico Sonntag + * @license https://opensource.org/licenses/GPL-3.0 GNU General Public License v3.0 + * @link https://github.com/fisharebest/webtrees/ + */ +class VendorModuleService +{ + /** + * The Composer package type identifier for Webtrees modules. + * + * @var string The package type identifier + */ + private const string MODULE_TYPE = 'webtrees-module'; + + /** + * Discovers and loads all Webtrees modules from the vendor directory. + * + * This is the primary entry point for vendor module discovery, called by the ModuleService + * during Webtrees' bootstrap process. It orchestrates the entire discovery and loading + * process for Composer-installed modules. + * + * @return Collection A collection of successfully loaded vendor modules. + * Empty collection if no modules are found or an error. + */ + public function getVendorModules(): Collection + { + // Check if Composer's runtime API is available + if (!$this->isComposerAvailable()) { + return new Collection(); + } + + return Collection::make($this->getInstalledWebtreesModules()) + ->map(function (string $packageName): ModuleCustomInterface|null { + $module = $this->loadVendorModule($packageName); + + if (!($module instanceof ModuleCustomInterface)) { + return null; + } + + $module->setName($this->generateModuleName($packageName)); + + return $module; + }) + ->filter() + ->mapWithKeys( + static fn (ModuleCustomInterface $module): array => [ + $module->name() => $module, + ] + ); + } + + /** + * Check if Composer's runtime API is available. + * + * @return bool + */ + private function isComposerAvailable(): bool + { + return class_exists(InstalledVersions::class); + } + + /** + * Get a list of all installed Composer packages of the type "webtrees-module". + * + * @return string[] + */ + private function getInstalledWebtreesModules(): array + { + return InstalledVersions::getInstalledPackagesByType(self::MODULE_TYPE); + } + + /** + * Get the installation path for a Composer package. + * + * @param string $packageName The Composer package name in vendor/package format + * + * @return null|string + */ + private function getPackageInstallPath(string $packageName): ?string + { + try { + return InstalledVersions::getInstallPath($packageName); + } catch (Throwable $exception) { + $this->logError( + 'Error retrieving installation path', + $exception + ); + + return null; + } + } + + /** + * Loads a Webtrees module from its Composer package location. + * + * This method handles the complete process of loading a module from the vendor directory, + * including file discovery, safe loading, validation, and configuration. + * + * @param string $packageName The Composer package name to load + * + * @return ModuleInterface|null The loaded and configured module instance, + * or null if loading fails for any reason + */ + private function loadVendorModule(string $packageName): ?ModuleInterface + { + // Get the installation path using Composer's API + $packagePath = $this->getPackageInstallPath($packageName); + + if ($packagePath === null) { + return null; + } + + $moduleFile = null; + + // Look for the module.php file + if (file_exists($packagePath . DIRECTORY_SEPARATOR . 'module.php')) { + $moduleFile = $packagePath . DIRECTORY_SEPARATOR . 'module.php'; + } + + if ($moduleFile === null) { + return null; + } + + // Load and return module + return $this->loadModuleFile($moduleFile); + } + + /** + * Loads a module.php file in an isolated scope to prevent variable pollution. + * + * @param string $filename The absolute path to the module.php file to load + * + * @return ModuleInterface|null The module instance if successfully loaded, + * null if loading fails or invalid return type + */ + private function loadModuleFile(string $filename): ?ModuleInterface + { + try { + return include $filename; + } catch (Throwable $exception) { + $this->logError( + 'Fatal error in vendor module: ' . basename(dirname($filename)), + $exception + ); + } + + return null; + } + + /** + * Logs error messages for debugging and administrative visibility. + * + * @param string $message The primary error message describing the problem + * @param Throwable|null $exception Optional exception providing additional details + * + * @return void + */ + private function logError(string $message, ?Throwable $exception = null): void + { + $fullMessage = $message; + + if ($exception !== null) { + $fullMessage .= ': ' . $exception->getMessage(); + } + + // Use FlashMessages for user-visible errors in admin interface + FlashMessages::addMessage( + $fullMessage, + 'danger' + ); + } + + /** + * Generates a unique module name from a Composer package name. + * + * This method creates a unique identifier for vendor modules that distinguishes them + * from traditional modules while maintaining readability. The generated name is used + * as the module's internal identifier within Webtrees. + * + * @param string $packageName The full Composer package name + * + * @return string The generated module name in _package_ format + * Always starts and ends with an underscore + */ + private function generateModuleName(string $packageName): string + { + $moduleName = substr( + $packageName, + strpos($packageName, '/') + 1 + ); + + return '_' . $moduleName . '_'; + } +} diff --git a/app/Services/ModuleService.php b/app/Services/ModuleService.php index 2ec2476eb5..debea22fe4 100644 --- a/app/Services/ModuleService.php +++ b/app/Services/ModuleService.php @@ -257,6 +257,7 @@ use Fisharebest\Webtrees\Module\XeneaTheme; use Fisharebest\Webtrees\Module\YahrzeitModule; use Fisharebest\Webtrees\Registry; +use Fisharebest\Webtrees\Services\Composer\VendorModuleService; use Fisharebest\Webtrees\Tree; use Fisharebest\Webtrees\Webtrees; use Illuminate\Support\Collection; @@ -617,6 +618,7 @@ public function all(bool $include_disabled = false): Collection return $this->coreModules() ->merge($this->customModules()) + ->merge($this->vendorModules()) ->map(static function (ModuleInterface $module) use ($module_info): ModuleInterface { $info = $module_info->get($module->name()); @@ -708,6 +710,16 @@ private function customModules(): Collection ->mapWithKeys(static fn (ModuleCustomInterface $module): array => [$module->name() => $module]); } + /** + * All vendor modules in the system. Vendor modules are installed via Composer. + * + * @return Collection + */ + private function vendorModules(): Collection + { + return (new VendorModuleService())->getVendorModules(); + } + /** * Load a custom module in a static scope, to prevent it from modifying local or object variables. */ From 9ad99e315c37a41a45435dbed6b289dd9fe8c601 Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Wed, 10 Jun 2026 10:31:05 +0200 Subject: [PATCH 2/2] Load vendor modules only from their standard vendor path; add theme support VendorModuleService discovered packages via InstalledVersions, which aggregates the bundled installed.php of every registered class loader. A module installed under modules_v4/ (moved there by the installer-plugin, or bundling its own vendor/ and registering itself as a Composer package) therefore showed up here as well as in ModuleService::customModules(), so its module.php was included twice. Modules that pull their class file with plain require (not require_once) then fatal with "Cannot redeclare class". Only load a package that sits at its standard Composer location (/); a relocated or nested-vendor self-entry reports a different install path and is left to customModules(). The vendor directory is resolved via the registered class loaders (the drupal-finder approach), as Composer exposes no dedicated accessor (composer/composer#2904). Also collect webtrees-theme packages, not just webtrees-module, mirroring the webtrees-module-installer-plugin, and accept ModuleThemeInterface alongside ModuleCustomInterface. --- app/Services/Composer/VendorModuleService.php | 74 +++++- app/Services/ModuleService.php | 2 +- .../Composer/VendorModuleServiceTest.php | 221 ++++++++++++++++++ 3 files changed, 284 insertions(+), 13 deletions(-) create mode 100644 tests/Unit/Services/Composer/VendorModuleServiceTest.php diff --git a/app/Services/Composer/VendorModuleService.php b/app/Services/Composer/VendorModuleService.php index 56630ef6e2..316c06cead 100644 --- a/app/Services/Composer/VendorModuleService.php +++ b/app/Services/Composer/VendorModuleService.php @@ -19,14 +19,17 @@ namespace Fisharebest\Webtrees\Services\Composer; +use Composer\Autoload\ClassLoader; use Composer\InstalledVersions; use Fisharebest\Webtrees\FlashMessages; use Fisharebest\Webtrees\Module\ModuleCustomInterface; use Fisharebest\Webtrees\Module\ModuleInterface; +use Fisharebest\Webtrees\Module\ModuleThemeInterface; use Illuminate\Support\Collection; use Throwable; use function dirname; +use function realpath; /** * Service for loading Webtrees modules from the vendor directory using Composer's InstalledVersions API. @@ -49,11 +52,12 @@ class VendorModuleService { /** - * The Composer package type identifier for Webtrees modules. + * The Composer package types identifying Webtrees modules and themes. + * Mirrors the package types handled by the webtrees-module-installer-plugin. * - * @var string The package type identifier + * @var list The package type identifiers */ - private const string MODULE_TYPE = 'webtrees-module'; + private const array MODULE_TYPES = ['webtrees-module', 'webtrees-theme']; /** * Discovers and loads all Webtrees modules from the vendor directory. @@ -62,8 +66,8 @@ class VendorModuleService * during Webtrees' bootstrap process. It orchestrates the entire discovery and loading * process for Composer-installed modules. * - * @return Collection A collection of successfully loaded vendor modules. - * Empty collection if no modules are found or an error. + * @return Collection A collection of successfully loaded + * vendor modules and themes. Empty when none are found or on error. */ public function getVendorModules(): Collection { @@ -73,10 +77,13 @@ public function getVendorModules(): Collection } return Collection::make($this->getInstalledWebtreesModules()) - ->map(function (string $packageName): ModuleCustomInterface|null { + ->map(function (string $packageName): ModuleCustomInterface|ModuleThemeInterface|null { $module = $this->loadVendorModule($packageName); - if (!($module instanceof ModuleCustomInterface)) { + if ( + !($module instanceof ModuleCustomInterface) + && !($module instanceof ModuleThemeInterface) + ) { return null; } @@ -86,7 +93,7 @@ public function getVendorModules(): Collection }) ->filter() ->mapWithKeys( - static fn (ModuleCustomInterface $module): array => [ + static fn (ModuleCustomInterface|ModuleThemeInterface $module): array => [ $module->name() => $module, ] ); @@ -103,13 +110,19 @@ private function isComposerAvailable(): bool } /** - * Get a list of all installed Composer packages of the type "webtrees-module". + * Get a list of all installed Composer packages of a Webtrees module or theme type. * * @return string[] */ - private function getInstalledWebtreesModules(): array + protected function getInstalledWebtreesModules(): array { - return InstalledVersions::getInstalledPackagesByType(self::MODULE_TYPE); + $packages = []; + + foreach (self::MODULE_TYPES as $type) { + $packages = [...$packages, ...InstalledVersions::getInstalledPackagesByType($type)]; + } + + return $packages; } /** @@ -119,7 +132,7 @@ private function getInstalledWebtreesModules(): array * * @return null|string */ - private function getPackageInstallPath(string $packageName): ?string + protected function getPackageInstallPath(string $packageName): ?string { try { return InstalledVersions::getInstallPath($packageName); @@ -133,6 +146,25 @@ private function getPackageInstallPath(string $packageName): ?string } } + /** + * Absolute path of the Composer vendor directory this service is autoloaded + * from. Composer exposes no dedicated accessor (composer/composer#2904), so + * — like drupal-finder — we ask each registered class loader which one can + * resolve this class; its registered vendor directory is the one we live + * in. Robust against a renamed `config.vendor-dir` and against the nested + * vendor directories that bundled modules register for their own autoload. + */ + protected function mainVendorDirectory(): string + { + foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) { + if ($loader->findFile(self::class) !== false) { + return $vendorDir; + } + } + + return ''; + } + /** * Loads a Webtrees module from its Composer package location. * @@ -153,6 +185,24 @@ private function loadVendorModule(string $packageName): ?ModuleInterface return null; } + // Only load a package that sits at its standard Composer location + // (/). A package relocated into modules_v4/ by the + // installer-plugin — or a nested-vendor self-entry from a module that + // bundles its own installed.php — reports a different install path and + // is already discovered by ModuleService::customModules(); loading it + // here too would include its module.php a second time and redeclare + // its class. + $packageRealPath = realpath($packagePath); + $standardPath = realpath($this->mainVendorDirectory() . '/' . $packageName); + + if ( + ($packageRealPath === false) + || ($standardPath === false) + || ($packageRealPath !== $standardPath) + ) { + return null; + } + $moduleFile = null; // Look for the module.php file diff --git a/app/Services/ModuleService.php b/app/Services/ModuleService.php index debea22fe4..c0d484ad93 100644 --- a/app/Services/ModuleService.php +++ b/app/Services/ModuleService.php @@ -713,7 +713,7 @@ private function customModules(): Collection /** * All vendor modules in the system. Vendor modules are installed via Composer. * - * @return Collection + * @return Collection */ private function vendorModules(): Collection { diff --git a/tests/Unit/Services/Composer/VendorModuleServiceTest.php b/tests/Unit/Services/Composer/VendorModuleServiceTest.php new file mode 100644 index 0000000000..4cfb99e1ff --- /dev/null +++ b/tests/Unit/Services/Composer/VendorModuleServiceTest.php @@ -0,0 +1,221 @@ +. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Tests\Unit\Services\Composer; + +use Fisharebest\Webtrees\Services\Composer\VendorModuleService; +use Fisharebest\Webtrees\Tests\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; + +use function file_put_contents; +use function is_dir; +use function mkdir; +use function rmdir; +use function scandir; +use function sys_get_temp_dir; +use function uniqid; +use function unlink; + +/** + * Test that vendor-module discovery loads only packages at their standard + * vendor location, supports themes, and skips relocated / nested self-entries. + */ +#[CoversClass(VendorModuleService::class)] +class VendorModuleServiceTest extends TestCase +{ + private string $vendorRoot; + + private string $relocatedRoot; + + protected function setUp(): void + { + parent::setUp(); + + $this->vendorRoot = sys_get_temp_dir() . '/wt-vms-vendor-' . uniqid(); + $this->relocatedRoot = sys_get_temp_dir() . '/wt-vms-relocated-' . uniqid(); + + // Packages at their standard / location. + $this->writeModuleFixture($this->vendorRoot . '/acme/real-module', $this->customModuleSource()); + $this->writeModuleFixture($this->vendorRoot . '/acme/real-theme', $this->themeModuleSource()); + + // A package relocated away from its standard location (e.g. moved into + // modules_v4/ by the installer-plugin, or a nested-vendor self-entry). + $this->writeModuleFixture($this->relocatedRoot . '/reloc-module', $this->customModuleSource()); + } + + protected function tearDown(): void + { + $this->removeRecursively($this->vendorRoot); + $this->removeRecursively($this->relocatedRoot); + + parent::tearDown(); + } + + public function testLoadsAModuleAtItsStandardVendorPath(): void + { + $service = $this->serviceFor( + ['acme/real-module'], + ['acme/real-module' => $this->vendorRoot . '/acme/real-module'], + ); + + $modules = $service->getVendorModules(); + + self::assertCount(1, $modules); + self::assertArrayHasKey('_real-module_', $modules->all()); + } + + public function testSkipsARelocatedModule(): void + { + $service = $this->serviceFor( + ['acme/reloc-module'], + ['acme/reloc-module' => $this->relocatedRoot . '/reloc-module'], + ); + + $modules = $service->getVendorModules(); + + // The package does not sit at /acme/reloc-module, so it was + // relocated (installer-plugin) or is a nested-vendor self-entry — + // ModuleService::customModules() already loads it, and loading it + // again here would redeclare its class. + self::assertCount(0, $modules); + } + + public function testLoadsAThemeAtItsStandardVendorPath(): void + { + $service = $this->serviceFor( + ['acme/real-theme'], + ['acme/real-theme' => $this->vendorRoot . '/acme/real-theme'], + ); + + $modules = $service->getVendorModules(); + + self::assertCount(1, $modules); + self::assertArrayHasKey('_real-theme_', $modules->all()); + } + + /** + * Build a service whose Composer seams return the supplied fixtures and + * whose main vendor directory is the test's temporary vendor root. + * + * @param list $packages + * @param array $installPaths + */ + private function serviceFor(array $packages, array $installPaths): VendorModuleService + { + return new class ($packages, $installPaths, $this->vendorRoot) extends VendorModuleService { + /** + * @param list $packages + * @param array $installPaths + */ + public function __construct( + private readonly array $packages, + private readonly array $installPaths, + private readonly string $vendorRoot, + ) { + } + + /** + * @return list + */ + protected function getInstalledWebtreesModules(): array + { + return $this->packages; + } + + protected function getPackageInstallPath(string $packageName): ?string + { + return $this->installPaths[$packageName] ?? null; + } + + protected function mainVendorDirectory(): string + { + return $this->vendorRoot; + } + }; + } + + private function writeModuleFixture(string $directory, string $source): void + { + mkdir($directory, 0o777, true); + file_put_contents($directory . '/module.php', $source); + } + + private function customModuleSource(): string + { + return <<<'PHP' + removeRecursively($path); + } else { + unlink($path); + } + } + + rmdir($directory); + } +}