Software localization

Creating a WordPress Multilingual Site: Best Practices for Custom Theme Localization

Round out your understanding of Wordpress theme localization with a bunch of recipes for accomplishing common i18n and l10n tasks.
Software localization blog category featured image | Phrase

In our first WordPress i18n article, we covered how to localize content with the Polylang plugin. In our second installment in this series, we looked at basic gettext with WordPress and how to start theme localization.

In this part, we'll round out our understanding of Wordpress theme localization with a bunch of recipes for accomplishing common tasks. By the end of this article, we'll have something to show our clients, Najla and Taha. In part 2, we started a custom localized theme for our clients' North African handmade crafts business, Handmade's Tale. By the end of this part, we'll actually have a deliverable to show Najla and Taha: a working localized theme. Here are the recipes we'll cover:

  • Basic i18n for theme UI elements (we started this in part 2)
  • Interpolation
  • Singular and plural variants of translated strings
  • Date & time formatting
  • Number formatting
  • Providing context for translators when translated strings are duplicated in different parts of our interface
  • Getting the current locale for use in our theme
  • Determining the current locale's layout direction
  • Writing locale-specific CSS
  • Conditional, per-locale loading of assets
  • Creating a custom language switcher

Note » I'm assuming you have basic knowledge of WordPress theme coding, including file structure, PHP, basic WordPress functions, HTML, and CSS.

As you can see, we will create a little grab bag of useful recipes that can assist us as we write our custom, localized WordPress themes. Let's get started!

Our Goal

When we're done, our site will look like this.

Translated demo app | Phrase

WordPress and Plugins

Let’s get started. Here’s what we had installed in part 2.

  • WordPress (5.0.3)
  • Plugins
    • Polylang (2.5.2) ~ handles content & URL localization
    • Loco Translate (2.2.0) ~ facilitates the translation of theme and plugin strings
    • Contact Form 7 (5.1.1) ~ makes it easy to build contact forms
    • CF7 Smart Grid Design Extension (2.8.0) ~ gives us more flexibility over CF7 forms from within the WordPress admin console (however, we’re really just installing it here since it’s a requirement of the Contact Form 7 Polylang extension)
    • Contact Form 7 Polylang extension (2.3.1) ~ allows us to use Polylang to localize CF7 forms

Note » Since part 2, WordPress and some plugins have been updated. The updates are minor and point releases and shouldn't have any effect on our work in this series to date. However, do note the new version numbers if you're building along with us.

Polylang

The Polylang plugin is handling a lot of the i18n work for us and, therefore, it deserves special mention. We'll be relying on the localization features that Polylang gives us as we build our theme. If you're not familiar with the basics of Polylang, you may want to check out part 1 of this series, where we cover the use of Polylang in detail.

Note » If you want to build along with us, or if you've completely unfamiliar with WordPress localization, we strongly recommend that you check out part 1 and part 2 of this series.

File Organization

We'll have a basic WordPress theme directory structure. We covered some of these files in part 2, and we'll cover more in this article.

/

├── wp-content/

|   ├── themes/

|   |   ├── handmadestale/

|   |   |   ├── classes/

|   |   |   |   ├── HMT_Language_Switcher_Widget.php

|   |   |   |   └── HMT_Walker_Nav_Menu.php

|   |   |   ├── img/

|   |   |   |   └── logo.png

|   |   |   ├── footer.php

|   |   |   ├── functions.php

|   |   |   ├── header.php

|   |   |   ├── index.php

|   |   |   ├── language-switcher.php

|   |   |   ├── loop.php

|   |   |   ├── page.php

|   |   |   ├── single.php

|   |   |   ├── style.css

|   |   |   └── stylesheet-links.php

|   |   └── ...

|   └── ...

└── ...

Note » Some file names have changed slightly since part 2.

Note » All the code for this article can be found in its companion Github repo.

Basic UI i18n (Recap)

In the last part, we covered basic WordPress theme localization in detail. Let's recap this briefly...

