PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.0
2.13.0 2.12.0 2.11.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 All 54 releases
← All changes | includes/seo/class-social-meta-manager.php +532 -101 1.28.0 → 2.13.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'])) {
@@ -851,9 +882,9 @@
851 882 }
852 883
853 884 // Card Type validation
854 885 $valid_card_types = ['summary', 'summary_large_image', 'app', 'player'];
855 - if (!empty($settings['twitter_card_type']) && in_array($settings['twitter_card_type'], $valid_card_types)) {
886 + if (!empty($settings['twitter_card_type']) && in_array($settings['twitter_card_type'], $valid_card_types, true)) {
856 887 $field_details[] = [
857 888 'field' => 'twitter_card_type',
858 889 'label' => 'Twitter Card type is properly configured.',
859 890 'status' => 'valid',
@@ -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 }
@@ -1071,12 +1151,21 @@
1071 1151 'enable_open_graph' => true,
1072 1152 'og_site_name' => $site_name,
1073 1153 'og_description' => $site_description,
1074 1154 'og_type' => 'website',
1075 - 'og_locale' => 'en_US',
1155 + // Empty by design: og:locale is resolved from the site locale via
1156 + // get_og_locale(), which is where the thinkrank_og_locale filter (and
1157 + // therefore the WPML/Polylang/TranslatePress integration) applies. A
1158 + // hardcoded default was merged into every settings read, so the
1159 + // resolver was unreachable and every install advertised en_US.
1160 + 'og_locale' => '',
1076 1161 'default_og_image' => '',
1077 1162 'og_image_width' => 1200,
1078 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,
1079 1168
1080 1169 // Twitter Cards settings
1081 1170 'enable_twitter_cards' => true,
1082 1171 'twitter_username' => '',
@@ -1116,8 +1205,15 @@
1116 1205 'fallback_to_excerpt' => true,
1117 1206 'strip_html_tags' => true,
1118 1207 'max_description_length' => 160,
1119 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 +
1120 1216 // Legacy format for backward compatibility (only when needed)
1121 1217 'custom_og_tags' => [],
1122 1218 'image_optimization' => true,
1123 1219 'auto_generate' => true
@@ -1171,20 +1267,13 @@
1171 1267 'description' => 'Default description for Open Graph tags',
1172 1268 'maxLength' => 160,
1173 1269 'default' => get_bloginfo('description')
1174 1270 ],
1175 - 'og_type' => [
1176 - 'type' => 'string',
1177 - 'title' => 'Open Graph Type',
1178 - 'description' => 'The type of content for Open Graph',
1179 - 'enum' => ['website', 'article', 'book', 'profile'],
1180 - 'default' => 'website'
1181 - ],
1182 1271 'og_locale' => [
1183 1272 'type' => 'string',
1184 1273 'title' => 'Locale',
1185 - 'description' => 'The locale for Open Graph tags',
1186 - 'default' => 'en_US'
1274 + 'description' => 'Optional override for og:locale. Leave empty to follow the site language.',
1275 + 'default' => ''
1187 1276 ],
1188 1277 'default_og_image' => [
1189 1278 'type' => 'string',
1190 1279 'title' => 'Default Open Graph Image',
@@ -1408,10 +1497,15 @@
1408 1497 'description' => '',
1409 1498 'url' => '',
1410 1499 'image' => '',
1411 1500 'twitter_title' => '', // Separate field for a Twitter-specific title
1501 + 'twitter_description' => '', // Separate field for a Twitter-specific description
1412 1502 'twitter_image' => '', // Separate field for Twitter-specific images
1413 - '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' => '',
1414 1508 'author' => [],
1415 1509 'published_time' => '',
1416 1510 'modified_time' => '',
1417 1511 'site_name' => $settings['og_site_name'] ?? get_bloginfo('name')
@@ -1416,8 +1510,12 @@
1416 1510 'modified_time' => '',
1417 1511 'site_name' => $settings['og_site_name'] ?? get_bloginfo('name')
1418 1512 ];
1419 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 +
1420 1518 if ($context_type === 'site') {
1421 1519 // Site-wide data with Social Media tab settings priority.
1422 1520 //
1423 1521 // og:title priority: an explicitly-configured OG Site Name wins;
@@ -1435,17 +1533,49 @@
1435 1533 $data['title'] = $fallback_title;
1436 1534 } else {
1437 1535 $data['title'] = $blogname;
1438 1536 }
1439 - $data['description'] = $settings['og_description'] ?? get_bloginfo('description');
1537 + // Same rule as the title above: the shipped default (the tagline)
1538 + // is not a choice, so the homepage's meta description wins over
1539 + // it. Without this an imported or hand-set homepage description
1540 + // printed as the meta description while og:/twitter:description
1541 + // kept the tagline (#897).
1542 + $og_description = (string) ($settings['og_description'] ?? '');
1543 + if ($og_description !== '' && $og_description !== get_bloginfo('description')) {
1544 + $data['description'] = $og_description;
1545 + } elseif ($fallback_description !== null && $fallback_description !== '') {
1546 + $data['description'] = $fallback_description;
1547 + } else {
1548 + $data['description'] = get_bloginfo('description');
1549 + }
1440 1550 // Use home_url('/') so og:url matches the homepage canonical
1441 1551 // (class-seo-manager.php) and the WebSite schema, which both include
1442 1552 // the trailing slash. A bare home_url() would key a different URL in
1443 1553 // social caches than the canonical.
1444 - $data['url'] = home_url('/');
1445 - $data['type'] = $settings['og_type'] ?? 'website';
1446 - $data['locale'] = $settings['og_locale'] ?? null;
1554 + //
1555 + // Except when this is not the site home. detect_current_context()
1556 + // collapses is_home() && !is_front_page() into 'homepage', so a
1557 + // static posts page — and page 2 of any blog listing — advertised
1558 + // the site home as its og:url while its own canonical said
1559 + // otherwise (#397).
1560 + $data['url'] = self::current_home_url();
1447 1561
1562 + // Leaving this null lets generate_og_tags() fall through to
1563 + // get_og_locale(), which is what every other context already does.
1564 + //
1565 + // A stored 'en_US' is deliberately treated as "not set". It was the
1566 + // hardcoded default on every install and there has never been a UI
1567 + // control for this field, so it cannot represent a deliberate choice
1568 + // — it is the old default persisted by an unrelated save of the
1569 + // Social Media tab. Honouring it would leave every already-saved
1570 + // site broken after this fix. Any other stored value is a genuine
1571 + // override and still wins; a site that really wants to force en_US
1572 + // can do so through the thinkrank_og_locale filter.
1573 + $stored_locale = trim((string) ($settings['og_locale'] ?? ''));
1574 + $data['locale'] = ('' !== $stored_locale && 'en_US' !== $stored_locale)
1575 + ? $stored_locale
1576 + : null;
1577 +
1448 1578 // Set Open Graph image with proper fallback
1449 1579 $data['image'] = $this->get_og_image_for_context($settings, null);
1450 1580
1451 1581 // Set Twitter image with proper fallback
@@ -1478,8 +1608,18 @@
1478 1608 (string) get_post_meta($post->ID, '_thinkrank_twitter_title', true),
1479 1609 $post->ID
1480 1610 );
1481 1611
1612 + // Per-post Twitter-specific description. twitter:description
1613 + // falls back to the OG/meta description when this is empty, so
1614 + // only capture it here; generate_twitter_tags() applies the
1615 + // fallback. There was no counterpart to the title lookup above,
1616 + // so the stored value never entered $data at all (#406).
1617 + $data['twitter_description'] = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1618 + (string) get_post_meta($post->ID, '_thinkrank_twitter_description', true),
1619 + $post->ID
1620 + );
1621 +
1482 1622 // Title priority: per-post OG override > effective SEO title
1483 1623 // (document <title> / metabox preview) > post title.
1484 1624 if ($og_title_override !== '') {
1485 1625 $data['title'] = $og_title_override;
@@ -1496,10 +1636,14 @@
1496 1636 $data['description'] = $fallback_description;
1497 1637 } else {
1498 1638 $data['description'] = $this->get_social_description($post);
1499 1639 }
1500 - $data['url'] = get_permalink($post);
1501 - $data['type'] = $settings['og_type'] ?? $this->determine_og_type($context_type, []);
1640 + // The canonical carries the <!--nextpage--> sub-page and the
1641 + // comment page; og:url said page 1 regardless, which is the
1642 + // same contradiction #397 fixed for the blog listing, one page
1643 + // type over (#397 review).
1644 + $data['url'] = self::with_singular_page((string) get_permalink($post));
1645 +
1502 1646 $data['published_time'] = get_the_date('c', $post);
1503 1647 $data['modified_time'] = get_the_modified_date('c', $post);
1504 1648 $data['post_id'] = $post->ID;
1505 1649
@@ -1527,8 +1671,93 @@
1527 1671 // logo/site-icon fallback in generate_og_tags() and ignored a
1528 1672 // configured default OG image.
1529 1673 $data['image'] = $this->get_og_image_for_context($settings, null);
1530 1674 $data['twitter_image'] = $this->get_twitter_image_for_context($settings, null);
1675 +
1676 + // A term archive owns SEO values of its own, and the caller has
1677 + // already resolved them into the fallbacks — the same strings
1678 + // rendered as the document <title> and the description tag. Leaving
1679 + // title and description empty here published the bare site name as
1680 + // og:title on every archive and no og:description at all, so a term
1681 + // SEO title never reached a social surface (#386).
1682 + // Archives have a URL of their own. The seed leaves it '', and the
1683 + // og:url emitter could not fall through to get_current_url() while
1684 + // the key existed, so no archive carried an og:url at all (#388).
1685 + // A search archive's URL is the search link, not the bare request
1686 + // path — get_current_url() reads $wp->request, which is empty for a
1687 + // search served from the front page, so og:url pointed at the site
1688 + // home while the page was a search result.
1689 + // The non-search archive URL is the page's own canonical, so og:url
1690 + // and <link rel="canonical"> agree on the paginated page rather than
1691 + // both claiming page 1 (#397).
1692 + // `?:` keeps the #388 guarantee that an archive always carries an
1693 + // og:url: get_non_singular_canonical_url() returns '' for a view it
1694 + // has no canonical rule for, and the request URL is still better
1695 + // than no tag at all.
1696 + $data['url'] = is_search()
1697 + ? get_search_link()
1698 + : (self::current_archive_url() ?: $this->get_current_url());
1699 +
1700 + $term = get_queried_object();
1701 + $term_id = ($term instanceof \WP_Term) ? (int) $term->term_id : 0;
1702 +
1703 + $og_title_override = '';
1704 + $og_description_override = '';
1705 +
1706 + if ($term_id > 0) {
1707 + // Terms carry the same social override keys as posts — the
1708 + // abilities API and the metabox both write them.
1709 + $og_title_override = Pattern_Resolver::resolve_term_value(
1710 + (string) get_term_meta($term_id, '_thinkrank_og_title', true),
1711 + $term_id
1712 + );
1713 + $og_description_override = Pattern_Resolver::resolve_term_value(
1714 + (string) get_term_meta($term_id, '_thinkrank_og_description', true),
1715 + $term_id
1716 + );
1717 +
1718 + // Twitter Cards fall back to og:title when this is empty, so
1719 + // only capture it; generate_twitter_tags() applies the fallback.
1720 + $data['twitter_title'] = Pattern_Resolver::resolve_term_value(
1721 + (string) get_term_meta($term_id, '_thinkrank_twitter_title', true),
1722 + $term_id
1723 + );
1724 + $data['twitter_description'] = Pattern_Resolver::resolve_term_value(
1725 + (string) get_term_meta($term_id, '_thinkrank_twitter_description', true),
1726 + $term_id
1727 + );
1728 +
1729 + $term_og_image = (string) get_term_meta($term_id, '_thinkrank_og_image', true);
1730 + if ('' !== $term_og_image) {
1731 + $data['image'] = $term_og_image;
1732 + }
1733 +
1734 + $term_twitter_image = (string) get_term_meta($term_id, '_thinkrank_twitter_image', true);
1735 + if ('' !== $term_twitter_image) {
1736 + $data['twitter_image'] = $term_twitter_image;
1737 + }
1738 + }
1739 +
1740 + // Author, date and search archives have no ThinkRank-managed title
1741 + // to inherit — Author_Archives_Manager owns the author one, and the
1742 + // rest have no template — so the caller's fallback arrives empty and
1743 + // og:title used to collapse to the bare site name. Fall through to
1744 + // what the page itself is called (#388).
1745 + if ($og_title_override !== '') {
1746 + $data['title'] = $og_title_override;
1747 + } elseif ($fallback_title !== null && $fallback_title !== '') {
1748 + $data['title'] = $fallback_title;
1749 + } else {
1750 + $data['title'] = $this->archive_fallback_title();
1751 + }
1752 +
1753 + if ($og_description_override !== '') {
1754 + $data['description'] = $og_description_override;
1755 + } elseif ($fallback_description !== null && $fallback_description !== '') {
1756 + $data['description'] = $fallback_description;
1757 + } else {
1758 + $data['description'] = $this->archive_fallback_description($term_id);
1759 + }
1531 1760 }
1532 1761
1533 1762 return $data;
1534 1763 }
@@ -1533,8 +1762,85 @@
1533 1762 return $data;
1534 1763 }
1535 1764
1536 1765 /**
1766 + * The canonical URL of the archive currently being rendered.
1767 + *
1768 + * Deliberately the same value class-seo-manager.php puts in
1769 + * <link rel="canonical">: an og:url that disagrees with the canonical is
1770 + * the bug this is fixing, so the two read from one source.
1771 + *
1772 + * @since 2.0.1
1773 + *
1774 + * @return string Archive URL, '' when there is none (search, 404).
1775 + */
1776 + private static function current_archive_url(): string {
1777 + if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) {
1778 + return '';
1779 + }
1780 +
1781 + return \ThinkRank\Frontend\SEO_Manager::get_non_singular_canonical_url();
1782 + }
1783 +
1784 + /**
1785 + * A permalink with the current sub-page or comment page appended.
1786 + *
1787 + * @since 2.0.1
1788 + *
1789 + * @param string $url Permalink.
1790 + * @return string
1791 + */
1792 + private static function with_singular_page(string $url): string {
1793 + if ('' === $url || !class_exists('\ThinkRank\Frontend\SEO_Manager')) {
1794 + return $url;
1795 + }
1796 +
1797 + return \ThinkRank\Frontend\SEO_Manager::with_singular_page($url);
1798 + }
1799 +
1800 + /**
1801 + * The URL of the page the 'site' context is actually being rendered for.
1802 + *
1803 + * home_url('/') for the front page, the posts page's own permalink when the
1804 + * site uses a static front page, and the paginated variant on page 2+.
1805 + *
1806 + * @since 2.0.1
1807 + *
1808 + * @return string
1809 + */
1810 + private static function current_home_url(): string {
1811 + $url = home_url('/');
1812 +
1813 + if (function_exists('is_home') && is_home() && !is_front_page()) {
1814 + $posts_page = (int) get_option('page_for_posts');
1815 +
1816 + if ($posts_page > 0) {
1817 + $permalink = get_permalink($posts_page);
1818 +
1819 + if (is_string($permalink) && '' !== $permalink) {
1820 + $url = $permalink;
1821 + }
1822 + }
1823 + }
1824 +
1825 + if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) {
1826 + return $url;
1827 + }
1828 +
1829 + // A static front page is a singular view, so its page number lives in
1830 + // `page`, not `paged` — the canonical already reads it that way, and
1831 + // og:url has to agree or the two describe different URLs.
1832 + if (function_exists('is_singular') && is_singular()) {
1833 + return \ThinkRank\Frontend\SEO_Manager::with_singular_page($url);
1834 + }
1835 +
1836 + return \ThinkRank\Frontend\SEO_Manager::with_pagination(
1837 + $url,
1838 + \ThinkRank\Frontend\SEO_Manager::current_page_number()
1839 + );
1840 + }
1841 +
1842 + /**
1537 1843 * Get Open Graph image for context with proper fallback
1538 1844 *
1539 1845 * @since 1.0.0
1540 1846 *
@@ -1548,8 +1854,12 @@
1548 1854 $image_id = get_post_thumbnail_id($post);
1549 1855 if ($image_id) {
1550 1856 $image_data = wp_get_attachment_image_src($image_id, 'large');
1551 1857 if (!empty($image_data[0])) {
1858 + // The ID is in hand here and gone once this returns a
1859 + // bare URL; say so rather than have the tag builders look
1860 + // it up again, twice, with a URL core cannot match (#847).
1861 + Attachment_Lookup::remember((string) $image_data[0], (int) $image_id);
1552 1862 return $image_data[0];
1553 1863 }
1554 1864 }
1555 1865 }
@@ -1596,8 +1906,12 @@
1596 1906 $image_id = get_post_thumbnail_id($post);
1597 1907 if ($image_id) {
1598 1908 $image_data = wp_get_attachment_image_src($image_id, 'large');
1599 1909 if (!empty($image_data[0])) {
1910 + // The ID is in hand here and gone once this returns a
1911 + // bare URL; say so rather than have the tag builders look
1912 + // it up again, twice, with a URL core cannot match (#847).
1913 + Attachment_Lookup::remember((string) $image_data[0], (int) $image_id);
1600 1914 return $image_data[0];
1601 1915 }
1602 1916 }
1603 1917 }
@@ -1628,15 +1942,33 @@
1628 1942 * @param \WP_Post $post Post object
1629 1943 * @return string Social media description
1630 1944 */
1631 1945 private function get_social_description(\WP_Post $post): string {
1632 - // Try excerpt first
1633 - $description = get_the_excerpt($post);
1946 + // A password-gated body must never become a social description. Core
1947 + // answers get_the_excerpt() with its "There is no excerpt because this
1948 + // is a protected post." placeholder rather than the body, so today the
1949 + // derive-from-content fallback below is unreachable here — but it is one
1950 + // core change away from leaking, and that placeholder sentence is not a
1951 + // description worth publishing to every crawler and unfurler either.
1952 + // An authored post_excerpt is written for public consumption, so it
1953 + // still stands (#363).
1954 + if (function_exists('post_password_required') && post_password_required($post)) {
1955 + return '' !== $post->post_excerpt ? $post->post_excerpt : get_bloginfo('description');
1956 + }
1634 1957
1635 - // If no excerpt, generate from content
1958 + // Try excerpt first. On a Bricks page core would derive that excerpt
1959 + // from the `post_content` Bricks throws away, so the visible body is
1960 + // used instead — a hand-written excerpt still wins (#651).
1961 + $superseding = Builder_Content::superseding_excerpt_source($post);
1962 + $description = '' !== $superseding
1963 + ? Pattern_Resolver::derive_excerpt($superseding, 30)
1964 + : get_the_excerpt($post);
1965 +
1966 + // If no excerpt, generate from content. Shortcodes and block delimiters
1967 + // are removed the way core's wp_trim_excerpt() does, so a shortcode-built
1968 + // page does not publish its source as og:description (#387).
1636 1969 if (empty($description)) {
1637 - $content = wp_strip_all_tags($post->post_content);
1638 - $description = wp_trim_words($content, 30, '...');
1970 + $description = Pattern_Resolver::derive_excerpt(Builder_Content::visible_content($post), 30);
1639 1971 }
1640 1972
1641 1973 // If still empty, use site description
1642 1974 if (empty($description)) {
@@ -1677,8 +2009,16 @@
1677 2009 * @param string $context Context type
1678 2010 * @return string Twitter Card type
1679 2011 */
1680 2012 private function determine_twitter_card_type(array $data, string $context): string {
2013 + // An explicitly chosen card type wins. Without this the setting was
2014 + // inert and the `app` and `player` options in the UI could never be
2015 + // emitted at all (#398).
2016 + $chosen = (string) ($data['twitter_card_type'] ?? '');
2017 + if ('' !== $chosen) {
2018 + return $chosen;
2019 + }
2020 +
1681 2021 // Use a large-image card when a Twitter image will actually be emitted.
1682 2022 // twitter:image resolves to the twitter-specific image first, then the OG
1683 2023 // image, so key the card type off the same precedence.
1684 2024 $twitter_image = !empty($data['twitter_image']) ? $data['twitter_image'] : ($data['image'] ?? '');
@@ -1685,8 +2025,105 @@
1685 2025 return !empty($twitter_image) ? 'summary_large_image' : 'summary';
1686 2026 }
1687 2027
1688 2028 /**
2029 + * What an archive calls itself, for the og:title of last resort.
2030 + *
2031 + * Deliberately not wp_get_document_title(): that re-enters the
2032 + * pre_get_document_title filter this plugin short-circuits, so it would
2033 + * recurse. get_the_archive_title() wraps its subject in a <span>, hence the
2034 + * strip.
2035 + *
2036 + * @since 2.0.1
2037 + *
2038 + * @return string Archive title, or '' when there is nothing sensible to say.
2039 + */
2040 + private function archive_fallback_title(): string {
2041 + if (is_search()) {
2042 + /* translators: %s: search query. */
2043 + return trim(sprintf(__('Search Results for "%s"', 'thinkrank'), get_search_query()));
2044 + }
2045 +
2046 + if (!function_exists('get_the_archive_title')) {
2047 + return '';
2048 + }
2049 +
2050 + return trim(wp_strip_all_tags((string) get_the_archive_title()));
2051 + }
2052 +
2053 + /**
2054 + * What an archive says about itself, for the og:description of last resort.
2055 + *
2056 + * @since 2.0.1
2057 + *
2058 + * @param int $term_id Queried term, or 0 when the archive is not a term.
2059 + * @return string Archive description, or '' when there is none.
2060 + */
2061 + private function archive_fallback_description(int $term_id): string {
2062 + if ($term_id > 0) {
2063 + $description = trim(wp_strip_all_tags((string) term_description($term_id)));
2064 +
2065 + if ('' !== $description) {
2066 + return $description;
2067 + }
2068 + }
2069 +
2070 + if (is_author()) {
2071 + $bio = trim(wp_strip_all_tags((string) get_the_author_meta('description', (int) get_query_var('author'))));
2072 +
2073 + if ('' !== $bio) {
2074 + return $bio;
2075 + }
2076 + }
2077 +
2078 + return '';
2079 + }
2080 +
2081 + /**
2082 + * The Open Graph type the user explicitly chose, or '' when they did not.
2083 + *
2084 + * `og_type` ships a default of 'website' that is merged into every settings
2085 + * read, so a stored 'website' cannot be told apart from "never touched" —
2086 + * the same trap the `og_locale` note above documents. Treating it as unset
2087 + * keeps determine_og_type() reachable, so a post is still `article`, while
2088 + * any other stored value is a genuine override and wins.
2089 + *
2090 + * @since 2.0.1
2091 + *
2092 + * @param array $settings Social meta settings.
2093 + * @return string The chosen type, or '' for "derive it".
2094 + */
2095 + private function configured_og_type(array $settings): string {
2096 + $type = trim((string) ($settings['og_type'] ?? ''));
2097 +
2098 + return ('' === $type || 'website' === $type) ? '' : $type;
2099 + }
2100 +
2101 + /**
2102 + * The Twitter card type the user explicitly chose, or '' when they did not.
2103 + *
2104 + * Same reasoning as configured_og_type(): the shipped default is
2105 + * 'summary_large_image', which is also what the automatic rule produces
2106 + * whenever an image is available, so it is treated as "not chosen" and the
2107 + * automatic rule stays in charge. `summary`, `app` and `player` are real
2108 + * choices and are honoured.
2109 + *
2110 + * @since 2.0.1
2111 + *
2112 + * @param array $settings Social meta settings.
2113 + * @return string The chosen card type, or '' for "derive it".
2114 + */
2115 + private function configured_twitter_card_type(array $settings): string {
2116 + $type = trim((string) ($settings['twitter_card_type'] ?? ''));
2117 +
2118 + if (!in_array($type, ['summary', 'app', 'player'], true)) {
2119 + return '';
2120 + }
2121 +
2122 + return $type;
2123 + }
2124 +
2125 + /**
1689 2126 * Optimize title for platform requirements
1690 2127 *
1691 2128 * @since 1.0.0
1692 2129 *
@@ -1706,16 +2143,13 @@
1706 2143 if (mb_strlen($title) <= $max_length) {
1707 2144 return $title;
1708 2145 }
1709 2146
1710 - // Truncate at word boundary
1711 - $truncated = wp_trim_words($title, 10, '');
1712 - if (mb_strlen($truncated) <= $max_length) {
1713 - return $truncated;
1714 - }
1715 -
1716 - // Hard truncate if necessary
1717 - return mb_substr($title, 0, $max_length - 3) . '...';
2147 + // Truncate at a word boundary where there is one, and hard-cut where
2148 + // there is not. This used to try wp_trim_words() first, which counts
2149 + // CHARACTERS on th/ja/zh_* — so it returned ~10 characters, passed the
2150 + // $max_length check below, and that was accepted as the title (#687).
2151 + return \ThinkRank\Core\Seo_Text::trim_to_length($title, $max_length);
1718 2152 }
1719 2153
1720 2154 /**
1721 2155 * Optimize description for platform requirements
@@ -1738,16 +2172,14 @@
1738 2172 if (mb_strlen($description) <= $max_length) {
1739 2173 return $description;
1740 2174 }
1741 2175
1742 - // Truncate at word boundary
1743 - $truncated = wp_trim_words($description, 25, '');
1744 - if (mb_strlen($truncated) <= $max_length) {
1745 - return $truncated;
1746 - }
1747 -
1748 - // Hard truncate if necessary
1749 - return mb_substr($description, 0, $max_length - 3) . '...';
2176 + // Truncate at a word boundary where there is one, and hard-cut where
2177 + // there is not. This used to try wp_trim_words() first, which counts
2178 + // words in English but CHARACTERS in th/ja/zh_* — so on those locales
2179 + // it returned ~25 characters, comfortably under $max_length, and that
2180 + // was accepted as the answer (#687).
2181 + return \ThinkRank\Core\Seo_Text::trim_to_length($description, $max_length);
1750 2182 }
1751 2183
1752 2184 /**
1753 2185 * Optimize image for platform requirements
@@ -1772,21 +2204,23 @@
1772 2204 if (empty($image_url)) {
1773 2205 return $image_data;
1774 2206 }
1775 2207
1776 - // Get image metadata
1777 - $attachment_id = attachment_url_to_postid($image_url);
2208 + // Get image metadata. The dimensions are those of the file this URL
2209 + // names — usually a generated size — not of the original upload, so
2210 + // the width and height published beside it describe the same image.
2211 + $attachment_id = Attachment_Lookup::id_from_url($image_url);
1778 2212 if ($attachment_id) {
1779 2213 $image_meta = wp_get_attachment_metadata($attachment_id);
1780 2214 $image_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
1781 - $mime_type = get_post_mime_type($attachment_id);
2215 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
1782 2216
1783 2217 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
1784 - $width = (int) $image_meta['width'];
1785 - $height = (int) $image_meta['height'];
2218 + $width = $image_file['width'];
2219 + $height = $image_file['height'];
1786 2220
1787 2221 $image_data['alt'] = $image_alt ?: '';
1788 - $image_data['type'] = $mime_type ?: '';
2222 + $image_data['type'] = $image_file['type'];
1789 2223
1790 2224 // SVGs and other vector uploads store 0x0 metadata. Dimension
1791 2225 // checks are meaningless there and dividing by 0 is fatal.
1792 2226 if ($width > 0 && $height > 0) {
@@ -1838,8 +2272,9 @@
1838 2272 $custom_logo_id = get_theme_mod('custom_logo');
1839 2273 if ($custom_logo_id) {
1840 2274 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'large');
1841 2275 if ($logo_data) {
2276 + Attachment_Lookup::remember((string) $logo_data[0], (int) $custom_logo_id);
1842 2277 return $logo_data[0];
1843 2278 }
1844 2279 }
1845 2280
@@ -1847,8 +2282,9 @@
1847 2282 $site_icon_id = get_option('site_icon');
1848 2283 if ($site_icon_id) {
1849 2284 $icon_data = wp_get_attachment_image_src($site_icon_id, 'large');
1850 2285 if ($icon_data) {
2286 + Attachment_Lookup::remember((string) $icon_data[0], (int) $site_icon_id);
1851 2287 return $icon_data[0];
1852 2288 }
1853 2289 }
1854 2290
@@ -2174,9 +2610,9 @@
2174 2610 if (isset($og_tags['og:description'])) {
2175 2611 $og_tags['og:description'] = $this->make_description_descriptive($og_tags['og:description']);
2176 2612 }
2177 2613 // Pinterest prefers article type for rich pins
2178 - if (in_array($og_tags['og:type'], ['website', 'blog'])) {
2614 + if (in_array($og_tags['og:type'], ['website', 'blog'], true)) {
2179 2615 $og_tags['og:type'] = 'article';
2180 2616 }
2181 2617 }
2182 2618 break;
@@ -2275,12 +2711,13 @@
2275 2711 private function make_description_catchy(string $description): string {
2276 2712 // TikTok prefers short, catchy descriptions
2277 2713 $catchy_words = ['viral', 'trending', 'must-see', 'epic', 'mind-blowing'];
2278 2714
2279 - // Limit to 100 characters for TikTok
2280 - if (strlen($description) > 100) {
2281 - $description = substr($description, 0, 97) . '...';
2282 - }
2715 + // Limit to 100 characters for TikTok. strlen()/substr() count BYTES,
2716 + // so this both fired three times too early on Thai/CJK text and cut
2717 + // mid-character, emitting a broken UTF-8 sequence rather than a short
2718 + // description (#687).
2719 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description, 100);
2283 2720
2284 2721 if (!preg_match('/\b(' . implode('|', $catchy_words) . ')\b/i', $description)) {
2285 2722 $description = '🔥 ' . $description;
2286 2723 }
@@ -2473,8 +2910,16 @@
2473 2910 private function generate_platform_meta_tags(array $settings): array {
2474 2911 $platform_tags = [];
2475 2912
2476 2913 $core = \ThinkRank\Core\Settings::instance();
2914 +
2915 + // One query for all seven instead of one query each. They are
2916 + // autoload=off like every thinkrank_* option, so WordPress cannot
2917 + // batch them out of `alloptions`, and this runs on every anonymous
2918 + // front-end request — on most sites to discover that all seven are
2919 + // empty and no tag is emitted at all (#393).
2920 + $core->prime(array_keys(self::PLATFORM_META_KEYS));
2921 +
2477 2922 // Core Settings (decrypted for sensitive keys) wins; the table value is a
2478 2923 // backward-compat fallback for installs that saved these before the UI
2479 2924 // moved to the Social Platforms tab.
2480 2925 $resolve = static function (string $key) use ($core, $settings): string {
@@ -2484,53 +2929,38 @@
2484 2929 }
2485 2930 return $value;
2486 2931 };
2487 2932
2488 - // Facebook meta tags
2489 - $facebook_app_id = $resolve('facebook_app_id');
2490 - if ('' !== $facebook_app_id) {
2491 - $platform_tags['fb:app_id'] = $facebook_app_id;
2492 - }
2933 + foreach (self::PLATFORM_META_KEYS as $key => $meta_name) {
2934 + $value = $resolve($key);
2493 2935
2494 - $facebook_admins = $resolve('facebook_admins');
2495 - if ('' !== $facebook_admins) {
2496 - $platform_tags['fb:admins'] = $facebook_admins;
2936 + if ('' !== $value) {
2937 + $platform_tags[$meta_name] = $value;
2938 + }
2497 2939 }
2498 2940
2499 - // Pinterest site verification
2500 - $pinterest = $resolve('pinterest_site_verification');
2501 - if ('' !== $pinterest) {
2502 - $platform_tags['pinterest-site-verification'] = $pinterest;
2503 - }
2504 -
2505 - // Instagram verification
2506 - $instagram = $resolve('instagram_verification');
2507 - if ('' !== $instagram) {
2508 - $platform_tags['instagram-site-verification'] = $instagram;
2509 - }
2510 -
2511 - // TikTok verification
2512 - $tiktok = $resolve('tiktok_verification');
2513 - if ('' !== $tiktok) {
2514 - $platform_tags['tiktok-site-verification'] = $tiktok;
2515 - }
2516 -
2517 - // YouTube channel verification
2518 - $youtube = $resolve('youtube_channel_id');
2519 - if ('' !== $youtube) {
2520 - $platform_tags['youtube-channel-id'] = $youtube;
2521 - }
2522 -
2523 - // WhatsApp Business verification
2524 - $whatsapp = $resolve('whatsapp_business_id');
2525 - if ('' !== $whatsapp) {
2526 - $platform_tags['whatsapp-business-id'] = $whatsapp;
2527 - }
2528 -
2529 2941 return $platform_tags;
2530 2942 }
2531 2943
2532 2944 /**
2945 + * Platform verification settings, mapped to the meta name each is emitted
2946 + * under. One list so the batch primed in generate_platform_meta_tags() and
2947 + * the keys it then reads cannot drift apart.
2948 + *
2949 + * @since 2.1.0
2950 + * @var array<string,string>
2951 + */
2952 + private const PLATFORM_META_KEYS = [
2953 + 'facebook_app_id' => 'fb:app_id',
2954 + 'facebook_admins' => 'fb:admins',
2955 + 'pinterest_site_verification' => 'pinterest-site-verification',
2956 + 'instagram_verification' => 'instagram-site-verification',
2957 + 'tiktok_verification' => 'tiktok-site-verification',
2958 + 'youtube_channel_id' => 'youtube-channel-id',
2959 + 'whatsapp_business_id' => 'whatsapp-business-id',
2960 + ];
2961 +
2962 + /**
2533 2963 * Convert OG, Twitter, and Platform tags to HTML meta tags
2534 2964 *
2535 2965 * @since 1.0.0
2536 2966 *
@@ -2611,13 +3041,14 @@
2611 3041 return $validation;
2612 3042 }
2613 3043
2614 3044 // Get image metadata if it's a local attachment
2615 - $attachment_id = attachment_url_to_postid($image_url);
3045 + $attachment_id = Attachment_Lookup::id_from_url($image_url);
2616 3046 if ($attachment_id) {
2617 3047 $image_meta = wp_get_attachment_metadata($attachment_id);
2618 - $meta_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
2619 - $meta_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
3048 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
3049 + $meta_width = $image_file['width'];
3050 + $meta_height = $image_file['height'];
2620 3051
2621 3052 // SVGs and other vector uploads store 0x0 metadata — skip the
2622 3053 // dimension/ratio checks instead of dividing by 0.
2623 3054 if ($image_meta && $meta_width > 0 && $meta_height > 0) {