PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.0.1 1.0.2 1.1.0 All 49 releases
← All changes | includes/admin/class-metabox-manager.php +1052 -124 1.0.22.7.0 View file →
@@ -1,5 +1,6 @@
1 1 <?php
2 +
2 3 /**
3 4 * Meta Box Manager
4 5 *
5 6 * Handles ThinkRank meta boxes in post/page edit screens
@@ -15,8 +16,12 @@
15 16 use ThinkRank\AI\Metadata_Generator;
16 17 use ThinkRank\AI\SEOScoreCalculator;
17 18 use ThinkRank\Core\Settings;
18 19 use ThinkRank\Core\Database;
20 +use ThinkRank\Core\Plan_Config;
21 +use ThinkRank\SEO\Focus_Keywords;
22 +use ThinkRank\SEO\Object_Redirect;
23 +use ThinkRank\SEO\Pattern_Resolver;
19 24
20 25 // Prevent direct access
21 26 if (!defined('ABSPATH')) {
22 27 exit;
@@ -29,9 +34,9 @@
29 34 *
30 35 * @since 1.0.0
31 36 */
32 37 class Metabox_Manager {
33 -
38 +
34 39 /**
35 40 * Settings instance
36 41 *
37 42 * @var Settings
@@ -36,9 +41,9 @@
36 41 *
37 42 * @var Settings
38 43 */
39 44 private Settings $settings;
40 -
45 +
41 46 /**
42 47 * Metadata generator instance
43 48 *
44 49 * @var Metadata_Generator
@@ -50,9 +55,9 @@
50 55 *
51 56 * @var SEOScoreCalculator
52 57 */
53 58 private SEOScoreCalculator $seo_calculator;
54 -
59 +
55 60 /**
56 61 * Constructor
57 62 *
58 63 * @param Settings|null $settings Settings instance
@@ -59,13 +64,13 @@
59 64 * @param Metadata_Generator|null $metadata_generator Metadata generator instance
60 65 * @param SEOScoreCalculator|null $seo_calculator SEO Score Calculator instance
61 66 */
62 67 public function __construct(?Settings $settings = null, ?Metadata_Generator $metadata_generator = null, ?SEOScoreCalculator $seo_calculator = null) {
63 - $this->settings = $settings ?? new Settings();
68 + $this->settings = $settings ?? Settings::instance();
64 69 $this->metadata_generator = $metadata_generator ?? new Metadata_Generator();
65 70 $this->seo_calculator = $seo_calculator ?? new SEOScoreCalculator(new Database());
66 71 }
67 -
72 +
68 73 /**
69 74 * Initialize meta box manager
70 75 *
71 76 * @return void
@@ -73,14 +78,18 @@
73 78 public function init(): void {
74 79 add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
75 80 add_action('save_post', [$this, 'save_meta_boxes'], 10, 2);
76 81 add_action('admin_enqueue_scripts', [$this, 'enqueue_metabox_scripts']);
82 + add_action('admin_notices', [$this, 'render_redirect_notice']);
77 83 add_action('init', [$this, 'register_meta_fields']);
78 84
79 85 // AJAX handlers for meta box functionality
80 86 add_action('wp_ajax_thinkrank_generate_post_metadata', [$this, 'ajax_generate_post_metadata']);
81 - add_action('wp_ajax_thinkrank_save_post_metadata', [$this, 'ajax_save_post_metadata']);
82 87
88 + // Full metabox save used by editors that don't submit the #post form
89 + // (e.g. the Elementor editor). Persists every metabox field at once.
90 + add_action('wp_ajax_thinkrank_save_metabox', [$this, 'ajax_save_metabox']);
91 +
83 92 // Removed debug hooks
84 93 }
85 94
86 95 /**
@@ -94,9 +103,9 @@
94 103 'show_in_rest' => true,
95 104 'single' => true,
96 105 'type' => 'string',
97 106 'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
98 - 'auth_callback' => function() {
107 + 'auth_callback' => function () {
99 108 return current_user_can('edit_posts') || current_user_can('edit_pages');
100 109 }
101 110 ]);
102 111
@@ -103,12 +112,130 @@
103 112 register_post_meta('', '_thinkrank_selected_schema_type', [
104 113 'show_in_rest' => true,
105 114 'single' => true,
106 115 'type' => 'string',
116 + 'auth_callback' => function () {
117 + return current_user_can('edit_posts') || current_user_can('edit_pages');
118 + }
119 + ]);
120 +
121 + register_post_meta('', '_thinkrank_additional_schemas', [
122 + 'show_in_rest' => true,
123 + 'single' => true,
124 + 'type' => 'string',
125 + 'sanitize_callback' => [$this, 'sanitize_json_ld_field'],
107 126 'auth_callback' => function() {
108 127 return current_user_can('edit_posts') || current_user_can('edit_pages');
109 128 }
110 129 ]);
130 +
131 + // SEO meta fields for import support
132 + $string_meta_fields = [
133 + '_thinkrank_canonical_url',
134 + '_thinkrank_og_title',
135 + '_thinkrank_og_description',
136 + '_thinkrank_og_image',
137 + '_thinkrank_twitter_title',
138 + '_thinkrank_twitter_description',
139 + '_thinkrank_twitter_image',
140 + '_thinkrank_imported_from',
141 + ];
142 +
143 + foreach ($string_meta_fields as $meta_key) {
144 + register_post_meta('', $meta_key, [
145 + 'show_in_rest' => true,
146 + 'single' => true,
147 + 'type' => 'string',
148 + 'auth_callback' => function () {
149 + return current_user_can('edit_posts') || current_user_can('edit_pages');
150 + }
151 + ]);
152 + }
153 +
154 + // Multiple focus keywords (array). The legacy single-value
155 + // `_thinkrank_focus_keyword` is kept in sync by Focus_Keywords for
156 + // backward compatibility and registered for REST as a string elsewhere.
157 + register_post_meta('', Focus_Keywords::META_KEY, [
158 + 'show_in_rest' => [
159 + 'schema' => [
160 + 'type' => 'array',
161 + 'items' => ['type' => 'string'],
162 + ],
163 + ],
164 + 'single' => true,
165 + 'type' => 'array',
166 + 'sanitize_callback' => function ($value) {
167 + return Focus_Keywords::normalize($value);
168 + },
169 + 'auth_callback' => function () {
170 + return current_user_can('edit_posts') || current_user_can('edit_pages');
171 + }
172 + ]);
173 +
174 + register_post_meta('', Focus_Keywords::LEGACY_META_KEY, [
175 + 'show_in_rest' => true,
176 + 'single' => true,
177 + 'type' => 'string',
178 + 'auth_callback' => function () {
179 + return current_user_can('edit_posts') || current_user_can('edit_pages');
180 + }
181 + ]);
182 +
183 + register_post_meta('', '_thinkrank_robots_meta_enabled', [
184 + 'show_in_rest' => true,
185 + 'single' => true,
186 + 'type' => 'integer',
187 + 'auth_callback' => function () {
188 + return current_user_can('edit_posts') || current_user_can('edit_pages');
189 + }
190 + ]);
191 +
192 + register_post_meta('', '_thinkrank_robots_meta', [
193 + 'show_in_rest' => true,
194 + 'single' => true,
195 + 'type' => 'string',
196 + 'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
197 + 'auth_callback' => function () {
198 + return current_user_can('edit_posts') || current_user_can('edit_pages');
199 + }
200 + ]);
201 +
202 + register_post_meta('', '_thinkrank_advanced_robots_meta', [
203 + 'show_in_rest' => true,
204 + 'single' => true,
205 + 'type' => 'string',
206 + 'sanitize_callback' => [$this, 'sanitize_json_meta_field'],
207 + 'auth_callback' => function () {
208 + return current_user_can('edit_posts') || current_user_can('edit_pages');
209 + }
210 + ]);
211 +
212 + register_post_meta('', \ThinkRank\SEO\Content_Visibility::SEARCH_META, [
213 + 'show_in_rest' => true,
214 + 'single' => true,
215 + 'type' => 'integer',
216 + 'auth_callback' => function () {
217 + return current_user_can('edit_posts') || current_user_can('edit_pages');
218 + }
219 + ]);
220 +
221 + register_post_meta('', \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, [
222 + 'show_in_rest' => true,
223 + 'single' => true,
224 + 'type' => 'integer',
225 + 'auth_callback' => function () {
226 + return current_user_can('edit_posts') || current_user_can('edit_pages');
227 + }
228 + ]);
229 +
230 + register_post_meta('', '_thinkrank_primary_category', [
231 + 'show_in_rest' => true,
232 + 'single' => true,
233 + 'type' => 'integer',
234 + 'auth_callback' => function () {
235 + return current_user_can('edit_posts') || current_user_can('edit_pages');
236 + }
237 + ]);
111 238 }
112 239
113 240 /**
114 241 * Sanitize JSON meta field data
@@ -151,9 +278,9 @@
151 278 * @param mixed $data Data to sanitize
152 279 * @param int $depth Current recursion depth
153 280 * @return mixed Sanitized data
154 281 */
155 - private function sanitize_json_recursively($data, int $depth = 0): mixed {
282 + private function sanitize_json_recursively($data, int $depth = 0) {
156 283 // Prevent deep recursion attacks
157 284 if ($depth > 10) {
158 285 return null;
159 286 }
@@ -183,8 +310,90 @@
183 310 // For any other data type, return null
184 311 return null;
185 312 }
186 313
314 + /**
315 + * Sanitize JSON-LD meta field data
316 + *
317 + * Validates JSON structure, recursively sanitizes all string values,
318 + * and preserves @ characters in keys (crucial for JSON-LD).
319 + *
320 + * @param string $value Raw JSON string value
321 + *
322 + * @return string Sanitized JSON string or empty string if invalid
323 + */
324 + public function sanitize_json_ld_field( string $value ): string {
325 + // Return empty string for non-string values
326 + if ( ! is_string( $value ) || empty( $value ) ) {
327 + return '';
328 + }
329 +
330 + // Validate JSON structure
331 + $decoded = json_decode( $value, true );
332 + if ( json_last_error() !== JSON_ERROR_NONE ) {
333 + // Invalid JSON - return empty string
334 + return '';
335 + }
336 +
337 + // Check for reasonable data size (prevent JSON bombs)
338 + if ( strlen( $value ) > 200000 ) { // Limit to 200KB for larger schemas
339 + return '';
340 + }
341 +
342 + // Recursively sanitize all values
343 + $sanitized = $this->sanitize_json_ld_recursively( $decoded );
344 +
345 + // Re-encode as JSON
346 + $result = wp_json_encode( $sanitized );
347 +
348 + return $result !== false ? $result : '';
349 + }
350 +
351 + /**
352 + * Recursively sanitize JSON-LD data
353 + *
354 + * Similar to sanitize_json_recursively but preserves @ symbol and case in keys.
355 + *
356 + * @param mixed $data Data to sanitize
357 + * @param int $depth Current recursion depth
358 + *
359 + * @return mixed Sanitized data
360 + */
361 + private function sanitize_json_ld_recursively( $data, int $depth = 0 ) {
362 + // Prevent deep recursion attacks
363 + if ( $depth > 10 ) {
364 + return null;
365 + }
366 +
367 + if ( is_array( $data ) ) {
368 + $sanitized = [];
369 + foreach ( $data as $key => $value ) {
370 + // Allow alphanumeric, underscore, dash, and @ (crucial for JSON-LD)
371 + // Also preserve case as JSON-LD keys are case-sensitive
372 + $clean_key = preg_replace( '/[^a-zA-Z0-9_\-@]/', '', (string) $key );
373 + $sanitized[ $clean_key ] = $this->sanitize_json_ld_recursively( $value, $depth + 1 );
374 + }
375 +
376 + return $sanitized;
377 + }
378 +
379 + if ( is_string( $data ) ) {
380 + // Sanitize string data to prevent XSS
381 + return sanitize_textarea_field( $data );
382 + }
383 +
384 + if ( is_numeric( $data ) ) {
385 + return $data;
386 + }
387 +
388 + if ( is_bool( $data ) ) {
389 + return $data;
390 + }
391 +
392 + // For any other data type, return null
393 + return null;
394 + }
395 +
187 396 // Removed debug methods
188 397
189 398 /**
190 399 * Add ThinkRank meta boxes
@@ -192,9 +401,11 @@
192 401 * @return void
193 402 */
194 403 public function add_meta_boxes(): void {
195 404 $post_types = $this->get_supported_post_types();
196 -
405 +
406 + $is_block_editor = $this->is_block_editor_screen();
407 +
197 408 foreach ($post_types as $post_type) {
198 409 add_meta_box(
199 410 'thinkrank-seo-metabox',
200 411 __('ThinkRank SEO', 'thinkrank'),
@@ -202,12 +413,68 @@
202 413 $post_type,
203 414 'normal',
204 415 'high'
205 416 );
417 +
418 + // Classic Editor sidebar quick-access widget (mirrors SureRank's
419 + // "Manage your SEO" sidebar box). Skipped in the Block Editor, which
420 + // surfaces the full panel below the content instead.
421 + if (!$is_block_editor) {
422 + add_meta_box(
423 + 'thinkrank-seo-sidebar',
424 + __('ThinkRank', 'thinkrank'),
425 + [$this, 'render_sidebar_meta_box'],
426 + $post_type,
427 + 'side',
428 + 'high'
429 + );
430 + }
206 431 }
207 432 }
208 -
433 +
209 434 /**
435 + * Whether the current edit screen is using the Block Editor.
436 + *
437 + * @return bool True when the Block Editor is active, false for Classic Editor.
438 + */
439 + private function is_block_editor_screen(): bool {
440 + if (!function_exists('get_current_screen')) {
441 + return false;
442 + }
443 +
444 + $screen = get_current_screen();
445 +
446 + return $screen instanceof \WP_Screen
447 + && method_exists($screen, 'is_block_editor')
448 + && $screen->is_block_editor();
449 + }
450 +
451 + /**
452 + * Render the Classic Editor sidebar quick-access widget.
453 + *
454 + * Shows a short label plus a button that scrolls to (and expands) the full
455 + * "ThinkRank SEO" panel in the main column.
456 + *
457 + * @param \WP_Post $post Post object.
458 + * @return void
459 + */
460 + public function render_sidebar_meta_box(\WP_Post $post): void {
461 + $box_title = apply_filters('thinkrank_seo_sidebar_box_title', __('Optimize this content for search & AI with ThinkRank.', 'thinkrank'), $post);
462 + $cta_label = apply_filters('thinkrank_seo_sidebar_cta_label', __('Open ThinkRank SEO', 'thinkrank'), $post);
463 + ?>
464 + <div class="thinkrank-classic-sidebar-box">
465 + <p class="thinkrank-classic-sidebar-box-title"><?php echo esc_html($box_title); ?></p>
466 + <button
467 + type="button"
468 + class="button button-primary thinkrank-classic-sidebar-box-cta"
469 + >
470 + <?php echo esc_html($cta_label); ?>
471 + </button>
472 + </div>
473 + <?php
474 + }
475 +
476 + /**
210 477 * Render SEO meta box
211 478 *
212 479 * @param \WP_Post $post Post object
213 480 * @return void
@@ -222,24 +489,48 @@
222 489 // Get post content for AI analysis
223 490 $content_preview = $this->get_content_preview($post);
224 491
225 492 // Render React metabox container with hidden form fields for data
226 - ?>
493 +?>
227 494 <div id="thinkrank-metabox-container" class="thinkrank-metabox">
228 - <div class="thinkrank-loading">
229 - <span class="spinner is-active"></span>
230 - <p><?php esc_html_e('Loading ThinkRank metabox...', 'thinkrank'); ?></p>
231 - </div>
232 495
233 496 <!-- Hidden form fields for React to read initial data -->
234 497 <input type="hidden" id="thinkrank_seo_title" name="thinkrank_seo_title" value="<?php echo esc_attr($existing_metadata['title'] ?? ''); ?>" />
235 498 <input type="hidden" id="thinkrank_meta_description" name="thinkrank_meta_description" value="<?php echo esc_attr($existing_metadata['description'] ?? ''); ?>" />
499 + <?php // Render-time mirrors of the two fields a background writer (Auto AI, bulk, import) can fill after load. The React app never touches these, so on save they still hold the value shown when the form loaded — letting persist_metadata() tell a stale blank apart from a deliberate clear. ?>
500 + <input type="hidden" id="thinkrank_seo_title__orig" name="thinkrank_seo_title__orig" value="<?php echo esc_attr($existing_metadata['title'] ?? ''); ?>" />
501 + <input type="hidden" id="thinkrank_meta_description__orig" name="thinkrank_meta_description__orig" value="<?php echo esc_attr($existing_metadata['description'] ?? ''); ?>" />
236 502 <input type="hidden" id="thinkrank_focus_keyword" name="thinkrank_focus_keyword" value="<?php echo esc_attr($existing_metadata['focus_keyword'] ?? ''); ?>" />
503 + <input type="hidden" id="thinkrank_focus_keywords" name="thinkrank_focus_keywords" value="<?php echo esc_attr(wp_json_encode($existing_metadata['focus_keywords'] ?? [])); ?>" />
237 504 <input type="hidden" id="thinkrank_seo_score" name="thinkrank_seo_score" value="<?php echo esc_attr($existing_metadata['seo_score'] ?? '0'); ?>" />
238 505 <input type="hidden" id="thinkrank_generated_at" name="thinkrank_generated_at" value="<?php echo esc_attr($existing_metadata['generated_at'] ?? ''); ?>" />
506 + <input type="hidden" id="thinkrank_pillar_content" name="thinkrank_pillar_content" value="<?php echo esc_attr($existing_metadata['pillar_content'] ?? ''); ?>" />
507 + <input type="hidden" id="thinkrank_exclude_from_search" name="thinkrank_exclude_from_search" value="<?php echo esc_attr((string) ($existing_metadata['exclude_from_search'] ?? '')); ?>" />
508 + <input type="hidden" id="thinkrank_exclude_from_archives" name="thinkrank_exclude_from_archives" value="<?php echo esc_attr((string) ($existing_metadata['exclude_from_archives'] ?? '')); ?>" />
509 + <input type="hidden" id="thinkrank_canonical_url" name="thinkrank_canonical_url" value="<?php echo esc_url($existing_metadata['canonical_url'] ?? ''); ?>" />
510 + <?php
511 + // The redirect lives in Pro's rules table, not post meta, so nothing
512 + // else hands it to the React app. Without these the field loads
513 + // empty, and its own hidden input then posts that empty value on the
514 + // next save, which Object_Redirect reads as "remove the redirect".
515 + // Rendered only when a provider can store it, matching MetaboxApp.
516 + if (Object_Redirect::is_supported()) :
517 + ?>
518 + <input type="hidden" id="thinkrank_redirect_url" name="thinkrank_redirect_url" value="<?php echo esc_attr((string) ($existing_metadata['redirect_url'] ?? '')); ?>" />
519 + <input type="hidden" id="thinkrank_redirect_type" name="thinkrank_redirect_type" value="<?php echo esc_attr((string) ($existing_metadata['redirect_type'] ?? Object_Redirect::DEFAULT_TYPE)); ?>" />
520 + <?php endif; ?>
521 + <input type="hidden" id="thinkrank_robots_meta_enabled" name="thinkrank_robots_meta_enabled" value="<?php echo esc_attr((string) ($existing_metadata['robots_meta_enabled'] ?? '0')); ?>" />
522 + <input type="hidden" id="thinkrank_robots_meta" name="thinkrank_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['robots_meta'] ?? '')); ?>" />
523 + <input type="hidden" id="thinkrank_advanced_robots_meta" name="thinkrank_advanced_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['advanced_robots_meta'] ?? '')); ?>" />
524 + <input type="hidden" id="thinkrank_og_title" name="thinkrank_og_title" value="<?php echo esc_attr((string) ($existing_metadata['og_title'] ?? '')); ?>" />
525 + <input type="hidden" id="thinkrank_og_description" name="thinkrank_og_description" value="<?php echo esc_attr((string) ($existing_metadata['og_description'] ?? '')); ?>" />
526 + <input type="hidden" id="thinkrank_og_image" name="thinkrank_og_image" value="<?php echo esc_url((string) ($existing_metadata['og_image'] ?? '')); ?>" />
527 + <input type="hidden" id="thinkrank_twitter_title" name="thinkrank_twitter_title" value="<?php echo esc_attr((string) ($existing_metadata['twitter_title'] ?? '')); ?>" />
528 + <input type="hidden" id="thinkrank_twitter_description" name="thinkrank_twitter_description" value="<?php echo esc_attr((string) ($existing_metadata['twitter_description'] ?? '')); ?>" />
529 + <input type="hidden" id="thinkrank_twitter_image" name="thinkrank_twitter_image" value="<?php echo esc_url((string) ($existing_metadata['twitter_image'] ?? '')); ?>" />
239 530 <textarea id="thinkrank_content_preview" style="display: none;"><?php echo esc_textarea($content_preview); ?></textarea>
240 531 </div>
241 - <?php
532 +<?php
242 533
243 534 }
244 535
245 536 /**
@@ -253,9 +544,9 @@
253 544 // Verify nonce
254 545 if (!isset($_POST['thinkrank_metabox_nonce'])) {
255 546 return;
256 547 }
257 -
548 +
258 549 $nonce = sanitize_text_field(wp_unslash($_POST['thinkrank_metabox_nonce']));
259 550 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_nonce')) {
260 551 return;
261 552 }
@@ -269,32 +560,336 @@
269 560 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
270 561 return;
271 562 }
272 563
273 - // Save metadata
564 + // The classic/block editor submits the metabox fields as part of the
565 + // #post form, so they arrive (slashed) in $_POST. Hand them straight to
566 + // the shared persistence routine.
567 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above
568 + $this->persist_metadata($post_id, wp_unslash($_POST));
569 +
570 + // The redirect is the one field here that can be refused outright. The
571 + // response to this request is a redirect back to the editor, so the
572 + // reason has to survive one page load to be seen at all.
573 + $redirect_error = $this->get_last_redirect_error();
574 + if (null !== $redirect_error) {
575 + $this->store_redirect_error($redirect_error);
576 + }
577 + }
578 +
579 + /**
580 + * Persist metabox fields for a post from a form-field-name => value map,
581 + * reusing the shared metabox persistence (sanitization, JSON encoding,
582 + * focus-keyword normalization, empty-value deletion).
583 + *
584 + * Intended for non-form callers such as the MCP abilities layer. The
585 + * caller is responsible for authorization; keys use the same
586 + * `thinkrank_*` field names accepted by the metabox form (e.g.
587 + * `thinkrank_seo_title`, `thinkrank_meta_description`, `thinkrank_robots_meta`).
588 + *
589 + * @param int $post_id Post to update.
590 + * @param array $fields Field name => value map.
591 + * @return void
592 + */
593 + public function save_seo_fields(int $post_id, array $fields): void {
594 + $this->persist_metadata($post_id, $fields);
595 + }
596 +
597 + /**
598 + * Persist all metabox fields for a post from a $_POST-shaped (already
599 + * unslashed) source array.
600 + *
601 + * Shared by `save_meta_boxes()` (classic/block editor form submit) and
602 + * `ajax_save_metabox()` (editors like Elementor that don't submit the #post
603 + * form). Nonce/permission checks are the caller's responsibility. Each field
604 + * is independently sanitized; missing keys are left untouched.
605 + *
606 + * @param int $post_id Post to update.
607 + * @param array $src Field name => raw value map (unslashed).
608 + * @return void
609 + */
610 + private function persist_metadata(int $post_id, array $src): void {
611 + // Title & meta description are handled separately below: they can be
612 + // written out-of-band (Auto AI on publish, imports)
613 + // after an editor was opened, so a plain save from that now-stale editor
614 + // would clobber the generated value with a blank. Focus keywords are
615 + // likewise handled separately (array meta) via Focus_Keywords below.
616 + //
617 + // Both fields may hold variable tags, so they are sanitized as templates:
618 + // sanitize_text_field()/sanitize_textarea_field() strip %date% and
619 + // %category% as percent-encoding and store "te%" / "tegory%" (#521).
620 + $this->persist_seo_text_field($post_id, $src, 'thinkrank_seo_title', '_thinkrank_seo_title', [Pattern_Resolver::class, 'sanitize_template']);
621 + $this->persist_seo_text_field($post_id, $src, 'thinkrank_meta_description', '_thinkrank_meta_description', [Pattern_Resolver::class, 'sanitize_template_textarea']);
622 +
274 623 $fields = [
275 - 'thinkrank_seo_title' => 'sanitize_text_field',
276 - 'thinkrank_meta_description' => 'sanitize_textarea_field',
277 - 'thinkrank_focus_keyword' => 'sanitize_text_field',
278 624 'thinkrank_seo_score' => 'absint',
279 625 'thinkrank_generated_at' => 'sanitize_text_field',
626 + 'thinkrank_pillar_content' => 'sanitize_text_field',
627 + 'thinkrank_exclude_from_search' => 'sanitize_text_field',
628 + 'thinkrank_exclude_from_archives' => 'sanitize_text_field',
280 629 ];
281 630
631 + // Focus keywords: prefer the JSON array field; fall back to the legacy
632 + // single string. Focus_Keywords::save() normalizes (dedupe, drop empty,
633 + // cap at MAX) and keeps the legacy single-value meta in sync.
634 + if (isset($src['thinkrank_focus_keywords'])) {
635 + $raw_keywords = $src['thinkrank_focus_keywords'];
636 + $decoded = is_string($raw_keywords) ? json_decode($raw_keywords, true) : $raw_keywords;
637 + Focus_Keywords::save($post_id, is_array($decoded) ? $decoded : []);
638 + } elseif (isset($src['thinkrank_focus_keyword'])) {
639 + Focus_Keywords::save($post_id, $src['thinkrank_focus_keyword']);
640 + }
641 +
642 + // Update the post slug (post_name) when the metabox permalink field
643 + // was edited. This touches the WP post itself, not post meta.
644 + //
645 + // The baseline is what the field was RENDERED with. Without it the
646 + // guard here was a bare isset(), and the hidden input is always
647 + // posted — so a user who edited WordPress's own permalink field in
648 + // the Classic Editor had their new slug written by core and then
649 + // overwritten by this page-load snapshot (#441).
650 + if (isset($src['thinkrank_post_slug'])) {
651 + $this->maybe_update_slug(
652 + $post_id,
653 + (string) $src['thinkrank_post_slug'],
654 + isset($src['thinkrank_post_slug_baseline'])
655 + ? (string) $src['thinkrank_post_slug_baseline']
656 + : null
657 + );
658 + }
659 +
660 + // Save canonical URL separately with URL sanitization
661 + if (isset($src['thinkrank_canonical_url'])) {
662 + $canonical_url = esc_url_raw((string) $src['thinkrank_canonical_url']);
663 + if (empty($canonical_url)) {
664 + delete_post_meta($post_id, '_thinkrank_canonical_url');
665 + } else {
666 + update_post_meta($post_id, '_thinkrank_canonical_url', $canonical_url);
667 + }
668 + }
669 +
670 + $this->last_redirect_error = $this->save_object_redirect('post', $post_id, $src);
671 +
282 672 foreach ($fields as $field => $sanitize_callback) {
283 - if (isset($_POST[$field])) {
284 - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Value is sanitized via callback below
285 - $raw_value = wp_unslash($_POST[$field]);
286 - $value = call_user_func($sanitize_callback, $raw_value);
673 + if (isset($src[$field])) {
674 + $value = call_user_func($sanitize_callback, $src[$field]);
287 675 update_post_meta($post_id, "_{$field}", $value);
288 676 }
289 677 }
290 678
679 + $this->save_robots_meta($post_id, $src);
680 + $this->save_visibility_meta($post_id, $src);
681 + $this->save_social_meta($post_id, $src);
682 +
291 683 // Update last modified timestamp
292 684 update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
293 685 }
294 686
295 -
296 687 /**
688 + * Persist one SEO text field with an out-of-band-write guard.
689 + *
690 + * Auto AI (on publish) and imports write the SEO title / meta description
691 + * directly to post meta. When that happens after an editor
692 + * was opened, the editor's hidden input is a stale blank; a normal save
693 + * would overwrite the freshly generated value with that blank. This guard
694 + * skips the write only when the submitted value is empty AND it was also
695 + * empty when the form was rendered (the `<field>__orig` mirror), yet the
696 + * stored value is now non-empty — i.e. a background writer won the race.
697 + *
698 + * A deliberate clear still applies: if the field held a value at render and
699 + * is submitted empty, `$orig` is non-empty so the guard does not trigger.
700 + * Callers that don't send the `__orig` mirror (e.g. the MCP/Elementor
701 + * paths) keep the plain write behavior.
702 + *
703 + * @param int $post_id Post being saved.
704 + * @param array $src Unslashed field map.
705 + * @param string $field POST field name (e.g. thinkrank_seo_title).
706 + * @param string $meta_key Target post meta key.
707 + * @param callable $sanitize Sanitizer applied to the submitted value.
708 + * @return void
709 + */
710 + private function persist_seo_text_field(int $post_id, array $src, string $field, string $meta_key, callable $sanitize): void {
711 + if (!isset($src[$field])) {
712 + return; // Field absent from this submit → leave the stored value untouched.
713 + }
714 +
715 + $submitted = (string) call_user_func($sanitize, (string) $src[$field]);
716 +
717 + if ('' === $submitted && isset($src[$field . '__orig'])) {
718 + $orig = (string) $src[$field . '__orig'];
719 + $stored = (string) get_post_meta($post_id, $meta_key, true);
720 + // Blank now, blank at render, but populated in storage → a background
721 + // write landed after this editor loaded; don't clobber it.
722 + if ('' === $orig && '' !== $stored) {
723 + return;
724 + }
725 + }
726 +
727 + update_post_meta($post_id, $meta_key, $submitted);
728 + }
729 +
730 + /**
731 + * Update the post slug (post_name) from the metabox permalink field.
732 + *
733 + * Runs inside the save_post cycle, so wp_update_post() would recurse — a
734 + * static guard prevents re-entry. WordPress applies wp_unique_post_slug(),
735 + * so a colliding slug is de-duplicated automatically. Empty input is left
736 + * alone (WP keeps/auto-generates the slug); auto-drafts and revisions are
737 + * skipped so we don't fight the editor's own slug generation.
738 + *
739 + * @param int $post_id Post to update.
740 + * @param string $raw_slug Desired slug from the metabox.
741 + * @return void
742 + */
743 + private function maybe_update_slug(int $post_id, string $raw_slug, ?string $baseline = null): void {
744 + static $updating = false;
745 + if ($updating) {
746 + return;
747 + }
748 +
749 + $post = get_post($post_id);
750 + if (!$post || wp_is_post_revision($post_id)) {
751 + return;
752 + }
753 +
754 + if (in_array($post->post_status, ['auto-draft', 'trash'], true)) {
755 + return;
756 + }
757 +
758 + $desired = sanitize_title($raw_slug);
759 + if ($desired === '') {
760 + return;
761 + }
762 +
763 + // Unchanged from what the form was rendered with, so the user did not
764 + // choose this value — they left it alone. Writing it back would undo
765 + // whatever core already saved from WordPress's own permalink field a
766 + // moment ago, on the same save_post priority (#441).
767 + //
768 + // Compared against the BASELINE rather than the current post_name on
769 + // purpose: by the time this runs core has already updated post_name,
770 + // so that comparison cannot tell a deliberate edit from a stale one.
771 + if ($baseline !== null && $desired === sanitize_title($baseline)) {
772 + return;
773 + }
774 +
775 + if ($desired === $post->post_name) {
776 + return;
777 + }
778 +
779 + $updating = true;
780 + wp_update_post([
781 + 'ID' => $post_id,
782 + 'post_name' => $desired,
783 + ]);
784 + $updating = false;
785 + }
786 +
787 + /**
788 + * Persist per-post Open Graph and Twitter Card overrides.
789 + *
790 + * Empty values delete the meta entry so the frontend falls back to the
791 + * SEO title / meta description / featured image chain.
792 + */
793 + private function save_social_meta(int $post_id, array $src): void {
794 + $text_fields = [
795 + 'thinkrank_og_title' => '_thinkrank_og_title',
796 + 'thinkrank_og_description' => '_thinkrank_og_description',
797 + 'thinkrank_twitter_title' => '_thinkrank_twitter_title',
798 + 'thinkrank_twitter_description' => '_thinkrank_twitter_description',
799 + ];
800 + foreach ($text_fields as $field => $meta_key) {
801 + if (!isset($src[$field])) {
802 + continue;
803 + }
804 + // Template fields: the frontend resolves their variable tags, so the
805 + // %tokens% have to survive the save (#521).
806 + $value = Pattern_Resolver::sanitize_template_textarea((string) $src[$field]);
807 + if ($value === '') {
808 + delete_post_meta($post_id, $meta_key);
809 + } else {
810 + update_post_meta($post_id, $meta_key, $value);
811 + }
812 + }
813 +
814 + $url_fields = [
815 + 'thinkrank_og_image' => '_thinkrank_og_image',
816 + 'thinkrank_twitter_image' => '_thinkrank_twitter_image',
817 + ];
818 + foreach ($url_fields as $field => $meta_key) {
819 + if (!isset($src[$field])) {
820 + continue;
821 + }
822 + $value = esc_url_raw((string) $src[$field]);
823 + if ($value === '') {
824 + delete_post_meta($post_id, $meta_key);
825 + } else {
826 + update_post_meta($post_id, $meta_key, $value);
827 + }
828 + }
829 + }
830 +
831 + /**
832 + * Save per-post robots meta and advanced robots meta from the metabox.
833 + *
834 + * Stores the robots payloads as JSON-encoded strings, sanitized via
835 + * sanitize_json_meta_field. The toggle plus the two JSON blobs are the
836 + * single source of truth for per-post robots overrides.
837 + */
838 + private function save_robots_meta(int $post_id, array $src): void {
839 + if (!isset($src['thinkrank_robots_meta_enabled'])) {
840 + return;
841 + }
842 +
843 + $enabled = (int) (bool) $src['thinkrank_robots_meta_enabled'];
844 + update_post_meta($post_id, '_thinkrank_robots_meta_enabled', $enabled);
845 +
846 + if (isset($src['thinkrank_robots_meta'])) {
847 + update_post_meta($post_id, '_thinkrank_robots_meta', $this->sanitize_json_meta_field((string) $src['thinkrank_robots_meta']));
848 + }
849 +
850 + if (isset($src['thinkrank_advanced_robots_meta'])) {
851 + update_post_meta($post_id, '_thinkrank_advanced_robots_meta', $this->sanitize_json_meta_field((string) $src['thinkrank_advanced_robots_meta']));
852 + }
853 + }
854 +
855 +
856 + /**
857 + * Save the per-post listing-visibility switches.
858 + *
859 + * Stored as 1 or deleted rather than 1/0: the excluded set is read with a
860 + * `meta_value = '1'` query, so a row holding 0 would be dead weight on every
861 + * post anyone ever unticked. Deleting keeps the postmeta table proportional
862 + * to the number of posts actually hidden.
863 + *
864 + * @since 2.7.0
865 + *
866 + * @param int $post_id Post being saved.
867 + * @param array $src Submitted fields.
868 + * @return void
869 + */
870 + private function save_visibility_meta(int $post_id, array $src): void {
871 + $fields = [
872 + 'thinkrank_exclude_from_search' => \ThinkRank\SEO\Content_Visibility::SEARCH_META,
873 + 'thinkrank_exclude_from_archives' => \ThinkRank\SEO\Content_Visibility::ARCHIVE_META,
874 + ];
875 +
876 + foreach ($fields as $field => $meta_key) {
877 + if (!isset($src[$field])) {
878 + continue;
879 + }
880 +
881 + if ((bool) $src[$field]) {
882 + update_post_meta($post_id, $meta_key, 1);
883 + } else {
884 + delete_post_meta($post_id, $meta_key);
885 + }
886 + }
887 +
888 + \ThinkRank\SEO\Content_Visibility::flush();
889 + }
890 +
891 + /**
297 892 * Enqueue meta box scripts
298 893 *
299 894 * @param string $hook Current admin page hook
300 895 * @return void
@@ -300,21 +895,24 @@
300 895 * @return void
301 896 */
302 897 public function enqueue_metabox_scripts(string $hook): void {
303 898 // Only load on post edit screens (including block editor)
304 - if (!in_array($hook, ['post.php', 'post-new.php'])) {
899 + if (!in_array($hook, ['post.php', 'post-new.php'], true)) {
305 900 return;
306 901 }
307 902
308 903 // Get current post type - handle both classic and block editor contexts
309 904 $current_post_type = $this->get_current_post_type();
310 - if (!$current_post_type || !in_array($current_post_type, $this->get_supported_post_types())) {
905 + if (!$current_post_type || !in_array($current_post_type, $this->get_supported_post_types(), true)) {
311 906 return;
312 907 }
313 908
314 909 // Get post object for additional data
315 910 global $post;
316 -
911 +
912 + // Ensure wp.media is available for the social-image media picker.
913 + wp_enqueue_media();
914 +
317 915 // No chunk dependencies needed - all bundled into main metabox.js
318 916 // Enqueue React metabox script with direct dependencies
319 917 $asset_file = THINKRANK_PLUGIN_DIR . 'assets/metabox.asset.php';
320 918 $asset = file_exists($asset_file) ? include $asset_file : [
@@ -321,9 +919,13 @@
321 919 'dependencies' => ['react', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-components'],
322 920 'version' => THINKRANK_VERSION
323 921 ];
324 922
325 - // Use dependencies directly from asset file
923 + // Use dependencies directly from the asset file. The pinned "Configure
924 + // SEO" launcher needs wp-plugins (already listed by the build) and
925 + // resolves PinnedItems from wp.editor / wp.interface at runtime, so we do
926 + // NOT add wp-interface here: it is not a registered script handle on
927 + // WP 7.x, and an unmet dependency would drop the whole metabox script.
326 928 $dependencies = $asset['dependencies'];
327 929
328 930 wp_enqueue_script(
329 931 'thinkrank-metabox',
@@ -331,22 +933,82 @@
331 933 $dependencies,
332 934 $asset['version'],
333 935 true
334 936 );
335 -
336 - // Localize script data
337 - wp_localize_script('thinkrank-metabox', 'thinkrankMetabox', [
937 +
938 + // Localize script data (shared builder; reused by the Elementor editor
939 + // integration, which has no #post form / hidden inputs of its own).
940 + wp_localize_script('thinkrank-metabox', 'thinkrankMetabox', $this->get_localized_data($post->ID));
941 +
942 + // Add defer attribute for non-blocking script loading
943 + wp_script_add_data('thinkrank-metabox', 'defer', true);
944 +
945 + // Enqueue metabox styles
946 + wp_enqueue_style(
947 + 'thinkrank-metabox',
948 + THINKRANK_PLUGIN_URL . 'assets/metabox.css',
949 + ['wp-components'],
950 + THINKRANK_VERSION
951 + );
952 +
953 + // Classic Editor sidebar widget: lightweight styles + a vanilla-JS
954 + // handler so the "Optimize Here" button works without depending on the
955 + // React bundle. The box itself is only registered in the Classic Editor.
956 + $sidebar_css = <<<'CSS'
957 +.thinkrank-classic-sidebar-box-title{margin:0 0 12px;font-size:13px;line-height:1.5;color:#1e1e1e;}
958 +.thinkrank-classic-sidebar-box-cta{width:100%;text-align:center;justify-content:center;}
959 +CSS;
960 + wp_add_inline_style('thinkrank-metabox', $sidebar_css);
961 +
962 + $sidebar_js = <<<'JS'
963 +(function(){
964 + document.addEventListener('click', function(e){
965 + var btn = e.target.closest && e.target.closest('.thinkrank-classic-sidebar-box-cta');
966 + if(!btn){return;}
967 + e.preventDefault();
968 + // Open the ThinkRank SEO drawer mounted by the React metabox app.
969 + window.dispatchEvent(new CustomEvent('thinkrank:toggle-seo-drawer'));
970 + });
971 +})();
972 +JS;
973 + wp_add_inline_script('thinkrank-metabox', $sidebar_js);
974 + }
975 +
976 + /**
977 + * Build the data object localized into the metabox script (`thinkrankMetabox`).
978 + *
979 + * Extracted so the Elementor editor integration can reuse the exact same
980 + * configuration. Callers that run outside the #post form (Elementor) also
981 + * read `existingMetadata`/`contentPreview`, which they add on top of this.
982 + *
983 + * @param int $post_id Post being edited.
984 + * @return array Localized config consumed by the React metabox.
985 + */
986 + public function get_localized_data(int $post_id): array {
987 + $post = get_post($post_id);
988 + $post_type = $post ? $post->post_type : 'post';
989 +
990 + // Resolve the correct REST API base for the current post type.
991 + // Falls back to the post type slug for any type without a rest_base.
992 + $post_type_obj = get_post_type_object($post_type);
993 + $post_rest_base = ($post_type_obj && !empty($post_type_obj->rest_base))
994 + ? $post_type_obj->rest_base
995 + : $post_type;
996 +
997 + return [
338 998 'ajaxUrl' => admin_url('admin-ajax.php'),
339 999 'nonce' => wp_create_nonce('thinkrank_metabox_ajax'),
340 - 'postId' => $post->ID,
341 - 'postType' => $post->post_type,
342 - 'postPermalink' => get_permalink($post->ID),
1000 + 'postId' => $post_id,
1001 + 'postType' => $post_type,
1002 + 'postRestBase' => $post_rest_base,
1003 + 'postPermalink' => get_permalink($post_id),
1004 + 'postSlug' => $post ? $post->post_name : '',
343 1005 'restUrl' => rest_url('thinkrank/v1/'),
344 1006 'restNonce' => wp_create_nonce('wp_rest'),
345 1007 'homeUrl' => home_url(),
346 1008 'siteName' => get_bloginfo('name'),
347 1009 'faviconUrl' => $this->get_site_favicon_url(),
348 - 'featuredImageUrl' => $this->get_post_featured_image_url($post->ID),
1010 + 'featuredImageUrl' => $this->get_post_featured_image_url($post_id),
349 1011 'strings' => [
350 1012 'generating' => __('Generating...', 'thinkrank'),
351 1013 'analyzing' => __('Analyzing...', 'thinkrank'),
352 1014 'error' => __('Error occurred', 'thinkrank'),
@@ -354,22 +1016,97 @@
354 1016 'generated' => __('Metadata generated successfully', 'thinkrank'),
355 1017 'contentTooShort' => __('Please add some content before generating SEO metadata.', 'thinkrank'),
356 1018 'apiError' => __('Failed to connect to AI service. Please check your API settings.', 'thinkrank'),
357 1019 ],
358 - ]);
359 -
360 - // Enqueue metabox styles
361 - wp_enqueue_style(
362 - 'thinkrank-metabox',
363 - THINKRANK_PLUGIN_URL . 'assets/metabox.css',
364 - [],
365 - THINKRANK_VERSION
366 - );
1020 + 'seoScore' => $this->get_persisted_seo_score($post_id),
1021 + 'postModified' => $post ? get_the_modified_date('c', $post) : '',
1022 + 'linkSuggestionsEnabled' => $this->is_link_suggestions_enabled($post_type),
1023 + 'postStatus' => get_post_status($post_id),
1024 + 'isPro' => Plan_Config::is_pro(),
1025 + // Whether a provider (Pro's Redirections feature) can actually store
1026 + // a redirect. False renders the field as an upsell rather than an
1027 + // input that accepts text nothing will ever act on.
1028 + 'redirectSupported' => Object_Redirect::is_supported(),
1029 + 'redirectTypes' => Object_Redirect::TYPES,
1030 + /**
1031 + * Filter the editor SEO panel's post-load refresh behaviour.
1032 + *
1033 + * The panel re-checks `/metadata/{id}` after load so values written
1034 + * by a background writer (Auto AI on publish, imports) appear
1035 + * without a reload. It only polls while the server
1036 + * reports a write in flight, but the poll lives in JavaScript, so
1037 + * the switch has to be localized into the bundle rather than being
1038 + * a PHP-side filter alone (#329).
1039 + *
1040 + * Set `enabled` to false to switch the refresh off entirely.
1041 + *
1042 + * @since 1.30.0
1043 + *
1044 + * @param array $config enabled (bool), intervalMs (int), maxTicks (int).
1045 + * @param int $post_id Post being edited.
1046 + */
1047 + 'seoRefresh' => apply_filters(
1048 + 'thinkrank_metabox_seo_refresh',
1049 + [
1050 + 'enabled' => true,
1051 + 'intervalMs' => 4000,
1052 + 'maxTicks' => 10,
1053 + ],
1054 + $post_id
1055 + ),
1056 + // Whether any AI provider API key is configured — gates the
1057 + // "Generate with AI" button in the metabox
1058 + 'aiConfigured' => !empty($this->settings->get('openai_api_key', ''))
1059 + || !empty($this->settings->get('claude_api_key', ''))
1060 + || !empty($this->settings->get('gemini_api_key', ''))
1061 + || !empty($this->settings->get('openrouter_api_key', '')),
1062 + // Resolved Global/Bulk SEO variable-tag patterns for this post, shown
1063 + // as placeholder previews when a field is empty (the frontend applies
1064 + // these same patterns on output). Typing a value overrides them.
1065 + 'patternPreviews' => Pattern_Resolver::previews($post_id),
1066 + // Token => value map (keys without %), for live client-side preview of
1067 + // a custom pattern typed into a metabox field.
1068 + 'patternVariables' => Pattern_Resolver::variables($post_id),
1069 + ];
1070 + }
367 1071
1072 + /**
1073 + * Latest persisted SEO score for a post.
1074 + *
1075 + * Read from the scores table — the same source the posts list column and the
1076 + * Analysis panel use — so the editor badge agrees with them on load rather
1077 + * than showing 0 until the Analysis panel mounts and fetches the score.
1078 + *
1079 + * The `thinkrank_seo_score` form field is unusable for this: it is only
1080 + * written by the AI "Analyze Content" flow and is reset to 0 on every save.
1081 + *
1082 + * @param int $post_id Post ID
1083 + * @return int|null Score, or null when the post has never been analyzed.
1084 + */
1085 + private function get_persisted_seo_score(int $post_id): ?int {
1086 + $existing = $this->seo_calculator->get_existing_score_data($post_id);
1087 + $score = $existing['overall_score'] ?? null;
368 1088
1089 + return $score !== null ? (int) $score : null;
369 1090 }
370 -
1091 +
371 1092 /**
1093 + * Check if link suggestions are enabled for a post type
1094 + *
1095 + * @param string $post_type Post type to check
1096 + * @return bool True if enabled, false otherwise
1097 + */
1098 + private function is_link_suggestions_enabled(string $post_type): bool {
1099 + $settings = get_option('thinkrank_global_seo_settings', []);
1100 +
1101 + if (isset($settings[$post_type]['link_suggestions'])) {
1102 + return (bool) $settings[$post_type]['link_suggestions'];
1103 + }
1104 +
1105 + return true;
1106 + }
1107 +
1108 + /**
372 1109 * Get current post type in admin context
373 1110 *
374 1111 * Handles both classic editor and block editor contexts
375 1112 *
@@ -415,9 +1152,9 @@
415 1152 * Get supported post types
416 1153 *
417 1154 * @return array Supported post types
418 1155 */
419 - private function get_supported_post_types(): array {
1156 + public function get_supported_post_types(): array {
420 1157 $default_types = ['post', 'page'];
421 1158
422 1159 // Add WooCommerce product if available
423 1160 if (class_exists('WooCommerce')) {
@@ -426,9 +1163,9 @@
426 1163
427 1164 // Add other common e-commerce post types
428 1165 $ecommerce_types = ['product', 'shop_order', 'shop_coupon'];
429 1166 foreach ($ecommerce_types as $type) {
430 - if (post_type_exists($type) && !in_array($type, $default_types)) {
1167 + if (post_type_exists($type) && !in_array($type, $default_types, true)) {
431 1168 $default_types[] = $type;
432 1169 }
433 1170 }
434 1171
@@ -438,28 +1175,45 @@
438 1175 'show_ui' => true,
439 1176 '_builtin' => false,
440 1177 ]);
441 1178
1179 + // WordPress internals that should never carry an SEO metabox. Fixed,
1180 + // so it is built once rather than per post type.
1181 + $wp_internal_types = [
1182 + 'attachment',
1183 + 'revision',
1184 + 'nav_menu_item',
1185 + 'custom_css',
1186 + 'customize_changeset',
1187 + 'oembed_cache',
1188 + 'user_request',
1189 + 'wp_block',
1190 + 'wp_template',
1191 + 'wp_template_part',
1192 + 'wp_global_styles',
1193 + 'wp_navigation',
1194 + 'acf-field',
1195 + 'acf-field-group',
1196 + ];
1197 +
1198 + // Builder template CPTs (Bricks, Elementor, Divi, Beaver Builder) are
1199 + // layout fragments, not pages with their own SEO. Global SEO already
1200 + // refuses them; this list is shared with that policy so the two cannot
1201 + // drift apart again (#621).
1202 + if (!class_exists('\ThinkRank\SEO\Global_SEO_Post_Types')) {
1203 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-global-seo-post-types.php';
1204 + }
1205 +
442 1206 foreach ($custom_post_types as $post_type) {
443 - // Skip certain post types that shouldn't have SEO metabox
444 - $excluded_types = [
445 - 'attachment',
446 - 'revision',
447 - 'nav_menu_item',
448 - 'custom_css',
449 - 'customize_changeset',
450 - 'oembed_cache',
451 - 'user_request',
452 - 'wp_block',
453 - 'wp_template',
454 - 'wp_template_part',
455 - 'wp_global_styles',
456 - 'wp_navigation',
457 - 'acf-field',
458 - 'acf-field-group',
459 - ];
1207 + // Resolved per post type, not hoisted: the shared list runs through
1208 + // a public filter that receives the post-type object, so an
1209 + // integrator can answer differently for different post types.
1210 + $excluded_types = array_merge(
1211 + $wp_internal_types,
1212 + \ThinkRank\SEO\Global_SEO_Post_Types::excluded_post_types(get_post_type_object($post_type))
1213 + );
460 1214
461 - if (!in_array($post_type, $excluded_types) && !in_array($post_type, $default_types)) {
1215 + if (!in_array($post_type, $excluded_types, true) && !in_array($post_type, $default_types, true)) {
462 1216 $default_types[] = $post_type;
463 1217 }
464 1218 }
465 1219
@@ -464,22 +1218,165 @@
464 1218 }
465 1219
466 1220 return apply_filters('thinkrank_supported_post_types', $default_types);
467 1221 }
468 -
1222 +
469 1223 /**
1224 + * Transient holding the last redirect error for the current user.
1225 + */
1226 + private const REDIRECT_ERROR_TRANSIENT = 'thinkrank_redirect_error_';
1227 +
1228 + /**
1229 + * Why the redirect field was refused on the most recent persist, if it was.
1230 + *
1231 + * @var \WP_Error|null
1232 + */
1233 + private ?\WP_Error $last_redirect_error = null;
1234 +
1235 + /**
1236 + * Persist the edit-screen redirect field.
1237 + *
1238 + * Absent keys are left alone, so a caller that never rendered the field
1239 + * (the AJAX save from an editor that submits a subset, an import) cannot
1240 + * clear a redirect by omission.
1241 + *
1242 + * The destination is not post meta — Pro's rules table holds it — so unlike
1243 + * every other field here this save can fail for reasons the editor needs to
1244 + * hear about: no Pro, plain permalinks, a destination that is the page's own
1245 + * URL. Failing silently would be the worst of both, since the field would
1246 + * redisplay empty on the next load with no explanation, so the reason is
1247 + * stashed for the notice rendered on the next screen.
1248 + *
1249 + * @param string $object_type 'post' or 'term'.
1250 + * @param int $object_id Object ID.
1251 + * @param array $src Field name => raw value map.
1252 + * @return void
1253 + */
1254 + private function save_object_redirect(string $object_type, int $object_id, array $src): ?\WP_Error {
1255 + if (!array_key_exists('thinkrank_redirect_url', $src)) {
1256 + return null;
1257 + }
1258 +
1259 + $url = (string) $src['thinkrank_redirect_url'];
1260 +
1261 + // With no provider there is nothing to store and nothing to clear.
1262 + // Staying quiet when the field was submitted empty keeps every ordinary
1263 + // save on a free site from raising an error about a field the editor
1264 + // never touched.
1265 + if (!Object_Redirect::is_supported()) {
1266 + if ('' !== trim($url)) {
1267 + return new \WP_Error(
1268 + 'thinkrank_redirect_unsupported',
1269 + __('Redirects require ThinkRank Pro with the Redirections feature active.', 'thinkrank')
1270 + );
1271 + }
1272 + return null;
1273 + }
1274 +
1275 + $type = array_key_exists('thinkrank_redirect_type', $src)
1276 + ? $src['thinkrank_redirect_type']
1277 + : Object_Redirect::DEFAULT_TYPE;
1278 +
1279 + $result = Object_Redirect::save($object_type, $object_id, $url, $type);
1280 +
1281 + return is_wp_error($result) ? $result : null;
1282 + }
1283 +
1284 + /**
1285 + * Why the last persist_metadata() call could not store the redirect.
1286 + *
1287 + * Every other metabox field either saves or is sanitized into something
1288 + * that does; this one can be refused, and each caller reports that
1289 + * differently — a notice for the form post, a JSON field for the AJAX save,
1290 + * an error message for the MCP ability.
1291 + *
1292 + * @return \WP_Error|null
1293 + */
1294 + public function get_last_redirect_error(): ?\WP_Error {
1295 + return $this->last_redirect_error;
1296 + }
1297 +
1298 + /**
1299 + * Remember why a redirect could not be saved, for the next admin screen.
1300 + *
1301 + * @param \WP_Error $error Failure.
1302 + * @return void
1303 + */
1304 + private function store_redirect_error(\WP_Error $error): void {
1305 + $user_id = get_current_user_id();
1306 + if ($user_id <= 0) {
1307 + return;
1308 + }
1309 +
1310 + set_transient(self::REDIRECT_ERROR_TRANSIENT . $user_id, $error->get_error_message(), MINUTE_IN_SECONDS);
1311 + }
1312 +
1313 + /**
1314 + * Show, once, why the last redirect save failed.
1315 + *
1316 + * @return void
1317 + */
1318 + public function render_redirect_notice(): void {
1319 + $user_id = get_current_user_id();
1320 + if ($user_id <= 0) {
1321 + return;
1322 + }
1323 +
1324 + $key = self::REDIRECT_ERROR_TRANSIENT . $user_id;
1325 + $message = get_transient($key);
1326 +
1327 + if (!is_string($message) || '' === $message) {
1328 + return;
1329 + }
1330 +
1331 + delete_transient($key);
1332 +
1333 + printf(
1334 + '<div class="notice notice-error is-dismissible"><p>%s</p></div>',
1335 + esc_html(
1336 + sprintf(
1337 + /* translators: %s: reason the redirect was not saved. */
1338 + __('ThinkRank could not save the redirect: %s', 'thinkrank'),
1339 + $message
1340 + )
1341 + )
1342 + );
1343 + }
1344 +
1345 + /**
470 1346 * Get existing post metadata
471 1347 *
472 1348 * @param int $post_id Post ID
473 1349 * @return array Existing metadata
474 1350 */
475 - private function get_post_metadata(int $post_id): array {
1351 + public function get_post_metadata(int $post_id): array {
1352 + // One lookup: get() goes through a filter Pro answers from the database.
1353 + $redirect = Object_Redirect::get('post', $post_id);
1354 +
476 1355 return [
477 1356 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
478 1357 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
479 - 'focus_keyword' => get_post_meta($post_id, '_thinkrank_focus_keyword', true),
1358 + 'focus_keyword' => Focus_Keywords::get_primary($post_id),
1359 + 'focus_keywords' => Focus_Keywords::get($post_id),
480 1360 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
481 1361 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1362 + 'pillar_content' => get_post_meta($post_id, '_thinkrank_pillar_content', true),
1363 + 'exclude_from_search' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::SEARCH_META, true),
1364 + 'exclude_from_archives' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, true),
1365 + 'canonical_url' => get_post_meta($post_id, '_thinkrank_canonical_url', true),
1366 + // Not post meta: the rule in Pro's redirections table is the value.
1367 + // See ThinkRank\SEO\Object_Redirect.
1368 + 'redirect_url' => $redirect['url'],
1369 + 'redirect_type' => $redirect['type'],
1370 + 'robots_meta_enabled' => get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1371 + 'robots_meta' => get_post_meta($post_id, '_thinkrank_robots_meta', true),
1372 + 'advanced_robots_meta' => get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1373 + 'og_title' => get_post_meta($post_id, '_thinkrank_og_title', true),
1374 + 'og_description' => get_post_meta($post_id, '_thinkrank_og_description', true),
1375 + 'og_image' => get_post_meta($post_id, '_thinkrank_og_image', true),
1376 + 'twitter_title' => get_post_meta($post_id, '_thinkrank_twitter_title', true),
1377 + 'twitter_description' => get_post_meta($post_id, '_thinkrank_twitter_description', true),
1378 + 'twitter_image' => get_post_meta($post_id, '_thinkrank_twitter_image', true),
482 1379 ];
483 1380 }
484 1381
485 1382 /**
@@ -484,21 +1381,35 @@
484 1381
485 1382 /**
486 1383 * Get site favicon URL
487 1384 *
488 - * @return string Favicon URL
1385 + * Prefers WordPress' native Site Icon (zero HTTP). Only when no Site Icon is
1386 + * configured does it probe common favicon locations — and that probe is
1387 + * cached (including a "no favicon" sentinel) so the editor never fires
1388 + * blocking loopback HEAD requests on every load.
1389 + *
1390 + * @since 1.16.2 Prefer get_site_icon_url() and cache the fallback probe.
1391 + * @return string Favicon URL, or '' when none can be resolved.
489 1392 */
490 1393 private function get_site_favicon_url(): string {
491 - // Try to get site icon first (WordPress 4.3+)
492 - $site_icon_id = get_option('site_icon');
493 - if ($site_icon_id) {
494 - $site_icon_url = wp_get_attachment_image_url($site_icon_id, 'full');
495 - if ($site_icon_url) {
496 - return $site_icon_url;
497 - }
1394 + // Try the native Site Icon first (WordPress 4.3+) — no HTTP required.
1395 + $site_icon_url = get_site_icon_url();
1396 + if ($site_icon_url) {
1397 + return $site_icon_url;
498 1398 }
499 1399
500 - // Fallback to common favicon locations
1400 + // No Site Icon configured: fall back to probing common favicon paths.
1401 + // Cache the resolved value (empty string included) so the blocking
1402 + // loopback probe runs at most once per ~12h per site host instead of on
1403 + // every editor load.
1404 + $cache_key = 'thinkrank_favicon_url_' . md5((string) wp_parse_url(home_url(), PHP_URL_HOST));
1405 + $cached = get_transient($cache_key);
1406 + if (false !== $cached) {
1407 + return (string) $cached;
1408 + }
1409 +
1410 + $favicon_url = '';
1411 +
501 1412 $favicon_paths = [
502 1413 '/favicon.ico',
503 1414 '/favicon.png',
504 1415 '/apple-touch-icon.png',
@@ -504,17 +1415,19 @@
504 1415 '/apple-touch-icon.png',
505 1416 ];
506 1417
507 1418 foreach ($favicon_paths as $path) {
508 - $favicon_url = home_url($path);
509 - $response = wp_remote_head($favicon_url);
1419 + $candidate = home_url($path);
1420 + $response = wp_remote_head($candidate, ['timeout' => 3]);
510 1421 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
511 - return $favicon_url;
1422 + $favicon_url = $candidate;
1423 + break;
512 1424 }
513 1425 }
514 1426
515 - // Final fallback - return a default favicon URL
516 - return home_url('/favicon.ico');
1427 + set_transient($cache_key, $favicon_url, 12 * HOUR_IN_SECONDS);
1428 +
1429 + return $favicon_url;
517 1430 }
518 1431
519 1432 /**
520 1433 * Get post featured image URL
@@ -524,14 +1437,16 @@
524 1437 */
525 1438 private function get_post_featured_image_url(int $post_id): ?string {
526 1439 $thumbnail_id = get_post_thumbnail_id($post_id);
527 1440 if ($thumbnail_id) {
528 - $image_url = wp_get_attachment_image_url($thumbnail_id, 'medium');
1441 + // Use the full-size image — social share images need a large source
1442 + // (the preview scales it down), not a small thumbnail.
1443 + $image_url = wp_get_attachment_image_url($thumbnail_id, 'full');
529 1444 return $image_url ?: null;
530 1445 }
531 1446 return null;
532 1447 }
533 -
1448 +
534 1449 /**
535 1450 * Get content preview for AI analysis
536 1451 *
537 1452 * @param \WP_Post $post Post object
@@ -536,24 +1451,33 @@
536 1451 *
537 1452 * @param \WP_Post $post Post object
538 1453 * @return string Content preview
539 1454 */
540 - private function get_content_preview(\WP_Post $post): string {
1455 + public function get_content_preview(\WP_Post $post): string {
541 1456 $content = $post->post_title . "\n\n";
542 -
1457 +
543 1458 if (!empty($post->post_excerpt)) {
544 1459 $content .= $post->post_excerpt . "\n\n";
545 1460 }
546 -
547 - $content .= $post->post_content;
548 -
1461 +
1462 + // Resolve through Builder_Content: a page builder keeps its words
1463 + // outside post_content (Oxygen empties it entirely), and the editor
1464 + // cannot reach postmeta, so without this the preview handed to the
1465 + // browser is just the title and the live panel reports "No content".
1466 + if (!class_exists('\\ThinkRank\\SEO\\Builder_Content')) {
1467 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1468 + }
1469 + $content .= \ThinkRank\SEO\Builder_Content::resolve($post);
1470 +
549 1471 // Clean and limit content
550 1472 $content = wp_strip_all_tags($content);
551 1473 $content = preg_replace('/\s+/', ' ', $content);
552 -
553 - return trim(substr($content, 0, 4000));
1474 +
1475 + // substr() counts BYTES: on Thai or CJK this handed the model a third
1476 + // of the intended content and cut the last character in half (#687).
1477 + return trim(\ThinkRank\Core\Seo_Text::trim_to_length($content, 4000));
554 1478 }
555 -
1479 +
556 1480 /**
557 1481 * AJAX handler for generating post metadata
558 1482 *
559 1483 * @return void
@@ -563,15 +1487,15 @@
563 1487 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
564 1488 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
565 1489 wp_die('Security check failed');
566 1490 }
567 -
1491 +
568 1492 // Check permissions
569 1493 $post_id = absint($_POST['post_id'] ?? 0);
570 1494 if (!current_user_can('edit_post', $post_id)) {
571 1495 wp_die('Insufficient permissions');
572 1496 }
573 -
1497 +
574 1498 try {
575 1499 $options = [
576 1500 'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')),
577 1501 'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')),
@@ -576,16 +1500,15 @@
576 1500 'target_keyword' => sanitize_text_field(wp_unslash($_POST['target_keyword'] ?? '')),
577 1501 'content_type' => sanitize_text_field(wp_unslash($_POST['content_type'] ?? 'blog_post')),
578 1502 'tone' => sanitize_text_field(wp_unslash($_POST['tone'] ?? 'professional')),
579 1503 ];
580 -
1504 +
581 1505 $metadata = $this->metadata_generator->generate_for_post($post_id, $options);
582 -
1506 +
583 1507 wp_send_json_success([
584 1508 'metadata' => $metadata,
585 1509 'message' => __('SEO metadata generated successfully!', 'thinkrank'),
586 1510 ]);
587 -
588 1511 } catch (\Exception $e) {
589 1512 wp_send_json_error([
590 1513 'message' => $e->getMessage(),
591 1514 ]);
@@ -590,42 +1513,47 @@
590 1513 'message' => $e->getMessage(),
591 1514 ]);
592 1515 }
593 1516 }
594 -
1517 +
595 1518 /**
596 - * AJAX handler for saving post metadata
597 - *
1519 + * AJAX: persist the full set of metabox fields for a post.
1520 + *
1521 + * Used by editors that don't submit the #post form (Elementor). Expects the
1522 + * same field names the classic/block metabox submits, sent as POST params.
1523 + * $_POST is slashed by WordPress, matching what `persist_metadata()` expects.
1524 + *
598 1525 * @return void
599 1526 */
600 - public function ajax_save_post_metadata(): void {
601 - // Verify nonce
1527 + public function ajax_save_metabox(): void {
602 1528 $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
603 1529 if (!wp_verify_nonce($nonce, 'thinkrank_metabox_ajax')) {
604 - wp_die('Security check failed');
1530 + wp_send_json_error(['message' => __('Security check failed', 'thinkrank')], 403);
605 1531 }
606 -
607 - // Check permissions
1532 +
608 1533 $post_id = absint($_POST['post_id'] ?? 0);
609 - if (!current_user_can('edit_post', $post_id)) {
610 - wp_die('Insufficient permissions');
1534 + if (!$post_id || !current_user_can('edit_post', $post_id)) {
1535 + wp_send_json_error(['message' => __('Insufficient permissions', 'thinkrank')], 403);
611 1536 }
612 -
613 - // Save metadata
614 - $metadata = [
615 - 'title' => sanitize_text_field(wp_unslash($_POST['title'] ?? '')),
616 - 'description' => sanitize_textarea_field(wp_unslash($_POST['description'] ?? '')),
617 - 'focus_keyword' => sanitize_text_field(wp_unslash($_POST['focus_keyword'] ?? '')),
618 - 'seo_score' => absint($_POST['seo_score'] ?? 0),
619 - ];
620 -
621 - foreach ($metadata as $key => $value) {
622 - update_post_meta($post_id, "_thinkrank_{$key}", $value);
1537 +
1538 + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; each field sanitized inside persist_metadata()
1539 + $this->persist_metadata($post_id, wp_unslash($_POST));
1540 +
1541 + // Everything else saved; only the redirect can have been refused. Report
1542 + // it in this response rather than as a notice on some later screen —
1543 + // this caller never reloads the page.
1544 + $redirect_error = $this->get_last_redirect_error();
1545 + if (null !== $redirect_error) {
1546 + wp_send_json_error([
1547 + 'message' => sprintf(
1548 + /* translators: %s: reason the redirect was not saved. */
1549 + __('Saved, except the redirect: %s', 'thinkrank'),
1550 + $redirect_error->get_error_message()
1551 + ),
1552 + ], 400);
623 1553 }
624 -
625 - update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
626 -
1554 +
627 1555 wp_send_json_success([
628 - 'message' => __('Metadata saved successfully!', 'thinkrank'),
1556 + 'message' => __('SEO settings saved successfully!', 'thinkrank'),
629 1557 ]);
630 1558 }
631 1559 }