| @@ -91,8 +91,14 @@ | ||
| 91 | 91 | case 'WebSite': |
| 92 | 92 | $schema = $this->populate_website_schema($schema, $data, $context); |
| 93 | 93 | break; |
| 94 | 94 | case 'WebPage': |
| 95 | + case 'AboutPage': | |
| 96 | + case 'ContactPage': | |
| 97 | + case 'ProfilePage': | |
| 98 | + // All four carry the same WebPage properties; only @type | |
| 99 | + // differs, and create_base_schema() has already set it from the | |
| 100 | + // requested type (#624). | |
| 95 | 101 | $schema = $this->populate_webpage_schema($schema, $data, $context); |
| 96 | 102 | break; |
| 97 | 103 | case 'FAQPage': |
| 98 | 104 | $schema = $this->populate_faq_schema($schema, $data, $context); |
| @@ -204,11 +210,11 @@ | ||
| 204 | 210 | } |
| 205 | 211 | |
| 206 | 212 | // Date published - prioritize user-configured date |
| 207 | 213 | if (!empty($data['site_data']['article_date_published'])) { |
| 208 | - $schema['datePublished'] = $data['site_data']['article_date_published']; | |
| 214 | + $schema['datePublished'] = $this->to_iso8601($data['site_data']['article_date_published']); | |
| 209 | 215 | } elseif (!empty($data['date'])) { |
| 210 | - $schema['datePublished'] = $data['date']; | |
| 216 | + $schema['datePublished'] = $this->to_iso8601($data['date']); | |
| 211 | 217 | } else { |
| 212 | 218 | $schema['datePublished'] = current_time('c'); |
| 213 | 219 | } |
| 214 | 220 | |
| @@ -229,11 +235,11 @@ | ||
| 229 | 235 | } |
| 230 | 236 | |
| 231 | 237 | // Date modified - prioritize user-configured date |
| 232 | 238 | if (!empty($data['site_data']['article_date_modified'])) { |
| 233 | - $schema['dateModified'] = $data['site_data']['article_date_modified']; | |
| 239 | + $schema['dateModified'] = $this->to_iso8601($data['site_data']['article_date_modified']); | |
| 234 | 240 | } elseif (!empty($data['modified'])) { |
| 235 | - $schema['dateModified'] = $data['modified']; | |
| 241 | + $schema['dateModified'] = $this->to_iso8601($data['modified']); | |
| 236 | 242 | } else { |
| 237 | 243 | $schema['dateModified'] = $schema['datePublished']; |
| 238 | 244 | } |
| 239 | 245 | |
| @@ -457,8 +463,54 @@ | ||
| 457 | 463 | return ''; |
| 458 | 464 | } |
| 459 | 465 | |
| 460 | 466 | /** |
| 467 | + * The WebSite SearchAction for the sitelinks search box, or null when the | |
| 468 | + * site switched it off. | |
| 469 | + * | |
| 470 | + * Reads the Website tab's two settings. website_enable_search absent (or | |
| 471 | + * null) means not configured, which stays on; any other value is read as | |
| 472 | + * a boolean, so the '' a switched-off toggle is stored as turns it off. | |
| 473 | + * | |
| 474 | + * website_search_url is used as the urlTemplate when it is an http(s) URL, | |
| 475 | + * or a site path, and carries the {search_term_string} placeholder that | |
| 476 | + * query-input names. Without the placeholder the template cannot take a | |
| 477 | + * query, so the default WordPress search URL is used instead, as it is | |
| 478 | + * when the field is empty. | |
| 479 | + * | |
| 480 | + * @since 2.15.0 | |
| 481 | + * | |
| 482 | + * @param array $settings Schema settings or site_data. | |
| 483 | + * @return array|null SearchAction node, or null when switched off. | |
| 484 | + */ | |
| 485 | + public static function website_search_action(array $settings): ?array { | |
| 486 | + $enabled = $settings['website_enable_search'] ?? null; | |
| 487 | + if (null !== $enabled && empty($enabled)) { | |
| 488 | + return null; | |
| 489 | + } | |
| 490 | + | |
| 491 | + $url_template = home_url('/?s={search_term_string}'); | |
| 492 | + | |
| 493 | + $custom = trim((string) ($settings['website_search_url'] ?? '')); | |
| 494 | + if ('' !== $custom && false !== strpos($custom, '{search_term_string}')) { | |
| 495 | + if (\ThinkRank\Core\Url_Validator::is_http_url($custom)) { | |
| 496 | + $url_template = $custom; | |
| 497 | + } elseif (\ThinkRank\Core\Url_Validator::is_http_url_or_path($custom)) { | |
| 498 | + $url_template = home_url($custom); | |
| 499 | + } | |
| 500 | + } | |
| 501 | + | |
| 502 | + return [ | |
| 503 | + '@type' => 'SearchAction', | |
| 504 | + 'target' => [ | |
| 505 | + '@type' => 'EntryPoint', | |
| 506 | + 'urlTemplate' => $url_template | |
| 507 | + ], | |
| 508 | + 'query-input' => 'required name=search_term_string' | |
| 509 | + ]; | |
| 510 | + } | |
| 511 | + | |
| 512 | + /** | |
| 461 | 513 | * Populate Organization schema |
| 462 | 514 | * PRESERVED: Exact same method logic from original Schema_Generator |
| 463 | 515 | * |
| 464 | 516 | * @since 1.0.0 |
| @@ -487,11 +539,18 @@ | ||
| 487 | 539 | $data['url'] ?? null, |
| 488 | 540 | home_url() |
| 489 | 541 | ); |
| 490 | 542 | |
| 491 | - // Logo from user configuration or theme customizer | |
| 492 | - if (!empty($data['site_data']['organization_logo'])) { | |
| 493 | - $schema['logo'] = $this->format_image_schema($data['site_data']['organization_logo']); | |
| 543 | + // Logo: the Schema Manager's Organization logo, then the Site Identity | |
| 544 | + // logo (the one WebSite.publisher and LocalBusiness already use, so the | |
| 545 | + // same entity carries the same logo everywhere), then the theme's | |
| 546 | + // custom logo. With none of them, logo is left out (#1010). | |
| 547 | + $logo_url = $this->first_non_empty( | |
| 548 | + $data['site_data']['organization_logo'] ?? null, | |
| 549 | + $data['site_data']['logo_url'] ?? null | |
| 550 | + ); | |
| 551 | + if ('' !== $logo_url) { | |
| 552 | + $schema['logo'] = $this->format_image_schema($logo_url); | |
| 494 | 553 | } else { |
| 495 | 554 | // Fallback to WordPress custom logo |
| 496 | 555 | $custom_logo_id = get_theme_mod('custom_logo'); |
| 497 | 556 | if ($custom_logo_id) { |
| @@ -506,31 +565,45 @@ | ||
| 506 | 565 | } |
| 507 | 566 | } |
| 508 | 567 | } |
| 509 | 568 | |
| 510 | - // Contact point from Business Info or contact configuration | |
| 511 | - $contact_point = ['@type' => 'ContactPoint']; | |
| 512 | - $has_contact_info = false; | |
| 569 | + // Contact point: the Schema Manager's own fields win, then Business | |
| 570 | + // Info. Reading telephone/email from Business Info alone and hard-coding | |
| 571 | + // contactType left the Organization form's Contact Type, Phone and Email | |
| 572 | + // inert — they saved but never reached the deployed markup, even though | |
| 573 | + // Seo_Manager already applied this precedence for the same entity. | |
| 574 | + $site_data = $data['site_data'] ?? []; | |
| 513 | 575 | |
| 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 | - } | |
| 576 | + $contact_phone = $this->first_non_empty( | |
| 577 | + $site_data['organization_contact_phone'] ?? null, | |
| 578 | + $business_data['business_phone'] ?? null | |
| 579 | + ); | |
| 519 | 580 | |
| 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 | - } | |
| 581 | + $contact_email = $this->first_non_empty( | |
| 582 | + $site_data['organization_contact_email'] ?? null, | |
| 583 | + $business_data['business_email'] ?? null | |
| 584 | + ); | |
| 525 | 585 | |
| 526 | - // Add contact type and hours if available | |
| 527 | - if ($has_contact_info) { | |
| 528 | - $contact_point['contactType'] = 'customer service'; | |
| 586 | + if ('' !== $contact_phone || '' !== $contact_email) { | |
| 587 | + $contact_point = [ | |
| 588 | + '@type' => 'ContactPoint', | |
| 589 | + 'contactType' => $this->first_non_empty( | |
| 590 | + $site_data['organization_contact_type'] ?? null, | |
| 591 | + 'customer service' | |
| 592 | + ), | |
| 593 | + ]; | |
| 529 | 594 | |
| 595 | + if ('' !== $contact_phone) { | |
| 596 | + $contact_point['telephone'] = $contact_phone; | |
| 597 | + } | |
| 598 | + | |
| 599 | + if ('' !== $contact_email) { | |
| 600 | + $contact_point['email'] = $contact_email; | |
| 601 | + } | |
| 602 | + | |
| 530 | 603 | // 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']; | |
| 604 | + if (!empty($site_data['organization_contact_hours'])) { | |
| 605 | + $contact_point['hoursAvailable'] = $site_data['organization_contact_hours']; | |
| 533 | 606 | } |
| 534 | 607 | |
| 535 | 608 | $schema['contactPoint'] = $contact_point; |
| 536 | 609 | } |
| @@ -628,9 +701,9 @@ | ||
| 628 | 701 | } |
| 629 | 702 | |
| 630 | 703 | // datePublished + url from content context. |
| 631 | 704 | if (!empty($data['date'])) { |
| 632 | - $schema['datePublished'] = $data['date']; | |
| 705 | + $schema['datePublished'] = $this->to_iso8601($data['date']); | |
| 633 | 706 | } |
| 634 | 707 | if (!empty($data['url'])) { |
| 635 | 708 | $schema['url'] = $data['url']; |
| 636 | 709 | } |
| @@ -708,12 +781,25 @@ | ||
| 708 | 781 | return $schema; |
| 709 | 782 | } |
| 710 | 783 | |
| 711 | 784 | /** |
| 712 | - * Truncate text to specified length | |
| 713 | - * PRESERVED: Exact same method logic from original Schema_Generator | |
| 785 | + * Normalise text for JSON-LD and truncate it to a length. | |
| 714 | 786 | * |
| 787 | + * Every description, headline and reviewBody this class emits passes | |
| 788 | + * through here, so this is the one place the deployed nodes get the same | |
| 789 | + * treatment the automatic ones have had since #766. Before, it only | |
| 790 | + * stripped tags: the block editor stores `&` as `&`, which was then | |
| 791 | + * published literally (nothing decodes JSON-LD), and the cut landed | |
| 792 | + * mid-word with "..." glued on. A deployed node outranks the automatic | |
| 793 | + * one, so deploying a page type made its description worse than not | |
| 794 | + * deploying anything. | |
| 795 | + * | |
| 796 | + * Seo_Text::trim_to_length() is multibyte-aware, which the #473 fix here | |
| 797 | + * already required (a byte cut mojibaked every non-Latin description), | |
| 798 | + * and falls back to a character cut for scripts without spaces. | |
| 799 | + * | |
| 715 | 800 | * @since 1.0.0 |
| 801 | + * @since 2.10.0 Decodes entities and cuts on a word boundary. | |
| 716 | 802 | * |
| 717 | 803 | * @param string $text Text to truncate |
| 718 | 804 | * @param int $length Maximum length |
| 719 | 805 | * @return string Truncated text |
| @@ -718,13 +804,46 @@ | ||
| 718 | 804 | * @param int $length Maximum length |
| 719 | 805 | * @return string Truncated text |
| 720 | 806 | */ |
| 721 | 807 | private function truncate_text(string $text, int $length): string { |
| 722 | - $text = wp_strip_all_tags($text); | |
| 723 | - if (strlen($text) <= $length) { | |
| 724 | - return $text; | |
| 808 | + return \ThinkRank\Core\Seo_Text::trim_to_length( | |
| 809 | + \ThinkRank\Core\Seo_Text::normalize_schema_text($text), | |
| 810 | + $length | |
| 811 | + ); | |
| 812 | + } | |
| 813 | + | |
| 814 | + /** | |
| 815 | + * Normalise a date into ISO 8601 with a timezone offset. | |
| 816 | + * | |
| 817 | + * Deployed schema is a stored snapshot, so rows written before #465 still | |
| 818 | + * hold raw MySQL datetimes ("2026-08-23 10:19:10"). Google reports those as | |
| 819 | + * an invalid date value and drops the Article rich result, so normalise on | |
| 820 | + * the way out as well as on the way in. | |
| 821 | + * | |
| 822 | + * @since 1.16.0 | |
| 823 | + * | |
| 824 | + * @param mixed $date Date in any parseable form. | |
| 825 | + * @return string ISO 8601 date, or '' when the input cannot be parsed. | |
| 826 | + */ | |
| 827 | + private function to_iso8601($date): string { | |
| 828 | + if (empty($date) || !is_scalar($date)) { | |
| 829 | + return ''; | |
| 725 | 830 | } |
| 726 | - return substr($text, 0, $length - 3) . '...'; | |
| 831 | + | |
| 832 | + $date = (string) $date; | |
| 833 | + | |
| 834 | + // Already ISO 8601 (has the date/time separator) — leave it alone. | |
| 835 | + if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $date)) { | |
| 836 | + return $date; | |
| 837 | + } | |
| 838 | + | |
| 839 | + $timestamp = strtotime($date); | |
| 840 | + | |
| 841 | + if (false === $timestamp) { | |
| 842 | + return ''; | |
| 843 | + } | |
| 844 | + | |
| 845 | + return (string) wp_date('c', $timestamp); | |
| 727 | 846 | } |
| 728 | 847 | |
| 729 | 848 | /** |
| 730 | 849 | * Format author schema |
| @@ -773,17 +892,18 @@ | ||
| 773 | 892 | '@type' => 'ImageObject', |
| 774 | 893 | 'url' => $image_url |
| 775 | 894 | ]; |
| 776 | 895 | |
| 777 | - // Try to get image dimensions if it's a WordPress attachment | |
| 778 | - $attachment_id = attachment_url_to_postid($image_url); | |
| 896 | + // Try to get image dimensions if it's a WordPress attachment — those | |
| 897 | + // of the file this URL names, which need not be the original upload. | |
| 898 | + $attachment_id = Attachment_Lookup::id_from_url($image_url); | |
| 779 | 899 | if ($attachment_id) { |
| 780 | - $image_data = wp_get_attachment_image_src($attachment_id, 'full'); | |
| 900 | + $image_file = Attachment_Lookup::describe($attachment_id, $image_url); | |
| 781 | 901 | // SVGs report 0x0 — omit the dimensions rather than emitting |
| 782 | 902 | // zeroes, which invalidate the ImageObject. |
| 783 | - if ($image_data && (int) $image_data[1] > 0 && (int) $image_data[2] > 0) { | |
| 784 | - $image_schema['width'] = (int) $image_data[1]; | |
| 785 | - $image_schema['height'] = (int) $image_data[2]; | |
| 903 | + if ($image_file['width'] > 0 && $image_file['height'] > 0) { | |
| 904 | + $image_schema['width'] = $image_file['width']; | |
| 905 | + $image_schema['height'] = $image_file['height']; | |
| 786 | 906 | } |
| 787 | 907 | } |
| 788 | 908 | |
| 789 | 909 | return $image_schema; |
| @@ -829,10 +949,19 @@ | ||
| 829 | 949 | * |
| 830 | 950 | * @return array Social media profile URLs |
| 831 | 951 | */ |
| 832 | 952 | private function get_social_profiles(): array { |
| 833 | - // Get Schema Manager settings for organization social profiles | |
| 834 | - $schema_manager = new \ThinkRank\SEO\Schema_Management_System(); | |
| 953 | + // Reuse one manager for the whole request. This method runs from inside | |
| 954 | + // the foreign-settings listener, and constructing a fresh | |
| 955 | + // Schema_Management_System on every Organization build was what let the | |
| 956 | + // listener count double per save (#463). The constructor's static guard | |
| 957 | + // stops the doubling; this stops the needless re-construction. | |
| 958 | + static $schema_manager = null; | |
| 959 | + | |
| 960 | + if (null === $schema_manager) { | |
| 961 | + $schema_manager = new \ThinkRank\SEO\Schema_Management_System(); | |
| 962 | + } | |
| 963 | + | |
| 835 | 964 | $settings = $schema_manager->get_settings('site', null); |
| 836 | 965 | |
| 837 | 966 | $social_profiles = []; |
| 838 | 967 | |
| @@ -848,9 +977,9 @@ | ||
| 848 | 977 | 'organization_social_telegram' |
| 849 | 978 | ]; |
| 850 | 979 | |
| 851 | 980 | foreach ($social_fields as $field) { |
| 852 | - if (!empty($settings[$field]) && filter_var($settings[$field], FILTER_VALIDATE_URL)) { | |
| 981 | + if (!empty($settings[$field]) && \ThinkRank\Core\Url_Validator::is_valid($settings[$field])) { | |
| 853 | 982 | $social_profiles[] = $settings[$field]; |
| 854 | 983 | } |
| 855 | 984 | } |
| 856 | 985 | |
| @@ -965,11 +1094,29 @@ | ||
| 965 | 1094 | * @return array Populated schema |
| 966 | 1095 | */ |
| 967 | 1096 | private function populate_website_schema(array $schema, array $data, string $context): array { |
| 968 | 1097 | // 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(); | |
| 1098 | + $schema['name'] = $this->first_non_empty( | |
| 1099 | + $data['site_data']['website_name'] ?? '', | |
| 1100 | + $data['title'] ?? '', | |
| 1101 | + get_bloginfo('name') | |
| 1102 | + ); | |
| 1103 | + $schema['url'] = $this->first_non_empty( | |
| 1104 | + $data['site_data']['website_url'] ?? '', | |
| 1105 | + $data['url'] ?? '', | |
| 1106 | + home_url() | |
| 1107 | + ); | |
| 971 | 1108 | |
| 1109 | + // Both WebSite producers have to carry this or the deployed node and the | |
| 1110 | + // default one disagree about the same site — the shape of failure #688 | |
| 1111 | + // documents. The default node is generate_website_schema() (#692). | |
| 1112 | + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema( | |
| 1113 | + $data['site_data']['alternate_name'] ?? null | |
| 1114 | + ); | |
| 1115 | + if (null !== $alternate_name) { | |
| 1116 | + $schema['alternateName'] = $alternate_name; | |
| 1117 | + } | |
| 1118 | + | |
| 972 | 1119 | // Recommended properties - prioritize user-configured Website schema description |
| 973 | 1120 | if (!empty($data['site_data']['website_description'])) { |
| 974 | 1121 | $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160); |
| 975 | 1122 | } elseif (!empty($data['content'])) { |
| @@ -974,9 +1121,13 @@ | ||
| 974 | 1121 | $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160); |
| 975 | 1122 | } elseif (!empty($data['content'])) { |
| 976 | 1123 | $schema['description'] = $this->truncate_text($data['content'], 160); |
| 977 | 1124 | } else { |
| 978 | - $schema['description'] = get_bloginfo('description'); | |
| 1125 | + // The tagline is stored esc_html()'d by sanitize_option(), so an | |
| 1126 | + // ampersand in it arrives as `&`. | |
| 1127 | + $schema['description'] = \ThinkRank\Core\Seo_Text::normalize_schema_text( | |
| 1128 | + (string) get_bloginfo('description') | |
| 1129 | + ); | |
| 979 | 1130 | } |
| 980 | 1131 | |
| 981 | 1132 | // Author - use organization or person data |
| 982 | 1133 | if (!empty($data['site_data']['organization_name'])) { |
| @@ -1032,19 +1183,14 @@ | ||
| 1032 | 1183 | } |
| 1033 | 1184 | |
| 1034 | 1185 | $schema['publisher'] = $publisher; |
| 1035 | 1186 | |
| 1036 | - // Search action for sitelinks search box (optional but recommended) | |
| 1037 | - if ($data['site_data']['website_enable_search'] ?? true) { | |
| 1038 | - $search_url = $data['site_data']['website_search_url'] ?? home_url('/?s={search_term_string}'); | |
| 1039 | - $schema['potentialAction'] = [ | |
| 1040 | - '@type' => 'SearchAction', | |
| 1041 | - 'target' => [ | |
| 1042 | - '@type' => 'EntryPoint', | |
| 1043 | - 'urlTemplate' => $search_url | |
| 1044 | - ], | |
| 1045 | - 'query-input' => 'required name=search_term_string' | |
| 1046 | - ]; | |
| 1187 | + // Search action for sitelinks search box (optional but recommended). | |
| 1188 | + // Shared with the live fallback in SEO_Manager so the deployed and | |
| 1189 | + // live WebSite nodes read the switch and the URL the same way (#1009). | |
| 1190 | + $search_action = self::website_search_action($data['site_data'] ?? []); | |
| 1191 | + if (null !== $search_action) { | |
| 1192 | + $schema['potentialAction'] = $search_action; | |
| 1047 | 1193 | } |
| 1048 | 1194 | |
| 1049 | 1195 | // Social media profiles (sameAs) |
| 1050 | 1196 | $social_profiles = $this->get_social_profiles(); |
| @@ -1052,9 +1198,10 @@ | ||
| 1052 | 1198 | $schema['sameAs'] = $social_profiles; |
| 1053 | 1199 | } |
| 1054 | 1200 | |
| 1055 | 1201 | // Language |
| 1056 | - $schema['inLanguage'] = get_locale(); | |
| 1202 | + // BCP-47, not the WP locale: schema.org expects en-US, get_locale() gives en_US (#473). | |
| 1203 | + $schema['inLanguage'] = get_bloginfo('language'); | |
| 1057 | 1204 | |
| 1058 | 1205 | return $schema; |
| 1059 | 1206 | } |
| 1060 | 1207 | |
| @@ -1081,13 +1228,13 @@ | ||
| 1081 | 1228 | $schema['description'] = $this->truncate_text($data['content'], 160); |
| 1082 | 1229 | } |
| 1083 | 1230 | |
| 1084 | 1231 | if (!empty($data['date'])) { |
| 1085 | - $schema['datePublished'] = $data['date']; | |
| 1232 | + $schema['datePublished'] = $this->to_iso8601($data['date']); | |
| 1086 | 1233 | } |
| 1087 | 1234 | |
| 1088 | 1235 | if (!empty($data['modified'])) { |
| 1089 | - $schema['dateModified'] = $data['modified']; | |
| 1236 | + $schema['dateModified'] = $this->to_iso8601($data['modified']); | |
| 1090 | 1237 | } |
| 1091 | 1238 | |
| 1092 | 1239 | $schema['isPartOf'] = [ |
| 1093 | 1240 | '@type' => 'WebSite', |
| @@ -1145,16 +1292,22 @@ | ||
| 1145 | 1292 | } |
| 1146 | 1293 | |
| 1147 | 1294 | $schema['mainEntity'] = $faq_data; |
| 1148 | 1295 | |
| 1149 | - // Optional properties | |
| 1150 | - $schema['name'] = $data['title'] ?? 'Frequently Asked Questions'; | |
| 1296 | + // Optional properties. The FAQ form's own Page Title / Page URL fields | |
| 1297 | + // win over the post's title and permalink — they were collected by the | |
| 1298 | + // form and then never read, so typing in them changed nothing. | |
| 1299 | + $schema['name'] = !empty($data['site_data']['faq_page_name']) | |
| 1300 | + ? $data['site_data']['faq_page_name'] | |
| 1301 | + : ($data['title'] ?? 'Frequently Asked Questions'); | |
| 1151 | 1302 | if (!empty($data['excerpt'])) { |
| 1152 | 1303 | $schema['description'] = $this->truncate_text($data['excerpt'], 160); |
| 1153 | 1304 | } |
| 1154 | 1305 | |
| 1155 | 1306 | // URL for the FAQ page |
| 1156 | - if (!empty($data['url'])) { | |
| 1307 | + if (!empty($data['site_data']['faq_page_url'])) { | |
| 1308 | + $schema['url'] = $data['site_data']['faq_page_url']; | |
| 1309 | + } elseif (!empty($data['url'])) { | |
| 1157 | 1310 | $schema['url'] = $data['url']; |
| 1158 | 1311 | } |
| 1159 | 1312 | |
| 1160 | 1313 | // About - recommended property |
| @@ -1199,8 +1352,18 @@ | ||
| 1199 | 1352 | private function populate_local_business_schema(array $schema, array $data, string $context): array { |
| 1200 | 1353 | // Get business data from Site Identity Business Info (single source of truth) |
| 1201 | 1354 | $business_data = $this->get_business_data_from_site_identity(); |
| 1202 | 1355 | |
| 1356 | + // The business type chosen in Local SEO is what this node IS. It was | |
| 1357 | + // read into $business_data and then never used, so every site published | |
| 1358 | + // "@type": "LocalBusiness" whatever it had picked, and the ~150-type | |
| 1359 | + // selector (#623) changed nothing but a meta tag. create_base_schema() | |
| 1360 | + // has already put @type in place, so reassigning keeps key order and a | |
| 1361 | + // site on the default publishes exactly what it did before. | |
| 1362 | + $schema['@type'] = \ThinkRank\Config\Local_Business_Types_Config::schema_type( | |
| 1363 | + $business_data['business_type'] ?? '' | |
| 1364 | + ); | |
| 1365 | + | |
| 1203 | 1366 | // Required properties - use business name from Business Info. |
| 1204 | 1367 | // `name` is required for LocalBusiness, so an empty saved value must fall |
| 1205 | 1368 | // through to the next source rather than emit "". |
| 1206 | 1369 | $schema['name'] = $this->first_non_empty( |
| @@ -1389,9 +1552,13 @@ | ||
| 1389 | 1552 | * @return array Populated schema |
| 1390 | 1553 | */ |
| 1391 | 1554 | private function populate_person_schema(array $schema, array $data, string $context): array { |
| 1392 | 1555 | // Required properties - prioritize user-configured fields |
| 1393 | - $schema['name'] = $data['site_data']['person_name'] ?? $data['author']['name'] ?? $data['title'] ?? ''; | |
| 1556 | + $schema['name'] = $this->first_non_empty( | |
| 1557 | + $data['site_data']['person_name'] ?? '', | |
| 1558 | + $data['author']['name'] ?? '', | |
| 1559 | + $data['title'] ?? '' | |
| 1560 | + ); | |
| 1394 | 1561 | |
| 1395 | 1562 | // Image from user configuration or fallback |
| 1396 | 1563 | if (!empty($data['site_data']['person_image'])) { |
| 1397 | 1564 | $schema['image'] = $this->format_image_schema($data['site_data']['person_image']); |
| @@ -1466,10 +1633,21 @@ | ||
| 1466 | 1633 | if (!empty($global_social_profiles)) { |
| 1467 | 1634 | $social_profiles = array_merge($social_profiles, $global_social_profiles); |
| 1468 | 1635 | } |
| 1469 | 1636 | |
| 1470 | - // Remove duplicates and empty values | |
| 1471 | - $social_profiles = array_unique(array_filter($social_profiles)); | |
| 1637 | + // Remove duplicates, empties and anything that is not a URL. schema.org | |
| 1638 | + // types sameAs as a URL, and the person social fields are free text, so | |
| 1639 | + // without this a typed-in note shipped as a sameAs member and made the | |
| 1640 | + // whole Person invalid (#480). get_social_profiles() above already | |
| 1641 | + // filters its own values the same way. | |
| 1642 | + $social_profiles = array_values(array_unique(array_filter( | |
| 1643 | + $social_profiles, | |
| 1644 | + static function ($url) { | |
| 1645 | + return is_string($url) | |
| 1646 | + && '' !== trim($url) | |
| 1647 | + && \ThinkRank\Core\Url_Validator::is_http_url($url); | |
| 1648 | + } | |
| 1649 | + ))); | |
| 1472 | 1650 | |
| 1473 | 1651 | if (!empty($social_profiles)) { |
| 1474 | 1652 | $schema['sameAs'] = $social_profiles; |
| 1475 | 1653 | } |