← All changes
|
includes/integrations/class-multilingual-manager.php
+205
-14
1.25.0
→
2.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 | } |