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.sayandguild.sayproperties
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-saykitThe 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
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
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:
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
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:
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.
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.sayis the view for the interaction's localeguild.sayis 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
- Runtime, cloning and freezing patterns
- Carbon API reference, every export