PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 All 58 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.14.3, at includes/frontend/class-seo-manager.php

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