1, 'context' => 1, 'tools' => 2, 'policy' => 1, 'session-log' => 2, 'memory' => 1, 'events' => 1, 'agent' => 1, 'registrar' => 1, ]; public const ON = 'on'; public const OFF = 'off'; public const FAILED = 'failed'; public const UNSUPPORTED = 'unsupported'; /** @var array}> */ private array $modules = []; /** * @param list $builtIn */ public function __construct(protected Settings $settings, protected EventSink $events, array $builtIn = []) { foreach ($builtIn as $module) { $this->add($module, true); } } /** * Add a module. Returns an error message, or '' when it was added. * * @since 0.1.0 */ public function add(Module $module, bool $builtIn = false): string { $id = $module->id(); if (!preg_match($builtIn ? '/^[a-z0-9][a-z0-9-]{1,40}$/' : '#^[a-z0-9][a-z0-9-]{0,40}/[a-z0-9][a-z0-9-]{0,40}$#', $id)) { return sprintf('module id "%s" must be %s', $id, $builtIn ? 'lowercase letters, digits and dashes' : 'vendor/name'); } if (isset($this->modules[$id])) { return sprintf('module "%s" is already registered', $id); } $this->modules[$id] = ['module' => $module, 'built_in' => $builtIn, 'state' => '', 'error' => '', 'info' => []]; return ''; } /** * Register every module of one origin (built-in or added) that has not run yet. * * @since 0.1.0 */ public function boot(Extensions $extensions, bool $builtIn): void { foreach ($this->modules as $id => $entry) { if ($entry['built_in'] !== $builtIn || $entry['state'] !== '') { continue; } try { $info = self::info($entry['module']); } catch (\Throwable $e) { $this->fail($id, [], 'describe() failed'); continue; } $this->modules[$id]['info'] = $info; $missing = []; foreach ($info['requires'] as $contract => $version) { if ((self::CONTRACTS[$contract] ?? null) !== $version) { $missing[] = "{$contract} {$version}"; } } if ($missing !== []) { $this->modules[$id]['state'] = self::UNSUPPORTED; $this->modules[$id]['error'] = 'needs ' . implode(', ', $missing); continue; } if (!$this->switchedOn($id, $info)) { $this->modules[$id]['state'] = self::OFF; continue; } $extensions->begin('module:' . $id); try { $entry['module']->register($extensions); $extensions->commit(); $this->modules[$id]['state'] = self::ON; } catch (\Throwable $e) { $extensions->discard(); $this->fail($id, $info, $e->getMessage()); } } } /** * Whether module `$id` is registered and on for this request. * * @since 0.1.0 */ public function enabled(string $id): bool { return ($this->modules[$id]['state'] ?? '') === self::ON; } /** * The built-in modules that are on. * * @since 0.1.0 * @return list */ public function active(): array { return array_values(array_map(static fn (array $entry): Module => $entry['module'], array_filter($this->modules, static fn (array $entry): bool => $entry['state'] === self::ON))); } /** * Whether module `$id` shipped with MazeGraph, as opposed to being added by a plugin or theme. * * @since 1.0.0 */ public function isBuiltIn(string $id): bool { return $this->modules[$id]['built_in'] ?? false; } /** * Every module with its state, for the Extensions page and the app's navigation. * * @since 0.1.0 * @return list|null}> */ public function describe(): array { $rows = []; foreach ($this->modules as $id => $entry) { $info = $entry['info']; $rows[] = [ 'id' => $id, // A module describes itself when it registers, at plugin load, in the site's language. MazeGraph's own // text is translated again here, when it is shown, in the viewer's language. 'name' => BuiltInText::tool((string) ($info['name'] ?? $id), $entry['built_in']), 'description' => BuiltInText::tool((string) ($info['description'] ?? ''), $entry['built_in']), 'version' => (string) ($info['version'] ?? ''), 'built_in' => $entry['built_in'], 'optional' => (bool) ($info['optional'] ?? true), 'state' => $entry['state'], 'error' => $entry['error'], 'nav' => $entry['built_in'] && is_array($info['nav'] ?? null) ? self::translatedNav($info['nav']) : null, ]; } return $rows; } /** * Ids of the modules that failed in this request. * * @since 0.1.0 * @return list */ public function failed(): array { return array_keys(array_filter($this->modules, static fn (array $entry): bool => $entry['state'] === self::FAILED)); } /** * Mark an active module failed when its Ability pack cannot be registered atomically. */ public function failRegistration(string $id, string $message): void { if (!isset($this->modules[$id])) { return; } $this->fail($id, $this->modules[$id]['info'], $message); } /** * @param array $info */ private function switchedOn(string $id, array $info): bool { if (!$info['optional']) { return true; } $choices = (array) $this->settings->get('modules'); return (bool) ($choices[$id] ?? $info['default']); } /** * @param array $info */ private function fail(string $id, array $info, string $message): void { if (!isset($this->modules[$id])) { return; } $entry = $this->modules[$id]; $entry['state'] = self::FAILED; $entry['error'] = mb_substr(wp_strip_all_tags($message), 0, 300); if ($info !== []) { $entry['info'] = $info; } $this->modules[$id] = $entry; $this->events->record(new Event('module.failed', ['module' => $id, 'code' => 'register_failed'])); } /** * A module's description with defaults. * * @return array{name: string, description: string, version: string, requires: array, optional: bool, default: bool, nav: array|null} */ private static function info(Module $module): array { $info = $module->describe(); $requires = []; foreach ((array) ($info['requires'] ?? []) as $contract => $version) { if (is_string($contract)) { $requires[$contract] = (int) $version; } } return [ 'name' => is_string($info['name'] ?? null) ? mb_substr($info['name'], 0, 80) : $module->id(), 'description' => is_string($info['description'] ?? null) ? mb_substr($info['description'], 0, 300) : '', 'version' => is_string($info['version'] ?? null) ? $info['version'] : '', 'requires' => $requires, 'optional' => (bool) ($info['optional'] ?? true), 'default' => (bool) ($info['default'] ?? true), 'nav' => is_array($info['nav'] ?? null) ? $info['nav'] : null, ]; } /** * @param array $nav * @return array */ private static function translatedNav(array $nav): array { if (is_string($nav['label'] ?? null)) { $nav['label'] = BuiltInText::tool($nav['label'], true); } return $nav; } }