Stone.jsDocs
Paradigm

Extensions

Internationalization (i18n)

One i18n layer for the backend and the frontend. Drop your catalogs in any i18n directory, 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

Then add one decorator. That is the whole setup: it registers the service provider (so constructor ({ i18n }) injects it anywhere), installs the middleware that resolves the request locale, and lets the build discover your catalogs on its own. Everything below is optional.

app/Application.tsts
import { I18n } from '@stone-js/i18n'
import { StoneApp } from '@stone-js/core'

@I18n({ locales: ['en', 'fr'], fallbackLocale: 'en' })
@StoneApp({ name: 'my-app' })
export class Application {}

The imperative equivalent hands the blueprint to defineStoneApp, exactly where the decorator form lists it.

app/Application.tsts
import { defineStoneApp } from '@stone-js/core'
import { i18nBlueprint } from '@stone-js/i18n'

export const Application = defineStoneApp(handler, { name: 'my-app' }, [i18nBlueprint])

#Zero-config layout

A catalog is any directory named i18n, holding <locale>/<namespace>.json files. The locale and namespace are read from the path, so no manifest is needed. The simplest project keeps one catalog.

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" }

#Catalogs at any depth

Catalogs are found anywhere under app, so a larger codebase can keep translations next to the code that uses them instead of in one growing directory.

projecttext
app/
  i18n/                        <- shared across the app
    en/common.json
    fr/common.json
  modules/
    billing/
      BillingService.ts
      i18n/                    <- owned by the billing module
        en/invoice.json
        fr/invoice.json
    crm/contacts/i18n/fr/contact.json

Every catalog contributes, and catalogs sharing a locale and a namespace merge deeply: several modules can each add their own keys to a shared common namespace. On a conflicting key the deeper catalog wins, so the result is identical on every machine and every build. node_modules and dotted directories are never scanned, because a dependency's translations are not yours.

#Loading catalogs

The recommended path is the CLI plugin: at build time it walks app for every i18n directory 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 defineBuilderConfig({ 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 defineBuilderConfig({ plugins: [i18nCliPlugin({ lazy: false })] })

#When the convention does not fit

Four options, from the least to the most explicit. A conventional project needs none of them.

OptionTypeDescription
root'app'The directory walked for catalogs.
dirname'i18n'The directory name that marks a catalog, for example 'locales'.
dirstringScan exactly this one directory, no walk, for translations kept outside root.
patternglobTake the files from a glob instead of the walk, when nothing above fits.
stone.config.mjsts
// Catalogs named `locales/` instead of `i18n/`, anywhere under `src`
export default defineBuilderConfig({ plugins: [i18nCliPlugin({ root: 'src', dirname: 'locales' })] })

// Full control, for a layout no convention describes
export default defineBuilderConfig({ plugins: [i18nCliPlugin({ pattern: 'packages/*/translations/*/*.json' })] })

Whatever a pattern matches must still end in <locale>/<namespace>.<ext>: that tail is how the runtime knows which locale and namespace a file carries.

#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 { defineConfig } from '@stone-js/core'
import { loadTranslations } from '@stone-js/i18n'

export const AppConfig = defineConfig((blueprint) => {
  blueprint.set('stone.i18n.locales', ['en', 'fr'])
  blueprint.set(
    'stone.i18n.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((blueprint) => blueprint.set('stone.i18n', {
  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)