PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
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 1.28.0 All 57 releases
← All changes | includes/seo/class-builder-content.php +377 -57 2.14.0 → 2.14.2 View file →
@@ -40,18 +40,22 @@
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 (`_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.
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.
48 52 *
49 53 * @var string[]
50 54 */
51 55 private const BUILDER_META_KEYS = [
52 - '_breakdance_data', // Oxygen 6+ / Breakdance
53 - '_oxygen_data', // Oxygen (earlier releases)
56 + '_breakdance_data', // Breakdance
57 + '_oxygen_data', // Oxygen 6+ (Breakdance engine, Oxygen prefix)
54 58 // Oxygen classic. 4.x writes the tree as JSON to `ct_builder_json`
55 59 // while still keeping `ct_builder_shortcodes`. A post carrying only
56 60 // the JSON key used to match no key at all and fall through to an
57 61 // empty `post_content`, which reads as a one-word page (#776).
@@ -131,8 +135,42 @@
131 135 */
132 136 private const BRICKS_POST_CONTENT_ELEMENT = 'post-content';
133 137
134 138 /**
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 + /**
135 173 * Resolved Bricks trees for this request, keyed by post ID.
136 174 *
137 175 * Rendering one page asks for the tree about twenty times — every
138 176 * description, every schema node, the FAQ guard — and resolving it is not
@@ -542,12 +580,82 @@
542 580 if (!is_array($stored) || empty($stored)) {
543 581 return [];
544 582 }
545 583
546 - return self::expand_bricks_components($stored);
584 + return self::expand_bricks_components(self::bricks_render_order($stored));
547 585 }
548 586
549 587 /**
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 + /**
550 658 * Resolve an arbitrary chunk of editor markup for the given post.
551 659 *
552 660 * The editor sends its live content to the scorer so an author sees their
553 661 * unsaved edits reflected. On a builder page that live string is the raw
@@ -627,8 +735,19 @@
627 735 * runs after the form prints its hidden `post_ID` and before the title and
628 736 * editor, which then showed the related post, and Update saved it over the
629 737 * original (#860).
630 738 *
739 + * Secondary queries also run with front-end statuses. In wp-admin core
740 + * marks every `WP_Query` as an admin query and, when no `post_status` is
741 + * set, adds the statuses the admin post list shows, draft among them, so
742 + * a related-posts shortcode listed drafts the front end never shows and
743 + * the editor-load analysis disagreed with REST and the page (#902).
744 + *
745 + * The main query points at the post too. Restoring the globals afterwards
746 + * (#860) did not reach between shortcodes: a related-posts loop's own
747 + * `wp_reset_postdata()` still found no post on the main query, so every
748 + * later shortcode in the same render saw the last looped post (#903).
749 + *
631 750 * @param string $raw Raw post content.
632 751 * @param \WP_Post $post Post the content belongs to.
633 752 * @return string Rendered content.
634 753 */
@@ -639,11 +758,29 @@
639 758
640 759 $content = $raw;
641 760 $previous = self::snapshot_post_globals();
642 761
762 + // After pre_get_posts core reads `is_admin` only to add the admin
763 + // list's statuses when none were asked for, so queries that set
764 + // `post_status`, and the main query, are untouched.
765 + $front_end_statuses = static function ($query): void {
766 + if ($query instanceof \WP_Query && !$query->is_main_query()) {
767 + $query->is_admin = false;
768 + }
769 + };
770 +
771 + // `wp_reset_postdata()` returns to the main query's post, which admin
772 + // and REST requests do not have. Restored in finally, null included.
773 + $main_query = (isset($GLOBALS['wp_query']) && $GLOBALS['wp_query'] instanceof \WP_Query) ? $GLOBALS['wp_query'] : null;
774 + $main_query_post = $main_query ? $main_query->post : null;
775 +
643 776 try {
644 777 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Made current for the render, restored in finally.
645 778 $GLOBALS['post'] = $post;
779 + add_action('pre_get_posts', $front_end_statuses, PHP_INT_MIN);
780 + if ($main_query) {
781 + $main_query->post = $post;
782 + }
646 783
647 784 // Fires `the_post`, which these paths never fired before: admin,
648 785 // REST and cron analysis had no current post at all. That is the
649 786 // same signal the front-end loop sends and it is what makes
@@ -666,8 +803,12 @@
666 803 }
667 804 } catch (\Throwable $e) {
668 805 return $raw;
669 806 } finally {
807 + if ($main_query) {
808 + $main_query->post = $main_query_post;
809 + }
810 + remove_action('pre_get_posts', $front_end_statuses, PHP_INT_MIN);
670 811 self::restore_post_globals($previous);
671 812 }
672 813
673 814 return self::is_blank($content) ? $raw : $content;
@@ -776,9 +917,9 @@
776 917 return '';
777 918 }
778 919
779 920 return self::strip_bricks_dynamic_tags(
780 - self::text_from_tree(self::without_bricks_element_labels($tree))
921 + self::text_from_tree(self::with_bricks_heading_tags(self::without_bricks_element_labels($tree)))
781 922 );
782 923 }
783 924
784 925 /**
@@ -976,9 +1117,9 @@
976 1117 continue;
977 1118 }
978 1119
979 1120 if (isset($component['id']) && $component['id'] === $cid && !empty($component['elements'])) {
980 - return is_array($component['elements']) ? $component['elements'] : [];
1121 + return is_array($component['elements']) ? self::bricks_render_order($component['elements']) : [];
981 1122 }
982 1123 }
983 1124
984 1125 return [];
@@ -1011,8 +1152,116 @@
1011 1152 return $tree;
1012 1153 }
1013 1154
1014 1155 /**
1156 + * Give each Bricks Heading the tag it renders with when none is stored.
1157 + *
1158 + * Applied on the Bricks path only. Most other builders' text nodes carry no
1159 + * tag because they are not headings, so a generic "text without a tag is a
1160 + * heading" rule in heading_tag_from() would turn every paragraph into one.
1161 + *
1162 + * A `tag` of `custom` is left alone: the element then renders its
1163 + * `customTag`, which is not necessarily a heading.
1164 + *
1165 + * @since 2.15.0
1166 + *
1167 + * @param array $tree Bricks content area.
1168 + * @return array Tree with each untagged Heading's default tag filled in.
1169 + */
1170 + private static function with_bricks_heading_tags(array $tree): array {
1171 + $default = null;
1172 +
1173 + foreach ($tree as $index => $element) {
1174 + if (!is_array($element) || self::BRICKS_HEADING_ELEMENT !== ($element['name'] ?? null)) {
1175 + continue;
1176 + }
1177 +
1178 + $settings = $element['settings'] ?? [];
1179 + if (!is_array($settings)) {
1180 + continue;
1181 + }
1182 +
1183 + $tag = $settings['tag'] ?? '';
1184 + if (is_string($tag) && '' !== trim($tag)) {
1185 + continue;
1186 + }
1187 +
1188 + if (null === $default) {
1189 + $default = self::bricks_default_heading_tag();
1190 + }
1191 +
1192 + $settings['tag'] = $default;
1193 + $tree[$index]['settings'] = $settings;
1194 + }
1195 +
1196 + return $tree;
1197 + }
1198 +
1199 + /**
1200 + * The tag Bricks gives a Heading that does not set one.
1201 + *
1202 + * The active theme style can change it. Bricks only loads theme styles for
1203 + * a front-end render, so in admin, REST and CLI requests this is the
1204 + * element's own default.
1205 + *
1206 + * @since 2.15.0
1207 + *
1208 + * @return string Heading tag, h1 to h6.
1209 + */
1210 + private static function bricks_default_heading_tag(): string {
1211 + if (class_exists('\\Bricks\\Theme_Styles')
1212 + && method_exists('\\Bricks\\Theme_Styles', 'get_setting_by_key')
1213 + ) {
1214 + try {
1215 + $styled = \Bricks\Theme_Styles::get_setting_by_key(self::BRICKS_HEADING_ELEMENT, 'tag');
1216 + } catch (\Throwable $e) {
1217 + $styled = null;
1218 + }
1219 +
1220 + if (is_string($styled) && preg_match('/^h[1-6]$/i', trim($styled))) {
1221 + return strtolower(trim($styled));
1222 + }
1223 + }
1224 +
1225 + return self::BRICKS_HEADING_DEFAULT_TAG;
1226 + }
1227 +
1228 + /**
1229 + * Give each Elementor Heading widget its default `header_size` if unstored.
1230 + *
1231 + * @since 2.15.0
1232 + *
1233 + * @param array $elements Decoded `_elementor_data`.
1234 + * @return array The same tree, with untagged Heading widgets tagged.
1235 + */
1236 + private static function with_elementor_heading_tags(array $elements): array {
1237 + foreach ($elements as $index => $element) {
1238 + if (!is_array($element)) {
1239 + continue;
1240 + }
1241 +
1242 + if ('heading' === ($element['widgetType'] ?? null)) {
1243 + $settings = $element['settings'] ?? [];
1244 + if (is_array($settings)) {
1245 + $size = $settings['header_size'] ?? '';
1246 + if (!is_string($size) || '' === trim($size)) {
1247 + $settings['header_size'] = self::ELEMENTOR_HEADING_DEFAULT_TAG;
1248 + $element['settings'] = $settings;
1249 + }
1250 + }
1251 + }
1252 +
1253 + if (!empty($element['elements']) && is_array($element['elements'])) {
1254 + $element['elements'] = self::with_elementor_heading_tags($element['elements']);
1255 + }
1256 +
1257 + $elements[$index] = $element;
1258 + }
1259 +
1260 + return $elements;
1261 + }
1262 +
1263 + /**
1015 1264 * Remove Bricks dynamic-data tags from extracted text.
1016 1265 *
1017 1266 * Bricks stores `{post_title}`, `{post_meta:price}`, `{echo:my_fn}` and the
1018 1267 * like verbatim and resolves them when it renders. Extraction reads the
@@ -1120,12 +1369,15 @@
1120 1369 $stored = get_post_meta($post_id, $key, true);
1121 1370
1122 1371 if (is_string($stored) && '' !== trim($stored)) {
1123 1372 $decoded = json_decode($stored, true);
1373 + if ('_elementor_data' === $key && is_array($decoded)) {
1374 + $decoded = self::with_elementor_heading_tags($decoded);
1375 + }
1124 1376
1125 1377 // JSON node tree (Breakdance/Oxygen 6, Elementor).
1126 1378 if (is_array($decoded)) {
1127 - $text = self::text_from_tree($decoded);
1379 + $text = self::text_from_tree(self::unwrap_tree_envelope($decoded));
1128 1380 if (!self::is_blank($text)) {
1129 1381 return $text;
1130 1382 }
1131 1383 continue;
@@ -1137,8 +1389,9 @@
1137 1389 // Some builders store an already-decoded tree — an array for most,
1138 1390 // an array of objects for Beaver Builder (#449).
1139 1391 $tree = self::as_children($stored);
1140 1392 if (null !== $tree) {
1393 + $tree = self::unwrap_tree_envelope($tree);
1141 1394 $text = self::text_from_tree($tree);
1142 1395 if (!self::is_blank($text)) {
1143 1396 return $text;
1144 1397 }
@@ -1148,8 +1401,48 @@
1148 1401 return '';
1149 1402 }
1150 1403
1151 1404 /**
1405 + * The node tree inside a Breakdance / Oxygen 6 storage envelope.
1406 + *
1407 + * Neither builder stores its tree directly. The meta value is
1408 + * `{"tree_json_string": "<the tree, JSON-encoded again>"}`, so one
1409 + * json_decode() yields the envelope, not the tree. Walked as a tree, the
1410 + * envelope is a single string leaf: kept whole as "content" when any
1411 + * element held rich text (the encoded JSON then reached scoring, the
1412 + * get-post-content ability and Markdown for AI), dropped when none did,
1413 + * leaving the page empty (#905).
1414 + *
1415 + * An envelope whose inner string does not decode returns an empty tree,
1416 + * never the string: handing the raw JSON back to the walker would bring
1417 + * the JSON-as-content failure back on corrupt data. Anything that is not
1418 + * an envelope is returned unchanged, so a bare tree still resolves.
1419 + *
1420 + * @since 2.15.0
1421 + *
1422 + * @param array $decoded Decoded meta value.
1423 + * @return array The node tree.
1424 + */
1425 + private static function unwrap_tree_envelope(array $decoded): array {
1426 + if (!array_key_exists('tree_json_string', $decoded)) {
1427 + return $decoded;
1428 + }
1429 +
1430 + $inner = is_string($decoded['tree_json_string'])
1431 + ? json_decode($decoded['tree_json_string'], true)
1432 + : $decoded['tree_json_string'];
1433 +
1434 + if (is_array($inner)) {
1435 + return $inner;
1436 + }
1437 +
1438 + // Re-serialised envelopes can carry the tree as an object.
1439 + $inner = self::as_children($inner);
1440 +
1441 + return null !== $inner ? $inner : [];
1442 + }
1443 +
1444 + /**
1152 1445 * Whether a meta key is one of Oxygen classic's storage keys.
1153 1446 *
1154 1447 * @since 2.10.0
1155 1448 *
@@ -1438,90 +1731,117 @@
1438 1731 *
1439 1732 * Values are joined with block-level markup so downstream heading, link and
1440 1733 * image detection keeps working on the result.
1441 1734 *
1735 + * One depth-first walk, so the output follows the tree's own order, which
1736 + * is the order the builders read here render in. This used to be two
1737 + * passes over the whole tree, one for the reconstructed headings, links and
1738 + * images and one for the remaining text, and the output followed pass
1739 + * order: every heading and button on the page first, every paragraph after
1740 + * them. That order became the meta description, og:description, the schema
1741 + * description and Pro's Markdown for AI document (#907).
1742 + *
1442 1743 * @param array $tree Decoded builder tree.
1443 1744 * @return string Collected HTML.
1444 1745 */
1445 1746 private static function text_from_tree(array $tree): string {
1446 - $collected = [];
1747 + // Each entry is [value, is_markup], in tree order.
1748 + $entries = [];
1447 1749
1448 1750 // Strings already represented inside reconstructed markup, so the plain
1449 - // sweep below doesn't emit a link label or heading a second time and
1450 - // double it in the word count.
1751 + // text doesn't emit a link label or heading a second time and double it
1752 + // in the word count. Applied after the walk, against the whole tree:
1753 + // a string folded into markup anywhere is dropped everywhere, exactly
1754 + // as it was when the markup pass ran over the whole tree first. Checking
1755 + // it during the walk instead would let a bare copy that appears before
1756 + // its heading through.
1451 1757 $consumed = [];
1452 1758
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) {
1759 + // Markup is content wherever it appears; bare strings only count when
1760 + // their key says they are content, so slugs and class names stay out of
1761 + // the word count.
1762 + $leaf = static function ($value, $key) use (&$entries): void {
1763 + if (!is_string($value) || '' === trim($value)) {
1460 1764 return;
1461 1765 }
1462 1766
1463 - $markup = self::markup_for_node($node, $consumed);
1767 + $is_content_key = is_string($key)
1768 + && in_array(strtolower($key), self::CONTENT_KEYS, true);
1769 +
1770 + if ($is_content_key || strpos($value, '<') !== false) {
1771 + $entries[] = [$value, false];
1772 + }
1773 + };
1774 +
1775 + // Text only, no reconstruction. Used for a `link` / `image` / video
1776 + // sub-object: a destination descriptor the parent has already folded
1777 + // into its markup. Rebuilding inside it would emit the same URL a second
1778 + // time as a bare link and turn an image's own `url` field into a
1779 + // spurious <a>, but any copy it carries still counts.
1780 + $sweep = static function ($node, $key) use (&$sweep, $leaf): void {
1781 + $children = self::as_children($node);
1782 + if (null === $children) {
1783 + $leaf($node, $key);
1784 + return;
1785 + }
1786 +
1787 + foreach ($children as $child_key => $child) {
1788 + $sweep($child, is_string($child_key) ? $child_key : $key);
1789 + }
1790 + };
1791 +
1792 + // Rebuild <a>, <img> and <hN> from node *shape*, then carry on through
1793 + // the node's own fields in order. This has to happen per node rather
1794 + // than per leaf: a link's label and its destination are separate
1795 + // sibling fields, so once the tree is flattened to leaves the pairing
1796 + // is gone.
1797 + $walk = static function ($node, $key = null) use (&$walk, $sweep, $leaf, &$entries, &$consumed): void {
1798 + $children = self::as_children($node);
1799 + if (null === $children) {
1800 + $leaf($node, $key);
1801 + return;
1802 + }
1803 +
1804 + $markup = self::markup_for_node($children, $consumed);
1464 1805 if ('' !== $markup) {
1465 - $collected[] = $markup;
1806 + $entries[] = [$markup, true];
1466 1807 }
1467 1808
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>.
1809 + foreach ($children as $child_key => $child) {
1810 + $next_key = is_string($child_key) ? $child_key : $key;
1811 +
1473 1812 if (is_string($child_key)
1474 1813 && (in_array(strtolower($child_key), self::URL_KEYS, true)
1475 1814 || in_array(strtolower($child_key), self::IMAGE_KEYS, true)
1476 1815 || in_array(strtolower($child_key), self::VIDEO_KEYS, true))
1477 1816 ) {
1817 + $sweep($child, $next_key);
1478 1818 continue;
1479 1819 }
1480 1820
1481 - $reconstruct($child);
1821 + $walk($child, $next_key);
1482 1822 }
1483 1823 };
1484 - $reconstruct($tree);
1485 1824
1486 - // Pass 2 — remaining visible text.
1487 - $walk = static function ($node, $key = null) use (&$walk, &$collected, &$consumed): void {
1488 - $children = self::as_children($node);
1489 - if (null !== $children) {
1490 - foreach ($children as $child_key => $child) {
1491 - $walk($child, is_string($child_key) ? $child_key : $key);
1492 - }
1493 - return;
1494 - }
1825 + $walk($tree);
1495 1826
1496 - if (!is_string($node) || '' === trim($node)) {
1497 - return;
1498 - }
1499 -
1827 + $collected = [];
1828 + foreach ($entries as [$value, $is_markup]) {
1500 1829 // Already inside a reconstructed tag.
1501 - if (in_array($node, $consumed, true)) {
1502 - return;
1830 + if (!$is_markup && in_array($value, $consumed, true)) {
1831 + continue;
1503 1832 }
1504 1833
1505 - $is_content_key = is_string($key)
1506 - && in_array(strtolower($key), self::CONTENT_KEYS, true);
1834 + $collected[] = $value;
1835 + }
1507 1836
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;
1513 - }
1514 - };
1515 -
1516 - $walk($tree);
1517 -
1518 1837 if (empty($collected)) {
1519 1838 return '';
1520 1839 }
1521 1840
1522 1841 // De-duplicate: builder trees often repeat a value across responsive
1523 - // breakpoints, which would otherwise multiply the word count.
1842 + // breakpoints, which would otherwise multiply the word count. Keeps the
1843 + // first occurrence, so a repeat never moves a value later in the page.
1524 1844 $collected = array_unique($collected);
1525 1845
1526 1846 return implode("\n", $collected);
1527 1847 }