` 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 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 "\n";
echo '' . "\n";
echo "\n";
}
}
/**
* Output SEO meta tags
*
* @return void
*/
public function output_seo_meta_tags(): void {
echo "\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 '' . "\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 '' . "\n";
}
}
// Output local SEO meta tags if business info is available
$this->output_local_seo_meta_tags();
// Output generator meta tag
echo '' . "\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 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 "\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 "\n";
// NAP (Name, Address, Phone) Consistency Meta Tags
if (!empty($settings['business_name'])) {
echo '' . "\n";
}
// Business address components
if (!empty($settings['business_address'])) {
echo '' . "\n";
}
if (!empty($settings['business_city'])) {
echo '' . "\n";
echo '' . "\n";
}
if (!empty($settings['business_state'])) {
echo '' . "\n";
}
if (!empty($settings['business_postal_code'])) {
echo '' . "\n";
}
if (!empty($settings['business_country'])) {
echo '' . "\n";
}
// Phone number
if (!empty($settings['business_phone'])) {
echo '' . "\n";
}
// Email address
if (!empty($settings['business_email'])) {
echo '' . "\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 '' . "\n";
echo '' . "\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 '' . "\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 '' . "\n";
}
}
// Business type
if (!empty($settings['business_type'])) {
echo '' . "\n";
}
echo "\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 "\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 '' . "\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 '' . "\n";
}
}
echo "\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 "\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 '' . "\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 '' . "\n";
}
}
echo "\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 "\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 '' . "\n";
} else {
// Other platform tags use name attribute
echo '' . "\n";
}
}
}
echo "\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 /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 "\n";
echo "\n";
echo "\n";
echo "\n";
echo "\n";
echo "\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 "\n";
// Add OG image — per-post override > featured image
if (is_singular() && $this->current_post_id) {
if (!empty($og_image_override)) {
echo "\n";
echo "\n";
} elseif (has_post_thumbnail($this->current_post_id)) {
$image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
echo "\n";
echo "\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 "\n";
echo "\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 "\n";
}
}
// Add image alt text
$image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', true);
if ($image_alt) {
echo "\n";
}
}
// Add article specific tags for posts only
if ($og_type === 'article') {
echo '' . "\n";
echo '' . "\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 "\n";
// Add categories as article:section
if (is_single()) {
$categories = get_the_category($this->current_post_id);
if (!empty($categories)) {
echo "name) . "\" />\n";
}
}
}
}
echo "\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 /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 "\n";
echo '' . "\n";
echo "\n";
echo "\n";
// Add Twitter image with proper fallback priority
$twitter_image_url = $this->get_twitter_image_with_fallback();
if ($twitter_image_url) {
echo "\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 "\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 "\n";
}
echo "\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 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 "\n";
echo "\n";
echo "\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(
"\n",
esc_url(self::with_pagination($base, $current - 1))
);
}
if ($current < $total) {
printf(
"\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 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 — 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
// , and this placeholder feeds the document as
// well as og:title and twitter:title — a date archive rendered
// as "Month: August 2026 | 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 "\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 "\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 = '';
$html .= '
';
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 = '';
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;
}
}