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.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 1.28.0 All 57 releases
← All changes | includes/seo/class-schema-management-system.php +186 -53 2.4.0 → 2.14.2 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 }
@@ -684,8 +730,27 @@
684 730 return $optimization;
685 731 }
686 732
687 733 /**
734 + * Deployed types an authoritative deploy takes off the page.
735 + *
736 + * Everything deployed that the payload left out, except the types the
737 + * caller sent but could not deploy: those failed validation, they were not
738 + * removed by the user, so their live copy stays. Retiring them took the
739 + * site's Organization down behind a success toast (#949).
740 + *
741 + * @since 2.14.1
742 + *
743 + * @param string[] $deployed Types deployed for the context now.
744 + * @param string[] $payload Types in this deploy.
745 + * @param string[] $retain_types Types sent but skipped by validation.
746 + * @return string[] Types to retire.
747 + */
748 + public static function types_to_retire(array $deployed, array $payload, array $retain_types = []): array {
749 + return array_values(array_diff($deployed, $payload, $retain_types));
750 + }
751 +
752 + /**
688 753 * Deploy schema markup with automated implementation
689 754 *
690 755 * @since 1.0.0
691 756 *
@@ -719,11 +784,12 @@
719 784 if (!empty($options['authoritative'])) {
720 785 $deployment['retired_schemas'] = $this->retire_schema_types(
721 786 $context_type,
722 787 $context_id,
723 - array_diff(
788 + self::types_to_retire(
724 789 array_keys($this->get_deployed_schemas($context_type, $context_id)),
725 - array_keys($schema_data)
790 + array_keys($schema_data),
791 + (array) ($options['retain_types'] ?? [])
726 792 )
727 793 );
728 794 }
729 795
@@ -1279,9 +1345,9 @@
1279 1345 $this->cache_manager->invalidate_all_cache();
1280 1346 }
1281 1347
1282 1348 // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change
1283 - if ($success && !empty($settings['auto_deploy'])) {
1349 + if ($success && self::should_auto_deploy($settings, $this->get_settings($context_type, $context_id))) {
1284 1350 $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
1285 1351 }
1286 1352
1287 1353 return $success;
@@ -1286,9 +1352,35 @@
1286 1352
1287 1353 return $success;
1288 1354 }
1289 1355
1356 +
1290 1357 /**
1358 + * Whether a save should redeploy the schema it changed.
1359 + *
1360 + * `auto_deploy` is a stored setting (on by default), not something a save
1361 + * restates. Reading it off the incoming patch meant a partial save — which
1362 + * is what the admin screen sends, one field at a time — skipped
1363 + * auto-deploy on a site that had it switched on, and the deployed snapshot
1364 + * the front end serves kept the name, logo and sameAs it was deployed
1365 + * with, however often the user saved (#904, the gate #12 left in place).
1366 + *
1367 + * A patch that does carry the key still wins, so a caller can deploy or
1368 + * hold deliberately.
1369 + *
1370 + * @param array $patch Settings being saved
1371 + * @param array $stored Settings as stored, after the save
1372 + * @return bool
1373 + */
1374 + public static function should_auto_deploy(array $patch, array $stored): bool {
1375 + if (array_key_exists('auto_deploy', $patch)) {
1376 + return !empty($patch['auto_deploy']);
1377 + }
1378 +
1379 + return !empty($stored['auto_deploy']);
1380 + }
1381 +
1382 + /**
1291 1383 * Auto-deploy schema when settings change
1292 1384 *
1293 1385 * Automatically regenerates and deploys schema markup when organization or other
1294 1386 * schema settings are modified, ensuring the frontend output stays in sync.
@@ -1583,12 +1675,23 @@
1583 1675 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1584 1676 // Post/page/product data
1585 1677 $post = get_post($context_id);
1586 1678 if ($post) {
1679 + // Resolve the featured image's URL to its ID here, where the ID
1680 + // is in hand, so the schema builder does not query for an
1681 + // attachment it was just given (#847). Offered as a hint rather
1682 + // than asserted: `post_thumbnail_url` can swap the URL for one
1683 + // the featured image does not own.
1684 + $thumbnail_url = get_the_post_thumbnail_url($post->ID, 'full');
1685 +
1686 + if ($thumbnail_url) {
1687 + Attachment_Lookup::id_from_url((string) $thumbnail_url, (int) get_post_thumbnail_id($post->ID));
1688 + }
1689 +
1587 1690 $content_data = [
1588 1691 'title' => $post->post_title,
1589 1692 'url' => get_permalink($post->ID),
1590 - 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30),
1693 + 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30),
1591 1694 'content' => $post->post_content,
1592 1695 'author' => [
1593 1696 'name' => get_the_author_meta('display_name', $post->post_author),
1594 1697 'url' => get_author_posts_url($post->post_author)
@@ -1598,9 +1701,9 @@
1598 1701 // Google rejects as "Invalid value in field datePublished"
1599 1702 // and drops the Article rich result (#465).
1600 1703 'date' => get_the_date('c', $post),
1601 1704 'modified' => get_the_modified_date('c', $post),
1602 - 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1705 + 'image' => $thumbnail_url,
1603 1706 'focus_keywords' => Focus_Keywords::get($post->ID),
1604 1707 'business_data' => $this->get_business_data_from_local_seo(),
1605 1708 'site_data' => $this->get_site_data_for_schema(),
1606 1709 'social_data' => $this->get_social_data_for_schema()
@@ -1993,12 +2096,10 @@
1993 2096
1994 2097 /**
1995 2098 * Get deployed schemas for frontend integration
1996 2099 *
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
2100 + * Returns the newest active, deployed row of each schema type for the
2101 + * context. Results are cached per context (see Schema_Cache_Manager).
2001 2102 *
2002 2103 * @since 1.0.0
2003 2104 *
2004 2105 * @param string $context_type Context type
@@ -2021,48 +2122,57 @@
2021 2122
2022 2123 // Use existing seo_schema table
2023 2124 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2024 2125
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)
2126 + // The newest row per type used to be picked with ROW_NUMBER() OVER
2127 + // (PARTITION BY schema_type ...). Window functions need MySQL 8.0 /
2128 + // MariaDB 10.2, and WordPress still runs on MySQL 5.7, where that is
2129 + // a syntax error on every page view and no deployed schema is ever
2130 + // output. A row is the newest of its type when no other row of the
2131 + // same context and type outranks it, so NOT EXISTS keeps the
2132 + // greatest-per-group in the database, and schema_data — JSON, and
2133 + // large — is only transferred for the rows that are output.
2134 + //
2135 + // `<=>` is NULL-safe equality: the site context stores context_id
2136 + // as NULL, and `n.context_id = s.context_id` is never true for it.
2137 + // schema_id breaks a same-second tie, which the window function
2138 + // left to chance.
2139 + $args = [$context_type];
2027 2140 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 - );
2141 + $context_where = 's.context_id IS NULL';
2042 2142 } 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 - );
2143 + $context_where = 's.context_id = %d';
2144 + $args[] = $context_id;
2058 2145 }
2059 2146
2147 + $sql = sprintf(
2148 + 'SELECT s.schema_type, s.schema_data FROM %1$s s'
2149 + . ' WHERE s.context_type = %%s AND %2$s AND s.is_active = 1 AND s.validation_status IN (\'deployed\', \'valid\')'
2150 + . ' AND NOT EXISTS ('
2151 + . 'SELECT 1 FROM %1$s n'
2152 + . ' WHERE n.context_type = s.context_type AND n.context_id <=> s.context_id AND n.schema_type = s.schema_type'
2153 + . ' AND n.is_active = 1 AND n.validation_status IN (\'deployed\', \'valid\')'
2154 + . ' AND (n.created_at > s.created_at OR (n.created_at = s.created_at AND n.schema_id > s.schema_id))'
2155 + . ')'
2156 + . ' ORDER BY s.schema_type',
2157 + $table_name,
2158 + $context_where
2159 + );
2160 +
2161 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2162 + $deployed_schemas = $wpdb->get_results(
2163 + $wpdb->prepare(
2164 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2165 + $sql,
2166 + ...$args
2167 + ),
2168 + ARRAY_A
2169 + );
2170 +
2060 2171 // Deliberately no early return on an empty result: it has to reach the
2061 2172 // cache write below. Most URLs have no deployed schema, so gating the
2062 2173 // 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).
2174 + // permanent cache misses, re-running the query on every pageview (#392).
2065 2175 $deployed_schemas = $deployed_schemas ?: [];
2066 2176
2067 2177 // Process schemas for return
2068 2178 $processed_schemas = [];
@@ -2134,15 +2244,10 @@
2134 2244 if (!is_string($url) || '' === trim($url)) {
2135 2245 return false;
2136 2246 }
2137 2247
2138 - if (!filter_var($url, FILTER_VALIDATE_URL)) {
2139 - return false;
2140 - }
2141 -
2142 - $scheme = wp_parse_url($url, PHP_URL_SCHEME);
2143 -
2144 - return in_array(strtolower((string) $scheme), ['http', 'https'], true);
2248 + // IRI-aware: a non-ASCII path or IDN host is a real URL (#924).
2249 + return \ThinkRank\Core\Url_Validator::is_http_url($url);
2145 2250 }
2146 2251
2147 2252 /**
2148 2253 * Drop values that are not URLs from URL-typed properties.
@@ -2242,9 +2347,12 @@
2242 2347 }
2243 2348
2244 2349 $type = $schema['@type'] ?? '';
2245 2350 $type = is_array($type) ? reset($type) : $type;
2246 - $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true);
2351 + // A LocalBusiness is deployed under the subtype the site chose, so the
2352 + // exemption has to cover every subtype, not only the literal root.
2353 + $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true)
2354 + || \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type);
2247 2355
2248 2356 if (isset($schema['url']) && !$is_entity) {
2249 2357 $schema['url'] = $permalink;
2250 2358 }
@@ -2272,9 +2380,15 @@
2272 2380 * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must
2273 2381 * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values
2274 2382 * inside author/publisher/@graph entries are covered too.
2275 2383 *
2384 + * Also decodes HTML entities in plain-text properties. Schema_Builder
2385 + * stored the block editor's `&amp;` as-is until 2.10.0, and nothing
2386 + * decodes JSON-LD downstream, so every deployed node built from post text
2387 + * published the entity literally.
2388 + *
2276 2389 * @since 1.16.0
2390 + * @since 2.10.0 Decodes entities in plain-text properties.
2277 2391 *
2278 2392 * @param array $schema Decoded schema data.
2279 2393 * @return array Normalised schema.
2280 2394 */
@@ -2283,8 +2397,16 @@
2283 2397 'datePublished', 'dateModified', 'dateCreated', 'uploadDate',
2284 2398 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires',
2285 2399 ];
2286 2400
2401 + // Plain text in schema.org. Answer/HowToStep `text` is deliberately
2402 + // absent: Google reads Answer.text as HTML, where an entity is correct
2403 + // and decoding `&lt;` would turn escaped text into live markup.
2404 + static $text_keys = [
2405 + 'name', 'headline', 'alternativeHeadline', 'description',
2406 + 'reviewBody', 'about', 'abstract', 'caption',
2407 + ];
2408 +
2287 2409 foreach ($schema as $key => $value) {
2288 2410 if (is_array($value)) {
2289 2411 $schema[$key] = $this->normalize_stored_schema($value);
2290 2412 continue;
@@ -2294,8 +2416,16 @@
2294 2416 $schema[$key] = str_replace('_', '-', $value);
2295 2417 continue;
2296 2418 }
2297 2419
2420 + // Decode only: a snapshot already truncated with an ellipsis must
2421 + // keep it, which the full Seo_Text::normalize_schema_text() would
2422 + // strip as an excerpt marker.
2423 + if (in_array($key, $text_keys, true) && is_string($value) && '' !== $value) {
2424 + $schema[$key] = \ThinkRank\Core\Seo_Text::decode_schema_entities($value);
2425 + continue;
2426 + }
2427 +
2298 2428 if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) {
2299 2429 continue;
2300 2430 }
2301 2431
@@ -2598,8 +2728,11 @@
2598 2728 'site_url' => home_url(),
2599 2729 'admin_email' => get_option('admin_email'),
2600 2730 'language' => get_locale(),
2601 2731 'timezone' => get_option('timezone_string'),
2732 + // Read by populate_website_schema(), so the deployed WebSite node
2733 + // carries the same alternateName as the default one (#692).
2734 + 'alternate_name' => $site_identity_settings['alternate_name'] ?? '',
2602 2735 'founded_date' => $site_identity_settings['founded_date'] ?? '',
2603 2736 'founder_name' => $site_identity_settings['founder_name'] ?? '',
2604 2737 'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
2605 2738 // Site Identity assets