PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.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 All 58 releases
← All changes | includes/admin/class-metabox-manager.php +150 -22 2.7.0 → 2.14.3 View file →
@@ -242,12 +242,21 @@
242 242 *
243 243 * Validates JSON structure and recursively sanitizes all string values
244 244 * to prevent XSS and injection attacks.
245 245 *
246 - * @param string $value Raw JSON string value
246 + * Untyped on purpose: this is a registered `sanitize_callback`, so
247 + * WordPress hands it whatever any writer stored. A `string` parameter made
248 + * an array or null a fatal TypeError instead of reaching the guard below
249 + * (#1014). An array is a decoded payload and is encoded rather than lost.
250 + *
251 + * @param mixed $value Raw JSON string value, or an already-decoded array
247 252 * @return string Sanitized JSON string or empty string if invalid
248 253 */
249 - public function sanitize_json_meta_field(string $value): string {
254 + public function sanitize_json_meta_field($value): string {
255 + if (is_array($value)) {
256 + $value = (string) wp_json_encode($value);
257 + }
258 +
250 259 // Return empty string for non-string values
251 260 if (!is_string($value) || empty($value)) {
252 261 return '';
253 262 }
@@ -316,13 +325,19 @@
316 325 *
317 326 * Validates JSON structure, recursively sanitizes all string values,
318 327 * and preserves @ characters in keys (crucial for JSON-LD).
319 328 *
320 - * @param string $value Raw JSON string value
329 + * Untyped for the same reason as sanitize_json_meta_field() (#1014).
321 330 *
331 + * @param mixed $value Raw JSON string value, or an already-decoded array
332 + *
322 333 * @return string Sanitized JSON string or empty string if invalid
323 334 */
324 - public function sanitize_json_ld_field( string $value ): string {
335 + public function sanitize_json_ld_field( $value ): string {
336 + if ( is_array( $value ) ) {
337 + $value = (string) wp_json_encode( $value );
338 + }
339 +
325 340 // Return empty string for non-string values
326 341 if ( ! is_string( $value ) || empty( $value ) ) {
327 342 return '';
328 343 }
@@ -1052,14 +1067,13 @@
1052 1067 'maxTicks' => 10,
1053 1068 ],
1054 1069 $post_id
1055 1070 ),
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', '')),
1071 + // Whether the selected AI provider is configured — gates the
1072 + // "Generate with AI" button in the metabox. An OpenAI-compatible
1073 + // endpoint counts once it has a base URL and a model id, with or
1074 + // without a key (#721).
1075 + 'aiConfigured' => $this->settings->has_ai_provider_configured(),
1062 1076 // Resolved Global/Bulk SEO variable-tag patterns for this post, shown
1063 1077 // as placeholder previews when a field is empty (the frontend applies
1064 1078 // these same patterns on output). Typing a value overrides them.
1065 1079 'patternPreviews' => Pattern_Resolver::previews($post_id),
@@ -1065,8 +1079,12 @@
1065 1079 'patternPreviews' => Pattern_Resolver::previews($post_id),
1066 1080 // Token => value map (keys without %), for live client-side preview of
1067 1081 // a custom pattern typed into a metabox field.
1068 1082 'patternVariables' => Pattern_Resolver::variables($post_id),
1083 + // The uncapped copy the Content Analysis panel measures. Localized
1084 + // here so all six editors get it, rather than each builder adding
1085 + // its own (#778). `contentPreview` stays as the AI budget.
1086 + 'contentFull' => $post instanceof \WP_Post ? $this->get_analysis_content($post) : '',
1069 1087 ];
1070 1088 }
1071 1089
1072 1090 /**
@@ -1446,18 +1464,68 @@
1446 1464 return null;
1447 1465 }
1448 1466
1449 1467 /**
1450 - * Get content preview for AI analysis
1451 - *
1452 - * @param \WP_Post $post Post object
1453 - * @return string Content preview
1468 + * Characters of page text an AI caller is given.
1469 + *
1470 + * A prompt budget, not a description of the page — one caller forwards this
1471 + * straight into an AI payload, so it stays capped.
1472 + *
1473 + * @since 2.10.0
1474 + * @var int
1454 1475 */
1455 - public function get_content_preview(\WP_Post $post): string {
1456 - $content = $post->post_title . "\n\n";
1476 + private const AI_PREVIEW_LENGTH = 4000;
1457 1477
1458 - if (!empty($post->post_excerpt)) {
1459 - $content .= $post->post_excerpt . "\n\n";
1478 + /**
1479 + * Safety ceiling for the analysis copy.
1480 + *
1481 + * High enough that no real article reaches it, low enough that a runaway
1482 + * builder tree cannot put a megabyte of text through wp_localize_script
1483 + * into every editor page load.
1484 + *
1485 + * @since 2.10.0
1486 + * @var int
1487 + */
1488 + private const ANALYSIS_MAX_LENGTH = 200000;
1489 +
1490 + /**
1491 + * Resolved body text per post, for this request.
1492 + *
1493 + * Every builder integration localizes get_localized_data() (which carries
1494 + * the analysis copy) and then adds get_content_preview() on top, and the
1495 + * classic screen renders the preview into the form as well as localizing
1496 + * the analysis copy. Each of those resolved the post through
1497 + * Builder_Content from scratch, so a builder page walked its whole tree
1498 + * twice on every editor load. Both now derive from one resolution.
1499 + *
1500 + * Keyed on the post's ID and modified time, so a post saved and re-read
1501 + * within the same request is resolved again.
1502 + *
1503 + * @since 2.10.0
1504 + * @var array<string,string>
1505 + */
1506 + private array $resolved_bodies = [];
1507 +
1508 + /**
1509 + * The post's body text, resolved through whatever built it.
1510 + *
1511 + * Body only. The title and excerpt used to be prepended here, which is
1512 + * right for an AI prompt but wrong for a measurement: the Analysis panel
1513 + * counted them as body copy, so Word Count and Keyword Density in a
1514 + * builder included the title and excerpt, while the same panel in the
1515 + * block editor (which reads the editor's content) did not. They are added
1516 + * back for the AI preview only.
1517 + *
1518 + * @since 2.10.0
1519 + *
1520 + * @param \WP_Post $post Post object.
1521 + * @return string Plain text, whitespace collapsed, uncapped.
1522 + */
1523 + private function resolve_post_body(\WP_Post $post): string {
1524 + $key = $post->ID . '|' . (string) ($post->post_modified_gmt ?? '');
1525 +
1526 + if (isset($this->resolved_bodies[$key])) {
1527 + return $this->resolved_bodies[$key];
1460 1528 }
1461 1529
1462 1530 // Resolve through Builder_Content: a page builder keeps its words
1463 1531 // outside post_content (Oxygen empties it entirely), and the editor
@@ -1465,17 +1533,77 @@
1465 1533 // browser is just the title and the live panel reports "No content".
1466 1534 if (!class_exists('\\ThinkRank\\SEO\\Builder_Content')) {
1467 1535 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1468 1536 }
1469 - $content .= \ThinkRank\SEO\Builder_Content::resolve($post);
1470 1537
1471 - // Clean and limit content
1538 + $this->resolved_bodies[$key] = self::collapse_text(\ThinkRank\SEO\Builder_Content::resolve($post));
1539 +
1540 + return $this->resolved_bodies[$key];
1541 + }
1542 +
1543 + /**
1544 + * Strip tags and collapse whitespace.
1545 + *
1546 + * @since 2.10.0
1547 + *
1548 + * @param string $content Markup or text.
1549 + * @return string
1550 + */
1551 + private static function collapse_text(string $content): string {
1472 1552 $content = wp_strip_all_tags($content);
1473 - $content = preg_replace('/\s+/', ' ', $content);
1474 1553
1554 + return trim((string) preg_replace('/\s+/', ' ', $content));
1555 + }
1556 +
1557 + /**
1558 + * The post's text for the editor's Content Analysis panel.
1559 + *
1560 + * Uncapped, because this is a measurement rather than a budget. In a page
1561 + * builder there is no `core/editor` store, no TinyMCE instance and no
1562 + * `#content` textarea, so the localized string is the ONLY thing the panel
1563 + * can count — and it was being handed the 4,000-character AI preview. Word
1564 + * Count, Keyword Density, Readability and Content Quality therefore
1565 + * described the first 4,000 characters and nothing after, freezing at
1566 + * roughly 600-700 words however long the page grew (#778).
1567 + *
1568 + * Still bounded, at a ceiling no real article reaches: this rides along on
1569 + * every editor page load.
1570 + *
1571 + * The body only, like the editor content it stands in for; the title and
1572 + * excerpt belong to the AI preview (see resolve_post_body()).
1573 + *
1574 + * @since 2.10.0
1575 + *
1576 + * @param \WP_Post $post Post object.
1577 + * @return string
1578 + */
1579 + public function get_analysis_content(\WP_Post $post): string {
1580 + return \ThinkRank\Core\Seo_Text::trim_to_length(
1581 + $this->resolve_post_body($post),
1582 + self::ANALYSIS_MAX_LENGTH
1583 + );
1584 + }
1585 +
1586 + /**
1587 + * Get content preview for AI analysis
1588 + *
1589 + * @param \WP_Post $post Post object
1590 + * @return string Content preview
1591 + */
1592 + public function get_content_preview(\WP_Post $post): string {
1593 + // The title and excerpt lead the preview: they are context an AI
1594 + // caller wants first, and they are not part of the measured body.
1595 + $lead = $post->post_title . "\n\n";
1596 +
1597 + if (!empty($post->post_excerpt)) {
1598 + $lead .= $post->post_excerpt . "\n\n";
1599 + }
1600 +
1601 + $preview = trim(self::collapse_text($lead) . ' ' . $this->resolve_post_body($post));
1602 +
1475 1603 // substr() counts BYTES: on Thai or CJK this handed the model a third
1476 1604 // of the intended content and cut the last character in half (#687).
1477 - return trim(\ThinkRank\Core\Seo_Text::trim_to_length($content, 4000));
1605 + return \ThinkRank\Core\Seo_Text::trim_to_length($preview, self::AI_PREVIEW_LENGTH);
1478 1606 }
1479 1607
1480 1608 /**
1481 1609 * AJAX handler for generating post metadata