Guide 02 · Developer docs

Template languages: Smarty and Twig

Core dna renders server-side sites with Smarty or with Twig, one engine per site, and treats the two as peers. This guide is the one place both are documented: what they share, each engine's language and the platform's additions and limits, the two side by side, and translation.

L1–L2
Unit 05 · Translation and internationalisation

Translated content and the language switcher

Content is translated as linked records, not through translation files, and it works identically in Smarty and Twig. How a translated record relates to its source, getOtherLanguageVersions() for hreflang links, a language switcher that finds the equivalent record, and why URLs carry no language of their own.

Who this is for. The developer building a multi-language site's page templates and header, on either engine. Afterwards you can render a translated record, link its versions to each other for search engines, and give visitors a switcher that keeps them on the same page.

The model: a translation is a record

A translated page, post or question is a separate record in its own language, at its own URL, with its own layout and content. Rendering it needs nothing special: it is a normal record, shown by its own layout's template like any other (Which template renders a record). What a template adds is the links between the versions.

  • Each record has a language. New records take the site's Default content language (Globals.defaultLanguage); the Supported content languages setting lists the others editors may choose.
  • Records are linked to a source. With Enable localization switched on in the site's Internationalization settings, an editor can mark a record as the translation of another, its source. The source and its translations form one group.
  • Which records can be linked: pages, blogs and blog posts, book pages, and FAQ pages and questions. A product has one description per language rather than linked records, and custom entity records carry a language without linked versions.

None of this involves the engine. The same records, the same methods and the same links reach a Smarty template and a Twig template.

All the versions of a record

getOtherLanguageVersions() returns every record in the group, the current one included, keyed by language code (the language's locale id, such as en-us or es-es), plus x-default for the version in the site's default content language. That is exactly the shape hreflang links need:

Template code templates/includes/frontend/hreflang.html
{foreach $page->getOtherLanguageVersions() as $code => $version}
  <link rel="alternate" hreflang="{$code}" href="{$_request->getSchemeAndHost()}{$version->getFullPath()}">
{/foreach}
{% for code, version in page.getOtherLanguageVersions() %}
  <link rel="alternate" hreflang="{{ code }}" href="{{ _request.getSchemeAndHost() }}{{ version.getFullPath() }}">
{% endfor %}

A record with no translations returns only itself, under its own code and, when it is in the default content language, under x-default as well. That is harmless in hreflang but not worth printing; the switcher below counts the languages other than x-default before showing anything. The site's /sitemap.xml already lists the same alternates for pages and book pages, so the head links and the sitemap agree.

A switcher that keeps the visitor on the same page

Link each language to the equivalent record, never to that language's home page:

Template code templates/includes/frontend/language-switcher.html
{$current = $page->getLanguage()}
{$languages = array_diff_key($page->getOtherLanguageVersions(), ['x-default' => true])}
{if $languages|count > 1}
  <ul class="language-switcher">
    {foreach $languages as $code => $version}
      {$language = $version->getLanguage()}
      <li>
        <a href="{$version->getFullPath()}" hreflang="{$code}" lang="{$language->getCode()}"
           data-lang="{$language->getCode()}"{if $current && $code == $current->getLcid()} aria-current="true"{/if}>
          {$language->getName()|escape}
        </a>
      </li>
    {/foreach}
  </ul>
{/if}
{% set current = page.getLanguage() %}
{% set languages = page.getOtherLanguageVersions()|filter((version, code) => code != 'x-default') %}
{% if languages|length > 1 %}
  <ul class="language-switcher">
    {% for code, version in languages %}
      {% set language = version.getLanguage() %}
      <li>
        <a href="{{ version.getFullPath() }}" hreflang="{{ code }}" lang="{{ language.getCode() }}"
           data-lang="{{ language.getCode() }}"{% if current and code == current.getLcid() %} aria-current="true"{% endif %}>
          {{ language.getName()|e }}
        </a>
      </li>
    {% endfor %}
  </ul>
{% endif %}

A language has a name (getName(), such as “English - United States”), a two-letter code (getCode(), en) and a locale id (getLcid(), en-us). The keys of getOtherLanguageVersions() are locale ids; the interface-language cookie takes the two-letter code. Show the names in their own language, or keep a short label per code in your translation files, rather than printing the admin's English names to Spanish visitors.

When the current page has no version in a language, that language is missing from the switcher. Say so where it matters (“This page is not available in Español”) rather than sending the visitor to a home page they did not ask for.

Moving the interface strings with the content

Following a switcher link changes the content, because the visitor lands on the other record. It does not change the interface strings, which follow the visitor's interface language (Which interface language a visitor gets). To move both, set the cdna_locale cookie to the two-letter code when the visitor picks a language; the platform reads the cookie but never sets it:

Template code
<script nonce="{$_inlineScriptNonce}">
  document.addEventListener('click', function (event) {
    var link = event.target.closest('.language-switcher a[data-lang]');
    if (link) {
      document.cookie = 'cdna_locale=' + link.dataset.lang + '; path=/; max-age=31536000; SameSite=Lax';
    }
  });
</script>
<script nonce="{{ _inlineScriptNonce }}">
  document.addEventListener('click', function (event) {
    var link = event.target.closest('.language-switcher a[data-lang]');
    if (link) {
      document.cookie = 'cdna_locale=' + link.dataset.lang + '; path=/; max-age=31536000; SameSite=Lax';
    }
  });
</script>
  • Use SameSite=Lax, not Strict. A visitor returning from an external page, such as a payment provider, would otherwise arrive without the cookie and see the default language.
  • Expect fewer cached pages. A page rendered for a cdna_locale that differs from the default is not stored in the shared page cache, so visitors on another language are served uncached pages. Keep those templates light.
  • The cookie only changes interface strings. It never redirects anyone to another record; the switcher's links do that.

URLs carry no language of their own

There is no language routing: a URL does not select a language, and the platform adds no language prefix. /es/organizaciones is Spanish because the Spanish page is called that and sits under a page called es that editors created. Translated pages usually have translated slugs too, so building a URL by putting /es in front of the English path finds nothing. Always link to a version through getOtherLanguageVersions() or the version's own getFullPath(), and keep the tree for each language in a parent page of its own if you want the language visible in the path.

Go deeper

Was this page helpful?

Your answer goes to the docs team.