diff --git a/readme.txt b/readme.txt index ee50f6a..994bdd2 100644 --- a/readme.txt +++ b/readme.txt @@ -208,6 +208,9 @@ Yes. All RoxyAPI shortcodes work inside any page builder that supports WordPress == Changelog == += 1.7.6 = +* Regional language settings now show the translated form. A site set to Spanish (Argentina), Spanish (Mexico), Portuguese (Portugal), German (Austria) or French (Canada) previously fell back to English even though that language was included. It now uses the translation for that language. + = 1.7.5 = * The visitor form is now translated into German, Spanish, French, Hindi, Portuguese, Russian, and Turkish. Set your site language and the birth details form, its help text, and its buttons appear in that language alongside the reading itself. @@ -383,6 +386,9 @@ Yes. All RoxyAPI shortcodes work inside any page builder that supports WordPress == Upgrade Notice == += 1.7.6 = +Recommended if your site language is a regional variant such as Spanish (Argentina) or Portuguese (Portugal). Those sites now get the translated form. + = 1.7.5 = Recommended for sites in German, Spanish, French, Hindi, Portuguese, Russian, or Turkish. The visitor form now appears in your site language. diff --git a/src/Plugin.php b/src/Plugin.php index 01f4393..d89994d 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -25,6 +25,7 @@ use RoxyAPI\Blocks\Registrar as BlocksRegistrar; use RoxyAPI\Shortcodes\Registrar as ShortcodesRegistrar; use RoxyAPI\Support\FormRouter; +use RoxyAPI\Support\LocaleFallback; use RoxyAPI\Support\Theming; use RoxyAPI\Support\UiBundle; @@ -38,6 +39,10 @@ public static function load( string $file ): void { register_activation_hook( $file, array( Activation::class, 'activate' ) ); register_deactivation_hook( $file, array( Activation::class, 'deactivate' ) ); + // Registered here rather than in boot(): a just-in-time textdomain load can fire + // before plugins_loaded, and the filter has to already be in place when it does. + LocaleFallback::register(); + add_action( 'plugins_loaded', array( self::class, 'boot' ) ); } diff --git a/src/Support/LocaleFallback.php b/src/Support/LocaleFallback.php new file mode 100644 index 0000000..9851fdc --- /dev/null +++ b/src/Support/LocaleFallback.php @@ -0,0 +1,96 @@ + + */ + private static array $cache = array(); + + public static function register(): void { + add_filter( 'load_textdomain_mofile', array( self::class, 'fallback' ), 10, 2 ); + } + + /** + * Redirect an unreadable translation path to a catalogue we ship for the same language. + * + * @param string $mofile Absolute path WordPress intends to load. + * @param string $domain Text domain being loaded. + * @return string The original path, or a same-language file we actually ship. + */ + public static function fallback( $mofile, $domain ) { + $mofile = (string) $mofile; + if ( $domain !== self::DOMAIN || is_readable( $mofile ) ) { + return $mofile; + } + if ( isset( self::$cache[ $mofile ] ) ) { + return self::$cache[ $mofile ]; + } + + /** + * A wp.org LANGUAGE PACK, once one exists for the exact locale, lands in + * WP_LANG_DIR/plugins and is passed here first. Only an unreadable path reaches this + * point, so an official pack always wins and this never shadows one. + */ + $resolved = self::same_language_file( $mofile ); + self::$cache[ $mofile ] = $resolved; + return $resolved; + } + + /** + * Nearest shipped catalogue for the same language, or the original path when there is none. + * + * @param string $mofile Absolute path WordPress intended to load. + * @return string + */ + private static function same_language_file( string $mofile ): string { + $dir = dirname( $mofile ); + $base = basename( $mofile, '.mo' ); + // Strip the domain prefix, then keep the two-letter language part of the locale. + $locale = (string) substr( $base, strlen( self::DOMAIN ) + 1 ); + $prefix = strtolower( substr( $locale, 0, 2 ) ); + if ( strlen( $prefix ) !== 2 ) { + return $mofile; + } + + $candidates = glob( $dir . '/' . self::DOMAIN . '-' . $prefix . '*.mo' ); + if ( ! $candidates ) { + // The plugin's own languages/ folder is the shipped set; WP_LANG_DIR may hold none. + $candidates = glob( dirname( ROXYAPI_PLUGIN_FILE ) . '/languages/' . self::DOMAIN . '-' . $prefix . '*.mo' ); + } + if ( ! $candidates ) { + return $mofile; + } + + // Deterministic: es_ES before es_MX, so two installs never disagree. + sort( $candidates ); + return (string) $candidates[0]; + } +} diff --git a/tests/phpunit/test-locale-fallback.php b/tests/phpunit/test-locale-fallback.php new file mode 100644 index 0000000..58f9866 --- /dev/null +++ b/tests/phpunit/test-locale-fallback.php @@ -0,0 +1,86 @@ +languages_dir() . '/roxyapi-' . $locale . '.mo'; + } + + public function test_the_shipped_catalogues_are_present(): void { + // Non-vacuity: with no shipped files every assertion below would pass trivially. + $found = glob( $this->languages_dir() . '/roxyapi-*.mo' ); + $this->assertNotEmpty( $found, 'Expected shipped .mo catalogues in languages/.' ); + $this->assertFileExists( $this->path_for( 'es_ES' ) ); + } + + public function test_a_regional_locale_falls_back_to_its_language(): void { + // es_AR is the locale that exposed this: a real customer site. + $requested = $this->path_for( 'es_AR' ); + $resolved = LocaleFallback::fallback( $requested, 'roxyapi' ); + + $this->assertNotSame( $requested, $resolved, 'es_AR must not be left unresolved.' ); + $this->assertStringContainsString( 'roxyapi-es_', $resolved ); + $this->assertFileExists( $resolved ); + } + + public function test_every_language_we_ship_covers_its_variants(): void { + $cases = array( + 'es_MX' => 'es_', + 'es_VE' => 'es_', + 'pt_PT' => 'pt_', + 'de_AT' => 'de_', + 'de_CH' => 'de_', + 'fr_CA' => 'fr_', + 'fr_BE' => 'fr_', + ); + foreach ( $cases as $locale => $expected_prefix ) { + $resolved = LocaleFallback::fallback( $this->path_for( $locale ), 'roxyapi' ); + $this->assertStringContainsString( + 'roxyapi-' . $expected_prefix, + $resolved, + "{$locale} should resolve to a {$expected_prefix}* catalogue." + ); + } + } + + public function test_an_exact_match_is_returned_untouched(): void { + // The filter must be inert whenever the requested file actually exists, so a wp.org + // language pack for the exact locale always wins over our shipped fallback. + $exact = $this->path_for( 'es_ES' ); + $this->assertSame( $exact, LocaleFallback::fallback( $exact, 'roxyapi' ) ); + } + + public function test_other_text_domains_are_never_touched(): void { + $foreign = WP_LANG_DIR . '/plugins/some-other-plugin-es_AR.mo'; + $this->assertSame( $foreign, LocaleFallback::fallback( $foreign, 'some-other-plugin' ) ); + } + + public function test_an_unsupported_language_is_left_alone(): void { + // Japanese is not one of the eight; the path must pass through so WordPress falls back + // to English rather than being handed an unrelated catalogue. + $ja = $this->path_for( 'ja' ); + $this->assertSame( $ja, LocaleFallback::fallback( $ja, 'roxyapi' ) ); + } +}