| @@ -74,9 +74,12 @@ | ||
| 74 | 74 | 'image_min_width' => 600, |
| 75 | 75 | 'image_min_height' => 900, |
| 76 | 76 | 'image_recommended_ratio' => 0.67, // 2:3 ratio |
| 77 | 77 | 'title_max_length' => 100, |
| 78 | - 'description_max_length' => 500 | |
| 78 | + // Pinterest has no tag of its own: it reads og:description, which | |
| 79 | + // the frontend caps at max_description_length. Previewing 500 | |
| 80 | + // promised up to 340 characters that are never emitted. | |
| 81 | + 'description_max_length' => 160 | |
| 79 | 82 | ], |
| 80 | 83 | 'whatsapp' => [ |
| 81 | 84 | 'og_required' => ['og:title', 'og:type', 'og:image', 'og:url'], |
| 82 | 85 | 'og_recommended' => ['og:description'], |
| @@ -130,10 +133,16 @@ | ||
| 130 | 133 | |
| 131 | 134 | $platform_spec = $this->supported_platforms[$platform]; |
| 132 | 135 | $og_tags = []; |
| 133 | 136 | |
| 134 | - // Determine OG type based on context | |
| 135 | - $og_type = $this->determine_og_type($context, $data); | |
| 137 | + // OG type: the type the user explicitly chose, otherwise derived from | |
| 138 | + // the context. This used to call determine_og_type() unconditionally, | |
| 139 | + // overwriting the value extract_social_content_data() had already read | |
| 140 | + // from the setting — so the Content Type dropdown, the abilities API | |
| 141 | + // and every imported og:type were silently discarded (#398). | |
| 142 | + $og_type = ($data['type'] ?? '') !== '' | |
| 143 | + ? $data['type'] | |
| 144 | + : $this->determine_og_type($context, $data); | |
| 136 | 145 | $og_tags['og:type'] = $og_type; |
| 137 | 146 | |
| 138 | 147 | // Required OG tags |
| 139 | 148 | // og:title must render the full resolved Open Graph title. The 60-char |
| @@ -139,10 +148,23 @@ | ||
| 139 | 148 | // og:title must render the full resolved Open Graph title. The 60-char |
| 140 | 149 | // cap in optimize_title_for_platform() is an SEO-title recommendation for |
| 141 | 150 | // search results and does not apply to the og:title social tag, so use the |
| 142 | 151 | // resolved title verbatim (falling back to the site name when empty). |
| 143 | - $og_tags['og:title'] = ($data['title'] ?? '') !== '' ? $data['title'] : get_bloginfo('name'); | |
| 144 | - $og_tags['og:url'] = $data['url'] ?? $this->get_current_url(); | |
| 152 | + // Stripped: a title carrying markup is attribute-escaped into this tag, | |
| 153 | + // so the reader sees a literal `<em>` rather than emphasis (#640). | |
| 154 | + $og_tags['og:title'] = \ThinkRank\Frontend\SEO_Manager::strip_title_tags( | |
| 155 | + ($data['title'] ?? '') !== '' ? (string) $data['title'] : (string) get_bloginfo('name') | |
| 156 | + ); | |
| 157 | + // `?:` rather than `??`: the key is always present, seeded as '', so the | |
| 158 | + // null-coalesce could never reach the fallback. og:url was emitted empty | |
| 159 | + // and then dropped by the !empty() guard in output_social_og_tags(), | |
| 160 | + // which is why archives carried no og:url at all (#388). | |
| 161 | + // Normalized for the site's scheme preference, so og:url and the | |
| 162 | + // canonical can never disagree about http vs https (#638); the same | |
| 163 | + // consistency #182 was about. | |
| 164 | + $og_tags['og:url'] = \ThinkRank\SEO\Url_Scheme::apply( | |
| 165 | + ($data['url'] ?? '') !== '' ? $data['url'] : $this->get_current_url() | |
| 166 | + ); | |
| 145 | 167 | |
| 146 | 168 | // Image handling with optimization |
| 147 | 169 | if (!empty($data['image'])) { |
| 148 | 170 | $optimized_image = $this->optimize_image_for_platform($data['image'], $platform); |
| @@ -209,17 +231,26 @@ | ||
| 209 | 231 | // to the resolved og:title/SEO title. Render it in full — the length cap |
| 210 | 232 | // in optimize_title_for_platform() is an SEO-title recommendation for |
| 211 | 233 | // search results, not a rule for the twitter:title social tag. |
| 212 | 234 | $twitter_title = ($data['twitter_title'] ?? '') !== '' ? $data['twitter_title'] : ($data['title'] ?? ''); |
| 213 | - $twitter_tags['twitter:title'] = $twitter_title !== '' ? $twitter_title : get_bloginfo('name'); | |
| 235 | + $twitter_tags['twitter:title'] = \ThinkRank\Frontend\SEO_Manager::strip_title_tags( | |
| 236 | + $twitter_title !== '' ? (string) $twitter_title : (string) get_bloginfo('name') | |
| 237 | + ); | |
| 214 | 238 | |
| 215 | - // Recommended tags | |
| 216 | - if (!empty($data['description'])) { | |
| 217 | - $twitter_tags['twitter:description'] = $this->optimize_description_for_platform($data['description'], 'twitter'); | |
| 239 | + // Recommended tags. Prefer a per-object Twitter-specific description, | |
| 240 | + // falling back to the resolved OG/meta description — mirroring the | |
| 241 | + // twitter:title cascade above. This lookup did not exist, so a Twitter | |
| 242 | + // description saved on the Social tab persisted, read back, and was | |
| 243 | + // then dropped in favour of the OG description (#406). | |
| 244 | + $twitter_description = ($data['twitter_description'] ?? '') !== '' | |
| 245 | + ? $data['twitter_description'] | |
| 246 | + : (string) ($data['description'] ?? ''); | |
| 247 | + if ($twitter_description !== '') { | |
| 248 | + $twitter_tags['twitter:description'] = $this->optimize_description_for_platform($twitter_description, 'twitter'); | |
| 218 | 249 | } |
| 219 | 250 | |
| 220 | 251 | // Image handling - prioritize Twitter-specific image |
| 221 | - $twitter_image = !empty($data['twitter_image']) ? $data['twitter_image'] : $data['image']; | |
| 252 | + $twitter_image = !empty($data['twitter_image']) ? $data['twitter_image'] : ($data['image'] ?? ''); | |
| 222 | 253 | if (!empty($twitter_image)) { |
| 223 | 254 | $optimized_image = $this->optimize_image_for_platform($twitter_image, 'twitter'); |
| 224 | 255 | $twitter_tags['twitter:image'] = $optimized_image['url']; |
| 225 | 256 | if (!empty($optimized_image['alt'])) { |
| @@ -937,8 +968,12 @@ | ||
| 937 | 968 | |
| 938 | 969 | $settings = $this->get_settings($context_type, $context_id); |
| 939 | 970 | $output = [ |
| 940 | 971 | 'og_tags' => [], |
| 972 | + // Secondary og:image entries. A separate list because $og_tags is | |
| 973 | + // keyed by property name and so can hold exactly one 'og:image' | |
| 974 | + // (#636); the emitter writes these straight after the primary. | |
| 975 | + 'og_extra_images' => [], | |
| 941 | 976 | 'twitter_tags' => [], |
| 942 | 977 | 'meta_tags' => [], |
| 943 | 978 | 'platform_tags' => [], |
| 944 | 979 | // Per-feature flags so each emitter can honor its own toggle. The |
| @@ -972,8 +1007,18 @@ | ||
| 972 | 1007 | // Add custom OG tags |
| 973 | 1008 | if (!empty($settings['custom_og_tags'])) { |
| 974 | 1009 | $output['og_tags'] = array_merge($output['og_tags'], $settings['custom_og_tags']); |
| 975 | 1010 | } |
| 1011 | + | |
| 1012 | + // Alternatives to offer after the primary image. Collected from the | |
| 1013 | + // resolved primary rather than re-deriving it, so the two can never | |
| 1014 | + // disagree about which image is the main one. | |
| 1015 | + if (!empty($settings['og_multiple_images'])) { | |
| 1016 | + $output['og_extra_images'] = Social_Images::additional( | |
| 1017 | + (int) $context_id, | |
| 1018 | + (string) ($output['og_tags']['og:image'] ?? '') | |
| 1019 | + ); | |
| 1020 | + } | |
| 976 | 1021 | } |
| 977 | 1022 | |
| 978 | 1023 | // Generate Twitter Card tags if enabled |
| 979 | 1024 | if ($twitter_enabled) { |
| @@ -1009,8 +1054,33 @@ | ||
| 1009 | 1054 | 'default_image', |
| 1010 | 1055 | ]; |
| 1011 | 1056 | |
| 1012 | 1057 | /** |
| 1058 | + * Site-wide switches with no per-context equivalent. | |
| 1059 | + * | |
| 1060 | + * Unlike INHERITED_SITE_KEYS above, these must inherit their site value | |
| 1061 | + * even when it is FALSE. The empty()-guarded loop used for images can only | |
| 1062 | + * ever propagate "on", which is right for an image URL (empty means "not | |
| 1063 | + * configured") and wrong for a boolean, where false is a deliberate choice. | |
| 1064 | + * | |
| 1065 | + * Without this the master Open Graph / Twitter Cards switches were honoured | |
| 1066 | + * on the homepage (mapped to the `site` context) and ignored on every post | |
| 1067 | + * and page, which read as though the toggle had worked (#557). | |
| 1068 | + * | |
| 1069 | + * @since 2.2.0 | |
| 1070 | + * @var string[] | |
| 1071 | + */ | |
| 1072 | + private const SITE_ONLY_KEYS = [ | |
| 1073 | + 'enable_open_graph', | |
| 1074 | + 'enable_twitter_cards', | |
| 1075 | + // Same shape as the two above and the same trap: a site-wide switch | |
| 1076 | + // with no per-context equivalent. Left out, it read as on while the | |
| 1077 | + // homepage rendered and as off on every post and page — which is where | |
| 1078 | + // the alternatives it controls actually come from (#636). | |
| 1079 | + 'og_multiple_images', | |
| 1080 | + ]; | |
| 1081 | + | |
| 1082 | + /** | |
| 1013 | 1083 | * Get settings for a context, inheriting the site-wide default images |
| 1014 | 1084 | * |
| 1015 | 1085 | * Two corrections over the generic lookup: |
| 1016 | 1086 | * |
| @@ -1043,8 +1113,18 @@ | ||
| 1043 | 1113 | return $settings; |
| 1044 | 1114 | } |
| 1045 | 1115 | |
| 1046 | 1116 | $site_settings = parent::get_settings('site', null); |
| 1117 | + | |
| 1118 | + // Site-wide switches: the site value is authoritative, false included. | |
| 1119 | + // array_key_exists, not empty() — '' is how a disabled toggle is stored. | |
| 1120 | + foreach (self::SITE_ONLY_KEYS as $key) { | |
| 1121 | + if (array_key_exists($key, $site_settings)) { | |
| 1122 | + $settings[$key] = $site_settings[$key]; | |
| 1123 | + } | |
| 1124 | + } | |
| 1125 | + | |
| 1126 | + // Default images: inherit only when this context has none of its own. | |
| 1047 | 1127 | foreach (self::INHERITED_SITE_KEYS as $key) { |
| 1048 | 1128 | if (empty($settings[$key]) && !empty($site_settings[$key])) { |
| 1049 | 1129 | $settings[$key] = $site_settings[$key]; |
| 1050 | 1130 | } |
| @@ -1080,8 +1160,12 @@ | ||
| 1080 | 1160 | 'og_locale' => '', |
| 1081 | 1161 | 'default_og_image' => '', |
| 1082 | 1162 | 'og_image_width' => 1200, |
| 1083 | 1163 | 'og_image_height' => 630, |
| 1164 | + // Offer alternative og:image tags after the primary one (#636). | |
| 1165 | + // Off by default: a page that shares with one image today must | |
| 1166 | + // keep sharing with that image after an update. | |
| 1167 | + 'og_multiple_images' => false, | |
| 1084 | 1168 | |
| 1085 | 1169 | // Twitter Cards settings |
| 1086 | 1170 | 'enable_twitter_cards' => true, |
| 1087 | 1171 | 'twitter_username' => '', |
| @@ -1121,8 +1205,15 @@ | ||
| 1121 | 1205 | 'fallback_to_excerpt' => true, |
| 1122 | 1206 | 'strip_html_tags' => true, |
| 1123 | 1207 | 'max_description_length' => 160, |
| 1124 | 1208 | |
| 1209 | + // oEmbed card (#637). All three default off: this rewrites what | |
| 1210 | + // other people's sites display, so an upgrade must not silently | |
| 1211 | + // change a card an existing embed has been showing for months. | |
| 1212 | + 'oembed_use_seo_title' => false, | |
| 1213 | + 'oembed_use_social_image' => false, | |
| 1214 | + 'oembed_remove_author' => false, | |
| 1215 | + | |
| 1125 | 1216 | // Legacy format for backward compatibility (only when needed) |
| 1126 | 1217 | 'custom_og_tags' => [], |
| 1127 | 1218 | 'image_optimization' => true, |
| 1128 | 1219 | 'auto_generate' => true |
| @@ -1406,10 +1497,15 @@ | ||
| 1406 | 1497 | 'description' => '', |
| 1407 | 1498 | 'url' => '', |
| 1408 | 1499 | 'image' => '', |
| 1409 | 1500 | 'twitter_title' => '', // Separate field for a Twitter-specific title |
| 1501 | + 'twitter_description' => '', // Separate field for a Twitter-specific description | |
| 1410 | 1502 | 'twitter_image' => '', // Separate field for Twitter-specific images |
| 1411 | - 'type' => 'website', | |
| 1503 | + // '' rather than 'website' means "derive it from the context". | |
| 1504 | + // Seeding a concrete type here made an explicit choice | |
| 1505 | + // indistinguishable from the shipped default (#398). | |
| 1506 | + 'type' => '', | |
| 1507 | + 'twitter_card_type' => '', | |
| 1412 | 1508 | 'author' => [], |
| 1413 | 1509 | 'published_time' => '', |
| 1414 | 1510 | 'modified_time' => '', |
| 1415 | 1511 | 'site_name' => $settings['og_site_name'] ?? get_bloginfo('name') |
| @@ -1414,8 +1510,12 @@ | ||
| 1414 | 1510 | 'modified_time' => '', |
| 1415 | 1511 | 'site_name' => $settings['og_site_name'] ?? get_bloginfo('name') |
| 1416 | 1512 | ]; |
| 1417 | 1513 | |
| 1514 | + // Explicit type choices, resolved once for whichever context this is. | |
| 1515 | + $data['type'] = $this->configured_og_type($settings); | |
| 1516 | + $data['twitter_card_type'] = $this->configured_twitter_card_type($settings); | |
| 1517 | + | |
| 1418 | 1518 | if ($context_type === 'site') { |
| 1419 | 1519 | // Site-wide data with Social Media tab settings priority. |
| 1420 | 1520 | // |
| 1421 | 1521 | // og:title priority: an explicitly-configured OG Site Name wins; |
| @@ -1438,10 +1538,16 @@ | ||
| 1438 | 1538 | // Use home_url('/') so og:url matches the homepage canonical |
| 1439 | 1539 | // (class-seo-manager.php) and the WebSite schema, which both include |
| 1440 | 1540 | // the trailing slash. A bare home_url() would key a different URL in |
| 1441 | 1541 | // social caches than the canonical. |
| 1442 | - $data['url'] = home_url('/'); | |
| 1443 | - $data['type'] = $settings['og_type'] ?? 'website'; | |
| 1542 | + // | |
| 1543 | + // Except when this is not the site home. detect_current_context() | |
| 1544 | + // collapses is_home() && !is_front_page() into 'homepage', so a | |
| 1545 | + // static posts page — and page 2 of any blog listing — advertised | |
| 1546 | + // the site home as its og:url while its own canonical said | |
| 1547 | + // otherwise (#397). | |
| 1548 | + $data['url'] = self::current_home_url(); | |
| 1549 | + | |
| 1444 | 1550 | // Leaving this null lets generate_og_tags() fall through to |
| 1445 | 1551 | // get_og_locale(), which is what every other context already does. |
| 1446 | 1552 | // |
| 1447 | 1553 | // A stored 'en_US' is deliberately treated as "not set". It was the |
| @@ -1490,8 +1596,18 @@ | ||
| 1490 | 1596 | (string) get_post_meta($post->ID, '_thinkrank_twitter_title', true), |
| 1491 | 1597 | $post->ID |
| 1492 | 1598 | ); |
| 1493 | 1599 | |
| 1600 | + // Per-post Twitter-specific description. twitter:description | |
| 1601 | + // falls back to the OG/meta description when this is empty, so | |
| 1602 | + // only capture it here; generate_twitter_tags() applies the | |
| 1603 | + // fallback. There was no counterpart to the title lookup above, | |
| 1604 | + // so the stored value never entered $data at all (#406). | |
| 1605 | + $data['twitter_description'] = \ThinkRank\SEO\Pattern_Resolver::resolve_value( | |
| 1606 | + (string) get_post_meta($post->ID, '_thinkrank_twitter_description', true), | |
| 1607 | + $post->ID | |
| 1608 | + ); | |
| 1609 | + | |
| 1494 | 1610 | // Title priority: per-post OG override > effective SEO title |
| 1495 | 1611 | // (document <title> / metabox preview) > post title. |
| 1496 | 1612 | if ($og_title_override !== '') { |
| 1497 | 1613 | $data['title'] = $og_title_override; |
| @@ -1508,10 +1624,14 @@ | ||
| 1508 | 1624 | $data['description'] = $fallback_description; |
| 1509 | 1625 | } else { |
| 1510 | 1626 | $data['description'] = $this->get_social_description($post); |
| 1511 | 1627 | } |
| 1512 | - $data['url'] = get_permalink($post); | |
| 1513 | - $data['type'] = $settings['og_type'] ?? $this->determine_og_type($context_type, []); | |
| 1628 | + // The canonical carries the <!--nextpage--> sub-page and the | |
| 1629 | + // comment page; og:url said page 1 regardless, which is the | |
| 1630 | + // same contradiction #397 fixed for the blog listing, one page | |
| 1631 | + // type over (#397 review). | |
| 1632 | + $data['url'] = self::with_singular_page((string) get_permalink($post)); | |
| 1633 | + | |
| 1514 | 1634 | $data['published_time'] = get_the_date('c', $post); |
| 1515 | 1635 | $data['modified_time'] = get_the_modified_date('c', $post); |
| 1516 | 1636 | $data['post_id'] = $post->ID; |
| 1517 | 1637 | |
| @@ -1539,8 +1659,93 @@ | ||
| 1539 | 1659 | // logo/site-icon fallback in generate_og_tags() and ignored a |
| 1540 | 1660 | // configured default OG image. |
| 1541 | 1661 | $data['image'] = $this->get_og_image_for_context($settings, null); |
| 1542 | 1662 | $data['twitter_image'] = $this->get_twitter_image_for_context($settings, null); |
| 1663 | + | |
| 1664 | + // A term archive owns SEO values of its own, and the caller has | |
| 1665 | + // already resolved them into the fallbacks — the same strings | |
| 1666 | + // rendered as the document <title> and the description tag. Leaving | |
| 1667 | + // title and description empty here published the bare site name as | |
| 1668 | + // og:title on every archive and no og:description at all, so a term | |
| 1669 | + // SEO title never reached a social surface (#386). | |
| 1670 | + // Archives have a URL of their own. The seed leaves it '', and the | |
| 1671 | + // og:url emitter could not fall through to get_current_url() while | |
| 1672 | + // the key existed, so no archive carried an og:url at all (#388). | |
| 1673 | + // A search archive's URL is the search link, not the bare request | |
| 1674 | + // path — get_current_url() reads $wp->request, which is empty for a | |
| 1675 | + // search served from the front page, so og:url pointed at the site | |
| 1676 | + // home while the page was a search result. | |
| 1677 | + // The non-search archive URL is the page's own canonical, so og:url | |
| 1678 | + // and <link rel="canonical"> agree on the paginated page rather than | |
| 1679 | + // both claiming page 1 (#397). | |
| 1680 | + // `?:` keeps the #388 guarantee that an archive always carries an | |
| 1681 | + // og:url: get_non_singular_canonical_url() returns '' for a view it | |
| 1682 | + // has no canonical rule for, and the request URL is still better | |
| 1683 | + // than no tag at all. | |
| 1684 | + $data['url'] = is_search() | |
| 1685 | + ? get_search_link() | |
| 1686 | + : (self::current_archive_url() ?: $this->get_current_url()); | |
| 1687 | + | |
| 1688 | + $term = get_queried_object(); | |
| 1689 | + $term_id = ($term instanceof \WP_Term) ? (int) $term->term_id : 0; | |
| 1690 | + | |
| 1691 | + $og_title_override = ''; | |
| 1692 | + $og_description_override = ''; | |
| 1693 | + | |
| 1694 | + if ($term_id > 0) { | |
| 1695 | + // Terms carry the same social override keys as posts — the | |
| 1696 | + // abilities API and the metabox both write them. | |
| 1697 | + $og_title_override = Pattern_Resolver::resolve_term_value( | |
| 1698 | + (string) get_term_meta($term_id, '_thinkrank_og_title', true), | |
| 1699 | + $term_id | |
| 1700 | + ); | |
| 1701 | + $og_description_override = Pattern_Resolver::resolve_term_value( | |
| 1702 | + (string) get_term_meta($term_id, '_thinkrank_og_description', true), | |
| 1703 | + $term_id | |
| 1704 | + ); | |
| 1705 | + | |
| 1706 | + // Twitter Cards fall back to og:title when this is empty, so | |
| 1707 | + // only capture it; generate_twitter_tags() applies the fallback. | |
| 1708 | + $data['twitter_title'] = Pattern_Resolver::resolve_term_value( | |
| 1709 | + (string) get_term_meta($term_id, '_thinkrank_twitter_title', true), | |
| 1710 | + $term_id | |
| 1711 | + ); | |
| 1712 | + $data['twitter_description'] = Pattern_Resolver::resolve_term_value( | |
| 1713 | + (string) get_term_meta($term_id, '_thinkrank_twitter_description', true), | |
| 1714 | + $term_id | |
| 1715 | + ); | |
| 1716 | + | |
| 1717 | + $term_og_image = (string) get_term_meta($term_id, '_thinkrank_og_image', true); | |
| 1718 | + if ('' !== $term_og_image) { | |
| 1719 | + $data['image'] = $term_og_image; | |
| 1720 | + } | |
| 1721 | + | |
| 1722 | + $term_twitter_image = (string) get_term_meta($term_id, '_thinkrank_twitter_image', true); | |
| 1723 | + if ('' !== $term_twitter_image) { | |
| 1724 | + $data['twitter_image'] = $term_twitter_image; | |
| 1725 | + } | |
| 1726 | + } | |
| 1727 | + | |
| 1728 | + // Author, date and search archives have no ThinkRank-managed title | |
| 1729 | + // to inherit — Author_Archives_Manager owns the author one, and the | |
| 1730 | + // rest have no template — so the caller's fallback arrives empty and | |
| 1731 | + // og:title used to collapse to the bare site name. Fall through to | |
| 1732 | + // what the page itself is called (#388). | |
| 1733 | + if ($og_title_override !== '') { | |
| 1734 | + $data['title'] = $og_title_override; | |
| 1735 | + } elseif ($fallback_title !== null && $fallback_title !== '') { | |
| 1736 | + $data['title'] = $fallback_title; | |
| 1737 | + } else { | |
| 1738 | + $data['title'] = $this->archive_fallback_title(); | |
| 1739 | + } | |
| 1740 | + | |
| 1741 | + if ($og_description_override !== '') { | |
| 1742 | + $data['description'] = $og_description_override; | |
| 1743 | + } elseif ($fallback_description !== null && $fallback_description !== '') { | |
| 1744 | + $data['description'] = $fallback_description; | |
| 1745 | + } else { | |
| 1746 | + $data['description'] = $this->archive_fallback_description($term_id); | |
| 1747 | + } | |
| 1543 | 1748 | } |
| 1544 | 1749 | |
| 1545 | 1750 | return $data; |
| 1546 | 1751 | } |
| @@ -1545,8 +1750,85 @@ | ||
| 1545 | 1750 | return $data; |
| 1546 | 1751 | } |
| 1547 | 1752 | |
| 1548 | 1753 | /** |
| 1754 | + * The canonical URL of the archive currently being rendered. | |
| 1755 | + * | |
| 1756 | + * Deliberately the same value class-seo-manager.php puts in | |
| 1757 | + * <link rel="canonical">: an og:url that disagrees with the canonical is | |
| 1758 | + * the bug this is fixing, so the two read from one source. | |
| 1759 | + * | |
| 1760 | + * @since 2.0.1 | |
| 1761 | + * | |
| 1762 | + * @return string Archive URL, '' when there is none (search, 404). | |
| 1763 | + */ | |
| 1764 | + private static function current_archive_url(): string { | |
| 1765 | + if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) { | |
| 1766 | + return ''; | |
| 1767 | + } | |
| 1768 | + | |
| 1769 | + return \ThinkRank\Frontend\SEO_Manager::get_non_singular_canonical_url(); | |
| 1770 | + } | |
| 1771 | + | |
| 1772 | + /** | |
| 1773 | + * A permalink with the current sub-page or comment page appended. | |
| 1774 | + * | |
| 1775 | + * @since 2.0.1 | |
| 1776 | + * | |
| 1777 | + * @param string $url Permalink. | |
| 1778 | + * @return string | |
| 1779 | + */ | |
| 1780 | + private static function with_singular_page(string $url): string { | |
| 1781 | + if ('' === $url || !class_exists('\ThinkRank\Frontend\SEO_Manager')) { | |
| 1782 | + return $url; | |
| 1783 | + } | |
| 1784 | + | |
| 1785 | + return \ThinkRank\Frontend\SEO_Manager::with_singular_page($url); | |
| 1786 | + } | |
| 1787 | + | |
| 1788 | + /** | |
| 1789 | + * The URL of the page the 'site' context is actually being rendered for. | |
| 1790 | + * | |
| 1791 | + * home_url('/') for the front page, the posts page's own permalink when the | |
| 1792 | + * site uses a static front page, and the paginated variant on page 2+. | |
| 1793 | + * | |
| 1794 | + * @since 2.0.1 | |
| 1795 | + * | |
| 1796 | + * @return string | |
| 1797 | + */ | |
| 1798 | + private static function current_home_url(): string { | |
| 1799 | + $url = home_url('/'); | |
| 1800 | + | |
| 1801 | + if (function_exists('is_home') && is_home() && !is_front_page()) { | |
| 1802 | + $posts_page = (int) get_option('page_for_posts'); | |
| 1803 | + | |
| 1804 | + if ($posts_page > 0) { | |
| 1805 | + $permalink = get_permalink($posts_page); | |
| 1806 | + | |
| 1807 | + if (is_string($permalink) && '' !== $permalink) { | |
| 1808 | + $url = $permalink; | |
| 1809 | + } | |
| 1810 | + } | |
| 1811 | + } | |
| 1812 | + | |
| 1813 | + if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) { | |
| 1814 | + return $url; | |
| 1815 | + } | |
| 1816 | + | |
| 1817 | + // A static front page is a singular view, so its page number lives in | |
| 1818 | + // `page`, not `paged` — the canonical already reads it that way, and | |
| 1819 | + // og:url has to agree or the two describe different URLs. | |
| 1820 | + if (function_exists('is_singular') && is_singular()) { | |
| 1821 | + return \ThinkRank\Frontend\SEO_Manager::with_singular_page($url); | |
| 1822 | + } | |
| 1823 | + | |
| 1824 | + return \ThinkRank\Frontend\SEO_Manager::with_pagination( | |
| 1825 | + $url, | |
| 1826 | + \ThinkRank\Frontend\SEO_Manager::current_page_number() | |
| 1827 | + ); | |
| 1828 | + } | |
| 1829 | + | |
| 1830 | + /** | |
| 1549 | 1831 | * Get Open Graph image for context with proper fallback |
| 1550 | 1832 | * |
| 1551 | 1833 | * @since 1.0.0 |
| 1552 | 1834 | * |
| @@ -1652,15 +1934,21 @@ | ||
| 1652 | 1934 | if (function_exists('post_password_required') && post_password_required($post)) { |
| 1653 | 1935 | return '' !== $post->post_excerpt ? $post->post_excerpt : get_bloginfo('description'); |
| 1654 | 1936 | } |
| 1655 | 1937 | |
| 1656 | - // Try excerpt first | |
| 1657 | - $description = get_the_excerpt($post); | |
| 1938 | + // Try excerpt first. On a Bricks page core would derive that excerpt | |
| 1939 | + // from the `post_content` Bricks throws away, so the visible body is | |
| 1940 | + // used instead — a hand-written excerpt still wins (#651). | |
| 1941 | + $superseding = Builder_Content::superseding_excerpt_source($post); | |
| 1942 | + $description = '' !== $superseding | |
| 1943 | + ? Pattern_Resolver::derive_excerpt($superseding, 30) | |
| 1944 | + : get_the_excerpt($post); | |
| 1658 | 1945 | |
| 1659 | - // If no excerpt, generate from content | |
| 1946 | + // If no excerpt, generate from content. Shortcodes and block delimiters | |
| 1947 | + // are removed the way core's wp_trim_excerpt() does, so a shortcode-built | |
| 1948 | + // page does not publish its source as og:description (#387). | |
| 1660 | 1949 | if (empty($description)) { |
| 1661 | - $content = wp_strip_all_tags($post->post_content); | |
| 1662 | - $description = wp_trim_words($content, 30, '...'); | |
| 1950 | + $description = Pattern_Resolver::derive_excerpt(Builder_Content::visible_content($post), 30); | |
| 1663 | 1951 | } |
| 1664 | 1952 | |
| 1665 | 1953 | // If still empty, use site description |
| 1666 | 1954 | if (empty($description)) { |
| @@ -1701,8 +1989,16 @@ | ||
| 1701 | 1989 | * @param string $context Context type |
| 1702 | 1990 | * @return string Twitter Card type |
| 1703 | 1991 | */ |
| 1704 | 1992 | private function determine_twitter_card_type(array $data, string $context): string { |
| 1993 | + // An explicitly chosen card type wins. Without this the setting was | |
| 1994 | + // inert and the `app` and `player` options in the UI could never be | |
| 1995 | + // emitted at all (#398). | |
| 1996 | + $chosen = (string) ($data['twitter_card_type'] ?? ''); | |
| 1997 | + if ('' !== $chosen) { | |
| 1998 | + return $chosen; | |
| 1999 | + } | |
| 2000 | + | |
| 1705 | 2001 | // Use a large-image card when a Twitter image will actually be emitted. |
| 1706 | 2002 | // twitter:image resolves to the twitter-specific image first, then the OG |
| 1707 | 2003 | // image, so key the card type off the same precedence. |
| 1708 | 2004 | $twitter_image = !empty($data['twitter_image']) ? $data['twitter_image'] : ($data['image'] ?? ''); |
| @@ -1709,8 +2005,105 @@ | ||
| 1709 | 2005 | return !empty($twitter_image) ? 'summary_large_image' : 'summary'; |
| 1710 | 2006 | } |
| 1711 | 2007 | |
| 1712 | 2008 | /** |
| 2009 | + * What an archive calls itself, for the og:title of last resort. | |
| 2010 | + * | |
| 2011 | + * Deliberately not wp_get_document_title(): that re-enters the | |
| 2012 | + * pre_get_document_title filter this plugin short-circuits, so it would | |
| 2013 | + * recurse. get_the_archive_title() wraps its subject in a <span>, hence the | |
| 2014 | + * strip. | |
| 2015 | + * | |
| 2016 | + * @since 2.0.1 | |
| 2017 | + * | |
| 2018 | + * @return string Archive title, or '' when there is nothing sensible to say. | |
| 2019 | + */ | |
| 2020 | + private function archive_fallback_title(): string { | |
| 2021 | + if (is_search()) { | |
| 2022 | + /* translators: %s: search query. */ | |
| 2023 | + return trim(sprintf(__('Search Results for "%s"', 'thinkrank'), get_search_query())); | |
| 2024 | + } | |
| 2025 | + | |
| 2026 | + if (!function_exists('get_the_archive_title')) { | |
| 2027 | + return ''; | |
| 2028 | + } | |
| 2029 | + | |
| 2030 | + return trim(wp_strip_all_tags((string) get_the_archive_title())); | |
| 2031 | + } | |
| 2032 | + | |
| 2033 | + /** | |
| 2034 | + * What an archive says about itself, for the og:description of last resort. | |
| 2035 | + * | |
| 2036 | + * @since 2.0.1 | |
| 2037 | + * | |
| 2038 | + * @param int $term_id Queried term, or 0 when the archive is not a term. | |
| 2039 | + * @return string Archive description, or '' when there is none. | |
| 2040 | + */ | |
| 2041 | + private function archive_fallback_description(int $term_id): string { | |
| 2042 | + if ($term_id > 0) { | |
| 2043 | + $description = trim(wp_strip_all_tags((string) term_description($term_id))); | |
| 2044 | + | |
| 2045 | + if ('' !== $description) { | |
| 2046 | + return $description; | |
| 2047 | + } | |
| 2048 | + } | |
| 2049 | + | |
| 2050 | + if (is_author()) { | |
| 2051 | + $bio = trim(wp_strip_all_tags((string) get_the_author_meta('description', (int) get_query_var('author')))); | |
| 2052 | + | |
| 2053 | + if ('' !== $bio) { | |
| 2054 | + return $bio; | |
| 2055 | + } | |
| 2056 | + } | |
| 2057 | + | |
| 2058 | + return ''; | |
| 2059 | + } | |
| 2060 | + | |
| 2061 | + /** | |
| 2062 | + * The Open Graph type the user explicitly chose, or '' when they did not. | |
| 2063 | + * | |
| 2064 | + * `og_type` ships a default of 'website' that is merged into every settings | |
| 2065 | + * read, so a stored 'website' cannot be told apart from "never touched" — | |
| 2066 | + * the same trap the `og_locale` note above documents. Treating it as unset | |
| 2067 | + * keeps determine_og_type() reachable, so a post is still `article`, while | |
| 2068 | + * any other stored value is a genuine override and wins. | |
| 2069 | + * | |
| 2070 | + * @since 2.0.1 | |
| 2071 | + * | |
| 2072 | + * @param array $settings Social meta settings. | |
| 2073 | + * @return string The chosen type, or '' for "derive it". | |
| 2074 | + */ | |
| 2075 | + private function configured_og_type(array $settings): string { | |
| 2076 | + $type = trim((string) ($settings['og_type'] ?? '')); | |
| 2077 | + | |
| 2078 | + return ('' === $type || 'website' === $type) ? '' : $type; | |
| 2079 | + } | |
| 2080 | + | |
| 2081 | + /** | |
| 2082 | + * The Twitter card type the user explicitly chose, or '' when they did not. | |
| 2083 | + * | |
| 2084 | + * Same reasoning as configured_og_type(): the shipped default is | |
| 2085 | + * 'summary_large_image', which is also what the automatic rule produces | |
| 2086 | + * whenever an image is available, so it is treated as "not chosen" and the | |
| 2087 | + * automatic rule stays in charge. `summary`, `app` and `player` are real | |
| 2088 | + * choices and are honoured. | |
| 2089 | + * | |
| 2090 | + * @since 2.0.1 | |
| 2091 | + * | |
| 2092 | + * @param array $settings Social meta settings. | |
| 2093 | + * @return string The chosen card type, or '' for "derive it". | |
| 2094 | + */ | |
| 2095 | + private function configured_twitter_card_type(array $settings): string { | |
| 2096 | + $type = trim((string) ($settings['twitter_card_type'] ?? '')); | |
| 2097 | + | |
| 2098 | + if (!in_array($type, ['summary', 'app', 'player'], true)) { | |
| 2099 | + return ''; | |
| 2100 | + } | |
| 2101 | + | |
| 2102 | + return $type; | |
| 2103 | + } | |
| 2104 | + | |
| 2105 | + /** | |
| 1713 | 2106 | * Optimize title for platform requirements |
| 1714 | 2107 | * |
| 1715 | 2108 | * @since 1.0.0 |
| 1716 | 2109 | * |
| @@ -1730,16 +2123,13 @@ | ||
| 1730 | 2123 | if (mb_strlen($title) <= $max_length) { |
| 1731 | 2124 | return $title; |
| 1732 | 2125 | } |
| 1733 | 2126 | |
| 1734 | - // Truncate at word boundary | |
| 1735 | - $truncated = wp_trim_words($title, 10, ''); | |
| 1736 | - if (mb_strlen($truncated) <= $max_length) { | |
| 1737 | - return $truncated; | |
| 1738 | - } | |
| 1739 | - | |
| 1740 | - // Hard truncate if necessary | |
| 1741 | - return mb_substr($title, 0, $max_length - 3) . '...'; | |
| 2127 | + // Truncate at a word boundary where there is one, and hard-cut where | |
| 2128 | + // there is not. This used to try wp_trim_words() first, which counts | |
| 2129 | + // CHARACTERS on th/ja/zh_* — so it returned ~10 characters, passed the | |
| 2130 | + // $max_length check below, and that was accepted as the title (#687). | |
| 2131 | + return \ThinkRank\Core\Seo_Text::trim_to_length($title, $max_length); | |
| 1742 | 2132 | } |
| 1743 | 2133 | |
| 1744 | 2134 | /** |
| 1745 | 2135 | * Optimize description for platform requirements |
| @@ -1762,16 +2152,14 @@ | ||
| 1762 | 2152 | if (mb_strlen($description) <= $max_length) { |
| 1763 | 2153 | return $description; |
| 1764 | 2154 | } |
| 1765 | 2155 | |
| 1766 | - // Truncate at word boundary | |
| 1767 | - $truncated = wp_trim_words($description, 25, ''); | |
| 1768 | - if (mb_strlen($truncated) <= $max_length) { | |
| 1769 | - return $truncated; | |
| 1770 | - } | |
| 1771 | - | |
| 1772 | - // Hard truncate if necessary | |
| 1773 | - return mb_substr($description, 0, $max_length - 3) . '...'; | |
| 2156 | + // Truncate at a word boundary where there is one, and hard-cut where | |
| 2157 | + // there is not. This used to try wp_trim_words() first, which counts | |
| 2158 | + // words in English but CHARACTERS in th/ja/zh_* — so on those locales | |
| 2159 | + // it returned ~25 characters, comfortably under $max_length, and that | |
| 2160 | + // was accepted as the answer (#687). | |
| 2161 | + return \ThinkRank\Core\Seo_Text::trim_to_length($description, $max_length); | |
| 1774 | 2162 | } |
| 1775 | 2163 | |
| 1776 | 2164 | /** |
| 1777 | 2165 | * Optimize image for platform requirements |
| @@ -2299,12 +2687,13 @@ | ||
| 2299 | 2687 | private function make_description_catchy(string $description): string { |
| 2300 | 2688 | // TikTok prefers short, catchy descriptions |
| 2301 | 2689 | $catchy_words = ['viral', 'trending', 'must-see', 'epic', 'mind-blowing']; |
| 2302 | 2690 | |
| 2303 | - // Limit to 100 characters for TikTok | |
| 2304 | - if (strlen($description) > 100) { | |
| 2305 | - $description = substr($description, 0, 97) . '...'; | |
| 2306 | - } | |
| 2691 | + // Limit to 100 characters for TikTok. strlen()/substr() count BYTES, | |
| 2692 | + // so this both fired three times too early on Thai/CJK text and cut | |
| 2693 | + // mid-character, emitting a broken UTF-8 sequence rather than a short | |
| 2694 | + // description (#687). | |
| 2695 | + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description, 100); | |
| 2307 | 2696 | |
| 2308 | 2697 | if (!preg_match('/\b(' . implode('|', $catchy_words) . ')\b/i', $description)) { |
| 2309 | 2698 | $description = '🔥 ' . $description; |
| 2310 | 2699 | } |
| @@ -2497,8 +2886,16 @@ | ||
| 2497 | 2886 | private function generate_platform_meta_tags(array $settings): array { |
| 2498 | 2887 | $platform_tags = []; |
| 2499 | 2888 | |
| 2500 | 2889 | $core = \ThinkRank\Core\Settings::instance(); |
| 2890 | + | |
| 2891 | + // One query for all seven instead of one query each. They are | |
| 2892 | + // autoload=off like every thinkrank_* option, so WordPress cannot | |
| 2893 | + // batch them out of `alloptions`, and this runs on every anonymous | |
| 2894 | + // front-end request — on most sites to discover that all seven are | |
| 2895 | + // empty and no tag is emitted at all (#393). | |
| 2896 | + $core->prime(array_keys(self::PLATFORM_META_KEYS)); | |
| 2897 | + | |
| 2501 | 2898 | // Core Settings (decrypted for sensitive keys) wins; the table value is a |
| 2502 | 2899 | // backward-compat fallback for installs that saved these before the UI |
| 2503 | 2900 | // moved to the Social Platforms tab. |
| 2504 | 2901 | $resolve = static function (string $key) use ($core, $settings): string { |
| @@ -2508,51 +2905,36 @@ | ||
| 2508 | 2905 | } |
| 2509 | 2906 | return $value; |
| 2510 | 2907 | }; |
| 2511 | 2908 | |
| 2512 | - // Facebook meta tags | |
| 2513 | - $facebook_app_id = $resolve('facebook_app_id'); | |
| 2514 | - if ('' !== $facebook_app_id) { | |
| 2515 | - $platform_tags['fb:app_id'] = $facebook_app_id; | |
| 2516 | - } | |
| 2909 | + foreach (self::PLATFORM_META_KEYS as $key => $meta_name) { | |
| 2910 | + $value = $resolve($key); | |
| 2517 | 2911 | |
| 2518 | - $facebook_admins = $resolve('facebook_admins'); | |
| 2519 | - if ('' !== $facebook_admins) { | |
| 2520 | - $platform_tags['fb:admins'] = $facebook_admins; | |
| 2912 | + if ('' !== $value) { | |
| 2913 | + $platform_tags[$meta_name] = $value; | |
| 2914 | + } | |
| 2521 | 2915 | } |
| 2522 | 2916 | |
| 2523 | - // Pinterest site verification | |
| 2524 | - $pinterest = $resolve('pinterest_site_verification'); | |
| 2525 | - if ('' !== $pinterest) { | |
| 2526 | - $platform_tags['pinterest-site-verification'] = $pinterest; | |
| 2527 | - } | |
| 2528 | - | |
| 2529 | - // Instagram verification | |
| 2530 | - $instagram = $resolve('instagram_verification'); | |
| 2531 | - if ('' !== $instagram) { | |
| 2532 | - $platform_tags['instagram-site-verification'] = $instagram; | |
| 2533 | - } | |
| 2534 | - | |
| 2535 | - // TikTok verification | |
| 2536 | - $tiktok = $resolve('tiktok_verification'); | |
| 2537 | - if ('' !== $tiktok) { | |
| 2538 | - $platform_tags['tiktok-site-verification'] = $tiktok; | |
| 2539 | - } | |
| 2540 | - | |
| 2541 | - // YouTube channel verification | |
| 2542 | - $youtube = $resolve('youtube_channel_id'); | |
| 2543 | - if ('' !== $youtube) { | |
| 2544 | - $platform_tags['youtube-channel-id'] = $youtube; | |
| 2545 | - } | |
| 2546 | - | |
| 2547 | - // WhatsApp Business verification | |
| 2548 | - $whatsapp = $resolve('whatsapp_business_id'); | |
| 2549 | - if ('' !== $whatsapp) { | |
| 2550 | - $platform_tags['whatsapp-business-id'] = $whatsapp; | |
| 2551 | - } | |
| 2552 | - | |
| 2553 | 2917 | return $platform_tags; |
| 2554 | 2918 | } |
| 2919 | + | |
| 2920 | + /** | |
| 2921 | + * Platform verification settings, mapped to the meta name each is emitted | |
| 2922 | + * under. One list so the batch primed in generate_platform_meta_tags() and | |
| 2923 | + * the keys it then reads cannot drift apart. | |
| 2924 | + * | |
| 2925 | + * @since 2.1.0 | |
| 2926 | + * @var array<string,string> | |
| 2927 | + */ | |
| 2928 | + private const PLATFORM_META_KEYS = [ | |
| 2929 | + 'facebook_app_id' => 'fb:app_id', | |
| 2930 | + 'facebook_admins' => 'fb:admins', | |
| 2931 | + 'pinterest_site_verification' => 'pinterest-site-verification', | |
| 2932 | + 'instagram_verification' => 'instagram-site-verification', | |
| 2933 | + 'tiktok_verification' => 'tiktok-site-verification', | |
| 2934 | + 'youtube_channel_id' => 'youtube-channel-id', | |
| 2935 | + 'whatsapp_business_id' => 'whatsapp-business-id', | |
| 2936 | + ]; | |
| 2555 | 2937 | |
| 2556 | 2938 | /** |
| 2557 | 2939 | * Convert OG, Twitter, and Platform tags to HTML meta tags |
| 2558 | 2940 | * |