| @@ -48,8 +48,27 @@ | ||
| 48 | 48 | */ |
| 49 | 49 | private const ANSWER_FAQ_NAME = 'thinkrank-faq'; |
| 50 | 50 | private const ANSWER_HOWTO_NAME = 'thinkrank-howto'; |
| 51 | 51 | |
| 52 | + /** | |
| 53 | + * Element names page builders give their own accordion / FAQ widgets. | |
| 54 | + * | |
| 55 | + * Detection used to recognise ThinkRank's surfaces only, so an FAQ built | |
| 56 | + * with the builder's own accordion was invisible — and the check then | |
| 57 | + * advised adding FAQ content to a site that already had it (#686). Used | |
| 58 | + * for the "is there Q&A content here?" question only; whether ThinkRank | |
| 59 | + * *emits* schema for it is a separate question, still answered by | |
| 60 | + * ThinkRank's own element names. | |
| 61 | + * | |
| 62 | + * @since 2.7.0 | |
| 63 | + * @var string[] | |
| 64 | + */ | |
| 65 | + private const GENERIC_QA_ELEMENT_MARKERS = [ | |
| 66 | + 'accordion', // Elementor, Bricks, Beaver Builder, Breakdance, Oxygen | |
| 67 | + 'toggle', // Elementor | |
| 68 | + 'faq', // Widely used in third-party add-on element names | |
| 69 | + ]; | |
| 70 | + | |
| 52 | 71 | // Check result statuses. |
| 53 | 72 | public const PASSED = 'passed'; |
| 54 | 73 | public const WARNING = 'warning'; |
| 55 | 74 | public const FAILED = 'failed'; |
| @@ -532,9 +551,9 @@ | ||
| 532 | 551 | if (!get_option('blog_public')) { |
| 533 | 552 | return [ |
| 534 | 553 | 'label' => __('Site is visible to search engines', 'thinkrank'), |
| 535 | 554 | 'status' => self::FAILED, |
| 536 | - 'message' => __('Your site is telling search engines not to index it — it will not appear in search results.', 'thinkrank'), | |
| 555 | + 'message' => __('Your site is telling search engines not to index it, so it will not appear in search results.', 'thinkrank'), | |
| 537 | 556 | 'how_to_fix' => __('Untick "Discourage search engines from indexing this site" under Settings → Reading.', 'thinkrank'), |
| 538 | 557 | ]; |
| 539 | 558 | } |
| 540 | 559 | |
| @@ -855,9 +874,9 @@ | ||
| 855 | 874 | 'label' => $label, |
| 856 | 875 | 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED, |
| 857 | 876 | /* translators: 1: posts missing a meta description, 2: sampled posts. */ |
| 858 | 877 | '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), |
| 859 | - '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'), | |
| 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'), | |
| 860 | 879 | 'value' => $value, |
| 861 | 880 | 'affected_posts' => $this->affected_posts($without), |
| 862 | 881 | ]; |
| 863 | 882 | } |
| @@ -1184,8 +1203,16 @@ | ||
| 1184 | 1203 | */ |
| 1185 | 1204 | private $sample_post_ids = null; |
| 1186 | 1205 | |
| 1187 | 1206 | /** |
| 1207 | + * Memoised answer to "does this site publish Q&A content?". | |
| 1208 | + * | |
| 1209 | + * @since 2.7.0 | |
| 1210 | + * @var bool|null | |
| 1211 | + */ | |
| 1212 | + private $site_has_qa_content = null; | |
| 1213 | + | |
| 1214 | + /** | |
| 1188 | 1215 | * The sampled post ids, fetched once and shared by every check that reads |
| 1189 | 1216 | * the same slice. |
| 1190 | 1217 | * |
| 1191 | 1218 | * @since 2.5.0 |
| @@ -1218,8 +1245,13 @@ | ||
| 1218 | 1245 | * REST request would fire every shortcode and block renderer on the site. |
| 1219 | 1246 | * The structural signals these checks look for (headings, lists, tables, |
| 1220 | 1247 | * the opening passage) survive in the stored markup. |
| 1221 | 1248 | * |
| 1249 | + * Except on a builder page, where they do not: the words and headings are | |
| 1250 | + * in builder meta and `post_content` is empty, or, on a Bricks page, holds | |
| 1251 | + * blocks Bricks never renders. Those rows read from builder_content(), so | |
| 1252 | + * a builder-built site is graded on its pages rather than skipped (#892). | |
| 1253 | + * | |
| 1222 | 1254 | * @since 2.5.0 |
| 1223 | 1255 | * @return array<int,array{id: int, content: string, text: string, modified: int}> |
| 1224 | 1256 | */ |
| 1225 | 1257 | private function get_content_sample(): array { |
| @@ -1228,10 +1260,12 @@ | ||
| 1228 | 1260 | } |
| 1229 | 1261 | |
| 1230 | 1262 | $post_ids = $this->sample_post_ids(); |
| 1231 | 1263 | |
| 1264 | + // Meta too: builder_content() asks every sampled post whether Bricks | |
| 1265 | + // owns it, and one query beats a hundred. | |
| 1232 | 1266 | if (function_exists('_prime_post_caches')) { |
| 1233 | - _prime_post_caches($post_ids, false, false); | |
| 1267 | + _prime_post_caches($post_ids, false, true); | |
| 1234 | 1268 | } |
| 1235 | 1269 | |
| 1236 | 1270 | $sample = []; |
| 1237 | 1271 | |
| @@ -1242,9 +1276,9 @@ | ||
| 1242 | 1276 | } |
| 1243 | 1277 | |
| 1244 | 1278 | $modified = isset($post->post_modified_gmt) ? strtotime((string) $post->post_modified_gmt . ' UTC') : false; |
| 1245 | 1279 | |
| 1246 | - $content = (string) $post->post_content; | |
| 1280 | + $content = $this->builder_content($post) ?? (string) $post->post_content; | |
| 1247 | 1281 | |
| 1248 | 1282 | $sample[] = [ |
| 1249 | 1283 | 'id' => (int) $post->ID, |
| 1250 | 1284 | 'content' => $content, |
| @@ -1258,8 +1292,45 @@ | ||
| 1258 | 1292 | return $sample; |
| 1259 | 1293 | } |
| 1260 | 1294 | |
| 1261 | 1295 | /** |
| 1296 | + * A sampled post's content from its page builder, when that is where it is. | |
| 1297 | + * | |
| 1298 | + * Null for every post whose `post_content` is what the visitor reads, so | |
| 1299 | + * those keep the raw-markup path and never pay for rendering. A post with | |
| 1300 | + * an empty `post_content`, or a Bricks page that discards it, resolves | |
| 1301 | + * through Builder_Content, the extractor every other server-side scoring | |
| 1302 | + * path reads through (#617). With nothing in `post_content` to render, the | |
| 1303 | + * resolution only reads stored builder meta: no shortcode or block | |
| 1304 | + * renderer runs. | |
| 1305 | + * | |
| 1306 | + * Guarded like builder_meta_keys(), so a partial checkout degrades to the | |
| 1307 | + * raw markup instead of fataling mid-audit. | |
| 1308 | + * | |
| 1309 | + * @since 2.15.0 | |
| 1310 | + * | |
| 1311 | + * @param \WP_Post $post Sampled post. | |
| 1312 | + * @return string|null Resolved content, or null to use `post_content`. | |
| 1313 | + */ | |
| 1314 | + private function builder_content(\WP_Post $post): ?string { | |
| 1315 | + if ([] === self::builder_meta_keys()) { | |
| 1316 | + return null; | |
| 1317 | + } | |
| 1318 | + | |
| 1319 | + if ('' !== trim((string) $post->post_content) | |
| 1320 | + && !Builder_Content::bricks_supersedes_post_content((int) $post->ID) | |
| 1321 | + ) { | |
| 1322 | + return null; | |
| 1323 | + } | |
| 1324 | + | |
| 1325 | + try { | |
| 1326 | + return Builder_Content::resolve($post); | |
| 1327 | + } catch (\Throwable $e) { | |
| 1328 | + return null; | |
| 1329 | + } | |
| 1330 | + } | |
| 1331 | + | |
| 1332 | + /** | |
| 1262 | 1333 | * Shared shape for the content-sampled GEO checks: count how many posts in |
| 1263 | 1334 | * the sample satisfy a predicate and grade it on the coverage thresholds |
| 1264 | 1335 | * the Content category already uses. |
| 1265 | 1336 | * |
| @@ -1280,23 +1351,30 @@ | ||
| 1280 | 1351 | string $fail_text, |
| 1281 | 1352 | string $how_to_fix, |
| 1282 | 1353 | string $empty_text |
| 1283 | 1354 | ): array { |
| 1284 | - // A page whose body is a shortcode or a builder layout leaves no | |
| 1285 | - // extractable text, so every prose-shaped question here answers "no" | |
| 1286 | - // for it — Cart, Checkout, My account and Shop would drag the category | |
| 1287 | - // down over content nobody wants quoted in an AI answer. Skipping them | |
| 1288 | - // is deliberate: this grades the pages that could be cited. | |
| 1289 | - $sample = []; | |
| 1290 | - foreach ($this->get_content_sample() as $row) { | |
| 1355 | + // A page whose body is only a shortcode or a commerce block (Checkout, | |
| 1356 | + // My account, Shop) leaves no extractable text, so every prose-shaped | |
| 1357 | + // question here answers "no" for it and would drag the category down | |
| 1358 | + // over content nobody wants quoted in an AI answer. Skipping them is | |
| 1359 | + // deliberate: this grades the pages that could be cited. Builder pages | |
| 1360 | + // are not in this group: get_content_sample() reads their builder | |
| 1361 | + // storage, so they have text and are graded (#892). | |
| 1362 | + $sampled = $this->get_content_sample(); | |
| 1363 | + $sample = []; | |
| 1364 | + $textless = []; | |
| 1365 | + foreach ($sampled as $row) { | |
| 1291 | 1366 | if ('' !== $row['text']) { |
| 1292 | 1367 | $sample[] = $row; |
| 1368 | + } else { | |
| 1369 | + $textless[] = $row['id']; | |
| 1293 | 1370 | } |
| 1294 | 1371 | } |
| 1295 | 1372 | |
| 1296 | 1373 | $total = count($sample); |
| 1297 | 1374 | |
| 1298 | - if (0 === $total) { | |
| 1375 | + // Nothing published: nothing to report. | |
| 1376 | + if ([] === $sampled) { | |
| 1299 | 1377 | return [ |
| 1300 | 1378 | 'label' => $label, |
| 1301 | 1379 | 'status' => self::PASSED, |
| 1302 | 1380 | 'message' => $empty_text, |
| @@ -1302,8 +1380,32 @@ | ||
| 1302 | 1380 | 'message' => $empty_text, |
| 1303 | 1381 | ]; |
| 1304 | 1382 | } |
| 1305 | 1383 | |
| 1384 | + // Pages exist, but none yielded text. That is a sample this check | |
| 1385 | + // could not read, not an empty site, and it used to pass with "No | |
| 1386 | + // published content to check yet", awarding the weight for content | |
| 1387 | + // nobody examined (#892). Say so instead. | |
| 1388 | + if (0 === $total) { | |
| 1389 | + return [ | |
| 1390 | + 'label' => $label, | |
| 1391 | + 'status' => self::WARNING, | |
| 1392 | + 'message' => sprintf( | |
| 1393 | + /* translators: %d: number of sampled pages. */ | |
| 1394 | + _n( | |
| 1395 | + 'ThinkRank found no readable text on the %d page it sampled, so this could not be checked.', | |
| 1396 | + 'ThinkRank found no readable text on any of the %d pages it sampled, so this could not be checked.', | |
| 1397 | + count($textless), | |
| 1398 | + 'thinkrank' | |
| 1399 | + ), | |
| 1400 | + count($textless) | |
| 1401 | + ), | |
| 1402 | + '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'), | |
| 1403 | + 'value' => sprintf('0/%d', count($textless)), | |
| 1404 | + 'affected_posts' => $this->affected_posts($textless), | |
| 1405 | + ]; | |
| 1406 | + } | |
| 1407 | + | |
| 1306 | 1408 | $matching = 0; |
| 1307 | 1409 | $failing = []; |
| 1308 | 1410 | foreach ($sample as $row) { |
| 1309 | 1411 | if ($predicate($row)) { |
| @@ -1658,9 +1760,18 @@ | ||
| 1658 | 1760 | 'message' => __('Your site publishes FAQ, How-To or Q&A structured data, which AI answers can quote question-and-answer pairs from directly.', 'thinkrank'), |
| 1659 | 1761 | ]; |
| 1660 | 1762 | } |
| 1661 | 1763 | |
| 1662 | - $how_to_fix = __('Add a ThinkRank FAQ or How-To block, widget or element to your key pages, or enable the FAQPage / HowTo schema types under Essential SEO → Schema.', 'thinkrank'); | |
| 1764 | + // Only advise FAQ/HowTo markup where there is question-and-answer | |
| 1765 | + // content to mark up. This used to be assigned unconditionally and | |
| 1766 | + // reused by both branches below, so a site of ordinary articles was | |
| 1767 | + // told to enable FAQPage — and a user who follows that gets FAQPage | |
| 1768 | + // schema with an empty or invented mainEntity, which is the failure | |
| 1769 | + // #494 documents. The check is allowed to report the gap; it is not | |
| 1770 | + // allowed to recommend manufacturing the content (#686). | |
| 1771 | + $how_to_fix = $this->site_has_qa_content() | |
| 1772 | + ? __('Add a ThinkRank FAQ or How-To block, widget or element to the pages that answer questions, or enable the FAQPage / HowTo schema types under Essential SEO → Schema.', 'thinkrank') | |
| 1773 | + : __('Only mark up question-and-answer content you already publish. If this site does not answer discrete questions, answer-shaped schema does not apply and there is nothing to fix here.', 'thinkrank'); | |
| 1663 | 1774 | |
| 1664 | 1775 | // Article/WebPage schema still tells an engine what the page is; the |
| 1665 | 1776 | // gap is the answer pairing, not structured data as a whole. |
| 1666 | 1777 | if ($this->schema_is_output()) { |
| @@ -1680,8 +1791,147 @@ | ||
| 1680 | 1791 | ]; |
| 1681 | 1792 | } |
| 1682 | 1793 | |
| 1683 | 1794 | /** |
| 1795 | + * Whether the site plausibly publishes question-and-answer content. | |
| 1796 | + * | |
| 1797 | + * Deliberately a different question from has_answer_schema(). That one asks | |
| 1798 | + * "does ThinkRank emit answer markup?", which is what the check reports on. | |
| 1799 | + * This one asks "is there anything here that answer markup would describe?", | |
| 1800 | + * which is what decides whether recommending it is sound advice — and it has | |
| 1801 | + * to see content ThinkRank did not author, because an FAQ built with a page | |
| 1802 | + * builder's own accordion is still an FAQ (#686). | |
| 1803 | + * | |
| 1804 | + * Any one signal is enough; all of them are bounded. | |
| 1805 | + * | |
| 1806 | + * @since 2.7.0 | |
| 1807 | + * @return bool | |
| 1808 | + */ | |
| 1809 | + private function site_has_qa_content(): bool { | |
| 1810 | + if (null !== $this->site_has_qa_content) { | |
| 1811 | + return $this->site_has_qa_content; | |
| 1812 | + } | |
| 1813 | + | |
| 1814 | + $this->site_has_qa_content = $this->qa_content_markers_exist() | |
| 1815 | + || $this->sample_has_question_headings(); | |
| 1816 | + | |
| 1817 | + return $this->site_has_qa_content; | |
| 1818 | + } | |
| 1819 | + | |
| 1820 | + /** | |
| 1821 | + * Q&A markers in stored content or builder trees, site-wide. | |
| 1822 | + * | |
| 1823 | + * Two bounded queries rather than a walk over the content sample: the | |
| 1824 | + * sample is the 100 most recent posts and pages, so a site whose only FAQ | |
| 1825 | + * lives in a custom post type, or further back than that, answered "no | |
| 1826 | + * answer content" while publishing exactly that (#686). | |
| 1827 | + * | |
| 1828 | + * @since 2.7.0 | |
| 1829 | + * @return bool | |
| 1830 | + */ | |
| 1831 | + private function qa_content_markers_exist(): bool { | |
| 1832 | + global $wpdb; | |
| 1833 | + | |
| 1834 | + // Block markup and core's details/summary block, in any public type. | |
| 1835 | + $content_markers = ['wp:thinkrank/faq', 'wp:thinkrank/howto', 'wp:details']; | |
| 1836 | + $clauses = []; | |
| 1837 | + $values = []; | |
| 1838 | + | |
| 1839 | + foreach ($content_markers as $marker) { | |
| 1840 | + $clauses[] = 'p.post_content LIKE %s'; | |
| 1841 | + $values[] = '%' . $wpdb->esc_like($marker) . '%'; | |
| 1842 | + } | |
| 1843 | + | |
| 1844 | + // The OR list is one '%s' per entry in a fixed class-level marker list, | |
| 1845 | + // so its length varies but its content never comes from input; every | |
| 1846 | + // value goes through prepare(). phpcs cannot see that, and this is the | |
| 1847 | + // usual variable-length-IN exemption. | |
| 1848 | + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1849 | + $found = (int) $wpdb->get_var( | |
| 1850 | + $wpdb->prepare( | |
| 1851 | + "SELECT 1 FROM {$wpdb->posts} p | |
| 1852 | + WHERE p.post_status = 'publish' | |
| 1853 | + AND (" . implode(' OR ', $clauses) . ') | |
| 1854 | + LIMIT 1', | |
| 1855 | + ...$values | |
| 1856 | + ) | |
| 1857 | + ); | |
| 1858 | + // phpcs:enable | |
| 1859 | + | |
| 1860 | + if (1 === $found) { | |
| 1861 | + return true; | |
| 1862 | + } | |
| 1863 | + | |
| 1864 | + // Builder trees: ThinkRank's own elements, and the builders' generic | |
| 1865 | + // accordion/toggle/FAQ elements, which are what a non-ThinkRank FAQ | |
| 1866 | + // is actually built from. | |
| 1867 | + $meta_keys = self::builder_meta_keys(); | |
| 1868 | + | |
| 1869 | + if (empty($meta_keys)) { | |
| 1870 | + return false; | |
| 1871 | + } | |
| 1872 | + | |
| 1873 | + $markers = array_merge( | |
| 1874 | + [self::ANSWER_FAQ_NAME, self::ANSWER_HOWTO_NAME], | |
| 1875 | + self::GENERIC_QA_ELEMENT_MARKERS | |
| 1876 | + ); | |
| 1877 | + | |
| 1878 | + $key_placeholders = implode(', ', array_fill(0, count($meta_keys), '%s')); | |
| 1879 | + $marker_clauses = []; | |
| 1880 | + $marker_values = []; | |
| 1881 | + | |
| 1882 | + foreach ($markers as $marker) { | |
| 1883 | + $marker_clauses[] = 'pm.meta_value LIKE %s'; | |
| 1884 | + $marker_values[] = '%' . $wpdb->esc_like($marker) . '%'; | |
| 1885 | + } | |
| 1886 | + | |
| 1887 | + // Same exemption: both placeholder runs are sized from fixed lists — | |
| 1888 | + // the builder meta keys and the marker list — and every value is | |
| 1889 | + // prepared. | |
| 1890 | + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1891 | + $found = (int) $wpdb->get_var( | |
| 1892 | + $wpdb->prepare( | |
| 1893 | + "SELECT 1 FROM {$wpdb->postmeta} pm | |
| 1894 | + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id | |
| 1895 | + WHERE p.post_status = 'publish' | |
| 1896 | + AND pm.meta_key IN ({$key_placeholders}) | |
| 1897 | + AND (" . implode(' OR ', $marker_clauses) . ') | |
| 1898 | + LIMIT 1', | |
| 1899 | + ...array_merge($meta_keys, $marker_values) | |
| 1900 | + ) | |
| 1901 | + ); | |
| 1902 | + // phpcs:enable | |
| 1903 | + | |
| 1904 | + return 1 === $found; | |
| 1905 | + } | |
| 1906 | + | |
| 1907 | + /** | |
| 1908 | + * Whether the sampled content asks questions in its headings. | |
| 1909 | + * | |
| 1910 | + * The weakest signal and the last one tried: prose that poses questions is | |
| 1911 | + * content answer markup could describe, even where nothing has been marked | |
| 1912 | + * up yet. Runs over the existing sample, so it costs nothing extra. | |
| 1913 | + * | |
| 1914 | + * @since 2.7.0 | |
| 1915 | + * @return bool | |
| 1916 | + */ | |
| 1917 | + private function sample_has_question_headings(): bool { | |
| 1918 | + foreach ($this->get_content_sample() as $row) { | |
| 1919 | + $content = (string) ($row['content'] ?? ''); | |
| 1920 | + | |
| 1921 | + if ('' === $content) { | |
| 1922 | + continue; | |
| 1923 | + } | |
| 1924 | + | |
| 1925 | + if (preg_match('/<h[2-4][^>]*>\s*[^<]*\?\s*<\/h[2-4]>/i', $content)) { | |
| 1926 | + return true; | |
| 1927 | + } | |
| 1928 | + } | |
| 1929 | + | |
| 1930 | + return false; | |
| 1931 | + } | |
| 1932 | + | |
| 1933 | + /** | |
| 1684 | 1934 | * Whether the site publishes FAQ / HowTo / Q&A structured data. |
| 1685 | 1935 | * |
| 1686 | 1936 | * Three sources, because three things emit it: the Schema Management |
| 1687 | 1937 | * System's enabled types, a per-post-type schema_type in Global SEO, and |
| @@ -1726,12 +1976,97 @@ | ||
| 1726 | 1976 | return true; |
| 1727 | 1977 | } |
| 1728 | 1978 | } |
| 1729 | 1979 | |
| 1980 | + // ...and again beyond the sample, which is the 100 most recent posts | |
| 1981 | + // and pages. A site whose only FAQ lives in a custom post type, or | |
| 1982 | + // simply further back than that, reported no answer schema while | |
| 1983 | + // ThinkRank was publishing exactly that (#686). Added alongside the | |
| 1984 | + // walk above rather than replacing it: the sample is already loaded and | |
| 1985 | + // resolves Bricks trees, so it stays the primary source and this only | |
| 1986 | + // extends the reach. | |
| 1987 | + foreach ($this->answer_content_candidates() as $post_id => $content) { | |
| 1988 | + if ($this->has_answer_content($post_id, $content)) { | |
| 1989 | + return true; | |
| 1990 | + } | |
| 1991 | + } | |
| 1992 | + | |
| 1730 | 1993 | return false; |
| 1731 | 1994 | } |
| 1732 | 1995 | |
| 1733 | 1996 | /** |
| 1997 | + * Posts that might carry a ThinkRank FAQ / How-To, from anywhere on the site. | |
| 1998 | + * | |
| 1999 | + * Narrowed in SQL to posts whose content or builder meta names one of | |
| 2000 | + * ThinkRank's answer surfaces, so the per-post confirmation below runs over | |
| 2001 | + * a handful of rows rather than the whole site. | |
| 2002 | + * | |
| 2003 | + * @since 2.7.0 | |
| 2004 | + * @return array<int,string> Post id => raw content. | |
| 2005 | + */ | |
| 2006 | + private function answer_content_candidates(): array { | |
| 2007 | + global $wpdb; | |
| 2008 | + | |
| 2009 | + $markers = [self::ANSWER_FAQ_NAME, self::ANSWER_HOWTO_NAME, 'wp:thinkrank/faq', 'wp:thinkrank/howto']; | |
| 2010 | + | |
| 2011 | + $content_clauses = []; | |
| 2012 | + $content_values = []; | |
| 2013 | + foreach ($markers as $marker) { | |
| 2014 | + $content_clauses[] = 'p.post_content LIKE %s'; | |
| 2015 | + $content_values[] = '%' . $wpdb->esc_like($marker) . '%'; | |
| 2016 | + } | |
| 2017 | + | |
| 2018 | + $meta_keys = self::builder_meta_keys(); | |
| 2019 | + $meta_sql = ''; | |
| 2020 | + $values = $content_values; | |
| 2021 | + | |
| 2022 | + if (!empty($meta_keys)) { | |
| 2023 | + $meta_clauses = []; | |
| 2024 | + $meta_values = []; | |
| 2025 | + foreach ($markers as $marker) { | |
| 2026 | + $meta_clauses[] = 'pm.meta_value LIKE %s'; | |
| 2027 | + $meta_values[] = '%' . $wpdb->esc_like($marker) . '%'; | |
| 2028 | + } | |
| 2029 | + | |
| 2030 | + $key_placeholders = implode(', ', array_fill(0, count($meta_keys), '%s')); | |
| 2031 | + $meta_sql = " OR EXISTS ( | |
| 2032 | + SELECT 1 FROM {$wpdb->postmeta} pm | |
| 2033 | + WHERE pm.post_id = p.ID | |
| 2034 | + AND pm.meta_key IN ({$key_placeholders}) | |
| 2035 | + AND (" . implode(' OR ', $meta_clauses) . ') | |
| 2036 | + )'; | |
| 2037 | + $values = array_merge($content_values, $meta_keys, $meta_values); | |
| 2038 | + } | |
| 2039 | + | |
| 2040 | + $values[] = self::CONTENT_SAMPLE_SIZE; | |
| 2041 | + | |
| 2042 | + // Same exemption as qa_content_markers_exist(): the clause lists are | |
| 2043 | + // sized from fixed marker and meta-key lists, and every value is | |
| 2044 | + // prepared. | |
| 2045 | + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 2046 | + $rows = $wpdb->get_results( | |
| 2047 | + $wpdb->prepare( | |
| 2048 | + "SELECT p.ID, p.post_content FROM {$wpdb->posts} p | |
| 2049 | + WHERE p.post_status = 'publish' | |
| 2050 | + AND ((" . implode(' OR ', $content_clauses) . ") | |
| 2051 | + {$meta_sql}) | |
| 2052 | + ORDER BY p.post_date DESC | |
| 2053 | + LIMIT %d", | |
| 2054 | + ...$values | |
| 2055 | + ), | |
| 2056 | + ARRAY_A | |
| 2057 | + ); | |
| 2058 | + // phpcs:enable | |
| 2059 | + | |
| 2060 | + $candidates = []; | |
| 2061 | + foreach ((array) $rows as $row) { | |
| 2062 | + $candidates[(int) $row['ID']] = (string) $row['post_content']; | |
| 2063 | + } | |
| 2064 | + | |
| 2065 | + return $candidates; | |
| 2066 | + } | |
| 2067 | + | |
| 2068 | + /** | |
| 1734 | 2069 | * Whether one post carries a ThinkRank FAQ or How-To that emits schema. |
| 1735 | 2070 | * |
| 1736 | 2071 | * Four surfaces, because `Schema_Graph` collects from four: the Gutenberg |
| 1737 | 2072 | * block in `post_content`, the Elementor widget, the Bricks element and the |
| @@ -1781,12 +2116,76 @@ | ||
| 1781 | 2116 | } |
| 1782 | 2117 | } |
| 1783 | 2118 | } |
| 1784 | 2119 | |
| 2120 | + // Oxygen and Breakdance were missing entirely, so a ThinkRank FAQ | |
| 2121 | + // element placed inside one of those pages reported no answer schema | |
| 2122 | + // while the page was publishing exactly that (#686). Builder_Content | |
| 2123 | + // already knows every key involved — Oxygen 6 is Breakdance under the | |
| 2124 | + // hood, and older releases used two other keys — so ask it rather than | |
| 2125 | + // keeping a second list that can drift. | |
| 2126 | + foreach (self::builder_meta_keys() as $meta_key) { | |
| 2127 | + if ('_fl_builder_data' === $meta_key || '_elementor_data' === $meta_key) { | |
| 2128 | + continue; // Handled above, in their own storage shapes. | |
| 2129 | + } | |
| 2130 | + | |
| 2131 | + $stored = get_post_meta($post_id, $meta_key, true); | |
| 2132 | + | |
| 2133 | + if (self::blob_names_answer_element($stored)) { | |
| 2134 | + return true; | |
| 2135 | + } | |
| 2136 | + } | |
| 2137 | + | |
| 1785 | 2138 | return false; |
| 1786 | 2139 | } |
| 1787 | 2140 | |
| 1788 | 2141 | /** |
| 2142 | + * Builder meta keys, or [] when Builder_Content is unavailable. | |
| 2143 | + * | |
| 2144 | + * Mirrors the defensive load in bricks_has_answer_element(): the analyzer | |
| 2145 | + * must degrade to "no answer content found" on a partial checkout rather | |
| 2146 | + * than fatal mid-audit. | |
| 2147 | + * | |
| 2148 | + * @since 2.7.0 | |
| 2149 | + * @return string[] | |
| 2150 | + */ | |
| 2151 | + private static function builder_meta_keys(): array { | |
| 2152 | + if (!class_exists('ThinkRank\\SEO\\Builder_Content')) { | |
| 2153 | + $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php'; | |
| 2154 | + if (!file_exists($file)) { | |
| 2155 | + return []; | |
| 2156 | + } | |
| 2157 | + require_once $file; | |
| 2158 | + } | |
| 2159 | + | |
| 2160 | + return Builder_Content::builder_meta_keys(); | |
| 2161 | + } | |
| 2162 | + | |
| 2163 | + /** | |
| 2164 | + * Whether a stored builder blob names a ThinkRank FAQ or How-To. | |
| 2165 | + * | |
| 2166 | + * The blob is a JSON tree for Breakdance/Oxygen 6 and a shortcode string | |
| 2167 | + * for Oxygen classic, so this matches on the element name appearing in the | |
| 2168 | + * serialized form rather than parsing each dialect. | |
| 2169 | + * | |
| 2170 | + * @since 2.7.0 | |
| 2171 | + * @param mixed $stored Raw meta value. | |
| 2172 | + * @return bool | |
| 2173 | + */ | |
| 2174 | + private static function blob_names_answer_element($stored): bool { | |
| 2175 | + if (is_array($stored)) { | |
| 2176 | + $stored = wp_json_encode($stored); | |
| 2177 | + } | |
| 2178 | + | |
| 2179 | + if (!is_string($stored) || '' === $stored) { | |
| 2180 | + return false; | |
| 2181 | + } | |
| 2182 | + | |
| 2183 | + return false !== strpos($stored, self::ANSWER_FAQ_NAME) | |
| 2184 | + || false !== strpos($stored, self::ANSWER_HOWTO_NAME); | |
| 2185 | + } | |
| 2186 | + | |
| 2187 | + /** | |
| 1789 | 2188 | * Whether a Bricks-rendered post holds a ThinkRank FAQ or How-To element. |
| 1790 | 2189 | * |
| 1791 | 2190 | * @since 2.5.0 |
| 1792 | 2191 | * @param int $post_id Post to inspect. |
| @@ -1916,9 +2315,9 @@ | ||
| 1916 | 2315 | /* translators: 1: matching posts, 2: sampled posts. */ |
| 1917 | 2316 | __('%1$d of your %2$d most recent pages use question-style headings.', 'thinkrank'), |
| 1918 | 2317 | /* translators: 1: posts without one, 2: sampled posts. */ |
| 1919 | 2318 | __('%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'), |
| 1920 | - __('Phrase at least one H2 or H3 per page as the question it answers — "How does X work?" rather than "Overview".', 'thinkrank'), | |
| 2319 | + __('Phrase at least one H2 or H3 per page as the question it answers: "How does X work?" rather than "Overview".', 'thinkrank'), | |
| 1921 | 2320 | __('No published content to check yet.', 'thinkrank') |
| 1922 | 2321 | ); |
| 1923 | 2322 | } |
| 1924 | 2323 | |