Note » If you're coding along and haven't completed all the code from part 2, you may want to clone and install the Github repo from part 2 and use it as your starting project to build on here. If you do so, make sure to go to Appearance > Menus in your admin panel and ensure that your menus are in the locations set in the theme. You may have to create new menus to see the locations.

We currently have our theme scaffolded with an index.php, header.php, and footer.php.

index.php

<?php get_template_part('header'); ?>

<main role="main" aria-label="Content">

    <!-- section -->

    <section>

        <!-- Content will go here -->

    </section>

    <!-- /section -->

</main>

<?php get_template_part('footer'); ?>

header.php

<!doctype html>

<html <?php language_attributes(); ?>>

    <head>

        <meta charset="<?php bloginfo('charset'); ?>">

        <title>

            <?php wp_title(''); ?>

            <?php if (wp_title('', false)) {

                echo ' | ';

            } ?>

            <?php bloginfo('name'); ?>

        </title>

        <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">

        <meta name="viewport" content="width=device-width, initial-scale=1.0">

        <meta name="description" content="<?php bloginfo('description'); ?>">

        <?php wp_head(); ?>

    </head>

    <body <?php body_class(); ?>>

        <!-- wrapper -->

        <div class="wrapper">

            <!-- header -->

            <header class="header clear" role="banner">

                <h1 class="site-heading">

                    <a

                        class="site-heading__link"

                        href="<?php echo home_url(); ?>"

                    >

                        <img

                            class="site-heading__logo"

                            src="<?php echo get_bloginfo('template_url') ?>/img/logo.png"

                        />

                        <?php bloginfo('name'); ?>

                    </a>

                </h1>

                <!-- nav -->

                <nav class="nav pure-g" role="navigation">

                    <div class="pure-u-2-3">

                        <?php wp_nav_menu(array(

                            'theme_location' => 'main-menu',

                            'container_class' => 'menu-main-menu-container pure-menu pure-menu-horizontal',

                            'items_wrap' => '<ul id="%1$s" class="pure-menu-list %2$s">%3$s</ul>',

                            'walker' => new HandmadesTale_Walker_Nav_Menu,

                        )); ?>

                    </div>

                </nav>

                <!-- /nav -->

            </header>

            <!-- /header -->

footer.php

 <!-- footer -->

            <footer class="footer" role="contentinfo">

                <!-- copyright -->

                <p class="footer__copyright">

                    &copy;

                    <?php echo esc_html(date('Y')); ?>

                    Copyright <?php bloginfo('name'); ?>.

                    <?php echo __('Powered by', 'handmadestale'); ?>

                    <a href="//wordpress.org" target="_blank">

                        <?php echo __('WordPress'); ?>

                    </a>.

                </p>

                <!-- /copyright -->

            </footer>

            <!-- /footer -->

        </div>

        <!-- /wrapper -->

        <?php wp_footer(); ?>

    </body>

</html>

This is all bread-and-better stuff for WordPress theme development. However, notice the use of the __() function above. __() takes a translatable string and a text domain and returns their translation in the current locale, if it's available. Let's build on top of this and add a loop.php file, since we're currently not showing any of our actual posts on the Newsfeed page

Note » We called our posts page Newsfeed in a previous part.

We'll add this line to our index.php file.

<?php get_template_part('loop'); ?>

Then we'll add the loop partial itself.

loop.php

<?php if (have_posts()) : while (have_posts()) : the_post(); ?>

    <!-- article -->

    <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>

        <!-- post thumbnail -->

        <?php if (has_post_thumbnail()) : ?>

            <a

                href="<?php the_permalink(); ?>"

                title="<?php the_title_attribute(); ?>"

            >

                <?php the_post_thumbnail(array(120, 120)); ?>

            </a>

        <?php endif; ?>

        <!-- /post thumbnail -->

        <!-- post title -->

        <h2>

            <a

                href="<?php the_permalink(); ?>"

                title="<?php the_title_attribute(); ?>"

            >

                <?php the_title(); ?>

            </a>

        </h2>

        <!-- /post title -->

        <?php the_content(); ?>

        <span class="author">

            <?php esc_html_e('Published by', 'handmadestale'); ?>

            <?php the_author_posts_link(); ?>

        </span>

        <!-- /post details -->

        <?php edit_post_link(); ?>

    </article>

    <!-- /article -->

