Translating Texts (i18n)
Atscript ships no translation runtime. Your .as files are written in one source language, and every text in them is a message id: a translator (your map, i18next, FormatJS, …) looks the text up and returns the translation.
export interface Signup {
@meta.label 'Email'
@expect.pattern '^\S+@\S+$', '', 'Enter a valid email'
email: string
@meta.label 'Password'
@expect.minLength 8
password: string
}Here 'Email', 'Password' and 'Enter a valid email' are message ids. The @expect.minLength error has no text of its own: its id is the code MIN_LENGTH.
The two kinds of ids
| The text comes from | Its id | Example |
|---|---|---|
An annotation (@meta.label, @meta.description, a constraint's message argument, @expect.message, …) | the text itself, as written | 'Enter a valid email' |
| The validator's built-in message | the error code (template with {params}) | MIN_LENGTH → Mindestens {min} Zeichen |
Error codes, their English templates and localizeErrors() are covered in Validation Reference — Error Codes.
Which annotations hold text is declared by their specs (userText). Core flags @meta.label, @meta.description, @meta.required and the message of every @expect.* constraint; plugins flag their own, see User-facing Text.
Serving a translated type
A server that sends types to a client (Serialization) translates them per request with translateServed():
import {
serializeAnnotatedType,
translateServed,
VALIDATOR_MESSAGES,
} from '@atscript/typescript/utils'
import { Signup } from './signup.as'
const json = serializeAnnotatedType(Signup) // once, at startup
// t: your translator for the request's locale, returning undefined when it has no translation
function servedSignup(t: (text: string) => string | undefined) {
const { value, texts } = translateServed(json, t)
const templates: Record<string, string> = {}
for (const code of Object.keys(VALIDATOR_MESSAGES)) {
const template = t(code) // e.g. 'Mindestens {min} Zeichen'
if (template !== undefined) templates[code] = template
}
return { ...value, $i18n: { ...templates, ...texts } }
}t(text)returns the translation, orundefinedto keep the text.- Every annotation holding text is translated, at any depth: props, array items, repeated types, reference targets, every item of a
multipleannotation, the message field of a constraint. The exception is a primitive's built-in format message (string.email's pattern message, …): it stays in English, because the validator reports it by its code (EMAIL), which the client translates through$i18n. - The input is never changed, and is returned itself when nothing was translated — cache the result per type and locale.
translateServed()accepts any JSON that carries serialized types, such as an envelope with the type inside it.
$i18n: texts the client needs
Put the texts the client translates itself into the served type's $i18n field (a flat { [id]: text } map, the templates first and texts over them):
- Validator templates by code, translated, keeping their
{name}placeholders (MIN_LENGTH: 'Mindestens {min} Zeichen'). textsreturned bytranslateServed(): the translated string literals of expressions (see below).
deserializeAnnotatedType() ignores $i18n.
Using moost?
setTranslator() from moost picks each request's locale and gives your code one translator, useTranslate(). See the moost i18n guide.
Translating on the client
The served type's labels and messages are already translated. Validation errors the client produces use the built-in templates, so translate them with $i18n:
import {
deserializeAnnotatedType,
localizeErrors,
messagesTranslator,
} from '@atscript/typescript/utils'
const served = await fetch('/signup/meta').then(r => r.json())
const type = deserializeAnnotatedType(served)
const validator = type.validator()
if (!validator.validate(data, true)) {
const errors = localizeErrors(validator.errors, messagesTranslator(served.$i18n ?? {}))
}Expressions: texts computed on the client
Some annotations hold the source of a function the client runs, such as a computed label or a custom validator returning its message. A server cannot translate the result, which depends on the data. Their specs flag them userText: 'expression' instead:
@ui.form.validate '(v) => v.password === v.confirm || "Passwords must match"'translateServed() leaves the source as written, translates each string literal in it ('…', "…", and templates without ${…}), and returns the translations in texts, keyed by the literal. The client looks a computed result up in $i18n by exact match: "Passwords must match" is translated, while a result built by concatenation ("Hello " + name) is not.
DOs and DON'Ts
- DO write each text once, in the source language, and use it as the id in your translations.
- DO serve validator templates by code in
$i18nso client-side validation speaks the user's language. - DON'T branch on
messagein clients: it changes with the locale. Usecodeandparams. - DON'T build texts from pieces in expressions (
'Add ' + label); exact-match translation only finds whole literals. - DON'T expect derived texts to be translated: a label that is not written in an annotation (taken from the field name, say) has no id.
Next Steps
- Validation Reference —
localizeErrors()andmessagesTranslator() - Serialization — the serialized type format
- User-facing Text — flagging a plugin's text arguments
- Error Handling —
@atscript/moost-validatorerror bodies (errors), translated per request bymoost