Skip to content

Commit a63c164

Browse files
committed
Add VendorModuleService for Composer-based Webtrees modules
1 parent 7f1eef1 commit a63c164

File tree

2 files changed

+249
-0
lines changed

2 files changed

+249
-0
lines changed
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
<?php
2+
3+
/**
4+
* webtrees: online genealogy
5+
* Copyright (C) 2025 webtrees development team
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
declare(strict_types=1);
19+
20+
namespace Fisharebest\Webtrees\Services\Composer;
21+
22+
use Composer\InstalledVersions;
23+
use Fisharebest\Webtrees\FlashMessages;
24+
use Fisharebest\Webtrees\Module\ModuleCustomInterface;
25+
use Fisharebest\Webtrees\Module\ModuleInterface;
26+
use Illuminate\Support\Collection;
27+
use Throwable;
28+
29+
use function dirname;
30+
31+
/**
32+
* Service for loading Webtrees modules from the vendor directory using Composer's InstalledVersions API.
33+
*
34+
* This service provides seamless integration between Composer-managed packages and Webtrees' module system,
35+
* enabling modules to be distributed and installed as standard Composer packages. It represents a modern
36+
* approach to module management, similar to how major PHP frameworks like Symfony, Laravel, and TYPO3
37+
* handle their extension ecosystems.
38+
*
39+
* The service acts as a bridge between Composer's package management and Webtrees' module system by:
40+
* - Discovering installed packages through Composer's InstalledVersions API
41+
* - Identifying which packages are Webtrees modules based on their type
42+
* - Loading and initializing modules from the vendor directory
43+
* - Integrating vendor modules with Webtrees' existing module infrastructure
44+
*
45+
* @author Rico Sonntag <[email protected]>
46+
* @license https://opensource.org/licenses/GPL-3.0 GNU General Public License v3.0
47+
* @link https://github.com/fisharebest/webtrees/
48+
*/
49+
class VendorModuleService
50+
{
51+
/**
52+
* The Composer package type identifier for Webtrees modules.
53+
*
54+
* @var string The package type identifier
55+
*/
56+
private const string MODULE_TYPE = 'webtrees-module';
57+
58+
/**
59+
* Discovers and loads all Webtrees modules from the vendor directory.
60+
*
61+
* This is the primary entry point for vendor module discovery, called by the ModuleService
62+
* during Webtrees' bootstrap process. It orchestrates the entire discovery and loading
63+
* process for Composer-installed modules.
64+
*
65+
* @return Collection<string, ModuleCustomInterface> A collection of successfully loaded vendor modules.
66+
* Empty collection if no modules are found or an error.
67+
*/
68+
public function getVendorModules(): Collection
69+
{
70+
// Check if Composer's runtime API is available
71+
if (!$this->isComposerAvailable()) {
72+
return new Collection();
73+
}
74+
75+
return Collection::make($this->getInstalledWebtreesModules())
76+
->map(function (string $packageName): ModuleCustomInterface|null {
77+
$module = $this->loadVendorModule($packageName);
78+
79+
if (!($module instanceof ModuleCustomInterface)) {
80+
return null;
81+
}
82+
83+
$module->setName($this->generateModuleName($packageName));
84+
85+
return $module;
86+
})
87+
->filter()
88+
->mapWithKeys(
89+
static fn(ModuleCustomInterface $module): array => [
90+
$module->name() => $module,
91+
]
92+
);
93+
}
94+
95+
/**
96+
* Check if Composer's runtime API is available.
97+
*
98+
* @return bool
99+
*/
100+
private function isComposerAvailable(): bool
101+
{
102+
return class_exists(InstalledVersions::class);
103+
}
104+
105+
/**
106+
* Get a list of all installed Composer packages of the type "webtrees-module".
107+
*
108+
* @return string[]
109+
*/
110+
private function getInstalledWebtreesModules(): array
111+
{
112+
return InstalledVersions::getInstalledPackagesByType(self::MODULE_TYPE);
113+
}
114+
115+
/**
116+
* Get the installation path for a Composer package.
117+
*
118+
* @param string $packageName The Composer package name in vendor/package format
119+
*
120+
* @return null|string
121+
*/
122+
private function getPackageInstallPath(string $packageName): ?string
123+
{
124+
try {
125+
return InstalledVersions::getInstallPath($packageName);
126+
} catch (Throwable $exception) {
127+
$this->logError(
128+
'Error retrieving installation path',
129+
$exception
130+
);
131+
132+
return null;
133+
}
134+
}
135+
136+
/**
137+
* Loads a Webtrees module from its Composer package location.
138+
*
139+
* This method handles the complete process of loading a module from the vendor directory,
140+
* including file discovery, safe loading, validation, and configuration.
141+
*
142+
* @param string $packageName The Composer package name to load
143+
*
144+
* @return ModuleInterface|null The loaded and configured module instance,
145+
* or null if loading fails for any reason
146+
*/
147+
private function loadVendorModule(string $packageName): ?ModuleInterface
148+
{
149+
// Get the installation path using Composer's API
150+
$packagePath = $this->getPackageInstallPath($packageName);
151+
152+
if ($packagePath === null) {
153+
return null;
154+
}
155+
156+
$moduleFile = null;
157+
158+
// Look for the module.php file
159+
if (file_exists($packagePath . DIRECTORY_SEPARATOR . 'module.php')) {
160+
$moduleFile = $packagePath . DIRECTORY_SEPARATOR . 'module.php';
161+
}
162+
163+
if ($moduleFile === null) {
164+
return null;
165+
}
166+
167+
// Load and return module
168+
return $this->loadModuleFile($moduleFile);
169+
}
170+
171+
/**
172+
* Loads a module.php file in an isolated scope to prevent variable pollution.
173+
*
174+
* @param string $filename The absolute path to the module.php file to load
175+
*
176+
* @return ModuleInterface|null The module instance if successfully loaded,
177+
* null if loading fails or invalid return type
178+
*/
179+
private function loadModuleFile(string $filename): ?ModuleInterface
180+
{
181+
try {
182+
return include $filename;
183+
} catch (Throwable $exception) {
184+
$this->logError(
185+
'Fatal error in vendor module: ' . basename(dirname($filename)),
186+
$exception
187+
);
188+
}
189+
190+
return null;
191+
}
192+
193+
/**
194+
* Logs error messages for debugging and administrative visibility.
195+
*
196+
* @param string $message The primary error message describing the problem
197+
* @param Throwable|null $exception Optional exception providing additional details
198+
*
199+
* @return void
200+
*/
201+
private function logError(string $message, ?Throwable $exception = null): void
202+
{
203+
$fullMessage = $message;
204+
205+
if ($exception !== null) {
206+
$fullMessage .= ': ' . $exception->getMessage();
207+
}
208+
209+
// Use FlashMessages for user-visible errors in admin interface
210+
FlashMessages::addMessage(
211+
$fullMessage,
212+
'danger'
213+
);
214+
}
215+
216+
/**
217+
* Generates a unique module name from a Composer package name.
218+
*
219+
* This method creates a unique identifier for vendor modules that distinguishes them
220+
* from traditional modules while maintaining readability. The generated name is used
221+
* as the module's internal identifier within Webtrees.
222+
*
223+
* @param string $packageName The full Composer package name
224+
*
225+
* @return string The generated module name in _package_ format
226+
* Always starts and ends with an underscore
227+
*/
228+
private function generateModuleName(string $packageName): string
229+
{
230+
$moduleName = substr(
231+
$packageName,
232+
strpos($packageName, '/') + 1
233+
);
234+
235+
return '_' . $moduleName . '_';
236+
}
237+
}

