| @@ -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 | |