Stone.jsDocs
Paradigm

Extensions

Internationalization (i18n)

One i18n layer for the backend and the frontend. Drop your catalogs in app/i18n, and the request locale is resolved and scoped for you: on the server every request gets its own translator, concurrency-safe; in the browser you switch the active locale in one call. Translation runs on i18next; numbers, dates and lists use the native Intl APIs.

#Install

terminalbash
npm i @stone-js/i18n

Register the opt-in i18nBlueprint in your app, then configure throughstone.i18n or defineI18n. Everything below is optional.

#Zero-config layout

Lay out catalogs as app/i18n/<locale>/<namespace>.json. The locale and namespace are read from the path, so no manifest is needed.

projecttext
app/i18n/
  en/
    common.json     { "hello": "Hello {{name}}!" }
    cart.json       { "items_one": "{{count}} item", "items_other": "{{count}} items" }
  fr/
    common.json     { "hello": "Bonjour {{name}} !" }
    cart.json       { "items_one": "{{count}} article", "items_other": "{{count}} articles" }

#Loading catalogs

The recommended path is the CLI plugin: it scans app/i18n at build time and generates the wiring with plain imports, so the same setup works on a backend service (Rollup), a browser SPA and SSR (Vite) alike. No wiring line in your code.

stone.config.mjsts
import { i18nCliPlugin } from '@stone-js/i18n/cli'

// stone.config.mjs
export default defineConfig({ plugins: [i18nCliPlugin()] })

Because @stone-js/i18n is first-party, it is also auto-discovered from your direct dependencies (announced on every build). Opt out with autoDiscoverPlugins: false. See Participate in the build for how the plugin system works.

#Lazy by default, no FOUC

Catalogs are lazy by default: only the active locale's catalog is imported, code-split per file, for a lighter payload. The locale is resolved in a kernel middleware that runs before your handler, and it awaits the catalog import, so the first render already has its translations: there is no flash of untranslated keys and no layout shift. Pass lazy: false to bundle every locale eagerly instead.

stone.config.mjsts
// Lazy is the default. To bundle every locale eagerly instead:
export default defineConfig({ plugins: [i18nCliPlugin({ lazy: false })] })

#By hand

Prefer to wire it yourself? Set stone.i18n.resources. On Vite targets (SPA, SSR),import.meta.glob autoloads them, isomorphic and tree-shaking. For a plain backend service, prefer the plugin: it emits static imports rather than import.meta.glob, which only Vite understands.

app/AppConfig.tsts
import { defineI18n, loadTranslations } from '@stone-js/i18n'

export const AppConfig = defineConfig(defineI18n({
  locales: ['en', 'fr'],
  resources: loadTranslations(import.meta.glob('/app/i18n/**/*.{json,ts,js,yaml,yml}', { eager: true }))
}))

#Per-request locale

The locale is resolved automatically, in order (first match wins), each candidate negotiated against locales (fr-CA becomes fr):

OrderTypeDescription
1. resolvercustomA custom resolver function, if you provide one.
2. route param:langA path-based locale when `param` is set and the router is available (isomorphic).
3. headersx-localeCustom headers x-locale, then x-lang, then x-language.
4. query?lang=The lang query parameter.
5. cookielocaleThe locale cookie.
6. Accept-LanguageheaderThe standard Accept-Language header.
7. fallbackdefaultThe event's own locale, then fallbackLocale.

#Translate and format

On the server, read the request-bound translator from the event with translatorFor(never mutates shared state, so it is safe under concurrency). Or inject the service (constructor ({ i18n })) and bind a locale with i18n.forLocale(locale).

app/GreetController.tsts
import { translatorFor } from '@stone-js/i18n'
import { EventHandler, Get } from '@stone-js/router'

@EventHandler('/greet')
export class GreetController {
  @Get('/')
  greet (event) {
    const t = translatorFor(event)              // bound to the request locale, concurrency-safe
    return {
      message: t.t('hello', { name: 'Ada' }),   // "Bonjour Ada !" for a fr request
      items: t.t('items', { ns: 'cart', count: 3 }), // ICU pluralization
      price: t.currency(19.9, 'EUR'),           // "19,90 €"
      reach: t.compact(1_500_000)               // "1,5 M"
    }
  }
}

Formatting is native Intl, locale-aware:

HelperTypeDescription
t(key, opts)stringInterpolation, ICU pluralization (count), namespaces, per-call locale.
number / compactstring1500000 becomes "1,500,000" or the compact "1.5M".
currency / percentstringcurrency(19.9, "EUR") and percent(0.25).
date / relativeTimestringTime-zone aware dates and "in 3 days".
liststring"a, b and c" for the active locale.
dir(locale?)ltr | rtlWriting direction, for the <html dir> attribute.

#On the frontend

The same API runs in the browser. Switch the active locale in one call, and set the document direction from dir(). For React components, i18n.raw exposes the underlying i18next instance, so you can wire react-i18next directly if you want.

app/LocaleSwitcher.tsxts
// A locale switcher in the browser
await i18n.setLocale('fr')                      // re-renders in French
document.documentElement.dir = i18n.dir()       // 'ltr' | 'rtl' for the <html> element

#Configure

Everything is optional, under stone.i18n.*:

app/AppConfig.tsts
export const AppConfig = defineConfig(defineI18n({
  locale: 'en',                    // active locale
  locales: ['en', 'fr', 'ar'],     // negotiated set (fr-CA -> fr)
  fallbackLocale: 'en',            // used for missing keys
  defaultNamespace: 'translation',
  timeZone: 'America/New_York',    // default for date formatting, per-call overridable
  param: 'lang',                   // resolve the locale from a :lang route param
  onMissingKey: (key, locale, ns) => console.warn('missing', locale, ns, key)
}))

Stone.js

Your app exists in every runtime. Until you run it.

An open-source project by Stone Foundation
Created by Mr. Stone (Evens Pierre)