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-seo-analyzer.php +173 -16 2.14.1 → 2.14.2 View file →
@@ -67,8 +67,37 @@
67 67 'toggle', // Elementor
68 68 'faq', // Widely used in third-party add-on element names
69 69 ];
70 70
71 + /**
72 + * Builder meta keys that never describe the published page.
73 + *
74 + * `_fl_builder_draft` holds Beaver Builder changes that were never
75 + * published, so a FAQ that exists only there is not on the page (#945).
76 + *
77 + * @since 2.14.2
78 + * @var string[]
79 + */
80 + private const UNPUBLISHED_BUILDER_META_KEYS = ['_fl_builder_draft'];
81 +
82 + /**
83 + * Builder layouts that only count while their builder renders the post.
84 + *
85 + * Elementor and Beaver Builder both keep their stored layout after the
86 + * author switches the page back to the block editor, so the layout alone
87 + * does not mean the builder renders it. Each key maps to the builder's own
88 + * flag and the value that means "on": Elementor's `_elementor_edit_mode`
89 + * is `builder`, Beaver's `_fl_builder_enabled` is any non-empty value
90 + * (null here). The same tests as FAQ_Content::builder() (#945).
91 + *
92 + * @since 2.14.2
93 + * @var array<string,array{0:string,1:string|null}>
94 + */
95 + private const FLAGGED_BUILDER_LAYOUTS = [
96 + '_elementor_data' => ['_elementor_edit_mode', 'builder'],
97 + '_fl_builder_data' => ['_fl_builder_enabled', null],
98 + ];
99 +
71 100 // Check result statuses.
72 101 public const PASSED = 'passed';
73 102 public const WARNING = 'warning';
74 103 public const FAILED = 'failed';
@@ -1692,12 +1721,9 @@
1692 1721 }
1693 1722
1694 1723 $collecting = false;
1695 1724
1696 - // `/` is the canonical full block; `/*` is the same instruction
1697 - // written for a wildcard-aware crawler, and every answer engine on
1698 - // the list is one.
1699 - if ('disallow' === $field && ('/' === $value || '/*' === $value)) {
1725 + if ('disallow' === $field && self::disallow_blocks_everything($value)) {
1700 1726 foreach ($current as $agent) {
1701 1727 $groups[$agent] = true;
1702 1728 }
1703 1729 }
@@ -1706,8 +1732,46 @@
1706 1732 return $groups;
1707 1733 }
1708 1734
1709 1735 /**
1736 + * Whether a `Disallow:` value matches every URL on the site.
1737 + *
1738 + * `/` is the canonical full block; `/*` is the same instruction written
1739 + * for a wildcard-aware crawler, and every answer engine on the list is
1740 + * one. A value is a path pattern in which `*` matches any run of
1741 + * characters, so a bare `*` (and `**`, `/**`) matches every path too, and
1742 + * Google's parser treats it that way. Recognising only `/` and `/*`
1743 + * reported a site that blocked everyone with `Disallow: *` as reachable
1744 + * by every answer engine (#890).
1745 + *
1746 + * A trailing `$` anchors the pattern to the end of the URL. After a `*`
1747 + * it changes nothing, since "any run of characters, then the end" still
1748 + * matches every path, so `*$` and `/*$` are full blocks as well. Without
1749 + * a `*` it narrows the match: `/$` is the home page only, and `/*.pdf$`
1750 + * is PDFs only, so neither counts.
1751 + *
1752 + * An empty value means "allow everything" and must never count as a
1753 + * block, so it is rejected here rather than left to the caller.
1754 + *
1755 + * `Allow:` lines are deliberately not weighed against this: a full
1756 + * robots.txt evaluator with longest-match precedence is a separate change.
1757 + *
1758 + * @since 2.14.2
1759 + * @param string $value Trimmed `Disallow:` value, comment already removed.
1760 + * @return bool
1761 + */
1762 + private static function disallow_blocks_everything(string $value): bool {
1763 + if ('' === $value) {
1764 + return false;
1765 + }
1766 +
1767 + // Optional leading slash, then either nothing (`/`) or a run of `*`
1768 + // with an optional end anchor (`*`, `/*`, `/*$`, `**$`). A `$` with
1769 + // no `*` before it never matches here, so `/$` stays a partial block.
1770 + return 1 === preg_match('#^/?(?:\*+\$?)?$#', $value);
1771 + }
1772 +
1773 + /**
1710 1774 * /llms.txt should be published — it is the one file whose entire purpose
1711 1775 * is telling an AI assistant what this site is and what to read.
1712 1776 *
1713 1777 * @since 2.5.0
@@ -1862,10 +1926,10 @@
1862 1926 }
1863 1927
1864 1928 // Builder trees: ThinkRank's own elements, and the builders' generic
1865 1929 // accordion/toggle/FAQ elements, which are what a non-ThinkRank FAQ
1866 - // is actually built from.
1867 - $meta_keys = self::builder_meta_keys();
1930 + // is actually built from. Only layouts the visitor is served count.
1931 + $meta_keys = self::rendered_builder_meta_keys();
1868 1932
1869 1933 if (empty($meta_keys)) {
1870 1934 return false;
1871 1935 }
@@ -1883,8 +1947,10 @@
1883 1947 $marker_clauses[] = 'pm.meta_value LIKE %s';
1884 1948 $marker_values[] = '%' . $wpdb->esc_like($marker) . '%';
1885 1949 }
1886 1950
1951 + [$gate_sql, $gate_values] = self::builder_layout_renders_sql('pm');
1952 +
1887 1953 // Same exemption: both placeholder runs are sized from fixed lists —
1888 1954 // the builder meta keys and the marker list — and every value is
1889 1955 // prepared.
1890 1956 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -1893,11 +1959,12 @@
1893 1959 "SELECT 1 FROM {$wpdb->postmeta} pm
1894 1960 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
1895 1961 WHERE p.post_status = 'publish'
1896 1962 AND pm.meta_key IN ({$key_placeholders})
1897 - AND (" . implode(' OR ', $marker_clauses) . ')
1898 - LIMIT 1',
1899 - ...array_merge($meta_keys, $marker_values)
1963 + AND (" . implode(' OR ', $marker_clauses) . ")
1964 + {$gate_sql}
1965 + LIMIT 1",
1966 + ...array_merge($meta_keys, $marker_values, $gate_values)
1900 1967 )
1901 1968 );
1902 1969 // phpcs:enable
1903 1970
@@ -2014,9 +2081,9 @@
2014 2081 $content_clauses[] = 'p.post_content LIKE %s';
2015 2082 $content_values[] = '%' . $wpdb->esc_like($marker) . '%';
2016 2083 }
2017 2084
2018 - $meta_keys = self::builder_meta_keys();
2085 + $meta_keys = self::rendered_builder_meta_keys();
2019 2086 $meta_sql = '';
2020 2087 $values = $content_values;
2021 2088
2022 2089 if (!empty($meta_keys)) {
@@ -2026,16 +2093,19 @@
2026 2093 $meta_clauses[] = 'pm.meta_value LIKE %s';
2027 2094 $meta_values[] = '%' . $wpdb->esc_like($marker) . '%';
2028 2095 }
2029 2096
2097 + [$gate_sql, $gate_values] = self::builder_layout_renders_sql('pm');
2098 +
2030 2099 $key_placeholders = implode(', ', array_fill(0, count($meta_keys), '%s'));
2031 2100 $meta_sql = " OR EXISTS (
2032 2101 SELECT 1 FROM {$wpdb->postmeta} pm
2033 2102 WHERE pm.post_id = p.ID
2034 2103 AND pm.meta_key IN ({$key_placeholders})
2035 - AND (" . implode(' OR ', $meta_clauses) . ')
2036 - )';
2037 - $values = array_merge($content_values, $meta_keys, $meta_values);
2104 + AND (" . implode(' OR ', $meta_clauses) . ")
2105 + {$gate_sql}
2106 + )";
2107 + $values = array_merge($content_values, $meta_keys, $meta_values, $gate_values);
2038 2108 }
2039 2109
2040 2110 $values[] = self::CONTENT_SAMPLE_SIZE;
2041 2111
@@ -2089,9 +2159,13 @@
2089 2159 return false;
2090 2160 }
2091 2161
2092 2162 // Elementor stores its tree as JSON, so the widget name appears verbatim.
2093 - $elementor = get_post_meta($post_id, '_elementor_data', true);
2163 + // Read only while Elementor renders the post: the tree survives
2164 + // switching the page back to the block editor (#945).
2165 + $elementor = self::builder_layout_renders($post_id, '_elementor_data')
2166 + ? get_post_meta($post_id, '_elementor_data', true)
2167 + : '';
2094 2168 if (is_string($elementor)
2095 2169 && (false !== strpos($elementor, '"' . self::ANSWER_FAQ_NAME . '"')
2096 2170 || false !== strpos($elementor, '"' . self::ANSWER_HOWTO_NAME . '"'))) {
2097 2171 return true;
@@ -2103,9 +2177,12 @@
2103 2177 return true;
2104 2178 }
2105 2179
2106 2180 // Beaver Builder keeps its layout in postmeta as a map of node objects.
2107 - $layout = get_post_meta($post_id, '_fl_builder_data', true);
2181 + // Read only while Beaver renders the post, for the same reason (#945).
2182 + $layout = self::builder_layout_renders($post_id, '_fl_builder_data')
2183 + ? get_post_meta($post_id, '_fl_builder_data', true)
2184 + : [];
2108 2185 if (is_array($layout)) {
2109 2186 foreach ($layout as $node) {
2110 2187 $settings = is_object($node) ? ($node->settings ?? null) : ($node['settings'] ?? null);
2111 2188 $settings = is_object($settings) ? get_object_vars($settings) : $settings;
@@ -2122,9 +2199,9 @@
2122 2199 // while the page was publishing exactly that (#686). Builder_Content
2123 2200 // already knows every key involved — Oxygen 6 is Breakdance under the
2124 2201 // hood, and older releases used two other keys — so ask it rather than
2125 2202 // keeping a second list that can drift.
2126 - foreach (self::builder_meta_keys() as $meta_key) {
2203 + foreach (self::rendered_builder_meta_keys() as $meta_key) {
2127 2204 if ('_fl_builder_data' === $meta_key || '_elementor_data' === $meta_key) {
2128 2205 continue; // Handled above, in their own storage shapes.
2129 2206 }
2130 2207
@@ -2157,8 +2234,88 @@
2157 2234 require_once $file;
2158 2235 }
2159 2236
2160 2237 return Builder_Content::builder_meta_keys();
2238 + }
2239 +
2240 + /**
2241 + * Builder meta keys that can hold the published page.
2242 + *
2243 + * {@see builder_meta_keys()} without the unpublished draft keys, which
2244 + * Builder_Content lists because the word-count index watches them, not
2245 + * because they are ever served (#945).
2246 + *
2247 + * @since 2.14.2
2248 + * @return string[]
2249 + */
2250 + private static function rendered_builder_meta_keys(): array {
2251 + return array_values(array_diff(self::builder_meta_keys(), self::UNPUBLISHED_BUILDER_META_KEYS));
2252 + }
2253 +
2254 + /**
2255 + * Whether a builder layout key holds what the visitor is served.
2256 + *
2257 + * The answer-content readers' guard for #945. Builder_Content gains the
2258 + * same guards for its resolver in #910, and once that lands this can ask
2259 + * it instead; until then it reads the builders' own flags, exactly as
2260 + * FAQ_Content::builder() does.
2261 + *
2262 + * @since 2.14.2
2263 + * @param int $post_id Post being inspected.
2264 + * @param string $meta_key Builder meta key about to be read.
2265 + * @return bool
2266 + */
2267 + private static function builder_layout_renders(int $post_id, string $meta_key): bool {
2268 + if (in_array($meta_key, self::UNPUBLISHED_BUILDER_META_KEYS, true)) {
2269 + return false;
2270 + }
2271 +
2272 + if (!isset(self::FLAGGED_BUILDER_LAYOUTS[$meta_key])) {
2273 + return true;
2274 + }
2275 +
2276 + [$flag, $on] = self::FLAGGED_BUILDER_LAYOUTS[$meta_key];
2277 + $value = get_post_meta($post_id, $flag, true);
2278 +
2279 + return null === $on ? !empty($value) : $on === (string) $value;
2280 + }
2281 +
2282 + /**
2283 + * {@see builder_layout_renders()} as an SQL condition on a postmeta row.
2284 + *
2285 + * The site-wide queries match markers in SQL with nothing confirming each
2286 + * row in PHP afterwards, so the flag test has to happen there too. Rows
2287 + * under any other key pass untouched. `!empty()` in PHP is false for '' and
2288 + * '0', which is what the "any non-empty value" branch excludes.
2289 + *
2290 + * @since 2.14.2
2291 + * @param string $alias Alias of the postmeta row being tested.
2292 + * @return array{0:string,1:array<int,string>} ` AND (...)` clause and its values.
2293 + */
2294 + private static function builder_layout_renders_sql(string $alias): array {
2295 + global $wpdb;
2296 +
2297 + $flagged = array_keys(self::FLAGGED_BUILDER_LAYOUTS);
2298 + $clauses = [$alias . '.meta_key NOT IN (' . implode(', ', array_fill(0, count($flagged), '%s')) . ')'];
2299 + $values = $flagged;
2300 +
2301 + foreach (self::FLAGGED_BUILDER_LAYOUTS as $meta_key => [$flag, $on]) {
2302 + $test = null === $on ? "flag.meta_value NOT IN ('', '0')" : 'flag.meta_value = %s';
2303 + $clauses[] = "({$alias}.meta_key = %s AND EXISTS (
2304 + SELECT 1 FROM {$wpdb->postmeta} flag
2305 + WHERE flag.post_id = {$alias}.post_id
2306 + AND flag.meta_key = %s
2307 + AND {$test}
2308 + ))";
2309 + $values[] = $meta_key;
2310 + $values[] = $flag;
2311 +
2312 + if (null !== $on) {
2313 + $values[] = $on;
2314 + }
2315 + }
2316 +
2317 + return [' AND (' . implode(' OR ', $clauses) . ')', $values];
2161 2318 }
2162 2319
2163 2320 /**
2164 2321 * Whether a stored builder blob names a ThinkRank FAQ or How-To.