Simple Website Engine

Simple Yet Powerful Website Generator

i18n

Overview

SWSE can serve the same website in several languages. The language of a request is decided by the URL, the translated content is picked up from the filesystem, and the strings which are not part of the content come from a dictionary file.

There are two mechanisms and they are used for different things:

  • Localised files - views/de/about.html shadows views/about.html. Use this for page content and prose. A missing file falls back to the default language automatically.
  • Dictionary - lang/de.php returns ['Read more' => 'Weiterlesen']. Use this for the chrome around the content: buttons, labels, form errors, flash messages.

Rule of thumb: a string which appears on three or more pages belongs in the dictionary, page specific prose belongs in a localised file.

Translations are completely optional. When LOCALES is not set in the .env file, every function described on this page does nothing and the engine behaves exactly as it did before translations existed. Adding translations to an existing website never moves the URLs which are already published.

Configuration

Translations are switched on from the .env file in the project root:

# .env file
LOCALES=en,de,fr
LOCALE_REDIRECT=true

LOCALES

A comma separated list of the languages the website is served in. The first one is the default language, which means it is the language the untranslated source files are written in.

Only codes like en or pt-BR are accepted. Anything else is a configuration mistake, it is skipped with a warning and it never reaches the filesystem.

LOCALE_REDIRECT

Optional. When it is set to true, a visitor landing on the bare root of the website is redirected to the language from their browser's Accept-Language header.

Only the root is negotiated this way. A real URL such as /about always serves the same content to everybody, because serving different content from the same address breaks caching and confuses crawlers.

The redirect is a temporary one, and a language the visitor picked themselves is remembered for the session and wins over the browser header from that point on.

BASE_URL

Optional, but recommended when the <!--hreflang--> directive is used. It keeps the generated alternate links absolute, which is what search engines expect. Without it the engine falls back to the host of the current request.

# .env file
BASE_URL=https://example.com

URL Prefixes

The first segment of the URL is the single source of truth for the language being served. When it matches one of the configured locales it is taken as the language and removed from the path, and what is left is routed exactly as it would be on a website without translations.

LOCALES=en,de,fr

/about        -> views/about.html          // default language, no prefix
/de/about     -> views/de/about.html       // German
/fr/about     -> views/fr/about.html       // French
/de/          -> views/de/index.html
/contact      -> actions/contact.php       // actions are never prefixed

The Default Language Is Never Prefixed

With LOCALES=en,de,fr the English pages stay on /about and never move to /en/about. This is what makes it safe to add translations to a website which is already published, no existing address changes.

Because a page must have exactly one address, a request to the prefixed form of the default language is permanently redirected to the unprefixed one:

/en/about  -> 301 -> /about

Unknown Prefixes Are Not Prefixes

A first segment which is not in LOCALES is simply part of the path. With the configuration above, /es/about is not Spanish, it is a request for views/es/about.html in the default language, and it ends in the 404 page when that file does not exist.

Remembering The Choice

Whenever a prefixed URL is served, the language is stored in the session. A visitor who followed a link to /de/about is treated as a German speaker afterwards, which is what makes LOCALE_REDIRECT send them to the German homepage on their next visit to / rather than back to the browser preference.

Localised Files

A translated page is a file placed in a directory named after the locale, directly below the directory it belongs to. It shadows the file with the same name in the default language:

project-root/
├── views/
│   ├── index.html          // default language
│   ├── about.html          // default language
│   ├── de/
│   │   ├── index.html      // German homepage
│   │   └── about.html      // German about page
│   └── fr/
│       └── index.html      // French homepage
└── layouts/
    ├── default.html
    └── de/
        └── default.html    // German layout

Localisation happens directly below a views/ or a layouts/ directory, in the project or in a plugin. Everything else, including the actions/ directory, is never localised.

Fallback

When the translated file does not exist, the default language file is served instead. In the example above /fr/about renders the French layout if there is one and the English views/about.html as content, because views/fr/about.html was never written.

This means a website can be translated one page at a time. Nothing breaks while the translation is incomplete, the untranslated pages simply keep showing the default language.

Includes Inside A Translated File

Includes are written the same way in a translated file as they are in the original, without the locale in the path:

<!--layout:/default.html ["title" => "Über uns"]-->
<!--include:sections/team.html-->

Each included file is resolved for the current language on its own, so views/de/sections/team.html is used when it exists and views/sections/team.html when it does not. The same applies to the layout, which is why the include above does not have to mention de anywhere.

404 Pages

The not found page is localised as well. Create views/de/404.html to show a German 404 page for the German part of the website.

Dictionaries

A dictionary is a PHP file in the lang/ directory of the project, named after the locale, which returns an array:

<?php
// lang/de.php
return [
    'Read more' => 'Weiterlesen',
    'Send message' => 'Nachricht senden',
    'Showing {count} results' => '{count} Ergebnisse',
];
project-root/
├── lang/
│   ├── de.php
│   └── fr.php
└── views/

The Source Text Is The Key

The key is the string itself, not an invented name like button.read_more. A string which has no translation yet renders as it was written instead of leaking a key onto the page, and the templates stay readable for somebody who does not know which languages exist.

For the same reason the default language usually needs no dictionary file at all. The strings in the templates already are the default language.

Placeholders

Values are injected with {name} placeholders, which lets a translation put them in a different position than the original:

'Showing {count} results' => '{count} Ergebnisse gefunden',

Substituted values are HTML escaped, so it is safe to pass user input into a placeholder.

Markup In Translations

The translated string itself is emitted as it was authored, which means a dictionary entry may legitimately contain HTML:

