*/ private array $themes = []; /** * @param (Closure(): array)|null $context */ public function register(string $key, string $label, string $description, ?string $requires = null, ?Closure $context = null): void { $this->themes[$key] = new ThemeDefinition($key, $label, $description, $requires, $context); } /** * Themes selectable/renderable in the current edition. The only real * enforcement point for gated themes — a theme requiring a capability * the current edition lacks simply never appears here. * * "default" always sorts first, regardless of registration order — * which provider boots first (core vs. the premium package) must * never decide this, so it's enforced here rather than left to * insertion order. * * @return list */ public function available(CapabilityRegistry $capabilities): array { $themes = array_values(array_filter( $this->themes, fn (ThemeDefinition $theme): bool => $theme->requires === null || $capabilities->has(Capability::from($theme->requires)), )); usort($themes, fn (ThemeDefinition $a, ThemeDefinition $b): int => ($b->key === 'default') <=> ($a->key === 'default')); return $themes; } /** * The key to actually render: the stored value if it's a real, * currently-available theme, else the safe "default" fallback — a * downgraded tenant or a self-hosted install without a premium * package must never see a broken page. */ public function resolve(string $key, CapabilityRegistry $capabilities): string { foreach ($this->available($capabilities) as $theme) { if ($theme->key === $key) { return $key; } } return 'default'; } public function has(string $key): bool { return isset($this->themes[$key]); } public function get(string $key): ?ThemeDefinition { return $this->themes[$key] ?? null; } }