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

347 lines 12.7 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 $meta_desc = self::resolve_description((int) get_queried_object_id());
59 if ($meta_desc === '') {
60 return;
61 }
62
63 $this->print_meta_description($meta_desc);
64 }
65
66 /**
67 * Print the description tag with the same framing SEO_Manager uses.
68 *
69 * Keeps author archives consistent with every other page type: the plugin
70 * header comment, the 160-character trim, and the open/close markers.
71 *
72 * @since 1.29.1
73 * @param string $description Resolved description text
74 * @return void
75 */
76 private function print_meta_description(string $description): void {
77 $description = self::tidy_whitespace($description);
78 if ($description === '') {
79 return;
80 }
81
82 // Keep the rendered description within the ~160 characters search
83 // engines display. Measure and cut in CHARACTERS: strlen() counts
84 // BYTES, so a Cyrillic/CJK description tripped a limit it was nowhere
85 // near, and wp_trim_words() cuts by WORD COUNT, so a long-worded
86 // description sailed past the cap entirely. Three units, three
87 // different answers.
88 $description = $this->trim_to_length($description, self::DESCRIPTION_MAX_LENGTH);
89
90 // Opens the block through SEO_Manager so its closing comment, printed
91 // on wp_head at priority 99, knows an opener was emitted.
92 \ThinkRank\Frontend\SEO_Manager::note_opening_comment();
93 echo "<!-- ThinkRank SEO Meta Description -->\n";
94 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
95 echo "<!-- /ThinkRank SEO Meta Description -->\n";
96 }
97
98 /**
99 * Characters search engines display for a meta description.
100 *
101 * @since 2.2.0
102 * @var int
103 */
104 private const DESCRIPTION_MAX_LENGTH = 160;
105
106 /**
107 * Trim a description to a character budget, multibyte-safe.
108 *
109 * Cuts on a word boundary when one is available inside the budget, so the
110 * result does not end mid-word; falls back to a hard character cut for
111 * scripts that do not use spaces (CJK), where a word-boundary search would
112 * find nothing and return the string untouched.
113 *
114 * @since 2.2.0
115 * @param string $description Description text.
116 * @param int $limit Maximum length in characters, ellipsis included.
117 * @return string
118 */
119 private function trim_to_length(string $description, int $limit): string {
120 // The implementation moved to Seo_Text so the four other
121 // description paths could stop carrying the broken version of it
122 // (#687). This stays as the local name the author-archive code reads.
123 return \ThinkRank\Core\Seo_Text::trim_to_length($description, $limit);
124 }
125
126 /**
127 * Modify document title for author archives
128 *
129 * @since 1.0.0
130 * @param string $title Original title
131 * @return string Modified title
132 */
133 public function modify_document_title(string $title): string {
134 if (!is_author()) {
135 return $title;
136 }
137
138 $resolved = self::resolve_title((int) get_queried_object_id());
139
140 return $resolved === '' ? $title : $this->with_page_suffix($resolved);
141 }
142
143 /**
144 * The SEO title an author archive renders, without the page indicator.
145 *
146 * A per-author SEO title (e.g. imported from another SEO plugin) overrides
147 * the Author Archives title template entirely. Public so SEO_Manager can
148 * hand the same string to og:title and twitter:title: they used to inherit
149 * the Site Identity author title instead, so the share card disagreed with
150 * the document <title> this class prints (#977).
151 *
152 * @since 2.15.0
153 * @param int $author_id Queried author.
154 * @return string Rendered title, or '' when the template is empty.
155 */
156 public static function resolve_title(int $author_id): string {
157 $custom_title = (string) get_user_meta($author_id, '_thinkrank_seo_title', true);
158 if (trim($custom_title) !== '') {
159 return self::render_template($custom_title, $author_id);
160 }
161
162 $template = (string) Settings::instance()->get('author_archives_title', Settings::DEFAULT_AUTHOR_ARCHIVES_TITLE);
163 if (trim($template) === '') {
164 return '';
165 }
166
167 return self::render_template($template, $author_id);
168 }
169
170 /**
171 * The meta description an author archive renders, before the length trim.
172 *
173 * A per-author meta description overrides the Author Archives description
174 * template. Public for the same reason as resolve_title(): it is also the
175 * og:description and twitter:description of the archive (#977).
176 *
177 * @since 2.15.0
178 * @param int $author_id Queried author.
179 * @return string Rendered description, or '' when there is none.
180 */
181 public static function resolve_description(int $author_id): string {
182 $custom_desc = (string) get_user_meta($author_id, '_thinkrank_meta_description', true);
183 if (trim($custom_desc) !== '') {
184 return self::render_template($custom_desc, $author_id);
185 }
186
187 // Default value matches what we set in endpoint
188 $template = (string) Settings::instance()->get('author_archives_meta_desc', Settings::DEFAULT_AUTHOR_ARCHIVES_META_DESC);
189 if (trim($template) === '') {
190 return '';
191 }
192
193 return self::render_template($template, $author_id);
194 }
195
196 /**
197 * Fill an author-archive template with the author and site values.
198 *
199 * The Author Archives tab offers %author_name%, %site_title%, %separator%
200 * and %page%, but the importers write Site Identity vocabulary into the
201 * same setting: Rank Math's stock "%name% %sep% %sitename%" arrives as
202 * "%author_name% %sep% %site_title%", and %sep% used to reach the <title>
203 * as literal text. The Site Identity aliases resolve here too, with the
204 * values that context gives them on an author archive.
205 *
206 * %page% resolves to nothing. modify_document_title() runs at priority 15,
207 * after SEO_Manager's at priority 1, so its own page indicator was the one
208 * that reached the page, and it rendered without the separator the rest
209 * of the site uses ("admin | tr Page 2" against "Uncategorized | tr |
210 * Page 2" everywhere else). The token stays recognised so a template that
211 * already contains it does not leak the literal string; the indicator
212 * itself comes from the one helper every other context uses (#397 review).
213 *
214 * @since 2.15.0
215 * @param string $template Title or description template.
216 * @param int $author_id Queried author.
217 * @return string Rendered text with whitespace tidied.
218 */
219 private static function render_template(string $template, int $author_id): string {
220 $author_name = (string) get_the_author_meta('display_name', $author_id);
221 $site_title = (string) get_bloginfo('name');
222 $tagline = (string) get_bloginfo('description');
223
224 $separator = '-';
225 if (class_exists('ThinkRank\SEO\Site_Identity_Manager')) {
226 $separator = \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol();
227 }
228
229 $replacements = [
230 '%author_name%' => $author_name,
231 '%author%' => $author_name,
232 '%archive_title%' => $author_name,
233 '%site_title%' => $site_title,
234 '%site_name%' => $site_title,
235 '%site_description%' => $tagline,
236 '%tagline%' => $tagline,
237 '%separator%' => $separator,
238 '%sep%' => $separator,
239 '%date%' => gmdate('F Y'),
240 '%page%' => '',
241 ];
242
243 return self::tidy_whitespace(str_replace(array_keys($replacements), array_values($replacements), $template));
244 }
245
246 /**
247 * Append the shared page indicator, when SEO_Manager is available.
248 *
249 * @since 2.0.1
250 * @param string $title Rendered author-archive title.
251 * @return string
252 */
253 private function with_page_suffix(string $title): string {
254 if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) {
255 return $title;
256 }
257
258 return \ThinkRank\Frontend\SEO_Manager::with_page_suffix($title);
259 }
260
261 /**
262 * Collapse the gaps left by variables that resolved to nothing.
263 *
264 * %page% is empty on an unpaginated archive, so the stock template ended
265 * every author <title> with a stray trailing space; two empty variables in
266 * a row would leave a double space mid-string.
267 *
268 * @since 1.29.1
269 * @param string $value Rendered template
270 * @return string Template with runs of whitespace collapsed and trimmed
271 */
272 private static function tidy_whitespace(string $value): string {
273 return trim((string) preg_replace('/\s+/u', ' ', $value));
274 }
275
276 /**
277 * Filter robots meta tag
278 *
279 * @since 1.0.0
280 * @param array $robots Robots meta array
281 * @return array Filtered robots meta
282 */
283 public function filter_robots(array $robots): array {
284 if (is_author()) {
285 $settings = Settings::instance();
286 $index = $settings->get('author_archives_index', true);
287
288 if (!$index) {
289 // Remove 'index' if present
290 $index_key = array_search('index', $robots, true);
291 if ($index_key !== false) {
292 unset($robots[$index_key]);
293 }
294 // Add 'noindex' if not present
295 if (!in_array('noindex', $robots, true)) {
296 $robots[] = 'noindex';
297 }
298 } else {
299 // If showing in search results, check if empty archives should be hidden
300 $show_empty = $settings->get('author_archives_show_empty', false);
301 if (!$show_empty) {
302 $author_id = get_queried_object_id();
303 // Check if author has any published posts
304 $post_count = count_user_posts($author_id, 'post', true); // true = only public posts
305 if ((int) $post_count === 0) {
306 // Remove 'index' if present
307 $index_key = array_search('index', $robots, true);
308 if ($index_key !== false) {
309 unset($robots[$index_key]);
310 }
311 // Add 'noindex' if not present
312 if (!in_array('noindex', $robots, true)) {
313 $robots[] = 'noindex';
314 }
315 }
316 }
317 }
318 }
319
320 return array_values($robots);
321 }
322
323 /**
324 * Redirect author archives to homepage if disabled
325 *
326 * @since 1.0.0
327 * @return void
328 */
329 public function maybe_redirect_author_archives(): void {
330 if (is_author()) {
331 $settings = Settings::instance();
332 // Default to true (enabled)
333 $enabled = $settings->get('author_archives_enabled', true);
334
335 if (!$enabled) {
336 // 302, not 301. This redirect lasts exactly as long as the
337 // setting stays off, but a 301 is cached by browsers and CDNs
338 // indefinitely — so turning author archives back on could not
339 // undo it for anyone who had already been redirected, and there
340 // was no server-side way to fix that.
341 wp_safe_redirect(home_url(), 302);
342 exit;
343 }
344 }
345 }
346 }
347