| 1 |
<?php |
| 2 |
|
| 3 |
// If this file is called directly, abort. |
| 4 |
if ( ! defined( 'WPINC' ) ) { |
| 5 |
die; |
| 6 |
} |
| 7 |
|
| 8 |
|
| 9 |
/** |
| 10 |
* Load or generate a translation JSON for a registered script. |
| 11 |
* |
| 12 |
* Checks whether wpLingua should provide a replacement wp-i18n JSON file for |
| 13 |
* the given script. If needed, reads the original JS, extracts wp-i18n strings, |
| 14 |
* builds a translations JSON and writes a cached file that WordPress can use. |
| 15 |
* |
| 16 |
* Behavior: |
| 17 |
* - Skips processing in admin or when the default file is already readable. |
| 18 |
* - Maps registered scripts that depend on 'wp-i18n' and live under wp-content. |
| 19 |
* - Returns either the original $file, a generated cached JSON path, or an |
| 20 |
* empty placeholder file path when no strings are found. |
| 21 |
* |
| 22 |
* @param string $file Path to the default translation file provided by WP. |
| 23 |
* @param string $handle Registered script handle. |
| 24 |
* @param string $domain Text domain passed by WordPress. |
| 25 |
* @return string Path to the translation JSON file to use. |
| 26 |
*/ |
| 27 |
function wplng_load_script_translation_file( $file, $handle, $domain ) { |
| 28 |
|
| 29 |
global $wplng_i18n_scripts; |
| 30 |
|
| 31 |
/** |
| 32 |
* If we are in the admin dashboard |
| 33 |
* or it's not a translated page |
| 34 |
* or if the translation file was generate by wp, plugin or theme |
| 35 |
* Use the default file |
| 36 |
*/ |
| 37 |
|
| 38 |
if ( is_admin() |
| 39 |
|| wplng_get_language_website_id() === wplng_get_language_current_id() |
| 40 |
|| empty( $file ) |
| 41 |
|| is_readable( $file ) |
| 42 |
) { |
| 43 |
return $file; |
| 44 |
} |
| 45 |
|
| 46 |
/** |
| 47 |
* Initialize $wplng_i18n_scripts if not already initialized |
| 48 |
*/ |
| 49 |
|
| 50 |
if ( $wplng_i18n_scripts === null ) { |
| 51 |
|
| 52 |
$wp_scripts = wp_scripts(); |
| 53 |
|
| 54 |
if ( ! $wp_scripts instanceof WP_Scripts ) { |
| 55 |
return $file; |
| 56 |
} |
| 57 |
|
| 58 |
$wplng_i18n_scripts = array(); |
| 59 |
|
| 60 |
foreach ( $wp_scripts->registered as $handle_temp => $script_object ) { |
| 61 |
|
| 62 |
if ( in_array( 'wp-i18n', (array) $script_object->deps, true ) |
| 63 |
&& wplng_str_contains( $script_object->src, '/wp-content/' ) |
| 64 |
) { |
| 65 |
|
| 66 |
$wplng_i18n_scripts[ $handle_temp ] = array( |
| 67 |
'handle' => $script_object->handle, |
| 68 |
'textdomain' => $script_object->textdomain, |
| 69 |
'src' => $script_object->src, |
| 70 |
); |
| 71 |
|
| 72 |
} |
| 73 |
} |
| 74 |
} |
| 75 |
|
| 76 |
/** |
| 77 |
* If we can not associate the current script to a knowed script |
| 78 |
* (We can not know the script JS file, return) |
| 79 |
*/ |
| 80 |
|
| 81 |
if ( empty( $wplng_i18n_scripts ) || empty( $wplng_i18n_scripts[ $handle ] ) ) { |
| 82 |
return $file; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Check if the replacement translation JSON was already generated by wpLingua |
| 87 |
*/ |
| 88 |
|
| 89 |
$file_nomalized = wp_normalize_path( $file ); |
| 90 |
$dir_cache_script = '/script-i18n'; |
| 91 |
|
| 92 |
$file_cache_relative = str_replace( |
| 93 |
wp_normalize_path( WP_CONTENT_DIR ), |
| 94 |
'', |
| 95 |
$file_nomalized |
| 96 |
); |
| 97 |
|
| 98 |
if ( $file_nomalized === $file_cache_relative ) { |
| 99 |
return $file; |
| 100 |
} |
| 101 |
|
| 102 |
$file_cache_absolute = WPLNG_CACHE_DIR . $dir_cache_script . $file_cache_relative; |
| 103 |
|
| 104 |
if ( is_readable( $file_cache_absolute ) ) { |
| 105 |
return $file_cache_absolute; |
| 106 |
} |
| 107 |
|
| 108 |
/** |
| 109 |
* Get the original script path and content |
| 110 |
*/ |
| 111 |
|
| 112 |
$script_path = str_replace( |
| 113 |
content_url(), |
| 114 |
WP_CONTENT_DIR, |
| 115 |
$wplng_i18n_scripts[ $handle ]['src'] |
| 116 |
); |
| 117 |
|
| 118 |
$script_path = wp_normalize_path( $script_path ); |
| 119 |
|
| 120 |
if ( ! is_readable( $script_path ) ) { |
| 121 |
return $file; |
| 122 |
} |
| 123 |
|
| 124 |
$script_content = file_get_contents( $script_path ); |
| 125 |
|
| 126 |
/** |
| 127 |
* Get texts in script |
| 128 |
*/ |
| 129 |
|
| 130 |
$texts = wplng_i18n_script_extract_strings( $script_content ); |
| 131 |
|
| 132 |
$texts_is_empty = true; |
| 133 |
|
| 134 |
foreach ( $texts as $texts_by_extraction_methode ) { |
| 135 |
if ( ! empty( $texts_by_extraction_methode ) ) { |
| 136 |
$texts_is_empty = false; |
| 137 |
break; |
| 138 |
} |
| 139 |
} |
| 140 |
|
| 141 |
if ( $texts_is_empty ) { |
| 142 |
|
| 143 |
// file_put_contents( $file_cache_relative, '' ); |
| 144 |
|
| 145 |
wplng_put_cache_file( |
| 146 |
'/script-i18n' . $file_cache_relative, |
| 147 |
'' |
| 148 |
); |
| 149 |
|
| 150 |
return $file; |
| 151 |
} |
| 152 |
|
| 153 |
/** |
| 154 |
* Make the translations JSON |
| 155 |
*/ |
| 156 |
|
| 157 |
$json_content = wplng_i18n_script_generate_json( |
| 158 |
$texts, |
| 159 |
$domain |
| 160 |
); |
| 161 |
|
| 162 |
/** |
| 163 |
* Generate the wpLingua cached JSON file |
| 164 |
*/ |
| 165 |
|
| 166 |
$file_writing_result = wplng_put_cache_file( |
| 167 |
'/script-i18n' . $file_cache_relative, |
| 168 |
$json_content |
| 169 |
); |
| 170 |
|
| 171 |
// Return the generated file if writing was successful |
| 172 |
if ( $file_writing_result !== false ) { |
| 173 |
return $file_cache_absolute; |
| 174 |
} |
| 175 |
|
| 176 |
return $file; |
| 177 |
} |
| 178 |
|
| 179 |
|
| 180 |
/** |
| 181 |
* Extract translatable strings from a JavaScript script. |
| 182 |
* |
| 183 |
* Scans minified WP JS for wp-i18n call patterns used in builds like: |
| 184 |
* (0,x.__)("text") |
| 185 |
* (0,x._x)("text","context") |
| 186 |
* (0,x._n)("singular","plural",count) |
| 187 |
* (0,x._nx)("singular","plural",count,"context") |
| 188 |
* |
| 189 |
* The function handles both single and double quoted strings, escaped characters, |
| 190 |
* optional domain arguments and returns an associative array grouping extracted |
| 191 |
* items by function name ('__', '_x', '_n', '_nx'). |
| 192 |
* |
| 193 |
* @param string $script JavaScript source code to scan. |
| 194 |
* @return array { |
| 195 |
* Associative array with keys: |
| 196 |
* '__' => array of [ 'text' => string, 'domain' => ?string ], |
| 197 |
* '_x' => array of [ 'text' => string, 'context' => string, 'domain' => ?string ], |
| 198 |
* '_n' => array of [ 'singular' => string, 'plural' => string, 'domain' => ?string ], |
| 199 |
* '_nx' => array of [ 'singular' => string, 'plural' => string, 'context' => string, 'domain' => ?string ], |
| 200 |
* } |
| 201 |
*/ |
| 202 |
function wplng_i18n_script_extract_strings( $script ) { |
| 203 |
|
| 204 |
$results = array( |
| 205 |
'__' => array(), |
| 206 |
'_x' => array(), |
| 207 |
'_n' => array(), |
| 208 |
'_nx' => array(), |
| 209 |
); |
| 210 |
|
| 211 |
// In WordPress minified scripts, calls look like: |
| 212 |
// (0,r.__)("text") or (0,t._x)("text","context") |
| 213 |
// where r/t/e/n are aliases for wp.i18n |
| 214 |
|
| 215 |
$patterns = array( |
| 216 |
// (0,X.__)("text") or (0,X.__)('text') with optional domain |
| 217 |
'__' => '/\(0,[a-zA-Z_$][a-zA-Z0-9_$]*\.__\)\s*\(\s*(["\'])((?:[^"\'\\\\]|\\\\.|(?!\1)["\'])*)\1(?:\s*,\s*(["\'])((?:[^"\'\\\\]|\\\\.|(?!\3)["\'])*)\3)?\s*\)/u', |
| 218 |
|
| 219 |
// (0,X._x)("text","context") or with single quotes, optional domain |
| 220 |
'_x' => '/\(0,[a-zA-Z_$][a-zA-Z0-9_$]*\._x\)\s*\(\s*(["\'])((?:[^"\'\\\\]|\\\\.|(?!\1)["\'])*)\1\s*,\s*(["\'])((?:[^"\'\\\\]|\\\\.|(?!\3)["\'])*)\3(?:\s*,\s*(["\'])((?:[^"\'\\\\]|\\\\.|(?!\5)["\'])*)\5)?\s*\)/u', |
| 221 |
|
| 222 |
// (0,X._n)("singular","plural",number) with optional domain |
| 223 |
'_n' => '/\(0,[a-zA-Z_$][a-zA-Z0-9_$]*\._n\)\s*\(\s*(["\'])((?:[^"\'\\\\]|\\\\.|(?!\1)["\'])*)\1\s*,\s*(["\'])((?:[^"\'\\\\]|\\\\.|(?!\3)["\'])*)\3\s*,\s*[^,)]+(?:\s*,\s*(["\'])((?:[^"\'\\\\]|\\\\.|(?!\5)["\'])*)\5)?\s*\)/u', |
| 224 |
|
| 225 |
// (0,X._nx)("singular","plural",number,"context") with optional domain |
| 226 |
'_nx' => '/\(0,[a-zA-Z_$][a-zA-Z0-9_$]*\._nx\)\s*\(\s*(["\'])((?:[^"\'\\\\]|\\\\.|(?!\1)["\'])*)\1\s*,\s*(["\'])((?:[^"\'\\\\]|\\\\.|(?!\3)["\'])*)\3\s*,\s*[^,]+\s*,\s*(["\'])((?:[^"\'\\\\]|\\\\.|(?!\5)["\'])*)\5(?:\s*,\s*(["\'])((?:[^"\'\\\\]|\\\\.|(?!\7)["\'])*)\7)?\s*\)/u', |
| 227 |
); |
| 228 |
|
| 229 |
// Extraction for __() |
| 230 |
if ( preg_match_all( $patterns['__'], $script, $matches, PREG_SET_ORDER ) ) { |
| 231 |
foreach ( $matches as $match ) { |
| 232 |
$text = wplng_unescape_js_string( $match[2] ); |
| 233 |
if ( ! empty( $text ) ) { |
| 234 |
$results['__'][] = array( |
| 235 |
'text' => $text, |
| 236 |
'domain' => isset( $match[4] ) && $match[4] !== '' ? wplng_unescape_js_string( $match[4] ) : null, |
| 237 |
); |
| 238 |
} |
| 239 |
} |
| 240 |
} |
| 241 |
|
| 242 |
// Extraction for _x() |
| 243 |
if ( preg_match_all( $patterns['_x'], $script, $matches, PREG_SET_ORDER ) ) { |
| 244 |
foreach ( $matches as $match ) { |
| 245 |
$text = wplng_unescape_js_string( $match[2] ); |
| 246 |
if ( ! empty( $text ) ) { |
| 247 |
$results['_x'][] = array( |
| 248 |
'text' => $text, |
| 249 |
'context' => wplng_unescape_js_string( $match[4] ), |
| 250 |
'domain' => isset( $match[6] ) && $match[6] !== '' ? wplng_unescape_js_string( $match[6] ) : null, |
| 251 |
); |
| 252 |
} |
| 253 |
} |
| 254 |
} |
| 255 |
|
| 256 |
// Extraction for _n() |
| 257 |
if ( preg_match_all( $patterns['_n'], $script, $matches, PREG_SET_ORDER ) ) { |
| 258 |
foreach ( $matches as $match ) { |
| 259 |
$singular = wplng_unescape_js_string( $match[2] ); |
| 260 |
if ( ! empty( $singular ) ) { |
| 261 |
$results['_n'][] = array( |
| 262 |
'singular' => $singular, |
| 263 |
'plural' => wplng_unescape_js_string( $match[4] ), |
| 264 |
'domain' => isset( $match[6] ) && $match[6] !== '' ? wplng_unescape_js_string( $match[6] ) : null, |
| 265 |
); |
| 266 |
} |
| 267 |
} |
| 268 |
} |
| 269 |
|
| 270 |
// Extraction for _nx() |
| 271 |
if ( preg_match_all( $patterns['_nx'], $script, $matches, PREG_SET_ORDER ) ) { |
| 272 |
foreach ( $matches as $match ) { |
| 273 |
$singular = wplng_unescape_js_string( $match[2] ); |
| 274 |
if ( ! empty( $singular ) ) { |
| 275 |
$results['_nx'][] = array( |
| 276 |
'singular' => $singular, |
| 277 |
'plural' => wplng_unescape_js_string( $match[4] ), |
| 278 |
'context' => wplng_unescape_js_string( $match[6] ), |
| 279 |
'domain' => isset( $match[8] ) && $match[8] !== '' ? wplng_unescape_js_string( $match[8] ) : null, |
| 280 |
); |
| 281 |
} |
| 282 |
} |
| 283 |
} |
| 284 |
|
| 285 |
return $results; |
| 286 |
} |
| 287 |
|
| 288 |
|
| 289 |
/** |
| 290 |
* Generates a WordPress wp-i18n compatible translation JSON |
| 291 |
* |
| 292 |
* @param array $texts The texts extracted by wplng_i18n_script_extract_strings() |
| 293 |
* @param string $domain The text domain |
| 294 |
* @param string $locale The locale (e.g., 'fr_FR') |
| 295 |
* @param string $script_path Path to the JS script (for reference) |
| 296 |
* @return string Encoded JSON |
| 297 |
*/ |
| 298 |
function wplng_i18n_script_generate_json( $texts, $domain = 'messages' ) { |
| 299 |
|
| 300 |
$locale = get_locale(); |
| 301 |
|
| 302 |
// Determine the plural form based on locale |
| 303 |
$plural_forms = wplng_get_language_plural_forms( $locale ); |
| 304 |
|
| 305 |
// Build the messages array |
| 306 |
$messages = array( |
| 307 |
'' => array( |
| 308 |
'domain' => $domain, |
| 309 |
'plural-forms' => $plural_forms, |
| 310 |
'lang' => str_replace( '_', '-', $locale ), |
| 311 |
), |
| 312 |
); |
| 313 |
|
| 314 |
// Add __() translations |
| 315 |
foreach ( $texts['__'] as $item ) { |
| 316 |
$key = $item['text']; |
| 317 |
$text_domain = ! empty( $item['domain'] ) ? $item['domain'] : $domain; |
| 318 |
// Value = array with the translation |
| 319 |
$messages[ $key ] = array( __( $item['text'], $text_domain ) ); |
| 320 |
} |
| 321 |
|
| 322 |
// Add _x() translations with context |
| 323 |
foreach ( $texts['_x'] as $item ) { |
| 324 |
// Key with context: "text\u0004context" |
| 325 |
$key = $item['text'] . "\x04" . $item['context']; |
| 326 |
$text_domain = ! empty( $item['domain'] ) ? $item['domain'] : $domain; |
| 327 |
$messages[ $key ] = array( _x( $item['text'], $item['context'], $text_domain ) ); |
| 328 |
} |
| 329 |
|
| 330 |
// Add _n() translations (plurals) |
| 331 |
foreach ( $texts['_n'] as $item ) { |
| 332 |
$key = $item['singular']; |
| 333 |
$text_domain = ! empty( $item['domain'] ) ? $item['domain'] : $domain; |
| 334 |
// Array with [translated singular, translated plural] |
| 335 |
$messages[ $key ] = array( |
| 336 |
__( $item['singular'], $text_domain ), |
| 337 |
__( $item['plural'], $text_domain ), |
| 338 |
); |
| 339 |
} |
| 340 |
|
| 341 |
// Add _nx() translations (plurals with context) |
| 342 |
foreach ( $texts['_nx'] as $item ) { |
| 343 |
$key = $item['singular'] . "\x04" . $item['context']; |
| 344 |
$text_domain = ! empty( $item['domain'] ) ? $item['domain'] : $domain; |
| 345 |
$messages[ $key ] = array( |
| 346 |
_x( $item['singular'], $item['context'], $text_domain ), |
| 347 |
_x( $item['plural'], $item['context'], $text_domain ), |
| 348 |
); |
| 349 |
} |
| 350 |
|
| 351 |
// Build the complete structure |
| 352 |
$json_data = array( |
| 353 |
'translation-revision-date' => gmdate( 'Y-m-d H:i:s+0000' ), |
| 354 |
'generator' => 'wpLingua', |
| 355 |
'domain' => $domain, |
| 356 |
'locale_data' => array( |
| 357 |
'messages' => $messages, |
| 358 |
), |
| 359 |
); |
| 360 |
|
| 361 |
return wp_json_encode( $json_data, JSON_UNESCAPED_UNICODE ); |
| 362 |
} |
| 363 |
|