Author image
Senior Developer

Language lessons: 10 Useful tips

To complete my series on multilingual Drupal, here are some mini-lessons I've learnt. Some of them are are to improve the experience for administrators & translators, others cover obscure corners of code.

Contents:

  1. Don't force everyone to use Drupal
  2. Administering translations
  3. Gather precise requirements to match translation models
  4. Language-specific styling
  5. Allow admins to edit any translation
  6. Get to know Drupal's language types & helper functions
  7. Using the entity metadata wrapper
  8. Options & limitations to define/use a fallback language
  9. Overriding core language functionality
  10. Ctools objects & caching issues

1. Don't force everyone to use Drupal

POEditor interface If there are numerous people involved in translating your site content, some of them may not need to use Drupal. It's not the best tool for every job! The default interfaces for lists of translatable strings and making translations are not great when dealing with thousands of strings - instead make use of Drupal's import & export functionality, and do the actual translating elsewhere when possible.

We have used shared google spreadsheets to list required translations, which our client can then enter translations into directly, or copy into from elsewhere. Spreadsheets are widely understood (at least to some extent) and can be fitted to suit the precise needs of all stakeholders. External translation agencies will often deal with spreadsheets, so don't force them to use Drupal's interface - or even the file formats imported & exported by Drupal. After all, while you're a Drupal expert, your partners in the translation workflow may not be! Although Lingotek's services might be an option worth exploring if you need some Drupal expertise in your translation workflow.

We have found POEditor to be a very good interface for dealing with translations, particularly in bulk. You can easily import the translation files exported from Drupal (.po or .pot) or in many other formats, and export translations for importing back into Drupal. This is useful when you need to convert file formats provided by external sources.

Having said that, it's quite possible to make the translation workflow within Drupal better...

2. Administering translations

Localization Client interface While Drupal's standard interface for translations is a bit restrictive, it can of course be easily extended - here's just a few options:

  • You can create views with 'Locale source' as a base table, to create your own list of translations.
  • The Localization client (l10n_client) module gives a nice popup interface for editing translations 'on the fly'.
  • While I haven't used it myself yet, the Translation Management Tool (TMGMT) module looks to provide a vastly better interface, making it easier to connect to external translation services, identify what is and is not translated, and unify many of Drupal's disparate translation interfaces.
  • If you don't actually want to deal with many translations, Drupal's standard interface may actually be too much rather than too little! Use the simple String Overrides module or even the String Overrides Advanced module that our very own Steven Jones has built. Drupal 7's standard interface doesn't actually let you 'translate' English interface text, so string overrides may be the best way to do that if you need to. (This is resolved in Drupal 8, as with many other language issues!)

I fully expect there are many many more options out there. What is your experience of administering translations & the workflow for them? Please contribute to this series - add a comment in the section below!

3. Gather precise requirements to match translation models

My previous article about content & entity (field) translation explains why you might use either of the two overarching approaches to translation in Drupal. Editorial workflows & content architecture will often be closer to one or the other model, but it's also common for some parts of a site to be different.

For example, on a recent site that used the field-level translation approach almost everywhere, that could not apply to site menus, because they could be ordered totally differently in different languages, not just directly translated at the deepest level. This meant the menu has to be translated 'as a whole' rather than as individual menu items, which is confusing.

Prepare for this by making the differences very clear, or find ways to 'hide' the differences in workflow & functionality if possible. Drupal's administrative pages for menus are quite separate to content, which can help in my case, but be careful if different translation systems are needed within a single system (e.g. between different content types). Changing content from one translation model to the other is not recommended, so pick the models carefully in the first place.

4. Language-specific styling

Language-specific CSS has been possible ever since CSS 2.0... I did not know this until relatively recently!

.strapline {
  text-transform: lowercase;
  width: 100px;
  float: left;
}
.strapline:lang(de) {
  text-transform: none;
  width: 120px;
}

