*/ private array $plugins = []; /** * Het absolute pad naar de pluginsdirectory. * * @since 2.6.5 * @var string */ private string $pluginsPath; /** * De geïnjecteerde plugin-API-instantie (CMSAPI of AdminPluginAPI). * * @since 2.6.5 * @var PluginAPIInterface|null */ private $api = null; /** * Lijst met ingeschakelde plugin-namen. * * @since 2.6.5 * @var array */ private array $enabledPlugins = []; /** * Geregistreerde action-hooks, gegroepeerd per hook-naam en prioriteit. * * @since 2.6.5 * @var array>> */ private array $actions = []; /** * Geregistreerde filter-hooks, gegroepeerd per hook-naam en prioriteit. * * @since 2.6.5 * @var array>> */ private array $filters = []; /** * De standaardtaal van de site, gebruikt als fallback voor plugins * zonder eigen default_language in plugin.json. * * @since 2.6.5 * @var string */ private string $siteDefaultLanguage = 'nl'; /** * Construeer een PluginManager en laadt direct de ingeschakelde plugins. * * @since 2.6.5 * * @param string $pluginsPath Absoluut pad naar de pluginsdirectory. * @param array $enabledPlugins Lijst met ingeschakelde plugin-namen. * @param string $siteDefaultLanguage Standaardtaal van de site. */ public function __construct(string $pluginsPath, array $enabledPlugins = [], string $siteDefaultLanguage = 'nl') { $this->pluginsPath = $pluginsPath; $this->enabledPlugins = $enabledPlugins; $this->siteDefaultLanguage = $siteDefaultLanguage !== '' ? $siteDefaultLanguage : 'nl'; $this->loadPlugins(); } /** * Stel de standaardtaal van de site in. * * Wordt gebruikt als fallback wanneer een plugin geen eigen `default_language` * declareert in plugin.json. * * @since 2.6.5 * * @param string $lang Taalcode (bijv. 'nl', 'en'). * @return void */ public function setSiteDefaultLanguage(string $lang): void { $this->siteDefaultLanguage = $lang !== '' ? $lang : 'nl'; } /** * Stel de plugin-API in en geef deze door aan alle geladen plugins. * * Plugins die een setAPI()-methode implementeren, ontvangen de API-instantie. * * @since 2.6.5 * * @param PluginAPIInterface $api De plugin-API-instantie. * @return void */ public function setAPI($api): void { $this->api = $api; foreach ($this->plugins as $plugin) { if (method_exists($plugin, 'setAPI')) { $plugin->setAPI($api); } } } /** * Controleer of een plugin als 'essential' is gemarkeerd in zijn plugin.json. * * Essential plugins worden altijd geladen, ongeacht de enabled_plugins-lijst, * en kunnen niet worden uitgeschakeld, bewerkt of verwijderd via de admin. * * @since 2.6.6 * * @param string $pluginName Plugin-naam (directorynaam). * @return bool True indien de plugin in plugin.json essential: true heeft. */ public function isEssentialPlugin(string $pluginName): bool { $pluginJsonFile = $this->pluginsPath . '/' . $pluginName . '/plugin.json'; if (!file_exists($pluginJsonFile)) { return false; } $pluginJson = json_decode(file_get_contents($pluginJsonFile), true); return is_array($pluginJson) && ($pluginJson['essential'] ?? false) === true; } /** * Laad alle ingeschakelde plugins uit de pluginsdirectory. * * Essential plugins (plugin.json `essential: true`) worden altijd geladen, * ook als ze niet in enabled_plugins staan. Per plugin wordt het hoofdbestand * (.php) geïncludeerd, de plugin-class geïnstantieerd, en action- en * filter-hooks automatisch geregistreerd. * * @since 2.6.5 * * @return void */ private function loadPlugins(): void { if (!is_dir($this->pluginsPath)) { return; } $pluginDirs = glob($this->pluginsPath . '/*', GLOB_ONLYDIR); foreach ($pluginDirs as $pluginDir) { $pluginName = basename($pluginDir); // Essential plugins are always loaded, regardless of enabled_plugins. $isEnabled = in_array($pluginName, $this->enabledPlugins, true); if (!$isEnabled && !$this->isEssentialPlugin($pluginName)) { continue; } // Keep the effective enabled list consistent for getEnabledPlugins()/isEnabled(). if (!$isEnabled) { $this->enabledPlugins[] = $pluginName; } $pluginFile = $pluginDir . '/' . $pluginName . '.php'; if (file_exists($pluginFile)) { require_once $pluginFile; $className = $pluginName; if (class_exists($className)) { $this->plugins[$pluginName] = new $className(); // Auto-register hooks from plugin methods $hookMethods = ['onPageLoad', 'onBeforeRender', 'onAfterRender', 'onSearch', 'onMenuBuild']; foreach ($hookMethods as $hook) { if (method_exists($this->plugins[$pluginName], $hook)) { $this->addAction($hook, [$this->plugins[$pluginName], $hook]); } } // Register filter methods $filterMethods = ['onContentFilter', 'onTitleFilter', 'onMenuFilter']; foreach ($filterMethods as $filter) { if (method_exists($this->plugins[$pluginName], $filter)) { $this->addFilter($filter, [$this->plugins[$pluginName], $filter]); } } } } } } /** * Registreer een action-callback voor een hook. * * @since 2.6.5 * * @param string $hook Naam van de action-hook. * @param callable $callback De callback die bij de hook wordt uitgevoerd. * @param int $priority Uitvoer-volgorde; lager wordt eerder uitgevoerd. * @return void */ public function addAction(string $hook, callable $callback, int $priority = 10): void { $this->actions[$hook][$priority][] = $callback; } /** * Registreer een filter-callback voor een hook. * * @since 2.6.5 * * @param string $hook Naam van de filter-hook. * @param callable $callback De callback die de waarde filtert. * @param int $priority Uitvoer-volgorde; lager wordt eerder uitgevoerd. * @return void */ public function addFilter(string $hook, callable $callback, int $priority = 10): void { $this->filters[$hook][$priority][] = $callback; } /** * Voer alle geregistreerde callbacks voor een action-hook uit. * * @since 2.6.5 * * @param string $hook Naam van de action-hook. * @param mixed ...$args Argumenten die aan elke callback worden doorgegeven. * @return void */ public function doAction(string $hook, ...$args): void { if (!isset($this->actions[$hook])) return; ksort($this->actions[$hook]); foreach ($this->actions[$hook] as $callbacks) { foreach ($callbacks as $callback) { $callback(...$args); } } } /** * Pas alle geregistreerde filter-callbacks toe op een waarde. * * @since 2.6.5 * * @param string $hook Naam van de filter-hook. * @param mixed $value De initiële waarde die wordt gefilterd. * @param mixed ...$args Extra argumenten voor elke filter-callback. * @return mixed De gefilterde waarde. */ public function applyFilters(string $hook, $value, ...$args) { if (!isset($this->filters[$hook])) return $value; ksort($this->filters[$hook]); foreach ($this->filters[$hook] as $callbacks) { foreach ($callbacks as $callback) { $value = $callback($value, ...$args); } } return $value; } /** * Haal een specifieke plugin-instantie op. * * @since 2.6.5 * * @param string $name Plugin-naam (directorynaam). * @return object|null De plugin-instantie of null indien niet geladen. */ public function getPlugin(string $name): ?object { return $this->plugins[$name] ?? null; } /** * Haal alle geladen plugin-instanties op. * * @since 2.6.5 * * @return array Plugin-instanties geïndexeerd op naam. */ public function getAllPlugins(): array { return $this->plugins; } /** * Haal de lijst met ingeschakelde plugin-namen op. * * @since 2.6.5 * * @return array Lijst met ingeschakelde plugin-namen. */ public function getEnabledPlugins(): array { return $this->enabledPlugins; } /** * Controleer of een plugin is ingeschakeld. * * @since 2.6.5 * * @param string $pluginName Plugin-naam (directorynaam). * @return bool True indien de plugin is ingeschakeld. */ public function isEnabled(string $pluginName): bool { return in_array($pluginName, $this->enabledPlugins, true); } /** * Haal de runtime-configuratie van een plugin op. * * Standaardwaarden uit plugin.json `settings` worden samengevoegd met * overrides uit het config.json van de plugin. * * @since 2.6.5 * * @param string $pluginName Plugin-naam (directorynaam). * @return array Opgeloste configuratie als [key => value]. */ public function getPluginConfig(string $pluginName): array { $pluginDir = $this->pluginsPath . '/' . $pluginName; $pluginJsonFile = $pluginDir . '/plugin.json'; $configJsonFile = $pluginDir . '/config.json'; $defaults = []; if (file_exists($pluginJsonFile)) { $pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? []; foreach ($pluginJson['settings'] ?? [] as $setting) { if (isset($setting['key'])) { $defaults[$setting['key']] = $setting['default'] ?? null; } } } $overrides = []; if (file_exists($configJsonFile)) { $overrides = json_decode(file_get_contents($configJsonFile), true) ?? []; } return array_merge($defaults, $overrides); } /** * Haal de standaard (fallback) taal van een plugin op. * * Resolved in volgorde van: * 1. plugin.json `default_language` * 2. CMS site-config `language.default` * 3. 'nl' * * @since 2.6.5 * * @param string $pluginName Plugin-directorynaam. * @return string Taalcode (bijv. 'nl', 'en'). */ public function getPluginDefaultLanguage(string $pluginName): string { $pluginJsonFile = $this->pluginsPath . '/' . $pluginName . '/plugin.json'; if (file_exists($pluginJsonFile)) { $pluginJson = json_decode(file_get_contents($pluginJsonFile), true) ?? []; if (!empty($pluginJson['default_language'])) { return (string)$pluginJson['default_language']; } } return $this->siteDefaultLanguage ?? 'nl'; } /** * Laad de vertalingen voor een plugin voor de aangevraagde taal. * * Fallback-keten: aangevraagde taal -> standaardtaal van de plugin -> lege array. * * @since 2.6.5 * * @param string $pluginName Plugin-directorynaam. * @param string $lang Aangevraagde taalcode. * @param string $context Vertaalcontext: 'admin' of 'site' (front-end). * Standaard 'admin' voor system-plugins, 'site' voor content-plugins. * @return array Vertalingen als [key => value]. */ public function getPluginTranslations(string $pluginName, string $lang, string $context = 'admin'): array { $langDir = $this->pluginsPath . '/' . $pluginName . '/language'; if (!is_dir($langDir)) { return []; } // Try requested language first $file = $langDir . '/' . $lang . '/' . $context . '.php'; if (file_exists($file)) { $t = include $file; if (is_array($t)) { return $t; } } // Fallback to the plugin's default language $defaultLang = $this->getPluginDefaultLanguage($pluginName); if ($defaultLang !== $lang) { $fallbackFile = $langDir . '/' . $defaultLang . '/' . $context . '.php'; if (file_exists($fallbackFile)) { $t = include $fallbackFile; if (is_array($t)) { return $t; } } } return []; } /** * Verzamel vertalingen voor alle geladen plugins voor de aangevraagde taal. * * Geeft een associatieve array terug, geïndexeerd op plugin-naam: * ['Statistics' => [...], 'Logs' => [...], ...] * * @since 2.6.5 * * @param string $lang Aangevraagde taalcode. * @param string $context 'admin' of 'site'. * @return array> Plugin-vertalingen geïndexeerd op plugin-naam. */ public function getAllPluginTranslations(string $lang, string $context = 'admin'): array { $all = []; foreach ($this->plugins as $pluginName => $plugin) { // System plugins follow the admin language; content plugins follow the content language. // The caller decides the context; here we just load for every plugin. $all[$pluginName] = $this->getPluginTranslations($pluginName, $lang, $context); } return $all; } /** * Controleer of een plugin viewable is. * * Een plugin is viewable tenzij zijn configuratie 'viewable' expliciet op false zet. * * @since 2.6.5 * * @param object $plugin De plugin-instantie. * @return bool True indien de plugin viewable is. */ public function isPluginViewable(object $plugin): bool { if (method_exists($plugin, 'getConfig')) { $config = $plugin->getConfig(); return !isset($config['viewable']) || $config['viewable'] !== false; } return true; } /** * Haal de samengevoegde sidebar-content van alle viewable plugins op. * * Elke plugin met een getSidebarContent()-methode levert HTML die wordt * ingepakt in een Bootstrap-card met de plugin-titel. * * @since 2.6.5 * * @param array|null $allowedPlugins Optionele whitelist van plugin-namen. * @return string De samengevoegde sidebar-HTML. */ public function getSidebarContent(?array $allowedPlugins = null): string { $sidebarContent = ''; foreach ($this->plugins as $pluginName => $plugin) { if (!$this->isPluginViewable($plugin) || !method_exists($plugin, 'getSidebarContent')) { continue; } if ($allowedPlugins !== null && !in_array($pluginName, $allowedPlugins, true)) { continue; } $content = $plugin->getSidebarContent(); if (trim($content) === '') { continue; } $title = 'Plugin'; if (method_exists($plugin, 'getConfig')) { $config = $plugin->getConfig(); $title = $config['title'] ?? 'Plugin'; } $sidebarContent .= '
' . htmlspecialchars($title) . '
' . $content . '
'; } return $sidebarContent; } /** * Haal CSS-URL's op van alle ingeschakelde plugins die getCssUrl() implementeren. * * @since 2.6.5 * * @return array Lijst met CSS-URL's. */ public function getPluginCssUrls(): array { $urls = []; foreach ($this->plugins as $pluginName => $plugin) { if (method_exists($plugin, 'getCssUrl')) { $url = $plugin->getCssUrl(); if (!empty($url)) { $urls[] = $url; } } } return $urls; } /** * Verzamel admin-menu-items van alle plugins die getAdminMenu() implementeren. * * Elke plugin levert [['route' => 'plugin-name/action', 'label' => 'Label', 'icon' => 'bi-icon']]. * * @since 2.6.5 * * @return array> Admin-menu-items van alle plugins. */ public function getAdminMenuItems(): array { $items = []; foreach ($this->plugins as $pluginName => $plugin) { if (method_exists($plugin, 'getAdminMenu')) { $pluginItems = $plugin->getAdminMenu(); if (is_array($pluginItems)) { foreach ($pluginItems as $item) { $items[] = $item; } } } } return $items; } /** * Dispatch een admin-route naar de plugin die deze afhandelt. * * Plugins implementeren handleAdminRoute(string $action): ?string * (geeft gerenderde HTML of null terug). * * @since 2.6.5 * * @param string $pluginName Plugin-naam. * @param string $action Actie/sub-route binnen de plugin. * @return string|null Gerenderde HTML, of null indien de plugin deze niet afhandelt. */ public function dispatchAdminRoute(string $pluginName, string $action): ?string { $plugin = $this->getPlugin($pluginName); if ($plugin === null) { return null; } if (method_exists($plugin, 'handleAdminRoute')) { return $plugin->handleAdminRoute($action); } return null; } /** * Bepaal welke plugin een opgegeven admin-route afhandelt. * * @since 2.6.5 * * @param string $route De admin-route (bijv. 'statistics' of 'statistics/details'). * @return array|null ['plugin' => naam, 'action' => actie, 'permission' => vereiste permissie] of null. */ public function resolveAdminRoute(string $route): ?array { foreach ($this->getAdminMenuItems() as $item) { $itemRoute = $item['route'] ?? ''; // Check if the requested route starts with this plugin's route if ($route === $itemRoute || str_starts_with($route, $itemRoute . '/')) { $action = substr($route, strlen($itemRoute) + 1); return [ 'plugin' => $item['plugin'] ?? '', 'action' => $action, 'permission' => $item['permission'] ?? 'plugins', ]; } } return null; } }