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

646 lines 22.6 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 $languages = apply_filters('wpml_active_languages', null, ['skip_missing' => 1]);
327 if (!is_array($languages) || empty($languages)) {
328 return [];
329 }
330
331 $default = (string) apply_filters('wpml_default_language', null);
332 $current = (string) apply_filters('wpml_current_language', null);
333 $out = [];
334
335 foreach ($languages as $language) {
336 $url = (string) ($language['url'] ?? '');
337 if ($url === '') {
338 continue;
339 }
340
341 $code = (string) ($language['language_code'] ?? $language['code'] ?? '');
342 if ($code === '') {
343 continue;
344 }
345
346 $out[] = [
347 'code' => $code,
348 // WPML's admin-configurable hreflang tag; authoritative when set.
349 'tag' => (string) ($language['tag'] ?? ''),
350 'locale' => (string) ($language['default_locale'] ?? ''),
351 'url' => $url,
352 'is_default' => $code === $default,
353 'is_current' => $code === $current,
354 ];
355 }
356
357 return $out;
358 }
359
360 /**
361 * Resolve alternates from Polylang.
362 *
363 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
364 */
365 private function get_polylang_alternates(): array {
366 $languages = pll_the_languages([
367 'raw' => 1,
368 'hide_if_no_translation' => 1,
369 ]);
370
371 if (!is_array($languages) || empty($languages)) {
372 return [];
373 }
374
375 $default = (string) pll_default_language('slug');
376 $out = [];
377
378 foreach ($languages as $language) {
379 $url = (string) ($language['url'] ?? '');
380 if ($url === '' || !empty($language['no_translation'])) {
381 continue;
382 }
383
384 $code = (string) ($language['slug'] ?? '');
385 if ($code === '') {
386 continue;
387 }
388
389 $out[] = [
390 'code' => $code,
391 // Polylang exposes a W3C-valid tag for exactly this purpose.
392 'tag' => (string) ($language['w3c'] ?? ''),
393 'locale' => (string) ($language['locale'] ?? ''),
394 'url' => $url,
395 'is_default' => $code === $default,
396 'is_current' => !empty($language['current_lang']),
397 ];
398 }
399
400 return $out;
401 }
402
403 /**
404 * Resolve alternates from TranslatePress.
405 *
406 * TranslatePress has no public helper for "every published language and its
407 * URL", so this reads the two components it exposes through its singleton:
408 * `settings` for the published-language list, `url_converter` for the URL of
409 * the current request in a given language.
410 *
411 * Its language codes are already locales (`en_US`, `de_DE`), which is why
412 * `locale` and `code` carry the same value here — unlike Polylang, where the
413 * slug and locale differ.
414 *
415 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
416 */
417 private function get_translatepress_alternates(): array {
418 if (!class_exists('\TRP_Translate_Press')) {
419 return [];
420 }
421
422 $trp = \TRP_Translate_Press::get_trp_instance();
423 if (!is_object($trp) || !method_exists($trp, 'get_component')) {
424 return [];
425 }
426
427 $settings_component = $trp->get_component('settings');
428 $url_converter = $trp->get_component('url_converter');
429
430 if (!is_object($settings_component)
431 || !is_object($url_converter)
432 || !method_exists($settings_component, 'get_settings')
433 || !method_exists($url_converter, 'get_url_for_language')
434 ) {
435 return [];
436 }
437
438 $settings = (array) $settings_component->get_settings();
439 $languages = $settings['publish-languages'] ?? [];
440
441 if (!is_array($languages) || empty($languages)) {
442 return [];
443 }
444
445 $default = (string) ($settings['default-language'] ?? '');
446
447 // TranslatePress tracks the language being rendered on a global rather
448 // than through an accessor.
449 global $TRP_LANGUAGE;
450 $current = is_string($TRP_LANGUAGE) ? $TRP_LANGUAGE : '';
451
452 $out = [];
453
454 foreach ($languages as $language) {
455 $language = (string) $language;
456 if ($language === '') {
457 continue;
458 }
459
460 // get_url_for_language() tags its return value so TranslatePress can
461 // tell already-processed links apart during output rewriting. That
462 // marker is internal and must never reach a href.
463 $url = str_replace(
464 '#TRPLINKPROCESSED',
465 '',
466 (string) $url_converter->get_url_for_language($language)
467 );
468
469 if ($url === '') {
470 continue;
471 }
472
473 // `de_DE_formal` is a TranslatePress formality variant, not a real
474 // locale — Open Graph and hreflang both reject it, and both formal
475 // and informal resolve to the same language anyway.
476 $locale = str_replace(['_formal', '_informal'], '', $language);
477
478 $out[] = [
479 'code' => $language,
480 // No admin-configurable hreflang tag; to_hreflang_code() falls
481 // through to the locale, which is what TranslatePress prints.
482 'tag' => '',
483 'locale' => $locale,
484 'url' => $url,
485 'is_default' => $language === $default,
486 'is_current' => $language === $current,
487 ];
488 }
489
490 return $out;
491 }
492
493 /**
494 * Print hreflang alternates plus x-default.
495 *
496 * @param array<int, array<string, mixed>> $languages Resolved alternates.
497 * @return void
498 */
499 private function print_hreflang(array $languages): void {
500 $default_url = '';
501
502 foreach ($languages as $language) {
503 $code = $this->to_hreflang_code($language);
504 if ($code === '') {
505 continue;
506 }
507
508 printf(
509 '<link rel="alternate" hreflang="%1$s" href="%2$s" />' . "\n",
510 esc_attr($code),
511 esc_url((string) $language['url'])
512 );
513
514 if (!empty($language['is_default'])) {
515 $default_url = (string) $language['url'];
516 }
517 }
518
519 if ($default_url !== '') {
520 printf(
521 '<link rel="alternate" hreflang="x-default" href="%s" />' . "\n",
522 esc_url($default_url)
523 );
524 }
525 }
526
527 /**
528 * Print `og:locale:alternate` for every language except the current one.
529 *
530 * @param array<int, array<string, mixed>> $languages Resolved alternates.
531 * @return void
532 */
533 private function print_og_locale_alternates(array $languages): void {
534 $current_locale = $this->get_current_locale();
535
536 foreach ($languages as $language) {
537 $locale = (string) $language['locale'];
538
539 // Skip the language being viewed. Keyed off the provider's own
540 // "current language" rather than get_locale(), which keeps
541 // reporting the default locale when the active language has no
542 // translation files installed.
543 if ($locale === '' || !empty($language['is_current']) || $locale === $current_locale) {
544 continue;
545 }
546
547 printf(
548 '<meta property="og:locale:alternate" content="%s" />' . "\n",
549 esc_attr($locale)
550 );
551 }
552 }
553
554 /**
555 * Pick the hreflang value for a language, mirroring how the provider
556 * itself would write it.
557 *
558 * Order matters: the provider's configured language tag wins, then the
559 * locale, then the bare code — the same precedence WPML uses. A region is
560 * never invented from the locale, because "de" and "de-DE" do not mean the
561 * same thing: the former targets German speakers everywhere, the latter
562 * only those in Germany, which would strand Austrian and Swiss readers.
563 *
564 * Formality suffixes are stripped first. `de_DE_formal` is a real WordPress
565 * locale, and it is shaped just like a valid subtag sequence — so without
566 * this it sailed through validation as `de-de-formal`, which is not a
567 * language tag and which search engines discard. This affects every
568 * provider, not just the one that surfaced it.
569 *
570 * @param array<string, mixed> $language Resolved language row.
571 * @return string Normalized hreflang value, or '' when unusable.
572 */
573 private function to_hreflang_code(array $language): string {
574 foreach (['tag', 'locale', 'code'] as $key) {
575 $value = trim((string) ($language[$key] ?? ''));
576 if ($value === '') {
577 continue;
578 }
579
580 $value = strtolower(str_replace('_', '-', $value));
581 $value = str_replace(['-formal', '-informal'], '', $value);
582
583 if (preg_match('/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/', $value)) {
584 return $value;
585 }
586 }
587
588 return '';
589 }
590
591 /**
592 * Widen a sitemap post query to every language.
593 *
594 * @param array<string, mixed> $args Query args.
595 * @return array<string, mixed>
596 */
597 public function filter_sitemap_query_args(array $args): array {
598 if ($this->provider === 'polylang') {
599 // Polylang filters through parse_query, which suppress_filters does
600 // not bypass. An empty language disables its language clause.
601 $args['lang'] = '';
602 return $args;
603 }
604
605 if ($this->provider === 'translatepress') {
606 // Nothing to widen. TranslatePress translates rendered output
607 // rather than creating a post per language, so the query already
608 // returns every piece of content exactly once — and forcing WPML's
609 // suppress_filters here would only override a caller's intent for
610 // no benefit.
611 return $args;
612 }
613
614 // WPML filters posts through the posts_* SQL filters, which get_posts()
615 // already bypasses via its suppress_filters default — that default is
616 // the only reason the sitemap sees every language today. Pin it so a
617 // caller cannot quietly turn it off.
618 //
619 // Measured against WPML 4.9.5: setting suppress_filters => false cut the
620 // sitemap down to the active language, and a 'lang' => 'all' argument
621 // was ignored outright, so neither is used here.
622 $args['suppress_filters'] = true;
623
624 return $args;
625 }
626
627 /**
628 * Widen a sitemap term query to every language.
629 *
630 * @param array<string, mixed> $args Query args.
631 * @return array<string, mixed>
632 */
633 public function filter_sitemap_term_query_args(array $args): array {
634 if ($this->provider === 'polylang') {
635 $args['lang'] = '';
636 }
637
638 // WPML does not language-filter get_terms() in the contexts the sitemap
639 // runs in (verified against WPML 4.9.5), and it ignores 'lang' => 'all',
640 // so there is nothing to add for it here. TranslatePress does not
641 // duplicate terms per language at all, so likewise nothing to do.
642 return $args;
643 }
644
645 }
646