Skip to content

Commit

Permalink
chore(i18n): Introduce Locale, Message, and MessageBundle
Browse files Browse the repository at this point in the history
These concepts will help us clean up the i18n code significantly.
  • Loading branch information
ewinslow committed Mar 21, 2015
1 parent 65a7c43 commit 0e33641
Show file tree
Hide file tree
Showing 12 changed files with 355 additions and 9 deletions.
45 changes: 45 additions & 0 deletions engine/classes/Elgg/I18n/ArrayMessageBundle.php
@@ -0,0 +1,45 @@
<?php
namespace Elgg\I18n;

/**
* WARNING: API IN FLUX. DO NOT USE DIRECTLY.
*
* Uses an array as a source for the message bundle.
*
* This is mostly useful for testing so we can configure translators
* in-memory instead of going to the file system.
*
* @since 1.11
*
* @access private
*/
final class ArrayMessageBundle implements MessageBundle {

/** @var array */
private $messages;

/**
* Constructor
*
* @param array $messages Map of locales to maps of keys to message-templates
*/
public function __construct(array $messages) {
$this->messages = $messages;
}

/** @inheritDoc */
public function get($key, Locale $locale) {
assert(is_string($key), '$key must be a string');

if (!isset($this->messages["$locale"]) || !is_array($this->messages["$locale"])) {
return null;
}

$messages = $this->messages["$locale"];
if (!is_string($key) || !isset($messages[$key]) || !is_string($messages[$key])) {
return null;
}

return new SprintfMessageTemplate($messages[$key]);
}
}
49 changes: 49 additions & 0 deletions engine/classes/Elgg/I18n/Locale.php
@@ -0,0 +1,49 @@
<?php
namespace Elgg\I18n;

/**
* WARNING: API IN FLUX. DO NOT USE DIRECTLY.
*
* Language class to ensure only valid languages are used.
*
* @since 1.11
*
* @access private
*/
final class Locale {

/** @var string */
private $locale;

/**
* Use Locale::parse to construct
*
* @param string $locale A string representation of the locale
*/
private function __construct($locale) {
$this->locale = $locale;
}

/** @inheritDoc */
public function __toString() {
return $this->locale;
}

/**
* Create a language, asserting that the language code is valid.
*
* @param string $locale Language code
*
* @return Locale
*
* @throws InvalidLocaleException
*/
public static function parse($locale) {
// TODO(evan): Better sanitizing of locales using \Locale perhaps
if (strlen($locale) < 2 || strlen($locale) > 5) {
throw new InvalidLocaleException("Unrecognized locale: $locale");
}

return new Locale($locale);
}
}
25 changes: 25 additions & 0 deletions engine/classes/Elgg/I18n/MessageBundle.php
@@ -0,0 +1,25 @@
<?php
namespace Elgg\I18n;

/**
* WARNING: API IN FLUX. DO NOT USE DIRECTLY.
*
* TODO(ewinslow): Have this extend Table(row=string, column=Locale, value=?MessageTemplate)
* if we ever support a Table data structure.
*
* @since 1.11
*
* @access private
*/
interface MessageBundle {

/**
* Fetches the translatable message associated with the given key
*
* @param string $key String identifier for the message
* @param Locale $locale Locale in which the message is written
*
* @return ?MessageTemplate The message object or null if not found.
*/
public function get($key, Locale $locale);
}
50 changes: 50 additions & 0 deletions engine/classes/Elgg/I18n/MessageTemplate.php
@@ -0,0 +1,50 @@
<?php
namespace Elgg\I18n;

/**
* WARNING: API IN FLUX. DO NOT USE DIRECTLY.
*
* A single localizable message template.
*
* We introduced this class because we want to have the flexibility of
* easily switching our message template language from sprintf to ICU...
*
* Example messages:
* - "{subject} spent {num_nights,number,integer} nights camping in {location}." (ICU)
* - "%s spent %d nights camping in %s" (sprintf)
*
* @since 1.11
*
* @access private
*/
abstract class MessageTemplate {
/** @var string */
protected $template;

/**
* Constructor
*
* @param string $template The message template
*/
public function __construct($template) {
$this->template = $template;
}

/**
* Applies the inputs to the message template and returns the result.
*
* @param array $args The inputs to this message
*
* @return string The rendered including all the interpolated inputs
*/
public abstract function format(array $args);

/**
* Get the string template this message uses for translation.
*
* @return string
*/
public function __toString() {
return $this->template;
}
}
53 changes: 53 additions & 0 deletions engine/classes/Elgg/I18n/MessageTranslator.php
@@ -0,0 +1,53 @@
<?php
namespace Elgg\I18n;

/**
* WARNING: API IN FLUX. DO NOT USE DIRECTLY.
*
* @access private
*
* @since 1.11
*/
class MessageTranslator implements TranslatorInterface {

// TODO Maybe this should be an array of locales to provide multiple fallbacks?
/** @var Locale */
private $defaultLocale;

/** @var MessageBundle */
private $messages;

/**
* Constructor
*
* @param Locale $defaultLocale The fallback locale
* @param MessageBundle $messages Messages that this translator is aware of
*/
public function __construct(Locale $defaultLocale, MessageBundle $messages) {
$this->defaultLocale = $defaultLocale;
$this->messages = $messages;
}

/** @inheritDoc */
public function translate($key, array $args = [], Locale $locale = null) {
$locales = [
$locale,
$this->defaultLocale,
Locale::parse('en'),
];

foreach ($locales as $locale) {
if (!$locale) {
continue;
}

$message = $this->messages->get($key, $locale);

if ($message) {
return $message->format($args);
}
}

return $key;
}
}
18 changes: 18 additions & 0 deletions engine/classes/Elgg/I18n/NullMessageTemplate.php
@@ -0,0 +1,18 @@
<?php
namespace Elgg\I18n;

