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 / ai / class-language-resolver.php

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

162 lines 6.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AI Target Language Resolver
4 *
5 * Resolves the human-readable language ThinkRank should instruct the AI to
6 * write metadata / briefs in. Without this, the generation prompts carried no
7 * language directive and lighter models (e.g. GPT-5-mini) defaulted to English
8 * on non-English sites — emitting English or mixed-language titles and
9 * descriptions on, say, a Spanish news site. See GitHub issue #234.
10 *
11 * Resolution order:
12 * 1. The post's own language when a multilingual plugin (Polylang / WPML) is
13 * active and a post id is supplied — the accurate signal on sites whose
14 * content language differs per post.
15 * 2. The site locale, get_locale().
16 * 3. The `thinkrank_ai_target_language` filter, for explicit overrides on
17 * sites whose content language differs from the WordPress locale.
18 *
19 * @package ThinkRank\AI
20 * @since 1.27.0
21 */
22
23 declare(strict_types=1);
24
25 namespace ThinkRank\AI;
26
27 // Prevent direct access.
28 if (!defined('ABSPATH')) {
29 exit;
30 }
31
32 /**
33 * Resolves the AI output language for a given post/site.
34 *
35 * @since 1.27.0
36 */
37 class Language_Resolver {
38
39 /**
40 * Primary-subtag → English language name map for the locales WordPress
41 * ships translations for. Used as a dependency-free fallback; when the
42 * intl extension is present we prefer its richer display names.
43 *
44 * @var array<string,string>
45 */
46 private const LANGUAGE_NAMES = [
47 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French',
48 'de' => 'German', 'it' => 'Italian', 'pt' => 'Portuguese',
49 'nl' => 'Dutch', 'ru' => 'Russian', 'pl' => 'Polish',
50 'sv' => 'Swedish', 'da' => 'Danish', 'nb' => 'Norwegian',
51 'nn' => 'Norwegian', 'fi' => 'Finnish', 'cs' => 'Czech',
52 'sk' => 'Slovak', 'ro' => 'Romanian', 'hu' => 'Hungarian',
53 'el' => 'Greek', 'tr' => 'Turkish', 'ar' => 'Arabic',
54 'he' => 'Hebrew', 'fa' => 'Persian', 'hi' => 'Hindi',
55 'bn' => 'Bengali', 'ur' => 'Urdu', 'th' => 'Thai',
56 'vi' => 'Vietnamese', 'id' => 'Indonesian', 'ms' => 'Malay',
57 'ja' => 'Japanese', 'ko' => 'Korean', 'zh' => 'Chinese',
58 'uk' => 'Ukrainian', 'bg' => 'Bulgarian', 'hr' => 'Croatian',
59 'sr' => 'Serbian', 'sl' => 'Slovenian', 'lt' => 'Lithuanian',
60 'lv' => 'Latvian', 'et' => 'Estonian', 'ca' => 'Catalan',
61 'gl' => 'Galician', 'eu' => 'Basque', 'af' => 'Afrikaans',
62 'sw' => 'Swahili', 'tl' => 'Filipino', 'is' => 'Icelandic',
63 ];
64
65 /**
66 * Resolve the human-readable target language name for a post/site.
67 *
68 * @since 1.27.0
69 *
70 * @param int $post_id Post being optimized, or 0 for a site-level target.
71 * @return string Human-readable language name, e.g. "Spanish".
72 */
73 public static function resolve(int $post_id = 0): string {
74 $locale = self::resolve_locale($post_id);
75 $language = self::locale_to_name($locale);
76
77 /**
78 * Filter the language ThinkRank instructs the AI to write output in.
79 *
80 * Return a plain language name ("Spanish", "Brazilian Portuguese").
81 * An empty string disables the language directive entirely, restoring
82 * the pre-1.27.0 behavior of letting the model infer the language.
83 *
84 * @since 1.27.0
85 *
86 * @param string $language Human-readable language name.
87 * @param int $post_id Post being optimized (0 when site-level).
88 * @param string $locale Resolved locale, e.g. "es_ES".
89 */
90 return (string) apply_filters('thinkrank_ai_target_language', $language, $post_id, $locale);
91 }
92
93 /**
94 * Resolve the effective locale for a post/site.
95 *
96 * @since 1.27.0
97 *
98 * @param int $post_id Post id, or 0 for the site locale.
99 * @return string A WordPress locale, e.g. "es_ES".
100 */
101 public static function resolve_locale(int $post_id = 0): string {
102 if ($post_id > 0) {
103 // Polylang: gives the post's own language locale directly.
104 if (function_exists('pll_get_post_language')) {
105 $loc = pll_get_post_language($post_id, 'locale');
106 if (is_string($loc) && $loc !== '') {
107 return $loc;
108 }
109 }
110
111 // WPML: post language details carry a locale (and always a code).
112 if (has_filter('wpml_post_language_details')) {
113 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML/core hook, not ours to name.
114 $details = apply_filters('wpml_post_language_details', null, $post_id);
115 if (is_array($details)) {
116 if (!empty($details['locale']) && is_string($details['locale'])) {
117 return $details['locale'];
118 }
119 if (!empty($details['language_code']) && is_string($details['language_code'])) {
120 // Only a bare code (e.g. "es"); locale_to_name reads the
121 // primary subtag anyway, so passing the code is fine.
122 return $details['language_code'];
123 }
124 }
125 }
126 }
127
128 return get_locale();
129 }
130
131 /**
132 * Convert a locale (or bare language code) to an English language name.
133 *
134 * Falls back to the intl extension when present, then to the built-in map,
135 * then to the raw locale so the directive is never blank for a real locale.
136 *
137 * @since 1.27.0
138 *
139 * @param string $locale Locale or language code, e.g. "es_ES" or "es".
140 * @return string Language name, e.g. "Spanish".
141 */
142 public static function locale_to_name(string $locale): string {
143 $locale = str_replace('-', '_', trim($locale));
144 if ($locale === '') {
145 return '';
146 }
147
148 $subtag = strtolower(explode('_', $locale)[0]);
149
150 if (class_exists('\Locale')) {
151 $name = \Locale::getDisplayLanguage($locale, 'en');
152 // getDisplayLanguage echoes the input back when it can't resolve;
153 // treat that as a miss and fall through to the map.
154 if (is_string($name) && $name !== '' && strtolower($name) !== strtolower($locale) && strtolower($name) !== $subtag) {
155 return $name;
156 }
157 }
158
159 return self::LANGUAGE_NAMES[$subtag] ?? $locale;
160 }
161 }
162