PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
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 +743 -74 2.8.0 → 2.14.1 View file →
@@ -40,19 +40,44 @@
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)
54 - 'ct_builder_shortcodes', // Oxygen classic
56 + '_breakdance_data', // Breakdance
57 + '_oxygen_data', // Oxygen 6+ (Breakdance engine, Oxygen prefix)
58 + // Oxygen classic. 4.x writes the tree as JSON to `ct_builder_json`
59 + // while still keeping `ct_builder_shortcodes`. A post carrying only
60 + // the JSON key used to match no key at all and fall through to an
61 + // empty `post_content`, which reads as a one-word page (#776).
62 + //
63 + // Oxygen 4.8.3 then renamed every `ct_*` post meta key to `_ct_*`
64 + // (`oxygen_vsb_update_4_8_3()` runs `oxy_prefix_meta_keys()` on
65 + // upgrade, and `oxy_get_post_meta()` only reads the prefixed name
66 + // from then on). A current Oxygen classic site therefore has only the
67 + // underscored keys, which nothing here listed, so every one of its
68 + // pages resolved as empty. The prefixed keys come first because they
69 + // are what Oxygen itself reads; the bare ones cover a site that has
70 + // not run the migration (or reverted it with `?unprefix_meta`).
71 + //
72 + // These four are not read in this loop: from_oxygen_classic() pairs
73 + // each JSON key with its shortcode sibling so the two forms can be
74 + // compared. They are listed here because this list is also what the
75 + // word-count index watches and what FAQ detection scans.
76 + '_ct_builder_json', // Oxygen classic 4.8.3+ (JSON tree)
77 + 'ct_builder_json', // Oxygen classic 4.0-4.8.2 (JSON tree)
78 + '_ct_builder_shortcodes', // Oxygen classic 4.8.3+ (shortcode tree)
79 + 'ct_builder_shortcodes', // Oxygen classic < 4.8.3 (shortcode tree)
55 80 '_elementor_data', // Elementor
56 81 // Beaver Builder. Published layout first: `_fl_builder_draft` holds
57 82 // unsaved changes and would score content the visitor cannot see.
58 83 // Both are arrays of stdClass nodes, which is why the walker below
@@ -110,8 +135,42 @@
110 135 */
111 136 private const BRICKS_POST_CONTENT_ELEMENT = 'post-content';
112 137
113 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 + /**
114 173 * Resolved Bricks trees for this request, keyed by post ID.
115 174 *
116 175 * Rendering one page asks for the tree about twenty times — every
117 176 * description, every schema node, the FAQ guard — and resolving it is not
@@ -142,11 +201,38 @@
142 201 private const CONTENT_KEYS = [
143 202 'text', 'title', 'subtitle', 'heading', 'subheading', 'content',
144 203 'description', 'caption', 'excerpt', 'label', 'value', 'html',
145 204 'editor', 'quote', 'answer', 'question', 'body', 'button_text',
205 + // Oxygen classic keeps an element's copy in `options.ct_content`
206 + // (headline, text block, rich text, link and button labels). It is the
207 + // field Oxygen's own serializer moves between the tags when it writes
208 + // shortcodes (`parse_components_tree()`), and the one Relevanssi and
209 + // Oxygen's WPML integration read. Missing from this list, the walker
210 + // kept only copy that happened to contain markup: a page of plain
211 + // headings and paragraphs lost almost all of its words.
212 + 'ct_content',
213 + // Oxygen's composite elements keep their copy under `options.original`
214 + // instead, one key per field. Taken from the list Oxygen itself treats
215 + // as text when it serializes (`$options_to_encode`); the numeric price
216 + // fields and the progress bar's right-hand percentage are left out.
217 + 'testimonial_text', 'testimonial_author', 'testimonial_author_info',
218 + 'icon_box_heading', 'icon_box_text',
219 + 'pricing_box_package_title', 'pricing_box_package_subtitle', 'pricing_box_content',
220 + 'progress_bar_left_text',
146 221 ];
147 222
148 223 /**
224 + * Oxygen classic's storage generations, as JSON key => shortcode key.
225 + *
226 + * @since 2.10.0
227 + * @var array<string,string>
228 + */
229 + private const OXYGEN_CLASSIC_KEYS = [
230 + '_ct_builder_json' => '_ct_builder_shortcodes',
231 + 'ct_builder_json' => 'ct_builder_shortcodes',
232 + ];
233 +
234 + /**
149 235 * JSON keys whose values hold a link destination.
150 236 *
151 237 * Builders store a link's destination in a structured field separate from
152 238 * its label, either as a bare URL string or as a `{ url: … }` object.
@@ -255,8 +341,22 @@
255 341 */
256 342 private const ALT_KEYS = ['alt', 'alt_text', 'image_alt', 'title'];
257 343
258 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 + /**
259 359 * Resolve the content worth analyzing for a post.
260 360 *
261 361 * @param \WP_Post $post Post being analyzed.
262 362 * @return string HTML/text to analyze.
@@ -480,12 +580,82 @@
480 580 if (!is_array($stored) || empty($stored)) {
481 581 return [];
482 582 }
483 583
484 - return self::expand_bricks_components($stored);
584 + return self::expand_bricks_components(self::bricks_render_order($stored));
485 585 }
486 586
487 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 + /**
488 658 * Resolve an arbitrary chunk of editor markup for the given post.
489 659 *
490 660 * The editor sends its live content to the scorer so an author sees their
491 661 * unsaved edits reflected. On a builder page that live string is the raw
@@ -506,9 +676,9 @@
506 676 * @param \WP_Post $post Post the markup belongs to.
507 677 * @return string Content to analyze.
508 678 */
509 679 public static function resolve_markup(string $raw, \WP_Post $post): string {
510 - $content = self::render_post_content($raw);
680 + $content = self::render_post_content($raw, $post);
511 681
512 682 // Block markup that renders to nothing usually means the builder that
513 683 // owns those blocks did not register them in this context — Divi 5
514 684 // loads its module library lazily per-request, so in CLI, REST, admin
@@ -556,19 +726,45 @@
556 726 *
557 727 * Best-effort: a third-party block that fatals must not take the whole
558 728 * score down with it.
559 729 *
560 - * @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 + * @param string $raw Raw post content.
740 + * @param \WP_Post $post Post the content belongs to.
561 741 * @return string Rendered content.
562 742 */
563 - private static function render_post_content(string $raw): string {
743 + private static function render_post_content(string $raw, \WP_Post $post): string {
564 744 if ('' === trim($raw)) {
565 745 return '';
566 746 }
567 747
568 - $content = $raw;
748 + $content = $raw;
749 + $previous = self::snapshot_post_globals();
569 750
570 751 try {
752 + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Made current for the render, restored in finally.
753 + $GLOBALS['post'] = $post;
754 +
755 + // Fires `the_post`, which these paths never fired before: admin,
756 + // REST and cron analysis had no current post at all. That is the
757 + // same signal the front-end loop sends and it is what makes
758 + // `get_the_ID()` work inside a shortcode, but it is a new call on a
759 + // path that runs in bulk — the word-count index resolves every post
760 + // it visits — so a theme that counts views on `the_post` will count
761 + // them during indexing. Accepted deliberately: without it a
762 + // shortcode cannot resolve its own post, which is the bug (#860).
763 + if (function_exists('setup_postdata')) {
764 + setup_postdata($post);
765 + }
766 +
571 767 if (function_exists('has_blocks') && function_exists('do_blocks') && has_blocks($raw)) {
572 768 $content = do_blocks($raw);
573 769 }
574 770
@@ -577,8 +773,10 @@
577 773 $content = do_shortcode($content);
578 774 }
579 775 } catch (\Throwable $e) {
580 776 return $raw;
777 + } finally {
778 + self::restore_post_globals($previous);
581 779 }
582 780
583 781 return self::is_blank($content) ? $raw : $content;
584 782 }
@@ -583,8 +781,49 @@
583 781 return self::is_blank($content) ? $raw : $content;
584 782 }
585 783
586 784 /**
785 + * The post globals as they are now; a global that is unset has no key.
786 + *
787 + * @since 2.12.0
788 + *
789 + * @return array<string,mixed>
790 + */
791 + private static function snapshot_post_globals(): array {
792 + $snapshot = [];
793 +
794 + foreach (self::POSTDATA_GLOBALS as $name) {
795 + if (array_key_exists($name, $GLOBALS)) {
796 + $snapshot[$name] = $GLOBALS[$name];
797 + }
798 + }
799 +
800 + return $snapshot;
801 + }
802 +
803 + /**
804 + * Put the post globals back as snapshot_post_globals() found them.
805 + *
806 + * Assigned directly rather than through `setup_postdata()`: there may have
807 + * been no post to set up, and re-running it would fire `the_post` again.
808 + *
809 + * @since 2.12.0
810 + *
811 + * @param array<string,mixed> $snapshot From snapshot_post_globals().
812 + * @return void
813 + */
814 + private static function restore_post_globals(array $snapshot): void {
815 + foreach (self::POSTDATA_GLOBALS as $name) {
816 + if (array_key_exists($name, $snapshot)) {
817 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Core's own globals, put back as they were.
818 + $GLOBALS[$name] = $snapshot[$name];
819 + } else {
820 + unset($GLOBALS[$name]);
821 + }
822 + }
823 + }
824 +
825 + /**
587 826 * Extract text from the attributes of parsed blocks.
588 827 *
589 828 * @param string $raw Raw post content containing block markup.
590 829 * @return string Collected text, or '' when nothing was found.
@@ -645,9 +884,9 @@
645 884 return '';
646 885 }
647 886
648 887 return self::strip_bricks_dynamic_tags(
649 - self::text_from_tree(self::without_bricks_element_labels($tree))
888 + self::text_from_tree(self::with_bricks_heading_tags(self::without_bricks_element_labels($tree)))
650 889 );
651 890 }
652 891
653 892 /**
@@ -845,9 +1084,9 @@
845 1084 continue;
846 1085 }
847 1086
848 1087 if (isset($component['id']) && $component['id'] === $cid && !empty($component['elements'])) {
849 - return is_array($component['elements']) ? $component['elements'] : [];
1088 + return is_array($component['elements']) ? self::bricks_render_order($component['elements']) : [];
850 1089 }
851 1090 }
852 1091
853 1092 return [];
@@ -880,8 +1119,116 @@
880 1119 return $tree;
881 1120 }
882 1121
883 1122 /**
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 + /**
884 1231 * Remove Bricks dynamic-data tags from extracted text.
885 1232 *
886 1233 * Bricks stores `{post_title}`, `{post_meta:price}`, `{echo:my_fn}` and the
887 1234 * like verbatim and resolves them when it renders. Extraction reads the
@@ -972,16 +1319,32 @@
972 1319 return $bricks;
973 1320 }
974 1321
975 1322 foreach (self::BUILDER_META_KEYS as $key) {
1323 + if (self::is_oxygen_classic_key($key)) {
1324 + // Resolved as a pair, once, at the first of its keys.
1325 + if ('_ct_builder_json' !== $key) {
1326 + continue;
1327 + }
1328 +
1329 + $oxygen = self::from_oxygen_classic($post_id);
1330 + if (!self::is_blank($oxygen)) {
1331 + return $oxygen;
1332 + }
1333 + continue;
1334 + }
1335 +
976 1336 $stored = get_post_meta($post_id, $key, true);
977 1337
978 1338 if (is_string($stored) && '' !== trim($stored)) {
979 1339 $decoded = json_decode($stored, true);
1340 + if ('_elementor_data' === $key && is_array($decoded)) {
1341 + $decoded = self::with_elementor_heading_tags($decoded);
1342 + }
980 1343
981 1344 // JSON node tree (Breakdance/Oxygen 6, Elementor).
982 1345 if (is_array($decoded)) {
983 - $text = self::text_from_tree($decoded);
1346 + $text = self::text_from_tree(self::unwrap_tree_envelope($decoded));
984 1347 if (!self::is_blank($text)) {
985 1348 return $text;
986 1349 }
987 1350 continue;
@@ -986,20 +1349,8 @@
986 1349 }
987 1350 continue;
988 1351 }
989 1352
990 - // Shortcode tree (Oxygen classic).
991 - if (strpos($stored, '[') !== false && function_exists('do_shortcode')) {
992 - try {
993 - $rendered = do_shortcode($stored);
994 - } catch (\Throwable $e) {
995 - $rendered = $stored;
996 - }
997 - if (!self::is_blank($rendered)) {
998 - return $rendered;
999 - }
1000 - }
1001 -
1002 1353 continue;
1003 1354 }
1004 1355
1005 1356 // Some builders store an already-decoded tree — an array for most,
@@ -1005,8 +1356,9 @@
1005 1356 // Some builders store an already-decoded tree — an array for most,
1006 1357 // an array of objects for Beaver Builder (#449).
1007 1358 $tree = self::as_children($stored);
1008 1359 if (null !== $tree) {
1360 + $tree = self::unwrap_tree_envelope($tree);
1009 1361 $text = self::text_from_tree($tree);
1010 1362 if (!self::is_blank($text)) {
1011 1363 return $text;
1012 1364 }
@@ -1016,8 +1368,298 @@
1016 1368 return '';
1017 1369 }
1018 1370
1019 1371 /**
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 + * Whether a meta key is one of Oxygen classic's storage keys.
1413 + *
1414 + * @since 2.10.0
1415 + *
1416 + * @param string $key Meta key.
1417 + * @return bool
1418 + */
1419 + private static function is_oxygen_classic_key(string $key): bool {
1420 + return isset(self::OXYGEN_CLASSIC_KEYS[$key]) || in_array($key, self::OXYGEN_CLASSIC_KEYS, true);
1421 + }
1422 +
1423 + /**
1424 + * Text of an Oxygen classic page, from whichever stored form holds more.
1425 + *
1426 + * Oxygen 4.x keeps the same tree twice: as JSON, and as the shortcodes it
1427 + * used before 4.0. The JSON is preferred because it carries copy the
1428 + * shortcode form hides (a composite element's text is base64-encoded
1429 + * inside `ct_options`, which is configuration and stripped). It is not
1430 + * trusted blindly, though. Reading `ct_builder_json` first once meant a
1431 + * key missing from CONTENT_KEYS silently threw the page away while the
1432 + * shortcode copy sat unread next to it, because a non-empty JSON result
1433 + * stopped the search. Comparing the two means the next such gap costs
1434 + * nothing: the richer form wins.
1435 + *
1436 + * A generation is only read as a pair. The prefixed keys are what Oxygen
1437 + * 4.8.3+ reads, so an unprefixed leftover next to them is stale.
1438 + *
1439 + * @since 2.10.0
1440 + *
1441 + * @param int $post_id Post ID.
1442 + * @return string Extracted text, or '' when Oxygen classic stored nothing.
1443 + */
1444 + private static function from_oxygen_classic(int $post_id): string {
1445 + foreach (self::OXYGEN_CLASSIC_KEYS as $json_key => $shortcode_key) {
1446 + $json = get_post_meta($post_id, $json_key, true);
1447 + $shortcodes = get_post_meta($post_id, $shortcode_key, true);
1448 +
1449 + $from_json = '';
1450 + if (is_string($json) && '' !== trim($json)) {
1451 + $decoded = json_decode($json, true);
1452 + if (is_array($decoded)) {
1453 + // `[oxygen data="..."]` is a dynamic-data placeholder
1454 + // Oxygen fills at render time. The shortcode path drops it
1455 + // with every other tag, so it goes here too or the two
1456 + // forms would disagree on the same page.
1457 + $from_json = (string) preg_replace(
1458 + '/\[oxygen\b[^\]]*\]/i',
1459 + ' ',
1460 + self::text_from_tree($decoded)
1461 + );
1462 + }
1463 + }
1464 +
1465 + $from_shortcodes = '';
1466 + if (is_string($shortcodes) && strpos($shortcodes, '[') !== false) {
1467 + $from_shortcodes = self::text_from_shortcodes($shortcodes);
1468 + }
1469 +
1470 + if (self::is_blank($from_json) && self::is_blank($from_shortcodes)) {
1471 + continue;
1472 + }
1473 +
1474 + return self::visible_word_count($from_json) >= self::visible_word_count($from_shortcodes)
1475 + ? $from_json
1476 + : $from_shortcodes;
1477 + }
1478 +
1479 + return '';
1480 + }
1481 +
1482 + /**
1483 + * Rough count of the words a visitor would read in extracted text.
1484 + *
1485 + * Only used to compare two extractions of the same page, so it needs to
1486 + * be consistent rather than locale-exact.
1487 + *
1488 + * @since 2.10.0
1489 + *
1490 + * @param string $text Extracted text or markup.
1491 + * @return int
1492 + */
1493 + private static function visible_word_count(string $text): int {
1494 + $plain = trim((string) preg_replace('/\s+/u', ' ', wp_strip_all_tags($text)));
1495 +
1496 + return '' === $plain ? 0 : count(explode(' ', $plain));
1497 + }
1498 +
1499 + /**
1500 + * Shortcode attributes that carry copy a visitor reads.
1501 + *
1502 + * An allow-list, not a deny-list. Oxygen Classic tags carry far more
1503 + * attributes than they do copy — `id`, `class`, `selector`, `url`,
1504 + * `ct_options` and friends — and a deny-list silently admits every
1505 + * attribute a future builder release invents, which is how markup ends up
1506 + * being counted as prose.
1507 + *
1508 + * @var string[]
1509 + */
1510 + private const SHORTCODE_TEXT_ATTRIBUTES = [
1511 + 'text',
1512 + 'content',
1513 + 'heading',
1514 + 'title',
1515 + 'subtitle',
1516 + 'label',
1517 + 'caption',
1518 + 'description',
1519 + 'alt',
1520 + 'button_text',
1521 + 'link_text',
1522 + ];
1523 +
1524 + /**
1525 + * Extract readable text from a shortcode tree, without rendering it.
1526 + *
1527 + * Oxygen Classic is the only builder whose storage is shortcodes rather
1528 + * than JSON, and the previous implementation handed the string to
1529 + * `do_shortcode()`. That silently depends on Oxygen having registered its
1530 + * `ct_*` handlers in the current request — which it has on a front-end
1531 + * view, and has not during bulk analysis, the post-list column, cron or
1532 + * REST/MCP. With no handlers registered `do_shortcode()` returns its input
1533 + * unchanged, so the raw shortcode source was scored as if it were the
1534 + * page's prose: `[ct_section`, `id="section-1"` and the rest counted toward
1535 + * the word count, while the actual copy sitting in `text="..."` attributes
1536 + * was never counted at all (#776).
1537 + *
1538 + * `strip_shortcodes()` is no help either — it also only knows registered
1539 + * shortcodes, so it leaves the same text untouched.
1540 + *
1541 + * Reading the stored tree directly is what every other builder here already
1542 + * does, and it matches the class's stated design: no render engine, no
1543 + * dependency on load order, safe during a bulk run.
1544 + *
1545 + * Parsing unconditionally, rather than rendering when Oxygen happens to be
1546 + * loaded and parsing otherwise, is deliberate. It makes the extracted text
1547 + * the same in every context, so the score in the editor matches the score
1548 + * from a bulk run or from MCP. The old code produced whichever of the two
1549 + * the request happened to allow, which is why the same post could report
1550 + * two different word counts depending on how it was asked.
1551 + *
1552 + * The trade-off is that rendered output (resolved images, links, anything
1553 + * Oxygen pulls in from a reusable part) is no longer reflected here. For
1554 + * what this text feeds — word count, content scoring, meta-description
1555 + * fallbacks and schema text — that markup was never the point, and counting
1556 + * it only when the builder happened to be booted was the bug.
1557 + *
1558 + * @since 2.10.0
1559 + *
1560 + * @param string $stored Raw shortcode source.
1561 + * @return string Extracted text.
1562 + */
1563 + private static function text_from_shortcodes(string $stored): string {
1564 + // Oxygen stores each element's settings as a JSON blob in `ct_options`.
1565 + // It is configuration, never copy, and it contains braces and brackets
1566 + // that would otherwise confuse the tag scan below, so it goes first.
1567 + //
1568 + // The blob is matched as a balanced JSON object, not as "up to the
1569 + // next quote". Oxygen wraps it in single quotes but does not escape
1570 + // an apostrophe inside it (`"nicename":"Bob's Plumbing"`), so the
1571 + // quote-to-quote match stopped mid-value and the rest of the blob,
1572 + // `s Plumbing"}'` and all, was left in the tag and leaked into the
1573 + // text. Strings inside the object are skipped whole, so neither a quote
1574 + // nor a brace inside a value can end the match early.
1575 + $source = (string) preg_replace(
1576 + '/\sct_options\s*=\s*\'(?<obj>\{(?:[^{}"]++|"(?:[^"\\\\]|\\\\.)*+"|(?&obj))*+\})\'/s',
1577 + '',
1578 + $stored
1579 + );
1580 +
1581 + // Anything not shaped like Oxygen's JSON blob keeps the old,
1582 + // quote-delimited strip.
1583 + $source = (string) preg_replace(
1584 + '/\sct_options\s*=\s*(["\']).*?\1/s',
1585 + '',
1586 + $source
1587 + );
1588 +
1589 + $attributes = implode('|', array_map(
1590 + static fn(string $name): string => preg_quote($name, '/'),
1591 + self::SHORTCODE_TEXT_ATTRIBUTES
1592 + ));
1593 +
1594 + // Replace each shortcode tag with whatever readable copy its attributes
1595 + // carry. Text between tags is left exactly where it is, so the result
1596 + // keeps the page's reading order rather than hoisting all the headings
1597 + // to the front.
1598 + // The attribute blob is matched quote-aware rather than as "anything up
1599 + // to the first `]`". Oxygen copy contains brackets often enough to
1600 + // matter — "Best tools [2026]", "[Updated] our policy" — and a naive
1601 + // scan ends the tag inside the `text` attribute, dropping the copy
1602 + // before the bracket and leaking the stray `"]` after it into the
1603 + // prose. Which is this bug's own failure mode: the wrong text scored.
1604 + //
1605 + // A tag name must start with a letter or underscore. `[2026]` is not a
1606 + // shortcode anyone can register, and scanning it as one dropped the
1607 + // year out of "Best tools [2026]".
1608 + $text = (string) preg_replace_callback(
1609 + '/\[\/?[a-zA-Z_][a-zA-Z0-9_-]*((?:[^\]"\']|"[^"]*"|\'[^\']*\')*)\]/',
1610 + static function (array $matches) use ($attributes): string {
1611 + if ('' === trim($matches[1])) {
1612 + return ' ';
1613 + }
1614 +
1615 + if (!preg_match_all(
1616 + '/\b(' . $attributes . ')\s*=\s*(["\'])(.*?)\2/s',
1617 + $matches[1],
1618 + $found,
1619 + PREG_SET_ORDER
1620 + )) {
1621 + return ' ';
1622 + }
1623 +
1624 + $parts = [];
1625 + foreach ($found as $attribute) {
1626 + $value = trim($attribute[3]);
1627 +
1628 + // An attribute holding markup or a JSON fragment is
1629 + // configuration that happens to share a name with a copy
1630 + // field, not something a visitor reads.
1631 + if ('' === $value || preg_match('/^[\[{<]/', $value)) {
1632 + continue;
1633 + }
1634 +
1635 + $parts[] = $value;
1636 + }
1637 +
1638 + return empty($parts) ? ' ' : ' ' . implode(' ', $parts) . ' ';
1639 + },
1640 + $source
1641 + );
1642 +
1643 + // Oxygen escapes square brackets in an element's copy before writing
1644 + // it between the tags, so that "Best tools [2026]" cannot be mistaken
1645 + // for a shortcode (`oxygen_vsb_filter_shortcode_content_encode()`).
1646 + // Decoded only now, after the tag scan, for the same reason; left
1647 + // encoded, the placeholders were scored as words of their own.
1648 + $text = str_replace(
1649 + ['_OXY_OPENING_BRACKET_', '_OXY_CLOSING_BRACKET_'],
1650 + ['[', ']'],
1651 + $text
1652 + );
1653 +
1654 + // Entities are stored encoded in attributes (&amp;, &#8217;), and would
1655 + // otherwise be counted as words.
1656 + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1657 +
1658 + return trim((string) preg_replace('/\s+/u', ' ', $text));
1659 + }
1660 +
1661 + /**
1020 1662 * A node's children, whether it stores them as an array or an object.
1021 1663 *
1022 1664 * The walker used to return immediately on `!is_array($node)`, so an
1023 1665 * object node was dropped along with its entire subtree — silently, as
@@ -1056,90 +1698,117 @@
1056 1698 *
1057 1699 * Values are joined with block-level markup so downstream heading, link and
1058 1700 * image detection keeps working on the result.
1059 1701 *
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 + *
1060 1710 * @param array $tree Decoded builder tree.
1061 1711 * @return string Collected HTML.
1062 1712 */
1063 1713 private static function text_from_tree(array $tree): string {
1064 - $collected = [];
1714 + // Each entry is [value, is_markup], in tree order.
1715 + $entries = [];
1065 1716
1066 1717 // Strings already represented inside reconstructed markup, so the plain
1067 - // sweep below doesn't emit a link label or heading a second time and
1068 - // double it in the word count.
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.
1069 1724 $consumed = [];
1070 1725
1071 - // Pass 1 — rebuild <a>, <img> and <hN> from node *shape*. This has to
1072 - // happen per node rather than per leaf: a link's label and its
1073 - // destination are separate sibling fields, so once the tree is
1074 - // flattened to leaves the pairing is gone.
1075 - $reconstruct = static function ($node) use (&$reconstruct, &$collected, &$consumed): void {
1076 - $node = self::as_children($node);
1077 - if (null === $node) {
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)) {
1078 1731 return;
1079 1732 }
1080 1733
1081 - $markup = self::markup_for_node($node, $consumed);
1734 + $is_content_key = is_string($key)
1735 + && in_array(strtolower($key), self::CONTENT_KEYS, true);
1736 +
1737 + if ($is_content_key || strpos($value, '<') !== false) {
1738 + $entries[] = [$value, false];
1739 + }
1740 + };
1741 +
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 {
1748 + $children = self::as_children($node);
1749 + if (null === $children) {
1750 + $leaf($node, $key);
1751 + return;
1752 + }
1753 +
1754 + foreach ($children as $child_key => $child) {
1755 + $sweep($child, is_string($child_key) ? $child_key : $key);
1756 + }
1757 + };
1758 +
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);
1768 + return;
1769 + }
1770 +
1771 + $markup = self::markup_for_node($children, $consumed);
1082 1772 if ('' !== $markup) {
1083 - $collected[] = $markup;
1773 + $entries[] = [$markup, true];
1084 1774 }
1085 1775
1086 - foreach ($node as $child_key => $child) {
1087 - // A `link` / `image` sub-object is a destination descriptor the
1088 - // parent has already folded into its markup. Descending into it
1089 - // would emit the same URL a second time as a bare link, and
1090 - // would turn an image's own `url` field into a spurious <a>.
1776 + foreach ($children as $child_key => $child) {
1777 + $next_key = is_string($child_key) ? $child_key : $key;
1778 +
1091 1779 if (is_string($child_key)
1092 1780 && (in_array(strtolower($child_key), self::URL_KEYS, true)
1093 1781 || in_array(strtolower($child_key), self::IMAGE_KEYS, true)
1094 1782 || in_array(strtolower($child_key), self::VIDEO_KEYS, true))
1095 1783 ) {
1784 + $sweep($child, $next_key);
1096 1785 continue;
1097 1786 }
1098 1787
1099 - $reconstruct($child);
1788 + $walk($child, $next_key);
1100 1789 }
1101 1790 };
1102 - $reconstruct($tree);
1103 1791
1104 - // Pass 2 — remaining visible text.
1105 - $walk = static function ($node, $key = null) use (&$walk, &$collected, &$consumed): void {
1106 - $children = self::as_children($node);
1107 - if (null !== $children) {
1108 - foreach ($children as $child_key => $child) {
1109 - $walk($child, is_string($child_key) ? $child_key : $key);
1110 - }
1111 - return;
1112 - }
1792 + $walk($tree);
1113 1793
1114 - if (!is_string($node) || '' === trim($node)) {
1115 - return;
1116 - }
1117 -
1794 + $collected = [];
1795 + foreach ($entries as [$value, $is_markup]) {
1118 1796 // Already inside a reconstructed tag.
1119 - if (in_array($node, $consumed, true)) {
1120 - return;
1797 + if (!$is_markup && in_array($value, $consumed, true)) {
1798 + continue;
1121 1799 }
1122 1800
1123 - $is_content_key = is_string($key)
1124 - && in_array(strtolower($key), self::CONTENT_KEYS, true);
1801 + $collected[] = $value;
1802 + }
1125 1803
1126 - // Markup is content wherever it appears; bare strings only count
1127 - // when their key says they are content, so slugs and class names
1128 - // stay out of the word count.
1129 - if ($is_content_key || strpos($node, '<') !== false) {
1130 - $collected[] = $node;
1131 - }
1132 - };
1133 -
1134 - $walk($tree);
1135 -
1136 1804 if (empty($collected)) {
1137 1805 return '';
1138 1806 }
1139 1807
1140 1808 // De-duplicate: builder trees often repeat a value across responsive
1141 - // breakpoints, which would otherwise multiply the word count.
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.
1142 1811 $collected = array_unique($collected);
1143 1812
1144 1813 return implode("\n", $collected);
1145 1814 }