PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 All 52 releases
thinkrank / thinkrank.php

thinkrank.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.11.0, at thinkrank.php

586 lines 20.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Plugin Name: ThinkRank
5 * Plugin URI: https://thinkrank.ai/
6 * Description: AI-native SEO plugin for WordPress. Automate and enhance your SEO with cutting-edge AI while maintaining editorial control.
7 * Version: 2.11.0
8 * Author: WPDeveloper
9 * Author URI: https://wpdeveloper.com/
10 * License: GPL v2 or later
11 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
12 * Text Domain: thinkrank
13 * Domain Path: /languages
14 * Requires at least: 6.0
15 * Requires PHP: 7.4
16 *
17 * @package ThinkRank
18 * @version 2.11.0
19 * @since 1.0.0
20 */
21
22 declare(strict_types=1);
23
24 // Prevent direct access
25 if (!defined('ABSPATH')) {
26 exit;
27 }
28
29 // Define plugin constants
30 define('THINKRANK_VERSION', '2.11.0');
31 define('THINKRANK_PLUGIN_FILE', __FILE__);
32 define('THINKRANK_PLUGIN_DIR', plugin_dir_path(__FILE__));
33 define('THINKRANK_PLUGIN_URL', plugin_dir_url(__FILE__));
34 define('THINKRANK_PLUGIN_BASENAME', plugin_basename(__FILE__));
35
36 // Minimum requirements check
37 if (version_compare(PHP_VERSION, '7.4', '<')) {
38 add_action('admin_notices', function () {
39 echo '<div class="notice notice-error"><p>';
40 echo esc_html__('ThinkRank requires PHP 7.4 or higher. Please upgrade your PHP version.', 'thinkrank');
41 echo '</p></div>';
42 });
43 return;
44 }
45
46 if (version_compare(get_bloginfo('version'), '6.0', '<')) {
47 add_action('admin_notices', function () {
48 echo '<div class="notice notice-error"><p>';
49 echo esc_html__('ThinkRank requires WordPress 6.0 or higher. Please upgrade your WordPress installation.', 'thinkrank');
50 echo '</p></div>';
51 });
52 return;
53 }
54
55 // Autoloader
56 require_once THINKRANK_PLUGIN_DIR . 'includes/class-autoloader.php';
57
58 /**
59 * Bundled AI Building Blocks (Abilities API + MCP Adapter).
60 *
61 * Loaded through the Jetpack Autoloader so that if the same libraries are also
62 * shipped by another plugin — or land in WordPress core — the newest copy wins
63 * and loads once, with no fatal class collisions. This lets ThinkRank serve its
64 * MCP endpoint out of the box, without requiring the standalone MCP Adapter and
65 * Abilities API plugins. See docs/mcp-server.md for the update procedure.
66 */
67 $thinkrank_mcp_runtime = THINKRANK_PLUGIN_DIR . 'dependencies/vendor/autoload_packages.php';
68 if (is_readable($thinkrank_mcp_runtime)) {
69 require_once $thinkrank_mcp_runtime;
70 }
71 unset($thinkrank_mcp_runtime);
72
73 /**
74 * Main ThinkRank Plugin Class
75 *
76 * Follows Single Responsibility Principle - only handles plugin initialization
77 *
78 * @since 1.0.0
79 */
80 final class ThinkRank {
81
82 /**
83 * Plugin instance (Singleton Pattern)
84 *
85 * @var ThinkRank|null
86 */
87 private static ?ThinkRank $instance = null;
88
89 /**
90 * Plugin components
91 *
92 * @var array
93 */
94 private array $components = [];
95
96 /**
97 * Get plugin instance (Singleton Pattern)
98 *
99 * @return ThinkRank
100 */
101 public static function get_instance(): ThinkRank {
102 if (null === self::$instance) {
103 self::$instance = new self();
104 }
105 return self::$instance;
106 }
107
108 /**
109 * Private constructor to prevent direct instantiation
110 */
111 private function __construct() {
112 $this->init_autoloader();
113 $this->init_hooks();
114 }
115
116 /**
117 * Prevent cloning
118 */
119 private function __clone() {
120 }
121
122 /**
123 * Prevent unserialization
124 *
125 * @throws \Exception On failure.
126 */
127 public function __wakeup() {
128 throw new \Exception('Cannot unserialize singleton');
129 }
130
131 /**
132 * Initialize autoloader
133 *
134 * @return void
135 */
136 private function init_autoloader(): void {
137 ThinkRank\Core\Autoloader::register();
138 }
139
140 /**
141 * Initialize WordPress hooks
142 *
143 * @return void
144 */
145 private function init_hooks(): void {
146 register_activation_hook(__FILE__, [$this, 'activate']);
147 register_deactivation_hook(__FILE__, [$this, 'deactivate']);
148
149 add_action('plugins_loaded', [$this, 'init']);
150 add_filter('plugin_action_links_' . THINKRANK_PLUGIN_BASENAME, [$this, 'add_action_links']);
151 }
152
153 /**
154 * Add a "Dashboard" link to the plugin action links on the Plugins page.
155 *
156 * @param array $links Existing plugin action links.
157 * @return array
158 */
159 public function add_action_links(array $links): array {
160 $dashboard_link = sprintf(
161 '<a href="%s">%s</a>',
162 esc_url(admin_url('admin.php?page=thinkrank')),
163 esc_html__('Dashboard', 'thinkrank')
164 );
165
166 array_unshift($links, $dashboard_link);
167
168 return $links;
169 }
170
171 /**
172 * Initialize plugin components
173 *
174 * @return void
175 */
176 public function init(): void {
177 try {
178 $this->maybe_update_database();
179 $this->register_sitemap_cron_listeners();
180 $this->clear_retired_brand_visibility_cron();
181 $this->load_components();
182 $this->init_components();
183 $this->load_template_functions();
184
185 do_action('thinkrank_loaded');
186 } catch (\Exception $e) {
187 $this->handle_error($e);
188 }
189 }
190
191 /**
192 * Register the sitemap regeneration WP-Cron listeners, and the
193 * content-change listeners that queue them.
194 *
195 * Runs on plugins_loaded (via init()), so the callbacks exist on every
196 * request — including WP-Cron, which never fires rest_api_init and therefore
197 * never builds the Sitemap REST endpoint (whose constructor would otherwise
198 * be the only place the scheduled regeneration hooks get a listener). The
199 * generator is built lazily inside the callback so this stays cheap on the
200 * vast majority of requests where no regeneration is due.
201 *
202 * The content-change listeners need the same treatment for the same
203 * reason: WP-Cron publishes scheduled posts, and Quick Edit, the classic
204 * editor and WP-CLI change content without REST either (#824).
205 *
206 * @return void
207 */
208 private function register_sitemap_cron_listeners(): void {
209 ThinkRank\SEO\Sitemap_Generator::register_content_listeners();
210
211 add_action('thinkrank_regenerate_sitemap', static function () {
212 (new ThinkRank\SEO\Sitemap_Generator())->auto_regenerate_sitemap();
213 });
214 add_action('thinkrank_regenerate_sitemap_settings', static function () {
215 (new ThinkRank\SEO\Sitemap_Generator())->regenerate_sitemap_from_settings();
216 });
217
218 add_action('shutdown', [$this, 'maybe_take_over_sitemap_regeneration'], 100);
219 }
220
221 /**
222 * Rebuild the sitemap in-request when WP-Cron has not delivered.
223 *
224 * WP-Cron only runs when a request arrives, so with DISABLE_WP_CRON set, a
225 * host blocking loopback requests, or very little traffic, the scheduled
226 * regeneration never fires and the sitemap silently stops updating (#629).
227 * Once the event is overdue by the generator's grace period, an admin, REST
228 * or WP-CLI request takes the work over so the site converges on its own.
229 *
230 * Front-end requests are deliberately excluded: this runs on `shutdown`,
231 * after the response, but generation on a large site is not free and a
232 * visitor should never pay for it. Admin traffic is what a site with broken
233 * cron reliably still has — the sitemap goes stale right after someone
234 * publishes something, and that someone is in wp-admin.
235 *
236 * @since 2.2.1
237 * @return void
238 */
239 public function maybe_take_over_sitemap_regeneration(): void {
240 // is_admin() is true for admin-ajax.php and REST_REQUEST for public
241 // core routes, both of which anonymous front-end traffic reaches — so
242 // without the logged-in test a visitor could still pay for the rebuild
243 // this method documents as never being theirs to pay for. WP-CLI has no
244 // user, and is trusted by definition. A cron run is the process this
245 // work belongs to in the first place: a scheduled event that was
246 // consumed while another request held the generation lock is never
247 // re-queued, so without this a site whose admins see only failures
248 // could not recover through a (often memory-unlimited) system cron.
249 //
250 // An MCP call counts too. It is an admin-equivalent action, but it is
251 // dispatched from `parse_request` on the pretty /thinkrank/mcp route,
252 // so it is neither is_admin() nor REST_REQUEST and never qualified —
253 // which meant an agent could change sitemap settings on a site with no
254 // working cron and leave the served file stale indefinitely, having
255 // already reported success (#764). The logged-in test still applies:
256 // Mcp_Server sets the current user only after a credential validates,
257 // so an anonymous POST to that route is not eligible.
258 $is_mcp_request = class_exists('ThinkRank\Mcp\Mcp_Manager')
259 && ThinkRank\Mcp\Mcp_Manager::is_serving_request();
260
261 $eligible = (defined('WP_CLI') && WP_CLI)
262 || wp_doing_cron()
263 || (
264 is_user_logged_in()
265 && (
266 is_admin()
267 || (defined('REST_REQUEST') && REST_REQUEST)
268 || $is_mcp_request
269 )
270 );
271
272 if (!$eligible) {
273 return;
274 }
275
276 // Cheap autoloaded-option read, so the vast majority of requests stop
277 // here without building the generator.
278 if (!ThinkRank\SEO\Sitemap_Generator::has_overdue_regeneration()) {
279 return;
280 }
281
282 // `shutdown` runs after the output buffers are flushed, but flushed is
283 // not delivered: on FPM the connection stays open until the process
284 // ends, so without this the browser — or the REST call the sitemap
285 // screen just made — waits out the whole generation. Hand the response
286 // back first, then rebuild.
287 $this->close_request();
288
289 // Read-only construction: the auto-generation hooks are pointless this
290 // late in the request and would only add duplicate callbacks.
291 (new ThinkRank\SEO\Sitemap_Generator(false))->run_overdue_regeneration();
292 }
293
294 /**
295 * Deliver the response and let the request keep working without the client.
296 *
297 * A no-op on SAPIs that cannot do it, where the caller simply pays for the
298 * work as before.
299 *
300 * @since 2.2.1
301 * @return void
302 */
303 private function close_request(): void {
304 if (defined('WP_CLI') && WP_CLI) {
305 return;
306 }
307
308 if (function_exists('fastcgi_finish_request')) {
309 fastcgi_finish_request();
310 return;
311 }
312
313 if (function_exists('litespeed_finish_request')) {
314 litespeed_finish_request();
315 }
316 }
317
318 /**
319 * Unschedule the cron events left behind by the removed Brand Visibility
320 * feature.
321 *
322 * A site that ran a Brand Visibility check can still carry its drain tick
323 * and watchdog. Their callbacks, and the watchdog's custom recurrence, no
324 * longer exist, so the events would only fire into nothing.
325 * wp_unschedule_hook() walks the autoloaded cron array and writes nothing
326 * when the hook is absent, so this costs nothing once they are gone.
327 * Deactivation and uninstall clear the same hooks (cleanup-manifest.php).
328 *
329 * @return void
330 */
331 private function clear_retired_brand_visibility_cron(): void {
332 foreach (['thinkrank_bv_tick', 'thinkrank_bv_watchdog'] as $hook) {
333 wp_unschedule_hook($hook);
334 }
335 }
336
337 /**
338 * Check if database schema needs updating and run migrations
339 *
340 * Standard WordPress pattern: compare stored db_version against current,
341 * run dbDelta if stale. This handles schema changes (new tables, new columns)
342 * without requiring plugin deactivation/reactivation.
343 *
344 * @since 1.10.0
345 * @return void
346 */
347 private function maybe_update_database(): void {
348 $schema = new ThinkRank\Database\Database_Schema();
349 if ($schema->needs_update()) {
350 $schema->create_tables();
351 }
352 }
353
354 /**
355 * Load plugin components (Dependency Injection Container pattern)
356 *
357 * @return void
358 */
359 private function load_components(): void {
360 $this->components = [
361 'database' => new ThinkRank\Core\Database(),
362 // Settings::instance() rather than a fresh object: the container
363 // and the singleton were otherwise two different Settings, each
364 // with its own memo, because instance() resolves the container and
365 // this array is assigned only after every constructor above has
366 // run. Anything resolving the singleton during that window got a
367 // standalone instance that outlived the request, so a hook
368 // registered from the component's init() ran against an object no
369 // consumer read through (#516).
370 'settings' => ThinkRank\Core\Settings::instance(),
371 'role_manager' => new ThinkRank\Core\Role_Manager(),
372 'security_headers' => new ThinkRank\Core\Security_Headers(),
373 'asset_optimizer' => new ThinkRank\Core\Asset_Optimizer(),
374 'usage_tracker' => new ThinkRank\Core\Usage_Tracker_Manager(),
375 'api' => new ThinkRank\API\Manager(),
376 'admin' => new ThinkRank\Admin\Manager(),
377 'blocks' => new ThinkRank\Editor\Blocks_Manager(),
378 'elementor' => new ThinkRank\Editor\Elementor_Manager(),
379 'bricks_elements' => new ThinkRank\Editor\Bricks_Elements_Manager(),
380 'beaver_modules' => new ThinkRank\Editor\Beaver_Modules_Manager(),
381 'ai' => new ThinkRank\AI\Manager(),
382 'frontend_seo' => new ThinkRank\Frontend\SEO_Manager(),
383 'seo_notice' => new ThinkRank\Admin\SEO_Notice(),
384 'search_visibility_notice' => new ThinkRank\Admin\Search_Visibility_Notice(),
385 'webroot_writable_notice' => new ThinkRank\Admin\Webroot_Writable_Notice(),
386 'performance_collector' => new ThinkRank\SEO\Performance_Data_Collector(),
387 'query_guard' => new ThinkRank\SEO\Query_Guard(),
388 'feeds' => new ThinkRank\SEO\Feed_Manager(),
389 'sitemap_stylesheet' => new ThinkRank\SEO\Sitemap_Stylesheet(),
390 'oembed' => new ThinkRank\SEO\Oembed_Manager(),
391 'content_visibility' => new ThinkRank\SEO\Content_Visibility(),
392 'instant_indexing' => new ThinkRank\SEO\Instant_Indexing_Manager(),
393 'instant_indexing_reconciler' => new ThinkRank\SEO\Instant_Indexing_Reconciler(),
394 'author_archives' => new ThinkRank\SEO\Author_Archives_Manager(),
395 'seo_analyzer' => new ThinkRank\SEO\SEO_Analyzer(),
396 // Bulk Snippets' persisted issue index: invalidation hooks must run
397 // on every request, since posts are edited everywhere but there.
398 'snippet_index' => new ThinkRank\SEO\Snippet_Index(),
399 // The thin content report's word counts: same reason, and a
400 // separate index because a word count and a snippet verdict go
401 // stale for different reasons (#565).
402 'word_count_index' => new ThinkRank\SEO\Word_Count_Index(),
403 'email_report' => new ThinkRank\SEO\Email_Report_Manager(),
404 'google_oauth' => new ThinkRank\Integrations\Google_OAuth_Proxy(),
405 'multilingual' => new ThinkRank\Integrations\Multilingual_Manager(),
406 'ai_traffic' => new ThinkRank\SEO\Ai_Traffic_Tracker(),
407 'analytics' => new ThinkRank\SEO\Analytics_Manager(),
408 'schema_conflict_health_check' => new ThinkRank\Diagnostics\Schema_Conflict_Health_Check(),
409 'abilities' => new ThinkRank\Abilities\Abilities_Registrar(),
410 'mcp' => new ThinkRank\Mcp\Mcp_Manager(),
411 ];
412 }
413
414 /**
415 * Initialize all components
416 *
417 * @return void
418 */
419 private function init_components(): void {
420 foreach ($this->components as $component) {
421 if (method_exists($component, 'init')) {
422 $component->init();
423 }
424 }
425 }
426
427 /**
428 * Load template functions for themes
429 *
430 * @return void
431 */
432 private function load_template_functions(): void {
433 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/template-functions.php';
434 }
435
436 /**
437 * Get component instance
438 *
439 * @param string $component Component name
440 * @return object|null
441 */
442 public function get_component(string $component): ?object {
443 return $this->components[$component] ?? null;
444 }
445
446 /**
447 * Shared Analytics Manager instance (lazy)
448 *
449 * @var ThinkRank\SEO\Analytics_Manager|null
450 */
451 private ?ThinkRank\SEO\Analytics_Manager $analytics_manager = null;
452
453 /**
454 * Get the shared Analytics Manager, with Google clients initialized.
455 *
456 * Consumers (including the Pro plugin, which probes for this accessor)
457 * should use this instead of constructing their own Analytics_Manager:
458 * each fresh construction re-reads/decrypts settings and re-initializes
459 * the Google API clients.
460 *
461 * @since 1.18.0
462 * @return ThinkRank\SEO\Analytics_Manager
463 */
464 public function get_analytics_manager(): ThinkRank\SEO\Analytics_Manager {
465 if ($this->analytics_manager === null) {
466 // Reuse the registered component. Constructing a second instance
467 // here would work, but only the registered one has had init() run
468 // on it, so the token-refresh cron would be scheduled against a
469 // different object than the one callers actually use.
470 $component = $this->components['analytics'] ?? null;
471
472 $this->analytics_manager = $component instanceof ThinkRank\SEO\Analytics_Manager
473 ? $component
474 : new ThinkRank\SEO\Analytics_Manager();
475
476 // init() defers initialize_clients() to the `init` hook; callers
477 // that arrive earlier still need working clients.
478 $this->analytics_manager->initialize_clients();
479 }
480 return $this->analytics_manager;
481 }
482
483 /**
484 * Plugin activation
485 *
486 * @return void
487 */
488 public function activate(): void {
489 try {
490 $activator = new ThinkRank\Core\Activator();
491 $activator->activate();
492
493 // Flush rewrite rules
494 flush_rewrite_rules();
495 } catch (\Exception $e) {
496 $this->handle_error($e);
497 wp_die(
498 esc_html__('ThinkRank activation failed. Please check your server logs.', 'thinkrank'),
499 esc_html__('Plugin Activation Error', 'thinkrank'),
500 ['back_link' => true]
501 );
502 }
503 }
504
505 /**
506 * Plugin deactivation
507 *
508 * @return void
509 */
510 public function deactivate(): void {
511 try {
512 $deactivator = new ThinkRank\Core\Deactivator();
513 $deactivator->deactivate();
514
515 // Flush rewrite rules
516 flush_rewrite_rules();
517 } catch (\Exception $e) {
518 $this->handle_error($e);
519 }
520 }
521
522 /**
523 * Handle errors consistently
524 *
525 * Logs errors and surfaces them via _doing_it_wrong() when
526 * WP_DEBUG is enabled, helping developers diagnose issues.
527 *
528 * @param \Exception $e Exception to handle
529 * @return void
530 */
531 private function handle_error(\Exception $e): void {
532 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
533 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
534 error_log('ThinkRank: ' . $e->getMessage());
535 }
536
537 if (defined('WP_DEBUG') && WP_DEBUG) {
538 _doing_it_wrong(
539 __METHOD__,
540 esc_html($e->getMessage()),
541 esc_html(THINKRANK_VERSION)
542 );
543 }
544 }
545
546 /**
547 * Get plugin version
548 *
549 * @return string
550 */
551 public function get_version(): string {
552 return THINKRANK_VERSION;
553 }
554
555 /**
556 * Get plugin directory path
557 *
558 * @return string
559 */
560 public function get_plugin_dir(): string {
561 return THINKRANK_PLUGIN_DIR;
562 }
563
564 /**
565 * Get plugin URL
566 *
567 * @return string
568 */
569 public function get_plugin_url(): string {
570 return THINKRANK_PLUGIN_URL;
571 }
572 }
573
574 /**
575 * Initialize the plugin
576 *
577 * @return ThinkRank
578 */
579 // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed -- plugin bootstrap: the accessor belongs next to the class it returns.
580 function thinkrank(): ThinkRank {
581 return ThinkRank::get_instance();
582 }
583
584 // Start the plugin
585 thinkrank();
586