197 lines
7.4 KiB
PHP
197 lines
7.4 KiB
PHP
<?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 array $routeHandlers = []; // callbacks: fn(string $route, string $method): bool
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Register a route handler from a plugin's backend.php.
|
|
* The callback receives ($route, $method) and should call exit() on a match.
|
|
* If it doesn't exit, the next handler / core route handling continues.
|
|
*/
|
|
public function onApiRoute(callable $handler): void {
|
|
$this->routeHandlers[] = $handler;
|
|
}
|
|
|
|
/**
|
|
* Dispatch a request to all registered plugin route handlers in order.
|
|
* Each handler that wants to claim the route must call exit() itself.
|
|
* Called from index.php BEFORE core route handling.
|
|
*/
|
|
public function dispatchRoute(string $route, string $method): void {
|
|
foreach ($this->routeHandlers as $handler) {
|
|
try { $handler($route, $method); }
|
|
catch (\Throwable $e) {
|
|
error_log('[plugin route] ' . $e->getMessage());
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'plugin route handler failed']);
|
|
exit;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
}
|