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-author-archives-manager.php +149 -47 1.28.0 → 2.9.0 View file →
@@ -42,49 +42,112 @@
42 42
43 43 /**
44 44 * Add meta description for author archives
45 45 *
46 + * This is the only place a description is printed for author archives —
47 + * SEO_Manager::output_meta_description() returns early on is_author() so
48 + * the two never both emit a <meta name="description"> tag.
49 + *
46 50 * @since 1.0.0
47 51 * @return void
48 52 */
49 53 public function add_meta_description(): void {
50 - if (is_author()) {
51 - $author_id = get_queried_object_id();
54 + if (!is_author()) {
55 + return;
56 + }
52 57
53 - // A per-author meta description (e.g. imported from another SEO plugin)
54 - // overrides the global template.
55 - $custom_desc = (string) get_user_meta($author_id, '_thinkrank_meta_description', true);
56 - if ($custom_desc !== '') {
57 - echo '<meta name="description" content="' . esc_attr($custom_desc) . '" />' . "\n";
58 - return;
59 - }
58 + $author_id = get_queried_object_id();
60 59
61 - $settings = Settings::instance();
62 - // Default value matches what we set in endpoint
63 - $template = $settings->get('author_archives_meta_desc', 'Articles written by %author_name% on %site_title%');
64 - if (empty($template)) {
65 - return;
66 - }
60 + // A per-author meta description (e.g. imported from another SEO plugin)
61 + // overrides the global template.
62 + $custom_desc = (string) get_user_meta($author_id, '_thinkrank_meta_description', true);
63 + if ($custom_desc !== '') {
64 + $this->print_meta_description($custom_desc);
65 + return;
66 + }
67 67
68 - // Get variables
69 - $author_name = get_the_author_meta('display_name', $author_id);
70 - $site_title = get_bloginfo('name');
68 + $settings = Settings::instance();
69 + // Default value matches what we set in endpoint
70 + $template = $settings->get('author_archives_meta_desc', Settings::DEFAULT_AUTHOR_ARCHIVES_META_DESC);
71 + if (empty($template)) {
72 + return;
73 + }
71 74
72 - $replacements = [
73 - '%author_name%' => $author_name,
74 - '%site_title%' => $site_title
75 - ];
75 + // Get variables
76 + $author_name = get_the_author_meta('display_name', $author_id);
77 + $site_title = get_bloginfo('name');
76 78
77 - $meta_desc = str_replace(array_keys($replacements), array_values($replacements), $template);
78 - $meta_desc = trim($meta_desc);
79 + $replacements = [
80 + '%author_name%' => $author_name,
81 + '%site_title%' => $site_title
82 + ];
79 83
80 - if (!empty($meta_desc)) {
81 - echo '<meta name="description" content="' . esc_attr($meta_desc) . '" />' . "\n";
82 - }
84 + $meta_desc = str_replace(array_keys($replacements), array_values($replacements), $template);
85 +
86 + $this->print_meta_description($meta_desc);
87 + }
88 +
89 + /**
90 + * Print the description tag with the same framing SEO_Manager uses.
91 + *
92 + * Keeps author archives consistent with every other page type: the plugin
93 + * header comment, the 160-character trim, and the open/close markers.
94 + *
95 + * @since 1.29.1
96 + * @param string $description Resolved description text
97 + * @return void
98 + */
99 + private function print_meta_description(string $description): void {
100 + $description = $this->tidy_whitespace($description);
101 + if ($description === '') {
102 + return;
83 103 }
104 +
105 + // Keep the rendered description within the ~160 characters search
106 + // engines display. Measure and cut in CHARACTERS: strlen() counts
107 + // BYTES, so a Cyrillic/CJK description tripped a limit it was nowhere
108 + // near, and wp_trim_words() cuts by WORD COUNT, so a long-worded
109 + // description sailed past the cap entirely. Three units, three
110 + // different answers.
111 + $description = $this->trim_to_length($description, self::DESCRIPTION_MAX_LENGTH);
112 +
113 + // Opens the block through SEO_Manager so its closing comment, printed
114 + // on wp_head at priority 99, knows an opener was emitted.
115 + \ThinkRank\Frontend\SEO_Manager::note_opening_comment();
116 + echo "<!-- ThinkRank SEO Meta Description -->\n";
117 + echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
118 + echo "<!-- /ThinkRank SEO Meta Description -->\n";
84 119 }
85 120
86 121 /**
122 + * Characters search engines display for a meta description.
123 + *
124 + * @since 2.2.0
125 + * @var int
126 + */
127 + private const DESCRIPTION_MAX_LENGTH = 160;
128 +
129 + /**
130 + * Trim a description to a character budget, multibyte-safe.
131 + *
132 + * Cuts on a word boundary when one is available inside the budget, so the
133 + * result does not end mid-word; falls back to a hard character cut for
134 + * scripts that do not use spaces (CJK), where a word-boundary search would
135 + * find nothing and return the string untouched.
136 + *
137 + * @since 2.2.0
138 + * @param string $description Description text.
139 + * @param int $limit Maximum length in characters, ellipsis included.
140 + * @return string
141 + */
142 + private function trim_to_length(string $description, int $limit): string {
143 + // The implementation moved to Seo_Text so the four other
144 + // description paths could stop carrying the broken version of it
145 + // (#687). This stays as the local name the author-archive code reads.
146 + return \ThinkRank\Core\Seo_Text::trim_to_length($description, $limit);
147 + }
148 +
149 + /**
87 150 * Modify document title for author archives
88 151 *
89 152 * @since 1.0.0
90 153 * @param string $title Original title
@@ -97,13 +160,13 @@
97 160 // A per-author SEO title (e.g. imported from another SEO plugin)
98 161 // overrides the global template entirely.
99 162 $custom_title = (string) get_user_meta($author_id, '_thinkrank_seo_title', true);
100 163 if ($custom_title !== '') {
101 - return $custom_title;
164 + return $this->with_page_suffix($custom_title);
102 165 }
103 166
104 167 $settings = Settings::instance();
105 - $template = $settings->get('author_archives_title', '%author_name% – %site_title% %page%');
168 + $template = $settings->get('author_archives_title', Settings::DEFAULT_AUTHOR_ARCHIVES_TITLE);
106 169
107 170 if (empty($template)) {
108 171 return $title;
109 172 }
@@ -117,29 +180,61 @@
117 180 if (class_exists('ThinkRank\SEO\Site_Identity_Manager')) {
118 181 $separator = \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol();
119 182 }
120 183
121 - // Page number
122 - $page_str = '';
123 - $paged = get_query_var('paged') ? (int) get_query_var('paged') : 1;
124 - if ($paged > 1) {
125 - // translators: %d is the page number for paginated author archives.
126 - $page_str = sprintf(__('Page %d', 'thinkrank'), $paged);
127 - }
128 -
184 + // %page% resolves to nothing here. This filter runs at priority 15,
185 + // after SEO_Manager's at priority 1, so its own page indicator was
186 + // the one that reached the page — and it rendered without the
187 + // separator the rest of the site uses ("admin | tr Page 2" against
188 + // "Uncategorized | tr | Page 2" everywhere else). The token stays
189 + // recognised so a template that already contains it does not leak
190 + // the literal string; the indicator itself comes from the one
191 + // helper every other context uses (#397 review).
129 192 $replacements = [
130 193 '%author_name%' => $author_name,
131 194 '%site_title%' => $site_title,
132 195 '%separator%' => $separator,
133 - '%page%' => $page_str
196 + '%page%' => ''
134 197 ];
135 198
136 - return str_replace(array_keys($replacements), array_values($replacements), $template);
199 + $rendered = str_replace(array_keys($replacements), array_values($replacements), $template);
200 +
201 + return $this->with_page_suffix($this->tidy_whitespace($rendered));
137 202 }
138 203 return $title;
139 204 }
140 205
141 206 /**
207 + * Append the shared page indicator, when SEO_Manager is available.
208 + *
209 + * @since 2.0.1
210 + * @param string $title Rendered author-archive title.
211 + * @return string
212 + */
213 + private function with_page_suffix(string $title): string {
214 + if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) {
215 + return $title;
216 + }
217 +
218 + return \ThinkRank\Frontend\SEO_Manager::with_page_suffix($title);
219 + }
220 +
221 + /**
222 + * Collapse the gaps left by variables that resolved to nothing.
223 + *
224 + * %page% is empty on an unpaginated archive, so the stock template ended
225 + * every author <title> with a stray trailing space; two empty variables in
226 + * a row would leave a double space mid-string.
227 + *
228 + * @since 1.29.1
229 + * @param string $value Rendered template
230 + * @return string Template with runs of whitespace collapsed and trimmed
231 + */
232 + private function tidy_whitespace(string $value): string {
233 + return trim((string) preg_replace('/\s+/u', ' ', $value));
234 + }
235 +
236 + /**
142 237 * Filter robots meta tag
143 238 *
144 239 * @since 1.0.0
145 240 * @param array $robots Robots meta array
@@ -151,13 +246,14 @@
151 246 $index = $settings->get('author_archives_index', true);
152 247
153 248 if (!$index) {
154 249 // Remove 'index' if present
155 - if (($key = array_search('index', $robots)) !== false) {
156 - unset($robots[$key]);
250 + $index_key = array_search('index', $robots, true);
251 + if ($index_key !== false) {
252 + unset($robots[$index_key]);
157 253 }
158 254 // Add 'noindex' if not present
159 - if (!in_array('noindex', $robots)) {
255 + if (!in_array('noindex', $robots, true)) {
160 256 $robots[] = 'noindex';
161 257 }
162 258 } else {
163 259 // If showing in search results, check if empty archives should be hidden
@@ -165,15 +261,16 @@
165 261 if (!$show_empty) {
166 262 $author_id = get_queried_object_id();
167 263 // Check if author has any published posts
168 264 $post_count = count_user_posts($author_id, 'post', true); // true = only public posts
169 - if ($post_count == 0) {
265 + if ((int) $post_count === 0) {
170 266 // Remove 'index' if present
171 - if (($key = array_search('index', $robots)) !== false) {
172 - unset($robots[$key]);
267 + $index_key = array_search('index', $robots, true);
268 + if ($index_key !== false) {
269 + unset($robots[$index_key]);
173 270 }
174 271 // Add 'noindex' if not present
175 - if (!in_array('noindex', $robots)) {
272 + if (!in_array('noindex', $robots, true)) {
176 273 $robots[] = 'noindex';
177 274 }
178 275 }
179 276 }
@@ -195,9 +292,14 @@
195 292 // Default to true (enabled)
196 293 $enabled = $settings->get('author_archives_enabled', true);
197 294
198 295 if (!$enabled) {
199 - wp_safe_redirect(home_url(), 301);
296 + // 302, not 301. This redirect lasts exactly as long as the
297 + // setting stays off, but a 301 is cached by browsers and CDNs
298 + // indefinitely — so turning author archives back on could not
299 + // undo it for anyone who had already been redirected, and there
300 + // was no server-side way to fix that.
301 + wp_safe_redirect(home_url(), 302);
200 302 exit;
201 303 }
202 304 }
203 305 }