| 1 |
<?php |
| 2 |
/** |
| 3 |
* Merges per-plugin JS translation catalogs so multiple sources (WP-CLI/core, |
| 4 |
* Loco Translate, etc.) can contribute to the same script's translations |
| 5 |
* |
| 6 |
* @package TableKit |
| 7 |
*/ |
| 8 |
|
| 9 |
namespace TableBuilder\Core; |
| 10 |
|
| 11 |
defined( 'ABSPATH' ) || exit; |
| 12 |
|
| 13 |
/** |
| 14 |
* Merges Jed-format JS translation files for the "table-builder-block" domain. |
| 15 |
*/ |
| 16 |
final class ScriptTranslationMerger { |
| 17 |
|
| 18 |
use \TableBuilder\Traits\Singleton; |
| 19 |
|
| 20 |
/** Text domain this merger is responsible for. */ |
| 21 |
private const DOMAIN = 'table-builder-block'; |
| 22 |
|
| 23 |
/** How long a merged result is cached before being recomputed. */ |
| 24 |
private const CACHE_TTL = DAY_IN_SECONDS; |
| 25 |
|
| 26 |
/** Prefix for the transient cache key. */ |
| 27 |
private const CACHE_PREFIX = 'tbb_js_i18n_'; |
| 28 |
|
| 29 |
/** |
| 30 |
* Hooks the script-translation merge filter into WordPress. |
| 31 |
*/ |
| 32 |
private function __construct() { |
| 33 |
add_filter( 'pre_load_script_translations', array( $this, 'maybe_merge' ), 10, 4 ); |
| 34 |
} |
| 35 |
|
| 36 |
/* |
| 37 |
----------------------------------------------------------------- |
| 38 |
* Filter callback |
| 39 |
* --------------------------------------------------------------- |
| 40 |
*/ |
| 41 |
|
| 42 |
/** |
| 43 |
* Merges cached translation catalogs into core's script-translation loading, |
| 44 |
* hooked to "pre_load_script_translations". |
| 45 |
* |
| 46 |
* @param string|false|null $translations JSON-encoded translations, or null/false if unresolved. |
| 47 |
* @param string|false $file The path core was about to try. Unused: we merge by domain, not by file. |
| 48 |
* @param string $handle Script handle. |
| 49 |
* @param string $domain Text domain. |
| 50 |
* @return string|false|null |
| 51 |
*/ |
| 52 |
public function maybe_merge( $translations, $file, $handle, $domain ) { |
| 53 |
if ( null !== $translations || self::DOMAIN !== $domain ) { |
| 54 |
return $translations; |
| 55 |
} |
| 56 |
|
| 57 |
$locale = $this->current_locale(); |
| 58 |
if ( null === $locale ) { |
| 59 |
return $translations; |
| 60 |
} |
| 61 |
|
| 62 |
$files = $this->find_translation_files( $locale ); |
| 63 |
if ( empty( $files ) ) { |
| 64 |
return $translations; |
| 65 |
} |
| 66 |
|
| 67 |
$merged = $this->get_cached_merge( $locale, $files ); |
| 68 |
|
| 69 |
return $merged ?? $translations; |
| 70 |
} |
| 71 |
|
| 72 |
/* |
| 73 |
----------------------------------------------------------------- |
| 74 |
* Locale / file discovery |
| 75 |
* --------------------------------------------------------------- |
| 76 |
*/ |
| 77 |
|
| 78 |
/** |
| 79 |
* Gets the current request's locale, or null if it's a source-language |
| 80 |
* locale (en_*) with nothing to merge. |
| 81 |
* |
| 82 |
* @return string|null |
| 83 |
*/ |
| 84 |
private function current_locale(): ?string { |
| 85 |
$locale = function_exists( 'determine_locale' ) ? determine_locale() : get_locale(); |
| 86 |
|
| 87 |
// Nothing to merge for the source language. |
| 88 |
if ( empty( $locale ) || 0 === strpos( $locale, 'en_' ) ) { |
| 89 |
return null; |
| 90 |
} |
| 91 |
|
| 92 |
return $locale; |
| 93 |
} |
| 94 |
|
| 95 |
/** |
| 96 |
* Gets the plugin's languages directory (absolute path). |
| 97 |
* |
| 98 |
* @return string |
| 99 |
*/ |
| 100 |
private function languages_dir(): string { |
| 101 |
return defined( 'TABLE_BUILDER_BLOCK_PLUGIN_DIR' ) |
| 102 |
? TABLE_BUILDER_BLOCK_PLUGIN_DIR . 'languages' |
| 103 |
: dirname( __DIR__, 2 ) . '/languages'; |
| 104 |
} |
| 105 |
|
| 106 |
/** |
| 107 |
* Finds every candidate translation JSON file for a locale. |
| 108 |
* |
| 109 |
* @param string $locale Locale to find candidate translation files for. |
| 110 |
* @return string[] Absolute paths of every candidate JSON file for this locale. |
| 111 |
*/ |
| 112 |
private function find_translation_files( string $locale ): array { |
| 113 |
$pattern = sprintf( '%s/%s-%s-*.json', $this->languages_dir(), self::DOMAIN, $locale ); |
| 114 |
|
| 115 |
$files = glob( $pattern ); |
| 116 |
|
| 117 |
return $files ? $files : array(); |
| 118 |
} |
| 119 |
|
| 120 |
/* |
| 121 |
----------------------------------------------------------------- |
| 122 |
* Caching |
| 123 |
* --------------------------------------------------------------- |
| 124 |
*/ |
| 125 |
|
| 126 |
/** |
| 127 |
* Gets a cached merged translation catalog for a locale/file-set, computing |
| 128 |
* and caching it (as a transient, keyed by a content fingerprint) if not already cached. |
| 129 |
* |
| 130 |
* @param string $locale Locale being merged for. |
| 131 |
* @param string[] $files Candidate translation JSON files to merge. |
| 132 |
* @return string|null The merged Jed-format JSON catalog, or null if there's nothing to merge. |
| 133 |
*/ |
| 134 |
private function get_cached_merge( string $locale, array $files ): ?string { |
| 135 |
$cache_key = self::CACHE_PREFIX . md5( self::DOMAIN . '|' . $locale . '|' . $this->fingerprint( $files ) ); |
| 136 |
$cached = get_transient( $cache_key ); |
| 137 |
|
| 138 |
if ( is_string( $cached ) && '' !== $cached ) { |
| 139 |
return $cached; |
| 140 |
} |
| 141 |
|
| 142 |
$merged = $this->merge( $files, $locale ); |
| 143 |
if ( null === $merged ) { |
| 144 |
return null; |
| 145 |
} |
| 146 |
|
| 147 |
set_transient( $cache_key, $merged, self::CACHE_TTL ); |
| 148 |
|
| 149 |
return $merged; |
| 150 |
} |
| 151 |
|
| 152 |
/** |
| 153 |
* Cheap "version" signal for the cache key: changes automatically the |
| 154 |
* moment any candidate file is added, edited, or removed. |
| 155 |
* |
| 156 |
* @param string[] $files Candidate files to fingerprint. |
| 157 |
* @return string MD5 hash summarizing each file's path/mtime/size. |
| 158 |
*/ |
| 159 |
private function fingerprint( array $files ): string { |
| 160 |
$parts = array_map( array( $this, 'file_signature' ), $files ); |
| 161 |
sort( $parts ); |
| 162 |
|
| 163 |
return md5( implode( '|', $parts ) ); |
| 164 |
} |
| 165 |
|
| 166 |
/** |
| 167 |
* Builds a "path:mtime:size" signature for one file, without relying on the |
| 168 |
* error control operator to silence a missing-file warning. |
| 169 |
* |
| 170 |
* @param string $file Absolute file path. |
| 171 |
* @return string The file's signature; mtime/size are 0 if the file no longer exists. |
| 172 |
*/ |
| 173 |
private function file_signature( string $file ): string { |
| 174 |
if ( ! file_exists( $file ) ) { |
| 175 |
return $file . ':0:0'; |
| 176 |
} |
| 177 |
|
| 178 |
return $file . ':' . filemtime( $file ) . ':' . filesize( $file ); |
| 179 |
} |
| 180 |
|
| 181 |
/* |
| 182 |
----------------------------------------------------------------- |
| 183 |
* Merging |
| 184 |
* --------------------------------------------------------------- |
| 185 |
*/ |
| 186 |
|
| 187 |
/** |
| 188 |
* Merges multiple Jed-format translation files into a single catalog. |
| 189 |
* |
| 190 |
* @param string[] $files Translation JSON files to merge. |
| 191 |
* @param string $locale Locale, used to build fallback meta if none of the files provide one. |
| 192 |
* @return string|null The merged Jed-format JSON catalog, or null if no messages were found. |
| 193 |
*/ |
| 194 |
private function merge( array $files, string $locale ): ?string { |
| 195 |
$messages = array(); |
| 196 |
$meta = null; |
| 197 |
|
| 198 |
foreach ( $files as $file ) { |
| 199 |
list($file_messages, $file_meta) = $this->read_jed_file( $file ); |
| 200 |
$messages = array_merge( $messages, $file_messages ); |
| 201 |
$meta = $meta ?? $file_meta; |
| 202 |
} |
| 203 |
|
| 204 |
if ( empty( $messages ) ) { |
| 205 |
return null; |
| 206 |
} |
| 207 |
|
| 208 |
$messages[''] = $meta ? $meta : $this->default_meta( $locale ); |
| 209 |
|
| 210 |
return wp_json_encode( |
| 211 |
array( |
| 212 |
'translation-revision-date' => gmdate( 'Y-m-d H:i:s' ), |
| 213 |
'generator' => self::class, |
| 214 |
'domain' => 'messages', |
| 215 |
'locale_data' => array( |
| 216 |
'messages' => $messages, |
| 217 |
), |
| 218 |
) |
| 219 |
); |
| 220 |
} |
| 221 |
|
| 222 |
/** |
| 223 |
* Reads one Jed-format translation JSON file. |
| 224 |
* |
| 225 |
* WP-CLI / WordPress core always nest the catalog under the literal key |
| 226 |
* "messages", regardless of the actual text domain -- but Loco Translate |
| 227 |
* nests it under the *real* domain name instead (e.g. "table-builder-block"). |
| 228 |
* Both are valid Jed 1.x files, just disagreeing on that one key name, so |
| 229 |
* we accept "messages" first and otherwise fall back to whatever single |
| 230 |
* catalog is actually present. |
| 231 |
* |
| 232 |
* @param string $file Absolute path to the Jed-format JSON file to read. |
| 233 |
* @return array{0: array<string, array>, 1: array|null} [messages keyed by msgid, meta row (the "" key)] |
| 234 |
*/ |
| 235 |
private function read_jed_file( string $file ): array { |
| 236 |
if ( ! is_readable( $file ) ) { |
| 237 |
return array( array(), null ); |
| 238 |
} |
| 239 |
|
| 240 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reads a local translation JSON file, not a remote URL; wp_remote_get() doesn't apply here. |
| 241 |
$contents = file_get_contents( $file ); |
| 242 |
if ( false === $contents ) { |
| 243 |
return array( array(), null ); |
| 244 |
} |
| 245 |
|
| 246 |
$data = json_decode( $contents, true ); |
| 247 |
if ( empty( $data['locale_data'] ) || ! is_array( $data['locale_data'] ) ) { |
| 248 |
return array( array(), null ); |
| 249 |
} |
| 250 |
|
| 251 |
$catalog = $data['locale_data']['messages'] ?? reset( $data['locale_data'] ); |
| 252 |
if ( ! is_array( $catalog ) ) { |
| 253 |
return array( array(), null ); |
| 254 |
} |
| 255 |
|
| 256 |
$messages = array(); |
| 257 |
$meta = null; |
| 258 |
|
| 259 |
foreach ( $catalog as $key => $value ) { |
| 260 |
if ( '' === $key ) { |
| 261 |
$meta = $value; |
| 262 |
continue; |
| 263 |
} |
| 264 |
// Skip empty translations so an untranslated string in one file |
| 265 |
// can't clobber a real translation found in another. |
| 266 |
if ( is_array( $value ) && isset( $value[0] ) && '' !== $value[0] ) { |
| 267 |
$messages[ $key ] = $value; |
| 268 |
} |
| 269 |
} |
| 270 |
|
| 271 |
return array( $messages, $meta ); |
| 272 |
} |
| 273 |
|
| 274 |
/** |
| 275 |
* Builds a fallback Jed meta ("") row when none of the merged files provided one. |
| 276 |
* |
| 277 |
* @param string $locale Locale to embed in the meta row. |
| 278 |
* @return array Jed meta row (domain/lang/plural-forms). |
| 279 |
*/ |
| 280 |
private function default_meta( string $locale ): array { |
| 281 |
return array( |
| 282 |
'domain' => 'messages', |
| 283 |
'lang' => $locale, |
| 284 |
'plural-forms' => 'nplurals=2; plural=(n != 1);', |
| 285 |
); |
| 286 |
} |
| 287 |
} |
| 288 |
|