*/ private array $modules = []; /** @var list */ private array $listeners = []; private bool $booted = false; public function __construct(private OptionStore $options) { } public function register(Module $module): void { $id = $module->id(); if (!preg_match('/^[a-z][a-z0-9_]{1,31}$/', $id)) { throw new ModuleException('Invalid module id.'); } if (isset($this->modules[$id])) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ModuleException("Module already registered: $id"); } $this->modules[$id] = $module; } /** @param callable(Module, bool): void $listener */ public function onChange(callable $listener): void { $this->listeners[] = $listener; } /** @return array */ public function all(): array { return $this->modules; } public function get(string $id): Module { if (!isset($this->modules[$id])) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ModuleException("Unknown module: $id"); } return $this->modules[$id]; } public function isEnabled(string $id): bool { return isset($this->modules[$id]) && in_array($id, $this->enabledIds(), true) && $this->modules[$id]->prerequisitesMet(); } public function enable(string $id): void { $module = $this->get($id); if (!$module->prerequisitesMet()) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ModuleException($module->prerequisiteMessage()); } foreach ($module->dependencies() as $dep) { if (!$this->isEnabled($dep)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ModuleException("Enable \"$dep\" before \"$id\"."); } } $enabled = $this->enabledIds(); if (in_array($id, $enabled, true)) { return; } $enabled[] = $id; $this->options->set(self::OPTION, $enabled); $this->notify($module, true); } public function disable(string $id): void { $module = $this->get($id); $dependents = []; foreach ($this->modules as $other) { if ($this->isEnabled($other->id()) && in_array($id, $other->dependencies(), true)) { $dependents[] = $other->id(); } } if ($dependents !== []) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ModuleException(sprintf('Disable %s first.', implode(', ', $dependents))); } $enabled = $this->enabledIds(); if (!in_array($id, $enabled, true)) { return; } $this->options->set(self::OPTION, array_values(array_diff($enabled, [$id]))); $this->notify($module, false); } /** Boots enabled modules in dependency order. Call once per request. */ public function boot(): void { if ($this->booted) { return; } $this->booted = true; foreach ($this->bootOrder() as $id) { if ($this->isEnabled($id)) { $this->modules[$id]->boot(); } } } /** True if a job of this type may be created or run now. Core job types (no owner) are always allowed. */ public function acceptsJobType(string $type): bool { foreach ($this->modules as $module) { if (in_array($type, $module->jobTypes(), true)) { return $this->isEnabled($module->id()); } } return str_starts_with($type, 'core.'); } /** @return list */ public function bootOrder(): array { $order = []; $visiting = []; $visit = function (string $id) use (&$visit, &$order, &$visiting): void { if (in_array($id, $order, true)) { return; } if (isset($visiting[$id])) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ModuleException("Circular module dependency at $id."); } $visiting[$id] = true; foreach ($this->get($id)->dependencies() as $dep) { $visit($dep); } unset($visiting[$id]); $order[] = $id; }; foreach (array_keys($this->modules) as $id) { $visit($id); } return $order; } /** @return list */ private function enabledIds(): array { $value = $this->options->get(self::OPTION, []); return is_array($value) ? array_values(array_filter($value, 'is_string')) : []; } private function notify(Module $module, bool $enabled): void { foreach ($this->listeners as $listener) { $listener($module, $enabled); } } }