*/ private array $moduleAliases = []; /** * @param ModuleInterface[] $modules */ public function __construct(array $modules) { array_walk($modules, $this->addModule(...)); $this->modules = $this->applyHierarchy($this->modules); $this->populateAliasMapping(); } private function addModule(ModuleInterface $module): void { $identifier = $module->getIdentifier(); if (isset($this->modules[$identifier])) { throw new \LogicException( 'A module with the identifier ' . $identifier . ' is already registered.', 1642174843 ); } $this->modules[$module->getIdentifier()] = $module; } public function hasModule(string $identifier): bool { return isset($this->modules[$identifier]) || isset($this->moduleAliases[$identifier]); } public function getModule(string $identifier): ModuleInterface { if (!$this->hasModule($identifier)) { throw new \InvalidArgumentException( 'Module with identifier ' . $identifier . ' does not exist.', 1642375889 ); } // Resolve the alias to the real module if (isset($this->moduleAliases[$identifier])) { $identifier = $this->moduleAliases[$identifier]; } return $this->modules[$identifier]; } /** * @return ModuleInterface[] */ public function getModules(): array { return $this->modules; } /** * Handle router configuration when the router is set up. * * The event registration is wired in `TYPO3\CMS\Backend\ServiceProvider::addEventListeners` * instead of using an `AsEventListener` because this service is not autowired, but has a * custom factory in `TYPO3\CMS\Backend\ServiceProvider`. */ public function registerRoutesForModules(RouterConfigurationEvent $event): void { $router = $event->router; foreach ($this->modules as $module) { if (!$module->hasParentModule() && !$module->isStandalone()) { // Skip first level modules, which are not standalone continue; } $routeCollection = new RouteCollection(); foreach ($module->getDefaultRouteOptions() as $routeIdentifier => $routeOptions) { $path = (string)(($routeOptions['path'] ?? false) ?: ('/' . $routeIdentifier)); $methods = (array)($routeOptions['methods'] ?? []); unset($routeOptions['path'], $routeOptions['methods']); if ($routeIdentifier === '_default') { // Add the first $route = new Route($module->getPath(), $routeOptions); if ($methods !== []) { $route->setMethods($methods); } $router->addRoute($module->getIdentifier(), $route, $module->getAliases()); } else { $route = new Route($path, $routeOptions); if ($methods !== []) { $route->setMethods($methods); } $routeCollection->add($routeIdentifier, $route); } } $routeCollection->addNamePrefix($module->getIdentifier() . '.'); $routeCollection->addPrefix($module->getPath()); $router->addRouteCollection($routeCollection); } } /** * Applies sorting (based on the "position" configuration) to all * registered modules and resolves the hierarchy (creating relations * by attaching modules to the $parentModule and $subModules properties). * * @param ModuleInterface[] $modules * @return ModuleInterface[] */ private function applyHierarchy(array $modules): array { // Fetch top-level (parent) modules and fill them with sorted sub modules $topLevelModules = []; foreach ($modules as $identifier => $module) { if ($module->getParentIdentifier() === '') { $topLevelModules[$identifier] = $module; } $subModules = array_filter( $modules, static fn(ModuleInterface $mod): bool => $mod->getParentIdentifier() === $identifier ); if ($subModules === []) { continue; } // Sort sub modules and connect them with their parent module $subModules = $this->applySorting($subModules); foreach ($subModules as $subModule) { $module->addSubModule($subModule); $subModule->setParentModule($module); } } // Promote single submodules to standalone modules and rebuild top-level modules afterwards $topLevelModules = array_filter( $this->promoteSingleSubmodulesToStandalone($modules, $topLevelModules), static fn(ModuleInterface $module): bool => !$module->hasParentModule() || $module->isStandalone() ); // Sort top level modules and return all modules (flat) with the correct sorting return $this->flattenModules($this->applySorting($topLevelModules)); } /** * Promotes submodules to standalone if their parent module has only one submodule and is not * standalone itself. This makes single submodules behave like standalone top-level modules. * * @param ModuleInterface[] $modules * @param ModuleInterface[] $topLevelModules * @return ModuleInterface[] */ private function promoteSingleSubmodulesToStandalone(array $modules, array $topLevelModules): array { foreach ($topLevelModules as $parentIdentifier => $parentModule) { // Skip already standalone modules if ($parentModule->isStandalone()) { continue; } // Only promote if explicitly enabled via appearance setting if (!($parentModule->getAppearance()['promotesSingleSubmoduleToStandalone'] ?? false)) { continue; } $subModules = $parentModule->getSubModules(); if (count($subModules) !== 1) { continue; } /** @var BaseModule $subModule */ $subModule = reset($subModules); // Promote the submodule to standalone, inheriting parent properties $subModule->promoteToStandalone( $parentModule->getNavigationComponent(), $parentModule->getPosition(), $parentModule->getAliases() ); // Remove parent module from registry unset($modules[$parentIdentifier]); } return $modules; } /** * Ensures that modules within one level of hierarchy are ordered properly the way * they were given respecting their "top", "bottom", "before" or "after" definition. * * @param ModuleInterface[] $modules * @return ModuleInterface[] */ private function applySorting(array $modules): array { $modulePositionInformation = []; // First create a list of all needed data, that is the identifier, and its position foreach ($modules as $identifier => $module) { // @todo Should we enforce ['after' => '*'] in case ->getPosition() is empty? $modulePositionInformation[$identifier] = $module->getPosition(); $modulePositionInformation[$identifier]['modulesToBeAddedDirectlyBefore'] = []; $modulePositionInformation[$identifier]['modulesToBeAddedDirectlyAfter'] = []; } // Identifiers of modules to be added at the top $highPriorityModules = []; // Identifiers of modules to be added at the bottom $lowPriorityModules = []; // Sort out the "top" and "bottom", and also build a graph of directly dependant (before/after) modules foreach ($modulePositionInformation as $identifier => $positionInformation) { if ($positionInformation['before'] ?? false) { if ($positionInformation['before'] === '*') { // Module should be added on top $highPriorityModules[] = $identifier; } elseif (isset($modules[$positionInformation['before']])) { // Build the dependencies in case a valid module identifier is configured $modulePositionInformation[$positionInformation['before']]['modulesToBeAddedDirectlyBefore'][] = $identifier; $modulePositionInformation[$identifier]['modulesToBeAddedDirectlyAfter'][] = $positionInformation['before']; } } elseif ($positionInformation['after'] ?? false) { if ($positionInformation['after'] === '*') { // Module should be added at the bottom $lowPriorityModules[] = $identifier; } elseif (isset($modules[$positionInformation['after']])) { // Build the dependencies in case a valid module identifier is configured $modulePositionInformation[$identifier]['modulesToBeAddedDirectlyBefore'][] = $positionInformation['after']; $modulePositionInformation[$positionInformation['after']]['modulesToBeAddedDirectlyAfter'][] = $identifier; } } } // First add the top items and their dependant modules $orderedModuleIdentifiers = $this->populateOrderingsForDependencies($highPriorityModules, $modulePositionInformation); // Now add the bottom items and their dependant modules // They will be cut out later, however, this is done now to also add their dependencies NOW (and not when looping over all items again) $orderedModuleIdentifiers = $this->populateOrderingsForDependencies($lowPriorityModules, $modulePositionInformation, $orderedModuleIdentifiers); $lastLowPriorityModule = end($orderedModuleIdentifiers); // Loop through all items and see which have not been added yet. Keep the original sorting. $orderedModuleIdentifiers = $this->populateOrderingsForDependencies(array_keys($modulePositionInformation), $modulePositionInformation, $orderedModuleIdentifiers); // Find the lowest priority module and move everything after that module to the very end if ($lowPriorityModules !== [] && $lastLowPriorityModule) { $firstLowPriorityModule = reset($lowPriorityModules); if ($firstLowPriorityModule !== $lastLowPriorityModule) { $firstPosition = array_search($firstLowPriorityModule, $orderedModuleIdentifiers, true); $lastPosition = array_search($lastLowPriorityModule, $orderedModuleIdentifiers, true); if ($firstPosition !== false && $lastPosition !== false) { $extractedItems = array_slice($orderedModuleIdentifiers, $firstPosition, $lastPosition); $orderedModuleIdentifiers = array_merge($orderedModuleIdentifiers, $extractedItems); } } } // Use the ordered list and replace all valid identifiers with the corresponding return array_replace(array_intersect_key(array_flip($orderedModuleIdentifiers), $modules), $modules); } /** * Given module identifiers are added based on their module position information to the * $alreadyOrderedModuleIdentifiers array (if not already present). In case the modules * to be added have dependencies to other modules and those modules exist, corresponding * modules are added to $alreadyOrderedModuleIdentifiers on the correct position as well. */ private function populateOrderingsForDependencies( array $moduleIdentifiersToBeAdded, array $modulePositionInformation, array $alreadyOrderedModuleIdentifiers = [] ): array { foreach ($moduleIdentifiersToBeAdded as $identifier) { // already placed somewhere if (in_array($identifier, $alreadyOrderedModuleIdentifiers, true)) { continue; } // Check if the current module has dependencies, which should be added BEFORE foreach ($modulePositionInformation[$identifier]['modulesToBeAddedDirectlyBefore'] ?? [] as $dependantIdentifier) { // already placed somewhere if (in_array($dependantIdentifier, $alreadyOrderedModuleIdentifiers, true)) { continue; } // Check if the dependent module has dependencies, which should be added BEFORE foreach ($modulePositionInformation[$dependantIdentifier]['modulesToBeAddedDirectlyBefore'] ?? [] as $dependantDependantIdentifier) { // already placed somewhere if (in_array($dependantDependantIdentifier, $alreadyOrderedModuleIdentifiers, true)) { continue; } // Add the sub dependency right away $alreadyOrderedModuleIdentifiers[] = $dependantDependantIdentifier; } // Add the dependant module now $alreadyOrderedModuleIdentifiers[] = $dependantIdentifier; } // Add the actual module now $alreadyOrderedModuleIdentifiers[] = $identifier; // Check if the current module has dependencies, which should be added AFTER foreach ($modulePositionInformation[$identifier]['modulesToBeAddedDirectlyAfter'] ?? [] as $dependantIdentifier) { // already placed somewhere if (in_array($dependantIdentifier, $alreadyOrderedModuleIdentifiers, true)) { continue; } // Add the dependant module right away $alreadyOrderedModuleIdentifiers[] = $dependantIdentifier; // Check if the dependent module has dependencies, which should be added AFTER foreach ($modulePositionInformation[$dependantIdentifier]['modulesToBeAddedDirectlyAfter'] ?? [] as $dependantDependantIdentifier) { // already placed somewhere if (in_array($dependantDependantIdentifier, $alreadyOrderedModuleIdentifiers, true)) { continue; } // Add the sub dependency right away $alreadyOrderedModuleIdentifiers[] = $dependantDependantIdentifier; } } } return $alreadyOrderedModuleIdentifiers; } /** * Create a flat modules array (looping through each level by calling "getSubmodules()" on the parent) */ private function flattenModules(array $modules, $flatModules = []): array { foreach ($modules as $module) { $flatModules[$module->getIdentifier()] = $module; if ($module->hasSubmodules()) { $flatModules = $this->flattenModules($module->getSubmodules(), $flatModules); } } return $flatModules; } private function populateAliasMapping(): void { foreach ($this->modules as $moduleIdentifier => $module) { foreach ($module->getAliases() as $aliasIdentifier) { // Note: The last module defining the same alias wins in general $this->moduleAliases[$aliasIdentifier] = $moduleIdentifier; } } } public function getModuleAliases(): array { return $this->moduleAliases; } }