Localisation
Answer each user in their language, and name your commands in theirs, from typed catalogs checked at compile time.
You'll learn
- Write a catalog per language, typed from the default one
- Localise command names and descriptions, and every reply
- Pick the user's language, the server's, or any other
- Check that every language is complete, in a test
Before this
A translator turns a message key into text in a language. Its messages come from catalogs, one per language, and one
set of catalogs serves both what Discord shows for your commands and what your bot says. The other catalogs are typed
from the default one: a key the default doesn't have, a {param} its message doesn't take, or a message of the
wrong shape, such as a plain text where the default has a plural, fails to compile. A key a catalog leaves out is
looked up in another catalog of the same language, then the default one, and
expectCompleteCatalog reports it in a test, as it does a plural form a language needs but a
catalog lacks.
When to use it
Localise as soon as your bot serves people in more than one language, or will: Discord tells the bot each user's language on every interaction, and each server's preferred one. MeoCord's translator adds nothing to your dependencies.
A bot that only ever speaks one language doesn't need it. Keeping its texts in one catalog still makes them easy to find, and a second language is a new file later.
Example
The default catalog, which every other language is checked against:
import { defineCatalog } from 'meocord/common'
// The default catalog: every other locale is checked against it
export default defineCatalog({
warn: { name: 'warn', description: 'Warn a member', done: 'Warned {user}.' },
warnings: { one: '{user} has {count} warning', other: '{user} has {count} warnings' },
// The label of the app's own message param type, in "is not a valid hex colour"
types: { color: 'hex colour' },
})Another language gives what it translates. What it leaves out falls back to the default:
// Any part of the default catalog; what it leaves out falls back to it
export default {
warn: { name: 'peringatan', description: 'Beri peringatan ke anggota', done: '{user} diberi peringatan.' },
// Indonesian has a single plural form
warnings: { other: '{user} punya {count} peringatan' },
types: { color: 'warna hex' },
// …
}The translator, made from both:
import { createTranslator } from 'meocord/common'
import enUS from '@src/locales/en-US'
import id from '@src/locales/id'
// At module scope: command builders run when their class is decorated
export const t = createTranslator({ default: 'en-US', locales: { 'en-US': enUS, id } })A command named and answered in the user's language:
@CommandBuilder(CommandType.SLASH)
export class WarnCommandBuilder implements CommandBuilderBase {
build() {
return new SlashCommandBuilder()
.setName(t.default('warn.name'))
.setNameLocalizations(t.localizations('warn.name'))
.setDescription(t.default('warn.description'))
.setDescriptionLocalizations(t.localizations('warn.description'))
.addUserOption(option => option.setName('member').setDescription('Who to warn').setRequired(true))
}
}// Interactions report the default name, so the route is `warn` in every language
@Command('warn', WarnCommandBuilder)
async warn(interaction: ChatInputCommandInteraction, { member }: { member: User }) {
// In the language of the user who ran the command
await respond(interaction).send({ content: t.for(interaction)('warn.done', { user: member.username }) })
}A user whose Discord is in Indonesian sees /peringatan, and gets "ada diberi peringatan." when they warn ada.
How it works
createTranslator checks its locales when its module loads: each must be a Discord locale, and the default must have
a catalog. A translate function, such as t.for(interaction), then looks up each message, fills its parameters and
returns a string.
A locale resolves message by message:
- its own catalog;
- another of the same language:
es-419toes-ES,en-GBtoen-US; - the default catalog.
Locales are discord.js Locale values, such as en-GB, es-419 or zh-TW. A bare en is refused, naming it.
A key no catalog has a message for, or one that names a group of messages, comes back as the key itself, rather than throwing. In development, the translator logs a warning naming it, once for each key.
Command names are read when a controller's @Command builds its builder, as the controller's module loads, which is why
the translator is made at module scope. An interaction still reports the command's default name, so
@Command('warn', ...) routes /peringatan too.
Choosing the language
| Call | Language |
|---|---|
t.for(interaction) | The user's, for an answer they read. |
t.for(interaction, { public: true }) | The server's, for a message everyone there reads; the user's in a DM. |
t.forGuild(guild) | The server's, for events and messages, which have no user language. |
t.locale(locale) | Any locale, such as one you stored with the user. |
t.default(key) | The default catalog. |
t.localizations(key) | Every translation of a message, for a command builder. |
t.localizations(key) returns only the locales whose catalog has the message, so Discord's own fallback applies to the
rest. Discord shows a name or a description as written, so it takes only a message without parameters: a key whose
message takes one doesn't compile, or, in a catalog TypeScript can't read, throws as the app loads. A translation that
uses a parameter is left out, and expectCompleteCatalog reports it. A helper of your own that
passes a key on to t.localizations() types it as LocalizationKey<typeof catalog>, from
meocord/common.
A presenter gets the user's locale as context.locale, for a loading view and error titles in
their language.
Parameters and plurals
'Warned {user}.' takes { user }, and a missing or misspelt parameter doesn't compile. Parameters take strings
and numbers; format numbers and dates yourself, with Intl.NumberFormat for instance.
A plural is an object whose keys are plural categories, zero, one, two, few, many and the required
other. It takes a numeric count, which picks the form through Intl.PluralRules for the language, so Russian's
few and many need no code of your own.
A parameter's name is ASCII letters, digits or _, such as {user} or {user_id2}, and a message may take hundreds.
Other text in braces is the message's own: 'Wrap text in { and }.' takes no parameters. A brace written twice is
one brace of text, so 'Buttons use ticket/{{id}}' shows ticket/{id} and takes no parameter, and '{{{user}}}'
shows the user parameter in braces.
Parameters in other languages
A translation may use any of its default message's parameters, in any order, and leave some out, since a language
can say something without the name. A plural's forms may also use {count}. A parameter the default message doesn't
take, such as a misspelt or translated name, would reach the user as written, so it's refused twice:
When the code compiles, for a catalog whose text TypeScript keeps: one made with
defineCatalog, writtenas const, or written inline. The error names each parameter, the message and its default text:text id: warn.done takes no {pengguna}; the default is "Warned {user}."In a test,
expectCompleteCatalogreads the catalogs' own strings, so it also checks a catalog TypeScript types asstring: a plain object, such as the Indonesian one above, or a JSON file.
In services
Pass the translator to the app, and a class injects it as Translator, typed by the default catalog:
// The translator `@MeoCord({ i18n: t })` provides, typed by the default catalog
@Service()
export class WarningsService {
constructor(private readonly t: Translator<typeof enUS>) {}
// A notice the whole server reads, so in the server's language
notice(guild: Guild, user: string, count: number): string {
return this.t.forGuild(guild)('warnings', { user, count })
}
}Importing t works too. Injecting it keeps a test free to provide another:
describe('WarningsService', () => {
const module = MeoCordTestingModule.create({
providers: [
{ provide: WarningsService, useClass: WarningsService },
{ provide: Translator, useValue: t },
],
}).compile()
it('writes a notice in the server’s language', () => {
const guild = createMockInteraction(Guild, { preferredLocale: Locale.Indonesian })
expect(module.get(WarningsService).notice(guild, 'ada', 2)).toBe('ada punya 2 peringatan')
})
})MeoCord's own texts
What MeoCord itself tells users goes through the same translator: a message command's usage and what is wrong with
it, the built-in !help, cooldown refusals, "Command not found!", the generic error, the direct messages
dmOnError and dmOnCooldown send, and the default
presenter's "Working on it…" and "Oops!". Add a meocord group to any catalog, all of it or part:
// MeoCord's own texts: any of them, and what is left out stays in MeoCord's English
meocord: {
usage: { heading: 'Cara pakai: {usage}', notValid: '{label}: "{word}" bukan {type} yang sah' },
types: { int: 'bilangan bulat' },
cooldown: { until: 'Pelan-pelan: coba lagi {when}.' },
fallback: { notFound: 'Perintah tidak ditemukan!' },
},Each text is looked up on its own, so a line a language leaves out stays in MeoCord's English. The keys and their
English are in MeoCordMessages: a key MeoCord lacks does not compile, nor, in a catalog
TypeScript keeps the text of, a {param} its English text lacks, and the error names the text and the params it takes.
expectCompleteCatalog checks the params of any catalog.
Answers to an interaction are in the user's language; replies to a message, the direct messages about it, and
!help, in the server's preferred language, or the default locale's in a direct message. MeoCord's English stands
as the English catalog: an English server or user gets it even when the default locale is another language, unless
your own en-US or en-GB catalog words the text.
const replyTo = async (content: string, preferredLocale?: Locale) => {
const guild = preferredLocale ? Object.assign(createMockGuild(), { preferredLocale }) : null
const message = createMockMessage({ content, guild })
await module.dispatch(message)
return (message.reply.mock.calls[0][0] as { content: string }).content
}
it("answers in the server's language, and each line the catalog lacks in English", async () => {
expect(await replyTo('!roll lots', Locale.Indonesian)).toBe(
'Cara pakai: !roll <sides>\nsides: "lots" bukan bilangan bulat yang sah',
)
expect(await replyTo('!paint red', Locale.Indonesian)).toBe(
'Cara pakai: !paint <accent>\naccent: "red" bukan warna hex yang sah',
)
expect(await replyTo('!roll 1 2', Locale.Indonesian)).toBe(
'Cara pakai: !roll <sides>\nThe command has more words than it takes',
)
})
it("answers a direct message in the default locale's language", async () => {
expect(await replyTo('!paint red')).toBe('Usage: !paint <accent>\naccent: "red" is not a valid hex colour')
})A type of your own is named in usage replies by its label. Give it a labelKey instead, a message of your catalog,
for a name in each server's language:
import { type MessageParamType } from 'meocord/interface'
// Named by a key of the app's catalog, so the usage reply names it in the server's language
export const color: MessageParamType<number> = {
labelKey: 'types.color',
parse: word => (/^#[0-9a-f]{6}$/i.test(word) ? parseInt(word.slice(1), 16) : undefined),
}An exception filter that answers MeoCord's errors its own way can keep their words:
translateError(error, t, target) returns the text the fallback would send, in the
language of an interaction, a message or a locale.
import { MessageFlags } from 'discord.js'
import { CooldownError, type ExecutionContext, translateError, Translator } from 'meocord/common'
import { Catch } from 'meocord/decorator'
import { type ExceptionFilter } from 'meocord/interface'
@Catch(CooldownError)
export class CooldownFilter implements ExceptionFilter<CooldownError> {
constructor(private readonly t: Translator) {}
async catch(error: CooldownError, context: ExecutionContext) {
const interaction = context.getInteraction()
// MeoCord's words for the wait, in the user's language, in the app's own answer
if (interaction?.isRepliable()) {
await interaction.reply({
content: `⏳ ${translateError(error, this.t, interaction)}`,
flags: MessageFlags.Ephemeral,
})
}
}
}Testing a catalog
expectCompleteCatalog(t) from meocord/testing fails with every message a language lacks, every message the
default catalog doesn't have, every plural form a language needs but lacks, and every
parameter the default message doesn't take:
it('has every message and plural form in every locale', () => {
expectCompleteCatalog(t)
})It lists each gap, language by language:
The catalogs are incomplete:
id: warn.done takes no {pengguna}; the default is "Warned {user}."MeoCord's own texts fall back to English by design, so it reports only a meocord key MeoCord lacks, and a
parameter MeoCord's English doesn't take:
id: meocord.usage.heading takes no {command}: MeoCord's English is "Usage: {usage}"expectCompleteCatalog(t, { meocord: true }) requires every language other than English to translate each of them:
it("names each of MeoCord's texts a locale leaves in English", () => {
expect(() => expectCompleteCatalog(t, { meocord: true })).toThrow('id: missing meocord.usage.headingMany')
})Gotchas
- The default catalog must be TypeScript, wrapped in
defineCatalog(...)or writtenas const. Parameters are typed from the message text, which TypeScript keeps only for a literal; a catalog that has lost it is refused with a compile error saying so. Other languages may be plain objects, or JSON; their parameters are then checked only byexpectCompleteCatalog. - Discord limits command and option names to 32 characters, lowercase for slash commands, and descriptions to 100. A
builder handed a longer one fails when the handler's
@Commandbuilds it, naming the handler, the builder and the command. A raw command body whose localised names or descriptions break them is caught at registration instead: nothing is registered, the error lists each field, and the bot stays up. - Injecting
Translatorneeds@MeoCord({ i18n }). Without it, any class that injects it, a guard, interceptor, filter, pipe or presenter included, stops the bot at startup, naming the class and saying what to pass. labelKeyneeds@MeoCord({ i18n }), and a message in the default catalog.@MeoCordrefuses one without either, where the app is declared.
Build it
The feedback bot speaks English only, with each text written where it's used. Put them in catalogs, one per language:
import { defineCatalog } from 'meocord/common'
export default defineCatalog({
feedback: {
name: 'feedback',
description: 'Send feedback to the staff',
modal: { title: 'Send feedback', about: 'What is it about?', details: 'Tell us more' },
thanks: 'Thanks! The staff will read it soon.',
review: {
heading: 'Feedback #{id} from {user}',
approve: 'Approve',
reject: 'Reject',
approved: 'Approved by {user}.',
rejected: 'Rejected by {user}.',
},
verdict: {
approved: 'Your feedback “{about}” was approved. Thank you!',
rejected: 'Your feedback “{about}” was not taken up this time.',
},
staffOnly: 'Only the staff can review feedback.',
notFound: 'That feedback no longer exists.',
chat: {
filed: 'Filed as feedback #{id}. Thank you!',
status: {
open: 'Feedback #{id} is open.',
approved: 'Feedback #{id} is approved.',
rejected: 'Feedback #{id} is rejected.',
},
unknown: 'There is no feedback #{id}.',
},
welcome: 'Thanks for adding me! Use /{command}, or mention me: {example}',
},
presenter: { loading: 'Working on it…', failed: 'Something went wrong' },
})export default {
feedback: {
name: 'masukan',
description: 'Kirim masukan ke staf',
modal: { title: 'Kirim masukan', about: 'Tentang apa?', details: 'Ceritakan lebih lanjut' },
thanks: 'Terima kasih! Staf akan segera membacanya.',
review: {
heading: 'Masukan #{id} dari {user}',
approve: 'Setujui',
reject: 'Tolak',
approved: 'Disetujui oleh {user}.',
rejected: 'Ditolak oleh {user}.',
},
verdict: {
approved: 'Masukanmu “{about}” disetujui. Terima kasih!',
rejected: 'Masukanmu “{about}” belum bisa diterima kali ini.',
},
staffOnly: 'Hanya staf yang bisa meninjau masukan.',
notFound: 'Masukan itu sudah tidak ada.',
chat: {
filed: 'Tersimpan sebagai masukan #{id}. Terima kasih!',
status: {
open: 'Masukan #{id} masih terbuka.',
approved: 'Masukan #{id} disetujui.',
rejected: 'Masukan #{id} ditolak.',
},
unknown: 'Tidak ada masukan #{id}.',
},
welcome: 'Terima kasih sudah menambahkanku! Pakai /{command}, atau sebut aku: {example}',
},
presenter: { loading: 'Sedang diproses…', failed: 'Terjadi kesalahan' },
}import { createTranslator } from 'meocord/common'
import enUS from '@src/tutorial/locales/en-US'
import id from '@src/tutorial/locales/id'
// At module scope: the command builder uses it when its class is decorated
export const t = createTranslator({ default: 'en-US', locales: { 'en-US': enUS, id } })Name the command in each language:
import { t } from '@src/tutorial/i18n'
// …
return new SlashCommandBuilder()
.setName(commandName)
.setNameLocalizations(t.localizations('feedback.name'))
.setDescription(t.default('feedback.description'))
.setDescriptionLocalizations(t.localizations('feedback.description'))
.setContexts(InteractionContextType.Guild)Replace each text with its message. The form and the thanks are in the member's language. The review post is in the server's, since the whole staff reads it:
import { t } from '@src/tutorial/i18n'
// …
const text = t.for(interaction)
// …
.setTitle(text('feedback.modal.title'))
// …
input('about', text('feedback.modal.about'), TextInputStyle.Short, 80),
input('details', text('feedback.modal.details'), TextInputStyle.Paragraph, 1000),
// …
// The review post is in the server's language, since the whole staff reads it
const staff = t.for(interaction, { public: true })
// …
.setLabel(staff(`feedback.review.${verdict}`))
// …
.setTitle(staff('feedback.review.heading', { id: feedback.id, user: interaction.user.username }))
// …
await respond(interaction).send({ content: t.for(interaction)('feedback.thanks'), flags: MessageFlags.Ephemeral })The verdict posted in the channel is in the server's language, and the author hears back in the language they wrote in:
import { t } from '@src/tutorial/i18n'
// …
const staff = t.for(interaction, { public: true })
// …
const verdict = (post ? EmbedBuilder.from(post) : new EmbedBuilder()).setFooter({
text: staff(`feedback.review.${status}`, { user: interaction.user.username }),
})
// …
// The author hears back in their own language; closed DMs are not the reviewer's problem
const text = t.locale(feedback.locale)(`feedback.verdict.${status}`, { about: feedback.about })The presenter's loading view and error title follow the member:
import { type Locale } from 'discord.js'
// …
import { t } from '@src/tutorial/i18n'
// …
loading({ locale, theme }: ResponseContext): ResponseView {
const text = t.locale(locale as Locale)('presenter.loading')
return { text, emoji: theme.emojis.loading, color: theme.colors.primary }
}
// …
error({ locale, theme }: ResponseContext, { message, tone }: PresentedError): ResponseView {
const title = t.locale(locale as Locale)('presenter.failed')
return { title, text: message, color: theme.colors[tone] }
}Check every language is complete:
describe('the feedback bot’s catalogs', () => {
it('has every message in every language', () => {
expectCompleteCatalog(t)
})
it('names the command in each language Discord shows it in', () => {
expect(t.localizations('feedback.name')).toMatchObject({ [Locale.Indonesian]: 'masukan' })
})
})Switch your Discord to Bahasa Indonesia and run /masukan: the form and the thanks are in Indonesian, and the review
post is in the server's language. Approve a report, and the loading view is in your language too.
Next steps
- Presenters: answer MeoCord's own loading and error views in the user's language.
- Exception filters: word an error in the user's language.
- Mocks: give a mock interaction a locale, to test each language's answer.