Theme
Design your own
On this page (8)

§Load it

The library ships a browser build that imports nothing. Use dist/core.js next to defuss-shadcn: it takes the df$ you hand it (createDomI18n(df$)) and leaves the global alone - dist/all.min.js would install itself as df$.i18n, and in this system only core.js extends df$. With a bundler, install the package and import the same names.

<script type="module">
  import { createDomI18n, createI18n } from 'https://cdn.jsdelivr.net/npm/defuss-i18n@0.2.0/dist/core.js';
  const locale = createI18n({ locale: 'en', fallback: ['en'] });
  const { bind, mount } = createDomI18n(df$); // the components' own runtime does the DOM work
  mount(document, locale);                     // every [data-i18n-component] on the page
  locale.setLocale('de');                      // synchronous: German when this returns
</script>
// bun add defuss-i18n
import { createDomI18n, createI18n } from 'defuss-i18n/core';
const locale = createI18n({ locale: 'en' });
const { bind } = createDomI18n(df$);

§The markup is the catalog

A component root carries data-i18n-component. Inside it, data-i18n-target="name" marks a region and <template data-i18n-for="name" data-i18n-locale="de"> holds that region in one language - beside the region, never inside it. The live markup is the default language, so the page reads right before any script runs. Attributes outside a region get variants: data-i18n-aria-label-de, data-i18n-placeholder-de, data-i18n-title-de (16 attributes in all). The example card below switches between English, German and Arabic - Arabic also turns the card right to left, which the components follow through their logical properties.

§A workshop card in three languages

One component root, three regions, one attribute variant. The date, the price and the seat count are values formatted for the language; the count picks the plural - Arabic has six forms. Reserve counts down.

what the card uses
Markup / callWhat it does
data-i18n-componentThe root: everything inside belongs to this component (a nested root starts another one). bind() writes lang and dir on it
data-i18n-target="title"A region: on a switch its content becomes the matching template. Keep regions to text and simple inline markup
<template data-i18n-for data-i18n-locale>One language of a region. Inert: the browser renders nothing of it and runs nothing in it
data-i18n-aria-label-de="Sprache"An attribute variant: the attribute takes the value of the current language
data-i18n-value="date"A slot for a value, inserted as plain text - never parsed as HTML
data-i18n-count="seats" + data-i18n-pluralThe value seats (a number) picks the template for Intl.PluralRules' category; other is required per language
bind(root, locale, { getState, values })Binds one root; values() runs on every render with the state getState() reads
binding.refresh()Renders again after the state changed - same language
locale.setLocale(tag)Switches every bound root, synchronously

§Numbers, dates and plurals

The controller formats in its current language: formatNumber and formatDate are Intl.NumberFormat and Intl.DateTimeFormat, plural(n) is Intl.PluralRules. Pass them as values and the region shows them; a count stays a number (it chooses the plural) and its formatted twin is a second value. The Admin Dashboard does exactly this for its order and customer counts - 9,842 in English, 9.842 in German - and for the bulk bar, whose "1 order selected" / "1,284 orders selected" is a plural over the data grid's selection.

let selected = 0;
const texts = bind(app, locale, {
  getState: () => selected,
  values: (n) => ({ selected: n, selectedLabel: locale.formatNumber(n) }),
});
grid.store.subscribe(({ config }) => {
  selected = config.selected.length;
  texts.refresh(); // the plural and the number follow
});

§Translating shipped components

A component's script owns its markup's state - a tab's aria-selected, a link's aria-current, a dialog's open. A region is replaced by its template on every switch, so it must not hold that state. The scaffolds follow four rules:

Catalogs grow with the app, so each scaffold keeps its templates in one block at the end of its root - one line per region and language, like a translation file. The library's validator finds what a catalog misses; the e2e tests run it over every scaffold:

import { validateI18n } from 'https://cdn.jsdelivr.net/npm/defuss-i18n@0.2.0/dist/core.js';
for (const issue of validateI18n(app, { locales: ['en', 'de'] }))
  console.warn(issue.code, issue.message, issue.element); // e.g. MISSING_LOCALE: Target nav-orders lacks de

§Choosing the language

Start in the visitor's language: resolveLocale matches navigator.languages against the languages you ship, walking parent tags (de-CH finds de) without guessing siblings. A Select (or a Settings row, as in the Messenger and the Desktop) switches; subscribe keeps every switch in step and is the place to keep the choice - a df$.store.persisted store - or to set <html lang dir> when the app is the whole page.

import { createI18n, resolveLocale } from 'https://cdn.jsdelivr.net/npm/defuss-i18n@0.2.0/dist/core.js';
const kept = df$.store.persisted('app:language', '');
const preferred = navigator.languages.map((tag) => resolveLocale(['en', 'de'], tag, [])).find(Boolean);
const locale = createI18n({ locale: kept.value || preferred || 'en' });
const select = df$('[data-app-locale]');
select.val(locale.locale).on('change', () => locale.setLocale(select.val()));
locale.subscribe(({ locale: tag, direction }) => {
  kept.set(tag);
  select.val(tag);
  document.documentElement.lang = tag;
  document.documentElement.dir = direction;
});

§Loading languages on demand

Ship the default language in the page and fetch the others when they are picked. loadLocale(tag, loader, commit) lets only the newest request commit: the loader fetches and parses without touching the page, the synchronous commit appends the templates and calls rescan(), then the language is published. Append parsed nodes, not markup strings - a template written through df$().html() loses its content.

const loaded = new Set(['en']);
async function switchTo(tag) {
  if (loaded.has(tag)) return locale.setLocale(tag);
  const result = await locale.loadLocale(
    tag,
    async (canonical, signal) => {
      const html = await (await fetch(`/i18n/${canonical}/app.html`, { signal })).text();
      return new DOMParser().parseFromString(`<body>${html}`, 'text/html').body.children;
    },
    (templates) => {
      app.append(...templates); // beside the regions
      texts.rescan();           // adopt them before the language is published
      loaded.add(tag);
    },
  );
  return result.status; // 'superseded' when another language was picked meanwhile
}

§Where it is used

Every Application Scaffold and every Website template binds its root once and switches between English and German; the routes of each are in Client-side Routing. The templates show the values: the Shop formats every price as a currency (€88.90, 88,90 €), the Comments say how long ago with Intl.RelativeTimeFormat (2 hours ago, vor 2 Stunden), the News date its masthead with formatDate, the Search counts its results.

the scaffolds' translations
ScaffoldThe switchWhat it translates
Admin DashboardTop barNavigation, views, palette; counts formatted, the selection a plural
MessengerSettings › App languageRail, list, panels, info column, every contact label
Issue TrackerSidebar footNavigation, view heads, palette, create dialog
Notes & DocsSidebar footSidebar, workspace menu, palette, trash
Status PageHeaderHeader, section heads, legend, history, console tabs
DesktopSettings › LanguageStart menu, quick settings, context menus, Settings, window controls
Document EditorBeside ShareDocument list, menus, toolbar, dialogs

Comments, ideas or improvements? Edit this page's source on GitHub