PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.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 1.28.0 1.27.0 1.26.0 1.25.0 All 54 releases
← All changes | includes/frontend/class-seo-manager.php +1427 -144 1.31.0 → 2.13.0 View file →
@@ -41,8 +41,16 @@
41 41 */
42 42 private array $current_metadata = [];
43 43
44 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 + /**
45 53 * Site Identity Manager instance
46 54 *
47 55 * @var \ThinkRank\SEO\Site_Identity_Manager|null
48 56 */
@@ -48,8 +56,29 @@
48 56 */
49 57 private ?\ThinkRank\SEO\Site_Identity_Manager $site_identity_manager = null;
50 58
51 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 + /**
52 81 * Social Meta Manager instance
53 82 *
54 83 * @var \ThinkRank\SEO\Social_Meta_Manager|null
55 84 */
@@ -83,8 +112,16 @@
83 112 */
84 113 private ?\ThinkRank\SEO\Image_SEO_Manager $image_seo_manager = null;
85 114
86 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 + /**
87 124 * Current page context
88 125 *
89 126 * @var string
90 127 */
@@ -90,8 +127,24 @@
90 127 */
91 128 private string $current_context = 'site';
92 129
93 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 + /**
94 147 * Memoised "should core's sitemap be disabled" flag. Null until resolved.
95 148 *
96 149 * @var bool|null
97 150 */
@@ -122,17 +175,22 @@
122 175
123 176 // Initialize Global SEO Schema Output
124 177 $this->initialize_global_seo_schema();
125 178
126 - // Initialize Google Analytics Tracking Manager
127 - $this->initialize_google_analytics_tracking();
128 -
129 179 // Initialize Image SEO Manager
130 180 $this->initialize_image_seo_manager();
131 181
182 + // Initialize External Links Manager (rel=nofollow / target=_blank)
183 + $this->initialize_external_links_manager();
184 +
132 185 // Initialize current post and context data first
133 186 add_action('wp', [$this, 'initialize_current_context']);
134 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 +
135 193 // Use HIGH PRIORITY hooks to override other SEO plugins
136 194 // Priority 1-5 ensures ThinkRank runs before other SEO plugins
137 195
138 196 // Override WordPress title with HIGH priority
@@ -163,11 +221,27 @@
163 221 // the page would emit two <link rel="canonical"> tags on singular views.
164 222 remove_action('wp_head', 'rel_canonical');
165 223 add_action('wp_head', [$this, 'output_canonical_url'], 6);
166 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 +
167 235 // Add Site Identity specific outputs
168 236 add_action('wp_head', [$this, 'output_site_schema_markup'], 7);
169 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();
170 244
171 245 // Add closing comment (runs last)
172 246 add_action('wp_head', [$this, 'output_closing_comment'], 99);
173 247
@@ -184,8 +258,24 @@
184 258
185 259 // Add robots.txt filter hook
186 260 add_filter('robots_txt', [$this, 'filter_robots_txt'], 10, 2);
187 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 +
188 278 // Take WordPress core's own sitemap offline while ThinkRank's is active.
189 279 // Two sitemap indexes on one site is a crawl conflict: core keeps
190 280 // /wp-sitemap.xml served and injects its own "Sitemap:" line into
191 281 // robots.txt (WP_Sitemaps::add_robots, priority 0). Until now that line
@@ -217,8 +307,17 @@
217 307 // Serve the Site Identity favicon through core's site-icon pipeline so
218 308 // wp_site_icon() outputs it on the front-end (and previews pick it up)
219 309 add_filter('get_site_icon_url', [$this, 'filter_site_icon_url'], 10, 2);
220 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 +
221 320 // Process image SEO in content
222 321 add_filter('the_content', [$this, 'filter_content_images'], 99999);
223 322 add_filter('post_thumbnail_html', [$this, 'filter_content_images'], 11, 2);
224 323 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'filter_content_images'], 11);
@@ -278,39 +377,73 @@
278 377 }
279 378
280 379 // Initialize Global SEO Schema Output and store reference
281 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 + );
282 389 $this->global_seo_schema->init();
283 390 }
284 391
285 392 /**
286 - * Initialize Google Analytics Tracking Manager
393 + * Initialize Image SEO Manager
287 394 *
288 395 * @return void
289 396 */
290 - private function initialize_google_analytics_tracking(): void {
291 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
292 - require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
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';
293 400 }
294 401
295 - // Initialize Google Analytics Tracking Manager
296 - new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
402 + $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
297 403 }
298 404
299 405 /**
300 - * Initialize Image SEO Manager
406 + * Initialize External Links Manager
301 407 *
408 + * @since 2.5.0
302 409 * @return void
303 410 */
304 - private function initialize_image_seo_manager(): void {
305 - if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
306 - require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-image-seo-manager.php';
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';
307 414 }
308 415
309 - $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
416 + $this->external_links_manager = new \ThinkRank\SEO\External_Links_Manager();
310 417 }
311 418
312 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 + /**
313 446 * Filter content to inject image SEO attributes
314 447 *
315 448 * @since 1.0.0
316 449 * @param string $content Content to filter
@@ -362,18 +495,74 @@
362 495 $this->current_metadata = $this->get_post_seo_metadata($post_id);
363 496 }
364 497 }
365 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 +
366 514 // Load site identity data
367 515 $this->load_site_identity_data();
368 516 }
369 517
370 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 + /**
371 551 * Detect current page context
372 552 *
373 553 * @return string Current context type
374 554 */
375 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 +
376 565 if (is_home() || is_front_page()) {
377 566 return 'homepage';
378 567 } elseif (is_single()) {
379 568 return 'post';
@@ -429,8 +618,37 @@
429 618 ];
430 619 }
431 620
432 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 + /**
433 651 * Resolve the effective SEO title for the current request.
434 652 *
435 653 * Same priority chain as override_document_title() — post-specific
436 654 * ThinkRank metadata (resolved _thinkrank_seo_title) > Global SEO
@@ -455,17 +673,23 @@
455 673 * @param string $title Original title
456 674 * @return string Modified title
457 675 */
458 676 public function override_document_title($title): string {
677 + // A content type with metas switched off keeps whatever title the theme
678 + // and WordPress produce (#660).
679 + if (!$this->metas_enabled()) {
680 + return $title;
681 + }
682 +
459 683 // First priority: Post-specific ThinkRank metadata
460 684 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
461 - return $this->current_metadata['title'];
685 + return self::with_page_suffix($this->current_metadata['title']);
462 686 }
463 687
464 688 // Second priority: Global SEO templates, Third priority: Site Identity templates
465 689 $generated_title = $this->generate_context_title();
466 690 if ($generated_title) {
467 - return $generated_title;
691 + return self::with_page_suffix($generated_title);
468 692 }
469 693
470 694 return $title;
471 695 }
@@ -470,8 +694,117 @@
470 694 return $title;
471 695 }
472 696
473 697 /**
698 + * Append a page indicator to a title on page 2 and beyond.
699 + *
700 + * This filter short-circuits pre_get_document_title at priority 1, which
701 + * drops the " – Page 2" core would otherwise add — so every page of an
702 + * archive, and every part of a multi-page post, shared one <title> (#397).
703 + * The templates have no %page% token, so the suffix is added here rather
704 + * than asking every site to edit its title format.
705 + *
706 + * @since 2.0.1
707 + *
708 + * @param string $title Resolved title.
709 + * @return string Title with the page indicator, when there is one.
710 + */
711 + /**
712 + * The archive's subject, without the label WordPress prefixes it with.
713 + *
714 + * `get_the_archive_title()` returns "Month: September 2026", "Archives:
715 + * Recipes", "Category: Uncategorized" — the label is core's, aimed at an
716 + * archive heading on the page, and it reads badly in a browser tab, an
717 + * og:title or a search result. Category, tag and author contexts already
718 + * avoid it by using the raw name; the generic archive context did not, so
719 + * date, custom-post-type and custom-taxonomy archives carried it (#640).
720 + *
721 + * Removed through core's own `get_the_archive_title_prefix` filter rather
722 + * than by matching the prefix text, because that text is translated and
723 + * differs per archive type — a string comparison would work in English and
724 + * silently stop working everywhere else.
725 + *
726 + * A site that wants a prefix can put one in its title template, where it is
727 + * visible and editable, instead of inheriting one it cannot see.
728 + *
729 + * @since 2.7.0
730 + *
731 + * @return string Archive subject, with markup and the core prefix removed.
732 + */
733 + private static function archive_subject(): string {
734 + $drop_prefix = static function (): string {
735 + return '';
736 + };
737 +
738 + add_filter('get_the_archive_title_prefix', $drop_prefix, 99);
739 +
740 + $title = (string) get_the_archive_title();
741 +
742 + remove_filter('get_the_archive_title_prefix', $drop_prefix, 99);
743 +
744 + // The <span> core wraps the subject in survives the prefix filter.
745 + return trim(wp_strip_all_tags($title));
746 + }
747 +
748 + /**
749 + * Remove HTML from a title that is about to be emitted.
750 + *
751 + * A title carrying markup is broken twice over, in two different ways, and
752 + * both were reaching real pages: inside `<title>` the tags render literally,
753 + * because that element is RCDATA and never parses them; inside `og:title`
754 + * and `twitter:title` they are attribute-escaped, so the reader sees
755 + * `&lt;em&gt;` as visible text (#640).
756 + *
757 + * Applied at the point of emission rather than at each source, so it covers
758 + * every branch that can produce a title — post meta, Global SEO templates,
759 + * Site Identity templates — without each having to remember.
760 + *
761 + * Unconditional rather than a setting: there is no title for which markup is
762 + * the correct output. The filter is the escape hatch for anyone who
763 + * disagrees, and lets a site keep entities it deliberately encoded.
764 + *
765 + * @since 2.7.0
766 + *
767 + * @param string $title Title about to be emitted.
768 + * @return string Title with any markup removed.
769 + */
770 + public static function strip_title_tags(string $title): string {
771 + /**
772 + * Filter whether HTML is stripped from generated titles.
773 + *
774 + * @since 2.7.0
775 + *
776 + * @param bool $strip Whether to strip. Default true.
777 + * @param string $title The title being emitted.
778 + */
779 + if (!apply_filters('thinkrank_strip_title_tags', true, $title)) {
780 + return $title;
781 + }
782 +
783 + return trim(wp_strip_all_tags($title));
784 + }
785 +
786 + public static function with_page_suffix(string $title): string {
787 + $title = self::strip_title_tags($title);
788 +
789 + $page = self::current_page_number();
790 +
791 + if ($page <= 1 || '' === $title) {
792 + return $title;
793 + }
794 +
795 + $separator = class_exists('\ThinkRank\SEO\Site_Identity_Manager')
796 + ? \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol()
797 + : '|';
798 +
799 + return $title . ' ' . $separator . ' ' . sprintf(
800 + /* translators: %d: page number. */
801 + __('Page %d', 'thinkrank'),
802 + $page
803 + );
804 + }
805 +
806 + /**
474 807 * Override WordPress wp_title (HIGH PRIORITY)
475 808 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates
476 809 *
477 810 * @param string $title Original title
@@ -478,18 +811,24 @@
478 811 * @param string $sep Title separator
479 812 * @return string Modified title
480 813 */
481 814 public function override_wp_title(string $title, string $sep = ''): string {
815 + if (!$this->metas_enabled()) {
816 + return $title;
817 + }
818 +
482 819 // First priority: Post-specific ThinkRank metadata
483 820 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
484 821 $site_name = get_bloginfo('name');
485 - return $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name;
822 + return self::with_page_suffix(
823 + $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name
824 + );
486 825 }
487 826
488 827 // Second priority: Global SEO templates, Third priority: Site Identity templates
489 828 $generated_title = $this->generate_context_title();
490 829 if ($generated_title) {
491 - return $generated_title;
830 + return self::with_page_suffix($generated_title);
492 831 }
493 832
494 833 return $title;
495 834 }
@@ -494,8 +833,25 @@
494 833 return $title;
495 834 }
496 835
497 836 /**
837 + * Whether ThinkRank owns the title and meta description for this request.
838 + *
839 + * Metas are on site-wide by default; the per-content-type matrix can switch
840 + * them off for one content type, in which case ThinkRank stops overriding
841 + * the document title and prints no meta description (#660).
842 + *
843 + * @since 2.5.0
844 + * @return bool
845 + */
846 + private function metas_enabled(): bool {
847 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
848 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_META,
849 + true
850 + );
851 + }
852 +
853 + /**
498 854 * Output meta description (HIGH PRIORITY)
499 855 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
500 856 *
501 857 * Author archives are skipped entirely: Author_Archives_Manager owns that
@@ -510,22 +866,24 @@
510 866 if (is_author()) {
511 867 return;
512 868 }
513 869
870 + if (!$this->metas_enabled()) {
871 + return;
872 + }
873 +
514 874 $description = $this->get_meta_description();
515 875
516 876 if ($description) {
517 877 // Output main ThinkRank SEO header comment (only once)
518 - static $header_output = false;
519 - if (!$header_output) {
520 - echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
521 - $header_output = true;
522 - }
878 + self::note_opening_comment();
523 879
524 880 // Ensure description is within optimal length (150-160 characters)
525 - if (strlen($description) > 160) {
526 - $description = wp_trim_words($description, 25, '...');
527 - }
881 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
882 + // CJK description tripped this limit at a third of its length, and
883 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
884 + // English, 25 characters in Thai (#687).
885 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
528 886
529 887 echo "<!-- ThinkRank SEO Meta Description -->\n";
530 888 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
531 889 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -566,13 +924,41 @@
566 924
567 925 // Output generator meta tag
568 926 echo '<meta name="generator" content="ThinkRank ' . esc_attr(THINKRANK_VERSION) . '" />' . "\n";
569 927
570 - // Output viewport meta tag if not already present
571 - if (!has_action('wp_head', 'wp_site_icon') || !wp_is_mobile()) {
572 - echo '<meta name="viewport" content="width=device-width, initial-scale=1.0" />' . "\n";
928 + // No viewport tag here. The viewport is the theme's responsibility and
929 + // every modern theme ships one, so emitting our own only ever produced a
930 + // second <meta name="viewport"> in the document. The old guard could not
931 + // prevent that either: has_action() returns the registered priority
932 + // (truthy), so its first operand was always false, and !wp_is_mobile() is
933 + // true for every desktop request.
934 + echo "<!-- /ThinkRank SEO Meta Tags -->\n";
935 + }
936 +
937 + /**
938 + * Build the basic robots directive list from a set of robots flags.
939 + *
940 + * Shared by the search/404 branch of get_robots_meta_content() so those
941 + * pages resolve their directives through the same rules as everything else
942 + * rather than a hardcoded literal.
943 + *
944 + * @since 2.5.0
945 + * @param array $settings Robots flags (index/noindex/nofollow/...).
946 + * @return string[] Directives.
947 + */
948 + private static function build_robots_directives(array $settings): array {
949 + $robots = [];
950 +
951 + $robots[] = !empty($settings['noindex']) ? 'noindex' : 'index';
952 + $robots[] = !empty($settings['nofollow']) ? 'nofollow' : 'follow';
953 +
954 + foreach (['noarchive', 'noimageindex', 'nosnippet'] as $directive) {
955 + if (!empty($settings[$directive])) {
956 + $robots[] = $directive;
957 + }
573 958 }
574 - echo "<!-- /ThinkRank SEO Meta Tags -->\n";
959 +
960 + return $robots;
575 961 }
576 962
577 963 /**
578 964 * Get robots meta content based on context and settings
@@ -581,13 +967,22 @@
581 967 */
582 968 private function get_robots_meta_content(): string {
583 969 $robots = [];
584 970
585 - // 404 and search results must never be indexed, regardless of the
586 - // configured global/post-type directives. Links are still followed so
587 - // crawlers can discover the rest of the site.
971 + // 404 and search results are noindex/follow by default — the behaviour
972 + // that used to be hardcoded here. It is now settings-driven (#660): the
973 + // Content Type Matrix can give either its own robots directives, and an
974 + // install that never touched them resolves to exactly the old pair.
588 975 if (is_404() || is_search()) {
589 - $robots = apply_filters('thinkrank_robots_meta', ['noindex', 'follow']);
976 + $entity = is_404()
977 + ? \ThinkRank\SEO\Content_Type_Settings::ENTITY_404
978 + : \ThinkRank\SEO\Content_Type_Settings::ENTITY_SEARCH;
979 +
980 + $robots = self::build_robots_directives(
981 + \ThinkRank\SEO\Content_Type_Settings::resolve_robots_meta($entity)
982 + );
983 +
984 + $robots = apply_filters('thinkrank_robots_meta', $robots);
590 985 return implode(', ', array_unique($robots));
591 986 }
592 987
593 988 // 1. Get global robot meta settings (Base)
@@ -616,8 +1011,24 @@
616 1011 $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']);
617 1012 }
618 1013 }
619 1014
1015 + // 2b. Apply the per-entity directives for the non-singular content
1016 + // types the matrix covers — taxonomy archives plus author and date
1017 + // archives. Terms keep their own per-term override, applied further
1018 + // down so it still wins over the taxonomy-wide value (#660).
1019 + if (!is_singular()) {
1020 + $entity_key = \ThinkRank\SEO\Content_Type_Settings::current_entity_key();
1021 +
1022 + if ($entity_key !== null) {
1023 + $entity_settings = \ThinkRank\SEO\Content_Type_Settings::get_entity_settings($entity_key);
1024 +
1025 + if (!empty($entity_settings['robots_meta_enabled']) && is_array($entity_settings['robots_meta'] ?? null)) {
1026 + $current_settings = array_merge($current_settings, $entity_settings['robots_meta']);
1027 + }
1028 + }
1029 + }
1030 +
620 1031 // Determine Index/Noindex based on merged settings
621 1032 // Priority: if noindex is true, it overrides index
622 1033 if (!empty($current_settings['noindex'])) {
623 1034 $robots[] = 'noindex';
@@ -681,12 +1092,30 @@
681 1092 $robots = $this->apply_post_robots_override(get_the_ID(), $robots, $current_settings);
682 1093 }
683 1094
684 1095 // Check for archive pages (search is handled by the early return above)
685 - if (is_archive()) {
1096 + //
1097 + // is_home() is deliberately included: the blog listing is not an
1098 + // is_archive(), so page 2 of a term archive was noindex while page 2 of
1099 + // the blog listing was index — the same kind of page, treated two
1100 + // different ways, on the same site (#397).
1101 + if (is_archive() || is_home()) {
686 1102 // Allow indexing of category/tag archives but be more conservative
687 1103 if (is_paged()) {
688 - $robots = ['noindex', 'follow'];
1104 + /**
1105 + * Filter whether a paginated archive is set noindex.
1106 + *
1107 + * Rank Math and Yoast now index paginated archives with a
1108 + * self-referential canonical by default, so a site that wants
1109 + * that can have it without patching.
1110 + *
1111 + * @since 2.0.1
1112 + *
1113 + * @param bool $noindex Whether to noindex this paginated page.
1114 + */
1115 + if (apply_filters('thinkrank_noindex_paged_archives', true)) {
1116 + $robots = ['noindex', 'follow'];
1117 + }
689 1118 }
690 1119
691 1120 // Honor the global date-archive noindex toggle (written by the
692 1121 // Rank Math/Yoast settings importer). Author archives are handled
@@ -966,9 +1395,9 @@
966 1395 *
967 1396 * @param array $og_tags Open Graph tags array
968 1397 * @return void
969 1398 */
970 - private function output_social_og_tags(array $og_tags): void {
1399 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
971 1400 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
972 1401 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
973 1402 // sets 'product' on product pages) must be applied to it, not only to
974 1403 // output_open_graph_tags().
@@ -1012,12 +1441,62 @@
1012 1441 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1013 1442 }
1014 1443 }
1015 1444
1445 + // Alternatives, after the primary and everything belonging to it.
1446 + // Order is the whole point: a consumer reads og:image tags in document
1447 + // order and treats the first as primary, and a structured property
1448 + // attaches to the most recently declared image — so each alternative's
1449 + // companions have to follow its own URL, not be grouped at the end.
1450 + self::output_extra_og_images($extra_images);
1451 +
1016 1452 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1017 1453 }
1018 1454
1019 1455 /**
1456 + * Emit the secondary og:image tags a page offers.
1457 + *
1458 + * Shared by the enhanced and basic emitters so both describe an
1459 + * alternative image the same way (#636).
1460 + *
1461 + * @since 2.7.0
1462 + *
1463 + * @param array $images Each with url, and width/height/type/alt where known.
1464 + * @return void
1465 + */
1466 + private static function output_extra_og_images(array $images): void {
1467 + foreach ($images as $image) {
1468 + $url = isset($image['url']) ? (string) $image['url'] : '';
1469 +
1470 + if ('' === $url) {
1471 + continue;
1472 + }
1473 +
1474 + echo '<meta property="og:image" content="' . esc_url($url) . '" />' . "\n";
1475 +
1476 + if (strpos($url, 'https://') === 0) {
1477 + echo '<meta property="og:image:secure_url" content="' . esc_url($url) . '" />' . "\n";
1478 + }
1479 +
1480 + // Only what is actually known: a dimension guessed for a remote
1481 + // image is a number a consumer lays a card out with before it has
1482 + // fetched the file.
1483 + if (!empty($image['width']) && !empty($image['height'])) {
1484 + echo '<meta property="og:image:width" content="' . esc_attr((string) $image['width']) . '" />' . "\n";
1485 + echo '<meta property="og:image:height" content="' . esc_attr((string) $image['height']) . '" />' . "\n";
1486 + }
1487 +
1488 + if (!empty($image['type'])) {
1489 + echo '<meta property="og:image:type" content="' . esc_attr((string) $image['type']) . '" />' . "\n";
1490 + }
1491 +
1492 + if (!empty($image['alt'])) {
1493 + echo '<meta property="og:image:alt" content="' . esc_attr((string) $image['alt']) . '" />' . "\n";
1494 + }
1495 + }
1496 + }
1497 +
1498 + /**
1020 1499 * Output social media Twitter Card tags from Social Meta Manager
1021 1500 *
1022 1501 * @param array $twitter_tags Twitter Card tags array
1023 1502 * @return void
@@ -1082,8 +1561,16 @@
1082 1561 *
1083 1562 * @return void
1084 1563 */
1085 1564 public function output_platform_meta_tags(): void {
1565 + // Same reasoning as the Open Graph and Twitter emitters: an error page
1566 + // has no shareable identity, and passing '404' through as a social
1567 + // context asks the manager for settings that describe a page which does
1568 + // not exist. Guarding all three keeps them from disagreeing.
1569 + if ($this->current_context === '404') {
1570 + return;
1571 + }
1572 +
1086 1573 // Try Social Meta Manager for platform tags
1087 1574 if ($this->social_manager) {
1088 1575 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1089 1576 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1135,8 +1622,23 @@
1135 1622 *
1136 1623 * @return void
1137 1624 */
1138 1625 public function output_open_graph_tags(): void {
1626 + // Per-content-type Open Graph switch. 'inherit' (the default) keeps the
1627 + // site-wide Social Media setting, which the emitters below read (#660).
1628 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1629 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_OPEN_GRAPH,
1630 + true
1631 + )) {
1632 + return;
1633 + }
1634 +
1635 + // An error page has no shareable identity. Emitting Open Graph here
1636 + // advertised the homepage as the og:url of a URL that does not exist.
1637 + if ($this->current_context === '404') {
1638 + return;
1639 + }
1640 +
1139 1641 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1140 1642 if ($this->social_manager) {
1141 1643 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1142 1644 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1157,9 +1659,12 @@
1157 1659 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1158 1660 // toggled off, emit nothing — do NOT fall through to the basic
1159 1661 // emitter (which would re-add a full OG block despite the toggle).
1160 1662 if (!empty($social_data['og_enabled'])) {
1161 - $this->output_social_og_tags($social_data['og_tags']);
1663 + $this->output_social_og_tags(
1664 + $social_data['og_tags'],
1665 + $social_data['og_extra_images'] ?? []
1666 + );
1162 1667 }
1163 1668 return;
1164 1669 }
1165 1670
@@ -1187,8 +1692,21 @@
1187 1692 (string) get_post_meta($this->current_post_id, '_thinkrank_og_description', true),
1188 1693 $this->current_post_id
1189 1694 );
1190 1695 $og_image_override = get_post_meta($this->current_post_id, '_thinkrank_og_image', true);
1696 + } elseif ($this->current_term_id) {
1697 + // Terms carry the same social override keys — the abilities API
1698 + // writes them — so honour them here rather than letting the term's
1699 + // SEO title stand in for an explicit og:title.
1700 + $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value(
1701 + (string) get_term_meta($this->current_term_id, '_thinkrank_og_title', true),
1702 + $this->current_term_id
1703 + );
1704 + $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value(
1705 + (string) get_term_meta($this->current_term_id, '_thinkrank_og_description', true),
1706 + $this->current_term_id
1707 + );
1708 + $og_image_override = get_term_meta($this->current_term_id, '_thinkrank_og_image', true);
1191 1709 }
1192 1710
1193 1711 // Get title using priority system: OG override > post-specific > Global SEO > Site Identity > default
1194 1712 $title = '';
@@ -1209,10 +1727,14 @@
1209 1727 $description = $og_description_override;
1210 1728 } else {
1211 1729 $description = $this->get_meta_description();
1212 1730 }
1213 - if (!$description) {
1214 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1731 + // Skipped for a protected post: core answers get_the_excerpt() with its
1732 + // "There is no excerpt because this is a protected post." placeholder,
1733 + // so this is not a leak — but publishing that sentence as the social
1734 + // description is worse than publishing none (#363).
1735 + if (!$description && !$this->is_content_password_protected()) {
1736 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1215 1737 }
1216 1738
1217 1739 $url = is_singular() ? get_permalink() : home_url();
1218 1740 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1240,11 +1762,11 @@
1240 1762 $og_type = apply_filters('thinkrank_og_type', $og_type);
1241 1763
1242 1764 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1243 1765 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1244 - echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n";
1766 + echo "<meta property=\"og:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1245 1767 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1246 - echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n";
1768 + echo "<meta property=\"og:url\" content=\"" . esc_url(\ThinkRank\SEO\Url_Scheme::apply($url)) . "\" />\n";
1247 1769 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1248 1770 /**
1249 1771 * Filter the og:locale value.
1250 1772 *
@@ -1261,14 +1783,17 @@
1261 1783 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1262 1784 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1263 1785
1264 1786 // Add OG image — per-post override > featured image
1787 + $primary_og_image = '';
1265 1788 if (is_singular() && $this->current_post_id) {
1266 1789 if (!empty($og_image_override)) {
1790 + $primary_og_image = (string) $og_image_override;
1267 1791 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1268 1792 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1269 1793 } elseif (has_post_thumbnail($this->current_post_id)) {
1270 1794 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1795 + $primary_og_image = (string) $image_url;
1271 1796 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1272 1797 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1273 1798
1274 1799 // Get image dimensions and alt text
@@ -1274,12 +1799,16 @@
1274 1799 // Get image dimensions and alt text
1275 1800 $image_id = get_post_thumbnail_id($this->current_post_id);
1276 1801 $image_meta = wp_get_attachment_metadata($image_id);
1277 1802 if ($image_meta) {
1803 + // The `large` file being published, not the original it
1804 + // was generated from: the metadata's own width and height
1805 + // describe an image this tag does not point at (#847).
1806 + $image_file = \ThinkRank\SEO\Attachment_Lookup::describe((int) $image_id, (string) $image_url);
1278 1807 // SVGs (and other vector uploads) report 0x0 — emitting
1279 1808 // those as og:image dimensions is invalid, so skip them.
1280 - $og_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1281 - $og_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1809 + $og_width = $image_file['width'];
1810 + $og_height = $image_file['height'];
1282 1811 if ($og_width > 0 && $og_height > 0) {
1283 1812 echo "<meta property=\"og:image:width\" content=\"" . esc_attr($og_width) . "\" />\n";
1284 1813 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1285 1814 }
@@ -1284,9 +1813,9 @@
1284 1813 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1285 1814 }
1286 1815 // Derive the real mime type instead of hardcoding image/jpeg,
1287 1816 // which mislabels PNG/WebP featured images.
1288 - $image_mime = get_post_mime_type($image_id);
1817 + $image_mime = $image_file['type'];
1289 1818 if ($image_mime) {
1290 1819 echo "<meta property=\"og:image:type\" content=\"" . esc_attr($image_mime) . "\" />\n";
1291 1820 }
1292 1821 }
@@ -1297,8 +1826,30 @@
1297 1826 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1298 1827 }
1299 1828 }
1300 1829
1830 + // Alternatives, same as the enhanced emitter above. This path only
1831 + // runs when the Social Meta Manager is unavailable, but the issue
1832 + // reported against it (#636) and a site that lands here should not
1833 + // silently lose a feature it switched on.
1834 + if (!empty($primary_og_image)) {
1835 + $social_settings = $this->social_manager
1836 + ? $this->social_manager->get_settings(
1837 + $this->current_context === 'homepage' ? 'site' : $this->current_context,
1838 + $this->current_post_id
1839 + )
1840 + : [];
1841 +
1842 + if (!empty($social_settings['og_multiple_images'])) {
1843 + self::output_extra_og_images(
1844 + \ThinkRank\SEO\Social_Images::additional(
1845 + (int) $this->current_post_id,
1846 + $primary_og_image
1847 + )
1848 + );
1849 + }
1850 + }
1851 +
1301 1852 // Add article specific tags for posts only
1302 1853 if ($og_type === 'article') {
1303 1854 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1304 1855 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1326,8 +1877,21 @@
1326 1877 *
1327 1878 * @return void
1328 1879 */
1329 1880 public function output_twitter_card_tags(): void {
1881 + // Per-content-type Twitter card switch; see output_open_graph_tags().
1882 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1883 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_TWITTER,
1884 + true
1885 + )) {
1886 + return;
1887 + }
1888 +
1889 + // Same reasoning as the Open Graph block: nothing on a 404 is shareable.
1890 + if ($this->current_context === '404') {
1891 + return;
1892 + }
1893 +
1330 1894 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1331 1895 if ($this->social_manager) {
1332 1896 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1333 1897 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1374,8 +1938,14 @@
1374 1938 $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_title', true), $pid);
1375 1939 $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_description', true), $pid);
1376 1940 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_title', true), $pid);
1377 1941 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_description', true), $pid);
1942 + } elseif ($this->current_term_id) {
1943 + $tid = $this->current_term_id;
1944 + $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_title', true), $tid);
1945 + $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_description', true), $tid);
1946 + $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_title', true), $tid);
1947 + $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_description', true), $tid);
1378 1948 }
1379 1949
1380 1950 // Title cascade: Twitter override > OG override > Global SEO > Site Identity > default
1381 1951 $title = '';
@@ -1400,10 +1970,14 @@
1400 1970 $description = $og_description_override;
1401 1971 } else {
1402 1972 $description = $this->get_meta_description();
1403 1973 }
1404 - if (!$description) {
1405 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1974 + // Skipped for a protected post: core answers get_the_excerpt() with its
1975 + // "There is no excerpt because this is a protected post." placeholder,
1976 + // so this is not a leak — but publishing that sentence as the social
1977 + // description is worse than publishing none (#363).
1978 + if (!$description && !$this->is_content_password_protected()) {
1979 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1406 1980 }
1407 1981
1408 1982 // Determine card type based on image availability
1409 1983 $card_type = 'summary';
@@ -1412,9 +1986,9 @@
1412 1986 }
1413 1987
1414 1988 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1415 1989 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1416 - echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
1990 + echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1417 1991 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1418 1992
1419 1993 // Add Twitter image with proper fallback priority
1420 1994 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1464,11 +2038,18 @@
1464 2038 }
1465 2039
1466 2040 if (empty($canonical_url)) {
1467 2041 $canonical_url = $this->current_post_id ? get_permalink($this->current_post_id) : get_permalink();
2042 +
2043 + // Core's rel_canonical() keeps the page number; this replaced
2044 + // it with a bare permalink, so every <!--nextpage--> sub-page
2045 + // and every /comment-page-N/ canonicalised to page 1 — a
2046 + // regression against core behaviour (#397). A custom canonical
2047 + // is left exactly as the user typed it.
2048 + $canonical_url = self::with_singular_page($canonical_url);
1468 2049 }
1469 2050 } else {
1470 - $canonical_url = $this->get_non_singular_canonical_url();
2051 + $canonical_url = self::get_non_singular_canonical_url();
1471 2052 }
1472 2053
1473 2054 /**
1474 2055 * Filter the canonical URL before output.
@@ -1482,14 +2063,115 @@
1482 2063 if (empty($canonical_url)) {
1483 2064 return;
1484 2065 }
1485 2066
2067 + // After the filter, so a canonical an add-on supplied is normalized
2068 + // too — and a cross-domain one is left alone, since Url_Scheme only
2069 + // touches URLs on this site's own host.
2070 + $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2071 +
1486 2072 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1487 2073 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1488 2074 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
2075 +
2076 + $this->output_pagination_links();
1489 2077 }
1490 2078
1491 2079 /**
2080 + * Emit rel="prev" / rel="next" on a paginated archive.
2081 + *
2082 + * Nothing emitted these at all (#397). Google stopped using them as an
2083 + * indexing signal in 2019, so this is not an SEO win with Google — Bing
2084 + * still reads them, and they are the standard way to describe a sequence,
2085 + * which is what the pages are.
2086 + *
2087 + * @since 2.0.1
2088 + *
2089 + * @return void
2090 + */
2091 + private function output_pagination_links(): void {
2092 + // Page 1 still wants a rel="next" when there is a page 2, so only
2093 + // singular views are skipped outright.
2094 + if (is_singular()) {
2095 + return;
2096 + }
2097 +
2098 + global $wp_query;
2099 +
2100 + $total = $wp_query ? (int) $wp_query->max_num_pages : 0;
2101 +
2102 + if ($total < 2) {
2103 + return;
2104 + }
2105 +
2106 + $base = self::get_non_singular_canonical_url();
2107 +
2108 + if ('' === $base) {
2109 + return;
2110 + }
2111 +
2112 + // get_non_singular_canonical_url() already carries the current page —
2113 + // strip it back to page 1 before building the neighbours.
2114 + $current = self::current_page_number();
2115 + $base = self::without_pagination($base);
2116 +
2117 + if ($current > 1) {
2118 + printf(
2119 + "<link rel=\"prev\" href=\"%s\" />\n",
2120 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
2121 + );
2122 + }
2123 +
2124 + if ($current < $total) {
2125 + printf(
2126 + "<link rel=\"next\" href=\"%s\" />\n",
2127 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
2128 + );
2129 + }
2130 + }
2131 +
2132 + /**
2133 + * The rewrite base WordPress uses for page numbers ('page' by default).
2134 + *
2135 + * @since 2.0.1
2136 + *
2137 + * @return string
2138 + */
2139 + private static function pagination_base(): string {
2140 + global $wp_rewrite;
2141 +
2142 + return $wp_rewrite && $wp_rewrite->pagination_base ? $wp_rewrite->pagination_base : 'page';
2143 + }
2144 +
2145 + /**
2146 + * Append the sub-page or comment-page number to a singular canonical.
2147 + *
2148 + * @since 2.0.1
2149 + *
2150 + * @param string $url Permalink.
2151 + * @return string Permalink with the current page appended, when there is one.
2152 + */
2153 + public static function with_singular_page(string $url): string {
2154 + global $wp_rewrite;
2155 +
2156 + $page = (int) get_query_var('page');
2157 +
2158 + if ($page > 1) {
2159 + return $wp_rewrite && $wp_rewrite->using_permalinks()
2160 + ? trailingslashit($url) . user_trailingslashit($page, 'single_paged')
2161 + : add_query_arg('page', $page, $url);
2162 + }
2163 +
2164 + $comment_page = (int) get_query_var('cpage');
2165 +
2166 + if ($comment_page > 1) {
2167 + return get_comments_pagenum_link($comment_page);
2168 + }
2169 +
2170 + return $url;
2171 + }
2172 +
2173 + /**
1492 2174 * Build the canonical URL for non-singular contexts.
1493 2175 *
1494 2176 * Covers the blog home, post type / taxonomy / author / date archives.
1495 2177 * Search results and 404 pages get no canonical (they are noindexed).
@@ -1497,9 +2179,9 @@
1497 2179 * self-referential rather than pointing at page 1.
1498 2180 *
1499 2181 * @return string Canonical URL or '' when none applies
1500 2182 */
1501 - private function get_non_singular_canonical_url(): string {
2183 + public static function get_non_singular_canonical_url(): string {
1502 2184 if (is_404() || is_search()) {
1503 2185 return '';
1504 2186 }
1505 2187
@@ -1530,17 +2212,89 @@
1530 2212 return '';
1531 2213 }
1532 2214
1533 2215 // Point paginated archives at their own page, not page 1.
2216 + return self::with_pagination($canonical_url, (int) get_query_var('paged'));
2217 + }
2218 +
2219 + /**
2220 + * Append a page number to a URL the way WordPress does.
2221 + *
2222 + * Extracted so the archive canonical is not the only thing that knows how
2223 + * to build a paged URL: the schema graph derived its @id from the
2224 + * un-paginated link, so every page of an archive claimed the same node
2225 + * identity, and the singular canonical dropped the page entirely (#397).
2226 + *
2227 + * @since 2.0.1
2228 + *
2229 + * @param string $url Base URL.
2230 + * @param int $page Page number; 1 or less returns the URL unchanged.
2231 + * @return string
2232 + */
2233 + public static function with_pagination(string $url, int $page): string {
2234 + if ($page <= 1 || '' === $url) {
2235 + return $url;
2236 + }
2237 +
2238 + global $wp_rewrite;
2239 +
2240 + if ($wp_rewrite && $wp_rewrite->using_permalinks()) {
2241 + return trailingslashit($url) . user_trailingslashit(
2242 + $wp_rewrite->pagination_base . '/' . $page,
2243 + 'paged'
2244 + );
2245 + }
2246 +
2247 + return add_query_arg('paged', $page, $url);
2248 + }
2249 +
2250 + /**
2251 + * Strip a page number from a URL, whichever form it takes.
2252 + *
2253 + * The inverse of with_pagination(). Pretty permalinks carry the page as a
2254 + * /page/N/ path segment, plain permalinks as a `paged` query arg, and a
2255 + * regex over the path alone silently left the latter in place — so
2256 + * rel="prev" on page 2 pointed at page 2 (#397 review).
2257 + *
2258 + * @since 2.0.1
2259 + *
2260 + * @param string $url URL that may carry a page number.
2261 + * @return string URL for page 1.
2262 + */
2263 + public static function without_pagination(string $url): string {
2264 + if ('' === $url) {
2265 + return $url;
2266 + }
2267 +
2268 + $url = remove_query_arg('paged', $url);
2269 +
2270 + return (string) preg_replace(
2271 + '#/' . preg_quote(self::pagination_base(), '#') . '/\d+/?$#',
2272 + '/',
2273 + $url
2274 + );
2275 + }
2276 +
2277 + /**
2278 + * The page number of the current request, archive or multi-page post.
2279 + *
2280 + * `paged` counts archive pages; `page` counts the <!--nextpage--> parts of
2281 + * a single post. They are never both set.
2282 + *
2283 + * @since 2.0.1
2284 + *
2285 + * @return int Page number, 1 when this is the first page.
2286 + */
2287 + public static function current_page_number(): int {
1534 2288 $paged = (int) get_query_var('paged');
2289 +
1535 2290 if ($paged > 1) {
1536 - global $wp_rewrite;
1537 - $canonical_url = $wp_rewrite->using_permalinks()
1538 - ? trailingslashit($canonical_url) . user_trailingslashit($wp_rewrite->pagination_base . '/' . $paged, 'paged')
1539 - : add_query_arg('paged', $paged, $canonical_url);
2291 + return $paged;
1540 2292 }
1541 2293
1542 - return $canonical_url;
2294 + $page = (int) get_query_var('page');
2295 +
2296 + return $page > 1 ? $page : 1;
1543 2297 }
1544 2298
1545 2299
1546 2300 /**
@@ -1548,12 +2302,13 @@
1548 2302 *
1549 2303 * @return bool True if has ThinkRank metadata
1550 2304 */
1551 2305 private function has_thinkrank_metadata(): bool {
1552 - if (!is_singular()) {
1553 - return false;
1554 - }
1555 -
2306 + // Populated by initialize_current_context() for singular views and for
2307 + // term archives, and left empty everywhere else — so the emptiness
2308 + // check is the whole test. The `!is_singular()` early return this
2309 + // replaced is what made every stored term title and description inert:
2310 + // the entire title/description cascade hangs off this method (#386).
1556 2311 return !empty($this->current_metadata['title']) || !empty($this->current_metadata['description']);
1557 2312 }
1558 2313
1559 2314 /**
@@ -1687,12 +2442,21 @@
1687 2442
1688 2443 // Get excerpt
1689 2444 $post = get_post($this->current_post_id);
1690 2445 if ($post) {
1691 - $excerpt = !empty($post->post_excerpt)
1692 - ? $post->post_excerpt
1693 - : wp_trim_words(wp_strip_all_tags($post->post_content), 25, '...');
1694 - $placeholders['%excerpt%'] = $excerpt;
2446 + // An authored post_excerpt is written for public consumption, so
2447 + // it stays. Falling back to the body does not: for a protected
2448 + // post that derivation leaks the gated content through any
2449 + // template containing %excerpt%, and this branch runs BEFORE the
2450 + // derive-from-content priority below, so guarding only that one
2451 + // would leave this path open (#363).
2452 + if (!empty($post->post_excerpt)) {
2453 + $placeholders['%excerpt%'] = $post->post_excerpt;
2454 + } elseif (!$this->is_content_password_protected($post->ID)) {
2455 + $placeholders['%excerpt%'] = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
2456 + \ThinkRank\SEO\Builder_Content::visible_content($post)
2457 + );
2458 + }
1695 2459 }
1696 2460
1697 2461 // Get author
1698 2462 $author_id = get_post_field('post_author', $this->current_post_id);
@@ -1752,8 +2516,17 @@
1752 2516 $settings = $this->site_identity_manager->get_settings('site');
1753 2517
1754 2518 switch ($this->current_context) {
1755 2519 case 'homepage':
2520 + // detect_current_context() collapses the static posts page into
2521 + // 'homepage', so it rendered the front page's title template and
2522 + // the two pages shipped the same <title> — a duplicate title on
2523 + // the site's two most-linked URLs (#397 review). It is a page,
2524 + // and it has its own name, so it gets the page template.
2525 + if (self::is_static_posts_page()) {
2526 + return $settings['page_title'] ?? $settings['homepage_title'] ?? null;
2527 + }
2528 +
1756 2529 return $settings['homepage_title'] ?? null;
1757 2530 case 'post':
1758 2531 return $settings['post_title'] ?? null;
1759 2532 case 'page':
@@ -1784,12 +2557,18 @@
1784 2557 $settings = $this->site_identity_manager->get_settings('site');
1785 2558 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
1786 2559
1787 2560 $placeholders = [
1788 - '%site_title%' => $settings['site_name'] ?? get_bloginfo('name'),
1789 - '%site_name%' => $settings['site_name'] ?? get_bloginfo('name'),
1790 - '%site_description%' => $settings['site_description'] ?? get_bloginfo('description'),
1791 - '%tagline%' => $settings['tagline'] ?? get_bloginfo('description'),
2561 + // first_non_empty(), not `??`: Site Identity persists these as ''
2562 + // rather than leaving them unset, and '' is not null — so the
2563 + // null-coalesce stopped dead on the empty string and the WordPress
2564 + // fallback was unreachable. A site with a tagline set in Settings →
2565 + // General rendered "%site_description%" as nothing (#398). This is
2566 + // the same reasoning first_non_empty()'s own docblock records.
2567 + '%site_title%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2568 + '%site_name%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2569 + '%site_description%' => $this->first_non_empty($settings['site_description'] ?? '', get_bloginfo('description')),
2570 + '%tagline%' => $this->first_non_empty($settings['tagline'] ?? '', get_bloginfo('description')),
1792 2571 '%separator%' => ' ' . $separator . ' ',
1793 2572 '%sep%' => ' ' . $separator . ' ',
1794 2573 '%date%' => gmdate('F Y'),
1795 2574 ];
@@ -1842,10 +2621,26 @@
1842 2621 $placeholders['%search_term%'] = get_search_query();
1843 2622 break;
1844 2623
1845 2624 case 'archive':
1846 - $placeholders['%archive_title%'] = get_the_archive_title();
2625 + // Stripped: get_the_archive_title() wraps its subject in a
2626 + // <span>, and this placeholder feeds the document <title> as
2627 + // well as og:title and twitter:title — a date archive rendered
2628 + // as "Month: <span>August 2026</span> | Site".
2629 + $placeholders['%archive_title%'] = self::archive_subject();
1847 2630 break;
2631 +
2632 + case 'homepage':
2633 + // The page template resolved for a static posts page needs the
2634 + // page's own name; without it %title%/%page_title% would render
2635 + // empty and collapse back to the site title.
2636 + if (self::is_static_posts_page()) {
2637 + $posts_page_title = get_the_title((int) get_option('page_for_posts'));
2638 + $placeholders['%title%'] = $posts_page_title;
2639 + $placeholders['%page_title%'] = $posts_page_title;
2640 + $placeholders['%post_title%'] = $posts_page_title;
2641 + }
2642 + break;
1848 2643 }
1849 2644
1850 2645 return $placeholders;
1851 2646 }
@@ -1850,8 +2645,19 @@
1850 2645 return $placeholders;
1851 2646 }
1852 2647
1853 2648 /**
2649 + * Whether this request is a static posts page rather than the front page.
2650 + *
2651 + * @since 2.0.1
2652 + *
2653 + * @return bool
2654 + */
2655 + private static function is_static_posts_page(): bool {
2656 + return is_home() && !is_front_page() && (int) get_option('page_for_posts') > 0;
2657 + }
2658 +
2659 + /**
1854 2660 * Process title template with placeholders
1855 2661 *
1856 2662 * @param string $template Template string
1857 2663 * @param array $placeholders Placeholder values
@@ -1888,8 +2694,42 @@
1888 2694 return \ThinkRank\SEO\Site_Identity_Manager::$title_separators[$separator_type]['symbol'] ?? \ThinkRank\SEO\Site_Identity_Manager::$title_separators['pipe']['symbol'];
1889 2695 }
1890 2696
1891 2697 /**
2698 + * Whether a post's body must not be read for a public surface.
2699 + *
2700 + * Deriving metadata from `post_content` publishes that content to everyone
2701 + * who requests the URL — and to every crawler and link-preview unfurler
2702 + * that reads og:description — while the page itself still shows only the
2703 + * password form, so the leak is invisible to the site owner (#363).
2704 + *
2705 + * This is the one thing every content reader should call before touching
2706 + * `post_content` for output. It mirrors core: a visitor who has already
2707 + * entered the correct password sees the body anyway, so nothing is hidden
2708 + * from them here either.
2709 + *
2710 + * @param int|null $post_id Optional. Post ID. Defaults to the current post.
2711 + * @return bool True when the body is password-gated for this visitor.
2712 + */
2713 + private function is_content_password_protected(?int $post_id = null): bool {
2714 + $post_id = $post_id ?? $this->current_post_id;
2715 +
2716 + if (!$post_id) {
2717 + return false;
2718 + }
2719 +
2720 + $post = get_post($post_id);
2721 +
2722 + if (!$post) {
2723 + return false;
2724 + }
2725 +
2726 + // Guarded for the same reason the schema path guards it: this class is
2727 + // also exercised outside a full front-end request.
2728 + return function_exists('post_password_required') && post_password_required($post);
2729 + }
2730 +
2731 + /**
1892 2732 * Get meta description with fallback system
1893 2733 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
1894 2734 *
1895 2735 * @return string|null Meta description or null if none available
@@ -1912,8 +2752,22 @@
1912 2752 if ($archive_description) {
1913 2753 return $archive_description;
1914 2754 }
1915 2755
2756 + // The blog-index homepage's own description (Site Identity
2757 + // homepage_description, #897), in the same vocabulary as its title. A
2758 + // static front page is a page, and its description is set on it.
2759 + if (is_front_page() && is_home() && $this->site_identity_data && $this->site_identity_data['enabled']) {
2760 + $identity = $this->site_identity_manager->get_settings('site');
2761 + $template = trim((string) ($identity['homepage_description'] ?? ''));
2762 + if ($template !== '') {
2763 + $homepage_description = trim($this->process_title_template($template, $this->get_title_placeholders()));
2764 + if ($homepage_description !== '') {
2765 + return $homepage_description;
2766 + }
2767 + }
2768 + }
2769 +
1916 2770 // Third priority: Site Identity default meta description
1917 2771 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
1918 2772 $settings = $this->site_identity_manager->get_settings('site');
1919 2773 $default_description = $settings['default_meta_description'] ?? '';
@@ -1922,13 +2776,23 @@
1922 2776 return $default_description;
1923 2777 }
1924 2778 }
1925 2779
1926 - // Fourth priority: Generate from content for posts/pages
1927 - if (is_singular() && $this->current_post_id) {
1928 - $post_content = get_post_field('post_content', $this->current_post_id);
2780 + // Fourth priority: Generate from content for posts/pages.
2781 + // Never for a password-protected post — deriving the description from a
2782 + // gated body published its first ~25 words in the page head, and the
2783 + // same value is reused for og:description and twitter:description, so
2784 + // one unguarded read leaked through three tags (#363).
2785 + if (is_singular() && $this->current_post_id && !$this->is_content_password_protected()) {
2786 + // Not the raw column: a Bricks page discards `post_content`, so
2787 + // whatever is still stored there is invisible — and this one value
2788 + // becomes the meta, og: and twitter: descriptions (#651).
2789 + $described = get_post($this->current_post_id);
2790 + $post_content = $described instanceof \WP_Post
2791 + ? \ThinkRank\SEO\Builder_Content::visible_content($described)
2792 + : get_post_field('post_content', $this->current_post_id);
1929 2793 if ($post_content) {
1930 - $excerpt = wp_trim_words(wp_strip_all_tags($post_content), 25, '...');
2794 + $excerpt = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post_content);
1931 2795 if (!empty($excerpt)) {
1932 2796 return $excerpt;
1933 2797 }
1934 2798 }
@@ -1972,11 +2836,13 @@
1972 2836 if ($description === '') {
1973 2837 return null;
1974 2838 }
1975 2839
1976 - if (strlen($description) > 160) {
1977 - $description = wp_trim_words($description, 25, '...');
1978 - }
2840 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2841 + // CJK description tripped this limit at a third of its length, and
2842 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2843 + // English, 25 characters in Thai (#687).
2844 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
1979 2845
1980 2846 return $description;
1981 2847 }
1982 2848
@@ -2023,11 +2889,13 @@
2023 2889 $description = preg_replace('/\s+/', ' ', $description);
2024 2890 $description = trim($description);
2025 2891
2026 2892 // Ensure description doesn't exceed recommended length (160 characters)
2027 - if (strlen($description) > 160) {
2028 - $description = wp_trim_words($description, 25, '...');
2029 - }
2893 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2894 + // CJK description tripped this limit at a third of its length, and
2895 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2896 + // English, 25 characters in Thai (#687).
2897 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2030 2898
2031 2899 return $description;
2032 2900 }
2033 2901
@@ -2041,8 +2909,19 @@
2041 2909 public function output_site_schema_markup(): void {
2042 2910 $has_schema_manager_output = false;
2043 2911 $has_website_schema = false;
2044 2912
2913 + // The master switch on Essential SEO -> Schema Manager. Until #461 this
2914 + // was never read here, so turning schema off left every deployed entity
2915 + // on the page. Read it once and bail before touching the graph.
2916 + if ($this->schema_manager) {
2917 + $schema_settings = $this->schema_manager->get_settings('site', null);
2918 +
2919 + if (isset($schema_settings['enabled']) && !$schema_settings['enabled']) {
2920 + return;
2921 + }
2922 + }
2923 +
2045 2924 // PRIORITY 1: Always output site-wide schemas (Organization, Website, LocalBusiness, Person)
2046 2925 if ($this->schema_manager) {
2047 2926 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
2048 2927
@@ -2047,9 +2926,9 @@
2047 2926 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
2048 2927
2049 2928 if (!empty($site_wide_schemas)) {
2050 2929 foreach ($site_wide_schemas as $schema_type => $schema_info) {
2051 - $this->output_schema_markup($schema_info['data'], $schema_type, 'Schema Manager');
2930 + Schema_Graph::instance()->add_supporting($schema_info['data'], (string) $schema_type);
2052 2931 }
2053 2932 $has_schema_manager_output = true;
2054 2933 $has_website_schema = isset($site_wide_schemas['WebSite']);
2055 2934 }
@@ -2070,9 +2949,9 @@
2070 2949 */
2071 2950 $website_schema = apply_filters('thinkrank_website_schema', $website_schema);
2072 2951
2073 2952 if (!empty($website_schema)) {
2074 - $this->output_schema_markup($website_schema, 'WebSite', 'Site Identity');
2953 + Schema_Graph::instance()->add_supporting($website_schema, 'WebSite');
2075 2954 }
2076 2955 }
2077 2956
2078 2957 // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages
@@ -2083,10 +2962,19 @@
2083 2962
2084 2963 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2085 2964
2086 2965 if (!empty($page_specific_schemas)) {
2087 - // Apply filter for Pro to allow multiple schemas
2088 - // In free version, it's limited to 1 schema if not filtered
2966 + // Every deployed schema is rendered, on every plan. How many a
2967 + // page carries is decided when schemas are activated in the
2968 + // editor, not trimmed here by plan (#673).
2969 +
2970 + /**
2971 + * Filter the page-specific schemas rendered on the current page.
2972 + *
2973 + * @param array $page_specific_schemas Deployed schemas keyed by schema type.
2974 + * @param string $context_type Context type (post, page, product, site).
2975 + * @param int $context_id Post ID.
2976 + */
2089 2977 $page_specific_schemas = apply_filters(
2090 2978 'thinkrank_page_schemas_to_render',
2091 2979 $page_specific_schemas,
2092 2980 $context_type,
@@ -2092,21 +2980,37 @@
2092 2980 $context_type,
2093 2981 $context_id
2094 2982 );
2095 2983
2096 - // If still multiple schemas and not Pro, limit to 1 (enforcing free limit)
2097 - $is_pro = \ThinkRank\Core\Plan_Config::is_pro();
2098 - if (!$is_pro && count($page_specific_schemas) > 2) {
2099 - $page_specific_schemas = array_slice($page_specific_schemas, 0, 2, true);
2100 - }
2984 + // A deployed node is a snapshot from Deploy time and outranks
2985 + // the automatic node, so page and article types would publish
2986 + // a frozen excerpt instead of the description the head
2987 + // resolves. Give them the live one, as the automatic node has.
2988 + $context_post = get_post($context_id);
2101 2989
2102 2990 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2103 - $this->output_schema_markup($schema_info['data'], $schema_type, 'Schema Manager');
2991 + $node = $schema_info['data'];
2992 +
2993 + if ($this->global_seo_schema && $context_post instanceof \WP_Post) {
2994 + $node = $this->global_seo_schema->refresh_deployed_description($node, (string) $schema_type, $context_post);
2995 + }
2996 +
2997 + Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager');
2104 2998 }
2105 2999 $has_schema_manager_output = true;
2106 3000 }
2107 3001 }
2108 3002
3003 + // Absorb FAQ content from the post body (FAQ block / Elementor widget)
3004 + // so it merges into the graph's single FAQPage instead of each producer
3005 + // emitting its own competing one.
3006 + if (is_singular()) {
3007 + $queried_post = get_post();
3008 + if ($queried_post instanceof \WP_Post) {
3009 + Schema_Graph::instance()->collect_post_faq($queried_post);
3010 + }
3011 + }
3012 +
2109 3013 // Skip Site Identity fallback if any Schema Manager schemas were output
2110 3014 if ($has_schema_manager_output) {
2111 3015 return;
2112 3016 }
@@ -2125,9 +3029,9 @@
2125 3029
2126 3030 $schema = $this->generate_organization_schema($settings);
2127 3031
2128 3032 if ($schema) {
2129 - $this->output_schema_markup($schema, 'Organization', 'Site Identity');
3033 + Schema_Graph::instance()->add_supporting($schema, 'Organization');
2130 3034 }
2131 3035 }
2132 3036
2133 3037 /**
@@ -2149,42 +3053,50 @@
2149 3053 'url' => home_url('/'),
2150 3054 ];
2151 3055
2152 3056 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
3057 + // The tagline is stored esc_html()'d by sanitize_option(), so a site
3058 + // called "Fish & Chips" published `&amp;` literally in its WebSite
3059 + // node; nothing decodes JSON-LD downstream.
3060 + $description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $description);
2153 3061 if (!empty($description)) {
2154 3062 $schema['description'] = $description;
2155 3063 }
2156 3064
2157 - $schema['potentialAction'] = [
2158 - '@type' => 'SearchAction',
2159 - 'target' => [
2160 - '@type' => 'EntryPoint',
2161 - 'urlTemplate' => home_url('/?s={search_term_string}'),
2162 - ],
2163 - 'query-input' => 'required name=search_term_string',
2164 - ];
3065 + // Site Identity has accepted an alternate name since the setup wizard
3066 + // shipped, and the MCP ability describes it as "published as schema
3067 + // alternateName" — but no producer ever read it, so the promise was
3068 + // false and every imported Yoast/Rank Math value sat unused (#692).
3069 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3070 + if (null !== $alternate_name) {
3071 + $schema['alternateName'] = $alternate_name;
3072 + }
2165 3073
2166 - return $schema;
2167 - }
3074 + // The sitelinks searchbox switch was honoured only for a deployed
3075 + // WebSite row; this live fallback added potentialAction unconditionally,
3076 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3077 + // Absent means not configured, which stays enabled.
3078 + $search_enabled = true;
3079 + if ($this->schema_manager) {
3080 + $schema_settings = $this->schema_manager->get_settings('site', null);
2168 3081
2169 - /**
2170 - * Output schema markup with consistent formatting
2171 - *
2172 - * @param array $schema_data Schema data
2173 - * @param string $schema_type Schema type name
2174 - * @param string $source Source of schema (Schema Manager, Site Identity, etc.)
2175 - * @return void
2176 - */
2177 - private function output_schema_markup(array $schema_data, string $schema_type, string $source): void {
2178 - if (empty($schema_data)) {
2179 - return;
3082 + if (array_key_exists('website_enable_search', $schema_settings)) {
3083 + $search_enabled = !empty($schema_settings['website_enable_search']);
3084 + }
2180 3085 }
2181 3086
2182 - echo '<!-- ThinkRank ' . esc_html($source) . ': ' . esc_html($schema_type) . ' Schema -->' . "\n";
2183 - echo '<script type="application/ld+json">' . "\n";
2184 - echo wp_json_encode($schema_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . "\n";
2185 - echo '</script>' . "\n";
2186 - echo '<!-- /ThinkRank ' . esc_html($source) . ': ' . esc_html($schema_type) . ' Schema -->' . "\n";
3087 + if ($search_enabled) {
3088 + $schema['potentialAction'] = [
3089 + '@type' => 'SearchAction',
3090 + 'target' => [
3091 + '@type' => 'EntryPoint',
3092 + 'urlTemplate' => home_url('/?s={search_term_string}'),
3093 + ],
3094 + 'query-input' => 'required name=search_term_string',
3095 + ];
3096 + }
3097 +
3098 + return $schema;
2187 3099 }
2188 3100
2189 3101 /**
2190 3102 * Generate organization schema markup
@@ -2380,8 +3292,15 @@
2380 3292 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2381 3293 return;
2382 3294 }
2383 3295
3296 + // A breadcrumb trail for a URL that does not exist, or for a search
3297 + // results page, describes nothing — and the plugin already emits no
3298 + // canonical on either (#471).
3299 + if (is_404() || is_search()) {
3300 + return;
3301 + }
3302 +
2384 3303 $settings = $this->site_identity_manager->get_settings('site');
2385 3304
2386 3305 // Only output if breadcrumbs are enabled
2387 3306 if (empty($settings['breadcrumbs_enabled'])) {
@@ -2387,42 +3306,74 @@
2387 3306 if (empty($settings['breadcrumbs_enabled'])) {
2388 3307 return;
2389 3308 }
2390 3309
3310 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3311 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3312 + // = 0 removed a deployed BreadcrumbList row and left this live one
3313 + // emitting the node anyway (#688). Absent means not configured, which
3314 + // stays enabled.
3315 + if ($this->schema_manager) {
3316 + $schema_settings = $this->schema_manager->get_settings('site', null);
3317 +
3318 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3319 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3320 + return;
3321 + }
3322 + }
3323 +
2391 3324 $breadcrumbs = $this->generate_breadcrumbs($settings);
2392 3325
2393 3326 if (!empty($breadcrumbs['schema'])) {
2394 - echo "<!-- ThinkRank SEO Breadcrumb Schema Markup -->\n";
2395 - echo '<script type="application/ld+json">' . "\n";
2396 - echo wp_json_encode($breadcrumbs['schema'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . "\n";
2397 - echo '</script>' . "\n";
2398 - echo "<!-- /ThinkRank SEO Breadcrumb Schema Markup -->\n";
3327 + Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
2399 3328 }
2400 3329 }
2401 3330
2402 3331 /**
3332 + * Emit everything ThinkRank collected for this request as one linked @graph.
3333 + *
3334 + * Runs after every producer has registered (site schema 7, breadcrumbs 8,
3335 + * Global SEO 15), so the graph can arbitrate between them.
3336 + *
3337 + * @since 1.32.0
3338 + * @return void
3339 + */
3340 + public function output_schema_graph(): void {
3341 + Schema_Graph::instance()->render();
3342 + }
3343 +
3344 + /**
2403 3345 * Output closing comment for ThinkRank SEO
2404 3346 *
2405 3347 * @return void
2406 3348 */
2407 3349 public function output_closing_comment(): void {
2408 - // Only output if we've output any SEO content
2409 - static $header_output = false;
2410 - if ($header_output || $this->has_seo_output()) {
3350 + // Close only what was actually opened. has_seo_output() is true on
3351 + // nearly every page, so testing it here printed a closing comment with
3352 + // no matching opener whenever the meta description was empty (search
3353 + // results, author archives without a description).
3354 + if (self::$opening_comment_output) {
2411 3355 echo "<!-- /ThinkRank SEO -->\n";
2412 3356 }
2413 3357 }
2414 3358
2415 3359 /**
2416 - * Check if any SEO content has been output
3360 + * Print the opening ThinkRank comment, once per request.
2417 3361 *
2418 - * @return bool True if SEO content was output
3362 + * Public and static so Author_Archives_Manager — which prints its own meta
3363 + * description on wp_head at priority 5 — opens the block through the same
3364 + * flag the closing comment reads.
3365 + *
3366 + * @since 2.0.1
3367 + * @return void
2419 3368 */
2420 - private function has_seo_output(): bool {
2421 - // Check if we have meta description or any other SEO data
2422 - return !empty($this->get_meta_description()) ||
2423 - $this->has_thinkrank_metadata() ||
2424 - ($this->site_identity_data && $this->site_identity_data['enabled']);
3369 + public static function note_opening_comment(): void {
3370 + if (self::$opening_comment_output) {
3371 + return;
3372 + }
3373 +
3374 + echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
3375 + self::$opening_comment_output = true;
2425 3376 }
2426 3377
2427 3378 /**
2428 3379 * Display breadcrumbs HTML
@@ -2616,8 +3567,151 @@
2616 3567 return $breadcrumbs;
2617 3568 }
2618 3569
2619 3570 /**
3571 + * Serve /llms.txt through PHP so the response declares UTF-8.
3572 + *
3573 + * Cheap guard first: every other front-end request leaves without loading
3574 + * the manager.
3575 + *
3576 + * @since 1.32.0
3577 + *
3578 + * @return void
3579 + */
3580 + /**
3581 + * Serve a ThinkRank sitemap document for this request, when it is one.
3582 + *
3583 + * Only acts in dynamic delivery mode. In static mode a real file exists and
3584 + * the web server returns it without WordPress ever loading, so answering
3585 + * here as well would mean two sources for the same bytes.
3586 + *
3587 + * @since 2.9.0
3588 + *
3589 + * @return void
3590 + */
3591 + public function maybe_serve_sitemap(): void {
3592 + $filename = $this->requested_sitemap_filename();
3593 + if ('' === $filename) {
3594 + return;
3595 + }
3596 +
3597 + try {
3598 + // Read-only instance: passing false keeps it from registering a
3599 + // second copy of the auto-generation hooks.
3600 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3601 + $settings = $generator->get_settings('site');
3602 +
3603 + if (empty($settings['enabled'])) {
3604 + return;
3605 + }
3606 +
3607 + if ('dynamic' !== $generator->resolve_delivery_mode($settings)) {
3608 + return;
3609 + }
3610 +
3611 + if (!$generator->publishes_document_name($filename, $settings)) {
3612 + return;
3613 + }
3614 +
3615 + $xml = $generator->render_document($filename, $settings);
3616 + } catch (\Throwable $e) {
3617 + // A failed render must not replace the sitemap with a fatal. Leave
3618 + // the request alone so WordPress answers as it otherwise would.
3619 + return;
3620 + }
3621 +
3622 + if (!is_string($xml) || '' === trim($xml)) {
3623 + return;
3624 + }
3625 +
3626 + status_header(200);
3627 + header('Content-Type: application/xml; charset=UTF-8');
3628 + header('X-Robots-Tag: noindex, follow', true);
3629 +
3630 + // Built XML, escaped by the builders as they assemble it; escaping the
3631 + // document here would corrupt it.
3632 + echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3633 + exit;
3634 + }
3635 +
3636 + /**
3637 + * The sitemap file name this request is asking for, if it looks like one.
3638 + *
3639 + * Deliberately a cheap shape test. Whether the site actually publishes the
3640 + * name is settled by the caller against the generator, so that a request
3641 + * for someone else's sitemap is never answered here.
3642 + *
3643 + * @since 2.9.0
3644 + *
3645 + * @return string File name, or '' when this is not a sitemap request.
3646 + */
3647 + private function requested_sitemap_filename(): string {
3648 + if (empty($_SERVER['REQUEST_URI'])) {
3649 + return '';
3650 + }
3651 +
3652 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3653 + if (!is_string($path) || '' === $path) {
3654 + return '';
3655 + }
3656 +
3657 + // Strip the install's home path so subdirectory installs match too.
3658 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3659 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3660 + $path = substr($path, strlen($home_path));
3661 + }
3662 +
3663 + $candidate = strtolower(trim($path, '/'));
3664 +
3665 + // One path segment ending in .xml. Anything nested is not a file we
3666 + // publish to the web root.
3667 + if ('' === $candidate || strpos($candidate, '/') !== false) {
3668 + return '';
3669 + }
3670 +
3671 + return substr($candidate, -4) === '.xml' ? $candidate : '';
3672 + }
3673 +
3674 + public function maybe_serve_llms_txt(): void {
3675 + if (!$this->is_llms_txt_request()) {
3676 + return;
3677 + }
3678 +
3679 + if (!class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) {
3680 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-llms-txt-manager.php';
3681 + }
3682 +
3683 + $manager = new \ThinkRank\SEO\LLMs_Txt_Manager();
3684 + $manager->serve_llms_txt();
3685 + }
3686 +
3687 + /**
3688 + * Whether the current request is for /llms.txt.
3689 + *
3690 + * @since 1.32.0
3691 + *
3692 + * @return bool
3693 + */
3694 + private function is_llms_txt_request(): bool {
3695 + if (empty($_SERVER['REQUEST_URI'])) {
3696 + return false;
3697 + }
3698 +
3699 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3700 + if (!is_string($path) || '' === $path) {
3701 + return false;
3702 + }
3703 +
3704 + // Strip the install's home path so subdirectory installs match too.
3705 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3706 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3707 + $path = substr($path, strlen($home_path));
3708 + }
3709 +
3710 + return 'llms.txt' === strtolower(trim($path, '/'));
3711 + }
3712 +
3713 + /**
2620 3714 * Filter WordPress robots.txt output
2621 3715 *
2622 3716 * @param string $output The default robots.txt output
2623 3717 * @param string $is_public Whether the site is public
@@ -2778,11 +3872,22 @@
2778 3872 // second copy of the save_post/term auto-generation hooks.
2779 3873 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
2780 3874 $settings = $generator->get_settings('site');
2781 3875
3876 + // "Can ThinkRank actually answer its sitemap URL right now?" In
3877 + // static mode that means the file is on disk; in dynamic mode
3878 + // maybe_serve_sitemap() answers it, so there is nothing to look
3879 + // for. Keeping the file test as the only answer would have left
3880 + // core's sitemap in place on every dynamic site, which is the
3881 + // crawl conflict this suppression exists to prevent (#752).
3882 + // The #346 behaviour is unchanged: a static site with nothing
3883 + // published still falls through to core rather than 404ing.
3884 + $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
3885 + || $generator->primary_sitemap_file_exists($settings);
3886 +
2782 3887 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
2783 3888 && !$this->publishes_at_core_sitemap_url($settings)
2784 - && $generator->primary_sitemap_file_exists($settings);
3889 + && $can_serve;
2785 3890
2786 3891 if ($this->thinkrank_sitemap_enabled) {
2787 3892 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
2788 3893 }
@@ -2872,15 +3977,17 @@
2872 3977 if (empty($settings['enabled'])) {
2873 3978 return (string) $url;
2874 3979 }
2875 3980
3981 + $size = (int) $size;
3982 +
2876 3983 // Apple touch icon has its own dedicated setting
2877 - if ((int) $size === 180 && !empty($settings['apple_touch_icon_url'])) {
2878 - return esc_url($settings['apple_touch_icon_url']);
3984 + if ($size === 180 && !empty($settings['apple_touch_icon_url'])) {
3985 + return $this->resolve_icon_url((string) $settings['apple_touch_icon_url'], $size);
2879 3986 }
2880 3987
2881 3988 if (!empty($settings['favicon_url'])) {
2882 - return esc_url($settings['favicon_url']);
3989 + return $this->resolve_icon_url((string) $settings['favicon_url'], $size);
2883 3990 }
2884 3991
2885 3992 return (string) $url;
2886 3993 }
@@ -2885,8 +3992,178 @@
2885 3992 return (string) $url;
2886 3993 }
2887 3994
2888 3995 /**
3996 + * Whether breadcrumb labels should prefer the SEO title.
3997 + *
3998 + * Off unless the site turns it on, so updating the plugin never rewrites an
3999 + * existing trail.
4000 + *
4001 + * @since 2.3.1
4002 + *
4003 + * @param array $settings Breadcrumb settings.
4004 + * @return bool
4005 + */
4006 + private function breadcrumbs_use_seo_title(array $settings): bool {
4007 + return !empty($settings['breadcrumb_use_seo_title']);
4008 + }
4009 +
4010 + /**
4011 + * Label for a post in the breadcrumb trail.
4012 + *
4013 + * With the toggle on, the post's own SEO title wins — the same
4014 + * `_thinkrank_seo_title` value (variable tags resolved) the document title
4015 + * uses — so the trail under a search snippet reads the same as the snippet
4016 + * itself. Anything empty falls back to the raw post title; the global title
4017 + * pattern is deliberately NOT part of the chain, since resolving it would
4018 + * append the site name to every crumb.
4019 + *
4020 + * @since 2.3.1
4021 + *
4022 + * @param int $post_id Post ID.
4023 + * @param array $settings Breadcrumb settings.
4024 + * @return string Breadcrumb label.
4025 + */
4026 + private function get_breadcrumb_post_title(int $post_id, array $settings): string {
4027 + $title = (string) get_the_title($post_id);
4028 +
4029 + if (!$this->breadcrumbs_use_seo_title($settings)) {
4030 + return $title;
4031 + }
4032 +
4033 + $seo_title = trim((string) get_post_meta($post_id, '_thinkrank_seo_title', true));
4034 +
4035 + if ('' === $seo_title) {
4036 + return $title;
4037 + }
4038 +
4039 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_value($seo_title, $post_id));
4040 +
4041 + return '' !== $resolved ? $resolved : $title;
4042 + }
4043 +
4044 + /**
4045 + * Label for a term in the breadcrumb trail.
4046 + *
4047 + * Term counterpart to {@see self::get_breadcrumb_post_title()}, resolving
4048 + * the term's `_thinkrank_seo_title` against its own values.
4049 + *
4050 + * @since 2.3.1
4051 + *
4052 + * @param object $term Term object.
4053 + * @param array $settings Breadcrumb settings.
4054 + * @return string Breadcrumb label.
4055 + */
4056 + private function get_breadcrumb_term_title($term, array $settings): string {
4057 + $name = (string) ($term->name ?? '');
4058 +
4059 + if (!$this->breadcrumbs_use_seo_title($settings) || empty($term->term_id)) {
4060 + return $name;
4061 + }
4062 +
4063 + $seo_title = trim((string) get_term_meta((int) $term->term_id, '_thinkrank_seo_title', true));
4064 +
4065 + if ('' === $seo_title) {
4066 + return $name;
4067 + }
4068 +
4069 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_term_value($seo_title, (int) $term->term_id));
4070 +
4071 + return '' !== $resolved ? $resolved : $name;
4072 + }
4073 +
4074 + /**
4075 + * Resolve a configured icon URL to the derivative that fits $size.
4076 + *
4077 + * wp_site_icon() calls get_site_icon_url() four times — 32, 192, 180 and
4078 + * 270 — and pairs the first two with a hardcoded sizes="" attribute. This
4079 + * filter used to answer all four with the same configured URL, so one
4080 + * upload was declared as every size at once: a 1536x1536 original served
4081 + * to paint a 32px tab icon, under a sizes="32x32" label that was simply
4082 + * untrue (#571).
4083 + *
4084 + * Resolution mirrors core's own get_site_icon_url(), including the
4085 + * >= 512 -> 'full' branch, so ThinkRank's override and the core pipeline
4086 + * pick the same file for the same request.
4087 + *
4088 + * An unresolvable URL (one hosted off-site) is returned unchanged. Nothing
4089 + * is knowable about its dimensions, and suppressing it instead would leave
4090 + * the page with no rel="icon" at all — a worse outcome than an approximate
4091 + * size hint.
4092 + *
4093 + * @param string $configured Configured icon URL.
4094 + * @param int $size Icon size core is asking for.
4095 + * @return string Icon URL for that size.
4096 + */
4097 + private function resolve_icon_url(string $configured, int $size): string {
4098 + $cache_key = md5($configured) . ':' . $size;
4099 + $cached = $this->icon_urls();
4100 +
4101 + if (isset($cached[$cache_key])) {
4102 + return $cached[$cache_key];
4103 + }
4104 +
4105 + $attachment_id = \ThinkRank\SEO\Site_Identity_Manager::icon_attachment_id($configured);
4106 +
4107 + if (!$attachment_id) {
4108 + $resolved = esc_url($configured);
4109 + } else {
4110 + // Mirrors core: at 512 and above the original is what is wanted, and
4111 + // asking for an intermediate size that large would only fall back to it.
4112 + $size_data = $size >= 512 ? 'full' : [$size, $size];
4113 + $url = wp_get_attachment_image_url($attachment_id, $size_data);
4114 + $resolved = $url ? esc_url($url) : esc_url($configured);
4115 + }
4116 +
4117 + $this->icon_urls[$cache_key] = $resolved;
4118 +
4119 + if (!$this->icon_urls_dirty) {
4120 + $this->icon_urls_dirty = true;
4121 + // Written once, after the response is assembled, rather than once
4122 + // per size: wp_site_icon() resolves four in a row.
4123 + add_action('shutdown', [$this, 'persist_icon_urls'], 5);
4124 + }
4125 +
4126 + return $resolved;
4127 + }
4128 +
4129 + /**
4130 + * The resolved-icon-URL map, loaded from its transient on first use.
4131 + *
4132 + * @return array<string, string>
4133 + */
4134 + private function icon_urls(): array {
4135 + if ($this->icon_urls === null) {
4136 + $stored = get_transient(\ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT);
4137 + $this->icon_urls = is_array($stored) ? $stored : [];
4138 + }
4139 +
4140 + return $this->icon_urls;
4141 + }
4142 +
4143 + /**
4144 + * Persist newly resolved icon URLs.
4145 + *
4146 + * Public because it runs on `shutdown`. Invalidated wholesale whenever the
4147 + * site identity settings are saved, which is the only moment the icon
4148 + * choice — or the derivatives behind it — can change.
4149 + *
4150 + * @return void
4151 + */
4152 + public function persist_icon_urls(): void {
4153 + if (!$this->icon_urls_dirty || !is_array($this->icon_urls)) {
4154 + return;
4155 + }
4156 +
4157 + $this->icon_urls_dirty = false;
4158 + set_transient(
4159 + \ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT,
4160 + $this->icon_urls,
4161 + DAY_IN_SECONDS
4162 + );
4163 + }
4164 +
4165 + /**
2889 4166 * Get breadcrumb items for current page
2890 4167 *
2891 4168 * @param array $settings Breadcrumb settings
2892 4169 * @return array Breadcrumb items
@@ -2914,9 +4191,9 @@
2914 4191 $categories = get_the_category($current_post_id);
2915 4192 if (!empty($categories)) {
2916 4193 $category = $categories[0];
2917 4194 $items[] = [
2918 - 'title' => $category->name,
4195 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
2919 4196 'url' => get_category_link($category->term_id),
2920 4197 'position' => $position++
2921 4198 ];
2922 4199 }
@@ -2921,12 +4198,18 @@
2921 4198 ];
2922 4199 }
2923 4200 }
2924 4201
2925 - // Add current post
2926 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4202 + // Add current post. `empty($x) || $x` is true for every possible
4203 + // value — an unset key, false, 0, '' and any truthy value alike —
4204 + // so the setting had no effect on the rendered breadcrumb or on
4205 + // the BreadcrumbList JSON-LD, while the admin preview honoured it
4206 + // and disagreed with live output (#398). Site_Identity_Manager
4207 + // already had the correct form: default to on, respect an
4208 + // explicit off.
4209 + if ($settings['show_current_page'] ?? true) {
2927 4210 $items[] = [
2928 - 'title' => get_the_title($current_post_id),
4211 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
2929 4212 'url' => get_permalink($current_post_id),
2930 4213 'position' => $position,
2931 4214 'current' => true
2932 4215 ];
@@ -2943,9 +4226,9 @@
2943 4226 while ($parent_id) {
2944 4227 $parent = get_post($parent_id);
2945 4228 if ($parent) {
2946 4229 $parents[] = [
2947 - 'title' => get_the_title($parent->ID),
4230 + 'title' => $this->get_breadcrumb_post_title($parent->ID, $settings),
2948 4231 'url' => get_permalink($parent->ID),
2949 4232 'position' => 0 // Will be set later
2950 4233 ];
2951 4234 $parent_id = $parent->post_parent;
@@ -2963,11 +4246,11 @@
2963 4246 $items[] = $parent;
2964 4247 }
2965 4248
2966 4249 // Add current page
2967 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4250 + if ($settings['show_current_page'] ?? true) {
2968 4251 $items[] = [
2969 - 'title' => get_the_title($current_post_id),
4252 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
2970 4253 'url' => get_permalink($current_post_id),
2971 4254 'position' => $position,
2972 4255 'current' => true
2973 4256 ];
@@ -2983,9 +4266,9 @@
2983 4266 while ($parent_id) {
2984 4267 $parent = get_category($parent_id);
2985 4268 if ($parent && !is_wp_error($parent)) {
2986 4269 $parents[] = [
2987 - 'title' => $parent->name,
4270 + 'title' => $this->get_breadcrumb_term_title($parent, $settings),
2988 4271 'url' => get_category_link($parent->term_id),
2989 4272 'position' => 0 // Will be set later
2990 4273 ];
2991 4274 $parent_id = $parent->parent;
@@ -3003,11 +4286,11 @@
3003 4286 $items[] = $parent;
3004 4287 }
3005 4288
3006 4289 // Add current category
3007 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4290 + if ($settings['show_current_page'] ?? true) {
3008 4291 $items[] = [
3009 - 'title' => $category->name,
4292 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3010 4293 'url' => get_category_link($category->term_id),
3011 4294 'position' => $position,
3012 4295 'current' => true
3013 4296 ];