PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 All 58 releases
← All changes | includes/seo/class-seo-analyzer.php +576 -20 2.5.0 → 2.14.2 View file →
@@ -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