O que é uma referência e por que ela existe
Uma referência é a ponte entre um diretório no disco e um namespace no espaço de chaves de tradução. Você declara uma referência atribuindo a ela um nome e um caminho. O nome se torna o primeiro segmento de toda chave que vier daquele diretório. O caminho aponta para um diretório cuja estrutura segue uma de duas convenções: arquivos JSON planos por locale ou subdiretórios por locale contendo vários arquivos JSON. As duas estruturas são válidas e podem coexistir em referências diferentes dentro do mesmo bot.
O motivo para referências existirem como conceito, em vez de haver um único diretório plano de locale, é que bots reais têm preocupações de tradução diferentes, e cada uma delas pertence a um lugar diferente. Nomes e descrições de slash commands são objetos de domínio, próximos do código. Mensagens de erro usadas em todo o bot compartilham um namespace. Strings de UI para modais, select menus e embeds formam outra camada. Misturar tudo isso em um único arquivo por locale cria custo de manutenção e dificulta identificar o que está faltando e onde. Referências dão a cada preocupação seu próprio diretório e seu próprio prefixo, ao mesmo tempo em que continuam servindo todas as consultas a partir do mesmo mapa plano em memória.
Essa separação é uma decisão de experiência de desenvolvimento, não uma decisão de performance em runtime. Em runtime, todas as referências são mescladas no mesmo mapa plano por locale. O nome da referência vira um prefixo de chave que evita colisões, mas não existe lookup separado por referência nem travessia de diretório durante a requisição.
Declarando múltiplas referências
import { defineI18n } from '@spraxium/i18n';
export const i18nConfig = defineI18n({
defaultLocale: 'en-US',
references: [
{ name: 'slash', path: './locales/slash' },
{ name: 'ui', path: './locales/ui' },
{ name: 'errors', path: './locales/errors' },
{ name: 'embeds', path: './locales/embeds' },
],
});Não existe limite para a quantidade de referências. Cada uma é carregada de forma independente e mesclada no mesmo registro. Desde que cada valor de name seja único, não pode haver colisão de chaves entre referências.
Estrutura de arquivos plana
Nesse layout, cada locale é um único arquivo JSON na raiz do diretório da referência. O nome do arquivo é o identificador do locale, e o conteúdo dele é o objeto de tradução daquele locale.
{
"notFound": "Resource not found",
"forbidden": "You do not have permission to do this",
"timeout": "The operation timed out after {{seconds}} seconds"
}Depois de carregar a referência { name: 'errors', path: './locales/errors' }, as chaves disponíveis no registro para en-US são:
errors.notFounderrors.forbiddenerrors.timeout
O nível de aninhamento dentro do arquivo JSON não importa. Estruturas profundamente aninhadas são achatadas recursivamente com notação por pontos ligando cada nível.
Estrutura de diretórios aninhados
Quando uma referência contém muitos arquivos, ou quando você quer organizar traduções por recurso dentro de um locale, pode usar subdiretórios. Cada subdiretório recebe o nome de um locale e contém vários arquivos JSON. O nome do arquivo, sem .json, se torna um segmento adicional do namespace.
{
"name": "ping",
"description": "Check the bot's response time",
"options": {
"target": {
"name": "target",
"description": "The server or service to ping"
}
}
}Depois de carregar a referência { name: 'slash', path: './locales/slash' }, as chaves disponíveis para en-US incluem:
slash.ping.nameslash.ping.descriptionslash.ping.options.target.nameslash.ping.options.target.descriptionslash.help.nameslash.settings.name- todas as outras chaves de todos os demais arquivos
O nome do arquivo ping se torna o segundo segmento depois de slash, e as chaves do JSON viram os segmentos seguintes. O caminho completo é sempre: referenceName.fileName.jsonKeyPath.
Misturando as duas estruturas
Você pode misturar livremente layouts planos e aninhados entre referências diferentes da mesma configuração. ReferenceLoader detecta qual layout usar no nível da referência verificando se as entradas dentro do diretório da referência são arquivos ou subdiretórios.
import { defineI18n } from '@spraxium/i18n';
export const i18nConfig = defineI18n({
defaultLocale: 'en-US',
references: [
// Nested layout: many files per locale, organized by feature
{ name: 'slash', path: './locales/slash' },
// Flat layout: simple key-value pairs in a single file per locale
{ name: 'errors', path: './locales/errors' },
{ name: 'system', path: './locales/system' },
],
});Acessando chaves entre referências
Todas as referências ficam acessíveis pela mesma API de serviço, sem a necessidade de informar a qual referência uma chave pertence. O nome da referência já faz parte da própria chave.
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { I18nService } from '@spraxium/i18n';
import type { ChatInputCommandInteraction } from 'discord.js';
import { StatusCommand } from '../commands/status.command';
@SlashCommandHandler(StatusCommand)
export class StatusHandler {
constructor(private readonly i18n: I18nService) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const locale = interaction.locale;
const userId = interaction.user.id;
const guildId = interaction.guildId ?? undefined;
// Keys from 'slash' reference
const title = this.i18n.t('slash.status.embed.title', locale);
// Keys from 'errors' reference, used when an upstream check fails
const permError = this.i18n.t('errors.forbidden', locale);
// Async resolution through user/guild preference chain
const footer = await this.i18n.tUser(
userId,
'slash.status.embed.footer',
{},
guildId,
);
await interaction.reply({ content: `${title}: ${footer}` });
}
}Usando chaves de referência em decorators de slash
Quando você adiciona um campo i18n a um decorator de slash, os caminhos de chave apontam para o mapa plano carregado. Eles usam o nome da referência como primeiro segmento, exatamente como aconteceriam em uma chamada a t().
import { SlashCommand } from '@spraxium/common';
@SlashCommand({
name: 'settings',
description: 'Manage bot settings',
i18n: {
name: 'slash.settings.name',
description: 'slash.settings.description',
},
})
export class SettingsCommand {}Quando o registrador de slash executa, o lifecycle de i18n já carregou o mapa plano. A bridge lê o registro para cada locale do Discord com suporte e para o qual seu bot tenha tradução, então repassa os resultados para os métodos builder do discord.js. Com isso, o comando aparece na UI do Discord com nomes localizados nativamente para todos os locales que você forneceu.
O que acontece quando um locale está ausente
Quando uma chave é pedida para um locale do qual o bot não tem arquivo, o registro cai silenciosamente no mapa do locale padrão. Se a chave também não existir no locale padrão, a string bruta da chave é retornada. O sistema nunca lança erro por tradução ausente em tempo de requisição. Em produção, ele degrada com segurança e emite um log em nível de trace para ajudar a encontrar lacunas durante o desenvolvimento.
Na prática, isso significa que você pode publicar traduções parciais. Se já tiver inglês e português e quiser adicionar espanhol em uma versão futura, dá para acrescentar os arquivos es-ES aos poucos. Usuários com preferência de locale em espanhol vão receber respostas em inglês, o padrão, até que os arquivos em espanhol estejam completos. Nada quebra. O fallback só fica visível para esses usuários.