German words are often longer than average, and German nouns should always be capitalised. So why not style German pages specially? This works through the use of language HTML attributes (usually set in Drupal's html.tpl.php template) and the :lang() pseudo-selector in CSS.

5. Allow admins to edit any translation

Bizarrely, any translation that does not match its sanitised equivalent cannot be edited by any user, regardless of their permissions. It's a good thing that untrusted editors cannot set translations to contain unsafe HTML, but this means strings containing unsafe HTML, or malformed HTML, can never be translated. If you want to bypass this, add the following to a custom module, which allows users with the 'Translate admin strings' permission to edit any translation:

/**
* Implements hook_form_FORM_ID_alter().
*/
function MYMODULE_form_i18n_string_locale_translate_edit_form_alter(&$form, &$form_state, $form_id) {
  $form['#validate'] = array('MYMODULE_i18n_string_locale_translate_edit_form_validate');
}

/**
* Process string editing form validations.
*/
function MYMODULE_i18n_string_locale_translate_edit_form_validate($form, &$form_state) {
  if (empty($form_state['values']['i18n_string']) && !user_access('translate admin strings')) {
    // If not i18n string use regular locale validation.
    $copy_state = $form_state;
    locale_translate_edit_form_validate($form, $copy_state);
  }
}

6. Get to know Drupal's language types & helper functions

Drupal has the ability to put parts of a page in different languages. An example of this would be the website of a company that might only do business nationally, but contributes articles & blogs to an international audience. In this case, the interface (header, blocks, any fixed text) could be in a local language (e.g. Dutch, Hungarian), whilst actual content (i.e. entered by editors into articles) could be in the international language of the audience they wish to reach beyond their own country (e.g. English, Spanish). Under the hood, Drupal also recognises URLs as having their own language type, but that is usually inherited directly from the interface.

When writing language-aware custom code, it's important to know which language type should be used. The i18n module provides some helper functions:

$content_lang = i18n_language_content();
$language = i18n_language_interface();
// The above two functions return language objects.
$langcode = $language->language;

There are plenty of helper functions within Drupal core itself too - here's just a few:

// The code of the language to use for a field value,
// using the fallback language if necessary.
$langcode = field_language('node', $node, 'field_link');
// Gets the name (or other properties) of the currently-set
// default language. No need to hardcode it!
$default_name = language_default('name');
// Check whether more than one language is set up.
$is_multilingual = drupal_multilingual();
// iso.inc contains a couple of predefined ISO code lists,
// take a look!
include_once DRUPAL_ROOT . '/includes/iso.inc';

As an aside, please use the constant LANGUAGE_NONE instead of the 'und' string when referring to the language-neutral code!

7. Using the entity metadata wrapper

The entity metadata wrapper is great for accessing things in code without caring about language - but if you want a field value in a specific language, even the language of the current page, you first have to call this before accessing translations:

 $wrapped_entity->language($langcode);

Also, you can't use either of the following for translated entity titles/labels:

 $wrapped_entity->label();
$wrapped_entity->title->value();

Instead, assuming you're using the Title module to make entity labels translatable, use:

 $wrapped_entity->title_field->value();

(title_field is the default field name for nodes. It may be different for other entity types - for example, name_field for taxonomy terms.)

8. Options & limitations to define/use a fallback language

Drupal core has a concept of a 'fallback' language - so for example, you could set 'Spanish' as the fallback for 'Portuguese' users on an otherwise-English site, as Portuguese-speakers may prefer Spanish content to English content when there is no Portuguese translation available. This would be achieved in bespoke code with hook_language_fallback_candidates_alter() and hook_field_language_alter(), but with limitations. These are only applied by Drupal core to content in fields, so will not apply the fallback to menus, blocks, site names, and many other sorts of text across your site that don't come from fields. That's a good argument to use 'fieldable' systems for as many of those as possible (e.g. Bean module for blocks), but unfortunately you will have to deal with many of these with disparate solutions or bespoke code in Drupal 7 if you want fallbacks to apply everywhere.

Despite that limitation, being able to set fallback languages seems pretty useful to me. I'd love to see contrib modules allow fallbacks to be customised by site builders in the UI - perhaps selecting fallbacks per-language in a similar way to how the negotiation methods can currently be selected, with the draggable options for re-ordering selections.

9. Overriding core language functionality

Drupal core's language detection & selection configuration screen Drupal's core language 'negotiation' system (selecting which languages to use for content and other things, configured at /admin/config/regional/language/configure) is pretty good, but will often need overriding to cover your exact requirements. For example, core can select language according to the exact domain, but when I had a variety of subdomains (perhaps for different staging environments) for a project, so I only wanted the domain suffix (e.g. '.fr') to determine the language rather than the whole domain, this needed extending.

Drupal does provide hook_language_negotiation_info_alter() to allow you to extend Drupal's record of what should be done, but the the actual records of what will be done are the language_negotiation_* variables, set per language type with language_negotiation_set(). Somewhat unexpectedly, since the negotiation callbacks & settings are statically cached, any changes you make have to be done for every language 'type' (content, interface & URL), even if you only intend to change one type.

Note that your module's .module file will only be included at the point of initialising language if it implements one of Drupal's 'bootstrap hooks'. An empty hook_language_init() in your module will do the trick if necessary. If you're replacing negotiation callbacks, you may well need to do this step, which may come as a surprise!

Now you know all this, you can override anything in the language negotiation system, but it's not obvious from just looking at the variables!

10. Ctools objects & caching issues

A lot of data is cached by Drupal - anything from Views to facet definitions. Sometimes already-translated text is cached, which means that translation would get used for all languages, which is incorrect. The best solution is to file a patch with a direct fix to the module in its issue queue on drupal.org. Otherwise, you can often implement hooks to change objects before they are cached... and again after they are loaded. As an example, we had to do the following to stop facets from having their 'Any' option (the 'no-selection' option) being translated before being cached:

/**
* Implements hook_form_FORM_ID_alter().
*
* Stop the untranslatable no-selection ('Any') setting from
* being changed as the translation comes from the default
* setting which is translatable.
*/
function MYMODULE_form_facetapi_facet_display_form_alter(&$form, &$form_state, $form_id) {
  list(, $realm, $facet) = $form_state['build_info']['args'];
  foreach (facetapi_get_widgets($realm, $facet) as $id => $plugin) {
    if (isset($form['widget']['widget_settings']['links'][$id]['no-selection'])) {
      $form['widget']['widget_settings']['links'][$id]['no-selection']['#access'] = FALSE;
    }
  }
}

/**
* Implements hook_schema_alter().
*
* Replace the save callback for facets to avoid saving no-selection
* settings.
*/
function MYMODULE_schema_alter(&$tables) {
  if (isset($tables['facetapi']['export'])) {
    $tables['facetapi']['export']['save callback'] = 'MYMODULE_replacement_facetapi_save_callback';
  }
}

/**
* Custom save callback for Facet API CTools objects.
*
* Strips any no-selection setting, since it is not translatable.
*
* @see ctools_export_crud_save().
*/
function MYMODULE_replacement_facetapi_save_callback($object) {
  if (isset($object->settings) && is_array($object->settings)) {
    unset($object->settings['no-selection']);
  }

  // Remaining code is lifted straight from ctools_export_crud_save().

  $table = 'facetapi';
  $schema = ctools_export_get_schema($table);
  $export = $schema['export'];

  // Objects should have a serial primary key. If not, simply fail to write.
  if (empty($export['primary key'])) {
    return FALSE;
  }

  $key = $export['primary key'];
  if ($object->export_type & EXPORT_IN_DATABASE) {
    // Existing record.
    $update = array($key);
  }
  else {
    // New record.
    $update = array();
    $object->export_type = EXPORT_IN_DATABASE;
  }
  return drupal_write_record($table, $object, $update);
}

/**
* Implements hook_facetapi_default_facet_settings_alter().
*
* Strips any no-selection setting, since it is not translatable.
*/
function MYMODULE_facetapi_default_facet_settings_alter(&$exports) {
  foreach ($exports as $id => $object) {
    if (isset($object->settings) && is_array($object->settings)) {
      unset($object->settings['no-selection']);
    }
  }
}

It's unusual to have to go to these lengths -- usually there's just a single alter hook you can use. Data objects ought to be saved in the database in the default language, or at least with a record of what language they are in (which is done for nodes), with translations pulled from elsewhere. This is particularly relevant for CTools objects though, which do not usually consider language a first-class citizen, or are often implemented by modules that have only added translation as an afterthought.


If you've read this far, congratulations! Let me know your own obscure lessons you have learnt in working with languages in Drupal!

Next article in series: 
Previous article in series: