Skip to content

Internationalization (i18n)

@akashjs/i18n provides signal-based internationalization. Locale changes are reactive — when you switch languages, every translated string in your UI updates automatically.

Setup

Install the package and create an i18n instance with your messages:

ts
import { createI18n } from '@akashjs/i18n';

const i18n = createI18n({
  defaultLocale: 'en',
  messages: {
    en: {
      greeting: 'Hello, {name}!',
      nav: {
        home: 'Home',
        about: 'About',
        contact: 'Contact',
      },
    },
    es: {
      greeting: '¡Hola, {name}!',
      nav: {
        home: 'Inicio',
        about: 'Acerca de',
        contact: 'Contacto',
      },
    },
  },
});

const { t, te, locale, setLocale, availableLocales } = i18n;

Translation with t()

The t() function looks up a key and returns the translated string.

ts
t('nav.home');  // 'Home'
t('nav.about'); // 'About'

If a key is not found, t() returns the key itself:

ts
t('missing.key'); // 'missing.key'

Interpolation

Use {placeholder} syntax in your messages. Pass values as the second argument:

ts
t('greeting', { name: 'World' });  // 'Hello, World!'
t('greeting', { name: 'Alice' });  // 'Hello, Alice!'

Params can be strings or numbers:

ts
// messages: { items: 'You have {count} items' }
t('items', { count: 5 }); // 'You have 5 items'

HTML Escaping

Interpolation values are HTML-escaped by default to prevent XSS. For example:

ts
t('greeting', { name: '<script>alert(1)</script>' });
// 'Hello, &lt;script&gt;alert(1)&lt;/script&gt;!'

This ensures user-provided input is safe to render in the DOM without additional sanitization.

Nested Messages

Messages can be nested objects. Access them with dot notation:

ts
const i18n = createI18n({
  defaultLocale: 'en',
  messages: {
    en: {
      user: {
        profile: {
          title: 'Profile',
          settings: 'Settings',
        },
      },
    },
  },
});

i18n.t('user.profile.title');    // 'Profile'
i18n.t('user.profile.settings'); // 'Settings'

Nested messages are flattened internally, so { user: { name: 'Name' } } and { 'user.name': 'Name' } are equivalent.

Locale Switching

Switch the active locale with setLocale(). This is async because it may need to load messages first.

ts
import { defineComponent } from '@akashjs/runtime';

const LanguageSwitcher = defineComponent((ctx) => {
  const { t, locale, setLocale, availableLocales } = i18n;

  return () => (
    <div>
      <p>Current: {locale()}</p>
      <p>{t('greeting', { name: 'World' })}</p>
      <button on:click={() => setLocale('en')}>English</button>
      <button on:click={() => setLocale('es')}>Español</button>
    </div>
  );
});

Because locale is a signal, calling setLocale() triggers reactive updates everywhere t() is used in a render function.

Fallback Locale

If a key is missing in the current locale, the fallback locale is checked before returning the raw key:

ts
const i18n = createI18n({
  defaultLocale: 'fr',
  fallbackLocale: 'en',
  messages: {
    en: {
      greeting: 'Hello!',
      farewell: 'Goodbye!',
    },
    fr: {
      greeting: 'Bonjour!',
      // 'farewell' is missing in French
    },
  },
});

i18n.setLocale('fr');
i18n.t('greeting'); // 'Bonjour!'
i18n.t('farewell'); // 'Goodbye!' (falls back to English)

Lazy Loading Messages

For large apps, load translations on demand instead of bundling every locale upfront:

ts
const i18n = createI18n({
  defaultLocale: 'en',
  messages: {
    en: { greeting: 'Hello!' },
  },
  loadMessages: async (locale) => {
    const mod = await import(`./locales/${locale}.json`);
    return mod.default;
  },
});

// When the user switches to Japanese, messages are fetched first
await i18n.setLocale('ja');
i18n.t('greeting'); // Uses the lazy-loaded Japanese message

setLocale() returns a Promise that resolves once the messages are loaded. Messages are cached — switching back to a previously loaded locale does not re-fetch.

Pluralization

Pass a count or n param to use pluralization. Define plural forms as nested keys with one, other, zero, two, few, many:

ts
const i18n = createI18n({
  defaultLocale: 'en',
  messages: {
    en: {
      items: {
        one: 'You have {count} item',
        other: 'You have {count} items',
      },
    },
  },
});

i18n.t('items', { count: 1 }); // 'You have 1 item'
i18n.t('items', { count: 5 }); // 'You have 5 items'
i18n.t('items', { count: 0 }); // 'You have 0 items'

You can also use n as the plural parameter:

ts
// messages: { items: { one: '{n} item', other: '{n} items' } }
i18n.t('items', { n: 3 }); // '3 items'

Pluralization works with fallback locales — if the key is missing in the current locale, the fallback locale's plural forms are used.

Built-in plural rules follow CLDR conventions for English, Spanish, French, and Arabic. You can provide custom rules:

ts
const i18n = createI18n({
  defaultLocale: 'ru',
  pluralRules: {
    ru: (count) => {
      const mod10 = count % 10;
      const mod100 = count % 100;
      if (mod10 === 1 && mod100 !== 11) return 'one';
      if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return 'few';
      return 'many';
    },
  },
  messages: {
    ru: {
      apples: {
        one: '{count} яблоко',
        few: '{count} яблока',
        many: '{count} яблок',
      },
    },
  },
});

Checking Key Existence with te()

Use te() to check if a translation key exists before using it:

ts
if (i18n.te('premium.banner')) {
  // Show the premium banner with translated text
}

// Useful for conditional UI
const HelpLink = defineComponent((ctx) => {
  const { t, te } = i18n;

  return () => (
    <div>
      {te('help.tooltip') ? (
        <span title={t('help.tooltip')}>{t('help.label')}</span>
      ) : (
        <span>{t('help.label')}</span>
      )}
    </div>
  );
});

te() checks both the current locale and the fallback locale.

Released under the MIT License.