PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/seo/class-site-identity-manager.php +234 -15 2.5.0 → 2.9.0 View file →
@@ -40,8 +40,36 @@
40 40 */
41 41 class Site_Identity_Manager extends Abstract_SEO_Manager {
42 42
43 43 /**
44 + * The per-context title formats as ThinkRank ships them.
45 + *
46 + * These are not in get_default_settings(): the admin screen seeds them on
47 + * first save, so on a real install they are stored values, indistinguishable
48 + * from a template the user typed. The migration needs to tell those two
49 + * apart — it may overwrite a shipped default with an imported template, and
50 + * must never overwrite a choice the user made — so this is the record of
51 + * what "untouched" looks like.
52 + *
53 + * Keep in step with getDefaultSettings() in
54 + * src/admin/components/essential-seo/SiteIdentityTab.js. SiteIdentityTitleFormatDefaultsTest
55 + * fails when the two drift.
56 + *
57 + * @since 2.8.0
58 + * @var array<string, string>
59 + */
60 + public const TITLE_FORMAT_DEFAULTS = [
61 + 'homepage_title' => '%site_title% %sep% %site_description%',
62 + 'post_title' => '%post_title% %sep% %site_title%',
63 + 'page_title' => '%page_title% %sep% %site_title%',
64 + 'category_title' => '%category_title% %sep% %site_title%',
65 + 'tag_title' => '%tag_title% %sep% %site_title%',
66 + 'author_title' => '%author_name% %sep% %site_title%',
67 + 'search_title' => 'Search Results for "%search_term%" %sep% %site_title%',
68 + 'archive_title' => '%archive_title% %sep% %site_title%',
69 + ];
70 +
71 + /**
44 72 * WordPress filesystem instance
45 73 *
46 74 * @since 1.0.0
47 75 * @var \WP_Filesystem_Base|null
@@ -303,8 +331,91 @@
303 331 }
304 332 }
305 333
306 334 /**
335 + * Save settings, then refresh what a new canonical scheme invalidates.
336 + *
337 + * The static sitemap files are written with the scheme in force when they
338 + * were built, and nothing else rebuilds them until a post or term changes.
339 + * So a change of scheme left every `<loc>` on the old one while canonical
340 + * and og:url had already moved (#736). Every writer (the settings route,
341 + * the robots route, the MCP abilities, an import) lands here.
342 + *
343 + * @since 2.7.0
344 + *
345 + * @param string $context_type Context type.
346 + * @param int|null $context_id Context ID.
347 + * @param array $settings Settings to save.
348 + * @return bool
349 + */
350 + public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
351 + if (!self::touches_canonical_scheme($context_type, $context_id, $settings)) {
352 + return parent::save_settings($context_type, $context_id, $settings);
353 + }
354 +
355 + $before = Url_Scheme::preference();
356 + $saved = parent::save_settings($context_type, $context_id, $settings);
357 +
358 + if ($saved) {
359 + $this->on_canonical_scheme_saved($before);
360 + }
361 +
362 + return $saved;
363 + }
364 +
365 + /**
366 + * Whether a save can change the site-wide canonical scheme.
367 + *
368 + * @since 2.7.0
369 + *
370 + * @param string $context_type Context type.
371 + * @param int|null $context_id Context ID.
372 + * @param array $settings Settings being saved.
373 + * @return bool
374 + */
375 + public static function touches_canonical_scheme(string $context_type, ?int $context_id, array $settings): bool {
376 + return 'site' === sanitize_key($context_type)
377 + && empty($context_id)
378 + && array_key_exists('canonical_scheme', $settings);
379 + }
380 +
381 + /**
382 + * Rebuild the static sitemaps when the effective scheme changed.
383 + *
384 + * Compares the effective preference, filter included, so a site whose
385 + * scheme is pinned by `thinkrank_canonical_scheme` does not rebuild on a
386 + * stored value that changes nothing it publishes.
387 + *
388 + * @since 2.7.0
389 + *
390 + * @param string $before Effective scheme before the save.
391 + * @return void
392 + */
393 + protected function on_canonical_scheme_saved(string $before): void {
394 + // The preference is cached for the request; the save just changed it.
395 + Url_Scheme::reset();
396 +
397 + if (Url_Scheme::preference() === $before) {
398 + return;
399 + }
400 +
401 + $this->schedule_sitemap_rebuild();
402 + }
403 +
404 + /**
405 + * Queue a settings-driven sitemap rebuild.
406 + *
407 + * Debounced and run after the response, like any other settings change
408 + * that alters what the sitemap publishes.
409 + *
410 + * @since 2.7.0
411 + * @return void
412 + */
413 + protected function schedule_sitemap_rebuild(): void {
414 + (new Sitemap_Generator(false))->schedule_regeneration();
415 + }
416 +
417 + /**
307 418 * Build the icon derivatives for a newly chosen favicon.
308 419 *
309 420 * Runs on save, which is the only moment the choice changes and the only
310 421 * place image work belongs — resolving a size on the front end must stay a
@@ -1178,17 +1289,19 @@
1178 1289 $home_text = $settings['breadcrumb_home_text'] ?? 'Home';
1179 1290 if (empty($home_text)) {
1180 1291 $optimization['warnings'][] = 'Empty home text reduces accessibility for screen readers';
1181 1292 $optimization['score'] -= 15;
1182 - } elseif (strlen($home_text) > 20) {
1183 - $optimization['suggestions'][] = 'Keep home text concise (current: ' . strlen($home_text) . ' chars)';
1293 + } elseif (mb_strlen($home_text) > 20) {
1294 + // mb_strlen: this number is shown to the user as "chars" (#687).
1295 + $optimization['suggestions'][] = 'Keep home text concise (current: ' . mb_strlen($home_text) . ' chars)';
1184 1296 $optimization['score'] -= 5;
1185 1297 }
1186 1298
1187 1299 // Check prefix usage
1188 1300 $prefix = $settings['breadcrumb_prefix'] ?? '';
1189 - if (!empty($prefix) && strlen($prefix) > 50) {
1190 - $optimization['suggestions'][] = 'Breadcrumb prefix is quite long (' . strlen($prefix) . ' chars) - consider shortening';
1301 + if (!empty($prefix) && mb_strlen($prefix) > 50) {
1302 + // mb_strlen: this number is shown to the user as "chars" (#687).
1303 + $optimization['suggestions'][] = 'Breadcrumb prefix is quite long (' . mb_strlen($prefix) . ' chars) - consider shortening';
1191 1304 $optimization['score'] -= 5;
1192 1305 }
1193 1306
1194 1307 // Current page display
@@ -2759,8 +2872,47 @@
2759 2872 * @since 2.0.1
2760 2873 *
2761 2874 * @return string[]
2762 2875 */
2876 + /**
2877 + * The stored alternate name(s), shaped for schema output.
2878 + *
2879 + * schema.org and Google both allow `alternateName` to carry one value or
2880 + * several, and the store already round-trips either shape, so this accepts
2881 + * both and normalises: null when there is nothing to publish, a bare string
2882 + * for one name, a list for more. Emitting a one-element array would be
2883 + * valid but noisier than it needs to be.
2884 + *
2885 + * Shared because both WebSite producers need it and must agree — a property
2886 + * added to one and not the other is how #688 happened.
2887 + *
2888 + * @since 2.7.0
2889 + *
2890 + * @param mixed $value Stored alternate_name value.
2891 + * @return string|string[]|null
2892 + */
2893 + public static function alternate_name_for_schema($value) {
2894 + $names = [];
2895 +
2896 + foreach ((array) $value as $name) {
2897 + if (!is_scalar($name)) {
2898 + continue;
2899 + }
2900 +
2901 + $name = trim((string) $name);
2902 +
2903 + if ('' !== $name && !in_array($name, $names, true)) {
2904 + $names[] = $name;
2905 + }
2906 + }
2907 +
2908 + if (empty($names)) {
2909 + return null;
2910 + }
2911 +
2912 + return 1 === count($names) ? $names[0] : $names;
2913 + }
2914 +
2763 2915 protected function additional_setting_keys(): array {
2764 2916 return [
2765 2917 // Title formats, one per context.
2766 2918 'homepage_title', 'post_title', 'page_title', 'category_title',
@@ -2811,8 +2963,18 @@
2811 2963 if (array_key_exists('ai_crawler_rules', $sanitized)) {
2812 2964 $sanitized['ai_crawler_rules'] = AI_Crawlers::normalize_rules($sanitized['ai_crawler_rules']);
2813 2965 }
2814 2966
2967 + // Same reasoning one key up, for the scheme override (#638). Anything
2968 + // that is not one of the three modes means "follow WordPress", and is
2969 + // stored as that rather than kept verbatim — otherwise get-site-identity
2970 + // -settings would report a scheme the site does not actually publish.
2971 + if (array_key_exists('canonical_scheme', $sanitized)) {
2972 + $sanitized['canonical_scheme'] = in_array($sanitized['canonical_scheme'], Url_Scheme::MODES, true)
2973 + ? $sanitized['canonical_scheme']
2974 + : Url_Scheme::AUTOMATIC;
2975 + }
2976 +
2815 2977 return $sanitized;
2816 2978 }
2817 2979
2818 2980 /**
@@ -2836,8 +2998,27 @@
2836 2998 'breadcrumb_home_text' => 'Home',
2837 2999 'breadcrumb_separator' => '>',
2838 3000 'robots_txt_enabled' => true,
2839 3001 'allow_search_engines' => true,
3002 + // Answer 404 when a content selector in the URL resolved to
3003 + // nothing (#634). On by default, unlike the other new settings
3004 + // here: it changes no URL a visitor or a correct crawler uses, only
3005 + // ones where WordPress resolved nothing and served the blog listing
3006 + // at 200 anyway.
3007 + 'query_protection' => true,
3008 +
3009 + // Feed controls (#635). All three off, so an upgrade changes
3010 + // nothing about what an existing site already sends its
3011 + // subscribers; a brand-new install is seeded with the signature and
3012 + // the noindex on, in Activator::seed_feed_defaults().
3013 + 'feed_excerpt_only' => false,
3014 + 'feed_source_link' => false,
3015 + 'feed_noindex' => false,
3016 +
3017 + // The scheme self-referential URLs go out with (#638). 'automatic'
3018 + // means substitute nothing and follow WordPress, which is what
3019 + // every site did before the setting existed.
3020 + 'canonical_scheme' => Url_Scheme::AUTOMATIC,
2840 3021 'robots_txt_content' => '',
2841 3022 // Empty map = every AI crawler allowed. Defaults must stay
2842 3023 // permissive so an upgrade never starts blocking a crawler a site
2843 3024 // was happily serving (#657).
@@ -3051,16 +3232,17 @@
3051 3232 // Remove extra whitespace
3052 3233 $title = preg_replace('/\s+/', ' ', $title);
3053 3234 $title = trim($title);
3054 3235
3055 - // Ensure title is not too long (60 characters max for SEO)
3056 - if (strlen($title) > 60) {
3057 - // Try to truncate at word boundary
3058 - $title = wp_trim_words($title, 8, '...');
3059 - if (strlen($title) > 60) {
3060 - $title = substr($title, 0, 57) . '...';
3061 - }
3062 - }
3236 + // Ensure title is not too long (60 characters max for SEO).
3237 + // All three units here were wrong for non-Latin text: strlen() counts
3238 + // BYTES so the gate fired at 20 Thai characters, wp_trim_words() counts
3239 + // CHARACTERS on th/ja/zh_* so `8` cut the title to 8 of them, and
3240 + // substr() cuts bytes so it split a character mid-sequence (#687).
3241 + $title = \ThinkRank\Core\Seo_Text::trim_to_length(
3242 + $title,
3243 + \ThinkRank\Core\Seo_Text::TITLE_MAX_LENGTH
3244 + );
3063 3245
3064 3246 // Ensure title is not empty
3065 3247 if (empty($title)) {
3066 3248 $title = get_bloginfo('name');
@@ -3450,8 +3632,29 @@
3450 3632 * @since 1.0.0
3451 3633 * @return array Array of sitemap URLs
3452 3634 */
3453 3635 private function get_sitemap_urls_for_robots(): array {
3636 + // One wrapper over every return path below, including the #104 extras.
3637 + // The Sitemap: line is the only absolute URL of ours in robots.txt and
3638 + // the one a crawler follows to find everything else, so it has to carry
3639 + // the site's scheme preference (#638). Applied here rather than where
3640 + // the body is assembled, because that path also renders a robots.txt a
3641 + // site owner typed themselves, and their text is not ours to rewrite.
3642 + return array_map(
3643 + static function (string $url): string {
3644 + return Url_Scheme::apply($url);
3645 + },
3646 + $this->collect_sitemap_urls_for_robots()
3647 + );
3648 + }
3649 +
3650 + /**
3651 + * The sitemap URLs robots.txt advertises, before the scheme preference.
3652 + *
3653 + * @since 1.0.0
3654 + * @return array Array of sitemap URLs
3655 + */
3656 + private function collect_sitemap_urls_for_robots(): array {
3454 3657 try {
3455 3658 // Get sitemap settings
3456 3659 $sitemap_generator = new \ThinkRank\SEO\Sitemap_Generator();
3457 3660 $sitemap_settings = $sitemap_generator->get_settings('site');
@@ -3500,9 +3703,22 @@
3500 3703 // business sitemap and the sitemaps other plugins register both land
3501 3704 // here for the same reason, so they go through one list (#104).
3502 3705 $extra = [];
3503 3706
3504 - if (file_exists(ABSPATH . 'local-sitemap.xml')) {
3707 + // Not a file test. Under dynamic delivery the local sitemap is
3708 + // served from PHP and no file is ever written, so file_exists()
3709 + // silently dropped a sitemap the site really does publish (#752).
3710 + // On static sites the file is still what proves it, so both count.
3711 + $local_sitemap_published = file_exists(ABSPATH . 'local-sitemap.xml');
3712 +
3713 + if (!$local_sitemap_published && class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
3714 + $generator = new \ThinkRank\SEO\Sitemap_Generator(false);
3715 +
3716 + $local_sitemap_published = 'dynamic' === $generator->resolve_delivery_mode()
3717 + && $generator->publishes_local_sitemap();
3718 + }
3719 +
3720 + if ($local_sitemap_published) {
3505 3721 $extra[] = '/local-sitemap.xml';
3506 3722 }
3507 3723
3508 3724 foreach (\ThinkRank\SEO\Sitemap_Generator::additional_sitemaps() as $path) {
@@ -3927,11 +4143,14 @@
3927 4143 $optimization['validation']['valid'] = false;
3928 4144 }
3929 4145
3930 4146 if (!empty($value) && isset($config['max_length'])) {
3931 - if (strlen($value) > $config['max_length']) {
4147 + // The warning says "characters", so measure and cut in characters:
4148 + // strlen()/substr() fired early on non-Latin values and the
4149 + // suggested replacement was cut mid-character (#687).
4150 + if (mb_strlen($value) > $config['max_length']) {
3932 4151 $optimization['validation']['warnings'][] = "{$element} exceeds maximum length of {$config['max_length']} characters";
3933 - $optimization['optimized_value'] = substr($value, 0, $config['max_length']);
4152 + $optimization['optimized_value'] = \ThinkRank\Core\Seo_Text::trim_to_length($value, (int) $config['max_length']);
3934 4153 }
3935 4154 }
3936 4155
3937 4156 // SEO-specific optimizations