PluginProbe
TableKit – WordPress Table Builder for Data Tables, WooCommerce Product Tables & Post Tables / trunk
TableKit – WordPress Table Builder for Data Tables, WooCommerce Product Tables & Post Tables vtrunk
2.2.13 2.2.12 2.2.11 2.2.10 2.2.9 2.2.8 2.2.7 2.2.6 2.2.5 2.2.4 2.2.3 trunk 1.0.0 1.0.1 2.0.0 2.0.1 2.1.0 2.1.1 2.1.2 2.2.0 2.2.1 2.2.2
table-builder-block / includes / Core / ScriptTranslationMerger.php

ScriptTranslationMerger.php in TableKit – WordPress Table Builder for Data Tables, WooCommerce Product Tables & Post Tables trunk, at includes/Core/ScriptTranslationMerger.php

288 lines 9.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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