PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 1.0.0 1.0.1 All 51 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.10.0, at includes/frontend/class-seo-manager.php

4,427 lines 174.3 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 // SVGs (and other vector uploads) report 0x0 — emitting
1804 // those as og:image dimensions is invalid, so skip them.
1805 $og_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1806 $og_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1807 if ($og_width > 0 && $og_height > 0) {
1808 echo "<meta property=\"og:image:width\" content=\"" . esc_attr($og_width) . "\" />\n";
1809 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1810 }
1811 // Derive the real mime type instead of hardcoding image/jpeg,
1812 // which mislabels PNG/WebP featured images.
1813 $image_mime = get_post_mime_type($image_id);
1814 if ($image_mime) {
1815 echo "<meta property=\"og:image:type\" content=\"" . esc_attr($image_mime) . "\" />\n";
1816 }
1817 }
1818
1819 // Add image alt text
1820 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', true);
1821 if ($image_alt) {
1822 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1823 }
1824 }
1825
1826 // Alternatives, same as the enhanced emitter above. This path only
1827 // runs when the Social Meta Manager is unavailable, but the issue
1828 // reported against it (#636) and a site that lands here should not
1829 // silently lose a feature it switched on.
1830 if (!empty($primary_og_image)) {
1831 $social_settings = $this->social_manager
1832 ? $this->social_manager->get_settings(
1833 $this->current_context === 'homepage' ? 'site' : $this->current_context,
1834 $this->current_post_id
1835 )
1836 : [];
1837
1838 if (!empty($social_settings['og_multiple_images'])) {
1839 self::output_extra_og_images(
1840 \ThinkRank\SEO\Social_Images::additional(
1841 (int) $this->current_post_id,
1842 $primary_og_image
1843 )
1844 );
1845 }
1846 }
1847
1848 // Add article specific tags for posts only
1849 if ($og_type === 'article') {
1850 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1851 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
1852
1853 // Add author
1854 $author_id = get_post_field('post_author', $this->current_post_id);
1855 $author_name = get_the_author_meta('display_name', $author_id);
1856 echo "<meta property=\"article:author\" content=\"" . esc_attr($author_name) . "\" />\n";
1857
1858 // Add categories as article:section
1859 if (is_single()) {
1860 $categories = get_the_category($this->current_post_id);
1861 if (!empty($categories)) {
1862 echo "<meta property=\"article:section\" content=\"" . esc_attr($categories[0]->name) . "\" />\n";
1863 }
1864 }
1865 }
1866 }
1867 echo "<!-- /ThinkRank SEO Open Graph Meta Tags -->\n";
1868 }
1869
1870 /**
1871 * Output Twitter Card meta tags (HIGH PRIORITY)
1872 * Uses Social Meta Manager with fallback to Site Identity templates
1873 *
1874 * @return void
1875 */
1876 public function output_twitter_card_tags(): void {
1877 // Per-content-type Twitter card switch; see output_open_graph_tags().
1878 if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1879 \ThinkRank\SEO\Content_Type_Settings::FEATURE_TWITTER,
1880 true
1881 )) {
1882 return;
1883 }
1884
1885 // Same reasoning as the Open Graph block: nothing on a 404 is shareable.
1886 if ($this->current_context === '404') {
1887 return;
1888 }
1889
1890 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1891 if ($this->social_manager) {
1892 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1893 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
1894
1895 // Twitter title/description derive from the same content data, so
1896 // pass the effective SEO title and meta description as fallbacks to
1897 // keep a cleared override in step with the document <title>/meta
1898 // description and the metabox preview.
1899 $social_data = $this->social_manager->get_output_data(
1900 $social_context,
1901 $this->current_post_id,
1902 $this->get_effective_seo_title(),
1903 $this->get_meta_description()
1904 );
1905
1906
1907 // The Social Meta Manager ran, so it owns Twitter output. If Twitter
1908 // Cards are toggled off, emit nothing — do NOT fall through to the
1909 // basic emitter (which would re-add twitter:* tags despite the toggle).
1910 if (!empty($social_data['twitter_enabled'])) {
1911 $this->output_social_twitter_tags($social_data['twitter_tags']);
1912 }
1913 return;
1914 }
1915
1916 // Priority 2: Fallback only when the Social Meta Manager is unavailable.
1917 $this->output_basic_twitter_tags();
1918 }
1919
1920 /**
1921 * Output basic Twitter Card tags (fallback implementation)
1922 *
1923 * @return void
1924 */
1925 private function output_basic_twitter_tags(): void {
1926 // Check for per-post Twitter overrides first, then fall through to OG overrides.
1927 $twitter_title_override = '';
1928 $twitter_description_override = '';
1929 $og_title_override = '';
1930 $og_description_override = '';
1931 if (is_singular() && $this->current_post_id) {
1932 // Social fields may hold variable tags entered in the metabox.
1933 $pid = $this->current_post_id;
1934 $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_title', true), $pid);
1935 $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_description', true), $pid);
1936 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_title', true), $pid);
1937 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_description', true), $pid);
1938 } elseif ($this->current_term_id) {
1939 $tid = $this->current_term_id;
1940 $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_title', true), $tid);
1941 $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_description', true), $tid);
1942 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_title', true), $tid);
1943 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_description', true), $tid);
1944 }
1945
1946 // Title cascade: Twitter override > OG override > Global SEO > Site Identity > default
1947 $title = '';
1948 if (!empty($twitter_title_override)) {
1949 $title = $twitter_title_override;
1950 } elseif (!empty($og_title_override)) {
1951 $title = $og_title_override;
1952 } elseif ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
1953 $title = $this->current_metadata['title'];
1954 } else {
1955 $title = $this->generate_context_title();
1956 }
1957 if (!$title) {
1958 $title = is_singular() ? get_the_title() : get_bloginfo('name');
1959 }
1960
1961 // Description cascade: Twitter override > OG override > meta description > excerpt
1962 $description = '';
1963 if (!empty($twitter_description_override)) {
1964 $description = $twitter_description_override;
1965 } elseif (!empty($og_description_override)) {
1966 $description = $og_description_override;
1967 } else {
1968 $description = $this->get_meta_description();
1969 }
1970 // Skipped for a protected post: core answers get_the_excerpt() with its
1971 // "There is no excerpt because this is a protected post." placeholder,
1972 // so this is not a leak — but publishing that sentence as the social
1973 // description is worse than publishing none (#363).
1974 if (!$description && !$this->is_content_password_protected()) {
1975 $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1976 }
1977
1978 // Determine card type based on image availability
1979 $card_type = 'summary';
1980 if (is_singular() && $this->current_post_id && has_post_thumbnail($this->current_post_id)) {
1981 $card_type = 'summary_large_image';
1982 }
1983
1984 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1985 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1986 echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1987 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1988
1989 // Add Twitter image with proper fallback priority
1990 $twitter_image_url = $this->get_twitter_image_with_fallback();
1991 if ($twitter_image_url) {
1992 echo "<meta name=\"twitter:image\" content=\"" . esc_url($twitter_image_url) . "\" />\n";
1993
1994 // Add image alt text for accessibility (if it's a featured image)
1995 if (is_singular() && $this->current_post_id && has_post_thumbnail($this->current_post_id)) {
1996 $featured_image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1997 if ($twitter_image_url === $featured_image_url) {
1998 $image_id = get_post_thumbnail_id($this->current_post_id);
1999 $image_alt = get_post_meta($image_id, '_wp_attachment_image_alt', true);
2000 if ($image_alt) {
2001 echo "<meta name=\"twitter:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
2002 }
2003 }
2004 }
2005 }
2006
2007 // Add site Twitter handle if configured
2008 if ($this->site_identity_data && !empty($this->site_identity_data['social']['twitter_username'])) {
2009 $twitter_handle = $this->site_identity_data['social']['twitter_username'];
2010 // Ensure handle starts with @
2011 if (strpos($twitter_handle, '@') !== 0) {
2012 $twitter_handle = '@' . $twitter_handle;
2013 }
2014 echo "<meta name=\"twitter:site\" content=\"" . esc_attr($twitter_handle) . "\" />\n";
2015 }
2016 echo "<!-- /ThinkRank SEO Twitter Card Meta Tags -->\n";
2017 }
2018
2019 /**
2020 * Output canonical URL
2021 *
2022 * @return void
2023 */
2024 public function output_canonical_url(): void {
2025 $canonical_url = '';
2026
2027 if (is_singular()) {
2028 // Check for custom canonical URL override
2029 if ($this->current_post_id) {
2030 $custom_canonical = get_post_meta($this->current_post_id, '_thinkrank_canonical_url', true);
2031 if (!empty($custom_canonical)) {
2032 $canonical_url = $custom_canonical;
2033 }
2034 }
2035
2036 if (empty($canonical_url)) {
2037 $canonical_url = $this->current_post_id ? get_permalink($this->current_post_id) : get_permalink();
2038
2039 // Core's rel_canonical() keeps the page number; this replaced
2040 // it with a bare permalink, so every <!--nextpage--> sub-page
2041 // and every /comment-page-N/ canonicalised to page 1 — a
2042 // regression against core behaviour (#397). A custom canonical
2043 // is left exactly as the user typed it.
2044 $canonical_url = self::with_singular_page($canonical_url);
2045 }
2046 } else {
2047 $canonical_url = self::get_non_singular_canonical_url();
2048 }
2049
2050 /**
2051 * Filter the canonical URL before output.
2052 *
2053 * @since 1.16.0
2054 *
2055 * @param string $canonical_url Canonical URL ('' suppresses the tag).
2056 */
2057 $canonical_url = apply_filters('thinkrank_canonical_url', $canonical_url);
2058
2059 if (empty($canonical_url)) {
2060 return;
2061 }
2062
2063 // After the filter, so a canonical an add-on supplied is normalized
2064 // too — and a cross-domain one is left alone, since Url_Scheme only
2065 // touches URLs on this site's own host.
2066 $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2067
2068 echo "<!-- ThinkRank SEO Canonical URL -->\n";
2069 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
2070 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
2071
2072 $this->output_pagination_links();
2073 }
2074
2075 /**
2076 * Emit rel="prev" / rel="next" on a paginated archive.
2077 *
2078 * Nothing emitted these at all (#397). Google stopped using them as an
2079 * indexing signal in 2019, so this is not an SEO win with Google — Bing
2080 * still reads them, and they are the standard way to describe a sequence,
2081 * which is what the pages are.
2082 *
2083 * @since 2.0.1
2084 *
2085 * @return void
2086 */
2087 private function output_pagination_links(): void {
2088 // Page 1 still wants a rel="next" when there is a page 2, so only
2089 // singular views are skipped outright.
2090 if (is_singular()) {
2091 return;
2092 }
2093
2094 global $wp_query;
2095
2096 $total = $wp_query ? (int) $wp_query->max_num_pages : 0;
2097
2098 if ($total < 2) {
2099 return;
2100 }
2101
2102 $base = self::get_non_singular_canonical_url();
2103
2104 if ('' === $base) {
2105 return;
2106 }
2107
2108 // get_non_singular_canonical_url() already carries the current page —
2109 // strip it back to page 1 before building the neighbours.
2110 $current = self::current_page_number();
2111 $base = self::without_pagination($base);
2112
2113 if ($current > 1) {
2114 printf(
2115 "<link rel=\"prev\" href=\"%s\" />\n",
2116 esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
2117 );
2118 }
2119
2120 if ($current < $total) {
2121 printf(
2122 "<link rel=\"next\" href=\"%s\" />\n",
2123 esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
2124 );
2125 }
2126 }
2127
2128 /**
2129 * The rewrite base WordPress uses for page numbers ('page' by default).
2130 *
2131 * @since 2.0.1
2132 *
2133 * @return string
2134 */
2135 private static function pagination_base(): string {
2136 global $wp_rewrite;
2137
2138 return $wp_rewrite && $wp_rewrite->pagination_base ? $wp_rewrite->pagination_base : 'page';
2139 }
2140
2141 /**
2142 * Append the sub-page or comment-page number to a singular canonical.
2143 *
2144 * @since 2.0.1
2145 *
2146 * @param string $url Permalink.
2147 * @return string Permalink with the current page appended, when there is one.
2148 */
2149 public static function with_singular_page(string $url): string {
2150 global $wp_rewrite;
2151
2152 $page = (int) get_query_var('page');
2153
2154 if ($page > 1) {
2155 return $wp_rewrite && $wp_rewrite->using_permalinks()
2156 ? trailingslashit($url) . user_trailingslashit($page, 'single_paged')
2157 : add_query_arg('page', $page, $url);
2158 }
2159
2160 $comment_page = (int) get_query_var('cpage');
2161
2162 if ($comment_page > 1) {
2163 return get_comments_pagenum_link($comment_page);
2164 }
2165
2166 return $url;
2167 }
2168
2169 /**
2170 * Build the canonical URL for non-singular contexts.
2171 *
2172 * Covers the blog home, post type / taxonomy / author / date archives.
2173 * Search results and 404 pages get no canonical (they are noindexed).
2174 * Paginated archives canonicalize to their own page URL so page 2+ is
2175 * self-referential rather than pointing at page 1.
2176 *
2177 * @return string Canonical URL or '' when none applies
2178 */
2179 public static function get_non_singular_canonical_url(): string {
2180 if (is_404() || is_search()) {
2181 return '';
2182 }
2183
2184 $canonical_url = '';
2185
2186 if (is_front_page() || is_home()) {
2187 $canonical_url = is_home() && !is_front_page()
2188 ? (string) get_permalink((int) get_option('page_for_posts'))
2189 : home_url('/');
2190 } elseif (is_post_type_archive()) {
2191 $canonical_url = (string) get_post_type_archive_link((string) get_query_var('post_type'));
2192 } elseif (is_category() || is_tag() || is_tax()) {
2193 $term_link = get_term_link(get_queried_object());
2194 $canonical_url = is_wp_error($term_link) ? '' : $term_link;
2195 } elseif (is_author()) {
2196 $canonical_url = get_author_posts_url((int) get_queried_object_id());
2197 } elseif (is_date()) {
2198 if (is_day()) {
2199 $canonical_url = get_day_link((int) get_query_var('year'), (int) get_query_var('monthnum'), (int) get_query_var('day'));
2200 } elseif (is_month()) {
2201 $canonical_url = get_month_link((int) get_query_var('year'), (int) get_query_var('monthnum'));
2202 } elseif (is_year()) {
2203 $canonical_url = get_year_link((int) get_query_var('year'));
2204 }
2205 }
2206
2207 if (empty($canonical_url)) {
2208 return '';
2209 }
2210
2211 // Point paginated archives at their own page, not page 1.
2212 return self::with_pagination($canonical_url, (int) get_query_var('paged'));
2213 }
2214
2215 /**
2216 * Append a page number to a URL the way WordPress does.
2217 *
2218 * Extracted so the archive canonical is not the only thing that knows how
2219 * to build a paged URL: the schema graph derived its @id from the
2220 * un-paginated link, so every page of an archive claimed the same node
2221 * identity, and the singular canonical dropped the page entirely (#397).
2222 *
2223 * @since 2.0.1
2224 *
2225 * @param string $url Base URL.
2226 * @param int $page Page number; 1 or less returns the URL unchanged.
2227 * @return string
2228 */
2229 public static function with_pagination(string $url, int $page): string {
2230 if ($page <= 1 || '' === $url) {
2231 return $url;
2232 }
2233
2234 global $wp_rewrite;
2235
2236 if ($wp_rewrite && $wp_rewrite->using_permalinks()) {
2237 return trailingslashit($url) . user_trailingslashit(
2238 $wp_rewrite->pagination_base . '/' . $page,
2239 'paged'
2240 );
2241 }
2242
2243 return add_query_arg('paged', $page, $url);
2244 }
2245
2246 /**
2247 * Strip a page number from a URL, whichever form it takes.
2248 *
2249 * The inverse of with_pagination(). Pretty permalinks carry the page as a
2250 * /page/N/ path segment, plain permalinks as a `paged` query arg, and a
2251 * regex over the path alone silently left the latter in place — so
2252 * rel="prev" on page 2 pointed at page 2 (#397 review).
2253 *
2254 * @since 2.0.1
2255 *
2256 * @param string $url URL that may carry a page number.
2257 * @return string URL for page 1.
2258 */
2259 public static function without_pagination(string $url): string {
2260 if ('' === $url) {
2261 return $url;
2262 }
2263
2264 $url = remove_query_arg('paged', $url);
2265
2266 return (string) preg_replace(
2267 '#/' . preg_quote(self::pagination_base(), '#') . '/\d+/?$#',
2268 '/',
2269 $url
2270 );
2271 }
2272
2273 /**
2274 * The page number of the current request, archive or multi-page post.
2275 *
2276 * `paged` counts archive pages; `page` counts the <!--nextpage--> parts of
2277 * a single post. They are never both set.
2278 *
2279 * @since 2.0.1
2280 *
2281 * @return int Page number, 1 when this is the first page.
2282 */
2283 public static function current_page_number(): int {
2284 $paged = (int) get_query_var('paged');
2285
2286 if ($paged > 1) {
2287 return $paged;
2288 }
2289
2290 $page = (int) get_query_var('page');
2291
2292 return $page > 1 ? $page : 1;
2293 }
2294
2295
2296 /**
2297 * Check if ThinkRank has metadata for current post
2298 *
2299 * @return bool True if has ThinkRank metadata
2300 */
2301 private function has_thinkrank_metadata(): bool {
2302 // Populated by initialize_current_context() for singular views and for
2303 // term archives, and left empty everywhere else — so the emptiness
2304 // check is the whole test. The `!is_singular()` early return this
2305 // replaced is what made every stored term title and description inert:
2306 // the entire title/description cascade hangs off this method (#386).
2307 return !empty($this->current_metadata['title']) || !empty($this->current_metadata['description']);
2308 }
2309
2310 /**
2311 * Check if current page has SEO data (public method for template functions)
2312 *
2313 * @return bool True if has SEO data
2314 */
2315 public function has_seo_data(): bool {
2316 // Check if Site Identity is enabled and active
2317 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
2318 return true;
2319 }
2320
2321 // Check if post has ThinkRank metadata
2322 return $this->has_thinkrank_metadata();
2323 }
2324
2325 /**
2326 * Get current breadcrumbs data (public method for template functions)
2327 *
2328 * @return array|null Breadcrumb data or null if not available
2329 */
2330 public function get_current_breadcrumbs(): ?array {
2331 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2332 return null;
2333 }
2334
2335 $settings = $this->site_identity_manager->get_settings('site');
2336
2337 if (empty($settings['breadcrumbs_enabled'])) {
2338 return null;
2339 }
2340
2341 return $this->generate_breadcrumbs($settings);
2342 }
2343
2344 /**
2345 * Get current SEO metadata
2346 *
2347 * @return array Current metadata
2348 */
2349 public function get_current_metadata(): array {
2350 return $this->current_metadata;
2351 }
2352
2353 /**
2354 * Generate title based on current context using Site Identity templates
2355 *
2356 * @return string|null Generated title or null if no template available
2357 */
2358 private function generate_context_title(): ?string {
2359 // Priority 1: Try Global SEO settings for current post type
2360 $global_seo_title = $this->get_global_seo_title();
2361 if ($global_seo_title) {
2362 return $global_seo_title;
2363 }
2364
2365 // Priority 2: Fall back to Site Identity templates
2366 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2367 return null;
2368 }
2369
2370 $template = $this->get_title_template_for_context();
2371 if (!$template) {
2372 return null;
2373 }
2374
2375 $placeholders = $this->get_title_placeholders();
2376 return $this->process_title_template($template, $placeholders);
2377 }
2378
2379 /**
2380 * Get title from Global SEO settings for current post type
2381 *
2382 * @return string|null Generated title or null if no Global SEO template available
2383 */
2384 private function get_global_seo_title(): ?string {
2385 // Only apply Global SEO to singular posts/pages
2386 if (!is_singular()) {
2387 return null;
2388 }
2389
2390 $post_type = get_post_type();
2391 if (!$post_type) {
2392 return null;
2393 }
2394
2395 // Get Global SEO settings for this post type
2396 $global_seo_settings = $this->get_global_seo_settings($post_type);
2397 if (empty($global_seo_settings['title'])) {
2398 return null;
2399 }
2400
2401 $template = $global_seo_settings['title'];
2402 $placeholders = $this->get_global_seo_placeholders();
2403
2404 return $this->process_global_seo_template($template, $placeholders);
2405 }
2406
2407 /**
2408 * Get Global SEO settings for a post type
2409 *
2410 * @param string $post_type Post type slug
2411 * @return array Global SEO settings or empty array
2412 */
2413 private function get_global_seo_settings(string $post_type): array {
2414 $all_settings = get_option('thinkrank_global_seo_settings', []);
2415 return $all_settings[$post_type] ?? [];
2416 }
2417
2418 /**
2419 * Get placeholders for Global SEO template processing
2420 *
2421 * @return array Placeholder values
2422 */
2423 private function get_global_seo_placeholders(): array {
2424 $placeholders = [
2425 '%title%' => '',
2426 '%sitename%' => get_bloginfo('name'),
2427 '%sep%' => $this->get_global_seo_separator(),
2428 '%excerpt%' => '',
2429 '%date%' => get_the_date(),
2430 '%modified%' => get_the_modified_date(),
2431 '%author%' => '',
2432 '%category%' => '',
2433 ];
2434
2435 // Get current post data if available
2436 if ($this->current_post_id) {
2437 $placeholders['%title%'] = get_the_title($this->current_post_id);
2438
2439 // Get excerpt
2440 $post = get_post($this->current_post_id);
2441 if ($post) {
2442 // An authored post_excerpt is written for public consumption, so
2443 // it stays. Falling back to the body does not: for a protected
2444 // post that derivation leaks the gated content through any
2445 // template containing %excerpt%, and this branch runs BEFORE the
2446 // derive-from-content priority below, so guarding only that one
2447 // would leave this path open (#363).
2448 if (!empty($post->post_excerpt)) {
2449 $placeholders['%excerpt%'] = $post->post_excerpt;
2450 } elseif (!$this->is_content_password_protected($post->ID)) {
2451 $placeholders['%excerpt%'] = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
2452 \ThinkRank\SEO\Builder_Content::visible_content($post)
2453 );
2454 }
2455 }
2456
2457 // Get author
2458 $author_id = get_post_field('post_author', $this->current_post_id);
2459 $placeholders['%author%'] = get_the_author_meta('display_name', $author_id);
2460
2461 // Get category (for posts)
2462 if (get_post_type($this->current_post_id) === 'post') {
2463 $categories = get_the_category($this->current_post_id);
2464 $placeholders['%category%'] = !empty($categories) ? $categories[0]->name : '';
2465 }
2466 }
2467
2468 return $placeholders;
2469 }
2470
2471 /**
2472 * Get separator for Global SEO title
2473 *
2474 * @return string Separator symbol
2475 */
2476 public function get_global_seo_separator(): string {
2477 return \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol();
2478 }
2479
2480 /**
2481 * Process Global SEO template with placeholders
2482 *
2483 * @param string $template Template string with variables
2484 * @param array $placeholders Placeholder values
2485 * @return string Processed title
2486 */
2487 private function process_global_seo_template(string $template, array $placeholders): string {
2488 // Replace all placeholders
2489 $title = str_replace(array_keys($placeholders), array_values($placeholders), $template);
2490
2491 // Clean up multiple spaces
2492 $title = preg_replace('/\s+/', ' ', $title);
2493 $title = trim($title);
2494
2495 // Clean up multiple separators (e.g., "| |" becomes "|")
2496 $separator = $placeholders['%sep%'] ?? '|';
2497 $separator_pattern = preg_quote($separator, '/');
2498 $title = preg_replace('/\s*' . $separator_pattern . '\s*' . $separator_pattern . '\s*/', ' ' . $separator . ' ', $title);
2499
2500 // Remove leading/trailing separators
2501 $title = trim($title, " \t\n\r\0\x0B" . $separator);
2502
2503 return $title;
2504 }
2505
2506 /**
2507 * Get title template for current context
2508 *
2509 * @return string|null Template string or null if not found
2510 */
2511 private function get_title_template_for_context(): ?string {
2512 $settings = $this->site_identity_manager->get_settings('site');
2513
2514 switch ($this->current_context) {
2515 case 'homepage':
2516 // detect_current_context() collapses the static posts page into
2517 // 'homepage', so it rendered the front page's title template and
2518 // the two pages shipped the same <title> — a duplicate title on
2519 // the site's two most-linked URLs (#397 review). It is a page,
2520 // and it has its own name, so it gets the page template.
2521 if (self::is_static_posts_page()) {
2522 return $settings['page_title'] ?? $settings['homepage_title'] ?? null;
2523 }
2524
2525 return $settings['homepage_title'] ?? null;
2526 case 'post':
2527 return $settings['post_title'] ?? null;
2528 case 'page':
2529 return $settings['page_title'] ?? null;
2530 case 'category':
2531 return $settings['category_title'] ?? null;
2532 case 'tag':
2533 return $settings['tag_title'] ?? null;
2534 case 'author':
2535 return $settings['author_title'] ?? null;
2536 case 'search':
2537 return $settings['search_title'] ?? null;
2538 case 'archive':
2539 return $settings['archive_title'] ?? null;
2540 default:
2541 return null;
2542 }
2543 }
2544
2545 /**
2546 * Get title placeholders for current context
2547 *
2548 * @return array Placeholder values
2549 */
2550 private function get_title_placeholders(): array {
2551 global $post, $wp_query;
2552
2553 $settings = $this->site_identity_manager->get_settings('site');
2554 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
2555
2556 $placeholders = [
2557 // first_non_empty(), not `??`: Site Identity persists these as ''
2558 // rather than leaving them unset, and '' is not null — so the
2559 // null-coalesce stopped dead on the empty string and the WordPress
2560 // fallback was unreachable. A site with a tagline set in Settings →
2561 // General rendered "%site_description%" as nothing (#398). This is
2562 // the same reasoning first_non_empty()'s own docblock records.
2563 '%site_title%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2564 '%site_name%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2565 '%site_description%' => $this->first_non_empty($settings['site_description'] ?? '', get_bloginfo('description')),
2566 '%tagline%' => $this->first_non_empty($settings['tagline'] ?? '', get_bloginfo('description')),
2567 '%separator%' => ' ' . $separator . ' ',
2568 '%sep%' => ' ' . $separator . ' ',
2569 '%date%' => gmdate('F Y'),
2570 ];
2571
2572 // Context-specific placeholders
2573 switch ($this->current_context) {
2574 case 'post':
2575 case 'page':
2576 if ($this->current_post_id) {
2577 $placeholders['%post_title%'] = get_the_title($this->current_post_id);
2578 $placeholders['%page_title%'] = get_the_title($this->current_post_id);
2579 $post_author = get_post_field('post_author', $this->current_post_id);
2580 $placeholders['%author%'] = get_the_author_meta('display_name', $post_author);
2581 $placeholders['%author_name%'] = get_the_author_meta('display_name', $post_author);
2582
2583 // Get categories for posts
2584 $post_type = get_post_type($this->current_post_id);
2585 if ($post_type === 'post') {
2586 $categories = get_the_category($this->current_post_id);
2587 $placeholders['%category%'] = !empty($categories) ? $categories[0]->name : '';
2588 }
2589 }
2590 break;
2591
2592 case 'category':
2593 $category = get_queried_object();
2594 if ($category) {
2595 $placeholders['%category_title%'] = $category->name;
2596 $placeholders['%category%'] = $category->name;
2597 }
2598 break;
2599
2600 case 'tag':
2601 $tag = get_queried_object();
2602 if ($tag) {
2603 $placeholders['%tag_title%'] = $tag->name;
2604 $placeholders['%tag%'] = $tag->name;
2605 }
2606 break;
2607
2608 case 'author':
2609 $author = get_queried_object();
2610 if ($author) {
2611 $placeholders['%author_name%'] = $author->display_name;
2612 $placeholders['%author%'] = $author->display_name;
2613 }
2614 break;
2615
2616 case 'search':
2617 $placeholders['%search_term%'] = get_search_query();
2618 break;
2619
2620 case 'archive':
2621 // Stripped: get_the_archive_title() wraps its subject in a
2622 // <span>, and this placeholder feeds the document <title> as
2623 // well as og:title and twitter:title — a date archive rendered
2624 // as "Month: <span>August 2026</span> | Site".
2625 $placeholders['%archive_title%'] = self::archive_subject();
2626 break;
2627
2628 case 'homepage':
2629 // The page template resolved for a static posts page needs the
2630 // page's own name; without it %title%/%page_title% would render
2631 // empty and collapse back to the site title.
2632 if (self::is_static_posts_page()) {
2633 $posts_page_title = get_the_title((int) get_option('page_for_posts'));
2634 $placeholders['%title%'] = $posts_page_title;
2635 $placeholders['%page_title%'] = $posts_page_title;
2636 $placeholders['%post_title%'] = $posts_page_title;
2637 }
2638 break;
2639 }
2640
2641 return $placeholders;
2642 }
2643
2644 /**
2645 * Whether this request is a static posts page rather than the front page.
2646 *
2647 * @since 2.0.1
2648 *
2649 * @return bool
2650 */
2651 private static function is_static_posts_page(): bool {
2652 return is_home() && !is_front_page() && (int) get_option('page_for_posts') > 0;
2653 }
2654
2655 /**
2656 * Process title template with placeholders
2657 *
2658 * @param string $template Template string
2659 * @param array $placeholders Placeholder values
2660 * @return string Processed title
2661 */
2662 private function process_title_template(string $template, array $placeholders): string {
2663 $title = str_replace(array_keys($placeholders), array_values($placeholders), $template);
2664
2665 // Clean up multiple separators and extra spaces
2666 $separator = $placeholders['%separator%'] ?? ' | ';
2667
2668 // Legacy templates stored a literal pipe as separator — apply the active separator to them
2669 $title = preg_replace('/\s*\|\s*/', $separator, $title);
2670 $title = preg_replace('/\s*' . preg_quote(trim($separator), '/') . '\s*' . preg_quote(trim($separator), '/') . '\s*/', $separator, $title);
2671 $title = preg_replace('/\s+/', ' ', $title);
2672 $title = trim($title);
2673
2674 // Remove trailing separator
2675 $separator_trimmed = trim($separator);
2676 if (substr($title, -strlen($separator_trimmed)) === $separator_trimmed) {
2677 $title = trim(substr($title, 0, -strlen($separator_trimmed)));
2678 }
2679
2680 return $title;
2681 }
2682
2683 /**
2684 * Get title separator symbol
2685 *
2686 * @param string $separator_type Separator type
2687 * @return string Separator symbol
2688 */
2689 private function get_title_separator(string $separator_type): string {
2690 return \ThinkRank\SEO\Site_Identity_Manager::$title_separators[$separator_type]['symbol'] ?? \ThinkRank\SEO\Site_Identity_Manager::$title_separators['pipe']['symbol'];
2691 }
2692
2693 /**
2694 * Whether a post's body must not be read for a public surface.
2695 *
2696 * Deriving metadata from `post_content` publishes that content to everyone
2697 * who requests the URL — and to every crawler and link-preview unfurler
2698 * that reads og:description — while the page itself still shows only the
2699 * password form, so the leak is invisible to the site owner (#363).
2700 *
2701 * This is the one thing every content reader should call before touching
2702 * `post_content` for output. It mirrors core: a visitor who has already
2703 * entered the correct password sees the body anyway, so nothing is hidden
2704 * from them here either.
2705 *
2706 * @param int|null $post_id Optional. Post ID. Defaults to the current post.
2707 * @return bool True when the body is password-gated for this visitor.
2708 */
2709 private function is_content_password_protected(?int $post_id = null): bool {
2710 $post_id = $post_id ?? $this->current_post_id;
2711
2712 if (!$post_id) {
2713 return false;
2714 }
2715
2716 $post = get_post($post_id);
2717
2718 if (!$post) {
2719 return false;
2720 }
2721
2722 // Guarded for the same reason the schema path guards it: this class is
2723 // also exercised outside a full front-end request.
2724 return function_exists('post_password_required') && post_password_required($post);
2725 }
2726
2727 /**
2728 * Get meta description with fallback system
2729 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
2730 *
2731 * @return string|null Meta description or null if none available
2732 */
2733 private function get_meta_description(): ?string {
2734 // First priority: Post-specific ThinkRank metadata
2735 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['description'])) {
2736 return $this->current_metadata['description'];
2737 }
2738
2739 // Second priority: Global SEO description template
2740 $global_seo_description = $this->get_global_seo_description();
2741 if ($global_seo_description) {
2742 return $global_seo_description;
2743 }
2744
2745 // Archive contexts: derive the description from the archive itself
2746 // (term description, post type description, author bio)
2747 $archive_description = $this->get_archive_meta_description();
2748 if ($archive_description) {
2749 return $archive_description;
2750 }
2751
2752 // Third priority: Site Identity default meta description
2753 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
2754 $settings = $this->site_identity_manager->get_settings('site');
2755 $default_description = $settings['default_meta_description'] ?? '';
2756
2757 if (!empty($default_description)) {
2758 return $default_description;
2759 }
2760 }
2761
2762 // Fourth priority: Generate from content for posts/pages.
2763 // Never for a password-protected post — deriving the description from a
2764 // gated body published its first ~25 words in the page head, and the
2765 // same value is reused for og:description and twitter:description, so
2766 // one unguarded read leaked through three tags (#363).
2767 if (is_singular() && $this->current_post_id && !$this->is_content_password_protected()) {
2768 // Not the raw column: a Bricks page discards `post_content`, so
2769 // whatever is still stored there is invisible — and this one value
2770 // becomes the meta, og: and twitter: descriptions (#651).
2771 $described = get_post($this->current_post_id);
2772 $post_content = $described instanceof \WP_Post
2773 ? \ThinkRank\SEO\Builder_Content::visible_content($described)
2774 : get_post_field('post_content', $this->current_post_id);
2775 if ($post_content) {
2776 $excerpt = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post_content);
2777 if (!empty($excerpt)) {
2778 return $excerpt;
2779 }
2780 }
2781 }
2782
2783 // Fifth priority: Site description for homepage
2784 if (is_home() || is_front_page()) {
2785 $site_description = get_bloginfo('description');
2786 if (!empty($site_description)) {
2787 return $site_description;
2788 }
2789 }
2790
2791 return null;
2792 }
2793
2794 /**
2795 * Get a meta description for archive contexts.
2796 *
2797 * Post type archives use the post type's description, taxonomy archives
2798 * the term description, author archives the author bio. Returns null for
2799 * non-archive contexts so the regular fallback chain continues.
2800 *
2801 * @return string|null Archive description or null when not applicable
2802 */
2803 private function get_archive_meta_description(): ?string {
2804 $description = '';
2805
2806 // Author archives are intentionally excluded — Author_Archives_Manager
2807 // outputs its own template-based meta description on wp_head.
2808 if (is_post_type_archive()) {
2809 $post_type_object = get_queried_object();
2810 if ($post_type_object instanceof \WP_Post_Type && !empty($post_type_object->description)) {
2811 $description = $post_type_object->description;
2812 }
2813 } elseif (is_category() || is_tag() || is_tax()) {
2814 $description = term_description() ?: '';
2815 }
2816
2817 $description = trim(wp_strip_all_tags((string) $description));
2818 if ($description === '') {
2819 return null;
2820 }
2821
2822 // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2823 // CJK description tripped this limit at a third of its length, and
2824 // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2825 // English, 25 characters in Thai (#687).
2826 $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2827
2828 return $description;
2829 }
2830
2831 /**
2832 * Get description from Global SEO settings for current post type
2833 *
2834 * @return string|null Generated description or null if no Global SEO template available
2835 */
2836 private function get_global_seo_description(): ?string {
2837 // Only apply Global SEO to singular posts/pages
2838 if (!is_singular()) {
2839 return null;
2840 }
2841
2842 $post_type = get_post_type();
2843 if (!$post_type) {
2844 return null;
2845 }
2846
2847 // Get Global SEO settings for this post type
2848 $global_seo_settings = $this->get_global_seo_settings($post_type);
2849 if (empty($global_seo_settings['description'])) {
2850 return null;
2851 }
2852
2853 $template = $global_seo_settings['description'];
2854 $placeholders = $this->get_global_seo_placeholders();
2855
2856 return $this->process_global_seo_description_template($template, $placeholders);
2857 }
2858
2859 /**
2860 * Process Global SEO description template with placeholders
2861 *
2862 * @param string $template Template string with variables
2863 * @param array $placeholders Placeholder values
2864 * @return string Processed description
2865 */
2866 private function process_global_seo_description_template(string $template, array $placeholders): string {
2867 // Replace all placeholders
2868 $description = str_replace(array_keys($placeholders), array_values($placeholders), $template);
2869
2870 // Clean up multiple spaces
2871 $description = preg_replace('/\s+/', ' ', $description);
2872 $description = trim($description);
2873
2874 // Ensure description doesn't exceed recommended length (160 characters)
2875 // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2876 // CJK description tripped this limit at a third of its length, and
2877 // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2878 // English, 25 characters in Thai (#687).
2879 $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2880
2881 return $description;
2882 }
2883
2884 /**
2885 * Output site-wide schema markup with priority system
2886 *
2887 * Priority: Schema Manager > Site Identity (like Twitter Cards approach)
2888 *
2889 * @return void
2890 */
2891 public function output_site_schema_markup(): void {
2892 $has_schema_manager_output = false;
2893 $has_website_schema = false;
2894
2895 // The master switch on Essential SEO -> Schema Manager. Until #461 this
2896 // was never read here, so turning schema off left every deployed entity
2897 // on the page. Read it once and bail before touching the graph.
2898 if ($this->schema_manager) {
2899 $schema_settings = $this->schema_manager->get_settings('site', null);
2900
2901 if (isset($schema_settings['enabled']) && !$schema_settings['enabled']) {
2902 return;
2903 }
2904 }
2905
2906 // PRIORITY 1: Always output site-wide schemas (Organization, Website, LocalBusiness, Person)
2907 if ($this->schema_manager) {
2908 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
2909
2910 if (!empty($site_wide_schemas)) {
2911 foreach ($site_wide_schemas as $schema_type => $schema_info) {
2912 Schema_Graph::instance()->add_supporting($schema_info['data'], (string) $schema_type);
2913 }
2914 $has_schema_manager_output = true;
2915 $has_website_schema = isset($site_wide_schemas['WebSite']);
2916 }
2917 }
2918
2919 // The homepage always gets a WebSite schema (with a SearchAction) so
2920 // search engines can associate the site name and sitelinks searchbox —
2921 // unless the Schema Manager already deployed one.
2922 if ((is_front_page() || is_home()) && !$has_website_schema) {
2923 $website_schema = $this->generate_website_schema();
2924
2925 /**
2926 * Filter the default homepage WebSite schema before output.
2927 *
2928 * @since 1.16.0
2929 *
2930 * @param array $website_schema WebSite schema array ([] suppresses output).
2931 */
2932 $website_schema = apply_filters('thinkrank_website_schema', $website_schema);
2933
2934 if (!empty($website_schema)) {
2935 Schema_Graph::instance()->add_supporting($website_schema, 'WebSite');
2936 }
2937 }
2938
2939 // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages
2940 if ($this->schema_manager && (is_single() || is_page())) {
2941 $context_id = get_the_ID();
2942 $context_type = get_post_type( $context_id );
2943 $context_type = in_array( $context_type, [ 'site', 'post', 'page', 'product' ] , true) ? $context_type : 'post';
2944
2945 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2946
2947 if (!empty($page_specific_schemas)) {
2948 // Every deployed schema is rendered, on every plan. How many a
2949 // page carries is decided when schemas are activated in the
2950 // editor, not trimmed here by plan (#673).
2951
2952 /**
2953 * Filter the page-specific schemas rendered on the current page.
2954 *
2955 * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2956 * @param string $context_type Context type (post, page, product, site).
2957 * @param int $context_id Post ID.
2958 */
2959 $page_specific_schemas = apply_filters(
2960 'thinkrank_page_schemas_to_render',
2961 $page_specific_schemas,
2962 $context_type,
2963 $context_id
2964 );
2965
2966 // A deployed node is a snapshot from Deploy time and outranks
2967 // the automatic node, so page and article types would publish
2968 // a frozen excerpt instead of the description the head
2969 // resolves. Give them the live one, as the automatic node has.
2970 $context_post = get_post($context_id);
2971
2972 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2973 $node = $schema_info['data'];
2974
2975 if ($this->global_seo_schema && $context_post instanceof \WP_Post) {
2976 $node = $this->global_seo_schema->refresh_deployed_description($node, (string) $schema_type, $context_post);
2977 }
2978
2979 Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager');
2980 }
2981 $has_schema_manager_output = true;
2982 }
2983 }
2984
2985 // Absorb FAQ content from the post body (FAQ block / Elementor widget)
2986 // so it merges into the graph's single FAQPage instead of each producer
2987 // emitting its own competing one.
2988 if (is_singular()) {
2989 $queried_post = get_post();
2990 if ($queried_post instanceof \WP_Post) {
2991 Schema_Graph::instance()->collect_post_faq($queried_post);
2992 }
2993 }
2994
2995 // Skip Site Identity fallback if any Schema Manager schemas were output
2996 if ($has_schema_manager_output) {
2997 return;
2998 }
2999
3000 // PRIORITY 2: Fall back to Site Identity schemas (like basic Twitter Cards)
3001 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
3002 return;
3003 }
3004
3005 $settings = $this->site_identity_manager->get_settings('site');
3006
3007 // Only output on homepage or if organization schema is enabled
3008 if (!is_home() && !is_front_page() && empty($settings['organization_schema'])) {
3009 return;
3010 }
3011
3012 $schema = $this->generate_organization_schema($settings);
3013
3014 if ($schema) {
3015 Schema_Graph::instance()->add_supporting($schema, 'Organization');
3016 }
3017 }
3018
3019 /**
3020 * Generate the default WebSite schema for the homepage.
3021 *
3022 * Includes a SearchAction potentialAction so search engines can surface a
3023 * sitelinks searchbox, mirroring what Rank Math/Yoast output by default.
3024 *
3025 * @return array WebSite schema
3026 */
3027 private function generate_website_schema(): array {
3028 $settings = $this->site_identity_manager ? $this->site_identity_manager->get_settings('site') : [];
3029
3030 $schema = [
3031 '@context' => 'https://schema.org',
3032 '@type' => 'WebSite',
3033 '@id' => home_url('/#website'),
3034 'name' => !empty($settings['site_name']) ? $settings['site_name'] : get_bloginfo('name'),
3035 'url' => home_url('/'),
3036 ];
3037
3038 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
3039 // The tagline is stored esc_html()'d by sanitize_option(), so a site
3040 // called "Fish & Chips" published `&amp;` literally in its WebSite
3041 // node; nothing decodes JSON-LD downstream.
3042 $description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $description);
3043 if (!empty($description)) {
3044 $schema['description'] = $description;
3045 }
3046
3047 // Site Identity has accepted an alternate name since the setup wizard
3048 // shipped, and the MCP ability describes it as "published as schema
3049 // alternateName" — but no producer ever read it, so the promise was
3050 // false and every imported Yoast/Rank Math value sat unused (#692).
3051 $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3052 if (null !== $alternate_name) {
3053 $schema['alternateName'] = $alternate_name;
3054 }
3055
3056 // The sitelinks searchbox switch was honoured only for a deployed
3057 // WebSite row; this live fallback added potentialAction unconditionally,
3058 // so website_enable_search = 0 still shipped the SearchAction (#688).
3059 // Absent means not configured, which stays enabled.
3060 $search_enabled = true;
3061 if ($this->schema_manager) {
3062 $schema_settings = $this->schema_manager->get_settings('site', null);
3063
3064 if (array_key_exists('website_enable_search', $schema_settings)) {
3065 $search_enabled = !empty($schema_settings['website_enable_search']);
3066 }
3067 }
3068
3069 if ($search_enabled) {
3070 $schema['potentialAction'] = [
3071 '@type' => 'SearchAction',
3072 'target' => [
3073 '@type' => 'EntryPoint',
3074 'urlTemplate' => home_url('/?s={search_term_string}'),
3075 ],
3076 'query-input' => 'required name=search_term_string',
3077 ];
3078 }
3079
3080 return $schema;
3081 }
3082
3083 /**
3084 * Generate organization schema markup
3085 *
3086 * Priority: Schema Manager organization settings > Site Identity settings
3087 *
3088 * @param array $settings Site identity settings (used as fallback)
3089 * @return array|null Schema data or null if insufficient data
3090 */
3091 private function generate_organization_schema(array $settings): ?array {
3092 // PRIORITY 1: Get Schema Manager organization settings
3093 $schema_settings = [];
3094 if ($this->schema_manager) {
3095 $schema_settings = $this->schema_manager->get_settings('site', null);
3096 }
3097
3098 // Determine organization values (Schema Manager > Site Identity > WordPress default).
3099 // Use first_non_empty() rather than ??: these settings keys are always present
3100 // and default to an empty string, so a ?? chain would stop dead on '' and never
3101 // reach the WordPress fallback.
3102 $org_name = $this->first_non_empty(
3103 $schema_settings['organization_name'] ?? null,
3104 $settings['site_name'] ?? null,
3105 get_bloginfo('name')
3106 );
3107
3108 $org_url = $this->first_non_empty(
3109 $schema_settings['organization_url'] ?? null,
3110 $settings['site_url'] ?? null,
3111 home_url()
3112 );
3113
3114 $org_description = $this->first_non_empty(
3115 $schema_settings['organization_description'] ?? null,
3116 $settings['site_description'] ?? null,
3117 get_bloginfo('description')
3118 );
3119
3120 if (empty($org_name)) {
3121 return null;
3122 }
3123
3124 // Determine organization type (Schema Manager setting or default)
3125 $org_type = $schema_settings['organization_type'] ?? 'Organization';
3126
3127 $schema = [
3128 '@context' => 'https://schema.org',
3129 '@type' => $org_type,
3130 '@id' => home_url() . '#organization',
3131 'name' => $org_name,
3132 'url' => $org_url,
3133 ];
3134
3135 // Add description if available
3136 if (!empty($org_description)) {
3137 $schema['description'] = $org_description;
3138 }
3139
3140 // Add logo if available with proper ImageObject structure
3141 // Priority: Schema Manager logo > Site Identity logo
3142 $logo_url = $schema_settings['organization_logo'] ?? $settings['logo_url'] ?? '';
3143
3144 if (!empty($logo_url)) {
3145 $schema['logo'] = [
3146 '@type' => 'ImageObject',
3147 '@id' => home_url() . '#logo',
3148 'url' => $logo_url,
3149 'contentUrl' => $logo_url,
3150 'caption' => $org_name . ' Logo'
3151 ];
3152
3153 // Also add as image property
3154 $schema['image'] = $schema['logo'];
3155 }
3156
3157 // Add social media accounts if available
3158 // Priority: Schema Manager social profiles > Site Identity social profiles
3159 $social_urls = [];
3160
3161 // Check Schema Manager organization social profiles first
3162 if (!empty($schema_settings['organization_social_facebook'])) {
3163 $social_urls[] = $schema_settings['organization_social_facebook'];
3164 }
3165 if (!empty($schema_settings['organization_social_twitter'])) {
3166 $twitter_url = $schema_settings['organization_social_twitter'];
3167 // Ensure it's a full URL
3168 if (strpos($twitter_url, 'http') !== 0) {
3169 $twitter_url = 'https://twitter.com/' . ltrim($twitter_url, '@');
3170 }
3171 $social_urls[] = $twitter_url;
3172 }
3173 if (!empty($schema_settings['organization_social_linkedin'])) {
3174 $social_urls[] = $schema_settings['organization_social_linkedin'];
3175 }
3176 if (!empty($schema_settings['organization_social_instagram'])) {
3177 $social_urls[] = $schema_settings['organization_social_instagram'];
3178 }
3179 if (!empty($schema_settings['organization_social_youtube'])) {
3180 $social_urls[] = $schema_settings['organization_social_youtube'];
3181 }
3182 if (!empty($schema_settings['organization_social_pinterest'])) {
3183 $social_urls[] = $schema_settings['organization_social_pinterest'];
3184 }
3185 if (!empty($schema_settings['organization_social_whatsapp'])) {
3186 $social_urls[] = $schema_settings['organization_social_whatsapp'];
3187 }
3188 if (!empty($schema_settings['organization_social_telegram'])) {
3189 $social_urls[] = $schema_settings['organization_social_telegram'];
3190 }
3191
3192 // Fallback to Site Identity social profiles if no Schema Manager profiles
3193 if (empty($social_urls) && !empty($this->site_identity_data['social'])) {
3194 $social_data = $this->site_identity_data['social'];
3195
3196 if (!empty($social_data['facebook_url'])) {
3197 $social_urls[] = $social_data['facebook_url'];
3198 }
3199 if (!empty($social_data['twitter_username'])) {
3200 $twitter_url = 'https://twitter.com/' . ltrim($social_data['twitter_username'], '@');
3201 $social_urls[] = $twitter_url;
3202 }
3203 if (!empty($social_data['linkedin_url'])) {
3204 $social_urls[] = $social_data['linkedin_url'];
3205 }
3206 if (!empty($social_data['instagram_url'])) {
3207 $social_urls[] = $social_data['instagram_url'];
3208 }
3209 if (!empty($social_data['youtube_url'])) {
3210 $social_urls[] = $social_data['youtube_url'];
3211 }
3212 }
3213
3214 if (!empty($social_urls)) {
3215 $schema['sameAs'] = $social_urls;
3216 }
3217
3218 // Add contact information if available
3219 // Priority: Schema Manager contact info > Site Identity contact info
3220 if (!empty($schema_settings['organization_contact_phone']) || !empty($schema_settings['organization_contact_email'])) {
3221 $contact_point = [
3222 '@type' => 'ContactPoint',
3223 'contactType' => $schema_settings['organization_contact_type'] ?? 'customer service'
3224 ];
3225
3226 if (!empty($schema_settings['organization_contact_phone'])) {
3227 $contact_point['telephone'] = $schema_settings['organization_contact_phone'];
3228 }
3229
3230 if (!empty($schema_settings['organization_contact_email'])) {
3231 $contact_point['email'] = $schema_settings['organization_contact_email'];
3232 }
3233
3234 if (!empty($schema_settings['organization_contact_hours'])) {
3235 $contact_point['hoursAvailable'] = $schema_settings['organization_contact_hours'];
3236 }
3237
3238 $schema['contactPoint'] = $contact_point;
3239 } elseif (!empty($settings['contact_email'])) {
3240 // Fallback to Site Identity contact email
3241 $schema['email'] = $settings['contact_email'];
3242 }
3243
3244 return $schema;
3245 }
3246
3247 /**
3248 * Return the first value that is a non-empty (after trim) string.
3249 *
3250 * Settings keys such as organization_url are always present and default to
3251 * an empty string, so the null-coalescing operator (??) cannot be used to
3252 * build a fallback chain: '' is not null and would short-circuit the chain.
3253 * This helper skips empty strings and returns the first real value, falling
3254 * back to '' when none qualify.
3255 *
3256 * @param string|null ...$values Candidate values in priority order.
3257 * @return string First non-empty value, or '' if none.
3258 */
3259 private function first_non_empty(...$values): string {
3260 foreach ($values as $value) {
3261 if (is_string($value) && trim($value) !== '') {
3262 return $value;
3263 }
3264 }
3265 return '';
3266 }
3267
3268 /**
3269 * Output breadcrumb schema markup
3270 *
3271 * @return void
3272 */
3273 public function output_breadcrumb_schema(): void {
3274 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
3275 return;
3276 }
3277
3278 // A breadcrumb trail for a URL that does not exist, or for a search
3279 // results page, describes nothing — and the plugin already emits no
3280 // canonical on either (#471).
3281 if (is_404() || is_search()) {
3282 return;
3283 }
3284
3285 $settings = $this->site_identity_manager->get_settings('site');
3286
3287 // Only output if breadcrumbs are enabled
3288 if (empty($settings['breadcrumbs_enabled'])) {
3289 return;
3290 }
3291
3292 // Schema Manager's own breadcrumb switch. Only Site Identity's
3293 // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3294 // = 0 removed a deployed BreadcrumbList row and left this live one
3295 // emitting the node anyway (#688). Absent means not configured, which
3296 // stays enabled.
3297 if ($this->schema_manager) {
3298 $schema_settings = $this->schema_manager->get_settings('site', null);
3299
3300 if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3301 && empty($schema_settings['enable_breadcrumbs_schema'])) {
3302 return;
3303 }
3304 }
3305
3306 $breadcrumbs = $this->generate_breadcrumbs($settings);
3307
3308 if (!empty($breadcrumbs['schema'])) {
3309 Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
3310 }
3311 }
3312
3313 /**
3314 * Emit everything ThinkRank collected for this request as one linked @graph.
3315 *
3316 * Runs after every producer has registered (site schema 7, breadcrumbs 8,
3317 * Global SEO 15), so the graph can arbitrate between them.
3318 *
3319 * @since 1.32.0
3320 * @return void
3321 */
3322 public function output_schema_graph(): void {
3323 Schema_Graph::instance()->render();
3324 }
3325
3326 /**
3327 * Output closing comment for ThinkRank SEO
3328 *
3329 * @return void
3330 */
3331 public function output_closing_comment(): void {
3332 // Close only what was actually opened. has_seo_output() is true on
3333 // nearly every page, so testing it here printed a closing comment with
3334 // no matching opener whenever the meta description was empty (search
3335 // results, author archives without a description).
3336 if (self::$opening_comment_output) {
3337 echo "<!-- /ThinkRank SEO -->\n";
3338 }
3339 }
3340
3341 /**
3342 * Print the opening ThinkRank comment, once per request.
3343 *
3344 * Public and static so Author_Archives_Manager — which prints its own meta
3345 * description on wp_head at priority 5 — opens the block through the same
3346 * flag the closing comment reads.
3347 *
3348 * @since 2.0.1
3349 * @return void
3350 */
3351 public static function note_opening_comment(): void {
3352 if (self::$opening_comment_output) {
3353 return;
3354 }
3355
3356 echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
3357 self::$opening_comment_output = true;
3358 }
3359
3360 /**
3361 * Display breadcrumbs HTML
3362 *
3363 * @return void
3364 */
3365 public function display_breadcrumbs(): void {
3366 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
3367 return;
3368 }
3369
3370 $settings = $this->site_identity_manager->get_settings('site');
3371
3372 // Only display if breadcrumbs are enabled
3373 if (empty($settings['breadcrumbs_enabled'])) {
3374 return;
3375 }
3376
3377 $breadcrumbs = $this->generate_breadcrumbs($settings);
3378
3379 if (!empty($breadcrumbs['html'])) {
3380 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is properly escaped in generate_breadcrumb_html method
3381 echo $breadcrumbs['html'];
3382 }
3383 }
3384
3385 /**
3386 * Render breadcrumbs for the [thinkrank_breadcrumbs] shortcode
3387 *
3388 * Respects the same site-identity / breadcrumbs_enabled gates as
3389 * display_breadcrumbs().
3390 *
3391 * @return string Breadcrumb HTML (empty string when disabled)
3392 */
3393 public function breadcrumbs_shortcode(): string {
3394 ob_start();
3395 $this->display_breadcrumbs();
3396 return (string) ob_get_clean();
3397 }
3398
3399 /**
3400 * Display the hero section for the `thinkrank_hero` action hook /
3401 * `thinkrank_hero()` template tag.
3402 *
3403 * Gated on the Site Identity master toggle. Emits nothing when no hero
3404 * content (title/subtitle/CTA) is configured, so an empty hero never
3405 * appears on the front end.
3406 *
3407 * @return void
3408 */
3409 public function display_hero(): void {
3410 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
3411 return;
3412 }
3413
3414 $settings = $this->site_identity_manager->get_settings('site');
3415 $html = $this->generate_hero_html($settings);
3416
3417 if ($html !== '') {
3418 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is escaped field-by-field in generate_hero_html().
3419 echo $html;
3420 }
3421 }
3422
3423 /**
3424 * Render the hero section for the [thinkrank_hero] shortcode.
3425 *
3426 * Respects the same gates as display_hero().
3427 *
3428 * @return string Hero HTML (empty string when disabled or unconfigured)
3429 */
3430 public function hero_shortcode(): string {
3431 ob_start();
3432 $this->display_hero();
3433 return (string) ob_get_clean();
3434 }
3435
3436 /**
3437 * Get the current hero section data without displaying it.
3438 *
3439 * @return array|null Hero data (title, subtitle, cta_text, cta_url,
3440 * background_image, html) or null when unavailable.
3441 */
3442 public function get_current_hero(): ?array {
3443 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
3444 return null;
3445 }
3446
3447 $settings = $this->site_identity_manager->get_settings('site');
3448
3449 $hero = [
3450 'title' => (string) ($settings['hero_title'] ?? ''),
3451 'subtitle' => (string) ($settings['hero_subtitle'] ?? ''),
3452 'cta_text' => (string) ($settings['hero_cta_text'] ?? ''),
3453 'cta_url' => (string) ($settings['hero_cta_url'] ?? ''),
3454 'background_image' => (string) ($settings['hero_background_image'] ?? ''),
3455 ];
3456
3457 // generate_hero_html() is the single source of truth for the
3458 // "is anything renderable?" gate (title, subtitle, or a complete CTA),
3459 // so defer to it rather than duplicate the check — and never expose an
3460 // empty hero.
3461 $hero['html'] = $this->generate_hero_html($settings);
3462 if ($hero['html'] === '') {
3463 return null;
3464 }
3465
3466 return $hero;
3467 }
3468
3469 /**
3470 * Build the hero section HTML from Site Identity settings.
3471 *
3472 * Every dynamic value is escaped at the point of output. Returns an empty
3473 * string when there is no title, subtitle, or complete CTA (text + URL).
3474 *
3475 * @param array $settings Site Identity settings
3476 * @return string Hero HTML, or '' when there is nothing to render
3477 */
3478 private function generate_hero_html(array $settings): string {
3479 $title = trim((string) ($settings['hero_title'] ?? ''));
3480 $subtitle = trim((string) ($settings['hero_subtitle'] ?? ''));
3481 $cta_text = trim((string) ($settings['hero_cta_text'] ?? ''));
3482 $cta_url = trim((string) ($settings['hero_cta_url'] ?? ''));
3483 $bg_image = trim((string) ($settings['hero_background_image'] ?? ''));
3484
3485 // A CTA is only meaningful with both a label and a destination.
3486 $has_cta = ($cta_text !== '' && $cta_url !== '');
3487
3488 // Don't emit an empty hero when nothing renderable is configured. A
3489 // background image alone — or CTA text without a URL — is not enough.
3490 if ($title === '' && $subtitle === '' && !$has_cta) {
3491 return '';
3492 }
3493
3494 $classes = ['thinkrank-hero'];
3495 $style = '';
3496 if ($bg_image !== '') {
3497 $classes[] = 'thinkrank-hero--has-image';
3498 $style = ' style="background-image:url(' . esc_url($bg_image) . ');"';
3499 }
3500
3501 $html = '<section class="' . esc_attr(implode(' ', $classes)) . '"' . $style . '>';
3502 $html .= '<div class="thinkrank-hero__inner">';
3503
3504 if ($title !== '') {
3505 $html .= '<h2 class="thinkrank-hero__title">' . esc_html($title) . '</h2>';
3506 }
3507
3508 if ($subtitle !== '') {
3509 $html .= '<p class="thinkrank-hero__subtitle">' . esc_html($subtitle) . '</p>';
3510 }
3511
3512 if ($has_cta) {
3513 $html .= '<a class="thinkrank-hero__cta" href="' . esc_url($cta_url) . '">' . esc_html($cta_text) . '</a>';
3514 }
3515
3516 $html .= '</div></section>';
3517
3518 return $html;
3519 }
3520
3521 /**
3522 * Generate breadcrumbs data
3523 *
3524 * @param array $settings Breadcrumb settings
3525 * @return array Breadcrumb data with HTML and schema
3526 */
3527 private function generate_breadcrumbs(array $settings): array {
3528 $breadcrumbs = [
3529 'items' => [],
3530 'html' => '',
3531 'schema' => null
3532 ];
3533
3534 // Get breadcrumb items
3535 $items = $this->get_breadcrumb_items($settings);
3536
3537 if (empty($items)) {
3538 return $breadcrumbs;
3539 }
3540
3541 $breadcrumbs['items'] = $items;
3542
3543 // Generate HTML
3544 $breadcrumbs['html'] = $this->generate_breadcrumb_html($items, $settings);
3545
3546 // Generate schema
3547 $breadcrumbs['schema'] = $this->generate_breadcrumb_schema($items);
3548
3549 return $breadcrumbs;
3550 }
3551
3552 /**
3553 * Serve /llms.txt through PHP so the response declares UTF-8.
3554 *
3555 * Cheap guard first: every other front-end request leaves without loading
3556 * the manager.
3557 *
3558 * @since 1.32.0
3559 *
3560 * @return void
3561 */
3562 /**
3563 * Serve a ThinkRank sitemap document for this request, when it is one.
3564 *
3565 * Only acts in dynamic delivery mode. In static mode a real file exists and
3566 * the web server returns it without WordPress ever loading, so answering
3567 * here as well would mean two sources for the same bytes.
3568 *
3569 * @since 2.9.0
3570 *
3571 * @return void
3572 */
3573 public function maybe_serve_sitemap(): void {
3574 $filename = $this->requested_sitemap_filename();
3575 if ('' === $filename) {
3576 return;
3577 }
3578
3579 try {
3580 // Read-only instance: passing false keeps it from registering a
3581 // second copy of the auto-generation hooks.
3582 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3583 $settings = $generator->get_settings('site');
3584
3585 if (empty($settings['enabled'])) {
3586 return;
3587 }
3588
3589 if ('dynamic' !== $generator->resolve_delivery_mode($settings)) {
3590 return;
3591 }
3592
3593 if (!$generator->publishes_document_name($filename, $settings)) {
3594 return;
3595 }
3596
3597 $xml = $generator->render_document($filename, $settings);
3598 } catch (\Throwable $e) {
3599 // A failed render must not replace the sitemap with a fatal. Leave
3600 // the request alone so WordPress answers as it otherwise would.
3601 return;
3602 }
3603
3604 if (!is_string($xml) || '' === trim($xml)) {
3605 return;
3606 }
3607
3608 status_header(200);
3609 header('Content-Type: application/xml; charset=UTF-8');
3610 header('X-Robots-Tag: noindex, follow', true);
3611
3612 // Built XML, escaped by the builders as they assemble it; escaping the
3613 // document here would corrupt it.
3614 echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3615 exit;
3616 }
3617
3618 /**
3619 * The sitemap file name this request is asking for, if it looks like one.
3620 *
3621 * Deliberately a cheap shape test. Whether the site actually publishes the
3622 * name is settled by the caller against the generator, so that a request
3623 * for someone else's sitemap is never answered here.
3624 *
3625 * @since 2.9.0
3626 *
3627 * @return string File name, or '' when this is not a sitemap request.
3628 */
3629 private function requested_sitemap_filename(): string {
3630 if (empty($_SERVER['REQUEST_URI'])) {
3631 return '';
3632 }
3633
3634 $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3635 if (!is_string($path) || '' === $path) {
3636 return '';
3637 }
3638
3639 // Strip the install's home path so subdirectory installs match too.
3640 $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3641 if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3642 $path = substr($path, strlen($home_path));
3643 }
3644
3645 $candidate = strtolower(trim($path, '/'));
3646
3647 // One path segment ending in .xml. Anything nested is not a file we
3648 // publish to the web root.
3649 if ('' === $candidate || strpos($candidate, '/') !== false) {
3650 return '';
3651 }
3652
3653 return substr($candidate, -4) === '.xml' ? $candidate : '';
3654 }
3655
3656 public function maybe_serve_llms_txt(): void {
3657 if (!$this->is_llms_txt_request()) {
3658 return;
3659 }
3660
3661 if (!class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) {
3662 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-llms-txt-manager.php';
3663 }
3664
3665 $manager = new \ThinkRank\SEO\LLMs_Txt_Manager();
3666 $manager->serve_llms_txt();
3667 }
3668
3669 /**
3670 * Whether the current request is for /llms.txt.
3671 *
3672 * @since 1.32.0
3673 *
3674 * @return bool
3675 */
3676 private function is_llms_txt_request(): bool {
3677 if (empty($_SERVER['REQUEST_URI'])) {
3678 return false;
3679 }
3680
3681 $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3682 if (!is_string($path) || '' === $path) {
3683 return false;
3684 }
3685
3686 // Strip the install's home path so subdirectory installs match too.
3687 $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3688 if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3689 $path = substr($path, strlen($home_path));
3690 }
3691
3692 return 'llms.txt' === strtolower(trim($path, '/'));
3693 }
3694
3695 /**
3696 * Filter WordPress robots.txt output
3697 *
3698 * @param string $output The default robots.txt output
3699 * @param string $is_public Whether the site is public
3700 * @return string Modified robots.txt content
3701 */
3702 public function filter_robots_txt(string $output, string $is_public): string {
3703 // Only override if Site Identity is enabled and robots.txt management is enabled
3704 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
3705 return $output;
3706 }
3707
3708 $settings = $this->site_identity_manager->get_settings('site');
3709 if (empty($settings['robots_txt_enabled'])) {
3710 return $output;
3711 }
3712
3713 // Serve the effective content (manual textarea edit if present, else
3714 // auto-generated) so the live /robots.txt matches what the admin sees.
3715 try {
3716 $content = $this->site_identity_manager->render_robots_txt();
3717
3718 if (!empty($content)) {
3719 return $content;
3720 }
3721 } catch (\Exception $e) {
3722 // Rendering failed - fall back to default output
3723 }
3724
3725 // Fallback to default output if rendering fails
3726 return $output;
3727 }
3728
3729 /**
3730 * Disable WordPress core's sitemap while ThinkRank's sitemap is enabled.
3731 *
3732 * Prevents the site from publishing two competing sitemap indexes. Core's
3733 * /wp-sitemap.xml is taken offline (it 404s) and, as a consequence, core
3734 * stops adding its own "Sitemap:" directive to robots.txt — including on the
3735 * paths where ThinkRank does not own the robots.txt output.
3736 *
3737 * Only ever turns core's sitemap *off*: when ThinkRank's sitemap is disabled
3738 * the incoming value is returned untouched, so core (or another plugin
3739 * filtering this) keeps whatever behaviour it already had.
3740 *
3741 * @since 1.31.0
3742 *
3743 * @param bool $enabled Whether core's sitemap functionality is enabled.
3744 * @return bool Filtered value.
3745 */
3746 public function filter_wp_sitemaps_enabled($enabled): bool {
3747 return $this->should_disable_core_sitemap() ? false : (bool) $enabled;
3748 }
3749
3750 /**
3751 * Redirect the sitemap URLs core owns to the sitemap ThinkRank publishes.
3752 *
3753 * Only the *index* route is redirected. Core's per-type children
3754 * (/wp-sitemap-posts-post-1.xml and friends) are genuinely gone once core is
3755 * switched off, and a 404 is the honest answer for those; the index is the
3756 * one URL crawlers and humans actually guess, and the one core's own
3757 * /sitemap.xml rule funnels into.
3758 *
3759 * @since 1.31.0
3760 *
3761 * @return void
3762 */
3763 public function redirect_core_sitemap_requests(): void {
3764 if ('index' !== get_query_var('sitemap')) {
3765 return;
3766 }
3767
3768 $request_uri = isset($_SERVER['REQUEST_URI'])
3769 ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI']))
3770 : '';
3771
3772 $target = $this->resolve_core_sitemap_redirect(
3773 (string) wp_parse_url($request_uri, PHP_URL_PATH)
3774 );
3775
3776 if ('' === $target) {
3777 return;
3778 }
3779
3780 wp_safe_redirect($target, 301, 'ThinkRank');
3781 exit;
3782 }
3783
3784 /**
3785 * Where a request for one of core's sitemap URLs should be sent, if anywhere.
3786 *
3787 * Split out from the hook so the rules are testable without dispatching a
3788 * request — the caller above is the only part that cannot be (it exits).
3789 *
3790 * @since 1.31.0
3791 *
3792 * @param string $requested_path Path of the incoming request.
3793 * @return string Absolute URL to redirect to, or '' to leave the request alone.
3794 */
3795 private function resolve_core_sitemap_redirect(string $requested_path): string {
3796 // Nothing to redirect to unless we have actually taken core offline,
3797 // which already implies our own sitemap file is on disk.
3798 if (!$this->should_disable_core_sitemap()) {
3799 return '';
3800 }
3801
3802 $target = $this->thinkrank_sitemap_url;
3803 if ('' === $target) {
3804 return '';
3805 }
3806
3807 // Never redirect a URL to itself. A site publishing at /sitemap.xml
3808 // normally has the web server serve that file before WordPress sees the
3809 // request, but on a setup where the request does reach PHP this is the
3810 // difference between a redirect and a loop.
3811 $destination = (string) wp_parse_url($target, PHP_URL_PATH);
3812
3813 if ('' !== $requested_path && untrailingslashit($requested_path) === untrailingslashit($destination)) {
3814 return '';
3815 }
3816
3817 return $target;
3818 }
3819
3820 /**
3821 * Whether core's sitemap should be switched off for this site.
3822 *
3823 * True when ThinkRank publishes its own sitemap — except in two cases where
3824 * taking core offline would leave a URL answering nothing:
3825 *
3826 * 1. The site is configured to publish *at core's own URL* (the "WordPress
3827 * Core" preset). Once the static file exists the web server serves it
3828 * ahead of WordPress anyway, so core can be left alone.
3829 * 2. ThinkRank's own sitemap file is not on disk yet. Sitemaps here are
3830 * static files with no dynamic route (see save_sitemap_to_file()), so
3831 * while the file is missing core's /sitemap.xml -> /wp-sitemap.xml
3832 * redirect is the only thing answering that URL; suppressing core would
3833 * turn a recoverable "enabled but not generated" state into a hard 404
3834 * for crawlers. Core is taken offline as soon as our file appears, so the
3835 * duplicate-index conflict this filter exists to prevent cannot occur —
3836 * two indexes are only ever reachable if both are actually published.
3837 *
3838 * Once our file does exist, the URLs core stops answering are handed to
3839 * redirect_core_sitemap_requests() rather than left to 404 — which is why
3840 * this also resolves the destination.
3841 *
3842 * Resolved lazily and memoised: this is consulted from an `init`-time filter
3843 * on every request, and the underlying settings read is object-cached. The
3844 * memoisation also keeps the file_exists() call to one per request.
3845 *
3846 * @since 1.31.0
3847 *
3848 * @return bool True when WordPress core's sitemap should be disabled.
3849 */
3850 private function should_disable_core_sitemap(): bool {
3851 if ($this->thinkrank_sitemap_enabled === null) {
3852 try {
3853 // Read-only instance — passing false keeps it from registering a
3854 // second copy of the save_post/term auto-generation hooks.
3855 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3856 $settings = $generator->get_settings('site');
3857
3858 // "Can ThinkRank actually answer its sitemap URL right now?" In
3859 // static mode that means the file is on disk; in dynamic mode
3860 // maybe_serve_sitemap() answers it, so there is nothing to look
3861 // for. Keeping the file test as the only answer would have left
3862 // core's sitemap in place on every dynamic site, which is the
3863 // crawl conflict this suppression exists to prevent (#752).
3864 // The #346 behaviour is unchanged: a static site with nothing
3865 // published still falls through to core rather than 404ing.
3866 $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
3867 || $generator->primary_sitemap_file_exists($settings);
3868
3869 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
3870 && !$this->publishes_at_core_sitemap_url($settings)
3871 && $can_serve;
3872
3873 if ($this->thinkrank_sitemap_enabled) {
3874 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
3875 }
3876 } catch (\Exception $e) {
3877 // Settings unreadable — leave core's sitemap alone rather than
3878 // removing a working sitemap on the strength of a failed read.
3879 $this->thinkrank_sitemap_enabled = false;
3880 $this->thinkrank_sitemap_url = '';
3881 }
3882 }
3883
3884 return $this->thinkrank_sitemap_enabled;
3885 }
3886
3887 /**
3888 * Whether any configured sitemap URL is WordPress core's own wp-sitemap.xml.
3889 *
3890 * @since 1.31.0
3891 *
3892 * @param array $settings Sitemap settings.
3893 * @return bool True when the site publishes at core's sitemap URL.
3894 */
3895 private function publishes_at_core_sitemap_url(array $settings): bool {
3896 foreach ((array) ($settings['sitemap_urls'] ?? []) as $sitemap) {
3897 if (!is_array($sitemap)) {
3898 continue;
3899 }
3900
3901 $path = (string) wp_parse_url((string) ($sitemap['url'] ?? ''), PHP_URL_PATH);
3902
3903 if (ltrim($path, '/') === 'wp-sitemap.xml') {
3904 return true;
3905 }
3906 }
3907
3908 return false;
3909 }
3910
3911 /**
3912 * Re-sync the physical robots.txt when WordPress's "Discourage search
3913 * engines" setting (blog_public) changes.
3914 *
3915 * Only acts when ThinkRank robots management is enabled AND a physical
3916 * robots.txt already exists — a stale physical file is the failure being
3917 * fixed. When no file exists the virtual robots_txt filter already reflects
3918 * blog_public live (render_robots_txt() enforces the full block), so there is
3919 * nothing to re-sync and no reason to create a file the user never generated.
3920 *
3921 * @return void
3922 */
3923 public function on_blog_public_changed(): void {
3924 if (!$this->site_identity_manager) {
3925 return;
3926 }
3927
3928 // Gate on robots management (robots_txt_enabled), not the Site Identity
3929 // master toggle: the physical file's lifecycle is governed by that
3930 // setting alone (same as sync_robots_txt_file() and the save endpoint),
3931 // and a stale physical file is served by the web server regardless of the
3932 // master toggle.
3933 $settings = $this->site_identity_manager->get_settings('site');
3934 if (empty($settings['robots_txt_enabled'])) {
3935 return;
3936 }
3937
3938 if (file_exists(ABSPATH . 'robots.txt')) {
3939 $this->site_identity_manager->sync_robots_txt_file();
3940 }
3941 }
3942
3943 /**
3944 * Use the Site Identity favicon as the site icon URL
3945 *
3946 * When a favicon is uploaded in ThinkRank Site Identity it takes
3947 * precedence over the core site icon; with no core icon set this also
3948 * makes has_site_icon() truthy so wp_site_icon() prints the icon tags.
3949 * Reads settings directly (not site_identity_data) because this filter
3950 * also runs in admin, before initialize_current_context().
3951 *
3952 * @param string $url Site icon URL from core
3953 * @param int $size Requested icon size
3954 * @return string Icon URL
3955 */
3956 public function filter_site_icon_url($url, $size = 512): string {
3957 $settings = $this->site_identity_manager->get_settings('site');
3958
3959 if (empty($settings['enabled'])) {
3960 return (string) $url;
3961 }
3962
3963 $size = (int) $size;
3964
3965 // Apple touch icon has its own dedicated setting
3966 if ($size === 180 && !empty($settings['apple_touch_icon_url'])) {
3967 return $this->resolve_icon_url((string) $settings['apple_touch_icon_url'], $size);
3968 }
3969
3970 if (!empty($settings['favicon_url'])) {
3971 return $this->resolve_icon_url((string) $settings['favicon_url'], $size);
3972 }
3973
3974 return (string) $url;
3975 }
3976
3977 /**
3978 * Whether breadcrumb labels should prefer the SEO title.
3979 *
3980 * Off unless the site turns it on, so updating the plugin never rewrites an
3981 * existing trail.
3982 *
3983 * @since 2.3.1
3984 *
3985 * @param array $settings Breadcrumb settings.
3986 * @return bool
3987 */
3988 private function breadcrumbs_use_seo_title(array $settings): bool {
3989 return !empty($settings['breadcrumb_use_seo_title']);
3990 }
3991
3992 /**
3993 * Label for a post in the breadcrumb trail.
3994 *
3995 * With the toggle on, the post's own SEO title wins — the same
3996 * `_thinkrank_seo_title` value (variable tags resolved) the document title
3997 * uses — so the trail under a search snippet reads the same as the snippet
3998 * itself. Anything empty falls back to the raw post title; the global title
3999 * pattern is deliberately NOT part of the chain, since resolving it would
4000 * append the site name to every crumb.
4001 *
4002 * @since 2.3.1
4003 *
4004 * @param int $post_id Post ID.
4005 * @param array $settings Breadcrumb settings.
4006 * @return string Breadcrumb label.
4007 */
4008 private function get_breadcrumb_post_title(int $post_id, array $settings): string {
4009 $title = (string) get_the_title($post_id);
4010
4011 if (!$this->breadcrumbs_use_seo_title($settings)) {
4012 return $title;
4013 }
4014
4015 $seo_title = trim((string) get_post_meta($post_id, '_thinkrank_seo_title', true));
4016
4017 if ('' === $seo_title) {
4018 return $title;
4019 }
4020
4021 $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_value($seo_title, $post_id));
4022
4023 return '' !== $resolved ? $resolved : $title;
4024 }
4025
4026 /**
4027 * Label for a term in the breadcrumb trail.
4028 *
4029 * Term counterpart to {@see self::get_breadcrumb_post_title()}, resolving
4030 * the term's `_thinkrank_seo_title` against its own values.
4031 *
4032 * @since 2.3.1
4033 *
4034 * @param object $term Term object.
4035 * @param array $settings Breadcrumb settings.
4036 * @return string Breadcrumb label.
4037 */
4038 private function get_breadcrumb_term_title($term, array $settings): string {
4039 $name = (string) ($term->name ?? '');
4040
4041 if (!$this->breadcrumbs_use_seo_title($settings) || empty($term->term_id)) {
4042 return $name;
4043 }
4044
4045 $seo_title = trim((string) get_term_meta((int) $term->term_id, '_thinkrank_seo_title', true));
4046
4047 if ('' === $seo_title) {
4048 return $name;
4049 }
4050
4051 $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_term_value($seo_title, (int) $term->term_id));
4052
4053 return '' !== $resolved ? $resolved : $name;
4054 }
4055
4056 /**
4057 * Resolve a configured icon URL to the derivative that fits $size.
4058 *
4059 * wp_site_icon() calls get_site_icon_url() four times — 32, 192, 180 and
4060 * 270 — and pairs the first two with a hardcoded sizes="" attribute. This
4061 * filter used to answer all four with the same configured URL, so one
4062 * upload was declared as every size at once: a 1536x1536 original served
4063 * to paint a 32px tab icon, under a sizes="32x32" label that was simply
4064 * untrue (#571).
4065 *
4066 * Resolution mirrors core's own get_site_icon_url(), including the
4067 * >= 512 -> 'full' branch, so ThinkRank's override and the core pipeline
4068 * pick the same file for the same request.
4069 *
4070 * An unresolvable URL (one hosted off-site) is returned unchanged. Nothing
4071 * is knowable about its dimensions, and suppressing it instead would leave
4072 * the page with no rel="icon" at all — a worse outcome than an approximate
4073 * size hint.
4074 *
4075 * @param string $configured Configured icon URL.
4076 * @param int $size Icon size core is asking for.
4077 * @return string Icon URL for that size.
4078 */
4079 private function resolve_icon_url(string $configured, int $size): string {
4080 $cache_key = md5($configured) . ':' . $size;
4081 $cached = $this->icon_urls();
4082
4083 if (isset($cached[$cache_key])) {
4084 return $cached[$cache_key];
4085 }
4086
4087 $attachment_id = \ThinkRank\SEO\Site_Identity_Manager::icon_attachment_id($configured);
4088
4089 if (!$attachment_id) {
4090 $resolved = esc_url($configured);
4091 } else {
4092 // Mirrors core: at 512 and above the original is what is wanted, and
4093 // asking for an intermediate size that large would only fall back to it.
4094 $size_data = $size >= 512 ? 'full' : [$size, $size];
4095 $url = wp_get_attachment_image_url($attachment_id, $size_data);
4096 $resolved = $url ? esc_url($url) : esc_url($configured);
4097 }
4098
4099 $this->icon_urls[$cache_key] = $resolved;
4100
4101 if (!$this->icon_urls_dirty) {
4102 $this->icon_urls_dirty = true;
4103 // Written once, after the response is assembled, rather than once
4104 // per size: wp_site_icon() resolves four in a row.
4105 add_action('shutdown', [$this, 'persist_icon_urls'], 5);
4106 }
4107
4108 return $resolved;
4109 }
4110
4111 /**
4112 * The resolved-icon-URL map, loaded from its transient on first use.
4113 *
4114 * @return array<string, string>
4115 */
4116 private function icon_urls(): array {
4117 if ($this->icon_urls === null) {
4118 $stored = get_transient(\ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT);
4119 $this->icon_urls = is_array($stored) ? $stored : [];
4120 }
4121
4122 return $this->icon_urls;
4123 }
4124
4125 /**
4126 * Persist newly resolved icon URLs.
4127 *
4128 * Public because it runs on `shutdown`. Invalidated wholesale whenever the
4129 * site identity settings are saved, which is the only moment the icon
4130 * choice — or the derivatives behind it — can change.
4131 *
4132 * @return void
4133 */
4134 public function persist_icon_urls(): void {
4135 if (!$this->icon_urls_dirty || !is_array($this->icon_urls)) {
4136 return;
4137 }
4138
4139 $this->icon_urls_dirty = false;
4140 set_transient(
4141 \ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT,
4142 $this->icon_urls,
4143 DAY_IN_SECONDS
4144 );
4145 }
4146
4147 /**
4148 * Get breadcrumb items for current page
4149 *
4150 * @param array $settings Breadcrumb settings
4151 * @return array Breadcrumb items
4152 */
4153 private function get_breadcrumb_items(array $settings): array {
4154 $items = [];
4155
4156 // Always start with home
4157 $home_text = $settings['breadcrumb_home_text'] ?? 'Home';
4158 $items[] = [
4159 'title' => $home_text,
4160 'url' => home_url(),
4161 'position' => 1
4162 ];
4163
4164 $position = 2;
4165
4166 if (is_single()) {
4167 $current_post_id = get_the_ID();
4168
4169 if ($current_post_id) {
4170 // Add categories for posts
4171 $post_type = get_post_type($current_post_id);
4172 if ($post_type === 'post') {
4173 $categories = get_the_category($current_post_id);
4174 if (!empty($categories)) {
4175 $category = $categories[0];
4176 $items[] = [
4177 'title' => $this->get_breadcrumb_term_title($category, $settings),
4178 'url' => get_category_link($category->term_id),
4179 'position' => $position++
4180 ];
4181 }
4182 }
4183
4184 // Add current post. `empty($x) || $x` is true for every possible
4185 // value — an unset key, false, 0, '' and any truthy value alike —
4186 // so the setting had no effect on the rendered breadcrumb or on
4187 // the BreadcrumbList JSON-LD, while the admin preview honoured it
4188 // and disagreed with live output (#398). Site_Identity_Manager
4189 // already had the correct form: default to on, respect an
4190 // explicit off.
4191 if ($settings['show_current_page'] ?? true) {
4192 $items[] = [
4193 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
4194 'url' => get_permalink($current_post_id),
4195 'position' => $position,
4196 'current' => true
4197 ];
4198 }
4199 }
4200 } elseif (is_page()) {
4201 $current_post_id = get_the_ID();
4202
4203 if ($current_post_id) {
4204 // Add parent pages
4205 $parents = [];
4206 $parent_id = wp_get_post_parent_id($current_post_id);
4207
4208 while ($parent_id) {
4209 $parent = get_post($parent_id);
4210 if ($parent) {
4211 $parents[] = [
4212 'title' => $this->get_breadcrumb_post_title($parent->ID, $settings),
4213 'url' => get_permalink($parent->ID),
4214 'position' => 0 // Will be set later
4215 ];
4216 $parent_id = $parent->post_parent;
4217 } else {
4218 break;
4219 }
4220 }
4221
4222 // Reverse to get correct order
4223 $parents = array_reverse($parents);
4224
4225 // Add parents with correct positions
4226 foreach ($parents as $parent) {
4227 $parent['position'] = $position++;
4228 $items[] = $parent;
4229 }
4230
4231 // Add current page
4232 if ($settings['show_current_page'] ?? true) {
4233 $items[] = [
4234 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
4235 'url' => get_permalink($current_post_id),
4236 'position' => $position,
4237 'current' => true
4238 ];
4239 }
4240 }
4241 } elseif (is_category()) {
4242 $category = get_queried_object();
4243
4244 // Add parent categories
4245 $parents = [];
4246 $parent_id = $category->parent;
4247
4248 while ($parent_id) {
4249 $parent = get_category($parent_id);
4250 if ($parent && !is_wp_error($parent)) {
4251 $parents[] = [
4252 'title' => $this->get_breadcrumb_term_title($parent, $settings),
4253 'url' => get_category_link($parent->term_id),
4254 'position' => 0 // Will be set later
4255 ];
4256 $parent_id = $parent->parent;
4257 } else {
4258 break;
4259 }
4260 }
4261
4262 // Reverse to get correct order
4263 $parents = array_reverse($parents);
4264
4265 // Add parents with correct positions
4266 foreach ($parents as $parent) {
4267 $parent['position'] = $position++;
4268 $items[] = $parent;
4269 }
4270
4271 // Add current category
4272 if ($settings['show_current_page'] ?? true) {
4273 $items[] = [
4274 'title' => $this->get_breadcrumb_term_title($category, $settings),
4275 'url' => get_category_link($category->term_id),
4276 'position' => $position,
4277 'current' => true
4278 ];
4279 }
4280 }
4281
4282 return $items;
4283 }
4284
4285 /**
4286 * Generate breadcrumb HTML
4287 *
4288 * @param array $items Breadcrumb items
4289 * @param array $settings Breadcrumb settings
4290 * @return string HTML output
4291 */
4292 private function generate_breadcrumb_html(array $items, array $settings): string {
4293 if (empty($items)) {
4294 return '';
4295 }
4296
4297 $separator = $settings['breadcrumb_separator'] ?? '>';
4298 $prefix = $settings['breadcrumb_prefix'] ?? '';
4299
4300 $html = '<nav class="thinkrank-breadcrumbs" aria-label="Breadcrumb">';
4301
4302 if (!empty($prefix)) {
4303 $html .= '<span class="breadcrumb-prefix">' . esc_html($prefix) . '</span> ';
4304 }
4305
4306 $html .= '<ol class="breadcrumb-list">';
4307
4308 $total_items = count($items);
4309
4310 foreach ($items as $index => $item) {
4311 $is_last = ($index === $total_items - 1);
4312 $is_current = !empty($item['current']);
4313
4314 $html .= '<li class="breadcrumb-item' . ($is_current ? ' current' : '') . '">';
4315
4316 if (!$is_current && !empty($item['url'])) {
4317 $html .= '<a href="' . esc_url($item['url']) . '">' . esc_html($item['title']) . '</a>';
4318 } else {
4319 $html .= '<span>' . esc_html($item['title']) . '</span>';
4320 }
4321
4322 if (!$is_last) {
4323 $html .= ' <span class="breadcrumb-separator">' . esc_html($separator) . '</span> ';
4324 }
4325
4326 $html .= '</li>';
4327 }
4328
4329 $html .= '</ol>';
4330 $html .= '</nav>';
4331
4332 return $html;
4333 }
4334
4335 /**
4336 * Generate breadcrumb schema markup
4337 *
4338 * @param array $items Breadcrumb items
4339 * @return array Schema data
4340 */
4341 private function generate_breadcrumb_schema(array $items): array {
4342 if (empty($items)) {
4343 return [];
4344 }
4345
4346 $schema_items = [];
4347
4348 foreach ($items as $item) {
4349 $schema_items[] = [
4350 '@type' => 'ListItem',
4351 'position' => $item['position'],
4352 'name' => $item['title'],
4353 'item' => $item['url']
4354 ];
4355 }
4356
4357 return [
4358 '@context' => 'https://schema.org',
4359 '@type' => 'BreadcrumbList',
4360 'itemListElement' => $schema_items
4361 ];
4362 }
4363
4364 /**
4365 * Get Twitter image with proper fallback priority
4366 *
4367 * @since 1.0.0
4368 *
4369 * @return string|null Twitter image URL or null if none available
4370 */
4371 private function get_twitter_image_with_fallback(): ?string {
4372 // Cascade: post-specific Twitter image > post-specific OG image > featured image.
4373 if (is_singular() && $this->current_post_id) {
4374 $post_twitter_image = get_post_meta($this->current_post_id, '_thinkrank_twitter_image', true);
4375 if (!empty($post_twitter_image)) {
4376 return $post_twitter_image;
4377 }
4378
4379 $post_og_image = get_post_meta($this->current_post_id, '_thinkrank_og_image', true);
4380 if (!empty($post_og_image)) {
4381 return $post_og_image;
4382 }
4383
4384 // Check featured image as fallback for posts
4385 if (has_post_thumbnail($this->current_post_id)) {
4386 $featured_image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
4387 if ($featured_image_url) {
4388 return $featured_image_url;
4389 }
4390 }
4391 }
4392
4393 // Check Social Meta Manager settings for Twitter-specific default image
4394 if ($this->social_manager) {
4395 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
4396 $social_settings = $this->social_manager->get_settings($social_context, $this->current_post_id);
4397
4398 // Prioritize Twitter-specific default image
4399 if (!empty($social_settings['default_twitter_image'])) {
4400 return $social_settings['default_twitter_image'];
4401 }
4402
4403 // Fallback to Open Graph default image
4404 if (!empty($social_settings['default_og_image'])) {
4405 return $social_settings['default_og_image'];
4406 }
4407
4408 // Final fallback to generic default image
4409 if (!empty($social_settings['default_image'])) {
4410 return $social_settings['default_image'];
4411 }
4412 }
4413
4414 // Check Site Identity data for social images
4415 if ($this->site_identity_data && !empty($this->site_identity_data['social'])) {
4416 $social_data = $this->site_identity_data['social'];
4417
4418 // Check for any configured social image
4419 if (!empty($social_data['default_image'])) {
4420 return $social_data['default_image'];
4421 }
4422 }
4423
4424 return null;
4425 }
4426 }
4427