PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.32.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.32.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
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 1.32.0, at includes/integrations/class-multilingual-manager.php

650 lines 23.3 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
85 if (!is_admin()) {
86 // WPML prints at wp_head priority 1 and Polylang at 10, so run
87 // after both: by then we know whether anything was printed.
88 add_action('wp_head', [$this, 'output_language_alternates'], 20);
89 add_filter('thinkrank_og_locale', [$this, 'filter_og_locale']);
90
91 if ($this->provider === 'wpml') {
92 // Fires inside WPML's own hreflang render, and only once it has
93 // decided to output. PHP_INT_MAX so we see the final list.
94 add_filter('wpml_hreflangs', [$this, 'note_provider_hreflang'], PHP_INT_MAX);
95 }
96 }
97 }
98
99 /**
100 * Record that WPML printed its own hreflang tags for this request.
101 *
102 * @param mixed $items WPML's hreflang list (code => url).
103 * @return mixed The list, untouched.
104 */
105 public function note_provider_hreflang($items) {
106 if (is_array($items) && !empty($items)) {
107 $this->provider_printed_hreflang = true;
108 }
109
110 return $items;
111 }
112
113 /**
114 * Report the locale of the language actually being viewed.
115 *
116 * WordPress only switches `get_locale()` once the active language's
117 * translation files are installed, so on a multilingual site without them
118 * every translated URL still advertises the default locale. The provider
119 * always knows which language is current, so prefer its answer.
120 *
121 * @param string $locale Locale WordPress reported.
122 * @return string Locale for the current language.
123 */
124 public function filter_og_locale($locale): string {
125 $current = $this->get_current_locale();
126
127 return $current !== '' ? $current : (string) $locale;
128 }
129
130 /**
131 * Locale the provider reports for the language being viewed.
132 *
133 * @return string Locale, or '' when it cannot be resolved.
134 */
135 private function get_current_locale(): string {
136 foreach ($this->get_alternates() as $language) {
137 if (!empty($language['is_current']) && !empty($language['locale'])) {
138 return (string) $language['locale'];
139 }
140 }
141
142 return '';
143 }
144
145 /**
146 * Which multilingual plugin is running.
147 *
148 * Checked in order of specificity. A site running two of these at once is
149 * already broken, so first match wins rather than trying to merge them.
150 *
151 * @return string 'wpml', 'polylang', 'translatepress', or '' when none is active.
152 */
153 private function detect_provider(): string {
154 if (defined('ICL_SITEPRESS_VERSION') && has_filter('wpml_active_languages')) {
155 return 'wpml';
156 }
157
158 if (function_exists('pll_the_languages') && function_exists('pll_default_language')) {
159 return 'polylang';
160 }
161
162 // TranslatePress. The constant alone isn't enough — the instance is what
163 // we actually read languages and URLs from, so require the class too.
164 if (defined('TRP_PLUGIN_VERSION') && class_exists('\TRP_Translate_Press')) {
165 return 'translatepress';
166 }
167
168 return '';
169 }
170
171 /**
172 * The active provider slug (exposed for tests and debugging).
173 *
174 * @return string
175 */
176 public function get_provider(): string {
177 return $this->provider;
178 }
179
180 /**
181 * Emit hreflang alternates (when the provider isn't) and Open Graph locale
182 * alternates for the current request.
183 *
184 * @return void
185 */
186 public function output_language_alternates(): void {
187 if (!$this->is_translatable_view()) {
188 return;
189 }
190
191 $languages = $this->get_alternates();
192 if (count($languages) < 2) {
193 // Nothing to cross-reference: a single language is not an alternate
194 // of itself.
195 return;
196 }
197
198 if ($this->should_output_hreflang()) {
199 $this->print_hreflang($languages);
200 }
201
202 $this->print_og_locale_alternates($languages);
203 }
204
205 /**
206 * Whether the current view maps onto a piece of translatable content.
207 *
208 * Search results, 404s and feeds have no meaningful per-language
209 * counterpart, and emitting alternates there is noise at best.
210 *
211 * @return bool
212 */
213 private function is_translatable_view(): bool {
214 $eligible = is_singular()
215 || is_front_page()
216 || is_home()
217 || is_category()
218 || is_tag()
219 || is_tax();
220
221 if (is_404() || is_search() || is_feed()) {
222 $eligible = false;
223 }
224
225 /**
226 * Filter whether ThinkRank emits language alternates for this request.
227 *
228 * @since 1.23.0
229 *
230 * @param bool $eligible Whether the current view is eligible.
231 */
232 return (bool) apply_filters('thinkrank_multilingual_output_alternates', $eligible);
233 }
234
235 /**
236 * Whether ThinkRank should print hreflang itself.
237 *
238 * Defaults to false whenever the active plugin already prints them, so the
239 * page never carries two competing sets.
240 *
241 * @return bool
242 */
243 private function should_output_hreflang(): bool {
244 $provider_handles_it = $this->provider_outputs_hreflang();
245
246 /**
247 * Filter whether ThinkRank prints hreflang alternates.
248 *
249 * Defaults to true only when the active multilingual plugin has its own
250 * hreflang output switched off. Force it to true to let ThinkRank own
251 * the tags (remember to disable the provider's, or the page ends up
252 * with duplicates).
253 *
254 * @since 1.23.0
255 *
256 * @param bool $should Whether ThinkRank should print hreflang.
257 * @param string $provider Active provider slug.
258 */
259 return (bool) apply_filters(
260 'thinkrank_multilingual_output_hreflang',
261 !$provider_handles_it,
262 $this->provider
263 );
264 }
265
266 /**
267 * Whether the active multilingual plugin already prints hreflang tags.
268 *
269 * @return bool
270 */
271 private function provider_outputs_hreflang(): bool {
272 if ($this->provider === 'wpml') {
273 // Observed during this very request (see note_provider_hreflang).
274 // WPML gates its output behind must_render(), so a site can have
275 // the setting enabled and still print nothing — in that case we
276 // step in rather than leaving the page without alternates.
277 return $this->provider_printed_hreflang;
278 }
279
280 // Polylang prints hreflang alternates on the front end with no setting
281 // to turn them off. TranslatePress hooks its own
282 // TRP_Url_Converter::add_hreflang_to_head() onto wp_head
283 // unconditionally, and emits region-independent variants and x-default
284 // on top — a second set from us would compete with all of it. Never
285 // double up on either.
286 return true;
287 }
288
289 /**
290 * Translated alternates for the current request.
291 *
292 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
293 */
294 private function get_alternates(): array {
295 switch ($this->provider) {
296 case 'wpml':
297 $alternates = $this->get_wpml_alternates();
298 break;
299 case 'polylang':
300 $alternates = $this->get_polylang_alternates();
301 break;
302 case 'translatepress':
303 $alternates = $this->get_translatepress_alternates();
304 break;
305 default:
306 $alternates = [];
307 }
308
309 /**
310 * Filter the resolved language alternates before output.
311 *
312 * @since 1.23.0
313 *
314 * @param array $alternates Resolved alternates.
315 * @param string $provider Active provider slug.
316 */
317 return (array) apply_filters('thinkrank_multilingual_alternates', $alternates, $this->provider);
318 }
319
320 /**
321 * Resolve alternates from WPML.
322 *
323 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
324 */
325 private function get_wpml_alternates(): array {
326 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
327 $languages = apply_filters('wpml_active_languages', null, ['skip_missing' => 1]);
328 if (!is_array($languages) || empty($languages)) {
329 return [];
330 }
331
332 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
333 $default = (string) apply_filters('wpml_default_language', null);
334 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
335 $current = (string) apply_filters('wpml_current_language', null);
336 $out = [];
337
338 foreach ($languages as $language) {
339 $url = (string) ($language['url'] ?? '');
340 if ($url === '') {
341 continue;
342 }
343
344 $code = (string) ($language['language_code'] ?? $language['code'] ?? '');
345 if ($code === '') {
346 continue;
347 }
348
349 $out[] = [
350 'code' => $code,
351 // WPML's admin-configurable hreflang tag; authoritative when set.
352 'tag' => (string) ($language['tag'] ?? ''),
353 'locale' => (string) ($language['default_locale'] ?? ''),
354 'url' => $url,
355 'is_default' => $code === $default,
356 'is_current' => $code === $current,
357 ];
358 }
359
360 return $out;
361 }
362
363 /**
364 * Resolve alternates from Polylang.
365 *
366 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
367 */
368 private function get_polylang_alternates(): array {
369 $languages = pll_the_languages([
370 'raw' => 1,
371 'hide_if_no_translation' => 1,
372 ]);
373
374 if (!is_array($languages) || empty($languages)) {
375 return [];
376 }
377
378 $default = (string) pll_default_language('slug');
379 $out = [];
380
381 foreach ($languages as $language) {
382 $url = (string) ($language['url'] ?? '');
383 if ($url === '' || !empty($language['no_translation'])) {
384 continue;
385 }
386
387 $code = (string) ($language['slug'] ?? '');
388 if ($code === '') {
389 continue;
390 }
391
392 $out[] = [
393 'code' => $code,
394 // Polylang exposes a W3C-valid tag for exactly this purpose.
395 'tag' => (string) ($language['w3c'] ?? ''),
396 'locale' => (string) ($language['locale'] ?? ''),
397 'url' => $url,
398 'is_default' => $code === $default,
399 'is_current' => !empty($language['current_lang']),
400 ];
401 }
402
403 return $out;
404 }
405
406 /**
407 * Resolve alternates from TranslatePress.
408 *
409 * TranslatePress has no public helper for "every published language and its
410 * URL", so this reads the two components it exposes through its singleton:
411 * `settings` for the published-language list, `url_converter` for the URL of
412 * the current request in a given language.
413 *
414 * Its language codes are already locales (`en_US`, `de_DE`), which is why
415 * `locale` and `code` carry the same value here — unlike Polylang, where the
416 * slug and locale differ.
417 *
418 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
419 */
420 private function get_translatepress_alternates(): array {
421 if (!class_exists('\TRP_Translate_Press')) {
422 return [];
423 }
424
425 $trp = \TRP_Translate_Press::get_trp_instance();
426 if (!is_object($trp) || !method_exists($trp, 'get_component')) {
427 return [];
428 }
429
430 $settings_component = $trp->get_component('settings');
431 $url_converter = $trp->get_component('url_converter');
432
433 if (!is_object($settings_component)
434 || !is_object($url_converter)
435 || !method_exists($settings_component, 'get_settings')
436 || !method_exists($url_converter, 'get_url_for_language')
437 ) {
438 return [];
439 }
440
441 $settings = (array) $settings_component->get_settings();
442 $languages = $settings['publish-languages'] ?? [];
443
444 if (!is_array($languages) || empty($languages)) {
445 return [];
446 }
447
448 $default = (string) ($settings['default-language'] ?? '');
449
450 // TranslatePress tracks the language being rendered on a global rather
451 // than through an accessor.
452 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- TranslatePress' own global; the name is theirs.
453 global $TRP_LANGUAGE;
454 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- TranslatePress' own global; the name is theirs.
455 $current = is_string($TRP_LANGUAGE) ? $TRP_LANGUAGE : '';
456
457 $out = [];
458
459 foreach ($languages as $language) {
460 $language = (string) $language;
461 if ($language === '') {
462 continue;
463 }
464
465 // get_url_for_language() tags its return value so TranslatePress can
466 // tell already-processed links apart during output rewriting. That
467 // marker is internal and must never reach a href.
468 $url = str_replace(
469 '#TRPLINKPROCESSED',
470 '',
471 (string) $url_converter->get_url_for_language($language)
472 );
473
474 if ($url === '') {
475 continue;
476 }
477
478 // `de_DE_formal` is a TranslatePress formality variant, not a real
479 // locale — Open Graph and hreflang both reject it, and both formal
480 // and informal resolve to the same language anyway.
481 $locale = str_replace(['_formal', '_informal'], '', $language);
482
483 $out[] = [
484 'code' => $language,
485 // No admin-configurable hreflang tag; to_hreflang_code() falls
486 // through to the locale, which is what TranslatePress prints.
487 'tag' => '',
488 'locale' => $locale,
489 'url' => $url,
490 'is_default' => $language === $default,
491 'is_current' => $language === $current,
492 ];
493 }
494
495 return $out;
496 }
497
498 /**
499 * Print hreflang alternates plus x-default.
500 *
501 * @param array<int, array<string, mixed>> $languages Resolved alternates.
502 * @return void
503 */
504 private function print_hreflang(array $languages): void {
505 $default_url = '';
506
507 foreach ($languages as $language) {
508 $code = $this->to_hreflang_code($language);
509 if ($code === '') {
510 continue;
511 }
512
513 printf(
514 '<link rel="alternate" hreflang="%1$s" href="%2$s" />' . "\n",
515 esc_attr($code),
516 esc_url((string) $language['url'])
517 );
518
519 if (!empty($language['is_default'])) {
520 $default_url = (string) $language['url'];
521 }
522 }
523
524 if ($default_url !== '') {
525 printf(
526 '<link rel="alternate" hreflang="x-default" href="%s" />' . "\n",
527 esc_url($default_url)
528 );
529 }
530 }
531
532 /**
533 * Print `og:locale:alternate` for every language except the current one.
534 *
535 * @param array<int, array<string, mixed>> $languages Resolved alternates.
536 * @return void
537 */
538 private function print_og_locale_alternates(array $languages): void {
539 $current_locale = $this->get_current_locale();
540
541 foreach ($languages as $language) {
542 $locale = (string) $language['locale'];
543
544 // Skip the language being viewed. Keyed off the provider's own
545 // "current language" rather than get_locale(), which keeps
546 // reporting the default locale when the active language has no
547 // translation files installed.
548 if ($locale === '' || !empty($language['is_current']) || $locale === $current_locale) {
549 continue;
550 }
551
552 printf(
553 '<meta property="og:locale:alternate" content="%s" />' . "\n",
554 esc_attr($locale)
555 );
556 }
557 }
558
559 /**
560 * Pick the hreflang value for a language, mirroring how the provider
561 * itself would write it.
562 *
563 * Order matters: the provider's configured language tag wins, then the
564 * locale, then the bare code — the same precedence WPML uses. A region is
565 * never invented from the locale, because "de" and "de-DE" do not mean the
566 * same thing: the former targets German speakers everywhere, the latter
567 * only those in Germany, which would strand Austrian and Swiss readers.
568 *
569 * Formality suffixes are stripped first. `de_DE_formal` is a real WordPress
570 * locale, and it is shaped just like a valid subtag sequence — so without
571 * this it sailed through validation as `de-de-formal`, which is not a
572 * language tag and which search engines discard. This affects every
573 * provider, not just the one that surfaced it.
574 *
575 * @param array<string, mixed> $language Resolved language row.
576 * @return string Normalized hreflang value, or '' when unusable.
577 */
578 private function to_hreflang_code(array $language): string {
579 foreach (['tag', 'locale', 'code'] as $key) {
580 $value = trim((string) ($language[$key] ?? ''));
581 if ($value === '') {
582 continue;
583 }
584
585 $value = strtolower(str_replace('_', '-', $value));
586 $value = str_replace(['-formal', '-informal'], '', $value);
587
588 if (preg_match('/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/', $value)) {
589 return $value;
590 }
591 }
592
593 return '';
594 }
595
596 /**
597 * Widen a sitemap post query to every language.
598 *
599 * @param array<string, mixed> $args Query args.
600 * @return array<string, mixed>
601 */
602 public function filter_sitemap_query_args(array $args): array {
603 if ($this->provider === 'polylang') {
604 // Polylang filters through parse_query, which suppress_filters does
605 // not bypass. An empty language disables its language clause.
606 $args['lang'] = '';
607 return $args;
608 }
609
610 if ($this->provider === 'translatepress') {
611 // Nothing to widen. TranslatePress translates rendered output
612 // rather than creating a post per language, so the query already
613 // returns every piece of content exactly once — and forcing WPML's
614 // suppress_filters here would only override a caller's intent for
615 // no benefit.
616 return $args;
617 }
618
619 // WPML filters posts through the posts_* SQL filters, which get_posts()
620 // already bypasses via its suppress_filters default — that default is
621 // the only reason the sitemap sees every language today. Pin it so a
622 // caller cannot quietly turn it off.
623 //
624 // Measured against WPML 4.9.5: setting suppress_filters => false cut the
625 // sitemap down to the active language, and a 'lang' => 'all' argument
626 // was ignored outright, so neither is used here.
627 $args['suppress_filters'] = true;
628
629 return $args;
630 }
631
632 /**
633 * Widen a sitemap term query to every language.
634 *
635 * @param array<string, mixed> $args Query args.
636 * @return array<string, mixed>
637 */
638 public function filter_sitemap_term_query_args(array $args): array {
639 if ($this->provider === 'polylang') {
640 $args['lang'] = '';
641 }
642
643 // WPML does not language-filter get_terms() in the contexts the sitemap
644 // runs in (verified against WPML 4.9.5), and it ignores 'lang' => 'all',
645 // so there is nothing to add for it here. TranslatePress does not
646 // duplicate terms per language at all, so likewise nothing to do.
647 return $args;
648 }
649 }
650