Por qué la pluralización no es una condición simple
La primera intuición al manejar texto plural es escribir algo como:
const message = count === 1 ? 'One item' : `${count} items`;Esto funciona en inglés. Falla en polaco, donde "2 items" es 2 elementy, "5 items" es
5 elementów, y "21 items" vuelve a ser 21 elementów. Falla en árabe, que tiene seis formas
de plural. Falla en ruso, que distingue entre un ítem, dos-a-cuatro ítems y cinco-o-más
ítems. Falla para cero. En francés, cero usa la forma singular (0 élément), mientras que en inglés
cero usa la forma plural (0 items).
La idea central es que la selección plural es una función tanto del idioma como del número. Cada
idioma importante tiene reglas documentadas en el proyecto Unicode CLDR (Common Locale Data Repository), y esas reglas mapean cualquier entero a una de cinco categorías con nombre: zero, one, two, few, many y other. Cada idioma usa un subconjunto de estas categorías, y other siempre es la
categoría de cierre obligatoria.
El motor de pluralización de Spraxium delega la selección de categorías a Intl.PluralRules, el motor V8 integrado en Node.js que implementa las reglas CLDR, y luego busca la variante con key correspondiente en el map plano de traducciones.
Convención de nombres para variantes plurales
Las keys plurales siguen el patrón baseKey_category. La base key es la misma key que usarías para
t(). La categoría es uno de los seis nombres CLDR anexado con un separador de guion bajo.
{
'items': {
'_zero': 'No items in your inventory',
'_one': 'One item in your inventory',
'_other': '{{count}} items in your inventory'
}
}Después de aplanar con el nombre de referencia commands y nombre de archivo items, las keys completas quedan:
commands.items._zerocommands.items._onecommands.items._other
El motor construye la key de búsqueda anexando _ más la categoría resuelta a la base key
que pases a tp().
Las seis categorías y cuándo aplica cada una
| Categoría | Aplica a | Idiomas de ejemplo y reglas |
|---|---|---|
| zero | Forma específica para cero | Árabe (0), galés (0) |
| one | Singular | Inglés (1), español (1), portugués (1) |
| two | Forma dual | Árabe (2), galés (2), hebreo (2) |
| few | Paucal / plural pequeño | Ruso (-), polaco (2, 3, 4 que no terminan en teen), árabe (-0) |
| many | Plural grande o irregular | Ruso (5+, 1-4), polaco (5+), árabe (1-9) |
| other | Plural general, categoría de cierre obligatoria | Inglés (0, 2+), chino (todos), japonés (todos) |
Los idiomas que no tienen una forma de cero dedicada (la mayoría de idiomas de Europa occidental) mapean cero a
other. Esto significa que en inglés 0 items se resuelve a _other, por eso la variante de cero en el
ejemplo de arriba dice "No items.". Necesitas proporcionar una entrada _zero específicamente para esa redacción.
Algoritmo de resolución
Determinación de categoría
Verifica si se registró una regla plural personalizada para el locale solicitado en el campo de configuración pluralRules. Si existe una regla personalizada, ejecuta esa función con el count numérico y usa la string de categoría que devuelva. Si no hay regla personalizada registrada, delega en Intl.PluralRules(locale).select(count), que implementa las reglas Unicode CLDR integradas en Node.js y devuelve una de las seis strings de categoría con nombre.
Construcción de key candidata
Construye la key de búsqueda concatenando la base key, un carácter de guion bajo
y la categoría resuelta. Por ejemplo, una base key commands.items con
categoría resuelta few produce la key candidata commands.items_few.
Esta string construida es la primera key que el registro buscará al
intentar encontrar la forma plural traducida.
Búsqueda primaria en el locale
Busca la key candidata en el map plano del locale solicitado llamando a LocaleRegistry.get(locale). El registro mantiene un Map<string, string> por locale cargado y devuelve la string traducida al instante si la key existe. Si la key se encuentra en este paso, la ejecución salta directamente a la interpolación.
Fallback a la categoría _other
Si la búsqueda primaria no devolvió nada y la categoría resuelta no es
ya other, construye una segunda key candidata usando _other como sufijo
y busca de nuevo en el mismo map del locale. Este fallback es lo que permite
proporcionar solo _one y _other para la mayoría de idiomas sin necesidad de
definir todas las categorías CLDR posibles, ya que other es el
cierre obligatorio.
Fallback al locale predeterminado
Si ni la key de categoría ni la key _other se encontraron en el locale solicitado,
y el locale solicitado no es ya el locale predeterminado configurado,
repite los pasos tres y cuatro contra el map plano del locale predeterminado.
Este nivel permite traducciones parciales: un idioma que solo cubre algunas
keys producirá silenciosamente el texto del locale predeterminado para las entradas faltantes
en lugar de romper la respuesta.
Retorno seguro de la key cruda
Si todas las búsquedas anteriores fallan, devuelve la base key cruda tal cual en lugar de lanzar un error. El motor de pluralización nunca lanza una excepción de key faltante en tiempo de petición, así que una traducción faltante aparece como texto visible de placeholder en la UI sin tumbar el bot ni dejar la interacción sin respuesta. Este comportamiento hace seguro publicar traducciones parciales en producción mientras el trabajo en los locales restantes sigue en progreso.
Interpolación de variables
Fusiona el count numérico en el objeto vars bajo la key count, dejándolo
disponible como {{ count }} en la string traducida sin trabajo extra
del llamador. Reemplaza cada placeholder {{ varName }} en la string
resuelta con su valor correspondiente del objeto vars fusionado.
La string totalmente interpolada se devuelve al handler que llamó a tp()
o tpUser().
Proveer traducciones para un idioma con reglas plurales complejas
El ruso requiere cuatro entradas para ser correcto. Si omites _few o _many, el motor cae
hacia _other para esos valores, lo que puede ser gramaticalmente incorrecto para tu string concreta.
{
'items': {
'_one': '{{count}} предмет',
'_few': '{{count}} предмета',
'_many': '{{count}} предметов',
'_other': '{{count}} предмета'
}
}i18n.tp('commands.items', 'ru', 1); // '1 предмет' (one)
i18n.tp('commands.items', 'ru', 2); // '2 предмета' (few)
i18n.tp('commands.items', 'ru', 5); // '5 предметов' (many)
i18n.tp('commands.items', 'ru', 11); // '11 предметов' (many, teen numbers are always many in Russian)
i18n.tp('commands.items', 'ru', 21); // '21 предмет' (one, 21 ends in 1)Usar pluralización en handlers
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { I18nService } from '@spraxium/i18n';
import type { ChatInputCommandInteraction } from 'discord.js';
import { InventoryCommand } from '../commands/inventory.command';
@SlashCommandHandler(InventoryCommand)
export class InventoryHandler {
constructor(private readonly i18n: I18nService) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const count = await getInventoryCount(interaction.user.id);
const locale = interaction.locale;
const message = this.i18n.tp('commands.items', locale, count, { count });
await interaction.reply(message);
}
}Reglas plurales personalizadas para casos límite
Cuando Intl.PluralRules da resultados incorrectos para un identificador de locale específico (esto es poco común, pero
puede ocurrir con strings de locale personalizadas o variantes de locale no cubiertas por los datos CLDR de la
versión actual de Node.js), puedes sobrescribir la regla en tiempo de configuración:
import { defineI18n } from '@spraxium/i18n';
export const i18nConfig = defineI18n({
defaultLocale: 'en-US',
references: [{ name: 'commands', path: './locales/commands' }],
pluralRules: {
'pt-BR': (count) => (count === 1 ? 'one' : 'other'),
ar: (count) => {
if (count === 0) return 'zero';
if (count === 1) return 'one';
if (count === 2) return 'two';
if (count % 100 >= 3 && count % 100 <= 10) return 'few';
if (count % 100 >= 11 && count % 100 <= 99) return 'many';
return 'other';
},
},
});Las reglas personalizadas reciben el count numérico y deben devolver una de las seis strings de categoría. Se ejecutan
antes de Intl.PluralRules y la sobrescriben por completo para el locale especificado. Los locales sin una
regla personalizada siguen usando Intl.PluralRules con normalidad.
La variable {{count}}
El count pasado a tp() o tpUser() queda disponible automáticamente como {{count}} en la
string traducida. No necesitas pasarlo explícitamente en el objeto vars. El motor lo fusiona
automáticamente para que cada variante plural pueda referenciar el count sin boilerplate extra.
{
'items': {
'_one': 'You have one item remaining',
'_other': 'You have {{count}} items remaining'
}
}Si pasas vars adicionales, se fusionan con count y todas quedan disponibles como {{varName}}
placeholders:
i18n.tp('commands.items', 'en-US', 5, { maxCapacity: 10 });
// Translation: 'You have {{count}} of {{maxCapacity}} item slots used'
// Result: 'You have 5 of 10 item slots used'