PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.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 1.26.0 1.25.0 trunk All 53 releases
← All changes | includes/seo/class-schema-management-system.php +135 -43 2.4.0 → 2.12.0 View file →
@@ -201,13 +201,43 @@
201 201 'WebPage' => [
202 202 'name' => 'WebPage',
203 203 'description' => 'Individual web pages',
204 204 'required_properties' => ['name', 'url'],
205 + // 'post' as well as 'page': the per-page selector reaches this for
206 + // any post type, and a registry limited to 'page' silently produced
207 + // nothing for the rest (#624).
205 208 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
206 209 'rich_snippets' => ['webpage', 'breadcrumb'],
207 - 'context_types' => ['page'],
210 + 'context_types' => ['page', 'post'],
208 211 'priority' => 'medium'
209 212 ],
213 + 'AboutPage' => [
214 + 'name' => 'AboutPage',
215 + 'description' => 'A page describing the organisation or person behind the site',
216 + 'required_properties' => ['name', 'url'],
217 + 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
218 + 'rich_snippets' => ['webpage', 'breadcrumb'],
219 + 'context_types' => ['page', 'post'],
220 + 'priority' => 'medium'
221 + ],
222 + 'ContactPage' => [
223 + 'name' => 'ContactPage',
224 + 'description' => 'A page giving contact details',
225 + 'required_properties' => ['name', 'url'],
226 + 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
227 + 'rich_snippets' => ['webpage', 'breadcrumb'],
228 + 'context_types' => ['page', 'post'],
229 + 'priority' => 'medium'
230 + ],
231 + 'ProfilePage' => [
232 + 'name' => 'ProfilePage',
233 + 'description' => 'A page about a single person or organisation',
234 + 'required_properties' => ['name', 'url'],
235 + 'recommended_properties' => ['description', 'author', 'datePublished', 'breadcrumb'],
236 + 'rich_snippets' => ['webpage', 'breadcrumb'],
237 + 'context_types' => ['page', 'post'],
238 + 'priority' => 'medium'
239 + ],
210 240 'FAQPage' => [
211 241 'name' => 'FAQPage',
212 242 'description' => 'Frequently Asked Questions pages',
213 243 'required_properties' => ['mainEntity'],
@@ -397,9 +427,25 @@
397 427 'business_phone', 'business_email', 'business_hours',
398 428 'business_latitude', 'business_longitude', 'business_price_range',
399 429 ];
400 430
401 - if (empty(array_intersect_key($settings, array_flip($business_keys)))) {
431 + // Which deployed types a Site Identity key can invalidate. The business
432 + // block feeds LocalBusiness and Organization; alternate_name feeds the
433 + // WebSite node, which had no entry here at all — so editing it left the
434 + // deployed schema showing the previous value until something else
435 + // happened to redeploy (#692).
436 + $refresh_types = [];
437 +
438 + if (!empty(array_intersect_key($settings, array_flip($business_keys)))) {
439 + $refresh_types[] = 'LocalBusiness';
440 + $refresh_types[] = 'Organization';
441 + }
442 +
443 + if (array_key_exists('alternate_name', $settings)) {
444 + $refresh_types[] = 'WebSite';
445 + }
446 +
447 + if (empty($refresh_types)) {
402 448 return;
403 449 }
404 450
405 451 $schema_settings = $this->get_settings($context_type, $context_id);
@@ -409,9 +455,9 @@
409 455
410 456 // Only refresh types that are actually deployed, so this never adds a
411 457 // type the admin did not enable.
412 458 $deployed = array_keys((array) $this->get_deployed_schemas($context_type, $context_id));
413 - $affected = array_values(array_intersect($deployed, ['LocalBusiness', 'Organization']));
459 + $affected = array_values(array_intersect($deployed, $refresh_types));
414 460
415 461 if (empty($affected)) {
416 462 return;
417 463 }
@@ -1583,12 +1629,23 @@
1583 1629 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1584 1630 // Post/page/product data
1585 1631 $post = get_post($context_id);
1586 1632 if ($post) {
1633 + // Resolve the featured image's URL to its ID here, where the ID
1634 + // is in hand, so the schema builder does not query for an
1635 + // attachment it was just given (#847). Offered as a hint rather
1636 + // than asserted: `post_thumbnail_url` can swap the URL for one
1637 + // the featured image does not own.
1638 + $thumbnail_url = get_the_post_thumbnail_url($post->ID, 'full');
1639 +
1640 + if ($thumbnail_url) {
1641 + Attachment_Lookup::id_from_url((string) $thumbnail_url, (int) get_post_thumbnail_id($post->ID));
1642 + }
1643 +
1587 1644 $content_data = [
1588 1645 'title' => $post->post_title,
1589 1646 'url' => get_permalink($post->ID),
1590 - 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30),
1647 + 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30),
1591 1648 'content' => $post->post_content,
1592 1649 'author' => [
1593 1650 'name' => get_the_author_meta('display_name', $post->post_author),
1594 1651 'url' => get_author_posts_url($post->post_author)
@@ -1598,9 +1655,9 @@
1598 1655 // Google rejects as "Invalid value in field datePublished"
1599 1656 // and drops the Article rich result (#465).
1600 1657 'date' => get_the_date('c', $post),
1601 1658 'modified' => get_the_modified_date('c', $post),
1602 - 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1659 + 'image' => $thumbnail_url,
1603 1660 'focus_keywords' => Focus_Keywords::get($post->ID),
1604 1661 'business_data' => $this->get_business_data_from_local_seo(),
1605 1662 'site_data' => $this->get_site_data_for_schema(),
1606 1663 'social_data' => $this->get_social_data_for_schema()
@@ -1993,12 +2050,10 @@
1993 2050
1994 2051 /**
1995 2052 * Get deployed schemas for frontend integration
1996 2053 *
1997 - * PERFORMANCE OPTIMIZED: This method now uses:
1998 - * 1. Schema caching layer (90% reduction in database queries)
1999 - * 2. Window function approach instead of correlated subquery (80-90% query performance improvement)
2000 - * 3. Composite index: idx_context_schema_active
2054 + * Returns the newest active, deployed row of each schema type for the
2055 + * context. Results are cached per context (see Schema_Cache_Manager).
2001 2056 *
2002 2057 * @since 1.0.0
2003 2058 *
2004 2059 * @param string $context_type Context type
@@ -2021,48 +2076,57 @@
2021 2076
2022 2077 // Use existing seo_schema table
2023 2078 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2024 2079
2025 - // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery
2026 - // This leverages the new composite index: idx_context_schema_active (context_type, schema_type, is_active, created_at DESC)
2080 + // The newest row per type used to be picked with ROW_NUMBER() OVER
2081 + // (PARTITION BY schema_type ...). Window functions need MySQL 8.0 /
2082 + // MariaDB 10.2, and WordPress still runs on MySQL 5.7, where that is
2083 + // a syntax error on every page view and no deployed schema is ever
2084 + // output. A row is the newest of its type when no other row of the
2085 + // same context and type outranks it, so NOT EXISTS keeps the
2086 + // greatest-per-group in the database, and schema_data — JSON, and
2087 + // large — is only transferred for the rows that are output.
2088 + //
2089 + // `<=>` is NULL-safe equality: the site context stores context_id
2090 + // as NULL, and `n.context_id = s.context_id` is never true for it.
2091 + // schema_id breaks a same-second tie, which the window function
2092 + // left to chance.
2093 + $args = [$context_type];
2027 2094 if (null === $context_id) {
2028 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2029 - $sql = sprintf(
2030 - 'SELECT schema_type, schema_data FROM (SELECT schema_type, schema_data, ROW_NUMBER() OVER (PARTITION BY schema_type ORDER BY created_at DESC) as rn FROM %s WHERE context_type = %%s AND context_id IS NULL AND is_active = 1 AND validation_status IN (\'deployed\', \'valid\')) ranked WHERE rn = 1 ORDER BY schema_type',
2031 - $table_name
2032 - );
2033 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2034 - $deployed_schemas = $wpdb->get_results(
2035 - $wpdb->prepare(
2036 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2037 - $sql,
2038 - $context_type
2039 - ),
2040 - ARRAY_A
2041 - );
2095 + $context_where = 's.context_id IS NULL';
2042 2096 } else {
2043 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2044 - $sql = sprintf(
2045 - 'SELECT schema_type, schema_data FROM (SELECT schema_type, schema_data, ROW_NUMBER() OVER (PARTITION BY schema_type ORDER BY created_at DESC) as rn FROM %s WHERE context_type = %%s AND context_id = %%d AND is_active = 1 AND validation_status IN (\'deployed\', \'valid\')) ranked WHERE rn = 1 ORDER BY schema_type',
2046 - $table_name
2047 - );
2048 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2049 - $deployed_schemas = $wpdb->get_results(
2050 - $wpdb->prepare(
2051 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2052 - $sql,
2053 - $context_type,
2054 - $context_id
2055 - ),
2056 - ARRAY_A
2057 - );
2097 + $context_where = 's.context_id = %d';
2098 + $args[] = $context_id;
2058 2099 }
2059 2100
2101 + $sql = sprintf(
2102 + 'SELECT s.schema_type, s.schema_data FROM %1$s s'
2103 + . ' WHERE s.context_type = %%s AND %2$s AND s.is_active = 1 AND s.validation_status IN (\'deployed\', \'valid\')'
2104 + . ' AND NOT EXISTS ('
2105 + . 'SELECT 1 FROM %1$s n'
2106 + . ' WHERE n.context_type = s.context_type AND n.context_id <=> s.context_id AND n.schema_type = s.schema_type'
2107 + . ' AND n.is_active = 1 AND n.validation_status IN (\'deployed\', \'valid\')'
2108 + . ' AND (n.created_at > s.created_at OR (n.created_at = s.created_at AND n.schema_id > s.schema_id))'
2109 + . ')'
2110 + . ' ORDER BY s.schema_type',
2111 + $table_name,
2112 + $context_where
2113 + );
2114 +
2115 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2116 + $deployed_schemas = $wpdb->get_results(
2117 + $wpdb->prepare(
2118 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2119 + $sql,
2120 + ...$args
2121 + ),
2122 + ARRAY_A
2123 + );
2124 +
2060 2125 // Deliberately no early return on an empty result: it has to reach the
2061 2126 // cache write below. Most URLs have no deployed schema, so gating the
2062 2127 // write on a non-empty result made the majority of front-end requests
2063 - // permanent cache misses, re-running a ROW_NUMBER() OVER (PARTITION BY
2064 - // ...) query with two filesorts on every pageview (#392).
2128 + // permanent cache misses, re-running the query on every pageview (#392).
2065 2129 $deployed_schemas = $deployed_schemas ?: [];
2066 2130
2067 2131 // Process schemas for return
2068 2132 $processed_schemas = [];
@@ -2242,9 +2306,12 @@
2242 2306 }
2243 2307
2244 2308 $type = $schema['@type'] ?? '';
2245 2309 $type = is_array($type) ? reset($type) : $type;
2246 - $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true);
2310 + // A LocalBusiness is deployed under the subtype the site chose, so the
2311 + // exemption has to cover every subtype, not only the literal root.
2312 + $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true)
2313 + || \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type);
2247 2314
2248 2315 if (isset($schema['url']) && !$is_entity) {
2249 2316 $schema['url'] = $permalink;
2250 2317 }
@@ -2272,9 +2339,15 @@
2272 2339 * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must
2273 2340 * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values
2274 2341 * inside author/publisher/@graph entries are covered too.
2275 2342 *
2343 + * Also decodes HTML entities in plain-text properties. Schema_Builder
2344 + * stored the block editor's `&amp;` as-is until 2.10.0, and nothing
2345 + * decodes JSON-LD downstream, so every deployed node built from post text
2346 + * published the entity literally.
2347 + *
2276 2348 * @since 1.16.0
2349 + * @since 2.10.0 Decodes entities in plain-text properties.
2277 2350 *
2278 2351 * @param array $schema Decoded schema data.
2279 2352 * @return array Normalised schema.
2280 2353 */
@@ -2283,8 +2356,16 @@
2283 2356 'datePublished', 'dateModified', 'dateCreated', 'uploadDate',
2284 2357 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires',
2285 2358 ];
2286 2359
2360 + // Plain text in schema.org. Answer/HowToStep `text` is deliberately
2361 + // absent: Google reads Answer.text as HTML, where an entity is correct
2362 + // and decoding `&lt;` would turn escaped text into live markup.
2363 + static $text_keys = [
2364 + 'name', 'headline', 'alternativeHeadline', 'description',
2365 + 'reviewBody', 'about', 'abstract', 'caption',
2366 + ];
2367 +
2287 2368 foreach ($schema as $key => $value) {
2288 2369 if (is_array($value)) {
2289 2370 $schema[$key] = $this->normalize_stored_schema($value);
2290 2371 continue;
@@ -2294,8 +2375,16 @@
2294 2375 $schema[$key] = str_replace('_', '-', $value);
2295 2376 continue;
2296 2377 }
2297 2378
2379 + // Decode only: a snapshot already truncated with an ellipsis must
2380 + // keep it, which the full Seo_Text::normalize_schema_text() would
2381 + // strip as an excerpt marker.
2382 + if (in_array($key, $text_keys, true) && is_string($value) && '' !== $value) {
2383 + $schema[$key] = \ThinkRank\Core\Seo_Text::decode_schema_entities($value);
2384 + continue;
2385 + }
2386 +
2298 2387 if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) {
2299 2388 continue;
2300 2389 }
2301 2390
@@ -2598,8 +2687,11 @@
2598 2687 'site_url' => home_url(),
2599 2688 'admin_email' => get_option('admin_email'),
2600 2689 'language' => get_locale(),
2601 2690 'timezone' => get_option('timezone_string'),
2691 + // Read by populate_website_schema(), so the deployed WebSite node
2692 + // carries the same alternateName as the default one (#692).
2693 + 'alternate_name' => $site_identity_settings['alternate_name'] ?? '',
2602 2694 'founded_date' => $site_identity_settings['founded_date'] ?? '',
2603 2695 'founder_name' => $site_identity_settings['founder_name'] ?? '',
2604 2696 'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
2605 2697 // Site Identity assets