| @@ -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'; |
| @@ -551,9 +580,9 @@ | ||
| 551 | 580 | if (!get_option('blog_public')) { |
| 552 | 581 | return [ |
| 553 | 582 | 'label' => __('Site is visible to search engines', 'thinkrank'), |
| 554 | 583 | 'status' => self::FAILED, |
| 555 | - 'message' => __('Your site is telling search engines not to index it — it will not appear in search results.', 'thinkrank'), | |
| 584 | + 'message' => __('Your site is telling search engines not to index it, so it will not appear in search results.', 'thinkrank'), | |
| 556 | 585 | 'how_to_fix' => __('Untick "Discourage search engines from indexing this site" under Settings → Reading.', 'thinkrank'), |
| 557 | 586 | ]; |
| 558 | 587 | } |
| 559 | 588 | |
| @@ -874,9 +903,9 @@ | ||
| 874 | 903 | 'label' => $label, |
| 875 | 904 | 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED, |
| 876 | 905 | /* translators: 1: posts missing a meta description, 2: sampled posts. */ |
| 877 | 906 | 'message' => sprintf(__('%1$d of your %2$d most recent posts are missing a meta description. Search engines fall back to arbitrary page text for their snippets.', 'thinkrank'), $missing, $total), |
| 878 | - 'how_to_fix' => __('Add meta descriptions in the ThinkRank SEO panel when editing a post — or use Bulk SEO Optimization to generate them with AI.', 'thinkrank'), | |
| 907 | + 'how_to_fix' => __('Add meta descriptions in the ThinkRank SEO panel when editing a post, or use Bulk SEO Optimization to generate them with AI.', 'thinkrank'), | |
| 879 | 908 | 'value' => $value, |
| 880 | 909 | 'affected_posts' => $this->affected_posts($without), |
| 881 | 910 | ]; |
| 882 | 911 | } |
| @@ -1245,8 +1274,13 @@ | ||
| 1245 | 1274 | * REST request would fire every shortcode and block renderer on the site. |
| 1246 | 1275 | * The structural signals these checks look for (headings, lists, tables, |
| 1247 | 1276 | * the opening passage) survive in the stored markup. |
| 1248 | 1277 | * |
| 1278 | + * Except on a builder page, where they do not: the words and headings are | |
| 1279 | + * in builder meta and `post_content` is empty, or, on a Bricks page, holds | |
| 1280 | + * blocks Bricks never renders. Those rows read from builder_content(), so | |
| 1281 | + * a builder-built site is graded on its pages rather than skipped (#892). | |
| 1282 | + * | |
| 1249 | 1283 | * @since 2.5.0 |
| 1250 | 1284 | * @return array<int,array{id: int, content: string, text: string, modified: int}> |
| 1251 | 1285 | */ |
| 1252 | 1286 | private function get_content_sample(): array { |
| @@ -1255,10 +1289,12 @@ | ||
| 1255 | 1289 | } |
| 1256 | 1290 | |
| 1257 | 1291 | $post_ids = $this->sample_post_ids(); |
| 1258 | 1292 | |
| 1293 | + // Meta too: builder_content() asks every sampled post whether Bricks | |
| 1294 | + // owns it, and one query beats a hundred. | |
| 1259 | 1295 | if (function_exists('_prime_post_caches')) { |
| 1260 | - _prime_post_caches($post_ids, false, false); | |
| 1296 | + _prime_post_caches($post_ids, false, true); | |
| 1261 | 1297 | } |
| 1262 | 1298 | |
| 1263 | 1299 | $sample = []; |
| 1264 | 1300 | |
| @@ -1269,9 +1305,9 @@ | ||
| 1269 | 1305 | } |
| 1270 | 1306 | |
| 1271 | 1307 | $modified = isset($post->post_modified_gmt) ? strtotime((string) $post->post_modified_gmt . ' UTC') : false; |
| 1272 | 1308 | |
| 1273 | - $content = (string) $post->post_content; | |
| 1309 | + $content = $this->builder_content($post) ?? (string) $post->post_content; | |
| 1274 | 1310 | |
| 1275 | 1311 | $sample[] = [ |
| 1276 | 1312 | 'id' => (int) $post->ID, |
| 1277 | 1313 | 'content' => $content, |
| @@ -1285,8 +1321,45 @@ | ||
| 1285 | 1321 | return $sample; |
| 1286 | 1322 | } |
| 1287 | 1323 | |
| 1288 | 1324 | /** |
| 1325 | + * A sampled post's content from its page builder, when that is where it is. | |
| 1326 | + * | |
| 1327 | + * Null for every post whose `post_content` is what the visitor reads, so | |
| 1328 | + * those keep the raw-markup path and never pay for rendering. A post with | |
| 1329 | + * an empty `post_content`, or a Bricks page that discards it, resolves | |
| 1330 | + * through Builder_Content, the extractor every other server-side scoring | |
| 1331 | + * path reads through (#617). With nothing in `post_content` to render, the | |
| 1332 | + * resolution only reads stored builder meta: no shortcode or block | |
| 1333 | + * renderer runs. | |
| 1334 | + * | |
| 1335 | + * Guarded like builder_meta_keys(), so a partial checkout degrades to the | |
| 1336 | + * raw markup instead of fataling mid-audit. | |
| 1337 | + * | |
| 1338 | + * @since 2.15.0 | |
| 1339 | + * | |
| 1340 | + * @param \WP_Post $post Sampled post. | |
| 1341 | + * @return string|null Resolved content, or null to use `post_content`. | |
| 1342 | + */ | |
| 1343 | + private function builder_content(\WP_Post $post): ?string { | |
| 1344 | + if ([] === self::builder_meta_keys()) { | |
| 1345 | + return null; | |
| 1346 | + } | |
| 1347 | + | |
| 1348 | + if ('' !== trim((string) $post->post_content) | |
| 1349 | + && !Builder_Content::bricks_supersedes_post_content((int) $post->ID) | |
| 1350 | + ) { | |
| 1351 | + return null; | |
| 1352 | + } | |
| 1353 | + | |
| 1354 | + try { | |
| 1355 | + return Builder_Content::resolve($post); | |
| 1356 | + } catch (\Throwable $e) { | |
| 1357 | + return null; | |
| 1358 | + } | |
| 1359 | + } | |
| 1360 | + | |
| 1361 | + /** | |
| 1289 | 1362 | * Shared shape for the content-sampled GEO checks: count how many posts in |
| 1290 | 1363 | * the sample satisfy a predicate and grade it on the coverage thresholds |
| 1291 | 1364 | * the Content category already uses. |
| 1292 | 1365 | * |
| @@ -1307,23 +1380,30 @@ | ||
| 1307 | 1380 | string $fail_text, |
| 1308 | 1381 | string $how_to_fix, |
| 1309 | 1382 | string $empty_text |
| 1310 | 1383 | ): array { |
| 1311 | - // A page whose body is a shortcode or a builder layout leaves no | |
| 1312 | - // extractable text, so every prose-shaped question here answers "no" | |
| 1313 | - // for it — Cart, Checkout, My account and Shop would drag the category | |
| 1314 | - // down over content nobody wants quoted in an AI answer. Skipping them | |
| 1315 | - // is deliberate: this grades the pages that could be cited. | |
| 1316 | - $sample = []; | |
| 1317 | - foreach ($this->get_content_sample() as $row) { | |
| 1384 | + // A page whose body is only a shortcode or a commerce block (Checkout, | |
| 1385 | + // My account, Shop) leaves no extractable text, so every prose-shaped | |
| 1386 | + // question here answers "no" for it and would drag the category down | |
| 1387 | + // over content nobody wants quoted in an AI answer. Skipping them is | |
| 1388 | + // deliberate: this grades the pages that could be cited. Builder pages | |
| 1389 | + // are not in this group: get_content_sample() reads their builder | |
| 1390 | + // storage, so they have text and are graded (#892). | |
| 1391 | + $sampled = $this->get_content_sample(); | |
| 1392 | + $sample = []; | |
| 1393 | + $textless = []; | |
| 1394 | + foreach ($sampled as $row) { | |
| 1318 | 1395 | if ('' !== $row['text']) { |
| 1319 | 1396 | $sample[] = $row; |
| 1397 | + } else { | |
| 1398 | + $textless[] = $row['id']; | |
| 1320 | 1399 | } |
| 1321 | 1400 | } |
| 1322 | 1401 | |
| 1323 | 1402 | $total = count($sample); |
| 1324 | 1403 | |
| 1325 | - if (0 === $total) { | |
| 1404 | + // Nothing published: nothing to report. | |
| 1405 | + if ([] === $sampled) { | |
| 1326 | 1406 | return [ |
| 1327 | 1407 | 'label' => $label, |
| 1328 | 1408 | 'status' => self::PASSED, |
| 1329 | 1409 | 'message' => $empty_text, |
| @@ -1329,8 +1409,32 @@ | ||
| 1329 | 1409 | 'message' => $empty_text, |
| 1330 | 1410 | ]; |
| 1331 | 1411 | } |
| 1332 | 1412 | |
| 1413 | + // Pages exist, but none yielded text. That is a sample this check | |
| 1414 | + // could not read, not an empty site, and it used to pass with "No | |
| 1415 | + // published content to check yet", awarding the weight for content | |
| 1416 | + // nobody examined (#892). Say so instead. | |
| 1417 | + if (0 === $total) { | |
| 1418 | + return [ | |
| 1419 | + 'label' => $label, | |
| 1420 | + 'status' => self::WARNING, | |
| 1421 | + 'message' => sprintf( | |
| 1422 | + /* translators: %d: number of sampled pages. */ | |
| 1423 | + _n( | |
| 1424 | + 'ThinkRank found no readable text on the %d page it sampled, so this could not be checked.', | |
| 1425 | + 'ThinkRank found no readable text on any of the %d pages it sampled, so this could not be checked.', | |
| 1426 | + count($textless), | |
| 1427 | + 'thinkrank' | |
| 1428 | + ), | |
| 1429 | + count($textless) | |
| 1430 | + ), | |
| 1431 | + 'how_to_fix' => __('Pages built only from shortcodes, or with a page builder ThinkRank does not support, have no text it can read. Publish pages with written content, or check that your page builder is supported.', 'thinkrank'), | |
| 1432 | + 'value' => sprintf('0/%d', count($textless)), | |
| 1433 | + 'affected_posts' => $this->affected_posts($textless), | |
| 1434 | + ]; | |
| 1435 | + } | |
| 1436 | + | |
| 1333 | 1437 | $matching = 0; |
| 1334 | 1438 | $failing = []; |
| 1335 | 1439 | foreach ($sample as $row) { |
| 1336 | 1440 | if ($predicate($row)) { |
| @@ -1617,12 +1721,9 @@ | ||
| 1617 | 1721 | } |
| 1618 | 1722 | |
| 1619 | 1723 | $collecting = false; |
| 1620 | 1724 | |
| 1621 | - // `/` is the canonical full block; `/*` is the same instruction | |
| 1622 | - // written for a wildcard-aware crawler, and every answer engine on | |
| 1623 | - // the list is one. | |
| 1624 | - if ('disallow' === $field && ('/' === $value || '/*' === $value)) { | |
| 1725 | + if ('disallow' === $field && self::disallow_blocks_everything($value)) { | |
| 1625 | 1726 | foreach ($current as $agent) { |
| 1626 | 1727 | $groups[$agent] = true; |
| 1627 | 1728 | } |
| 1628 | 1729 | } |
| @@ -1631,8 +1732,46 @@ | ||
| 1631 | 1732 | return $groups; |
| 1632 | 1733 | } |
| 1633 | 1734 | |
| 1634 | 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 | + /** | |
| 1635 | 1774 | * /llms.txt should be published — it is the one file whose entire purpose |
| 1636 | 1775 | * is telling an AI assistant what this site is and what to read. |
| 1637 | 1776 | * |
| 1638 | 1777 | * @since 2.5.0 |
| @@ -1787,10 +1926,10 @@ | ||
| 1787 | 1926 | } |
| 1788 | 1927 | |
| 1789 | 1928 | // Builder trees: ThinkRank's own elements, and the builders' generic |
| 1790 | 1929 | // accordion/toggle/FAQ elements, which are what a non-ThinkRank FAQ |
| 1791 | - // is actually built from. | |
| 1792 | - $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(); | |
| 1793 | 1932 | |
| 1794 | 1933 | if (empty($meta_keys)) { |
| 1795 | 1934 | return false; |
| 1796 | 1935 | } |
| @@ -1808,8 +1947,10 @@ | ||
| 1808 | 1947 | $marker_clauses[] = 'pm.meta_value LIKE %s'; |
| 1809 | 1948 | $marker_values[] = '%' . $wpdb->esc_like($marker) . '%'; |
| 1810 | 1949 | } |
| 1811 | 1950 | |
| 1951 | + [$gate_sql, $gate_values] = self::builder_layout_renders_sql('pm'); | |
| 1952 | + | |
| 1812 | 1953 | // Same exemption: both placeholder runs are sized from fixed lists — |
| 1813 | 1954 | // the builder meta keys and the marker list — and every value is |
| 1814 | 1955 | // prepared. |
| 1815 | 1956 | // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| @@ -1818,11 +1959,12 @@ | ||
| 1818 | 1959 | "SELECT 1 FROM {$wpdb->postmeta} pm |
| 1819 | 1960 | INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id |
| 1820 | 1961 | WHERE p.post_status = 'publish' |
| 1821 | 1962 | AND pm.meta_key IN ({$key_placeholders}) |
| 1822 | - AND (" . implode(' OR ', $marker_clauses) . ') | |
| 1823 | - LIMIT 1', | |
| 1824 | - ...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) | |
| 1825 | 1967 | ) |
| 1826 | 1968 | ); |
| 1827 | 1969 | // phpcs:enable |
| 1828 | 1970 | |
| @@ -1939,9 +2081,9 @@ | ||
| 1939 | 2081 | $content_clauses[] = 'p.post_content LIKE %s'; |
| 1940 | 2082 | $content_values[] = '%' . $wpdb->esc_like($marker) . '%'; |
| 1941 | 2083 | } |
| 1942 | 2084 | |
| 1943 | - $meta_keys = self::builder_meta_keys(); | |
| 2085 | + $meta_keys = self::rendered_builder_meta_keys(); | |
| 1944 | 2086 | $meta_sql = ''; |
| 1945 | 2087 | $values = $content_values; |
| 1946 | 2088 | |
| 1947 | 2089 | if (!empty($meta_keys)) { |
| @@ -1951,16 +2093,19 @@ | ||
| 1951 | 2093 | $meta_clauses[] = 'pm.meta_value LIKE %s'; |
| 1952 | 2094 | $meta_values[] = '%' . $wpdb->esc_like($marker) . '%'; |
| 1953 | 2095 | } |
| 1954 | 2096 | |
| 2097 | + [$gate_sql, $gate_values] = self::builder_layout_renders_sql('pm'); | |
| 2098 | + | |
| 1955 | 2099 | $key_placeholders = implode(', ', array_fill(0, count($meta_keys), '%s')); |
| 1956 | 2100 | $meta_sql = " OR EXISTS ( |
| 1957 | 2101 | SELECT 1 FROM {$wpdb->postmeta} pm |
| 1958 | 2102 | WHERE pm.post_id = p.ID |
| 1959 | 2103 | AND pm.meta_key IN ({$key_placeholders}) |
| 1960 | - AND (" . implode(' OR ', $meta_clauses) . ') | |
| 1961 | - )'; | |
| 1962 | - $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); | |
| 1963 | 2108 | } |
| 1964 | 2109 | |
| 1965 | 2110 | $values[] = self::CONTENT_SAMPLE_SIZE; |
| 1966 | 2111 | |
| @@ -2014,9 +2159,13 @@ | ||
| 2014 | 2159 | return false; |
| 2015 | 2160 | } |
| 2016 | 2161 | |
| 2017 | 2162 | // Elementor stores its tree as JSON, so the widget name appears verbatim. |
| 2018 | - $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 | + : ''; | |
| 2019 | 2168 | if (is_string($elementor) |
| 2020 | 2169 | && (false !== strpos($elementor, '"' . self::ANSWER_FAQ_NAME . '"') |
| 2021 | 2170 | || false !== strpos($elementor, '"' . self::ANSWER_HOWTO_NAME . '"'))) { |
| 2022 | 2171 | return true; |
| @@ -2028,9 +2177,12 @@ | ||
| 2028 | 2177 | return true; |
| 2029 | 2178 | } |
| 2030 | 2179 | |
| 2031 | 2180 | // Beaver Builder keeps its layout in postmeta as a map of node objects. |
| 2032 | - $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 | + : []; | |
| 2033 | 2185 | if (is_array($layout)) { |
| 2034 | 2186 | foreach ($layout as $node) { |
| 2035 | 2187 | $settings = is_object($node) ? ($node->settings ?? null) : ($node['settings'] ?? null); |
| 2036 | 2188 | $settings = is_object($settings) ? get_object_vars($settings) : $settings; |
| @@ -2047,9 +2199,9 @@ | ||
| 2047 | 2199 | // while the page was publishing exactly that (#686). Builder_Content |
| 2048 | 2200 | // already knows every key involved — Oxygen 6 is Breakdance under the |
| 2049 | 2201 | // hood, and older releases used two other keys — so ask it rather than |
| 2050 | 2202 | // keeping a second list that can drift. |
| 2051 | - foreach (self::builder_meta_keys() as $meta_key) { | |
| 2203 | + foreach (self::rendered_builder_meta_keys() as $meta_key) { | |
| 2052 | 2204 | if ('_fl_builder_data' === $meta_key || '_elementor_data' === $meta_key) { |
| 2053 | 2205 | continue; // Handled above, in their own storage shapes. |
| 2054 | 2206 | } |
| 2055 | 2207 | |
| @@ -2085,8 +2237,88 @@ | ||
| 2085 | 2237 | return Builder_Content::builder_meta_keys(); |
| 2086 | 2238 | } |
| 2087 | 2239 | |
| 2088 | 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]; | |
| 2318 | + } | |
| 2319 | + | |
| 2320 | + /** | |
| 2089 | 2321 | * Whether a stored builder blob names a ThinkRank FAQ or How-To. |
| 2090 | 2322 | * |
| 2091 | 2323 | * The blob is a JSON tree for Breakdance/Oxygen 6 and a shortcode string |
| 2092 | 2324 | * for Oxygen classic, so this matches on the element name appearing in the |
| @@ -2240,9 +2472,9 @@ | ||
| 2240 | 2472 | /* translators: 1: matching posts, 2: sampled posts. */ |
| 2241 | 2473 | __('%1$d of your %2$d most recent pages use question-style headings.', 'thinkrank'), |
| 2242 | 2474 | /* translators: 1: posts without one, 2: sampled posts. */ |
| 2243 | 2475 | __('%1$d of your %2$d most recent pages have no question-style heading. Assistants match a user\'s question against your headings first.', 'thinkrank'), |
| 2244 | - __('Phrase at least one H2 or H3 per page as the question it answers — "How does X work?" rather than "Overview".', 'thinkrank'), | |
| 2476 | + __('Phrase at least one H2 or H3 per page as the question it answers: "How does X work?" rather than "Overview".', 'thinkrank'), | |
| 2245 | 2477 | __('No published content to check yet.', 'thinkrank') |
| 2246 | 2478 | ); |
| 2247 | 2479 | } |
| 2248 | 2480 | |