PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
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-schema-management-system.php +283 -60 2.3.0 → 2.14.3 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,35 @@
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 + // The Site Identity logo is the Organization logo's fallback, the
448 + // WebSite publisher's logo and the LocalBusiness logo. Without this a
449 + // logo change reached none of the deployed nodes until a manual
450 + // Deploy (#1010).
451 + if (array_key_exists('logo_url', $settings)) {
452 + $refresh_types[] = 'Organization';
453 + $refresh_types[] = 'WebSite';
454 + $refresh_types[] = 'LocalBusiness';
455 + }
456 +
457 + if (empty($refresh_types)) {
402 458 return;
403 459 }
404 460
405 461 $schema_settings = $this->get_settings($context_type, $context_id);
@@ -409,9 +465,9 @@
409 465
410 466 // Only refresh types that are actually deployed, so this never adds a
411 467 // type the admin did not enable.
412 468 $deployed = array_keys((array) $this->get_deployed_schemas($context_type, $context_id));
413 - $affected = array_values(array_intersect($deployed, ['LocalBusiness', 'Organization']));
469 + $affected = array_values(array_intersect($deployed, $refresh_types));
414 470
415 471 if (empty($affected)) {
416 472 return;
417 473 }
@@ -684,8 +740,27 @@
684 740 return $optimization;
685 741 }
686 742
687 743 /**
744 + * Deployed types an authoritative deploy takes off the page.
745 + *
746 + * Everything deployed that the payload left out, except the types the
747 + * caller sent but could not deploy: those failed validation, they were not
748 + * removed by the user, so their live copy stays. Retiring them took the
749 + * site's Organization down behind a success toast (#949).
750 + *
751 + * @since 2.14.1
752 + *
753 + * @param string[] $deployed Types deployed for the context now.
754 + * @param string[] $payload Types in this deploy.
755 + * @param string[] $retain_types Types sent but skipped by validation.
756 + * @return string[] Types to retire.
757 + */
758 + public static function types_to_retire(array $deployed, array $payload, array $retain_types = []): array {
759 + return array_values(array_diff($deployed, $payload, $retain_types));
760 + }
761 +
762 + /**
688 763 * Deploy schema markup with automated implementation
689 764 *
690 765 * @since 1.0.0
691 766 *
@@ -719,11 +794,12 @@
719 794 if (!empty($options['authoritative'])) {
720 795 $deployment['retired_schemas'] = $this->retire_schema_types(
721 796 $context_type,
722 797 $context_id,
723 - array_diff(
798 + self::types_to_retire(
724 799 array_keys($this->get_deployed_schemas($context_type, $context_id)),
725 - array_keys($schema_data)
800 + array_keys($schema_data),
801 + (array) ($options['retain_types'] ?? [])
726 802 )
727 803 );
728 804 }
729 805
@@ -1011,11 +1087,17 @@
1011 1087 }
1012 1088 break;
1013 1089
1014 1090 case 'Organization':
1015 - // Ensure logo for rich snippets
1091 + // Fill a missing logo from the site icon when there is one.
1092 + // logo is recommended, not required, so with nothing to point
1093 + // at it stays out rather than naming a file that is not there
1094 + // (#1010).
1016 1095 if (empty($schema_data['logo'])) {
1017 - $schema_data['logo'] = $this->get_default_organization_logo();
1096 + $logo_url = $this->get_default_organization_logo();
1097 + if ('' !== $logo_url) {
1098 + $schema_data['logo'] = $logo_url;
1099 + }
1018 1100 }
1019 1101 break;
1020 1102
1021 1103 case 'Product':
@@ -1168,11 +1250,14 @@
1168 1250
1169 1251 /**
1170 1252 * Get default organization logo for rich snippets
1171 1253 *
1254 + * The theme's custom logo, then the site icon. Returns '' when neither
1255 + * is set; callers then leave logo out of the schema.
1256 + *
1172 1257 * @since 1.0.0
1173 1258 *
1174 - * @return string Default logo URL
1259 + * @return string Logo URL, or '' when none is available
1175 1260 */
1176 1261 private function get_default_organization_logo(): string {
1177 1262 // Try to get custom logo
1178 1263 $custom_logo_id = get_theme_mod('custom_logo');
@@ -1182,16 +1267,16 @@
1182 1267 return $logo_url;
1183 1268 }
1184 1269 }
1185 1270
1186 - // Fallback to site icon or default
1271 + // Fallback to site icon
1187 1272 $site_icon_url = get_site_icon_url();
1188 1273 if ($site_icon_url) {
1189 1274 return $site_icon_url;
1190 1275 }
1191 1276
1192 - // Final fallback
1193 - return home_url('/wp-content/plugins/thinkrank/assets/images/default-logo.jpg');
1277 + // No logo anywhere: there is no bundled default image to point at.
1278 + return '';
1194 1279 }
1195 1280
1196 1281 /**
1197 1282 * Schema keys outside the shared config defaults.
@@ -1279,9 +1364,9 @@
1279 1364 $this->cache_manager->invalidate_all_cache();
1280 1365 }
1281 1366
1282 1367 // AUTO-DEPLOY: Automatically regenerate and deploy schema when settings change
1283 - if ($success && !empty($settings['auto_deploy'])) {
1368 + if ($success && self::should_auto_deploy($settings, $this->get_settings($context_type, $context_id))) {
1284 1369 $this->auto_deploy_schema_on_settings_change($context_type, $context_id, $settings);
1285 1370 }
1286 1371
1287 1372 return $success;
@@ -1286,9 +1371,58 @@
1286 1371
1287 1372 return $success;
1288 1373 }
1289 1374
1375 +
1290 1376 /**
1377 + * Whether a save should redeploy the schema it changed.
1378 + *
1379 + * `auto_deploy` is a stored setting (on by default), not something a save
1380 + * restates. Reading it off the incoming patch meant a partial save — which
1381 + * is what the admin screen sends, one field at a time — skipped
1382 + * auto-deploy on a site that had it switched on, and the deployed snapshot
1383 + * the front end serves kept the name, logo and sameAs it was deployed
1384 + * with, however often the user saved (#904, the gate #12 left in place).
1385 + *
1386 + * A patch that does carry the key still wins, so a caller can deploy or
1387 + * hold deliberately.
1388 + *
1389 + * @param array $patch Settings being saved
1390 + * @param array $stored Settings as stored, after the save
1391 + * @return bool
1392 + */
1393 + public static function should_auto_deploy(array $patch, array $stored): bool {
1394 + if (array_key_exists('auto_deploy', $patch)) {
1395 + return !empty($patch['auto_deploy']);
1396 + }
1397 +
1398 + return !empty($stored['auto_deploy']);
1399 + }
1400 +
1401 + /**
1402 + * Keep only the schema types that are valid for a context.
1403 + *
1404 + * The site context holds the site-level entities only
1405 + * (Schema_Settings_Config::get_site_level_schema_types()). Other contexts
1406 + * have no server-side list, so their types pass through unchanged.
1407 + *
1408 + * @since 2.15.0
1409 + *
1410 + * @param string $context_type Context type.
1411 + * @param array $types Schema types, as selected or stored.
1412 + * @return string[] The types valid for the context, in their given order.
1413 + */
1414 + public static function filter_types_for_context(string $context_type, array $types): array {
1415 + $types = array_values(array_filter($types, 'is_string'));
1416 +
1417 + if ('site' !== $context_type) {
1418 + return $types;
1419 + }
1420 +
1421 + return array_values(array_intersect($types, Schema_Settings_Config::get_site_level_schema_types()));
1422 + }
1423 +
1424 + /**
1291 1425 * Auto-deploy schema when settings change
1292 1426 *
1293 1427 * Automatically regenerates and deploys schema markup when organization or other
1294 1428 * schema settings are modified, ensuring the frontend output stays in sync.
@@ -1331,9 +1465,16 @@
1331 1465 // types — including ones the user had explicitly deselected (#461).
1332 1466 // An empty selection means "auto", so only filter when one is set.
1333 1467 $enabled_types = $settings['enabled_schema_types'] ?? $this->get_settings($context_type, $context_id)['enabled_schema_types'] ?? [];
1334 1468
1335 - if (!empty($enabled_types) && is_array($enabled_types)) {
1469 + // Only types that can live in this context count as a selection. A
1470 + // foreign type (Article or WebPage in the site context) is not one
1471 + // the user can see or untick, and treating it as the selection
1472 + // retired every deployed site entity and kept them from redeploying
1473 + // (#1004).
1474 + $enabled_types = is_array($enabled_types) ? self::filter_types_for_context($context_type, $enabled_types) : [];
1475 +
1476 + if (!empty($enabled_types)) {
1336 1477 $schema_types_to_regenerate = array_values(
1337 1478 array_intersect($schema_types_to_regenerate, $enabled_types)
1338 1479 );
1339 1480 }
@@ -1421,8 +1562,11 @@
1421 1562 // Identity and never appear in a schema settings payload — so WebSite
1422 1563 // schema never auto-deployed no matter what was edited (#455).
1423 1564 $website_keys = [
1424 1565 'website_name', 'website_url', 'website_description', 'website_author',
1566 + // The sitelinks search box switch and URL shape potentialAction,
1567 + // so a save of either has to redeploy WebSite too (#1009).
1568 + 'website_enable_search', 'website_search_url',
1425 1569 ];
1426 1570
1427 1571 foreach ($website_keys as $key) {
1428 1572 if (isset($settings[$key])) {
@@ -1583,12 +1727,23 @@
1583 1727 } elseif ($context_id && in_array($context_type, ['post', 'page', 'product'], true)) {
1584 1728 // Post/page/product data
1585 1729 $post = get_post($context_id);
1586 1730 if ($post) {
1731 + // Resolve the featured image's URL to its ID here, where the ID
1732 + // is in hand, so the schema builder does not query for an
1733 + // attachment it was just given (#847). Offered as a hint rather
1734 + // than asserted: `post_thumbnail_url` can swap the URL for one
1735 + // the featured image does not own.
1736 + $thumbnail_url = get_the_post_thumbnail_url($post->ID, 'full');
1737 +
1738 + if ($thumbnail_url) {
1739 + Attachment_Lookup::id_from_url((string) $thumbnail_url, (int) get_post_thumbnail_id($post->ID));
1740 + }
1741 +
1587 1742 $content_data = [
1588 1743 'title' => $post->post_title,
1589 1744 'url' => get_permalink($post->ID),
1590 - 'excerpt' => $post->post_excerpt ?: wp_trim_words($post->post_content, 30),
1745 + 'excerpt' => $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words($post->post_content, 30),
1591 1746 'content' => $post->post_content,
1592 1747 'author' => [
1593 1748 'name' => get_the_author_meta('display_name', $post->post_author),
1594 1749 'url' => get_author_posts_url($post->post_author)
@@ -1598,9 +1753,9 @@
1598 1753 // Google rejects as "Invalid value in field datePublished"
1599 1754 // and drops the Article rich result (#465).
1600 1755 'date' => get_the_date('c', $post),
1601 1756 'modified' => get_the_modified_date('c', $post),
1602 - 'image' => get_the_post_thumbnail_url($post->ID, 'full'),
1757 + 'image' => $thumbnail_url,
1603 1758 'focus_keywords' => Focus_Keywords::get($post->ID),
1604 1759 'business_data' => $this->get_business_data_from_local_seo(),
1605 1760 'site_data' => $this->get_site_data_for_schema(),
1606 1761 'social_data' => $this->get_social_data_for_schema()
@@ -1991,14 +2146,39 @@
1991 2146 ];
1992 2147 }
1993 2148
1994 2149 /**
2150 + * Context types a deployed schema row can be stored under.
2151 + *
2152 + * @since 2.15.0
2153 + * @var string[]
2154 + */
2155 + public const CONTEXT_TYPES = ['site', 'post', 'page', 'product'];
2156 +
2157 + /**
2158 + * The context_type a post's deployed schema rows are stored under.
2159 + *
2160 + * The editor's Schema tab writes and reads rows under the post's own type
2161 + * when it is one of CONTEXT_TYPES, and under 'post' for every other type,
2162 + * so a WooCommerce product's rows are 'product' rows. Every server-side
2163 + * reader of a post's rows goes through this so it looks where the writer
2164 + * wrote: a reader that hard-coded 'post' never found a product's
2165 + * deployed Product schema (#1008).
2166 + *
2167 + * @since 2.15.0
2168 + *
2169 + * @param string $post_type Post type of the post.
2170 + * @return string Context type.
2171 + */
2172 + public static function context_type_for_post_type(string $post_type): string {
2173 + return in_array($post_type, self::CONTEXT_TYPES, true) ? $post_type : 'post';
2174 + }
2175 +
2176 + /**
1995 2177 * Get deployed schemas for frontend integration
1996 2178 *
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
2179 + * Returns the newest active, deployed row of each schema type for the
2180 + * context. Results are cached per context (see Schema_Cache_Manager).
2001 2181 *
2002 2182 * @since 1.0.0
2003 2183 *
2004 2184 * @param string $context_type Context type
@@ -2021,48 +2201,57 @@
2021 2201
2022 2202 // Use existing seo_schema table
2023 2203 $table_name = $wpdb->prefix . 'thinkrank_seo_schema';
2024 2204
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)
2205 + // The newest row per type used to be picked with ROW_NUMBER() OVER
2206 + // (PARTITION BY schema_type ...). Window functions need MySQL 8.0 /
2207 + // MariaDB 10.2, and WordPress still runs on MySQL 5.7, where that is
2208 + // a syntax error on every page view and no deployed schema is ever
2209 + // output. A row is the newest of its type when no other row of the
2210 + // same context and type outranks it, so NOT EXISTS keeps the
2211 + // greatest-per-group in the database, and schema_data — JSON, and
2212 + // large — is only transferred for the rows that are output.
2213 + //
2214 + // `<=>` is NULL-safe equality: the site context stores context_id
2215 + // as NULL, and `n.context_id = s.context_id` is never true for it.
2216 + // schema_id breaks a same-second tie, which the window function
2217 + // left to chance.
2218 + $args = [$context_type];
2027 2219 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 - );
2220 + $context_where = 's.context_id IS NULL';
2042 2221 } 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 - );
2222 + $context_where = 's.context_id = %d';
2223 + $args[] = $context_id;
2058 2224 }
2059 2225
2226 + $sql = sprintf(
2227 + 'SELECT s.schema_type, s.schema_data FROM %1$s s'
2228 + . ' WHERE s.context_type = %%s AND %2$s AND s.is_active = 1 AND s.validation_status IN (\'deployed\', \'valid\')'
2229 + . ' AND NOT EXISTS ('
2230 + . 'SELECT 1 FROM %1$s n'
2231 + . ' WHERE n.context_type = s.context_type AND n.context_id <=> s.context_id AND n.schema_type = s.schema_type'
2232 + . ' AND n.is_active = 1 AND n.validation_status IN (\'deployed\', \'valid\')'
2233 + . ' AND (n.created_at > s.created_at OR (n.created_at = s.created_at AND n.schema_id > s.schema_id))'
2234 + . ')'
2235 + . ' ORDER BY s.schema_type',
2236 + $table_name,
2237 + $context_where
2238 + );
2239 +
2240 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Schema retrieval requires direct database access
2241 + $deployed_schemas = $wpdb->get_results(
2242 + $wpdb->prepare(
2243 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
2244 + $sql,
2245 + ...$args
2246 + ),
2247 + ARRAY_A
2248 + );
2249 +
2060 2250 // Deliberately no early return on an empty result: it has to reach the
2061 2251 // cache write below. Most URLs have no deployed schema, so gating the
2062 2252 // 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).
2253 + // permanent cache misses, re-running the query on every pageview (#392).
2065 2254 $deployed_schemas = $deployed_schemas ?: [];
2066 2255
2067 2256 // Process schemas for return
2068 2257 $processed_schemas = [];
@@ -2134,15 +2323,10 @@
2134 2323 if (!is_string($url) || '' === trim($url)) {
2135 2324 return false;
2136 2325 }
2137 2326
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);
2327 + // IRI-aware: a non-ASCII path or IDN host is a real URL (#924).
2328 + return \ThinkRank\Core\Url_Validator::is_http_url($url);
2145 2329 }
2146 2330
2147 2331 /**
2148 2332 * Drop values that are not URLs from URL-typed properties.
@@ -2242,9 +2426,12 @@
2242 2426 }
2243 2427
2244 2428 $type = $schema['@type'] ?? '';
2245 2429 $type = is_array($type) ? reset($type) : $type;
2246 - $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true);
2430 + // A LocalBusiness is deployed under the subtype the site chose, so the
2431 + // exemption has to cover every subtype, not only the literal root.
2432 + $is_entity = in_array((string) $type, self::ENTITY_URL_TYPES, true)
2433 + || \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type);
2247 2434
2248 2435 if (isset($schema['url']) && !$is_entity) {
2249 2436 $schema['url'] = $permalink;
2250 2437 }
@@ -2272,9 +2459,15 @@
2272 2459 * Covers non-ISO-8601 dates (#465) and WP locales in inLanguage, which must
2273 2460 * be a BCP-47 tag — en-US, not en_US (#473). Walks nested nodes so values
2274 2461 * inside author/publisher/@graph entries are covered too.
2275 2462 *
2463 + * Also decodes HTML entities in plain-text properties. Schema_Builder
2464 + * stored the block editor's `&amp;` as-is until 2.10.0, and nothing
2465 + * decodes JSON-LD downstream, so every deployed node built from post text
2466 + * published the entity literally.
2467 + *
2276 2468 * @since 1.16.0
2469 + * @since 2.10.0 Decodes entities in plain-text properties.
2277 2470 *
2278 2471 * @param array $schema Decoded schema data.
2279 2472 * @return array Normalised schema.
2280 2473 */
@@ -2283,8 +2476,16 @@
2283 2476 'datePublished', 'dateModified', 'dateCreated', 'uploadDate',
2284 2477 'startDate', 'endDate', 'validFrom', 'validThrough', 'expires',
2285 2478 ];
2286 2479
2480 + // Plain text in schema.org. Answer/HowToStep `text` is deliberately
2481 + // absent: Google reads Answer.text as HTML, where an entity is correct
2482 + // and decoding `&lt;` would turn escaped text into live markup.
2483 + static $text_keys = [
2484 + 'name', 'headline', 'alternativeHeadline', 'description',
2485 + 'reviewBody', 'about', 'abstract', 'caption',
2486 + ];
2487 +
2287 2488 foreach ($schema as $key => $value) {
2288 2489 if (is_array($value)) {
2289 2490 $schema[$key] = $this->normalize_stored_schema($value);
2290 2491 continue;
@@ -2294,8 +2495,16 @@
2294 2495 $schema[$key] = str_replace('_', '-', $value);
2295 2496 continue;
2296 2497 }
2297 2498
2499 + // Decode only: a snapshot already truncated with an ellipsis must
2500 + // keep it, which the full Seo_Text::normalize_schema_text() would
2501 + // strip as an excerpt marker.
2502 + if (in_array($key, $text_keys, true) && is_string($value) && '' !== $value) {
2503 + $schema[$key] = \ThinkRank\Core\Seo_Text::decode_schema_entities($value);
2504 + continue;
2505 + }
2506 +
2298 2507 if (!in_array($key, $date_keys, true) || !is_string($value) || '' === $value) {
2299 2508 continue;
2300 2509 }
2301 2510
@@ -2432,8 +2641,11 @@
2432 2641 /**
2433 2642 * Retire deployed types that are no longer in the user's Schema Types selection.
2434 2643 *
2435 2644 * An empty selection means "auto-detect", so nothing is retired in that case.
2645 + * Types that are not valid for the context are ignored, so a selection
2646 + * holding only foreign types counts as empty rather than as one that
2647 + * excludes every deployed site entity (#1004).
2436 2648 *
2437 2649 * @since 1.16.0
2438 2650 *
2439 2651 * @param string $context_type Context type.
@@ -2441,8 +2653,10 @@
2441 2653 * @param array $enabled_types The user's selected types.
2442 2654 * @return int Number of rows deactivated.
2443 2655 */
2444 2656 private function retire_unselected_schema_types(string $context_type, ?int $context_id, array $enabled_types): int {
2657 + $enabled_types = self::filter_types_for_context($context_type, $enabled_types);
2658 +
2445 2659 if (empty($enabled_types)) {
2446 2660 return 0;
2447 2661 }
2448 2662
@@ -2598,8 +2812,11 @@
2598 2812 'site_url' => home_url(),
2599 2813 'admin_email' => get_option('admin_email'),
2600 2814 'language' => get_locale(),
2601 2815 'timezone' => get_option('timezone_string'),
2816 + // Read by populate_website_schema(), so the deployed WebSite node
2817 + // carries the same alternateName as the default one (#692).
2818 + 'alternate_name' => $site_identity_settings['alternate_name'] ?? '',
2602 2819 'founded_date' => $site_identity_settings['founded_date'] ?? '',
2603 2820 'founder_name' => $site_identity_settings['founder_name'] ?? '',
2604 2821 'company_type' => $site_identity_settings['company_type'] ?? 'Organization',
2605 2822 // Site Identity assets
@@ -2631,8 +2848,14 @@
2631 2848 'website_name' => $schema_settings['website_name'] ?? '',
2632 2849 'website_url' => $schema_settings['website_url'] ?? '',
2633 2850 'website_description' => $schema_settings['website_description'] ?? '',
2634 2851 'website_author' => $schema_settings['website_author'] ?? '',
2852 + // Read by Schema_Builder::website_search_action(). Without them the
2853 + // deployed WebSite always shipped the default SearchAction, whatever
2854 + // the sitelinks search box switch and URL said (#1009). null keeps
2855 + // "not configured" distinct from a switched-off ''.
2856 + 'website_enable_search' => $schema_settings['website_enable_search'] ?? null,
2857 + 'website_search_url' => $schema_settings['website_search_url'] ?? '',
2635 2858
2636 2859 'organization_logo' => $schema_settings['organization_logo'] ?? '',
2637 2860 // Social media links (sameAs)
2638 2861 'organization_social_facebook' => $schema_settings['organization_social_facebook'] ?? '',