| @@ -48,8 +48,56 @@ | ||
| 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 | + | |
| 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 | + | |
| 52 | 100 | // Check result statuses. |
| 53 | 101 | public const PASSED = 'passed'; |
| 54 | 102 | public const WARNING = 'warning'; |
| 55 | 103 | public const FAILED = 'failed'; |
| @@ -532,9 +580,9 @@ | ||
| 532 | 580 | if (!get_option('blog_public')) { |
| 533 | 581 | return [ |
| 534 | 582 | 'label' => __('Site is visible to search engines', 'thinkrank'), |
| 535 | 583 | 'status' => self::FAILED, |
| 536 | - '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'), | |
| 537 | 585 | 'how_to_fix' => __('Untick "Discourage search engines from indexing this site" under Settings → Reading.', 'thinkrank'), |
| 538 | 586 | ]; |
| 539 | 587 | } |
| 540 | 588 | |
| @@ -855,9 +903,9 @@ | ||
| 855 | 903 | 'label' => $label, |
| 856 | 904 | 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED, |
| 857 | 905 | /* translators: 1: posts missing a meta description, 2: sampled posts. */ |
| 858 | 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), |
| 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'), | |
| 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'), | |
| 860 | 908 | 'value' => $value, |
| 861 | 909 | 'affected_posts' => $this->affected_posts($without), |
| 862 | 910 | ]; |
| 863 | 911 | } |
| @@ -1184,8 +1232,16 @@ | ||
| 1184 | 1232 | */ |
| 1185 | 1233 | private $sample_post_ids = null; |
| 1186 | 1234 | |
| 1187 | 1235 | /** |
| 1236 | + * Memoised answer to "does this site publish Q&A content?". | |
| 1237 | + * | |
| 1238 | + * @since 2.7.0 | |
| 1239 | + * @var bool|null | |
| 1240 | + */ | |
| 1241 | + private $site_has_qa_content = null; | |
| 1242 | + | |
| 1243 | + /** | |
| 1188 | 1244 | * The sampled post ids, fetched once and shared by every check that reads |
| 1189 | 1245 | * the same slice. |
| 1190 | 1246 | * |
| 1191 | 1247 | * @since 2.5.0 |
| @@ -1218,8 +1274,13 @@ | ||
| 1218 | 1274 | * REST request would fire every shortcode and block renderer on the site. |
| 1219 | 1275 | * The structural signals these checks look for (headings, lists, tables, |
| 1220 | 1276 | * the opening passage) survive in the stored markup. |
| 1221 | 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 | + * | |
| 1222 | 1283 | * @since 2.5.0 |
| 1223 | 1284 | * @return array<int,array{id: int, content: string, text: string, modified: int}> |
| 1224 | 1285 | */ |
| 1225 | 1286 | private function get_content_sample(): array { |
| @@ -1228,10 +1289,12 @@ | ||
| 1228 | 1289 | } |
| 1229 | 1290 | |
| 1230 | 1291 | $post_ids = $this->sample_post_ids(); |
| 1231 | 1292 | |
| 1293 | + // Meta too: builder_content() asks every sampled post whether Bricks | |
| 1294 | + // owns it, and one query beats a hundred. | |
| 1232 | 1295 | if (function_exists('_prime_post_caches')) { |
| 1233 | - _prime_post_caches($post_ids, false, false); | |
| 1296 | + _prime_post_caches($post_ids, false, true); | |
| 1234 | 1297 | } |
| 1235 | 1298 | |
| 1236 | 1299 | $sample = []; |
| 1237 | 1300 | |
| @@ -1242,9 +1305,9 @@ | ||
| 1242 | 1305 | } |
| 1243 | 1306 | |
| 1244 | 1307 | $modified = isset($post->post_modified_gmt) ? strtotime((string) $post->post_modified_gmt . ' UTC') : false; |
| 1245 | 1308 | |
| 1246 | - $content = (string) $post->post_content; | |
| 1309 | + $content = $this->builder_content($post) ?? (string) $post->post_content; | |
| 1247 | 1310 | |
| 1248 | 1311 | $sample[] = [ |
| 1249 | 1312 | 'id' => (int) $post->ID, |
| 1250 | 1313 | 'content' => $content, |
| @@ -1258,8 +1321,45 @@ | ||
| 1258 | 1321 | return $sample; |
| 1259 | 1322 | } |
| 1260 | 1323 | |
| 1261 | 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 | + /** | |
| 1262 | 1362 | * Shared shape for the content-sampled GEO checks: count how many posts in |
| 1263 | 1363 | * the sample satisfy a predicate and grade it on the coverage thresholds |
| 1264 | 1364 | * the Content category already uses. |
| 1265 | 1365 | * |
| @@ -1280,23 +1380,30 @@ | ||
| 1280 | 1380 | string $fail_text, |
| 1281 | 1381 | string $how_to_fix, |
| 1282 | 1382 | string $empty_text |
| 1283 | 1383 | ): 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) { | |
| 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) { | |
| 1291 | 1395 | if ('' !== $row['text']) { |
| 1292 | 1396 | $sample[] = $row; |
| 1397 | + } else { | |
| 1398 | + $textless[] = $row['id']; | |
| 1293 | 1399 | } |
| 1294 | 1400 | } |
| 1295 | 1401 | |
| 1296 | 1402 | $total = count($sample); |
| 1297 | 1403 | |
| 1298 | - if (0 === $total) { | |
| 1404 | + // Nothing published: nothing to report. | |
| 1405 | + if ([] === $sampled) { | |
| 1299 | 1406 | return [ |
| 1300 | 1407 | 'label' => $label, |
| 1301 | 1408 | 'status' => self::PASSED, |
| 1302 | 1409 | 'message' => $empty_text, |
| @@ -1302,8 +1409,32 @@ | ||
| 1302 | 1409 | 'message' => $empty_text, |
| 1303 | 1410 | ]; |
| 1304 | 1411 | } |
| 1305 | 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 | + | |
| 1306 | 1437 | $matching = 0; |
| 1307 | 1438 | $failing = []; |
| 1308 | 1439 | foreach ($sample as $row) { |
| 1309 | 1440 | if ($predicate($row)) { |
| @@ -1590,12 +1721,9 @@ | ||
| 1590 | 1721 | } |
| 1591 | 1722 | |
| 1592 | 1723 | $collecting = false; |
| 1593 | 1724 | |
| 1594 | - // `/` is the canonical full block; `/*` is the same instruction | |
| 1595 | - // written for a wildcard-aware crawler, and every answer engine on | |
| 1596 | - // the list is one. | |
| 1597 | - if ('disallow' === $field && ('/' === $value || '/*' === $value)) { | |
| 1725 | + if ('disallow' === $field && self::disallow_blocks_everything($value)) { | |
| 1598 | 1726 | foreach ($current as $agent) { |
| 1599 | 1727 | $groups[$agent] = true; |
| 1600 | 1728 | } |
| 1601 | 1729 | } |
| @@ -1604,8 +1732,46 @@ | ||
| 1604 | 1732 | return $groups; |
| 1605 | 1733 | } |
| 1606 | 1734 | |
| 1607 | 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 | + /** | |
| 1608 | 1774 | * /llms.txt should be published — it is the one file whose entire purpose |
| 1609 | 1775 | * is telling an AI assistant what this site is and what to read. |
| 1610 | 1776 | * |
| 1611 | 1777 | * @since 2.5.0 |
| @@ -1658,9 +1824,18 @@ | ||
| 1658 | 1824 | '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 | 1825 | ]; |
| 1660 | 1826 | } |
| 1661 | 1827 | |
| 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'); | |
| 1828 | + // Only advise FAQ/HowTo markup where there is question-and-answer | |
| 1829 | + // content to mark up. This used to be assigned unconditionally and | |
| 1830 | + // reused by both branches below, so a site of ordinary articles was | |
| 1831 | + // told to enable FAQPage — and a user who follows that gets FAQPage | |
| 1832 | + // schema with an empty or invented mainEntity, which is the failure | |
| 1833 | + // #494 documents. The check is allowed to report the gap; it is not | |
| 1834 | + // allowed to recommend manufacturing the content (#686). | |
| 1835 | + $how_to_fix = $this->site_has_qa_content() | |
| 1836 | + ? __('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') | |
| 1837 | + : __('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 | 1838 | |
| 1664 | 1839 | // Article/WebPage schema still tells an engine what the page is; the |
| 1665 | 1840 | // gap is the answer pairing, not structured data as a whole. |
| 1666 | 1841 | if ($this->schema_is_output()) { |
| @@ -1680,8 +1855,150 @@ | ||
| 1680 | 1855 | ]; |
| 1681 | 1856 | } |
| 1682 | 1857 | |
| 1683 | 1858 | /** |
| 1859 | + * Whether the site plausibly publishes question-and-answer content. | |
| 1860 | + * | |
| 1861 | + * Deliberately a different question from has_answer_schema(). That one asks | |
| 1862 | + * "does ThinkRank emit answer markup?", which is what the check reports on. | |
| 1863 | + * This one asks "is there anything here that answer markup would describe?", | |
| 1864 | + * which is what decides whether recommending it is sound advice — and it has | |
| 1865 | + * to see content ThinkRank did not author, because an FAQ built with a page | |
| 1866 | + * builder's own accordion is still an FAQ (#686). | |
| 1867 | + * | |
| 1868 | + * Any one signal is enough; all of them are bounded. | |
| 1869 | + * | |
| 1870 | + * @since 2.7.0 | |
| 1871 | + * @return bool | |
| 1872 | + */ | |
| 1873 | + private function site_has_qa_content(): bool { | |
| 1874 | + if (null !== $this->site_has_qa_content) { | |
| 1875 | + return $this->site_has_qa_content; | |
| 1876 | + } | |
| 1877 | + | |
| 1878 | + $this->site_has_qa_content = $this->qa_content_markers_exist() | |
| 1879 | + || $this->sample_has_question_headings(); | |
| 1880 | + | |
| 1881 | + return $this->site_has_qa_content; | |
| 1882 | + } | |
| 1883 | + | |
| 1884 | + /** | |
| 1885 | + * Q&A markers in stored content or builder trees, site-wide. | |
| 1886 | + * | |
| 1887 | + * Two bounded queries rather than a walk over the content sample: the | |
| 1888 | + * sample is the 100 most recent posts and pages, so a site whose only FAQ | |
| 1889 | + * lives in a custom post type, or further back than that, answered "no | |
| 1890 | + * answer content" while publishing exactly that (#686). | |
| 1891 | + * | |
| 1892 | + * @since 2.7.0 | |
| 1893 | + * @return bool | |
| 1894 | + */ | |
| 1895 | + private function qa_content_markers_exist(): bool { | |
| 1896 | + global $wpdb; | |
| 1897 | + | |
| 1898 | + // Block markup and core's details/summary block, in any public type. | |
| 1899 | + $content_markers = ['wp:thinkrank/faq', 'wp:thinkrank/howto', 'wp:details']; | |
| 1900 | + $clauses = []; | |
| 1901 | + $values = []; | |
| 1902 | + | |
| 1903 | + foreach ($content_markers as $marker) { | |
| 1904 | + $clauses[] = 'p.post_content LIKE %s'; | |
| 1905 | + $values[] = '%' . $wpdb->esc_like($marker) . '%'; | |
| 1906 | + } | |
| 1907 | + | |
| 1908 | + // The OR list is one '%s' per entry in a fixed class-level marker list, | |
| 1909 | + // so its length varies but its content never comes from input; every | |
| 1910 | + // value goes through prepare(). phpcs cannot see that, and this is the | |
| 1911 | + // usual variable-length-IN exemption. | |
| 1912 | + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1913 | + $found = (int) $wpdb->get_var( | |
| 1914 | + $wpdb->prepare( | |
| 1915 | + "SELECT 1 FROM {$wpdb->posts} p | |
| 1916 | + WHERE p.post_status = 'publish' | |
| 1917 | + AND (" . implode(' OR ', $clauses) . ') | |
| 1918 | + LIMIT 1', | |
| 1919 | + ...$values | |
| 1920 | + ) | |
| 1921 | + ); | |
| 1922 | + // phpcs:enable | |
| 1923 | + | |
| 1924 | + if (1 === $found) { | |
| 1925 | + return true; | |
| 1926 | + } | |
| 1927 | + | |
| 1928 | + // Builder trees: ThinkRank's own elements, and the builders' generic | |
| 1929 | + // accordion/toggle/FAQ elements, which are what a non-ThinkRank FAQ | |
| 1930 | + // is actually built from. Only layouts the visitor is served count. | |
| 1931 | + $meta_keys = self::rendered_builder_meta_keys(); | |
| 1932 | + | |
| 1933 | + if (empty($meta_keys)) { | |
| 1934 | + return false; | |
| 1935 | + } | |
| 1936 | + | |
| 1937 | + $markers = array_merge( | |
| 1938 | + [self::ANSWER_FAQ_NAME, self::ANSWER_HOWTO_NAME], | |
| 1939 | + self::GENERIC_QA_ELEMENT_MARKERS | |
| 1940 | + ); | |
| 1941 | + | |
| 1942 | + $key_placeholders = implode(', ', array_fill(0, count($meta_keys), '%s')); | |
| 1943 | + $marker_clauses = []; | |
| 1944 | + $marker_values = []; | |
| 1945 | + | |
| 1946 | + foreach ($markers as $marker) { | |
| 1947 | + $marker_clauses[] = 'pm.meta_value LIKE %s'; | |
| 1948 | + $marker_values[] = '%' . $wpdb->esc_like($marker) . '%'; | |
| 1949 | + } | |
| 1950 | + | |
| 1951 | + [$gate_sql, $gate_values] = self::builder_layout_renders_sql('pm'); | |
| 1952 | + | |
| 1953 | + // Same exemption: both placeholder runs are sized from fixed lists — | |
| 1954 | + // the builder meta keys and the marker list — and every value is | |
| 1955 | + // prepared. | |
| 1956 | + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 1957 | + $found = (int) $wpdb->get_var( | |
| 1958 | + $wpdb->prepare( | |
| 1959 | + "SELECT 1 FROM {$wpdb->postmeta} pm | |
| 1960 | + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id | |
| 1961 | + WHERE p.post_status = 'publish' | |
| 1962 | + AND pm.meta_key IN ({$key_placeholders}) | |
| 1963 | + AND (" . implode(' OR ', $marker_clauses) . ") | |
| 1964 | + {$gate_sql} | |
| 1965 | + LIMIT 1", | |
| 1966 | + ...array_merge($meta_keys, $marker_values, $gate_values) | |
| 1967 | + ) | |
| 1968 | + ); | |
| 1969 | + // phpcs:enable | |
| 1970 | + | |
| 1971 | + return 1 === $found; | |
| 1972 | + } | |
| 1973 | + | |
| 1974 | + /** | |
| 1975 | + * Whether the sampled content asks questions in its headings. | |
| 1976 | + * | |
| 1977 | + * The weakest signal and the last one tried: prose that poses questions is | |
| 1978 | + * content answer markup could describe, even where nothing has been marked | |
| 1979 | + * up yet. Runs over the existing sample, so it costs nothing extra. | |
| 1980 | + * | |
| 1981 | + * @since 2.7.0 | |
| 1982 | + * @return bool | |
| 1983 | + */ | |
| 1984 | + private function sample_has_question_headings(): bool { | |
| 1985 | + foreach ($this->get_content_sample() as $row) { | |
| 1986 | + $content = (string) ($row['content'] ?? ''); | |
| 1987 | + | |
| 1988 | + if ('' === $content) { | |
| 1989 | + continue; | |
| 1990 | + } | |
| 1991 | + | |
| 1992 | + if (preg_match('/<h[2-4][^>]*>\s*[^<]*\?\s*<\/h[2-4]>/i', $content)) { | |
| 1993 | + return true; | |
| 1994 | + } | |
| 1995 | + } | |
| 1996 | + | |
| 1997 | + return false; | |
| 1998 | + } | |
| 1999 | + | |
| 2000 | + /** | |
| 1684 | 2001 | * Whether the site publishes FAQ / HowTo / Q&A structured data. |
| 1685 | 2002 | * |
| 1686 | 2003 | * Three sources, because three things emit it: the Schema Management |
| 1687 | 2004 | * System's enabled types, a per-post-type schema_type in Global SEO, and |
| @@ -1726,12 +2043,100 @@ | ||
| 1726 | 2043 | return true; |
| 1727 | 2044 | } |
| 1728 | 2045 | } |
| 1729 | 2046 | |
| 2047 | + // ...and again beyond the sample, which is the 100 most recent posts | |
| 2048 | + // and pages. A site whose only FAQ lives in a custom post type, or | |
| 2049 | + // simply further back than that, reported no answer schema while | |
| 2050 | + // ThinkRank was publishing exactly that (#686). Added alongside the | |
| 2051 | + // walk above rather than replacing it: the sample is already loaded and | |
| 2052 | + // resolves Bricks trees, so it stays the primary source and this only | |
| 2053 | + // extends the reach. | |
| 2054 | + foreach ($this->answer_content_candidates() as $post_id => $content) { | |
| 2055 | + if ($this->has_answer_content($post_id, $content)) { | |
| 2056 | + return true; | |
| 2057 | + } | |
| 2058 | + } | |
| 2059 | + | |
| 1730 | 2060 | return false; |
| 1731 | 2061 | } |
| 1732 | 2062 | |
| 1733 | 2063 | /** |
| 2064 | + * Posts that might carry a ThinkRank FAQ / How-To, from anywhere on the site. | |
| 2065 | + * | |
| 2066 | + * Narrowed in SQL to posts whose content or builder meta names one of | |
| 2067 | + * ThinkRank's answer surfaces, so the per-post confirmation below runs over | |
| 2068 | + * a handful of rows rather than the whole site. | |
| 2069 | + * | |
| 2070 | + * @since 2.7.0 | |
| 2071 | + * @return array<int,string> Post id => raw content. | |
| 2072 | + */ | |
| 2073 | + private function answer_content_candidates(): array { | |
| 2074 | + global $wpdb; | |
| 2075 | + | |
| 2076 | + $markers = [self::ANSWER_FAQ_NAME, self::ANSWER_HOWTO_NAME, 'wp:thinkrank/faq', 'wp:thinkrank/howto']; | |
| 2077 | + | |
| 2078 | + $content_clauses = []; | |
| 2079 | + $content_values = []; | |
| 2080 | + foreach ($markers as $marker) { | |
| 2081 | + $content_clauses[] = 'p.post_content LIKE %s'; | |
| 2082 | + $content_values[] = '%' . $wpdb->esc_like($marker) . '%'; | |
| 2083 | + } | |
| 2084 | + | |
| 2085 | + $meta_keys = self::rendered_builder_meta_keys(); | |
| 2086 | + $meta_sql = ''; | |
| 2087 | + $values = $content_values; | |
| 2088 | + | |
| 2089 | + if (!empty($meta_keys)) { | |
| 2090 | + $meta_clauses = []; | |
| 2091 | + $meta_values = []; | |
| 2092 | + foreach ($markers as $marker) { | |
| 2093 | + $meta_clauses[] = 'pm.meta_value LIKE %s'; | |
| 2094 | + $meta_values[] = '%' . $wpdb->esc_like($marker) . '%'; | |
| 2095 | + } | |
| 2096 | + | |
| 2097 | + [$gate_sql, $gate_values] = self::builder_layout_renders_sql('pm'); | |
| 2098 | + | |
| 2099 | + $key_placeholders = implode(', ', array_fill(0, count($meta_keys), '%s')); | |
| 2100 | + $meta_sql = " OR EXISTS ( | |
| 2101 | + SELECT 1 FROM {$wpdb->postmeta} pm | |
| 2102 | + WHERE pm.post_id = p.ID | |
| 2103 | + AND pm.meta_key IN ({$key_placeholders}) | |
| 2104 | + AND (" . implode(' OR ', $meta_clauses) . ") | |
| 2105 | + {$gate_sql} | |
| 2106 | + )"; | |
| 2107 | + $values = array_merge($content_values, $meta_keys, $meta_values, $gate_values); | |
| 2108 | + } | |
| 2109 | + | |
| 2110 | + $values[] = self::CONTENT_SAMPLE_SIZE; | |
| 2111 | + | |
| 2112 | + // Same exemption as qa_content_markers_exist(): the clause lists are | |
| 2113 | + // sized from fixed marker and meta-key lists, and every value is | |
| 2114 | + // prepared. | |
| 2115 | + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching | |
| 2116 | + $rows = $wpdb->get_results( | |
| 2117 | + $wpdb->prepare( | |
| 2118 | + "SELECT p.ID, p.post_content FROM {$wpdb->posts} p | |
| 2119 | + WHERE p.post_status = 'publish' | |
| 2120 | + AND ((" . implode(' OR ', $content_clauses) . ") | |
| 2121 | + {$meta_sql}) | |
| 2122 | + ORDER BY p.post_date DESC | |
| 2123 | + LIMIT %d", | |
| 2124 | + ...$values | |
| 2125 | + ), | |
| 2126 | + ARRAY_A | |
| 2127 | + ); | |
| 2128 | + // phpcs:enable | |
| 2129 | + | |
| 2130 | + $candidates = []; | |
| 2131 | + foreach ((array) $rows as $row) { | |
| 2132 | + $candidates[(int) $row['ID']] = (string) $row['post_content']; | |
| 2133 | + } | |
| 2134 | + | |
| 2135 | + return $candidates; | |
| 2136 | + } | |
| 2137 | + | |
| 2138 | + /** | |
| 1734 | 2139 | * Whether one post carries a ThinkRank FAQ or How-To that emits schema. |
| 1735 | 2140 | * |
| 1736 | 2141 | * Four surfaces, because `Schema_Graph` collects from four: the Gutenberg |
| 1737 | 2142 | * block in `post_content`, the Elementor widget, the Bricks element and the |
| @@ -1754,9 +2159,13 @@ | ||
| 1754 | 2159 | return false; |
| 1755 | 2160 | } |
| 1756 | 2161 | |
| 1757 | 2162 | // Elementor stores its tree as JSON, so the widget name appears verbatim. |
| 1758 | - $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 | + : ''; | |
| 1759 | 2168 | if (is_string($elementor) |
| 1760 | 2169 | && (false !== strpos($elementor, '"' . self::ANSWER_FAQ_NAME . '"') |
| 1761 | 2170 | || false !== strpos($elementor, '"' . self::ANSWER_HOWTO_NAME . '"'))) { |
| 1762 | 2171 | return true; |
| @@ -1768,9 +2177,12 @@ | ||
| 1768 | 2177 | return true; |
| 1769 | 2178 | } |
| 1770 | 2179 | |
| 1771 | 2180 | // Beaver Builder keeps its layout in postmeta as a map of node objects. |
| 1772 | - $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 | + : []; | |
| 1773 | 2185 | if (is_array($layout)) { |
| 1774 | 2186 | foreach ($layout as $node) { |
| 1775 | 2187 | $settings = is_object($node) ? ($node->settings ?? null) : ($node['settings'] ?? null); |
| 1776 | 2188 | $settings = is_object($settings) ? get_object_vars($settings) : $settings; |
| @@ -1781,12 +2193,156 @@ | ||
| 1781 | 2193 | } |
| 1782 | 2194 | } |
| 1783 | 2195 | } |
| 1784 | 2196 | |
| 2197 | + // Oxygen and Breakdance were missing entirely, so a ThinkRank FAQ | |
| 2198 | + // element placed inside one of those pages reported no answer schema | |
| 2199 | + // while the page was publishing exactly that (#686). Builder_Content | |
| 2200 | + // already knows every key involved — Oxygen 6 is Breakdance under the | |
| 2201 | + // hood, and older releases used two other keys — so ask it rather than | |
| 2202 | + // keeping a second list that can drift. | |
| 2203 | + foreach (self::rendered_builder_meta_keys() as $meta_key) { | |
| 2204 | + if ('_fl_builder_data' === $meta_key || '_elementor_data' === $meta_key) { | |
| 2205 | + continue; // Handled above, in their own storage shapes. | |
| 2206 | + } | |
| 2207 | + | |
| 2208 | + $stored = get_post_meta($post_id, $meta_key, true); | |
| 2209 | + | |
| 2210 | + if (self::blob_names_answer_element($stored)) { | |
| 2211 | + return true; | |
| 2212 | + } | |
| 2213 | + } | |
| 2214 | + | |
| 1785 | 2215 | return false; |
| 1786 | 2216 | } |
| 1787 | 2217 | |
| 1788 | 2218 | /** |
| 2219 | + * Builder meta keys, or [] when Builder_Content is unavailable. | |
| 2220 | + * | |
| 2221 | + * Mirrors the defensive load in bricks_has_answer_element(): the analyzer | |
| 2222 | + * must degrade to "no answer content found" on a partial checkout rather | |
| 2223 | + * than fatal mid-audit. | |
| 2224 | + * | |
| 2225 | + * @since 2.7.0 | |
| 2226 | + * @return string[] | |
| 2227 | + */ | |
| 2228 | + private static function builder_meta_keys(): array { | |
| 2229 | + if (!class_exists('ThinkRank\\SEO\\Builder_Content')) { | |
| 2230 | + $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php'; | |
| 2231 | + if (!file_exists($file)) { | |
| 2232 | + return []; | |
| 2233 | + } | |
| 2234 | + require_once $file; | |
| 2235 | + } | |
| 2236 | + | |
| 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]; | |
| 2318 | + } | |
| 2319 | + | |
| 2320 | + /** | |
| 2321 | + * Whether a stored builder blob names a ThinkRank FAQ or How-To. | |
| 2322 | + * | |
| 2323 | + * The blob is a JSON tree for Breakdance/Oxygen 6 and a shortcode string | |
| 2324 | + * for Oxygen classic, so this matches on the element name appearing in the | |
| 2325 | + * serialized form rather than parsing each dialect. | |
| 2326 | + * | |
| 2327 | + * @since 2.7.0 | |
| 2328 | + * @param mixed $stored Raw meta value. | |
| 2329 | + * @return bool | |
| 2330 | + */ | |
| 2331 | + private static function blob_names_answer_element($stored): bool { | |
| 2332 | + if (is_array($stored)) { | |
| 2333 | + $stored = wp_json_encode($stored); | |
| 2334 | + } | |
| 2335 | + | |
| 2336 | + if (!is_string($stored) || '' === $stored) { | |
| 2337 | + return false; | |
| 2338 | + } | |
| 2339 | + | |
| 2340 | + return false !== strpos($stored, self::ANSWER_FAQ_NAME) | |
| 2341 | + || false !== strpos($stored, self::ANSWER_HOWTO_NAME); | |
| 2342 | + } | |
| 2343 | + | |
| 2344 | + /** | |
| 1789 | 2345 | * Whether a Bricks-rendered post holds a ThinkRank FAQ or How-To element. |
| 1790 | 2346 | * |
| 1791 | 2347 | * @since 2.5.0 |
| 1792 | 2348 | * @param int $post_id Post to inspect. |
| @@ -1916,9 +2472,9 @@ | ||
| 1916 | 2472 | /* translators: 1: matching posts, 2: sampled posts. */ |
| 1917 | 2473 | __('%1$d of your %2$d most recent pages use question-style headings.', 'thinkrank'), |
| 1918 | 2474 | /* translators: 1: posts without one, 2: sampled posts. */ |
| 1919 | 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'), |
| 1920 | - __('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'), | |
| 1921 | 2477 | __('No published content to check yet.', 'thinkrank') |
| 1922 | 2478 | ); |
| 1923 | 2479 | } |
| 1924 | 2480 | |