Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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' ) );
}

Expand Down
96 changes: 96 additions & 0 deletions src/Support/LocaleFallback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php
/**
* Serves a shipped translation to regional locales we do not ship a file for.
*
* WordPress matches translation files by EXACT locale. A site set to `es_AR` looks for
* `roxyapi-es_AR.mo` and, finding none, renders English even though `roxyapi-es_ES.mo` sits
* in the same folder and is the same language. WordPress ships dozens of regional Spanish,
* Portuguese, German and French locales, so shipping one file per variant would mean copying
* the same catalogue twenty times and remembering to copy it again whenever a new locale is
* added upstream.
*
* This maps by language prefix instead: any `xx_YY` falls back to whichever `xx_*` catalogue
* is present. One file per language keeps working for every current and future variant.
*
* @package RoxyAPI
*/

namespace RoxyAPI\Support;

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

class LocaleFallback {

/** Text domain this fallback applies to. Matches the wp.org slug. */
private const DOMAIN = 'roxyapi';

/**
* Resolved paths keyed by requested file, so the directory is scanned at most once per
* locale per request.
*
* @var array<string, string>
*/
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];
}
}
86 changes: 86 additions & 0 deletions tests/phpunit/test-locale-fallback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php
/**
* A regional locale must resolve to the catalogue shipped for its language.
*
* WordPress matches translation files by EXACT locale, so a site set to `es_AR` looks for
* `roxyapi-es_AR.mo`, finds none, and renders English even though `roxyapi-es_ES.mo` is sitting
* in the same folder. WordPress offers dozens of regional Spanish, Portuguese, German and French
* locales, so the alternative to this filter is copying one catalogue per variant and copying it
* again every time a locale is added upstream.
*
* Verified against a real install before this test existed: `es_AR` rendered the form entirely in
* English with the Spanish catalogue present.
*
* @package RoxyAPI
*/

namespace RoxyAPI\Tests;

use RoxyAPI\Support\LocaleFallback;

class Test_Locale_Fallback extends \WP_UnitTestCase {

private function languages_dir(): string {
return dirname( ROXYAPI_PLUGIN_FILE ) . '/languages';
}

private function path_for( string $locale ): string {
return $this->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' ) );
}
}