PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 53 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.12.0, at includes/frontend/class-seo-manager.php

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