tag. * * @since 1.0.0 * @return void */ public function add_meta_description(): void { if (!is_author()) { return; } $author_id = get_queried_object_id(); // A per-author meta description (e.g. imported from another SEO plugin) // overrides the global template. $custom_desc = (string) get_user_meta($author_id, '_thinkrank_meta_description', true); if ($custom_desc !== '') { $this->print_meta_description($custom_desc); return; } $settings = Settings::instance(); // Default value matches what we set in endpoint $template = $settings->get('author_archives_meta_desc', Settings::DEFAULT_AUTHOR_ARCHIVES_META_DESC); if (empty($template)) { return; } // Get variables $author_name = get_the_author_meta('display_name', $author_id); $site_title = get_bloginfo('name'); $replacements = [ '%author_name%' => $author_name, '%site_title%' => $site_title ]; $meta_desc = str_replace(array_keys($replacements), array_values($replacements), $template); $this->print_meta_description($meta_desc); } /** * Print the description tag with the same framing SEO_Manager uses. * * Keeps author archives consistent with every other page type: the plugin * header comment, the 160-character trim, and the open/close markers. * * @since 1.29.1 * @param string $description Resolved description text * @return void */ private function print_meta_description(string $description): void { $description = $this->tidy_whitespace($description); if ($description === '') { return; } // Keep the rendered description within the ~160 characters search // engines display. Measure and cut in CHARACTERS: strlen() counts // BYTES, so a Cyrillic/CJK description tripped a limit it was nowhere // near, and wp_trim_words() cuts by WORD COUNT, so a long-worded // description sailed past the cap entirely. Three units, three // different answers. $description = $this->trim_to_length($description, self::DESCRIPTION_MAX_LENGTH); // Opens the block through SEO_Manager so its closing comment, printed // on wp_head at priority 99, knows an opener was emitted. \ThinkRank\Frontend\SEO_Manager::note_opening_comment(); echo "\n"; echo '' . "\n"; echo "\n"; } /** * Characters search engines display for a meta description. * * @since 2.2.0 * @var int */ private const DESCRIPTION_MAX_LENGTH = 160; /** * Trim a description to a character budget, multibyte-safe. * * Cuts on a word boundary when one is available inside the budget, so the * result does not end mid-word; falls back to a hard character cut for * scripts that do not use spaces (CJK), where a word-boundary search would * find nothing and return the string untouched. * * @since 2.2.0 * @param string $description Description text. * @param int $limit Maximum length in characters, ellipsis included. * @return string */ private function trim_to_length(string $description, int $limit): string { // The implementation moved to Seo_Text so the four other // description paths could stop carrying the broken version of it // (#687). This stays as the local name the author-archive code reads. return \ThinkRank\Core\Seo_Text::trim_to_length($description, $limit); } /** * Modify document title for author archives * * @since 1.0.0 * @param string $title Original title * @return string Modified title */ public function modify_document_title(string $title): string { if (is_author()) { $author_id = get_queried_object_id(); // A per-author SEO title (e.g. imported from another SEO plugin) // overrides the global template entirely. $custom_title = (string) get_user_meta($author_id, '_thinkrank_seo_title', true); if ($custom_title !== '') { return $this->with_page_suffix($custom_title); } $settings = Settings::instance(); $template = $settings->get('author_archives_title', Settings::DEFAULT_AUTHOR_ARCHIVES_TITLE); if (empty($template)) { return $title; } // Get variables $author_name = get_the_author_meta('display_name', $author_id); $site_title = get_bloginfo('name'); // Separator $separator = '-'; if (class_exists('ThinkRank\SEO\Site_Identity_Manager')) { $separator = \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol(); } // %page% resolves to nothing here. This filter runs at priority 15, // after SEO_Manager's at priority 1, so its own page indicator was // the one that reached the page — and it rendered without the // separator the rest of the site uses ("admin | tr Page 2" against // "Uncategorized | tr | Page 2" everywhere else). The token stays // recognised so a template that already contains it does not leak // the literal string; the indicator itself comes from the one // helper every other context uses (#397 review). $replacements = [ '%author_name%' => $author_name, '%site_title%' => $site_title, '%separator%' => $separator, '%page%' => '' ]; $rendered = str_replace(array_keys($replacements), array_values($replacements), $template); return $this->with_page_suffix($this->tidy_whitespace($rendered)); } return $title; } /** * Append the shared page indicator, when SEO_Manager is available. * * @since 2.0.1 * @param string $title Rendered author-archive title. * @return string */ private function with_page_suffix(string $title): string { if (!class_exists('\ThinkRank\Frontend\SEO_Manager')) { return $title; } return \ThinkRank\Frontend\SEO_Manager::with_page_suffix($title); } /** * Collapse the gaps left by variables that resolved to nothing. * * %page% is empty on an unpaginated archive, so the stock template ended * every author