PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.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 1.26.0 1.25.0 All 54 releases
← All changes | includes/seo/class-schema-management-system.php +140 -41 2.8.0 → 2.13.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'],
@@ -1295,9 +1325,9 @@
1295 1325 $this->cache_manager->invalidate_all_cache();
1296 1326 }
1297 1327
1298 1328 // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change
1299 - if ($success && !empty($settings['auto_deploy'])) {
1329 + if ($success && self::should_auto_deploy($settings, $this->get_settings($context_type, $context_id))) {
1300 1330 $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
1301 1331 }
1302 1332
1303 1333 return $success;
@@ -1302,9 +1332,35 @@
1302 1332
1303 1333 return $success;
1304 1334 }
1305 1335
1336 +
1306 1337 /**
1338 + * Whether a save should redeploy the schema it changed.
1339 + *
1340 + * `auto_deploy` is a stored setting (on by default), not something a save
1341 + * restates. Reading it off the incoming patch meant a partial save — which
1342 + * is what the admin screen sends, one field at a time — skipped
1343 + * auto-deploy on a site that had it switched on, and the deployed snapshot
1344 + * the front end serves kept the name, logo and sameAs it was deployed
1345 + * with, however often the user saved (#904, the gate #12 left in place).
1346 + *
1347 + * A patch that does carry the key still wins, so a caller can deploy or
1348 + * hold deliberately.
1349 + *
1350 + * @param array $patch Settings being saved
1351 + * @param array $stored Settings as stored, after the save
1352 + * @return bool
1353 + */
1354 + public static function should_auto_deploy(array $patch, array $stored): bool {
1355 + if (array_key_exists('auto_deploy', $patch)) {
1356 + return !empty($patch['auto_deploy']);
1357 + }
1358 +
1359 + return !empty($stored['auto_deploy']);
1360 + }
1361 +
1362 + /**
1307 1363 * Auto-deploy schema when settings change
1308 1364 *
1309 1365 * Automatically regenerates and deploys schema markup when organization or other
1310 1366 * schema settings are modified, ensuring the frontend output stays in sync.
@@ -1599,8 +1655,19 @@
1599 1655 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1600 1656 // Post/page/product data
1601 1657 $post = get_post($context_id);
1602 1658 if ($post) {
1659 + // Resolve the featured image's URL to its ID here, where the ID
1660 + // is in hand, so the schema builder does not query for an
1661 + // attachment it was just given (#847). Offered as a hint rather
1662 + // than asserted: `post_thumbnail_url` can swap the URL for one
1663 + // the featured image does not own.
1664 + $thumbnail_url = get_the_post_thumbnail_url($post->ID, 'full');
1665 +
1666 + if ($thumbnail_url) {
1667 + Attachment_Lookup::id_from_url((string) $thumbnail_url, (int) get_post_thumbnail_id($post->ID));
1668 + }
1669 +
1603 1670 $content_data = [
1604 1671 'title' => $post->post_title,
1605 1672 'url' => get_permalink($post->ID),
1606 1673 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30),
@@ -1614,9 +1681,9 @@
1614 1681 // Google rejects as "Invalid value in field datePublished"
1615 1682 // and drops the Article rich result (#465).
1616 1683 'date' => get_the_date('c', $post),
1617 1684 'modified' => get_the_modified_date('c', $post),
1618 - 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1685 + 'image' => $thumbnail_url,
1619 1686 'focus_keywords' => Focus_Keywords::get($post->ID),
1620 1687 'business_data' => $this->get_business_data_from_local_seo(),
1621 1688 'site_data' => $this->get_site_data_for_schema(),
1622 1689 'social_data' => $this->get_social_data_for_schema()
@@ -2009,12 +2076,10 @@
2009 2076
2010 2077 /**
2011 2078 * Get deployed schemas for frontend integration
2012 2079 *
2013 - * PERFORMANCE OPTIMIZED: This method now uses:
2014 - * 1. Schema caching layer (90% reduction in database queries)
2015 - * 2. Window function approach instead of correlated subquery (80-90% query performance improvement)
2016 - * 3. Composite index: idx_context_schema_active
2080 + * Returns the newest active, deployed row of each schema type for the
2081 + * context. Results are cached per context (see Schema_Cache_Manager).
2017 2082 *
2018 2083 * @since 1.0.0
2019 2084 *
2020 2085 * @param string $context_type Context type
@@ -2037,48 +2102,57 @@
2037 2102
2038 2103 // Use existing seo_schema table
2039 2104 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2040 2105
2041 - // OPTIMIZED QUERY: Use window function approach to eliminate correlated subquery
2042 - // This leverages the new composite index: idx_context_schema_active (context_type, schema_type, is_active, created_at DESC)
2106 + // The newest row per type used to be picked with ROW_NUMBER() OVER
2107 + // (PARTITION BY schema_type ...). Window functions need MySQL 8.0 /
2108 + // MariaDB 10.2, and WordPress still runs on MySQL 5.7, where that is
2109 + // a syntax error on every page view and no deployed schema is ever
2110 + // output. A row is the newest of its type when no other row of the
2111 + // same context and type outranks it, so NOT EXISTS keeps the
2112 + // greatest-per-group in the database, and schema_data — JSON, and
2113 + // large — is only transferred for the rows that are output.
2114 + //
2115 + // `<=>` is NULL-safe equality: the site context stores context_id
2116 + // as NULL, and `n.context_id = s.context_id` is never true for it.
2117 + // schema_id breaks a same-second tie, which the window function
2118 + // left to chance.
2119 + $args = [$context_type];
2043 2120 if (null === $context_id) {
2044 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2045 - $sql = sprintf(
2046 - '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',
2047 - $table_name
2048 - );
2049 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2050 - $deployed_schemas = $wpdb->get_results(
2051 - $wpdb->prepare(
2052 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2053 - $sql,
2054 - $context_type
2055 - ),
2056 - ARRAY_A
2057 - );
2121 + $context_where = 's.context_id IS NULL';
2058 2122 } else {
2059 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2060 - $sql = sprintf(
2061 - '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',
2062 - $table_name
2063 - );
2064 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2065 - $deployed_schemas = $wpdb->get_results(
2066 - $wpdb->prepare(
2067 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2068 - $sql,
2069 - $context_type,
2070 - $context_id
2071 - ),
2072 - ARRAY_A
2073 - );
2123 + $context_where = 's.context_id = %d';
2124 + $args[] = $context_id;
2074 2125 }
2075 2126
2127 + $sql = sprintf(
2128 + 'SELECT s.schema_type, s.schema_data FROM %1$s s'
2129 + . ' WHERE s.context_type = %%s AND %2$s AND s.is_active = 1 AND s.validation_status IN (\'deployed\', \'valid\')'
2130 + . ' AND NOT EXISTS ('
2131 + . 'SELECT 1 FROM %1$s n'
2132 + . ' WHERE n.context_type = s.context_type AND n.context_id <=> s.context_id AND n.schema_type = s.schema_type'
2133 + . ' AND n.is_active = 1 AND n.validation_status IN (\'deployed\', \'valid\')'
2134 + . ' AND (n.created_at > s.created_at OR (n.created_at = s.created_at AND n.schema_id > s.schema_id))'
2135 + . ')'
2136 + . ' ORDER BY s.schema_type',
2137 + $table_name,
2138 + $context_where
2139 + );
2140 +
2141 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2142 + $deployed_schemas = $wpdb->get_results(
2143 + $wpdb->prepare(
2144 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2145 + $sql,
2146 + ...$args
2147 + ),
2148 + ARRAY_A
2149 + );
2150 +
2076 2151 // Deliberately no early return on an empty result: it has to reach the
2077 2152 // cache write below. Most URLs have no deployed schema, so gating the
2078 2153 // write on a non-empty result made the majority of front-end requests
2079 - // permanent cache misses, re-running a ROW_NUMBER() OVER (PARTITION BY
2080 - // ...) query with two filesorts on every pageview (#392).
2154 + // permanent cache misses, re-running the query on every pageview (#392).
2081 2155 $deployed_schemas = $deployed_schemas ?: [];
2082 2156
2083 2157 // Process schemas for return
2084 2158 $processed_schemas = [];
@@ -2258,9 +2332,12 @@
2258 2332 }
2259 2333
2260 2334 $type = $schema['@type'] ?? '';
2261 2335 $type = is_array($type) ? reset($type) : $type;
2262 - $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true);
2336 + // A LocalBusiness is deployed under the subtype the site chose, so the
2337 + // exemption has to cover every subtype, not only the literal root.
2338 + $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true)
2339 + || \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type);
2263 2340
2264 2341 if (isset($schema['url']) && !$is_entity) {
2265 2342 $schema['url'] = $permalink;
2266 2343 }
@@ -2288,9 +2365,15 @@
2288 2365 * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must
2289 2366 * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values
2290 2367 * inside author/publisher/@graph entries are covered too.
2291 2368 *
2369 + * Also decodes HTML entities in plain-text properties. Schema_Builder
2370 + * stored the block editor's `&amp;` as-is until 2.10.0, and nothing
2371 + * decodes JSON-LD downstream, so every deployed node built from post text
2372 + * published the entity literally.
2373 + *
2292 2374 * @since 1.16.0
2375 + * @since 2.10.0 Decodes entities in plain-text properties.
2293 2376 *
2294 2377 * @param array $schema Decoded schema data.
2295 2378 * @return array Normalised schema.
2296 2379 */
@@ -2299,8 +2382,16 @@
2299 2382 'datePublished', 'dateModified', 'dateCreated', 'uploadDate',
2300 2383 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires',
2301 2384 ];
2302 2385
2386 + // Plain text in schema.org. Answer/HowToStep `text` is deliberately
2387 + // absent: Google reads Answer.text as HTML, where an entity is correct
2388 + // and decoding `&lt;` would turn escaped text into live markup.
2389 + static $text_keys = [
2390 + 'name', 'headline', 'alternativeHeadline', 'description',
2391 + 'reviewBody', 'about', 'abstract', 'caption',
2392 + ];
2393 +
2303 2394 foreach ($schema as $key => $value) {
2304 2395 if (is_array($value)) {
2305 2396 $schema[$key] = $this->normalize_stored_schema($value);
2306 2397 continue;
@@ -2307,8 +2398,16 @@
2307 2398 }
2308 2399
2309 2400 if ('inLanguage' === $key && is_string($value) && '' !== $value) {
2310 2401 $schema[$key] = str_replace('_', '-', $value);
2402 + continue;
2403 + }
2404 +
2405 + // Decode only: a snapshot already truncated with an ellipsis must
2406 + // keep it, which the full Seo_Text::normalize_schema_text() would
2407 + // strip as an excerpt marker.
2408 + if (in_array($key, $text_keys, true) && is_string($value) && '' !== $value) {
2409 + $schema[$key] = \ThinkRank\Core\Seo_Text::decode_schema_entities($value);
2311 2410 continue;
2312 2411 }
2313 2412
2314 2413 if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) {