| @@ -377,8 +377,16 @@ | ||
| 377 | 377 | } |
| 378 | 378 | |
| 379 | 379 | // Initialize Global SEO Schema Output and store reference |
| 380 | 380 | $this->global_seo_schema = new Global_SEO_Schema_Output(); |
| 381 | + // Let schema reuse the description this class already resolves, so the | |
| 382 | + // JSON-LD and the meta/og/twitter tags cannot disagree about what the | |
| 383 | + // page is (#766). Passed as a callback rather than a value: schema is | |
| 384 | + // built during wp_head, by which point the request context this | |
| 385 | + // resolution depends on is set, and it must not be captured earlier. | |
| 386 | + $this->global_seo_schema->set_description_resolver( | |
| 387 | + fn (): string => (string) $this->get_meta_description() | |
| 388 | + ); | |
| 381 | 389 | $this->global_seo_schema->init(); |
| 382 | 390 | } |
| 383 | 391 | |
| 384 | 392 | /** |
| @@ -658,8 +666,53 @@ | ||
| 658 | 666 | return $this->generate_context_title(); |
| 659 | 667 | } |
| 660 | 668 | |
| 661 | 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 | + /** | |
| 662 | 715 | * Override WordPress document title (HIGH PRIORITY) |
| 663 | 716 | * Priority: Post-specific metadata > Global SEO templates > Site Identity templates |
| 664 | 717 | * |
| 665 | 718 | * @param string $title Original title |
| @@ -1572,10 +1625,10 @@ | ||
| 1572 | 1625 | // (platform tags don't depend on them, so output is unchanged). |
| 1573 | 1626 | $social_data = $this->social_manager->get_output_data( |
| 1574 | 1627 | $social_context, |
| 1575 | 1628 | $this->current_post_id, |
| 1576 | - $this->get_effective_seo_title(), | |
| 1577 | - $this->get_meta_description() | |
| 1629 | + $this->get_social_fallback_title(), | |
| 1630 | + $this->get_social_fallback_description() | |
| 1578 | 1631 | ); |
| 1579 | 1632 | |
| 1580 | 1633 | if ($social_data['enabled'] && !empty($social_data['platform_tags'])) { |
| 1581 | 1634 | $this->output_social_platform_tags($social_data['platform_tags']); |
| @@ -1643,10 +1696,10 @@ | ||
| 1643 | 1696 | // value the preview shows. |
| 1644 | 1697 | $social_data = $this->social_manager->get_output_data( |
| 1645 | 1698 | $social_context, |
| 1646 | 1699 | $this->current_post_id, |
| 1647 | - $this->get_effective_seo_title(), | |
| 1648 | - $this->get_meta_description() | |
| 1700 | + $this->get_social_fallback_title(), | |
| 1701 | + $this->get_social_fallback_description() | |
| 1649 | 1702 | ); |
| 1650 | 1703 | |
| 1651 | 1704 | // The Social Meta Manager ran, so it owns Open Graph output. If OG is |
| 1652 | 1705 | // toggled off, emit nothing — do NOT fall through to the basic |
| @@ -1791,12 +1844,16 @@ | ||
| 1791 | 1844 | // Get image dimensions and alt text |
| 1792 | 1845 | $image_id = get_post_thumbnail_id($this->current_post_id); |
| 1793 | 1846 | $image_meta = wp_get_attachment_metadata($image_id); |
| 1794 | 1847 | if ($image_meta) { |
| 1848 | + // The `large` file being published, not the original it | |
| 1849 | + // was generated from: the metadata's own width and height | |
| 1850 | + // describe an image this tag does not point at (#847). | |
| 1851 | + $image_file = \ThinkRank\SEO\Attachment_Lookup::describe((int) $image_id, (string) $image_url); | |
| 1795 | 1852 | // SVGs (and other vector uploads) report 0x0 — emitting |
| 1796 | 1853 | // those as og:image dimensions is invalid, so skip them. |
| 1797 | - $og_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0; | |
| 1798 | - $og_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0; | |
| 1854 | + $og_width = $image_file['width']; | |
| 1855 | + $og_height = $image_file['height']; | |
| 1799 | 1856 | if ($og_width > 0 && $og_height > 0) { |
| 1800 | 1857 | echo "<meta property=\"og:image:width\" content=\"" . esc_attr($og_width) . "\" />\n"; |
| 1801 | 1858 | echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n"; |
| 1802 | 1859 | } |
| @@ -1801,9 +1858,9 @@ | ||
| 1801 | 1858 | echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n"; |
| 1802 | 1859 | } |
| 1803 | 1860 | // Derive the real mime type instead of hardcoding image/jpeg, |
| 1804 | 1861 | // which mislabels PNG/WebP featured images. |
| 1805 | - $image_mime = get_post_mime_type($image_id); | |
| 1862 | + $image_mime = $image_file['type']; | |
| 1806 | 1863 | if ($image_mime) { |
| 1807 | 1864 | echo "<meta property=\"og:image:type\" content=\"" . esc_attr($image_mime) . "\" />\n"; |
| 1808 | 1865 | } |
| 1809 | 1866 | } |
| @@ -1890,10 +1947,10 @@ | ||
| 1890 | 1947 | // description and the metabox preview. |
| 1891 | 1948 | $social_data = $this->social_manager->get_output_data( |
| 1892 | 1949 | $social_context, |
| 1893 | 1950 | $this->current_post_id, |
| 1894 | - $this->get_effective_seo_title(), | |
| 1895 | - $this->get_meta_description() | |
| 1951 | + $this->get_social_fallback_title(), | |
| 1952 | + $this->get_social_fallback_description() | |
| 1896 | 1953 | ); |
| 1897 | 1954 | |
| 1898 | 1955 | |
| 1899 | 1956 | // The Social Meta Manager ran, so it owns Twitter output. If Twitter |
| @@ -2489,9 +2546,9 @@ | ||
| 2489 | 2546 | $separator_pattern = preg_quote($separator, '/'); |
| 2490 | 2547 | $title = preg_replace('/\s*' . $separator_pattern . '\s*' . $separator_pattern . '\s*/', ' ' . $separator . ' ', $title); |
| 2491 | 2548 | |
| 2492 | 2549 | // Remove leading/trailing separators |
| 2493 | - $title = trim($title, " \t\n\r\0\x0B" . $separator); | |
| 2550 | + $title = \ThinkRank\Core\Seo_Text::trim_separator($title, $separator); | |
| 2494 | 2551 | |
| 2495 | 2552 | return $title; |
| 2496 | 2553 | } |
| 2497 | 2554 | |
| @@ -2740,8 +2797,22 @@ | ||
| 2740 | 2797 | if ($archive_description) { |
| 2741 | 2798 | return $archive_description; |
| 2742 | 2799 | } |
| 2743 | 2800 | |
| 2801 | + // The blog-index homepage's own description (Site Identity | |
| 2802 | + // homepage_description, #897), in the same vocabulary as its title. A | |
| 2803 | + // static front page is a page, and its description is set on it. | |
| 2804 | + if (is_front_page() && is_home() && $this->site_identity_data && $this->site_identity_data['enabled']) { | |
| 2805 | + $identity = $this->site_identity_manager->get_settings('site'); | |
| 2806 | + $template = trim((string) ($identity['homepage_description'] ?? '')); | |
| 2807 | + if ($template !== '') { | |
| 2808 | + $homepage_description = trim($this->process_title_template($template, $this->get_title_placeholders())); | |
| 2809 | + if ($homepage_description !== '') { | |
| 2810 | + return $homepage_description; | |
| 2811 | + } | |
| 2812 | + } | |
| 2813 | + } | |
| 2814 | + | |
| 2744 | 2815 | // Third priority: Site Identity default meta description |
| 2745 | 2816 | if ($this->site_identity_data && $this->site_identity_data['enabled']) { |
| 2746 | 2817 | $settings = $this->site_identity_manager->get_settings('site'); |
| 2747 | 2818 | $default_description = $settings['default_meta_description'] ?? ''; |
| @@ -2880,9 +2951,9 @@ | ||
| 2880 | 2951 | * |
| 2881 | 2952 | * @return void |
| 2882 | 2953 | */ |
| 2883 | 2954 | public function output_site_schema_markup(): void { |
| 2884 | - $has_schema_manager_output = false; | |
| 2955 | + $has_site_entity_output = false; | |
| 2885 | 2956 | $has_website_schema = false; |
| 2886 | 2957 | |
| 2887 | 2958 | // The master switch on Essential SEO -> Schema Manager. Until #461 this |
| 2888 | 2959 | // was never read here, so turning schema off left every deployed entity |
| @@ -2902,9 +2973,9 @@ | ||
| 2902 | 2973 | if (!empty($site_wide_schemas)) { |
| 2903 | 2974 | foreach ($site_wide_schemas as $schema_type => $schema_info) { |
| 2904 | 2975 | Schema_Graph::instance()->add_supporting($schema_info['data'], (string) $schema_type); |
| 2905 | 2976 | } |
| 2906 | - $has_schema_manager_output = true; | |
| 2977 | + $has_site_entity_output = self::schemas_include_site_entity($site_wide_schemas); | |
| 2907 | 2978 | $has_website_schema = isset($site_wide_schemas['WebSite']); |
| 2908 | 2979 | } |
| 2909 | 2980 | } |
| 2910 | 2981 | |
| @@ -2930,10 +3001,9 @@ | ||
| 2930 | 3001 | |
| 2931 | 3002 | // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages |
| 2932 | 3003 | if ($this->schema_manager && (is_single() || is_page())) { |
| 2933 | 3004 | $context_id = get_the_ID(); |
| 2934 | - $context_type = get_post_type( $context_id ); | |
| 2935 | - $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 ) ); | |
| 2936 | 3006 | |
| 2937 | 3007 | $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id); |
| 2938 | 3008 | |
| 2939 | 3009 | if (!empty($page_specific_schemas)) { |
| @@ -2954,12 +3024,27 @@ | ||
| 2954 | 3024 | $context_type, |
| 2955 | 3025 | $context_id |
| 2956 | 3026 | ); |
| 2957 | 3027 | |
| 3028 | + // A deployed node is a snapshot from Deploy time and outranks | |
| 3029 | + // the automatic node, so page and article types would publish | |
| 3030 | + // a frozen excerpt instead of the description the head | |
| 3031 | + // resolves. Give them the live one, as the automatic node has. | |
| 3032 | + $context_post = get_post($context_id); | |
| 3033 | + | |
| 2958 | 3034 | foreach ($page_specific_schemas as $schema_type => $schema_info) { |
| 2959 | - Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager'); | |
| 3035 | + $node = $schema_info['data']; | |
| 3036 | + | |
| 3037 | + if ($this->global_seo_schema && $context_post instanceof \WP_Post) { | |
| 3038 | + $node = $this->global_seo_schema->refresh_deployed_description($node, (string) $schema_type, $context_post); | |
| 3039 | + } | |
| 3040 | + | |
| 3041 | + Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager'); | |
| 2960 | 3042 | } |
| 2961 | - $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 | + } | |
| 2962 | 3047 | } |
| 2963 | 3048 | } |
| 2964 | 3049 | |
| 2965 | 3050 | // Absorb FAQ content from the post body (FAQ block / Elementor widget) |
| @@ -2971,10 +3056,17 @@ | ||
| 2971 | 3056 | Schema_Graph::instance()->collect_post_faq($queried_post); |
| 2972 | 3057 | } |
| 2973 | 3058 | } |
| 2974 | 3059 | |
| 2975 | - // Skip Site Identity fallback if any Schema Manager schemas were output | |
| 2976 | - 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) { | |
| 2977 | 3069 | return; |
| 2978 | 3070 | } |
| 2979 | 3071 | |
| 2980 | 3072 | // PRIORITY 2: Fall back to Site Identity schemas (like basic Twitter Cards) |
| @@ -2996,8 +3088,67 @@ | ||
| 2996 | 3088 | } |
| 2997 | 3089 | } |
| 2998 | 3090 | |
| 2999 | 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 | + /** | |
| 3000 | 3151 | * Generate the default WebSite schema for the homepage. |
| 3001 | 3152 | * |
| 3002 | 3153 | * Includes a SearchAction potentialAction so search engines can surface a |
| 3003 | 3154 | * sitelinks searchbox, mirroring what Rank Math/Yoast output by default. |
| @@ -3010,13 +3161,20 @@ | ||
| 3010 | 3161 | $schema = [ |
| 3011 | 3162 | '@context' => 'https://schema.org', |
| 3012 | 3163 | '@type' => 'WebSite', |
| 3013 | 3164 | '@id' => home_url('/#website'), |
| 3014 | - '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 | + ), | |
| 3015 | 3169 | 'url' => home_url('/'), |
| 3016 | 3170 | ]; |
| 3017 | 3171 | |
| 3018 | 3172 | $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description'); |
| 3173 | + // The tagline is stored esc_html()'d by sanitize_option(), so a site | |
| 3174 | + // called "Fish & Chips" published `&` literally in its WebSite | |
| 3175 | + // node; nothing decodes JSON-LD downstream. | |
| 3176 | + $description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $description); | |
| 3019 | 3177 | if (!empty($description)) { |
| 3020 | 3178 | $schema['description'] = $description; |
| 3021 | 3179 | } |
| 3022 | 3180 | |
| @@ -3031,29 +3189,18 @@ | ||
| 3031 | 3189 | |
| 3032 | 3190 | // The sitelinks searchbox switch was honoured only for a deployed |
| 3033 | 3191 | // WebSite row; this live fallback added potentialAction unconditionally, |
| 3034 | 3192 | // so website_enable_search = 0 still shipped the SearchAction (#688). |
| 3035 | - // Absent means not configured, which stays enabled. | |
| 3036 | - $search_enabled = true; | |
| 3037 | - if ($this->schema_manager) { | |
| 3038 | - $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) : []; | |
| 3039 | 3197 | |
| 3040 | - if (array_key_exists('website_enable_search', $schema_settings)) { | |
| 3041 | - $search_enabled = !empty($schema_settings['website_enable_search']); | |
| 3042 | - } | |
| 3198 | + $search_action = \ThinkRank\SEO\Schema_Builder::website_search_action($schema_settings); | |
| 3199 | + if (null !== $search_action) { | |
| 3200 | + $schema['potentialAction'] = $search_action; | |
| 3043 | 3201 | } |
| 3044 | 3202 | |
| 3045 | - if ($search_enabled) { | |
| 3046 | - $schema['potentialAction'] = [ | |
| 3047 | - '@type' => 'SearchAction', | |
| 3048 | - 'target' => [ | |
| 3049 | - '@type' => 'EntryPoint', | |
| 3050 | - 'urlTemplate' => home_url('/?s={search_term_string}'), | |
| 3051 | - ], | |
| 3052 | - 'query-input' => 'required name=search_term_string', | |
| 3053 | - ]; | |
| 3054 | - } | |
| 3055 | - | |
| 3056 | 3203 | return $schema; |
| 3057 | 3204 | } |
| 3058 | 3205 | |
| 3059 | 3206 | /** |
| @@ -3092,8 +3239,13 @@ | ||
| 3092 | 3239 | $settings['site_description'] ?? null, |
| 3093 | 3240 | get_bloginfo('description') |
| 3094 | 3241 | ); |
| 3095 | 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 | + | |
| 3096 | 3248 | if (empty($org_name)) { |
| 3097 | 3249 | return null; |
| 3098 | 3250 | } |
| 3099 | 3251 | |
| @@ -3113,10 +3265,15 @@ | ||
| 3113 | 3265 | $schema['description'] = $org_description; |
| 3114 | 3266 | } |
| 3115 | 3267 | |
| 3116 | 3268 | // Add logo if available with proper ImageObject structure |
| 3117 | - // Priority: Schema Manager logo > Site Identity logo | |
| 3118 | - $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 | + ); | |
| 3119 | 3276 | |
| 3120 | 3277 | if (!empty($logo_url)) { |
| 3121 | 3278 | $schema['logo'] = [ |
| 3122 | 3279 | '@type' => 'ImageObject', |
| @@ -4321,12 +4478,16 @@ | ||
| 4321 | 4478 | |
| 4322 | 4479 | $schema_items = []; |
| 4323 | 4480 | |
| 4324 | 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). | |
| 4325 | 4486 | $schema_items[] = [ |
| 4326 | 4487 | '@type' => 'ListItem', |
| 4327 | 4488 | 'position' => $item['position'], |
| 4328 | - 'name' => $item['title'], | |
| 4489 | + 'name' => \ThinkRank\Core\Seo_Text::normalize_schema_name((string) $item['title']), | |
| 4329 | 4490 | 'item' => $item['url'] |
| 4330 | 4491 | ]; |
| 4331 | 4492 | } |
| 4332 | 4493 | |