'Read our <a href="/terms">terms</a>' => 'Lesen Sie unsere <a href="/terms">Bedingungen</a>',

Dictionaries are project files, written by the same people who write the templates. Never build one from user submitted content.

Plugin Dictionaries

A plugin carries its own dictionary in plugin-name/lang/de.php. When a plugin is serving the request, its dictionary is loaded first and the project dictionary is merged on top of it, so the project can override any individual string of a plugin without touching the plugin itself.

project-root/
├── lang/
│   └── de.php              // wins
└── blog/
    └── lang/
        └── de.php          // plugin defaults

Template Directives

Three directives are available in the templates. Like every other directive in SWSE they are written as HTML comments, so a template with translations in it is still a valid HTML file.

Translate a string

Use the t: prefix to look a string up in the dictionary of the current language:

<a href="/blog"><!--t:Read more--></a>
<button><!--t:Send message--></button>

Values are passed the same way as they are passed to an include, in square brackets:

<!--t:Showing {count} results ['count'=>42]-->

The text may not span multiple lines. When the string is missing from the dictionary, or when no locales are configured at all, the text is rendered as it was written.

Current language code

Use <!--locale--> to output the language of the current request, which is what the lang attribute of the document needs:

<html lang="<!--locale-->">

Keep in mind that this outputs an empty string when LOCALES is not configured, so on a single language website write the language code in the layout instead of using this directive.

Alternate language links

Use <!--hreflang--> in the <head> of the layout to tell search engines about the other languages of the page which is being served:

<head>
    <title><!--$title--></title>
    <!--hreflang-->
</head>

On /de/about with LOCALES=en,de,fr it renders:

<link rel="alternate" hreflang="en" href="https://example.com/about">
<link rel="alternate" hreflang="de" href="https://example.com/de/about">
<link rel="alternate" hreflang="fr" href="https://example.com/fr/about">
<link rel="alternate" hreflang="x-default" href="https://example.com/about">

The addresses are built from BASE_URL when it is set and from the host of the request when it is not. The directive renders nothing when fewer than two locales are configured, so it can safely stay in a layout which is also used by a single language website.

Translations In Actions

Actions are code, and code is not translated. There is no actions/de/ directory, the same action runs for every language. What an action can do is translate the strings it produces, with the __() helper:

__($text, $vars = [])
<?php
// actions/contact.php
class Contact {
    public function post() {
        verifyCsrf();

        if (getPost('email') === '') {
            setFlash('error', __('Please enter your email address'));
            redirect('/contact');
        }

        setFlash('success', __('Thanks {name}, we will be in touch', ['name' => getPost('name')]));
        redirect(localeUrl('contact', currentLocale()));
    }
}

It looks the text up in the dictionary of the current language, exactly like <!--t:...--> does in a template, and returns the text unchanged when there is no translation for it.

currentLocale()
$locale = currentLocale();      // "de" on /de/about, the default language elsewhere

Useful when the action has to pick something itself, a date format or a currency for example, or when it loads content from a database column which depends on the language.

localeUrl($route, $locale)
localeUrl('about', 'de');       // "/de/about"
localeUrl('about', 'en');       // "/about"    - the default language has no prefix
localeUrl('index', 'de');       // "/de/"

Builds the address of a route in a given language. Use it whenever an action redirects, so the visitor stays in the language they were browsing in.

availableLocales() and defaultLocale()
availableLocales();             // ["en", "de", "fr"], empty when translations are off
defaultLocale();                // "en"

The route which is being served, without its language prefix, is available as $_ENV['ROUTE'] in an action and as <!--e("ROUTE")--> in a template.

A Language Switcher

The engine does not render a language switcher, because where it goes and what it looks like is a decision of the website. Building one is a few lines: ask the engine which languages exist and where the current page lives in each of them.

Prepare the links in an action, or in a common parent class so that every page has them:

<?php
// actions/about.php
class About {
    public function get() {
        $languages = [];

        foreach (availableLocales() as $locale) {
            $languages[] = [
                'code' => $locale,
                'url' => localeUrl($_ENV['ROUTE'], $locale),
                'current' => $locale === currentLocale() ? 'current' : '',
            ];
        }

        return ['languages' => $languages];
    }
}

Then render them in the template:

<ul class="languages">
<!--foreach($languages as $language)-->
    <li class="<!--$language[current]-->">
        <a href="<!--$language[url]-->"><!--$language[code]--></a>
    </li>
<!--endforeach-->
</ul>

Because the links point at the same route in another language, the visitor stays on the page they were reading instead of being sent back to the homepage. Following one of them also stores the choice in the session, so LOCALE_REDIRECT respects it on the next visit.

A page which has no action of its own can still build the same list, as long as something in the request prepares it. Adding it to a shared parent class of the actions is the usual way to keep it in one place.

Adding A Language, Step By Step

Adding German to a website which is currently English only:

  1. Add LOCALES=en,de to the .env file. English is listed first, so it stays the default and all its URLs stay where they are.
  2. Create views/de/ and translate the pages into it, starting with views/de/index.html. Pages which are not there yet keep serving English.
  3. Create lang/de.php and move the strings which repeat across the website into it, then replace them in the templates with <!--t:...--> and in the actions with __().
  4. Put <!--locale--> in the lang attribute of the layout and <!--hreflang--> in its <head>, and set BASE_URL so the alternate links are absolute.
  5. Add a language switcher, and optionally LOCALE_REDIRECT=true so first time visitors land in their own language.

Removing a language is the reverse and is just as safe: take it out of LOCALES and its prefix stops being recognised, at which point the files under that locale directory are simply never read.