| @@ -1064,8 +1064,12 @@ | ||
| 1064 | 1064 | 'patternPreviews' => Pattern_Resolver::previews($post_id), |
| 1065 | 1065 | // Token => value map (keys without %), for live client-side preview of |
| 1066 | 1066 | // a custom pattern typed into a metabox field. |
| 1067 | 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) : '', | |
| 1068 | 1072 | ]; |
| 1069 | 1073 | } |
| 1070 | 1074 | |
| 1071 | 1075 | /** |
| @@ -1445,18 +1449,68 @@ | ||
| 1445 | 1449 | return null; |
| 1446 | 1450 | } |
| 1447 | 1451 | |
| 1448 | 1452 | /** |
| 1449 | - * Get content preview for AI analysis | |
| 1450 | - * | |
| 1451 | - * @param \WP_Post $post Post object | |
| 1452 | - * @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 | |
| 1453 | 1460 | */ |
| 1454 | - public function get_content_preview(\WP_Post $post): string { | |
| 1455 | - $content = $post->post_title . "\n\n"; | |
| 1461 | + private const AI_PREVIEW_LENGTH = 4000; | |
| 1456 | 1462 | |
| 1457 | - if (!empty($post->post_excerpt)) { | |
| 1458 | - $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]; | |
| 1459 | 1513 | } |
| 1460 | 1514 | |
| 1461 | 1515 | // Resolve through Builder_Content: a page builder keeps its words |
| 1462 | 1516 | // outside post_content (Oxygen empties it entirely), and the editor |
| @@ -1464,17 +1518,77 @@ | ||
| 1464 | 1518 | // browser is just the title and the live panel reports "No content". |
| 1465 | 1519 | if (!class_exists('\\ThinkRank\\SEO\\Builder_Content')) { |
| 1466 | 1520 | require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php'; |
| 1467 | 1521 | } |
| 1468 | - $content .= \ThinkRank\SEO\Builder_Content::resolve($post); | |
| 1469 | 1522 | |
| 1470 | - // 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 { | |
| 1471 | 1537 | $content = wp_strip_all_tags($content); |
| 1472 | - $content = preg_replace('/\s+/', ' ', $content); | |
| 1473 | 1538 | |
| 1539 | + return trim((string) preg_replace('/\s+/', ' ', $content)); | |
| 1540 | + } | |
| 1541 | + | |
| 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 | + | |
| 1474 | 1588 | // substr() counts BYTES: on Thai or CJK this handed the model a third |
| 1475 | 1589 | // of the intended content and cut the last character in half (#687). |
| 1476 | - return trim(\ThinkRank\Core\Seo_Text::trim_to_length($content, 4000)); | |
| 1590 | + return \ThinkRank\Core\Seo_Text::trim_to_length($preview, self::AI_PREVIEW_LENGTH); | |
| 1477 | 1591 | } |
| 1478 | 1592 | |
| 1479 | 1593 | /** |
| 1480 | 1594 | * AJAX handler for generating post metadata |