PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / frontend / class-seo-manager.php

class-seo-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/frontend/class-seo-manager.php

4,290 lines 168.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Frontend SEO Manager Class
5 *
6 * Handles frontend SEO meta tag output and WordPress integration
7 *
8 * @package ThinkRank\Frontend
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\Frontend;
15
16 // Prevent direct access
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * Frontend SEO Manager Class
23 *
24 * Single Responsibility: Output SEO meta tags and integrate with WordPress SEO
25 *
26 * @since 1.0.0
27 */
28 class SEO_Manager {
29
30 /**
31 * Current post ID
32 *
33 * @var int|null
34 */
35 private ?int $current_post_id = null;
36
37 /**
38 * Current post metadata
39 *
40 * @var array
41 */
42 private array $current_metadata = [];
43
44 /**
45 * Term ID of the archive being rendered, when the request is a term archive.
46 *
47 * @since 2.0.1
48 * @var int|null
49 */
50 private ?int $current_term_id = null;
51
52 /**
53 * Site Identity Manager instance
54 *
55 * @var \ThinkRank\SEO\Site_Identity_Manager|null
56 */
57 private ?\ThinkRank\SEO\Site_Identity_Manager $site_identity_manager = null;
58
59 /**
60 * Resolved icon URLs, keyed by "<md5 of configured URL>:<size>".
61 *
62 * wp_site_icon() renders four tags per page and each one resolves the same
63 * setting, so without this the lookup is four rounds of
64 * attachment_url_to_postid() — an uncached postmeta query apiece — for one
65 * answer. Loaded from, and persisted to, a transient: this filter runs in
66 * wp_head on every FRONT-END request, and the mapping only changes when the
67 * icon setting does.
68 *
69 * @var array<string, string>|null Null until loaded.
70 */
71 private ?array $icon_urls = null;
72
73 /**
74 * Whether $icon_urls gained an entry that is not in the transient yet.
75 *
76 * @var bool
77 */
78 private bool $icon_urls_dirty = false;
79
80 /**
81 * Social Meta Manager instance
82 *
83 * @var \ThinkRank\SEO\Social_Meta_Manager|null
84 */
85 private ?\ThinkRank\SEO\Social_Meta_Manager $social_manager = null;
86
87 /**
88 * Schema Management System instance
89 *
90 * @var \ThinkRank\SEO\Schema_Management_System|null
91 */
92 private ?\ThinkRank\SEO\Schema_Management_System $schema_manager = null;
93
94 /**
95 * Global SEO Schema Output instance
96 *
97 * @var Global_SEO_Schema_Output|null
98 */
99 private ?Global_SEO_Schema_Output $global_seo_schema = null;
100
101 /**
102 * Site identity data cache
103 *
104 * @var array|null
105 */
106 private ?array $site_identity_data = null;
107
108 /**
109 * Image SEO Manager instance
110 *
111 * @var \ThinkRank\SEO\Image_SEO_Manager|null
112 */
113 private ?\ThinkRank\SEO\Image_SEO_Manager $image_seo_manager = null;
114
115 /**
116 * External Links Manager instance
117 *
118 * @since 2.5.0
119 * @var \ThinkRank\SEO\External_Links_Manager|null
120 */
121 private ?\ThinkRank\SEO\External_Links_Manager $external_links_manager = null;
122
123 /**
124 * Current page context
125 *
126 * @var string
127 */
128 private string $current_context = 'site';
129
130 /**
131 * Whether the opening "Search Engine Optimization by ThinkRank" comment has
132 * already been printed for this request.
133 *
134 * Shared across the request rather than kept as a local `static` inside the
135 * emitter, because the closing comment is printed from a different method
136 * (and the opening one can also come from Author_Archives_Manager). Without
137 * that, output_closing_comment() decided on its own always-false local
138 * static and emitted an orphan `<!-- /ThinkRank SEO -->` on every page whose
139 * meta description was empty.
140 *
141 * @since 2.0.1
142 * @var bool
143 */
144 private static bool $opening_comment_output = false;
145
146 /**
147 * Memoised "should core's sitemap be disabled" flag. Null until resolved.
148 *
149 * @var bool|null
150 */
151 private ?bool $thinkrank_sitemap_enabled = null;
152
153 /**
154 * Memoised public URL of the sitemap ThinkRank publishes. Empty until
155 * should_disable_core_sitemap() has resolved, and while it resolves false.
156 *
157 * @var string
158 */
159 private string $thinkrank_sitemap_url = '';
160
161 /**
162 * Initialize SEO manager
163 *
164 * @return void
165 */
166 public function init(): void {
167 // Initialize Site Identity Manager
168 $this->initialize_site_identity_manager();
169
170 // Initialize Social Meta Manager
171 $this->initialize_social_meta_manager();
172
173 // Initialize Schema Manager for enhanced schema output
174 $this->initialize_schema_manager();
175
176 // Initialize Global SEO Schema Output
177 $this->initialize_global_seo_schema();
178
179 // Initialize Image SEO Manager
180 $this->initialize_image_seo_manager();
181
182 // Initialize External Links Manager (rel=nofollow / target=_blank)
183 $this->initialize_external_links_manager();
184
185 // Initialize current post and context data first
186 add_action('wp', [$this, 'initialize_current_context']);
187
188 // ...then let it be corrected if the request turns into a 404 later.
189 // Late, so every set_404() on this hook has already run; still well
190 // before wp_head, which the template fires.
191 add_action('template_redirect', [$this, 'recheck_404_context'], 999);
192
193 // Use HIGH PRIORITY hooks to override other SEO plugins
194 // Priority 1-5 ensures ThinkRank runs before other SEO plugins
195
196 // Override WordPress title with HIGH priority
197 add_filter('pre_get_document_title', [$this, 'override_document_title'], 1);
198 add_filter('wp_title', [$this, 'override_wp_title'], 1, 2);
199
200 // Remove WordPress core's robots output so ours isn't duplicated.
201 // Core registers wp_robots() on wp_head at priority 1; without this the
202 // page would emit two <meta name="robots"> tags (core's + ThinkRank's).
203 // The priority MUST match core's (1) or remove_action is a no-op.
204 //
205 // Exception: when "Discourage search engines" is enabled (blog_public=0),
206 // leave core's wp_robots in place so it emits the native noindex directive,
207 // and ThinkRank suppresses its own robots tag (see output_seo_meta_tags).
208 if (get_option('blog_public')) {
209 remove_action('wp_head', 'wp_robots', 1);
210 }
211
212 // Output meta tags with HIGH priority
213 add_action('wp_head', [$this, 'output_meta_description'], 1);
214 add_action('wp_head', [$this, 'output_seo_meta_tags'], 2);
215 add_action('wp_head', [$this, 'output_open_graph_tags'], 3);
216 add_action('wp_head', [$this, 'output_twitter_card_tags'], 4);
217 add_action('wp_head', [$this, 'output_platform_meta_tags'], 5);
218
219 // Remove WordPress core's canonical output so ours isn't duplicated.
220 // Core registers rel_canonical() on wp_head at priority 10; without this
221 // the page would emit two <link rel="canonical"> tags on singular views.
222 remove_action('wp_head', 'rel_canonical');
223 add_action('wp_head', [$this, 'output_canonical_url'], 6);
224
225 // Silence the Bricks theme's own SEO + Open Graph output so a Bricks
226 // site doesn't ship two of every tag. Bricks is a THEME, so it loads
227 // after plugins: at this point BRICKS_VERSION is not yet defined and a
228 // `defined()` guard here would always be false. Registering the filters
229 // unconditionally is correct and free — the hooks only ever fire from
230 // inside Bricks itself (#257). This mirrors the core rel_canonical and
231 // wp_robots removals above: one producer per tag.
232 add_filter('bricks/frontend/disable_seo', '__return_true');
233 add_filter('bricks/frontend/disable_opengraph', '__return_true');
234
235 // Add Site Identity specific outputs
236 add_action('wp_head', [$this, 'output_site_schema_markup'], 7);
237 add_action('wp_head', [$this, 'output_breadcrumb_schema'], 8);
238 // Late enough that Global_SEO_Schema_Output (priority 15) has registered.
239 add_action('wp_head', [$this, 'output_schema_graph'], 20);
240 // Tell the graph it has a renderer, so a body producer asking whether
241 // its FAQ was absorbed can trigger collection itself when a block theme
242 // renders the post content ahead of wp_head.
243 Schema_Graph::instance()->schedule_render();
244
245 // Add closing comment (runs last)
246 add_action('wp_head', [$this, 'output_closing_comment'], 99);
247
248 // Add breadcrumb display hook
249 add_action('thinkrank_breadcrumbs', [$this, 'display_breadcrumbs']);
250
251 // Breadcrumb shortcode for use inside post/page content
252 add_shortcode('thinkrank_breadcrumbs', [$this, 'breadcrumbs_shortcode']);
253
254 // Hero section (Site Identity → Hero & Branding): theme action hook +
255 // shortcode so the configured hero title/subtitle/CTA/background render.
256 add_action('thinkrank_hero', [$this, 'display_hero']);
257 add_shortcode('thinkrank_hero', [$this, 'hero_shortcode']);
258
259 // Add robots.txt filter hook
260 add_filter('robots_txt', [$this, 'filter_robots_txt'], 10, 2);
261
262 // Serve /llms.txt from PHP when the request reaches WordPress. A
263 // published llms.txt is a physical file, so the web server normally
264 // answers it — with `text/plain` and no charset, which renders UTF-8
265 // content as mojibake. This route (plus the .htaccess block written by
266 // LLMs_Txt_Manager for the static file) guarantees an explicit UTF-8
267 // charset. Priority 8 keeps it ahead of redirect_canonical().
268 add_action('template_redirect', [$this, 'maybe_serve_llms_txt'], 8);
269
270 // Take WordPress core's own sitemap offline while ThinkRank's is active.
271 // Two sitemap indexes on one site is a crawl conflict: core keeps
272 // /wp-sitemap.xml served and injects its own "Sitemap:" line into
273 // robots.txt (WP_Sitemaps::add_robots, priority 0). Until now that line
274 // only disappeared as a side effect of filter_robots_txt() replacing the
275 // whole filter output, which does not happen when robots.txt management
276 // is off, when Site Identity is disabled, or when another SEO plugin
277 // claims the filter first — and it never took /wp-sitemap.xml itself
278 // offline, so crawlers could still find and follow the duplicate index.
279 add_filter('wp_sitemaps_enabled', [$this, 'filter_wp_sitemaps_enabled']);
280
281 // …and point the URLs core owned at our sitemap, rather than letting
282 // them dead-end. Disabling core's sitemap does not unhook the two core
283 // paths that route /sitemap.xml: WP_Rewrite::rewrite_rules() adds the
284 // `sitemap\.xml` rule unconditionally, and redirect_canonical() 301s any
285 // request carrying the `sitemap` query var to /wp-sitemap.xml without
286 // consulting wp_sitemaps_enabled — which then 404s. Runs before both
287 // redirect_canonical() and WP_Sitemaps::render_sitemaps() (priority 10),
288 // and after a Pro redirect rule (priority 1) so a user-defined redirect
289 // for these URLs still wins.
290 add_action('template_redirect', [$this, 'redirect_core_sitemap_requests'], 9);
291
292 // Keep an existing physical robots.txt in step with WordPress's
293 // "Discourage search engines" toggle (blog_public). A physical file
294 // bypasses core's robots_txt filter, so flipping blog_public after the
295 // file was written would otherwise leave the previous crawl policy served
296 // until an unrelated robots save. Covers both transitions.
297 add_action('update_option_blog_public', [$this, 'on_blog_public_changed'], 10, 0);
298
299 // Serve the Site Identity favicon through core's site-icon pipeline so
300 // wp_site_icon() outputs it on the front-end (and previews pick it up)
301 add_filter('get_site_icon_url', [$this, 'filter_site_icon_url'], 10, 2);
302
303 // Rewrite outbound anchors (rel=nofollow / target=_blank). Runs at
304 // the very end of the_content, after core's formatting AND after the
305 // image filter above, so it sees the markup the visitor will get. The
306 // stored post_content is never touched — turning the settings off
307 // restores the author's markup exactly.
308 add_filter('the_content', [$this, 'filter_external_links'], 100000);
309 add_filter('the_excerpt', [$this, 'filter_external_links'], 100000);
310 add_filter('widget_text_content', [$this, 'filter_external_links'], 100000);
311
312 // Process image SEO in content
313 add_filter('the_content', [$this, 'filter_content_images'], 99999);
314 add_filter('post_thumbnail_html', [$this, 'filter_content_images'], 11, 2);
315 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'filter_content_images'], 11);
316
317 // Persist alt text to the Media Library for newly uploaded images (opt-in).
318 add_action('add_attachment', [$this, 'maybe_fill_attachment_alt']);
319 }
320
321 /**
322 * Initialize Site Identity Manager
323 *
324 * @return void
325 */
326 private function initialize_site_identity_manager(): void {
327 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
328 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
329 }
330
331 $this->site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
332 }
333
334 /**
335 * Initialize Social Meta Manager
336 *
337 * @return void
338 */
339 private function initialize_social_meta_manager(): void {
340 if (!class_exists('ThinkRank\\SEO\\Social_Meta_Manager')) {
341 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-social-meta-manager.php';
342 }
343
344 $this->social_manager = new \ThinkRank\SEO\Social_Meta_Manager();
345 }
346
347 /**
348 * Initialize Schema Manager for enhanced schema output
349 *
350 * @return void
351 */
352 private function initialize_schema_manager(): void {
353 if (!class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
354 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-management-system.php';
355 }
356
357 // Initialize Schema Manager and store reference for integration
358 $this->schema_manager = new \ThinkRank\SEO\Schema_Management_System();
359 }
360
361 /**
362 * Initialize Global SEO Schema Output
363 *
364 * @return void
365 */
366 private function initialize_global_seo_schema(): void {
367 if (!class_exists('ThinkRank\\Frontend\\Global_SEO_Schema_Output')) {
368 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-global-seo-schema-output.php';
369 }
370
371 // Initialize Global SEO Schema Output and store reference
372 $this->global_seo_schema = new Global_SEO_Schema_Output();
373 $this->global_seo_schema->init();
374 }
375
376 /**
377 * Initialize Image SEO Manager
378 *
379 * @return void
380 */
381 private function initialize_image_seo_manager(): void {
382 if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
383 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
384 }
385
386 $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
387 }
388
389 /**
390 * Initialize External Links Manager
391 *
392 * @since 2.5.0
393 * @return void
394 */
395 private function initialize_external_links_manager(): void {
396 if (!class_exists('ThinkRank\\SEO\\External_Links_Manager')) {
397 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-external-links-manager.php';
398 }
399
400 $this->external_links_manager = new \ThinkRank\SEO\External_Links_Manager();
401 }
402
403 /**
404 * Filter rendered content to annotate external links
405 *
406 * @since 2.5.0
407 * @param mixed $content Content to filter; passed through untouched when
408 * it is not a string.
409 * @return mixed Filtered content.
410 */
411 public function filter_external_links($content) {
412 // No return type: a filter value another plugin hands through as null
413 // or an object belongs to whoever set it, and coercing it to '' would
414 // silently drop their content on the floor.
415 if (!is_string($content) || $content === '' || !$this->external_links_manager) {
416 return $content;
417 }
418
419 // Feeds carry the same markup to a reader we do not control; leave
420 // them as authored rather than annotating for a context that has no
421 // browser tab to open.
422 if (is_feed()) {
423 return $content;
424 }
425
426 return $this->external_links_manager->process_content($content);
427 }
428
429 /**
430 * Filter content to inject image SEO attributes
431 *
432 * @since 1.0.0
433 * @param string $content Content to filter
434 * @return string Filtered content
435 */
436 public function filter_content_images(string $content, $post_id = null): string {
437 if (!$this->image_seo_manager) {
438 return $content;
439 }
440
441 // Ensure post_id is an integer if provided
442 if ($post_id !== null && !is_numeric($post_id)) {
443 $post_id = null;
444 }
445
446 return $this->image_seo_manager->process_content($content, $post_id ? (int) $post_id : null);
447 }
448
449 /**
450 * Persist generated alt text to a freshly uploaded image (opt-in).
451 *
452 * Delegates to the Image SEO Manager, which no-ops unless the
453 * "save alt to media" + "fill on upload" settings are enabled.
454 *
455 * @since 1.19.1
456 * @param int $attachment_id The newly created attachment ID.
457 * @return void
458 */
459 public function maybe_fill_attachment_alt($attachment_id): void {
460 if ($this->image_seo_manager && is_numeric($attachment_id)) {
461 $this->image_seo_manager->maybe_auto_fill_on_upload((int) $attachment_id);
462 }
463 }
464
465 /**
466 * Initialize current context and post data
467 *
468 * @return void
469 */
470 public function initialize_current_context(): void {
471 // Determine current context
472 $this->current_context = $this->detect_current_context();
473
474 // Initialize post data if singular
475 if (is_singular()) {
476 $post_id = get_the_ID();
477 if ($post_id) {
478 $this->current_post_id = $post_id;
479 $this->current_metadata = $this->get_post_seo_metadata($post_id);
480 }
481 }
482
483 // Term archives. Category, tag and custom-taxonomy pages store their SEO
484 // title and description as term meta — written by the term UI, by the
485 // abilities API and by the Yoast/RankMath/AIOSEO/SEOPress importer — but
486 // nothing here ever read them, so the whole title/description cascade
487 // fell through to the theme default and no description tag was printed
488 // at all. Term robots was fixed for the same reason in 1.31.0 (#290);
489 // this is the title and description half (#386).
490 if (is_category() || is_tag() || is_tax()) {
491 $queried = get_queried_object();
492 if ($queried instanceof \WP_Term) {
493 $this->current_term_id = $queried->term_id;
494 $this->current_metadata = $this->get_term_seo_metadata($queried->term_id);
495 }
496 }
497
498 // Load site identity data
499 $this->load_site_identity_data();
500 }
501
502 /**
503 * Drop the request's post identity once it has become a 404.
504 *
505 * `initialize_current_context()` runs on `wp`, but a request can be turned
506 * into a 404 after that: `set_404()` on `template_redirect` is the ordinary
507 * way to refuse a URL that did resolve to a real post, and both core and
508 * plugins do it — ThinkRank Pro's Markdown for AI refuses an ineligible
509 * `.md` URL that way. The snapshot still said `post`/`page` and still held
510 * the post id and its metadata, so the error page shipped that post's meta
511 * description, focus keywords and — where the social emitters got that far
512 * — its og:description and twitter:description, all of which a request that
513 * was a 404 from the start never prints (#655).
514 *
515 * Clearing the snapshot rather than special-casing each emitter is what
516 * makes every consumer agree, including the ones that read
517 * `$current_metadata` without ever asking what the context is.
518 *
519 * @since 2.3.1
520 *
521 * @return void
522 */
523 public function recheck_404_context(): void {
524 if (!is_404() || '404' === $this->current_context) {
525 return;
526 }
527
528 $this->current_context = '404';
529 $this->current_post_id = null;
530 $this->current_term_id = null;
531 $this->current_metadata = [];
532 }
533
534 /**
535 * Detect current page context
536 *
537 * @return string Current context type
538 */
539 private function detect_current_context(): string {
540 // 404 first: a not-found request matches none of the branches below and
541 // used to fall through to 'site', which handed crawlers the homepage's
542 // social identity for an error page. It gets its own context so the
543 // social layer can skip it, matching get_non_singular_canonical_url(),
544 // which already suppresses the canonical for 404 and search.
545 if (is_404()) {
546 return '404';
547 }
548
549 if (is_home() || is_front_page()) {
550 return 'homepage';
551 } elseif (is_single()) {
552 return 'post';
553 } elseif (is_page()) {
554 return 'page';
555 } elseif (is_category()) {
556 return 'category';
557 } elseif (is_tag()) {
558 return 'tag';
559 } elseif (is_author()) {
560 return 'author';
561 } elseif (is_search()) {
562 return 'search';
563 } elseif (is_archive()) {
564 return 'archive';
565 }
566
567 return 'site';
568 }
569
570 /**
571 * Load site identity data
572 *
573 * @return void
574 */
575 private function load_site_identity_data(): void {
576 if ($this->site_identity_manager && $this->site_identity_data === null) {
577 $this->site_identity_data = $this->site_identity_manager->get_output_data('site', null);
578 }
579 }
580
581 /**
582 * Get SEO metadata for a post
583 *
584 * @param int $post_id Post ID
585 * @return array SEO metadata
586 */
587 private function get_post_seo_metadata(int $post_id): array {
588 $focus_keywords = \ThinkRank\SEO\Focus_Keywords::get($post_id);
589
590 $title = get_post_meta($post_id, '_thinkrank_seo_title', true);
591 $description = get_post_meta($post_id, '_thinkrank_meta_description', true);
592
593 return [
594 // Per-post values may contain variable tags (e.g. "%title% %sep%
595 // %sitename%") entered in the metabox, so resolve them. Literal
596 // values without tags pass through unchanged.
597 'title' => $title ? \ThinkRank\SEO\Pattern_Resolver::resolve_value($title, $post_id) : $title,
598 'description' => $description ? \ThinkRank\SEO\Pattern_Resolver::resolve_value($description, $post_id) : $description,
599 'focus_keyword' => $focus_keywords[0] ?? '',
600 'focus_keywords' => $focus_keywords,
601 'seo_score' => get_post_meta($post_id, '_thinkrank_seo_score', true),
602 ];
603 }
604
605 /**
606 * Get SEO metadata for a term.
607 *
608 * Mirrors get_post_seo_metadata(): the stored values may carry variable
609 * tags, so they are resolved against the term's own values. Focus keyword
610 * and score have no term equivalent on the frontend and stay empty.
611 *
612 * @since 2.0.1
613 *
614 * @param int $term_id Term ID.
615 * @return array SEO metadata.
616 */
617 private function get_term_seo_metadata(int $term_id): array {
618 $title = get_term_meta($term_id, '_thinkrank_seo_title', true);
619 $description = get_term_meta($term_id, '_thinkrank_meta_description', true);
620
621 return [
622 'title' => $title
623 ? \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) $title, $term_id)
624 : '',
625 'description' => $description
626 ? \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) $description, $term_id)
627 : '',
628 'focus_keyword' => '',
629 'focus_keywords' => [],
630 'seo_score' => '',
631 ];
632 }
633
634 /**
635 * Resolve the effective SEO title for the current request.
636 *
637 * Same priority chain as override_document_title() — post-specific
638 * ThinkRank metadata (resolved _thinkrank_seo_title) > Global SEO
639 * template > Site Identity template — without the raw WordPress-title
640 * fallback. Returns null when no ThinkRank-managed title applies, letting
641 * callers (e.g. the social manager OG fallback) drop to their own default.
642 *
643 * @return string|null Effective SEO title, or null if none applies.
644 */
645 private function get_effective_seo_title(): ?string {
646 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
647 return $this->current_metadata['title'];
648 }
649
650 return $this->generate_context_title();
651 }
652
653 /**
654 * Override WordPress document title (HIGH PRIORITY)
655 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates
656 *
657 * @param string $title Original title
658 * @return string Modified title
659 */
660 public function override_document_title($title): string {
661 // A content type with metas switched off keeps whatever title the theme
662 // and WordPress produce (#660).
663 if (!$this->metas_enabled()) {
664 return $title;
665 }
666
667 // First priority: Post-specific ThinkRank metadata
668 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
669 return self::with_page_suffix($this->current_metadata['title']);
670 }
671
672 // Second priority: Global SEO templates, Third priority: Site Identity templates
673 $generated_title = $this->generate_context_title();
674 if ($generated_title) {
675 return self::with_page_suffix($generated_title);
676 }
677
678 return $title;
679 }
680
681 /**
682 * Append a page indicator to a title on page 2 and beyond.
683 *
684 * This filter short-circuits pre_get_document_title at priority 1, which
685 * drops the " – Page 2" core would otherwise add — so every page of an
686 * archive, and every part of a multi-page post, shared one <title> (#397).
687 * The templates have no %page% token, so the suffix is added here rather
688 * than asking every site to edit its title format.
689 *
690 * @since 2.0.1
691 *
692 * @param string $title Resolved title.
693 * @return string Title with the page indicator, when there is one.
694 */
695 /**
696 * The archive's subject, without the label WordPress prefixes it with.
697 *
698 * `get_the_archive_title()` returns "Month: September 2026", "Archives:
699 * Recipes", "Category: Uncategorized" — the label is core's, aimed at an
700 * archive heading on the page, and it reads badly in a browser tab, an
701 * og:title or a search result. Category, tag and author contexts already
702 * avoid it by using the raw name; the generic archive context did not, so
703 * date, custom-post-type and custom-taxonomy archives carried it (#640).
704 *
705 * Removed through core's own `get_the_archive_title_prefix` filter rather
706 * than by matching the prefix text, because that text is translated and
707 * differs per archive type — a string comparison would work in English and
708 * silently stop working everywhere else.
709 *
710 * A site that wants a prefix can put one in its title template, where it is
711 * visible and editable, instead of inheriting one it cannot see.
712 *
713 * @since 2.7.0
714 *
715 * @return string Archive subject, with markup and the core prefix removed.
716 */
717 private static function archive_subject(): string {
718 $drop_prefix = static function (): string {
719 return '';
720 };
721
722 add_filter('get_the_archive_title_prefix', $drop_prefix, 99);
723
724 $title = (string) get_the_archive_title();
725
726 remove_filter('get_the_archive_title_prefix', $drop_prefix, 99);
727
728 // The <span> core wraps the subject in survives the prefix filter.
729 return trim(wp_strip_all_tags($title));
730 }
731
732 /**
733 * Remove HTML from a title that is about to be emitted.
734 *
735 * A title carrying markup is broken twice over, in two different ways, and
736 * both were reaching real pages: inside `<title>` the tags render literally,
737 * because that element is RCDATA and never parses them; inside `og:title`
738 * and `twitter:title` they are attribute-escaped, so the reader sees
739 * `&lt;em&gt;` as visible text (#640).
740 *
741 * Applied at the point of emission rather than at each source, so it covers
742 * every branch that can produce a title — post meta, Global SEO templates,
743 * Site Identity templates — without each having to remember.
744 *
745 * Unconditional rather than a setting: there is no title for which markup is
746 * the correct output. The filter is the escape hatch for anyone who
747 * disagrees, and lets a site keep entities it deliberately encoded.
748 *
749 * @since 2.7.0
750 *
751 * @param string $title Title about to be emitted.
752 * @return string Title with any markup removed.
753 */
754 public static function strip_title_tags(string $title): string {
755 /**
756 * Filter whether HTML is stripped from generated titles.
757 *
758 * @since 2.7.0
759 *
760 * @param bool $strip Whether to strip. Default true.
761 * @param string $title The title being emitted.
762 */
763 if (!apply_filters('thinkrank_strip_title_tags', true, $title)) {
764 return $title;
765 }
766
767 return trim(wp_strip_all_tags($title));
768 }
769
770 public static function with_page_suffix(string $title): string {
771 $title = self::strip_title_tags($title);
772
773 $page = self::current_page_number();
774
775 if ($page <= 1 || '' === $title) {
776 return $title;
777 }
778
779 $separator = class_exists('\ThinkRank\SEO\Site_Identity_Manager')
780 ? \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol()
781 : '|';
782
783 return $title . ' ' . $separator . ' ' . sprintf(
784 /* translators: %d: page number. */
785 __('Page %d', 'thinkrank'),
786 $page
787 );
788 }
789
790 /**
791 * Override WordPress wp_title (HIGH PRIORITY)
792 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates
793 *
794 * @param string $title Original title
795 * @param string $sep Title separator
796 * @return string Modified title
797 */
798 public function override_wp_title(string $title, string $sep = ''): string {
799 if (!$this->metas_enabled()) {
800 return $title;
801 }
802
803 // First priority: Post-specific ThinkRank metadata
804 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
805 $site_name = get_bloginfo('name');
806 return self::with_page_suffix(
807 $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name
808 );
809 }
810
811 // Second priority: Global SEO templates, Third priority: Site Identity templates
812 $generated_title = $this->generate_context_title();
813 if ($generated_title) {
814 return self::with_page_suffix($generated_title);
815 }
816
817 return $title;
818 }
819
820 /**
821 * Whether ThinkRank owns the title and meta description for this request.
822 *
823 * Metas are on site-wide by default; the per-content-type matrix can switch
824 * them off for one content type, in which case ThinkRank stops overriding
825 * the document title and prints no meta description (#660).
826 *
827 * @since 2.5.0
828 * @return bool
829 */
830 private function metas_enabled(): bool {
831 return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
832 \ThinkRank\SEO\Content_Type_Settings::FEATURE_META,
833 true
834 );
835 }
836
837 /**
838 * Output meta description (HIGH PRIORITY)
839 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
840 *
841 * Author archives are skipped entirely: Author_Archives_Manager owns that
842 * context and prints its own template-based description on wp_head at
843 * priority 5. get_archive_meta_description() already declines to build one
844 * there, but the fallback chain used to continue into the Site Identity
845 * default, so the page ended up with two <meta name="description"> tags.
846 *
847 * @return void
848 */
849 public function output_meta_description(): void {
850 if (is_author()) {
851 return;
852 }
853
854 if (!$this->metas_enabled()) {
855 return;
856 }
857
858 $description = $this->get_meta_description();
859
860 if ($description) {
861 // Output main ThinkRank SEO header comment (only once)
862 self::note_opening_comment();
863
864 // Ensure description is within optimal length (150-160 characters)
865 // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
866 // CJK description tripped this limit at a third of its length, and
867 // wp_trim_words() then cut by a unit the locale chooses — 25 words in
868 // English, 25 characters in Thai (#687).
869 $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
870
871 echo "<!-- ThinkRank SEO Meta Description -->\n";
872 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
873 echo "<!-- /ThinkRank SEO Meta Description -->\n";
874 }
875 }
876
877 /**
878 * Output SEO meta tags
879 *
880 * @return void
881 */
882 public function output_seo_meta_tags(): void {
883 echo "<!-- ThinkRank SEO Meta Tags -->\n";
884
885 // Output robots meta tag with proper directives.
886 // When "Discourage search engines" (blog_public=0) is enabled, defer to
887 // WordPress core's native noindex output and skip ThinkRank's tag so we
888 // don't emit a conflicting/duplicate directive.
889 if (get_option('blog_public')) {
890 $robots_content = $this->get_robots_meta_content();
891 echo '<meta name="robots" content="' . esc_attr($robots_content) . '" />' . "\n";
892 }
893
894 // Output focus keywords as meta keywords (all keywords, comma-separated)
895 $focus_keywords = $this->current_metadata['focus_keywords'] ?? [];
896 if (empty($focus_keywords) && !empty($this->current_metadata['focus_keyword'])) {
897 $focus_keywords = [$this->current_metadata['focus_keyword']];
898 }
899 if (!empty($focus_keywords)) {
900 $keywords = implode(', ', array_filter(array_map('trim', (array) $focus_keywords), 'strlen'));
901 if (!empty($keywords)) {
902 echo '<meta name="keywords" content="' . esc_attr($keywords) . '" />' . "\n";
903 }
904 }
905
906 // Output local SEO meta tags if business info is available
907 $this->output_local_seo_meta_tags();
908
909 // Output generator meta tag
910 echo '<meta name="generator" content="ThinkRank ' . esc_attr(THINKRANK_VERSION) . '" />' . "\n";
911
912 // No viewport tag here. The viewport is the theme's responsibility and
913 // every modern theme ships one, so emitting our own only ever produced a
914 // second <meta name="viewport"> in the document. The old guard could not
915 // prevent that either: has_action() returns the registered priority
916 // (truthy), so its first operand was always false, and !wp_is_mobile() is
917 // true for every desktop request.
918 echo "<!-- /ThinkRank SEO Meta Tags -->\n";
919 }
920
921 /**
922 * Build the basic robots directive list from a set of robots flags.
923 *
924 * Shared by the search/404 branch of get_robots_meta_content() so those
925 * pages resolve their directives through the same rules as everything else
926 * rather than a hardcoded literal.
927 *
928 * @since 2.5.0
929 * @param array $settings Robots flags (index/noindex/nofollow/...).
930 * @return string[] Directives.
931 */
932 private static function build_robots_directives(array $settings): array {
933 $robots = [];
934
935 $robots[] = !empty($settings['noindex']) ? 'noindex' : 'index';
936 $robots[] = !empty($settings['nofollow']) ? 'nofollow' : 'follow';
937
938 foreach (['noarchive', 'noimageindex', 'nosnippet'] as $directive) {
939 if (!empty($settings[$directive])) {
940 $robots[] = $directive;
941 }
942 }
943
944 return $robots;
945 }
946
947 /**
948 * Get robots meta content based on context and settings
949 *
950 * @return string Robots meta content
951 */
952 private function get_robots_meta_content(): string {
953 $robots = [];
954
955 // 404 and search results are noindex/follow by default — the behaviour
956 // that used to be hardcoded here. It is now settings-driven (#660): the
957 // Content Type Matrix can give either its own robots directives, and an
958 // install that never touched them resolves to exactly the old pair.
959 if (is_404() || is_search()) {
960 $entity = is_404()
961 ? \ThinkRank\SEO\Content_Type_Settings::ENTITY_404
962 : \ThinkRank\SEO\Content_Type_Settings::ENTITY_SEARCH;
963
964 $robots = self::build_robots_directives(
965 \ThinkRank\SEO\Content_Type_Settings::resolve_robots_meta($entity)
966 );
967
968 $robots = apply_filters('thinkrank_robots_meta', $robots);
969 return implode(', ', array_unique($robots));
970 }
971
972 // 1. Get global robot meta settings (Base)
973 $global_settings = get_option('thinkrank_global_robot_meta_settings', []);
974
975 // Initialize current settings with global defaults
976 $current_settings = wp_parse_args($global_settings, [
977 'index' => true,
978 'noindex' => false,
979 'nofollow' => false,
980 'noarchive' => false,
981 'noimageindex' => false,
982 'nosnippet' => false,
983 ]);
984
985 // 2. Apply Post Type based option (if singular)
986 if (is_singular()) {
987 $post_type = get_post_type();
988 $global_seo_settings = get_option('thinkrank_global_seo_settings', []);
989
990 // Check if post type settings are enabled
991 $robots_enabled = isset($global_seo_settings[$post_type]['robots_meta_enabled']) && $global_seo_settings[$post_type]['robots_meta_enabled'];
992
993 if ($robots_enabled && isset($global_seo_settings[$post_type]['robots_meta']) && is_array($global_seo_settings[$post_type]['robots_meta'])) {
994 // Merge post type settings over global settings
995 $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']);
996 }
997 }
998
999 // 2b. Apply the per-entity directives for the non-singular content
1000 // types the matrix covers — taxonomy archives plus author and date
1001 // archives. Terms keep their own per-term override, applied further
1002 // down so it still wins over the taxonomy-wide value (#660).
1003 if (!is_singular()) {
1004 $entity_key = \ThinkRank\SEO\Content_Type_Settings::current_entity_key();
1005
1006 if ($entity_key !== null) {
1007 $entity_settings = \ThinkRank\SEO\Content_Type_Settings::get_entity_settings($entity_key);
1008
1009 if (!empty($entity_settings['robots_meta_enabled']) && is_array($entity_settings['robots_meta'] ?? null)) {
1010 $current_settings = array_merge($current_settings, $entity_settings['robots_meta']);
1011 }
1012 }
1013 }
1014
1015 // Determine Index/Noindex based on merged settings
1016 // Priority: if noindex is true, it overrides index
1017 if (!empty($current_settings['noindex'])) {
1018 $robots[] = 'noindex';
1019 } else {
1020 // Default to index if noindex is not set
1021 $robots[] = 'index';
1022 }
1023
1024 // Determine Follow/Nofollow based on merged settings
1025 if (!empty($current_settings['nofollow'])) {
1026 $robots[] = 'nofollow';
1027 } else {
1028 $robots[] = 'follow';
1029 }
1030
1031 // Other directives
1032 if (!empty($current_settings['noarchive'])) {
1033 $robots[] = 'noarchive';
1034 }
1035 if (!empty($current_settings['noimageindex'])) {
1036 $robots[] = 'noimageindex';
1037 }
1038 if (!empty($current_settings['nosnippet'])) {
1039 $robots[] = 'nosnippet';
1040 }
1041
1042 // Add advanced directives for better SEO
1043 // Get advanced settings
1044 $advanced_settings = [
1045 'snippet_enabled' => true,
1046 'max_snippet' => -1,
1047 'video_preview_enabled' => true,
1048 'max_video_preview' => -1,
1049 'image_preview_enabled' => true,
1050 'max_image_preview' => 'large'
1051 ];
1052
1053 // Apply post type specific advanced settings if enabled
1054 if (is_singular() && isset($robots_enabled) && $robots_enabled && isset($global_seo_settings[$post_type]['advanced_robots_meta'])) {
1055 $advanced_settings = array_merge($advanced_settings, $global_seo_settings[$post_type]['advanced_robots_meta']);
1056 }
1057
1058 // Generate advanced directives
1059 if (empty($current_settings['nosnippet'])) {
1060 if ($advanced_settings['snippet_enabled']) {
1061 $robots[] = 'max-snippet:' . (int)$advanced_settings['max_snippet'];
1062 }
1063
1064 if ($advanced_settings['video_preview_enabled']) {
1065 $robots[] = 'max-video-preview:' . (int)$advanced_settings['max_video_preview'];
1066 }
1067 }
1068
1069 // Only add max-image-preview if we are allowing image indexing
1070 if (empty($current_settings['noimageindex']) && $advanced_settings['image_preview_enabled']) {
1071 $robots[] = 'max-image-preview:' . esc_attr($advanced_settings['max_image_preview']);
1072 }
1073
1074 // 3. Check for single post meta based option (Overrides everything)
1075 if (is_singular()) {
1076 $robots = $this->apply_post_robots_override(get_the_ID(), $robots, $current_settings);
1077 }
1078
1079 // Check for archive pages (search is handled by the early return above)
1080 //
1081 // is_home() is deliberately included: the blog listing is not an
1082 // is_archive(), so page 2 of a term archive was noindex while page 2 of
1083 // the blog listing was index — the same kind of page, treated two
1084 // different ways, on the same site (#397).
1085 if (is_archive() || is_home()) {
1086 // Allow indexing of category/tag archives but be more conservative
1087 if (is_paged()) {
1088 /**
1089 * Filter whether a paginated archive is set noindex.
1090 *
1091 * Rank Math and Yoast now index paginated archives with a
1092 * self-referential canonical by default, so a site that wants
1093 * that can have it without patching.
1094 *
1095 * @since 2.0.1
1096 *
1097 * @param bool $noindex Whether to noindex this paginated page.
1098 */
1099 if (apply_filters('thinkrank_noindex_paged_archives', true)) {
1100 $robots = ['noindex', 'follow'];
1101 }
1102 }
1103
1104 // Honor the global date-archive noindex toggle (written by the
1105 // Rank Math/Yoast settings importer). Author archives are handled
1106 // by Author_Archives_Manager via the thinkrank_robots_meta filter.
1107 if (is_date() && !empty($current_settings['noindex_date_archives'])) {
1108 $robots = ['noindex', 'follow'];
1109 }
1110 }
1111
1112 // 4. Term meta override for taxonomy archives (Overrides everything).
1113 //
1114 // Terms had no branch here at all — not a wrong key or a skipped
1115 // conditional, the lookup simply did not exist — so a category, tag or
1116 // custom-taxonomy archive saved with noindex still rendered the global
1117 // default. The stored value read back correctly through the abilities
1118 // API, which made the setting look applied when it never reached output.
1119 //
1120 // Deliberately placed *after* the archive block so it is a real
1121 // override, matching how a per-post override is final for singular
1122 // views. Running it earlier would let is_paged() overwrite a term's
1123 // explicit directives on page 2 of its own archive.
1124 if (is_category() || is_tag() || is_tax()) {
1125 $queried = get_queried_object();
1126 if ($queried instanceof \WP_Term) {
1127 $robots = $this->apply_term_robots_override($queried->term_id, $robots, $current_settings);
1128 }
1129 }
1130
1131 // Apply filters for customization
1132 $robots = apply_filters('thinkrank_robots_meta', $robots);
1133
1134 // Remove duplicates and implode
1135 return implode(', ', array_unique($robots));
1136 }
1137
1138 /**
1139 * Apply per-post robots overrides on top of the cascaded directives.
1140 *
1141 * Reads `_thinkrank_robots_meta` (JSON) when `_thinkrank_robots_meta_enabled`
1142 * is truthy. When the override is off, the cascaded directives pass through
1143 * unchanged.
1144 *
1145 * @param int $post_id Post being rendered
1146 * @param array $robots Directives accumulated so far
1147 * @param array $current_settings Effective robots flags (global + post type)
1148 * @return array Updated robots directive list
1149 */
1150 private function apply_post_robots_override(int $post_id, array $robots, array $current_settings): array {
1151 return $this->apply_meta_robots_override(
1152 (bool) get_post_meta($post_id, '_thinkrank_robots_meta_enabled', true),
1153 (string) get_post_meta($post_id, '_thinkrank_robots_meta', true),
1154 (string) get_post_meta($post_id, '_thinkrank_advanced_robots_meta', true),
1155 $robots,
1156 $current_settings
1157 );
1158 }
1159
1160 /**
1161 * Apply per-term robots overrides on top of the cascaded directives.
1162 *
1163 * The term-meta twin of apply_post_robots_override(). Terms store the same
1164 * three keys with the same shapes — written by the update-term-seo ability
1165 * and by the Rank Math / Yoast / AIOSEO / SEOPress importer — so the two
1166 * paths share one engine rather than a second copy that can drift.
1167 *
1168 * @since 1.31.0
1169 *
1170 * @param int $term_id Term being rendered
1171 * @param array $robots Directives accumulated so far
1172 * @param array $current_settings Effective robots flags (global + post type)
1173 * @return array Updated robots directive list
1174 */
1175 private function apply_term_robots_override(int $term_id, array $robots, array $current_settings): array {
1176 return $this->apply_meta_robots_override(
1177 (bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true),
1178 (string) get_term_meta($term_id, '_thinkrank_robots_meta', true),
1179 (string) get_term_meta($term_id, '_thinkrank_advanced_robots_meta', true),
1180 $robots,
1181 $current_settings
1182 );
1183 }
1184
1185 /**
1186 * Rebuild the robots directives from a stored override, whatever holds it.
1187 *
1188 * Kept free of get_post_meta()/get_term_meta() so posts and terms cannot
1189 * diverge: term support was missing entirely because the only override
1190 * logic lived behind a post-meta read.
1191 *
1192 * @since 1.31.0
1193 *
1194 * @param bool $enabled Whether the override is switched on
1195 * @param string $raw_robots JSON robots flags
1196 * @param string $raw_advanced JSON advanced directives
1197 * @param array $robots Directives accumulated so far
1198 * @param array $current_settings Effective robots flags (global + post type)
1199 * @return array Updated robots directive list
1200 */
1201 private function apply_meta_robots_override(bool $enabled, string $raw_robots, string $raw_advanced, array $robots, array $current_settings): array {
1202 if (!$enabled) {
1203 return $robots;
1204 }
1205
1206 $post_robots = $raw_robots !== '' ? json_decode($raw_robots, true) : null;
1207 if (!is_array($post_robots)) {
1208 return $robots;
1209 }
1210
1211 $post_advanced = $raw_advanced !== '' ? json_decode($raw_advanced, true) : null;
1212
1213 $effective = array_merge($current_settings, array_intersect_key($post_robots, array_flip([
1214 'index', 'noindex', 'nofollow', 'noarchive', 'noimageindex', 'nosnippet',
1215 ])));
1216
1217 $rebuilt = [];
1218 $rebuilt[] = !empty($effective['noindex']) ? 'noindex' : 'index';
1219 $rebuilt[] = !empty($effective['nofollow']) ? 'nofollow' : 'follow';
1220
1221 if (!empty($effective['noarchive'])) {
1222 $rebuilt[] = 'noarchive';
1223 }
1224 if (!empty($effective['noimageindex'])) {
1225 $rebuilt[] = 'noimageindex';
1226 }
1227 if (!empty($effective['nosnippet'])) {
1228 $rebuilt[] = 'nosnippet';
1229 }
1230
1231 if (is_array($post_advanced)) {
1232 $advanced = array_merge([
1233 'snippet_enabled' => true,
1234 'max_snippet' => -1,
1235 'video_preview_enabled' => true,
1236 'max_video_preview' => -1,
1237 'image_preview_enabled' => true,
1238 'max_image_preview' => 'large',
1239 ], $post_advanced);
1240
1241 if (empty($effective['nosnippet'])) {
1242 if (!empty($advanced['snippet_enabled'])) {
1243 $rebuilt[] = 'max-snippet:' . (int) $advanced['max_snippet'];
1244 }
1245 if (!empty($advanced['video_preview_enabled'])) {
1246 $rebuilt[] = 'max-video-preview:' . (int) $advanced['max_video_preview'];
1247 }
1248 }
1249
1250 if (empty($effective['noimageindex']) && !empty($advanced['image_preview_enabled'])) {
1251 $rebuilt[] = 'max-image-preview:' . sanitize_text_field((string) $advanced['max_image_preview']);
1252 }
1253 }
1254
1255 return $rebuilt;
1256 }
1257
1258 /**
1259 * Output local SEO meta tags for business information
1260 *
1261 * @return void
1262 */
1263 private function output_local_seo_meta_tags(): void {
1264 if (!$this->site_identity_manager) {
1265 return;
1266 }
1267
1268 $settings = $this->site_identity_manager->get_settings('site');
1269
1270 // Only output if local SEO is enabled and business info is available
1271 if (empty($settings['local_seo_enabled']) || empty($settings['business_name'])) {
1272 return;
1273 }
1274
1275 echo "<!-- ThinkRank Local SEO Meta Tags -->\n";
1276
1277 // NAP (Name, Address, Phone) Consistency Meta Tags
1278 if (!empty($settings['business_name'])) {
1279 echo '<meta name="business:name" content="' . esc_attr($settings['business_name']) . '" />' . "\n";
1280 }
1281
1282 // Business address components
1283 if (!empty($settings['business_address'])) {
1284 echo '<meta name="business:contact_data:street_address" content="' . esc_attr($settings['business_address']) . '" />' . "\n";
1285 }
1286
1287 if (!empty($settings['business_city'])) {
1288 echo '<meta name="business:contact_data:locality" content="' . esc_attr($settings['business_city']) . '" />' . "\n";
1289 echo '<meta name="geo.placename" content="' . esc_attr($settings['business_city']) . '" />' . "\n";
1290 }
1291
1292 if (!empty($settings['business_state'])) {
1293 echo '<meta name="business:contact_data:region" content="' . esc_attr($settings['business_state']) . '" />' . "\n";
1294 }
1295
1296 if (!empty($settings['business_postal_code'])) {
1297 echo '<meta name="business:contact_data:postal_code" content="' . esc_attr($settings['business_postal_code']) . '" />' . "\n";
1298 }
1299
1300 if (!empty($settings['business_country'])) {
1301 echo '<meta name="business:contact_data:country_name" content="' . esc_attr($settings['business_country']) . '" />' . "\n";
1302 }
1303
1304 // Phone number
1305 if (!empty($settings['business_phone'])) {
1306 echo '<meta name="business:contact_data:phone_number" content="' . esc_attr($settings['business_phone']) . '" />' . "\n";
1307 }
1308
1309 // Email address
1310 if (!empty($settings['business_email'])) {
1311 echo '<meta name="business:contact_data:email" content="' . esc_attr($settings['business_email']) . '" />' . "\n";
1312 }
1313
1314 // Geo-location meta tags (if coordinates are available)
1315 if (!empty($settings['business_latitude']) && !empty($settings['business_longitude'])) {
1316 $coordinates = $settings['business_latitude'] . ';' . $settings['business_longitude'];
1317 echo '<meta name="geo.position" content="' . esc_attr($coordinates) . '" />' . "\n";
1318 echo '<meta name="ICBM" content="' . esc_attr($settings['business_latitude'] . ', ' . $settings['business_longitude']) . '" />' . "\n";
1319 }
1320
1321 // Regional meta tag (state/country combination)
1322 if (!empty($settings['business_state']) && !empty($settings['business_country'])) {
1323 $region = strtoupper($settings['business_country']) . '-' . strtoupper($settings['business_state']);
1324 echo '<meta name="geo.region" content="' . esc_attr($region) . '" />' . "\n";
1325 }
1326
1327 // Business hours in structured format
1328 if (!empty($settings['business_hours']) && is_array($settings['business_hours'])) {
1329 $formatted_hours = $this->format_business_hours_for_meta($settings['business_hours']);
1330 if (!empty($formatted_hours)) {
1331 echo '<meta name="business:hours" content="' . esc_attr($formatted_hours) . '" />' . "\n";
1332 }
1333 }
1334
1335 // Business type
1336 if (!empty($settings['business_type'])) {
1337 echo '<meta name="business:type" content="' . esc_attr($settings['business_type']) . '" />' . "\n";
1338 }
1339
1340 echo "<!-- /ThinkRank Local SEO Meta Tags -->\n";
1341 }
1342
1343 /**
1344 * Format business hours for meta tag output
1345 *
1346 * @param array $business_hours Business hours array
1347 * @return string Formatted hours string
1348 */
1349 private function format_business_hours_for_meta(array $business_hours): string {
1350 $formatted_days = [];
1351
1352 $day_abbreviations = [
1353 'monday' => 'Mo',
1354 'tuesday' => 'Tu',
1355 'wednesday' => 'We',
1356 'thursday' => 'Th',
1357 'friday' => 'Fr',
1358 'saturday' => 'Sa',
1359 'sunday' => 'Su'
1360 ];
1361
1362 foreach ($day_abbreviations as $day => $abbrev) {
1363 if (isset($business_hours[$day]) && !empty($business_hours[$day])) {
1364 $day_data = $business_hours[$day];
1365
1366 if (!empty($day_data['closed']) || empty($day_data['open']) || empty($day_data['close'])) {
1367 continue; // Skip closed days
1368 }
1369
1370 $formatted_days[] = $abbrev . ' ' . $day_data['open'] . '-' . $day_data['close'];
1371 }
1372 }
1373
1374 return implode(', ', $formatted_days);
1375 }
1376
1377 /**
1378 * Output social media Open Graph tags from Social Meta Manager
1379 *
1380 * @param array $og_tags Open Graph tags array
1381 * @return void
1382 */
1383 private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
1384 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
1385 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
1386 // sets 'product' on product pages) must be applied to it, not only to
1387 // output_open_graph_tags().
1388 if (isset($og_tags['og:type'])) {
1389 $og_tags['og:type'] = apply_filters('thinkrank_og_type', $og_tags['og:type']);
1390 }
1391
1392 echo "<!-- ThinkRank SEO Open Graph Tags (Enhanced) -->\n";
1393
1394 // Define optimal order for Open Graph tags
1395 $og_order = [
1396 'og:title',
1397 'og:description',
1398 'og:type',
1399 'og:url',
1400 'og:site_name',
1401 'og:locale',
1402 'og:image',
1403 'og:image:width',
1404 'og:image:height',
1405 'og:image:type',
1406 'og:image:alt',
1407 'article:published_time',
1408 'article:modified_time',
1409 'article:author',
1410 'article:section'
1411 ];
1412
1413 // Output tags in optimal order
1414 foreach ($og_order as $property) {
1415 if (!empty($og_tags[$property])) {
1416 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call.
1417 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $og_tags[$property]) . '" />' . "\n";
1418 }
1419 }
1420
1421 // Output any remaining tags not in the order list
1422 foreach ($og_tags as $property => $content) {
1423 if (!empty($content) && !in_array($property, $og_order, true)) {
1424 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call.
1425 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1426 }
1427 }
1428
1429 // Alternatives, after the primary and everything belonging to it.
1430 // Order is the whole point: a consumer reads og:image tags in document
1431 // order and treats the first as primary, and a structured property
1432 // attaches to the most recently declared image — so each alternative's
1433 // companions have to follow its own URL, not be grouped at the end.
1434 self::output_extra_og_images($extra_images);
1435
1436 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1437 }
1438
1439 /**
1440 * Emit the secondary og:image tags a page offers.
1441 *
1442 * Shared by the enhanced and basic emitters so both describe an
1443 * alternative image the same way (#636).
1444 *
1445 * @since 2.7.0
1446 *
1447 * @param array $images Each with url, and width/height/type/alt where known.
1448 * @return void
1449 */
1450 private static function output_extra_og_images(array $images): void {
1451 foreach ($images as $image) {
1452 $url = isset($image['url']) ? (string) $image['url'] : '';
1453
1454 if ('' === $url) {
1455 continue;
1456 }
1457
1458 echo '<meta property="og:image" content="' . esc_url($url) . '" />' . "\n";
1459
1460 if (strpos($url, 'https://') === 0) {
1461 echo '<meta property="og:image:secure_url" content="' . esc_url($url) . '" />' . "\n";
1462 }
1463
1464 // Only what is actually known: a dimension guessed for a remote
1465 // image is a number a consumer lays a card out with before it has
1466 // fetched the file.
1467 if (!empty($image['width']) && !empty($image['height'])) {
1468 echo '<meta property="og:image:width" content="' . esc_attr((string) $image['width']) . '" />' . "\n";
1469 echo '<meta property="og:image:height" content="' . esc_attr((string) $image['height']) . '" />' . "\n";
1470 }
1471
1472 if (!empty($image['type'])) {
1473 echo '<meta property="og:image:type" content="' . esc_attr((string) $image['type']) . '" />' . "\n";
1474 }
1475
1476 if (!empty($image['alt'])) {
1477 echo '<meta property="og:image:alt" content="' . esc_attr((string) $image['alt']) . '" />' . "\n";
1478 }
1479 }
1480 }
1481
1482 /**
1483 * Output social media Twitter Card tags from Social Meta Manager
1484 *
1485 * @param array $twitter_tags Twitter Card tags array
1486 * @return void
1487 */
1488 private function output_social_twitter_tags(array $twitter_tags): void {
1489 echo "<!-- ThinkRank SEO Twitter Card Tags (Enhanced) -->\n";
1490
1491 // Define optimal order for Twitter Card tags
1492 $twitter_order = [
1493 'twitter:card',
1494 'twitter:title',
1495 'twitter:description',
1496 'twitter:site',
1497 'twitter:creator',
1498 'twitter:image',
1499 'twitter:image:alt'
1500 ];
1501
1502 // Output tags in optimal order
1503 foreach ($twitter_order as $name) {
1504 if (!empty($twitter_tags[$name])) {
1505 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call.
1506 echo '<meta name="' . esc_attr($name) . '" content="' . $this->esc_meta_value($name, $twitter_tags[$name]) . '" />' . "\n";
1507 }
1508 }
1509
1510 // Output any remaining tags not in the order list
1511 foreach ($twitter_tags as $name => $content) {
1512 if (!empty($content) && !in_array($name, $twitter_order, true)) {
1513 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_meta_value() applies esc_url()/esc_attr(); the sniff cannot follow a method call.
1514 echo '<meta name="' . esc_attr($name) . '" content="' . $this->esc_meta_value($name, $content) . '" />' . "\n";
1515 }
1516 }
1517
1518 echo "<!-- /ThinkRank SEO Twitter Card Tags -->\n";
1519 }
1520
1521 /**
1522 * Escape a social meta tag value, using esc_url() for URL-valued keys so a
1523 * javascript:/data: scheme is stripped and output stays spec-compliant, and
1524 * esc_attr() for everything else.
1525 *
1526 * @param string $key The OG/Twitter property or name.
1527 * @param string|int $value The tag value. Image dimension keys
1528 * (og:image:width/height) arrive as integers, so
1529 * accept any scalar and normalise to string here —
1530 * the file is under strict_types, which would
1531 * otherwise throw a TypeError on the int.
1532 * @return string Escaped value.
1533 */
1534 private function esc_meta_value(string $key, $value): string {
1535 $value = (string) $value;
1536 $url_keys = [
1537 'og:image', 'og:image:url', 'og:image:secure_url', 'og:url',
1538 'twitter:image', 'twitter:player',
1539 ];
1540 return in_array($key, $url_keys, true) ? esc_url($value) : esc_attr($value);
1541 }
1542
1543 /**
1544 * Output platform-specific meta tags
1545 *
1546 * @return void
1547 */
1548 public function output_platform_meta_tags(): void {
1549 // Same reasoning as the Open Graph and Twitter emitters: an error page
1550 // has no shareable identity, and passing '404' through as a social
1551 // context asks the manager for settings that describe a page which does
1552 // not exist. Guarding all three keeps them from disagreeing.
1553 if ($this->current_context === '404') {
1554 return;
1555 }
1556
1557 // Try Social Meta Manager for platform tags
1558 if ($this->social_manager) {
1559 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1560 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
1561
1562 // Pass the same effective title/description as the OG and Twitter
1563 // callbacks so all three share one memoized get_output_data() result
1564 // (platform tags don't depend on them, so output is unchanged).
1565 $social_data = $this->social_manager->get_output_data(
1566 $social_context,
1567 $this->current_post_id,
1568 $this->get_effective_seo_title(),
1569 $this->get_meta_description()
1570 );
1571
1572 if ($social_data['enabled'] && !empty($social_data['platform_tags'])) {
1573 $this->output_social_platform_tags($social_data['platform_tags']);
1574 }
1575 }
1576 }
1577
1578 /**
1579 * Output social media platform tags from Social Meta Manager
1580 *
1581 * @param array $platform_tags Platform tags array
1582 * @return void
1583 */
1584 private function output_social_platform_tags(array $platform_tags): void {
1585 echo "<!-- ThinkRank SEO Platform Meta Tags -->\n";
1586
1587 foreach ($platform_tags as $name => $content) {
1588 if (!empty($content)) {
1589 // Determine if it should be property or name attribute
1590 if (strpos($name, 'fb:') === 0) {
1591 // Facebook tags use property attribute
1592 echo '<meta property="' . esc_attr($name) . '" content="' . esc_attr($content) . '" />' . "\n";
1593 } else {
1594 // Other platform tags use name attribute
1595 echo '<meta name="' . esc_attr($name) . '" content="' . esc_attr($content) . '" />' . "\n";
1596 }
1597 }
1598 }
1599
1600 echo "<!-- /ThinkRank SEO Platform Meta Tags -->\n";
1601 }
1602
1603 /**
1604 * Output Open Graph meta tags (HIGH PRIORITY)
1605 * Uses Social Meta Manager with fallback to Site Identity templates
1606 *
1607 * @return void
1608 */
1609 public function output_open_graph_tags(): void {
1610 // Per-content-type Open Graph switch. 'inherit' (the default) keeps the
1611 // site-wide Social Media setting, which the emitters below read (#660).
1612 if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1613 \ThinkRank\SEO\Content_Type_Settings::FEATURE_OPEN_GRAPH,
1614 true
1615 )) {
1616 return;
1617 }
1618
1619 // An error page has no shareable identity. Emitting Open Graph here
1620 // advertised the homepage as the og:url of a URL that does not exist.
1621 if ($this->current_context === '404') {
1622 return;
1623 }
1624
1625 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1626 if ($this->social_manager) {
1627 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1628 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
1629
1630 // Effective SEO title/description for this request (resolved
1631 // per-post value > Global SEO template > Site Identity), identical
1632 // to what is output as the document <title>/meta description and
1633 // mirrored by the Social metabox preview. Passed as fallbacks so a
1634 // cleared Open Graph Title/Description renders the same inherited
1635 // value the preview shows.
1636 $social_data = $this->social_manager->get_output_data(
1637 $social_context,
1638 $this->current_post_id,
1639 $this->get_effective_seo_title(),
1640 $this->get_meta_description()
1641 );
1642
1643 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1644 // toggled off, emit nothing — do NOT fall through to the basic
1645 // emitter (which would re-add a full OG block despite the toggle).
1646 if (!empty($social_data['og_enabled'])) {
1647 $this->output_social_og_tags(
1648 $social_data['og_tags'],
1649 $social_data['og_extra_images'] ?? []
1650 );
1651 }
1652 return;
1653 }
1654
1655 // Priority 2: Fallback only when the Social Meta Manager is unavailable.
1656 $this->output_basic_og_tags();
1657 }
1658
1659 /**
1660 * Output basic Open Graph tags (fallback implementation)
1661 *
1662 * @return void
1663 */
1664 private function output_basic_og_tags(): void {
1665 // Check for per-post OG overrides first
1666 $og_title_override = '';
1667 $og_description_override = '';
1668 $og_image_override = '';
1669 if (is_singular() && $this->current_post_id) {
1670 // Social fields may hold variable tags entered in the metabox.
1671 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1672 (string) get_post_meta($this->current_post_id, '_thinkrank_og_title', true),
1673 $this->current_post_id
1674 );
1675 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value(
1676 (string) get_post_meta($this->current_post_id, '_thinkrank_og_description', true),
1677 $this->current_post_id
1678 );
1679 $og_image_override = get_post_meta($this->current_post_id, '_thinkrank_og_image', true);
1680 } elseif ($this->current_term_id) {
1681 // Terms carry the same social override keys — the abilities API
1682 // writes them — so honour them here rather than letting the term's
1683 // SEO title stand in for an explicit og:title.
1684 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value(
1685 (string) get_term_meta($this->current_term_id, '_thinkrank_og_title', true),
1686 $this->current_term_id
1687 );
1688 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value(
1689 (string) get_term_meta($this->current_term_id, '_thinkrank_og_description', true),
1690 $this->current_term_id
1691 );
1692 $og_image_override = get_term_meta($this->current_term_id, '_thinkrank_og_image', true);
1693 }
1694
1695 // Get title using priority system: OG override > post-specific > Global SEO > Site Identity > default
1696 $title = '';
1697 if (!empty($og_title_override)) {
1698 $title = $og_title_override;
1699 } elseif ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
1700 $title = $this->current_metadata['title'];
1701 } else {
1702 $title = $this->generate_context_title();
1703 }
1704 if (!$title) {
1705 $title = is_singular() ? get_the_title() : get_bloginfo('name');
1706 }
1707
1708 // Get description with OG override priority
1709 $description = '';
1710 if (!empty($og_description_override)) {
1711 $description = $og_description_override;
1712 } else {
1713 $description = $this->get_meta_description();
1714 }
1715 // Skipped for a protected post: core answers get_the_excerpt() with its
1716 // "There is no excerpt because this is a protected post." placeholder,
1717 // so this is not a leak — but publishing that sentence as the social
1718 // description is worse than publishing none (#363).
1719 if (!$description && !$this->is_content_password_protected()) {
1720 $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1721 }
1722
1723 $url = is_singular() ? get_permalink() : home_url();
1724 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
1725 ? $this->site_identity_data['identity']['site_name']
1726 : get_bloginfo('name');
1727
1728 // Determine proper og:type based on context
1729 $og_type = 'website';
1730 if (is_singular('post')) {
1731 $og_type = 'article';
1732 } elseif (is_singular('page')) {
1733 $og_type = 'website';
1734 } elseif (is_home() || is_front_page()) {
1735 $og_type = 'website';
1736 }
1737
1738 /**
1739 * Filter the Open Graph og:type. Add-ons (e.g. ThinkRank Pro's
1740 * WooCommerce module) use this to set 'product' on product pages.
1741 *
1742 * @since 1.14.0
1743 *
1744 * @param string $og_type Determined og:type.
1745 */
1746 $og_type = apply_filters('thinkrank_og_type', $og_type);
1747
1748 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1749 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1750 echo "<meta property=\"og:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1751 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1752 echo "<meta property=\"og:url\" content=\"" . esc_url(\ThinkRank\SEO\Url_Scheme::apply($url)) . "\" />\n";
1753 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1754 /**
1755 * Filter the og:locale value.
1756 *
1757 * Defaults to get_locale(), which is only language-correct while the
1758 * active language's translation files are installed — on a multilingual
1759 * site without them WordPress keeps reporting the default locale even
1760 * on translated URLs. The multilingual integration overrides this with
1761 * the locale its provider reports for the current language.
1762 *
1763 * @since 1.23.0
1764 *
1765 * @param string $locale Locale for the current request.
1766 */
1767 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1768 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1769
1770 // Add OG image — per-post override > featured image
1771 $primary_og_image = '';
1772 if (is_singular() && $this->current_post_id) {
1773 if (!empty($og_image_override)) {
1774 $primary_og_image = (string) $og_image_override;
1775 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1776 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1777 } elseif (has_post_thumbnail($this->current_post_id)) {
1778 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1779 $primary_og_image = (string) $image_url;
1780 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1781 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1782
1783 // Get image dimensions and alt text
1784 $image_id = get_post_thumbnail_id($this->current_post_id);
1785 $image_meta = wp_get_attachment_metadata($image_id);
1786 if ($image_meta) {
1787 // SVGs (and other vector uploads) report 0x0 — emitting
1788 // those as og:image dimensions is invalid, so skip them.
1789 $og_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1790 $og_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1791 if ($og_width > 0 && $og_height > 0) {
1792 echo "<meta property=\"og:image:width\" content=\"" . esc_attr($og_width) . "\" />\n";
1793 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1794 }
1795 // Derive the real mime type instead of hardcoding image/jpeg,
1796 // which mislabels PNG/WebP featured images.
1797 $image_mime = get_post_mime_type($image_id);
1798 if ($image_mime) {
1799 echo "<meta property=\"og:image:type\" content=\"" . esc_attr($image_mime) . "\" />\n";
1800 }
1801 }
1802
1803 // Add image alt text
1804 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', true);
1805 if ($image_alt) {
1806 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1807 }
1808 }
1809
1810 // Alternatives, same as the enhanced emitter above. This path only
1811 // runs when the Social Meta Manager is unavailable, but the issue
1812 // reported against it (#636) and a site that lands here should not
1813 // silently lose a feature it switched on.
1814 if (!empty($primary_og_image)) {
1815 $social_settings = $this->social_manager
1816 ? $this->social_manager->get_settings(
1817 $this->current_context === 'homepage' ? 'site' : $this->current_context,
1818 $this->current_post_id
1819 )
1820 : [];
1821
1822 if (!empty($social_settings['og_multiple_images'])) {
1823 self::output_extra_og_images(
1824 \ThinkRank\SEO\Social_Images::additional(
1825 (int) $this->current_post_id,
1826 $primary_og_image
1827 )
1828 );
1829 }
1830 }
1831
1832 // Add article specific tags for posts only
1833 if ($og_type === 'article') {
1834 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1835 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
1836
1837 // Add author
1838 $author_id = get_post_field('post_author', $this->current_post_id);
1839 $author_name = get_the_author_meta('display_name', $author_id);
1840 echo "<meta property=\"article:author\" content=\"" . esc_attr($author_name) . "\" />\n";
1841
1842 // Add categories as article:section
1843 if (is_single()) {
1844 $categories = get_the_category($this->current_post_id);
1845 if (!empty($categories)) {
1846 echo "<meta property=\"article:section\" content=\"" . esc_attr($categories[0]->name) . "\" />\n";
1847 }
1848 }
1849 }
1850 }
1851 echo "<!-- /ThinkRank SEO Open Graph Meta Tags -->\n";
1852 }
1853
1854 /**
1855 * Output Twitter Card meta tags (HIGH PRIORITY)
1856 * Uses Social Meta Manager with fallback to Site Identity templates
1857 *
1858 * @return void
1859 */
1860 public function output_twitter_card_tags(): void {
1861 // Per-content-type Twitter card switch; see output_open_graph_tags().
1862 if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1863 \ThinkRank\SEO\Content_Type_Settings::FEATURE_TWITTER,
1864 true
1865 )) {
1866 return;
1867 }
1868
1869 // Same reasoning as the Open Graph block: nothing on a 404 is shareable.
1870 if ($this->current_context === '404') {
1871 return;
1872 }
1873
1874 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1875 if ($this->social_manager) {
1876 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1877 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
1878
1879 // Twitter title/description derive from the same content data, so
1880 // pass the effective SEO title and meta description as fallbacks to
1881 // keep a cleared override in step with the document <title>/meta
1882 // description and the metabox preview.
1883 $social_data = $this->social_manager->get_output_data(
1884 $social_context,
1885 $this->current_post_id,
1886 $this->get_effective_seo_title(),
1887 $this->get_meta_description()
1888 );
1889
1890
1891 // The Social Meta Manager ran, so it owns Twitter output. If Twitter
1892 // Cards are toggled off, emit nothing — do NOT fall through to the
1893 // basic emitter (which would re-add twitter:* tags despite the toggle).
1894 if (!empty($social_data['twitter_enabled'])) {
1895 $this->output_social_twitter_tags($social_data['twitter_tags']);
1896 }
1897 return;
1898 }
1899
1900 // Priority 2: Fallback only when the Social Meta Manager is unavailable.
1901 $this->output_basic_twitter_tags();
1902 }
1903
1904 /**
1905 * Output basic Twitter Card tags (fallback implementation)
1906 *
1907 * @return void
1908 */
1909 private function output_basic_twitter_tags(): void {
1910 // Check for per-post Twitter overrides first, then fall through to OG overrides.
1911 $twitter_title_override = '';
1912 $twitter_description_override = '';
1913 $og_title_override = '';
1914 $og_description_override = '';
1915 if (is_singular() && $this->current_post_id) {
1916 // Social fields may hold variable tags entered in the metabox.
1917 $pid = $this->current_post_id;
1918 $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_title', true), $pid);
1919 $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_description', true), $pid);
1920 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_title', true), $pid);
1921 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_description', true), $pid);
1922 } elseif ($this->current_term_id) {
1923 $tid = $this->current_term_id;
1924 $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_title', true), $tid);
1925 $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_description', true), $tid);
1926 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_title', true), $tid);
1927 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_description', true), $tid);
1928 }
1929
1930 // Title cascade: Twitter override > OG override > Global SEO > Site Identity > default
1931 $title = '';
1932 if (!empty($twitter_title_override)) {
1933 $title = $twitter_title_override;
1934 } elseif (!empty($og_title_override)) {
1935 $title = $og_title_override;
1936 } elseif ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
1937 $title = $this->current_metadata['title'];
1938 } else {
1939 $title = $this->generate_context_title();
1940 }
1941 if (!$title) {
1942 $title = is_singular() ? get_the_title() : get_bloginfo('name');
1943 }
1944
1945 // Description cascade: Twitter override > OG override > meta description > excerpt
1946 $description = '';
1947 if (!empty($twitter_description_override)) {
1948 $description = $twitter_description_override;
1949 } elseif (!empty($og_description_override)) {
1950 $description = $og_description_override;
1951 } else {
1952 $description = $this->get_meta_description();
1953 }
1954 // Skipped for a protected post: core answers get_the_excerpt() with its
1955 // "There is no excerpt because this is a protected post." placeholder,
1956 // so this is not a leak — but publishing that sentence as the social
1957 // description is worse than publishing none (#363).
1958 if (!$description && !$this->is_content_password_protected()) {
1959 $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1960 }
1961
1962 // Determine card type based on image availability
1963 $card_type = 'summary';
1964 if (is_singular() && $this->current_post_id && has_post_thumbnail($this->current_post_id)) {
1965 $card_type = 'summary_large_image';
1966 }
1967
1968 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1969 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1970 echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1971 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1972
1973 // Add Twitter image with proper fallback priority
1974 $twitter_image_url = $this->get_twitter_image_with_fallback();
1975 if ($twitter_image_url) {
1976 echo "<meta name=\"twitter:image\" content=\"" . esc_url($twitter_image_url) . "\" />\n";
1977
1978 // Add image alt text for accessibility (if it's a featured image)
1979 if (is_singular() && $this->current_post_id && has_post_thumbnail($this->current_post_id)) {
1980 $featured_image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1981 if ($twitter_image_url === $featured_image_url) {
1982 $image_id = get_post_thumbnail_id($this->current_post_id);
1983 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', true);
1984 if ($image_alt) {
1985 echo "<meta name=\"twitter:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1986 }
1987 }
1988 }
1989 }
1990
1991 // Add site Twitter handle if configured
1992 if ($this->site_identity_data && !empty($this->site_identity_data['social']['twitter_username'])) {
1993 $twitter_handle = $this->site_identity_data['social']['twitter_username'];
1994 // Ensure handle starts with @
1995 if (strpos($twitter_handle, '@') !== 0) {
1996 $twitter_handle = '@' . $twitter_handle;
1997 }
1998 echo "<meta name=\"twitter:site\" content=\"" . esc_attr($twitter_handle) . "\" />\n";
1999 }
2000 echo "<!-- /ThinkRank SEO Twitter Card Meta Tags -->\n";
2001 }
2002
2003 /**
2004 * Output canonical URL
2005 *
2006 * @return void
2007 */
2008 public function output_canonical_url(): void {
2009 $canonical_url = '';
2010
2011 if (is_singular()) {
2012 // Check for custom canonical URL override
2013 if ($this->current_post_id) {
2014 $custom_canonical = get_post_meta($this->current_post_id, '_thinkrank_canonical_url', true);
2015 if (!empty($custom_canonical)) {
2016 $canonical_url = $custom_canonical;
2017 }
2018 }
2019
2020 if (empty($canonical_url)) {
2021 $canonical_url = $this->current_post_id ? get_permalink($this->current_post_id) : get_permalink();
2022
2023 // Core's rel_canonical() keeps the page number; this replaced
2024 // it with a bare permalink, so every <!--nextpage--> sub-page
2025 // and every /comment-page-N/ canonicalised to page 1 — a
2026 // regression against core behaviour (#397). A custom canonical
2027 // is left exactly as the user typed it.
2028 $canonical_url = self::with_singular_page($canonical_url);
2029 }
2030 } else {
2031 $canonical_url = self::get_non_singular_canonical_url();
2032 }
2033
2034 /**
2035 * Filter the canonical URL before output.
2036 *
2037 * @since 1.16.0
2038 *
2039 * @param string $canonical_url Canonical URL ('' suppresses the tag).
2040 */
2041 $canonical_url = apply_filters('thinkrank_canonical_url', $canonical_url);
2042
2043 if (empty($canonical_url)) {
2044 return;
2045 }
2046
2047 // After the filter, so a canonical an add-on supplied is normalized
2048 // too — and a cross-domain one is left alone, since Url_Scheme only
2049 // touches URLs on this site's own host.
2050 $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2051
2052 echo "<!-- ThinkRank SEO Canonical URL -->\n";
2053 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
2054 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
2055
2056 $this->output_pagination_links();
2057 }
2058
2059 /**
2060 * Emit rel="prev" / rel="next" on a paginated archive.
2061 *
2062 * Nothing emitted these at all (#397). Google stopped using them as an
2063 * indexing signal in 2019, so this is not an SEO win with Google — Bing
2064 * still reads them, and they are the standard way to describe a sequence,
2065 * which is what the pages are.
2066 *
2067 * @since 2.0.1
2068 *
2069 * @return void
2070 */
2071 private function output_pagination_links(): void {
2072 // Page 1 still wants a rel="next" when there is a page 2, so only
2073 // singular views are skipped outright.
2074 if (is_singular()) {
2075 return;
2076 }
2077
2078 global $wp_query;
2079
2080 $total = $wp_query ? (int) $wp_query->max_num_pages : 0;
2081
2082 if ($total < 2) {
2083 return;
2084 }
2085
2086 $base = self::get_non_singular_canonical_url();
2087
2088 if ('' === $base) {
2089 return;
2090 }
2091
2092 // get_non_singular_canonical_url() already carries the current page —
2093 // strip it back to page 1 before building the neighbours.
2094 $current = self::current_page_number();
2095 $base = self::without_pagination($base);
2096
2097 if ($current > 1) {
2098 printf(
2099 "<link rel=\"prev\" href=\"%s\" />\n",
2100 esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
2101 );
2102 }
2103
2104 if ($current < $total) {
2105 printf(
2106 "<link rel=\"next\" href=\"%s\" />\n",
2107 esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
2108 );
2109 }
2110 }
2111
2112 /**
2113 * The rewrite base WordPress uses for page numbers ('page' by default).
2114 *
2115 * @since 2.0.1
2116 *
2117 * @return string
2118 */
2119 private static function pagination_base(): string {
2120 global $wp_rewrite;
2121
2122 return $wp_rewrite && $wp_rewrite->pagination_base ? $wp_rewrite->pagination_base : 'page';
2123 }
2124
2125 /**
2126 * Append the sub-page or comment-page number to a singular canonical.
2127 *
2128 * @since 2.0.1
2129 *
2130 * @param string $url Permalink.
2131 * @return string Permalink with the current page appended, when there is one.
2132 */
2133 public static function with_singular_page(string $url): string {
2134 global $wp_rewrite;
2135
2136 $page = (int) get_query_var('page');
2137
2138 if ($page > 1) {
2139 return $wp_rewrite && $wp_rewrite->using_permalinks()
2140 ? trailingslashit($url) . user_trailingslashit($page, 'single_paged')
2141 : add_query_arg('page', $page, $url);
2142 }
2143
2144 $comment_page = (int) get_query_var('cpage');
2145
2146 if ($comment_page > 1) {
2147 return get_comments_pagenum_link($comment_page);
2148 }
2149
2150 return $url;
2151 }
2152
2153 /**
2154 * Build the canonical URL for non-singular contexts.
2155 *
2156 * Covers the blog home, post type / taxonomy / author / date archives.
2157 * Search results and 404 pages get no canonical (they are noindexed).
2158 * Paginated archives canonicalize to their own page URL so page 2+ is
2159 * self-referential rather than pointing at page 1.
2160 *
2161 * @return string Canonical URL or '' when none applies
2162 */
2163 public static function get_non_singular_canonical_url(): string {
2164 if (is_404() || is_search()) {
2165 return '';
2166 }
2167
2168 $canonical_url = '';
2169
2170 if (is_front_page() || is_home()) {
2171 $canonical_url = is_home() && !is_front_page()
2172 ? (string) get_permalink((int) get_option('page_for_posts'))
2173 : home_url('/');
2174 } elseif (is_post_type_archive()) {
2175 $canonical_url = (string) get_post_type_archive_link((string) get_query_var('post_type'));
2176 } elseif (is_category() || is_tag() || is_tax()) {
2177 $term_link = get_term_link(get_queried_object());
2178 $canonical_url = is_wp_error($term_link) ? '' : $term_link;
2179 } elseif (is_author()) {
2180 $canonical_url = get_author_posts_url((int) get_queried_object_id());
2181 } elseif (is_date()) {
2182 if (is_day()) {
2183 $canonical_url = get_day_link((int) get_query_var('year'), (int) get_query_var('monthnum'), (int) get_query_var('day'));
2184 } elseif (is_month()) {
2185 $canonical_url = get_month_link((int) get_query_var('year'), (int) get_query_var('monthnum'));
2186 } elseif (is_year()) {
2187 $canonical_url = get_year_link((int) get_query_var('year'));
2188 }
2189 }
2190
2191 if (empty($canonical_url)) {
2192 return '';
2193 }
2194
2195 // Point paginated archives at their own page, not page 1.
2196 return self::with_pagination($canonical_url, (int) get_query_var('paged'));
2197 }
2198
2199 /**
2200 * Append a page number to a URL the way WordPress does.
2201 *
2202 * Extracted so the archive canonical is not the only thing that knows how
2203 * to build a paged URL: the schema graph derived its @id from the
2204 * un-paginated link, so every page of an archive claimed the same node
2205 * identity, and the singular canonical dropped the page entirely (#397).
2206 *
2207 * @since 2.0.1
2208 *
2209 * @param string $url Base URL.
2210 * @param int $page Page number; 1 or less returns the URL unchanged.
2211 * @return string
2212 */
2213 public static function with_pagination(string $url, int $page): string {
2214 if ($page <= 1 || '' === $url) {
2215 return $url;
2216 }
2217
2218 global $wp_rewrite;
2219
2220 if ($wp_rewrite && $wp_rewrite->using_permalinks()) {
2221 return trailingslashit($url) . user_trailingslashit(
2222 $wp_rewrite->pagination_base . '/' . $page,
2223 'paged'
2224 );
2225 }
2226
2227 return add_query_arg('paged', $page, $url);
2228 }
2229
2230 /**
2231 * Strip a page number from a URL, whichever form it takes.
2232 *
2233 * The inverse of with_pagination(). Pretty permalinks carry the page as a
2234 * /page/N/ path segment, plain permalinks as a `paged` query arg, and a
2235 * regex over the path alone silently left the latter in place — so
2236 * rel="prev" on page 2 pointed at page 2 (#397 review).
2237 *
2238 * @since 2.0.1
2239 *
2240 * @param string $url URL that may carry a page number.
2241 * @return string URL for page 1.
2242 */
2243 public static function without_pagination(string $url): string {
2244 if ('' === $url) {
2245 return $url;
2246 }
2247
2248 $url = remove_query_arg('paged', $url);
2249
2250 return (string) preg_replace(
2251 '#/' . preg_quote(self::pagination_base(), '#') . '/\d+/?$#',
2252 '/',
2253 $url
2254 );
2255 }
2256
2257 /**
2258 * The page number of the current request, archive or multi-page post.
2259 *
2260 * `paged` counts archive pages; `page` counts the <!--nextpage--> parts of
2261 * a single post. They are never both set.
2262 *
2263 * @since 2.0.1
2264 *
2265 * @return int Page number, 1 when this is the first page.
2266 */
2267 public static function current_page_number(): int {
2268 $paged = (int) get_query_var('paged');
2269
2270 if ($paged > 1) {
2271 return $paged;
2272 }
2273
2274 $page = (int) get_query_var('page');
2275
2276 return $page > 1 ? $page : 1;
2277 }
2278
2279
2280 /**
2281 * Check if ThinkRank has metadata for current post
2282 *
2283 * @return bool True if has ThinkRank metadata
2284 */
2285 private function has_thinkrank_metadata(): bool {
2286 // Populated by initialize_current_context() for singular views and for
2287 // term archives, and left empty everywhere else — so the emptiness
2288 // check is the whole test. The `!is_singular()` early return this
2289 // replaced is what made every stored term title and description inert:
2290 // the entire title/description cascade hangs off this method (#386).
2291 return !empty($this->current_metadata['title']) || !empty($this->current_metadata['description']);
2292 }
2293
2294 /**
2295 * Check if current page has SEO data (public method for template functions)
2296 *
2297 * @return bool True if has SEO data
2298 */
2299 public function has_seo_data(): bool {
2300 // Check if Site Identity is enabled and active
2301 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
2302 return true;
2303 }
2304
2305 // Check if post has ThinkRank metadata
2306 return $this->has_thinkrank_metadata();
2307 }
2308
2309 /**
2310 * Get current breadcrumbs data (public method for template functions)
2311 *
2312 * @return array|null Breadcrumb data or null if not available
2313 */
2314 public function get_current_breadcrumbs(): ?array {
2315 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2316 return null;
2317 }
2318
2319 $settings = $this->site_identity_manager->get_settings('site');
2320
2321 if (empty($settings['breadcrumbs_enabled'])) {
2322 return null;
2323 }
2324
2325 return $this->generate_breadcrumbs($settings);
2326 }
2327
2328 /**
2329 * Get current SEO metadata
2330 *
2331 * @return array Current metadata
2332 */
2333 public function get_current_metadata(): array {
2334 return $this->current_metadata;
2335 }
2336
2337 /**
2338 * Generate title based on current context using Site Identity templates
2339 *
2340 * @return string|null Generated title or null if no template available
2341 */
2342 private function generate_context_title(): ?string {
2343 // Priority 1: Try Global SEO settings for current post type
2344 $global_seo_title = $this->get_global_seo_title();
2345 if ($global_seo_title) {
2346 return $global_seo_title;
2347 }
2348
2349 // Priority 2: Fall back to Site Identity templates
2350 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2351 return null;
2352 }
2353
2354 $template = $this->get_title_template_for_context();
2355 if (!$template) {
2356 return null;
2357 }
2358
2359 $placeholders = $this->get_title_placeholders();
2360 return $this->process_title_template($template, $placeholders);
2361 }
2362
2363 /**
2364 * Get title from Global SEO settings for current post type
2365 *
2366 * @return string|null Generated title or null if no Global SEO template available
2367 */
2368 private function get_global_seo_title(): ?string {
2369 // Only apply Global SEO to singular posts/pages
2370 if (!is_singular()) {
2371 return null;
2372 }
2373
2374 $post_type = get_post_type();
2375 if (!$post_type) {
2376 return null;
2377 }
2378
2379 // Get Global SEO settings for this post type
2380 $global_seo_settings = $this->get_global_seo_settings($post_type);
2381 if (empty($global_seo_settings['title'])) {
2382 return null;
2383 }
2384
2385 $template = $global_seo_settings['title'];
2386 $placeholders = $this->get_global_seo_placeholders();
2387
2388 return $this->process_global_seo_template($template, $placeholders);
2389 }
2390
2391 /**
2392 * Get Global SEO settings for a post type
2393 *
2394 * @param string $post_type Post type slug
2395 * @return array Global SEO settings or empty array
2396 */
2397 private function get_global_seo_settings(string $post_type): array {
2398 $all_settings = get_option('thinkrank_global_seo_settings', []);
2399 return $all_settings[$post_type] ?? [];
2400 }
2401
2402 /**
2403 * Get placeholders for Global SEO template processing
2404 *
2405 * @return array Placeholder values
2406 */
2407 private function get_global_seo_placeholders(): array {
2408 $placeholders = [
2409 '%title%' => '',
2410 '%sitename%' => get_bloginfo('name'),
2411 '%sep%' => $this->get_global_seo_separator(),
2412 '%excerpt%' => '',
2413 '%date%' => get_the_date(),
2414 '%modified%' => get_the_modified_date(),
2415 '%author%' => '',
2416 '%category%' => '',
2417 ];
2418
2419 // Get current post data if available
2420 if ($this->current_post_id) {
2421 $placeholders['%title%'] = get_the_title($this->current_post_id);
2422
2423 // Get excerpt
2424 $post = get_post($this->current_post_id);
2425 if ($post) {
2426 // An authored post_excerpt is written for public consumption, so
2427 // it stays. Falling back to the body does not: for a protected
2428 // post that derivation leaks the gated content through any
2429 // template containing %excerpt%, and this branch runs BEFORE the
2430 // derive-from-content priority below, so guarding only that one
2431 // would leave this path open (#363).
2432 if (!empty($post->post_excerpt)) {
2433 $placeholders['%excerpt%'] = $post->post_excerpt;
2434 } elseif (!$this->is_content_password_protected($post->ID)) {
2435 $placeholders['%excerpt%'] = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
2436 \ThinkRank\SEO\Builder_Content::visible_content($post)
2437 );
2438 }
2439 }
2440
2441 // Get author
2442 $author_id = get_post_field('post_author', $this->current_post_id);
2443 $placeholders['%author%'] = get_the_author_meta('display_name', $author_id);
2444
2445 // Get category (for posts)
2446 if (get_post_type($this->current_post_id) === 'post') {
2447 $categories = get_the_category($this->current_post_id);
2448 $placeholders['%category%'] = !empty($categories) ? $categories[0]->name : '';
2449 }
2450 }
2451
2452 return $placeholders;
2453 }
2454
2455 /**
2456 * Get separator for Global SEO title
2457 *
2458 * @return string Separator symbol
2459 */
2460 public function get_global_seo_separator(): string {
2461 return \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol();
2462 }
2463
2464 /**
2465 * Process Global SEO template with placeholders
2466 *
2467 * @param string $template Template string with variables
2468 * @param array $placeholders Placeholder values
2469 * @return string Processed title
2470 */
2471 private function process_global_seo_template(string $template, array $placeholders): string {
2472 // Replace all placeholders
2473 $title = str_replace(array_keys($placeholders), array_values($placeholders), $template);
2474
2475 // Clean up multiple spaces
2476 $title = preg_replace('/\s+/', ' ', $title);
2477 $title = trim($title);
2478
2479 // Clean up multiple separators (e.g., "| |" becomes "|")
2480 $separator = $placeholders['%sep%'] ?? '|';
2481 $separator_pattern = preg_quote($separator, '/');
2482 $title = preg_replace('/\s*' . $separator_pattern . '\s*' . $separator_pattern . '\s*/', ' ' . $separator . ' ', $title);
2483
2484 // Remove leading/trailing separators
2485 $title = trim($title, " \t\n\r\0\x0B" . $separator);
2486
2487 return $title;
2488 }
2489
2490 /**
2491 * Get title template for current context
2492 *
2493 * @return string|null Template string or null if not found
2494 */
2495 private function get_title_template_for_context(): ?string {
2496 $settings = $this->site_identity_manager->get_settings('site');
2497
2498 switch ($this->current_context) {
2499 case 'homepage':
2500 // detect_current_context() collapses the static posts page into
2501 // 'homepage', so it rendered the front page's title template and
2502 // the two pages shipped the same <title> — a duplicate title on
2503 // the site's two most-linked URLs (#397 review). It is a page,
2504 // and it has its own name, so it gets the page template.
2505 if (self::is_static_posts_page()) {
2506 return $settings['page_title'] ?? $settings['homepage_title'] ?? null;
2507 }
2508
2509 return $settings['homepage_title'] ?? null;
2510 case 'post':
2511 return $settings['post_title'] ?? null;
2512 case 'page':
2513 return $settings['page_title'] ?? null;
2514 case 'category':
2515 return $settings['category_title'] ?? null;
2516 case 'tag':
2517 return $settings['tag_title'] ?? null;
2518 case 'author':
2519 return $settings['author_title'] ?? null;
2520 case 'search':
2521 return $settings['search_title'] ?? null;
2522 case 'archive':
2523 return $settings['archive_title'] ?? null;
2524 default:
2525 return null;
2526 }
2527 }
2528
2529 /**
2530 * Get title placeholders for current context
2531 *
2532 * @return array Placeholder values
2533 */
2534 private function get_title_placeholders(): array {
2535 global $post, $wp_query;
2536
2537 $settings = $this->site_identity_manager->get_settings('site');
2538 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
2539
2540 $placeholders = [
2541 // first_non_empty(), not `??`: Site Identity persists these as ''
2542 // rather than leaving them unset, and '' is not null — so the
2543 // null-coalesce stopped dead on the empty string and the WordPress
2544 // fallback was unreachable. A site with a tagline set in Settings →
2545 // General rendered "%site_description%" as nothing (#398). This is
2546 // the same reasoning first_non_empty()'s own docblock records.
2547 '%site_title%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2548 '%site_name%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2549 '%site_description%' => $this->first_non_empty($settings['site_description'] ?? '', get_bloginfo('description')),
2550 '%tagline%' => $this->first_non_empty($settings['tagline'] ?? '', get_bloginfo('description')),
2551 '%separator%' => ' ' . $separator . ' ',
2552 '%sep%' => ' ' . $separator . ' ',
2553 '%date%' => gmdate('F Y'),
2554 ];
2555
2556 // Context-specific placeholders
2557 switch ($this->current_context) {
2558 case 'post':
2559 case 'page':
2560 if ($this->current_post_id) {
2561 $placeholders['%post_title%'] = get_the_title($this->current_post_id);
2562 $placeholders['%page_title%'] = get_the_title($this->current_post_id);
2563 $post_author = get_post_field('post_author', $this->current_post_id);
2564 $placeholders['%author%'] = get_the_author_meta('display_name', $post_author);
2565 $placeholders['%author_name%'] = get_the_author_meta('display_name', $post_author);
2566
2567 // Get categories for posts
2568 $post_type = get_post_type($this->current_post_id);
2569 if ($post_type === 'post') {
2570 $categories = get_the_category($this->current_post_id);
2571 $placeholders['%category%'] = !empty($categories) ? $categories[0]->name : '';
2572 }
2573 }
2574 break;
2575
2576 case 'category':
2577 $category = get_queried_object();
2578 if ($category) {
2579 $placeholders['%category_title%'] = $category->name;
2580 $placeholders['%category%'] = $category->name;
2581 }
2582 break;
2583
2584 case 'tag':
2585 $tag = get_queried_object();
2586 if ($tag) {
2587 $placeholders['%tag_title%'] = $tag->name;
2588 $placeholders['%tag%'] = $tag->name;
2589 }
2590 break;
2591
2592 case 'author':
2593 $author = get_queried_object();
2594 if ($author) {
2595 $placeholders['%author_name%'] = $author->display_name;
2596 $placeholders['%author%'] = $author->display_name;
2597 }
2598 break;
2599
2600 case 'search':
2601 $placeholders['%search_term%'] = get_search_query();
2602 break;
2603
2604 case 'archive':
2605 // Stripped: get_the_archive_title() wraps its subject in a
2606 // <span>, and this placeholder feeds the document <title> as
2607 // well as og:title and twitter:title — a date archive rendered
2608 // as "Month: <span>August 2026</span> | Site".
2609 $placeholders['%archive_title%'] = self::archive_subject();
2610 break;
2611
2612 case 'homepage':
2613 // The page template resolved for a static posts page needs the
2614 // page's own name; without it %title%/%page_title% would render
2615 // empty and collapse back to the site title.
2616 if (self::is_static_posts_page()) {
2617 $posts_page_title = get_the_title((int) get_option('page_for_posts'));
2618 $placeholders['%title%'] = $posts_page_title;
2619 $placeholders['%page_title%'] = $posts_page_title;
2620 $placeholders['%post_title%'] = $posts_page_title;
2621 }
2622 break;
2623 }
2624
2625 return $placeholders;
2626 }
2627
2628 /**
2629 * Whether this request is a static posts page rather than the front page.
2630 *
2631 * @since 2.0.1
2632 *
2633 * @return bool
2634 */
2635 private static function is_static_posts_page(): bool {
2636 return is_home() && !is_front_page() && (int) get_option('page_for_posts') > 0;
2637 }
2638
2639 /**
2640 * Process title template with placeholders
2641 *
2642 * @param string $template Template string
2643 * @param array $placeholders Placeholder values
2644 * @return string Processed title
2645 */
2646 private function process_title_template(string $template, array $placeholders): string {
2647 $title = str_replace(array_keys($placeholders), array_values($placeholders), $template);
2648
2649 // Clean up multiple separators and extra spaces
2650 $separator = $placeholders['%separator%'] ?? ' | ';
2651
2652 // Legacy templates stored a literal pipe as separator — apply the active separator to them
2653 $title = preg_replace('/\s*\|\s*/', $separator, $title);
2654 $title = preg_replace('/\s*' . preg_quote(trim($separator), '/') . '\s*' . preg_quote(trim($separator), '/') . '\s*/', $separator, $title);
2655 $title = preg_replace('/\s+/', ' ', $title);
2656 $title = trim($title);
2657
2658 // Remove trailing separator
2659 $separator_trimmed = trim($separator);
2660 if (substr($title, -strlen($separator_trimmed)) === $separator_trimmed) {
2661 $title = trim(substr($title, 0, -strlen($separator_trimmed)));
2662 }
2663
2664 return $title;
2665 }
2666
2667 /**
2668 * Get title separator symbol
2669 *
2670 * @param string $separator_type Separator type
2671 * @return string Separator symbol
2672 */
2673 private function get_title_separator(string $separator_type): string {
2674 return \ThinkRank\SEO\Site_Identity_Manager::$title_separators[$separator_type]['symbol'] ?? \ThinkRank\SEO\Site_Identity_Manager::$title_separators['pipe']['symbol'];
2675 }
2676
2677 /**
2678 * Whether a post's body must not be read for a public surface.
2679 *
2680 * Deriving metadata from `post_content` publishes that content to everyone
2681 * who requests the URL — and to every crawler and link-preview unfurler
2682 * that reads og:description — while the page itself still shows only the
2683 * password form, so the leak is invisible to the site owner (#363).
2684 *
2685 * This is the one thing every content reader should call before touching
2686 * `post_content` for output. It mirrors core: a visitor who has already
2687 * entered the correct password sees the body anyway, so nothing is hidden
2688 * from them here either.
2689 *
2690 * @param int|null $post_id Optional. Post ID. Defaults to the current post.
2691 * @return bool True when the body is password-gated for this visitor.
2692 */
2693 private function is_content_password_protected(?int $post_id = null): bool {
2694 $post_id = $post_id ?? $this->current_post_id;
2695
2696 if (!$post_id) {
2697 return false;
2698 }
2699
2700 $post = get_post($post_id);
2701
2702 if (!$post) {
2703 return false;
2704 }
2705
2706 // Guarded for the same reason the schema path guards it: this class is
2707 // also exercised outside a full front-end request.
2708 return function_exists('post_password_required') && post_password_required($post);
2709 }
2710
2711 /**
2712 * Get meta description with fallback system
2713 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
2714 *
2715 * @return string|null Meta description or null if none available
2716 */
2717 private function get_meta_description(): ?string {
2718 // First priority: Post-specific ThinkRank metadata
2719 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['description'])) {
2720 return $this->current_metadata['description'];
2721 }
2722
2723 // Second priority: Global SEO description template
2724 $global_seo_description = $this->get_global_seo_description();
2725 if ($global_seo_description) {
2726 return $global_seo_description;
2727 }
2728
2729 // Archive contexts: derive the description from the archive itself
2730 // (term description, post type description, author bio)
2731 $archive_description = $this->get_archive_meta_description();
2732 if ($archive_description) {
2733 return $archive_description;
2734 }
2735
2736 // Third priority: Site Identity default meta description
2737 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
2738 $settings = $this->site_identity_manager->get_settings('site');
2739 $default_description = $settings['default_meta_description'] ?? '';
2740
2741 if (!empty($default_description)) {
2742 return $default_description;
2743 }
2744 }
2745
2746 // Fourth priority: Generate from content for posts/pages.
2747 // Never for a password-protected post — deriving the description from a
2748 // gated body published its first ~25 words in the page head, and the
2749 // same value is reused for og:description and twitter:description, so
2750 // one unguarded read leaked through three tags (#363).
2751 if (is_singular() && $this->current_post_id && !$this->is_content_password_protected()) {
2752 // Not the raw column: a Bricks page discards `post_content`, so
2753 // whatever is still stored there is invisible — and this one value
2754 // becomes the meta, og: and twitter: descriptions (#651).
2755 $described = get_post($this->current_post_id);
2756 $post_content = $described instanceof \WP_Post
2757 ? \ThinkRank\SEO\Builder_Content::visible_content($described)
2758 : get_post_field('post_content', $this->current_post_id);
2759 if ($post_content) {
2760 $excerpt = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post_content);
2761 if (!empty($excerpt)) {
2762 return $excerpt;
2763 }
2764 }
2765 }
2766
2767 // Fifth priority: Site description for homepage
2768 if (is_home() || is_front_page()) {
2769 $site_description = get_bloginfo('description');
2770 if (!empty($site_description)) {
2771 return $site_description;
2772 }
2773 }
2774
2775 return null;
2776 }
2777
2778 /**
2779 * Get a meta description for archive contexts.
2780 *
2781 * Post type archives use the post type's description, taxonomy archives
2782 * the term description, author archives the author bio. Returns null for
2783 * non-archive contexts so the regular fallback chain continues.
2784 *
2785 * @return string|null Archive description or null when not applicable
2786 */
2787 private function get_archive_meta_description(): ?string {
2788 $description = '';
2789
2790 // Author archives are intentionally excluded — Author_Archives_Manager
2791 // outputs its own template-based meta description on wp_head.
2792 if (is_post_type_archive()) {
2793 $post_type_object = get_queried_object();
2794 if ($post_type_object instanceof \WP_Post_Type && !empty($post_type_object->description)) {
2795 $description = $post_type_object->description;
2796 }
2797 } elseif (is_category() || is_tag() || is_tax()) {
2798 $description = term_description() ?: '';
2799 }
2800
2801 $description = trim(wp_strip_all_tags((string) $description));
2802 if ($description === '') {
2803 return null;
2804 }
2805
2806 // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2807 // CJK description tripped this limit at a third of its length, and
2808 // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2809 // English, 25 characters in Thai (#687).
2810 $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2811
2812 return $description;
2813 }
2814
2815 /**
2816 * Get description from Global SEO settings for current post type
2817 *
2818 * @return string|null Generated description or null if no Global SEO template available
2819 */
2820 private function get_global_seo_description(): ?string {
2821 // Only apply Global SEO to singular posts/pages
2822 if (!is_singular()) {
2823 return null;
2824 }
2825
2826 $post_type = get_post_type();
2827 if (!$post_type) {
2828 return null;
2829 }
2830
2831 // Get Global SEO settings for this post type
2832 $global_seo_settings = $this->get_global_seo_settings($post_type);
2833 if (empty($global_seo_settings['description'])) {
2834 return null;
2835 }
2836
2837 $template = $global_seo_settings['description'];
2838 $placeholders = $this->get_global_seo_placeholders();
2839
2840 return $this->process_global_seo_description_template($template, $placeholders);
2841 }
2842
2843 /**
2844 * Process Global SEO description template with placeholders
2845 *
2846 * @param string $template Template string with variables
2847 * @param array $placeholders Placeholder values
2848 * @return string Processed description
2849 */
2850 private function process_global_seo_description_template(string $template, array $placeholders): string {
2851 // Replace all placeholders
2852 $description = str_replace(array_keys($placeholders), array_values($placeholders), $template);
2853
2854 // Clean up multiple spaces
2855 $description = preg_replace('/\s+/', ' ', $description);
2856 $description = trim($description);
2857
2858 // Ensure description doesn't exceed recommended length (160 characters)
2859 // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2860 // CJK description tripped this limit at a third of its length, and
2861 // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2862 // English, 25 characters in Thai (#687).
2863 $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2864
2865 return $description;
2866 }
2867
2868 /**
2869 * Output site-wide schema markup with priority system
2870 *
2871 * Priority: Schema Manager > Site Identity (like Twitter Cards approach)
2872 *
2873 * @return void
2874 */
2875 public function output_site_schema_markup(): void {
2876 $has_schema_manager_output = false;
2877 $has_website_schema = false;
2878
2879 // The master switch on Essential SEO -> Schema Manager. Until #461 this
2880 // was never read here, so turning schema off left every deployed entity
2881 // on the page. Read it once and bail before touching the graph.
2882 if ($this->schema_manager) {
2883 $schema_settings = $this->schema_manager->get_settings('site', null);
2884
2885 if (isset($schema_settings['enabled']) && !$schema_settings['enabled']) {
2886 return;
2887 }
2888 }
2889
2890 // PRIORITY 1: Always output site-wide schemas (Organization, Website, LocalBusiness, Person)
2891 if ($this->schema_manager) {
2892 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
2893
2894 if (!empty($site_wide_schemas)) {
2895 foreach ($site_wide_schemas as $schema_type => $schema_info) {
2896 Schema_Graph::instance()->add_supporting($schema_info['data'], (string) $schema_type);
2897 }
2898 $has_schema_manager_output = true;
2899 $has_website_schema = isset($site_wide_schemas['WebSite']);
2900 }
2901 }
2902
2903 // The homepage always gets a WebSite schema (with a SearchAction) so
2904 // search engines can associate the site name and sitelinks searchbox —
2905 // unless the Schema Manager already deployed one.
2906 if ((is_front_page() || is_home()) && !$has_website_schema) {
2907 $website_schema = $this->generate_website_schema();
2908
2909 /**
2910 * Filter the default homepage WebSite schema before output.
2911 *
2912 * @since 1.16.0
2913 *
2914 * @param array $website_schema WebSite schema array ([] suppresses output).
2915 */
2916 $website_schema = apply_filters('thinkrank_website_schema', $website_schema);
2917
2918 if (!empty($website_schema)) {
2919 Schema_Graph::instance()->add_supporting($website_schema, 'WebSite');
2920 }
2921 }
2922
2923 // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages
2924 if ($this->schema_manager && (is_single() || is_page())) {
2925 $context_id = get_the_ID();
2926 $context_type = get_post_type( $context_id );
2927 $context_type = in_array( $context_type, [ 'site', 'post', 'page', 'product' ] , true) ? $context_type : 'post';
2928
2929 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2930
2931 if (!empty($page_specific_schemas)) {
2932 // Every deployed schema is rendered, on every plan. How many a
2933 // page carries is decided when schemas are activated in the
2934 // editor, not trimmed here by plan (#673).
2935
2936 /**
2937 * Filter the page-specific schemas rendered on the current page.
2938 *
2939 * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2940 * @param string $context_type Context type (post, page, product, site).
2941 * @param int $context_id Post ID.
2942 */
2943 $page_specific_schemas = apply_filters(
2944 'thinkrank_page_schemas_to_render',
2945 $page_specific_schemas,
2946 $context_type,
2947 $context_id
2948 );
2949
2950 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2951 Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
2952 }
2953 $has_schema_manager_output = true;
2954 }
2955 }
2956
2957 // Absorb FAQ content from the post body (FAQ block / Elementor widget)
2958 // so it merges into the graph's single FAQPage instead of each producer
2959 // emitting its own competing one.
2960 if (is_singular()) {
2961 $queried_post = get_post();
2962 if ($queried_post instanceof \WP_Post) {
2963 Schema_Graph::instance()->collect_post_faq($queried_post);
2964 }
2965 }
2966
2967 // Skip Site Identity fallback if any Schema Manager schemas were output
2968 if ($has_schema_manager_output) {
2969 return;
2970 }
2971
2972 // PRIORITY 2: Fall back to Site Identity schemas (like basic Twitter Cards)
2973 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2974 return;
2975 }
2976
2977 $settings = $this->site_identity_manager->get_settings('site');
2978
2979 // Only output on homepage or if organization schema is enabled
2980 if (!is_home() && !is_front_page() && empty($settings['organization_schema'])) {
2981 return;
2982 }
2983
2984 $schema = $this->generate_organization_schema($settings);
2985
2986 if ($schema) {
2987 Schema_Graph::instance()->add_supporting($schema, 'Organization');
2988 }
2989 }
2990
2991 /**
2992 * Generate the default WebSite schema for the homepage.
2993 *
2994 * Includes a SearchAction potentialAction so search engines can surface a
2995 * sitelinks searchbox, mirroring what Rank Math/Yoast output by default.
2996 *
2997 * @return array WebSite schema
2998 */
2999 private function generate_website_schema(): array {
3000 $settings = $this->site_identity_manager ? $this->site_identity_manager->get_settings('site') : [];
3001
3002 $schema = [
3003 '@context' => 'https://schema.org',
3004 '@type' => 'WebSite',
3005 '@id' => home_url('/#website'),
3006 'name' => !empty($settings['site_name']) ? $settings['site_name'] : get_bloginfo('name'),
3007 'url' => home_url('/'),
3008 ];
3009
3010 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
3011 if (!empty($description)) {
3012 $schema['description'] = $description;
3013 }
3014
3015 // Site Identity has accepted an alternate name since the setup wizard
3016 // shipped, and the MCP ability describes it as "published as schema
3017 // alternateName" — but no producer ever read it, so the promise was
3018 // false and every imported Yoast/Rank Math value sat unused (#692).
3019 $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3020 if (null !== $alternate_name) {
3021 $schema['alternateName'] = $alternate_name;
3022 }
3023
3024 // The sitelinks searchbox switch was honoured only for a deployed
3025 // WebSite row; this live fallback added potentialAction unconditionally,
3026 // so website_enable_search = 0 still shipped the SearchAction (#688).
3027 // Absent means not configured, which stays enabled.
3028 $search_enabled = true;
3029 if ($this->schema_manager) {
3030 $schema_settings = $this->schema_manager->get_settings('site', null);
3031
3032 if (array_key_exists('website_enable_search', $schema_settings)) {
3033 $search_enabled = !empty($schema_settings['website_enable_search']);
3034 }
3035 }
3036
3037 if ($search_enabled) {
3038 $schema['potentialAction'] = [
3039 '@type' => 'SearchAction',
3040 'target' => [
3041 '@type' => 'EntryPoint',
3042 'urlTemplate' => home_url('/?s={search_term_string}'),
3043 ],
3044 'query-input' => 'required name=search_term_string',
3045 ];
3046 }
3047
3048 return $schema;
3049 }
3050
3051 /**
3052 * Generate organization schema markup
3053 *
3054 * Priority: Schema Manager organization settings > Site Identity settings
3055 *
3056 * @param array $settings Site identity settings (used as fallback)
3057 * @return array|null Schema data or null if insufficient data
3058 */
3059 private function generate_organization_schema(array $settings): ?array {
3060 // PRIORITY 1: Get Schema Manager organization settings
3061 $schema_settings = [];
3062 if ($this->schema_manager) {
3063 $schema_settings = $this->schema_manager->get_settings('site', null);
3064 }
3065
3066 // Determine organization values (Schema Manager > Site Identity > WordPress default).
3067 // Use first_non_empty() rather than ??: these settings keys are always present
3068 // and default to an empty string, so a ?? chain would stop dead on '' and never
3069 // reach the WordPress fallback.
3070 $org_name = $this->first_non_empty(
3071 $schema_settings['organization_name'] ?? null,
3072 $settings['site_name'] ?? null,
3073 get_bloginfo('name')
3074 );
3075
3076 $org_url = $this->first_non_empty(
3077 $schema_settings['organization_url'] ?? null,
3078 $settings['site_url'] ?? null,
3079 home_url()
3080 );
3081
3082 $org_description = $this->first_non_empty(
3083 $schema_settings['organization_description'] ?? null,
3084 $settings['site_description'] ?? null,
3085 get_bloginfo('description')
3086 );
3087
3088 if (empty($org_name)) {
3089 return null;
3090 }
3091
3092 // Determine organization type (Schema Manager setting or default)
3093 $org_type = $schema_settings['organization_type'] ?? 'Organization';
3094
3095 $schema = [
3096 '@context' => 'https://schema.org',
3097 '@type' => $org_type,
3098 '@id' => home_url() . '#organization',
3099 'name' => $org_name,
3100 'url' => $org_url,
3101 ];
3102
3103 // Add description if available
3104 if (!empty($org_description)) {
3105 $schema['description'] = $org_description;
3106 }
3107
3108 // Add logo if available with proper ImageObject structure
3109 // Priority: Schema Manager logo > Site Identity logo
3110 $logo_url = $schema_settings['organization_logo'] ?? $settings['logo_url'] ?? '';
3111
3112 if (!empty($logo_url)) {
3113 $schema['logo'] = [
3114 '@type' => 'ImageObject',
3115 '@id' => home_url() . '#logo',
3116 'url' => $logo_url,
3117 'contentUrl' => $logo_url,
3118 'caption' => $org_name . ' Logo'
3119 ];
3120
3121 // Also add as image property
3122 $schema['image'] = $schema['logo'];
3123 }
3124
3125 // Add social media accounts if available
3126 // Priority: Schema Manager social profiles > Site Identity social profiles
3127 $social_urls = [];
3128
3129 // Check Schema Manager organization social profiles first
3130 if (!empty($schema_settings['organization_social_facebook'])) {
3131 $social_urls[] = $schema_settings['organization_social_facebook'];
3132 }
3133 if (!empty($schema_settings['organization_social_twitter'])) {
3134 $twitter_url = $schema_settings['organization_social_twitter'];
3135 // Ensure it's a full URL
3136 if (strpos($twitter_url, 'http') !== 0) {
3137 $twitter_url = 'https://twitter.com/' . ltrim($twitter_url, '@');
3138 }
3139 $social_urls[] = $twitter_url;
3140 }
3141 if (!empty($schema_settings['organization_social_linkedin'])) {
3142 $social_urls[] = $schema_settings['organization_social_linkedin'];
3143 }
3144 if (!empty($schema_settings['organization_social_instagram'])) {
3145 $social_urls[] = $schema_settings['organization_social_instagram'];
3146 }
3147 if (!empty($schema_settings['organization_social_youtube'])) {
3148 $social_urls[] = $schema_settings['organization_social_youtube'];
3149 }
3150 if (!empty($schema_settings['organization_social_pinterest'])) {
3151 $social_urls[] = $schema_settings['organization_social_pinterest'];
3152 }
3153 if (!empty($schema_settings['organization_social_whatsapp'])) {
3154 $social_urls[] = $schema_settings['organization_social_whatsapp'];
3155 }
3156 if (!empty($schema_settings['organization_social_telegram'])) {
3157 $social_urls[] = $schema_settings['organization_social_telegram'];
3158 }
3159
3160 // Fallback to Site Identity social profiles if no Schema Manager profiles
3161 if (empty($social_urls) && !empty($this->site_identity_data['social'])) {
3162 $social_data = $this->site_identity_data['social'];
3163
3164 if (!empty($social_data['facebook_url'])) {
3165 $social_urls[] = $social_data['facebook_url'];
3166 }
3167 if (!empty($social_data['twitter_username'])) {
3168 $twitter_url = 'https://twitter.com/' . ltrim($social_data['twitter_username'], '@');
3169 $social_urls[] = $twitter_url;
3170 }
3171 if (!empty($social_data['linkedin_url'])) {
3172 $social_urls[] = $social_data['linkedin_url'];
3173 }
3174 if (!empty($social_data['instagram_url'])) {
3175 $social_urls[] = $social_data['instagram_url'];
3176 }
3177 if (!empty($social_data['youtube_url'])) {
3178 $social_urls[] = $social_data['youtube_url'];
3179 }
3180 }
3181
3182 if (!empty($social_urls)) {
3183 $schema['sameAs'] = $social_urls;
3184 }
3185
3186 // Add contact information if available
3187 // Priority: Schema Manager contact info > Site Identity contact info
3188 if (!empty($schema_settings['organization_contact_phone']) || !empty($schema_settings['organization_contact_email'])) {
3189 $contact_point = [
3190 '@type' => 'ContactPoint',
3191 'contactType' => $schema_settings['organization_contact_type'] ?? 'customer service'
3192 ];
3193
3194 if (!empty($schema_settings['organization_contact_phone'])) {
3195 $contact_point['telephone'] = $schema_settings['organization_contact_phone'];
3196 }
3197
3198 if (!empty($schema_settings['organization_contact_email'])) {
3199 $contact_point['email'] = $schema_settings['organization_contact_email'];
3200 }
3201
3202 if (!empty($schema_settings['organization_contact_hours'])) {
3203 $contact_point['hoursAvailable'] = $schema_settings['organization_contact_hours'];
3204 }
3205
3206 $schema['contactPoint'] = $contact_point;
3207 } elseif (!empty($settings['contact_email'])) {
3208 // Fallback to Site Identity contact email
3209 $schema['email'] = $settings['contact_email'];
3210 }
3211
3212 return $schema;
3213 }
3214
3215 /**
3216 * Return the first value that is a non-empty (after trim) string.
3217 *
3218 * Settings keys such as organization_url are always present and default to
3219 * an empty string, so the null-coalescing operator (??) cannot be used to
3220 * build a fallback chain: '' is not null and would short-circuit the chain.
3221 * This helper skips empty strings and returns the first real value, falling
3222 * back to '' when none qualify.
3223 *
3224 * @param string|null ...$values Candidate values in priority order.
3225 * @return string First non-empty value, or '' if none.
3226 */
3227 private function first_non_empty(...$values): string {
3228 foreach ($values as $value) {
3229 if (is_string($value) && trim($value) !== '') {
3230 return $value;
3231 }
3232 }
3233 return '';
3234 }
3235
3236 /**
3237 * Output breadcrumb schema markup
3238 *
3239 * @return void
3240 */
3241 public function output_breadcrumb_schema(): void {
3242 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
3243 return;
3244 }
3245
3246 // A breadcrumb trail for a URL that does not exist, or for a search
3247 // results page, describes nothing — and the plugin already emits no
3248 // canonical on either (#471).
3249 if (is_404() || is_search()) {
3250 return;
3251 }
3252
3253 $settings = $this->site_identity_manager->get_settings('site');
3254
3255 // Only output if breadcrumbs are enabled
3256 if (empty($settings['breadcrumbs_enabled'])) {
3257 return;
3258 }
3259
3260 // Schema Manager's own breadcrumb switch. Only Site Identity's
3261 // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3262 // = 0 removed a deployed BreadcrumbList row and left this live one
3263 // emitting the node anyway (#688). Absent means not configured, which
3264 // stays enabled.
3265 if ($this->schema_manager) {
3266 $schema_settings = $this->schema_manager->get_settings('site', null);
3267
3268 if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3269 && empty($schema_settings['enable_breadcrumbs_schema'])) {
3270 return;
3271 }
3272 }
3273
3274 $breadcrumbs = $this->generate_breadcrumbs($settings);
3275
3276 if (!empty($breadcrumbs['schema'])) {
3277 Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
3278 }
3279 }
3280
3281 /**
3282 * Emit everything ThinkRank collected for this request as one linked @graph.
3283 *
3284 * Runs after every producer has registered (site schema 7, breadcrumbs 8,
3285 * Global SEO 15), so the graph can arbitrate between them.
3286 *
3287 * @since 1.32.0
3288 * @return void
3289 */
3290 public function output_schema_graph(): void {
3291 Schema_Graph::instance()->render();
3292 }
3293
3294 /**
3295 * Output closing comment for ThinkRank SEO
3296 *
3297 * @return void
3298 */
3299 public function output_closing_comment(): void {
3300 // Close only what was actually opened. has_seo_output() is true on
3301 // nearly every page, so testing it here printed a closing comment with
3302 // no matching opener whenever the meta description was empty (search
3303 // results, author archives without a description).
3304 if (self::$opening_comment_output) {
3305 echo "<!-- /ThinkRank SEO -->\n";
3306 }
3307 }
3308
3309 /**
3310 * Print the opening ThinkRank comment, once per request.
3311 *
3312 * Public and static so Author_Archives_Manager — which prints its own meta
3313 * description on wp_head at priority 5 — opens the block through the same
3314 * flag the closing comment reads.
3315 *
3316 * @since 2.0.1
3317 * @return void
3318 */
3319 public static function note_opening_comment(): void {
3320 if (self::$opening_comment_output) {
3321 return;
3322 }
3323
3324 echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
3325 self::$opening_comment_output = true;
3326 }
3327
3328 /**
3329 * Display breadcrumbs HTML
3330 *
3331 * @return void
3332 */
3333 public function display_breadcrumbs(): void {
3334 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
3335 return;
3336 }
3337
3338 $settings = $this->site_identity_manager->get_settings('site');
3339
3340 // Only display if breadcrumbs are enabled
3341 if (empty($settings['breadcrumbs_enabled'])) {
3342 return;
3343 }
3344
3345 $breadcrumbs = $this->generate_breadcrumbs($settings);
3346
3347 if (!empty($breadcrumbs['html'])) {
3348 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is properly escaped in generate_breadcrumb_html method
3349 echo $breadcrumbs['html'];
3350 }
3351 }
3352
3353 /**
3354 * Render breadcrumbs for the [thinkrank_breadcrumbs] shortcode
3355 *
3356 * Respects the same site-identity / breadcrumbs_enabled gates as
3357 * display_breadcrumbs().
3358 *
3359 * @return string Breadcrumb HTML (empty string when disabled)
3360 */
3361 public function breadcrumbs_shortcode(): string {
3362 ob_start();
3363 $this->display_breadcrumbs();
3364 return (string) ob_get_clean();
3365 }
3366
3367 /**
3368 * Display the hero section for the `thinkrank_hero` action hook /
3369 * `thinkrank_hero()` template tag.
3370 *
3371 * Gated on the Site Identity master toggle. Emits nothing when no hero
3372 * content (title/subtitle/CTA) is configured, so an empty hero never
3373 * appears on the front end.
3374 *
3375 * @return void
3376 */
3377 public function display_hero(): void {
3378 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
3379 return;
3380 }
3381
3382 $settings = $this->site_identity_manager->get_settings('site');
3383 $html = $this->generate_hero_html($settings);
3384
3385 if ($html !== '') {
3386 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is escaped field-by-field in generate_hero_html().
3387 echo $html;
3388 }
3389 }
3390
3391 /**
3392 * Render the hero section for the [thinkrank_hero] shortcode.
3393 *
3394 * Respects the same gates as display_hero().
3395 *
3396 * @return string Hero HTML (empty string when disabled or unconfigured)
3397 */
3398 public function hero_shortcode(): string {
3399 ob_start();
3400 $this->display_hero();
3401 return (string) ob_get_clean();
3402 }
3403
3404 /**
3405 * Get the current hero section data without displaying it.
3406 *
3407 * @return array|null Hero data (title, subtitle, cta_text, cta_url,
3408 * background_image, html) or null when unavailable.
3409 */
3410 public function get_current_hero(): ?array {
3411 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
3412 return null;
3413 }
3414
3415 $settings = $this->site_identity_manager->get_settings('site');
3416
3417 $hero = [
3418 'title' => (string) ($settings['hero_title'] ?? ''),
3419 'subtitle' => (string) ($settings['hero_subtitle'] ?? ''),
3420 'cta_text' => (string) ($settings['hero_cta_text'] ?? ''),
3421 'cta_url' => (string) ($settings['hero_cta_url'] ?? ''),
3422 'background_image' => (string) ($settings['hero_background_image'] ?? ''),
3423 ];
3424
3425 // generate_hero_html() is the single source of truth for the
3426 // "is anything renderable?" gate (title, subtitle, or a complete CTA),
3427 // so defer to it rather than duplicate the check — and never expose an
3428 // empty hero.
3429 $hero['html'] = $this->generate_hero_html($settings);
3430 if ($hero['html'] === '') {
3431 return null;
3432 }
3433
3434 return $hero;
3435 }
3436
3437 /**
3438 * Build the hero section HTML from Site Identity settings.
3439 *
3440 * Every dynamic value is escaped at the point of output. Returns an empty
3441 * string when there is no title, subtitle, or complete CTA (text + URL).
3442 *
3443 * @param array $settings Site Identity settings
3444 * @return string Hero HTML, or '' when there is nothing to render
3445 */
3446 private function generate_hero_html(array $settings): string {
3447 $title = trim((string) ($settings['hero_title'] ?? ''));
3448 $subtitle = trim((string) ($settings['hero_subtitle'] ?? ''));
3449 $cta_text = trim((string) ($settings['hero_cta_text'] ?? ''));
3450 $cta_url = trim((string) ($settings['hero_cta_url'] ?? ''));
3451 $bg_image = trim((string) ($settings['hero_background_image'] ?? ''));
3452
3453 // A CTA is only meaningful with both a label and a destination.
3454 $has_cta = ($cta_text !== '' && $cta_url !== '');
3455
3456 // Don't emit an empty hero when nothing renderable is configured. A
3457 // background image alone — or CTA text without a URL — is not enough.
3458 if ($title === '' && $subtitle === '' && !$has_cta) {
3459 return '';
3460 }
3461
3462 $classes = ['thinkrank-hero'];
3463 $style = '';
3464 if ($bg_image !== '') {
3465 $classes[] = 'thinkrank-hero--has-image';
3466 $style = ' style="background-image:url(' . esc_url($bg_image) . ');"';
3467 }
3468
3469 $html = '<section class="' . esc_attr(implode(' ', $classes)) . '"' . $style . '>';
3470 $html .= '<div class="thinkrank-hero__inner">';
3471
3472 if ($title !== '') {
3473 $html .= '<h2 class="thinkrank-hero__title">' . esc_html($title) . '</h2>';
3474 }
3475
3476 if ($subtitle !== '') {
3477 $html .= '<p class="thinkrank-hero__subtitle">' . esc_html($subtitle) . '</p>';
3478 }
3479
3480 if ($has_cta) {
3481 $html .= '<a class="thinkrank-hero__cta" href="' . esc_url($cta_url) . '">' . esc_html($cta_text) . '</a>';
3482 }
3483
3484 $html .= '</div></section>';
3485
3486 return $html;
3487 }
3488
3489 /**
3490 * Generate breadcrumbs data
3491 *
3492 * @param array $settings Breadcrumb settings
3493 * @return array Breadcrumb data with HTML and schema
3494 */
3495 private function generate_breadcrumbs(array $settings): array {
3496 $breadcrumbs = [
3497 'items' => [],
3498 'html' => '',
3499 'schema' => null
3500 ];
3501
3502 // Get breadcrumb items
3503 $items = $this->get_breadcrumb_items($settings);
3504
3505 if (empty($items)) {
3506 return $breadcrumbs;
3507 }
3508
3509 $breadcrumbs['items'] = $items;
3510
3511 // Generate HTML
3512 $breadcrumbs['html'] = $this->generate_breadcrumb_html($items, $settings);
3513
3514 // Generate schema
3515 $breadcrumbs['schema'] = $this->generate_breadcrumb_schema($items);
3516
3517 return $breadcrumbs;
3518 }
3519
3520 /**
3521 * Serve /llms.txt through PHP so the response declares UTF-8.
3522 *
3523 * Cheap guard first: every other front-end request leaves without loading
3524 * the manager.
3525 *
3526 * @since 1.32.0
3527 *
3528 * @return void
3529 */
3530 public function maybe_serve_llms_txt(): void {
3531 if (!$this->is_llms_txt_request()) {
3532 return;
3533 }
3534
3535 if (!class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) {
3536 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-llms-txt-manager.php';
3537 }
3538
3539 $manager = new \ThinkRank\SEO\LLMs_Txt_Manager();
3540 $manager->serve_llms_txt();
3541 }
3542
3543 /**
3544 * Whether the current request is for /llms.txt.
3545 *
3546 * @since 1.32.0
3547 *
3548 * @return bool
3549 */
3550 private function is_llms_txt_request(): bool {
3551 if (empty($_SERVER['REQUEST_URI'])) {
3552 return false;
3553 }
3554
3555 $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3556 if (!is_string($path) || '' === $path) {
3557 return false;
3558 }
3559
3560 // Strip the install's home path so subdirectory installs match too.
3561 $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3562 if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3563 $path = substr($path, strlen($home_path));
3564 }
3565
3566 return 'llms.txt' === strtolower(trim($path, '/'));
3567 }
3568
3569 /**
3570 * Filter WordPress robots.txt output
3571 *
3572 * @param string $output The default robots.txt output
3573 * @param string $is_public Whether the site is public
3574 * @return string Modified robots.txt content
3575 */
3576 public function filter_robots_txt(string $output, string $is_public): string {
3577 // Only override if Site Identity is enabled and robots.txt management is enabled
3578 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
3579 return $output;
3580 }
3581
3582 $settings = $this->site_identity_manager->get_settings('site');
3583 if (empty($settings['robots_txt_enabled'])) {
3584 return $output;
3585 }
3586
3587 // Serve the effective content (manual textarea edit if present, else
3588 // auto-generated) so the live /robots.txt matches what the admin sees.
3589 try {
3590 $content = $this->site_identity_manager->render_robots_txt();
3591
3592 if (!empty($content)) {
3593 return $content;
3594 }
3595 } catch (\Exception $e) {
3596 // Rendering failed - fall back to default output
3597 }
3598
3599 // Fallback to default output if rendering fails
3600 return $output;
3601 }
3602
3603 /**
3604 * Disable WordPress core's sitemap while ThinkRank's sitemap is enabled.
3605 *
3606 * Prevents the site from publishing two competing sitemap indexes. Core's
3607 * /wp-sitemap.xml is taken offline (it 404s) and, as a consequence, core
3608 * stops adding its own "Sitemap:" directive to robots.txt — including on the
3609 * paths where ThinkRank does not own the robots.txt output.
3610 *
3611 * Only ever turns core's sitemap *off*: when ThinkRank's sitemap is disabled
3612 * the incoming value is returned untouched, so core (or another plugin
3613 * filtering this) keeps whatever behaviour it already had.
3614 *
3615 * @since 1.31.0
3616 *
3617 * @param bool $enabled Whether core's sitemap functionality is enabled.
3618 * @return bool Filtered value.
3619 */
3620 public function filter_wp_sitemaps_enabled($enabled): bool {
3621 return $this->should_disable_core_sitemap() ? false : (bool) $enabled;
3622 }
3623
3624 /**
3625 * Redirect the sitemap URLs core owns to the sitemap ThinkRank publishes.
3626 *
3627 * Only the *index* route is redirected. Core's per-type children
3628 * (/wp-sitemap-posts-post-1.xml and friends) are genuinely gone once core is
3629 * switched off, and a 404 is the honest answer for those; the index is the
3630 * one URL crawlers and humans actually guess, and the one core's own
3631 * /sitemap.xml rule funnels into.
3632 *
3633 * @since 1.31.0
3634 *
3635 * @return void
3636 */
3637 public function redirect_core_sitemap_requests(): void {
3638 if ('index' !== get_query_var('sitemap')) {
3639 return;
3640 }
3641
3642 $request_uri = isset($_SERVER['REQUEST_URI'])
3643 ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI']))
3644 : '';
3645
3646 $target = $this->resolve_core_sitemap_redirect(
3647 (string) wp_parse_url($request_uri, PHP_URL_PATH)
3648 );
3649
3650 if ('' === $target) {
3651 return;
3652 }
3653
3654 wp_safe_redirect($target, 301, 'ThinkRank');
3655 exit;
3656 }
3657
3658 /**
3659 * Where a request for one of core's sitemap URLs should be sent, if anywhere.
3660 *
3661 * Split out from the hook so the rules are testable without dispatching a
3662 * request — the caller above is the only part that cannot be (it exits).
3663 *
3664 * @since 1.31.0
3665 *
3666 * @param string $requested_path Path of the incoming request.
3667 * @return string Absolute URL to redirect to, or '' to leave the request alone.
3668 */
3669 private function resolve_core_sitemap_redirect(string $requested_path): string {
3670 // Nothing to redirect to unless we have actually taken core offline,
3671 // which already implies our own sitemap file is on disk.
3672 if (!$this->should_disable_core_sitemap()) {
3673 return '';
3674 }
3675
3676 $target = $this->thinkrank_sitemap_url;
3677 if ('' === $target) {
3678 return '';
3679 }
3680
3681 // Never redirect a URL to itself. A site publishing at /sitemap.xml
3682 // normally has the web server serve that file before WordPress sees the
3683 // request, but on a setup where the request does reach PHP this is the
3684 // difference between a redirect and a loop.
3685 $destination = (string) wp_parse_url($target, PHP_URL_PATH);
3686
3687 if ('' !== $requested_path && untrailingslashit($requested_path) === untrailingslashit($destination)) {
3688 return '';
3689 }
3690
3691 return $target;
3692 }
3693
3694 /**
3695 * Whether core's sitemap should be switched off for this site.
3696 *
3697 * True when ThinkRank publishes its own sitemap — except in two cases where
3698 * taking core offline would leave a URL answering nothing:
3699 *
3700 * 1. The site is configured to publish *at core's own URL* (the "WordPress
3701 * Core" preset). Once the static file exists the web server serves it
3702 * ahead of WordPress anyway, so core can be left alone.
3703 * 2. ThinkRank's own sitemap file is not on disk yet. Sitemaps here are
3704 * static files with no dynamic route (see save_sitemap_to_file()), so
3705 * while the file is missing core's /sitemap.xml -> /wp-sitemap.xml
3706 * redirect is the only thing answering that URL; suppressing core would
3707 * turn a recoverable "enabled but not generated" state into a hard 404
3708 * for crawlers. Core is taken offline as soon as our file appears, so the
3709 * duplicate-index conflict this filter exists to prevent cannot occur —
3710 * two indexes are only ever reachable if both are actually published.
3711 *
3712 * Once our file does exist, the URLs core stops answering are handed to
3713 * redirect_core_sitemap_requests() rather than left to 404 — which is why
3714 * this also resolves the destination.
3715 *
3716 * Resolved lazily and memoised: this is consulted from an `init`-time filter
3717 * on every request, and the underlying settings read is object-cached. The
3718 * memoisation also keeps the file_exists() call to one per request.
3719 *
3720 * @since 1.31.0
3721 *
3722 * @return bool True when WordPress core's sitemap should be disabled.
3723 */
3724 private function should_disable_core_sitemap(): bool {
3725 if ($this->thinkrank_sitemap_enabled === null) {
3726 try {
3727 // Read-only instance — passing false keeps it from registering a
3728 // second copy of the save_post/term auto-generation hooks.
3729 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3730 $settings = $generator->get_settings('site');
3731
3732 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
3733 && !$this->publishes_at_core_sitemap_url($settings)
3734 && $generator->primary_sitemap_file_exists($settings);
3735
3736 if ($this->thinkrank_sitemap_enabled) {
3737 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
3738 }
3739 } catch (\Exception $e) {
3740 // Settings unreadable — leave core's sitemap alone rather than
3741 // removing a working sitemap on the strength of a failed read.
3742 $this->thinkrank_sitemap_enabled = false;
3743 $this->thinkrank_sitemap_url = '';
3744 }
3745 }
3746
3747 return $this->thinkrank_sitemap_enabled;
3748 }
3749
3750 /**
3751 * Whether any configured sitemap URL is WordPress core's own wp-sitemap.xml.
3752 *
3753 * @since 1.31.0
3754 *
3755 * @param array $settings Sitemap settings.
3756 * @return bool True when the site publishes at core's sitemap URL.
3757 */
3758 private function publishes_at_core_sitemap_url(array $settings): bool {
3759 foreach ((array) ($settings['sitemap_urls'] ?? []) as $sitemap) {
3760 if (!is_array($sitemap)) {
3761 continue;
3762 }
3763
3764 $path = (string) wp_parse_url((string) ($sitemap['url'] ?? ''), PHP_URL_PATH);
3765
3766 if (ltrim($path, '/') === 'wp-sitemap.xml') {
3767 return true;
3768 }
3769 }
3770
3771 return false;
3772 }
3773
3774 /**
3775 * Re-sync the physical robots.txt when WordPress's "Discourage search
3776 * engines" setting (blog_public) changes.
3777 *
3778 * Only acts when ThinkRank robots management is enabled AND a physical
3779 * robots.txt already exists — a stale physical file is the failure being
3780 * fixed. When no file exists the virtual robots_txt filter already reflects
3781 * blog_public live (render_robots_txt() enforces the full block), so there is
3782 * nothing to re-sync and no reason to create a file the user never generated.
3783 *
3784 * @return void
3785 */
3786 public function on_blog_public_changed(): void {
3787 if (!$this->site_identity_manager) {
3788 return;
3789 }
3790
3791 // Gate on robots management (robots_txt_enabled), not the Site Identity
3792 // master toggle: the physical file's lifecycle is governed by that
3793 // setting alone (same as sync_robots_txt_file() and the save endpoint),
3794 // and a stale physical file is served by the web server regardless of the
3795 // master toggle.
3796 $settings = $this->site_identity_manager->get_settings('site');
3797 if (empty($settings['robots_txt_enabled'])) {
3798 return;
3799 }
3800
3801 if (file_exists(ABSPATH . 'robots.txt')) {
3802 $this->site_identity_manager->sync_robots_txt_file();
3803 }
3804 }
3805
3806 /**
3807 * Use the Site Identity favicon as the site icon URL
3808 *
3809 * When a favicon is uploaded in ThinkRank Site Identity it takes
3810 * precedence over the core site icon; with no core icon set this also
3811 * makes has_site_icon() truthy so wp_site_icon() prints the icon tags.
3812 * Reads settings directly (not site_identity_data) because this filter
3813 * also runs in admin, before initialize_current_context().
3814 *
3815 * @param string $url Site icon URL from core
3816 * @param int $size Requested icon size
3817 * @return string Icon URL
3818 */
3819 public function filter_site_icon_url($url, $size = 512): string {
3820 $settings = $this->site_identity_manager->get_settings('site');
3821
3822 if (empty($settings['enabled'])) {
3823 return (string) $url;
3824 }
3825
3826 $size = (int) $size;
3827
3828 // Apple touch icon has its own dedicated setting
3829 if ($size === 180 && !empty($settings['apple_touch_icon_url'])) {
3830 return $this->resolve_icon_url((string) $settings['apple_touch_icon_url'], $size);
3831 }
3832
3833 if (!empty($settings['favicon_url'])) {
3834 return $this->resolve_icon_url((string) $settings['favicon_url'], $size);
3835 }
3836
3837 return (string) $url;
3838 }
3839
3840 /**
3841 * Whether breadcrumb labels should prefer the SEO title.
3842 *
3843 * Off unless the site turns it on, so updating the plugin never rewrites an
3844 * existing trail.
3845 *
3846 * @since 2.3.1
3847 *
3848 * @param array $settings Breadcrumb settings.
3849 * @return bool
3850 */
3851 private function breadcrumbs_use_seo_title(array $settings): bool {
3852 return !empty($settings['breadcrumb_use_seo_title']);
3853 }
3854
3855 /**
3856 * Label for a post in the breadcrumb trail.
3857 *
3858 * With the toggle on, the post's own SEO title wins — the same
3859 * `_thinkrank_seo_title` value (variable tags resolved) the document title
3860 * uses — so the trail under a search snippet reads the same as the snippet
3861 * itself. Anything empty falls back to the raw post title; the global title
3862 * pattern is deliberately NOT part of the chain, since resolving it would
3863 * append the site name to every crumb.
3864 *
3865 * @since 2.3.1
3866 *
3867 * @param int $post_id Post ID.
3868 * @param array $settings Breadcrumb settings.
3869 * @return string Breadcrumb label.
3870 */
3871 private function get_breadcrumb_post_title(int $post_id, array $settings): string {
3872 $title = (string) get_the_title($post_id);
3873
3874 if (!$this->breadcrumbs_use_seo_title($settings)) {
3875 return $title;
3876 }
3877
3878 $seo_title = trim((string) get_post_meta($post_id, '_thinkrank_seo_title', true));
3879
3880 if ('' === $seo_title) {
3881 return $title;
3882 }
3883
3884 $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_value($seo_title, $post_id));
3885
3886 return '' !== $resolved ? $resolved : $title;
3887 }
3888
3889 /**
3890 * Label for a term in the breadcrumb trail.
3891 *
3892 * Term counterpart to {@see self::get_breadcrumb_post_title()}, resolving
3893 * the term's `_thinkrank_seo_title` against its own values.
3894 *
3895 * @since 2.3.1
3896 *
3897 * @param object $term Term object.
3898 * @param array $settings Breadcrumb settings.
3899 * @return string Breadcrumb label.
3900 */
3901 private function get_breadcrumb_term_title($term, array $settings): string {
3902 $name = (string) ($term->name ?? '');
3903
3904 if (!$this->breadcrumbs_use_seo_title($settings) || empty($term->term_id)) {
3905 return $name;
3906 }
3907
3908 $seo_title = trim((string) get_term_meta((int) $term->term_id, '_thinkrank_seo_title', true));
3909
3910 if ('' === $seo_title) {
3911 return $name;
3912 }
3913
3914 $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_term_value($seo_title, (int) $term->term_id));
3915
3916 return '' !== $resolved ? $resolved : $name;
3917 }
3918
3919 /**
3920 * Resolve a configured icon URL to the derivative that fits $size.
3921 *
3922 * wp_site_icon() calls get_site_icon_url() four times — 32, 192, 180 and
3923 * 270 — and pairs the first two with a hardcoded sizes="" attribute. This
3924 * filter used to answer all four with the same configured URL, so one
3925 * upload was declared as every size at once: a 1536x1536 original served
3926 * to paint a 32px tab icon, under a sizes="32x32" label that was simply
3927 * untrue (#571).
3928 *
3929 * Resolution mirrors core's own get_site_icon_url(), including the
3930 * >= 512 -> 'full' branch, so ThinkRank's override and the core pipeline
3931 * pick the same file for the same request.
3932 *
3933 * An unresolvable URL (one hosted off-site) is returned unchanged. Nothing
3934 * is knowable about its dimensions, and suppressing it instead would leave
3935 * the page with no rel="icon" at all — a worse outcome than an approximate
3936 * size hint.
3937 *
3938 * @param string $configured Configured icon URL.
3939 * @param int $size Icon size core is asking for.
3940 * @return string Icon URL for that size.
3941 */
3942 private function resolve_icon_url(string $configured, int $size): string {
3943 $cache_key = md5($configured) . ':' . $size;
3944 $cached = $this->icon_urls();
3945
3946 if (isset($cached[$cache_key])) {
3947 return $cached[$cache_key];
3948 }
3949
3950 $attachment_id = \ThinkRank\SEO\Site_Identity_Manager::icon_attachment_id($configured);
3951
3952 if (!$attachment_id) {
3953 $resolved = esc_url($configured);
3954 } else {
3955 // Mirrors core: at 512 and above the original is what is wanted, and
3956 // asking for an intermediate size that large would only fall back to it.
3957 $size_data = $size >= 512 ? 'full' : [$size, $size];
3958 $url = wp_get_attachment_image_url($attachment_id, $size_data);
3959 $resolved = $url ? esc_url($url) : esc_url($configured);
3960 }
3961
3962 $this->icon_urls[$cache_key] = $resolved;
3963
3964 if (!$this->icon_urls_dirty) {
3965 $this->icon_urls_dirty = true;
3966 // Written once, after the response is assembled, rather than once
3967 // per size: wp_site_icon() resolves four in a row.
3968 add_action('shutdown', [$this, 'persist_icon_urls'], 5);
3969 }
3970
3971 return $resolved;
3972 }
3973
3974 /**
3975 * The resolved-icon-URL map, loaded from its transient on first use.
3976 *
3977 * @return array<string, string>
3978 */
3979 private function icon_urls(): array {
3980 if ($this->icon_urls === null) {
3981 $stored = get_transient(\ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT);
3982 $this->icon_urls = is_array($stored) ? $stored : [];
3983 }
3984
3985 return $this->icon_urls;
3986 }
3987
3988 /**
3989 * Persist newly resolved icon URLs.
3990 *
3991 * Public because it runs on `shutdown`. Invalidated wholesale whenever the
3992 * site identity settings are saved, which is the only moment the icon
3993 * choice — or the derivatives behind it — can change.
3994 *
3995 * @return void
3996 */
3997 public function persist_icon_urls(): void {
3998 if (!$this->icon_urls_dirty || !is_array($this->icon_urls)) {
3999 return;
4000 }
4001
4002 $this->icon_urls_dirty = false;
4003 set_transient(
4004 \ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT,
4005 $this->icon_urls,
4006 DAY_IN_SECONDS
4007 );
4008 }
4009
4010 /**
4011 * Get breadcrumb items for current page
4012 *
4013 * @param array $settings Breadcrumb settings
4014 * @return array Breadcrumb items
4015 */
4016 private function get_breadcrumb_items(array $settings): array {
4017 $items = [];
4018
4019 // Always start with home
4020 $home_text = $settings['breadcrumb_home_text'] ?? 'Home';
4021 $items[] = [
4022 'title' => $home_text,
4023 'url' => home_url(),
4024 'position' => 1
4025 ];
4026
4027 $position = 2;
4028
4029 if (is_single()) {
4030 $current_post_id = get_the_ID();
4031
4032 if ($current_post_id) {
4033 // Add categories for posts
4034 $post_type = get_post_type($current_post_id);
4035 if ($post_type === 'post') {
4036 $categories = get_the_category($current_post_id);
4037 if (!empty($categories)) {
4038 $category = $categories[0];
4039 $items[] = [
4040 'title' => $this->get_breadcrumb_term_title($category, $settings),
4041 'url' => get_category_link($category->term_id),
4042 'position' => $position++
4043 ];
4044 }
4045 }
4046
4047 // Add current post. `empty($x) || $x` is true for every possible
4048 // value — an unset key, false, 0, '' and any truthy value alike —
4049 // so the setting had no effect on the rendered breadcrumb or on
4050 // the BreadcrumbList JSON-LD, while the admin preview honoured it
4051 // and disagreed with live output (#398). Site_Identity_Manager
4052 // already had the correct form: default to on, respect an
4053 // explicit off.
4054 if ($settings['show_current_page'] ?? true) {
4055 $items[] = [
4056 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
4057 'url' => get_permalink($current_post_id),
4058 'position' => $position,
4059 'current' => true
4060 ];
4061 }
4062 }
4063 } elseif (is_page()) {
4064 $current_post_id = get_the_ID();
4065
4066 if ($current_post_id) {
4067 // Add parent pages
4068 $parents = [];
4069 $parent_id = wp_get_post_parent_id($current_post_id);
4070
4071 while ($parent_id) {
4072 $parent = get_post($parent_id);
4073 if ($parent) {
4074 $parents[] = [
4075 'title' => $this->get_breadcrumb_post_title($parent->ID, $settings),
4076 'url' => get_permalink($parent->ID),
4077 'position' => 0 // Will be set later
4078 ];
4079 $parent_id = $parent->post_parent;
4080 } else {
4081 break;
4082 }
4083 }
4084
4085 // Reverse to get correct order
4086 $parents = array_reverse($parents);
4087
4088 // Add parents with correct positions
4089 foreach ($parents as $parent) {
4090 $parent['position'] = $position++;
4091 $items[] = $parent;
4092 }
4093
4094 // Add current page
4095 if ($settings['show_current_page'] ?? true) {
4096 $items[] = [
4097 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
4098 'url' => get_permalink($current_post_id),
4099 'position' => $position,
4100 'current' => true
4101 ];
4102 }
4103 }
4104 } elseif (is_category()) {
4105 $category = get_queried_object();
4106
4107 // Add parent categories
4108 $parents = [];
4109 $parent_id = $category->parent;
4110
4111 while ($parent_id) {
4112 $parent = get_category($parent_id);
4113 if ($parent && !is_wp_error($parent)) {
4114 $parents[] = [
4115 'title' => $this->get_breadcrumb_term_title($parent, $settings),
4116 'url' => get_category_link($parent->term_id),
4117 'position' => 0 // Will be set later
4118 ];
4119 $parent_id = $parent->parent;
4120 } else {
4121 break;
4122 }
4123 }
4124
4125 // Reverse to get correct order
4126 $parents = array_reverse($parents);
4127
4128 // Add parents with correct positions
4129 foreach ($parents as $parent) {
4130 $parent['position'] = $position++;
4131 $items[] = $parent;
4132 }
4133
4134 // Add current category
4135 if ($settings['show_current_page'] ?? true) {
4136 $items[] = [
4137 'title' => $this->get_breadcrumb_term_title($category, $settings),
4138 'url' => get_category_link($category->term_id),
4139 'position' => $position,
4140 'current' => true
4141 ];
4142 }
4143 }
4144
4145 return $items;
4146 }
4147
4148 /**
4149 * Generate breadcrumb HTML
4150 *
4151 * @param array $items Breadcrumb items
4152 * @param array $settings Breadcrumb settings
4153 * @return string HTML output
4154 */
4155 private function generate_breadcrumb_html(array $items, array $settings): string {
4156 if (empty($items)) {
4157 return '';
4158 }
4159
4160 $separator = $settings['breadcrumb_separator'] ?? '>';
4161 $prefix = $settings['breadcrumb_prefix'] ?? '';
4162
4163 $html = '<nav class="thinkrank-breadcrumbs" aria-label="Breadcrumb">';
4164
4165 if (!empty($prefix)) {
4166 $html .= '<span class="breadcrumb-prefix">' . esc_html($prefix) . '</span> ';
4167 }
4168
4169 $html .= '<ol class="breadcrumb-list">';
4170
4171 $total_items = count($items);
4172
4173 foreach ($items as $index => $item) {
4174 $is_last = ($index === $total_items - 1);
4175 $is_current = !empty($item['current']);
4176
4177 $html .= '<li class="breadcrumb-item' . ($is_current ? ' current' : '') . '">';
4178
4179 if (!$is_current && !empty($item['url'])) {
4180 $html .= '<a href="' . esc_url($item['url']) . '">' . esc_html($item['title']) . '</a>';
4181 } else {
4182 $html .= '<span>' . esc_html($item['title']) . '</span>';
4183 }
4184
4185 if (!$is_last) {
4186 $html .= ' <span class="breadcrumb-separator">' . esc_html($separator) . '</span> ';
4187 }
4188
4189 $html .= '</li>';
4190 }
4191
4192 $html .= '</ol>';
4193 $html .= '</nav>';
4194
4195 return $html;
4196 }
4197
4198 /**
4199 * Generate breadcrumb schema markup
4200 *
4201 * @param array $items Breadcrumb items
4202 * @return array Schema data
4203 */
4204 private function generate_breadcrumb_schema(array $items): array {
4205 if (empty($items)) {
4206 return [];
4207 }
4208
4209 $schema_items = [];
4210
4211 foreach ($items as $item) {
4212 $schema_items[] = [
4213 '@type' => 'ListItem',
4214 'position' => $item['position'],
4215 'name' => $item['title'],
4216 'item' => $item['url']
4217 ];
4218 }
4219
4220 return [
4221 '@context' => 'https://schema.org',
4222 '@type' => 'BreadcrumbList',
4223 'itemListElement' => $schema_items
4224 ];
4225 }
4226
4227 /**
4228 * Get Twitter image with proper fallback priority
4229 *
4230 * @since 1.0.0
4231 *
4232 * @return string|null Twitter image URL or null if none available
4233 */
4234 private function get_twitter_image_with_fallback(): ?string {
4235 // Cascade: post-specific Twitter image > post-specific OG image > featured image.
4236 if (is_singular() && $this->current_post_id) {
4237 $post_twitter_image = get_post_meta($this->current_post_id, '_thinkrank_twitter_image', true);
4238 if (!empty($post_twitter_image)) {
4239 return $post_twitter_image;
4240 }
4241
4242 $post_og_image = get_post_meta($this->current_post_id, '_thinkrank_og_image', true);
4243 if (!empty($post_og_image)) {
4244 return $post_og_image;
4245 }
4246
4247 // Check featured image as fallback for posts
4248 if (has_post_thumbnail($this->current_post_id)) {
4249 $featured_image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
4250 if ($featured_image_url) {
4251 return $featured_image_url;
4252 }
4253 }
4254 }
4255
4256 // Check Social Meta Manager settings for Twitter-specific default image
4257 if ($this->social_manager) {
4258 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
4259 $social_settings = $this->social_manager->get_settings($social_context, $this->current_post_id);
4260
4261 // Prioritize Twitter-specific default image
4262 if (!empty($social_settings['default_twitter_image'])) {
4263 return $social_settings['default_twitter_image'];
4264 }
4265
4266 // Fallback to Open Graph default image
4267 if (!empty($social_settings['default_og_image'])) {
4268 return $social_settings['default_og_image'];
4269 }
4270
4271 // Final fallback to generic default image
4272 if (!empty($social_settings['default_image'])) {
4273 return $social_settings['default_image'];
4274 }
4275 }
4276
4277 // Check Site Identity data for social images
4278 if ($this->site_identity_data && !empty($this->site_identity_data['social'])) {
4279 $social_data = $this->site_identity_data['social'];
4280
4281 // Check for any configured social image
4282 if (!empty($social_data['default_image'])) {
4283 return $social_data['default_image'];
4284 }
4285 }
4286
4287 return null;
4288 }
4289 }
4290