PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.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 1.28.0 1.27.0 1.26.0 1.25.0 trunk All 53 releases
← All changes | includes/seo/class-builder-content.php +414 -17 2.4.0 → 2.12.0 View file →
@@ -50,9 +50,30 @@
50 50 */
51 51 private const BUILDER_META_KEYS = [
52 52 '_breakdance_data', // Oxygen 6+ / Breakdance
53 53 '_oxygen_data', // Oxygen (earlier releases)
54 - 'ct_builder_shortcodes', // Oxygen classic
54 + // Oxygen classic. 4.x writes the tree as JSON to `ct_builder_json`
55 + // while still keeping `ct_builder_shortcodes`. A post carrying only
56 + // the JSON key used to match no key at all and fall through to an
57 + // empty `post_content`, which reads as a one-word page (#776).
58 + //
59 + // Oxygen 4.8.3 then renamed every `ct_*` post meta key to `_ct_*`
60 + // (`oxygen_vsb_update_4_8_3()` runs `oxy_prefix_meta_keys()` on
61 + // upgrade, and `oxy_get_post_meta()` only reads the prefixed name
62 + // from then on). A current Oxygen classic site therefore has only the
63 + // underscored keys, which nothing here listed, so every one of its
64 + // pages resolved as empty. The prefixed keys come first because they
65 + // are what Oxygen itself reads; the bare ones cover a site that has
66 + // not run the migration (or reverted it with `?unprefix_meta`).
67 + //
68 + // These four are not read in this loop: from_oxygen_classic() pairs
69 + // each JSON key with its shortcode sibling so the two forms can be
70 + // compared. They are listed here because this list is also what the
71 + // word-count index watches and what FAQ detection scans.
72 + '_ct_builder_json', // Oxygen classic 4.8.3+ (JSON tree)
73 + 'ct_builder_json', // Oxygen classic 4.0-4.8.2 (JSON tree)
74 + '_ct_builder_shortcodes', // Oxygen classic 4.8.3+ (shortcode tree)
75 + 'ct_builder_shortcodes', // Oxygen classic < 4.8.3 (shortcode tree)
55 76 '_elementor_data', // Elementor
56 77 // Beaver Builder. Published layout first: `_fl_builder_draft` holds
57 78 // unsaved changes and would score content the visitor cannot see.
58 79 // Both are arrays of stdClass nodes, which is why the walker below
@@ -142,11 +163,38 @@
142 163 private const CONTENT_KEYS = [
143 164 'text', 'title', 'subtitle', 'heading', 'subheading', 'content',
144 165 'description', 'caption', 'excerpt', 'label', 'value', 'html',
145 166 'editor', 'quote', 'answer', 'question', 'body', 'button_text',
167 + // Oxygen classic keeps an element's copy in `options.ct_content`
168 + // (headline, text block, rich text, link and button labels). It is the
169 + // field Oxygen's own serializer moves between the tags when it writes
170 + // shortcodes (`parse_components_tree()`), and the one Relevanssi and
171 + // Oxygen's WPML integration read. Missing from this list, the walker
172 + // kept only copy that happened to contain markup: a page of plain
173 + // headings and paragraphs lost almost all of its words.
174 + 'ct_content',
175 + // Oxygen's composite elements keep their copy under `options.original`
176 + // instead, one key per field. Taken from the list Oxygen itself treats
177 + // as text when it serializes (`$options_to_encode`); the numeric price
178 + // fields and the progress bar's right-hand percentage are left out.
179 + 'testimonial_text', 'testimonial_author', 'testimonial_author_info',
180 + 'icon_box_heading', 'icon_box_text',
181 + 'pricing_box_package_title', 'pricing_box_package_subtitle', 'pricing_box_content',
182 + 'progress_bar_left_text',
146 183 ];
147 184
148 185 /**
186 + * Oxygen classic's storage generations, as JSON key => shortcode key.
187 + *
188 + * @since 2.10.0
189 + * @var array<string,string>
190 + */
191 + private const OXYGEN_CLASSIC_KEYS = [
192 + '_ct_builder_json' => '_ct_builder_shortcodes',
193 + 'ct_builder_json' => 'ct_builder_shortcodes',
194 + ];
195 +
196 + /**
149 197 * JSON keys whose values hold a link destination.
150 198 *
151 199 * Builders store a link's destination in a structured field separate from
152 200 * its label, either as a bare URL string or as a `{ url: … }` object.
@@ -255,8 +303,22 @@
255 303 */
256 304 private const ALT_KEYS = ['alt', 'alt_text', 'image_alt', 'title'];
257 305
258 306 /**
307 + * The global post and every global `setup_postdata()` writes.
308 + *
309 + * Rendering points them at the post being analyzed, then puts each one
310 + * back exactly as it was, unset included (#860).
311 + *
312 + * @since 2.12.0
313 + * @var string[]
314 + */
315 + private const POSTDATA_GLOBALS = [
316 + 'post', 'id', 'authordata', 'currentday', 'currentmonth',
317 + 'page', 'pages', 'multipage', 'more', 'numpages',
318 + ];
319 +
320 + /**
259 321 * Resolve the content worth analyzing for a post.
260 322 *
261 323 * @param \WP_Post $post Post being analyzed.
262 324 * @return string HTML/text to analyze.
@@ -417,8 +479,23 @@
417 479 *
418 480 * @param int $post_id Post being resolved.
419 481 * @return array<int,mixed> Elements, or [] when Bricks renders nothing here.
420 482 */
483 + /**
484 + * The builder meta keys, for callers that need to inspect the raw storage
485 + * rather than the text extracted from it.
486 + *
487 + * The SEO Analyzer reads these to answer "is there a ThinkRank FAQ element
488 + * on this post?", which is a question about the stored tree, not about the
489 + * words in it (#686).
490 + *
491 + * @since 2.7.0
492 + * @return string[]
493 + */
494 + public static function builder_meta_keys(): array {
495 + return self::BUILDER_META_KEYS;
496 + }
497 +
421 498 public static function bricks_tree(int $post_id): array {
422 499 if (array_key_exists($post_id, self::$bricks_trees)) {
423 500 return self::$bricks_trees[$post_id];
424 501 }
@@ -491,9 +568,9 @@
491 568 * @param \WP_Post $post Post the markup belongs to.
492 569 * @return string Content to analyze.
493 570 */
494 571 public static function resolve_markup(string $raw, \WP_Post $post): string {
495 - $content = self::render_post_content($raw);
572 + $content = self::render_post_content($raw, $post);
496 573
497 574 // Block markup that renders to nothing usually means the builder that
498 575 // owns those blocks did not register them in this context — Divi 5
499 576 // loads its module library lazily per-request, so in CLI, REST, admin
@@ -541,19 +618,45 @@
541 618 *
542 619 * Best-effort: a third-party block that fatals must not take the whole
543 620 * score down with it.
544 621 *
545 - * @param string $raw Raw post content.
622 + * Runs as the post's own context, as it would on the front end. Admin and
623 + * REST requests have no current post, so a shortcode reading
624 + * `get_the_ID()` got nothing, and one looping a related-posts query left
625 + * the global post on the last of them: its `wp_reset_postdata()` goes back
626 + * to the main query's post, and there is none. On the Classic Editor this
627 + * runs after the form prints its hidden `post_ID` and before the title and
628 + * editor, which then showed the related post, and Update saved it over the
629 + * original (#860).
630 + *
631 + * @param string $raw Raw post content.
632 + * @param \WP_Post $post Post the content belongs to.
546 633 * @return string Rendered content.
547 634 */
548 - private static function render_post_content(string $raw): string {
635 + private static function render_post_content(string $raw, \WP_Post $post): string {
549 636 if ('' === trim($raw)) {
550 637 return '';
551 638 }
552 639
553 - $content = $raw;
640 + $content = $raw;
641 + $previous = self::snapshot_post_globals();
554 642
555 643 try {
644 + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Made current for the render, restored in finally.
645 + $GLOBALS['post'] = $post;
646 +
647 + // Fires `the_post`, which these paths never fired before: admin,
648 + // REST and cron analysis had no current post at all. That is the
649 + // same signal the front-end loop sends and it is what makes
650 + // `get_the_ID()` work inside a shortcode, but it is a new call on a
651 + // path that runs in bulk — the word-count index resolves every post
652 + // it visits — so a theme that counts views on `the_post` will count
653 + // them during indexing. Accepted deliberately: without it a
654 + // shortcode cannot resolve its own post, which is the bug (#860).
655 + if (function_exists('setup_postdata')) {
656 + setup_postdata($post);
657 + }
658 +
556 659 if (function_exists('has_blocks') && function_exists('do_blocks') && has_blocks($raw)) {
557 660 $content = do_blocks($raw);
558 661 }
559 662
@@ -562,8 +665,10 @@
562 665 $content = do_shortcode($content);
563 666 }
564 667 } catch (\Throwable $e) {
565 668 return $raw;
669 + } finally {
670 + self::restore_post_globals($previous);
566 671 }
567 672
568 673 return self::is_blank($content) ? $raw : $content;
569 674 }
@@ -568,8 +673,49 @@
568 673 return self::is_blank($content) ? $raw : $content;
569 674 }
570 675
571 676 /**
677 + * The post globals as they are now; a global that is unset has no key.
678 + *
679 + * @since 2.12.0
680 + *
681 + * @return array<string,mixed>
682 + */
683 + private static function snapshot_post_globals(): array {
684 + $snapshot = [];
685 +
686 + foreach (self::POSTDATA_GLOBALS as $name) {
687 + if (array_key_exists($name, $GLOBALS)) {
688 + $snapshot[$name] = $GLOBALS[$name];
689 + }
690 + }
691 +
692 + return $snapshot;
693 + }
694 +
695 + /**
696 + * Put the post globals back as snapshot_post_globals() found them.
697 + *
698 + * Assigned directly rather than through `setup_postdata()`: there may have
699 + * been no post to set up, and re-running it would fire `the_post` again.
700 + *
701 + * @since 2.12.0
702 + *
703 + * @param array<string,mixed> $snapshot From snapshot_post_globals().
704 + * @return void
705 + */
706 + private static function restore_post_globals(array $snapshot): void {
707 + foreach (self::POSTDATA_GLOBALS as $name) {
708 + if (array_key_exists($name, $snapshot)) {
709 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Core's own globals, put back as they were.
710 + $GLOBALS[$name] = $snapshot[$name];
711 + } else {
712 + unset($GLOBALS[$name]);
713 + }
714 + }
715 + }
716 +
717 + /**
572 718 * Extract text from the attributes of parsed blocks.
573 719 *
574 720 * @param string $raw Raw post content containing block markup.
575 721 * @return string Collected text, or '' when nothing was found.
@@ -957,8 +1103,21 @@
957 1103 return $bricks;
958 1104 }
959 1105
960 1106 foreach (self::BUILDER_META_KEYS as $key) {
1107 + if (self::is_oxygen_classic_key($key)) {
1108 + // Resolved as a pair, once, at the first of its keys.
1109 + if ('_ct_builder_json' !== $key) {
1110 + continue;
1111 + }
1112 +
1113 + $oxygen = self::from_oxygen_classic($post_id);
1114 + if (!self::is_blank($oxygen)) {
1115 + return $oxygen;
1116 + }
1117 + continue;
1118 + }
1119 +
961 1120 $stored = get_post_meta($post_id, $key, true);
962 1121
963 1122 if (is_string($stored) && '' !== trim($stored)) {
964 1123 $decoded = json_decode($stored, true);
@@ -971,20 +1130,8 @@
971 1130 }
972 1131 continue;
973 1132 }
974 1133
975 - // Shortcode tree (Oxygen classic).
976 - if (strpos($stored, '[') !== false && function_exists('do_shortcode')) {
977 - try {
978 - $rendered = do_shortcode($stored);
979 - } catch (\Throwable $e) {
980 - $rendered = $stored;
981 - }
982 - if (!self::is_blank($rendered)) {
983 - return $rendered;
984 - }
985 - }
986 -
987 1134 continue;
988 1135 }
989 1136
990 1137 // Some builders store an already-decoded tree — an array for most,
@@ -998,8 +1145,258 @@
998 1145 }
999 1146 }
1000 1147
1001 1148 return '';
1149 + }
1150 +
1151 + /**
1152 + * Whether a meta key is one of Oxygen classic's storage keys.
1153 + *
1154 + * @since 2.10.0
1155 + *
1156 + * @param string $key Meta key.
1157 + * @return bool
1158 + */
1159 + private static function is_oxygen_classic_key(string $key): bool {
1160 + return isset(self::OXYGEN_CLASSIC_KEYS[$key]) || in_array($key, self::OXYGEN_CLASSIC_KEYS, true);
1161 + }
1162 +
1163 + /**
1164 + * Text of an Oxygen classic page, from whichever stored form holds more.
1165 + *
1166 + * Oxygen 4.x keeps the same tree twice: as JSON, and as the shortcodes it
1167 + * used before 4.0. The JSON is preferred because it carries copy the
1168 + * shortcode form hides (a composite element's text is base64-encoded
1169 + * inside `ct_options`, which is configuration and stripped). It is not
1170 + * trusted blindly, though. Reading `ct_builder_json` first once meant a
1171 + * key missing from CONTENT_KEYS silently threw the page away while the
1172 + * shortcode copy sat unread next to it, because a non-empty JSON result
1173 + * stopped the search. Comparing the two means the next such gap costs
1174 + * nothing: the richer form wins.
1175 + *
1176 + * A generation is only read as a pair. The prefixed keys are what Oxygen
1177 + * 4.8.3+ reads, so an unprefixed leftover next to them is stale.
1178 + *
1179 + * @since 2.10.0
1180 + *
1181 + * @param int $post_id Post ID.
1182 + * @return string Extracted text, or '' when Oxygen classic stored nothing.
1183 + */
1184 + private static function from_oxygen_classic(int $post_id): string {
1185 + foreach (self::OXYGEN_CLASSIC_KEYS as $json_key => $shortcode_key) {
1186 + $json = get_post_meta($post_id, $json_key, true);
1187 + $shortcodes = get_post_meta($post_id, $shortcode_key, true);
1188 +
1189 + $from_json = '';
1190 + if (is_string($json) && '' !== trim($json)) {
1191 + $decoded = json_decode($json, true);
1192 + if (is_array($decoded)) {
1193 + // `[oxygen data="..."]` is a dynamic-data placeholder
1194 + // Oxygen fills at render time. The shortcode path drops it
1195 + // with every other tag, so it goes here too or the two
1196 + // forms would disagree on the same page.
1197 + $from_json = (string) preg_replace(
1198 + '/\[oxygen\b[^\]]*\]/i',
1199 + ' ',
1200 + self::text_from_tree($decoded)
1201 + );
1202 + }
1203 + }
1204 +
1205 + $from_shortcodes = '';
1206 + if (is_string($shortcodes) && strpos($shortcodes, '[') !== false) {
1207 + $from_shortcodes = self::text_from_shortcodes($shortcodes);
1208 + }
1209 +
1210 + if (self::is_blank($from_json) && self::is_blank($from_shortcodes)) {
1211 + continue;
1212 + }
1213 +
1214 + return self::visible_word_count($from_json) >= self::visible_word_count($from_shortcodes)
1215 + ? $from_json
1216 + : $from_shortcodes;
1217 + }
1218 +
1219 + return '';
1220 + }
1221 +
1222 + /**
1223 + * Rough count of the words a visitor would read in extracted text.
1224 + *
1225 + * Only used to compare two extractions of the same page, so it needs to
1226 + * be consistent rather than locale-exact.
1227 + *
1228 + * @since 2.10.0
1229 + *
1230 + * @param string $text Extracted text or markup.
1231 + * @return int
1232 + */
1233 + private static function visible_word_count(string $text): int {
1234 + $plain = trim((string) preg_replace('/\s+/u', ' ', wp_strip_all_tags($text)));
1235 +
1236 + return '' === $plain ? 0 : count(explode(' ', $plain));
1237 + }
1238 +
1239 + /**
1240 + * Shortcode attributes that carry copy a visitor reads.
1241 + *
1242 + * An allow-list, not a deny-list. Oxygen Classic tags carry far more
1243 + * attributes than they do copy — `id`, `class`, `selector`, `url`,
1244 + * `ct_options` and friends — and a deny-list silently admits every
1245 + * attribute a future builder release invents, which is how markup ends up
1246 + * being counted as prose.
1247 + *
1248 + * @var string[]
1249 + */
1250 + private const SHORTCODE_TEXT_ATTRIBUTES = [
1251 + 'text',
1252 + 'content',
1253 + 'heading',
1254 + 'title',
1255 + 'subtitle',
1256 + 'label',
1257 + 'caption',
1258 + 'description',
1259 + 'alt',
1260 + 'button_text',
1261 + 'link_text',
1262 + ];
1263 +
1264 + /**
1265 + * Extract readable text from a shortcode tree, without rendering it.
1266 + *
1267 + * Oxygen Classic is the only builder whose storage is shortcodes rather
1268 + * than JSON, and the previous implementation handed the string to
1269 + * `do_shortcode()`. That silently depends on Oxygen having registered its
1270 + * `ct_*` handlers in the current request — which it has on a front-end
1271 + * view, and has not during bulk analysis, the post-list column, cron or
1272 + * REST/MCP. With no handlers registered `do_shortcode()` returns its input
1273 + * unchanged, so the raw shortcode source was scored as if it were the
1274 + * page's prose: `[ct_section`, `id="section-1"` and the rest counted toward
1275 + * the word count, while the actual copy sitting in `text="..."` attributes
1276 + * was never counted at all (#776).
1277 + *
1278 + * `strip_shortcodes()` is no help either — it also only knows registered
1279 + * shortcodes, so it leaves the same text untouched.
1280 + *
1281 + * Reading the stored tree directly is what every other builder here already
1282 + * does, and it matches the class's stated design: no render engine, no
1283 + * dependency on load order, safe during a bulk run.
1284 + *
1285 + * Parsing unconditionally, rather than rendering when Oxygen happens to be
1286 + * loaded and parsing otherwise, is deliberate. It makes the extracted text
1287 + * the same in every context, so the score in the editor matches the score
1288 + * from a bulk run or from MCP. The old code produced whichever of the two
1289 + * the request happened to allow, which is why the same post could report
1290 + * two different word counts depending on how it was asked.
1291 + *
1292 + * The trade-off is that rendered output (resolved images, links, anything
1293 + * Oxygen pulls in from a reusable part) is no longer reflected here. For
1294 + * what this text feeds — word count, content scoring, meta-description
1295 + * fallbacks and schema text — that markup was never the point, and counting
1296 + * it only when the builder happened to be booted was the bug.
1297 + *
1298 + * @since 2.10.0
1299 + *
1300 + * @param string $stored Raw shortcode source.
1301 + * @return string Extracted text.
1302 + */
1303 + private static function text_from_shortcodes(string $stored): string {
1304 + // Oxygen stores each element's settings as a JSON blob in `ct_options`.
1305 + // It is configuration, never copy, and it contains braces and brackets
1306 + // that would otherwise confuse the tag scan below, so it goes first.
1307 + //
1308 + // The blob is matched as a balanced JSON object, not as "up to the
1309 + // next quote". Oxygen wraps it in single quotes but does not escape
1310 + // an apostrophe inside it (`"nicename":"Bob's Plumbing"`), so the
1311 + // quote-to-quote match stopped mid-value and the rest of the blob,
1312 + // `s Plumbing"}'` and all, was left in the tag and leaked into the
1313 + // text. Strings inside the object are skipped whole, so neither a quote
1314 + // nor a brace inside a value can end the match early.
1315 + $source = (string) preg_replace(
1316 + '/\sct_options\s*=\s*\'(?<obj>\{(?:[^{}"]++|"(?:[^"\\\\]|\\\\.)*+"|(?&obj))*+\})\'/s',
1317 + '',
1318 + $stored
1319 + );
1320 +
1321 + // Anything not shaped like Oxygen's JSON blob keeps the old,
1322 + // quote-delimited strip.
1323 + $source = (string) preg_replace(
1324 + '/\sct_options\s*=\s*(["\']).*?\1/s',
1325 + '',
1326 + $source
1327 + );
1328 +
1329 + $attributes = implode('|', array_map(
1330 + static fn(string $name): string => preg_quote($name, '/'),
1331 + self::SHORTCODE_TEXT_ATTRIBUTES
1332 + ));
1333 +
1334 + // Replace each shortcode tag with whatever readable copy its attributes
1335 + // carry. Text between tags is left exactly where it is, so the result
1336 + // keeps the page's reading order rather than hoisting all the headings
1337 + // to the front.
1338 + // The attribute blob is matched quote-aware rather than as "anything up
1339 + // to the first `]`". Oxygen copy contains brackets often enough to
1340 + // matter — "Best tools [2026]", "[Updated] our policy" — and a naive
1341 + // scan ends the tag inside the `text` attribute, dropping the copy
1342 + // before the bracket and leaking the stray `"]` after it into the
1343 + // prose. Which is this bug's own failure mode: the wrong text scored.
1344 + //
1345 + // A tag name must start with a letter or underscore. `[2026]` is not a
1346 + // shortcode anyone can register, and scanning it as one dropped the
1347 + // year out of "Best tools [2026]".
1348 + $text = (string) preg_replace_callback(
1349 + '/\[\/?[a-zA-Z_][a-zA-Z0-9_-]*((?:[^\]"\']|"[^"]*"|\'[^\']*\')*)\]/',
1350 + static function (array $matches) use ($attributes): string {
1351 + if ('' === trim($matches[1])) {
1352 + return ' ';
1353 + }
1354 +
1355 + if (!preg_match_all(
1356 + '/\b(' . $attributes . ')\s*=\s*(["\'])(.*?)\2/s',
1357 + $matches[1],
1358 + $found,
1359 + PREG_SET_ORDER
1360 + )) {
1361 + return ' ';
1362 + }
1363 +
1364 + $parts = [];
1365 + foreach ($found as $attribute) {
1366 + $value = trim($attribute[3]);
1367 +
1368 + // An attribute holding markup or a JSON fragment is
1369 + // configuration that happens to share a name with a copy
1370 + // field, not something a visitor reads.
1371 + if ('' === $value || preg_match('/^[\[{<]/', $value)) {
1372 + continue;
1373 + }
1374 +
1375 + $parts[] = $value;
1376 + }
1377 +
1378 + return empty($parts) ? ' ' : ' ' . implode(' ', $parts) . ' ';
1379 + },
1380 + $source
1381 + );
1382 +
1383 + // Oxygen escapes square brackets in an element's copy before writing
1384 + // it between the tags, so that "Best tools [2026]" cannot be mistaken
1385 + // for a shortcode (`oxygen_vsb_filter_shortcode_content_encode()`).
1386 + // Decoded only now, after the tag scan, for the same reason; left
1387 + // encoded, the placeholders were scored as words of their own.
1388 + $text = str_replace(
1389 + ['_OXY_OPENING_BRACKET_', '_OXY_CLOSING_BRACKET_'],
1390 + ['[', ']'],
1391 + $text
1392 + );
1393 +
1394 + // Entities are stored encoded in attributes (&amp;, &#8217;), and would
1395 + // otherwise be counted as words.
1396 + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1397 +
1398 + return trim((string) preg_replace('/\s+/u', ' ', $text));
1002 1399 }
1003 1400
1004 1401 /**
1005 1402 * A node's children, whether it stores them as an array or an object.