/ 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 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[] = ''; } } 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[] = "\n" . file_get_contents($path); } } } return implode("\n", $out); } /** Render '; } } return implode("\n ", $out); } }