PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 releases
thinkrank / includes / integrations / class-multilingual-manager.php

class-multilingual-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/integrations/class-multilingual-manager.php

698 lines 25.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Multilingual (WPML / Polylang / TranslatePress) integration.
4 *
5 * Auto-enables when a supported multilingual plugin is active and fills the
6 * gaps ThinkRank leaves on a translated site:
7 *
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 * - `hreflang` alternates, but ONLY when the active multilingual plugin did
12 * not already print them for this request. All three ship their own
13 * hreflang output, so emitting ours unconditionally would produce
14 * duplicate, competing alternates — worse than the gap it set out to fix.
15 * WPML in particular registers its callback unconditionally and then gates
16 * the actual output behind internal `must_render()` checks, so "the setting
17 * is enabled" does not imply "tags were printed". We therefore observe what
18 * WPML did during the request and only fill in when it printed nothing.
19 * - Language-aware sitemaps, via the sitemap query filters. What this needs
20 * in practice was measured rather than assumed — see the two filter methods
21 * at the bottom of this class.
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 *
31 * @package ThinkRank\Integrations
32 * @since 1.23.0
33 */
34
35 declare(strict_types=1);
36
37 namespace ThinkRank\Integrations;
38
39 if (!defined('ABSPATH')) {
40 exit;
41 }
42
43 /**
44 * Bridges ThinkRank's SEO output with WPML and Polylang.
45 */
46 class Multilingual_Manager {
47
48 /**
49 * Active provider: 'wpml', 'polylang', 'translatepress', or '' when the
50 * site is monolingual.
51 *
52 * @var string
53 */
54 private $provider = '';
55
56 /**
57 * Whether the active plugin actually printed hreflang for this request.
58 *
59 * Observed rather than assumed: WPML registers its hreflang callback
60 * unconditionally but then gates the output behind its own `must_render()`
61 * checks, so "the setting is on" is not the same as "tags were printed".
62 * We watch for its filter instead and only fill in when nothing appeared.
63 *
64 * @var bool
65 */
66 private $provider_printed_hreflang = false;
67
68 /**
69 * Wire up hooks when a multilingual plugin is active.
70 *
71 * @return void
72 */
73 public function init(): void {
74 $this->provider = $this->detect_provider();
75
76 if ($this->provider === '') {
77 return;
78 }
79
80 // Keep sitemap queries covering every language. Registered for admin
81 // and front end alike: the sitemap can be generated from either.
82 add_filter('thinkrank_sitemap_query_args', [$this, 'filter_sitemap_query_args']);
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);
85
86 if (!is_admin()) {
87 // WPML prints at wp_head priority 1 and Polylang at 10, so run
88 // after both: by then we know whether anything was printed.
89 add_action('wp_head', [$this, 'output_language_alternates'], 20);
90 add_filter('thinkrank_og_locale', [$this, 'filter_og_locale']);
91
92 if ($this->provider === 'wpml') {
93 // Fires inside WPML's own hreflang render, and only once it has
94 // decided to output. PHP_INT_MAX so we see the final list.
95 add_filter('wpml_hreflangs', [$this, 'note_provider_hreflang'], PHP_INT_MAX);
96 }
97 }
98 }
99
100 /**
101 * Record that WPML printed its own hreflang tags for this request.
102 *
103 * @param mixed $items WPML's hreflang list (code => url).
104 * @return mixed The list, untouched.
105 */
106 public function note_provider_hreflang($items) {
107 if (is_array($items) && !empty($items)) {
108 $this->provider_printed_hreflang = true;
109 }
110
111 return $items;
112 }
113
114 /**
115 * Report the locale of the language actually being viewed.
116 *
117 * WordPress only switches `get_locale()` once the active language's
118 * translation files are installed, so on a multilingual site without them
119 * every translated URL still advertises the default locale. The provider
120 * always knows which language is current, so prefer its answer.
121 *
122 * @param string $locale Locale WordPress reported.
123 * @return string Locale for the current language.
124 */
125 public function filter_og_locale($locale): string {
126 $current = $this->get_current_locale();
127
128 return $current !== '' ? $current : (string) $locale;
129 }
130
131 /**
132 * Locale the provider reports for the language being viewed.
133 *
134 * @return string Locale, or '' when it cannot be resolved.
135 */
136 private function get_current_locale(): string {
137 foreach ($this->get_alternates() as $language) {
138 if (!empty($language['is_current']) && !empty($language['locale'])) {
139 return (string) $language['locale'];
140 }
141 }
142
143 return '';
144 }
145
146 /**
147 * Which multilingual plugin is running.
148 *
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.
153 */
154 private function detect_provider(): string {
155 if (defined('ICL_SITEPRESS_VERSION') && has_filter('wpml_active_languages')) {
156 return 'wpml';
157 }
158
159 if (function_exists('pll_the_languages') && function_exists('pll_default_language')) {
160 return 'polylang';
161 }
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
169 return '';
170 }
171
172 /**
173 * The active provider slug (exposed for tests and debugging).
174 *
175 * @return string
176 */
177 public function get_provider(): string {
178 return $this->provider;
179 }
180
181 /**
182 * Emit hreflang alternates (when the provider isn't) and Open Graph locale
183 * alternates for the current request.
184 *
185 * @return void
186 */
187 public function output_language_alternates(): void {
188 if (!$this->is_translatable_view()) {
189 return;
190 }
191
192 $languages = $this->get_alternates();
193 if (count($languages) < 2) {
194 // Nothing to cross-reference: a single language is not an alternate
195 // of itself.
196 return;
197 }
198
199 if ($this->should_output_hreflang()) {
200 $this->print_hreflang($languages);
201 }
202
203 $this->print_og_locale_alternates($languages);
204 }
205
206 /**
207 * Whether the current view maps onto a piece of translatable content.
208 *
209 * Search results, 404s and feeds have no meaningful per-language
210 * counterpart, and emitting alternates there is noise at best.
211 *
212 * @return bool
213 */
214 private function is_translatable_view(): bool {
215 $eligible = is_singular()
216 || is_front_page()
217 || is_home()
218 || is_category()
219 || is_tag()
220 || is_tax();
221
222 if (is_404() || is_search() || is_feed()) {
223 $eligible = false;
224 }
225
226 /**
227 * Filter whether ThinkRank emits language alternates for this request.
228 *
229 * @since 1.23.0
230 *
231 * @param bool $eligible Whether the current view is eligible.
232 */
233 return (bool) apply_filters('thinkrank_multilingual_output_alternates', $eligible);
234 }
235
236 /**
237 * Whether ThinkRank should print hreflang itself.
238 *
239 * Defaults to false whenever the active plugin already prints them, so the
240 * page never carries two competing sets.
241 *
242 * @return bool
243 */
244 private function should_output_hreflang(): bool {
245 $provider_handles_it = $this->provider_outputs_hreflang();
246
247 /**
248 * Filter whether ThinkRank prints hreflang alternates.
249 *
250 * Defaults to true only when the active multilingual plugin has its own
251 * hreflang output switched off. Force it to true to let ThinkRank own
252 * the tags (remember to disable the provider's, or the page ends up
253 * with duplicates).
254 *
255 * @since 1.23.0
256 *
257 * @param bool $should Whether ThinkRank should print hreflang.
258 * @param string $provider Active provider slug.
259 */
260 return (bool) apply_filters(
261 'thinkrank_multilingual_output_hreflang',
262 !$provider_handles_it,
263 $this->provider
264 );
265 }
266
267 /**
268 * Whether the active multilingual plugin already prints hreflang tags.
269 *
270 * @return bool
271 */
272 private function provider_outputs_hreflang(): bool {
273 if ($this->provider === 'wpml') {
274 // Observed during this very request (see note_provider_hreflang).
275 // WPML gates its output behind must_render(), so a site can have
276 // the setting enabled and still print nothing — in that case we
277 // step in rather than leaving the page without alternates.
278 return $this->provider_printed_hreflang;
279 }
280
281 // Polylang prints hreflang alternates on the front end with no setting
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.
287 return true;
288 }
289
290 /**
291 * Translated alternates for the current request.
292 *
293 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
294 */
295 private function get_alternates(): array {
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 }
309
310 /**
311 * Filter the resolved language alternates before output.
312 *
313 * @since 1.23.0
314 *
315 * @param array $alternates Resolved alternates.
316 * @param string $provider Active provider slug.
317 */
318 return (array) apply_filters('thinkrank_multilingual_alternates', $alternates, $this->provider);
319 }
320
321 /**
322 * Resolve alternates from WPML.
323 *
324 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
325 */
326 private function get_wpml_alternates(): array {
327 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
328 $languages = apply_filters('wpml_active_languages', null, ['skip_missing' => 1]);
329 if (!is_array($languages) || empty($languages)) {
330 return [];
331 }
332
333 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
334 $default = (string) apply_filters('wpml_default_language', null);
335 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
336 $current = (string) apply_filters('wpml_current_language', null);
337 $out = [];
338
339 foreach ($languages as $language) {
340 $url = (string) ($language['url'] ?? '');
341 if ($url === '') {
342 continue;
343 }
344
345 $code = (string) ($language['language_code'] ?? $language['code'] ?? '');
346 if ($code === '') {
347 continue;
348 }
349
350 $out[] = [
351 'code' => $code,
352 // WPML's admin-configurable hreflang tag; authoritative when set.
353 'tag' => (string) ($language['tag'] ?? ''),
354 'locale' => (string) ($language['default_locale'] ?? ''),
355 'url' => $url,
356 'is_default' => $code === $default,
357 'is_current' => $code === $current,
358 ];
359 }
360
361 return $out;
362 }
363
364 /**
365 * Resolve alternates from Polylang.
366 *
367 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
368 */
369 private function get_polylang_alternates(): array {
370 $languages = pll_the_languages([
371 'raw' => 1,
372 'hide_if_no_translation' => 1,
373 ]);
374
375 if (!is_array($languages) || empty($languages)) {
376 return [];
377 }
378
379 $default = (string) pll_default_language('slug');
380 $out = [];
381
382 foreach ($languages as $language) {
383 $url = (string) ($language['url'] ?? '');
384 if ($url === '' || !empty($language['no_translation'])) {
385 continue;
386 }
387
388 $code = (string) ($language['slug'] ?? '');
389 if ($code === '') {
390 continue;
391 }
392
393 $out[] = [
394 'code' => $code,
395 // Polylang exposes a W3C-valid tag for exactly this purpose.
396 'tag' => (string) ($language['w3c'] ?? ''),
397 'locale' => (string) ($language['locale'] ?? ''),
398 'url' => $url,
399 'is_default' => $code === $default,
400 'is_current' => !empty($language['current_lang']),
401 ];
402 }
403
404 return $out;
405 }
406
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 /**
500 * Print hreflang alternates plus x-default.
501 *
502 * @param array<int, array<string, mixed>> $languages Resolved alternates.
503 * @return void
504 */
505 private function print_hreflang(array $languages): void {
506 $default_url = '';
507
508 foreach ($languages as $language) {
509 $code = $this->to_hreflang_code($language);
510 if ($code === '') {
511 continue;
512 }
513
514 printf(
515 '<link rel="alternate" hreflang="%1$s" href="%2$s" />' . "\n",
516 esc_attr($code),
517 esc_url((string) $language['url'])
518 );
519
520 if (!empty($language['is_default'])) {
521 $default_url = (string) $language['url'];
522 }
523 }
524
525 if ($default_url !== '') {
526 printf(
527 '<link rel="alternate" hreflang="x-default" href="%s" />' . "\n",
528 esc_url($default_url)
529 );
530 }
531 }
532
533 /**
534 * Print `og:locale:alternate` for every language except the current one.
535 *
536 * @param array<int, array<string, mixed>> $languages Resolved alternates.
537 * @return void
538 */
539 private function print_og_locale_alternates(array $languages): void {
540 $current_locale = $this->get_current_locale();
541
542 foreach ($languages as $language) {
543 $locale = (string) $language['locale'];
544
545 // Skip the language being viewed. Keyed off the provider's own
546 // "current language" rather than get_locale(), which keeps
547 // reporting the default locale when the active language has no
548 // translation files installed.
549 if ($locale === '' || !empty($language['is_current']) || $locale === $current_locale) {
550 continue;
551 }
552
553 printf(
554 '<meta property="og:locale:alternate" content="%s" />' . "\n",
555 esc_attr($locale)
556 );
557 }
558 }
559
560 /**
561 * Pick the hreflang value for a language, mirroring how the provider
562 * itself would write it.
563 *
564 * Order matters: the provider's configured language tag wins, then the
565 * locale, then the bare code — the same precedence WPML uses. A region is
566 * never invented from the locale, because "de" and "de-DE" do not mean the
567 * same thing: the former targets German speakers everywhere, the latter
568 * only those in Germany, which would strand Austrian and Swiss readers.
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 *
576 * @param array<string, mixed> $language Resolved language row.
577 * @return string Normalized hreflang value, or '' when unusable.
578 */
579 private function to_hreflang_code(array $language): string {
580 foreach (['tag', 'locale', 'code'] as $key) {
581 $value = trim((string) ($language[$key] ?? ''));
582 if ($value === '') {
583 continue;
584 }
585
586 $value = strtolower(str_replace('_', '-', $value));
587 $value = str_replace(['-formal', '-informal'], '', $value);
588
589 if (preg_match('/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/', $value)) {
590 return $value;
591 }
592 }
593
594 return '';
595 }
596
597 /**
598 * Widen a sitemap post query to every language.
599 *
600 * @param array<string, mixed> $args Query args.
601 * @return array<string, mixed>
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
649 public function filter_sitemap_query_args(array $args): array {
650 if ($this->provider === 'polylang') {
651 // Polylang filters through parse_query, which suppress_filters does
652 // not bypass. An empty language disables its language clause.
653 $args['lang'] = '';
654 return $args;
655 }
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
666 // WPML filters posts through the posts_* SQL filters, which get_posts()
667 // already bypasses via its suppress_filters default — that default is
668 // the only reason the sitemap sees every language today. Pin it so a
669 // caller cannot quietly turn it off.
670 //
671 // Measured against WPML 4.9.5: setting suppress_filters => false cut the
672 // sitemap down to the active language, and a 'lang' => 'all' argument
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.
675 $args['suppress_filters'] = true;
676
677 return $args;
678 }
679
680 /**
681 * Widen a sitemap term query to every language.
682 *
683 * @param array<string, mixed> $args Query args.
684 * @return array<string, mixed>
685 */
686 public function filter_sitemap_term_query_args(array $args): array {
687 if ($this->provider === 'polylang') {
688 $args['lang'] = '';
689 }
690
691 // WPML does not language-filter get_terms() in the contexts the sitemap
692 // runs in (verified against WPML 4.9.5), and it ignores 'lang' => 'all',
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.
695 return $args;
696 }
697 }
698