Skip to content
GitHub

Localisation

MeoCord 4.2 · Structuring your app · page 22 of 41 · since 4.1.0

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

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:

locales/en-US.ts
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:

locales/id.ts
// 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:

i18n.ts
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:

controllers/slash/builders/warn.builder.ts
@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))
  }
}
controllers/slash/warn.slash.controller.ts
// 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:

  1. its own catalog;
  2. another of the same language: es-419 to es-ES, en-GB to en-US;
  3. 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

CallLanguage
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, written as 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, expectCompleteCatalog reads the catalogs' own strings, so it also checks a catalog TypeScript types as string: 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:

services/moderation/warnings.service.ts
// 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:

services/moderation/warnings.service.spec.ts
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:

locales/id.ts
// 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.

i18n-texts/paint.controller.spec.ts
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:

i18n-texts/color.ts
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.

i18n-texts/cooldown.filter.ts
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:

controllers/slash/warn.slash.controller.spec.ts
it('has every message and plural form in every locale', () => {
  expectCompleteCatalog(t)
})

It lists each gap, language by language:

text
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:

text
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:

i18n-texts/paint.controller.spec.ts
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 written as 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 by expectCompleteCatalog.
  • 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 @Command builds 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 Translator needs @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.
  • labelKey needs @MeoCord({ i18n }), and a message in the default catalog. @MeoCord refuses 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:

tutorial/locales/en-US.ts
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' },
})
tutorial/locales/id.ts
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' },
}
tutorial/i18n.ts
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:

tutorial/feedback.builder.ts
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:

tutorial/feedback.controller.ts
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:

tutorial/review.controller.ts
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:

tutorial/feedback.presenter.ts
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:

tutorial/i18n.spec.ts
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.