| @@ -28,9 +28,8 @@ | ||
| 28 | 28 | * |
| 29 | 29 | * @return void |
| 30 | 30 | */ |
| 31 | 31 | public function register_styles_scripts() { |
| 32 | - // Register i18n loader early so other scripts can depend on it. | |
| 33 | 32 | $this->register_global_scripts(); |
| 34 | 33 | $this->register_scripts(); |
| 35 | 34 | $this->register_styles(); |
| 36 | 35 | } |
| @@ -37,10 +36,8 @@ | ||
| 37 | 36 | |
| 38 | 37 | /** |
| 39 | 38 | * Register scripts |
| 40 | 39 | * |
| 41 | - * @param array $scripts | |
| 42 | - * | |
| 43 | 40 | * @return void |
| 44 | 41 | */ |
| 45 | 42 | public function register_scripts() { |
| 46 | 43 | $scripts = $this->get_scripts(); |
| @@ -51,9 +48,8 @@ | ||
| 51 | 48 | $version = isset( $script['version'] ) ? $script['version'] : $this->get_version( $script['src'] ); |
| 52 | 49 | |
| 53 | 50 | $deps = $this->get_dependencies( $script['src'], $deps ); |
| 54 | 51 | |
| 55 | - // Mirror wp-cafe: if script uses wp-i18n, ensure our i18n loader runs first. | |
| 56 | 52 | if ( in_array( 'wp-i18n', $deps, true ) ) { |
| 57 | 53 | $deps[] = 'booktics-i18n-loader'; |
| 58 | 54 | } |
| 59 | 55 | |
| @@ -58,16 +54,12 @@ | ||
| 58 | 54 | } |
| 59 | 55 | |
| 60 | 56 | wp_register_script( $handle, $script['src'], $deps, $version, $in_footer ); |
| 61 | 57 | |
| 62 | - // Enable JS translations for bundles that use wp.i18n. | |
| 63 | 58 | if ( function_exists( 'wp_set_script_translations' ) ) { |
| 64 | - // Don't force a path: translations are commonly in WP_LANG_DIR/plugins. | |
| 65 | 59 | wp_set_script_translations( $handle, 'booktics' ); |
| 66 | 60 | } |
| 67 | 61 | |
| 68 | - // Ensure preloaded locale data is actually registered in wp.i18n for | |
| 69 | - // pages that never trigger per-chunk JSON downloads. | |
| 70 | 62 | if ( 'booktics-packages' === $handle ) { |
| 71 | 63 | wp_add_inline_script( |
| 72 | 64 | 'booktics-packages', |
| 73 | 65 | '(function(){try{if(window.bookticsPreloadedLocaleData && window.wp && window.wp.i18n && window.wp.i18n.setLocaleData){window.wp.i18n.setLocaleData(window.bookticsPreloadedLocaleData,"booktics");}}catch(e){}})();', |
| @@ -113,8 +105,9 @@ | ||
| 113 | 105 | 'locale' => determine_locale(), |
| 114 | 106 | 'domainMap' => [], |
| 115 | 107 | 'domainPaths' => [], |
| 116 | 108 | 'translationMap' => (object) [], |
| 109 | + 'preloaded' => false, | |
| 117 | 110 | ]; |
| 118 | 111 | |
| 119 | 112 | $lang_dir = WP_LANG_DIR; |
| 120 | 113 | $content_dir = WP_CONTENT_DIR; |
| @@ -129,51 +122,27 @@ | ||
| 129 | 122 | substr( trailingslashit( $lang_dir ), strlen( untrailingslashit( $abspath ) ) ) |
| 130 | 123 | ); |
| 131 | 124 | } |
| 132 | 125 | |
| 133 | - $data['translationMap'] = (object) $this->get_translation_map(); | |
| 134 | - $data['domainPaths'] = (object) $this->get_translation_directories(); | |
| 126 | + $locale_data = $this->get_preloaded_locale_data( $data['locale'] ); | |
| 127 | + $data['preloaded'] = ! empty( $locale_data ); | |
| 135 | 128 | |
| 129 | + // The consolidated locale data already covers every string in the bundles, | |
| 130 | + // so the per-chunk hash map is only needed when it could not be built. | |
| 131 | + if ( ! $data['preloaded'] ) { | |
| 132 | + $data['translationMap'] = (object) $this->get_translation_map(); | |
| 133 | + } | |
| 134 | + | |
| 136 | 135 | wp_enqueue_script( 'booktics-i18n-loader' ); |
| 137 | 136 | |
| 138 | 137 | $data['domainMap'] = (object) $data['domainMap']; |
| 139 | 138 | $data['domainPaths'] = (object) $data['domainPaths']; |
| 140 | 139 | |
| 141 | - // If a consolidated .l10n.php file exists, preload all messages to avoid | |
| 142 | - // relying on per-chunk JSON requests (which may be missing). | |
| 143 | - $preload = null; | |
| 144 | - $l10n_file = trailingslashit( WP_LANG_DIR ) . 'plugins/booktics-' . $data['locale'] . '.l10n.php'; | |
| 145 | - $l10n_file_alt = trailingslashit( WP_LANG_DIR ) . 'plugins/booktics-' . str_replace( '-', '_', $data['locale'] ) . '.l10n.php'; | |
| 146 | - $l10n_file_legacy = trailingslashit( WP_LANG_DIR ) . 'plugins/booktics-' . str_replace( '_', '-', $data['locale'] ) . '.l10n.php'; | |
| 147 | - $l10n_candidates = array_unique( array_filter( [ $l10n_file, $l10n_file_alt, $l10n_file_legacy ] ) ); | |
| 148 | - | |
| 149 | - foreach ( $l10n_candidates as $candidate ) { | |
| 150 | - if ( file_exists( $candidate ) ) { | |
| 151 | - $preload = include $candidate; | |
| 152 | - break; | |
| 153 | - } | |
| 154 | - } | |
| 155 | - | |
| 156 | 140 | $inline = '(function(){var k="bookticsI18nLoader"; if (typeof window[k] === "undefined") { window[k] = {}; } window[k].state = ' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES ) . ';'; |
| 157 | 141 | |
| 158 | - if ( is_array( $preload ) && ! empty( $preload['messages'] ) && is_array( $preload['messages'] ) ) { | |
| 159 | - $messages = []; | |
| 160 | - foreach ( $preload['messages'] as $msgid => $msgstr ) { | |
| 161 | - if ( is_string( $msgid ) && is_string( $msgstr ) ) { | |
| 162 | - $messages[ $msgid ] = [ $msgstr ]; | |
| 163 | - } | |
| 164 | - } | |
| 165 | - | |
| 166 | - $locale_data = [ | |
| 167 | - '' => [ | |
| 168 | - 'domain' => 'booktics', | |
| 169 | - 'lang' => $data['locale'], | |
| 170 | - 'plural-forms' => isset( $preload['plural-forms'] ) ? $preload['plural-forms'] : 'nplurals=2; plural=n != 1;', | |
| 171 | - ], | |
| 172 | - ] + $messages; | |
| 173 | - | |
| 174 | - $encoded_locale_data = wp_json_encode( $locale_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); | |
| 175 | - $inline .= ' window[k].preloadedLocaleData = ' . $encoded_locale_data . '; window.bookticsPreloadedLocaleData = ' . $encoded_locale_data . ';'; | |
| 142 | + if ( ! empty( $locale_data ) ) { | |
| 143 | + $inline .= ' window[k].preloadedLocaleData = window.bookticsPreloadedLocaleData = ' | |
| 144 | + . wp_json_encode( $locale_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) . ';'; | |
| 176 | 145 | } |
| 177 | 146 | |
| 178 | 147 | $inline .= '})();'; |
| 179 | 148 | |
| @@ -186,10 +155,8 @@ | ||
| 186 | 155 | |
| 187 | 156 | /** |
| 188 | 157 | * Register styles |
| 189 | 158 | * |
| 190 | - * @param array $styles | |
| 191 | - * | |
| 192 | 159 | * @return void |
| 193 | 160 | */ |
| 194 | 161 | public function register_styles() { |
| 195 | 162 | $styles = $this->get_styles(); |
| @@ -238,9 +205,9 @@ | ||
| 238 | 205 | return $assets; |
| 239 | 206 | } |
| 240 | 207 | |
| 241 | 208 | /** |
| 242 | - * Get script file version | |
| 209 | + * Get script file version from .asset.php (generated by DependencyExtractionWebpackPlugin). | |
| 243 | 210 | * |
| 244 | 211 | * @param string $file_name |
| 245 | 212 | * |
| 246 | 213 | * @return string |
| @@ -255,36 +222,32 @@ | ||
| 255 | 222 | * Get file path from url |
| 256 | 223 | * |
| 257 | 224 | * @param string $url |
| 258 | 225 | * |
| 259 | - * @return string | |
| 226 | + * @return string|false | |
| 260 | 227 | */ |
| 261 | 228 | private function get_file_path( $url ) { |
| 262 | - // Check if the URL is valid | |
| 263 | 229 | if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) { |
| 264 | 230 | return false; |
| 265 | 231 | } |
| 266 | 232 | |
| 267 | - // Parse the URL | |
| 268 | 233 | $url_parts = wp_parse_url( $url ); |
| 269 | 234 | |
| 270 | - // Check if the URL has a path component | |
| 271 | 235 | if ( ! isset( $url_parts['path'] ) ) { |
| 272 | - return false; // URL does not contain a path | |
| 236 | + return false; | |
| 273 | 237 | } |
| 274 | 238 | |
| 275 | - $clean_path = str_replace( '.js', '.asset.php', $url_parts['path'] ); | |
| 239 | + $path = $url_parts['path']; | |
| 240 | + $path = preg_replace( '/\.js$/', '.asset.php', $path ); | |
| 276 | 241 | |
| 277 | - // Get the file path from the URL path. | |
| 278 | 242 | if ( isset( $_SERVER['DOCUMENT_ROOT'] ) ) { |
| 279 | - $file_path = sanitize_text_field( wp_unslash( $_SERVER['DOCUMENT_ROOT'] ) ) . $clean_path; | |
| 243 | + $file_path = sanitize_text_field( wp_unslash( $_SERVER['DOCUMENT_ROOT'] ) ) . $path; | |
| 280 | 244 | } else { |
| 281 | - $file_path = ''; // Or another appropriate fallback. | |
| 245 | + $file_path = ''; | |
| 282 | 246 | } |
| 283 | 247 | |
| 284 | - // Check if the file exists | |
| 285 | 248 | if ( ! file_exists( $file_path ) ) { |
| 286 | - return false; // File does not exist. | |
| 249 | + return false; | |
| 287 | 250 | } |
| 288 | 251 | |
| 289 | 252 | return $file_path; |
| 290 | 253 | } |
| @@ -289,13 +252,105 @@ | ||
| 289 | 252 | return $file_path; |
| 290 | 253 | } |
| 291 | 254 | |
| 292 | 255 | /** |
| 256 | + * Build the Jed locale data for every translated string in the text domain. | |
| 257 | + * | |
| 258 | + * Script translations are normally split into one JSON file per bundled | |
| 259 | + * script, keyed by an md5 of that script's path. Async chunk filenames carry | |
| 260 | + * a content hash and change on every build, so those JSON files go stale as | |
| 261 | + * soon as the plugin is updated. Registering the whole domain up front keeps | |
| 262 | + * the React apps translated regardless of how the bundles were chunked. | |
| 263 | + * | |
| 264 | + * @param string $locale Locale being rendered. | |
| 265 | + * | |
| 266 | + * @return array Jed formatted locale data, empty when there is nothing to preload. | |
| 267 | + */ | |
| 268 | + private function get_preloaded_locale_data( $locale ) { | |
| 269 | + static $cache = []; | |
| 270 | + | |
| 271 | + if ( isset( $cache[ $locale ] ) ) { | |
| 272 | + return $cache[ $locale ]; | |
| 273 | + } | |
| 274 | + | |
| 275 | + $cache[ $locale ] = []; | |
| 276 | + | |
| 277 | + if ( 'en_US' === $locale ) { | |
| 278 | + return $cache[ $locale ]; | |
| 279 | + } | |
| 280 | + | |
| 281 | + if ( ! is_textdomain_loaded( 'booktics' ) ) { | |
| 282 | + // Registers the plugin's own /languages path. Just-in-time loading only | |
| 283 | + // finds WP.org language packs, so without this a bundled .mo is ignored. | |
| 284 | + // phpcs:ignore PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound | |
| 285 | + load_plugin_textdomain( 'booktics', false, dirname( plugin_basename( BOOKTICS_FILE ) ) . '/languages' ); | |
| 286 | + } | |
| 287 | + | |
| 288 | + $translations = get_translations_for_domain( 'booktics' ); | |
| 289 | + | |
| 290 | + // WP_Translations (WP 6.5+, .l10n.php files) does not extend Translations, | |
| 291 | + // so check for the entries it exposes rather than the class. | |
| 292 | + if ( ! is_object( $translations ) || $translations instanceof \NOOP_Translations ) { | |
| 293 | + return $cache[ $locale ]; | |
| 294 | + } | |
| 295 | + | |
| 296 | + $entries = (array) $translations->entries; | |
| 297 | + $messages = []; | |
| 298 | + | |
| 299 | + foreach ( $entries as $entry ) { | |
| 300 | + if ( ! $entry instanceof \Translation_Entry || ! is_string( $entry->singular ) || '' === $entry->singular ) { | |
| 301 | + continue; | |
| 302 | + } | |
| 303 | + | |
| 304 | + $entry_translations = array_values( array_filter( | |
| 305 | + (array) $entry->translations, | |
| 306 | + function ( $translation ) { | |
| 307 | + return is_string( $translation ) && '' !== $translation; | |
| 308 | + } | |
| 309 | + ) ); | |
| 310 | + | |
| 311 | + if ( empty( $entry_translations ) ) { | |
| 312 | + continue; | |
| 313 | + } | |
| 314 | + | |
| 315 | + // wp.i18n keys contextual strings as "contextsingular". | |
| 316 | + $key = ( is_string( $entry->context ) && '' !== $entry->context ) | |
| 317 | + ? $entry->context . "\4" . $entry->singular | |
| 318 | + : $entry->singular; | |
| 319 | + | |
| 320 | + $messages[ $key ] = $entry_translations; | |
| 321 | + } | |
| 322 | + | |
| 323 | + if ( empty( $messages ) ) { | |
| 324 | + return $cache[ $locale ]; | |
| 325 | + } | |
| 326 | + | |
| 327 | + $headers = (array) $translations->headers; | |
| 328 | + | |
| 329 | + $locale_data = [ | |
| 330 | + '' => [ | |
| 331 | + 'domain' => 'booktics', | |
| 332 | + 'lang' => $locale, | |
| 333 | + 'plural-forms' => ! empty( $headers['Plural-Forms'] ) ? $headers['Plural-Forms'] : 'nplurals=2; plural=n != 1;', | |
| 334 | + ], | |
| 335 | + ] + $messages; | |
| 336 | + | |
| 337 | + /** | |
| 338 | + * Filter the locale data preloaded for the React apps. | |
| 339 | + * | |
| 340 | + * Useful to trim the payload down to the strings a site actually renders. | |
| 341 | + * | |
| 342 | + * @param array $locale_data Jed formatted locale data. | |
| 343 | + * @param string $locale Locale being rendered. | |
| 344 | + */ | |
| 345 | + $cache[ $locale ] = apply_filters( 'booktics_preloaded_locale_data', $locale_data, $locale ); | |
| 346 | + | |
| 347 | + return $cache[ $locale ]; | |
| 348 | + } | |
| 349 | + | |
| 350 | + /** | |
| 293 | 351 | * Get map of translation file hashes to their URLs. |
| 294 | 352 | * |
| 295 | - * Scans all translation directories for JSON files matching | |
| 296 | - * the current locale and domain, building a hash => URL map. | |
| 297 | - * | |
| 298 | 353 | * @return array Hash => URL map. |
| 299 | 354 | */ |
| 300 | 355 | private function get_translation_map() { |
| 301 | 356 | $map = []; |
| @@ -319,13 +374,12 @@ | ||
| 319 | 374 | continue; |
| 320 | 375 | } |
| 321 | 376 | |
| 322 | 377 | foreach ( $files as $file ) { |
| 323 | - $filename = basename( $file ); | |
| 324 | - // Extract hash: everything after "{domain}-{locale}-". | |
| 378 | + $filename = basename( $file ); | |
| 325 | 379 | $variant_prefix = $domain . '-' . $variant . '-'; |
| 326 | 380 | if ( strpos( $filename, $variant_prefix ) === 0 ) { |
| 327 | - $hash = substr( $filename, strlen( $variant_prefix ), -5 ); // strip .json | |
| 381 | + $hash = substr( $filename, strlen( $variant_prefix ), -5 ); | |
| 328 | 382 | if ( $hash ) { |
| 329 | 383 | $map[ $hash ] = trailingslashit( $dir['url'] ) . $filename; |
| 330 | 384 | } |
| 331 | 385 | } |
| @@ -337,11 +391,8 @@ | ||
| 337 | 391 | } |
| 338 | 392 | |
| 339 | 393 | /** |
| 340 | 394 | * Return translation directory paths and their corresponding URLs. |
| 341 | - * | |
| 342 | - * Covers the standard WP languages/plugins/ directory, Loco Translate's | |
| 343 | - * directory, and the plugin's own bundled languages/ folder. | |
| 344 | 395 | * |
| 345 | 396 | * @return array<int, array{path: string, url: string}> |
| 346 | 397 | */ |
| 347 | 398 | private function get_translation_directories() { |