PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.0
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 1.28.0 1.27.0 All 56 releases
← All changes | includes/seo/class-builder-content.php +57 -344 2.14.1 → 2.14.0 View file →
@@ -40,22 +40,18 @@
40 40
41 41 /**
42 42 * Post meta keys that hold builder data, in priority order.
43 43 *
44 - * Several generations of the same builder are listed on purpose. Oxygen 6
45 - * is Breakdance under the hood and writes the same tree, under its own
46 - * prefix: Breakdance keeps it in `_breakdance_data`, Oxygen 6 in
47 - * `_oxygen_data` (the key is `__bdox('_meta_prefix') . 'data'`, and the
48 - * prefix is `_oxygen_` under Oxygen). Both store it inside a
49 - * `tree_json_string` envelope, see unwrap_tree_envelope(). Earlier Oxygen
50 - * releases used the shortcode-based `ct_builder_shortcodes` and its JSON
51 - * sibling. A site can only have one of them.
44 + * Several generations of the same builder are listed on purpose: Oxygen 6
45 + * is Breakdance under the hood (`_breakdance_data`), while earlier Oxygen
46 + * releases used `_oxygen_data` or the shortcode-based
47 + * `ct_builder_shortcodes`. A site can only have one of them.
52 48 *
53 49 * @var string[]
54 50 */
55 51 private const BUILDER_META_KEYS = [
56 - '_breakdance_data', // Breakdance
57 - '_oxygen_data', // Oxygen 6+ (Breakdance engine, Oxygen prefix)
52 + '_breakdance_data', // Oxygen 6+ / Breakdance
53 + '_oxygen_data', // Oxygen (earlier releases)
58 54 // Oxygen classic. 4.x writes the tree as JSON to `ct_builder_json`
59 55 // while still keeping `ct_builder_shortcodes`. A post carrying only
60 56 // the JSON key used to match no key at all and fall through to an
61 57 // empty `post_content`, which reads as a one-word page (#776).
@@ -135,42 +131,8 @@
135 131 */
136 132 private const BRICKS_POST_CONTENT_ELEMENT = 'post-content';
137 133
138 134 /**
139 - * Bricks' Heading element, and the tag it renders when none is stored.
140 - *
141 - * Bricks leaves a setting out of storage while it equals its default, so a
142 - * Heading left on its default tag is stored with no `tag` at all. Bricks
143 - * 2.4.1 renders it as `h3` (`Element_Heading::$tag`, overridable by the
144 - * active theme style's `tag`), and the walker, which only wraps text whose
145 - * node names a tag, read it as body copy (#908).
146 - *
147 - * @since 2.15.0
148 - * @var string
149 - */
150 - private const BRICKS_HEADING_ELEMENT = 'heading';
151 -
152 - /**
153 - * Tag a Bricks Heading renders when neither it nor a theme style sets one.
154 - *
155 - * @since 2.15.0
156 - * @var string
157 - */
158 - private const BRICKS_HEADING_DEFAULT_TAG = 'h3';
159 -
160 - /**
161 - * Tag an Elementor Heading widget renders when `header_size` is not stored.
162 - *
163 - * Elementor saves `settings.toJSON({ remove: ['default'] })`, so a heading
164 - * left on its default size has no `header_size` in `_elementor_data`, and
165 - * that default is `h2` (#908).
166 - *
167 - * @since 2.15.0
168 - * @var string
169 - */
170 - private const ELEMENTOR_HEADING_DEFAULT_TAG = 'h2';
171 -
172 - /**
173 135 * Resolved Bricks trees for this request, keyed by post ID.
174 136 *
175 137 * Rendering one page asks for the tree about twenty times — every
176 138 * description, every schema node, the FAQ guard — and resolving it is not
@@ -580,82 +542,12 @@
580 542 if (!is_array($stored) || empty($stored)) {
581 543 return [];
582 544 }
583 545
584 - return self::expand_bricks_components(self::bricks_render_order($stored));
546 + return self::expand_bricks_components($stored);
585 547 }
586 548
587 549 /**
588 - * A flat Bricks element list, in the order Bricks renders it.
589 - *
590 - * Bricks stores one flat list and links it with `parent` and `children`
591 - * ids. `Frontend::render_data()` renders the root elements in list order
592 - * and each element's children in the order of its `children` array, so a
593 - * child's position in the list says nothing about where it appears on the
594 - * page. Walking the list as stored put a section's contents wherever they
595 - * happened to be saved (#907).
596 - *
597 - * Anything the walk does not reach (an orphan, a cycle) keeps its stored
598 - * position after the rest, so no copy is dropped.
599 - *
600 - * @since 2.15.0
601 - *
602 - * @param array $elements Flat Bricks element list.
603 - * @return array The same elements, in render order.
604 - */
605 - private static function bricks_render_order(array $elements): array {
606 - $by_id = [];
607 - foreach ($elements as $index => $element) {
608 - $id = is_array($element) ? ($element['id'] ?? null) : null;
609 - if (is_scalar($id) && '' !== (string) $id && !isset($by_id[(string) $id])) {
610 - $by_id[(string) $id] = $index;
611 - }
612 - }
613 -
614 - if (empty($by_id)) {
615 - return $elements;
616 - }
617 -
618 - $ordered = [];
619 - $placed = [];
620 -
621 - $place = static function ($index) use (&$place, &$ordered, &$placed, $elements, $by_id): void {
622 - if (isset($placed[$index])) {
623 - return;
624 - }
625 -
626 - $placed[$index] = true;
627 - $ordered[] = $elements[$index];
628 -
629 - $children = is_array($elements[$index]) ? ($elements[$index]['children'] ?? []) : [];
630 - if (!is_array($children)) {
631 - return;
632 - }
633 -
634 - foreach ($children as $child_id) {
635 - if (is_scalar($child_id) && isset($by_id[(string) $child_id])) {
636 - $place($by_id[(string) $child_id]);
637 - }
638 - }
639 - };
640 -
641 - foreach ($elements as $index => $element) {
642 - $parent = is_array($element) ? ($element['parent'] ?? null) : null;
643 - if (empty($parent) || !is_scalar($parent) || !isset($by_id[(string) $parent])) {
644 - $place($index);
645 - }
646 - }
647 -
648 - foreach ($elements as $index => $element) {
649 - if (!isset($placed[$index])) {
650 - $ordered[] = $element;
651 - }
652 - }
653 -
654 - return $ordered;
655 - }
656 -
657 - /**
658 550 * Resolve an arbitrary chunk of editor markup for the given post.
659 551 *
660 552 * The editor sends its live content to the scorer so an author sees their
661 553 * unsaved edits reflected. On a builder page that live string is the raw
@@ -884,9 +776,9 @@
884 776 return '';
885 777 }
886 778
887 779 return self::strip_bricks_dynamic_tags(
888 - self::text_from_tree(self::with_bricks_heading_tags(self::without_bricks_element_labels($tree)))
780 + self::text_from_tree(self::without_bricks_element_labels($tree))
889 781 );
890 782 }
891 783
892 784 /**
@@ -1084,9 +976,9 @@
1084 976 continue;
1085 977 }
1086 978
1087 979 if (isset($component['id']) && $component['id'] === $cid && !empty($component['elements'])) {
1088 - return is_array($component['elements']) ? self::bricks_render_order($component['elements']) : [];
980 + return is_array($component['elements']) ? $component['elements'] : [];
1089 981 }
1090 982 }
1091 983
1092 984 return [];
@@ -1119,116 +1011,8 @@
1119 1011 return $tree;
1120 1012 }
1121 1013
1122 1014 /**
1123 - * Give each Bricks Heading the tag it renders with when none is stored.
1124 - *
1125 - * Applied on the Bricks path only. Most other builders' text nodes carry no
1126 - * tag because they are not headings, so a generic "text without a tag is a
1127 - * heading" rule in heading_tag_from() would turn every paragraph into one.
1128 - *
1129 - * A `tag` of `custom` is left alone: the element then renders its
1130 - * `customTag`, which is not necessarily a heading.
1131 - *
1132 - * @since 2.15.0
1133 - *
1134 - * @param array $tree Bricks content area.
1135 - * @return array Tree with each untagged Heading's default tag filled in.
1136 - */
1137 - private static function with_bricks_heading_tags(array $tree): array {
1138 - $default = null;
1139 -
1140 - foreach ($tree as $index => $element) {
1141 - if (!is_array($element) || self::BRICKS_HEADING_ELEMENT !== ($element['name'] ?? null)) {
1142 - continue;
1143 - }
1144 -
1145 - $settings = $element['settings'] ?? [];
1146 - if (!is_array($settings)) {
1147 - continue;
1148 - }
1149 -
1150 - $tag = $settings['tag'] ?? '';
1151 - if (is_string($tag) && '' !== trim($tag)) {
1152 - continue;
1153 - }
1154 -
1155 - if (null === $default) {
1156 - $default = self::bricks_default_heading_tag();
1157 - }
1158 -
1159 - $settings['tag'] = $default;
1160 - $tree[$index]['settings'] = $settings;
1161 - }
1162 -
1163 - return $tree;
1164 - }
1165 -
1166 - /**
1167 - * The tag Bricks gives a Heading that does not set one.
1168 - *
1169 - * The active theme style can change it. Bricks only loads theme styles for
1170 - * a front-end render, so in admin, REST and CLI requests this is the
1171 - * element's own default.
1172 - *
1173 - * @since 2.15.0
1174 - *
1175 - * @return string Heading tag, h1 to h6.
1176 - */
1177 - private static function bricks_default_heading_tag(): string {
1178 - if (class_exists('\\Bricks\\Theme_Styles')
1179 - && method_exists('\\Bricks\\Theme_Styles', 'get_setting_by_key')
1180 - ) {
1181 - try {
1182 - $styled = \Bricks\Theme_Styles::get_setting_by_key(self::BRICKS_HEADING_ELEMENT, 'tag');
1183 - } catch (\Throwable $e) {
1184 - $styled = null;
1185 - }
1186 -
1187 - if (is_string($styled) && preg_match('/^h[1-6]$/i', trim($styled))) {
1188 - return strtolower(trim($styled));
1189 - }
1190 - }
1191 -
1192 - return self::BRICKS_HEADING_DEFAULT_TAG;
1193 - }
1194 -
1195 - /**
1196 - * Give each Elementor Heading widget its default `header_size` if unstored.
1197 - *
1198 - * @since 2.15.0
1199 - *
1200 - * @param array $elements Decoded `_elementor_data`.
1201 - * @return array The same tree, with untagged Heading widgets tagged.
1202 - */
1203 - private static function with_elementor_heading_tags(array $elements): array {
1204 - foreach ($elements as $index => $element) {
1205 - if (!is_array($element)) {
1206 - continue;
1207 - }
1208 -
1209 - if ('heading' === ($element['widgetType'] ?? null)) {
1210 - $settings = $element['settings'] ?? [];
1211 - if (is_array($settings)) {
1212 - $size = $settings['header_size'] ?? '';
1213 - if (!is_string($size) || '' === trim($size)) {
1214 - $settings['header_size'] = self::ELEMENTOR_HEADING_DEFAULT_TAG;
1215 - $element['settings'] = $settings;
1216 - }
1217 - }
1218 - }
1219 -
1220 - if (!empty($element['elements']) && is_array($element['elements'])) {
1221 - $element['elements'] = self::with_elementor_heading_tags($element['elements']);
1222 - }
1223 -
1224 - $elements[$index] = $element;
1225 - }
1226 -
1227 - return $elements;
1228 - }
1229 -
1230 - /**
1231 1015 * Remove Bricks dynamic-data tags from extracted text.
1232 1016 *
1233 1017 * Bricks stores `{post_title}`, `{post_meta:price}`, `{echo:my_fn}` and the
1234 1018 * like verbatim and resolves them when it renders. Extraction reads the
@@ -1336,15 +1120,12 @@
1336 1120 $stored = get_post_meta($post_id, $key, true);
1337 1121
1338 1122 if (is_string($stored) && '' !== trim($stored)) {
1339 1123 $decoded = json_decode($stored, true);
1340 - if ('_elementor_data' === $key && is_array($decoded)) {
1341 - $decoded = self::with_elementor_heading_tags($decoded);
1342 - }
1343 1124
1344 1125 // JSON node tree (Breakdance/Oxygen 6, Elementor).
1345 1126 if (is_array($decoded)) {
1346 - $text = self::text_from_tree(self::unwrap_tree_envelope($decoded));
1127 + $text = self::text_from_tree($decoded);
1347 1128 if (!self::is_blank($text)) {
1348 1129 return $text;
1349 1130 }
1350 1131 continue;
@@ -1356,9 +1137,8 @@
1356 1137 // Some builders store an already-decoded tree — an array for most,
1357 1138 // an array of objects for Beaver Builder (#449).
1358 1139 $tree = self::as_children($stored);
1359 1140 if (null !== $tree) {
1360 - $tree = self::unwrap_tree_envelope($tree);
1361 1141 $text = self::text_from_tree($tree);
1362 1142 if (!self::is_blank($text)) {
1363 1143 return $text;
1364 1144 }
@@ -1368,48 +1148,8 @@
1368 1148 return '';
1369 1149 }
1370 1150
1371 1151 /**
1372 - * The node tree inside a Breakdance / Oxygen 6 storage envelope.
1373 - *
1374 - * Neither builder stores its tree directly. The meta value is
1375 - * `{"tree_json_string": "<the tree, JSON-encoded again>"}`, so one
1376 - * json_decode() yields the envelope, not the tree. Walked as a tree, the
1377 - * envelope is a single string leaf: kept whole as "content" when any
1378 - * element held rich text (the encoded JSON then reached scoring, the
1379 - * get-post-content ability and Markdown for AI), dropped when none did,
1380 - * leaving the page empty (#905).
1381 - *
1382 - * An envelope whose inner string does not decode returns an empty tree,
1383 - * never the string: handing the raw JSON back to the walker would bring
1384 - * the JSON-as-content failure back on corrupt data. Anything that is not
1385 - * an envelope is returned unchanged, so a bare tree still resolves.
1386 - *
1387 - * @since 2.15.0
1388 - *
1389 - * @param array $decoded Decoded meta value.
1390 - * @return array The node tree.
1391 - */
1392 - private static function unwrap_tree_envelope(array $decoded): array {
1393 - if (!array_key_exists('tree_json_string', $decoded)) {
1394 - return $decoded;
1395 - }
1396 -
1397 - $inner = is_string($decoded['tree_json_string'])
1398 - ? json_decode($decoded['tree_json_string'], true)
1399 - : $decoded['tree_json_string'];
1400 -
1401 - if (is_array($inner)) {
1402 - return $inner;
1403 - }
1404 -
1405 - // Re-serialised envelopes can carry the tree as an object.
1406 - $inner = self::as_children($inner);
1407 -
1408 - return null !== $inner ? $inner : [];
1409 - }
1410 -
1411 - /**
1412 1152 * Whether a meta key is one of Oxygen classic's storage keys.
1413 1153 *
1414 1154 * @since 2.10.0
1415 1155 *
@@ -1698,117 +1438,90 @@
1698 1438 *
1699 1439 * Values are joined with block-level markup so downstream heading, link and
1700 1440 * image detection keeps working on the result.
1701 1441 *
1702 - * One depth-first walk, so the output follows the tree's own order, which
1703 - * is the order the builders read here render in. This used to be two
1704 - * passes over the whole tree, one for the reconstructed headings, links and
1705 - * images and one for the remaining text, and the output followed pass
1706 - * order: every heading and button on the page first, every paragraph after
1707 - * them. That order became the meta description, og:description, the schema
1708 - * description and Pro's Markdown for AI document (#907).
1709 - *
1710 1442 * @param array $tree Decoded builder tree.
1711 1443 * @return string Collected HTML.
1712 1444 */
1713 1445 private static function text_from_tree(array $tree): string {
1714 - // Each entry is [value, is_markup], in tree order.
1715 - $entries = [];
1446 + $collected = [];
1716 1447
1717 1448 // Strings already represented inside reconstructed markup, so the plain
1718 - // text doesn't emit a link label or heading a second time and double it
1719 - // in the word count. Applied after the walk, against the whole tree:
1720 - // a string folded into markup anywhere is dropped everywhere, exactly
1721 - // as it was when the markup pass ran over the whole tree first. Checking
1722 - // it during the walk instead would let a bare copy that appears before
1723 - // its heading through.
1449 + // sweep below doesn't emit a link label or heading a second time and
1450 + // double it in the word count.
1724 1451 $consumed = [];
1725 1452
1726 - // Markup is content wherever it appears; bare strings only count when
1727 - // their key says they are content, so slugs and class names stay out of
1728 - // the word count.
1729 - $leaf = static function ($value, $key) use (&$entries): void {
1730 - if (!is_string($value) || '' === trim($value)) {
1453 + // Pass 1 — rebuild <a>, <img> and <hN> from node *shape*. This has to
1454 + // happen per node rather than per leaf: a link's label and its
1455 + // destination are separate sibling fields, so once the tree is
1456 + // flattened to leaves the pairing is gone.
1457 + $reconstruct = static function ($node) use (&$reconstruct, &$collected, &$consumed): void {
1458 + $node = self::as_children($node);
1459 + if (null === $node) {
1731 1460 return;
1732 1461 }
1733 1462
1734 - $is_content_key = is_string($key)
1735 - && in_array(strtolower($key), self::CONTENT_KEYS, true);
1463 + $markup = self::markup_for_node($node, $consumed);
1464 + if ('' !== $markup) {
1465 + $collected[] = $markup;
1466 + }
1736 1467
1737 - if ($is_content_key || strpos($value, '<') !== false) {
1738 - $entries[] = [$value, false];
1468 + foreach ($node as $child_key => $child) {
1469 + // A `link` / `image` sub-object is a destination descriptor the
1470 + // parent has already folded into its markup. Descending into it
1471 + // would emit the same URL a second time as a bare link, and
1472 + // would turn an image's own `url` field into a spurious <a>.
1473 + if (is_string($child_key)
1474 + && (in_array(strtolower($child_key), self::URL_KEYS, true)
1475 + || in_array(strtolower($child_key), self::IMAGE_KEYS, true)
1476 + || in_array(strtolower($child_key), self::VIDEO_KEYS, true))
1477 + ) {
1478 + continue;
1479 + }
1480 +
1481 + $reconstruct($child);
1739 1482 }
1740 1483 };
1484 + $reconstruct($tree);
1741 1485
1742 - // Text only, no reconstruction. Used for a `link` / `image` / video
1743 - // sub-object: a destination descriptor the parent has already folded
1744 - // into its markup. Rebuilding inside it would emit the same URL a second
1745 - // time as a bare link and turn an image's own `url` field into a
1746 - // spurious <a>, but any copy it carries still counts.
1747 - $sweep = static function ($node, $key) use (&$sweep, $leaf): void {
1486 + // Pass 2 — remaining visible text.
1487 + $walk = static function ($node, $key = null) use (&$walk, &$collected, &$consumed): void {
1748 1488 $children = self::as_children($node);
1749 - if (null === $children) {
1750 - $leaf($node, $key);
1489 + if (null !== $children) {
1490 + foreach ($children as $child_key => $child) {
1491 + $walk($child, is_string($child_key) ? $child_key : $key);
1492 + }
1751 1493 return;
1752 1494 }
1753 1495
1754 - foreach ($children as $child_key => $child) {
1755 - $sweep($child, is_string($child_key) ? $child_key : $key);
1496 + if (!is_string($node) || '' === trim($node)) {
1497 + return;
1756 1498 }
1757 - };
1758 1499
1759 - // Rebuild <a>, <img> and <hN> from node *shape*, then carry on through
1760 - // the node's own fields in order. This has to happen per node rather
1761 - // than per leaf: a link's label and its destination are separate
1762 - // sibling fields, so once the tree is flattened to leaves the pairing
1763 - // is gone.
1764 - $walk = static function ($node, $key = null) use (&$walk, $sweep, $leaf, &$entries, &$consumed): void {
1765 - $children = self::as_children($node);
1766 - if (null === $children) {
1767 - $leaf($node, $key);
1500 + // Already inside a reconstructed tag.
1501 + if (in_array($node, $consumed, true)) {
1768 1502 return;
1769 1503 }
1770 1504
1771 - $markup = self::markup_for_node($children, $consumed);
1772 - if ('' !== $markup) {
1773 - $entries[] = [$markup, true];
1774 - }
1505 + $is_content_key = is_string($key)
1506 + && in_array(strtolower($key), self::CONTENT_KEYS, true);
1775 1507
1776 - foreach ($children as $child_key => $child) {
1777 - $next_key = is_string($child_key) ? $child_key : $key;
1778 -
1779 - if (is_string($child_key)
1780 - && (in_array(strtolower($child_key), self::URL_KEYS, true)
1781 - || in_array(strtolower($child_key), self::IMAGE_KEYS, true)
1782 - || in_array(strtolower($child_key), self::VIDEO_KEYS, true))
1783 - ) {
1784 - $sweep($child, $next_key);
1785 - continue;
1786 - }
1787 -
1788 - $walk($child, $next_key);
1508 + // Markup is content wherever it appears; bare strings only count
1509 + // when their key says they are content, so slugs and class names
1510 + // stay out of the word count.
1511 + if ($is_content_key || strpos($node, '<') !== false) {
1512 + $collected[] = $node;
1789 1513 }
1790 1514 };
1791 1515
1792 1516 $walk($tree);
1793 1517
1794 - $collected = [];
1795 - foreach ($entries as [$value, $is_markup]) {
1796 - // Already inside a reconstructed tag.
1797 - if (!$is_markup && in_array($value, $consumed, true)) {
1798 - continue;
1799 - }
1800 -
1801 - $collected[] = $value;
1802 - }
1803 -
1804 1518 if (empty($collected)) {
1805 1519 return '';
1806 1520 }
1807 1521
1808 1522 // De-duplicate: builder trees often repeat a value across responsive
1809 - // breakpoints, which would otherwise multiply the word count. Keeps the
1810 - // first occurrence, so a repeat never moves a value later in the page.
1523 + // breakpoints, which would otherwise multiply the word count.
1811 1524 $collected = array_unique($collected);
1812 1525
1813 1526 return implode("\n", $collected);
1814 1527 }