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.htmlshadowsviews/about.html. Use this for page content and prose. A missing file falls back to the default language automatically. -
Dictionary -
lang/de.phpreturns['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:
-
Add
LOCALES=en,deto the.envfile. English is listed first, so it stays the default and all its URLs stay where they are. -
Create
views/de/and translate the pages into it, starting withviews/de/index.html. Pages which are not there yet keep serving English. -
Create
lang/de.phpand move the strings which repeat across the website into it, then replace them in the templates with<!--t:...-->and in the actions with__(). -
Put
<!--locale-->in thelangattribute of the layout and<!--hreflang-->in its<head>, and setBASE_URLso the alternate links are absolute. -
Add a language switcher, and optionally
LOCALE_REDIRECT=trueso 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.