SayKit
Integrations

Carbon

Use SayKit with Carbon, localise Discord commands, components, and replies

Carbon is a Discord bot framework. @saykit/carbon is its SayKit integration:

  • registers a shared catalogue with your Carbon client
  • helps command, component, and modal classes expose translated metadata
  • adds locale-aware interaction.say and guild.say properties

The result: commands appear in Discord's localised UI per user, and replies match the user's locale.

Install

pnpm add saykit @saykit/carbon
pnpm add -D @saykit/config @saykit/format-po @saykit/transform-js unplugin-saykit

The Carbon example builds with tsdown, so it uses unplugin-saykit/rolldown. Any Carbon-compatible bundler works, pick the matching entry point from unplugin-saykit.

Configure

saykit.config.ts

saykit.config.ts
import { defineConfig } from '@saykit/config';
import po from '@saykit/format-po';
import js from '@saykit/transform-js';

export default defineConfig({
  locales: ['en', 'fr'],
  buckets: [
    {
      include: ['src/**/*.ts'],
      output: 'src/locales/{locale}.{extension}',
      formatter: po(),
      transformer: js(),
    },
  ],
});

tsdown.config.ts

tsdown.config.ts
import { defineConfig } from 'tsdown';
import saykit from 'unplugin-saykit/rolldown';

export default defineConfig({
  entry: ['src/entry.ts'],
  plugins: [saykit()],
});

App setup

A shared catalogue

Eagerly load locales so the bot can answer immediately on cold start:

src/i18n.ts
import { createCatalogue } from 'saykit';

export const catalogue = createCatalogue({
  en: await import('./locales/en.po').then((m) => m.default),
  fr: await import('./locales/fr.po').then((m) => m.default),
});

Register SayPlugin

src/index.ts
import { Client } from '@buape/carbon';
import { SayPlugin } from '@saykit/carbon';
import { PingCommand } from './commands/ping.js';
import { catalogue } from './i18n.js';

const client = new Client({/* options */}, { commands: [new PingCommand()] }, [
  new SayPlugin(catalogue),
]);

SayPlugin installs the catalogue into the global registry and applies extensions so interaction.say and guild.say work.

Localising commands

createWithSay(catalogue) binds a withSay to your catalogue, normally once beside the catalogue itself:

src/i18n.ts
import { createWithSay } from '@saykit/carbon';

export const withSay = createWithSay(catalogue);

withSay() then wraps a Carbon class with a constructor that accepts a mapping function. The mapping function runs once per locale; SayKit uses the results to populate Carbon's per-locale command metadata.

src/commands/ping.ts
import { Command, type CommandInteraction } from '@buape/carbon';
import { withSay } from '../i18n.js';

export class PingCommand extends withSay(Command) {
  constructor() {
    super((say) => ({
      name: say`ping`,
      description: say`Ping the bot!`,
    }));
  }

  async run(interaction: CommandInteraction) {
    await interaction.reply({
      content: interaction.say`Pong!`,
    });
  }
}

When you register PingCommand, Carbon receives every translation of name and description at once, Discord then renders the command in whichever locale each user has.

Subcommands and options

CommandWithSubcommands works the same way:

export class MathsCommand extends withSay(CommandWithSubcommands) {
  constructor() {
    super((say) => ({
      name: say`maths`,
      description: say`Maths commands!`,
      subcommands: [new AddCommand(), new SubtractCommand()],
    }));
  }
}

Options work too, everything visible in the Discord UI is localisable:

super((say) => ({
  name: say`add`,
  description: say`Add two numbers!`,
  options: [
    {
      name: say`a`,
      description: say`The first number.`,
      type: ApplicationCommandOptionType.Number,
      required: true,
    },
  ],
}));

Components and modals

For BaseComponent and Modal subclasses, withSay accepts the translated props directly, no per-locale mapping, because Discord doesn't localise component metadata the way it does commands:

import { Button } from '@buape/carbon';
import type { View } from 'saykit';

export class RollAgainButton extends withSay(Button) {
  customId = 'roll-again';
  constructor(say: View) {
    super({ label: say`Roll Again` });
  }
}

interaction.say and guild.say

Once SayPlugin is registered:

  • interaction.say is the view for the interaction's locale
  • guild.say is the view for the guild's preferred locale

Use either anywhere you'd normally use say:

await interaction.reply({
  content: interaction.say`The dice rolled ${result}!`,
});
await someOperation(guild.say`Welcome to ${guildName}!`);

The catalogue memoises one view per locale, so repeated accesses hand back the same value.

Live example

The examples/carbon package is a working Cloudflare Workers bot with localised commands.

Next

On this page