/**
* WARNING: API IN FLUX. DO NOT USE DIRECTLY.
*
* A message that always ignores all parameters and just returns the template.
*
* @since 1.11
*
* @access private
*/
final class NullMessage extends Message {
/** @inheritDoc */
public function format(array $args) {
return "$this";
}
}
13 changes: 8 additions & 5 deletions engine/classes/Elgg/I18n/NullTranslator.php
Expand Up @@ -5,13 +5,16 @@
/**
* WARNING: API IN FLUX. DO NOT USE DIRECTLY.
*
* @access private
* A translator that does nothing except return the key that was requested.
*
* This translator is useful during development if you want to be able to
* easily tell what the available keys are for changing the wording of UI elements.
*
* @since 1.10.0
*
* @package Elgg.Core
* @subpackage I18n
* @since 1.10.0
* @access private
*/
class NullTranslator extends Translator {
final class NullTranslator extends Translator {
/** @inheritDoc */
public function translate($key, $args = array(), $lang = '') {
return $key;
Expand Down
18 changes: 18 additions & 0 deletions engine/classes/Elgg/I18n/SprintfMessageTemplate.php
@@ -0,0 +1,18 @@
<?php
namespace Elgg\I18n;

/**
* WARNING: API IN FLUX. DO NOT USE DIRECTLY.
*
* A message that uses vsprintf to insert arguments into the template.
*
* @since 1.11
*
* @access private
*/
final class SprintfMessageTemplate extends MessageTemplate {
/** @inheritDoc */
public function format(array $args) {
return \vsprintf($this->template, $args);
}
}
4 changes: 1 addition & 3 deletions engine/classes/Elgg/I18n/Translator.php
Expand Up @@ -6,9 +6,7 @@
*
* @access private
*
* @package Elgg.Core
* @subpackage I18n
* @since 1.10.0
* @since 1.10.0
*/
class Translator {

Expand Down
37 changes: 37 additions & 0 deletions engine/classes/Elgg/I18n/TranslatorInterface.php
@@ -0,0 +1,37 @@
<?php
namespace Elgg\I18n;

/**
* WARNING: API IN FLUX. DO NOT USE DIRECTLY.
*
* Can "translate" language keys into various human-readable, localized strings.
*
* TODO(ewinslow): Remove the "Interface" suffix
*
* @since 1.11
*
* @access private
*/
interface TranslatorInterface {
/**
* Given a message key, returns a best-effort translated string.
*
* If the translator doesn't know how to translate into the specified locale,
* it can try translating into a related or similar locale (e.g. en-US => en).
*
* If no locale is specified, or if no translation can be found for the specified
* locale, the translator may choose to fall back to some other language(s).
*
* It should never throw exceptions, since lack of translation should never be
* cause to bring down an app or cancel a request. However, implementations may
* log warnings to alert admins that requested language strings are missing.
*
* @param string $key A key identifying the message to translate.
* @param array $args An array of arguments with which to format the message.
* @param Locale $locale Optionally, the standard language code
* (defaults to site/user default, then English)
*
* @return string The final, best-effort translation.
*/
function translate($key, array $args = [], Locale $locale = null);
}
47 changes: 47 additions & 0 deletions engine/tests/phpunit/Elgg/I18n/MessageTranslatorTest.php
@@ -0,0 +1,47 @@
<?php
namespace Elgg\I18n;

use PHPUnit_Framework_TestCase as TestCase;

class MessageTranslatorTest extends TestCase {
public function setUp() {
$this->english = Locale::parse('en');
$this->spanish = Locale::parse('es');
}

public function testKeyIsReturnedIfNoTranslationCanBeFound() {
$messages = new ArrayMessageBundle([]);
$translator = new MessageTranslator(Locale::parse('en'), $messages);

$this->assertEquals('foobar', $translator->translate('foobar'));
}

public function testTranslateReturnsTranslationForSpecifiedLocaleIfAvailable() {
$messages = new ArrayMessageBundle([
'en' => ['one' => 'one'],
'es' => ['one' => 'uno'],
]);
$translator = new MessageTranslator(Locale::parse('en'), $messages);

$this->assertEquals('uno', $translator->translate('one', [], Locale::parse('es')));
}

public function testTranslateReturnsTranslationForDefaultLocaleIfNoLocaleWasSpecified() {
$messages = new ArrayMessageBundle([
'en' => ['one' => 'one'],
'es' => ['one' => 'uno'],
]);
$translator = new MessageTranslator(Locale::parse('en'), $messages);

$this->assertEquals('one', $translator->translate('one', []));
}

public function testFallsBackToLanguageIfTranslationForSpecifiedLanguageIsNotAvailable() {
$messages = new ArrayMessageBundle([
'en' => ['one' => 'one'],
]);
$translator = new MessageTranslator(Locale::parse('en'), $messages);

$this->assertEquals('one', $translator->translate('one', [], Locale::parse('es')));
}
}

0 comments on commit 0e33641

Please sign in to comment.