1<?php
2/**
3 * Nexus Forum — Addon System
4 *
5 * Two hook patterns:
6 *
7 * AddonManager::fire($hook, $data)
8 * FILTER pattern — each callback receives the return value of the previous.
9 * Use for transforming a single value (e.g. render_post_content: HTML string in/out).
10 *
11 * AddonManager::collect($hook, $context)
12 * COLLECTOR pattern — every callback receives the same $context array.
13 * Non-empty string returns are accumulated and joined.
14 * Use for output hooks where multiple addons each contribute HTML
15 * (e.g. render_post_footer, render_topic_header, before_page_head).
16 */
17if (!defined('NEXUS')) exit('Forbidden');
18
19class AddonManager {
20 private static array $hooks = [];
21 private static array $loaded = [];
22 private static ?array $active = null;
23
24
25 public static function boot(): void {
26 foreach (self::activeList() as $slug) {
27 $main = ROOT . '/addons/' . $slug . '/main.php';
28 if (file_exists($main) && !isset(self::$loaded[$slug])) {
29 try {
30 require_once $main;
31 self::$loaded[$slug] = true;
32 } catch (Throwable $e) {
33 error_log("Addon [$slug] boot error: " . $e->getMessage());
34 }
35 }
36 }
37 }
38
39
40 public static function on(string $hook, callable $cb, int $priority = 10): void {
41 self::$hooks[$hook][$priority][] = $cb;
42 }
43
44
45 public static function fire(string $hook, mixed $data = null): mixed {
46 if (empty(self::$hooks[$hook])) return $data;
47 $buckets = self::$hooks[$hook];
48 ksort($buckets);
49 foreach ($buckets as $callbacks) {
50 foreach ($callbacks as $cb) {
51 try {
52 $result = $cb($data);
53 if ($result !== null) $data = $result;
54 } catch (Throwable $e) {
55 error_log("Addon hook [$hook] filter error: " . $e->getMessage());
56 }
57 }
58 }
59 return $data;
60 }
61
62
63
64 public static function collect(string $hook, mixed $context = null): string {
65 if (empty(self::$hooks[$hook])) return '';
66 $buckets = self::$hooks[$hook];
67 ksort($buckets);
68 $output = '';
69 foreach ($buckets as $callbacks) {
70 foreach ($callbacks as $cb) {
71 try {
72 $result = $cb($context);
73 if (is_string($result) && $result !== '') {
74 $output .= $result;
75 }
76 } catch (Throwable $e) {
77 error_log("Addon hook [$hook] collect error: " . $e->getMessage());
78 }
79 }
80 }
81 return $output;
82 }
83
84 public static function hasHook(string $hook): bool {
85 return !empty(self::$hooks[$hook]);
86 }
87
88
89 public static function activeList(): array {
90 if (self::$active !== null) return self::$active;
91 try {
92 $val = DB::val("SELECT value FROM settings WHERE `key`='active_addons'");
93 self::$active = $val ? (json_decode($val, true) ?: []) : [];
94 } catch (Throwable $e) {
95 self::$active = [];
96 }
97 return self::$active;
98 }
99
100
101 public static function all(): array {
102 $dir = ROOT . '/addons';
103 $active = self::activeList();
104 $addons = [];
105 if (!is_dir($dir)) return [];
106 foreach (scandir($dir) as $slug) {
107 if ($slug[0] === '.') continue;
108 $manifest = $dir . '/' . $slug . '/nexus-addon.json';
109 if (!file_exists($manifest)) continue;
110 $info = json_decode(file_get_contents($manifest), true);
111 if (!$info || empty($info['name'])) continue;
112 $addons[$slug] = array_merge([
113 'slug' => $slug, 'name' => $slug, 'description' => '',
114 'version' => '1.0.0', 'author' => 'Unknown', 'url' => '',
115 'hooks' => [], 'requires' => [],
116 ], $info, [
117 'slug' => $slug,
118 'active' => in_array($slug, $active),
119 ]);
120 }
121 return $addons;
122 }
123
124
125 public static function activate(string $slug): bool {
126 $active = self::activeList();
127 if (in_array($slug, $active)) return true;
128 $install = ROOT . '/addons/' . $slug . '/install.php';
129 if (file_exists($install)) {
130 try { require $install; } catch (Throwable $e) {
131 error_log("Addon [$slug] install error: " . $e->getMessage());
132 return false;
133 }
134 }
135 $active[] = $slug;
136 self::saveActive($active);
137 self::$active = $active;
138 return true;
139 }
140
141 public static function deactivate(string $slug): bool {
142 $active = self::activeList();
143 if (!in_array($slug, $active)) return true;
144 $uninstall = ROOT . '/addons/' . $slug . '/uninstall.php';
145 if (file_exists($uninstall)) {
146 try { require $uninstall; } catch (Throwable $e) {
147 error_log("Addon [$slug] uninstall error: " . $e->getMessage());
148 }
149 }
150 $active = array_values(array_filter($active, fn($s) => $s !== $slug));
151 self::saveActive($active);
152 self::$active = $active;
153 return true;
154 }
155
156 private static function saveActive(array $active): void {
157 DB::upsert('settings', 'key', 'value', 'active_addons', json_encode(array_values($active)));
158 }
159}
160
161
162
163
164function addon_hook(string $hook, mixed $data = null): mixed {
165 return AddonManager::fire($hook, $data);
166}
167
168
169function addon_collect(string $hook, mixed $context = null): string {
170 return AddonManager::collect($hook, $context);
171}
172
173
174function addon_on(string $hook, callable $cb, int $priority = 10): void {
175 AddonManager::on($hook, $cb, $priority);
176}