| @@ -666,8 +666,53 @@ | ||
| 666 | 666 | return $this->generate_context_title(); |
| 667 | 667 | } |
| 668 | 668 | |
| 669 | 669 | /** |
| 670 | + * The title Open Graph and Twitter fall back to when no override is set. | |
| 671 | + * | |
| 672 | + * The effective SEO title everywhere except author archives, whose | |
| 673 | + * <title> is printed by Author_Archives_Manager (a pre_get_document_title | |
| 674 | + * filter at priority 15 that replaces this class's). Passing | |
| 675 | + * get_effective_seo_title() there gave og:title and twitter:title the Site | |
| 676 | + * Identity author title, so the share card and the tab disagreed (#977). | |
| 677 | + * | |
| 678 | + * @since 2.15.0 | |
| 679 | + * @return string|null Fallback title, or null if none applies. | |
| 680 | + */ | |
| 681 | + private function get_social_fallback_title(): ?string { | |
| 682 | + if (is_author()) { | |
| 683 | + $author_title = \ThinkRank\SEO\Author_Archives_Manager::resolve_title((int) get_queried_object_id()); | |
| 684 | + if ($author_title !== '') { | |
| 685 | + return $author_title; | |
| 686 | + } | |
| 687 | + } | |
| 688 | + | |
| 689 | + return $this->get_effective_seo_title(); | |
| 690 | + } | |
| 691 | + | |
| 692 | + /** | |
| 693 | + * The description Open Graph and Twitter fall back to when no override is set. | |
| 694 | + * | |
| 695 | + * Same split as get_social_fallback_title(): on an author archive the meta | |
| 696 | + * description comes from Author_Archives_Manager, and get_meta_description() | |
| 697 | + * knows nothing about it, so og:description and twitter:description were | |
| 698 | + * missing there even while the description tag was printed (#977). | |
| 699 | + * | |
| 700 | + * @since 2.15.0 | |
| 701 | + * @return string|null Fallback description, or null if none applies. | |
| 702 | + */ | |
| 703 | + private function get_social_fallback_description(): ?string { | |
| 704 | + if (is_author()) { | |
| 705 | + $author_description = \ThinkRank\SEO\Author_Archives_Manager::resolve_description((int) get_queried_object_id()); | |
| 706 | + if ($author_description !== '') { | |
| 707 | + return $author_description; | |
| 708 | + } | |
| 709 | + } | |
| 710 | + | |
| 711 | + return $this->get_meta_description(); | |
| 712 | + } | |
| 713 | + | |
| 714 | + /** | |
| 670 | 715 | * Override WordPress document title (HIGH PRIORITY) |
| 671 | 716 | * Priority: Post-specific metadata > Global SEO templates > Site Identity templates |
| 672 | 717 | * |
| 673 | 718 | * @param string $title Original title |
| @@ -1580,10 +1625,10 @@ | ||
| 1580 | 1625 | // (platform tags don't depend on them, so output is unchanged). |
| 1581 | 1626 | $social_data = $this->social_manager->get_output_data( |
| 1582 | 1627 | $social_context, |
| 1583 | 1628 | $this->current_post_id, |
| 1584 | - $this->get_effective_seo_title(), | |
| 1585 | - $this->get_meta_description() | |
| 1629 | + $this->get_social_fallback_title(), | |
| 1630 | + $this->get_social_fallback_description() | |
| 1586 | 1631 | ); |
| 1587 | 1632 | |
| 1588 | 1633 | if ($social_data['enabled'] && !empty($social_data['platform_tags'])) { |
| 1589 | 1634 | $this->output_social_platform_tags($social_data['platform_tags']); |
| @@ -1651,10 +1696,10 @@ | ||
| 1651 | 1696 | // value the preview shows. |
| 1652 | 1697 | $social_data = $this->social_manager->get_output_data( |
| 1653 | 1698 | $social_context, |
| 1654 | 1699 | $this->current_post_id, |
| 1655 | - $this->get_effective_seo_title(), | |
| 1656 | - $this->get_meta_description() | |
| 1700 | + $this->get_social_fallback_title(), | |
| 1701 | + $this->get_social_fallback_description() | |
| 1657 | 1702 | ); |
| 1658 | 1703 | |
| 1659 | 1704 | // The Social Meta Manager ran, so it owns Open Graph output. If OG is |
| 1660 | 1705 | // toggled off, emit nothing — do NOT fall through to the basic |
| @@ -1902,10 +1947,10 @@ | ||
| 1902 | 1947 | // description and the metabox preview. |
| 1903 | 1948 | $social_data = $this->social_manager->get_output_data( |
| 1904 | 1949 | $social_context, |
| 1905 | 1950 | $this->current_post_id, |
| 1906 | - $this->get_effective_seo_title(), | |
| 1907 | - $this->get_meta_description() | |
| 1951 | + $this->get_social_fallback_title(), | |
| 1952 | + $this->get_social_fallback_description() | |
| 1908 | 1953 | ); |
| 1909 | 1954 | |
| 1910 | 1955 | |
| 1911 | 1956 | // The Social Meta Manager ran, so it owns Twitter output. If Twitter |
| @@ -2501,9 +2546,9 @@ | ||
| 2501 | 2546 | $separator_pattern = preg_quote($separator, '/'); |
| 2502 | 2547 | $title = preg_replace('/\s*' . $separator_pattern . '\s*' . $separator_pattern . '\s*/', ' ' . $separator . ' ', $title); |
| 2503 | 2548 | |
| 2504 | 2549 | // Remove leading/trailing separators |
| 2505 | - $title = trim($title, " \t\n\r\0\x0B" . $separator); | |
| 2550 | + $title = \ThinkRank\Core\Seo_Text::trim_separator($title, $separator); | |
| 2506 | 2551 | |
| 2507 | 2552 | return $title; |
| 2508 | 2553 | } |
| 2509 | 2554 | |
| @@ -2906,9 +2951,9 @@ | ||
| 2906 | 2951 | * |
| 2907 | 2952 | * @return void |
| 2908 | 2953 | */ |
| 2909 | 2954 | public function output_site_schema_markup(): void { |
| 2910 | - $has_schema_manager_output = false; | |
| 2955 | + $has_site_entity_output = false; | |
| 2911 | 2956 | $has_website_schema = false; |
| 2912 | 2957 | |
| 2913 | 2958 | // The master switch on Essential SEO -> Schema Manager. Until #461 this |
| 2914 | 2959 | // was never read here, so turning schema off left every deployed entity |
| @@ -2928,9 +2973,9 @@ | ||
| 2928 | 2973 | if (!empty($site_wide_schemas)) { |
| 2929 | 2974 | foreach ($site_wide_schemas as $schema_type => $schema_info) { |
| 2930 | 2975 | Schema_Graph::instance()->add_supporting($schema_info['data'], (string) $schema_type); |
| 2931 | 2976 | } |
| 2932 | - $has_schema_manager_output = true; | |
| 2977 | + $has_site_entity_output = self::schemas_include_site_entity($site_wide_schemas); | |
| 2933 | 2978 | $has_website_schema = isset($site_wide_schemas['WebSite']); |
| 2934 | 2979 | } |
| 2935 | 2980 | } |
| 2936 | 2981 | |
| @@ -2956,10 +3001,9 @@ | ||
| 2956 | 3001 | |
| 2957 | 3002 | // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages |
| 2958 | 3003 | if ($this->schema_manager && (is_single() || is_page())) { |
| 2959 | 3004 | $context_id = get_the_ID(); |
| 2960 | - $context_type = get_post_type( $context_id ); | |
| 2961 | - $context_type = in_array( $context_type, [ 'site', 'post', 'page', 'product' ] , true) ? $context_type : 'post'; | |
| 3005 | + $context_type = \ThinkRank\SEO\Schema_Management_System::context_type_for_post_type( (string) get_post_type( $context_id ) ); | |
| 2962 | 3006 | |
| 2963 | 3007 | $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id); |
| 2964 | 3008 | |
| 2965 | 3009 | if (!empty($page_specific_schemas)) { |
| @@ -2995,9 +3039,12 @@ | ||
| 2995 | 3039 | } |
| 2996 | 3040 | |
| 2997 | 3041 | Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager'); |
| 2998 | 3042 | } |
| 2999 | - $has_schema_manager_output = true; | |
| 3043 | + | |
| 3044 | + if (!$has_site_entity_output) { | |
| 3045 | + $has_site_entity_output = self::schemas_include_site_entity($page_specific_schemas); | |
| 3046 | + } | |
| 3000 | 3047 | } |
| 3001 | 3048 | } |
| 3002 | 3049 | |
| 3003 | 3050 | // Absorb FAQ content from the post body (FAQ block / Elementor widget) |
| @@ -3009,10 +3056,17 @@ | ||
| 3009 | 3056 | Schema_Graph::instance()->collect_post_faq($queried_post); |
| 3010 | 3057 | } |
| 3011 | 3058 | } |
| 3012 | 3059 | |
| 3013 | - // Skip Site Identity fallback if any Schema Manager schemas were output | |
| 3014 | - if ($has_schema_manager_output) { | |
| 3060 | + // The deployed snapshot is exact, so the live builder never runs over | |
| 3061 | + // it. But the Site Identity fallback below emits one entity, the | |
| 3062 | + // Organization, and it used to be skipped whenever ANY row was | |
| 3063 | + // output: a site whose deployed set was a WebSite, or a post with | |
| 3064 | + // only a deployed Article, published no Organization at all and its | |
| 3065 | + // publisher could not resolve to #organization (#1006). Skip it only | |
| 3066 | + // when the Schema Manager already supplied the entity it stands in | |
| 3067 | + // for, which also keeps a deployed #organization from being doubled. | |
| 3068 | + if ($has_site_entity_output) { | |
| 3015 | 3069 | return; |
| 3016 | 3070 | } |
| 3017 | 3071 | |
| 3018 | 3072 | // PRIORITY 2: Fall back to Site Identity schemas (like basic Twitter Cards) |
| @@ -3034,8 +3088,67 @@ | ||
| 3034 | 3088 | } |
| 3035 | 3089 | } |
| 3036 | 3090 | |
| 3037 | 3091 | /** |
| 3092 | + * Organization types the Schema Manager can publish the site entity as. | |
| 3093 | + * | |
| 3094 | + * The values the organization_type setting offers. LocalBusiness and its | |
| 3095 | + * subtypes are recognised through Local_Business_Types_Config instead. | |
| 3096 | + * | |
| 3097 | + * @since 2.15.0 | |
| 3098 | + * @var string[] | |
| 3099 | + */ | |
| 3100 | + private const SITE_ENTITY_ORGANIZATION_TYPES = [ | |
| 3101 | + 'Organization', | |
| 3102 | + 'Corporation', | |
| 3103 | + 'EducationalOrganization', | |
| 3104 | + 'GovernmentOrganization', | |
| 3105 | + 'NGO', | |
| 3106 | + 'PerformingGroup', | |
| 3107 | + 'SportsOrganization', | |
| 3108 | + ]; | |
| 3109 | + | |
| 3110 | + /** | |
| 3111 | + * Whether deployed schemas already describe the site's own entity. | |
| 3112 | + * | |
| 3113 | + * The entity is what the Site Identity Organization fallback stands in | |
| 3114 | + * for: an Organization (or one of its configurable subtypes), a Person, | |
| 3115 | + * or a LocalBusiness of any subtype. Both the row's schema type and the | |
| 3116 | + * node's resolved @type are checked, since a LocalBusiness row is | |
| 3117 | + * published as "Dentist" and an Organization row can carry a subtype. | |
| 3118 | + * | |
| 3119 | + * @since 2.15.0 | |
| 3120 | + * | |
| 3121 | + * @param array $schemas Deployed schemas keyed by schema type, each with a 'data' node. | |
| 3122 | + * @return bool | |
| 3123 | + */ | |
| 3124 | + private static function schemas_include_site_entity(array $schemas): bool { | |
| 3125 | + foreach ($schemas as $schema_type => $schema_info) { | |
| 3126 | + $types = [(string) $schema_type]; | |
| 3127 | + | |
| 3128 | + if (is_array($schema_info) && is_array($schema_info['data'] ?? null) && isset($schema_info['data']['@type'])) { | |
| 3129 | + $types = array_merge($types, (array) $schema_info['data']['@type']); | |
| 3130 | + } | |
| 3131 | + | |
| 3132 | + foreach ($types as $type) { | |
| 3133 | + if (!is_string($type)) { | |
| 3134 | + continue; | |
| 3135 | + } | |
| 3136 | + | |
| 3137 | + if ( | |
| 3138 | + 'Person' === $type | |
| 3139 | + || in_array($type, self::SITE_ENTITY_ORGANIZATION_TYPES, true) | |
| 3140 | + || \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type) | |
| 3141 | + ) { | |
| 3142 | + return true; | |
| 3143 | + } | |
| 3144 | + } | |
| 3145 | + } | |
| 3146 | + | |
| 3147 | + return false; | |
| 3148 | + } | |
| 3149 | + | |
| 3150 | + /** | |
| 3038 | 3151 | * Generate the default WebSite schema for the homepage. |
| 3039 | 3152 | * |
| 3040 | 3153 | * Includes a SearchAction potentialAction so search engines can surface a |
| 3041 | 3154 | * sitelinks searchbox, mirroring what Rank Math/Yoast output by default. |
| @@ -3048,9 +3161,12 @@ | ||
| 3048 | 3161 | $schema = [ |
| 3049 | 3162 | '@context' => 'https://schema.org', |
| 3050 | 3163 | '@type' => 'WebSite', |
| 3051 | 3164 | '@id' => home_url('/#website'), |
| 3052 | - 'name' => !empty($settings['site_name']) ? $settings['site_name'] : get_bloginfo('name'), | |
| 3165 | + // The site name is stored esc_html()'d too, like the tagline below. | |
| 3166 | + 'name' => \ThinkRank\Core\Seo_Text::normalize_schema_name( | |
| 3167 | + (string) (!empty($settings['site_name']) ? $settings['site_name'] : get_bloginfo('name')) | |
| 3168 | + ), | |
| 3053 | 3169 | 'url' => home_url('/'), |
| 3054 | 3170 | ]; |
| 3055 | 3171 | |
| 3056 | 3172 | $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description'); |
| @@ -3073,29 +3189,18 @@ | ||
| 3073 | 3189 | |
| 3074 | 3190 | // The sitelinks searchbox switch was honoured only for a deployed |
| 3075 | 3191 | // WebSite row; this live fallback added potentialAction unconditionally, |
| 3076 | 3192 | // so website_enable_search = 0 still shipped the SearchAction (#688). |
| 3077 | - // Absent means not configured, which stays enabled. | |
| 3078 | - $search_enabled = true; | |
| 3079 | - if ($this->schema_manager) { | |
| 3080 | - $schema_settings = $this->schema_manager->get_settings('site', null); | |
| 3193 | + // Absent means not configured, which stays enabled. The switch and the | |
| 3194 | + // custom search URL go through the same helper the deployed WebSite | |
| 3195 | + // uses, so the two paths cannot drift apart again (#1009). | |
| 3196 | + $schema_settings = $this->schema_manager ? $this->schema_manager->get_settings('site', null) : []; | |
| 3081 | 3197 | |
| 3082 | - if (array_key_exists('website_enable_search', $schema_settings)) { | |
| 3083 | - $search_enabled = !empty($schema_settings['website_enable_search']); | |
| 3084 | - } | |
| 3198 | + $search_action = \ThinkRank\SEO\Schema_Builder::website_search_action($schema_settings); | |
| 3199 | + if (null !== $search_action) { | |
| 3200 | + $schema['potentialAction'] = $search_action; | |
| 3085 | 3201 | } |
| 3086 | 3202 | |
| 3087 | - if ($search_enabled) { | |
| 3088 | - $schema['potentialAction'] = [ | |
| 3089 | - '@type' => 'SearchAction', | |
| 3090 | - 'target' => [ | |
| 3091 | - '@type' => 'EntryPoint', | |
| 3092 | - 'urlTemplate' => home_url('/?s={search_term_string}'), | |
| 3093 | - ], | |
| 3094 | - 'query-input' => 'required name=search_term_string', | |
| 3095 | - ]; | |
| 3096 | - } | |
| 3097 | - | |
| 3098 | 3203 | return $schema; |
| 3099 | 3204 | } |
| 3100 | 3205 | |
| 3101 | 3206 | /** |
| @@ -3134,8 +3239,13 @@ | ||
| 3134 | 3239 | $settings['site_description'] ?? null, |
| 3135 | 3240 | get_bloginfo('description') |
| 3136 | 3241 | ); |
| 3137 | 3242 | |
| 3243 | + // Stored esc_html()'d by sanitize_option(), so "Fish & Chips" was | |
| 3244 | + // published as `Fish & Chips` (#976). Same for the description. | |
| 3245 | + $org_name = \ThinkRank\Core\Seo_Text::normalize_schema_name((string) $org_name); | |
| 3246 | + $org_description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $org_description); | |
| 3247 | + | |
| 3138 | 3248 | if (empty($org_name)) { |
| 3139 | 3249 | return null; |
| 3140 | 3250 | } |
| 3141 | 3251 | |
| @@ -3155,10 +3265,15 @@ | ||
| 3155 | 3265 | $schema['description'] = $org_description; |
| 3156 | 3266 | } |
| 3157 | 3267 | |
| 3158 | 3268 | // Add logo if available with proper ImageObject structure |
| 3159 | - // Priority: Schema Manager logo > Site Identity logo | |
| 3160 | - $logo_url = $schema_settings['organization_logo'] ?? $settings['logo_url'] ?? ''; | |
| 3269 | + // Priority: Schema Manager logo > Site Identity logo. first_non_empty() | |
| 3270 | + // rather than ??: organization_logo is always present and defaults to | |
| 3271 | + // '', which stopped a ?? chain before the Site Identity logo (#1010). | |
| 3272 | + $logo_url = $this->first_non_empty( | |
| 3273 | + $schema_settings['organization_logo'] ?? null, | |
| 3274 | + $settings['logo_url'] ?? null | |
| 3275 | + ); | |
| 3161 | 3276 | |
| 3162 | 3277 | if (!empty($logo_url)) { |
| 3163 | 3278 | $schema['logo'] = [ |
| 3164 | 3279 | '@type' => 'ImageObject', |
| @@ -4363,12 +4478,16 @@ | ||
| 4363 | 4478 | |
| 4364 | 4479 | $schema_items = []; |
| 4365 | 4480 | |
| 4366 | 4481 | foreach ($items as $item) { |
| 4482 | + // The trail's labels are HTML (get_the_title() is texturized, term | |
| 4483 | + // names are stored esc_html()'d), which the visible breadcrumb | |
| 4484 | + // prints as such. JSON-LD is plain text, so the schema copy is | |
| 4485 | + // decoded here and the visible trail is left alone (#976). | |
| 4367 | 4486 | $schema_items[] = [ |
| 4368 | 4487 | '@type' => 'ListItem', |
| 4369 | 4488 | 'position' => $item['position'], |
| 4370 | - 'name' => $item['title'], | |
| 4489 | + 'name' => \ThinkRank\Core\Seo_Text::normalize_schema_name((string) $item['title']), | |
| 4371 | 4490 | 'item' => $item['url'] |
| 4372 | 4491 | ]; |
| 4373 | 4492 | } |
| 4374 | 4493 | |