| @@ -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 | } |