Last updated: 31/01/2012 1:11pm

New website!

Check out the new website for a new or updated article on this topic:
www.balbuss.com/internationalize/

Internationalize - i18n

24-09-2010
Added a (somewhat experimental) way to translate Enum dropdowns... 

 The -t(...) method

For code to be usable with different languages, it must first be ' internationalized'. This mainly involves using the _t(...) method where-ever there's a bit of text to be displayed in the CMS or on the website. Unfortunately not all of the (third party) SilverStripe modules have been prepared for this. It's just not high priority for everyone, but for us 'outlanders' it is often a matter of use/don't use :-( I usually try to internationalize right from the start, as it can be one hell of a job later on. The Silverstripe docs on i18n are extensive.

In a SilverStripe class:

class MyExample extends DataObject {

function someFunction() {

// don't do this:
$title = 'A basic example';

// but do this (use the classname (MyExample) as 'namespace'):
$title = _t('MyExample.TITLE', 'A basic example');
...  
}
}

Using the _t(..) method in a template, omit the 'namespace' bit - SilverStripe will automatically add the name of the template. Note: there can be no whitespace after the comma, since this will generate some obscure error!

In a MyExample.ss template

<% _t('TITLE','A basic example'); %>

 Auto-generate a language file with i18nTextCollector

Once all _t( ... ) methods are in place, generating a languagefile is a piece of cake. In fact the i18TextCollector task will do it for us, generating a default en_US.php languagefile, for which it will need a writable /lang/ directory present in the module. This file can then be translated manually for use with other locales. To generate a languagefile for a module called 'mymodule', visit the following URL:

http://www.mysite.ext/dev/tasks/i18nTextCollectorTask/?module=mymodule

You have to be logged in as administrator for this to work. The website will now shout: Running task 'i18n Textcollector Task'... and then nothing further seems to happen, but the languagefile will be created. Each time you run the collector, the file will be recreated, so all changes you might have made will be lost! Languagefile enties for the two examples above will look like this:

/mymodule/lang/en_US.php:

// from the SilverStripe class
$lang['MyExample']['TITLE'] = 'A basic example';

// from the template
$lang['MyExample.ss']['TITLE'] = 'A basic example';

 i18nEntityProvider

Variables can be used in the _t(...) method. Something like this is very possible:

$title = _t("MyExample.$someVar", 'A basic example');

There is a small problem though: in this situation, the textCollector cannot create an entry in the languagefile, since it doesn't know the value of $someValue. Suppose the content of $someVar would be selected from an array of strings. In that case you'd need translations for every arrayvalue. You could add these translations to the languagefile by hand - or use the i18nEntityProvider.

All classes implementing this interface, have a method provideI18nEntities() that offer dynamic translations to the textCollector to 'harvest. Suppose we have some array we need translations for:

class MyPage extends Page {

  public $someArray = array('red', 'white', 'blue');

 
  function provideI18nEntities() {
   $entities = parent::provideI18nEntities();

    foreach ($someArray  as $value) {
      $entities["MyPage.$value"] = array($value);
    }

    return $entities;
  }
}

The Page class' parents already implement the i18nEntityProvider interface, so you need to add the parent::provideI18nEntities() to be sure. The TextCollector recognizes the provideI18nEntities() method and will add the following to the language file:

$lang['en_US']['MyPage']['red'] = 'red';
$lang['en_US']['MyPage']['white'] = 'white'; $lang['en_US']['MyPage']['blue'] = 'blue';

 Modules to collect

Collecting text for the entire SilverStripe core setup (2.4!) maybe isn't something you should want to do. But still, if you do (The languagefiles for the distribs are not always up to date), these are the modules to consider.

  • cms
  • sapphire
  • mysite
  • googlesitemaps
  • themes (not technically modules!)

Collecting sapphire...

Beware: on collecting text for the sapphire module, the textcollector might stumble on files that use the phpUnit testing framework, like most files in the sapphire/dev folder - unless you do actually have the framework installed.

Collecting mysite...

For some reason there exists a provideI18nEntities method in the SiteTree class that wants to create a ['Page']['PLURALNAME'] and a ['Page']['SINGULARNAME'] entry in the sapphire language file as soon as you try to collect for the mysite module. This will effectively overwrite the sapphire language file with only these two entries. Collecting for one module shouldn't really affect other modules and I have as yet found no obvious reason why these entities can't exist in the mysite language file where the Page class resides. Since I don't want to remove code from core classes, I override the method in the Page class instead:

function provideI18nEntities() {
$entities = parent::provideI18nEntities();

if(isset($entities['Page.SINGULARNAME']))
$entities['Page.SINGULARNAME'][3] = 'mysite';
if(isset($entities['Page.PLURALNAME']))
$entities['Page.PLURALNAME'][3] = 'mysite';

return $entities;
}

Collecting cms...

01-06-2010
As of rev. 105996 all now goes well. In older versions the following might still happen, so I'll leave it in for a while:

When performing a textCollection on the cms module, something weird happens concerning the translations for the main menu items in the CMS. These are not written to the cms language file, but an extra languagefile is created in a folder called 0 in the website root. Thes translations can be copied to the sapphire folder, and the new 0 folder then be removed:

$lang['en_US']['AssetAdmin']['MENUTITLE'] ='Files & Images';
$lang['en_US']['CMSMain']['MENUTITLE'] = 'Pages';
$lang['en_US']['CommentAdmin']['MENUTITLE'] = 'Comments';
$lang['en_US']['ReportAdmin']['MENUTITLE'] = 'Reports';
$lang['en_US']['SecurityAdmin']['MENUTITLE'] = 'Security';
$lang['en_US']['ModelAdmin']['MENUTITLE'] = 'ModelAdmin';

Collecting a theme...

A theme is not a module, so it can't be collected. Still there might be translations within it's templates, that need collecting. One way to overcome this is:

  1. copy the theme itself to the root of the website
  2. create a _config.php file so SilverStripe thinks it's a module
  3. make the theme writable or create a writable lang directory
  4. create an empty code/ directory within the theme
  5. perform textCollection
  6. copy the translations from the new en_US.php file to the one in mysite
  7. remove the copied  theme from the website root

TODO: test if the translations work!

 Translating static variables

SilverStripe doc on translating static variables

Translation of static variables is somewhat complex. You can't just use the _t(...) method in a static context, since it is only accessible in a dynamic class instance. So the way to go is by using a custom getter, and this is a very simple method, that will work if the variable value is always the same:

static $SomeVar = 'some value';

function getSomeVar() {
return _t('ThisClass.SOMEVAR', $this->stat('SomeVar'));
}

A selection of values

Now if you never know in advance what value a static var will have, there's no use in preparing a translation. But suppose there are three values to choose from. You'd need three possible translations that you want the textcollector to find first. This is where the i18nEntityProvider interface comes in handy again. Its provideI18nEntities() method prepares a couple of translations for the textfile -even if they're not yet used:

class MyExample extends DataObject implements i18nEntityProvider {

static $TimeOfDay = 'morning';

// prepare a couple of strings for translation
function provideI18nEntities() {
$entities = parent::provideI18nEntities

$entities['MyExample.morning'] = array('morning');
$entities['MyExample.afternoon'] = array('afternoon');
$entities['MyExample.evening'] = array('evening');

return $entities;
}
}

Running the textcollector would result in the following entry in the en_US.php language file:

$lang['en_US']['MyExample']['morning'] = 'morning';
$lang['en_US']['MyExample']['afternoon'] = 'afternoon';
$lang['en_US']['MyExample']['evening'] = 'evening';

