Files
E.Noorlander 6d5ca7cab4 v2.6.1d (Lyra): Plugin i18n, plugin editor vernieuwd, media invoegen
Plugin internationalisatie: plugins hebben eigen language/ mappen. Systeem
plugins volgen admin taal (admin.php), content plugins volgen content taal
(site.php). Fallback chain: geselecteerd -> plugin default_language -> CMS
default. PluginManager/AdminPluginAPI/CMSAPI uitgebreid met
getPluginTranslations()/t(). plugin.json settings ondersteunen
label_key/help_key/option_label_key.

Plugin uniformiteit: alle 6 plugins hebben uniforme structuur (README.md,
assets/.gitkeep, language/nl|en/). plugins/README.md herschreven.
guide plugin-development.md (NL+EN) volledig herschreven.

Plugin editor vernieuwd: geneste bestandsbrowser zijbalk, nieuw bestand
aanmaken, uploaden naar assets/, verwijderen en verplaatsen. Nieuwe routes:
plugins-file-upload, plugins-file-delete, plugins-file-move. Path-traversal
bescherming + protected plugins geblokkeerd.

Media invoegen in editor: nieuw /admin/media-list JSON endpoint + herbruikbare
_media-modal.twig include. Plugin-context scant assets/ map. editor-toolbar.js
modeMap uitgebreid voor css/scss/js/json.

Plugin overzicht knoppen: alleen iconen met title/aria-label.

Tests: pentest 30/30, WCAG 2.1 AA 25/25.
2026-08-18 13:49:52 +00:00

386 lines
13 KiB
PHP

<?php
class PluginManager
{
private array $plugins = [];
private string $pluginsPath;
private $api = null;
private array $enabledPlugins = [];
private array $actions = [];
private array $filters = [];
private string $siteDefaultLanguage = 'nl';
public function __construct(string $pluginsPath, array $enabledPlugins = [], string $siteDefaultLanguage = 'nl')
{
$this->pluginsPath = $pluginsPath;
$this->enabledPlugins = $enabledPlugins;
$this->siteDefaultLanguage = $siteDefaultLanguage !== '' ? $siteDefaultLanguage : 'nl';
$this->loadPlugins();
}
/**
* Set the CMS site default language. Used as a fallback when a plugin
* does not declare its own `default_language` in plugin.json.
*/
public function setSiteDefaultLanguage(string $lang): void
{
$this->siteDefaultLanguage = $lang !== '' ? $lang : 'nl';
}
public function setAPI($api): void
{
$this->api = $api;
foreach ($this->plugins as $plugin) {
if (method_exists($plugin, 'setAPI')) {
$plugin->setAPI($api);
}
}
}
private function loadPlugins(): void
{
if (!is_dir($this->pluginsPath)) {
return;
}
$pluginDirs = glob($this->pluginsPath . '/*', GLOB_ONLYDIR);
foreach ($pluginDirs as $pluginDir) {
$pluginName = basename($pluginDir);
if (!in_array($pluginName, $this->enabledPlugins, true)) {
continue;
}
$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]);
}
}
}
}
}
}
public function addAction(string $hook, callable $callback, int $priority = 10): void
{
$this->actions[$hook][$priority][] = $callback;
}
public function addFilter(string $hook, callable $callback, int $priority = 10): void
{
$this->filters[$hook][$priority][] = $callback;
}
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);
}
}
}
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;
}
public function getPlugin(string $name): ?object
{
return $this->plugins[$name] ?? null;
}
public function getAllPlugins(): array
{
return $this->plugins;
}
public function getEnabledPlugins(): array
{
return $this->enabledPlugins;
}
public function isEnabled(string $pluginName): bool
{
return in_array($pluginName, $this->enabledPlugins, true);
}
/**
* Get the runtime config for a plugin: defaults from plugin.json `settings`
* merged with overrides from the plugin's config.json.
*
* @param string $pluginName Plugin name (directory name)
* @return array Resolved config: [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);
}
/**
* Get the default (fallback) language declared by a plugin.
*
* Resolved from (in order):
* 1. plugin.json `default_language`
* 2. CMS site config `language.default`
* 3. 'nl'
*
* @param string $pluginName Plugin directory name
* @return string Language code (e.g. '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';
}
/**
* Load the translations for a plugin for the requested language.
*
* Fallback chain: requested language -> plugin default language -> empty array.
*
* @param string $pluginName Plugin directory name
* @param string $lang Requested language code
* @param string $context Translation context: 'admin' or 'site' (front-end).
* Defaults to 'admin' for system plugins, 'site' for content plugins.
* @return array Translations [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 [];
}
/**
* Collect translations for every loaded plugin for the requested language.
*
* Returns an associative array keyed by plugin name:
* ['Statistics' => [...], 'Logs' => [...], ...]
*
* @param string $lang Requested language code
* @param string $context 'admin' or 'site'
* @return array Plugin translations keyed by plugin name
*/
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;
}
public function isPluginViewable(object $plugin): bool
{
if (method_exists($plugin, 'getConfig')) {
$config = $plugin->getConfig();
return !isset($config['viewable']) || $config['viewable'] !== false;
}
return true;
}
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 .= '
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">' . htmlspecialchars($title) . '</h5>
</div>
<div class="card-body">
' . $content . '
</div>
</div>';
}
return $sidebarContent;
}
/**
* Get CSS URLs from all enabled plugins that provide getCssUrl().
*
* @return array List of CSS URLs
*/
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;
}
/**
* Collect admin menu items from all enabled plugins that implement getAdminMenu().
* Each plugin returns [['route' => 'plugin-name/action', 'label' => 'Label', 'icon' => 'bi-icon']].
*
* @return array Admin menu items from all 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 an admin route to a plugin that handles it.
* Plugins implement handleAdminRoute(string $action): ?string (returns rendered HTML or null).
*
* @param string $pluginName Plugin name
* @param string $action Action/sub-route within the plugin
* @return string|null Rendered HTML, or null if plugin doesn't handle it
*/
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;
}
/**
* Find which plugin handles a given admin route.
*
* @param string $route The admin route (e.g. 'statistics' or 'statistics/details')
* @return array|null ['plugin' => name, 'action' => action, 'permission' => required permission] or 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;
}
}