PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/seo/class-schema-builder.php +398 -61 1.0.22.7.0 View file →
@@ -111,8 +111,14 @@
111 111 break;
112 112 case 'HowTo':
113 113 $schema = $this->populate_howto_schema($schema, $data, $context);
114 114 break;
115 + case 'Review':
116 + $schema = $this->populate_review_schema($schema, $data, $context);
117 + break;
118 + case 'VideoObject':
119 + $schema = $this->populate_video_object_schema($schema, $data, $context);
120 + break;
115 121 default:
116 122 $schema = $this->populate_generic_schema($schema, $data, $context);
117 123 break;
118 124 }
@@ -137,10 +143,14 @@
137 143
138 144 // Clean up empty values
139 145 $schema = $this->clean_schema_array($schema);
140 146
141 - // Generate JSON-LD with proper formatting
142 - $json_flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
147 + // Generate JSON-LD with proper formatting. Include the HEX flags so a
148 + // </script> in any field is emitted as <\/script> and can't break out
149 + // of the surrounding <script type="application/ld+json"> block, matching
150 + // the site-wide schema output path.
151 + $json_flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
152 + | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
143 153 if (defined('WP_DEBUG') && WP_DEBUG) {
144 154 $json_flags |= JSON_PRETTY_PRINT;
145 155 }
146 156
@@ -194,11 +204,11 @@
194 204 }
195 205
196 206 // Date published - prioritize user-configured date
197 207 if (!empty($data['site_data']['article_date_published'])) {
198 - $schema['datePublished'] = $data['site_data']['article_date_published'];
208 + $schema['datePublished'] = $this->to_iso8601($data['site_data']['article_date_published']);
199 209 } elseif (!empty($data['date'])) {
200 - $schema['datePublished'] = $data['date'];
210 + $schema['datePublished'] = $this->to_iso8601($data['date']);
201 211 } else {
202 212 $schema['datePublished'] = current_time('c');
203 213 }
204 214
@@ -219,11 +229,11 @@
219 229 }
220 230
221 231 // Date modified - prioritize user-configured date
222 232 if (!empty($data['site_data']['article_date_modified'])) {
223 - $schema['dateModified'] = $data['site_data']['article_date_modified'];
233 + $schema['dateModified'] = $this->to_iso8601($data['site_data']['article_date_modified']);
224 234 } elseif (!empty($data['modified'])) {
225 - $schema['dateModified'] = $data['modified'];
235 + $schema['dateModified'] = $this->to_iso8601($data['modified']);
226 236 } else {
227 237 $schema['dateModified'] = $schema['datePublished'];
228 238 }
229 239
@@ -243,12 +253,11 @@
243 253 // Use frontend-calculated word count if provided (from SEO Analysis method)
244 254 // Otherwise fallback to backend calculation
245 255 $schema['wordCount'] = isset($data['word_count']) ? (int) $data['word_count'] : str_word_count(wp_strip_all_tags($data['content']));
246 256
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']));
257 + // Use focus keywords if available, otherwise extract from content.
258 + $focus_keywords = $this->resolve_focus_keywords($data);
259 + if (!empty($focus_keywords)) {
251 260 $schema['keywords'] = array_merge($focus_keywords, $this->extract_keywords_from_content($data['content']));
252 261 // Remove duplicates and limit to 10
253 262 $schema['keywords'] = array_slice(array_unique($schema['keywords']), 0, 10);
254 263 } else {
@@ -259,8 +268,31 @@
259 268 return $schema;
260 269 }
261 270
262 271 /**
272 + * Resolve the focus keyword list from schema content data.
273 + *
274 + * Prefers the multi-keyword array (`focus_keywords`); falls back to the
275 + * legacy single/comma-separated `focus_keyword` string.
276 + *
277 + * @param array $data Schema content data.
278 + * @return string[] Trimmed, non-empty focus keywords.
279 + */
280 + private function resolve_focus_keywords(array $data): array {
281 + $keywords = [];
282 +
283 + if (!empty($data['focus_keywords']) && is_array($data['focus_keywords'])) {
284 + $keywords = $data['focus_keywords'];
285 + } elseif (!empty($data['focus_keyword'])) {
286 + $keywords = explode(',', (string) $data['focus_keyword']);
287 + }
288 +
289 + $keywords = array_map('trim', $keywords);
290 +
291 + return array_values(array_filter($keywords, 'strlen'));
292 + }
293 +
294 + /**
263 295 * Populate Product schema
264 296 * PRESERVED: Exact same method logic from original Schema_Generator
265 297 *
266 298 * @since 1.0.0
@@ -337,9 +369,12 @@
337 369 'bestRating' => '5'
338 370 ],
339 371 'author' => [
340 372 '@type' => 'Person',
341 - 'name' => $data['site_data']['organization_name'] ?? get_bloginfo('name')
373 + 'name' => $this->first_non_empty(
374 + $data['site_data']['organization_name'] ?? null,
375 + get_bloginfo('name')
376 + )
342 377 ]
343 378 ];
344 379 }
345 380
@@ -405,8 +440,25 @@
405 440 return $schema;
406 441 }
407 442
408 443 /**
444 + * First argument that is a non-empty string (after trimming).
445 + *
446 + * @since 1.17.0
447 + *
448 + * @param mixed ...$values Candidate values in priority order.
449 + * @return string First non-empty candidate, or '' when none qualify.
450 + */
451 + private function first_non_empty(...$values): string {
452 + foreach ($values as $value) {
453 + if (is_string($value) && trim($value) !== '') {
454 + return $value;
455 + }
456 + }
457 + return '';
458 + }
459 +
460 + /**
409 461 * Populate Organization schema
410 462 * PRESERVED: Exact same method logic from original Schema_Generator
411 463 *
412 464 * @since 1.0.0
@@ -419,11 +471,23 @@
419 471 private function populate_organization_schema(array $schema, array $data, string $context): array {
420 472 // Get business data from Site Identity Business Info (single source of truth)
421 473 $business_data = $this->get_business_data_from_site_identity();
422 474
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();
475 + // Required properties - prioritize Schema Manager organization settings.
476 + // first_non_empty() rather than ??: a saved-but-empty string is "set"
477 + // and would otherwise stop the fallback chain dead.
478 + $schema['name'] = $this->first_non_empty(
479 + $data['site_data']['organization_name'] ?? null,
480 + $business_data['business_name'] ?? null,
481 + $data['title'] ?? null,
482 + get_bloginfo('name')
483 + );
484 + $schema['url'] = $this->first_non_empty(
485 + $data['site_data']['organization_url'] ?? null,
486 + $business_data['business_website'] ?? null,
487 + $data['url'] ?? null,
488 + home_url()
489 + );
426 490
427 491 // Logo from user configuration or theme customizer
428 492 if (!empty($data['site_data']['organization_logo'])) {
429 493 $schema['logo'] = $this->format_image_schema($data['site_data']['organization_logo']);
@@ -442,31 +506,45 @@
442 506 }
443 507 }
444 508 }
445 509
446 - // Contact point from Business Info or contact configuration
447 - $contact_point = ['@type' => 'ContactPoint'];
448 - $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'] ?? [];
449 516
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 - }
517 + $contact_phone = $this->first_non_empty(
518 + $site_data['organization_contact_phone'] ?? null,
519 + $business_data['business_phone'] ?? null
520 + );
455 521
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 - }
522 + $contact_email = $this->first_non_empty(
523 + $site_data['organization_contact_email'] ?? null,
524 + $business_data['business_email'] ?? null
525 + );
461 526
462 - // Add contact type and hours if available
463 - if ($has_contact_info) {
464 - $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 + ];
465 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 +
466 544 // 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'];
545 + if (!empty($site_data['organization_contact_hours'])) {
546 + $contact_point['hoursAvailable'] = $site_data['organization_contact_hours'];
469 547 }
470 548
471 549 $schema['contactPoint'] = $contact_point;
472 550 }
@@ -509,8 +587,143 @@
509 587 return $schema;
510 588 }
511 589
512 590 /**
591 + * Populate Review schema (standalone review of an item).
592 + *
593 + * Emits a schema.org Review: the reviewed entity (itemReviewed), a Rating,
594 + * the reviewing author, and an optional review body. Falls back to the post
595 + * title for the reviewed item and the post author for the reviewer when the
596 + * user-configured fields are empty, so an imported review with sparse data
597 + * still produces valid markup.
598 + *
599 + * @since 1.13.0
600 + *
601 + * @param array $schema Base schema
602 + * @param array $data Content data
603 + * @param string $context Context type
604 + * @return array Populated schema
605 + */
606 + private function populate_review_schema(array $schema, array $data, string $context): array {
607 + $site_data = $data['site_data'] ?? [];
608 +
609 + // itemReviewed — the thing being reviewed; fall back to the content title.
610 + $item_name = $site_data['review_item_name'] ?? $data['title'] ?? '';
611 + if (!empty($item_name)) {
612 + $item_type = $site_data['review_item_type'] ?? 'Thing';
613 + $schema['itemReviewed'] = [
614 + '@type' => $item_type,
615 + 'name' => $item_name,
616 + ];
617 + }
618 +
619 + // reviewRating — only emitted when a rating value is present.
620 + $rating_value = $site_data['review_rating_value'] ?? '';
621 + if ($rating_value !== '' && $rating_value !== null) {
622 + $schema['reviewRating'] = [
623 + '@type' => 'Rating',
624 + 'ratingValue' => $rating_value,
625 + 'bestRating' => $site_data['review_best_rating'] ?? '5',
626 + 'worstRating' => $site_data['review_worst_rating'] ?? '1',
627 + ];
628 + }
629 +
630 + // author — user-configured reviewer, else the post author.
631 + $author = $site_data['review_author'] ?? ($data['author']['name'] ?? '');
632 + if (!empty($author)) {
633 + $schema['author'] = [
634 + '@type' => 'Person',
635 + 'name' => $author,
636 + ];
637 + }
638 +
639 + // reviewBody — optional free-text review.
640 + if (!empty($site_data['review_body'])) {
641 + $schema['reviewBody'] = $this->truncate_text($site_data['review_body'], 500);
642 + }
643 +
644 + // datePublished + url from content context.
645 + if (!empty($data['date'])) {
646 + $schema['datePublished'] = $this->to_iso8601($data['date']);
647 + }
648 + if (!empty($data['url'])) {
649 + $schema['url'] = $data['url'];
650 + }
651 +
652 + return $schema;
653 + }
654 +
655 + /**
656 + * Populate VideoObject schema.
657 + *
658 + * Google requires name, description, thumbnailUrl and uploadDate; contentUrl
659 + * and/or embedUrl are strongly recommended so the video is playable. Each
660 + * required field falls back to content context (title, excerpt, featured
661 + * image, publish date) when the user has not set a video-specific value.
662 + *
663 + * @since 1.0.0
664 + *
665 + * @param array $schema Base schema
666 + * @param array $data Content data
667 + * @param string $context Context type
668 + * @return array Populated schema
669 + */
670 + private function populate_video_object_schema(array $schema, array $data, string $context): array {
671 + $site_data = $data['site_data'] ?? [];
672 +
673 + // name — required; fall back to the content title.
674 + $schema['name'] = $this->truncate_text(
675 + $site_data['video_name'] ?? $data['title'] ?? '',
676 + 110
677 + );
678 +
679 + // description — required; fall back to excerpt then content.
680 + $description = $site_data['video_description'] ?? '';
681 + if ($description === '') {
682 + $description = $data['excerpt'] ?? $data['content'] ?? '';
683 + }
684 + if ($description !== '') {
685 + $schema['description'] = $this->truncate_text($description, 160);
686 + }
687 +
688 + // thumbnailUrl — required; fall back to the featured/content image.
689 + $thumbnail = $site_data['video_thumbnail'] ?? '';
690 + if ($thumbnail === '' && !empty($data['image'])) {
691 + $thumbnail = is_array($data['image']) ? ($data['image']['url'] ?? '') : $data['image'];
692 + }
693 + if ($thumbnail !== '') {
694 + $schema['thumbnailUrl'] = $thumbnail;
695 + }
696 +
697 + // uploadDate — required; fall back to the content publish date.
698 + $upload_date = $site_data['video_upload_date'] ?? '';
699 + if ($upload_date === '') {
700 + $upload_date = $data['date'] ?? current_time('c');
701 + }
702 + $schema['uploadDate'] = $upload_date;
703 +
704 + // contentUrl / embedUrl — recommended; at least one makes the video playable.
705 + if (!empty($site_data['video_content_url'])) {
706 + $schema['contentUrl'] = $site_data['video_content_url'];
707 + }
708 + if (!empty($site_data['video_embed_url'])) {
709 + $schema['embedUrl'] = $site_data['video_embed_url'];
710 + }
711 +
712 + // duration — optional ISO 8601 (e.g. PT1M33S).
713 + if (!empty($site_data['video_duration'])) {
714 + $schema['duration'] = $site_data['video_duration'];
715 + }
716 +
717 + // url from content context.
718 + if (!empty($data['url'])) {
719 + $schema['url'] = $data['url'];
720 + }
721 +
722 + return $schema;
723 + }
724 +
725 + /**
513 726 * Truncate text to specified length
514 727 * PRESERVED: Exact same method logic from original Schema_Generator
515 728 *
516 729 * @since 1.0.0
@@ -520,15 +733,55 @@
520 733 * @return string Truncated text
521 734 */
522 735 private function truncate_text(string $text, int $length): string {
523 736 $text = wp_strip_all_tags($text);
524 - 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) {
525 743 return $text;
526 744 }
527 - return substr($text, 0, $length - 3) . '...';
745 +
746 + return mb_substr($text, 0, max(0, $length - 3)) . '...';
528 747 }
529 748
530 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 + /**
531 784 * Format author schema
532 785 * PRESERVED: Exact same method logic from original Schema_Generator
533 786 *
534 787 * @since 1.0.0
@@ -578,11 +831,13 @@
578 831 // Try to get image dimensions if it's a WordPress attachment
579 832 $attachment_id = attachment_url_to_postid($image_url);
580 833 if ($attachment_id) {
581 834 $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];
835 + // SVGs report 0x0 — omit the dimensions rather than emitting
836 + // zeroes, which invalidate the ImageObject.
837 + if ($image_data && (int) $image_data[1] > 0 && (int) $image_data[2] > 0) {
838 + $image_schema['width'] = (int) $image_data[1];
839 + $image_schema['height'] = (int) $image_data[2];
585 840 }
586 841 }
587 842
588 843 return $image_schema;
@@ -628,10 +883,19 @@
628 883 *
629 884 * @return array Social media profile URLs
630 885 */
631 886 private function get_social_profiles(): array {
632 - // Get Schema Manager settings for organization social profiles
633 - $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 +
634 898 $settings = $schema_manager->get_settings('site', null);
635 899
636 900 $social_profiles = [];
637 901
@@ -640,9 +904,12 @@
640 904 'organization_social_facebook',
641 905 'organization_social_twitter',
642 906 'organization_social_linkedin',
643 907 'organization_social_instagram',
644 - 'organization_social_youtube'
908 + 'organization_social_youtube',
909 + 'organization_social_pinterest',
910 + 'organization_social_whatsapp',
911 + 'organization_social_telegram'
645 912 ];
646 913
647 914 foreach ($social_fields as $field) {
648 915 if (!empty($settings[$field]) && filter_var($settings[$field], FILTER_VALIDATE_URL)) {
@@ -672,9 +939,9 @@
672 939
673 940 $keywords = [];
674 941 foreach ($words as $word) {
675 942 $word = strtolower(trim($word));
676 - if (strlen($word) > 3 && !in_array($word, $common_words)) {
943 + if (strlen($word) > 3 && !in_array($word, $common_words, true)) {
677 944 $keywords[] = $word;
678 945 }
679 946 }
680 947
@@ -740,9 +1007,9 @@
740 1007 $schema[$key] = $this->clean_schema_array($value);
741 1008 if (empty($schema[$key])) {
742 1009 unset($schema[$key]);
743 1010 }
744 - } elseif (empty($value) && $value !== 0 && $value !== '0' && !in_array($key, $critical_fields)) {
1011 + } elseif (empty($value) && $value !== 0 && $value !== '0' && !in_array($key, $critical_fields, true)) {
745 1012 unset($schema[$key]);
746 1013 }
747 1014 }
748 1015
@@ -761,11 +1028,29 @@
761 1028 * @return array Populated schema
762 1029 */
763 1030 private function populate_website_schema(array $schema, array $data, string $context): array {
764 1031 // 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();
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 + );
767 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 +
768 1053 // Recommended properties - prioritize user-configured Website schema description
769 1054 if (!empty($data['site_data']['website_description'])) {
770 1055 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
771 1056 } elseif (!empty($data['content'])) {
@@ -795,10 +1080,16 @@
795 1080
796 1081 // Publisher - enhanced with logo from Site Identity
797 1082 $publisher = [
798 1083 '@type' => 'Organization',
799 - 'name' => $data['site_data']['organization_name'] ?? get_bloginfo('name'),
800 - 'url' => $data['site_data']['organization_url'] ?? home_url()
1084 + 'name' => $this->first_non_empty(
1085 + $data['site_data']['organization_name'] ?? null,
1086 + get_bloginfo('name')
1087 + ),
1088 + 'url' => $this->first_non_empty(
1089 + $data['site_data']['organization_url'] ?? null,
1090 + home_url()
1091 + )
801 1092 ];
802 1093
803 1094 // Add logo to publisher from Site Identity or user configuration
804 1095 if (!empty($data['site_data']['logo_url'])) {
@@ -824,9 +1115,12 @@
824 1115 $schema['publisher'] = $publisher;
825 1116
826 1117 // Search action for sitelinks search box (optional but recommended)
827 1118 if ($data['site_data']['website_enable_search'] ?? true) {
828 - $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 + );
829 1123 $schema['potentialAction'] = [
830 1124 '@type' => 'SearchAction',
831 1125 'target' => [
832 1126 '@type' => 'EntryPoint',
@@ -842,9 +1136,10 @@
842 1136 $schema['sameAs'] = $social_profiles;
843 1137 }
844 1138
845 1139 // Language
846 - $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');
847 1142
848 1143 return $schema;
849 1144 }
850 1145
@@ -871,13 +1166,13 @@
871 1166 $schema['description'] = $this->truncate_text($data['content'], 160);
872 1167 }
873 1168
874 1169 if (!empty($data['date'])) {
875 - $schema['datePublished'] = $data['date'];
1170 + $schema['datePublished'] = $this->to_iso8601($data['date']);
876 1171 }
877 1172
878 1173 if (!empty($data['modified'])) {
879 - $schema['dateModified'] = $data['modified'];
1174 + $schema['dateModified'] = $this->to_iso8601($data['modified']);
880 1175 }
881 1176
882 1177 $schema['isPartOf'] = [
883 1178 '@type' => 'WebSite',
@@ -935,16 +1230,22 @@
935 1230 }
936 1231
937 1232 $schema['mainEntity'] = $faq_data;
938 1233
939 - // Optional properties
940 - $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');
941 1240 if (!empty($data['excerpt'])) {
942 1241 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
943 1242 }
944 1243
945 1244 // URL for the FAQ page
946 - 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'])) {
947 1248 $schema['url'] = $data['url'];
948 1249 }
949 1250
950 1251 // About - recommended property
@@ -989,10 +1290,17 @@
989 1290 private function populate_local_business_schema(array $schema, array $data, string $context): array {
990 1291 // Get business data from Site Identity Business Info (single source of truth)
991 1292 $business_data = $this->get_business_data_from_site_identity();
992 1293
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');
1294 + // Required properties - use business name from Business Info.
1295 + // `name` is required for LocalBusiness, so an empty saved value must fall
1296 + // through to the next source rather than emit "".
1297 + $schema['name'] = $this->first_non_empty(
1298 + $business_data['business_name'] ?? null,
1299 + $data['site_data']['organization_name'] ?? null,
1300 + $data['title'] ?? null,
1301 + get_bloginfo('name')
1302 + );
995 1303
996 1304 // Address is required for LocalBusiness - use Business Info data
997 1305 if (!empty($business_data['business_address'])) {
998 1306 $schema['address'] = [
@@ -1100,9 +1408,12 @@
1100 1408 'organization_social_facebook',
1101 1409 'organization_social_twitter',
1102 1410 'organization_social_linkedin',
1103 1411 'organization_social_instagram',
1104 - 'organization_social_youtube'
1412 + 'organization_social_youtube',
1413 + 'organization_social_pinterest',
1414 + 'organization_social_whatsapp',
1415 + 'organization_social_telegram'
1105 1416 ];
1106 1417
1107 1418 foreach ($social_fields as $field) {
1108 1419 if (!empty($data['site_data'][$field])) {
@@ -1169,9 +1480,13 @@
1169 1480 * @return array Populated schema
1170 1481 */
1171 1482 private function populate_person_schema(array $schema, array $data, string $context): array {
1172 1483 // Required properties - prioritize user-configured fields
1173 - $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 + );
1174 1489
1175 1490 // Image from user configuration or fallback
1176 1491 if (!empty($data['site_data']['person_image'])) {
1177 1492 $schema['image'] = $this->format_image_schema($data['site_data']['person_image']);
@@ -1246,10 +1561,26 @@
1246 1561 if (!empty($global_social_profiles)) {
1247 1562 $social_profiles = array_merge($social_profiles, $global_social_profiles);
1248 1563 }
1249 1564
1250 - // Remove duplicates and empty values
1251 - $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 + )));
1252 1583
1253 1584 if (!empty($social_profiles)) {
1254 1585 $schema['sameAs'] = $social_profiles;
1255 1586 }
@@ -1376,9 +1707,12 @@
1376 1707 'sunday' => 'Su'
1377 1708 ];
1378 1709
1379 1710 foreach ($business_hours as $day => $hours) {
1380 - $day_code = $day_mapping[strtolower($day)] ?? $day;
1711 + // $day may be an int key when business_hours is a numerically-indexed
1712 + // list (e.g. from an import/API); cast before strtolower() so it does
1713 + // not throw a TypeError under strict_types.
1714 + $day_code = $day_mapping[strtolower((string) $day)] ?? $day;
1381 1715 if (!empty($hours['open']) && !empty($hours['close'])) {
1382 1716 $opening_hours[] = "{$day_code} {$hours['open']}-{$hours['close']}";
1383 1717 }
1384 1718 }
@@ -1437,9 +1771,12 @@
1437 1771 } else {
1438 1772 // Fallback to site data
1439 1773 $schema['creator'] = [
1440 1774 '@type' => 'Organization',
1441 - 'name' => $data['site_data']['organization_name'] ?? get_bloginfo('name'),
1775 + 'name' => $this->first_non_empty(
1776 + $data['site_data']['organization_name'] ?? null,
1777 + get_bloginfo('name')
1778 + ),
1442 1779 'url' => home_url()
1443 1780 ];
1444 1781 }
1445 1782