';
echo esc_html__('ThinkRank requires PHP 7.4 or higher. Please upgrade your PHP version.', 'thinkrank');
echo '
';
});
return;
}
if (version_compare(get_bloginfo('version'), '6.0', '<')) {
add_action('admin_notices', function () {
echo '';
echo esc_html__('ThinkRank requires WordPress 6.0 or higher. Please upgrade your WordPress installation.', 'thinkrank');
echo '
';
});
return;
}
// Autoloader
require_once THINKRANK_PLUGIN_DIR . 'includes/class-autoloader.php';
/**
* Bundled AI Building Blocks (Abilities API + MCP Adapter).
*
* Loaded through the Jetpack Autoloader so that if the same libraries are also
* shipped by another plugin — or land in WordPress core — the newest copy wins
* and loads once, with no fatal class collisions. This lets ThinkRank serve its
* MCP endpoint out of the box, without requiring the standalone MCP Adapter and
* Abilities API plugins. See docs/mcp-server.md for the update procedure.
*/
$thinkrank_mcp_runtime = THINKRANK_PLUGIN_DIR . 'dependencies/vendor/autoload_packages.php';
if (is_readable($thinkrank_mcp_runtime)) {
require_once $thinkrank_mcp_runtime;
}
unset($thinkrank_mcp_runtime);
/**
* Main ThinkRank Plugin Class
*
* Follows Single Responsibility Principle - only handles plugin initialization
*
* @since 1.0.0
*/
final class ThinkRank {
/**
* Plugin instance (Singleton Pattern)
*
* @var ThinkRank|null
*/
private static ?ThinkRank $instance = null;
/**
* Plugin components
*
* @var array
*/
private array $components = [];
/**
* Get plugin instance (Singleton Pattern)
*
* @return ThinkRank
*/
public static function get_instance(): ThinkRank {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Private constructor to prevent direct instantiation
*/
private function __construct() {
$this->init_autoloader();
$this->init_hooks();
}
/**
* Prevent cloning
*/
private function __clone() {
}
/**
* Prevent unserialization
*
* @throws \Exception On failure.
*/
public function __wakeup() {
throw new \Exception('Cannot unserialize singleton');
}
/**
* Initialize autoloader
*
* @return void
*/
private function init_autoloader(): void {
ThinkRank\Core\Autoloader::register();
}
/**
* Initialize WordPress hooks
*
* @return void
*/
private function init_hooks(): void {
register_activation_hook(__FILE__, [$this, 'activate']);
register_deactivation_hook(__FILE__, [$this, 'deactivate']);
add_action('plugins_loaded', [$this, 'init']);
add_filter('plugin_action_links_' . THINKRANK_PLUGIN_BASENAME, [$this, 'add_action_links']);
}
/**
* Add a "Dashboard" link to the plugin action links on the Plugins page.
*
* @param array $links Existing plugin action links.
* @return array
*/
public function add_action_links(array $links): array {
$dashboard_link = sprintf(
'%s',
esc_url(admin_url('admin.php?page=thinkrank')),
esc_html__('Dashboard', 'thinkrank')
);
array_unshift($links, $dashboard_link);
return $links;
}
/**
* Initialize plugin components
*
* @return void
*/
public function init(): void {
try {
$this->maybe_update_database();
$this->register_sitemap_cron_listeners();
$this->register_brand_visibility_cron();
$this->load_components();
$this->init_components();
$this->load_template_functions();
do_action('thinkrank_loaded');
} catch (\Exception $e) {
$this->handle_error($e);
}
}
/**
* Register the sitemap regeneration WP-Cron listeners.
*
* Runs on plugins_loaded (via init()), so the callbacks exist on every
* request — including WP-Cron, which never fires rest_api_init and therefore
* never builds the Sitemap REST endpoint (whose constructor would otherwise
* be the only place the scheduled regeneration hooks get a listener). The
* generator is built lazily inside the callback so this stays cheap on the
* vast majority of requests where no regeneration is due.
*
* @return void
*/
private function register_sitemap_cron_listeners(): void {
add_action('thinkrank_regenerate_sitemap', static function () {
(new ThinkRank\SEO\Sitemap_Generator())->auto_regenerate_sitemap();
});
add_action('thinkrank_regenerate_sitemap_settings', static function () {
(new ThinkRank\SEO\Sitemap_Generator())->regenerate_sitemap_from_settings();
});
add_action('shutdown', [$this, 'maybe_take_over_sitemap_regeneration'], 100);
}
/**
* Rebuild the sitemap in-request when WP-Cron has not delivered.
*
* WP-Cron only runs when a request arrives, so with DISABLE_WP_CRON set, a
* host blocking loopback requests, or very little traffic, the scheduled
* regeneration never fires and the sitemap silently stops updating (#629).
* Once the event is overdue by the generator's grace period, an admin, REST
* or WP-CLI request takes the work over so the site converges on its own.
*
* Front-end requests are deliberately excluded: this runs on `shutdown`,
* after the response, but generation on a large site is not free and a
* visitor should never pay for it. Admin traffic is what a site with broken
* cron reliably still has — the sitemap goes stale right after someone
* publishes something, and that someone is in wp-admin.
*
* @since 2.2.1
* @return void
*/
public function maybe_take_over_sitemap_regeneration(): void {
// is_admin() is true for admin-ajax.php and REST_REQUEST for public
// core routes, both of which anonymous front-end traffic reaches — so
// without the logged-in test a visitor could still pay for the rebuild
// this method documents as never being theirs to pay for. WP-CLI has no
// user, and is trusted by definition.
$eligible = (defined('WP_CLI') && WP_CLI)
|| (
is_user_logged_in()
&& (
is_admin()
|| (defined('REST_REQUEST') && REST_REQUEST)
)
);
if (!$eligible) {
return;
}
// Cheap autoloaded-option read, so the vast majority of requests stop
// here without building the generator.
if (!ThinkRank\SEO\Sitemap_Generator::has_overdue_regeneration()) {
return;
}
// `shutdown` runs after the output buffers are flushed, but flushed is
// not delivered: on FPM the connection stays open until the process
// ends, so without this the browser — or the REST call the sitemap
// screen just made — waits out the whole generation. Hand the response
// back first, then rebuild.
$this->close_request();
// Read-only construction: the auto-generation hooks are pointless this
// late in the request and would only add duplicate callbacks.
(new ThinkRank\SEO\Sitemap_Generator(false))->run_overdue_regeneration();
}
/**
* Deliver the response and let the request keep working without the client.
*
* A no-op on SAPIs that cannot do it, where the caller simply pays for the
* work as before.
*
* @since 2.2.1
* @return void
*/
private function close_request(): void {
if (defined('WP_CLI') && WP_CLI) {
return;
}
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
return;
}
if (function_exists('litespeed_finish_request')) {
litespeed_finish_request();
}
}
/**
* Register the Brand Visibility run-drain listener.
*
* Runs on plugins_loaded (via init()) rather than from the REST endpoint,
* because the ticks that drain a run are WP-Cron requests — they never
* reach rest_api_init, so registering the listener there would mean a run
* starts and then never progresses.
*
* @return void
*/
private function register_brand_visibility_cron(): void {
add_action(ThinkRank\AI\Brand_Visibility_Runner::TICK_HOOK, static function () {
(new ThinkRank\AI\Brand_Visibility_Runner())->tick();
});
// Safety net: a tick killed by a fatal or a worker timeout never
// reaches its own reschedule, which would strand the run. The
// watchdog re-arms the drain and unschedules itself when idle.
add_filter('cron_schedules', [ThinkRank\AI\Brand_Visibility_Runner::class, 'add_cron_interval']); // phpcs:ignore WordPress.WP.CronInterval.ChangeDetected
add_action(ThinkRank\AI\Brand_Visibility_Runner::WATCHDOG_HOOK, static function () {
(new ThinkRank\AI\Brand_Visibility_Runner())->watchdog();
});
}
/**
* Check if database schema needs updating and run migrations
*
* Standard WordPress pattern: compare stored db_version against current,
* run dbDelta if stale. This handles schema changes (new tables, new columns)
* without requiring plugin deactivation/reactivation.
*
* @since 1.10.0
* @return void
*/
private function maybe_update_database(): void {
$schema = new ThinkRank\Database\Database_Schema();
if ($schema->needs_update()) {
$schema->create_tables();
}
}
/**
* Load plugin components (Dependency Injection Container pattern)
*
* @return void
*/
private function load_components(): void {
$this->components = [
'database' => new ThinkRank\Core\Database(),
'settings' => new ThinkRank\Core\Settings(),
'role_manager' => new ThinkRank\Core\Role_Manager(),
'security_headers' => new ThinkRank\Core\Security_Headers(),
'asset_optimizer' => new ThinkRank\Core\Asset_Optimizer(),
'usage_tracker' => new ThinkRank\Core\Usage_Tracker_Manager(),
'api' => new ThinkRank\API\Manager(),
'admin' => new ThinkRank\Admin\Manager(),
'blocks' => new ThinkRank\Editor\Blocks_Manager(),
'elementor' => new ThinkRank\Editor\Elementor_Manager(),
'bricks_elements' => new ThinkRank\Editor\Bricks_Elements_Manager(),
'beaver_modules' => new ThinkRank\Editor\Beaver_Modules_Manager(),
'ai' => new ThinkRank\AI\Manager(),
'frontend_seo' => new ThinkRank\Frontend\SEO_Manager(),
'seo_notice' => new ThinkRank\Admin\SEO_Notice(),
'search_visibility_notice' => new ThinkRank\Admin\Search_Visibility_Notice(),
'performance_collector' => new ThinkRank\SEO\Performance_Data_Collector(),
'instant_indexing' => new ThinkRank\SEO\Instant_Indexing_Manager(),
'instant_indexing_reconciler' => new ThinkRank\SEO\Instant_Indexing_Reconciler(),
'author_archives' => new ThinkRank\SEO\Author_Archives_Manager(),
'seo_analyzer' => new ThinkRank\SEO\SEO_Analyzer(),
'email_report' => new ThinkRank\SEO\Email_Report_Manager(),
'google_oauth' => new ThinkRank\Integrations\Google_OAuth_Proxy(),
'multilingual' => new ThinkRank\Integrations\Multilingual_Manager(),
'ai_traffic' => new ThinkRank\SEO\Ai_Traffic_Tracker(),
'auto_ai' => new ThinkRank\SEO\Auto_Ai_Optimizer(),
'analytics' => new ThinkRank\SEO\Analytics_Manager(),
'abilities' => new ThinkRank\Abilities\Abilities_Registrar(),
'mcp' => new ThinkRank\Mcp\Mcp_Manager(),
];
}
/**
* Initialize all components
*
* @return void
*/
private function init_components(): void {
foreach ($this->components as $component) {
if (method_exists($component, 'init')) {
$component->init();
}
}
}
/**
* Load template functions for themes
*
* @return void
*/
private function load_template_functions(): void {
require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/template-functions.php';
}
/**
* Get component instance
*
* @param string $component Component name
* @return object|null
*/
public function get_component(string $component): ?object {
return $this->components[$component] ?? null;
}
/**
* Shared Analytics Manager instance (lazy)
*
* @var ThinkRank\SEO\Analytics_Manager|null
*/
private ?ThinkRank\SEO\Analytics_Manager $analytics_manager = null;
/**
* Get the shared Analytics Manager, with Google clients initialized.
*
* Consumers (including the Pro plugin, which probes for this accessor)
* should use this instead of constructing their own Analytics_Manager:
* each fresh construction re-reads/decrypts settings and re-initializes
* the Google API clients.
*
* @since 1.18.0
* @return ThinkRank\SEO\Analytics_Manager
*/
public function get_analytics_manager(): ThinkRank\SEO\Analytics_Manager {
if ($this->analytics_manager === null) {
// Reuse the registered component. Constructing a second instance
// here would work, but only the registered one has had init() run
// on it, so the token-refresh cron would be scheduled against a
// different object than the one callers actually use.
$component = $this->components['analytics'] ?? null;
$this->analytics_manager = $component instanceof ThinkRank\SEO\Analytics_Manager
? $component
: new ThinkRank\SEO\Analytics_Manager();
// init() defers initialize_clients() to the `init` hook; callers
// that arrive earlier still need working clients.
$this->analytics_manager->initialize_clients();
}
return $this->analytics_manager;
}
/**
* Plugin activation
*
* @return void
*/
public function activate(): void {
try {
$activator = new ThinkRank\Core\Activator();
$activator->activate();
// Flush rewrite rules
flush_rewrite_rules();
} catch (\Exception $e) {
$this->handle_error($e);
wp_die(
esc_html__('ThinkRank activation failed. Please check your server logs.', 'thinkrank'),
esc_html__('Plugin Activation Error', 'thinkrank'),
['back_link' => true]
);
}
}
/**
* Plugin deactivation
*
* @return void
*/
public function deactivate(): void {
try {
$deactivator = new ThinkRank\Core\Deactivator();
$deactivator->deactivate();
// Flush rewrite rules
flush_rewrite_rules();
} catch (\Exception $e) {
$this->handle_error($e);
}
}
/**
* Handle errors consistently
*
* Logs errors and surfaces them via _doing_it_wrong() when
* WP_DEBUG is enabled, helping developers diagnose issues.
*
* @param \Exception $e Exception to handle
* @return void
*/
private function handle_error(\Exception $e): void {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log('ThinkRank: ' . $e->getMessage());
}
if (defined('WP_DEBUG') && WP_DEBUG) {
_doing_it_wrong(
__METHOD__,
esc_html($e->getMessage()),
esc_html(THINKRANK_VERSION)
);
}
}
/**
* Get plugin version
*
* @return string
*/
public function get_version(): string {
return THINKRANK_VERSION;
}
/**
* Get plugin directory path
*
* @return string
*/
public function get_plugin_dir(): string {
return THINKRANK_PLUGIN_DIR;
}
/**
* Get plugin URL
*
* @return string
*/
public function get_plugin_url(): string {
return THINKRANK_PLUGIN_URL;
}
}
/**
* Initialize the plugin
*
* @return ThinkRank
*/
// phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed -- plugin bootstrap: the accessor belongs next to the class it returns.
function thinkrank(): ThinkRank {
return ThinkRank::get_instance();
}
// Start the plugin
thinkrank();