PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.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 All 57 releases
← All changes | includes/seo/class-social-meta-manager.php +538 -99 1.31.0 → 2.14.2 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'])) {
@@ -492,12 +523,29 @@
492 523 $validation['valid'] = false;
493 524 }
494 525 }
495 526
527 + // The default Open Graph and Twitter images are checked in the field
528 + // details above only while their feature is switched on, but they are
529 + // stored (and shown in the admin preview's src) either way.
530 + $flagged = [];
531 + foreach ($field_details as $field) {
532 + if ('error' === ($field['status'] ?? '')) {
533 + $flagged[] = $field['field'] ?? '';
534 + }
535 + }
536 + foreach (['default_og_image' => 'Default Open Graph image', 'default_twitter_image' => 'Default Twitter Card image'] as $key => $label) {
537 + if (!empty($settings[$key]) && !in_array($key, $flagged, true)
538 + && !\ThinkRank\Core\Url_Validator::is_http_url($settings[$key])) {
539 + $validation['errors'][] = $label . ' must be an http or https URL.';
540 + $validation['valid'] = false;
541 + }
542 + }
543 +
496 544 // Validate default image
497 545 if (isset($settings['default_image']) && !empty($settings['default_image'])) {
498 - if (!filter_var($settings['default_image'], FILTER_VALIDATE_URL)) {
499 - $validation['errors'][] = 'default_image must be a valid URL';
546 + if (!\ThinkRank\Core\Url_Validator::is_http_url($settings['default_image'])) {
547 + $validation['errors'][] = 'default_image must be an http or https URL';
500 548 $validation['valid'] = false;
501 549 } else {
502 550 // Check image dimensions and format
503 551 $image_validation = $this->validate_social_image($settings['default_image']);
@@ -735,9 +783,12 @@
735 783 }
736 784
737 785 // Default Image validation
738 786 if (!empty($settings['default_og_image'])) {
739 - if (filter_var($settings['default_og_image'], FILTER_VALIDATE_URL)) {
787 + // http(s) only, and refused rather than warned about: the
788 + // value becomes og:image and the admin preview's src, and a
789 + // javascript: URL passed is_valid().
790 + if (\ThinkRank\Core\Url_Validator::is_http_url($settings['default_og_image'])) {
740 791 $field_details[] = [
741 792 'field' => 'default_og_image',
742 793 'label' => 'Default Open Graph image is configured.',
743 794 'status' => 'valid',
@@ -745,11 +796,11 @@
745 796 ];
746 797 } else {
747 798 $field_details[] = [
748 799 'field' => 'default_og_image',
749 - 'label' => 'Default Open Graph image URL appears invalid.',
750 - 'status' => 'warning',
751 - 'icon' => '⚠'
800 + 'label' => 'Default Open Graph image must be an http or https URL.',
801 + 'status' => 'error',
802 + 'icon' => '✗'
752 803 ];
753 804 }
754 805 } else {
755 806 $field_details[] = [
@@ -869,9 +920,9 @@
869 920 }
870 921
871 922 // Default Twitter Image validation
872 923 if (!empty($settings['default_twitter_image'])) {
873 - if (filter_var($settings['default_twitter_image'], FILTER_VALIDATE_URL)) {
924 + if (\ThinkRank\Core\Url_Validator::is_http_url($settings['default_twitter_image'])) {
874 925 $field_details[] = [
875 926 'field' => 'default_twitter_image',
876 927 'label' => 'Default Twitter Card image is configured.',
877 928 'status' => 'valid',
@@ -879,11 +930,11 @@
879 930 ];
880 931 } else {
881 932 $field_details[] = [
882 933 'field' => 'default_twitter_image',
883 - 'label' => 'Default Twitter Card image URL appears invalid.',
884 - 'status' => 'warning',
885 - 'icon' => '⚠'
934 + 'label' => 'Default Twitter Card image must be an http or https URL.',
935 + 'status' => 'error',
936 + 'icon' => '✗'
886 937 ];
887 938 }
888 939 } else {
889 940 $field_details[] = [
@@ -937,8 +988,12 @@
937 988
938 989 $settings = $this->get_settings($context_type, $context_id);
939 990 $output = [
940 991 'og_tags' => [],
992 + // Secondary og:image entries. A separate list because $og_tags is
993 + // keyed by property name and so can hold exactly one 'og:image'
994 + // (#636); the emitter writes these straight after the primary.
995 + 'og_extra_images' => [],
941 996 'twitter_tags' => [],
942 997 'meta_tags' => [],
943 998 'platform_tags' => [],
944 999 // Per-feature flags so each emitter can honor its own toggle. The
@@ -972,8 +1027,18 @@
972 1027 // Add custom OG tags
973 1028 if (!empty($settings['custom_og_tags'])) {
974 1029 $output['og_tags'] = array_merge($output['og_tags'], $settings['custom_og_tags']);
975 1030 }
1031 +
1032 + // Alternatives to offer after the primary image. Collected from the
1033 + // resolved primary rather than re-deriving it, so the two can never
1034 + // disagree about which image is the main one.
1035 + if (!empty($settings['og_multiple_images'])) {
1036 + $output['og_extra_images'] = Social_Images::additional(
1037 + (int) $context_id,
1038 + (string) ($output['og_tags']['og:image'] ?? '')
1039 + );
1040 + }
976 1041 }
977 1042
978 1043 // Generate Twitter Card tags if enabled
979 1044 if ($twitter_enabled) {
@@ -1009,8 +1074,33 @@
1009 1074 'default_image',
1010 1075 ];
1011 1076
1012 1077 /**
1078 + * Site-wide switches with no per-context equivalent.
1079 + *
1080 + * Unlike INHERITED_SITE_KEYS above, these must inherit their site value
1081 + * even when it is FALSE. The empty()-guarded loop used for images can only
1082 + * ever propagate "on", which is right for an image URL (empty means "not
1083 + * configured") and wrong for a boolean, where false is a deliberate choice.
1084 + *
1085 + * Without this the master Open Graph / Twitter Cards switches were honoured
1086 + * on the homepage (mapped to the `site` context) and ignored on every post
1087 + * and page, which read as though the toggle had worked (#557).
1088 + *
1089 + * @since 2.2.0
1090 + * @var string[]
1091 + */
1092 + private const SITE_ONLY_KEYS = [
1093 + 'enable_open_graph',
1094 + 'enable_twitter_cards',
1095 + // Same shape as the two above and the same trap: a site-wide switch
1096 + // with no per-context equivalent. Left out, it read as on while the
1097 + // homepage rendered and as off on every post and page — which is where
1098 + // the alternatives it controls actually come from (#636).
1099 + 'og_multiple_images',
1100 + ];
1101 +
1102 + /**
1013 1103 * Get settings for a context, inheriting the site-wide default images
1014 1104 *
1015 1105 * Two corrections over the generic lookup:
1016 1106 *
@@ -1043,8 +1133,18 @@
1043 1133 return $settings;
1044 1134 }
1045 1135
1046 1136 $site_settings = parent::get_settings('site', null);
1137 +
1138 + // Site-wide switches: the site value is authoritative, false included.
1139 + // array_key_exists, not empty() — '' is how a disabled toggle is stored.
1140 + foreach (self::SITE_ONLY_KEYS as $key) {
1141 + if (array_key_exists($key, $site_settings)) {
1142 + $settings[$key] = $site_settings[$key];
1143 + }
1144 + }
1145 +
1146 + // Default images: inherit only when this context has none of its own.
1047 1147 foreach (self::INHERITED_SITE_KEYS as $key) {
1048 1148 if (empty($settings[$key]) && !empty($site_settings[$key])) {
1049 1149 $settings[$key] = $site_settings[$key];
1050 1150 }
@@ -1080,8 +1180,12 @@
1080 1180 'og_locale' => '',
1081 1181 'default_og_image' => '',
1082 1182 'og_image_width' => 1200,
1083 1183 'og_image_height' => 630,
1184 + // Offer alternative og:image tags after the primary one (#636).
1185 + // Off by default: a page that shares with one image today must
1186 + // keep sharing with that image after an update.
1187 + 'og_multiple_images' => false,
1084 1188
1085 1189 // Twitter Cards settings
1086 1190 'enable_twitter_cards' => true,
1087 1191 'twitter_username' => '',
@@ -1121,8 +1225,15 @@
1121 1225 'fallback_to_excerpt' => true,
1122 1226 'strip_html_tags' => true,
1123 1227 'max_description_length' => 160,
1124 1228
1229 + // oEmbed card (#637). All three default off: this rewrites what
1230 + // other people's sites display, so an upgrade must not silently
1231 + // change a card an existing embed has been showing for months.
1232 + 'oembed_use_seo_title' => false,
1233 + 'oembed_use_social_image' => false,
1234 + 'oembed_remove_author' => false,
1235 +
1125 1236 // Legacy format for backward compatibility (only when needed)
1126 1237 'custom_og_tags' => [],
1127 1238 'image_optimization' => true,
1128 1239 'auto_generate' => true
@@ -1406,10 +1517,15 @@
1406 1517 'description' => '',
1407 1518 'url' => '',
1408 1519 'image' => '',
1409 1520 'twitter_title' => '', // Separate field for a Twitter-specific title
1521 + 'twitter_description' => '', // Separate field for a Twitter-specific description
1410 1522 'twitter_image' => '', // Separate field for Twitter-specific images
1411 - 'type' => 'website',
1523 + // '' rather than 'website' means "derive it from the context".
1524 + // Seeding a concrete type here made an explicit choice
1525 + // indistinguishable from the shipped default (#398).
1526 + 'type' => '',
1527 + 'twitter_card_type' => '',
1412 1528 'author' => [],
1413 1529 'published_time' => '',
1414 1530 'modified_time' => '',
1415 1531 'site_name' => $settings['og_site_name'] ?? get_bloginfo('name')
@@ -1414,8 +1530,12 @@
1414 1530 'modified_time' => '',
1415 1531 'site_name' => $settings['og_site_name'] ?? get_bloginfo('name')
1416 1532 ];
1417 1533
1534 + // Explicit type choices, resolved once for whichever context this is.
1535 + $data['type'] = $this->configured_og_type($settings);
1536 + $data['twitter_card_type'] = $this->configured_twitter_card_type($settings);
1537 +
1418 1538 if ($context_type === 'site') {
1419 1539 // Site-wide data with Social Media tab settings priority.
1420 1540 //
1421 1541 // og:title priority: an explicitly-configured OG Site Name wins;
@@ -1433,15 +1553,33 @@
1433 1553 $data['title'] = $fallback_title;
1434 1554 } else {
1435 1555 $data['title'] = $blogname;
1436 1556 }
1437 - $data['description'] = $settings['og_description'] ?? get_bloginfo('description');
1557 + // Same rule as the title above: the shipped default (the tagline)
1558 + // is not a choice, so the homepage's meta description wins over
1559 + // it. Without this an imported or hand-set homepage description
1560 + // printed as the meta description while og:/twitter:description
1561 + // kept the tagline (#897).
1562 + $og_description = (string) ($settings['og_description'] ?? '');
1563 + if ($og_description !== '' && $og_description !== get_bloginfo('description')) {
1564 + $data['description'] = $og_description;
1565 + } elseif ($fallback_description !== null && $fallback_description !== '') {
1566 + $data['description'] = $fallback_description;
1567 + } else {
1568 + $data['description'] = get_bloginfo('description');
1569 + }
1438 1570 // Use home_url('/') so og:url matches the homepage canonical
1439 1571 // (class-seo-manager.php) and the WebSite schema, which both include
1440 1572 // the trailing slash. A bare home_url() would key a different URL in
1441 1573 // social caches than the canonical.
1442 - $data['url'] = home_url('/');
1443 - $data['type'] = $settings['og_type'] ?? 'website';
1574 + //
1575 + // Except when this is not the site home. detect_current_context()
1576 + // collapses is_home() && !is_front_page() into 'homepage', so a
1577 + // static posts page — and page 2 of any blog listing — advertised
1578 + // the site home as its og:url while its own canonical said
1579 + // otherwise (#397).
1580 + $data['url'] = self::current_home_url();
1581 +
1444 1582 // Leaving this null lets generate_og_tags() fall through to
1445 1583 // get_og_locale(), which is what every other context already does.
1446 1584 //
1447 1585 // A stored 'en_US' is deliberately treated as "not set". It was the
@@ -1490,8 +1628,18 @@
1490 1628 (string) get_post_meta($post->ID, '_thinkrank_twitter_title', true),
1491 1629 $post->ID
1492 1630 );
1493 1631
1632 + // Per-post Twitter-specific description. twitter:description
1633 + // falls back to the OG/meta description when this is empty, so
1634 + // only capture it here; generate_twitter_tags() applies the
1635 + // fallback. There was no counterpart to the title lookup above,
1636 + // so the stored value never entered $data at all (#406).
1637 + $data['twitter_description'] = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1638 + (string) get_post_meta($post->ID, '_thinkrank_twitter_description', true),
1639 + $post->ID
1640 + );
1641 +
1494 1642 // Title priority: per-post OG override > effective SEO title
1495 1643 // (document <title> / metabox preview) > post title.
1496 1644 if ($og_title_override !== '') {
1497 1645 $data['title'] = $og_title_override;
@@ -1508,10 +1656,14 @@
1508 1656 $data['description'] = $fallback_description;
1509 1657 } else {
1510 1658 $data['description'] = $this->get_social_description($post);
1511 1659 }
1512 - $data['url'] = get_permalink($post);
1513 - $data['type'] = $settings['og_type'] ?? $this->determine_og_type($context_type, []);
1660 + // The canonical carries the <!--nextpage--> sub-page and the
1661 + // comment page; og:url said page 1 regardless, which is the
1662 + // same contradiction #397 fixed for the blog listing, one page
1663 + // type over (#397 review).
1664 + $data['url'] = self::with_singular_page((string) get_permalink($post));
1665 +
1514 1666 $data['published_time'] = get_the_date('c', $post);
1515 1667 $data['modified_time'] = get_the_modified_date('c', $post);
1516 1668 $data['post_id'] = $post->ID;
1517 1669
@@ -1539,8 +1691,93 @@
1539 1691 // logo/site-icon fallback in generate_og_tags() and ignored a
1540 1692 // configured default OG image.
1541 1693 $data['image'] = $this->get_og_image_for_context($settings, null);
1542 1694 $data['twitter_image'] = $this->get_twitter_image_for_context($settings, null);
1695 +
1696 + // A term archive owns SEO values of its own, and the caller has
1697 + // already resolved them into the fallbacks — the same strings
1698 + // rendered as the document <title> and the description tag. Leaving
1699 + // title and description empty here published the bare site name as
1700 + // og:title on every archive and no og:description at all, so a term
1701 + // SEO title never reached a social surface (#386).
1702 + // Archives have a URL of their own. The seed leaves it '', and the
1703 + // og:url emitter could not fall through to get_current_url() while
1704 + // the key existed, so no archive carried an og:url at all (#388).
1705 + // A search archive's URL is the search link, not the bare request
1706 + // path — get_current_url() reads $wp->request, which is empty for a
1707 + // search served from the front page, so og:url pointed at the site
1708 + // home while the page was a search result.
1709 + // The non-search archive URL is the page's own canonical, so og:url
1710 + // and <link rel="canonical"> agree on the paginated page rather than
1711 + // both claiming page 1 (#397).
1712 + // `?:` keeps the #388 guarantee that an archive always carries an
1713 + // og:url: get_non_singular_canonical_url() returns '' for a view it
1714 + // has no canonical rule for, and the request URL is still better
1715 + // than no tag at all.
1716 + $data['url'] = is_search()
1717 + ? get_search_link()
1718 + : (self::current_archive_url() ?: $this->get_current_url());
1719 +
1720 + $term = get_queried_object();
1721 + $term_id = ($term instanceof \WP_Term) ? (int) $term->term_id : 0;
1722 +
1723 + $og_title_override = '';
1724 + $og_description_override = '';
1725 +
1726 + if ($term_id > 0) {
1727 + // Terms carry the same social override keys as posts — the
1728 + // abilities API and the metabox both write them.
1729 + $og_title_override = Pattern_Resolver::resolve_term_value(
1730 + (string) get_term_meta($term_id, '_thinkrank_og_title', true),
1731 + $term_id
1732 + );
1733 + $og_description_override = Pattern_Resolver::resolve_term_value(
1734 + (string) get_term_meta($term_id, '_thinkrank_og_description', true),
1735 + $term_id
1736 + );
1737 +
1738 + // Twitter Cards fall back to og:title when this is empty, so
1739 + // only capture it; generate_twitter_tags() applies the fallback.
1740 + $data['twitter_title'] = Pattern_Resolver::resolve_term_value(
1741 + (string) get_term_meta($term_id, '_thinkrank_twitter_title', true),
1742 + $term_id
1743 + );
1744 + $data['twitter_description'] = Pattern_Resolver::resolve_term_value(
1745 + (string) get_term_meta($term_id, '_thinkrank_twitter_description', true),
1746 + $term_id
1747 + );
1748 +
1749 + $term_og_image = (string) get_term_meta($term_id, '_thinkrank_og_image', true);
1750 + if ('' !== $term_og_image) {
1751 + $data['image'] = $term_og_image;
1752 + }
1753 +
1754 + $term_twitter_image = (string) get_term_meta($term_id, '_thinkrank_twitter_image', true);
1755 + if ('' !== $term_twitter_image) {
1756 + $data['twitter_image'] = $term_twitter_image;
1757 + }
1758 + }
1759 +
1760 + // Author, date and search archives have no ThinkRank-managed title
1761 + // to inherit — Author_Archives_Manager owns the author one, and the
1762 + // rest have no template — so the caller's fallback arrives empty and
1763 + // og:title used to collapse to the bare site name. Fall through to
1764 + // what the page itself is called (#388).
1765 + if ($og_title_override !== '') {
1766 + $data['title'] = $og_title_override;
1767 + } elseif ($fallback_title !== null && $fallback_title !== '') {
1768 + $data['title'] = $fallback_title;
1769 + } else {
1770 + $data['title'] = $this->archive_fallback_title();
1771 + }
1772 +
1773 + if ($og_description_override !== '') {
1774 + $data['description'] = $og_description_override;
1775 + } elseif ($fallback_description !== null && $fallback_description !== '') {
1776 + $data['description'] = $fallback_description;
1777 + } else {
1778 + $data['description'] = $this->archive_fallback_description($term_id);
1779 + }
1543 1780 }
1544 1781
1545 1782 return $data;
1546 1783 }
@@ -1545,8 +1782,85 @@
1545 1782 return $data;
1546 1783 }
1547 1784
1548 1785 /**
1786 + * The canonical URL of the archive currently being rendered.
1787 + *
1788 + * Deliberately the same value class-seo-manager.php puts in
1789 + * <link rel="canonical">: an og:url that disagrees with the canonical is
1790 + * the bug this is fixing, so the two read from one source.
1791 + *
1792 + * @since 2.0.1
1793 + *
1794 + * @return string Archive URL, '' when there is none (search, 404).
1795 + */
1796 + private static function current_archive_url(): string {
1797 + if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) {
1798 + return '';
1799 + }
1800 +
1801 + return \ThinkRank\Frontend\SEO_Manager::get_non_singular_canonical_url();
1802 + }
1803 +
1804 + /**
1805 + * A permalink with the current sub-page or comment page appended.
1806 + *
1807 + * @since 2.0.1
1808 + *
1809 + * @param string $url Permalink.
1810 + * @return string
1811 + */
1812 + private static function with_singular_page(string $url): string {
1813 + if ('' === $url || !class_exists('\ThinkRank\Frontend\SEO_Manager')) {
1814 + return $url;
1815 + }
1816 +
1817 + return \ThinkRank\Frontend\SEO_Manager::with_singular_page($url);
1818 + }
1819 +
1820 + /**
1821 + * The URL of the page the 'site' context is actually being rendered for.
1822 + *
1823 + * home_url('/') for the front page, the posts page's own permalink when the
1824 + * site uses a static front page, and the paginated variant on page 2+.
1825 + *
1826 + * @since 2.0.1
1827 + *
1828 + * @return string
1829 + */
1830 + private static function current_home_url(): string {
1831 + $url = home_url('/');
1832 +
1833 + if (function_exists('is_home') && is_home() && !is_front_page()) {
1834 + $posts_page = (int) get_option('page_for_posts');
1835 +
1836 + if ($posts_page > 0) {
1837 + $permalink = get_permalink($posts_page);
1838 +
1839 + if (is_string($permalink) && '' !== $permalink) {
1840 + $url = $permalink;
1841 + }
1842 + }
1843 + }
1844 +
1845 + if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) {
1846 + return $url;
1847 + }
1848 +
1849 + // A static front page is a singular view, so its page number lives in
1850 + // `page`, not `paged` — the canonical already reads it that way, and
1851 + // og:url has to agree or the two describe different URLs.
1852 + if (function_exists('is_singular') && is_singular()) {
1853 + return \ThinkRank\Frontend\SEO_Manager::with_singular_page($url);
1854 + }
1855 +
1856 + return \ThinkRank\Frontend\SEO_Manager::with_pagination(
1857 + $url,
1858 + \ThinkRank\Frontend\SEO_Manager::current_page_number()
1859 + );
1860 + }
1861 +
1862 + /**
1549 1863 * Get Open Graph image for context with proper fallback
1550 1864 *
1551 1865 * @since 1.0.0
1552 1866 *
@@ -1560,8 +1874,12 @@
1560 1874 $image_id = get_post_thumbnail_id($post);
1561 1875 if ($image_id) {
1562 1876 $image_data = wp_get_attachment_image_src($image_id, 'large');
1563 1877 if (!empty($image_data[0])) {
1878 + // The ID is in hand here and gone once this returns a
1879 + // bare URL; say so rather than have the tag builders look
1880 + // it up again, twice, with a URL core cannot match (#847).
1881 + Attachment_Lookup::remember((string) $image_data[0], (int) $image_id);
1564 1882 return $image_data[0];
1565 1883 }
1566 1884 }
1567 1885 }
@@ -1608,8 +1926,12 @@
1608 1926 $image_id = get_post_thumbnail_id($post);
1609 1927 if ($image_id) {
1610 1928 $image_data = wp_get_attachment_image_src($image_id, 'large');
1611 1929 if (!empty($image_data[0])) {
1930 + // The ID is in hand here and gone once this returns a
1931 + // bare URL; say so rather than have the tag builders look
1932 + // it up again, twice, with a URL core cannot match (#847).
1933 + Attachment_Lookup::remember((string) $image_data[0], (int) $image_id);
1612 1934 return $image_data[0];
1613 1935 }
1614 1936 }
1615 1937 }
@@ -1640,15 +1962,33 @@
1640 1962 * @param \WP_Post $post Post object
1641 1963 * @return string Social media description
1642 1964 */
1643 1965 private function get_social_description(\WP_Post $post): string {
1644 - // Try excerpt first
1645 - $description = get_the_excerpt($post);
1966 + // A password-gated body must never become a social description. Core
1967 + // answers get_the_excerpt() with its "There is no excerpt because this
1968 + // is a protected post." placeholder rather than the body, so today the
1969 + // derive-from-content fallback below is unreachable here — but it is one
1970 + // core change away from leaking, and that placeholder sentence is not a
1971 + // description worth publishing to every crawler and unfurler either.
1972 + // An authored post_excerpt is written for public consumption, so it
1973 + // still stands (#363).
1974 + if (function_exists('post_password_required') && post_password_required($post)) {
1975 + return '' !== $post->post_excerpt ? $post->post_excerpt : get_bloginfo('description');
1976 + }
1646 1977
1647 - // If no excerpt, generate from content
1978 + // Try excerpt first. On a Bricks page core would derive that excerpt
1979 + // from the `post_content` Bricks throws away, so the visible body is
1980 + // used instead — a hand-written excerpt still wins (#651).
1981 + $superseding = Builder_Content::superseding_excerpt_source($post);
1982 + $description = '' !== $superseding
1983 + ? Pattern_Resolver::derive_excerpt($superseding, 30)
1984 + : get_the_excerpt($post);
1985 +
1986 + // If no excerpt, generate from content. Shortcodes and block delimiters
1987 + // are removed the way core's wp_trim_excerpt() does, so a shortcode-built
1988 + // page does not publish its source as og:description (#387).
1648 1989 if (empty($description)) {
1649 - $content = wp_strip_all_tags($post->post_content);
1650 - $description = wp_trim_words($content, 30, '...');
1990 + $description = Pattern_Resolver::derive_excerpt(Builder_Content::visible_content($post), 30);
1651 1991 }
1652 1992
1653 1993 // If still empty, use site description
1654 1994 if (empty($description)) {
@@ -1689,8 +2029,16 @@
1689 2029 * @param string $context Context type
1690 2030 * @return string Twitter Card type
1691 2031 */
1692 2032 private function determine_twitter_card_type(array $data, string $context): string {
2033 + // An explicitly chosen card type wins. Without this the setting was
2034 + // inert and the `app` and `player` options in the UI could never be
2035 + // emitted at all (#398).
2036 + $chosen = (string) ($data['twitter_card_type'] ?? '');
2037 + if ('' !== $chosen) {
2038 + return $chosen;
2039 + }
2040 +
1693 2041 // Use a large-image card when a Twitter image will actually be emitted.
1694 2042 // twitter:image resolves to the twitter-specific image first, then the OG
1695 2043 // image, so key the card type off the same precedence.
1696 2044 $twitter_image = !empty($data['twitter_image']) ? $data['twitter_image'] : ($data['image'] ?? '');
@@ -1697,8 +2045,105 @@
1697 2045 return !empty($twitter_image) ? 'summary_large_image' : 'summary';
1698 2046 }
1699 2047
1700 2048 /**
2049 + * What an archive calls itself, for the og:title of last resort.
2050 + *
2051 + * Deliberately not wp_get_document_title(): that re-enters the
2052 + * pre_get_document_title filter this plugin short-circuits, so it would
2053 + * recurse. get_the_archive_title() wraps its subject in a <span>, hence the
2054 + * strip.
2055 + *
2056 + * @since 2.0.1
2057 + *
2058 + * @return string Archive title, or '' when there is nothing sensible to say.
2059 + */
2060 + private function archive_fallback_title(): string {
2061 + if (is_search()) {
2062 + /* translators: %s: search query. */
2063 + return trim(sprintf(__('Search Results for "%s"', 'thinkrank'), get_search_query()));
2064 + }
2065 +
2066 + if (!function_exists('get_the_archive_title')) {
2067 + return '';
2068 + }
2069 +
2070 + return trim(wp_strip_all_tags((string) get_the_archive_title()));
2071 + }
2072 +
2073 + /**
2074 + * What an archive says about itself, for the og:description of last resort.
2075 + *
2076 + * @since 2.0.1
2077 + *
2078 + * @param int $term_id Queried term, or 0 when the archive is not a term.
2079 + * @return string Archive description, or '' when there is none.
2080 + */
2081 + private function archive_fallback_description(int $term_id): string {
2082 + if ($term_id > 0) {
2083 + $description = trim(wp_strip_all_tags((string) term_description($term_id)));
2084 +
2085 + if ('' !== $description) {
2086 + return $description;
2087 + }
2088 + }
2089 +
2090 + if (is_author()) {
2091 + $bio = trim(wp_strip_all_tags((string) get_the_author_meta('description', (int) get_query_var('author'))));
2092 +
2093 + if ('' !== $bio) {
2094 + return $bio;
2095 + }
2096 + }
2097 +
2098 + return '';
2099 + }
2100 +
2101 + /**
2102 + * The Open Graph type the user explicitly chose, or '' when they did not.
2103 + *
2104 + * `og_type` ships a default of 'website' that is merged into every settings
2105 + * read, so a stored 'website' cannot be told apart from "never touched" —
2106 + * the same trap the `og_locale` note above documents. Treating it as unset
2107 + * keeps determine_og_type() reachable, so a post is still `article`, while
2108 + * any other stored value is a genuine override and wins.
2109 + *
2110 + * @since 2.0.1
2111 + *
2112 + * @param array $settings Social meta settings.
2113 + * @return string The chosen type, or '' for "derive it".
2114 + */
2115 + private function configured_og_type(array $settings): string {
2116 + $type = trim((string) ($settings['og_type'] ?? ''));
2117 +
2118 + return ('' === $type || 'website' === $type) ? '' : $type;
2119 + }
2120 +
2121 + /**
2122 + * The Twitter card type the user explicitly chose, or '' when they did not.
2123 + *
2124 + * Same reasoning as configured_og_type(): the shipped default is
2125 + * 'summary_large_image', which is also what the automatic rule produces
2126 + * whenever an image is available, so it is treated as "not chosen" and the
2127 + * automatic rule stays in charge. `summary`, `app` and `player` are real
2128 + * choices and are honoured.
2129 + *
2130 + * @since 2.0.1
2131 + *
2132 + * @param array $settings Social meta settings.
2133 + * @return string The chosen card type, or '' for "derive it".
2134 + */
2135 + private function configured_twitter_card_type(array $settings): string {
2136 + $type = trim((string) ($settings['twitter_card_type'] ?? ''));
2137 +
2138 + if (!in_array($type, ['summary', 'app', 'player'], true)) {
2139 + return '';
2140 + }
2141 +
2142 + return $type;
2143 + }
2144 +
2145 + /**
1701 2146 * Optimize title for platform requirements
1702 2147 *
1703 2148 * @since 1.0.0
1704 2149 *
@@ -1718,16 +2163,13 @@
1718 2163 if (mb_strlen($title) <= $max_length) {
1719 2164 return $title;
1720 2165 }
1721 2166
1722 - // Truncate at word boundary
1723 - $truncated = wp_trim_words($title, 10, '');
1724 - if (mb_strlen($truncated) <= $max_length) {
1725 - return $truncated;
1726 - }
1727 -
1728 - // Hard truncate if necessary
1729 - return mb_substr($title, 0, $max_length - 3) . '...';
2167 + // Truncate at a word boundary where there is one, and hard-cut where
2168 + // there is not. This used to try wp_trim_words() first, which counts
2169 + // CHARACTERS on th/ja/zh_* — so it returned ~10 characters, passed the
2170 + // $max_length check below, and that was accepted as the title (#687).
2171 + return \ThinkRank\Core\Seo_Text::trim_to_length($title, $max_length);
1730 2172 }
1731 2173
1732 2174 /**
1733 2175 * Optimize description for platform requirements
@@ -1750,16 +2192,14 @@
1750 2192 if (mb_strlen($description) <= $max_length) {
1751 2193 return $description;
1752 2194 }
1753 2195
1754 - // Truncate at word boundary
1755 - $truncated = wp_trim_words($description, 25, '');
1756 - if (mb_strlen($truncated) <= $max_length) {
1757 - return $truncated;
1758 - }
1759 -
1760 - // Hard truncate if necessary
1761 - return mb_substr($description, 0, $max_length - 3) . '...';
2196 + // Truncate at a word boundary where there is one, and hard-cut where
2197 + // there is not. This used to try wp_trim_words() first, which counts
2198 + // words in English but CHARACTERS in th/ja/zh_* — so on those locales
2199 + // it returned ~25 characters, comfortably under $max_length, and that
2200 + // was accepted as the answer (#687).
2201 + return \ThinkRank\Core\Seo_Text::trim_to_length($description, $max_length);
1762 2202 }
1763 2203
1764 2204 /**
1765 2205 * Optimize image for platform requirements
@@ -1784,21 +2224,23 @@
1784 2224 if (empty($image_url)) {
1785 2225 return $image_data;
1786 2226 }
1787 2227
1788 - // Get image metadata
1789 - $attachment_id = attachment_url_to_postid($image_url);
2228 + // Get image metadata. The dimensions are those of the file this URL
2229 + // names — usually a generated size — not of the original upload, so
2230 + // the width and height published beside it describe the same image.
2231 + $attachment_id = Attachment_Lookup::id_from_url($image_url);
1790 2232 if ($attachment_id) {
1791 2233 $image_meta = wp_get_attachment_metadata($attachment_id);
1792 2234 $image_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
1793 - $mime_type = get_post_mime_type($attachment_id);
2235 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
1794 2236
1795 2237 if ($image_meta && isset($image_meta['width'], $image_meta['height'])) {
1796 - $width = (int) $image_meta['width'];
1797 - $height = (int) $image_meta['height'];
2238 + $width = $image_file['width'];
2239 + $height = $image_file['height'];
1798 2240
1799 2241 $image_data['alt'] = $image_alt ?: '';
1800 - $image_data['type'] = $mime_type ?: '';
2242 + $image_data['type'] = $image_file['type'];
1801 2243
1802 2244 // SVGs and other vector uploads store 0x0 metadata. Dimension
1803 2245 // checks are meaningless there and dividing by 0 is fatal.
1804 2246 if ($width > 0 && $height > 0) {
@@ -1850,8 +2292,9 @@
1850 2292 $custom_logo_id = get_theme_mod('custom_logo');
1851 2293 if ($custom_logo_id) {
1852 2294 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'large');
1853 2295 if ($logo_data) {
2296 + Attachment_Lookup::remember((string) $logo_data[0], (int) $custom_logo_id);
1854 2297 return $logo_data[0];
1855 2298 }
1856 2299 }
1857 2300
@@ -1859,8 +2302,9 @@
1859 2302 $site_icon_id = get_option('site_icon');
1860 2303 if ($site_icon_id) {
1861 2304 $icon_data = wp_get_attachment_image_src($site_icon_id, 'large');
1862 2305 if ($icon_data) {
2306 + Attachment_Lookup::remember((string) $icon_data[0], (int) $site_icon_id);
1863 2307 return $icon_data[0];
1864 2308 }
1865 2309 }
1866 2310
@@ -2287,12 +2731,13 @@
2287 2731 private function make_description_catchy(string $description): string {
2288 2732 // TikTok prefers short, catchy descriptions
2289 2733 $catchy_words = ['viral', 'trending', 'must-see', 'epic', 'mind-blowing'];
2290 2734
2291 - // Limit to 100 characters for TikTok
2292 - if (strlen($description) > 100) {
2293 - $description = substr($description, 0, 97) . '...';
2294 - }
2735 + // Limit to 100 characters for TikTok. strlen()/substr() count BYTES,
2736 + // so this both fired three times too early on Thai/CJK text and cut
2737 + // mid-character, emitting a broken UTF-8 sequence rather than a short
2738 + // description (#687).
2739 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description, 100);
2295 2740
2296 2741 if (!preg_match('/\b(' . implode('|', $catchy_words) . ')\b/i', $description)) {
2297 2742 $description = '🔥 ' . $description;
2298 2743 }
@@ -2485,8 +2930,16 @@
2485 2930 private function generate_platform_meta_tags(array $settings): array {
2486 2931 $platform_tags = [];
2487 2932
2488 2933 $core = \ThinkRank\Core\Settings::instance();
2934 +
2935 + // One query for all seven instead of one query each. They are
2936 + // autoload=off like every thinkrank_* option, so WordPress cannot
2937 + // batch them out of `alloptions`, and this runs on every anonymous
2938 + // front-end request — on most sites to discover that all seven are
2939 + // empty and no tag is emitted at all (#393).
2940 + $core->prime(array_keys(self::PLATFORM_META_KEYS));
2941 +
2489 2942 // Core Settings (decrypted for sensitive keys) wins; the table value is a
2490 2943 // backward-compat fallback for installs that saved these before the UI
2491 2944 // moved to the Social Platforms tab.
2492 2945 $resolve = static function (string $key) use ($core, $settings): string {
@@ -2496,53 +2949,38 @@
2496 2949 }
2497 2950 return $value;
2498 2951 };
2499 2952
2500 - // Facebook meta tags
2501 - $facebook_app_id = $resolve('facebook_app_id');
2502 - if ('' !== $facebook_app_id) {
2503 - $platform_tags['fb:app_id'] = $facebook_app_id;
2504 - }
2953 + foreach (self::PLATFORM_META_KEYS as $key => $meta_name) {
2954 + $value = $resolve($key);
2505 2955
2506 - $facebook_admins = $resolve('facebook_admins');
2507 - if ('' !== $facebook_admins) {
2508 - $platform_tags['fb:admins'] = $facebook_admins;
2956 + if ('' !== $value) {
2957 + $platform_tags[$meta_name] = $value;
2958 + }
2509 2959 }
2510 2960
2511 - // Pinterest site verification
2512 - $pinterest = $resolve('pinterest_site_verification');
2513 - if ('' !== $pinterest) {
2514 - $platform_tags['pinterest-site-verification'] = $pinterest;
2515 - }
2516 -
2517 - // Instagram verification
2518 - $instagram = $resolve('instagram_verification');
2519 - if ('' !== $instagram) {
2520 - $platform_tags['instagram-site-verification'] = $instagram;
2521 - }
2522 -
2523 - // TikTok verification
2524 - $tiktok = $resolve('tiktok_verification');
2525 - if ('' !== $tiktok) {
2526 - $platform_tags['tiktok-site-verification'] = $tiktok;
2527 - }
2528 -
2529 - // YouTube channel verification
2530 - $youtube = $resolve('youtube_channel_id');
2531 - if ('' !== $youtube) {
2532 - $platform_tags['youtube-channel-id'] = $youtube;
2533 - }
2534 -
2535 - // WhatsApp Business verification
2536 - $whatsapp = $resolve('whatsapp_business_id');
2537 - if ('' !== $whatsapp) {
2538 - $platform_tags['whatsapp-business-id'] = $whatsapp;
2539 - }
2540 -
2541 2961 return $platform_tags;
2542 2962 }
2543 2963
2544 2964 /**
2965 + * Platform verification settings, mapped to the meta name each is emitted
2966 + * under. One list so the batch primed in generate_platform_meta_tags() and
2967 + * the keys it then reads cannot drift apart.
2968 + *
2969 + * @since 2.1.0
2970 + * @var array<string,string>
2971 + */
2972 + private const PLATFORM_META_KEYS = [
2973 + 'facebook_app_id' => 'fb:app_id',
2974 + 'facebook_admins' => 'fb:admins',
2975 + 'pinterest_site_verification' => 'pinterest-site-verification',
2976 + 'instagram_verification' => 'instagram-site-verification',
2977 + 'tiktok_verification' => 'tiktok-site-verification',
2978 + 'youtube_channel_id' => 'youtube-channel-id',
2979 + 'whatsapp_business_id' => 'whatsapp-business-id',
2980 + ];
2981 +
2982 + /**
2545 2983 * Convert OG, Twitter, and Platform tags to HTML meta tags
2546 2984 *
2547 2985 * @since 1.0.0
2548 2986 *
@@ -2617,19 +3055,20 @@
2617 3055 return $validation;
2618 3056 }
2619 3057
2620 3058 // Check if URL is valid
2621 - if (!filter_var($image_url, FILTER_VALIDATE_URL)) {
3059 + if (!\ThinkRank\Core\Url_Validator::is_http_url($image_url)) {
2622 3060 $validation['warnings'][] = 'Invalid image URL';
2623 3061 return $validation;
2624 3062 }
2625 3063
2626 3064 // Get image metadata if it's a local attachment
2627 - $attachment_id = attachment_url_to_postid($image_url);
3065 + $attachment_id = Attachment_Lookup::id_from_url($image_url);
2628 3066 if ($attachment_id) {
2629 3067 $image_meta = wp_get_attachment_metadata($attachment_id);
2630 - $meta_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
2631 - $meta_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
3068 + $image_file = Attachment_Lookup::describe($attachment_id, $image_url);
3069 + $meta_width = $image_file['width'];
3070 + $meta_height = $image_file['height'];
2632 3071
2633 3072 // SVGs and other vector uploads store 0x0 metadata — skip the
2634 3073 // dimension/ratio checks instead of dividing by 0.
2635 3074 if ($image_meta && $meta_width > 0 && $meta_height > 0) {