plugin infrastruktur

This commit is contained in:
th committed 2026-04-10 07:58:07 +02:00
1 parent 1545b90f39
commit 3f4ba3c0f4
35 files changed
+1303 -195

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
<?php
/**
* PluginManager — Plugin discovery, loading, and asset rendering.
*
* Plugins live in /public/plugins/<name>/ and must have a plugin.json manifest:
* {
* "name": "canvas",
* "version": "1.0.0",
* "description": "ASCII art canvas editor",
* "frontend": {
* "js": ["canvas.js"],
* "css": ["canvas.css"],
* "html": ["canvas.html"]
* },
* "backend": "backend.php" // optional
* }
*
* Plugins are entirely optional — the core works fine without any.
*/
class PluginManager {
private array $plugins = [];
private array $slots = [];
private array $disabled = []; // names of plugins the user has disabled
private ?string $stateFile = null; // path to plugins.json
private static ?PluginManager $instance = null;
public static function instance(): self {
if (self::$instance === null) self::$instance = new self();
return self::$instance;
}
/** Scan a directory for plugins. Each subdirectory with a plugin.json is registered. */
public function scan(string $dir): void {
if (!is_dir($dir)) return;
$entries = @scandir($dir);
if (!$entries) return;
sort($entries);
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') continue;
$pluginDir = $dir . '/' . $entry;
if (!is_dir($pluginDir)) continue;
$manifestPath = $pluginDir . '/plugin.json';
if (!file_exists($manifestPath)) continue;
$raw = @file_get_contents($manifestPath);
$manifest = $raw ? json_decode($raw, true) : null;
if (!is_array($manifest)) continue;
$name = $manifest['name'] ?? $entry;
$this->plugins[$name] = [
'dir' => $pluginDir,
'slug' => $entry,
'manifest' => $manifest,
];
}
}
/** After scan() and loadDisabled(), require_once the backend.php of every ENABLED plugin. */
public function loadBackends(): void {
foreach ($this->plugins as $name => $p) {
if (!$this->isEnabled($name)) continue;
$backend = $p['manifest']['backend'] ?? null;
if (!$backend) continue;
$backendPath = $p['dir'] . '/' . $backend;
if (file_exists($backendPath)) require_once $backendPath;
}
}
public function plugins(): array {
return $this->plugins;
}
/** Load the disabled-plugins state file. Creates it lazily on first write. */
public function loadDisabled(string $stateFile): void {
$this->stateFile = $stateFile;
if (!file_exists($stateFile)) { $this->disabled = []; return; }
$raw = @file_get_contents($stateFile);
if (!$raw) { $this->disabled = []; return; }
$data = json_decode($raw, true);
$list = is_array($data) ? ($data['disabled'] ?? []) : [];
$this->disabled = array_values(array_filter($list, 'is_string'));
}
/** Persist the disabled list to the state file. Returns true on success. */
public function setDisabled(array $names): bool {
if ($this->stateFile === null) return false;
$clean = array_values(array_unique(array_filter($names, 'is_string')));
$this->disabled = $clean;
$dir = dirname($this->stateFile);
if (!is_dir($dir)) @mkdir($dir, 0755, true);
$payload = json_encode(['disabled' => $clean], JSON_PRETTY_PRINT);
return @file_put_contents($this->stateFile, $payload) !== false;
}
public function isEnabled(string $name): bool {
return !in_array($name, $this->disabled, true);
}
public function disabledList(): array {
return $this->disabled;
}
/** Public-friendly plugin list for the API: [{name, version, description, enabled}]. */
public function pluginsForApi(): array {
$out = [];
foreach ($this->plugins as $name => $p) {
$m = $p['manifest'];
$out[] = [
'name' => $name,
'version' => $m['version'] ?? '',
'description' => $m['description'] ?? '',
'enabled' => $this->isEnabled($name),
'hasBackend' => !empty($m['backend']),
];
}
return $out;
}
/** Append HTML content to a named slot. Backend plugins use this. */
public function addToSlot(string $slot, string $html): void {
$this->slots[$slot][] = $html;
}
/** Render a slot's accumulated HTML. */
public function slot(string $slot): string {
return implode("\n", $this->slots[$slot] ?? []);
}
/** Render <link> tags for enabled plugin CSS files. */
public function cssLinks(string $baseUrl = 'plugins'): string {
$out = [];
foreach ($this->plugins as $name => $p) {
if (!$this->isEnabled($name)) continue;
$cssList = $p['manifest']['frontend']['css'] ?? [];
foreach ($cssList as $css) {
$out[] = '<link rel="stylesheet" href="' . $baseUrl . '/' . $p['slug'] . '/' . $css . '?v=' . time() . '">';
}
}
return implode("\n ", $out);
}
/** Render inlined HTML fragments from enabled plugins (for overlays etc.). */
public function htmlFragments(): string {
$out = [];
foreach ($this->plugins as $name => $p) {
if (!$this->isEnabled($name)) continue;
$htmlList = $p['manifest']['frontend']['html'] ?? [];
foreach ($htmlList as $htmlFile) {
$path = $p['dir'] . '/' . $htmlFile;
if (file_exists($path)) {
$out[] = "<!-- plugin:{$p['slug']}:{$htmlFile} -->\n" . file_get_contents($path);
}
}
}
return implode("\n", $out);
}
/** Render <script type="module"> tags for enabled plugin JS entry points. */
public function scriptTags(string $baseUrl = 'plugins'): string {
$out = [];
foreach ($this->plugins as $name => $p) {
if (!$this->isEnabled($name)) continue;
$jsList = $p['manifest']['frontend']['js'] ?? [];
foreach ($jsList as $js) {
$out[] = '<script type="module" src="' . $baseUrl . '/' . $p['slug'] . '/' . $js . '?v=' . time() . '"></script>';
}
}
return implode("\n ", $out);
}
}
+7
View File
@@ -8,6 +8,7 @@ if (function_exists('opcache_invalidate')) {
require_once $configPath;
require_once __DIR__ . '/Database.php';
require_once __DIR__ . '/PluginManager.php';
// Ensure data directory is writable
$dataDir = dirname(DB_PATH);
@@ -16,3 +17,9 @@ if (!is_writable($dataDir) && is_dir($dataDir)) {
echo 'Data directory is not writable';
exit(1);
}
// Discover plugins (each subdir of public/plugins/ with a plugin.json)
$pluginManager = PluginManager::instance();
$pluginManager->scan(__DIR__ . '/../public/plugins');
$pluginManager->loadDisabled(dirname(DB_PATH) . '/plugins.json');
$pluginManager->loadBackends(); // require_once enabled backend.php files