PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 All 58 releases
← All changes | includes/frontend/class-seo-manager.php +1416 -129 1.32.0 → 2.14.3 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,8 +221,18 @@
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);
170 238 // Late enough that Global_SEO_Schema_Output (priority 15) has registered.
@@ -198,8 +266,16 @@
198 266 // LLMs_Txt_Manager for the static file) guarantees an explicit UTF-8
199 267 // charset. Priority 8 keeps it ahead of redirect_canonical().
200 268 add_action('template_redirect', [$this, 'maybe_serve_llms_txt'], 8);
201 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 +
202 278 // Take WordPress core's own sitemap offline while ThinkRank's is active.
203 279 // Two sitemap indexes on one site is a crawl conflict: core keeps
204 280 // /wp-sitemap.xml served and injects its own "Sitemap:" line into
205 281 // robots.txt (WP_Sitemaps::add_robots, priority 0). Until now that line
@@ -231,8 +307,17 @@
231 307 // Serve the Site Identity favicon through core's site-icon pipeline so
232 308 // wp_site_icon() outputs it on the front-end (and previews pick it up)
233 309 add_filter('get_site_icon_url', [$this, 'filter_site_icon_url'], 10, 2);
234 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 +
235 320 // Process image SEO in content
236 321 add_filter('the_content', [$this, 'filter_content_images'], 99999);
237 322 add_filter('post_thumbnail_html', [$this, 'filter_content_images'], 11, 2);
238 323 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'filter_content_images'], 11);
@@ -292,39 +377,73 @@
292 377 }
293 378
294 379 // Initialize Global SEO Schema Output and store reference
295 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 + );
296 389 $this->global_seo_schema->init();
297 390 }
298 391
299 392 /**
300 - * Initialize Google Analytics Tracking Manager
393 + * Initialize Image SEO Manager
301 394 *
302 395 * @return void
303 396 */
304 - private function initialize_google_analytics_tracking(): void {
305 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
306 - 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';
307 400 }
308 401
309 - // Initialize Google Analytics Tracking Manager
310 - new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
402 + $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
311 403 }
312 404
313 405 /**
314 - * Initialize Image SEO Manager
406 + * Initialize External Links Manager
315 407 *
408 + * @since 2.5.0
316 409 * @return void
317 410 */
318 - private function initialize_image_seo_manager(): void {
319 - if (!class_exists('ThinkRank\\SEO\\Image_SEO_Manager')) {
320 - 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';
321 414 }
322 415
323 - $this->image_seo_manager = new \ThinkRank\SEO\Image_SEO_Manager();
416 + $this->external_links_manager = new \ThinkRank\SEO\External_Links_Manager();
324 417 }
325 418
326 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 + /**
327 446 * Filter content to inject image SEO attributes
328 447 *
329 448 * @since 1.0.0
330 449 * @param string $content Content to filter
@@ -376,18 +495,74 @@
376 495 $this->current_metadata = $this->get_post_seo_metadata($post_id);
377 496 }
378 497 }
379 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 +
380 514 // Load site identity data
381 515 $this->load_site_identity_data();
382 516 }
383 517
384 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 + /**
385 551 * Detect current page context
386 552 *
387 553 * @return string Current context type
388 554 */
389 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 +
390 565 if (is_home() || is_front_page()) {
391 566 return 'homepage';
392 567 } elseif (is_single()) {
393 568 return 'post';
@@ -443,8 +618,37 @@
443 618 ];
444 619 }
445 620
446 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 + /**
447 651 * Resolve the effective SEO title for the current request.
448 652 *
449 653 * Same priority chain as override_document_title() — post-specific
450 654 * ThinkRank metadata (resolved _thinkrank_seo_title) > Global SEO
@@ -462,8 +666,53 @@
462 666 return $this->generate_context_title();
463 667 }
464 668
465 669 /**
670 + * The title Open Graph and Twitter fall back to when no override is set.
671 + *
672 + * The effective SEO title everywhere except author archives, whose
673 + * <title> is printed by Author_Archives_Manager (a pre_get_document_title
674 + * filter at priority 15 that replaces this class's). Passing
675 + * get_effective_seo_title() there gave og:title and twitter:title the Site
676 + * Identity author title, so the share card and the tab disagreed (#977).
677 + *
678 + * @since 2.15.0
679 + * @return string|null Fallback title, or null if none applies.
680 + */
681 + private function get_social_fallback_title(): ?string {
682 + if (is_author()) {
683 + $author_title = \ThinkRank\SEO\Author_Archives_Manager::resolve_title((int) get_queried_object_id());
684 + if ($author_title !== '') {
685 + return $author_title;
686 + }
687 + }
688 +
689 + return $this->get_effective_seo_title();
690 + }
691 +
692 + /**
693 + * The description Open Graph and Twitter fall back to when no override is set.
694 + *
695 + * Same split as get_social_fallback_title(): on an author archive the meta
696 + * description comes from Author_Archives_Manager, and get_meta_description()
697 + * knows nothing about it, so og:description and twitter:description were
698 + * missing there even while the description tag was printed (#977).
699 + *
700 + * @since 2.15.0
701 + * @return string|null Fallback description, or null if none applies.
702 + */
703 + private function get_social_fallback_description(): ?string {
704 + if (is_author()) {
705 + $author_description = \ThinkRank\SEO\Author_Archives_Manager::resolve_description((int) get_queried_object_id());
706 + if ($author_description !== '') {
707 + return $author_description;
708 + }
709 + }
710 +
711 + return $this->get_meta_description();
712 + }
713 +
714 + /**
466 715 * Override WordPress document title (HIGH PRIORITY)
467 716 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates
468 717 *
469 718 * @param string $title Original title
@@ -469,17 +718,23 @@
469 718 * @param string $title Original title
470 719 * @return string Modified title
471 720 */
472 721 public function override_document_title($title): string {
722 + // A content type with metas switched off keeps whatever title the theme
723 + // and WordPress produce (#660).
724 + if (!$this->metas_enabled()) {
725 + return $title;
726 + }
727 +
473 728 // First priority: Post-specific ThinkRank metadata
474 729 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
475 - return $this->current_metadata['title'];
730 + return self::with_page_suffix($this->current_metadata['title']);
476 731 }
477 732
478 733 // Second priority: Global SEO templates, Third priority: Site Identity templates
479 734 $generated_title = $this->generate_context_title();
480 735 if ($generated_title) {
481 - return $generated_title;
736 + return self::with_page_suffix($generated_title);
482 737 }
483 738
484 739 return $title;
485 740 }
@@ -484,8 +739,117 @@
484 739 return $title;
485 740 }
486 741
487 742 /**
743 + * Append a page indicator to a title on page 2 and beyond.
744 + *
745 + * This filter short-circuits pre_get_document_title at priority 1, which
746 + * drops the " – Page 2" core would otherwise add — so every page of an
747 + * archive, and every part of a multi-page post, shared one <title> (#397).
748 + * The templates have no %page% token, so the suffix is added here rather
749 + * than asking every site to edit its title format.
750 + *
751 + * @since 2.0.1
752 + *
753 + * @param string $title Resolved title.
754 + * @return string Title with the page indicator, when there is one.
755 + */
756 + /**
757 + * The archive's subject, without the label WordPress prefixes it with.
758 + *
759 + * `get_the_archive_title()` returns "Month: September 2026", "Archives:
760 + * Recipes", "Category: Uncategorized" — the label is core's, aimed at an
761 + * archive heading on the page, and it reads badly in a browser tab, an
762 + * og:title or a search result. Category, tag and author contexts already
763 + * avoid it by using the raw name; the generic archive context did not, so
764 + * date, custom-post-type and custom-taxonomy archives carried it (#640).
765 + *
766 + * Removed through core's own `get_the_archive_title_prefix` filter rather
767 + * than by matching the prefix text, because that text is translated and
768 + * differs per archive type — a string comparison would work in English and
769 + * silently stop working everywhere else.
770 + *
771 + * A site that wants a prefix can put one in its title template, where it is
772 + * visible and editable, instead of inheriting one it cannot see.
773 + *
774 + * @since 2.7.0
775 + *
776 + * @return string Archive subject, with markup and the core prefix removed.
777 + */
778 + private static function archive_subject(): string {
779 + $drop_prefix = static function (): string {
780 + return '';
781 + };
782 +
783 + add_filter('get_the_archive_title_prefix', $drop_prefix, 99);
784 +
785 + $title = (string) get_the_archive_title();
786 +
787 + remove_filter('get_the_archive_title_prefix', $drop_prefix, 99);
788 +
789 + // The <span> core wraps the subject in survives the prefix filter.
790 + return trim(wp_strip_all_tags($title));
791 + }
792 +
793 + /**
794 + * Remove HTML from a title that is about to be emitted.
795 + *
796 + * A title carrying markup is broken twice over, in two different ways, and
797 + * both were reaching real pages: inside `<title>` the tags render literally,
798 + * because that element is RCDATA and never parses them; inside `og:title`
799 + * and `twitter:title` they are attribute-escaped, so the reader sees
800 + * `&lt;em&gt;` as visible text (#640).
801 + *
802 + * Applied at the point of emission rather than at each source, so it covers
803 + * every branch that can produce a title — post meta, Global SEO templates,
804 + * Site Identity templates — without each having to remember.
805 + *
806 + * Unconditional rather than a setting: there is no title for which markup is
807 + * the correct output. The filter is the escape hatch for anyone who
808 + * disagrees, and lets a site keep entities it deliberately encoded.
809 + *
810 + * @since 2.7.0
811 + *
812 + * @param string $title Title about to be emitted.
813 + * @return string Title with any markup removed.
814 + */
815 + public static function strip_title_tags(string $title): string {
816 + /**
817 + * Filter whether HTML is stripped from generated titles.
818 + *
819 + * @since 2.7.0
820 + *
821 + * @param bool $strip Whether to strip. Default true.
822 + * @param string $title The title being emitted.
823 + */
824 + if (!apply_filters('thinkrank_strip_title_tags', true, $title)) {
825 + return $title;
826 + }
827 +
828 + return trim(wp_strip_all_tags($title));
829 + }
830 +
831 + public static function with_page_suffix(string $title): string {
832 + $title = self::strip_title_tags($title);
833 +
834 + $page = self::current_page_number();
835 +
836 + if ($page <= 1 || '' === $title) {
837 + return $title;
838 + }
839 +
840 + $separator = class_exists('\ThinkRank\SEO\Site_Identity_Manager')
841 + ? \ThinkRank\SEO\Site_Identity_Manager::get_active_separator_symbol()
842 + : '|';
843 +
844 + return $title . ' ' . $separator . ' ' . sprintf(
845 + /* translators: %d: page number. */
846 + __('Page %d', 'thinkrank'),
847 + $page
848 + );
849 + }
850 +
851 + /**
488 852 * Override WordPress wp_title (HIGH PRIORITY)
489 853 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates
490 854 *
491 855 * @param string $title Original title
@@ -492,18 +856,24 @@
492 856 * @param string $sep Title separator
493 857 * @return string Modified title
494 858 */
495 859 public function override_wp_title(string $title, string $sep = ''): string {
860 + if (!$this->metas_enabled()) {
861 + return $title;
862 + }
863 +
496 864 // First priority: Post-specific ThinkRank metadata
497 865 if ($this->has_thinkrank_metadata() && !empty($this->current_metadata['title'])) {
498 866 $site_name = get_bloginfo('name');
499 - return $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name;
867 + return self::with_page_suffix(
868 + $this->current_metadata['title'] . ($sep ? " $sep " : ' | ') . $site_name
869 + );
500 870 }
501 871
502 872 // Second priority: Global SEO templates, Third priority: Site Identity templates
503 873 $generated_title = $this->generate_context_title();
504 874 if ($generated_title) {
505 - return $generated_title;
875 + return self::with_page_suffix($generated_title);
506 876 }
507 877
508 878 return $title;
509 879 }
@@ -508,8 +878,25 @@
508 878 return $title;
509 879 }
510 880
511 881 /**
882 + * Whether ThinkRank owns the title and meta description for this request.
883 + *
884 + * Metas are on site-wide by default; the per-content-type matrix can switch
885 + * them off for one content type, in which case ThinkRank stops overriding
886 + * the document title and prints no meta description (#660).
887 + *
888 + * @since 2.5.0
889 + * @return bool
890 + */
891 + private function metas_enabled(): bool {
892 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
893 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_META,
894 + true
895 + );
896 + }
897 +
898 + /**
512 899 * Output meta description (HIGH PRIORITY)
513 900 * Priority: Post-specific metadata > Global SEO templates > Site Identity templates > WordPress defaults
514 901 *
515 902 * Author archives are skipped entirely: Author_Archives_Manager owns that
@@ -524,22 +911,24 @@
524 911 if (is_author()) {
525 912 return;
526 913 }
527 914
915 + if (!$this->metas_enabled()) {
916 + return;
917 + }
918 +
528 919 $description = $this->get_meta_description();
529 920
530 921 if ($description) {
531 922 // Output main ThinkRank SEO header comment (only once)
532 - static $header_output = false;
533 - if (!$header_output) {
534 - echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
535 - $header_output = true;
536 - }
923 + self::note_opening_comment();
537 924
538 925 // Ensure description is within optimal length (150-160 characters)
539 - if (strlen($description) > 160) {
540 - $description = wp_trim_words($description, 25, '...');
541 - }
926 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
927 + // CJK description tripped this limit at a third of its length, and
928 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
929 + // English, 25 characters in Thai (#687).
930 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
542 931
543 932 echo "<!-- ThinkRank SEO Meta Description -->\n";
544 933 echo '<meta name="description" content="' . esc_attr($description) . '" />' . "\n";
545 934 echo "<!-- /ThinkRank SEO Meta Description -->\n";
@@ -580,13 +969,41 @@
580 969
581 970 // Output generator meta tag
582 971 echo '<meta name="generator" content="ThinkRank ' . esc_attr(THINKRANK_VERSION) . '" />' . "\n";
583 972
584 - // Output viewport meta tag if not already present
585 - if (!has_action('wp_head', 'wp_site_icon') || !wp_is_mobile()) {
586 - echo '<meta name="viewport" content="width=device-width, initial-scale=1.0" />' . "\n";
973 + // No viewport tag here. The viewport is the theme's responsibility and
974 + // every modern theme ships one, so emitting our own only ever produced a
975 + // second <meta name="viewport"> in the document. The old guard could not
976 + // prevent that either: has_action() returns the registered priority
977 + // (truthy), so its first operand was always false, and !wp_is_mobile() is
978 + // true for every desktop request.
979 + echo "<!-- /ThinkRank SEO Meta Tags -->\n";
980 + }
981 +
982 + /**
983 + * Build the basic robots directive list from a set of robots flags.
984 + *
985 + * Shared by the search/404 branch of get_robots_meta_content() so those
986 + * pages resolve their directives through the same rules as everything else
987 + * rather than a hardcoded literal.
988 + *
989 + * @since 2.5.0
990 + * @param array $settings Robots flags (index/noindex/nofollow/...).
991 + * @return string[] Directives.
992 + */
993 + private static function build_robots_directives(array $settings): array {
994 + $robots = [];
995 +
996 + $robots[] = !empty($settings['noindex']) ? 'noindex' : 'index';
997 + $robots[] = !empty($settings['nofollow']) ? 'nofollow' : 'follow';
998 +
999 + foreach (['noarchive', 'noimageindex', 'nosnippet'] as $directive) {
1000 + if (!empty($settings[$directive])) {
1001 + $robots[] = $directive;
1002 + }
587 1003 }
588 - echo "<!-- /ThinkRank SEO Meta Tags -->\n";
1004 +
1005 + return $robots;
589 1006 }
590 1007
591 1008 /**
592 1009 * Get robots meta content based on context and settings
@@ -595,13 +1012,22 @@
595 1012 */
596 1013 private function get_robots_meta_content(): string {
597 1014 $robots = [];
598 1015
599 - // 404 and search results must never be indexed, regardless of the
600 - // configured global/post-type directives. Links are still followed so
601 - // crawlers can discover the rest of the site.
1016 + // 404 and search results are noindex/follow by default — the behaviour
1017 + // that used to be hardcoded here. It is now settings-driven (#660): the
1018 + // Content Type Matrix can give either its own robots directives, and an
1019 + // install that never touched them resolves to exactly the old pair.
602 1020 if (is_404() || is_search()) {
603 - $robots = apply_filters('thinkrank_robots_meta', ['noindex', 'follow']);
1021 + $entity = is_404()
1022 + ? \ThinkRank\SEO\Content_Type_Settings::ENTITY_404
1023 + : \ThinkRank\SEO\Content_Type_Settings::ENTITY_SEARCH;
1024 +
1025 + $robots = self::build_robots_directives(
1026 + \ThinkRank\SEO\Content_Type_Settings::resolve_robots_meta($entity)
1027 + );
1028 +
1029 + $robots = apply_filters('thinkrank_robots_meta', $robots);
604 1030 return implode(', ', array_unique($robots));
605 1031 }
606 1032
607 1033 // 1. Get global robot meta settings (Base)
@@ -630,8 +1056,24 @@
630 1056 $current_settings = array_merge($current_settings, $global_seo_settings[$post_type]['robots_meta']);
631 1057 }
632 1058 }
633 1059
1060 + // 2b. Apply the per-entity directives for the non-singular content
1061 + // types the matrix covers — taxonomy archives plus author and date
1062 + // archives. Terms keep their own per-term override, applied further
1063 + // down so it still wins over the taxonomy-wide value (#660).
1064 + if (!is_singular()) {
1065 + $entity_key = \ThinkRank\SEO\Content_Type_Settings::current_entity_key();
1066 +
1067 + if ($entity_key !== null) {
1068 + $entity_settings = \ThinkRank\SEO\Content_Type_Settings::get_entity_settings($entity_key);
1069 +
1070 + if (!empty($entity_settings['robots_meta_enabled']) && is_array($entity_settings['robots_meta'] ?? null)) {
1071 + $current_settings = array_merge($current_settings, $entity_settings['robots_meta']);
1072 + }
1073 + }
1074 + }
1075 +
634 1076 // Determine Index/Noindex based on merged settings
635 1077 // Priority: if noindex is true, it overrides index
636 1078 if (!empty($current_settings['noindex'])) {
637 1079 $robots[] = 'noindex';
@@ -695,12 +1137,30 @@
695 1137 $robots = $this->apply_post_robots_override(get_the_ID(), $robots, $current_settings);
696 1138 }
697 1139
698 1140 // Check for archive pages (search is handled by the early return above)
699 - if (is_archive()) {
1141 + //
1142 + // is_home() is deliberately included: the blog listing is not an
1143 + // is_archive(), so page 2 of a term archive was noindex while page 2 of
1144 + // the blog listing was index — the same kind of page, treated two
1145 + // different ways, on the same site (#397).
1146 + if (is_archive() || is_home()) {
700 1147 // Allow indexing of category/tag archives but be more conservative
701 1148 if (is_paged()) {
702 - $robots = ['noindex', 'follow'];
1149 + /**
1150 + * Filter whether a paginated archive is set noindex.
1151 + *
1152 + * Rank Math and Yoast now index paginated archives with a
1153 + * self-referential canonical by default, so a site that wants
1154 + * that can have it without patching.
1155 + *
1156 + * @since 2.0.1
1157 + *
1158 + * @param bool $noindex Whether to noindex this paginated page.
1159 + */
1160 + if (apply_filters('thinkrank_noindex_paged_archives', true)) {
1161 + $robots = ['noindex', 'follow'];
1162 + }
703 1163 }
704 1164
705 1165 // Honor the global date-archive noindex toggle (written by the
706 1166 // Rank Math/Yoast settings importer). Author archives are handled
@@ -980,9 +1440,9 @@
980 1440 *
981 1441 * @param array $og_tags Open Graph tags array
982 1442 * @return void
983 1443 */
984 - private function output_social_og_tags(array $og_tags): void {
1444 + private function output_social_og_tags(array $og_tags, array $extra_images = []): void {
985 1445 // Honor the thinkrank_og_type filter here too — this "Enhanced" path is
986 1446 // the active OG emitter, so add-ons (e.g. Pro's WooCommerce module which
987 1447 // sets 'product' on product pages) must be applied to it, not only to
988 1448 // output_open_graph_tags().
@@ -1026,12 +1486,62 @@
1026 1486 echo '<meta property="' . esc_attr($property) . '" content="' . $this->esc_meta_value($property, $content) . '" />' . "\n";
1027 1487 }
1028 1488 }
1029 1489
1490 + // Alternatives, after the primary and everything belonging to it.
1491 + // Order is the whole point: a consumer reads og:image tags in document
1492 + // order and treats the first as primary, and a structured property
1493 + // attaches to the most recently declared image — so each alternative's
1494 + // companions have to follow its own URL, not be grouped at the end.
1495 + self::output_extra_og_images($extra_images);
1496 +
1030 1497 echo "<!-- /ThinkRank SEO Open Graph Tags -->\n";
1031 1498 }
1032 1499
1033 1500 /**
1501 + * Emit the secondary og:image tags a page offers.
1502 + *
1503 + * Shared by the enhanced and basic emitters so both describe an
1504 + * alternative image the same way (#636).
1505 + *
1506 + * @since 2.7.0
1507 + *
1508 + * @param array $images Each with url, and width/height/type/alt where known.
1509 + * @return void
1510 + */
1511 + private static function output_extra_og_images(array $images): void {
1512 + foreach ($images as $image) {
1513 + $url = isset($image['url']) ? (string) $image['url'] : '';
1514 +
1515 + if ('' === $url) {
1516 + continue;
1517 + }
1518 +
1519 + echo '<meta property="og:image" content="' . esc_url($url) . '" />' . "\n";
1520 +
1521 + if (strpos($url, 'https://') === 0) {
1522 + echo '<meta property="og:image:secure_url" content="' . esc_url($url) . '" />' . "\n";
1523 + }
1524 +
1525 + // Only what is actually known: a dimension guessed for a remote
1526 + // image is a number a consumer lays a card out with before it has
1527 + // fetched the file.
1528 + if (!empty($image['width']) && !empty($image['height'])) {
1529 + echo '<meta property="og:image:width" content="' . esc_attr((string) $image['width']) . '" />' . "\n";
1530 + echo '<meta property="og:image:height" content="' . esc_attr((string) $image['height']) . '" />' . "\n";
1531 + }
1532 +
1533 + if (!empty($image['type'])) {
1534 + echo '<meta property="og:image:type" content="' . esc_attr((string) $image['type']) . '" />' . "\n";
1535 + }
1536 +
1537 + if (!empty($image['alt'])) {
1538 + echo '<meta property="og:image:alt" content="' . esc_attr((string) $image['alt']) . '" />' . "\n";
1539 + }
1540 + }
1541 + }
1542 +
1543 + /**
1034 1544 * Output social media Twitter Card tags from Social Meta Manager
1035 1545 *
1036 1546 * @param array $twitter_tags Twitter Card tags array
1037 1547 * @return void
@@ -1096,8 +1606,16 @@
1096 1606 *
1097 1607 * @return void
1098 1608 */
1099 1609 public function output_platform_meta_tags(): void {
1610 + // Same reasoning as the Open Graph and Twitter emitters: an error page
1611 + // has no shareable identity, and passing '404' through as a social
1612 + // context asks the manager for settings that describe a page which does
1613 + // not exist. Guarding all three keeps them from disagreeing.
1614 + if ($this->current_context === '404') {
1615 + return;
1616 + }
1617 +
1100 1618 // Try Social Meta Manager for platform tags
1101 1619 if ($this->social_manager) {
1102 1620 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1103 1621 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1107,10 +1625,10 @@
1107 1625 // (platform tags don't depend on them, so output is unchanged).
1108 1626 $social_data = $this->social_manager->get_output_data(
1109 1627 $social_context,
1110 1628 $this->current_post_id,
1111 - $this->get_effective_seo_title(),
1112 - $this->get_meta_description()
1629 + $this->get_social_fallback_title(),
1630 + $this->get_social_fallback_description()
1113 1631 );
1114 1632
1115 1633 if ($social_data['enabled'] && !empty($social_data['platform_tags'])) {
1116 1634 $this->output_social_platform_tags($social_data['platform_tags']);
@@ -1149,8 +1667,23 @@
1149 1667 *
1150 1668 * @return void
1151 1669 */
1152 1670 public function output_open_graph_tags(): void {
1671 + // Per-content-type Open Graph switch. 'inherit' (the default) keeps the
1672 + // site-wide Social Media setting, which the emitters below read (#660).
1673 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1674 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_OPEN_GRAPH,
1675 + true
1676 + )) {
1677 + return;
1678 + }
1679 +
1680 + // An error page has no shareable identity. Emitting Open Graph here
1681 + // advertised the homepage as the og:url of a URL that does not exist.
1682 + if ($this->current_context === '404') {
1683 + return;
1684 + }
1685 +
1153 1686 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1154 1687 if ($this->social_manager) {
1155 1688 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1156 1689 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1163,10 +1696,10 @@
1163 1696 // value the preview shows.
1164 1697 $social_data = $this->social_manager->get_output_data(
1165 1698 $social_context,
1166 1699 $this->current_post_id,
1167 - $this->get_effective_seo_title(),
1168 - $this->get_meta_description()
1700 + $this->get_social_fallback_title(),
1701 + $this->get_social_fallback_description()
1169 1702 );
1170 1703
1171 1704 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1172 1705 // toggled off, emit nothing — do NOT fall through to the basic
@@ -1171,9 +1704,12 @@
1171 1704 // The Social Meta Manager ran, so it owns Open Graph output. If OG is
1172 1705 // toggled off, emit nothing — do NOT fall through to the basic
1173 1706 // emitter (which would re-add a full OG block despite the toggle).
1174 1707 if (!empty($social_data['og_enabled'])) {
1175 - $this->output_social_og_tags($social_data['og_tags']);
1708 + $this->output_social_og_tags(
1709 + $social_data['og_tags'],
1710 + $social_data['og_extra_images'] ?? []
1711 + );
1176 1712 }
1177 1713 return;
1178 1714 }
1179 1715
@@ -1201,8 +1737,21 @@
1201 1737 (string) get_post_meta($this->current_post_id, '_thinkrank_og_description', true),
1202 1738 $this->current_post_id
1203 1739 );
1204 1740 $og_image_override = get_post_meta($this->current_post_id, '_thinkrank_og_image', true);
1741 + } elseif ($this->current_term_id) {
1742 + // Terms carry the same social override keys — the abilities API
1743 + // writes them — so honour them here rather than letting the term's
1744 + // SEO title stand in for an explicit og:title.
1745 + $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value(
1746 + (string) get_term_meta($this->current_term_id, '_thinkrank_og_title', true),
1747 + $this->current_term_id
1748 + );
1749 + $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value(
1750 + (string) get_term_meta($this->current_term_id, '_thinkrank_og_description', true),
1751 + $this->current_term_id
1752 + );
1753 + $og_image_override = get_term_meta($this->current_term_id, '_thinkrank_og_image', true);
1205 1754 }
1206 1755
1207 1756 // Get title using priority system: OG override > post-specific > Global SEO > Site Identity > default
1208 1757 $title = '';
@@ -1228,9 +1777,9 @@
1228 1777 // "There is no excerpt because this is a protected post." placeholder,
1229 1778 // so this is not a leak — but publishing that sentence as the social
1230 1779 // description is worse than publishing none (#363).
1231 1780 if (!$description && !$this->is_content_password_protected()) {
1232 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1781 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1233 1782 }
1234 1783
1235 1784 $url = is_singular() ? get_permalink() : home_url();
1236 1785 $site_name = $this->site_identity_data && !empty($this->site_identity_data['identity']['site_name'])
@@ -1258,11 +1807,11 @@
1258 1807 $og_type = apply_filters('thinkrank_og_type', $og_type);
1259 1808
1260 1809 echo "<!-- ThinkRank SEO Open Graph Meta Tags -->\n";
1261 1810 echo "<meta property=\"og:type\" content=\"" . esc_attr($og_type) . "\" />\n";
1262 - echo "<meta property=\"og:title\" content=\"" . esc_attr($title) . "\" />\n";
1811 + echo "<meta property=\"og:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1263 1812 echo "<meta property=\"og:description\" content=\"" . esc_attr($description) . "\" />\n";
1264 - echo "<meta property=\"og:url\" content=\"" . esc_url($url) . "\" />\n";
1813 + echo "<meta property=\"og:url\" content=\"" . esc_url(\ThinkRank\SEO\Url_Scheme::apply($url)) . "\" />\n";
1265 1814 echo "<meta property=\"og:site_name\" content=\"" . esc_attr($site_name) . "\" />\n";
1266 1815 /**
1267 1816 * Filter the og:locale value.
1268 1817 *
@@ -1279,14 +1828,17 @@
1279 1828 $og_locale = (string) apply_filters('thinkrank_og_locale', get_locale());
1280 1829 echo "<meta property=\"og:locale\" content=\"" . esc_attr($og_locale) . "\" />\n";
1281 1830
1282 1831 // Add OG image — per-post override > featured image
1832 + $primary_og_image = '';
1283 1833 if (is_singular() && $this->current_post_id) {
1284 1834 if (!empty($og_image_override)) {
1835 + $primary_og_image = (string) $og_image_override;
1285 1836 echo "<meta property=\"og:image\" content=\"" . esc_url($og_image_override) . "\" />\n";
1286 1837 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($og_image_override) . "\" />\n";
1287 1838 } elseif (has_post_thumbnail($this->current_post_id)) {
1288 1839 $image_url = get_the_post_thumbnail_url($this->current_post_id, 'large');
1840 + $primary_og_image = (string) $image_url;
1289 1841 echo "<meta property=\"og:image\" content=\"" . esc_url($image_url) . "\" />\n";
1290 1842 echo "<meta property=\"og:image:secure_url\" content=\"" . esc_url($image_url) . "\" />\n";
1291 1843
1292 1844 // Get image dimensions and alt text
@@ -1292,12 +1844,16 @@
1292 1844 // Get image dimensions and alt text
1293 1845 $image_id = get_post_thumbnail_id($this->current_post_id);
1294 1846 $image_meta = wp_get_attachment_metadata($image_id);
1295 1847 if ($image_meta) {
1848 + // The `large` file being published, not the original it
1849 + // was generated from: the metadata's own width and height
1850 + // describe an image this tag does not point at (#847).
1851 + $image_file = \ThinkRank\SEO\Attachment_Lookup::describe((int) $image_id, (string) $image_url);
1296 1852 // SVGs (and other vector uploads) report 0x0 — emitting
1297 1853 // those as og:image dimensions is invalid, so skip them.
1298 - $og_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1299 - $og_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1854 + $og_width = $image_file['width'];
1855 + $og_height = $image_file['height'];
1300 1856 if ($og_width > 0 && $og_height > 0) {
1301 1857 echo "<meta property=\"og:image:width\" content=\"" . esc_attr($og_width) . "\" />\n";
1302 1858 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1303 1859 }
@@ -1302,9 +1858,9 @@
1302 1858 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1303 1859 }
1304 1860 // Derive the real mime type instead of hardcoding image/jpeg,
1305 1861 // which mislabels PNG/WebP featured images.
1306 - $image_mime = get_post_mime_type($image_id);
1862 + $image_mime = $image_file['type'];
1307 1863 if ($image_mime) {
1308 1864 echo "<meta property=\"og:image:type\" content=\"" . esc_attr($image_mime) . "\" />\n";
1309 1865 }
1310 1866 }
@@ -1315,8 +1871,30 @@
1315 1871 echo "<meta property=\"og:image:alt\" content=\"" . esc_attr($image_alt) . "\" />\n";
1316 1872 }
1317 1873 }
1318 1874
1875 + // Alternatives, same as the enhanced emitter above. This path only
1876 + // runs when the Social Meta Manager is unavailable, but the issue
1877 + // reported against it (#636) and a site that lands here should not
1878 + // silently lose a feature it switched on.
1879 + if (!empty($primary_og_image)) {
1880 + $social_settings = $this->social_manager
1881 + ? $this->social_manager->get_settings(
1882 + $this->current_context === 'homepage' ? 'site' : $this->current_context,
1883 + $this->current_post_id
1884 + )
1885 + : [];
1886 +
1887 + if (!empty($social_settings['og_multiple_images'])) {
1888 + self::output_extra_og_images(
1889 + \ThinkRank\SEO\Social_Images::additional(
1890 + (int) $this->current_post_id,
1891 + $primary_og_image
1892 + )
1893 + );
1894 + }
1895 + }
1896 +
1319 1897 // Add article specific tags for posts only
1320 1898 if ($og_type === 'article') {
1321 1899 echo '<meta property="article:published_time" content="' . esc_attr(get_the_date('c', $this->current_post_id)) . '" />' . "\n";
1322 1900 echo '<meta property="article:modified_time" content="' . esc_attr(get_the_modified_date('c', $this->current_post_id)) . '" />' . "\n";
@@ -1344,8 +1922,21 @@
1344 1922 *
1345 1923 * @return void
1346 1924 */
1347 1925 public function output_twitter_card_tags(): void {
1926 + // Per-content-type Twitter card switch; see output_open_graph_tags().
1927 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
1928 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_TWITTER,
1929 + true
1930 + )) {
1931 + return;
1932 + }
1933 +
1934 + // Same reasoning as the Open Graph block: nothing on a 404 is shareable.
1935 + if ($this->current_context === '404') {
1936 + return;
1937 + }
1938 +
1348 1939 // Priority 1: Try Social Meta Manager (Social Media tab settings)
1349 1940 if ($this->social_manager) {
1350 1941 // Map context for Social Meta Manager (homepage -> site for site-wide settings)
1351 1942 $social_context = $this->current_context === 'homepage' ? 'site' : $this->current_context;
@@ -1356,10 +1947,10 @@
1356 1947 // description and the metabox preview.
1357 1948 $social_data = $this->social_manager->get_output_data(
1358 1949 $social_context,
1359 1950 $this->current_post_id,
1360 - $this->get_effective_seo_title(),
1361 - $this->get_meta_description()
1951 + $this->get_social_fallback_title(),
1952 + $this->get_social_fallback_description()
1362 1953 );
1363 1954
1364 1955
1365 1956 // The Social Meta Manager ran, so it owns Twitter output. If Twitter
@@ -1392,8 +1983,14 @@
1392 1983 $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_title', true), $pid);
1393 1984 $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_twitter_description', true), $pid);
1394 1985 $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_title', true), $pid);
1395 1986 $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_value((string) get_post_meta($pid, '_thinkrank_og_description', true), $pid);
1987 + } elseif ($this->current_term_id) {
1988 + $tid = $this->current_term_id;
1989 + $twitter_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_title', true), $tid);
1990 + $twitter_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_twitter_description', true), $tid);
1991 + $og_title_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_title', true), $tid);
1992 + $og_description_override = \ThinkRank\SEO\Pattern_Resolver::resolve_term_value((string) get_term_meta($tid, '_thinkrank_og_description', true), $tid);
1396 1993 }
1397 1994
1398 1995 // Title cascade: Twitter override > OG override > Global SEO > Site Identity > default
1399 1996 $title = '';
@@ -1423,9 +2020,9 @@
1423 2020 // "There is no excerpt because this is a protected post." placeholder,
1424 2021 // so this is not a leak — but publishing that sentence as the social
1425 2022 // description is worse than publishing none (#363).
1426 2023 if (!$description && !$this->is_content_password_protected()) {
1427 - $description = is_singular() ? wp_trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
2024 + $description = is_singular() ? \ThinkRank\Core\Seo_Text::trim_words(get_the_excerpt(), 30) : get_bloginfo('description');
1428 2025 }
1429 2026
1430 2027 // Determine card type based on image availability
1431 2028 $card_type = 'summary';
@@ -1434,9 +2031,9 @@
1434 2031 }
1435 2032
1436 2033 echo "<!-- ThinkRank SEO Twitter Card Meta Tags -->\n";
1437 2034 echo '<meta name="twitter:card" content="' . esc_attr($card_type) . '" />' . "\n";
1438 - echo "<meta name=\"twitter:title\" content=\"" . esc_attr($title) . "\" />\n";
2035 + echo "<meta name=\"twitter:title\" content=\"" . esc_attr(self::strip_title_tags($title)) . "\" />\n";
1439 2036 echo "<meta name=\"twitter:description\" content=\"" . esc_attr($description) . "\" />\n";
1440 2037
1441 2038 // Add Twitter image with proper fallback priority
1442 2039 $twitter_image_url = $this->get_twitter_image_with_fallback();
@@ -1486,11 +2083,18 @@
1486 2083 }
1487 2084
1488 2085 if (empty($canonical_url)) {
1489 2086 $canonical_url = $this->current_post_id ? get_permalink($this->current_post_id) : get_permalink();
2087 +
2088 + // Core's rel_canonical() keeps the page number; this replaced
2089 + // it with a bare permalink, so every <!--nextpage--> sub-page
2090 + // and every /comment-page-N/ canonicalised to page 1 — a
2091 + // regression against core behaviour (#397). A custom canonical
2092 + // is left exactly as the user typed it.
2093 + $canonical_url = self::with_singular_page($canonical_url);
1490 2094 }
1491 2095 } else {
1492 - $canonical_url = $this->get_non_singular_canonical_url();
2096 + $canonical_url = self::get_non_singular_canonical_url();
1493 2097 }
1494 2098
1495 2099 /**
1496 2100 * Filter the canonical URL before output.
@@ -1504,14 +2108,115 @@
1504 2108 if (empty($canonical_url)) {
1505 2109 return;
1506 2110 }
1507 2111
2112 + // After the filter, so a canonical an add-on supplied is normalized
2113 + // too — and a cross-domain one is left alone, since Url_Scheme only
2114 + // touches URLs on this site's own host.
2115 + $canonical_url = \ThinkRank\SEO\Url_Scheme::apply($canonical_url);
2116 +
1508 2117 echo "<!-- ThinkRank SEO Canonical URL -->\n";
1509 2118 echo "<link rel=\"canonical\" href=\"" . esc_url($canonical_url) . "\" />\n";
1510 2119 echo "<!-- /ThinkRank SEO Canonical URL -->\n";
2120 +
2121 + $this->output_pagination_links();
1511 2122 }
1512 2123
1513 2124 /**
2125 + * Emit rel="prev" / rel="next" on a paginated archive.
2126 + *
2127 + * Nothing emitted these at all (#397). Google stopped using them as an
2128 + * indexing signal in 2019, so this is not an SEO win with Google — Bing
2129 + * still reads them, and they are the standard way to describe a sequence,
2130 + * which is what the pages are.
2131 + *
2132 + * @since 2.0.1
2133 + *
2134 + * @return void
2135 + */
2136 + private function output_pagination_links(): void {
2137 + // Page 1 still wants a rel="next" when there is a page 2, so only
2138 + // singular views are skipped outright.
2139 + if (is_singular()) {
2140 + return;
2141 + }
2142 +
2143 + global $wp_query;
2144 +
2145 + $total = $wp_query ? (int) $wp_query->max_num_pages : 0;
2146 +
2147 + if ($total < 2) {
2148 + return;
2149 + }
2150 +
2151 + $base = self::get_non_singular_canonical_url();
2152 +
2153 + if ('' === $base) {
2154 + return;
2155 + }
2156 +
2157 + // get_non_singular_canonical_url() already carries the current page —
2158 + // strip it back to page 1 before building the neighbours.
2159 + $current = self::current_page_number();
2160 + $base = self::without_pagination($base);
2161 +
2162 + if ($current > 1) {
2163 + printf(
2164 + "<link rel=\"prev\" href=\"%s\" />\n",
2165 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current - 1)))
2166 + );
2167 + }
2168 +
2169 + if ($current < $total) {
2170 + printf(
2171 + "<link rel=\"next\" href=\"%s\" />\n",
2172 + esc_url(\ThinkRank\SEO\Url_Scheme::apply(self::with_pagination($base, $current + 1)))
2173 + );
2174 + }
2175 + }
2176 +
2177 + /**
2178 + * The rewrite base WordPress uses for page numbers ('page' by default).
2179 + *
2180 + * @since 2.0.1
2181 + *
2182 + * @return string
2183 + */
2184 + private static function pagination_base(): string {
2185 + global $wp_rewrite;
2186 +
2187 + return $wp_rewrite && $wp_rewrite->pagination_base ? $wp_rewrite->pagination_base : 'page';
2188 + }
2189 +
2190 + /**
2191 + * Append the sub-page or comment-page number to a singular canonical.
2192 + *
2193 + * @since 2.0.1
2194 + *
2195 + * @param string $url Permalink.
2196 + * @return string Permalink with the current page appended, when there is one.
2197 + */
2198 + public static function with_singular_page(string $url): string {
2199 + global $wp_rewrite;
2200 +
2201 + $page = (int) get_query_var('page');
2202 +
2203 + if ($page > 1) {
2204 + return $wp_rewrite && $wp_rewrite->using_permalinks()
2205 + ? trailingslashit($url) . user_trailingslashit($page, 'single_paged')
2206 + : add_query_arg('page', $page, $url);
2207 + }
2208 +
2209 + $comment_page = (int) get_query_var('cpage');
2210 +
2211 + if ($comment_page > 1) {
2212 + return get_comments_pagenum_link($comment_page);
2213 + }
2214 +
2215 + return $url;
2216 + }
2217 +
2218 + /**
1514 2219 * Build the canonical URL for non-singular contexts.
1515 2220 *
1516 2221 * Covers the blog home, post type / taxonomy / author / date archives.
1517 2222 * Search results and 404 pages get no canonical (they are noindexed).
@@ -1519,9 +2224,9 @@
1519 2224 * self-referential rather than pointing at page 1.
1520 2225 *
1521 2226 * @return string Canonical URL or '' when none applies
1522 2227 */
1523 - private function get_non_singular_canonical_url(): string {
2228 + public static function get_non_singular_canonical_url(): string {
1524 2229 if (is_404() || is_search()) {
1525 2230 return '';
1526 2231 }
1527 2232
@@ -1552,17 +2257,89 @@
1552 2257 return '';
1553 2258 }
1554 2259
1555 2260 // Point paginated archives at their own page, not page 1.
2261 + return self::with_pagination($canonical_url, (int) get_query_var('paged'));
2262 + }
2263 +
2264 + /**
2265 + * Append a page number to a URL the way WordPress does.
2266 + *
2267 + * Extracted so the archive canonical is not the only thing that knows how
2268 + * to build a paged URL: the schema graph derived its @id from the
2269 + * un-paginated link, so every page of an archive claimed the same node
2270 + * identity, and the singular canonical dropped the page entirely (#397).
2271 + *
2272 + * @since 2.0.1
2273 + *
2274 + * @param string $url Base URL.
2275 + * @param int $page Page number; 1 or less returns the URL unchanged.
2276 + * @return string
2277 + */
2278 + public static function with_pagination(string $url, int $page): string {
2279 + if ($page <= 1 || '' === $url) {
2280 + return $url;
2281 + }
2282 +
2283 + global $wp_rewrite;
2284 +
2285 + if ($wp_rewrite && $wp_rewrite->using_permalinks()) {
2286 + return trailingslashit($url) . user_trailingslashit(
2287 + $wp_rewrite->pagination_base . '/' . $page,
2288 + 'paged'
2289 + );
2290 + }
2291 +
2292 + return add_query_arg('paged', $page, $url);
2293 + }
2294 +
2295 + /**
2296 + * Strip a page number from a URL, whichever form it takes.
2297 + *
2298 + * The inverse of with_pagination(). Pretty permalinks carry the page as a
2299 + * /page/N/ path segment, plain permalinks as a `paged` query arg, and a
2300 + * regex over the path alone silently left the latter in place — so
2301 + * rel="prev" on page 2 pointed at page 2 (#397 review).
2302 + *
2303 + * @since 2.0.1
2304 + *
2305 + * @param string $url URL that may carry a page number.
2306 + * @return string URL for page 1.
2307 + */
2308 + public static function without_pagination(string $url): string {
2309 + if ('' === $url) {
2310 + return $url;
2311 + }
2312 +
2313 + $url = remove_query_arg('paged', $url);
2314 +
2315 + return (string) preg_replace(
2316 + '#/' . preg_quote(self::pagination_base(), '#') . '/\d+/?$#',
2317 + '/',
2318 + $url
2319 + );
2320 + }
2321 +
2322 + /**
2323 + * The page number of the current request, archive or multi-page post.
2324 + *
2325 + * `paged` counts archive pages; `page` counts the <!--nextpage--> parts of
2326 + * a single post. They are never both set.
2327 + *
2328 + * @since 2.0.1
2329 + *
2330 + * @return int Page number, 1 when this is the first page.
2331 + */
2332 + public static function current_page_number(): int {
1556 2333 $paged = (int) get_query_var('paged');
2334 +
1557 2335 if ($paged > 1) {
1558 - global $wp_rewrite;
1559 - $canonical_url = $wp_rewrite->using_permalinks()
1560 - ? trailingslashit($canonical_url) . user_trailingslashit($wp_rewrite->pagination_base . '/' . $paged, 'paged')
1561 - : add_query_arg('paged', $paged, $canonical_url);
2336 + return $paged;
1562 2337 }
1563 2338
1564 - return $canonical_url;
2339 + $page = (int) get_query_var('page');
2340 +
2341 + return $page > 1 ? $page : 1;
1565 2342 }
1566 2343
1567 2344
1568 2345 /**
@@ -1570,12 +2347,13 @@
1570 2347 *
1571 2348 * @return bool True if has ThinkRank metadata
1572 2349 */
1573 2350 private function has_thinkrank_metadata(): bool {
1574 - if (!is_singular()) {
1575 - return false;
1576 - }
1577 -
2351 + // Populated by initialize_current_context() for singular views and for
2352 + // term archives, and left empty everywhere else — so the emptiness
2353 + // check is the whole test. The `!is_singular()` early return this
2354 + // replaced is what made every stored term title and description inert:
2355 + // the entire title/description cascade hangs off this method (#386).
1578 2356 return !empty($this->current_metadata['title']) || !empty($this->current_metadata['description']);
1579 2357 }
1580 2358
1581 2359 /**
@@ -1718,9 +2496,11 @@
1718 2496 // would leave this path open (#363).
1719 2497 if (!empty($post->post_excerpt)) {
1720 2498 $placeholders['%excerpt%'] = $post->post_excerpt;
1721 2499 } elseif (!$this->is_content_password_protected($post->ID)) {
1722 - $placeholders['%excerpt%'] = wp_trim_words(wp_strip_all_tags($post->post_content), 25, '...');
2500 + $placeholders['%excerpt%'] = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
2501 + \ThinkRank\SEO\Builder_Content::visible_content($post)
2502 + );
1723 2503 }
1724 2504 }
1725 2505
1726 2506 // Get author
@@ -1766,9 +2546,9 @@
1766 2546 $separator_pattern = preg_quote($separator, '/');
1767 2547 $title = preg_replace('/\s*' . $separator_pattern . '\s*' . $separator_pattern . '\s*/', ' ' . $separator . ' ', $title);
1768 2548
1769 2549 // Remove leading/trailing separators
1770 - $title = trim($title, " \t\n\r\0\x0B" . $separator);
2550 + $title = \ThinkRank\Core\Seo_Text::trim_separator($title, $separator);
1771 2551
1772 2552 return $title;
1773 2553 }
1774 2554
@@ -1781,8 +2561,17 @@
1781 2561 $settings = $this->site_identity_manager->get_settings('site');
1782 2562
1783 2563 switch ($this->current_context) {
1784 2564 case 'homepage':
2565 + // detect_current_context() collapses the static posts page into
2566 + // 'homepage', so it rendered the front page's title template and
2567 + // the two pages shipped the same <title> — a duplicate title on
2568 + // the site's two most-linked URLs (#397 review). It is a page,
2569 + // and it has its own name, so it gets the page template.
2570 + if (self::is_static_posts_page()) {
2571 + return $settings['page_title'] ?? $settings['homepage_title'] ?? null;
2572 + }
2573 +
1785 2574 return $settings['homepage_title'] ?? null;
1786 2575 case 'post':
1787 2576 return $settings['post_title'] ?? null;
1788 2577 case 'page':
@@ -1813,12 +2602,18 @@
1813 2602 $settings = $this->site_identity_manager->get_settings('site');
1814 2603 $separator = $this->get_title_separator($settings['title_separator'] ?? 'pipe');
1815 2604
1816 2605 $placeholders = [
1817 - '%site_title%' => $settings['site_name'] ?? get_bloginfo('name'),
1818 - '%site_name%' => $settings['site_name'] ?? get_bloginfo('name'),
1819 - '%site_description%' => $settings['site_description'] ?? get_bloginfo('description'),
1820 - '%tagline%' => $settings['tagline'] ?? get_bloginfo('description'),
2606 + // first_non_empty(), not `??`: Site Identity persists these as ''
2607 + // rather than leaving them unset, and '' is not null — so the
2608 + // null-coalesce stopped dead on the empty string and the WordPress
2609 + // fallback was unreachable. A site with a tagline set in Settings →
2610 + // General rendered "%site_description%" as nothing (#398). This is
2611 + // the same reasoning first_non_empty()'s own docblock records.
2612 + '%site_title%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2613 + '%site_name%' => $this->first_non_empty($settings['site_name'] ?? '', get_bloginfo('name')),
2614 + '%site_description%' => $this->first_non_empty($settings['site_description'] ?? '', get_bloginfo('description')),
2615 + '%tagline%' => $this->first_non_empty($settings['tagline'] ?? '', get_bloginfo('description')),
1821 2616 '%separator%' => ' ' . $separator . ' ',
1822 2617 '%sep%' => ' ' . $separator . ' ',
1823 2618 '%date%' => gmdate('F Y'),
1824 2619 ];
@@ -1871,10 +2666,26 @@
1871 2666 $placeholders['%search_term%'] = get_search_query();
1872 2667 break;
1873 2668
1874 2669 case 'archive':
1875 - $placeholders['%archive_title%'] = get_the_archive_title();
2670 + // Stripped: get_the_archive_title() wraps its subject in a
2671 + // <span>, and this placeholder feeds the document <title> as
2672 + // well as og:title and twitter:title — a date archive rendered
2673 + // as "Month: <span>August 2026</span> | Site".
2674 + $placeholders['%archive_title%'] = self::archive_subject();
1876 2675 break;
2676 +
2677 + case 'homepage':
2678 + // The page template resolved for a static posts page needs the
2679 + // page's own name; without it %title%/%page_title% would render
2680 + // empty and collapse back to the site title.
2681 + if (self::is_static_posts_page()) {
2682 + $posts_page_title = get_the_title((int) get_option('page_for_posts'));
2683 + $placeholders['%title%'] = $posts_page_title;
2684 + $placeholders['%page_title%'] = $posts_page_title;
2685 + $placeholders['%post_title%'] = $posts_page_title;
2686 + }
2687 + break;
1877 2688 }
1878 2689
1879 2690 return $placeholders;
1880 2691 }
@@ -1879,8 +2690,19 @@
1879 2690 return $placeholders;
1880 2691 }
1881 2692
1882 2693 /**
2694 + * Whether this request is a static posts page rather than the front page.
2695 + *
2696 + * @since 2.0.1
2697 + *
2698 + * @return bool
2699 + */
2700 + private static function is_static_posts_page(): bool {
2701 + return is_home() && !is_front_page() && (int) get_option('page_for_posts') > 0;
2702 + }
2703 +
2704 + /**
1883 2705 * Process title template with placeholders
1884 2706 *
1885 2707 * @param string $template Template string
1886 2708 * @param array $placeholders Placeholder values
@@ -1975,8 +2797,22 @@
1975 2797 if ($archive_description) {
1976 2798 return $archive_description;
1977 2799 }
1978 2800
2801 + // The blog-index homepage's own description (Site Identity
2802 + // homepage_description, #897), in the same vocabulary as its title. A
2803 + // static front page is a page, and its description is set on it.
2804 + if (is_front_page() && is_home() && $this->site_identity_data && $this->site_identity_data['enabled']) {
2805 + $identity = $this->site_identity_manager->get_settings('site');
2806 + $template = trim((string) ($identity['homepage_description'] ?? ''));
2807 + if ($template !== '') {
2808 + $homepage_description = trim($this->process_title_template($template, $this->get_title_placeholders()));
2809 + if ($homepage_description !== '') {
2810 + return $homepage_description;
2811 + }
2812 + }
2813 + }
2814 +
1979 2815 // Third priority: Site Identity default meta description
1980 2816 if ($this->site_identity_data && $this->site_identity_data['enabled']) {
1981 2817 $settings = $this->site_identity_manager->get_settings('site');
1982 2818 $default_description = $settings['default_meta_description'] ?? '';
@@ -1991,11 +2827,17 @@
1991 2827 // gated body published its first ~25 words in the page head, and the
1992 2828 // same value is reused for og:description and twitter:description, so
1993 2829 // one unguarded read leaked through three tags (#363).
1994 2830 if (is_singular() && $this->current_post_id && !$this->is_content_password_protected()) {
1995 - $post_content = get_post_field('post_content', $this->current_post_id);
2831 + // Not the raw column: a Bricks page discards `post_content`, so
2832 + // whatever is still stored there is invisible — and this one value
2833 + // becomes the meta, og: and twitter: descriptions (#651).
2834 + $described = get_post($this->current_post_id);
2835 + $post_content = $described instanceof \WP_Post
2836 + ? \ThinkRank\SEO\Builder_Content::visible_content($described)
2837 + : get_post_field('post_content', $this->current_post_id);
1996 2838 if ($post_content) {
1997 - $excerpt = wp_trim_words(wp_strip_all_tags($post_content), 25, '...');
2839 + $excerpt = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post_content);
1998 2840 if (!empty($excerpt)) {
1999 2841 return $excerpt;
2000 2842 }
2001 2843 }
@@ -2039,11 +2881,13 @@
2039 2881 if ($description === '') {
2040 2882 return null;
2041 2883 }
2042 2884
2043 - if (strlen($description) > 160) {
2044 - $description = wp_trim_words($description, 25, '...');
2045 - }
2885 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2886 + // CJK description tripped this limit at a third of its length, and
2887 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2888 + // English, 25 characters in Thai (#687).
2889 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2046 2890
2047 2891 return $description;
2048 2892 }
2049 2893
@@ -2090,11 +2934,13 @@
2090 2934 $description = preg_replace('/\s+/', ' ', $description);
2091 2935 $description = trim($description);
2092 2936
2093 2937 // Ensure description doesn't exceed recommended length (160 characters)
2094 - if (strlen($description) > 160) {
2095 - $description = wp_trim_words($description, 25, '...');
2096 - }
2938 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
2939 + // CJK description tripped this limit at a third of its length, and
2940 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
2941 + // English, 25 characters in Thai (#687).
2942 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
2097 2943
2098 2944 return $description;
2099 2945 }
2100 2946
@@ -2105,11 +2951,22 @@
2105 2951 *
2106 2952 * @return void
2107 2953 */
2108 2954 public function output_site_schema_markup(): void {
2109 - $has_schema_manager_output = false;
2955 + $has_site_entity_output = false;
2110 2956 $has_website_schema = false;
2111 2957
2958 + // The master switch on Essential SEO -> Schema Manager. Until #461 this
2959 + // was never read here, so turning schema off left every deployed entity
2960 + // on the page. Read it once and bail before touching the graph.
2961 + if ($this->schema_manager) {
2962 + $schema_settings = $this->schema_manager->get_settings('site', null);
2963 +
2964 + if (isset($schema_settings['enabled']) && !$schema_settings['enabled']) {
2965 + return;
2966 + }
2967 + }
2968 +
2112 2969 // PRIORITY 1: Always output site-wide schemas (Organization, Website, LocalBusiness, Person)
2113 2970 if ($this->schema_manager) {
2114 2971 $site_wide_schemas = $this->schema_manager->get_deployed_schemas('site', null);
2115 2972
@@ -2116,9 +2973,9 @@
2116 2973 if (!empty($site_wide_schemas)) {
2117 2974 foreach ($site_wide_schemas as $schema_type => $schema_info) {
2118 2975 Schema_Graph::instance()->add_supporting($schema_info['data'], (string) $schema_type);
2119 2976 }
2120 - $has_schema_manager_output = true;
2977 + $has_site_entity_output = self::schemas_include_site_entity($site_wide_schemas);
2121 2978 $has_website_schema = isset($site_wide_schemas['WebSite']);
2122 2979 }
2123 2980 }
2124 2981
@@ -2144,16 +3001,24 @@
2144 3001
2145 3002 // PRIORITY 2: Also output page-specific schemas (Article, HowTo, FAQ, etc.) on individual posts/pages
2146 3003 if ($this->schema_manager && (is_single() || is_page())) {
2147 3004 $context_id = get_the_ID();
2148 - $context_type = get_post_type( $context_id );
2149 - $context_type = in_array( $context_type, [ 'site', 'post', 'page', 'product' ] , true) ? $context_type : 'post';
3005 + $context_type = \ThinkRank\SEO\Schema_Management_System::context_type_for_post_type( (string) get_post_type( $context_id ) );
2150 3006
2151 3007 $page_specific_schemas = $this->schema_manager->get_deployed_schemas($context_type, $context_id);
2152 3008
2153 3009 if (!empty($page_specific_schemas)) {
2154 - // Apply filter for Pro to allow multiple schemas
2155 - // In free version, it's limited to 1 schema if not filtered
3010 + // Every deployed schema is rendered, on every plan. How many a
3011 + // page carries is decided when schemas are activated in the
3012 + // editor, not trimmed here by plan (#673).
3013 +
3014 + /**
3015 + * Filter the page-specific schemas rendered on the current page.
3016 + *
3017 + * @param array $page_specific_schemas Deployed schemas keyed by schema type.
3018 + * @param string $context_type Context type (post, page, product, site).
3019 + * @param int $context_id Post ID.
3020 + */
2156 3021 $page_specific_schemas = apply_filters(
2157 3022 'thinkrank_page_schemas_to_render',
2158 3023 $page_specific_schemas,
2159 3024 $context_type,
@@ -2159,18 +3024,27 @@
2159 3024 $context_type,
2160 3025 $context_id
2161 3026 );
2162 3027
2163 - // If still multiple schemas and not Pro, limit to 1 (enforcing free limit)
2164 - $is_pro = \ThinkRank\Core\Plan_Config::is_pro();
2165 - if (!$is_pro && count($page_specific_schemas) > 2) {
2166 - $page_specific_schemas = array_slice($page_specific_schemas, 0, 2, true);
3028 + // A deployed node is a snapshot from Deploy time and outranks
3029 + // the automatic node, so page and article types would publish
3030 + // a frozen excerpt instead of the description the head
3031 + // resolves. Give them the live one, as the automatic node has.
3032 + $context_post = get_post($context_id);
3033 +
3034 + foreach ($page_specific_schemas as $schema_type => $schema_info) {
3035 + $node = $schema_info['data'];
3036 +
3037 + if ($this->global_seo_schema && $context_post instanceof \WP_Post) {
3038 + $node = $this->global_seo_schema->refresh_deployed_description($node, (string) $schema_type, $context_post);
3039 + }
3040 +
3041 + Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager');
2167 3042 }
2168 3043
2169 - foreach ($page_specific_schemas as $schema_type => $schema_info) {
2170 - Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
3044 + if (!$has_site_entity_output) {
3045 + $has_site_entity_output = self::schemas_include_site_entity($page_specific_schemas);
2171 3046 }
2172 - $has_schema_manager_output = true;
2173 3047 }
2174 3048 }
2175 3049
2176 3050 // Absorb FAQ content from the post body (FAQ block / Elementor widget)
@@ -2182,10 +3056,17 @@
2182 3056 Schema_Graph::instance()->collect_post_faq($queried_post);
2183 3057 }
2184 3058 }
2185 3059
2186 - // Skip Site Identity fallback if any Schema Manager schemas were output
2187 - if ($has_schema_manager_output) {
3060 + // The deployed snapshot is exact, so the live builder never runs over
3061 + // it. But the Site Identity fallback below emits one entity, the
3062 + // Organization, and it used to be skipped whenever ANY row was
3063 + // output: a site whose deployed set was a WebSite, or a post with
3064 + // only a deployed Article, published no Organization at all and its
3065 + // publisher could not resolve to #organization (#1006). Skip it only
3066 + // when the Schema Manager already supplied the entity it stands in
3067 + // for, which also keeps a deployed #organization from being doubled.
3068 + if ($has_site_entity_output) {
2188 3069 return;
2189 3070 }
2190 3071
2191 3072 // PRIORITY 2: Fall back to Site Identity schemas (like basic Twitter Cards)
@@ -2207,8 +3088,67 @@
2207 3088 }
2208 3089 }
2209 3090
2210 3091 /**
3092 + * Organization types the Schema Manager can publish the site entity as.
3093 + *
3094 + * The values the organization_type setting offers. LocalBusiness and its
3095 + * subtypes are recognised through Local_Business_Types_Config instead.
3096 + *
3097 + * @since 2.15.0
3098 + * @var string[]
3099 + */
3100 + private const SITE_ENTITY_ORGANIZATION_TYPES = [
3101 + 'Organization',
3102 + 'Corporation',
3103 + 'EducationalOrganization',
3104 + 'GovernmentOrganization',
3105 + 'NGO',
3106 + 'PerformingGroup',
3107 + 'SportsOrganization',
3108 + ];
3109 +
3110 + /**
3111 + * Whether deployed schemas already describe the site's own entity.
3112 + *
3113 + * The entity is what the Site Identity Organization fallback stands in
3114 + * for: an Organization (or one of its configurable subtypes), a Person,
3115 + * or a LocalBusiness of any subtype. Both the row's schema type and the
3116 + * node's resolved @type are checked, since a LocalBusiness row is
3117 + * published as "Dentist" and an Organization row can carry a subtype.
3118 + *
3119 + * @since 2.15.0
3120 + *
3121 + * @param array $schemas Deployed schemas keyed by schema type, each with a 'data' node.
3122 + * @return bool
3123 + */
3124 + private static function schemas_include_site_entity(array $schemas): bool {
3125 + foreach ($schemas as $schema_type => $schema_info) {
3126 + $types = [(string) $schema_type];
3127 +
3128 + if (is_array($schema_info) && is_array($schema_info['data'] ?? null) && isset($schema_info['data']['@type'])) {
3129 + $types = array_merge($types, (array) $schema_info['data']['@type']);
3130 + }
3131 +
3132 + foreach ($types as $type) {
3133 + if (!is_string($type)) {
3134 + continue;
3135 + }
3136 +
3137 + if (
3138 + 'Person' === $type
3139 + || in_array($type, self::SITE_ENTITY_ORGANIZATION_TYPES, true)
3140 + || \ThinkRank\Config\Local_Business_Types_Config::is_local_business($type)
3141 + ) {
3142 + return true;
3143 + }
3144 + }
3145 + }
3146 +
3147 + return false;
3148 + }
3149 +
3150 + /**
2211 3151 * Generate the default WebSite schema for the homepage.
2212 3152 *
2213 3153 * Includes a SearchAction potentialAction so search engines can surface a
2214 3154 * sitelinks searchbox, mirroring what Rank Math/Yoast output by default.
@@ -2221,26 +3161,46 @@
2221 3161 $schema = [
2222 3162 '@context' => 'https://schema.org',
2223 3163 '@type' => 'WebSite',
2224 3164 '@id' => home_url('/#website'),
2225 - 'name' => !empty($settings['site_name']) ? $settings['site_name'] : get_bloginfo('name'),
3165 + // The site name is stored esc_html()'d too, like the tagline below.
3166 + 'name' => \ThinkRank\Core\Seo_Text::normalize_schema_name(
3167 + (string) (!empty($settings['site_name']) ? $settings['site_name'] : get_bloginfo('name'))
3168 + ),
2226 3169 'url' => home_url('/'),
2227 3170 ];
2228 3171
2229 3172 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
3173 + // The tagline is stored esc_html()'d by sanitize_option(), so a site
3174 + // called "Fish & Chips" published `&amp;` literally in its WebSite
3175 + // node; nothing decodes JSON-LD downstream.
3176 + $description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $description);
2230 3177 if (!empty($description)) {
2231 3178 $schema['description'] = $description;
2232 3179 }
2233 3180
2234 - $schema['potentialAction'] = [
2235 - '@type' => 'SearchAction',
2236 - 'target' => [
2237 - '@type' => 'EntryPoint',
2238 - 'urlTemplate' => home_url('/?s={search_term_string}'),
2239 - ],
2240 - 'query-input' => 'required name=search_term_string',
2241 - ];
3181 + // Site Identity has accepted an alternate name since the setup wizard
3182 + // shipped, and the MCP ability describes it as "published as schema
3183 + // alternateName" — but no producer ever read it, so the promise was
3184 + // false and every imported Yoast/Rank Math value sat unused (#692).
3185 + $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema($settings['alternate_name'] ?? null);
3186 + if (null !== $alternate_name) {
3187 + $schema['alternateName'] = $alternate_name;
3188 + }
2242 3189
3190 + // The sitelinks searchbox switch was honoured only for a deployed
3191 + // WebSite row; this live fallback added potentialAction unconditionally,
3192 + // so website_enable_search = 0 still shipped the SearchAction (#688).
3193 + // Absent means not configured, which stays enabled. The switch and the
3194 + // custom search URL go through the same helper the deployed WebSite
3195 + // uses, so the two paths cannot drift apart again (#1009).
3196 + $schema_settings = $this->schema_manager ? $this->schema_manager->get_settings('site', null) : [];
3197 +
3198 + $search_action = \ThinkRank\SEO\Schema_Builder::website_search_action($schema_settings);
3199 + if (null !== $search_action) {
3200 + $schema['potentialAction'] = $search_action;
3201 + }
3202 +
2243 3203 return $schema;
2244 3204 }
2245 3205
2246 3206 /**
@@ -2279,8 +3239,13 @@
2279 3239 $settings['site_description'] ?? null,
2280 3240 get_bloginfo('description')
2281 3241 );
2282 3242
3243 + // Stored esc_html()'d by sanitize_option(), so "Fish & Chips" was
3244 + // published as `Fish &amp; Chips` (#976). Same for the description.
3245 + $org_name = \ThinkRank\Core\Seo_Text::normalize_schema_name((string) $org_name);
3246 + $org_description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $org_description);
3247 +
2283 3248 if (empty($org_name)) {
2284 3249 return null;
2285 3250 }
2286 3251
@@ -2300,10 +3265,15 @@
2300 3265 $schema['description'] = $org_description;
2301 3266 }
2302 3267
2303 3268 // Add logo if available with proper ImageObject structure
2304 - // Priority: Schema Manager logo > Site Identity logo
2305 - $logo_url = $schema_settings['organization_logo'] ?? $settings['logo_url'] ?? '';
3269 + // Priority: Schema Manager logo > Site Identity logo. first_non_empty()
3270 + // rather than ??: organization_logo is always present and defaults to
3271 + // '', which stopped a ?? chain before the Site Identity logo (#1010).
3272 + $logo_url = $this->first_non_empty(
3273 + $schema_settings['organization_logo'] ?? null,
3274 + $settings['logo_url'] ?? null
3275 + );
2306 3276
2307 3277 if (!empty($logo_url)) {
2308 3278 $schema['logo'] = [
2309 3279 '@type' => 'ImageObject',
@@ -2437,8 +3407,15 @@
2437 3407 if (!$this->site_identity_data || !$this->site_identity_data['enabled']) {
2438 3408 return;
2439 3409 }
2440 3410
3411 + // A breadcrumb trail for a URL that does not exist, or for a search
3412 + // results page, describes nothing — and the plugin already emits no
3413 + // canonical on either (#471).
3414 + if (is_404() || is_search()) {
3415 + return;
3416 + }
3417 +
2441 3418 $settings = $this->site_identity_manager->get_settings('site');
2442 3419
2443 3420 // Only output if breadcrumbs are enabled
2444 3421 if (empty($settings['breadcrumbs_enabled'])) {
@@ -2444,8 +3421,22 @@
2444 3421 if (empty($settings['breadcrumbs_enabled'])) {
2445 3422 return;
2446 3423 }
2447 3424
3425 + // Schema Manager's own breadcrumb switch. Only Site Identity's
3426 + // breadcrumbs_enabled was consulted here, so enable_breadcrumbs_schema
3427 + // = 0 removed a deployed BreadcrumbList row and left this live one
3428 + // emitting the node anyway (#688). Absent means not configured, which
3429 + // stays enabled.
3430 + if ($this->schema_manager) {
3431 + $schema_settings = $this->schema_manager->get_settings('site', null);
3432 +
3433 + if (array_key_exists('enable_breadcrumbs_schema', $schema_settings)
3434 + && empty($schema_settings['enable_breadcrumbs_schema'])) {
3435 + return;
3436 + }
3437 + }
3438 +
2448 3439 $breadcrumbs = $this->generate_breadcrumbs($settings);
2449 3440
2450 3441 if (!empty($breadcrumbs['schema'])) {
2451 3442 Schema_Graph::instance()->add_supporting($breadcrumbs['schema'], 'BreadcrumbList');
@@ -2470,25 +3461,34 @@
2470 3461 *
2471 3462 * @return void
2472 3463 */
2473 3464 public function output_closing_comment(): void {
2474 - // Only output if we've output any SEO content
2475 - static $header_output = false;
2476 - if ($header_output || $this->has_seo_output()) {
3465 + // Close only what was actually opened. has_seo_output() is true on
3466 + // nearly every page, so testing it here printed a closing comment with
3467 + // no matching opener whenever the meta description was empty (search
3468 + // results, author archives without a description).
3469 + if (self::$opening_comment_output) {
2477 3470 echo "<!-- /ThinkRank SEO -->\n";
2478 3471 }
2479 3472 }
2480 3473
2481 3474 /**
2482 - * Check if any SEO content has been output
3475 + * Print the opening ThinkRank comment, once per request.
2483 3476 *
2484 - * @return bool True if SEO content was output
3477 + * Public and static so Author_Archives_Manager — which prints its own meta
3478 + * description on wp_head at priority 5 — opens the block through the same
3479 + * flag the closing comment reads.
3480 + *
3481 + * @since 2.0.1
3482 + * @return void
2485 3483 */
2486 - private function has_seo_output(): bool {
2487 - // Check if we have meta description or any other SEO data
2488 - return !empty($this->get_meta_description()) ||
2489 - $this->has_thinkrank_metadata() ||
2490 - ($this->site_identity_data && $this->site_identity_data['enabled']);
3484 + public static function note_opening_comment(): void {
3485 + if (self::$opening_comment_output) {
3486 + return;
3487 + }
3488 +
3489 + echo "<!-- Search Engine Optimization by ThinkRank - https://thinkrank.ai/ -->\n";
3490 + self::$opening_comment_output = true;
2491 3491 }
2492 3492
2493 3493 /**
2494 3494 * Display breadcrumbs HTML
@@ -2691,8 +3691,102 @@
2691 3691 * @since 1.32.0
2692 3692 *
2693 3693 * @return void
2694 3694 */
3695 + /**
3696 + * Serve a ThinkRank sitemap document for this request, when it is one.
3697 + *
3698 + * Only acts in dynamic delivery mode. In static mode a real file exists and
3699 + * the web server returns it without WordPress ever loading, so answering
3700 + * here as well would mean two sources for the same bytes.
3701 + *
3702 + * @since 2.9.0
3703 + *
3704 + * @return void
3705 + */
3706 + public function maybe_serve_sitemap(): void {
3707 + $filename = $this->requested_sitemap_filename();
3708 + if ('' === $filename) {
3709 + return;
3710 + }
3711 +
3712 + try {
3713 + // Read-only instance: passing false keeps it from registering a
3714 + // second copy of the auto-generation hooks.
3715 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3716 + $settings = $generator->get_settings('site');
3717 +
3718 + if (empty($settings['enabled'])) {
3719 + return;
3720 + }
3721 +
3722 + if ('dynamic' !== $generator->resolve_delivery_mode($settings)) {
3723 + return;
3724 + }
3725 +
3726 + if (!$generator->publishes_document_name($filename, $settings)) {
3727 + return;
3728 + }
3729 +
3730 + $xml = $generator->render_document($filename, $settings);
3731 + } catch (\Throwable $e) {
3732 + // A failed render must not replace the sitemap with a fatal. Leave
3733 + // the request alone so WordPress answers as it otherwise would.
3734 + return;
3735 + }
3736 +
3737 + if (!is_string($xml) || '' === trim($xml)) {
3738 + return;
3739 + }
3740 +
3741 + status_header(200);
3742 + header('Content-Type: application/xml; charset=UTF-8');
3743 + header('X-Robots-Tag: noindex, follow', true);
3744 +
3745 + // Built XML, escaped by the builders as they assemble it; escaping the
3746 + // document here would corrupt it.
3747 + echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3748 + exit;
3749 + }
3750 +
3751 + /**
3752 + * The sitemap file name this request is asking for, if it looks like one.
3753 + *
3754 + * Deliberately a cheap shape test. Whether the site actually publishes the
3755 + * name is settled by the caller against the generator, so that a request
3756 + * for someone else's sitemap is never answered here.
3757 + *
3758 + * @since 2.9.0
3759 + *
3760 + * @return string File name, or '' when this is not a sitemap request.
3761 + */
3762 + private function requested_sitemap_filename(): string {
3763 + if (empty($_SERVER['REQUEST_URI'])) {
3764 + return '';
3765 + }
3766 +
3767 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3768 + if (!is_string($path) || '' === $path) {
3769 + return '';
3770 + }
3771 +
3772 + // Strip the install's home path so subdirectory installs match too.
3773 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3774 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3775 + $path = substr($path, strlen($home_path));
3776 + }
3777 +
3778 + $candidate = strtolower(trim($path, '/'));
3779 +
3780 + // One path segment ending in .xml. Anything nested is not a file we
3781 + // publish to the web root.
3782 + if ('' === $candidate || strpos($candidate, '/') !== false) {
3783 + return '';
3784 + }
3785 +
3786 + return substr($candidate, -4) === '.xml' ? $candidate : '';
3787 + }
3788 +
2695 3789 public function maybe_serve_llms_txt(): void {
2696 3790 if (!$this->is_llms_txt_request()) {
2697 3791 return;
2698 3792 }
@@ -2893,11 +3987,22 @@
2893 3987 // second copy of the save_post/term auto-generation hooks.
2894 3988 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
2895 3989 $settings = $generator->get_settings('site');
2896 3990
3991 + // "Can ThinkRank actually answer its sitemap URL right now?" In
3992 + // static mode that means the file is on disk; in dynamic mode
3993 + // maybe_serve_sitemap() answers it, so there is nothing to look
3994 + // for. Keeping the file test as the only answer would have left
3995 + // core's sitemap in place on every dynamic site, which is the
3996 + // crawl conflict this suppression exists to prevent (#752).
3997 + // The #346 behaviour is unchanged: a static site with nothing
3998 + // published still falls through to core rather than 404ing.
3999 + $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
4000 + || $generator->primary_sitemap_file_exists($settings);
4001 +
2897 4002 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
2898 4003 && !$this->publishes_at_core_sitemap_url($settings)
2899 - && $generator->primary_sitemap_file_exists($settings);
4004 + && $can_serve;
2900 4005
2901 4006 if ($this->thinkrank_sitemap_enabled) {
2902 4007 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
2903 4008 }
@@ -2987,15 +4092,17 @@
2987 4092 if (empty($settings['enabled'])) {
2988 4093 return (string) $url;
2989 4094 }
2990 4095
4096 + $size = (int) $size;
4097 +
2991 4098 // Apple touch icon has its own dedicated setting
2992 - if ((int) $size === 180 && !empty($settings['apple_touch_icon_url'])) {
2993 - return esc_url($settings['apple_touch_icon_url']);
4099 + if ($size === 180 && !empty($settings['apple_touch_icon_url'])) {
4100 + return $this->resolve_icon_url((string) $settings['apple_touch_icon_url'], $size);
2994 4101 }
2995 4102
2996 4103 if (!empty($settings['favicon_url'])) {
2997 - return esc_url($settings['favicon_url']);
4104 + return $this->resolve_icon_url((string) $settings['favicon_url'], $size);
2998 4105 }
2999 4106
3000 4107 return (string) $url;
3001 4108 }
@@ -3000,8 +4107,178 @@
3000 4107 return (string) $url;
3001 4108 }
3002 4109
3003 4110 /**
4111 + * Whether breadcrumb labels should prefer the SEO title.
4112 + *
4113 + * Off unless the site turns it on, so updating the plugin never rewrites an
4114 + * existing trail.
4115 + *
4116 + * @since 2.3.1
4117 + *
4118 + * @param array $settings Breadcrumb settings.
4119 + * @return bool
4120 + */
4121 + private function breadcrumbs_use_seo_title(array $settings): bool {
4122 + return !empty($settings['breadcrumb_use_seo_title']);
4123 + }
4124 +
4125 + /**
4126 + * Label for a post in the breadcrumb trail.
4127 + *
4128 + * With the toggle on, the post's own SEO title wins — the same
4129 + * `_thinkrank_seo_title` value (variable tags resolved) the document title
4130 + * uses — so the trail under a search snippet reads the same as the snippet
4131 + * itself. Anything empty falls back to the raw post title; the global title
4132 + * pattern is deliberately NOT part of the chain, since resolving it would
4133 + * append the site name to every crumb.
4134 + *
4135 + * @since 2.3.1
4136 + *
4137 + * @param int $post_id Post ID.
4138 + * @param array $settings Breadcrumb settings.
4139 + * @return string Breadcrumb label.
4140 + */
4141 + private function get_breadcrumb_post_title(int $post_id, array $settings): string {
4142 + $title = (string) get_the_title($post_id);
4143 +
4144 + if (!$this->breadcrumbs_use_seo_title($settings)) {
4145 + return $title;
4146 + }
4147 +
4148 + $seo_title = trim((string) get_post_meta($post_id, '_thinkrank_seo_title', true));
4149 +
4150 + if ('' === $seo_title) {
4151 + return $title;
4152 + }
4153 +
4154 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_value($seo_title, $post_id));
4155 +
4156 + return '' !== $resolved ? $resolved : $title;
4157 + }
4158 +
4159 + /**
4160 + * Label for a term in the breadcrumb trail.
4161 + *
4162 + * Term counterpart to {@see self::get_breadcrumb_post_title()}, resolving
4163 + * the term's `_thinkrank_seo_title` against its own values.
4164 + *
4165 + * @since 2.3.1
4166 + *
4167 + * @param object $term Term object.
4168 + * @param array $settings Breadcrumb settings.
4169 + * @return string Breadcrumb label.
4170 + */
4171 + private function get_breadcrumb_term_title($term, array $settings): string {
4172 + $name = (string) ($term->name ?? '');
4173 +
4174 + if (!$this->breadcrumbs_use_seo_title($settings) || empty($term->term_id)) {
4175 + return $name;
4176 + }
4177 +
4178 + $seo_title = trim((string) get_term_meta((int) $term->term_id, '_thinkrank_seo_title', true));
4179 +
4180 + if ('' === $seo_title) {
4181 + return $name;
4182 + }
4183 +
4184 + $resolved = trim(\ThinkRank\SEO\Pattern_Resolver::resolve_term_value($seo_title, (int) $term->term_id));
4185 +
4186 + return '' !== $resolved ? $resolved : $name;
4187 + }
4188 +
4189 + /**
4190 + * Resolve a configured icon URL to the derivative that fits $size.
4191 + *
4192 + * wp_site_icon() calls get_site_icon_url() four times — 32, 192, 180 and
4193 + * 270 — and pairs the first two with a hardcoded sizes="" attribute. This
4194 + * filter used to answer all four with the same configured URL, so one
4195 + * upload was declared as every size at once: a 1536x1536 original served
4196 + * to paint a 32px tab icon, under a sizes="32x32" label that was simply
4197 + * untrue (#571).
4198 + *
4199 + * Resolution mirrors core's own get_site_icon_url(), including the
4200 + * >= 512 -> 'full' branch, so ThinkRank's override and the core pipeline
4201 + * pick the same file for the same request.
4202 + *
4203 + * An unresolvable URL (one hosted off-site) is returned unchanged. Nothing
4204 + * is knowable about its dimensions, and suppressing it instead would leave
4205 + * the page with no rel="icon" at all — a worse outcome than an approximate
4206 + * size hint.
4207 + *
4208 + * @param string $configured Configured icon URL.
4209 + * @param int $size Icon size core is asking for.
4210 + * @return string Icon URL for that size.
4211 + */
4212 + private function resolve_icon_url(string $configured, int $size): string {
4213 + $cache_key = md5($configured) . ':' . $size;
4214 + $cached = $this->icon_urls();
4215 +
4216 + if (isset($cached[$cache_key])) {
4217 + return $cached[$cache_key];
4218 + }
4219 +
4220 + $attachment_id = \ThinkRank\SEO\Site_Identity_Manager::icon_attachment_id($configured);
4221 +
4222 + if (!$attachment_id) {
4223 + $resolved = esc_url($configured);
4224 + } else {
4225 + // Mirrors core: at 512 and above the original is what is wanted, and
4226 + // asking for an intermediate size that large would only fall back to it.
4227 + $size_data = $size >= 512 ? 'full' : [$size, $size];
4228 + $url = wp_get_attachment_image_url($attachment_id, $size_data);
4229 + $resolved = $url ? esc_url($url) : esc_url($configured);
4230 + }
4231 +
4232 + $this->icon_urls[$cache_key] = $resolved;
4233 +
4234 + if (!$this->icon_urls_dirty) {
4235 + $this->icon_urls_dirty = true;
4236 + // Written once, after the response is assembled, rather than once
4237 + // per size: wp_site_icon() resolves four in a row.
4238 + add_action('shutdown', [$this, 'persist_icon_urls'], 5);
4239 + }
4240 +
4241 + return $resolved;
4242 + }
4243 +
4244 + /**
4245 + * The resolved-icon-URL map, loaded from its transient on first use.
4246 + *
4247 + * @return array<string, string>
4248 + */
4249 + private function icon_urls(): array {
4250 + if ($this->icon_urls === null) {
4251 + $stored = get_transient(\ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT);
4252 + $this->icon_urls = is_array($stored) ? $stored : [];
4253 + }
4254 +
4255 + return $this->icon_urls;
4256 + }
4257 +
4258 + /**
4259 + * Persist newly resolved icon URLs.
4260 + *
4261 + * Public because it runs on `shutdown`. Invalidated wholesale whenever the
4262 + * site identity settings are saved, which is the only moment the icon
4263 + * choice — or the derivatives behind it — can change.
4264 + *
4265 + * @return void
4266 + */
4267 + public function persist_icon_urls(): void {
4268 + if (!$this->icon_urls_dirty || !is_array($this->icon_urls)) {
4269 + return;
4270 + }
4271 +
4272 + $this->icon_urls_dirty = false;
4273 + set_transient(
4274 + \ThinkRank\SEO\Site_Identity_Manager::ICON_URL_TRANSIENT,
4275 + $this->icon_urls,
4276 + DAY_IN_SECONDS
4277 + );
4278 + }
4279 +
4280 + /**
3004 4281 * Get breadcrumb items for current page
3005 4282 *
3006 4283 * @param array $settings Breadcrumb settings
3007 4284 * @return array Breadcrumb items
@@ -3029,9 +4306,9 @@
3029 4306 $categories = get_the_category($current_post_id);
3030 4307 if (!empty($categories)) {
3031 4308 $category = $categories[0];
3032 4309 $items[] = [
3033 - 'title' => $category->name,
4310 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3034 4311 'url' => get_category_link($category->term_id),
3035 4312 'position' => $position++
3036 4313 ];
3037 4314 }
@@ -3036,12 +4313,18 @@
3036 4313 ];
3037 4314 }
3038 4315 }
3039 4316
3040 - // Add current post
3041 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4317 + // Add current post. `empty($x) || $x` is true for every possible
4318 + // value — an unset key, false, 0, '' and any truthy value alike —
4319 + // so the setting had no effect on the rendered breadcrumb or on
4320 + // the BreadcrumbList JSON-LD, while the admin preview honoured it
4321 + // and disagreed with live output (#398). Site_Identity_Manager
4322 + // already had the correct form: default to on, respect an
4323 + // explicit off.
4324 + if ($settings['show_current_page'] ?? true) {
3042 4325 $items[] = [
3043 - 'title' => get_the_title($current_post_id),
4326 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3044 4327 'url' => get_permalink($current_post_id),
3045 4328 'position' => $position,
3046 4329 'current' => true
3047 4330 ];
@@ -3058,9 +4341,9 @@
3058 4341 while ($parent_id) {
3059 4342 $parent = get_post($parent_id);
3060 4343 if ($parent) {
3061 4344 $parents[] = [
3062 - 'title' => get_the_title($parent->ID),
4345 + 'title' => $this->get_breadcrumb_post_title($parent->ID, $settings),
3063 4346 'url' => get_permalink($parent->ID),
3064 4347 'position' => 0 // Will be set later
3065 4348 ];
3066 4349 $parent_id = $parent->post_parent;
@@ -3078,11 +4361,11 @@
3078 4361 $items[] = $parent;
3079 4362 }
3080 4363
3081 4364 // Add current page
3082 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4365 + if ($settings['show_current_page'] ?? true) {
3083 4366 $items[] = [
3084 - 'title' => get_the_title($current_post_id),
4367 + 'title' => $this->get_breadcrumb_post_title($current_post_id, $settings),
3085 4368 'url' => get_permalink($current_post_id),
3086 4369 'position' => $position,
3087 4370 'current' => true
3088 4371 ];
@@ -3098,9 +4381,9 @@
3098 4381 while ($parent_id) {
3099 4382 $parent = get_category($parent_id);
3100 4383 if ($parent && !is_wp_error($parent)) {
3101 4384 $parents[] = [
3102 - 'title' => $parent->name,
4385 + 'title' => $this->get_breadcrumb_term_title($parent, $settings),
3103 4386 'url' => get_category_link($parent->term_id),
3104 4387 'position' => 0 // Will be set later
3105 4388 ];
3106 4389 $parent_id = $parent->parent;
@@ -3118,11 +4401,11 @@
3118 4401 $items[] = $parent;
3119 4402 }
3120 4403
3121 4404 // Add current category
3122 - if (empty($settings['show_current_page']) || $settings['show_current_page']) {
4405 + if ($settings['show_current_page'] ?? true) {
3123 4406 $items[] = [
3124 - 'title' => $category->name,
4407 + 'title' => $this->get_breadcrumb_term_title($category, $settings),
3125 4408 'url' => get_category_link($category->term_id),
3126 4409 'position' => $position,
3127 4410 'current' => true
3128 4411 ];
@@ -3195,12 +4478,16 @@
3195 4478
3196 4479 $schema_items = [];
3197 4480
3198 4481 foreach ($items as $item) {
4482 + // The trail's labels are HTML (get_the_title() is texturized, term
4483 + // names are stored esc_html()'d), which the visible breadcrumb
4484 + // prints as such. JSON-LD is plain text, so the schema copy is
4485 + // decoded here and the visible trail is left alone (#976).
3199 4486 $schema_items[] = [
3200 4487 '@type' => 'ListItem',
3201 4488 'position' => $item['position'],
3202 - 'name' => $item['title'],
4489 + 'name' => \ThinkRank\Core\Seo_Text::normalize_schema_name((string) $item['title']),
3203 4490 'item' => $item['url']
3204 4491 ];
3205 4492 }
3206 4493