PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
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 1.28.0 1.27.0 All 56 releases
← All changes | includes/seo/class-seo-analyzer.php +413 -14 2.6.0 → 2.14.1 View file →
@@ -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