And then for the getter: you use the value of the variable (that should either be 'morning', 'afternoon' or 'evening' to get at the translation.

function getTimeOfDay() {
return _t("MyExample." . $this->stat('TimeOfDay'), $value);
}

This can now be referred to from the template as:

The time of day is: $TimeOfDay

 Translating field labels

In some situations custom getters won't work - especially when SilverStripe uses scaffolding to create forms. In that situation SilverStripe will call on the DataObject::FieldLabels() method to provide it with an array of fields and their labels. So to provide the correct translation for a label, we need to override the FieldLabels() method in our custom DataObject class. Something like:

class MyExample extends DataObject implements i18nEntityProvider {

static $db = array(
'Name' => 'Varchar',
...
);

  function fieldLabels($includerelations = true) {
    $labels = parent::fieldLabels($includerelations);

// add a translation to the $labels array for each db field
// if $includerelations == true (and it normally is) you need to add
// the db_ prefix, cause that's what SS will look for

    foreach($this->stat('db') as $key => $value) {
      $labels[$key] = _t("MyExample.db_{$key}", $key);
    }

    return $labels;
  }

Now you need to make sure that these translations again exist in the language file, so you must to provide them for the textcollector. Note: in scaffolding, SilverStripe wants the keys for these translations to have a prefix refering to the type of relation: db, has_one, has_many etc.:

  function provideI18nEntities() {
$entities = parent::provideI18nEntities();

// provide translations for $db fields
foreach($this->stat('db') as $key => $value) {
$entities["MyExample.db_{$key}"] = array($key);
}

// provide translations for $has_one fields
foreach($this->stat('has_one') as $key => $value) {
$entities["MyExample.has_one_{$key}"] = array($key);
}
return $entities;
}

Effectively, a field called 'Name' and a has_one relation called 'Image' will end up in the language file like so:

$lang['en_US']['MyExample']['db_Name'] = 'Name';
$lang['en_US']['MyExample']['has_one_Image'] = 'Image';

 Special labels: summary and searchable

Special labels for fields like $summary_fields and $searchable_fields in ComplexTableFields are treated in a similar fashion. You can use the foreach loop in the provideI18nEntities() and the FieldLabels() method, without having to use any relational prefixes. You can also skip the provideI18nEntities() method and provide translations 'as is'. Mind these fields can refer to fields within related objects or even to functions, so they need not be real database fields at all. Something like this will work (textcollector will spot these translations):

  static $summary_fields = array(
'Name', // db Name field
'Image.ID', // ID of a has_one Image
'MyFunc' // return value of some function
  );

  function fieldLabels($includerelations = true) {
    $labels = parent::fieldLabels($includerelations);

    $labels['Name'] = _t('MyExample.NAME', 'Last name');
$labels['Image.ID'] = _t('MyExample.NAME', 'Image ID');
$labels['MyFunc'] = _t('MyExample.MYFUNC', 'Function result');

    return $labels;
  }

 Translating default values

Default values are the values that form fields are set to when a form is first opened for a new record. These values need to be set before the form is created. Normally you would do something like this:

static $db = array(
'Name' => 'Varchar'
);

static $defaults = array(
'Name' => 'Enter your name here'
);

In this case SilverStripe will also call the PopulateDefaults() method to find default values, and sice this is a dynamic function, it is the obvious place to provide it with a translation (where $this->Name represents the field's value):

  static $db = array(
'Name' => 'Varchar',
);


public function populateDefaults(){
parent::populateDefaults();

// create a new 'current' value
$this->Name = _t('MyExample.DEFAULT_NAME', 'Enter your name here');
}

Note: a default value is only offered when an object is newly created in the cms, it is skipped on opening previously stored records for obvious reasons...

 Translate an Enum dropdown

Creating a standard dropdown based on the values of an Enum db field isn't too difficult. The Enum class offers an enumValues() method, that will return the array ready to populate the DropDown. You get to this method by instantiating the enum class. But translating it is more difficult. For each key=>value pair in the array returned by enumValues(), the value equals the key - so we need to translate the value. You could do that by creating a foreach-loop to that extend for every enum in yor code, but wouldn't be great if enumValue() already did this?  There's more then one way to do this, extending the Enum class is one of them:

<?php
class i18nEnum extends Enum {

 /*
* returns Enum values as a simple array
*/

function getEnum() {
return $this->enum;
}


/*
*  Override the enumValues method and return a translated array
* The translation always defaults to its original value
*/

function enumValues($namespace = 'Enum' , $hasEmpty = false) {
$translatedOptions = array();

$options = ($hasEmpty) ?
array_merge(array('' => ''), $this->enum) : $this->enum;

    // use the namespace provided by the class that 'owns' the enum
if (!empty($options)) {
foreach ($options as $value) {
$translatedOptions[$value] = _t("$namespace.$value", $value);
}
}
return $translatedOptions;
}
}

Now creating an i18nEnum dropdown in the CMS works just like it always did:

<?php
class MyPage extends Page {

  public static $db = array(
    'Colors' => "i18nEnum('red, white, blue', 'red')"
  );


  function getCMSFields() {
    $fields = parent::getCMSFields();
   
    /*
     * get the translated array to populate the dropdown
     */

    $options = singleton('Page')->dbObject('Colors')->enumValues();

    $fields->addFieldToTab(
'Root.Content.Main',
new DropDownField(
'Colors',
'Some colors',
$options
));
 
    return $fields;
  }
}

Note: you could probably also  use a decorator instead of extending the class, and be able to still use the Enum fieldtype, and have an additional i18nEnumValues() method. 

TextCollector again...

Unfortunately it's not possible to have the i18nEnum class provide the translations for the textcollector as well, because the enum values are different for every Enum field you create. So in this case we'll still have to use the Page's own providI18nEntities() method - or add translations to the languagefile by hand:

<?php
class MyPage extends Page {

  ...

  function provideI18nEntities() {
    $entities = parent::provideI18nEntities();
 
    $colorArray =
      singleton('Page')->dbObject('Colors')->getEnum();
   
    foreach($colorArray as $color) {
      $entities["$this->ClassName.$color"] = array($color);                            
    }
    return $entities;
  }
}

TODO: find a better way...

 Translating JavaScript files

The translation of JavaScript Files is more or less similar to that of PHP code, and is handled by the sapphire/javascript/i18n.js file. This script will try to detect the current locale based on the http-equiv="Content-language" metatag on loading. If no locale can be established, the script will default to its defaultLocale property.

First you need to internationalize your javascript file in the following manner:

// mystring = 'this is a message';

mystring = ss.i18n._t('MYSCRIPT.MESSAGE', 'this is a message');

Next you need a language file. Start with the default en_US.js file in your modules javascript/lang/ directory.

/mymodule/javascript/lang/en_US.js

if(typeof(ss) == 'undefined' || typeof(ss.i18n) == 'undefined') {
if(typeof(console) != 'undefined') console.error('Class ss.i18n not defined');
} else {
ss.i18n.addDictionary('en_US', {
'MYSCRIPT.MESSAGE': 'this is a message',
'MYSCRIPT.SOMETHING': 'Something completely different'
});
}

After that translate this file into any language you want. Aside from adding the correct filename and translating strings, don't forget to set the correct locale for the dictionary as well :-)

Finally you need to point SilverStripe to the language files, by adding a requirement to the php code. In a page this would be in init() for the website or maybe in getCMSFields() for the backend. The following will add all files in the javascript/lang directory (but you can be selective as well):

Requirements::add_i18n_javascript('mymodule/javascript/lang');  

Note:
Sometimes, especially in the cms, the locale for the current userprofile is not detected correctly or just not in time. In case of emergency, you could hardcode the defaultlocale property in i18n.js to the preferred locale.

 Included templates - bug?

Sometimes SilverStripe templates refuse to show their translated content. This is because included templates won't use their own namespace in the language file, but that of their top parent instead. Being unaware of this might result in a lot of hairpulling while things just don't translate - more about that here. This might be resolved from version 2.4.x

 SiteTree contextmenu - omission...

In versions 2.4x the SiteTree context menu in the CMS can't be translated. Fix here.

 Multilingual site vs just another language?

To open the CMS in a different language, just change the language settings for your user-profile. To create a website for a different language, you need to set the locale in your config file (default in sapphire/_config.php).

/sapphire/_config.php:

i18n::set_locale('nl_NL');

A multilingal site is a different matter. I haven't needed it up till now, but I've started a new article here...

 Translate the default name for a new page in the SiteTree

When you add a new page to the SiteTree, it's (Menu)Title is construed based on the PageType ClassName, preceded by whatever translation of the keyword 'new'. You'd get titles like 'new MyContactPage' where you'd want 'new contact page' or 'nieuwe contactpagina' based on the users locale... The user will change it anyway, but still it doen't look nice. To remedy:

Make sure you provide a translation for every pagetype in your languagefiles like this:

 

$lang['en_US']['MyContactPage']['SINGULAR_NAME'] = 'contact page';

Next add the following code to your Page class (will work for alle extended pages as well):

 

	function onBeforeWrite(){
		parent::onBeforeWrite();
		if (empty($this->ID)) {
			$this->Title = _t('CMSMain.NEW', 'new') . ' ' . $this->i18n_singular_name();
		}
	}

 

10 Most recently updated pages

Post your comment

Comments

  • You should really commit the i18nEnum patch to to Silverstripe ;)

    Posted by Martijn, 10/02/2011 3:37pm (3 years ago)

RSS feed for comments on this page | RSS feed for all comments