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 +464 -61 2.11.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
@@ -303,8 +341,22 @@
303 341 */
304 342 private const ALT_KEYS = ['alt', 'alt_text', 'image_alt', 'title'];
305 343
306 344 /**
345 + * The global post and every global `setup_postdata()` writes.
346 + *
347 + * Rendering points them at the post being analyzed, then puts each one
348 + * back exactly as it was, unset included (#860).
349 + *
350 + * @since 2.12.0
351 + * @var string[]
352 + */
353 + private const POSTDATA_GLOBALS = [
354 + 'post', 'id', 'authordata', 'currentday', 'currentmonth',
355 + 'page', 'pages', 'multipage', 'more', 'numpages',
356 + ];
357 +
358 + /**
307 359 * Resolve the content worth analyzing for a post.
308 360 *
309 361 * @param \WP_Post $post Post being analyzed.
310 362 * @return string HTML/text to analyze.
@@ -528,12 +580,82 @@
528 580 if (!is_array($stored) || empty($stored)) {
529 581 return [];
530 582 }
531 583
532 - return self::expand_bricks_components($stored);
584 + return self::expand_bricks_components(self::bricks_render_order($stored));
533 585 }
534 586
535 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 + /**
536 658 * Resolve an arbitrary chunk of editor markup for the given post.
537 659 *
538 660 * The editor sends its live content to the scorer so an author sees their
539 661 * unsaved edits reflected. On a builder page that live string is the raw
@@ -554,9 +676,9 @@
554 676 * @param \WP_Post $post Post the markup belongs to.
555 677 * @return string Content to analyze.
556 678 */
557 679 public static function resolve_markup(string $raw, \WP_Post $post): string {
558 - $content = self::render_post_content($raw);
680 + $content = self::render_post_content($raw, $post);
559 681
560 682 // Block markup that renders to nothing usually means the builder that
561 683 // owns those blocks did not register them in this context — Divi 5
562 684 // loads its module library lazily per-request, so in CLI, REST, admin
@@ -604,19 +726,74 @@
604 726 *
605 727 * Best-effort: a third-party block that fatals must not take the whole
606 728 * score down with it.
607 729 *
608 - * @param string $raw Raw post content.
730 + * Runs as the post's own context, as it would on the front end. Admin and
731 + * REST requests have no current post, so a shortcode reading
732 + * `get_the_ID()` got nothing, and one looping a related-posts query left
733 + * the global post on the last of them: its `wp_reset_postdata()` goes back
734 + * to the main query's post, and there is none. On the Classic Editor this
735 + * runs after the form prints its hidden `post_ID` and before the title and
736 + * editor, which then showed the related post, and Update saved it over the
737 + * original (#860).
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 + *
750 + * @param string $raw Raw post content.
751 + * @param \WP_Post $post Post the content belongs to.
609 752 * @return string Rendered content.
610 753 */
611 - private static function render_post_content(string $raw): string {
754 + private static function render_post_content(string $raw, \WP_Post $post): string {
612 755 if ('' === trim($raw)) {
613 756 return '';
614 757 }
615 758
616 - $content = $raw;
759 + $content = $raw;
760 + $previous = self::snapshot_post_globals();
617 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 +
618 776 try {
777 + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Made current for the render, restored in finally.
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 + }
783 +
784 + // Fires `the_post`, which these paths never fired before: admin,
785 + // REST and cron analysis had no current post at all. That is the
786 + // same signal the front-end loop sends and it is what makes
787 + // `get_the_ID()` work inside a shortcode, but it is a new call on a
788 + // path that runs in bulk — the word-count index resolves every post
789 + // it visits — so a theme that counts views on `the_post` will count
790 + // them during indexing. Accepted deliberately: without it a
791 + // shortcode cannot resolve its own post, which is the bug (#860).
792 + if (function_exists('setup_postdata')) {
793 + setup_postdata($post);
794 + }
795 +
619 796 if (function_exists('has_blocks') && function_exists('do_blocks') && has_blocks($raw)) {
620 797 $content = do_blocks($raw);
621 798 }
622 799
@@ -625,8 +802,14 @@
625 802 $content = do_shortcode($content);
626 803 }
627 804 } catch (\Throwable $e) {
628 805 return $raw;
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);
811 + self::restore_post_globals($previous);
629 812 }
630 813
631 814 return self::is_blank($content) ? $raw : $content;
632 815 }
@@ -631,8 +814,49 @@
631 814 return self::is_blank($content) ? $raw : $content;
632 815 }
633 816
634 817 /**
818 + * The post globals as they are now; a global that is unset has no key.
819 + *
820 + * @since 2.12.0
821 + *
822 + * @return array<string,mixed>
823 + */
824 + private static function snapshot_post_globals(): array {
825 + $snapshot = [];
826 +
827 + foreach (self::POSTDATA_GLOBALS as $name) {
828 + if (array_key_exists($name, $GLOBALS)) {
829 + $snapshot[$name] = $GLOBALS[$name];
830 + }
831 + }
832 +
833 + return $snapshot;
834 + }
835 +
836 + /**
837 + * Put the post globals back as snapshot_post_globals() found them.
838 + *
839 + * Assigned directly rather than through `setup_postdata()`: there may have
840 + * been no post to set up, and re-running it would fire `the_post` again.
841 + *
842 + * @since 2.12.0
843 + *
844 + * @param array<string,mixed> $snapshot From snapshot_post_globals().
845 + * @return void
846 + */
847 + private static function restore_post_globals(array $snapshot): void {
848 + foreach (self::POSTDATA_GLOBALS as $name) {
849 + if (array_key_exists($name, $snapshot)) {
850 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Core's own globals, put back as they were.
851 + $GLOBALS[$name] = $snapshot[$name];
852 + } else {
853 + unset($GLOBALS[$name]);
854 + }
855 + }
856 + }
857 +
858 + /**
635 859 * Extract text from the attributes of parsed blocks.
636 860 *
637 861 * @param string $raw Raw post content containing block markup.
638 862 * @return string Collected text, or '' when nothing was found.
@@ -693,9 +917,9 @@
693 917 return '';
694 918 }
695 919
696 920 return self::strip_bricks_dynamic_tags(
697 - 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)))
698 922 );
699 923 }
700 924
701 925 /**
@@ -893,9 +1117,9 @@
893 1117 continue;
894 1118 }
895 1119
896 1120 if (isset($component['id']) && $component['id'] === $cid && !empty($component['elements'])) {
897 - return is_array($component['elements']) ? $component['elements'] : [];
1121 + return is_array($component['elements']) ? self::bricks_render_order($component['elements']) : [];
898 1122 }
899 1123 }
900 1124
901 1125 return [];
@@ -928,8 +1152,116 @@
928 1152 return $tree;
929 1153 }
930 1154
931 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 + /**
932 1264 * Remove Bricks dynamic-data tags from extracted text.
933 1265 *
934 1266 * Bricks stores `{post_title}`, `{post_meta:price}`, `{echo:my_fn}` and the
935 1267 * like verbatim and resolves them when it renders. Extraction reads the
@@ -1037,12 +1369,15 @@
1037 1369 $stored = get_post_meta($post_id, $key, true);
1038 1370
1039 1371 if (is_string($stored) && '' !== trim($stored)) {
1040 1372 $decoded = json_decode($stored, true);
1373 + if ('_elementor_data' === $key && is_array($decoded)) {
1374 + $decoded = self::with_elementor_heading_tags($decoded);
1375 + }
1041 1376
1042 1377 // JSON node tree (Breakdance/Oxygen 6, Elementor).
1043 1378 if (is_array($decoded)) {
1044 - $text = self::text_from_tree($decoded);
1379 + $text = self::text_from_tree(self::unwrap_tree_envelope($decoded));
1045 1380 if (!self::is_blank($text)) {
1046 1381 return $text;
1047 1382 }
1048 1383 continue;
@@ -1054,8 +1389,9 @@
1054 1389 // Some builders store an already-decoded tree — an array for most,
1055 1390 // an array of objects for Beaver Builder (#449).
1056 1391 $tree = self::as_children($stored);
1057 1392 if (null !== $tree) {
1393 + $tree = self::unwrap_tree_envelope($tree);
1058 1394 $text = self::text_from_tree($tree);
1059 1395 if (!self::is_blank($text)) {
1060 1396 return $text;
1061 1397 }
@@ -1065,8 +1401,48 @@
1065 1401 return '';
1066 1402 }
1067 1403
1068 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 + /**
1069 1445 * Whether a meta key is one of Oxygen classic's storage keys.
1070 1446 *
1071 1447 * @since 2.10.0
1072 1448 *
@@ -1355,90 +1731,117 @@
1355 1731 *
1356 1732 * Values are joined with block-level markup so downstream heading, link and
1357 1733 * image detection keeps working on the result.
1358 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 + *
1359 1743 * @param array $tree Decoded builder tree.
1360 1744 * @return string Collected HTML.
1361 1745 */
1362 1746 private static function text_from_tree(array $tree): string {
1363 - $collected = [];
1747 + // Each entry is [value, is_markup], in tree order.
1748 + $entries = [];
1364 1749
1365 1750 // Strings already represented inside reconstructed markup, so the plain
1366 - // sweep below doesn't emit a link label or heading a second time and
1367 - // 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.
1368 1757 $consumed = [];
1369 1758
1370 - // Pass 1 — rebuild <a>, <img> and <hN> from node *shape*. This has to
1371 - // happen per node rather than per leaf: a link's label and its
1372 - // destination are separate sibling fields, so once the tree is
1373 - // flattened to leaves the pairing is gone.
1374 - $reconstruct = static function ($node) use (&$reconstruct, &$collected, &$consumed): void {
1375 - $node = self::as_children($node);
1376 - 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)) {
1377 1764 return;
1378 1765 }
1379 1766
1380 - $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);
1381 1805 if ('' !== $markup) {
1382 - $collected[] = $markup;
1806 + $entries[] = [$markup, true];
1383 1807 }
1384 1808
1385 - foreach ($node as $child_key => $child) {
1386 - // A `link` / `image` sub-object is a destination descriptor the
1387 - // parent has already folded into its markup. Descending into it
1388 - // would emit the same URL a second time as a bare link, and
1389 - // 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 +
1390 1812 if (is_string($child_key)
1391 1813 && (in_array(strtolower($child_key), self::URL_KEYS, true)
1392 1814 || in_array(strtolower($child_key), self::IMAGE_KEYS, true)
1393 1815 || in_array(strtolower($child_key), self::VIDEO_KEYS, true))
1394 1816 ) {
1817 + $sweep($child, $next_key);
1395 1818 continue;
1396 1819 }
1397 1820
1398 - $reconstruct($child);
1821 + $walk($child, $next_key);
1399 1822 }
1400 1823 };
1401 - $reconstruct($tree);
1402 1824
1403 - // Pass 2 — remaining visible text.
1404 - $walk = static function ($node, $key = null) use (&$walk, &$collected, &$consumed): void {
1405 - $children = self::as_children($node);
1406 - if (null !== $children) {
1407 - foreach ($children as $child_key => $child) {
1408 - $walk($child, is_string($child_key) ? $child_key : $key);
1409 - }
1410 - return;
1411 - }
1825 + $walk($tree);
1412 1826
1413 - if (!is_string($node) || '' === trim($node)) {
1414 - return;
1415 - }
1416 -
1827 + $collected = [];
1828 + foreach ($entries as [$value, $is_markup]) {
1417 1829 // Already inside a reconstructed tag.
1418 - if (in_array($node, $consumed, true)) {
1419 - return;
1830 + if (!$is_markup && in_array($value, $consumed, true)) {
1831 + continue;
1420 1832 }
1421 1833
1422 - $is_content_key = is_string($key)
1423 - && in_array(strtolower($key), self::CONTENT_KEYS, true);
1834 + $collected[] = $value;
1835 + }
1424 1836
1425 - // Markup is content wherever it appears; bare strings only count
1426 - // when their key says they are content, so slugs and class names
1427 - // stay out of the word count.
1428 - if ($is_content_key || strpos($node, '<') !== false) {
1429 - $collected[] = $node;
1430 - }
1431 - };
1432 -
1433 - $walk($tree);
1434 -
1435 1837 if (empty($collected)) {
1436 1838 return '';
1437 1839 }
1438 1840
1439 1841 // De-duplicate: builder trees often repeat a value across responsive
1440 - // 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.
1441 1844 $collected = array_unique($collected);
1442 1845
1443 1846 return implode("\n", $collected);
1444 1847 }