JezK
Edit File: Addon.php
<?php namespace GeminiLabs\SiteReviews\Addons; use GeminiLabs\SiteReviews\Arguments; use GeminiLabs\SiteReviews\Contracts\DefaultsContract; use GeminiLabs\SiteReviews\Contracts\PluginContract; use GeminiLabs\SiteReviews\Database; use GeminiLabs\SiteReviews\Helpers\Str; use GeminiLabs\SiteReviews\Plugin; /** * @property string $file * @property string $id * @property string $languages * @property bool $licensed * @property string $name * @property string $slug * @property string $testedTo * @property string $version */ abstract class Addon implements PluginContract { use Plugin { __construct as protected initPlugin; action as protected pluginAction; filter as protected pluginFilter; filterArrayUnique as protected pluginFilterArrayUnique; filterArrayUniqueInt as protected pluginFilterArrayUniqueInt; filterArrayUniqueString as protected pluginFilterArrayUniqueString; path as protected pluginPath; settingPath as protected pluginSettingPath; url as protected pluginUrl; } public const ID = ''; public const LICENSED = false; public const NAME = ''; public const POST_TYPE = ''; public const SLUG = ''; protected ?PluginContract $host = null; protected bool $isHost = false; protected bool $isModule = false; public function __construct(?PluginContract $host = null) { $this->host = $host; // settings storage routing follows the host, independent of file shape $file = wp_normalize_path((new \ReflectionClass($this))->getFileName()); $derived = dirname(dirname($file)).'/'.$this->id.'.php'; if (file_exists($derived) || !$host instanceof PluginContract) { $this->initPlugin(); // this is a standalone addon return; } $this->basename = $host->basename; $this->file = $host->file; $this->isModule = true; // this is a premium plugin module $this->languages = $host->languages; $this->testedTo = $host->testedTo; $this->updateUrl = $host->updateUrl; $this->uri = $host->uri; $reflection = new \ReflectionClass($this); $version = $reflection->hasConstant('VERSION') ? $reflection->getConstant('VERSION') : ''; $this->version = is_string($version) && '' !== $version ? $version // module version fetched at build time : $host->version; // fallback to the host version } /** * Hosted, every hook also fires under the addon's own id first, so a * snippet written against the standalone addon keeps working. Silent * unless something is listening — apply_filters_deprecated() checks. * * @param mixed ...$args */ public function action(string $hook, ...$args): void { if ($this->host instanceof PluginContract && $this->hasLegacyHooks()) { do_action_deprecated(static::ID."/{$hook}", $args, $this->host->version, $this->hookPrefix()."/{$hook}"); } $this->pluginAction($hook, ...$args); } /** * @param mixed ...$args * * @return mixed */ public function filter(string $hook, ...$args) { $args = $this->deprecatedHook($hook, $args); return $this->pluginFilter($hook, ...$args); } /** * @param mixed ...$args */ public function filterArrayUnique(string $hook, ...$args): array { $args = $this->deprecatedHook($hook, $args); return $this->pluginFilterArrayUnique($hook, ...$args); } /** * @param mixed ...$args */ public function filterArrayUniqueInt(string $hook, ...$args): array { $args = $this->deprecatedHook($hook, $args); return $this->pluginFilterArrayUniqueInt($hook, ...$args); } /** * @param mixed ...$args */ public function filterArrayUniqueString(string $hook, ...$args): array { $args = $this->deprecatedHook($hook, $args); return $this->pluginFilterArrayUniqueString($hook, ...$args); } /** * A hosted addon's hooks are namespaced by its HOST, with its own slug as * the next segment: site-reviews-premium/{slug}/{hook}. Flattening to the * host alone would collide — core fires {prefix}/activated once per addon, * and every addon binds its installer to it. */ public function hookPrefix(): string { return $this->host instanceof PluginContract ? "{$this->host->id}/".static::SLUG : static::ID; } public function hostedBy(): ?PluginContract { return $this->host; } /** * @return static */ public function init() { $reflection = new \ReflectionClass($this); $hooks = Str::replaceLast($reflection->getShortname(), 'Hooks', $reflection->getName()); if (class_exists($hooks)) { glsr()->singleton($hooks); glsr($hooks)->run(); glsr($hooks)->runDeferred(); glsr($hooks)->runIntegrations(); } else { glsr_log()->error('The '.static::NAME.' addon is missing a Hooks class'); } return $this; } /** * Whether this addon hosts other addon modules. */ public function isHost(): bool { return $this->isHost; } public function make(string $class, array $parameters = []) { $class = Str::camelCase($class); $class = ltrim(str_replace([__NAMESPACE__, 'GeminiLabs\SiteReviews'], '', $class), '\\'); $class = __NAMESPACE__.'\\'.$class; return glsr($class, $parameters); } /** * Marks this addon as a host of other addon modules. */ public function markAsHost(): void { $this->isHost = true; } /** * @param mixed $fallback * * @return mixed */ public function option(string $path = '', $fallback = '', string $cast = '') { $path = Str::removePrefix($path, 'settings.'); $path = Str::prefix($path, $this->settingsPath().'.'); return glsr_get_option($path, $fallback, $cast); } /** * @param string $defaultsClass a Defaults class used to restrict the options */ public function options(string $defaultsClass = ''): Arguments { $options = glsr_get_option($this->settingsPath(), [], 'array'); if (is_a($defaultsClass, DefaultsContract::class, true)) { $options = glsr($defaultsClass)->restrict($options); } return glsr()->args($options); } public function path(string $file = '', bool $realpath = true): string { if ($this->isModule) { $file = $this->hostedFile($file); } return $this->pluginPath($file, $realpath); } public function posts(int $perPage = -1, string $placeholder = ''): array { if (!$this->hasPostType()) { return []; } $results = glsr(Database::class)->posts([ 'post_status' => 'publish', 'post_type' => static::POST_TYPE, 'posts_per_page' => $perPage, ]); if (!empty($placeholder)) { return ['' => $placeholder] + $results; } return $results; } /** * Accepts the standalone-era spelling too, so `settings.addons.{slug}.x` * and a bare `x` both answer `settings.{hostSlug}.{slug}.x` when the addon * runs hosted — the same paths OptionManager::get()/set() remap. */ public function settingPath(string $path = ''): string { $path = Str::removePrefix(trim($path), 'settings.'); $path = Str::removePrefix($path, 'addons.'.static::SLUG); return $this->pluginSettingPath($path); } /** * The addon's mount point inside the composed settings view (without the leading "settings."). */ public function settingsPath(): string { if ($this->host instanceof PluginContract) { return $this->host->slug.'.'.static::SLUG; } return $this->isHost() ? static::SLUG : 'addons.'.static::SLUG; } /** * The WP option key that holds this addon's settings. */ public function storageKey(): string { if ($this->host instanceof PluginContract) { return Database\OptionManager::addonKey($this->host->id); } return Database\OptionManager::addonKey(static::ID); } /** * The path inside the storage option that holds this addon's values. */ public function storagePath(): string { if ($this->host instanceof PluginContract) { return 'settings.'.static::SLUG; } return 'settings'; } /** * Updates one of this addon's settings; the write is routed to the addon's * own storage option (or its host's option when the addon is hosted). */ public function updateOption(string $path, $value = ''): bool { $path = Str::removePrefix($path, 'settings.'); $path = Str::prefix($path, $this->settingsPath().'.'); return glsr(Database\OptionManager::class)->set(Str::prefix($path, 'settings.'), $value); } public function url(string $path = ''): string { if ($this->isModule) { $path = $this->hostedFile($path); } return $this->pluginUrl($path); } /** * Fires the addon's own {id}/{hook} before the hosted one and answers with * the filtered arguments. WordPress only warns when something is actually * listening, so a site with no legacy snippets sees nothing. */ protected function deprecatedHook(string $hook, array $args): array { if ($this->host instanceof PluginContract && !empty($args) && $this->hasLegacyHooks()) { $args[0] = apply_filters_deprecated(static::ID."/{$hook}", $args, $this->host->version, $this->hookPrefix()."/{$hook}"); } return $args; } /** * Whether standalone releases of this addon exist whose {id}/{hook} tags * third-party code may still listen on. An addon that only ever shipped * as a beta overrides this to false: there is no legacy surface to * bridge, so its hooks fire under the hosted prefix alone. */ protected function hasLegacyHooks(): bool { return true; } /** * Maps a standalone-shaped file path to its location inside the host's merged file tree. */ protected function hostedFile(string $file): string { $file = ltrim(trim($file), '/'); if ('' === $file) { return $file; } if (str_starts_with($file, 'plugin/')) { // Namespaces outside the premium prefix fall back to the last segment $namespace = (new \ReflectionClass($this))->getNamespaceName(); $prefix = 'GeminiLabs\\SiteReviews\\Premium\\'; $relative = str_starts_with($namespace, $prefix) ? substr($namespace, strlen($prefix)) : substr((string) strrchr($namespace, '\\'), 1); return sprintf('plugin/%s/%s', str_replace('\\', '/', $relative), substr($file, strlen('plugin/'))); } // The "settings" config name is slug-mapped, every other config path passes through as-is if ('config/settings.php' === $file) { return 'config/settings/'.static::SLUG.'.php'; } if (str_starts_with($file, 'config/')) { return $file; } if ('assets/blocks' === $file || str_starts_with($file, 'assets/blocks/')) { return rtrim('assets/blocks/'.static::SLUG.'/'.substr($file, strlen('assets/blocks/')), '/'); } // Shared asset trees are not slug-mapped foreach ([ 'assets/css/', 'assets/images/', 'assets/integrations/', 'assets/js/', 'assets/npm/', ] as $shared) { if (str_starts_with($file, $shared)) { return $file; } } // Per-addon (standalone) assets live under assets/standalone/{slug}/ if (str_starts_with($file, 'assets/')) { return 'assets/standalone/'.static::SLUG.'/'.substr($file, strlen('assets/')); } foreach (['templates/', 'views/'] as $prefix) { if (str_starts_with($file, $prefix)) { return $prefix.static::SLUG.'/'.substr($file, strlen($prefix)); } } return $file; } }