PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 1.0.1 All 51 releases
← All changes | includes/admin/class-metabox-manager.php +407 -40 2.1.0 → 2.10.0 View file →
@@ -18,8 +18,9 @@
18 18 use ThinkRank\Core\Settings;
19 19 use ThinkRank\Core\Database;
20 20 use ThinkRank\Core\Plan_Config;
21 21 use ThinkRank\SEO\Focus_Keywords;
22 +use ThinkRank\SEO\Object_Redirect;
22 23 use ThinkRank\SEO\Pattern_Resolver;
23 24
24 25 // Prevent direct access
25 26 if (!defined('ABSPATH')) {
@@ -77,8 +78,9 @@
77 78 public function init(): void {
78 79 add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
79 80 add_action('save_post', [$this, 'save_meta_boxes'], 10, 2);
80 81 add_action('admin_enqueue_scripts', [$this, 'enqueue_metabox_scripts']);
82 + add_action('admin_notices', [$this, 'render_redirect_notice']);
81 83 add_action('init', [$this, 'register_meta_fields']);
82 84
83 85 // AJAX handlers for meta box functionality
84 86 add_action('wp_ajax_thinkrank_generate_post_metadata', [$this, 'ajax_generate_post_metadata']);
@@ -206,8 +208,26 @@
206 208 return current_user_can('edit_posts') || current_user_can('edit_pages');
207 209 }
208 210 ]);
209 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 +
210 230 register_post_meta('', '_thinkrank_primary_category', [
211 231 'show_in_rest' => true,
212 232 'single' => true,
213 233 'type' => 'integer',
@@ -483,9 +503,22 @@
483 503 <input type="hidden" id="thinkrank_focus_keywords" name="thinkrank_focus_keywords" value="<?php echo esc_attr(wp_json_encode($existing_metadata['focus_keywords'] ?? [])); ?>" />
484 504 <input type="hidden" id="thinkrank_seo_score" name="thinkrank_seo_score" value="<?php echo esc_attr($existing_metadata['seo_score'] ?? '0'); ?>" />
485 505 <input type="hidden" id="thinkrank_generated_at" name="thinkrank_generated_at" value="<?php echo esc_attr($existing_metadata['generated_at'] ?? ''); ?>" />
486 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'] ?? '')); ?>" />
487 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; ?>
488 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')); ?>" />
489 522 <input type="hidden" id="thinkrank_robots_meta" name="thinkrank_robots_meta" value="<?php echo esc_attr((string) ($existing_metadata['robots_meta'] ?? '')); ?>" />
490 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'] ?? '')); ?>" />
491 524 <input type="hidden" id="thinkrank_og_title" name="thinkrank_og_title" value="<?php echo esc_attr((string) ($existing_metadata['og_title'] ?? '')); ?>" />
@@ -532,8 +565,16 @@
532 565 // #post form, so they arrive (slashed) in $_POST. Hand them straight to
533 566 // the shared persistence routine.
534 567 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above
535 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 + }
536 577 }
537 578
538 579 /**
539 580 * Persist metabox fields for a post from a form-field-name => value map,
@@ -571,15 +612,21 @@
571 612 // written out-of-band (Auto AI on publish, imports)
572 613 // after an editor was opened, so a plain save from that now-stale editor
573 614 // would clobber the generated value with a blank. Focus keywords are
574 615 // likewise handled separately (array meta) via Focus_Keywords below.
575 - $this->persist_seo_text_field($post_id, $src, 'thinkrank_seo_title', '_thinkrank_seo_title', 'sanitize_text_field');
576 - $this->persist_seo_text_field($post_id, $src, 'thinkrank_meta_description', '_thinkrank_meta_description', 'sanitize_textarea_field');
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']);
577 622
578 623 $fields = [
579 624 'thinkrank_seo_score' => 'absint',
580 625 'thinkrank_generated_at' => 'sanitize_text_field',
581 626 'thinkrank_pillar_content' => 'sanitize_text_field',
627 + 'thinkrank_exclude_from_search' => 'sanitize_text_field',
628 + 'thinkrank_exclude_from_archives' => 'sanitize_text_field',
582 629 ];
583 630
584 631 // Focus keywords: prefer the JSON array field; fall back to the legacy
585 632 // single string. Focus_Keywords::save() normalizes (dedupe, drop empty,
@@ -619,8 +666,10 @@
619 666 update_post_meta($post_id, '_thinkrank_canonical_url', $canonical_url);
620 667 }
621 668 }
622 669
670 + $this->last_redirect_error = $this->save_object_redirect('post', $post_id, $src);
671 +
623 672 foreach ($fields as $field => $sanitize_callback) {
624 673 if (isset($src[$field])) {
625 674 $value = call_user_func($sanitize_callback, $src[$field]);
626 675 update_post_meta($post_id, "_{$field}", $value);
@@ -627,8 +676,9 @@
627 676 }
628 677 }
629 678
630 679 $this->save_robots_meta($post_id, $src);
680 + $this->save_visibility_meta($post_id, $src);
631 681 $this->save_social_meta($post_id, $src);
632 682
633 683 // Update last modified timestamp
634 684 update_post_meta($post_id, '_thinkrank_last_updated', current_time('mysql'));
@@ -750,9 +800,11 @@
750 800 foreach ($text_fields as $field => $meta_key) {
751 801 if (!isset($src[$field])) {
752 802 continue;
753 803 }
754 - $value = sanitize_textarea_field((string) $src[$field]);
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]);
755 807 if ($value === '') {
756 808 delete_post_meta($post_id, $meta_key);
757 809 } else {
758 810 update_post_meta($post_id, $meta_key, $value);
@@ -801,8 +853,43 @@
801 853 }
802 854
803 855
804 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 + /**
805 892 * Enqueue meta box scripts
806 893 *
807 894 * @param string $hook Current admin page hook
808 895 * @return void
@@ -934,8 +1021,13 @@
934 1021 'postModified' => $post ? get_the_modified_date('c', $post) : '',
935 1022 'linkSuggestionsEnabled' => $this->is_link_suggestions_enabled($post_type),
936 1023 'postStatus' => get_post_status($post_id),
937 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,
938 1030 /**
939 1031 * Filter the editor SEO panel's post-load refresh behaviour.
940 1032 *
941 1033 * The panel re-checks `/metadata/{id}` after load so values written
@@ -960,16 +1052,13 @@
960 1052 'maxTicks' => 10,
961 1053 ],
962 1054 $post_id
963 1055 ),
964 - // Whether any AI provider API key is configured — gates the
965 - // "Generate with AI" button in the metabox
966 - 'aiConfigured' => !empty($this->settings->get('openai_api_key', ''))
967 - || !empty($this->settings->get('claude_api_key', ''))
968 - || !empty($this->settings->get('gemini_api_key', ''))
969 - || !empty($this->settings->get('openrouter_api_key', '')),
970 - // Focus keywords plan limits (max_keywords; 0 = unlimited).
971 - 'focusKeywords' => Plan_Config::focus_keywords(),
1056 + // Whether the selected AI provider is configured — gates the
1057 + // "Generate with AI" button in the metabox. An OpenAI-compatible
1058 + // endpoint counts once it has a base URL and a model id, with or
1059 + // without a key (#721).
1060 + 'aiConfigured' => $this->settings->has_ai_provider_configured(),
972 1061 // Resolved Global/Bulk SEO variable-tag patterns for this post, shown
973 1062 // as placeholder previews when a field is empty (the frontend applies
974 1063 // these same patterns on output). Typing a value overrides them.
975 1064 'patternPreviews' => Pattern_Resolver::previews($post_id),
@@ -975,8 +1064,12 @@
975 1064 'patternPreviews' => Pattern_Resolver::previews($post_id),
976 1065 // Token => value map (keys without %), for live client-side preview of
977 1066 // a custom pattern typed into a metabox field.
978 1067 'patternVariables' => Pattern_Resolver::variables($post_id),
1068 + // The uncapped copy the Content Analysis panel measures. Localized
1069 + // here so all six editors get it, rather than each builder adding
1070 + // its own (#778). `contentPreview` stays as the AI budget.
1071 + 'contentFull' => $post instanceof \WP_Post ? $this->get_analysis_content($post) : '',
979 1072 ];
980 1073 }
981 1074
982 1075 /**
@@ -1085,26 +1178,43 @@
1085 1178 'show_ui' => true,
1086 1179 '_builtin' => false,
1087 1180 ]);
1088 1181
1182 + // WordPress internals that should never carry an SEO metabox. Fixed,
1183 + // so it is built once rather than per post type.
1184 + $wp_internal_types = [
1185 + 'attachment',
1186 + 'revision',
1187 + 'nav_menu_item',
1188 + 'custom_css',
1189 + 'customize_changeset',
1190 + 'oembed_cache',
1191 + 'user_request',
1192 + 'wp_block',
1193 + 'wp_template',
1194 + 'wp_template_part',
1195 + 'wp_global_styles',
1196 + 'wp_navigation',
1197 + 'acf-field',
1198 + 'acf-field-group',
1199 + ];
1200 +
1201 + // Builder template CPTs (Bricks, Elementor, Divi, Beaver Builder) are
1202 + // layout fragments, not pages with their own SEO. Global SEO already
1203 + // refuses them; this list is shared with that policy so the two cannot
1204 + // drift apart again (#621).
1205 + if (!class_exists('\ThinkRank\SEO\Global_SEO_Post_Types')) {
1206 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-global-seo-post-types.php';
1207 + }
1208 +
1089 1209 foreach ($custom_post_types as $post_type) {
1090 - // Skip certain post types that shouldn't have SEO metabox
1091 - $excluded_types = [
1092 - 'attachment',
1093 - 'revision',
1094 - 'nav_menu_item',
1095 - 'custom_css',
1096 - 'customize_changeset',
1097 - 'oembed_cache',
1098 - 'user_request',
1099 - 'wp_block',
1100 - 'wp_template',
1101 - 'wp_template_part',
1102 - 'wp_global_styles',
1103 - 'wp_navigation',
1104 - 'acf-field',
1105 - 'acf-field-group',
1106 - ];
1210 + // Resolved per post type, not hoisted: the shared list runs through
1211 + // a public filter that receives the post-type object, so an
1212 + // integrator can answer differently for different post types.
1213 + $excluded_types = array_merge(
1214 + $wp_internal_types,
1215 + \ThinkRank\SEO\Global_SEO_Post_Types::excluded_post_types(get_post_type_object($post_type))
1216 + );
1107 1217
1108 1218 if (!in_array($post_type, $excluded_types, true) && !in_array($post_type, $default_types, true)) {
1109 1219 $default_types[] = $post_type;
1110 1220 }
@@ -1113,8 +1223,130 @@
1113 1223 return apply_filters('thinkrank_supported_post_types', $default_types);
1114 1224 }
1115 1225
1116 1226 /**
1227 + * Transient holding the last redirect error for the current user.
1228 + */
1229 + private const REDIRECT_ERROR_TRANSIENT = 'thinkrank_redirect_error_';
1230 +
1231 + /**
1232 + * Why the redirect field was refused on the most recent persist, if it was.
1233 + *
1234 + * @var \WP_Error|null
1235 + */
1236 + private ?\WP_Error $last_redirect_error = null;
1237 +
1238 + /**
1239 + * Persist the edit-screen redirect field.
1240 + *
1241 + * Absent keys are left alone, so a caller that never rendered the field
1242 + * (the AJAX save from an editor that submits a subset, an import) cannot
1243 + * clear a redirect by omission.
1244 + *
1245 + * The destination is not post meta — Pro's rules table holds it — so unlike
1246 + * every other field here this save can fail for reasons the editor needs to
1247 + * hear about: no Pro, plain permalinks, a destination that is the page's own
1248 + * URL. Failing silently would be the worst of both, since the field would
1249 + * redisplay empty on the next load with no explanation, so the reason is
1250 + * stashed for the notice rendered on the next screen.
1251 + *
1252 + * @param string $object_type 'post' or 'term'.
1253 + * @param int $object_id Object ID.
1254 + * @param array $src Field name => raw value map.
1255 + * @return void
1256 + */
1257 + private function save_object_redirect(string $object_type, int $object_id, array $src): ?\WP_Error {
1258 + if (!array_key_exists('thinkrank_redirect_url', $src)) {
1259 + return null;
1260 + }
1261 +
1262 + $url = (string) $src['thinkrank_redirect_url'];
1263 +
1264 + // With no provider there is nothing to store and nothing to clear.
1265 + // Staying quiet when the field was submitted empty keeps every ordinary
1266 + // save on a free site from raising an error about a field the editor
1267 + // never touched.
1268 + if (!Object_Redirect::is_supported()) {
1269 + if ('' !== trim($url)) {
1270 + return new \WP_Error(
1271 + 'thinkrank_redirect_unsupported',
1272 + __('Redirects require ThinkRank Pro with the Redirections feature active.', 'thinkrank')
1273 + );
1274 + }
1275 + return null;
1276 + }
1277 +
1278 + $type = array_key_exists('thinkrank_redirect_type', $src)
1279 + ? $src['thinkrank_redirect_type']
1280 + : Object_Redirect::DEFAULT_TYPE;
1281 +
1282 + $result = Object_Redirect::save($object_type, $object_id, $url, $type);
1283 +
1284 + return is_wp_error($result) ? $result : null;
1285 + }
1286 +
1287 + /**
1288 + * Why the last persist_metadata() call could not store the redirect.
1289 + *
1290 + * Every other metabox field either saves or is sanitized into something
1291 + * that does; this one can be refused, and each caller reports that
1292 + * differently — a notice for the form post, a JSON field for the AJAX save,
1293 + * an error message for the MCP ability.
1294 + *
1295 + * @return \WP_Error|null
1296 + */
1297 + public function get_last_redirect_error(): ?\WP_Error {
1298 + return $this->last_redirect_error;
1299 + }
1300 +
1301 + /**
1302 + * Remember why a redirect could not be saved, for the next admin screen.
1303 + *
1304 + * @param \WP_Error $error Failure.
1305 + * @return void
1306 + */
1307 + private function store_redirect_error(\WP_Error $error): void {
1308 + $user_id = get_current_user_id();
1309 + if ($user_id <= 0) {
1310 + return;
1311 + }
1312 +
1313 + set_transient(self::REDIRECT_ERROR_TRANSIENT . $user_id, $error->get_error_message(), MINUTE_IN_SECONDS);
1314 + }
1315 +
1316 + /**
1317 + * Show, once, why the last redirect save failed.
1318 + *
1319 + * @return void
1320 + */
1321 + public function render_redirect_notice(): void {
1322 + $user_id = get_current_user_id();
1323 + if ($user_id <= 0) {
1324 + return;
1325 + }
1326 +
1327 + $key = self::REDIRECT_ERROR_TRANSIENT . $user_id;
1328 + $message = get_transient($key);
1329 +
1330 + if (!is_string($message) || '' === $message) {
1331 + return;
1332 + }
1333 +
1334 + delete_transient($key);
1335 +
1336 + printf(
1337 + '<div class="notice notice-error is-dismissible"><p>%s</p></div>',
1338 + esc_html(
1339 + sprintf(
1340 + /* translators: %s: reason the redirect was not saved. */
1341 + __('ThinkRank could not save the redirect: %s', 'thinkrank'),
1342 + $message
1343 + )
1344 + )
1345 + );
1346 + }
1347 +
1348 + /**
1117 1349 * Get existing post metadata
1118 1350 *
1119 1351 * @param int $post_id Post ID
1120 1352 * @return array Existing metadata
@@ -1119,8 +1351,11 @@
1119 1351 * @param int $post_id Post ID
1120 1352 * @return array Existing metadata
1121 1353 */
1122 1354 public function get_post_metadata(int $post_id): array {
1355 + // One lookup: get() goes through a filter Pro answers from the database.
1356 + $redirect = Object_Redirect::get('post', $post_id);
1357 +
1123 1358 return [
1124 1359 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true),
1125 1360 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true),
1126 1361 'focus_keyword' => Focus_Keywords::get_primary($post_id),
@@ -1127,9 +1362,15 @@
1127 1362 'focus_keywords' => Focus_Keywords::get($post_id),
1128 1363 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
1129 1364 'generated_at' => get_post_meta($post_id, '_thinkrank_generated_at', true),
1130 1365 'pillar_content' => get_post_meta($post_id, '_thinkrank_pillar_content', true),
1366 + 'exclude_from_search' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::SEARCH_META, true),
1367 + 'exclude_from_archives' => get_post_meta($post_id, \ThinkRank\SEO\Content_Visibility::ARCHIVE_META, true),
1131 1368 'canonical_url' => get_post_meta($post_id, '_thinkrank_canonical_url', true),
1369 + // Not post meta: the rule in Pro's redirections table is the value.
1370 + // See ThinkRank\SEO\Object_Redirect.
1371 + 'redirect_url' => $redirect['url'],
1372 + 'redirect_type' => $redirect['type'],
1132 1373 'robots_meta_enabled' => get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1133 1374 'robots_meta' => get_post_meta($post_id, '_thinkrank_robots_meta', true),
1134 1375 'advanced_robots_meta' => get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1135 1376 'og_title' => get_post_meta($post_id, '_thinkrank_og_title', true),
@@ -1208,18 +1449,68 @@
1208 1449 return null;
1209 1450 }
1210 1451
1211 1452 /**
1212 - * Get content preview for AI analysis
1213 - *
1214 - * @param \WP_Post $post Post object
1215 - * @return string Content preview
1453 + * Characters of page text an AI caller is given.
1454 + *
1455 + * A prompt budget, not a description of the page — one caller forwards this
1456 + * straight into an AI payload, so it stays capped.
1457 + *
1458 + * @since 2.10.0
1459 + * @var int
1216 1460 */
1217 - public function get_content_preview(\WP_Post $post): string {
1218 - $content = $post->post_title . "\n\n";
1461 + private const AI_PREVIEW_LENGTH = 4000;
1219 1462
1220 - if (!empty($post->post_excerpt)) {
1221 - $content .= $post->post_excerpt . "\n\n";
1463 + /**
1464 + * Safety ceiling for the analysis copy.
1465 + *
1466 + * High enough that no real article reaches it, low enough that a runaway
1467 + * builder tree cannot put a megabyte of text through wp_localize_script
1468 + * into every editor page load.
1469 + *
1470 + * @since 2.10.0
1471 + * @var int
1472 + */
1473 + private const ANALYSIS_MAX_LENGTH = 200000;
1474 +
1475 + /**
1476 + * Resolved body text per post, for this request.
1477 + *
1478 + * Every builder integration localizes get_localized_data() (which carries
1479 + * the analysis copy) and then adds get_content_preview() on top, and the
1480 + * classic screen renders the preview into the form as well as localizing
1481 + * the analysis copy. Each of those resolved the post through
1482 + * Builder_Content from scratch, so a builder page walked its whole tree
1483 + * twice on every editor load. Both now derive from one resolution.
1484 + *
1485 + * Keyed on the post's ID and modified time, so a post saved and re-read
1486 + * within the same request is resolved again.
1487 + *
1488 + * @since 2.10.0
1489 + * @var array<string,string>
1490 + */
1491 + private array $resolved_bodies = [];
1492 +
1493 + /**
1494 + * The post's body text, resolved through whatever built it.
1495 + *
1496 + * Body only. The title and excerpt used to be prepended here, which is
1497 + * right for an AI prompt but wrong for a measurement: the Analysis panel
1498 + * counted them as body copy, so Word Count and Keyword Density in a
1499 + * builder included the title and excerpt, while the same panel in the
1500 + * block editor (which reads the editor's content) did not. They are added
1501 + * back for the AI preview only.
1502 + *
1503 + * @since 2.10.0
1504 + *
1505 + * @param \WP_Post $post Post object.
1506 + * @return string Plain text, whitespace collapsed, uncapped.
1507 + */
1508 + private function resolve_post_body(\WP_Post $post): string {
1509 + $key = $post->ID . '|' . (string) ($post->post_modified_gmt ?? '');
1510 +
1511 + if (isset($this->resolved_bodies[$key])) {
1512 + return $this->resolved_bodies[$key];
1222 1513 }
1223 1514
1224 1515 // Resolve through Builder_Content: a page builder keeps its words
1225 1516 // outside post_content (Oxygen empties it entirely), and the editor
@@ -1227,18 +1518,80 @@
1227 1518 // browser is just the title and the live panel reports "No content".
1228 1519 if (!class_exists('\\ThinkRank\\SEO\\Builder_Content')) {
1229 1520 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1230 1521 }
1231 - $content .= \ThinkRank\SEO\Builder_Content::resolve($post);
1232 1522
1233 - // Clean and limit content
1523 + $this->resolved_bodies[$key] = self::collapse_text(\ThinkRank\SEO\Builder_Content::resolve($post));
1524 +
1525 + return $this->resolved_bodies[$key];
1526 + }
1527 +
1528 + /**
1529 + * Strip tags and collapse whitespace.
1530 + *
1531 + * @since 2.10.0
1532 + *
1533 + * @param string $content Markup or text.
1534 + * @return string
1535 + */
1536 + private static function collapse_text(string $content): string {
1234 1537 $content = wp_strip_all_tags($content);
1235 - $content = preg_replace('/\s+/', ' ', $content);
1236 1538
1237 - return trim(substr($content, 0, 4000));
1539 + return trim((string) preg_replace('/\s+/', ' ', $content));
1238 1540 }
1239 1541
1240 1542 /**
1543 + * The post's text for the editor's Content Analysis panel.
1544 + *
1545 + * Uncapped, because this is a measurement rather than a budget. In a page
1546 + * builder there is no `core/editor` store, no TinyMCE instance and no
1547 + * `#content` textarea, so the localized string is the ONLY thing the panel
1548 + * can count — and it was being handed the 4,000-character AI preview. Word
1549 + * Count, Keyword Density, Readability and Content Quality therefore
1550 + * described the first 4,000 characters and nothing after, freezing at
1551 + * roughly 600-700 words however long the page grew (#778).
1552 + *
1553 + * Still bounded, at a ceiling no real article reaches: this rides along on
1554 + * every editor page load.
1555 + *
1556 + * The body only, like the editor content it stands in for; the title and
1557 + * excerpt belong to the AI preview (see resolve_post_body()).
1558 + *
1559 + * @since 2.10.0
1560 + *
1561 + * @param \WP_Post $post Post object.
1562 + * @return string
1563 + */
1564 + public function get_analysis_content(\WP_Post $post): string {
1565 + return \ThinkRank\Core\Seo_Text::trim_to_length(
1566 + $this->resolve_post_body($post),
1567 + self::ANALYSIS_MAX_LENGTH
1568 + );
1569 + }
1570 +
1571 + /**
1572 + * Get content preview for AI analysis
1573 + *
1574 + * @param \WP_Post $post Post object
1575 + * @return string Content preview
1576 + */
1577 + public function get_content_preview(\WP_Post $post): string {
1578 + // The title and excerpt lead the preview: they are context an AI
1579 + // caller wants first, and they are not part of the measured body.
1580 + $lead = $post->post_title . "\n\n";
1581 +
1582 + if (!empty($post->post_excerpt)) {
1583 + $lead .= $post->post_excerpt . "\n\n";
1584 + }
1585 +
1586 + $preview = trim(self::collapse_text($lead) . ' ' . $this->resolve_post_body($post));
1587 +
1588 + // substr() counts BYTES: on Thai or CJK this handed the model a third
1589 + // of the intended content and cut the last character in half (#687).
1590 + return \ThinkRank\Core\Seo_Text::trim_to_length($preview, self::AI_PREVIEW_LENGTH);
1591 + }
1592 +
1593 + /**
1241 1594 * AJAX handler for generating post metadata
1242 1595 *
1243 1596 * @return void
1244 1597 */
@@ -1296,8 +1649,22 @@
1296 1649 }
1297 1650
1298 1651 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; each field sanitized inside persist_metadata()
1299 1652 $this->persist_metadata($post_id, wp_unslash($_POST));
1653 +
1654 + // Everything else saved; only the redirect can have been refused. Report
1655 + // it in this response rather than as a notice on some later screen —
1656 + // this caller never reloads the page.
1657 + $redirect_error = $this->get_last_redirect_error();
1658 + if (null !== $redirect_error) {
1659 + wp_send_json_error([
1660 + 'message' => sprintf(
1661 + /* translators: %s: reason the redirect was not saved. */
1662 + __('Saved, except the redirect: %s', 'thinkrank'),
1663 + $redirect_error->get_error_message()
1664 + ),
1665 + ], 400);
1666 + }
1300 1667
1301 1668 wp_send_json_success([
1302 1669 'message' => __('SEO settings saved successfully!', 'thinkrank'),
1303 1670 ]);