<?php endwhile; ?>

<?php else : ?>

    <!-- article -->

    <article>

        <h2>

            <?php esc_html_e('Sorry, nothing to display.', 'handmadestale'); ?>

        </h2>

    </article>

    <!-- /article -->

<?php endif; ?>

This is a standard WordPress loop, of course. With Polylang installed, the loop will automatically load the correct translations for our posts ie. the ones for the current locale.

Translated demo app | Phrase

Photo by Color Crescent on Unsplash

Note » The Arabic UI strings shown above were translated using the Loco Translate and Polylang plugins. We go over that in part 1 and part 2.

Also, notice the use of esc_html_e() above. This function works exactly like __(), except it escapes its text for HTML use and echoes the translated string instead of returning it.

Note » WordPress provides several _e() variants of its i18n functions, and they can be convenient when we want to echo out a translated string right away, which is most of the time. We cover many of WordPress' i18n functions in part 2.

Interpolation

You may have noticed that our copyright text in footer.php hasn't been internationalized.

Copyright <?php bloginfo('name'); ?>.

Being the judicious WordPress developers we are, we should make this string translatable. And since we're pulling in our site name dynamically through WordPress' bloginfo(), we need a way to interpolate strings into our translations at runtime. This is commonly achieved with PHP's built-in sprintf() or printf() functions. Let's update our footer.php to use printf().

<?php printf(

    __('Copyright 2019 %s', 'handmadestale'),

    get_bloginfo('name')

); ?>

We use our good old __() function to register and return the translatable string "Copyright 2019 %s". Nothing new there. We then pass the translated string that __() gives us to printf() as a format string. printf() understands interpolation placeholders, like %s, and will replace them with the succeeding parameters it receives. In this case %s will be replaced with the localized name of our website, which we fetch using get_bloginfo('name').

Note » Read more about sprintf() and printf() in the official PHP documentation.

All we have to do at this point is provide a translation for the string "Copyright 2019 %s" for each locale we support. We need to take care to place the %s interpolation placeholder appropriately in our translations.

Copyright footer with % | Phrase

Plurals

Let's say we wanted to show the number of published posts on our website. We could simply include something like the following in one of our template files.

<?php echo hmt_published_post_count(); ?> posts.

However, this won't work when we just have one post, nor is it translatable. Let's fix both of these things.

Note » Check out the simple code for hmt_published_post_count() in this article's companion Github repo. In case you couldn't guess, by the way, it returns an int.

<?php printf(

    _n('%d post', '%d posts', hmt_published_post_count(), 'handmadestale'),

    hmt_published_post_count()

); ?>

This mouthful of code uses WordPress' _n() function to select between two translatable strings, one for singular and one for plural. _n() selects between the two forms based on its third integer parameter. The function's fourth parameter is the usual text domain which ensures the the translations are loaded from the correct PO file

Note » See part 2 of this series for more details on text domains and PO files.

With that code in place, we just need to provide the singular and plural forms for our string when we translate it.

Dealing with More than Two Plural Forms

While WordPress' _n() function seems to only be aware of two forms, singular and plural, some locales have more. Arabic, for example, can have 6 plural forms. Fortunately, the underlying gettext system that _n() uses has a way to define several plural forms. And _n() does actually support more than two forms, even though it appears not to.

For example, after defining the above string with _n() and re-syncing our our template POT file, we may find that it contains the following string definition.

#: index.php:12

#, php-format

msgid "%d post"

msgid_plural "%d posts"

msgstr[0] ""

msgstr[1] ""

Note the msgid_plural and the array-like mgstr[i] syntax here. This is designating this string as a plural in the template file.

When our Arabic PO file is generated based on the POT template, it may have a header that looks like this.

"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100 >= 3 "

"&& n%100<=10 ? 3 : n%100 >= 11 && n%100<=99 ? 4 : 5;\n"

