PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk All 53 releases
← All changes | includes/frontend/class-seo-manager.php +146 -5 2.7.0 → 2.12.0 View file →
@@ -266,8 +266,16 @@
266 266 // LLMs_Txt_Manager for the static file) guarantees an explicit UTF-8
267 267 // charset. Priority 8 keeps it ahead of redirect_canonical().
268 268 add_action('template_redirect', [$this, 'maybe_serve_llms_txt'], 8);
269 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 +
270 278 // Take WordPress core's own sitemap offline while ThinkRank's is active.
271 279 // Two sitemap indexes on one site is a crawl conflict: core keeps
272 280 // /wp-sitemap.xml served and injects its own "Sitemap:" line into
273 281 // robots.txt (WP_Sitemaps::add_robots, priority 0). Until now that line
@@ -369,8 +377,16 @@
369 377 }
370 378
371 379 // Initialize Global SEO Schema Output and store reference
372 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 + );
373 389 $this->global_seo_schema->init();
374 390 }
375 391
376 392 /**
@@ -1783,12 +1799,16 @@
1783 1799 // Get image dimensions and alt text
1784 1800 $image_id = get_post_thumbnail_id($this->current_post_id);
1785 1801 $image_meta = wp_get_attachment_metadata($image_id);
1786 1802 if ($image_meta) {
1803 + // The `large` file being published, not the original it
1804 + // was generated from: the metadata's own width and height
1805 + // describe an image this tag does not point at (#847).
1806 + $image_file = \ThinkRank\SEO\Attachment_Lookup::describe((int) $image_id, (string) $image_url);
1787 1807 // SVGs (and other vector uploads) report 0x0 — emitting
1788 1808 // those as og:image dimensions is invalid, so skip them.
1789 - $og_width = isset($image_meta['width']) ? (int) $image_meta['width'] : 0;
1790 - $og_height = isset($image_meta['height']) ? (int) $image_meta['height'] : 0;
1809 + $og_width = $image_file['width'];
1810 + $og_height = $image_file['height'];
1791 1811 if ($og_width > 0 && $og_height > 0) {
1792 1812 echo "<meta property=\"og:image:width\" content=\"" . esc_attr($og_width) . "\" />\n";
1793 1813 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1794 1814 }
@@ -1793,9 +1813,9 @@
1793 1813 echo "<meta property=\"og:image:height\" content=\"" . esc_attr($og_height) . "\" />\n";
1794 1814 }
1795 1815 // Derive the real mime type instead of hardcoding image/jpeg,
1796 1816 // which mislabels PNG/WebP featured images.
1797 - $image_mime = get_post_mime_type($image_id);
1817 + $image_mime = $image_file['type'];
1798 1818 if ($image_mime) {
1799 1819 echo "<meta property=\"og:image:type\" content=\"" . esc_attr($image_mime) . "\" />\n";
1800 1820 }
1801 1821 }
@@ -2946,10 +2966,22 @@
2946 2966 $context_type,
2947 2967 $context_id
2948 2968 );
2949 2969
2970 + // A deployed node is a snapshot from Deploy time and outranks
2971 + // the automatic node, so page and article types would publish
2972 + // a frozen excerpt instead of the description the head
2973 + // resolves. Give them the live one, as the automatic node has.
2974 + $context_post = get_post($context_id);
2975 +
2950 2976 foreach ($page_specific_schemas as $schema_type => $schema_info) {
2951 - Schema_Graph::instance()->add_primary($schema_info['data'], (string) $schema_type, 'schema_manager');
2977 + $node = $schema_info['data'];
2978 +
2979 + if ($this->global_seo_schema && $context_post instanceof \WP_Post) {
2980 + $node = $this->global_seo_schema->refresh_deployed_description($node, (string) $schema_type, $context_post);
2981 + }
2982 +
2983 + Schema_Graph::instance()->add_primary($node, (string) $schema_type, 'schema_manager');
2952 2984 }
2953 2985 $has_schema_manager_output = true;
2954 2986 }
2955 2987 }
@@ -3007,8 +3039,12 @@
3007 3039 'url' => home_url('/'),
3008 3040 ];
3009 3041
3010 3042 $description = !empty($settings['site_description']) ? $settings['site_description'] : get_bloginfo('description');
3043 + // The tagline is stored esc_html()'d by sanitize_option(), so a site
3044 + // called "Fish & Chips" published `&amp;` literally in its WebSite
3045 + // node; nothing decodes JSON-LD downstream.
3046 + $description = \ThinkRank\Core\Seo_Text::normalize_schema_text((string) $description);
3011 3047 if (!empty($description)) {
3012 3048 $schema['description'] = $description;
3013 3049 }
3014 3050
@@ -3526,8 +3562,102 @@
3526 3562 * @since 1.32.0
3527 3563 *
3528 3564 * @return void
3529 3565 */
3566 + /**
3567 + * Serve a ThinkRank sitemap document for this request, when it is one.
3568 + *
3569 + * Only acts in dynamic delivery mode. In static mode a real file exists and
3570 + * the web server returns it without WordPress ever loading, so answering
3571 + * here as well would mean two sources for the same bytes.
3572 + *
3573 + * @since 2.9.0
3574 + *
3575 + * @return void
3576 + */
3577 + public function maybe_serve_sitemap(): void {
3578 + $filename = $this->requested_sitemap_filename();
3579 + if ('' === $filename) {
3580 + return;
3581 + }
3582 +
3583 + try {
3584 + // Read-only instance: passing false keeps it from registering a
3585 + // second copy of the auto-generation hooks.
3586 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3587 + $settings = $generator->get_settings('site');
3588 +
3589 + if (empty($settings['enabled'])) {
3590 + return;
3591 + }
3592 +
3593 + if ('dynamic' !== $generator->resolve_delivery_mode($settings)) {
3594 + return;
3595 + }
3596 +
3597 + if (!$generator->publishes_document_name($filename, $settings)) {
3598 + return;
3599 + }
3600 +
3601 + $xml = $generator->render_document($filename, $settings);
3602 + } catch (\Throwable $e) {
3603 + // A failed render must not replace the sitemap with a fatal. Leave
3604 + // the request alone so WordPress answers as it otherwise would.
3605 + return;
3606 + }
3607 +
3608 + if (!is_string($xml) || '' === trim($xml)) {
3609 + return;
3610 + }
3611 +
3612 + status_header(200);
3613 + header('Content-Type: application/xml; charset=UTF-8');
3614 + header('X-Robots-Tag: noindex, follow', true);
3615 +
3616 + // Built XML, escaped by the builders as they assemble it; escaping the
3617 + // document here would corrupt it.
3618 + echo $xml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
3619 + exit;
3620 + }
3621 +
3622 + /**
3623 + * The sitemap file name this request is asking for, if it looks like one.
3624 + *
3625 + * Deliberately a cheap shape test. Whether the site actually publishes the
3626 + * name is settled by the caller against the generator, so that a request
3627 + * for someone else's sitemap is never answered here.
3628 + *
3629 + * @since 2.9.0
3630 + *
3631 + * @return string File name, or '' when this is not a sitemap request.
3632 + */
3633 + private function requested_sitemap_filename(): string {
3634 + if (empty($_SERVER['REQUEST_URI'])) {
3635 + return '';
3636 + }
3637 +
3638 + $path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH);
3639 + if (!is_string($path) || '' === $path) {
3640 + return '';
3641 + }
3642 +
3643 + // Strip the install's home path so subdirectory installs match too.
3644 + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
3645 + if ('' !== $home_path && '/' !== $home_path && 0 === strpos($path, $home_path)) {
3646 + $path = substr($path, strlen($home_path));
3647 + }
3648 +
3649 + $candidate = strtolower(trim($path, '/'));
3650 +
3651 + // One path segment ending in .xml. Anything nested is not a file we
3652 + // publish to the web root.
3653 + if ('' === $candidate || strpos($candidate, '/') !== false) {
3654 + return '';
3655 + }
3656 +
3657 + return substr($candidate, -4) === '.xml' ? $candidate : '';
3658 + }
3659 +
3530 3660 public function maybe_serve_llms_txt(): void {
3531 3661 if (!$this->is_llms_txt_request()) {
3532 3662 return;
3533 3663 }
@@ -3728,11 +3858,22 @@
3728 3858 // second copy of the save_post/term auto-generation hooks.
3729 3859 $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3730 3860 $settings = $generator->get_settings('site');
3731 3861
3862 + // "Can ThinkRank actually answer its sitemap URL right now?" In
3863 + // static mode that means the file is on disk; in dynamic mode
3864 + // maybe_serve_sitemap() answers it, so there is nothing to look
3865 + // for. Keeping the file test as the only answer would have left
3866 + // core's sitemap in place on every dynamic site, which is the
3867 + // crawl conflict this suppression exists to prevent (#752).
3868 + // The #346 behaviour is unchanged: a static site with nothing
3869 + // published still falls through to core rather than 404ing.
3870 + $can_serve = 'dynamic' === $generator->resolve_delivery_mode($settings)
3871 + || $generator->primary_sitemap_file_exists($settings);
3872 +
3732 3873 $this->thinkrank_sitemap_enabled = !empty($settings['enabled'])
3733 3874 && !$this->publishes_at_core_sitemap_url($settings)
3734 - && $generator->primary_sitemap_file_exists($settings);
3875 + && $can_serve;
3735 3876
3736 3877 if ($this->thinkrank_sitemap_enabled) {
3737 3878 $this->thinkrank_sitemap_url = $generator->get_primary_sitemap_url($settings);
3738 3879 }