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

507 lines 16.8 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) 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. Neither WPML nor
9 * Polylang emits Open Graph locale alternates, so without this the social
10 * 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. WPML and Polylang both ship
13 * their own 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 * @package ThinkRank\Integrations
24 * @since 1.23.0
25 */
26
27 declare(strict_types=1);
28
29 namespace ThinkRank\Integrations;
30
31 if (!defined('ABSPATH')) {
32 exit;
33 }
34
35 /**
36 * Bridges ThinkRank's SEO output with WPML and Polylang.
37 */
38 class Multilingual_Manager {
39
40 /**
41 * Active provider: 'wpml', 'polylang' or '' when the site is monolingual.
42 *
43 * @var string
44 */
45 private $provider = '';
46
47 /**
48 * Whether the active plugin actually printed hreflang for this request.
49 *
50 * Observed rather than assumed: WPML registers its hreflang callback
51 * unconditionally but then gates the output behind its own `must_render()`
52 * checks, so "the setting is on" is not the same as "tags were printed".
53 * We watch for its filter instead and only fill in when nothing appeared.
54 *
55 * @var bool
56 */
57 private $provider_printed_hreflang = false;
58
59 /**
60 * Wire up hooks when a multilingual plugin is active.
61 *
62 * @return void
63 */
64 public function init(): void {
65 $this->provider = $this->detect_provider();
66
67 if ($this->provider === '') {
68 return;
69 }
70
71 // Keep sitemap queries covering every language. Registered for admin
72 // and front end alike: the sitemap can be generated from either.
73 add_filter('thinkrank_sitemap_query_args', [$this, 'filter_sitemap_query_args']);
74 add_filter('thinkrank_sitemap_term_query_args', [$this, 'filter_sitemap_term_query_args']);
75
76 if (!is_admin()) {
77 // WPML prints at wp_head priority 1 and Polylang at 10, so run
78 // after both: by then we know whether anything was printed.
79 add_action('wp_head', [$this, 'output_language_alternates'], 20);
80 add_filter('thinkrank_og_locale', [$this, 'filter_og_locale']);
81
82 if ($this->provider === 'wpml') {
83 // Fires inside WPML's own hreflang render, and only once it has
84 // decided to output. PHP_INT_MAX so we see the final list.
85 add_filter('wpml_hreflangs', [$this, 'note_provider_hreflang'], PHP_INT_MAX);
86 }
87 }
88 }
89
90 /**
91 * Record that WPML printed its own hreflang tags for this request.
92 *
93 * @param mixed $items WPML's hreflang list (code => url).
94 * @return mixed The list, untouched.
95 */
96 public function note_provider_hreflang($items) {
97 if (is_array($items) && !empty($items)) {
98 $this->provider_printed_hreflang = true;
99 }
100
101 return $items;
102 }
103
104 /**
105 * Report the locale of the language actually being viewed.
106 *
107 * WordPress only switches `get_locale()` once the active language's
108 * translation files are installed, so on a multilingual site without them
109 * every translated URL still advertises the default locale. The provider
110 * always knows which language is current, so prefer its answer.
111 *
112 * @param string $locale Locale WordPress reported.
113 * @return string Locale for the current language.
114 */
115 public function filter_og_locale($locale): string {
116 $current = $this->get_current_locale();
117
118 return $current !== '' ? $current : (string) $locale;
119 }
120
121 /**
122 * Locale the provider reports for the language being viewed.
123 *
124 * @return string Locale, or '' when it cannot be resolved.
125 */
126 private function get_current_locale(): string {
127 foreach ($this->get_alternates() as $language) {
128 if (!empty($language['is_current']) && !empty($language['locale'])) {
129 return (string) $language['locale'];
130 }
131 }
132
133 return '';
134 }
135
136 /**
137 * Which multilingual plugin is running.
138 *
139 * @return string 'wpml', 'polylang', or '' when none is active.
140 */
141 private function detect_provider(): string {
142 if (defined('ICL_SITEPRESS_VERSION') && has_filter('wpml_active_languages')) {
143 return 'wpml';
144 }
145
146 if (function_exists('pll_the_languages') && function_exists('pll_default_language')) {
147 return 'polylang';
148 }
149
150 return '';
151 }
152
153 /**
154 * The active provider slug (exposed for tests and debugging).
155 *
156 * @return string
157 */
158 public function get_provider(): string {
159 return $this->provider;
160 }
161
162 /**
163 * Emit hreflang alternates (when the provider isn't) and Open Graph locale
164 * alternates for the current request.
165 *
166 * @return void
167 */
168 public function output_language_alternates(): void {
169 if (!$this->is_translatable_view()) {
170 return;
171 }
172
173 $languages = $this->get_alternates();
174 if (count($languages) < 2) {
175 // Nothing to cross-reference: a single language is not an alternate
176 // of itself.
177 return;
178 }
179
180 if ($this->should_output_hreflang()) {
181 $this->print_hreflang($languages);
182 }
183
184 $this->print_og_locale_alternates($languages);
185 }
186
187 /**
188 * Whether the current view maps onto a piece of translatable content.
189 *
190 * Search results, 404s and feeds have no meaningful per-language
191 * counterpart, and emitting alternates there is noise at best.
192 *
193 * @return bool
194 */
195 private function is_translatable_view(): bool {
196 $eligible = is_singular()
197 || is_front_page()
198 || is_home()
199 || is_category()
200 || is_tag()
201 || is_tax();
202
203 if (is_404() || is_search() || is_feed()) {
204 $eligible = false;
205 }
206
207 /**
208 * Filter whether ThinkRank emits language alternates for this request.
209 *
210 * @since 1.23.0
211 *
212 * @param bool $eligible Whether the current view is eligible.
213 */
214 return (bool) apply_filters('thinkrank_multilingual_output_alternates', $eligible);
215 }
216
217 /**
218 * Whether ThinkRank should print hreflang itself.
219 *
220 * Defaults to false whenever the active plugin already prints them, so the
221 * page never carries two competing sets.
222 *
223 * @return bool
224 */
225 private function should_output_hreflang(): bool {
226 $provider_handles_it = $this->provider_outputs_hreflang();
227
228 /**
229 * Filter whether ThinkRank prints hreflang alternates.
230 *
231 * Defaults to true only when the active multilingual plugin has its own
232 * hreflang output switched off. Force it to true to let ThinkRank own
233 * the tags (remember to disable the provider's, or the page ends up
234 * with duplicates).
235 *
236 * @since 1.23.0
237 *
238 * @param bool $should Whether ThinkRank should print hreflang.
239 * @param string $provider Active provider slug.
240 */
241 return (bool) apply_filters(
242 'thinkrank_multilingual_output_hreflang',
243 !$provider_handles_it,
244 $this->provider
245 );
246 }
247
248 /**
249 * Whether the active multilingual plugin already prints hreflang tags.
250 *
251 * @return bool
252 */
253 private function provider_outputs_hreflang(): bool {
254 if ($this->provider === 'wpml') {
255 // Observed during this very request (see note_provider_hreflang).
256 // WPML gates its output behind must_render(), so a site can have
257 // the setting enabled and still print nothing — in that case we
258 // step in rather than leaving the page without alternates.
259 return $this->provider_printed_hreflang;
260 }
261
262 // Polylang prints hreflang alternates on the front end with no setting
263 // to turn them off, so never double up.
264 return true;
265 }
266
267 /**
268 * Translated alternates for the current request.
269 *
270 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
271 */
272 private function get_alternates(): array {
273 $alternates = $this->provider === 'wpml'
274 ? $this->get_wpml_alternates()
275 : $this->get_polylang_alternates();
276
277 /**
278 * Filter the resolved language alternates before output.
279 *
280 * @since 1.23.0
281 *
282 * @param array $alternates Resolved alternates.
283 * @param string $provider Active provider slug.
284 */
285 return (array) apply_filters('thinkrank_multilingual_alternates', $alternates, $this->provider);
286 }
287
288 /**
289 * Resolve alternates from WPML.
290 *
291 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
292 */
293 private function get_wpml_alternates(): array {
294 $languages = apply_filters('wpml_active_languages', null, ['skip_missing' => 1]);
295 if (!is_array($languages) || empty($languages)) {
296 return [];
297 }
298
299 $default = (string) apply_filters('wpml_default_language', null);
300 $current = (string) apply_filters('wpml_current_language', null);
301 $out = [];
302
303 foreach ($languages as $language) {
304 $url = (string) ($language['url'] ?? '');
305 if ($url === '') {
306 continue;
307 }
308
309 $code = (string) ($language['language_code'] ?? $language['code'] ?? '');
310 if ($code === '') {
311 continue;
312 }
313
314 $out[] = [
315 'code' => $code,
316 // WPML's admin-configurable hreflang tag; authoritative when set.
317 'tag' => (string) ($language['tag'] ?? ''),
318 'locale' => (string) ($language['default_locale'] ?? ''),
319 'url' => $url,
320 'is_default' => $code === $default,
321 'is_current' => $code === $current,
322 ];
323 }
324
325 return $out;
326 }
327
328 /**
329 * Resolve alternates from Polylang.
330 *
331 * @return array<int, array{code: string, locale: string, url: string, is_default: bool}>
332 */
333 private function get_polylang_alternates(): array {
334 $languages = pll_the_languages([
335 'raw' => 1,
336 'hide_if_no_translation' => 1,
337 ]);
338
339 if (!is_array($languages) || empty($languages)) {
340 return [];
341 }
342
343 $default = (string) pll_default_language('slug');
344 $out = [];
345
346 foreach ($languages as $language) {
347 $url = (string) ($language['url'] ?? '');
348 if ($url === '' || !empty($language['no_translation'])) {
349 continue;
350 }
351
352 $code = (string) ($language['slug'] ?? '');
353 if ($code === '') {
354 continue;
355 }
356
357 $out[] = [
358 'code' => $code,
359 // Polylang exposes a W3C-valid tag for exactly this purpose.
360 'tag' => (string) ($language['w3c'] ?? ''),
361 'locale' => (string) ($language['locale'] ?? ''),
362 'url' => $url,
363 'is_default' => $code === $default,
364 'is_current' => !empty($language['current_lang']),
365 ];
366 }
367
368 return $out;
369 }
370
371 /**
372 * Print hreflang alternates plus x-default.
373 *
374 * @param array<int, array<string, mixed>> $languages Resolved alternates.
375 * @return void
376 */
377 private function print_hreflang(array $languages): void {
378 $default_url = '';
379
380 foreach ($languages as $language) {
381 $code = $this->to_hreflang_code($language);
382 if ($code === '') {
383 continue;
384 }
385
386 printf(
387 '<link rel="alternate" hreflang="%1$s" href="%2$s" />' . "\n",
388 esc_attr($code),
389 esc_url((string) $language['url'])
390 );
391
392 if (!empty($language['is_default'])) {
393 $default_url = (string) $language['url'];
394 }
395 }
396
397 if ($default_url !== '') {
398 printf(
399 '<link rel="alternate" hreflang="x-default" href="%s" />' . "\n",
400 esc_url($default_url)
401 );
402 }
403 }
404
405 /**
406 * Print `og:locale:alternate` for every language except the current one.
407 *
408 * @param array<int, array<string, mixed>> $languages Resolved alternates.
409 * @return void
410 */
411 private function print_og_locale_alternates(array $languages): void {
412 $current_locale = $this->get_current_locale();
413
414 foreach ($languages as $language) {
415 $locale = (string) $language['locale'];
416
417 // Skip the language being viewed. Keyed off the provider's own
418 // "current language" rather than get_locale(), which keeps
419 // reporting the default locale when the active language has no
420 // translation files installed.
421 if ($locale === '' || !empty($language['is_current']) || $locale === $current_locale) {
422 continue;
423 }
424
425 printf(
426 '<meta property="og:locale:alternate" content="%s" />' . "\n",
427 esc_attr($locale)
428 );
429 }
430 }
431
432 /**
433 * Pick the hreflang value for a language, mirroring how the provider
434 * itself would write it.
435 *
436 * Order matters: the provider's configured language tag wins, then the
437 * locale, then the bare code — the same precedence WPML uses. A region is
438 * never invented from the locale, because "de" and "de-DE" do not mean the
439 * same thing: the former targets German speakers everywhere, the latter
440 * only those in Germany, which would strand Austrian and Swiss readers.
441 *
442 * @param array<string, mixed> $language Resolved language row.
443 * @return string Normalized hreflang value, or '' when unusable.
444 */
445 private function to_hreflang_code(array $language): string {
446 foreach (['tag', 'locale', 'code'] as $key) {
447 $value = trim((string) ($language[$key] ?? ''));
448 if ($value === '') {
449 continue;
450 }
451
452 $value = strtolower(str_replace('_', '-', $value));
453
454 if (preg_match('/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/', $value)) {
455 return $value;
456 }
457 }
458
459 return '';
460 }
461
462 /**
463 * Widen a sitemap post query to every language.
464 *
465 * @param array<string, mixed> $args Query args.
466 * @return array<string, mixed>
467 */
468 public function filter_sitemap_query_args(array $args): array {
469 if ($this->provider === 'polylang') {
470 // Polylang filters through parse_query, which suppress_filters does
471 // not bypass. An empty language disables its language clause.
472 $args['lang'] = '';
473 return $args;
474 }
475
476 // WPML filters posts through the posts_* SQL filters, which get_posts()
477 // already bypasses via its suppress_filters default — that default is
478 // the only reason the sitemap sees every language today. Pin it so a
479 // caller cannot quietly turn it off.
480 //
481 // Measured against WPML 4.9.5: setting suppress_filters => false cut the
482 // sitemap down to the active language, and a 'lang' => 'all' argument
483 // was ignored outright, so neither is used here.
484 $args['suppress_filters'] = true;
485
486 return $args;
487 }
488
489 /**
490 * Widen a sitemap term query to every language.
491 *
492 * @param array<string, mixed> $args Query args.
493 * @return array<string, mixed>
494 */
495 public function filter_sitemap_term_query_args(array $args): array {
496 if ($this->provider === 'polylang') {
497 $args['lang'] = '';
498 }
499
500 // WPML does not language-filter get_terms() in the contexts the sitemap
501 // runs in (verified against WPML 4.9.5), and it ignores 'lang' => 'all',
502 // so there is nothing to add for it here.
503 return $args;
504 }
505
506 }
507