@c9up/rosetta
v0.3.0
Published
Rosetta — dedicated i18n module for the Ream ecosystem
Readme
@c9up/rosetta
Internationalization for Ream applications with an API aligned with
@adonisjs/i18n. Rosetta has no runtime npm dependency: message parsing,
content negotiation and YAML catalog loading are implemented in the package,
while locale data and formatting come from Node's built-in Intl APIs.
Rendering runs on Intl, so CLDR plural rules and number/date formatting track
the runtime instead of a table maintained here. Parsing is pure TypeScript:
each ICU message is parsed to an AST once per process and cached. The package
is platform-independent — no native binary, no build toolchain, and the same
behaviour on glibc, musl/Alpine and in the browser.
Requirements
- Node.js 22 or newer
- JSON, YAML or YML translation catalogs
Configuration
Register the provider and use the same config/i18n.ts shape as AdonisJS.
// reamrc.ts
export default {
providers: [() => import('@c9up/rosetta/i18n_provider')],
}// config/i18n.ts
import { defineConfig, formatters, loaders } from '@c9up/rosetta'
export default defineConfig({
defaultLocale: 'en',
formatter: formatters.icu(),
fallbackLocales: {
'fr-CH': 'fr',
},
loaders: [
loaders.fs({
location: new URL('../resources/lang/', import.meta.url),
}),
],
})The configure export creates this config, a locale-detection middleware and
registers the provider when used by Ream's package configurator.
Translations
Catalogs follow the Adonis namespace layout.
resources/lang/
en/messages.json
fr/messages.yaml{
"greeting": "Hello {name}",
"items": "{count, plural, one {# item} other {# items}}"
}import i18nManager from '@c9up/rosetta/services/main'
const i18n = i18nManager.locale('en')
i18n.t('messages.greeting', { name: 'John' })
i18n.t('messages.missing', {}, 'Fallback message')Supported ICU constructs include interpolation, select, plural,
selectordinal, number skeletons, date/time styles, nested messages, offsets
and ICU apostrophe escaping. Plural rules and localized number/date output use
the runtime CLDR data through Intl.
Formatting
Every Intl-backed helper is available both on a locale-scoped i18n instance
and on a standalone Formatter, which is the same class — i18n extends it.
import { Formatter } from '@c9up/rosetta'
const formatter = new Formatter('en-US')
formatter.formatNumber(1234567.89) // '1,234,567.89'
formatter.formatCurrency(1234.56, { currency: 'USD' }) // '$1,234.56'
formatter.formatRelativeTime(-1, 'day') // '1 day ago'
formatter.formatList(['John', 'Jane', 'Bob']) // 'John, Jane, and Bob'
formatter.switchLocale('fr')
formatter.formatNumber(1234.56) // '1 234,56'formatNumber, formatCurrency, formatDate, formatTime,
formatRelativeTime (including 'auto'), formatPlural, formatList and
formatDisplayNames all read the locale from the instance.
HTTP, Inker And Validation
@c9up/rosetta/middleware detects the best supported locale from
request.languages() or Accept-Language, assigns a request-scoped ctx.i18n
instance and shares it with the view layer. Its static
registerMessagesProvider method connects a compatible request validator to
i18n.createMessagesProvider().
For Ream templates, @c9up/inker is the Edge-equivalent integration. Its
provider resolves Rosetta through the rosetta container alias and exposes the
canonical t() helper; the middleware writes the negotiated language to
ctx.locale, which Inker reads per render. The Edge adapter remains exported
from @c9up/rosetta/plugins/edge for Edge-compatible non-Ream hosts.
Typed Translation Keys
Catalogs are JSON/YAML read at runtime, so the compiler cannot see them.
generateCatalogTypes closes that gap: it reads your catalogs and emits a
declaration that types t() — unknown keys, misspelled variables and wrong
variable types all become compile errors.
// scripts/i18n-types.ts
import { writeFileSync } from 'node:fs'
import { generateCatalogTypes } from '@c9up/rosetta'
import i18nManager from '@c9up/rosetta/services/main'
await i18nManager.loadTranslations()
writeFileSync(
'types/i18n.d.ts',
generateCatalogTypes(i18nManager.getTranslations(), { referenceLocale: 'en' }),
)The generated file augments TranslationKeys — the same declaration-merging
idiom AdonisJS uses for EventsList:
declare module '@c9up/rosetta' {
interface TranslationKeys {
'messages.greeting': { 'name': string | number }
'messages.items': { 'count': number }
}
}i18n.t('messages.greeting', { name: 'Hugo' }) // ok
i18n.t('messages.greetingg', { name: 'Hugo' }) // error: unknown key
i18n.t('messages.greeting', { nam: 'Hugo' }) // error: no such variable
i18n.t('messages.items', { count: '2' }) // error: a plural takes a number
i18n.t('messages.items') // error: `count` is required
i18n.t('messages.plain', { count: 1 }) // error: takes no variablesUntil you generate that file, TranslationKeys is empty and t() accepts
every string exactly as before — opting in is what turns the checks on.
For reference, @adonisjs/[email protected] declares
t(identifier: string, data?: Record<string, any>, fallbackMessage?: string),
so none of the six lines above is an error there.
Checking Catalogs
Catalogs drift silently: a key lands in en and is forgotten in fr, a
variable is renamed in one locale only, a message is edited into invalid ICU.
None of it fails until a user with that locale reaches that key. checkCatalogs
reconciles every locale against a reference one and reports four kinds of drift
— missing keys, orphan keys (with a did you mean for typos), ICU variables
that disagree, and messages that no longer parse.
import { checkCatalogs, verifyCatalogs } from '@c9up/rosetta'
const findings = checkCatalogs(i18nManager.getTranslations(), {
referenceLocale: 'en',
})
// [{ locale: 'fr', key: 'items', kind: 'param-mismatch',
// detail: "expects n:plural, but 'en' declares count:plural" }]
// Boot guard — warns by default, `mode: 'throw'` to fail fast.
verifyCatalogs(i18nManager.getTranslations(), { referenceLocale: 'en' })runCatalogCheck(translations, opts) prints the report and returns an exit
code, for a CI script. This has no @adonisjs/i18n equivalent — Adonis reports
a missing translation at runtime, once the request is already being served.
Reloading
Translations are cached after boot. Reloading is atomic: a loader failure keeps the previous complete catalog active.
await i18nManager.reloadTranslations()YAML Scope
The dependency-free YAML reader supports the catalog forms used by the package: nested mappings, quoted and unquoted scalar values, comments, and literal or folded block scalars. Unsupported constructs such as sequences, flow mappings, anchors, aliases, tags and merge keys raise an error instead of being silently partially loaded. JSON catalogs have no such YAML-specific restrictions.
Upgrading To 0.2.0
The Rust N-API engine and its binary are gone; the package is pure TypeScript.
- Remove any
pnpm build:napistep from your build or CI. There is no binary and no Rust toolchain to install. isNativeAvailable()is no longer exported. It reported whether the native binary had loaded, which is now always moot.
Nothing else changed: same API, same catalogs, and every message renders
exactly as before. Rendering already ran on Intl — the engine only parsed —
so this is a packaging change, not a behavioural one. Alpine/musl images, which
never had a binary to load, are unaffected.
Entry Points
@c9up/rosetta@c9up/rosetta/i18n_provider@c9up/rosetta/factories@c9up/rosetta/middleware@c9up/rosetta/plugins/edge@c9up/rosetta/repl@c9up/rosetta/services/main@c9up/rosetta/types
License
MIT
