PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/seo/class-social-meta-manager.php +460 -78 2.0.0 → 2.9.0 View file →
@@ -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 *