| @@ -204,11 +204,11 @@ | ||
| 204 | 204 | } |
| 205 | 205 | |
| 206 | 206 | // Date published - prioritize user-configured date |
| 207 | 207 | if (!empty($data['site_data']['article_date_published'])) { |
| 208 | - $schema['datePublished'] = $data['site_data']['article_date_published']; | |
| 208 | + $schema['datePublished'] = $this->to_iso8601($data['site_data']['article_date_published']); | |
| 209 | 209 | } elseif (!empty($data['date'])) { |
| 210 | - $schema['datePublished'] = $data['date']; | |
| 210 | + $schema['datePublished'] = $this->to_iso8601($data['date']); | |
| 211 | 211 | } else { |
| 212 | 212 | $schema['datePublished'] = current_time('c'); |
| 213 | 213 | } |
| 214 | 214 | |
| @@ -229,11 +229,11 @@ | ||
| 229 | 229 | } |
| 230 | 230 | |
| 231 | 231 | // Date modified - prioritize user-configured date |
| 232 | 232 | if (!empty($data['site_data']['article_date_modified'])) { |
| 233 | - $schema['dateModified'] = $data['site_data']['article_date_modified']; | |
| 233 | + $schema['dateModified'] = $this->to_iso8601($data['site_data']['article_date_modified']); | |
| 234 | 234 | } elseif (!empty($data['modified'])) { |
| 235 | - $schema['dateModified'] = $data['modified']; | |
| 235 | + $schema['dateModified'] = $this->to_iso8601($data['modified']); | |
| 236 | 236 | } else { |
| 237 | 237 | $schema['dateModified'] = $schema['datePublished']; |
| 238 | 238 | } |
| 239 | 239 | |
| @@ -506,31 +506,45 @@ | ||
| 506 | 506 | } |
| 507 | 507 | } |
| 508 | 508 | } |
| 509 | 509 | |
| 510 | - // Contact point from Business Info or contact configuration | |
| 511 | - $contact_point = ['@type' => 'ContactPoint']; | |
| 512 | - $has_contact_info = false; | |
| 510 | + // Contact point: the Schema Manager's own fields win, then Business | |
| 511 | + // Info. Reading telephone/email from Business Info alone and hard-coding | |
| 512 | + // contactType left the Organization form's Contact Type, Phone and Email | |
| 513 | + // inert — they saved but never reached the deployed markup, even though | |
| 514 | + // Seo_Manager already applied this precedence for the same entity. | |
| 515 | + $site_data = $data['site_data'] ?? []; | |
| 513 | 516 | |
| 514 | - // Use Business Info phone as primary contact | |
| 515 | - if (!empty($business_data['business_phone'])) { | |
| 516 | - $contact_point['telephone'] = $business_data['business_phone']; | |
| 517 | - $has_contact_info = true; | |
| 518 | - } | |
| 517 | + $contact_phone = $this->first_non_empty( | |
| 518 | + $site_data['organization_contact_phone'] ?? null, | |
| 519 | + $business_data['business_phone'] ?? null | |
| 520 | + ); | |
| 519 | 521 | |
| 520 | - // Use Business Info email as primary contact | |
| 521 | - if (!empty($business_data['business_email'])) { | |
| 522 | - $contact_point['email'] = $business_data['business_email']; | |
| 523 | - $has_contact_info = true; | |
| 524 | - } | |
| 522 | + $contact_email = $this->first_non_empty( | |
| 523 | + $site_data['organization_contact_email'] ?? null, | |
| 524 | + $business_data['business_email'] ?? null | |
| 525 | + ); | |
| 525 | 526 | |
| 526 | - // Add contact type and hours if available | |
| 527 | - if ($has_contact_info) { | |
| 528 | - $contact_point['contactType'] = 'customer service'; | |
| 527 | + if ('' !== $contact_phone || '' !== $contact_email) { | |
| 528 | + $contact_point = [ | |
| 529 | + '@type' => 'ContactPoint', | |
| 530 | + 'contactType' => $this->first_non_empty( | |
| 531 | + $site_data['organization_contact_type'] ?? null, | |
| 532 | + 'customer service' | |
| 533 | + ), | |
| 534 | + ]; | |
| 529 | 535 | |
| 536 | + if ('' !== $contact_phone) { | |
| 537 | + $contact_point['telephone'] = $contact_phone; | |
| 538 | + } | |
| 539 | + | |
| 540 | + if ('' !== $contact_email) { | |
| 541 | + $contact_point['email'] = $contact_email; | |
| 542 | + } | |
| 543 | + | |
| 530 | 544 | // Add contact hours if available from organization settings |
| 531 | - if (!empty($data['site_data']['organization_contact_hours'])) { | |
| 532 | - $contact_point['hoursAvailable'] = $data['site_data']['organization_contact_hours']; | |
| 545 | + if (!empty($site_data['organization_contact_hours'])) { | |
| 546 | + $contact_point['hoursAvailable'] = $site_data['organization_contact_hours']; | |
| 533 | 547 | } |
| 534 | 548 | |
| 535 | 549 | $schema['contactPoint'] = $contact_point; |
| 536 | 550 | } |
| @@ -628,9 +642,9 @@ | ||
| 628 | 642 | } |
| 629 | 643 | |
| 630 | 644 | // datePublished + url from content context. |
| 631 | 645 | if (!empty($data['date'])) { |
| 632 | - $schema['datePublished'] = $data['date']; | |
| 646 | + $schema['datePublished'] = $this->to_iso8601($data['date']); | |
| 633 | 647 | } |
| 634 | 648 | if (!empty($data['url'])) { |
| 635 | 649 | $schema['url'] = $data['url']; |
| 636 | 650 | } |
| @@ -719,15 +733,55 @@ | ||
| 719 | 733 | * @return string Truncated text |
| 720 | 734 | */ |
| 721 | 735 | private function truncate_text(string $text, int $length): string { |
| 722 | 736 | $text = wp_strip_all_tags($text); |
| 723 | - if (strlen($text) <= $length) { | |
| 737 | + | |
| 738 | + // Multibyte-aware. strlen()/substr() count bytes, so a cut landing | |
| 739 | + // mid-character produced invalid UTF-8 — wp_json_encode()'s sanity | |
| 740 | + // check then replaced the tail with "?", mojibaking every non-Latin | |
| 741 | + // site's description and headline (#473). | |
| 742 | + if (mb_strlen($text) <= $length) { | |
| 724 | 743 | return $text; |
| 725 | 744 | } |
| 726 | - return substr($text, 0, $length - 3) . '...'; | |
| 745 | + | |
| 746 | + return mb_substr($text, 0, max(0, $length - 3)) . '...'; | |
| 727 | 747 | } |
| 728 | 748 | |
| 729 | 749 | /** |
| 750 | + * Normalise a date into ISO 8601 with a timezone offset. | |
| 751 | + * | |
| 752 | + * Deployed schema is a stored snapshot, so rows written before #465 still | |
| 753 | + * hold raw MySQL datetimes ("2026-08-23 10:19:10"). Google reports those as | |
| 754 | + * an invalid date value and drops the Article rich result, so normalise on | |
| 755 | + * the way out as well as on the way in. | |
| 756 | + * | |
| 757 | + * @since 1.16.0 | |
| 758 | + * | |
| 759 | + * @param mixed $date Date in any parseable form. | |
| 760 | + * @return string ISO 8601 date, or '' when the input cannot be parsed. | |
| 761 | + */ | |
| 762 | + private function to_iso8601($date): string { | |
| 763 | + if (empty($date) || !is_scalar($date)) { | |
| 764 | + return ''; | |
| 765 | + } | |
| 766 | + | |
| 767 | + $date = (string) $date; | |
| 768 | + | |
| 769 | + // Already ISO 8601 (has the date/time separator) — leave it alone. | |
| 770 | + if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $date)) { | |
| 771 | + return $date; | |
| 772 | + } | |
| 773 | + | |
| 774 | + $timestamp = strtotime($date); | |
| 775 | + | |
| 776 | + if (false === $timestamp) { | |
| 777 | + return ''; | |
| 778 | + } | |
| 779 | + | |
| 780 | + return (string) wp_date('c', $timestamp); | |
| 781 | + } | |
| 782 | + | |
| 783 | + /** | |
| 730 | 784 | * Format author schema |
| 731 | 785 | * PRESERVED: Exact same method logic from original Schema_Generator |
| 732 | 786 | * |
| 733 | 787 | * @since 1.0.0 |
| @@ -829,10 +883,19 @@ | ||
| 829 | 883 | * |
| 830 | 884 | * @return array Social media profile URLs |
| 831 | 885 | */ |
| 832 | 886 | private function get_social_profiles(): array { |
| 833 | - // Get Schema Manager settings for organization social profiles | |
| 834 | - $schema_manager = new \ThinkRank\SEO\Schema_Management_System(); | |
| 887 | + // Reuse one manager for the whole request. This method runs from inside | |
| 888 | + // the foreign-settings listener, and constructing a fresh | |
| 889 | + // Schema_Management_System on every Organization build was what let the | |
| 890 | + // listener count double per save (#463). The constructor's static guard | |
| 891 | + // stops the doubling; this stops the needless re-construction. | |
| 892 | + static $schema_manager = null; | |
| 893 | + | |
| 894 | + if (null === $schema_manager) { | |
| 895 | + $schema_manager = new \ThinkRank\SEO\Schema_Management_System(); | |
| 896 | + } | |
| 897 | + | |
| 835 | 898 | $settings = $schema_manager->get_settings('site', null); |
| 836 | 899 | |
| 837 | 900 | $social_profiles = []; |
| 838 | 901 | |
| @@ -965,11 +1028,29 @@ | ||
| 965 | 1028 | * @return array Populated schema |
| 966 | 1029 | */ |
| 967 | 1030 | private function populate_website_schema(array $schema, array $data, string $context): array { |
| 968 | 1031 | // Required properties - prioritize user-configured Website schema fields |
| 969 | - $schema['name'] = $data['site_data']['website_name'] ?? $data['title'] ?? get_bloginfo('name'); | |
| 970 | - $schema['url'] = $data['site_data']['website_url'] ?? $data['url'] ?? home_url(); | |
| 1032 | + $schema['name'] = $this->first_non_empty( | |
| 1033 | + $data['site_data']['website_name'] ?? '', | |
| 1034 | + $data['title'] ?? '', | |
| 1035 | + get_bloginfo('name') | |
| 1036 | + ); | |
| 1037 | + $schema['url'] = $this->first_non_empty( | |
| 1038 | + $data['site_data']['website_url'] ?? '', | |
| 1039 | + $data['url'] ?? '', | |
| 1040 | + home_url() | |
| 1041 | + ); | |
| 971 | 1042 | |
| 1043 | + // Both WebSite producers have to carry this or the deployed node and the | |
| 1044 | + // default one disagree about the same site — the shape of failure #688 | |
| 1045 | + // documents. The default node is generate_website_schema() (#692). | |
| 1046 | + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema( | |
| 1047 | + $data['site_data']['alternate_name'] ?? null | |
| 1048 | + ); | |
| 1049 | + if (null !== $alternate_name) { | |
| 1050 | + $schema['alternateName'] = $alternate_name; | |
| 1051 | + } | |
| 1052 | + | |
| 972 | 1053 | // Recommended properties - prioritize user-configured Website schema description |
| 973 | 1054 | if (!empty($data['site_data']['website_description'])) { |
| 974 | 1055 | $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160); |
| 975 | 1056 | } elseif (!empty($data['content'])) { |
| @@ -1034,9 +1115,12 @@ | ||
| 1034 | 1115 | $schema['publisher'] = $publisher; |
| 1035 | 1116 | |
| 1036 | 1117 | // Search action for sitelinks search box (optional but recommended) |
| 1037 | 1118 | if ($data['site_data']['website_enable_search'] ?? true) { |
| 1038 | - $search_url = $data['site_data']['website_search_url'] ?? home_url('/?s={search_term_string}'); | |
| 1119 | + $search_url = $this->first_non_empty( | |
| 1120 | + $data['site_data']['website_search_url'] ?? '', | |
| 1121 | + home_url('/?s={search_term_string}') | |
| 1122 | + ); | |
| 1039 | 1123 | $schema['potentialAction'] = [ |
| 1040 | 1124 | '@type' => 'SearchAction', |
| 1041 | 1125 | 'target' => [ |
| 1042 | 1126 | '@type' => 'EntryPoint', |
| @@ -1052,9 +1136,10 @@ | ||
| 1052 | 1136 | $schema['sameAs'] = $social_profiles; |
| 1053 | 1137 | } |
| 1054 | 1138 | |
| 1055 | 1139 | // Language |
| 1056 | - $schema['inLanguage'] = get_locale(); | |
| 1140 | + // BCP-47, not the WP locale: schema.org expects en-US, get_locale() gives en_US (#473). | |
| 1141 | + $schema['inLanguage'] = get_bloginfo('language'); | |
| 1057 | 1142 | |
| 1058 | 1143 | return $schema; |
| 1059 | 1144 | } |
| 1060 | 1145 | |
| @@ -1081,13 +1166,13 @@ | ||
| 1081 | 1166 | $schema['description'] = $this->truncate_text($data['content'], 160); |
| 1082 | 1167 | } |
| 1083 | 1168 | |
| 1084 | 1169 | if (!empty($data['date'])) { |
| 1085 | - $schema['datePublished'] = $data['date']; | |
| 1170 | + $schema['datePublished'] = $this->to_iso8601($data['date']); | |
| 1086 | 1171 | } |
| 1087 | 1172 | |
| 1088 | 1173 | if (!empty($data['modified'])) { |
| 1089 | - $schema['dateModified'] = $data['modified']; | |
| 1174 | + $schema['dateModified'] = $this->to_iso8601($data['modified']); | |
| 1090 | 1175 | } |
| 1091 | 1176 | |
| 1092 | 1177 | $schema['isPartOf'] = [ |
| 1093 | 1178 | '@type' => 'WebSite', |
| @@ -1145,16 +1230,22 @@ | ||
| 1145 | 1230 | } |
| 1146 | 1231 | |
| 1147 | 1232 | $schema['mainEntity'] = $faq_data; |
| 1148 | 1233 | |
| 1149 | - // Optional properties | |
| 1150 | - $schema['name'] = $data['title'] ?? 'Frequently Asked Questions'; | |
| 1234 | + // Optional properties. The FAQ form's own Page Title / Page URL fields | |
| 1235 | + // win over the post's title and permalink — they were collected by the | |
| 1236 | + // form and then never read, so typing in them changed nothing. | |
| 1237 | + $schema['name'] = !empty($data['site_data']['faq_page_name']) | |
| 1238 | + ? $data['site_data']['faq_page_name'] | |
| 1239 | + : ($data['title'] ?? 'Frequently Asked Questions'); | |
| 1151 | 1240 | if (!empty($data['excerpt'])) { |
| 1152 | 1241 | $schema['description'] = $this->truncate_text($data['excerpt'], 160); |
| 1153 | 1242 | } |
| 1154 | 1243 | |
| 1155 | 1244 | // URL for the FAQ page |
| 1156 | - if (!empty($data['url'])) { | |
| 1245 | + if (!empty($data['site_data']['faq_page_url'])) { | |
| 1246 | + $schema['url'] = $data['site_data']['faq_page_url']; | |
| 1247 | + } elseif (!empty($data['url'])) { | |
| 1157 | 1248 | $schema['url'] = $data['url']; |
| 1158 | 1249 | } |
| 1159 | 1250 | |
| 1160 | 1251 | // About - recommended property |
| @@ -1389,9 +1480,13 @@ | ||
| 1389 | 1480 | * @return array Populated schema |
| 1390 | 1481 | */ |
| 1391 | 1482 | private function populate_person_schema(array $schema, array $data, string $context): array { |
| 1392 | 1483 | // Required properties - prioritize user-configured fields |
| 1393 | - $schema['name'] = $data['site_data']['person_name'] ?? $data['author']['name'] ?? $data['title'] ?? ''; | |
| 1484 | + $schema['name'] = $this->first_non_empty( | |
| 1485 | + $data['site_data']['person_name'] ?? '', | |
| 1486 | + $data['author']['name'] ?? '', | |
| 1487 | + $data['title'] ?? '' | |
| 1488 | + ); | |
| 1394 | 1489 | |
| 1395 | 1490 | // Image from user configuration or fallback |
| 1396 | 1491 | if (!empty($data['site_data']['person_image'])) { |
| 1397 | 1492 | $schema['image'] = $this->format_image_schema($data['site_data']['person_image']); |
| @@ -1466,10 +1561,26 @@ | ||
| 1466 | 1561 | if (!empty($global_social_profiles)) { |
| 1467 | 1562 | $social_profiles = array_merge($social_profiles, $global_social_profiles); |
| 1468 | 1563 | } |
| 1469 | 1564 | |
| 1470 | - // Remove duplicates and empty values | |
| 1471 | - $social_profiles = array_unique(array_filter($social_profiles)); | |
| 1565 | + // Remove duplicates, empties and anything that is not a URL. schema.org | |
| 1566 | + // types sameAs as a URL, and the person social fields are free text, so | |
| 1567 | + // without this a typed-in note shipped as a sameAs member and made the | |
| 1568 | + // whole Person invalid (#480). get_social_profiles() above already | |
| 1569 | + // filters its own values the same way. | |
| 1570 | + $social_profiles = array_values(array_unique(array_filter( | |
| 1571 | + $social_profiles, | |
| 1572 | + static function ($url) { | |
| 1573 | + return is_string($url) | |
| 1574 | + && '' !== trim($url) | |
| 1575 | + && filter_var($url, FILTER_VALIDATE_URL) | |
| 1576 | + && in_array( | |
| 1577 | + strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)), | |
| 1578 | + ['http', 'https'], | |
| 1579 | + true | |
| 1580 | + ); | |
| 1581 | + } | |
| 1582 | + ))); | |
| 1472 | 1583 | |
| 1473 | 1584 | if (!empty($social_profiles)) { |
| 1474 | 1585 | $schema['sameAs'] = $social_profiles; |
| 1475 | 1586 | } |