PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.1.0 1.10.0 All 48 releases
← All changes | includes/integrations/class-multilingual-manager.php +205 -14 1.25.02.7.0 View file →
@@ -1,17 +1,17 @@
1 1 <?php
2 2 /**
3 - * Multilingual (WPML / Polylang) integration.
3 + * Multilingual (WPML / Polylang / TranslatePress) integration.
4 4 *
5 5 * Auto-enables when a supported multilingual plugin is active and fills the
6 6 * gaps ThinkRank leaves on a translated site:
7 7 *
8 - * - `og:locale:alternate` for every translated language. Neither WPML nor
9 - * Polylang emits Open Graph locale alternates, so without this the social
10 - * crawlers see a single-language site.
8 + * - `og:locale:alternate` for every translated language. None of the three
9 + * supported plugins emits Open Graph locale alternates, so without this the
10 + * social crawlers see a single-language site.
11 11 * - `hreflang` alternates, but ONLY when the active multilingual plugin did
12 - * not already print them for this request. WPML and Polylang both ship
13 - * their own hreflang output, so emitting ours unconditionally would produce
12 + * not already print them for this request. All three ship their own
13 + * hreflang output, so emitting ours unconditionally would produce
14 14 * duplicate, competing alternates — worse than the gap it set out to fix.
15 15 * WPML in particular registers its callback unconditionally and then gates
16 16 * the actual output behind internal `must_render()` checks, so "the setting
17 17 * is enabled" does not imply "tags were printed". We therefore observe what
@@ -19,8 +19,16 @@
19 19 * - Language-aware sitemaps, via the sitemap query filters. What this needs
20 20 * in practice was measured rather than assumed — see the two filter methods
21 21 * at the bottom of this class.
22 22 *
23 + * A note on TranslatePress, which is architecturally unlike the other two: it
24 + * does not create a post per language. One post is rendered and its output is
25 + * translated on the way out, so there is nothing extra for the sitemap to find
26 + * and no per-language post meta to reconcile — the whole sitemap side is a
27 + * no-op there. What it does need is `og:locale`, which TranslatePress never
28 + * touches at all: without this integration every translated URL advertises the
29 + * site's default locale to social crawlers.
30 + *
23 31 * @package ThinkRank\Integrations
24 32 * @since 1.23.0
25 33 */
26 34
@@ -37,9 +45,10 @@
37 45 */
38 46 class Multilingual_Manager {
39 47
40 48 /**
41 - * Active provider: 'wpml', 'polylang' or '' when the site is monolingual.
49 + * Active provider: 'wpml', 'polylang', 'translatepress', or '' when the
50 + * site is monolingual.
42 51 *
43 52 * @var string
44 53 */
45 54 private $provider = '';
@@ -71,8 +80,9 @@
71 80 // Keep sitemap queries covering every language. Registered for admin
72 81 // and front end alike: the sitemap can be generated from either.
73 82 add_filter('thinkrank_sitemap_query_args', [$this, 'filter_sitemap_query_args']);
74 83 add_filter('thinkrank_sitemap_term_query_args', [$this, 'filter_sitemap_term_query_args']);
84 + add_filter('thinkrank_sitemap_post_permalink', [$this, 'localize_sitemap_permalink'], 10, 2);
75 85
76 86 if (!is_admin()) {
77 87 // WPML prints at wp_head priority 1 and Polylang at 10, so run
78 88 // after both: by then we know whether anything was printed.
@@ -135,9 +145,12 @@
135 145
136 146 /**
137 147 * Which multilingual plugin is running.
138 148 *
139 - * @return string 'wpml', 'polylang', or '' when none is active.
149 + * Checked in order of specificity. A site running two of these at once is
150 + * already broken, so first match wins rather than trying to merge them.
151 + *
152 + * @return string 'wpml', 'polylang', 'translatepress', or '' when none is active.
140 153 */
141 154 private function detect_provider(): string {
142 155 if (defined('ICL_SITEPRESS_VERSION') && has_filter('wpml_active_languages')) {
143 156 return 'wpml';
@@ -146,8 +159,14 @@
146 159 if (function_exists('pll_the_languages') && function_exists('pll_default_language')) {
147 160 return 'polylang';
148 161 }
149 162
163 + // TranslatePress. The constant alone isn't enough — the instance is what
164 + // we actually read languages and URLs from, so require the class too.
165 + if (defined('TRP_PLUGIN_VERSION') && class_exists('\TRP_Translate_Press')) {
166 + return 'translatepress';
167 + }
168 +
150 169 return '';
151 170 }
152 171
153 172 /**
@@ -259,9 +278,13 @@
259 278 return $this->provider_printed_hreflang;
260 279 }
261 280
262 281 // Polylang prints hreflang alternates on the front end with no setting
263 - // to turn them off, so never double up.
282 + // to turn them off. TranslatePress hooks its own
283 + // TRP_Url_Converter::add_hreflang_to_head() onto wp_head
284 + // unconditionally, and emits region-independent variants and x-default
285 + // on top — a second set from us would compete with all of it. Never
286 + // double up on either.
264 287 return true;
265 288 }
266 289
267 290 /**
@@ -269,11 +292,21 @@
269 292 *
270 293 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
271 294 */
272 295 private function get_alternates(): array {
273 - $alternates = $this->provider === 'wpml'
274 - ? $this->get_wpml_alternates()
275 - : $this->get_polylang_alternates();
296 + switch ($this->provider) {
297 + case 'wpml':
298 + $alternates = $this->get_wpml_alternates();
299 + break;
300 + case 'polylang':
301 + $alternates = $this->get_polylang_alternates();
302 + break;
303 + case 'translatepress':
304 + $alternates = $this->get_translatepress_alternates();
305 + break;
306 + default:
307 + $alternates = [];
308 + }
276 309
277 310 /**
278 311 * Filter the resolved language alternates before output.
279 312 *
@@ -290,14 +323,17 @@
290 323 *
291 324 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
292 325 */
293 326 private function get_wpml_alternates(): array {
327 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
294 328 $languages = apply_filters('wpml_active_languages', null, ['skip_missing' => 1]);
295 329 if (!is_array($languages) || empty($languages)) {
296 330 return [];
297 331 }
298 332
333 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
299 334 $default = (string) apply_filters('wpml_default_language', null);
335 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
300 336 $current = (string) apply_filters('wpml_current_language', null);
301 337 $out = [];
302 338
303 339 foreach ($languages as $language) {
@@ -368,8 +404,100 @@
368 404 return $out;
369 405 }
370 406
371 407 /**
408 + * Resolve alternates from TranslatePress.
409 + *
410 + * TranslatePress has no public helper for "every published language and its
411 + * URL", so this reads the two components it exposes through its singleton:
412 + * `settings` for the published-language list, `url_converter` for the URL of
413 + * the current request in a given language.
414 + *
415 + * Its language codes are already locales (`en_US`, `de_DE`), which is why
416 + * `locale` and `code` carry the same value here — unlike Polylang, where the
417 + * slug and locale differ.
418 + *
419 + * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
420 + */
421 + private function get_translatepress_alternates(): array {
422 + if (!class_exists('\TRP_Translate_Press')) {
423 + return [];
424 + }
425 +
426 + $trp = \TRP_Translate_Press::get_trp_instance();
427 + if (!is_object($trp) || !method_exists($trp, 'get_component')) {
428 + return [];
429 + }
430 +
431 + $settings_component = $trp->get_component('settings');
432 + $url_converter = $trp->get_component('url_converter');
433 +
434 + if (!is_object($settings_component)
435 + || !is_object($url_converter)
436 + || !method_exists($settings_component, 'get_settings')
437 + || !method_exists($url_converter, 'get_url_for_language')
438 + ) {
439 + return [];
440 + }
441 +
442 + $settings = (array) $settings_component->get_settings();
443 + $languages = $settings['publish-languages'] ?? [];
444 +
445 + if (!is_array($languages) || empty($languages)) {
446 + return [];
447 + }
448 +
449 + $default = (string) ($settings['default-language'] ?? '');
450 +
451 + // TranslatePress tracks the language being rendered on a global rather
452 + // than through an accessor.
453 + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- TranslatePress' own global; the name is theirs.
454 + global $TRP_LANGUAGE;
455 + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- TranslatePress' own global; the name is theirs.
456 + $current = is_string($TRP_LANGUAGE) ? $TRP_LANGUAGE : '';
457 +
458 + $out = [];
459 +
460 + foreach ($languages as $language) {
461 + $language = (string) $language;
462 + if ($language === '') {
463 + continue;
464 + }
465 +
466 + // get_url_for_language() tags its return value so TranslatePress can
467 + // tell already-processed links apart during output rewriting. That
468 + // marker is internal and must never reach a href.
469 + $url = str_replace(
470 + '#TRPLINKPROCESSED',
471 + '',
472 + (string) $url_converter->get_url_for_language($language)
473 + );
474 +
475 + if ($url === '') {
476 + continue;
477 + }
478 +
479 + // `de_DE_formal` is a TranslatePress formality variant, not a real
480 + // locale — Open Graph and hreflang both reject it, and both formal
481 + // and informal resolve to the same language anyway.
482 + $locale = str_replace(['_formal', '_informal'], '', $language);
483 +
484 + $out[] = [
485 + 'code' => $language,
486 + // No admin-configurable hreflang tag; to_hreflang_code() falls
487 + // through to the locale, which is what TranslatePress prints.
488 + 'tag' => '',
489 + 'locale' => $locale,
490 + 'url' => $url,
491 + 'is_default' => $language === $default,
492 + 'is_current' => $language === $current,
493 + ];
494 + }
495 +
496 + return $out;
497 + }
498 +
499 + /**
372 500 * Print hreflang alternates plus x-default.
373 501 *
374 502 * @param array<int, array<string, mixed>> $languages Resolved alternates.
375 503 * @return void
@@ -438,8 +566,14 @@
438 566 * never invented from the locale, because "de" and "de-DE" do not mean the
439 567 * same thing: the former targets German speakers everywhere, the latter
440 568 * only those in Germany, which would strand Austrian and Swiss readers.
441 569 *
570 + * Formality suffixes are stripped first. `de_DE_formal` is a real WordPress
571 + * locale, and it is shaped just like a valid subtag sequence — so without
572 + * this it sailed through validation as `de-de-formal`, which is not a
573 + * language tag and which search engines discard. This affects every
574 + * provider, not just the one that surfaced it.
575 + *
442 576 * @param array<string, mixed> $language Resolved language row.
443 577 * @return string Normalized hreflang value, or '' when unusable.
444 578 */
445 579 private function to_hreflang_code(array $language): string {
@@ -449,8 +583,9 @@
449 583 continue;
450 584 }
451 585
452 586 $value = strtolower(str_replace('_', '-', $value));
587 + $value = str_replace(['-formal', '-informal'], '', $value);
453 588
454 589 if (preg_match('/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/', $value)) {
455 590 return $value;
456 591 }
@@ -464,8 +599,54 @@
464 599 *
465 600 * @param array<string, mixed> $args Query args.
466 601 * @return array<string, mixed>
467 602 */
603 + /**
604 + * Resolve a sitemap entry's permalink in the post's own language.
605 + *
606 + * The sitemap query runs with suppress_filters pinned (see
607 + * filter_sitemap_query_args) so every language's rows are fetched — but
608 + * that also strips WPML's chance to contextualise the permalink, and the
609 + * debounced cron rebuild runs with no language context at all. Each
610 + * translation therefore resolved to the default-language URL: N sitemap
611 + * entries with different lastmod and images sharing one identical <loc>
612 + * (#409).
613 + *
614 + * WPML's stateless conversion API fixes it per row: look up the post's
615 + * own language, then ask wpml_permalink for the URL in that language.
616 + * Both are documented WPML hooks and no-op safely when absent. Polylang
617 + * needs none of this — its permalink filtering rides post_link, which
618 + * get_permalink() applies regardless of suppress_filters — and
619 + * TranslatePress translates rendered output without duplicating posts.
620 + *
621 + * @since 2.0.1
622 + * @param string $url Permalink as WordPress resolved it.
623 + * @param \WP_Post $post Post the entry describes.
624 + * @return string
625 + */
626 + public function localize_sitemap_permalink(string $url, \WP_Post $post): string {
627 + if ($this->provider !== 'wpml') {
628 + return $url;
629 + }
630 +
631 + // WPML's own documented filters; the prefix rule does not apply to a
632 + // third-party hook we are consuming rather than declaring.
633 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
634 + $lang = apply_filters('wpml_element_language_code', null, [
635 + 'element_id' => $post->ID,
636 + 'element_type' => 'post_' . $post->post_type,
637 + ]);
638 +
639 + if (!is_string($lang) || $lang === '') {
640 + return $url;
641 + }
642 +
643 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
644 + $localized = apply_filters('wpml_permalink', $url, $lang, true);
645 +
646 + return is_string($localized) && $localized !== '' ? $localized : $url;
647 + }
648 +
468 649 public function filter_sitemap_query_args(array $args): array {
469 650 if ($this->provider === 'polylang') {
470 651 // Polylang filters through parse_query, which suppress_filters does
471 652 // not bypass. An empty language disables its language clause.
@@ -472,8 +653,17 @@
472 653 $args['lang'] = '';
473 654 return $args;
474 655 }
475 656
657 + if ($this->provider === 'translatepress') {
658 + // Nothing to widen. TranslatePress translates rendered output
659 + // rather than creating a post per language, so the query already
660 + // returns every piece of content exactly once — and forcing WPML's
661 + // suppress_filters here would only override a caller's intent for
662 + // no benefit.
663 + return $args;
664 + }
665 +
476 666 // WPML filters posts through the posts_* SQL filters, which get_posts()
477 667 // already bypasses via its suppress_filters default — that default is
478 668 // the only reason the sitemap sees every language today. Pin it so a
479 669 // caller cannot quietly turn it off.
@@ -480,8 +670,9 @@
480 670 //
481 671 // Measured against WPML 4.9.5: setting suppress_filters => false cut the
482 672 // sitemap down to the active language, and a 'lang' => 'all' argument
483 673 // was ignored outright, so neither is used here.
674 + // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- Dropping it narrows the sitemap to the active language under WPML; see the note above.
484 675 $args['suppress_filters'] = true;
485 676
486 677 return $args;
487 678 }
@@ -498,9 +689,9 @@
498 689 }
499 690
500 691 // WPML does not language-filter get_terms() in the contexts the sitemap
501 692 // runs in (verified against WPML 4.9.5), and it ignores 'lang' => 'all',
502 - // so there is nothing to add for it here.
693 + // so there is nothing to add for it here. TranslatePress does not
694 + // duplicate terms per language at all, so likewise nothing to do.
503 695 return $args;
504 696 }
505 -
506 697 }