| @@ -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. |