This header defines each of Arabic's 6 plural forms, and a corresponding count integer for each. This kind of header is supplied for us automatically by Loco Translate plugin when we use it for gettext translations.

Note » If all this POT, PO, gettext nonsense looks confusing to you, check out part 2 of this series where we cover this stuff in detail.

In fact, if we're using Loco Translate we can ignore all these syntactic details. Simply refreshing and saving our template and Arabic files yields an easy-to-use interface for supplying the 6 Arabic plural forms.

Loco translate Arabic plural forms menu | Phrase

All we need to do is fill in each of the 6 forms and save the file. WordPress' _n() function will pick up the details from the PO file and present the appropriate form from the several we've provided.

Date & Time Formatting

Displaying the Localized Post Publish Date & Time

Let's add the publish date to our posts in our loop.php file.

   <span class="date">

            <time

                datetime="<?php the_time('Y-m-d'); ?> <?php the_time('H:i'); ?>"

            >

                <?php the_date(); ?>

                <?php the_time(); ?>

            </time>

        </span>

Luckily, using the built-in WordPress functions the_date() and the_time() are enough to display the localized publish date and time for a post within The Loop. We can configure the per-locale format in the Polylang settings under Languages > String translations in the WordPress admin.

String translations menu per format | Phrase

Note » Did you notice our use of the_time() within the <time> tag above? When we pass a format parameter to the_time() it overrides any formats we've set previously in the WordPress admin. And, when used with a format parameter, the_time() can be used to output both the date and the time.

Check out the official documentation for the_date() and the_time(). Also see the Formatting Date and Time entry in the WordPress Codex for details on the available date formats.

Displaying an Arbitrary Localized Date & Time

What if we wanted to display any date and make sure that we can localize it? Well, WordPress provides a handy date_i18n() function that can help with that.

<?php echo date_i18n('l, j F Y g:i A', strtotime('2019/01/15')); ?>

Note » strtotime() returns a Unix timestamp from a date string.

The first parameter we supply to date_i18n() is a date format string. The second parameter is a Unix timestamp. date_i18n() will return the date and/or time (depending on our format) for the given locale if the locale specifies it. We can also use the locale we configured in the Polylang settings above if we want to.

<?php echo date_i18n(get_option('date_format'), 1549368000); ?>

