PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.2.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.2.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 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-author-archives-manager.php

class-author-archives-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.2.0, at includes/seo/class-author-archives-manager.php

319 lines 11.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Author Archives Manager
4 *
5 * Handles author archives functionality including redirects
6 *
7 * @package ThinkRank
8 * @subpackage SEO
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\SEO;
15
16 // Prevent direct access.
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 use ThinkRank\Core\Settings;
22
23 /**
24 * Author Archives Manager Class
25 *
26 * @since 1.0.0
27 */
28 class Author_Archives_Manager {
29
30 /**
31 * Initialize the manager
32 *
33 * @since 1.0.0
34 * @return void
35 */
36 public function init(): void {
37 add_action('template_redirect', [$this, 'maybe_redirect_author_archives']);
38 add_filter('thinkrank_robots_meta', [$this, 'filter_robots']);
39 add_filter('pre_get_document_title', [$this, 'modify_document_title'], 15);
40 add_action('wp_head', [$this, 'add_meta_description'], 5);
41 }
42
43 /**
44 * Add meta description for author archives
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 *
50 * @since 1.0.0
51 * @return void
52 */
53 public function add_meta_description(): void {
54 if (!is_author()) {
55 return;
56 }
57
58 $author_id = get_queried_object_id();
59
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
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 }
74
75 // Get variables
76 $author_name = get_the_author_meta('display_name', $author_id);
77 $site_title = get_bloginfo('name');
78
79 $replacements = [
80 '%author_name%' => $author_name,
81 '%site_title%' => $site_title
82 ];
83
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;
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";
119 }
120
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 if (mb_strlen($description) <= $limit) {
144 return $description;
145 }
146
147 // Reserve one character for the ellipsis.
148 $budget = $limit - 1;
149 $cut = mb_substr($description, 0, $budget);
150 $last_gap = mb_strrpos($cut, ' ');
151
152 // Only honour a word boundary that is not absurdly early — otherwise a
153 // long unbroken token would collapse the description to a few chars.
154 if (false !== $last_gap && $last_gap > (int) ($budget * 0.6)) {
155 $cut = mb_substr($cut, 0, $last_gap);
156 }
157
158 return rtrim($cut) . '';
159 }
160
161 /**
162 * Modify document title for author archives
163 *
164 * @since 1.0.0
165 * @param string $title Original title
166 * @return string Modified title
167 */
168 public function modify_document_title(string $title): string {
169 if (is_author()) {
170 $author_id = get_queried_object_id();
171
172 // A per-author SEO title (e.g. imported from another SEO plugin)
173 // overrides the global template entirely.
174 $custom_title = (string) get_user_meta($author_id, '_thinkrank_seo_title', true);
175 if ($custom_title !== '') {
176 return $this->with_page_suffix($custom_title);
177 }
178
179 $settings = Settings::instance();
180 $template = $settings->get('author_archives_title', Settings::DEFAULT_AUTHOR_ARCHIVES_TITLE);
181
182 if (empty($template)) {
183 return $title;
184 }
185
186 // Get variables
187 $author_name = get_the_author_meta('display_name', $author_id);
188 $site_title = get_bloginfo('name');
189
190 // Separator
191 $separator = '-';
192 if (class_exists('ThinkRank\SEO\Site_Identity_Manager')) {
193 $separator = \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol();
194 }
195
196 // %page% resolves to nothing here. This filter runs at priority 15,
197 // after SEO_Manager's at priority 1, so its own page indicator was
198 // the one that reached the page — and it rendered without the
199 // separator the rest of the site uses ("admin | tr Page 2" against
200 // "Uncategorized | tr | Page 2" everywhere else). The token stays
201 // recognised so a template that already contains it does not leak
202 // the literal string; the indicator itself comes from the one
203 // helper every other context uses (#397 review).
204 $replacements = [
205 '%author_name%' => $author_name,
206 '%site_title%' => $site_title,
207 '%separator%' => $separator,
208 '%page%' => ''
209 ];
210
211 $rendered = str_replace(array_keys($replacements), array_values($replacements), $template);
212
213 return $this->with_page_suffix($this->tidy_whitespace($rendered));
214 }
215 return $title;
216 }
217
218 /**
219 * Append the shared page indicator, when SEO_Manager is available.
220 *
221 * @since 2.0.1
222 * @param string $title Rendered author-archive title.
223 * @return string
224 */
225 private function with_page_suffix(string $title): string {
226 if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) {
227 return $title;
228 }
229
230 return \ThinkRank\Frontend\SEO_Manager::with_page_suffix($title);
231 }
232
233 /**
234 * Collapse the gaps left by variables that resolved to nothing.
235 *
236 * %page% is empty on an unpaginated archive, so the stock template ended
237 * every author <title> with a stray trailing space; two empty variables in
238 * a row would leave a double space mid-string.
239 *
240 * @since 1.29.1
241 * @param string $value Rendered template
242 * @return string Template with runs of whitespace collapsed and trimmed
243 */
244 private function tidy_whitespace(string $value): string {
245 return trim((string) preg_replace('/\s+/u', ' ', $value));
246 }
247
248 /**
249 * Filter robots meta tag
250 *
251 * @since 1.0.0
252 * @param array $robots Robots meta array
253 * @return array Filtered robots meta
254 */
255 public function filter_robots(array $robots): array {
256 if (is_author()) {
257 $settings = Settings::instance();
258 $index = $settings->get('author_archives_index', true);
259
260 if (!$index) {
261 // Remove 'index' if present
262 $index_key = array_search('index', $robots, true);
263 if ($index_key !== false) {
264 unset($robots[$index_key]);
265 }
266 // Add 'noindex' if not present
267 if (!in_array('noindex', $robots, true)) {
268 $robots[] = 'noindex';
269 }
270 } else {
271 // If showing in search results, check if empty archives should be hidden
272 $show_empty = $settings->get('author_archives_show_empty', false);
273 if (!$show_empty) {
274 $author_id = get_queried_object_id();
275 // Check if author has any published posts
276 $post_count = count_user_posts($author_id, 'post', true); // true = only public posts
277 if ((int) $post_count === 0) {
278 // Remove 'index' if present
279 $index_key = array_search('index', $robots, true);
280 if ($index_key !== false) {
281 unset($robots[$index_key]);
282 }
283 // Add 'noindex' if not present
284 if (!in_array('noindex', $robots, true)) {
285 $robots[] = 'noindex';
286 }
287 }
288 }
289 }
290 }
291
292 return array_values($robots);
293 }
294
295 /**
296 * Redirect author archives to homepage if disabled
297 *
298 * @since 1.0.0
299 * @return void
300 */
301 public function maybe_redirect_author_archives(): void {
302 if (is_author()) {
303 $settings = Settings::instance();
304 // Default to true (enabled)
305 $enabled = $settings->get('author_archives_enabled', true);
306
307 if (!$enabled) {
308 // 302, not 301. This redirect lasts exactly as long as the
309 // setting stays off, but a 301 is cached by browsers and CDNs
310 // indefinitely — so turning author archives back on could not
311 // undo it for anyone who had already been redirected, and there
312 // was no server-side way to fix that.
313 wp_safe_redirect(home_url(), 302);
314 exit;
315 }
316 }
317 }
318 }
319