app/Services/ModuleService.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@
258258
use Fisharebest\Webtrees\Module\XeneaTheme;
259259
use Fisharebest\Webtrees\Module\YahrzeitModule;
260260
use Fisharebest\Webtrees\Registry;
261+
use Fisharebest\Webtrees\Services\Composer\VendorModuleService;
261262
use Fisharebest\Webtrees\Tree;
262263
use Fisharebest\Webtrees\Webtrees;
263264
use Illuminate\Support\Collection;
@@ -624,6 +625,7 @@ public function all(bool $include_disabled = false): Collection
624625

625626
return $this->coreModules()
626627
->merge($this->customModules())
628+
->merge($this->vendorModules())
627629
->map(static function (ModuleInterface $module) use ($module_info): ModuleInterface {
628630
$info = $module_info->get($module->name());
629631

@@ -715,6 +717,16 @@ private function customModules(): Collection
715717
->mapWithKeys(static fn (ModuleCustomInterface $module): array => [$module->name() => $module]);
716718
}
717719

720+
/**
721+
* All vendor modules in the system. Vendor modules are installed via Composer.
722+
*
723+
* @return Collection<string, ModuleCustomInterface>
724+
*/
725+
private function vendorModules(): Collection
726+
{
727+
return (new VendorModuleService())->getVendorModules();
728+
}
729+
718730
/**
719731
* Load a custom module in a static scope, to prevent it from modifying local or object variables.
720732
*/

0 commit comments

Comments
 (0)