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
← All changes | thinkrank.php +336 -44 1.0.0 → 2.11.0 View file →
@@ -1,22 +1,22 @@
1 1 <?php
2 +
2 3 /**
3 4 * Plugin Name: ThinkRank
4 - * Plugin URI: https://thinkrank.ai/wordpress-plugin
5 + * Plugin URI: https://thinkrank.ai/
5 6 * Description: AI-native SEO plugin for WordPress. Automate and enhance your SEO with cutting-edge AI while maintaining editorial control.
6 - * Version: 1.0.0
7 - * Author: rupok
8 - * Author URI: https://thinkrank.ai
7 + * Version: 2.11.0
8 + * Author: WPDeveloper
9 + * Author URI: https://wpdeveloper.com/
9 10 * License: GPL v2 or later
10 11 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
11 12 * Text Domain: thinkrank
12 13 * Domain Path: /languages
13 14 * Requires at least: 6.0
14 - * Tested up to: 6.8
15 - * Requires PHP: 8.0
15 + * Requires PHP: 7.4
16 16 *
17 17 * @package ThinkRank
18 - * @version 1.0.0
18 + * @version 2.11.0
19 19 * @since 1.0.0
20 20 */
21 21
22 22 declare(strict_types=1);
@@ -26,9 +26,9 @@
26 26 exit;
27 27 }
28 28
29 29 // Define plugin constants
30 -define('THINKRANK_VERSION', '1.0.0');
30 +define('THINKRANK_VERSION', '2.11.0');
31 31 define('THINKRANK_PLUGIN_FILE', __FILE__);
32 32 define('THINKRANK_PLUGIN_DIR', plugin_dir_path(__FILE__));
33 33 define('THINKRANK_PLUGIN_URL', plugin_dir_url(__FILE__));
34 34 define('THINKRANK_PLUGIN_BASENAME', plugin_basename(__FILE__));
@@ -33,12 +33,12 @@
33 33 define('THINKRANK_PLUGIN_URL', plugin_dir_url(__FILE__));
34 34 define('THINKRANK_PLUGIN_BASENAME', plugin_basename(__FILE__));
35 35
36 36 // Minimum requirements check
37 -if (version_compare(PHP_VERSION, '8.0', '<')) {
38 - add_action('admin_notices', function() {
37 +if (version_compare(PHP_VERSION, '7.4', '<')) {
38 + add_action('admin_notices', function () {
39 39 echo '<div class="notice notice-error"><p>';
40 - echo esc_html__('ThinkRank requires PHP 8.0 or higher. Please upgrade your PHP version.', 'thinkrank');
40 + echo esc_html__('ThinkRank requires PHP 7.4 or higher. Please upgrade your PHP version.', 'thinkrank');
41 41 echo '</p></div>';
42 42 });
43 43 return;
44 44 }
@@ -43,9 +43,9 @@
43 43 return;
44 44 }
45 45
46 46 if (version_compare(get_bloginfo('version'), '6.0', '<')) {
47 - add_action('admin_notices', function() {
47 + add_action('admin_notices', function () {
48 48 echo '<div class="notice notice-error"><p>';
49 49 echo esc_html__('ThinkRank requires WordPress 6.0 or higher. Please upgrade your WordPress installation.', 'thinkrank');
50 50 echo '</p></div>';
51 51 });
@@ -55,8 +55,23 @@
55 55 // Autoloader
56 56 require_once THINKRANK_PLUGIN_DIR . 'includes/class-autoloader.php';
57 57
58 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 +/**
59 74 * Main ThinkRank Plugin Class
60 75 *
61 76 * Follows Single Responsibility Principle - only handles plugin initialization
62 77 *
@@ -62,9 +77,9 @@
62 77 *
63 78 * @since 1.0.0
64 79 */
65 80 final class ThinkRank {
66 -
81 +
67 82 /**
68 83 * Plugin instance (Singleton Pattern)
69 84 *
70 85 * @var ThinkRank|null
@@ -69,9 +84,9 @@
69 84 *
70 85 * @var ThinkRank|null
71 86 */
72 87 private static ?ThinkRank $instance = null;
73 -
88 +
74 89 /**
75 90 * Plugin components
76 91 *
77 92 * @var array
@@ -76,9 +91,9 @@
76 91 *
77 92 * @var array
78 93 */
79 94 private array $components = [];
80 -
95 +
81 96 /**
82 97 * Get plugin instance (Singleton Pattern)
83 98 *
84 99 * @return ThinkRank
@@ -88,9 +103,9 @@
88 103 self::$instance = new self();
89 104 }
90 105 return self::$instance;
91 106 }
92 -
107 +
93 108 /**
94 109 * Private constructor to prevent direct instantiation
95 110 */
96 111 private function __construct() {
@@ -96,21 +111,24 @@
96 111 private function __construct() {
97 112 $this->init_autoloader();
98 113 $this->init_hooks();
99 114 }
100 -
115 +
101 116 /**
102 117 * Prevent cloning
103 118 */
104 - private function __clone() {}
105 -
119 + private function __clone() {
120 + }
121 +
106 122 /**
107 123 * Prevent unserialization
124 + *
125 + * @throws \Exception On failure.
108 126 */
109 127 public function __wakeup() {
110 128 throw new \Exception('Cannot unserialize singleton');
111 129 }
112 -
130 +
113 131 /**
114 132 * Initialize autoloader
115 133 *
116 134 * @return void
@@ -117,9 +135,9 @@
117 135 */
118 136 private function init_autoloader(): void {
119 137 ThinkRank\Core\Autoloader::register();
120 138 }
121 -
139 +
122 140 /**
123 141 * Initialize WordPress hooks
124 142 *
125 143 * @return void
@@ -128,11 +146,30 @@
128 146 register_activation_hook(__FILE__, [$this, 'activate']);
129 147 register_deactivation_hook(__FILE__, [$this, 'deactivate']);
130 148
131 149 add_action('plugins_loaded', [$this, 'init']);
150 + add_filter('plugin_action_links_' . THINKRANK_PLUGIN_BASENAME, [$this, 'add_action_links']);
132 151 }
133 -
152 +
134 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 + /**
135 172 * Initialize plugin components
136 173 *
137 174 * @return void
138 175 */
@@ -137,8 +174,11 @@
137 174 * @return void
138 175 */
139 176 public function init(): void {
140 177 try {
178 + $this->maybe_update_database();
179 + $this->register_sitemap_cron_listeners();
180 + $this->clear_retired_brand_visibility_cron();
141 181 $this->load_components();
142 182 $this->init_components();
143 183 $this->load_template_functions();
144 184
@@ -146,10 +186,173 @@
146 186 } catch (\Exception $e) {
147 187 $this->handle_error($e);
148 188 }
149 189 }
150 -
190 +
151 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 + /**
152 355 * Load plugin components (Dependency Injection Container pattern)
153 356 *
154 357 * @return void
155 358 */
@@ -154,22 +357,61 @@
154 357 * @return void
155 358 */
156 359 private function load_components(): void {
157 360 $this->components = [
158 - 'database' => new ThinkRank\Core\Database(),
159 - 'settings' => new ThinkRank\Core\Settings(),
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(),
160 372 'security_headers' => new ThinkRank\Core\Security_Headers(),
161 373 'asset_optimizer' => new ThinkRank\Core\Asset_Optimizer(),
162 - 'api' => new ThinkRank\API\Manager(),
163 - 'admin' => new ThinkRank\Admin\Manager(),
164 - 'ai' => new ThinkRank\AI\Manager(),
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(),
165 382 'frontend_seo' => new ThinkRank\Frontend\SEO_Manager(),
166 - 'seo_notice' => new ThinkRank\Admin\SEO_Notice(),
167 - 'integrations' => new ThinkRank\Integrations\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(),
168 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(),
169 411 ];
170 412 }
171 -
413 +
172 414 /**
173 415 * Initialize all components
174 416 *
175 417 * @return void
@@ -189,12 +431,12 @@
189 431 */
190 432 private function load_template_functions(): void {
191 433 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/template-functions.php';
192 434 }
193 -
435 +
194 436 /**
195 437 * Get component instance
196 - *
438 + *
197 439 * @param string $component Component name
198 440 * @return object|null
199 441 */
200 442 public function get_component(string $component): ?object {
@@ -199,10 +441,47 @@
199 441 */
200 442 public function get_component(string $component): ?object {
201 443 return $this->components[$component] ?? null;
202 444 }
203 -
445 +
204 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 + /**
205 484 * Plugin activation
206 485 *
207 486 * @return void
208 487 */
@@ -209,12 +488,11 @@
209 488 public function activate(): void {
210 489 try {
211 490 $activator = new ThinkRank\Core\Activator();
212 491 $activator->activate();
213 -
492 +
214 493 // Flush rewrite rules
215 494 flush_rewrite_rules();
216 -
217 495 } catch (\Exception $e) {
218 496 $this->handle_error($e);
219 497 wp_die(
220 498 esc_html__('ThinkRank activation failed. Please check your server logs.', 'thinkrank'),
@@ -222,9 +500,9 @@
222 500 ['back_link' => true]
223 501 );
224 502 }
225 503 }
226 -
504 +
227 505 /**
228 506 * Plugin deactivation
229 507 *
230 508 * @return void
@@ -232,27 +510,40 @@
232 510 public function deactivate(): void {
233 511 try {
234 512 $deactivator = new ThinkRank\Core\Deactivator();
235 513 $deactivator->deactivate();
236 -
514 +
237 515 // Flush rewrite rules
238 516 flush_rewrite_rules();
239 -
240 517 } catch (\Exception $e) {
241 518 $this->handle_error($e);
242 519 }
243 520 }
244 -
521 +
245 522 /**
246 523 * Handle errors consistently
247 524 *
525 + * Logs errors and surfaces them via _doing_it_wrong() when
526 + * WP_DEBUG is enabled, helping developers diagnose issues.
527 + *
248 528 * @param \Exception $e Exception to handle
249 529 * @return void
250 530 */
251 531 private function handle_error(\Exception $e): void {
252 - // Error handling without debug output
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 + }
253 544 }
254 -
545 +
255 546 /**
256 547 * Get plugin version
257 548 *
258 549 * @return string
@@ -259,9 +550,9 @@
259 550 */
260 551 public function get_version(): string {
261 552 return THINKRANK_VERSION;
262 553 }
263 -
554 +
264 555 /**
265 556 * Get plugin directory path
266 557 *
267 558 * @return string
@@ -268,9 +559,9 @@
268 559 */
269 560 public function get_plugin_dir(): string {
270 561 return THINKRANK_PLUGIN_DIR;
271 562 }
272 -
563 +
273 564 /**
274 565 * Get plugin URL
275 566 *
276 567 * @return string
@@ -284,8 +575,9 @@
284 575 * Initialize the plugin
285 576 *
286 577 * @return ThinkRank
287 578 */
579 +// phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed -- plugin bootstrap: the accessor belongs next to the class it returns.
288 580 function thinkrank(): ThinkRank {
289 581 return ThinkRank::get_instance();
290 582 }
291 583