PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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.7.0, at includes/seo/class-author-archives-manager.php

307 lines 10.8 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 // 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 /**
150 * Modify document title for author archives
151 *
152 * @since 1.0.0
153 * @param string $title Original title
154 * @return string Modified title
155 */
156 public function modify_document_title(string $title): string {
157 if (is_author()) {
158 $author_id = get_queried_object_id();
159
160 // A per-author SEO title (e.g. imported from another SEO plugin)
161 // overrides the global template entirely.
162 $custom_title = (string) get_user_meta($author_id, '_thinkrank_seo_title', true);
163 if ($custom_title !== '') {
164 return $this->with_page_suffix($custom_title);
165 }
166
167 $settings = Settings::instance();
168 $template = $settings->get('author_archives_title', Settings::DEFAULT_AUTHOR_ARCHIVES_TITLE);
169
170 if (empty($template)) {
171 return $title;
172 }
173
174 // Get variables
175 $author_name = get_the_author_meta('display_name', $author_id);
176 $site_title = get_bloginfo('name');
177
178 // Separator
179 $separator = '-';
180 if (class_exists('ThinkRank\SEO\Site_Identity_Manager')) {
181 $separator = \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol();
182 }
183
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).
192 $replacements = [
193 '%author_name%' => $author_name,
194 '%site_title%' => $site_title,
195 '%separator%' => $separator,
196 '%page%' => ''
197 ];
198
199 $rendered = str_replace(array_keys($replacements), array_values($replacements), $template);
200
201 return $this->with_page_suffix($this->tidy_whitespace($rendered));
202 }
203 return $title;
204 }
205
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 /**
237 * Filter robots meta tag
238 *
239 * @since 1.0.0
240 * @param array $robots Robots meta array
241 * @return array Filtered robots meta
242 */
243 public function filter_robots(array $robots): array {
244 if (is_author()) {
245 $settings = Settings::instance();
246 $index = $settings->get('author_archives_index', true);
247
248 if (!$index) {
249 // Remove 'index' if present
250 $index_key = array_search('index', $robots, true);
251 if ($index_key !== false) {
252 unset($robots[$index_key]);
253 }
254 // Add 'noindex' if not present
255 if (!in_array('noindex', $robots, true)) {
256 $robots[] = 'noindex';
257 }
258 } else {
259 // If showing in search results, check if empty archives should be hidden
260 $show_empty = $settings->get('author_archives_show_empty', false);
261 if (!$show_empty) {
262 $author_id = get_queried_object_id();
263 // Check if author has any published posts
264 $post_count = count_user_posts($author_id, 'post', true); // true = only public posts
265 if ((int) $post_count === 0) {
266 // Remove 'index' if present
267 $index_key = array_search('index', $robots, true);
268 if ($index_key !== false) {
269 unset($robots[$index_key]);
270 }
271 // Add 'noindex' if not present
272 if (!in_array('noindex', $robots, true)) {
273 $robots[] = 'noindex';
274 }
275 }
276 }
277 }
278 }
279
280 return array_values($robots);
281 }
282
283 /**
284 * Redirect author archives to homepage if disabled
285 *
286 * @since 1.0.0
287 * @return void
288 */
289 public function maybe_redirect_author_archives(): void {
290 if (is_author()) {
291 $settings = Settings::instance();
292 // Default to true (enabled)
293 $enabled = $settings->get('author_archives_enabled', true);
294
295 if (!$enabled) {
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);
302 exit;
303 }
304 }
305 }
306 }
307