| 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 |
|