` on every page whose * meta description was empty. * * @since 2.0.1 * @var bool */ private static bool $opening_comment_output = false; /** * Memoised "should core's sitemap be disabled" flag. Null until resolved. * * @var bool|null */ private ?bool $thinkrank_sitemap_enabled = null; /** * Memoised public URL of the sitemap ThinkRank publishes. Empty until * should_disable_core_sitemap() has resolved, and while it resolves false. * * @var string */ private string $thinkrank_sitemap_url = ''; /** * Initialize SEO manager * * @return void */ public function init(): void { // Initialize Site Identity Manager $this->initialize_site_identity_manager(); // Initialize Social Meta Manager $this->initialize_social_meta_manager(); // Initialize Schema Manager for enhanced schema output $this->initialize_schema_manager(); // Initialize Global SEO Schema Output $this->initialize_global_seo_schema(); // Initialize Google Analytics Tracking Manager $this->initialize_google_analytics_tracking(); // Initialize Image SEO Manager $this->initialize_image_seo_manager(); // Initialize current post and context data first add_action('wp', [$this, 'initialize_current_context']); // Use HIGH PRIORITY hooks to override other SEO plugins // Priority 1-5 ensures ThinkRank runs before other SEO plugins // Override WordPress title with HIGH priority add_filter('pre_get_document_title', [$this, 'override_document_title'], 1); add_filter('wp_title', [$this, 'override_wp_title'], 1, 2); // Remove WordPress core's robots output so ours isn't duplicated. // Core registers wp_robots() on wp_head at priority 1; without this the // page would emit two tags (core's + ThinkRank's). // The priority MUST match core's (1) or remove_action is a no-op. // // Exception: when "Discourage search engines" is enabled (blog_public=0), // leave core's wp_robots in place so it emits the native noindex directive, // and ThinkRank suppresses its own robots tag (see output_seo_meta_tags). if (get_option('blog_public')) { remove_action('wp_head', 'wp_robots', 1); } // Output meta tags with HIGH priority add_action('wp_head', [$this, 'output_meta_description'], 1); add_action('wp_head', [$this, 'output_seo_meta_tags'], 2); add_action('wp_head', [$this, 'output_open_graph_tags'], 3); add_action('wp_head', [$this, 'output_twitter_card_tags'], 4); add_action('wp_head', [$this, 'output_platform_meta_tags'], 5); // Remove WordPress core's canonical output so ours isn't duplicated. // Core registers rel_canonical() on wp_head at priority 10; without this // the page would emit two tags on singular views. remove_action('wp_head', 'rel_canonical'); add_action('wp_head', [$this, 'output_canonical_url'], 6); // Silence the Bricks theme's own SEO + Open Graph output so a Bricks // site doesn't ship two of every tag. Bricks is a THEME, so it loads // after plugins: at this point BRICKS_VERSION is not yet defined and a // `defined()` guard here would always be false. Registering the filters // unconditionally is correct and free — the hooks only ever fire from // inside Bricks itself (#257). This mirrors the core rel_canonical and // wp_robots removals above: one producer per tag. add_filter('bricks/frontend/disable_seo', '__return_true'); add_filter('bricks/frontend/disable_opengraph', '__return_true'); // Add Site Identity specific outputs add_action('wp_head', [$this, 'output_site_schema_markup'], 7); add_action('wp_head', [$this, 'output_breadcrumb_schema'], 8); // Late enough that Global_SEO_Schema_Output (priority 15) has registered. add_action('wp_head', [$this, 'output_schema_graph'], 20); // Tell the graph it has a renderer, so a body producer asking whether // its FAQ was absorbed can trigger collection itself when a block theme // renders the post content ahead of wp_head. Schema_Graph::instance()->schedule_render(); // Add closing comment (runs last) add_action('wp_head', [$this, 'output_closing_comment'], 99); // Add breadcrumb display hook add_action('thinkrank_breadcrumbs', [$this, 'display_breadcrumbs']); // Breadcrumb shortcode for use inside post/page content add_shortcode('thinkrank_breadcrumbs', [$this, 'breadcrumbs_shortcode']); // Hero section (Site Identity → Hero & Branding): theme action hook + // shortcode so the configured hero title/subtitle/CTA/background render. add_action('thinkrank_hero', [$this, 'display_hero']); add_shortcode('thinkrank_hero', [$this, 'hero_shortcode']); // Add robots.txt filter hook add_filter('robots_txt', [$this, 'filter_robots_txt'], 10, 2); // Serve /llms.txt from PHP when the request reaches WordPress. A // published llms.txt is a physical file, so the web server normally // answers it — with `text/plain` and no charset, which renders UTF-8 // content as mojibake. This route (plus the .htaccess block written by // LLMs_Txt_Manager for the static file) guarantees an explicit UTF-8 // charset. Priority 8 keeps it ahead of redirect_canonical(). add_action('template_redirect', [$this, 'maybe_serve_llms_txt'], 8); // Take WordPress core's own sitemap offline while ThinkRank's is active. // Two sitemap indexes on one site is a crawl conflict: core keeps // /wp-sitemap.xml served and injects its own "Sitemap:" line into // robots.txt (WP_Sitemaps::add_robots, priority 0). Until now that line // only disappeared as a side effect of filter_robots_txt() replacing the // whole filter output, which does not happen when robots.txt management // is off, when Site Identity is disabled, or when another SEO plugin // claims the filter first — and it never took /wp-sitemap.xml itself // offline, so crawlers could still find and follow the duplicate index. add_filter('wp_sitemaps_enabled', [$this, 'filter_wp_sitemaps_enabled']); // …and point the URLs core owned at our sitemap, rather than letting // them dead-end. Disabling core's sitemap does not unhook the two core // paths that route /sitemap.xml: WP_Rewrite::rewrite_rules() adds the // `sitemap\.xml` rule unconditionally, and redirect_canonical() 301s any // request carrying the `sitemap` query var to /wp-sitemap.xml without // consulting wp_sitemaps_enabled — which then 404s. Runs before both // redirect_canonical() and WP_Sitemaps::render_sitemaps() (priority 10), // and after a Pro redirect rule (priority 1) so a user-defined redirect // for these URLs still wins. add_action('template_redirect', [$this, 'redirect_core_sitemap_requests'], 9); // Keep an existing physical robots.txt in step with WordPress's // "Discourage search engines" toggle (blog_public). A physical file // bypasses core's robots_txt filter, so flipping blog_public after the // file was written would otherwise leave the previous crawl policy served // until an unrelated robots save. Covers both transitions. add_action('update_option_blog_public', [$this, 'on_blog_public_changed'], 10, 0); // Serve the Site Identity favicon through core's site-icon pipeline so // wp_site_icon() outputs it on the front-end (and previews pick it up) add_filter('get_site_icon_url', [$this, 'filter_site_icon_url'], 10, 2); // Process image SEO in content add_filter('the_content', [$this, 'filter_content_images'], 99999); add_filter('post_thumbnail_html', [$this, 'filter_content_images'], 11, 2); add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'filter_content_images'], 11); // Persist alt text to the Media Library for newly uploaded images (opt-in). add_action('add_attachment', [$this, 'maybe_fill_attachment_alt']); } /** * Initialize Site Identity Manager * * @return void */ private function initialize_site_identity_manager(): void { if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php'; } $this->site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager(); } /** * Initialize Social Meta Manager * * @return void */ private function initialize_social_meta_manager(): void { if (!class_exists('ThinkRank\\SEO\\Social_Meta_Manager')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-social-meta-manager.php'; } $this->social_manager = new \ThinkRank\SEO\Social_Meta_Manager(); } /** * Initialize Schema Manager for enhanced schema output * * @return void */ private function initialize_schema_manager(): void { if (!class_exists('ThinkRank\\SEO\\Schema_Management_System')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-management-system.php'; } // Initialize Schema Manager and store reference for integration $this->schema_manager = new \ThinkRank\SEO\Schema_Management_System(); } /** * Initialize Global SEO Schema Output * * @return void */ private function initialize_global_seo_schema(): void { if (!class_exists('ThinkRank\\Frontend\\Global_SEO_Schema_Output')) { require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-global-seo-schema-output.php'; } // Initialize Global SEO Schema Output and store reference $this->global_seo_schema = new Global_SEO_Schema_Output(); $this->global_seo_schema->init(); } /** * Initialize Google Analytics Tracking Manager * * @return void */ private function initialize_google_analytics_tracking(): void { if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) { require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php'; } // Initialize Google Analytics Tracking Manager new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager(); } /** * Initialize Image SEO Manager * * @return void */ private function initialize_image_seo_manager(): void { if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php'; } $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager(); } /** * Filter content to inject image SEO attributes * * @since 1.0.0 * @param string $content Content to filter * @return string Filtered content */ public function filter_content_images(string $content, $post_id = null): string { if (!$this->image_seo_manager) { return $content; } // Ensure post_id is an integer if provided if ($post_id !== null && !is_numeric($post_id)) { $post_id = null; } return $this->image_seo_manager->process_content($content, $post_id ? (int) $post_id : null); } /** * Persist generated alt text to a freshly uploaded image (opt-in). * * Delegates to the Image SEO Manager, which no-ops unless the * "save alt to media" + "fill on upload" settings are enabled. * * @since 1.19.1 * @param int $attachment_id The newly created attachment ID. * @return void */ public function maybe_fill_attachment_alt($attachment_id): void { if ($this->image_seo_manager && is_numeric($attachment_id)) { $this->image_seo_manager->maybe_auto_fill_on_upload((int) $attachment_id); } } /** * Initialize current context and post data * * @return void */ public function initialize_current_context(): void { // Determine current context $this->current_context = $this->detect_current_context(); // Initialize post data if singular if (is_singular()) { $post_id = get_the_ID(); if ($post_id) { $this->current_post_id = $post_id; $this->current_metadata = $this->get_post_seo_metadata($post_id); } } // Term archives. Category, tag and custom-taxonomy pages store their SEO // title and description as term meta — written by the term UI, by the // abilities API and by the Yoast/RankMath/AIOSEO/SEOPress importer — but // nothing here ever read them, so the whole title/description cascade // fell through to the theme default and no description tag was printed // at all. Term robots was fixed for the same reason in 1.31.0 (#290); // this is the title and description half (#386). if (is_category() || is_tag() || is_tax()) { $queried = get_queried_object(); if ($queried instanceof \WP_Term) { $this->current_term_id = $queried->term_id; $this->current_metadata = $this->get_term_seo_metadata($queried->term_id); } } // Load site identity data $this->load_site_identity_data(); } /** * Detect current page context * * @return string Current context type */ private function detect_current_context(): string { // 404 first: a not-found request matches none of the branches below and // used to fall through to 'site', which handed crawlers the homepage's // social identity for an error page. It gets its own context so the // social layer can skip it, matching get_non_singular_canonical_url(), // which already suppresses the canonical for 404 and search. if (is_404()) { return '404'; } if (is_home() || is_front_page()) { return 'homepage'; } elseif (is_single()) { return 'post'; } elseif (is_page()) { return 'page'; } elseif (is_category()) { return 'category'; } elseif (is_tag()) { return 'tag'; } elseif (is_author()) { return 'author'; } elseif (is_search()) { return 'search'; } elseif (is_archive()) { return 'archive'; } return 'site'; } /** * Load site identity data * * @return void */ private function load_site_identity_data(): void { if ($this->site_identity_manager && $this->site_identity_data === null) { $this->site_identity_data = $this->site_identity_manager->get_output_data('site', null); } } /** * Get SEO metadata for a post * * @param int $post_id Post ID * @return array SEO metadata */ private function get_post_seo_metadata(int $post_id): array { $focus_keywords = \ThinkRank\SEO\Focus_Keywords::get($post_id); $title = get_post_meta($post_id, '_thinkrank_seo_title', true); $description = get_post_meta($post_id, '_thinkrank_meta_description', true); return [ // Per-post values may contain variable tags (e.g. "%title% %sep% // %sitename%") entered in the metabox, so resolve them. Literal // values without tags pass through unchanged. 'title' => $title ? \ThinkRank\SEO\Pattern_Resolver::resolve_value($title, $post_id) : $title, 'description' => $description ? \ThinkRank\SEO\Pattern_Resolver::resolve_value($description, $post_id) : $description, 'focus_keyword' => $focus_keywords[0] ?? '', 'focus_keywords' => $focus_keywords, 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true), ]; } /** * Get SEO metadata for a term. * * Mirrors get_post_seo_metadata(): the stored values may carry variable * tags, so they are resolved against the term's own values. Focus keyword * and score have no term equivalent on the frontend and stay empty. * * @since 2.0.1 * * @param int $term_id Term ID. * @return array SEO metadata. */ private function get_term_seo_metadata(int $term_id): array { $title = get_term_meta($term_id, '_thinkrank_seo_title', true); $description = get_term_meta($term_id, '_thinkrank_meta_description', true); return [ 'title' => $title ? \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) $title, $term_id) : '', 'description' => $description ? \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) $description, $term_id) : '', 'focus_keyword' => '', 'focus_keywords' => [], 'seo_score' => '', ]; } /** * Resolve the effective SEO title for the current request. * * Same priority chain as override_document_title() — post-specific * ThinkRank metadata (resolved _thinkrank_seo_title) > Global SEO * template > Site Identity template — without the raw WordPress-title * fallback. Returns null when no ThinkRank-managed title applies, letting * callers (e.g. the social manager OG fallback) drop to their own default. * * @return string|null Effective SEO title, or null if none applies. */ private function get_effective_seo_title(): ?string { if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) { return $this->current_metadata['title']; } return $this->generate_context_title(); } /** * Override WordPress document title (HIGH PRIORITY) * Priority: Post-specific metadata > Global SEO templates > Site Identity templates * * @param string $title Original title * @return string Modified title */ public function override_document_title($title): string { // First priority: Post-specific ThinkRank metadata if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) { return self::with_page_suffix($this->current_metadata['title']); } // Second priority: Global SEO templates, Third priority: Site Identity templates $generated_title = $this->generate_context_title(); if ($generated_title) { return self::with_page_suffix($generated_title); } return $title; } /** * Append a page indicator to a title on page 2 and beyond. * * This filter short-circuits pre_get_document_title at priority 1, which * drops the " – Page 2" core would otherwise add — so every page of an * archive, and every part of a multi-page post, shared one (#397). * The templates have no %page% token, so the suffix is added here rather * than asking every site to edit its title format. * * @since 2.0.1 * * @param string $title Resolved title. * @return string Title with the page indicator, when there is one. */ public static function with_page_suffix(string $title): string { $page = self::current_page_number(); if ($page <= 1 || '' === $title) { return $title; } $separator = class_exists('\ThinkRank\SEO\Site_Identity_Manager') ? \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol() : '|'; return $title . ' ' . $separator . ' ' . sprintf( /* translators: %d: page number. */ __('Page %d', 'thinkrank'), $page ); } /** * Override WordPress wp_title (HIGH PRIORITY) * Priority: Post-specific metadata > Global SEO templates > Site Identity templates * * @param string $title Original title * @param string $sep Title separator * @return string Modified title */ public function override_wp_title(string $title, string $sep = ''): string { // First priority: Post-specific ThinkRank metadata if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) { $site_name = get_bloginfo('name'); return self::with_page_suffix( $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name ); } // Second priority: Global SEO templates, Third priority: Site Identity templates $generated_title = $this->generate_context_title(); if ($generated_title) { return self::with_page_suffix($generated_title); } return $title; } /** * Output meta description (HIGH PRIORITY) * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults * * Author archives are skipped entirely: Author_Archives_Manager owns that * context and prints its own template-based description on wp_head at * priority 5. get_archive_meta_description() already declines to build one * there, but the fallback chain used to continue into the Site Identity * default, so the page ended up with two <meta name="description"> tags. * * @return void */ public function output_meta_description(): void { if (is_author()) { return; } $description = $this->get_meta_description(); if ($description) { // Output main ThinkRank SEO header comment (only once) self::note_opening_comment(); // Ensure description is within optimal length (150-160 characters) if (strlen($description) > 160) { $description = wp_trim_words($description, 25, '...'); } echo "<!-- ThinkRank SEO Meta Description -->\n"; echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n"; echo "<!-- /ThinkRank SEO Meta Description -->\n"; } } /** * Output SEO meta tags * * @return void */ public function output_seo_meta_tags(): void { echo "<!-- ThinkRank SEO Meta Tags -->\n"; // Output robots meta tag with proper directives. // When "Discourage search engines" (blog_public=0) is enabled, defer to // WordPress core's native noindex output and skip ThinkRank's tag so we // don't emit a conflicting/duplicate directive. if (get_option('blog_public')) { $robots_content = $this->get_robots_meta_content(); echo '<meta name="robots" content="' . esc_attr($robots_content) . '" />' . "\n"; } // Output focus keywords as meta keywords (all keywords, comma-separated) $focus_keywords = $this->current_metadata['focus_keywords'] ?? []; if (empty($focus_keywords) && !empty($this->current_metadata['focus_keyword'])) { $focus_keywords = [$this->current_metadata['focus_keyword']]; } if (!empty($focus_keywords)) { $keywords = implode(', ', array_filter(array_map('trim', (array) $focus_keywords), 'strlen')); if (!empty($keywords)) { echo '<meta name="keywords" content="' . esc_attr($keywords) . '" />' . "\n"; } } // Output local SEO meta tags if business info is available $this->output_local_seo_meta_tags(); // Output generator meta tag echo '<meta name="generator" content="ThinkRank ' . esc_attr(THINKRANK_VERSION) . '" />' . "\n"; // No viewport tag here. The viewport is the theme's responsibility and // every modern theme ships one, so emitting our own only ever produced a // second <meta name="viewport"> in the document. The old guard could not // prevent that either: has_action() returns the registered priority // (truthy), so its first operand was always false, and !wp_is_mobile() is // true for every desktop request. echo "<!-- /ThinkRank SEO Meta Tags -->\n"; } /** * Get robots meta content based on context and settings * * @return string Robots meta content */ private function get_robots_meta_content(): string { $robots = []; // 404 and search results must never be indexed, regardless of the // configured global/post-type directives. Links are still followed so // crawlers can discover the rest of the site. if (is_404() || is_search()) { $robots = apply_filters('thinkrank_robots_meta', ['noindex', 'follow']); return implode(', ', array_unique($robots)); } // 1. Get global robot meta settings (Base) $global_settings = get_option('thinkrank_global_robot_meta_settings', []); // Initialize current settings with global defaults $current_settings = wp_parse_args($global_settings, [ 'index' => true, 'noindex' => false, 'nofollow' => false, 'noarchive' => false, 'noimageindex' => false, 'nosnippet' => false, ]); // 2. Apply Post Type based option (if singular) if (is_singular()) { $post_type = get_post_type(); $global_seo_settings = get_option('thinkrank_global_seo_settings', []); // Check if post type settings are enabled $robots_enabled = isset($global_seo_settings[$post_type]['robots_meta_enabled']) && $global_seo_settings[$post_type]['robots_meta_enabled']; if ($robots_enabled && isset($global_seo_settings[$post_type]['robots_meta']) && is_array($global_seo_settings[$post_type]['robots_meta'])) { // Merge post type settings over global settings $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']); } } // Determine Index/Noindex based on merged settings // Priority: if noindex is true, it overrides index if (!empty($current_settings['noindex'])) { $robots[] = 'noindex'; } else { // Default to index if noindex is not set $robots[] = 'index'; } // Determine Follow/Nofollow based on merged settings if (!empty($current_settings['nofollow'])) { $robots[] = 'nofollow'; } else { $robots[] = 'follow'; } // Other directives if (!empty($current_settings['noarchive'])) { $robots[] = 'noarchive'; } if (!empty($current_settings['noimageindex'])) { $robots[] = 'noimageindex'; } if (!empty($current_settings['nosnippet'])) { $robots[] = 'nosnippet'; } // Add advanced directives for better SEO // Get advanced settings $advanced_settings = [ 'snippet_enabled' => true, 'max_snippet' => -1, 'video_preview_enabled' => true, 'max_video_preview' => -1, 'image_preview_enabled' => true, 'max_image_preview' => 'large' ]; // Apply post type specific advanced settings if enabled if (is_singular() && isset($robots_enabled) && $robots_enabled && isset($global_seo_settings[$post_type]['advanced_robots_meta'])) { $advanced_settings = array_merge($advanced_settings, $global_seo_settings[$post_type]['advanced_robots_meta']); } // Generate advanced directives if (empty($current_settings['nosnippet'])) { if ($advanced_settings['snippet_enabled']) { $robots[] = 'max-snippet:' . (int)$advanced_settings['max_snippet']; } if ($advanced_settings['video_preview_enabled']) { $robots[] = 'max-video-preview:' . (int)$advanced_settings['max_video_preview']; } } // Only add max-image-preview if we are allowing image indexing if (empty($current_settings['noimageindex']) && $advanced_settings['image_preview_enabled']) { $robots[] = 'max-image-preview:' . esc_attr($advanced_settings['max_image_preview']); } // 3. Check for single post meta based option (Overrides everything) if (is_singular()) { $robots = $this->apply_post_robots_override(get_the_ID(), $robots, $current_settings); } // Check for archive pages (search is handled by the early return above) // // is_home() is deliberately included: the blog listing is not an // is_archive(), so page 2 of a term archive was noindex while page 2 of // the blog listing was index — the same kind of page, treated two // different ways, on the same site (#397). if (is_archive() || is_home()) { // Allow indexing of category/tag archives but be more conservative if (is_paged()) { /** * Filter whether a paginated archive is set noindex. * * Rank Math and Yoast now index paginated archives with a * self-referential canonical by default, so a site that wants * that can have it without patching. * * @since 2.0.1 * * @param bool $noindex Whether to noindex this paginated page. */ if (apply_filters('thinkrank_noindex_paged_archives', true)) { $robots = ['noindex', 'follow']; } } // Honor the global date-archive noindex toggle (written by the // Rank Math/Yoast settings importer). Author archives are handled // by Author_Archives_Manager via the thinkrank_robots_meta filter. if (is_date() && !empty($current_settings['noindex_date_archives'])) { $robots = ['noindex', 'follow']; } } // 4. Term meta override for taxonomy archives (Overrides everything). // // Terms had no branch here at all — not a wrong key or a skipped // conditional, the lookup simply did not exist — so a category, tag or // custom-taxonomy archive saved with noindex still rendered the global // default. The stored value read back correctly through the abilities // API, which made the setting look applied when it never reached output. // // Deliberately placed *after* the archive block so it is a real // override, matching how a per-post override is final for singular // views. Running it earlier would let is_paged() overwrite a term's // explicit directives on page 2 of its own archive. if (is_category() || is_tag() || is_tax()) { $queried = get_queried_object(); if ($queried instanceof \WP_Term) { $robots = $this->apply_term_robots_override($queried->term_id, $robots, $current_settings); } } // Apply filters for customization $robots = apply_filters('thinkrank_robots_meta', $robots); // Remove duplicates and implode return implode(', ', array_unique($robots)); } /** * Apply per-post robots overrides on top of the cascaded directives. * * Reads `_thinkrank_robots_meta` (JSON) when `_thinkrank_robots_meta_enabled` * is truthy. When the override is off, the cascaded directives pass through * unchanged. * * @param int $post_id Post being rendered * @param array $robots Directives accumulated so far * @param array $current_settings Effective robots flags (global + post type) * @return array Updated robots directive list */ private function apply_post_robots_override(int $post_id, array $robots, array $current_settings): array { return $this->apply_meta_robots_override( (bool) get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true), (string) get_post_meta($post_id, '_thinkrank_robots_meta', true), (string) get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true), $robots, $current_settings ); } /** * Apply per-term robots overrides on top of the cascaded directives. * * The term-meta twin of apply_post_robots_override(). Terms store the same * three keys with the same shapes — written by the update-term-seo ability * and by the Rank Math / Yoast / AIOSEO / SEOPress importer — so the two * paths share one engine rather than a second copy that can drift. * * @since 1.31.0 * * @param int $term_id Term being rendered * @param array $robots Directives accumulated so far * @param array $current_settings Effective robots flags (global + post type) * @return array Updated robots directive list */ private function apply_term_robots_override(int $term_id, array $robots, array $current_settings): array { return $this->apply_meta_robots_override( (bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true), (string) get_term_meta($term_id, '_thinkrank_robots_meta', true), (string) get_term_meta($term_id, '_thinkrank_advanced_robots_meta', true), $robots, $current_settings ); } /** * Rebuild the robots directives from a stored override, whatever holds it. * * Kept free of get_post_meta()/get_term_meta() so posts and terms cannot * diverge: term support was missing entirely because the only override * logic lived behind a post-meta read. * * @since 1.31.0 * * @param bool $enabled Whether the override is switched on * @param string $raw_robots JSON robots flags * @param string $raw_advanced JSON advanced directives * @param array $robots Directives accumulated so far * @param array $current_settings Effective robots flags (global + post type) * @return array Updated robots directive list */ private function apply_meta_robots_override(bool $enabled, string $raw_robots, string $raw_advanced, array $robots, array $current_settings): array { if (!$enabled) { return $robots; } $post_robots = $raw_robots !== '' ? json_decode($raw_robots, true) : null; if (!is_array($post_robots)) { return $robots; } $post_advanced = $raw_advanced !== '' ? json_decode($raw_advanced, true) : null; $effective = array_merge($current_settings, array_intersect_key($post_robots, array_flip([ 'index', 'noindex', 'nofollow', 'noarchive', 'noimageindex', 'nosnippet', ]))); $rebuilt = []; $rebuilt[] = !empty($effective['noindex']) ? 'noindex' : 'index'; $rebuilt[] = !empty($effective['nofollow']) ? 'nofollow' : 'follow'; if (!empty($effective['noarchive'])) { $rebuilt[] = 'noarchive'; } if (!empty($effective['noimageindex'])) { $rebuilt[] = 'noimageindex'; } if (!empty($effective['nosnippet'])) { $rebuilt[] = 'nosnippet'; } if (is_array($post_advanced)) { $advanced = array_merge([ 'snippet_enabled' => true, 'max_snippet' => -1, 'video_preview_enabled' => true, 'max_video_preview' => -1, 'image_preview_enabled' => true, 'max_image_preview' => 'large', ], $post_advanced); if (empty($effective['nosnippet'])) { if (!empty($advanced['snippet_enabled'])) { $rebuilt[] = 'max-snippet:' . (int) $advanced['max_snippet']; } if (!empty($advanced['video_preview_enabled'])) { $rebuilt[] = 'max-video-preview:' . (int) $advanced['max_video_preview']; } } if (empty($effective['noimageindex']) && !empty($advanced['image_preview_enabled'])) { $rebuilt[] = 'max-image-preview:' . sanitize_text_field((string) $advanced['max_image_preview']); } } return $rebuilt; } /** * Output local SEO meta tags for business information * * @return void */ private function output_local_seo_meta_tags(): void { if (!$this->site_identity_manager) { return; } $settings = $this->site_identity_manager->get_settings('site'); // Only output if local SEO is enabled and business info is available if (empty($settings['local_seo_enabled']) || empty($settings['business_name'])) { return; } echo "<!-- ThinkRank Local SEO Meta Tags -->\n"; // NAP (Name, Address, Phone) Consistency Meta Tags if (!empty($settings['business_name'])) { echo '<meta name="business:name" content="' . esc_attr($settings['business_name']) . '" />' . "\n"; } // Business address components if (!empty($settings['business_address'])) { echo '<meta name="business:contact_data:street_address" content="' . esc_attr($settings['business_address']) . '" />' . "\n"; } if (!empty($settings['business_city'])) { echo '<meta name="business:contact_data:locality" content="' . esc_attr($settings['business_city']) . '" />' . "\n"; echo '<meta name="geo.placename" content="' . esc_attr($settings['business_city']) . '" />' . "\n"; } if (!empty($settings['business_state'])) { echo '<meta name="business:contact_data:region" content="' . esc_attr($settings['business_state']) . '" />' . "\n"; } if (!empty($settings['business_postal_code'])) { echo '<meta name="business:contact_data:postal_code" content="' . esc_attr($settings['business_postal_code']) . '" />' . "\n"; } if (!empty($settings['business_country'])) { echo '<meta name="business:contact_data:country_name" content="' . esc_attr($settings['business_country']) . '" />' . "\n"; } // Phone number if (!empty($settings['business_phone'])) { echo '<meta name="business:contact_data:phone_number" content="' . esc_attr($settings['business_phone']) . '" />' . "\n"; } // Email address if (!empty($settings['business_email'])) { echo '<meta name="business:contact_data:email" content="' . esc_attr($settings['business_email']) . '" />' . "\n"; } // Geo-location meta tags (if coordinates are available) if (!empty($settings['business_latitude']) && !empty($settings['business_longitude'])) { $coordinates = $settings['business_latitude'] . ';' . $settings['business_longitude']; echo '<meta name="geo.position" content="' . esc_attr($coordinates) . '" />' . "\n"; echo '<meta name="ICBM" content="' . esc_attr($settings['business_latitude'] . ', ' . $settings['business_longitude']) . '" />' . "\n"; } // Regional meta tag (state/country combination) if (!empty($settings['business_state']) && !empty($settings['business_country'])) { $region = strtoupper($settings['business_country']) . '-' . strtoupper($settings['business_state']); echo '<meta name="geo.region" content="' . esc_attr($region) . '" />' . "\n"; } // Business hours in structured format if (!empty($settings['business_hours']) && is_array($settings['business_hours'])) { $formatted_hours = $this->format_business_hours_for_meta($settings['business_hours']); if (!empty($formatted_hours)) { echo '<meta name="business:hours" content="' . esc_attr($formatted_hours) . '" />' . "\n"; } } // Business type if (!empty($settings['business_type'])) { echo '<meta name="business:type" content="' . esc_attr($settings['business_type']) . '" />' . "\n"; } echo "<!-- /ThinkRank Local SEO Meta Tags -->\n"; } /** * Format business hours for meta tag output * * @param array $business_hours Business hours array * @return string Formatted hours string */ private function format_business_hours_for_meta(array $business_hours): string { $formatted_days = []; $day_abbreviations = [ 'monday' => 'Mo', 'tuesday' => 'Tu', 'wednesday' => 'We', 'thursday' => 'Th', 'friday' => 'Fr', 'saturday' => 'Sa', 'sunday' => 'Su' ]; foreach ($day_abbreviations as $day => $abbrev) { if (isset($business_hours[$day]) && !empty($business_hours[$day])) { $day_data = $business_hours[$day]; if (!empty($day_data['closed']) || empty($day_data['open']) || empty($day_data['close'])) { continue; // Skip closed days } $formatted_days[] = $abbrev . ' ' . $day_data['open'] . '-' . $day_data['close']; } } return implode(', ', $formatted_days); } /** * Output social media Open Graph tags from Social Meta Manager * * @param array $og_tags Open Graph tags array * @return void */ private function output_social_og_tags(array $og_tags): void { // Honor the thinkrank_og_type filter here too — this "Enhanced" path is // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which // sets 'product' on product pages) must be applied to it, not only to // output_open_graph_tags(). if (isset($og_tags['og:type'])) { $og_tags['og:type'] = apply_filters('thinkrank_og_type', $og_tags['og:type']); } echo "<!-- ThinkRank SEO Open Graph Tags (Enhanced) -->\n"; // Define optimal order for Open Graph tags $og_order = [ 'og:title', 'og:description', 'og:type', 'og:url', 'og:site_name', 'og:locale', 'og:image', 'og:image:width', 'og:image:height', 'og:image:type', 'og:image:alt', 'article:published_time', 'article:modified_time', 'article:author', 'article:section' ]; // Output tags in optimal order foreach ($og_order as $property) { if (!empty($og_tags[$property])) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call. echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $og_tags[$property]) . '" />' . "\n"; } } // Output any remaining tags not in the order list foreach ($og_tags as $property => $content) { if (!empty($content) && !in_array($property, $og_order, true)) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call. echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n"; } } echo "<!-- /ThinkRank SEO Open Graph Tags -->\n"; } /** * Output social media Twitter Card tags from Social Meta Manager * * @param array $twitter_tags Twitter Card tags array * @return void */ private function output_social_twitter_tags(array $twitter_tags): void { echo "<!-- ThinkRank SEO Twitter Card Tags (Enhanced) -->\n"; // Define optimal order for Twitter Card tags $twitter_order = [ 'twitter:card', 'twitter:title', 'twitter:description', 'twitter:site', 'twitter:creator', 'twitter:image', 'twitter:image:alt' ]; // Output tags in optimal order foreach ($twitter_order as $name) { if (!empty($twitter_tags[$name])) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call. echo '<meta name="' . esc_attr($name) . '" content="' . $this->esc_meta_value($name, $twitter_tags[$name]) . '" />' . "\n"; } } // Output any remaining tags not in the order list foreach ($twitter_tags as $name => $content) { if (!empty($content) && !in_array($name, $twitter_order, true)) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call. echo '<meta name="' . esc_attr($name) . '" content="' . $this->esc_meta_value($name, $content) . '" />' . "\n"; } } echo "<!-- /ThinkRank SEO Twitter Card Tags -->\n"; } /** * Escape a social meta tag value, using esc_url() for URL-valued keys so a * javascript:/data: scheme is stripped and output stays spec-compliant, and * esc_attr() for everything else. * * @param string $key The OG/Twitter property or name. * @param string|int $value The tag value. Image dimension keys * (og:image:width/height) arrive as integers, so * accept any scalar and normalise to string here — * the file is under strict_types, which would * otherwise throw a TypeError on the int. * @return string Escaped value. */ private function esc_meta_value(string $key, $value): string { $value = (string) $value; $url_keys = [ 'og:image', 'og:image:url', 'og:image:secure_url', 'og:url', 'twitter:image', 'twitter:player', ]; return in_array($key, $url_keys, true) ? esc_url($value) : esc_attr($value); } /** * Output platform-specific meta tags * * @return void */ public function output_platform_meta_tags(): void { // Try Social Meta Manager for platform tags if ($this->social_manager) { // Map context for Social Meta Manager (homepage -> site for site-wide settings) $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context; // Pass the same effective title/description as the OG and Twitter // callbacks so all three share one memoized get_output_data() result // (platform tags don't depend on them, so output is unchanged). $social_data = $this->social_manager->get_output_data( $social_context, $this->current_post_id, $this->get_effective_seo_title(), $this->get_meta_description() ); if ($social_data['enabled'] && !empty($social_data['platform_tags'])) { $this->output_social_platform_tags($social_data['platform_tags']); } } } /** * Output social media platform tags from Social Meta Manager * * @param array $platform_tags Platform tags array * @return void */ private function output_social_platform_tags(array $platform_tags): void { echo "<!-- ThinkRank SEO Platform Meta Tags -->\n"; foreach ($platform_tags as $name => $content) { if (!empty($content)) { // Determine if it should be property or name attribute if (strpos($name, 'fb:') === 0) { // Facebook tags use property attribute echo '<meta property="' . esc_attr($name) . '" content="' . esc_attr($content) . '" />' . "\n"; } else { // Other platform tags use name attribute echo '<meta name="' . esc_attr($name) . '" content="' . esc_attr($content) . '" />' . "\n"; } } } echo "<!-- /ThinkRank SEO Platform Meta Tags -->\n"; } /** * Output Open Graph meta tags (HIGH PRIORITY) * Uses Social Meta Manager with fallback to Site Identity templates * * @return void */ public function output_open_graph_tags(): void { // An error page has no shareable identity. Emitting Open Graph here // advertised the homepage as the og:url of a URL that does not exist. if ($this->current_context === '404') { return; } // Priority 1: Try Social Meta Manager (Social Media tab settings) if ($this->social_manager) { // Map context for Social Meta Manager (homepage -> site for site-wide settings) $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context; // Effective SEO title/description for this request (resolved // per-post value > Global SEO template > Site Identity), identical // to what is output as the document <title>/meta description and // mirrored by the Social metabox preview. Passed as fallbacks so a // cleared Open Graph Title/Description renders the same inherited // value the preview shows. $social_data = $this->social_manager->get_output_data( $social_context, $this->current_post_id, $this->get_effective_seo_title(), $this->get_meta_description() ); // The Social Meta Manager ran, so it owns Open Graph output. If OG is // toggled off, emit nothing — do NOT fall through to the basic // emitter (which would re-add a full OG block despite the toggle). if (!empty($social_data['og_enabled'])) { $this->output_social_og_tags($social_data['og_tags']); } return; } // Priority 2: Fallback only when the Social Meta Manager is unavailable. $this->output_basic_og_tags(); } /** * Output basic Open Graph tags (fallback implementation) * * @return void */ private function output_basic_og_tags(): void { // Check for per-post OG overrides first $og_title_override = ''; $og_description_override = ''; $og_image_override = ''; if (is_singular() && $this->current_post_id) { // Social fields may hold variable tags entered in the metabox. $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value( (string) get_post_meta($this->current_post_id, '_thinkrank_og_title', true), $this->current_post_id ); $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value( (string) get_post_meta($this->current_post_id, '_thinkrank_og_description', true), $this->current_post_id ); $og_image_override = get_post_meta($this->current_post_id, '_thinkrank_og_image', true); } elseif ($this->current_term_id) { // Terms carry the same social override keys — the abilities API // writes them — so honour them here rather than letting the term's // SEO title stand in for an explicit og:title. $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value( (string) get_term_meta($this->current_term_id, '_thinkrank_og_title', true), $this->current_term_id ); $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value( (string) get_term_meta($this->current_term_id, '_thinkrank_og_description', true), $this->current_term_id ); $og_image_override = get_term_meta($this->current_term_id, '_thinkrank_og_image', true); } // Get title using priority system: OG override > post-specific > Global SEO > Site Identity > default $title = ''; if (!empty($og_title_override)) { $title = $og_title_override; } elseif ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) { $title = $this->current_metadata['title']; } else { $title = $this->generate_context_title(); } if (!$title) { $title = is_singular() ? get_the_title() : get_bloginfo('name'); } // Get description with OG override priority $description = ''; if (!empty($og_description_override)) { $description = $og_description_override; } else { $description = $this->get_meta_description(); } // Skipped for a protected post: core answers get_the_excerpt() with its // "There is no excerpt because this is a protected post." placeholder, // so this is not a leak — but publishing that sentence as the social // description is worse than publishing none (#363). if (!$description && !$this->is_content_password_protected()) { $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description'); } $url = is_singular() ? get_permalink() : home_url(); $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name']) ? $this->site_identity_data['identity']['site_name'] : get_bloginfo('name'); // Determine proper og:type based on context $og_type = 'website'; if (is_singular('post')) { $og_type = 'article'; } elseif (is_singular('page')) { $og_type = 'website'; } elseif (is_home() || is_front_page()) { $og_type = 'website'; } /** * Filter the Open Graph og:type. Add-ons (e.g. ThinkRank Pro's * WooCommerce module) use this to set 'product' on product pages. * * @since 1.14.0 * * @param string $og_type Determined og:type. */ $og_type = apply_filters('thinkrank_og_type', $og_type); echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n"; echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n"; echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n"; echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n"; echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n"; echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n"; /** * Filter the og:locale value. * * Defaults to get_locale(), which is only language-correct while the * active language's translation files are installed — on a multilingual * site without them WordPress keeps reporting the default locale even * on translated URLs. The multilingual integration overrides this with * the locale its provider reports for the current language. * * @since 1.23.0 * * @param string $locale Locale for the current request. */ $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale()); echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n"; // Add OG image — per-post override > featured image if (is_singular() && $this->current_post_id) { if (!empty($og_image_override)) { echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n"; echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n"; } elseif (has_post_thumbnail($this->current_post_id)) { $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large'); echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n"; echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n"; // Get image dimensions and alt text $image_id = get_post_thumbnail_id($this->current_post_id); $image_meta = wp_get_attachment_metadata($image_id); if ($image_meta) { // SVGs (and other vector uploads) report 0x0 — emitting // those as og:image dimensions is invalid, so skip them. $og_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0; $og_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0; if ($og_width > 0 && $og_height > 0) { echo "<meta property=\"og:image:width\" content=\"" . esc_attr($og_width) . "\" />\n"; echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n"; } // Derive the real mime type instead of hardcoding image/jpeg, // which mislabels PNG/WebP featured images. $image_mime = get_post_mime_type($image_id); if ($image_mime) { echo "<meta property=\"og:image:type\" content=\"" . esc_attr($image_mime) . "\" />\n"; } } // Add image alt text $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', true); if ($image_alt) { echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n"; } } // Add article specific tags for posts only if ($og_type === 'article') { echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n"; echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n"; // Add author $author_id = get_post_field('post_author', $this->current_post_id); $author_name = get_the_author_meta('display_name', $author_id); echo "<meta property=\"article:author\" content=\"" . esc_attr($author_name) . "\" />\n"; // Add categories as article:section if (is_single()) { $categories = get_the_category($this->current_post_id); if (!empty($categories)) { echo "<meta property=\"article:section\" content=\"" . esc_attr($categories[0]->name) . "\" />\n"; } } } } echo "<!-- /ThinkRank SEO Open Graph Meta Tags -->\n"; } /** * Output Twitter Card meta tags (HIGH PRIORITY) * Uses Social Meta Manager with fallback to Site Identity templates * * @return void */ public function output_twitter_card_tags(): void { // Same reasoning as the Open Graph block: nothing on a 404 is shareable. if ($this->current_context === '404') { return; } // Priority 1: Try Social Meta Manager (Social Media tab settings) if ($this->social_manager) { // Map context for Social Meta Manager (homepage -> site for site-wide settings) $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context; // Twitter title/description derive from the same content data, so // pass the effective SEO title and meta description as fallbacks to // keep a cleared override in step with the document <title>/meta // description and the metabox preview. $social_data = $this->social_manager->get_output_data( $social_context, $this->current_post_id, $this->get_effective_seo_title(), $this->get_meta_description() ); // The Social Meta Manager ran, so it owns Twitter output. If Twitter // Cards are toggled off, emit nothing — do NOT fall through to the // basic emitter (which would re-add twitter:* tags despite the toggle). if (!empty($social_data['twitter_enabled'])) { $this->output_social_twitter_tags($social_data['twitter_tags']); } return; } // Priority 2: Fallback only when the Social Meta Manager is unavailable. $this->output_basic_twitter_tags(); } /** * Output basic Twitter Card tags (fallback implementation) * * @return void */ private function output_basic_twitter_tags(): void { // Check for per-post Twitter overrides first, then fall through to OG overrides. $twitter_title_override = ''; $twitter_description_override = ''; $og_title_override = ''; $og_description_override = ''; if (is_singular() && $this->current_post_id) { // Social fields may hold variable tags entered in the metabox. $pid = $this->current_post_id; $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_title', true), $pid); $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_description', true), $pid); $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_title', true), $pid); $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_description', true), $pid); } elseif ($this->current_term_id) { $tid = $this->current_term_id; $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_title', true), $tid); $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_description', true), $tid); $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_title', true), $tid); $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_description', true), $tid); } // Title cascade: Twitter override > OG override > Global SEO > Site Identity > default $title = ''; if (!empty($twitter_title_override)) { $title = $twitter_title_override; } elseif (!empty($og_title_override)) { $title = $og_title_override; } elseif ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) { $title = $this->current_metadata['title']; } else { $title = $this->generate_context_title(); } if (!$title) { $title = is_singular() ? get_the_title() : get_bloginfo('name'); } // Description cascade: Twitter override > OG override > meta description > excerpt $description = ''; if (!empty($twitter_description_override)) { $description = $twitter_description_override; } elseif (!empty($og_description_override)) { $description = $og_description_override; } else { $description = $this->get_meta_description(); } // Skipped for a protected post: core answers get_the_excerpt() with its // "There is no excerpt because this is a protected post." placeholder, // so this is not a leak — but publishing that sentence as the social // description is worse than publishing none (#363). if (!$description && !$this->is_content_password_protected()) { $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description'); } // Determine card type based on image availability $card_type = 'summary'; if (is_singular() && $this->current_post_id && has_post_thumbnail($this->current_post_id)) { $card_type = 'summary_large_image'; } echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n"; echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n"; echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n"; echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n"; // Add Twitter image with proper fallback priority $twitter_image_url = $this->get_twitter_image_with_fallback(); if ($twitter_image_url) { echo "<meta name=\"twitter:image\" content=\"" . esc_url($twitter_image_url) . "\" />\n"; // Add image alt text for accessibility (if it's a featured image) if (is_singular() && $this->current_post_id && has_post_thumbnail($this->current_post_id)) { $featured_image_url = get_the_post_thumbnail_url($this->current_post_id, 'large'); if ($twitter_image_url === $featured_image_url) { $image_id = get_post_thumbnail_id($this->current_post_id); $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', true); if ($image_alt) { echo "<meta name=\"twitter:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n"; } } } } // Add site Twitter handle if configured if ($this->site_identity_data && !empty($this->site_identity_data['social']['twitter_username'])) { $twitter_handle = $this->site_identity_data['social']['twitter_username']; // Ensure handle starts with @ if (strpos($twitter_handle, '@') !== 0) { $twitter_handle = '@' . $twitter_handle; } echo "<meta name=\"twitter:site\" content=\"" . esc_attr($twitter_handle) . "\" />\n"; } echo "<!-- /ThinkRank SEO Twitter Card Meta Tags -->\n"; } /** * Output canonical URL * * @return void */ public function output_canonical_url(): void { $canonical_url = ''; if (is_singular()) { // Check for custom canonical URL override if ($this->current_post_id) { $custom_canonical = get_post_meta($this->current_post_id, '_thinkrank_canonical_url', true); if (!empty($custom_canonical)) { $canonical_url = $custom_canonical; } } if (empty($canonical_url)) { $canonical_url = $this->current_post_id ? get_permalink($this->current_post_id) : get_permalink(); // Core's rel_canonical() keeps the page number; this replaced // it with a bare permalink, so every <!--nextpage--> sub-page // and every /comment-page-N/ canonicalised to page 1 — a // regression against core behaviour (#397). A custom canonical // is left exactly as the user typed it. $canonical_url = self::with_singular_page($canonical_url); } } else { $canonical_url = self::get_non_singular_canonical_url(); } /** * Filter the canonical URL before output. * * @since 1.16.0 * * @param string $canonical_url Canonical URL ('' suppresses the tag). */ $canonical_url = apply_filters('thinkrank_canonical_url', $canonical_url); if (empty($canonical_url)) { return; } echo "<!-- ThinkRank SEO Canonical URL -->\n"; echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n"; echo "<!-- /ThinkRank SEO Canonical URL -->\n"; $this->output_pagination_links(); } /** * Emit rel="prev" / rel="next" on a paginated archive. * * Nothing emitted these at all (#397). Google stopped using them as an * indexing signal in 2019, so this is not an SEO win with Google — Bing * still reads them, and they are the standard way to describe a sequence, * which is what the pages are. * * @since 2.0.1 * * @return void */ private function output_pagination_links(): void { // Page 1 still wants a rel="next" when there is a page 2, so only // singular views are skipped outright. if (is_singular()) { return; } global $wp_query; $total = $wp_query ? (int) $wp_query->max_num_pages : 0; if ($total < 2) { return; } $base = self::get_non_singular_canonical_url(); if ('' === $base) { return; } // get_non_singular_canonical_url() already carries the current page — // strip it back to page 1 before building the neighbours. $current = self::current_page_number(); $base = self::without_pagination($base); if ($current > 1) { printf( "<link rel=\"prev\" href=\"%s\" />\n", esc_url(self::with_pagination($base, $current - 1)) ); } if ($current < $total) { printf( "<link rel=\"next\" href=\"%s\" />\n", esc_url(self::with_pagination($base, $current + 1)) ); } } /** * The rewrite base WordPress uses for page numbers ('page' by default). * * @since 2.0.1 * * @return string */ private static function pagination_base(): string { global $wp_rewrite; return $wp_rewrite && $wp_rewrite->pagination_base ? $wp_rewrite->pagination_base : 'page'; } /** * Append the sub-page or comment-page number to a singular canonical. * * @since 2.0.1 * * @param string $url Permalink. * @return string Permalink with the current page appended, when there is one. */ public static function with_singular_page(string $url): string { global $wp_rewrite; $page = (int) get_query_var('page'); if ($page > 1) { return $wp_rewrite && $wp_rewrite->using_permalinks() ? trailingslashit($url) . user_trailingslashit($page, 'single_paged') : add_query_arg('page', $page, $url); } $comment_page = (int) get_query_var('cpage'); if ($comment_page > 1) { return get_comments_pagenum_link($comment_page); } return $url; } /** * Build the canonical URL for non-singular contexts. * * Covers the blog home, post type / taxonomy / author / date archives. * Search results and 404 pages get no canonical (they are noindexed). * Paginated archives canonicalize to their own page URL so page 2+ is * self-referential rather than pointing at page 1. * * @return string Canonical URL or '' when none applies */ public static function get_non_singular_canonical_url(): string { if (is_404() || is_search()) { return ''; } $canonical_url = ''; if (is_front_page() || is_home()) { $canonical_url = is_home() && !is_front_page() ? (string) get_permalink((int) get_option('page_for_posts')) : home_url('/'); } elseif (is_post_type_archive()) { $canonical_url = (string) get_post_type_archive_link((string) get_query_var('post_type')); } elseif (is_category() || is_tag() || is_tax()) { $term_link = get_term_link(get_queried_object()); $canonical_url = is_wp_error($term_link) ? '' : $term_link; } elseif (is_author()) { $canonical_url = get_author_posts_url((int) get_queried_object_id()); } elseif (is_date()) { if (is_day()) { $canonical_url = get_day_link((int) get_query_var('year'), (int) get_query_var('monthnum'), (int) get_query_var('day')); } elseif (is_month()) { $canonical_url = get_month_link((int) get_query_var('year'), (int) get_query_var('monthnum')); } elseif (is_year()) { $canonical_url = get_year_link((int) get_query_var('year')); } } if (empty($canonical_url)) { return ''; } // Point paginated archives at their own page, not page 1. return self::with_pagination($canonical_url, (int) get_query_var('paged')); } /** * Append a page number to a URL the way WordPress does. * * Extracted so the archive canonical is not the only thing that knows how * to build a paged URL: the schema graph derived its @id from the * un-paginated link, so every page of an archive claimed the same node * identity, and the singular canonical dropped the page entirely (#397). * * @since 2.0.1 * * @param string $url Base URL. * @param int $page Page number; 1 or less returns the URL unchanged. * @return string */ public static function with_pagination(string $url, int $page): string { if ($page <= 1 || '' === $url) { return $url; } global $wp_rewrite; if ($wp_rewrite && $wp_rewrite->using_permalinks()) { return trailingslashit($url) . user_trailingslashit( $wp_rewrite->pagination_base . '/' . $page, 'paged' ); } return add_query_arg('paged', $page, $url); } /** * Strip a page number from a URL, whichever form it takes. * * The inverse of with_pagination(). Pretty permalinks carry the page as a * /page/N/ path segment, plain permalinks as a `paged` query arg, and a * regex over the path alone silently left the latter in place — so * rel="prev" on page 2 pointed at page 2 (#397 review). * * @since 2.0.1 * * @param string $url URL that may carry a page number. * @return string URL for page 1. */ public static function without_pagination(string $url): string { if ('' === $url) { return $url; } $url = remove_query_arg('paged', $url); return (string) preg_replace( '#/' . preg_quote(self::pagination_base(), '#') . '/\d+/?$#', '/', $url ); } /** * The page number of the current request, archive or multi-page post. * * `paged` counts archive pages; `page` counts the <!--nextpage--> parts of * a single post. They are never both set. * * @since 2.0.1 * * @return int Page number, 1 when this is the first page. */ public static function current_page_number(): int { $paged = (int) get_query_var('paged'); if ($paged > 1) { return $paged; } $page = (int) get_query_var('page'); return $page > 1 ? $page : 1; } /** * Check if ThinkRank has metadata for current post * * @return bool True if has ThinkRank metadata */ private function has_thinkrank_metadata(): bool { // Populated by initialize_current_context() for singular views and for // term archives, and left empty everywhere else — so the emptiness // check is the whole test. The `!is_singular()` early return this // replaced is what made every stored term title and description inert: // the entire title/description cascade hangs off this method (#386). return !empty($this->current_metadata['title']) || !empty($this->current_metadata['description']); } /** * Check if current page has SEO data (public method for template functions) * * @return bool True if has SEO data */ public function has_seo_data(): bool { // Check if Site Identity is enabled and active if ($this->site_identity_data && $this->site_identity_data['enabled']) { return true; } // Check if post has ThinkRank metadata return $this->has_thinkrank_metadata(); } /** * Get current breadcrumbs data (public method for template functions) * * @return array|null Breadcrumb data or null if not available */ public function get_current_breadcrumbs(): ?array { if (!$this->site_identity_data || !$this->site_identity_data['enabled']) { return null; } $settings = $this->site_identity_manager->get_settings('site'); if (empty($settings['breadcrumbs_enabled'])) { return null; } return $this->generate_breadcrumbs($settings); } /** * Get current SEO metadata * * @return array Current metadata */ public function get_current_metadata(): array { return $this->current_metadata; } /** * Generate title based on current context using Site Identity templates * * @return string|null Generated title or null if no template available */ private function generate_context_title(): ?string { // Priority 1: Try Global SEO settings for current post type $global_seo_title = $this->get_global_seo_title(); if ($global_seo_title) { return $global_seo_title; } // Priority 2: Fall back to Site Identity templates if (!$this->site_identity_data || !$this->site_identity_data['enabled']) { return null; } $template = $this->get_title_template_for_context(); if (!$template) { return null; } $placeholders = $this->get_title_placeholders(); return $this->process_title_template($template, $placeholders); } /** * Get title from Global SEO settings for current post type * * @return string|null Generated title or null if no Global SEO template available */ private function get_global_seo_title(): ?string { // Only apply Global SEO to singular posts/pages if (!is_singular()) { return null; } $post_type = get_post_type(); if (!$post_type) { return null; } // Get Global SEO settings for this post type $global_seo_settings = $this->get_global_seo_settings($post_type); if (empty($global_seo_settings['title'])) { return null; } $template = $global_seo_settings['title']; $placeholders = $this->get_global_seo_placeholders(); return $this->process_global_seo_template($template, $placeholders); } /** * Get Global SEO settings for a post type * * @param string $post_type Post type slug * @return array Global SEO settings or empty array */ private function get_global_seo_settings(string $post_type): array { $all_settings = get_option('thinkrank_global_seo_settings', []); return $all_settings[$post_type] ?? []; } /** * Get placeholders for Global SEO template processing * * @return array Placeholder values */ private function get_global_seo_placeholders(): array { $placeholders = [ '%title%' => '', '%sitename%' => get_bloginfo('name'), '%sep%' => $this->get_global_seo_separator(), '%excerpt%' => '', '%date%' => get_the_date(), '%modified%' => get_the_modified_date(), '%author%' => '', '%category%' => '', ]; // Get current post data if available if ($this->current_post_id) { $placeholders['%title%'] = get_the_title($this->current_post_id); // Get excerpt $post = get_post($this->current_post_id); if ($post) { // An authored post_excerpt is written for public consumption, so // it stays. Falling back to the body does not: for a protected // post that derivation leaks the gated content through any // template containing %excerpt%, and this branch runs BEFORE the // derive-from-content priority below, so guarding only that one // would leave this path open (#363). if (!empty($post->post_excerpt)) { $placeholders['%excerpt%'] = $post->post_excerpt; } elseif (!$this->is_content_password_protected($post->ID)) { $placeholders['%excerpt%'] = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt( (string) $post->post_content ); } } // Get author $author_id = get_post_field('post_author', $this->current_post_id); $placeholders['%author%'] = get_the_author_meta('display_name', $author_id); // Get category (for posts) if (get_post_type($this->current_post_id) === 'post') { $categories = get_the_category($this->current_post_id); $placeholders['%category%'] = !empty($categories) ? $categories[0]->name : ''; } } return $placeholders; } /** * Get separator for Global SEO title * * @return string Separator symbol */ public function get_global_seo_separator(): string { return \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol(); } /** * Process Global SEO template with placeholders * * @param string $template Template string with variables * @param array $placeholders Placeholder values * @return string Processed title */ private function process_global_seo_template(string $template, array $placeholders): string { // Replace all placeholders $title = str_replace(array_keys($placeholders), array_values($placeholders), $template); // Clean up multiple spaces $title = preg_replace('/\s+/', ' ', $title); $title = trim($title); // Clean up multiple separators (e.g., "| |" becomes "|") $separator = $placeholders['%sep%'] ?? '|'; $separator_pattern = preg_quote($separator, '/'); $title = preg_replace('/\s*' . $separator_pattern . '\s*' . $separator_pattern . '\s*/', ' ' . $separator . ' ', $title); // Remove leading/trailing separators $title = trim($title, " \t\n\r\0\x0B" . $separator); return $title; } /** * Get title template for current context * * @return string|null Template string or null if not found */ private function get_title_template_for_context(): ?string { $settings = $this->site_identity_manager->get_settings('site'); switch ($this->current_context) { case 'homepage': // detect_current_context() collapses the static posts page into // 'homepage', so it rendered the front page's title template and // the two pages shipped the same <title> — a duplicate title on // the site's two most-linked URLs (#397 review). It is a page, // and it has its own name, so it gets the page template. if (self::is_static_posts_page()) { return $settings['page_title'] ?? $settings['homepage_title'] ?? null; } return $settings['homepage_title'] ?? null; case 'post': return $settings['post_title'] ?? null; case 'page': return $settings['page_title'] ?? null; case 'category': return $settings['category_title'] ?? null; case 'tag': return $settings['tag_title'] ?? null; case 'author': return $settings['author_title'] ?? null; case 'search': return $settings['search_title'] ?? null; case 'archive': return $settings['archive_title'] ?? null; default: return null; } } /** * Get title placeholders for current context * * @return array Placeholder values */ private function get_title_placeholders(): array { global $post, $wp_query; $settings = $this->site_identity_manager->get_settings('site'); $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe'); $placeholders = [ // first_non_empty(), not `??`: Site Identity persists these as '' // rather than leaving them unset, and '' is not null — so the // null-coalesce stopped dead on the empty string and the WordPress // fallback was unreachable. A site with a tagline set in Settings → // General rendered "%site_description%" as nothing (#398). This is // the same reasoning first_non_empty()'s own docblock records. '%site_title%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')), '%site_name%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')), '%site_description%' => $this->first_non_empty($settings['site_description'] ?? '', get_bloginfo('description')), '%tagline%' => $this->first_non_empty($settings['tagline'] ?? '', get_bloginfo('description')), '%separator%' => ' ' . $separator . ' ', '%sep%' => ' ' . $separator . ' ', '%date%' => gmdate('F Y'), ]; // Context-specific placeholders switch ($this->current_context) { case 'post': case 'page': if ($this->current_post_id) { $placeholders['%post_title%'] = get_the_title($this->current_post_id); $placeholders['%page_title%'] = get_the_title($this->current_post_id); $post_author = get_post_field('post_author', $this->current_post_id); $placeholders['%author%'] = get_the_author_meta('display_name', $post_author); $placeholders['%author_name%'] = get_the_author_meta('display_name', $post_author); // Get categories for posts $post_type = get_post_type($this->current_post_id); if ($post_type === 'post') { $categories = get_the_category($this->current_post_id); $placeholders['%category%'] = !empty($categories) ? $categories[0]->name : ''; } } break; case 'category': $category = get_queried_object(); if ($category) { $placeholders['%category_title%'] = $category->name; $placeholders['%category%'] = $category->name; } break; case 'tag': $tag = get_queried_object(); if ($tag) { $placeholders['%tag_title%'] = $tag->name; $placeholders['%tag%'] = $tag->name; } break; case 'author': $author = get_queried_object(); if ($author) { $placeholders['%author_name%'] = $author->display_name; $placeholders['%author%'] = $author->display_name; } break; case 'search': $placeholders['%search_term%'] = get_search_query(); break; case 'archive': // Stripped: get_the_archive_title() wraps its subject in a // <span>, and this placeholder feeds the document <title> as // well as og:title and twitter:title — a date archive rendered // as "Month: <span>August 2026</span> | Site". $placeholders['%archive_title%'] = wp_strip_all_tags((string) get_the_archive_title()); break; case 'homepage': // The page template resolved for a static posts page needs the // page's own name; without it %title%/%page_title% would render // empty and collapse back to the site title. if (self::is_static_posts_page()) { $posts_page_title = get_the_title((int) get_option('page_for_posts')); $placeholders['%title%'] = $posts_page_title; $placeholders['%page_title%'] = $posts_page_title; $placeholders['%post_title%'] = $posts_page_title; } break; } return $placeholders; } /** * Whether this request is a static posts page rather than the front page. * * @since 2.0.1 * * @return bool */ private static function is_static_posts_page(): bool { return is_home() && !is_front_page() && (int) get_option('page_for_posts') > 0; } /** * Process title template with placeholders * * @param string $template Template string * @param array $placeholders Placeholder values * @return string Processed title */ private function process_title_template(string $template, array $placeholders): string { $title = str_replace(array_keys($placeholders), array_values($placeholders), $template); // Clean up multiple separators and extra spaces $separator = $placeholders['%separator%'] ?? ' | '; // Legacy templates stored a literal pipe as separator — apply the active separator to them $title = preg_replace('/\s*\|\s*/', $separator, $title); $title = preg_replace('/\s*' . preg_quote(trim($separator), '/') . '\s*' . preg_quote(trim($separator), '/') . '\s*/', $separator, $title); $title = preg_replace('/\s+/', ' ', $title); $title = trim($title); // Remove trailing separator $separator_trimmed = trim($separator); if (substr($title, -strlen($separator_trimmed)) === $separator_trimmed) { $title = trim(substr($title, 0, -strlen($separator_trimmed))); } return $title; } /** * Get title separator symbol * * @param string $separator_type Separator type * @return string Separator symbol */ private function get_title_separator(string $separator_type): string { return \ThinkRank\SEO\Site_Identity_Manager::$title_separators[$separator_type]['symbol'] ?? \ThinkRank\SEO\Site_Identity_Manager::$title_separators['pipe']['symbol']; } /** * Whether a post's body must not be read for a public surface. * * Deriving metadata from `post_content` publishes that content to everyone * who requests the URL — and to every crawler and link-preview unfurler * that reads og:description — while the page itself still shows only the * password form, so the leak is invisible to the site owner (#363). * * This is the one thing every content reader should call before touching * `post_content` for output. It mirrors core: a visitor who has already * entered the correct password sees the body anyway, so nothing is hidden * from them here either. * * @param int|null $post_id Optional. Post ID. Defaults to the current post. * @return bool True when the body is password-gated for this visitor. */ private function is_content_password_protected(?int $post_id = null): bool { $post_id = $post_id ?? $this->current_post_id; if (!$post_id) { return false; } $post = get_post($post_id); if (!$post) { return false; } // Guarded for the same reason the schema path guards it: this class is // also exercised outside a full front-end request. return function_exists('post_password_required') && post_password_required($post); } /** * Get meta description with fallback system * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults * * @return string|null Meta description or null if none available */ private function get_meta_description(): ?string { // First priority: Post-specific ThinkRank metadata if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['description'])) { return $this->current_metadata['description']; } // Second priority: Global SEO description template $global_seo_description = $this->get_global_seo_description(); if ($global_seo_description) { return $global_seo_description; } // Archive contexts: derive the description from the archive itself // (term description, post type description, author bio) $archive_description = $this->get_archive_meta_description(); if ($archive_description) { return $archive_description; } // Third priority: Site Identity default meta description if ($this->site_identity_data && $this->site_identity_data['enabled']) { $settings = $this->site_identity_manager->get_settings('site'); $default_description = $settings['default_meta_description'] ?? ''; if (!empty($default_description)) { return $default_description; } } // Fourth priority: Generate from content for posts/pages. // Never for a password-protected post — deriving the description from a // gated body published its first ~25 words in the page head, and the // same value is reused for og:description and twitter:description, so // one unguarded read leaked through three tags (#363). if (is_singular() && $this->current_post_id && !$this->is_content_password_protected()) { $post_content = get_post_field('post_content', $this->current_post_id); if ($post_content) { $excerpt = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post_content); if (!empty($excerpt)) { return $excerpt; } } } // Fifth priority: Site description for homepage if (is_home() || is_front_page()) { $site_description = get_bloginfo('description'); if (!empty($site_description)) { return $site_description; } } return null; } /** * Get a meta description for archive contexts. * * Post type archives use the post type's description, taxonomy archives * the term description, author archives the author bio. Returns null for * non-archive contexts so the regular fallback chain continues. * * @return string|null Archive description or null when not applicable */ private function get_archive_meta_description(): ?string { $description = ''; // Author archives are intentionally excluded — Author_Archives_Manager // outputs its own template-based meta description on wp_head. if (is_post_type_archive()) { $post_type_object = get_queried_object(); if ($post_type_object instanceof \WP_Post_Type && !empty($post_type_object->description)) { $description = $post_type_object->description; } } elseif (is_category() || is_tag() || is_tax()) { $description = term_description() ?: ''; } $description = trim(wp_strip_all_tags((string) $description)); if ($description === '') { return null; } if (strlen($description) > 160) { $description = wp_trim_words($description, 25, '...'); } return $description; } /** * Get description from Global SEO settings for current post type * * @return string|null Generated description or null if no Global SEO template available */ private function get_global_seo_description(): ?string { // Only apply Global SEO to singular posts/pages if (!is_singular()) { return null; } $post_type = get_post_type(); if (!$post_type) { return null; } // Get Global SEO settings for this post type $global_seo_settings = $this->get_global_seo_settings($post_type); if (empty($global_seo_settings['description'])) { return null; } $template = $global_seo_settings['description']; $placeholders = $this->get_global_seo_placeholders(); return $this->process_global_seo_description_template($template, $placeholders); } /** * Process Global SEO description template with placeholders * * @param string $template Template string with variables * @param array $placeholders Placeholder values * @return string Processed description */ private function process_global_seo_description_template(string $template, array $placeholders): string { // Replace all placeholders $description = str_replace(array_keys($placeholders), array_values($placeholders), $template); // Clean up multiple spaces $description = preg_replace('/\s+/', ' ', $description); $description = trim($description); // Ensure description doesn't exceed recommended length (160 characters) if (strlen($description) > 160) { $description = wp_trim_words($description, 25, '...'); } return $description; } /** * Output site-wide schema markup with priority system * * Priority: Schema Manager > Site Identity (like Twitter Cards approach) * * @return void */ public function output_site_schema_markup(): void { $has_schema_manager_output = false; $has_website_schema = false; // The master switch on Essential SEO -> Schema Manager. Until #461 this // was never read here, so turning schema off left every deployed entity // on the page. Read it once and bail before touching the graph. if ($this->schema_manager) { $schema_settings = $this->schema_manager->get_settings('site', null); if (isset($schema_settings['enabled']) && !$schema_settings['enabled']) { return; } } // PRIORITY 1: Always output site-wide schemas (Organization, Website, LocalBusiness, Person) if ($this->schema_manager) { $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null); if (!empty($site_wide_schemas)) { foreach ($site_wide_schemas as $schema_type => $schema_info) { Schema_Graph::instance()->add_supporting($schema_info['data'], (string) $schema_type); } $has_schema_manager_output = true; $has_website_schema = isset($site_wide_schemas['WebSite']); } } // The homepage always gets a WebSite schema (with a SearchAction) so // search engines can associate the site name and sitelinks searchbox — // unless the Schema Manager already deployed one. if ((is_front_page() || is_home()) && !$has_website_schema) { $website_schema = $this->generate_website_schema(); /** * Filter the default homepage WebSite schema before output. * * @since 1.16.0 * * @param array $website_schema WebSite schema array ([] suppresses output). */ $website_schema = apply_filters('thinkrank_website_schema', $website_schema); if (!empty($website_schema)) { Schema_Graph::instance()->add_supporting($website_schema, 'WebSite'); } } // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages if ($this->schema_manager && (is_single() || is_page())) { $context_id = get_the_ID(); $context_type = get_post_type( $context_id ); $context_type = in_array( $context_type, [ 'site', 'post', 'page', 'product' ] , true) ? $context_type : 'post'; $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id); if (!empty($page_specific_schemas)) { // Apply filter for Pro to allow multiple schemas $page_specific_schemas = apply_filters( 'thinkrank_page_schemas_to_render', $page_specific_schemas, $context_type, $context_id ); // Free tier renders at most self::FREE_PAGE_SCHEMA_LIMIT // page-specific schemas; Pro renders all of them. // // Both comments here used to say the free limit was 1 while the // code allowed 2 (#405). The number the code enforces is what // has shipped, so that is what stands — lowering it would take // a schema away from every free site on upgrade — and it now // lives in one named place instead of twice in prose and twice // in a literal. if (!\ThinkRank\Core\Plan_Config::is_pro() && count($page_specific_schemas) > self::FREE_PAGE_SCHEMA_LIMIT) { $page_specific_schemas = array_slice( $page_specific_schemas, 0, self::FREE_PAGE_SCHEMA_LIMIT, true ); } foreach ($page_specific_schemas as $schema_type => $schema_info) { Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager'); } $has_schema_manager_output = true; } } // Absorb FAQ content from the post body (FAQ block / Elementor widget) // so it merges into the graph's single FAQPage instead of each producer // emitting its own competing one. if (is_singular()) { $queried_post = get_post(); if ($queried_post instanceof \WP_Post) { Schema_Graph::instance()->collect_post_faq($queried_post); } } // Skip Site Identity fallback if any Schema Manager schemas were output if ($has_schema_manager_output) { return; } // PRIORITY 2: Fall back to Site Identity schemas (like basic Twitter Cards) if (!$this->site_identity_data || !$this->site_identity_data['enabled']) { return; } $settings = $this->site_identity_manager->get_settings('site'); // Only output on homepage or if organization schema is enabled if (!is_home() && !is_front_page() && empty($settings['organization_schema'])) { return; } $schema = $this->generate_organization_schema($settings); if ($schema) { Schema_Graph::instance()->add_supporting($schema, 'Organization'); } } /** * Generate the default WebSite schema for the homepage. * * Includes a SearchAction potentialAction so search engines can surface a * sitelinks searchbox, mirroring what Rank Math/Yoast output by default. * * @return array WebSite schema */ private function generate_website_schema(): array { $settings = $this->site_identity_manager ? $this->site_identity_manager->get_settings('site') : []; $schema = [ '@context' => 'https://schema.org', '@type' => 'WebSite', '@id' => home_url('/#website'), 'name' => !empty($settings['site_name']) ? $settings['site_name'] : get_bloginfo('name'), 'url' => home_url('/'), ]; $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description'); if (!empty($description)) { $schema['description'] = $description; } $schema['potentialAction'] = [ '@type' => 'SearchAction', 'target' => [ '@type' => 'EntryPoint', 'urlTemplate' => home_url('/?s={search_term_string}'), ], 'query-input' => 'required name=search_term_string', ]; return $schema; } /** * Generate organization schema markup * * Priority: Schema Manager organization settings > Site Identity settings * * @param array $settings Site identity settings (used as fallback) * @return array|null Schema data or null if insufficient data */ private function generate_organization_schema(array $settings): ?array { // PRIORITY 1: Get Schema Manager organization settings $schema_settings = []; if ($this->schema_manager) { $schema_settings = $this->schema_manager->get_settings('site', null); } // Determine organization values (Schema Manager > Site Identity > WordPress default). // Use first_non_empty() rather than ??: these settings keys are always present // and default to an empty string, so a ?? chain would stop dead on '' and never // reach the WordPress fallback. $org_name = $this->first_non_empty( $schema_settings['organization_name'] ?? null, $settings['site_name'] ?? null, get_bloginfo('name') ); $org_url = $this->first_non_empty( $schema_settings['organization_url'] ?? null, $settings['site_url'] ?? null, home_url() ); $org_description = $this->first_non_empty( $schema_settings['organization_description'] ?? null, $settings['site_description'] ?? null, get_bloginfo('description') ); if (empty($org_name)) { return null; } // Determine organization type (Schema Manager setting or default) $org_type = $schema_settings['organization_type'] ?? 'Organization'; $schema = [ '@context' => 'https://schema.org', '@type' => $org_type, '@id' => home_url() . '#organization', 'name' => $org_name, 'url' => $org_url, ]; // Add description if available if (!empty($org_description)) { $schema['description'] = $org_description; } // Add logo if available with proper ImageObject structure // Priority: Schema Manager logo > Site Identity logo $logo_url = $schema_settings['organization_logo'] ?? $settings['logo_url'] ?? ''; if (!empty($logo_url)) { $schema['logo'] = [ '@type' => 'ImageObject', '@id' => home_url() . '#logo', 'url' => $logo_url, 'contentUrl' => $logo_url, 'caption' => $org_name . ' Logo' ]; // Also add as image property $schema['image'] = $schema['logo']; } // Add social media accounts if available // Priority: Schema Manager social profiles > Site Identity social profiles $social_urls = []; // Check Schema Manager organization social profiles first if (!empty($schema_settings['organization_social_facebook'])) { $social_urls[] = $schema_settings['organization_social_facebook']; } if (!empty($schema_settings['organization_social_twitter'])) { $twitter_url = $schema_settings['organization_social_twitter']; // Ensure it's a full URL if (strpos($twitter_url, 'http') !== 0) { $twitter_url = 'https://twitter.com/' . ltrim($twitter_url, '@'); } $social_urls[] = $twitter_url; } if (!empty($schema_settings['organization_social_linkedin'])) { $social_urls[] = $schema_settings['organization_social_linkedin']; } if (!empty($schema_settings['organization_social_instagram'])) { $social_urls[] = $schema_settings['organization_social_instagram']; } if (!empty($schema_settings['organization_social_youtube'])) { $social_urls[] = $schema_settings['organization_social_youtube']; } if (!empty($schema_settings['organization_social_pinterest'])) { $social_urls[] = $schema_settings['organization_social_pinterest']; } if (!empty($schema_settings['organization_social_whatsapp'])) { $social_urls[] = $schema_settings['organization_social_whatsapp']; } if (!empty($schema_settings['organization_social_telegram'])) { $social_urls[] = $schema_settings['organization_social_telegram']; } // Fallback to Site Identity social profiles if no Schema Manager profiles if (empty($social_urls) && !empty($this->site_identity_data['social'])) { $social_data = $this->site_identity_data['social']; if (!empty($social_data['facebook_url'])) { $social_urls[] = $social_data['facebook_url']; } if (!empty($social_data['twitter_username'])) { $twitter_url = 'https://twitter.com/' . ltrim($social_data['twitter_username'], '@'); $social_urls[] = $twitter_url; } if (!empty($social_data['linkedin_url'])) { $social_urls[] = $social_data['linkedin_url']; } if (!empty($social_data['instagram_url'])) { $social_urls[] = $social_data['instagram_url']; } if (!empty($social_data['youtube_url'])) { $social_urls[] = $social_data['youtube_url']; } } if (!empty($social_urls)) { $schema['sameAs'] = $social_urls; } // Add contact information if available // Priority: Schema Manager contact info > Site Identity contact info if (!empty($schema_settings['organization_contact_phone']) || !empty($schema_settings['organization_contact_email'])) { $contact_point = [ '@type' => 'ContactPoint', 'contactType' => $schema_settings['organization_contact_type'] ?? 'customer service' ]; if (!empty($schema_settings['organization_contact_phone'])) { $contact_point['telephone'] = $schema_settings['organization_contact_phone']; } if (!empty($schema_settings['organization_contact_email'])) { $contact_point['email'] = $schema_settings['organization_contact_email']; } if (!empty($schema_settings['organization_contact_hours'])) { $contact_point['hoursAvailable'] = $schema_settings['organization_contact_hours']; } $schema['contactPoint'] = $contact_point; } elseif (!empty($settings['contact_email'])) { // Fallback to Site Identity contact email $schema['email'] = $settings['contact_email']; } return $schema; } /** * Return the first value that is a non-empty (after trim) string. * * Settings keys such as organization_url are always present and default to * an empty string, so the null-coalescing operator (??) cannot be used to * build a fallback chain: '' is not null and would short-circuit the chain. * This helper skips empty strings and returns the first real value, falling * back to '' when none qualify. * * @param string|null ...$values Candidate values in priority order. * @return string First non-empty value, or '' if none. */ private function first_non_empty(...$values): string { foreach ($values as $value) { if (is_string($value) && trim($value) !== '') { return $value; } } return ''; } /** * Output breadcrumb schema markup * * @return void */ public function output_breadcrumb_schema(): void { if (!$this->site_identity_data || !$this->site_identity_data['enabled']) { return; } // A breadcrumb trail for a URL that does not exist, or for a search // results page, describes nothing — and the plugin already emits no // canonical on either (#471). if (is_404() || is_search()) { return; } $settings = $this->site_identity_manager->get_settings('site'); // Only output if breadcrumbs are enabled if (empty($settings['breadcrumbs_enabled'])) { return; } $breadcrumbs = $this->generate_breadcrumbs($settings); if (!empty($breadcrumbs['schema'])) { Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList'); } } /** * Emit everything ThinkRank collected for this request as one linked @graph. * * Runs after every producer has registered (site schema 7, breadcrumbs 8, * Global SEO 15), so the graph can arbitrate between them. * * @since 1.32.0 * @return void */ public function output_schema_graph(): void { Schema_Graph::instance()->render(); } /** * Output closing comment for ThinkRank SEO * * @return void */ public function output_closing_comment(): void { // Close only what was actually opened. has_seo_output() is true on // nearly every page, so testing it here printed a closing comment with // no matching opener whenever the meta description was empty (search // results, author archives without a description). if (self::$opening_comment_output) { echo "<!-- /ThinkRank SEO -->\n"; } } /** * Print the opening ThinkRank comment, once per request. * * Public and static so Author_Archives_Manager — which prints its own meta * description on wp_head at priority 5 — opens the block through the same * flag the closing comment reads. * * @since 2.0.1 * @return void */ public static function note_opening_comment(): void { if (self::$opening_comment_output) { return; } echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n"; self::$opening_comment_output = true; } /** * Display breadcrumbs HTML * * @return void */ public function display_breadcrumbs(): void { if (!$this->site_identity_data || !$this->site_identity_data['enabled']) { return; } $settings = $this->site_identity_manager->get_settings('site'); // Only display if breadcrumbs are enabled if (empty($settings['breadcrumbs_enabled'])) { return; } $breadcrumbs = $this->generate_breadcrumbs($settings); if (!empty($breadcrumbs['html'])) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is properly escaped in generate_breadcrumb_html method echo $breadcrumbs['html']; } } /** * Render breadcrumbs for the [thinkrank_breadcrumbs] shortcode * * Respects the same site-identity / breadcrumbs_enabled gates as * display_breadcrumbs(). * * @return string Breadcrumb HTML (empty string when disabled) */ public function breadcrumbs_shortcode(): string { ob_start(); $this->display_breadcrumbs(); return (string) ob_get_clean(); } /** * Display the hero section for the `thinkrank_hero` action hook / * `thinkrank_hero()` template tag. * * Gated on the Site Identity master toggle. Emits nothing when no hero * content (title/subtitle/CTA) is configured, so an empty hero never * appears on the front end. * * @return void */ public function display_hero(): void { if (!$this->site_identity_data || !$this->site_identity_data['enabled']) { return; } $settings = $this->site_identity_manager->get_settings('site'); $html = $this->generate_hero_html($settings); if ($html !== '') { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is escaped field-by-field in generate_hero_html(). echo $html; } } /** * Render the hero section for the [thinkrank_hero] shortcode. * * Respects the same gates as display_hero(). * * @return string Hero HTML (empty string when disabled or unconfigured) */ public function hero_shortcode(): string { ob_start(); $this->display_hero(); return (string) ob_get_clean(); } /** * Get the current hero section data without displaying it. * * @return array|null Hero data (title, subtitle, cta_text, cta_url, * background_image, html) or null when unavailable. */ public function get_current_hero(): ?array { if (!$this->site_identity_data || !$this->site_identity_data['enabled']) { return null; } $settings = $this->site_identity_manager->get_settings('site'); $hero = [ 'title' => (string) ($settings['hero_title'] ?? ''), 'subtitle' => (string) ($settings['hero_subtitle'] ?? ''), 'cta_text' => (string) ($settings['hero_cta_text'] ?? ''), 'cta_url' => (string) ($settings['hero_cta_url'] ?? ''), 'background_image' => (string) ($settings['hero_background_image'] ?? ''), ]; // generate_hero_html() is the single source of truth for the // "is anything renderable?" gate (title, subtitle, or a complete CTA), // so defer to it rather than duplicate the check — and never expose an // empty hero. $hero['html'] = $this->generate_hero_html($settings); if ($hero['html'] === '') { return null; } return $hero; } /** * Build the hero section HTML from Site Identity settings. * * Every dynamic value is escaped at the point of output. Returns an empty * string when there is no title, subtitle, or complete CTA (text + URL). * * @param array $settings Site Identity settings * @return string Hero HTML, or '' when there is nothing to render */ private function generate_hero_html(array $settings): string { $title = trim((string) ($settings['hero_title'] ?? '')); $subtitle = trim((string) ($settings['hero_subtitle'] ?? '')); $cta_text = trim((string) ($settings['hero_cta_text'] ?? '')); $cta_url = trim((string) ($settings['hero_cta_url'] ?? '')); $bg_image = trim((string) ($settings['hero_background_image'] ?? '')); // A CTA is only meaningful with both a label and a destination. $has_cta = ($cta_text !== '' && $cta_url !== ''); // Don't emit an empty hero when nothing renderable is configured. A // background image alone — or CTA text without a URL — is not enough. if ($title === '' && $subtitle === '' && !$has_cta) { return ''; } $classes = ['thinkrank-hero']; $style = ''; if ($bg_image !== '') { $classes[] = 'thinkrank-hero--has-image'; $style = ' style="background-image:url(' . esc_url($bg_image) . ');"'; } $html = '<section class="' . esc_attr(implode(' ', $classes)) . '"' . $style . '>'; $html .= '<div class="thinkrank-hero__inner">'; if ($title !== '') { $html .= '<h2 class="thinkrank-hero__title">' . esc_html($title) . '</h2>'; } if ($subtitle !== '') { $html .= '<p class="thinkrank-hero__subtitle">' . esc_html($subtitle) . '</p>'; } if ($has_cta) { $html .= '<a class="thinkrank-hero__cta" href="' . esc_url($cta_url) . '">' . esc_html($cta_text) . '</a>'; } $html .= '</div></section>'; return $html; } /** * Generate breadcrumbs data * * @param array $settings Breadcrumb settings * @return array Breadcrumb data with HTML and schema */ private function generate_breadcrumbs(array $settings): array { $breadcrumbs = [ 'items' => [], 'html' => '', 'schema' => null ]; // Get breadcrumb items $items = $this->get_breadcrumb_items($settings); if (empty($items)) { return $breadcrumbs; } $breadcrumbs['items'] = $items; // Generate HTML $breadcrumbs['html'] = $this->generate_breadcrumb_html($items, $settings); // Generate schema $breadcrumbs['schema'] = $this->generate_breadcrumb_schema($items); return $breadcrumbs; } /** * Serve /llms.txt through PHP so the response declares UTF-8. * * Cheap guard first: every other front-end request leaves without loading * the manager. * * @since 1.32.0 * * @return void */ public function maybe_serve_llms_txt(): void { if (!$this->is_llms_txt_request()) { return; } if (!class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-llms-txt-manager.php'; } $manager = new \ThinkRank\SEO\LLMs_Txt_Manager(); $manager->serve_llms_txt(); } /** * Whether the current request is for /llms.txt. * * @since 1.32.0 * * @return bool */ private function is_llms_txt_request(): bool { if (empty($_SERVER['REQUEST_URI'])) { return false; } $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH); if (!is_string($path) || '' === $path) { return false; } // Strip the install's home path so subdirectory installs match too. $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH); if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) { $path = substr($path, strlen($home_path)); } return 'llms.txt' === strtolower(trim($path, '/')); } /** * Filter WordPress robots.txt output * * @param string $output The default robots.txt output * @param string $is_public Whether the site is public * @return string Modified robots.txt content */ public function filter_robots_txt(string $output, string $is_public): string { // Only override if Site Identity is enabled and robots.txt management is enabled if (!$this->site_identity_data || !$this->site_identity_data['enabled']) { return $output; } $settings = $this->site_identity_manager->get_settings('site'); if (empty($settings['robots_txt_enabled'])) { return $output; } // Serve the effective content (manual textarea edit if present, else // auto-generated) so the live /robots.txt matches what the admin sees. try { $content = $this->site_identity_manager->render_robots_txt(); if (!empty($content)) { return $content; } } catch (\Exception $e) { // Rendering failed - fall back to default output } // Fallback to default output if rendering fails return $output; } /** * Disable WordPress core's sitemap while ThinkRank's sitemap is enabled. * * Prevents the site from publishing two competing sitemap indexes. Core's * /wp-sitemap.xml is taken offline (it 404s) and, as a consequence, core * stops adding its own "Sitemap:" directive to robots.txt — including on the * paths where ThinkRank does not own the robots.txt output. * * Only ever turns core's sitemap *off*: when ThinkRank's sitemap is disabled * the incoming value is returned untouched, so core (or another plugin * filtering this) keeps whatever behaviour it already had. * * @since 1.31.0 * * @param bool $enabled Whether core's sitemap functionality is enabled. * @return bool Filtered value. */ public function filter_wp_sitemaps_enabled($enabled): bool { return $this->should_disable_core_sitemap() ? false : (bool) $enabled; } /** * Redirect the sitemap URLs core owns to the sitemap ThinkRank publishes. * * Only the *index* route is redirected. Core's per-type children * (/wp-sitemap-posts-post-1.xml and friends) are genuinely gone once core is * switched off, and a 404 is the honest answer for those; the index is the * one URL crawlers and humans actually guess, and the one core's own * /sitemap.xml rule funnels into. * * @since 1.31.0 * * @return void */ public function redirect_core_sitemap_requests(): void { if ('index' !== get_query_var('sitemap')) { return; } $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : ''; $target = $this->resolve_core_sitemap_redirect( (string) wp_parse_url($request_uri, PHP_URL_PATH) ); if ('' === $target) { return; } wp_safe_redirect($target, 301, 'ThinkRank'); exit; } /** * Where a request for one of core's sitemap URLs should be sent, if anywhere. * * Split out from the hook so the rules are testable without dispatching a * request — the caller above is the only part that cannot be (it exits). * * @since 1.31.0 * * @param string $requested_path Path of the incoming request. * @return string Absolute URL to redirect to, or '' to leave the request alone. */ private function resolve_core_sitemap_redirect(string $requested_path): string { // Nothing to redirect to unless we have actually taken core offline, // which already implies our own sitemap file is on disk. if (!$this->should_disable_core_sitemap()) { return ''; } $target = $this->thinkrank_sitemap_url; if ('' === $target) { return ''; } // Never redirect a URL to itself. A site publishing at /sitemap.xml // normally has the web server serve that file before WordPress sees the // request, but on a setup where the request does reach PHP this is the // difference between a redirect and a loop. $destination = (string) wp_parse_url($target, PHP_URL_PATH); if ('' !== $requested_path && untrailingslashit($requested_path) === untrailingslashit($destination)) { return ''; } return $target; } /** * Whether core's sitemap should be switched off for this site. * * True when ThinkRank publishes its own sitemap — except in two cases where * taking core offline would leave a URL answering nothing: * * 1. The site is configured to publish *at core's own URL* (the "WordPress * Core" preset). Once the static file exists the web server serves it * ahead of WordPress anyway, so core can be left alone. * 2. ThinkRank's own sitemap file is not on disk yet. Sitemaps here are * static files with no dynamic route (see save_sitemap_to_file()), so * while the file is missing core's /sitemap.xml -> /wp-sitemap.xml * redirect is the only thing answering that URL; suppressing core would * turn a recoverable "enabled but not generated" state into a hard 404 * for crawlers. Core is taken offline as soon as our file appears, so the * duplicate-index conflict this filter exists to prevent cannot occur — * two indexes are only ever reachable if both are actually published. * * Once our file does exist, the URLs core stops answering are handed to * redirect_core_sitemap_requests() rather than left to 404 — which is why * this also resolves the destination. * * Resolved lazily and memoised: this is consulted from an `init`-time filter * on every request, and the underlying settings read is object-cached. The * memoisation also keeps the file_exists() call to one per request. * * @since 1.31.0 * * @return bool True when WordPress core's sitemap should be disabled. */ private function should_disable_core_sitemap(): bool { if ($this->thinkrank_sitemap_enabled === null) { try { // Read-only instance — passing false keeps it from registering a // second copy of the save_post/term auto-generation hooks. $generator = new \ThinkRank\SEO\Sitemap_Generator(false); $settings = $generator->get_settings('site'); $this->thinkrank_sitemap_enabled = !empty($settings['enabled']) && !$this->publishes_at_core_sitemap_url($settings) && $generator->primary_sitemap_file_exists($settings); if ($this->thinkrank_sitemap_enabled) { $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings); } } catch (\Exception $e) { // Settings unreadable — leave core's sitemap alone rather than // removing a working sitemap on the strength of a failed read. $this->thinkrank_sitemap_enabled = false; $this->thinkrank_sitemap_url = ''; } } return $this->thinkrank_sitemap_enabled; } /** * Whether any configured sitemap URL is WordPress core's own wp-sitemap.xml. * * @since 1.31.0 * * @param array $settings Sitemap settings. * @return bool True when the site publishes at core's sitemap URL. */ private function publishes_at_core_sitemap_url(array $settings): bool { foreach ((array) ($settings['sitemap_urls'] ?? []) as $sitemap) { if (!is_array($sitemap)) { continue; } $path = (string) wp_parse_url((string) ($sitemap['url'] ?? ''), PHP_URL_PATH); if (ltrim($path, '/') === 'wp-sitemap.xml') { return true; } } return false; } /** * Re-sync the physical robots.txt when WordPress's "Discourage search * engines" setting (blog_public) changes. * * Only acts when ThinkRank robots management is enabled AND a physical * robots.txt already exists — a stale physical file is the failure being * fixed. When no file exists the virtual robots_txt filter already reflects * blog_public live (render_robots_txt() enforces the full block), so there is * nothing to re-sync and no reason to create a file the user never generated. * * @return void */ public function on_blog_public_changed(): void { if (!$this->site_identity_manager) { return; } // Gate on robots management (robots_txt_enabled), not the Site Identity // master toggle: the physical file's lifecycle is governed by that // setting alone (same as sync_robots_txt_file() and the save endpoint), // and a stale physical file is served by the web server regardless of the // master toggle. $settings = $this->site_identity_manager->get_settings('site'); if (empty($settings['robots_txt_enabled'])) { return; } if (file_exists(ABSPATH . 'robots.txt')) { $this->site_identity_manager->sync_robots_txt_file(); } } /** * Use the Site Identity favicon as the site icon URL * * When a favicon is uploaded in ThinkRank Site Identity it takes * precedence over the core site icon; with no core icon set this also * makes has_site_icon() truthy so wp_site_icon() prints the icon tags. * Reads settings directly (not site_identity_data) because this filter * also runs in admin, before initialize_current_context(). * * @param string $url Site icon URL from core * @param int $size Requested icon size * @return string Icon URL */ public function filter_site_icon_url($url, $size = 512): string { $settings = $this->site_identity_manager->get_settings('site'); if (empty($settings['enabled'])) { return (string) $url; } // Apple touch icon has its own dedicated setting if ((int) $size === 180 && !empty($settings['apple_touch_icon_url'])) { return esc_url($settings['apple_touch_icon_url']); } if (!empty($settings['favicon_url'])) { return esc_url($settings['favicon_url']); } return (string) $url; } /** * Get breadcrumb items for current page * * @param array $settings Breadcrumb settings * @return array Breadcrumb items */ private function get_breadcrumb_items(array $settings): array { $items = []; // Always start with home $home_text = $settings['breadcrumb_home_text'] ?? 'Home'; $items[] = [ 'title' => $home_text, 'url' => home_url(), 'position' => 1 ]; $position = 2; if (is_single()) { $current_post_id = get_the_ID(); if ($current_post_id) { // Add categories for posts $post_type = get_post_type($current_post_id); if ($post_type === 'post') { $categories = get_the_category($current_post_id); if (!empty($categories)) { $category = $categories[0]; $items[] = [ 'title' => $category->name, 'url' => get_category_link($category->term_id), 'position' => $position++ ]; } } // Add current post. `empty($x) || $x` is true for every possible // value — an unset key, false, 0, '' and any truthy value alike — // so the setting had no effect on the rendered breadcrumb or on // the BreadcrumbList JSON-LD, while the admin preview honoured it // and disagreed with live output (#398). Site_Identity_Manager // already had the correct form: default to on, respect an // explicit off. if ($settings['show_current_page'] ?? true) { $items[] = [ 'title' => get_the_title($current_post_id), 'url' => get_permalink($current_post_id), 'position' => $position, 'current' => true ]; } } } elseif (is_page()) { $current_post_id = get_the_ID(); if ($current_post_id) { // Add parent pages $parents = []; $parent_id = wp_get_post_parent_id($current_post_id); while ($parent_id) { $parent = get_post($parent_id); if ($parent) { $parents[] = [ 'title' => get_the_title($parent->ID), 'url' => get_permalink($parent->ID), 'position' => 0 // Will be set later ]; $parent_id = $parent->post_parent; } else { break; } } // Reverse to get correct order $parents = array_reverse($parents); // Add parents with correct positions foreach ($parents as $parent) { $parent['position'] = $position++; $items[] = $parent; } // Add current page if ($settings['show_current_page'] ?? true) { $items[] = [ 'title' => get_the_title($current_post_id), 'url' => get_permalink($current_post_id), 'position' => $position, 'current' => true ]; } } } elseif (is_category()) { $category = get_queried_object(); // Add parent categories $parents = []; $parent_id = $category->parent; while ($parent_id) { $parent = get_category($parent_id); if ($parent && !is_wp_error($parent)) { $parents[] = [ 'title' => $parent->name, 'url' => get_category_link($parent->term_id), 'position' => 0 // Will be set later ]; $parent_id = $parent->parent; } else { break; } } // Reverse to get correct order $parents = array_reverse($parents); // Add parents with correct positions foreach ($parents as $parent) { $parent['position'] = $position++; $items[] = $parent; } // Add current category if ($settings['show_current_page'] ?? true) { $items[] = [ 'title' => $category->name, 'url' => get_category_link($category->term_id), 'position' => $position, 'current' => true ]; } } return $items; } /** * Generate breadcrumb HTML * * @param array $items Breadcrumb items * @param array $settings Breadcrumb settings * @return string HTML output */ private function generate_breadcrumb_html(array $items, array $settings): string { if (empty($items)) { return ''; } $separator = $settings['breadcrumb_separator'] ?? '>'; $prefix = $settings['breadcrumb_prefix'] ?? ''; $html = '<nav class="thinkrank-breadcrumbs" aria-label="Breadcrumb">'; if (!empty($prefix)) { $html .= '<span class="breadcrumb-prefix">' . esc_html($prefix) . '</span> '; } $html .= '<ol class="breadcrumb-list">'; $total_items = count($items); foreach ($items as $index => $item) { $is_last = ($index === $total_items - 1); $is_current = !empty($item['current']); $html .= '<li class="breadcrumb-item' . ($is_current ? ' current' : '') . '">'; if (!$is_current && !empty($item['url'])) { $html .= '<a href="' . esc_url($item['url']) . '">' . esc_html($item['title']) . '</a>'; } else { $html .= '<span>' . esc_html($item['title']) . '</span>'; } if (!$is_last) { $html .= ' <span class="breadcrumb-separator">' . esc_html($separator) . '</span> '; } $html .= '</li>'; } $html .= '</ol>'; $html .= '</nav>'; return $html; } /** * Generate breadcrumb schema markup * * @param array $items Breadcrumb items * @return array Schema data */ private function generate_breadcrumb_schema(array $items): array { if (empty($items)) { return []; } $schema_items = []; foreach ($items as $item) { $schema_items[] = [ '@type' => 'ListItem', 'position' => $item['position'], 'name' => $item['title'], 'item' => $item['url'] ]; } return [ '@context' => 'https://schema.org', '@type' => 'BreadcrumbList', 'itemListElement' => $schema_items ]; } /** * Get Twitter image with proper fallback priority * * @since 1.0.0 * * @return string|null Twitter image URL or null if none available */ private function get_twitter_image_with_fallback(): ?string { // Cascade: post-specific Twitter image > post-specific OG image > featured image. if (is_singular() && $this->current_post_id) { $post_twitter_image = get_post_meta($this->current_post_id, '_thinkrank_twitter_image', true); if (!empty($post_twitter_image)) { return $post_twitter_image; } $post_og_image = get_post_meta($this->current_post_id, '_thinkrank_og_image', true); if (!empty($post_og_image)) { return $post_og_image; } // Check featured image as fallback for posts if (has_post_thumbnail($this->current_post_id)) { $featured_image_url = get_the_post_thumbnail_url($this->current_post_id, 'large'); if ($featured_image_url) { return $featured_image_url; } } } // Check Social Meta Manager settings for Twitter-specific default image if ($this->social_manager) { $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context; $social_settings = $this->social_manager->get_settings($social_context, $this->current_post_id); // Prioritize Twitter-specific default image if (!empty($social_settings['default_twitter_image'])) { return $social_settings['default_twitter_image']; } // Fallback to Open Graph default image if (!empty($social_settings['default_og_image'])) { return $social_settings['default_og_image']; } // Final fallback to generic default image if (!empty($social_settings['default_image'])) { return $social_settings['default_image']; } } // Check Site Identity data for social images if ($this->site_identity_data && !empty($this->site_identity_data['social'])) { $social_data = $this->site_identity_data['social']; // Check for any configured social image if (!empty($social_data['default_image'])) { return $social_data['default_image']; } } return null; } }