Languages and formats
i18nTranslate messages and format money, dates and numbers per locale, with right-to-left and Indian scripts and missing strings caught by the check.
i18nFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
i18n translates your messages and formats money, dates and numbers for each locale, with right-to-left and Indian scripts working from the start. Reach for it on the first string a user reads.
At a glance
i18n:
driver: files
locales: [en-IN, hi-IN, ta-IN]import { t, i18n } from "mainsdk";
const title = t("orders.count", { count: orders.length });
const total = i18n.format.money(125000, "INR"); // ₹1,25,000 in en-INapp/orders/heading.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
describe("heading", () => {
it("pluralises the order count in Hindi and English", () => {
const app = createTestApp({ main: "./main.yaml", dir: process.cwd() });
assert.equal(app.i18n.t("orders.count", { count: 1 }), "1 order");
assert.equal(app.i18n.with("hi-IN", () => app.i18n.t("orders.count", { count: 3 })), "3 ऑर्डर");
});
});Calls
t(key, values)i18n.locale()i18n.negotiate(req, { user })i18n.with(locale, fn)i18n.format.number(n, opts)i18n.format.money(amount, currency)i18n.format.date(d, opts)i18n.format.list(items, opts)i18n.format.relative(d)i18n.dir(locale)i18n.drafts.list({ locale })i18n.drafts.approve(id, { text })i18n.drafts.reject(id, { reason })new Catalog(cfg, messages?)new Drafts(o)FORMATS(const)I18N_DRIVERS(const)new I18n(o)NEGOTIATE_SOURCES(const)PSEUDO_LOCALES(const)checkMessages(o)compileMessage(src)createI18n(o)crowdinSource(o)detectSyntax(src)exportBundle(format, b, opts?)extractKeys(source, file)fileCache(file)fileDraftStore(dir)formatMessage(m, ctx)formatsFor(locale)icuToMf2(src)importBundle(format, text)lastGood(source, cache, { log })localeDir(locale)localeSupported(locale)lokaliseSource(o)matchLocale(tag, locales)memoryDraftStore()negotiate(o, req, ctx?)parseAcceptLanguage(header)parseI18nConfig(raw)parseIcu(src)parseMf2(src)printMf2(m)pseudoMessage(src, locale)pseudoMessages(messages, locale)readGlossary(file)readLocales(dir)splitPlural(src)tolgeeSource(o)variablesOf(m)writeLocale(dir, locale, { messages })i18n.handle(req, next, { user? })
Guarantees
Whatever the driver.
- A missing translation fails the check, not the user
- Formats from Unicode CLDR through the runtime's Intl, with no locale data shipped
- Locale from the user's choice first, never guessed from IP alone
- A production page never shows a message key
- AI translations reach users only after a person approves them
Drivers
5 drivers. Select one to see its line in main.yaml.
i18n:
driver: filesChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-I18N-0001 | Checkmainsdk check, before deploy | A key is missing from a required locale |
MAIN-I18N-0002 | Checkmainsdk check, before deploy | A locale isn't supported by the target runtime's Intl |
MAIN-I18N-0003 | Checkmainsdk check, before deploy | A message has a syntax error, or its variables differ from the default locale's |
MAIN-I18N-0006 | Checkmainsdk check, before deploy | review: none: AI drafts reach users without review (warning) |
MAIN-I18N-0008 | Checkmainsdk check, before deploy | i18n.negotiate lists ip or an unknown source |
MAIN-I18N-0004 | RuntimeError responses and logs | A message was missing and a fallback was used (logged once per key and locale) |
MAIN-I18N-0005 | RuntimeError responses and logs | t() was called without a value its message needs (logged) |
MAIN-I18N-0007 | RuntimeError responses and logs | An agent or service tried to approve a translation draft (403) |
MAIN-I18N-0009 | RuntimeError responses and logs | No message for the key in any locale; production never shows the key (logged) |
MAIN-I18N-0010 | RuntimeError responses and logs | See the spec |
MAIN-I18N-0011 | RuntimeError responses and logs | See the spec |
MAIN-I18N-0012 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Messages in MessageFormat 2 with ICU fallback | Phase 1 | 0.4 | i18n#mf2 |
| Numbers, money, dates, lists and relative times from Intl | Phase 1 | 0.4 | i18n#formats |
| Locale negotiated per request | Phase 1 | 0.4 | i18n#negotiate |
| Fallback chains | Phase 1 | 0.4 | i18n#fallback |
| Missing keys and placeholder mismatches in check | Phase 1 | 0.4 | i18n#checks |
| Pseudo-localization | Phase 1 | 0.4 | i18n#pseudo |
| AI drafts with a glossary and a person's review | Phase 1 | 0.4 | i18n#ai-draft |
| XLIFF, xcstrings, Android strings and ICU JSON import and export | Phase 1 | 0.4 | i18n#interchange-drivers |
| Android and iOS string files from the same locales | Phase 3 | 1.1 | expo-adapter |
Quick start
i18n:
driver: files
locales: [en-IN, hi-IN, ta-IN]orders.count: "{count, plural, one {# order} other {# orders}}"import { i18n, t } from "mainsdk";
export function heading(orders: unknown[]) {
const title = t("orders.count", { count: orders.length });
const total = i18n.format.money(125000, "INR"); // ₹1,25,000.00 in en-IN
return `${title} · ${total}`;
}Configuration
i18n:
driver: files # files | tolgee | crowdin | lokalise | ai-draft
default: en-IN
locales: [en-IN, hi-IN, ta-IN] # the locales you ship
required: [en-IN, hi-IN] # check fails on a missing key in these
negotiate: [choice, account, header] # never ip
cookie: locale # cookie that holds the reader's explicit choice
dir: locales # folder of locale files| Key | What it does |
|---|---|
driver | files (default), tolgee, crowdin, lokalise or ai-draft |
default | The locale every chain ends in; en when unset |
locales | The locales you ship; the default alone when unset |
required | Locales where a missing key fails mainsdk check |
fallback | Per-locale chain, such as fallback: { hi-IN: [hi, en-IN] } |
negotiate | Sources in order: choice, account, header |
ai-draft | model, glossary (default locales/glossary.yaml) and review per locale (person, the default, or none) |
crowdin, tolgee, lokalise | project, token (a secret: reference), base-url, download-host, language-ids for the provider driver you pick |
runtime.icu | full or small; tells check which ICU data your runtime has |
An unknown key under i18n stops the app at load with MAIN-CONFIG-0003, naming the key.
Locale files live in locales/<locale>.yaml and fall back along a chain: hi-IN, then hi, then en-IN, then en. Templates follow the same chain. A market overlay can set its own locales; see main.yaml.
i18n:
locales: [en-IN, hi-IN, ta-IN]What you get
Messages use Unicode MessageFormat 2, and ICU MessageFormat strings still load. Formats come from Unicode CLDR through the runtime's Intl, so ₹1,25,000 shows correctly in India and MainSDK ships no locale data.
Examples
Right-to-left layout
import { i18n } from "mainsdk";
export function htmlAttributes() {
return { lang: i18n.locale(), dir: i18n.dir() };
}Spread the result on <html> in your root layout: <html {...htmlAttributes()}>.
Negotiate the locale per request
i18n.handle() negotiates the locale from the choice cookie, the account and Accept-Language, then runs your handler in it. t() and i18n.format.* inside use that locale.
import { auth, i18n, t } from "mainsdk";
export async function GET(req: Request) {
const session = await auth.require(req);
return i18n.handle(req, () => Response.json({ title: t("orders.count", { count: 2 }) }), {
user: { id: session.user },
});
}Use i18n.negotiate(req, { user }) to get only the locale, and i18n.with("hi-IN", fn) to run code in a locale you choose, such as an email in the recipient's language.
Dates and other formats in the reader's locale
import { i18n } from "mainsdk";
export function shippedOn(date: Date) {
return i18n.format.date(date); // "27 Oct 2026" in en-IN
}i18n.format also has number(), money(), list() and relative(): list(["a", "b", "c"]) gives "a, b and c" and number(1234567.5) gives 12,34,567.5 in en-IN.
Formats in PDF templates
pdf templates format money, numbers and dates through the same locale formats, for example {{total | money:INR}}. Locale-specific template files (order.shipped.hi-IN.yaml) for email and notify are planned.
Reference
Everything below is exported from mainsdk and is pure TypeScript. Most apps use only t() and the i18n instance; the rest is for tools, tests and custom drivers.
Formats
Each takes the active locale from i18n.locale().
| Call | What it does |
|---|---|
i18n.format.number(n, opts) | Formats a number; opts are Intl.NumberFormat options |
i18n.format.list(items, opts) | Joins items as a locale list, such as "a, b and c" |
i18n.format.relative(d) | Formats a date relative to now, such as "2 days ago" |
formatsFor(locale) returns the same formats for one locale, localeSupported(locale) is true when the runtime's Intl can format it, and localeDir(locale) returns ltr or rtl.
Build the interface
| Call | What it does |
|---|---|
createI18n(o) | Builds the interface from config, messages, sources, log, ai and a draft store |
new I18n(o) | The class createI18n returns |
new Catalog(cfg, messages) | Holds every locale's messages and walks the fallback chain |
new Drafts(o) | The drafts service behind i18n.drafts: generate, list, approve and reject |
parseI18nConfig(raw) | Reads and validates the i18n section; an unknown key or driver is refused with its name |
I18N_DRIVERS(const) | The driver names: files, tolgee, crowdin, lokalise, ai-draft |
NEGOTIATE_SOURCES(const) | The sources a locale may come from: choice, account, header |
Messages
| Call | What it does |
|---|---|
compileMessage(src) | Parses a message in either syntax, once per process; a syntax error is MAIN-I18N-0003 |
detectSyntax(src) | Returns mf2 or icu for a message string |
parseMf2(src) | Parses MessageFormat 2 into a tree |
parseIcu(src) | Parses ICU MessageFormat 1 into the same tree |
printMf2(m) | Prints a tree as MessageFormat 2 text that parses back to the same tree |
icuToMf2(src) | Converts an ICU string to MessageFormat 2 with the same output |
formatMessage(m, ctx) | Formats a parsed message for a locale and values |
variablesOf(m) | Lists the external variables a message reads |
splitPlural(src) | Splits a whole-message plural into its variable and cases, or returns null |
checkMessages(o) | Returns MAIN-I18N-0001 and MAIN-I18N-0003 findings for config, messages and the keys used |
extractKeys(source, file) | Finds t("key") calls with a literal key; a computed key cannot be checked |
Locale negotiation
| Call | What it does |
|---|---|
negotiate(o, req, ctx) | Picks one request's locale from the choice cookie, the account and the header; never reads an IP address |
matchLocale(tag, locales) | Returns the declared locale a tag selects: exact, then by truncation, then any region of the language |
parseAcceptLanguage(header) | Returns the tags of an Accept-Language header ordered by quality, dropping q=0 |
Files, drafts and providers
| Call | What it does |
|---|---|
readLocales(dir) | Reads every <locale>.yaml in a folder; nested maps become dotted keys |
writeLocale(dir, locale, { messages }) | Writes one locale as a flat file sorted by key |
readGlossary(file) | Reads the glossary; a missing file is an empty glossary |
fileDraftStore(dir) | Keeps drafts in <dir>/.drafts/<locale>.json so a pull request can review them |
memoryDraftStore() | Keeps drafts in memory, for tests |
tolgeeSource(o), crowdinSource(o), lokaliseSource(o) | The provider drivers; each returns a message source |
lastGood(source, cache, { log }) | Wraps a source so a failed load serves the last good set and logs the outage once |
fileCache(file) | Keeps the last good set in one JSON file |
Interchange and pseudo-locales
| Call | What it does |
|---|---|
FORMATS(const) | The interchange formats: xliff, xcstrings, android, icu-json |
exportBundle(format, b, opts) | Writes a bundle in a format; XLIFF and Android hold one locale, named with opts.locale when there are several |
importBundle(format, text) | Reads a file in a format; one message that does not parse returns nothing and raises MAIN-I18N-0003 |
PSEUDO_LOCALES(const) | The pseudo-locale tags: en-XA and ar-XB |
pseudoMessage(src, locale) | Pseudo-localizes one message |
pseudoMessages(messages, locale) | Pseudo-localizes every message of a locale |
Errors and checks
- A key missing from a
requiredlocale failsmainsdk checkwithMAIN-I18N-0001, and a message that does not parse or whose variables differ from the default locale's withMAIN-I18N-0003. - A locale the target runtime's
Intlcan't format isMAIN-I18N-0002;negotiatelistingipisMAIN-I18N-0008. review: noneon an AI-draft locale is warned withMAIN-I18N-0006.- At run time a fallback logs
MAIN-I18N-0004, a valuet()was not given logsMAIN-I18N-0005, and a key missing everywhere logsMAIN-I18N-0009; production returns an empty string, never the key. mainsdk i18n pseudowrites long, accented and right-to-left pseudo-locales to catch layout breaks before any translator sees them.
Every code is in check codes. See The check.
Swapping drivers
i18n:
driver: tolgee # was filesYour t() and i18n.* calls stay the same. The provider drivers pull messages from Tolgee, Crowdin or Lokalise and keep the last good set when the provider is down.
With ai-draft, mainsdk i18n draft [--locale hi-IN] asks the ai interface for the messages a locale lacks, follows your glossary, and holds each draft as pending in locales/.drafts/. The call goes through every ai guard. A draft reaches users only after a person approves it:
import { i18n } from "mainsdk";
const { items } = await i18n.drafts.list({ locale: "hi-IN" });
for (const d of items.filter((x) => x.status === "pending")) {
await i18n.drafts.approve(d.id); // or approve(d.id, { text: "edited" })
// await i18n.drafts.reject(d.id, { reason: "wrong term" });
}An agent or service that tries to approve or reject gets MAIN-I18N-0007; the refusal is audited.
Strings move between drivers and translators' tools with mainsdk i18n export --format <f> and mainsdk i18n import <file>, where the format is xliff, xcstrings, android or icu-json. mainsdk i18n extract lists the keys your code uses and the ones the default locale lacks.
Testing
Point the test app at your main.yaml and project folder; it reads locales/ and runs on in-memory drivers.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
describe("heading", () => {
it("pluralises the order count in Hindi and English", () => {
const app = createTestApp({ main: "./main.yaml", dir: process.cwd() });
assert.equal(app.i18n.t("orders.count", { count: 1 }), "1 order");
assert.equal(app.i18n.with("hi-IN", () => app.i18n.t("orders.count", { count: 3 })), "3 ऑर्डर");
});
});Security and privacy
- The locale comes from the user's choice first, then their account, then the browser. It is never guessed from IP alone.
- AI drafts go through the
aiinterface with redaction, limits and audit, and are held for a person's review. - Android
values-hi/and iOS.xcstringsfiles generated from the same locales with the Expo adapter are planned for phase 3.
FAQ
Do Indian scripts render correctly? In the browser, yes: shaping is the browser's job. In PDFs, the built-in renderer doesn't shape Devanagari or Tamil, so use the gotenberg or docraptor driver; check reports MAIN-DOC-0011 otherwise.
What happens if a string is missing at run time? The check fails before merge, so it should not reach users. At run time the fallback chain applies, and the first miss for a key is logged once.
Next
Was this page helpful?