, flags:array, switches:array, built:int}|null */ private static ?array $data = null; /** * Guards against a rebuild that ends up asking for the manifest it is building. * * Everything `rebuild()` touches is supposed to answer from outside the * manifest, but this class is what the rest of the plugin reads its cheap * facts from, so a future flag that forgets the rule would recurse until the * stack ran out — several hundred frames away from the line at fault. * * @var bool */ private static bool $rebuilding = false; /** * Read the manifest, rebuilding it if it is missing or stale. * * @return array{v:int, modules:list, flags:array, switches:array, built:int} */ public static function get(): array { if ( null !== self::$data ) { return self::$data; } $stored = get_option( self::OPTION ); if ( ! is_array( $stored ) || ( $stored['v'] ?? 0 ) !== self::VERSION ) { $stored = self::rebuild(); } self::$data = self::normalise( $stored ); return self::$data; } /** * Slugs of the modules that are switched on. * * @return list */ public static function enabledModules(): array { return self::get()['modules']; } /** * Is a given module switched on? * * @param string $slug Module slug. */ public static function isEnabled( string $slug ): bool { return in_array( $slug, self::get()['modules'], true ); } /** * Read an environment flag captured at build time. * * Flags answer questions that are stable between manifest rebuilds and would * otherwise cost work at boot — "is WooCommerce active", "is Woodmart the * current theme". They are refreshed whenever the manifest is rebuilt, which * includes plugin and theme switches. * * @param string $flag Flag name. * @param bool $default Value when the flag was never captured. */ public static function flag( string $flag, bool $default = false ): bool { return self::get()['flags'][ $flag ] ?? $default; } /** * Read a module setting that the boot path depends on. * * These are the handful of settings that decide whether a hook is registered * at all, so they are needed before anything else runs. Going through * {@see Settings} for them would cost a `get_option` per module on every * request — four of them, on a site with no persistent object cache, for * questions whose answers change perhaps twice in a site's lifetime. * * Only values that differ from the catalogue default are stored, and only for * enabled modules. A site that has never touched these screens — which is most * sites — stores nothing at all here, which is what keeps the manifest inside * its size budget as more modules ship. * * Anything read after the boot path — a value used inside a handler — belongs * in {@see Settings}, not here. * * @param string $path Dotted `slug.key`, e.g. `otp_auth.theme_panel`. */ public static function bootSwitch( string $path ): bool { $stored = self::get()['switches']; if ( array_key_exists( $path, $stored ) ) { return $stored[ $path ]; } return self::catalogueDefault( $path ); } /** * The shipped default for a boot switch. * * Read from the catalogue rather than passed in at the call site, so the * default has exactly one home. `ModuleManager` is already loaded by the time * any of this is asked — it is what called `register()` — so this costs no * file read. * * @param string $path Dotted `slug.key`. */ private static function catalogueDefault( string $path ): bool { $pos = strpos( $path, '.' ); if ( false === $pos ) { return false; } $slug = substr( $path, 0, $pos ); $key = substr( $path, $pos + 1 ); return (bool) ( ModuleManager::bootSwitches()[ $slug ][ $key ]['default'] ?? false ); } /** * Recompute the manifest from current state and persist it. * * Called on activation, on module toggles, and whenever plugins or themes * change — never on a normal page view. * * @return array{v:int, modules:list, flags:array, switches:array, built:int} */ public static function rebuild(): array { if ( self::$rebuilding ) { // Re-entered from inside a rebuild. Hand back whatever is stored, which // is stale by definition but finite — the rebuild in progress will // overwrite it a moment later. $stored = get_option( self::OPTION ); return self::normalise( is_array( $stored ) ? $stored : [] ); } self::$rebuilding = true; try { return self::build(); } finally { self::$rebuilding = false; } } /** * The body of a rebuild. * * @return array{v:int, modules:list, flags:array, switches:array, built:int} */ private static function build(): array { // A rebuild reads the catalogue as it stands *now*. Rebuilds happen in the // requests where add-ons are activated and deactivated, which are exactly // the requests where the memoised catalogue can be missing rows or holding // rows that are gone. Re-applying the filter is a few array merges, and a // rebuild is rare by design. ModuleManager::flushCatalogue(); $enabled = get_option( 'rahkar_wp_enabled_modules' ); $enabled = is_array( $enabled ) ? array_values( array_filter( $enabled, 'is_string' ) ) : []; $flags = self::detectFlags(); $data = [ 'v' => self::VERSION, 'modules' => $enabled, 'flags' => $flags, // The pro answer is passed down rather than read back through // `Tier::unlocked()`. Reading it back would call `flag()`, which calls // `get()`, which — mid-rebuild, with nothing memoised yet — calls // `rebuild()` again. That recursion is not hypothetical; it is what this // argument exists to prevent. 'switches' => self::detectSwitches( $enabled, (bool) ( $flags[ Tier::FLAG ] ?? false ) ), 'built' => time(), ]; // Autoload is deliberate: this is the one option we *want* in `alloptions`. update_option( self::OPTION, $data, true ); self::$data = $data; return $data; } /** * Persist the enabled-module list and rebuild. * * @param list $slugs Module slugs to enable. */ public static function setEnabledModules( array $slugs ): void { update_option( 'rahkar_wp_enabled_modules', array_values( array_unique( $slugs ) ), false ); self::rebuild(); } /** * Drop the manifest entirely. Used on uninstall. */ public static function delete(): void { delete_option( self::OPTION ); delete_option( 'rahkar_wp_enabled_modules' ); self::$data = null; } /** * Serialised size of the current manifest, in bytes. */ public static function size(): int { return strlen( maybe_serialize( self::get() ) ); } /** * Capture environment facts that boot-time decisions depend on. * * @return array */ private static function detectFlags(): array { $template = ''; if ( function_exists( 'wp_get_theme' ) ) { $theme = wp_get_theme(); $template = $theme->get_template(); } return [ 'wc' => class_exists( 'WooCommerce' ) || self::isPluginActive( 'woocommerce/woocommerce.php' ), 'woodmart' => 'woodmart' === $template, 'elementor' => defined( 'ELEMENTOR_VERSION' ) || self::isPluginActive( 'elementor/elementor.php' ), // Not a licence check. The pro add-on decides what its licence means and // records the verdict through `Tier::unlock()`; this copies that verdict // into the manifest so reading it during module registration costs // nothing. A site with no add-on has no option and lands on false. 'pro' => (bool) get_option( Tier::OPTION, false ), ]; } /** * Capture the boot switches of every enabled module. * * Reads each module's settings row directly rather than through * {@see Settings}, because a rebuild can be triggered from a context where no * module has registered its defaults — the settings screen, an upgrade, a * plugin activation. The catalogue carries the default alongside the key for * exactly that reason. * * @param list $enabled Slugs of the modules that are switched on. * @param bool $proUnlocked Whether pro features may run on this site. * * @return array */ private static function detectSwitches( array $enabled, bool $proUnlocked ): array { $captured = []; foreach ( ModuleManager::bootSwitches() as $slug => $switches ) { if ( ! in_array( $slug, $enabled, true ) ) { continue; } $stored = get_option( Settings::PREFIX . $slug ); $stored = is_array( $stored ) ? $stored : []; foreach ( $switches as $key => $switch ) { // A pro switch that this site may not have is captured as shipped, // whatever is stored. That is what makes a lapsed licence actually // take effect: the value the admin saved while licensed stays in the // options table, ready for when they renew, but the boot path stops // seeing it. if ( Tier::PRO === $switch['tier'] && ! $proUnlocked ) { continue; } // Absent means "as shipped". Writing the default down would cost // bytes in the one option the whole site loads on every request, to // say what the catalogue already says. if ( ! array_key_exists( $key, $stored ) ) { continue; } if ( (bool) $stored[ $key ] === $switch['default'] ) { continue; } $captured[ $slug . '.' . $key ] = (bool) $stored[ $key ]; } } return $captured; } /** * Active-plugin check that does not require `wp-admin/includes/plugin.php`. * * @param string $basename Plugin basename. */ private static function isPluginActive( string $basename ): bool { $active = get_option( 'active_plugins', [] ); if ( is_array( $active ) && in_array( $basename, $active, true ) ) { return true; } if ( ! is_multisite() ) { return false; } $network = get_site_option( 'active_sitewide_plugins', [] ); return is_array( $network ) && isset( $network[ $basename ] ); } /** * Guarantee the shape of a manifest read from the database. * * @param array $raw Stored manifest. * * @return array{v:int, modules:list, flags:array, switches:array, built:int} */ private static function normalise( array $raw ): array { $modules = $raw['modules'] ?? []; $flags = $raw['flags'] ?? []; $switches = $raw['switches'] ?? []; return [ 'v' => self::VERSION, 'modules' => is_array( $modules ) ? array_values( array_filter( $modules, 'is_string' ) ) : [], 'flags' => is_array( $flags ) ? array_map( 'boolval', $flags ) : [], 'switches' => is_array( $switches ) ? array_map( 'boolval', $switches ) : [], 'built' => (int) ( $raw['built'] ?? 0 ), ]; } }