default` — free, which is most of them * — or as `key => [ 'default' => …, 'tier' => Tier::PRO ]`. The second form is * for add-on rows; a pro switch on a row shipped here would mean shipping the * code it unlocks, which is the thing the split exists to avoid. * * `icon` names a glyph in the design system's sprite, used on the module card. * It sits here with the rest of the metadata rather than on the module class * for the usual reason: the modules screen reads it for every module it lists, * including ones it is about to show as locked and never construct. * * Adding a module means adding a row here; the class itself declares only * behaviour. * * @var array, contexts: int, tier: string, icon: string, switches?: array}> */ private const CATALOGUE = [ 'iran_locations' => [ 'class' => IranLocations\IranLocationsModule::class, 'icon' => 'truck', 'tier' => Tier::FREE, 'contexts' => Context::FRONTEND | Context::AJAX | Context::REST | Context::ADMIN, 'switches' => [ 'persian_state_labels' => true, 'replace_city_field' => true, ], ], 'notifications' => [ 'class' => Notifications\NotificationsModule::class, 'icon' => 'plug', 'tier' => Tier::FREE, 'contexts' => Context::ANY_REQUEST | Context::ANY_BACKGROUND | Context::ADMIN, ], 'order_print' => [ 'class' => OrderPrint\OrderPrintModule::class, 'icon' => 'receipt', 'tier' => Tier::FREE, // Admin-only, and that is the whole cost story: on a product page, a // cart, or a checkout the catalogue skips this before opening a file. 'contexts' => Context::ADMIN, ], 'otp_auth' => [ 'class' => OtpAuth\OtpAuthModule::class, 'icon' => 'key', 'tier' => Tier::FREE, // ADMIN for the skins and monitoring screens' menu entries; the // background contexts for its two queue handlers — housekeeping and // administrator alerts — and nothing else. 'contexts' => Context::FRONTEND | Context::AJAX | Context::REST | Context::LOGIN | Context::ADMIN | Context::ANY_BACKGROUND, 'switches' => [ 'theme_panel' => true, // Read inside the theme-panel branch of `register()`, so the // checkout page pays nothing for it on a store that leaves it off. 'checkout_gate' => false, // Integers and booleans the page routing needs before deciding // whether to hook `template_redirect` at all. A page id is captured // as "is one chosen", which is all the boot path asks. 'login_page' => false, 'replace_wc_login' => false, 'replace_wp_login' => false, ], ], 'jalali' => [ 'class' => Jalali\JalaliModule::class, 'icon' => 'chart', 'tier' => Tier::FREE, // Dates are rendered nearly everywhere, so the mask is wide by // necessity. The saving still holds: the module registers two filters // and nothing else until one of them fires, and the REST bit is here // only so the module can decline the request from the inside — the // API must keep serving Gregorian. 'contexts' => Context::ANY_UI | Context::AJAX | Context::REST | Context::ANY_BACKGROUND, 'switches' => [ 'convert_admin' => true, 'convert_frontend' => true, 'datepicker' => true, 'woo_admin_dates' => true, 'woo_report_range' => true, ], ], 'order_tracking' => [ 'class' => OrderTracking\OrderTrackingModule::class, 'icon' => 'truck', 'tier' => Tier::FREE, // Public REST on the front, plus the order-edit fields in admin. 'contexts' => Context::ANY_REQUEST | Context::ADMIN, ], 'testimonials' => [ 'class' => Testimonials\TestimonialsModule::class, 'icon' => 'star', 'tier' => Tier::FREE, // The post type and its meta have to exist wherever something reads // them — a theme's front page, the block editor over REST — and the // submit endpoint is a REST write. Only the moderation box is admin, // and that half is gated inside register(). 'contexts' => Context::ANY_REQUEST | Context::ANY_BACKGROUND | Context::ADMIN, ], 'courier_status' => [ 'class' => CourierStatus\CourierStatusModule::class, 'icon' => 'truck', 'tier' => Tier::FREE, // An order status is not a screen: it must be registered wherever an // order can change state, which is every request context plus cron and // CLI. The mask is wide because the feature genuinely is — the module // still adds four callbacks and constructs nothing until one fires. 'contexts' => Context::ANY_REQUEST | Context::ANY_BACKGROUND | Context::ADMIN, ], 'hub_connector' => [ 'class' => HubConnector\HubConnectorModule::class, 'icon' => 'chart', 'tier' => Tier::FREE, // Orders change at checkout, in the admin, over REST and from cron, and // the flush job drains in background contexts, so the mask is wide. // Until a site is connected the module hooks only its two job handlers. 'contexts' => Context::ANY_REQUEST | Context::ANY_BACKGROUND | Context::ADMIN, 'switches' => [ 'connected' => false, ], ], 'tracked_links' => [ 'class' => TrackedLinks\TrackedLinksModule::class, 'icon' => 'external', 'tier' => Tier::FREE, // Links are answered on front-end requests, orders are stamped at // checkout (front end, AJAX, REST), and links are made in the background // jobs that send messages, which need the event types registered. 'contexts' => Context::ANY_REQUEST | Context::ANY_BACKGROUND | Context::ADMIN, ], 'customer_stats' => [ 'class' => CustomerStats\CustomerStatsModule::class, 'icon' => 'chart', 'tier' => Tier::FREE, // Orders are saved at checkout, in the admin, over REST and from cron, // and the first fill runs in the queue. 'contexts' => Context::ANY_REQUEST | Context::ANY_BACKGROUND | Context::ADMIN, ], 'matomo' => [ 'class' => Matomo\MatomoModule::class, 'icon' => 'chart', 'tier' => Tier::FREE, // The tracker prints on front-end pages, the cart rides in AJAX // fragments, checkouts run over REST, and server-side orders go from // the queue. 'contexts' => Context::ANY_REQUEST | Context::ANY_BACKGROUND | Context::ADMIN, ], ]; /** * Instantiated modules, keyed by slug. * * @var array */ private array $instances = []; /** * Whether every catalogue entry has been constructed, by {@see self::all()}. * * @var bool */ private bool $catalogueLoaded = false; /** * The catalogue after filtering and normalisation. * * @var array, contexts: int, tier: string, switches: array}>|null */ private static ?array $resolved = null; /** * The module catalogue, with anything an add-on contributed merged in. * * The filter is what makes a pro add-on possible without shipping its code to * free users: it hands back extra rows in the same shape, and everything * downstream — the gates, the modules screen, the settings screen — treats * them as though they had always been here. * * Add-ons must attach the filter while their plugin file is being included, * not on `plugins_loaded`. Registration runs at `plugins_loaded` priority 5, * and plugin files are all included before that hook fires at all, so * attaching at include time works regardless of which plugin loads first. * * Entries must keep holding the class *name*. Returning an instance, or * anything that has to be autoloaded to be inspected, defeats the first two * cost gates for every module in the catalogue, not just the added one. * * @return array, contexts: int, tier: string, switches: array}> */ public static function catalogue(): array { if ( null !== self::$resolved ) { return self::$resolved; } /** * Filters the module catalogue. * * @param array> $catalogue Module rows, keyed by slug. */ $filtered = apply_filters( 'rahkar_wp_modules', self::CATALOGUE ); self::$resolved = self::normalise( is_array( $filtered ) ? $filtered : self::CATALOGUE ); return self::$resolved; } /** * Give every row the same shape, and drop rows that cannot work. * * Defensive because the input is no longer only ours. A malformed third-party * row should disappear quietly rather than fatal a site on a hook that fires * before anything can report it. * * @param array $rows Raw catalogue. * * @return array, contexts: int, tier: string, switches: array}> */ private static function normalise( array $rows ): array { $clean = []; foreach ( $rows as $slug => $row ) { if ( ! is_string( $slug ) || ! is_array( $row ) ) { continue; } $class = $row['class'] ?? null; $contexts = $row['contexts'] ?? null; if ( ! is_string( $class ) || '' === $class || ! is_int( $contexts ) ) { continue; } $switches = []; foreach ( (array) ( $row['switches'] ?? [] ) as $key => $switch ) { $switches[ (string) $key ] = is_array( $switch ) ? [ 'default' => (bool) ( $switch['default'] ?? false ), 'tier' => Tier::normalise( $switch['tier'] ?? Tier::FREE ), ] : [ 'default' => (bool) $switch, 'tier' => Tier::FREE, ]; } $clean[ $slug ] = [ 'class' => $class, 'contexts' => $contexts, 'tier' => Tier::normalise( $row['tier'] ?? Tier::FREE ), 'icon' => is_string( $row['icon'] ?? null ) ? $row['icon'] : 'settings', 'switches' => $switches, ]; } return $clean; } /** * Forget the filtered catalogue, so the next read applies the filter again. * * The memo is right for a normal request, where the set of filters on * `rahkar_wp_modules` is fixed once plugins have loaded. It is wrong in the * requests where that set changes: activating an add-on includes its file * *after* the catalogue was resolved without it, and deactivating one * detaches its filter while the old rows are still memoised. * {@see Manifest::rebuild()} calls this for that reason — a rebuild that * read a stale catalogue would capture the wrong boot switches and keep them * until the next rebuild. * * Also used by the test suite. */ public static function flushCatalogue(): void { self::$resolved = null; } /** * Register every module that clears both gates. */ public function registerEnabled(): void { $enabled = Manifest::enabledModules(); $context = Context::current(); $activated = []; foreach ( $enabled as $slug ) { $entry = self::catalogue()[ $slug ] ?? null; // Gate one — unknown or switched-off module. Nothing is loaded. if ( null === $entry ) { continue; } // Gate two — wrong kind of request. Still nothing is loaded. if ( 0 === ( $entry['contexts'] & $context ) ) { continue; } // Gate three — a pro module on a site without the licence. Checked here // rather than trusted to the modules screen, so writing the slug // straight into `rahkar_wp_enabled_modules` achieves nothing. Costs one // array lookup against the manifest, which is already in memory. if ( ! Tier::allows( $entry['tier'] ) ) { continue; } // Only now does the autoloader open a file. $module = $this->instantiate( $slug, $entry['class'] ); if ( [] !== $module->unmetRequirements() ) { continue; } // A closure, not the array. Calling `defaults()` here would run the // module's default list on every request — and those lists name class // constants, so running one opens the classes that declare them. The // module object is already in hand; only the call is deferred. Settings::registerDefaults( $slug, static fn(): array => $module->defaults() ); $module->register(); $activated[] = $slug; } // Guarded by the constant rather than by Profiler::enabled(), because // calling that method would autoload the profiler on every production // request just to be told profiling is off. if ( defined( 'RAHKAR_WP_PROFILE' ) && RAHKAR_WP_PROFILE ) { Profiler::note( 'modules', $activated ); } } /** * Every module in the catalogue, enabled or not. * * This constructs all of them, so it belongs only to the admin modules screen * — the one place that legitimately needs the full list. * * @return array */ public function all(): array { if ( $this->catalogueLoaded ) { return $this->instances; } foreach ( self::catalogue() as $slug => $entry ) { if ( class_exists( $entry['class'] ) ) { $this->instantiate( $slug, $entry['class'] ); } } $this->catalogueLoaded = true; return $this->instances; } /** * Look up a module by slug, constructing it if needed. * * @param string $slug Module slug. */ public function get( string $slug ): ?Module { if ( isset( $this->instances[ $slug ] ) ) { return $this->instances[ $slug ]; } $entry = self::catalogue()[ $slug ] ?? null; if ( null === $entry || ! class_exists( $entry['class'] ) ) { return null; } return $this->instantiate( $slug, $entry['class'] ); } /** * Contexts a module is allowed to register in. * * @param string $slug Module slug. */ public function contextsFor( string $slug ): int { return self::catalogue()[ $slug ]['contexts'] ?? 0; } /** * The sprite glyph shown on a module's card. * * @param string $slug Module slug. */ public function iconFor( string $slug ): string { return self::catalogue()[ $slug ]['icon'] ?? 'settings'; } /** * Which tier a module belongs to. * * @param string $slug Module slug. */ public function tierFor( string $slug ): string { return self::catalogue()[ $slug ]['tier'] ?? Tier::FREE; } /** * Is this module both pro and out of reach on this site? * * @param string $slug Module slug. */ public function isLocked( string $slug ): bool { return ! Tier::allows( $this->tierFor( $slug ) ); } /** * The boot switches declared for every module, keyed by slug. * * Static, and reading it constructs nothing — {@see Manifest} calls this while * rebuilding, and loading eight module classes to ask them for a list of * option keys would defeat the point of holding the list out here. * * @return array> */ public static function bootSwitches(): array { $switches = []; foreach ( self::catalogue() as $slug => $entry ) { if ( [] !== $entry['switches'] ) { $switches[ $slug ] = $entry['switches']; } } return $switches; } /** * Slugs of every module the plugin ships. * * @return list */ public function knownSlugs(): array { return array_keys( self::catalogue() ); } /** * Turn a module on. * * @param string $slug Module slug. */ public function enable( string $slug ): void { if ( ! isset( self::catalogue()[ $slug ] ) || $this->isLocked( $slug ) ) { return; } $enabled = Manifest::enabledModules(); if ( ! in_array( $slug, $enabled, true ) ) { $enabled[] = $slug; Manifest::setEnabledModules( $enabled ); } } /** * Turn a module off. * * Settings are kept, so re-enabling restores the previous configuration. * * @param string $slug Module slug. */ public function disable( string $slug ): void { Manifest::setEnabledModules( array_values( array_diff( Manifest::enabledModules(), [ $slug ] ) ) ); } /** * Construct and cache a module. * * @param string $slug Module slug. * @param class-string $class Module class. */ private function instantiate( string $slug, string $class ): Module { $module = new $class(); $this->instances[ $slug ] = $module; return $module; } }