get_option('date_format') will pull our localized date format from our settings, so we can use it to keep our date formats DRY (Don't Repeat Yourself).

Note » Check out the documentation for date_i18n() on the WordPress Codex.

Number Formatting

WordPress provides a function for conveniently formatting numbers based on the current locale.

<?php echo number_format_i18n(2388); ?>

number_format() takes a numeric parameter and a second, optional precision parameter.

<?php echo number_format_i18n('1243.1', 2); ?>

The preceding line will output 1,243.10 depending on the current locale, respecting the fact that we want 2 decimal places in the formatted output.

Note » From my tests, number_format() didn't output Indian numerals when the current locale was Arabic. Arabic formally uses Indian— ١ ٢ ٣ —numerals. However, number_format() was outputting Arabic— 1 2 3 —numerals when the current locale was Arabic. Confused yet? Yeah, me too.

Note » Check out the official Codex documentation on number_format().

Providing Context for Translators

There may be cases where we use the same word or phrase in two different places in our UI, and the word has a different meaning in each place. For example, we may use the word "More" for an infinite scroll button. We may also use "More" for a go-to-details button. In a given locale we may want to use different phrases for those two pieces of text. However, if we simply wrap the word in __(), our gettext system will try to avoid duplication and offer only one instance of the Word to translators.

To help solve this problem gettext gives us an additional context parameter that we can use when registering our translatable strings. WordPress taps into context through its x functions. Let's take a look.

<?php

    // Place A in our UI

    echo _x('Menu', 'Hamburger icon for mobile devices', 'handmadestale');

?>

<?php

    // Place B in our UI

    _ex('Menu', 'Footer menu header', 'handmadestale');

?>

Note » _x() is analogous to __() and _ex() is analogous to _e().

Notice the second parameter in the calls to _x() and _ex() above. This is the context parameter, and it allows the gettext system to provide the two strings separately for translation.

If we sync up our POT and PO files in our Loco Translate plugin, we can see context in action.

Arabic translation footer menu | Phrase

We now have two instances of the word "Menu", each with a descriptive context, and each can be translated separately.

Note » Many WordPress i18n functions have context equivalents. For example, the plural i18n function, _n() has a context equivalent, _nx(). You can find all the WordPress i18n functions listed on the WordPress Codex.

Getting the Current Locale

When building internationalized WordPress sites we sometimes need to fork our code based on the current locale or language. WordPress has us covered there. The get_locale() function will return the current locale code.

<?php echo get_locale(); ?>

The above will display fr-CA when the current locale is French-Canadian.

Note » Check out the get_locale() function in the WordPress Codex.

Getting the Current Language Code with Polylang

The Polylang plugin used for content translation offers some handy functions. One of these is pll_current_language(). This function returns just the language code of the current locale.

<?php echo pll_current_language(); ?>

The above will display fr when the current locale is French-Canadian.

Determining the Current Locale's Layout Direction

Some locales are written left-to-right and others right-to-left. This often has an impact on our layouts. Arabic and Hebrew web pages, for example, may have a sidebar on the left instead of a Latin-centric right sidebar. To check the direction of the current layout, we can use WordPress' is_rtl() function.

<?php echo is_rtl() ? 'right-to-left' : 'left-to-right'; ?>

The function returns a boolean and can come in handy when we're customizing our layouts.

Note » Check out the is_rtl() function in the WordPress Codex.

Writing Locale-Specific CSS

When we used language_attributes() in our header.php above, WordPress was kind enough to output handy language attributes into our <html> tag.

<html <?php language_attributes(); ?>>

The above will render to <html dir="rtl" lang="ar"> when the current locale is Arabic, for example. We can use this to target specific locales and layout directions in our CSS.

h1 {

    font-family: 'Pacifico', cursive;

}

[lang="ar"] h1 {

    font-family: 'Lemonada', cursive;

}

In the above example, we display a font for use with our default locale (English in our case), and another font for Arabic. We rely on the HTML language attribute and CSS attribute selectors for our override. This technique pairs well with conditional, per-locale asset loading, which we'll go over in a moment.

We can similarly target a specific layout direction.

.text-end {

    text-align: right; /* Fallback */

    text-align: end;

}

[dir="rtl"] .text-end {

    text-align: left; /* Fallback */

    text-align: end;

}

Here we're using the standard end value for the text-align CSS property. However, since at the time of writing some browsers don't support end, we use the older right and left values as a fallback.

Conditional, Per-locale Loading of Assets

Let's add this line in our header.php, right before our call to wp_head():

<?php get_template_part('stylesheet-links'); ?>

This is loading in a file that has some conditional logic pertaining to locales.

stylesheet-links.php

<link

    rel="stylesheet"

    href="//cdnjs.cloudflare.com/ajax/libs/pure/1.0.0/pure-min.css"

>

<?php if (!function_exists('pll_current_language')) {

    throw new Exception('Polylang plugin must be installed to determine ' .

        'current language');

} ?>

<?php if (pll_current_language() == 'ar') : ?>

    <link

        rel="stylesheet"

        href="https://fonts.googleapis.com/css?family=Lemonada|Cairo"

    >

<?php else : ?>

    <link

        rel="stylesheet"

        href="https://fonts.googleapis.com/css?family=Merriweather|Pacifico"

    >

<?php endif; ?>

<link

    rel="stylesheet"

    href="<?php echo get_bloginfo('template_url'); ?>/style.css"

>

After loading in our little CSS framework, Pure.css, we check to see if the Polylang pll_current_language() is available and sqwauk if it's not. This lets developers know about our dependencies right away.

We then use the pll_current_language() function to determine the current language and load the appropriate Google Font for it.

Creating a Custom Language Switcher

Sometimes the language switchers that plugins like Polylang provide aren't enough, and we need something a little more customized to our needs. This isn't too difficult to accomplish with WordPress' widget system and the Polylang plugin.

We can create a class to render our Widget.

<?php

class HMT_Language_Switcher_Widget extends WP_Widget

{

    public function __construct()

    {

        parent::__construct(

            'HMT_Language_Switcher_Widget',

            __("Handmade's Tale Language Switcher", 'handmadestale'),

            array(

                'description' => __(

                    "Custom language switcher for the Handmade's Tale website",

                    'handmadestale'

                ),

            )

        );

    }

    public function widget($args, $instance)

    {

        if (!function_exists('pll_the_languages')) {

            throw new Exception(__('The Polylang plugin is needed to show a ' .

                'language switcher.'));

        }

        $languages = pll_the_languages(array('raw' => 1));

        if (count($languages) == 0) {

            _e('No languages to display');

            return;

        }

        // make local variables available to the template

        include(locate_template('language-switcher.php'));

    }

}

The meat of our logic is in the overridden widget() method. Here we use Polylang's pll_the_languages() function with a raw flag to get an array of language names and URLs corresponding to all the translations available for the current page.

Note » See the Polylang function reference for more on pll_the_languages(), which can actually render a language switcher itself.

We pass the returned languages to a language-switcher partial for rendering.

<?php if (!isset($languages)) {

    throw new Exception(__('Expected $languages array for printing'));

} ?>

<ul class="pure-menu-list pure-menu-horizontal text-end">

    <?php foreach ($languages as $language) : ?>

        <li class="pure-menu-item">

            <a class="pure-menu-link" href="<?php echo $language['url']; ?>">

                <?php echo $language['name']; ?>

            </a>

        </li>

    <?php endforeach; ?>

</ul>

We then just need to do the usual registration of our widget in functions.php.

<?php

require __DIR__ . '/classes/HMT_Language_Switcher_Widget.php';

// ...

/**

 * Register our sidebars and widgetized areas.

 */

function hmt_widgets_init()

{

    register_sidebar(array(

        'name' => __('Trailing Main Navigation'),

        'id' => 'trailing_main_nav',

        'before_widget' => '<div>',

        'after_widget' => '</div>',

        'before_title' => '<h2>',

        'after_title' => '</h2>',

    ));

}

add_action('widgets_init', 'hmt_widgets_init');

/**

 * Register our widgets.

 */

function hmt_language_switcher_widget()

{

    register_widget('HMT_Language_Switcher_Widget');

}

add_action('widgets_init', 'hmt_language_switcher_widget');

Finally, we can bring our widget into our theme templates.

<div class="pure-u-1-3">

    <!-- #language-switcher -->

    <?php if (is_active_sidebar('trailing_main_nav')) : ?>

        <div id="trailing_main_nav" class="widget-area">

            <?php dynamic_sidebar('trailing_main_nav'); ?>

        </div>

    <?php endif; ?>

    <!-- /#language-switcher -->

</div>

That's all there is to it.

In Closing

With WordPress, Polylang, and a little custom work, we've been able to put together a nice little website for our clients and learn quite a bit of WordPress i18n and gettext along the way. If you want to see this journey from its beginning, check out part 1 and part 2 of this series.

If you’re writing your own custom themes for WordPress, consider using Phrase to translate them. Phrase works with POT, PO, and MO files out of the box, and provides a pro feature set for i18n developers and translators. Phrase can sync to your Github repo to detect when locale files change. It also provides tools for searching for translation strings and proofreading your translations. Phrase even includes collaboration tools so that you can save time as you work with your translators. Check out Phrase’s full feature set, and try it for free for 14 days. You can sign up for a full subscription or cancel at any time.

WordPress allows us to get websites up very quickly and cost-effectively while empowering our clients to administer them. With the knowledge we've developed in this series we can internationalize our sites and provide them in different languages and locales while producing custom, branded front-ends. I hope you've enjoyed this series, and keep on coding 👩🏽‍💻👨🏼‍💻