PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/seo/class-schema-builder.php +429 -66 1.0.1 → 2.10.0 View file →
@@ -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);
@@ -111,8 +117,14 @@
111 117 break;
112 118 case 'HowTo':
113 119 $schema = $this->populate_howto_schema($schema, $data, $context);
114 120 break;
121 + case 'Review':
122 + $schema = $this->populate_review_schema($schema, $data, $context);
123 + break;
124 + case 'VideoObject':
125 + $schema = $this->populate_video_object_schema($schema, $data, $context);
126 + break;
115 127 default:
116 128 $schema = $this->populate_generic_schema($schema, $data, $context);
117 129 break;
118 130 }
@@ -137,10 +149,14 @@
137 149
138 150 // Clean up empty values
139 151 $schema = $this->clean_schema_array($schema);
140 152
141 - // Generate JSON-LD with proper formatting
142 - $json_flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
153 + // Generate JSON-LD with proper formatting. Include the HEX flags so a
154 + // </script> in any field is emitted as <\/script> and can't break out
155 + // of the surrounding <script type="application/ld+json"> block, matching
156 + // the site-wide schema output path.
157 + $json_flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
158 + | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
143 159 if (defined('WP_DEBUG') && WP_DEBUG) {
144 160 $json_flags |= JSON_PRETTY_PRINT;
145 161 }
146 162
@@ -194,11 +210,11 @@
194 210 }
195 211
196 212 // Date published - prioritize user-configured date
197 213 if (!empty($data['site_data']['article_date_published'])) {
198 - $schema['datePublished'] = $data['site_data']['article_date_published'];
214 + $schema['datePublished'] = $this->to_iso8601($data['site_data']['article_date_published']);
199 215 } elseif (!empty($data['date'])) {
200 - $schema['datePublished'] = $data['date'];
216 + $schema['datePublished'] = $this->to_iso8601($data['date']);
201 217 } else {
202 218 $schema['datePublished'] = current_time('c');
203 219 }
204 220
@@ -219,11 +235,11 @@
219 235 }
220 236
221 237 // Date modified - prioritize user-configured date
222 238 if (!empty($data['site_data']['article_date_modified'])) {
223 - $schema['dateModified'] = $data['site_data']['article_date_modified'];
239 + $schema['dateModified'] = $this->to_iso8601($data['site_data']['article_date_modified']);
224 240 } elseif (!empty($data['modified'])) {
225 - $schema['dateModified'] = $data['modified'];
241 + $schema['dateModified'] = $this->to_iso8601($data['modified']);
226 242 } else {
227 243 $schema['dateModified'] = $schema['datePublished'];
228 244 }
229 245
@@ -243,12 +259,11 @@
243 259 // Use frontend-calculated word count if provided (from SEO Analysis method)
244 260 // Otherwise fallback to backend calculation
245 261 $schema['wordCount'] = isset($data['word_count']) ? (int) $data['word_count'] : str_word_count(wp_strip_all_tags($data['content']));
246 262
247 - // Use focus keyword if available, otherwise extract from content
248 - if (!empty($data['focus_keyword'])) {
249 - // Split focus keyword into individual keywords
250 - $focus_keywords = array_map('trim', explode(',', $data['focus_keyword']));
263 + // Use focus keywords if available, otherwise extract from content.
264 + $focus_keywords = $this->resolve_focus_keywords($data);
265 + if (!empty($focus_keywords)) {
251 266 $schema['keywords'] = array_merge($focus_keywords, $this->extract_keywords_from_content($data['content']));
252 267 // Remove duplicates and limit to 10
253 268 $schema['keywords'] = array_slice(array_unique($schema['keywords']), 0, 10);
254 269 } else {
@@ -259,8 +274,31 @@
259 274 return $schema;
260 275 }
261 276
262 277 /**
278 + * Resolve the focus keyword list from schema content data.
279 + *
280 + * Prefers the multi-keyword array (`focus_keywords`); falls back to the
281 + * legacy single/comma-separated `focus_keyword` string.
282 + *
283 + * @param array $data Schema content data.
284 + * @return string[] Trimmed, non-empty focus keywords.
285 + */
286 + private function resolve_focus_keywords(array $data): array {
287 + $keywords = [];
288 +
289 + if (!empty($data['focus_keywords']) && is_array($data['focus_keywords'])) {
290 + $keywords = $data['focus_keywords'];
291 + } elseif (!empty($data['focus_keyword'])) {
292 + $keywords = explode(',', (string) $data['focus_keyword']);
293 + }
294 +
295 + $keywords = array_map('trim', $keywords);
296 +
297 + return array_values(array_filter($keywords, 'strlen'));
298 + }
299 +
300 + /**
263 301 * Populate Product schema
264 302 * PRESERVED: Exact same method logic from original Schema_Generator
265 303 *
266 304 * @since 1.0.0
@@ -337,9 +375,12 @@
337 375 'bestRating' => '5'
338 376 ],
339 377 'author' => [
340 378 '@type' => 'Person',
341 - 'name' => $data['site_data']['organization_name'] ?? get_bloginfo('name')
379 + 'name' => $this->first_non_empty(
380 + $data['site_data']['organization_name'] ?? null,
381 + get_bloginfo('name')
382 + )
342 383 ]
343 384 ];
344 385 }
345 386
@@ -405,8 +446,25 @@
405 446 return $schema;
406 447 }
407 448
408 449 /**
450 + * First argument that is a non-empty string (after trimming).
451 + *
452 + * @since 1.17.0
453 + *
454 + * @param mixed ...$values Candidate values in priority order.
455 + * @return string First non-empty candidate, or '' when none qualify.
456 + */
457 + private function first_non_empty(...$values): string {
458 + foreach ($values as $value) {
459 + if (is_string($value) && trim($value) !== '') {
460 + return $value;
461 + }
462 + }
463 + return '';
464 + }
465 +
466 + /**
409 467 * Populate Organization schema
410 468 * PRESERVED: Exact same method logic from original Schema_Generator
411 469 *
412 470 * @since 1.0.0
@@ -419,11 +477,23 @@
419 477 private function populate_organization_schema(array $schema, array $data, string $context): array {
420 478 // Get business data from Site Identity Business Info (single source of truth)
421 479 $business_data = $this->get_business_data_from_site_identity();
422 480
423 - // Required properties - prioritize Schema Manager organization settings
424 - $schema['name'] = $data['site_data']['organization_name'] ?? $business_data['business_name'] ?? $data['title'] ?? get_bloginfo('name');
425 - $schema['url'] = $data['site_data']['organization_url'] ?? $business_data['business_website'] ?? $data['url'] ?? home_url();
481 + // Required properties - prioritize Schema Manager organization settings.
482 + // first_non_empty() rather than ??: a saved-but-empty string is "set"
483 + // and would otherwise stop the fallback chain dead.
484 + $schema['name'] = $this->first_non_empty(
485 + $data['site_data']['organization_name'] ?? null,
486 + $business_data['business_name'] ?? null,
487 + $data['title'] ?? null,
488 + get_bloginfo('name')
489 + );
490 + $schema['url'] = $this->first_non_empty(
491 + $data['site_data']['organization_url'] ?? null,
492 + $business_data['business_website'] ?? null,
493 + $data['url'] ?? null,
494 + home_url()
495 + );
426 496
427 497 // Logo from user configuration or theme customizer
428 498 if (!empty($data['site_data']['organization_logo'])) {
429 499 $schema['logo'] = $this->format_image_schema($data['site_data']['organization_logo']);
@@ -442,31 +512,45 @@
442 512 }
443 513 }
444 514 }
445 515
446 - // Contact point from Business Info or contact configuration
447 - $contact_point = ['@type' => 'ContactPoint'];
448 - $has_contact_info = false;
516 + // Contact point: the Schema Manager's own fields win, then Business
517 + // Info. Reading telephone/email from Business Info alone and hard-coding
518 + // contactType left the Organization form's Contact Type, Phone and Email
519 + // inert — they saved but never reached the deployed markup, even though
520 + // Seo_Manager already applied this precedence for the same entity.
521 + $site_data = $data['site_data'] ?? [];
449 522
450 - // Use Business Info phone as primary contact
451 - if (!empty($business_data['business_phone'])) {
452 - $contact_point['telephone'] = $business_data['business_phone'];
453 - $has_contact_info = true;
454 - }
523 + $contact_phone = $this->first_non_empty(
524 + $site_data['organization_contact_phone'] ?? null,
525 + $business_data['business_phone'] ?? null
526 + );
455 527
456 - // Use Business Info email as primary contact
457 - if (!empty($business_data['business_email'])) {
458 - $contact_point['email'] = $business_data['business_email'];
459 - $has_contact_info = true;
460 - }
528 + $contact_email = $this->first_non_empty(
529 + $site_data['organization_contact_email'] ?? null,
530 + $business_data['business_email'] ?? null
531 + );
461 532
462 - // Add contact type and hours if available
463 - if ($has_contact_info) {
464 - $contact_point['contactType'] = 'customer service';
533 + if ('' !== $contact_phone || '' !== $contact_email) {
534 + $contact_point = [
535 + '@type' => 'ContactPoint',
536 + 'contactType' => $this->first_non_empty(
537 + $site_data['organization_contact_type'] ?? null,
538 + 'customer service'
539 + ),
540 + ];
465 541
542 + if ('' !== $contact_phone) {
543 + $contact_point['telephone'] = $contact_phone;
544 + }
545 +
546 + if ('' !== $contact_email) {
547 + $contact_point['email'] = $contact_email;
548 + }
549 +
466 550 // Add contact hours if available from organization settings
467 - if (!empty($data['site_data']['organization_contact_hours'])) {
468 - $contact_point['hoursAvailable'] = $data['site_data']['organization_contact_hours'];
551 + if (!empty($site_data['organization_contact_hours'])) {
552 + $contact_point['hoursAvailable'] = $site_data['organization_contact_hours'];
469 553 }
470 554
471 555 $schema['contactPoint'] = $contact_point;
472 556 }
@@ -509,23 +593,204 @@
509 593 return $schema;
510 594 }
511 595
512 596 /**
513 - * Truncate text to specified length
514 - * PRESERVED: Exact same method logic from original Schema_Generator
597 + * Populate Review schema (standalone review of an item).
515 598 *
599 + * Emits a schema.org Review: the reviewed entity (itemReviewed), a Rating,
600 + * the reviewing author, and an optional review body. Falls back to the post
601 + * title for the reviewed item and the post author for the reviewer when the
602 + * user-configured fields are empty, so an imported review with sparse data
603 + * still produces valid markup.
604 + *
605 + * @since 1.13.0
606 + *
607 + * @param array $schema Base schema
608 + * @param array $data Content data
609 + * @param string $context Context type
610 + * @return array Populated schema
611 + */
612 + private function populate_review_schema(array $schema, array $data, string $context): array {
613 + $site_data = $data['site_data'] ?? [];
614 +
615 + // itemReviewed — the thing being reviewed; fall back to the content title.
616 + $item_name = $site_data['review_item_name'] ?? $data['title'] ?? '';
617 + if (!empty($item_name)) {
618 + $item_type = $site_data['review_item_type'] ?? 'Thing';
619 + $schema['itemReviewed'] = [
620 + '@type' => $item_type,
621 + 'name' => $item_name,
622 + ];
623 + }
624 +
625 + // reviewRating — only emitted when a rating value is present.
626 + $rating_value = $site_data['review_rating_value'] ?? '';
627 + if ($rating_value !== '' && $rating_value !== null) {
628 + $schema['reviewRating'] = [
629 + '@type' => 'Rating',
630 + 'ratingValue' => $rating_value,
631 + 'bestRating' => $site_data['review_best_rating'] ?? '5',
632 + 'worstRating' => $site_data['review_worst_rating'] ?? '1',
633 + ];
634 + }
635 +
636 + // author — user-configured reviewer, else the post author.
637 + $author = $site_data['review_author'] ?? ($data['author']['name'] ?? '');
638 + if (!empty($author)) {
639 + $schema['author'] = [
640 + '@type' => 'Person',
641 + 'name' => $author,
642 + ];
643 + }
644 +
645 + // reviewBody — optional free-text review.
646 + if (!empty($site_data['review_body'])) {
647 + $schema['reviewBody'] = $this->truncate_text($site_data['review_body'], 500);
648 + }
649 +
650 + // datePublished + url from content context.
651 + if (!empty($data['date'])) {
652 + $schema['datePublished'] = $this->to_iso8601($data['date']);
653 + }
654 + if (!empty($data['url'])) {
655 + $schema['url'] = $data['url'];
656 + }
657 +
658 + return $schema;
659 + }
660 +
661 + /**
662 + * Populate VideoObject schema.
663 + *
664 + * Google requires name, description, thumbnailUrl and uploadDate; contentUrl
665 + * and/or embedUrl are strongly recommended so the video is playable. Each
666 + * required field falls back to content context (title, excerpt, featured
667 + * image, publish date) when the user has not set a video-specific value.
668 + *
516 669 * @since 1.0.0
517 670 *
671 + * @param array $schema Base schema
672 + * @param array $data Content data
673 + * @param string $context Context type
674 + * @return array Populated schema
675 + */
676 + private function populate_video_object_schema(array $schema, array $data, string $context): array {
677 + $site_data = $data['site_data'] ?? [];
678 +
679 + // name — required; fall back to the content title.
680 + $schema['name'] = $this->truncate_text(
681 + $site_data['video_name'] ?? $data['title'] ?? '',
682 + 110
683 + );
684 +
685 + // description — required; fall back to excerpt then content.
686 + $description = $site_data['video_description'] ?? '';
687 + if ($description === '') {
688 + $description = $data['excerpt'] ?? $data['content'] ?? '';
689 + }
690 + if ($description !== '') {
691 + $schema['description'] = $this->truncate_text($description, 160);
692 + }
693 +
694 + // thumbnailUrl — required; fall back to the featured/content image.
695 + $thumbnail = $site_data['video_thumbnail'] ?? '';
696 + if ($thumbnail === '' && !empty($data['image'])) {
697 + $thumbnail = is_array($data['image']) ? ($data['image']['url'] ?? '') : $data['image'];
698 + }
699 + if ($thumbnail !== '') {
700 + $schema['thumbnailUrl'] = $thumbnail;
701 + }
702 +
703 + // uploadDate — required; fall back to the content publish date.
704 + $upload_date = $site_data['video_upload_date'] ?? '';
705 + if ($upload_date === '') {
706 + $upload_date = $data['date'] ?? current_time('c');
707 + }
708 + $schema['uploadDate'] = $upload_date;
709 +
710 + // contentUrl / embedUrl — recommended; at least one makes the video playable.
711 + if (!empty($site_data['video_content_url'])) {
712 + $schema['contentUrl'] = $site_data['video_content_url'];
713 + }
714 + if (!empty($site_data['video_embed_url'])) {
715 + $schema['embedUrl'] = $site_data['video_embed_url'];
716 + }
717 +
718 + // duration — optional ISO 8601 (e.g. PT1M33S).
719 + if (!empty($site_data['video_duration'])) {
720 + $schema['duration'] = $site_data['video_duration'];
721 + }
722 +
723 + // url from content context.
724 + if (!empty($data['url'])) {
725 + $schema['url'] = $data['url'];
726 + }
727 +
728 + return $schema;
729 + }
730 +
731 + /**
732 + * Normalise text for JSON-LD and truncate it to a length.
733 + *
734 + * Every description, headline and reviewBody this class emits passes
735 + * through here, so this is the one place the deployed nodes get the same
736 + * treatment the automatic ones have had since #766. Before, it only
737 + * stripped tags: the block editor stores `&` as `&amp;`, which was then
738 + * published literally (nothing decodes JSON-LD), and the cut landed
739 + * mid-word with "..." glued on. A deployed node outranks the automatic
740 + * one, so deploying a page type made its description worse than not
741 + * deploying anything.
742 + *
743 + * Seo_Text::trim_to_length() is multibyte-aware, which the #473 fix here
744 + * already required (a byte cut mojibaked every non-Latin description),
745 + * and falls back to a character cut for scripts without spaces.
746 + *
747 + * @since 1.0.0
748 + * @since 2.10.0 Decodes entities and cuts on a word boundary.
749 + *
518 750 * @param string $text Text to truncate
519 751 * @param int $length Maximum length
520 752 * @return string Truncated text
521 753 */
522 754 private function truncate_text(string $text, int $length): string {
523 - $text = wp_strip_all_tags($text);
524 - if (strlen($text) <= $length) {
525 - return $text;
755 + return \ThinkRank\Core\Seo_Text::trim_to_length(
756 + \ThinkRank\Core\Seo_Text::normalize_schema_text($text),
757 + $length
758 + );
759 + }
760 +
761 + /**
762 + * Normalise a date into ISO 8601 with a timezone offset.
763 + *
764 + * Deployed schema is a stored snapshot, so rows written before #465 still
765 + * hold raw MySQL datetimes ("2026-08-23 10:19:10"). Google reports those as
766 + * an invalid date value and drops the Article rich result, so normalise on
767 + * the way out as well as on the way in.
768 + *
769 + * @since 1.16.0
770 + *
771 + * @param mixed $date Date in any parseable form.
772 + * @return string ISO 8601 date, or '' when the input cannot be parsed.
773 + */
774 + private function to_iso8601($date): string {
775 + if (empty($date) || !is_scalar($date)) {
776 + return '';
526 777 }
527 - return substr($text, 0, $length - 3) . '...';
778 +
779 + $date = (string) $date;
780 +
781 + // Already ISO 8601 (has the date/time separator) — leave it alone.
782 + if (preg_match('/^\d{4}-\d{2}-\d{2}T/', $date)) {
783 + return $date;
784 + }
785 +
786 + $timestamp = strtotime($date);
787 +
788 + if (false === $timestamp) {
789 + return '';
790 + }
791 +
792 + return (string) wp_date('c', $timestamp);
528 793 }
529 794
530 795 /**
531 796 * Format author schema
@@ -578,11 +843,13 @@
578 843 // Try to get image dimensions if it's a WordPress attachment
579 844 $attachment_id = attachment_url_to_postid($image_url);
580 845 if ($attachment_id) {
581 846 $image_data = wp_get_attachment_image_src($attachment_id, 'full');
582 - if ($image_data) {
583 - $image_schema['width'] = $image_data[1];
584 - $image_schema['height'] = $image_data[2];
847 + // SVGs report 0x0 — omit the dimensions rather than emitting
848 + // zeroes, which invalidate the ImageObject.
849 + if ($image_data && (int) $image_data[1] > 0 && (int) $image_data[2] > 0) {
850 + $image_schema['width'] = (int) $image_data[1];
851 + $image_schema['height'] = (int) $image_data[2];
585 852 }
586 853 }
587 854
588 855 return $image_schema;
@@ -628,10 +895,19 @@
628 895 *
629 896 * @return array Social media profile URLs
630 897 */
631 898 private function get_social_profiles(): array {
632 - // Get Schema Manager settings for organization social profiles
633 - $schema_manager = new \ThinkRank\SEO\Schema_Management_System();
899 + // Reuse one manager for the whole request. This method runs from inside
900 + // the foreign-settings listener, and constructing a fresh
901 + // Schema_Management_System on every Organization build was what let the
902 + // listener count double per save (#463). The constructor's static guard
903 + // stops the doubling; this stops the needless re-construction.
904 + static $schema_manager = null;
905 +
906 + if (null === $schema_manager) {
907 + $schema_manager = new \ThinkRank\SEO\Schema_Management_System();
908 + }
909 +
634 910 $settings = $schema_manager->get_settings('site', null);
635 911
636 912 $social_profiles = [];
637 913
@@ -640,9 +916,12 @@
640 916 'organization_social_facebook',
641 917 'organization_social_twitter',
642 918 'organization_social_linkedin',
643 919 'organization_social_instagram',
644 - 'organization_social_youtube'
920 + 'organization_social_youtube',
921 + 'organization_social_pinterest',
922 + 'organization_social_whatsapp',
923 + 'organization_social_telegram'
645 924 ];
646 925
647 926 foreach ($social_fields as $field) {
648 927 if (!empty($settings[$field]) && filter_var($settings[$field], FILTER_VALIDATE_URL)) {
@@ -672,9 +951,9 @@
672 951
673 952 $keywords = [];
674 953 foreach ($words as $word) {
675 954 $word = strtolower(trim($word));
676 - if (strlen($word) > 3 && !in_array($word, $common_words)) {
955 + if (strlen($word) > 3 && !in_array($word, $common_words, true)) {
677 956 $keywords[] = $word;
678 957 }
679 958 }
680 959
@@ -740,9 +1019,9 @@
740 1019 $schema[$key] = $this->clean_schema_array($value);
741 1020 if (empty($schema[$key])) {
742 1021 unset($schema[$key]);
743 1022 }
744 - } elseif (empty($value) && $value !== 0 && $value !== '0' && !in_array($key, $critical_fields)) {
1023 + } elseif (empty($value) && $value !== 0 && $value !== '0' && !in_array($key, $critical_fields, true)) {
745 1024 unset($schema[$key]);
746 1025 }
747 1026 }
748 1027
@@ -761,11 +1040,29 @@
761 1040 * @return array Populated schema
762 1041 */
763 1042 private function populate_website_schema(array $schema, array $data, string $context): array {
764 1043 // Required properties - prioritize user-configured Website schema fields
765 - $schema['name'] = $data['site_data']['website_name'] ?? $data['title'] ?? get_bloginfo('name');
766 - $schema['url'] = $data['site_data']['website_url'] ?? $data['url'] ?? home_url();
1044 + $schema['name'] = $this->first_non_empty(
1045 + $data['site_data']['website_name'] ?? '',
1046 + $data['title'] ?? '',
1047 + get_bloginfo('name')
1048 + );
1049 + $schema['url'] = $this->first_non_empty(
1050 + $data['site_data']['website_url'] ?? '',
1051 + $data['url'] ?? '',
1052 + home_url()
1053 + );
767 1054
1055 + // Both WebSite producers have to carry this or the deployed node and the
1056 + // default one disagree about the same site — the shape of failure #688
1057 + // documents. The default node is generate_website_schema() (#692).
1058 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema(
1059 + $data['site_data']['alternate_name'] ?? null
1060 + );
1061 + if (null !== $alternate_name) {
1062 + $schema['alternateName'] = $alternate_name;
1063 + }
1064 +
768 1065 // Recommended properties - prioritize user-configured Website schema description
769 1066 if (!empty($data['site_data']['website_description'])) {
770 1067 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
771 1068 } elseif (!empty($data['content'])) {
@@ -770,9 +1067,13 @@
770 1067 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
771 1068 } elseif (!empty($data['content'])) {
772 1069 $schema['description'] = $this->truncate_text($data['content'], 160);
773 1070 } else {
774 - $schema['description'] = get_bloginfo('description');
1071 + // The tagline is stored esc_html()'d by sanitize_option(), so an
1072 + // ampersand in it arrives as `&amp;`.
1073 + $schema['description'] = \ThinkRank\Core\Seo_Text::normalize_schema_text(
1074 + (string) get_bloginfo('description')
1075 + );
775 1076 }
776 1077
777 1078 // Author - use organization or person data
778 1079 if (!empty($data['site_data']['organization_name'])) {
@@ -795,10 +1096,16 @@
795 1096
796 1097 // Publisher - enhanced with logo from Site Identity
797 1098 $publisher = [
798 1099 '@type' => 'Organization',
799 - 'name' => $data['site_data']['organization_name'] ?? get_bloginfo('name'),
800 - 'url' => $data['site_data']['organization_url'] ?? home_url()
1100 + 'name' => $this->first_non_empty(
1101 + $data['site_data']['organization_name'] ?? null,
1102 + get_bloginfo('name')
1103 + ),
1104 + 'url' => $this->first_non_empty(
1105 + $data['site_data']['organization_url'] ?? null,
1106 + home_url()
1107 + )
801 1108 ];
802 1109
803 1110 // Add logo to publisher from Site Identity or user configuration
804 1111 if (!empty($data['site_data']['logo_url'])) {
@@ -824,9 +1131,12 @@
824 1131 $schema['publisher'] = $publisher;
825 1132
826 1133 // Search action for sitelinks search box (optional but recommended)
827 1134 if ($data['site_data']['website_enable_search'] ?? true) {
828 - $search_url = $data['site_data']['website_search_url'] ?? home_url('/?s={search_term_string}');
1135 + $search_url = $this->first_non_empty(
1136 + $data['site_data']['website_search_url'] ?? '',
1137 + home_url('/?s={search_term_string}')
1138 + );
829 1139 $schema['potentialAction'] = [
830 1140 '@type' => 'SearchAction',
831 1141 'target' => [
832 1142 '@type' => 'EntryPoint',
@@ -842,9 +1152,10 @@
842 1152 $schema['sameAs'] = $social_profiles;
843 1153 }
844 1154
845 1155 // Language
846 - $schema['inLanguage'] = get_locale();
1156 + // BCP-47, not the WP locale: schema.org expects en-US, get_locale() gives en_US (#473).
1157 + $schema['inLanguage'] = get_bloginfo('language');
847 1158
848 1159 return $schema;
849 1160 }
850 1161
@@ -871,13 +1182,13 @@
871 1182 $schema['description'] = $this->truncate_text($data['content'], 160);
872 1183 }
873 1184
874 1185 if (!empty($data['date'])) {
875 - $schema['datePublished'] = $data['date'];
1186 + $schema['datePublished'] = $this->to_iso8601($data['date']);
876 1187 }
877 1188
878 1189 if (!empty($data['modified'])) {
879 - $schema['dateModified'] = $data['modified'];
1190 + $schema['dateModified'] = $this->to_iso8601($data['modified']);
880 1191 }
881 1192
882 1193 $schema['isPartOf'] = [
883 1194 '@type' => 'WebSite',
@@ -935,16 +1246,22 @@
935 1246 }
936 1247
937 1248 $schema['mainEntity'] = $faq_data;
938 1249
939 - // Optional properties
940 - $schema['name'] = $data['title'] ?? 'Frequently Asked Questions';
1250 + // Optional properties. The FAQ form's own Page Title / Page URL fields
1251 + // win over the post's title and permalink — they were collected by the
1252 + // form and then never read, so typing in them changed nothing.
1253 + $schema['name'] = !empty($data['site_data']['faq_page_name'])
1254 + ? $data['site_data']['faq_page_name']
1255 + : ($data['title'] ?? 'Frequently Asked Questions');
941 1256 if (!empty($data['excerpt'])) {
942 1257 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
943 1258 }
944 1259
945 1260 // URL for the FAQ page
946 - if (!empty($data['url'])) {
1261 + if (!empty($data['site_data']['faq_page_url'])) {
1262 + $schema['url'] = $data['site_data']['faq_page_url'];
1263 + } elseif (!empty($data['url'])) {
947 1264 $schema['url'] = $data['url'];
948 1265 }
949 1266
950 1267 // About - recommended property
@@ -989,11 +1306,28 @@
989 1306 private function populate_local_business_schema(array $schema, array $data, string $context): array {
990 1307 // Get business data from Site Identity Business Info (single source of truth)
991 1308 $business_data = $this->get_business_data_from_site_identity();
992 1309
993 - // Required properties - use business name from Business Info
994 - $schema['name'] = $business_data['business_name'] ?? $data['site_data']['organization_name'] ?? $data['title'] ?? get_bloginfo('name');
1310 + // The business type chosen in Local SEO is what this node IS. It was
1311 + // read into $business_data and then never used, so every site published
1312 + // "@type": "LocalBusiness" whatever it had picked, and the ~150-type
1313 + // selector (#623) changed nothing but a meta tag. create_base_schema()
1314 + // has already put @type in place, so reassigning keeps key order and a
1315 + // site on the default publishes exactly what it did before.
1316 + $schema['@type'] = \ThinkRank\Config\Local_Business_Types_Config::schema_type(
1317 + $business_data['business_type'] ?? ''
1318 + );
995 1319
1320 + // Required properties - use business name from Business Info.
1321 + // `name` is required for LocalBusiness, so an empty saved value must fall
1322 + // through to the next source rather than emit "".
1323 + $schema['name'] = $this->first_non_empty(
1324 + $business_data['business_name'] ?? null,
1325 + $data['site_data']['organization_name'] ?? null,
1326 + $data['title'] ?? null,
1327 + get_bloginfo('name')
1328 + );
1329 +
996 1330 // Address is required for LocalBusiness - use Business Info data
997 1331 if (!empty($business_data['business_address'])) {
998 1332 $schema['address'] = [
999 1333 '@type' => 'PostalAddress',
@@ -1100,9 +1434,12 @@
1100 1434 'organization_social_facebook',
1101 1435 'organization_social_twitter',
1102 1436 'organization_social_linkedin',
1103 1437 'organization_social_instagram',
1104 - 'organization_social_youtube'
1438 + 'organization_social_youtube',
1439 + 'organization_social_pinterest',
1440 + 'organization_social_whatsapp',
1441 + 'organization_social_telegram'
1105 1442 ];
1106 1443
1107 1444 foreach ($social_fields as $field) {
1108 1445 if (!empty($data['site_data'][$field])) {
@@ -1169,9 +1506,13 @@
1169 1506 * @return array Populated schema
1170 1507 */
1171 1508 private function populate_person_schema(array $schema, array $data, string $context): array {
1172 1509 // Required properties - prioritize user-configured fields
1173 - $schema['name'] = $data['site_data']['person_name'] ?? $data['author']['name'] ?? $data['title'] ?? '';
1510 + $schema['name'] = $this->first_non_empty(
1511 + $data['site_data']['person_name'] ?? '',
1512 + $data['author']['name'] ?? '',
1513 + $data['title'] ?? ''
1514 + );
1174 1515
1175 1516 // Image from user configuration or fallback
1176 1517 if (!empty($data['site_data']['person_image'])) {
1177 1518 $schema['image'] = $this->format_image_schema($data['site_data']['person_image']);
@@ -1246,10 +1587,26 @@
1246 1587 if (!empty($global_social_profiles)) {
1247 1588 $social_profiles = array_merge($social_profiles, $global_social_profiles);
1248 1589 }
1249 1590
1250 - // Remove duplicates and empty values
1251 - $social_profiles = array_unique(array_filter($social_profiles));
1591 + // Remove duplicates, empties and anything that is not a URL. schema.org
1592 + // types sameAs as a URL, and the person social fields are free text, so
1593 + // without this a typed-in note shipped as a sameAs member and made the
1594 + // whole Person invalid (#480). get_social_profiles() above already
1595 + // filters its own values the same way.
1596 + $social_profiles = array_values(array_unique(array_filter(
1597 + $social_profiles,
1598 + static function ($url) {
1599 + return is_string($url)
1600 + && '' !== trim($url)
1601 + && filter_var($url, FILTER_VALIDATE_URL)
1602 + && in_array(
1603 + strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)),
1604 + ['http', 'https'],
1605 + true
1606 + );
1607 + }
1608 + )));
1252 1609
1253 1610 if (!empty($social_profiles)) {
1254 1611 $schema['sameAs'] = $social_profiles;
1255 1612 }
@@ -1376,9 +1733,12 @@
1376 1733 'sunday' => 'Su'
1377 1734 ];
1378 1735
1379 1736 foreach ($business_hours as $day => $hours) {
1380 - $day_code = $day_mapping[strtolower($day)] ?? $day;
1737 + // $day may be an int key when business_hours is a numerically-indexed
1738 + // list (e.g. from an import/API); cast before strtolower() so it does
1739 + // not throw a TypeError under strict_types.
1740 + $day_code = $day_mapping[strtolower((string) $day)] ?? $day;
1381 1741 if (!empty($hours['open']) && !empty($hours['close'])) {
1382 1742 $opening_hours[] = "{$day_code} {$hours['open']}-{$hours['close']}";
1383 1743 }
1384 1744 }
@@ -1437,9 +1797,12 @@
1437 1797 } else {
1438 1798 // Fallback to site data
1439 1799 $schema['creator'] = [
1440 1800 '@type' => 'Organization',
1441 - 'name' => $data['site_data']['organization_name'] ?? get_bloginfo('name'),
1801 + 'name' => $this->first_non_empty(
1802 + $data['site_data']['organization_name'] ?? null,
1803 + get_bloginfo('name')
1804 + ),
1442 1805 'url' => home_url()
1443 1806 ];
1444 1807 }
1445 1808