Implementación de un servicio de traducción simple y efectivo en Javascript

Esta aproximación tiene el objetivo de implementar un servicio estricto de traducción dentro de aplicaciones Javascript y con muy pocos artefactos.

Durante todo este año, y para mi sorpresa, fui embarcado en un proyecto donde el 80% del código estaba escrito en Typescript. Esto aumento mi interés en el lenguaje, ya que ofrece una capa de reglas estrictas —y algunas veces exageradas— a Javascript. Sin embargo, desde siempre he sentido que la internacionalización de las aplicaciones en Javascript no es consistente. He tenido la experiencia de ver cuanto intento de traducción —algunos nefastos— en este lenguaje.

Es por eso que decidí implementar un servicio de traducción bastante rudimentario y simple que pudiera manejar correctamente las traducciones dentro de toda la aplicación. Aunque el stack en donde lo he implementado es híbrido, el servicio de traducción es completamente portable.

Modelo

La implementación ofrece simpleza y flexibilidad. Se trata de una clase facade principal compuesta de diccionarios, uno por cada idioma. La idea principal es estandarizar los mensajes por medio de claves para que puedan ser reemplazados según el idioma demandado.

Modelo del servicio de traducción para Javascript

La clase facade Translator es estática, lo que permite el uso fácil sobre todo el alcance de la agregación. Los puntos principales que tuve en cuenta fueron:

  • Configuración regional por defecto: representado por el atributo string localeDefault. Este atributo decidirá cuál debe ser el diccionario de idioma que se usará por defecto. Aunque dentro de este modelo la decisión es difusa, en la implementación que proporcionaré más adelante tendré en cuenta un valor obtenido por una variable de entorno y también por un valor fallback incrustado directamente en el código, cuanod nada más esté disponible.
  • Una forma sencilla de recuperar el diccionario de idioma: esta está representada por la operación ITranslationDictionary? getDictionary(string?). Dentro de la implementación, esta operación retorna el diccionario asociado a localeDefault; y si este no existe, retorna un valor nulo. El parámetro locale es opcional, y en caso de no ser especificado, usará el valor almacenado en localeDefault.
  • Una operación para traducir: esta está representada por la operación string trans(string, string?). Esta basada en casi todos los paquetes de traducción que se pueden encontrar en el mercado, y está pensada para usarse solo con la clave de traducción. Si se usa el segundo parámetro, el diccionario que se usará no será el valor por defecto sino del valor que se especifique ahí. Esta operación siempre retornará un string; esto maximiza la compatibilidad. En caso de que el diccionario o la entrada dentro de este no exista, la clave es la cadena que se retorna.

Garantizar la consistencia de los diccionarios

Cada diccionario representa un idioma y es independiente a los demás, sin embargo esto podría dar lugar a inconsistencias como que no todas las claves estén presentes en algún diccionario. Aunque por la forma de construcción de la clase Translator esto no supone un problema lógico, no pasa lo mismo con un error humano, que facilmente puede olvidar alguna clave. Para solucionar este problema hago uso de una interfaz ITranslationDictionary, la cual sirve de enumerador de todas las claves de traducción a usar, y cada diccionario debe implementarla.

Implementación

La implementación mínima consta de tres artefactos: una clase facade, una interfaz ITranslationDictionary y al menos una clase que implemente dicha interfaz. Vale la pena resaltar que aquí hago uso de algunas instrucciones específicas de Typescript, sin embargo puede convertirse facilmente a Javascript puro.

Clase facade Translator

import { TranslationDictionaryInterface } from './TranslationDictionaryInterface'
import EsDictionary from './dictionary/EsDictionary'

const defaultLocale: string = process.env.APP_LOCALE || 'es'

export default class Translator {
    private static dictionaries: Record<string, TranslationDictionaryInterface> = {
      es: new EsDictionary()
    };

    private static getDictionary (locale?: string): TranslationDictionaryInterface | null {
      return Translator.dictionaries[locale || defaultLocale] || null
    }

    public static trans (translationKey: string, locale?: string): string {
      const dictionary: TranslationDictionaryInterface | null = this.getDictionary(locale)
      if (!dictionary) {
        console.warn('Translation dictionary not found', locale || defaultLocale)
        return translationKey
      }
      const translation: string | null = dictionary[translationKey] as string | null
      if (translation == null) {
        console.warn('Translation key not found on dictionary', translationKey, locale || defaultLocale)
      }
      return translation || translationKey
    }
}

Para determinar la configuración regional por defecto hago uso de variables de entorno y un valor incrustado a manera de fallback. En caso de tener una aplicación que defina de manera dinámica su configuración regional, por ejemplo, en algún parámetro de URL, se puede cambiar facilmente por otro método que se ajuste a las medidas.

Diccionarios estáticamente incrustados vs. cargados dinámicamente

La razón por la que la lista de diccionarios en la variable dictionaries es construida de manera estática, es que de ese modo garantizamos que el código no sea mutable o que haya un riesgo mayor de inyección de código. Al principio usé importaciones dinámicas basadas en la configuración regional —cargar los diccionarios bajo demanda—. A primera vista ofrece una carga más rápida debido a que los diccionarios se cargan bajo demanda y puede ser cacheables, pero a costa de una configuración de compilación más compleja y ampliación de la superficie de ataque por medio de XSS. Además, hay más consumo de recursos en esa petición HTTP adicional que si el diccionario estuviera ya incrustado. Por lo que al final me decidí por simplificar la clase y añadir una lista estática.

Otra ventaja de los diccionarios incrustados, es que la clase no es asíncrona. Cuando probé la carga dinámica, la clase tuvo que adaptarse al modelo asíncrono async/await. Esto quiere decir que si hay un problema de red o demora en el hilo de traducción, el usuario final no podrá ver los mensajes inmediatamente, y podría incluso resultar en un problema de usabilidad. Por lo tanto, esta solución de carga dinámica no la recomiendo para este tipo de servicios críticos.

Acceso a los atributos de la clase TranslationDictionaryInterface

En la línea 21 se accede a las propiedades con la notación de corchetes y no la tradicional notación de punto. Esto se debe a que para efectos de recorrido, esta notación puede ser de más utilidad y mejor comprendida en el futuro. Pero esta notación en Typescript no está permitida, o al menos no lo está hasta que se defina explícitamente. Para eso se debe usar la característica index signature en la interfaz del diccionario, la cual veremos a continuación.

Interfaz de diccionario TranslationDictionaryInterface

export interface TranslationDictionaryInterface {
    [key: string]: any;

    helloWorld: string;
    anotherTransUnit: string;
    testStringA: string;
    testStringB: string;
}

La interfaz es bastante sencilla. Su objetivo es el de enumerar las diferentes cadenas de texto traducibles dentro de la aplicación. Para esto, uso atributos en donde los nombres de los mismos son las claves a usar. Estos atributos son siempre los mismos en todos los diccionarios, por lo que el objetivo se cumple.

Index signature

La línea 2 es la implementación de una característica de Typescript llamada index signature. Para el efecto práctico de esta implementación, es definir explícitamente la forma en como se indexan los atributos, para posteriormente ser accedidos sin errores.

Y todo esto es porque la forma de acceder por medio de clave → valor, es usar la notación de corchetes objeto[atributo], en vez de la notación de punto objeto.atributo. Debido a la forma en como Javascript implementa internamente la notación de corchetes —que es totalmente válida—, hace que el compilador de Typescript no la entienda y arroje un error. Esta línea especifica qué tipo de dato es el conjunto clave → valor, y por lo tanto, el compilador lo sepa y entienda que ese acceso está pensado.

Clase DictionaryInterface

import { TranslationDictionaryInterface } from '../TranslationDictionaryInterface'

export default class EsDictionary implements TranslationDictionaryInterface {
    public helloWorld: string = '¡Hola, mundo!';
    public anotherTransUnit: string = 'Otra cadena de traducción';
    public testStringA: string = 'Prueba en español A';
    public testStringB: string = 'Prueba en español B';
}

La implementación de la interface es sencilla. Esta clase data tiene como objetivo almacenar la carga útil de todo el servicio. Al implementar la interfaz se asegura que la clase tendrá los atributos esperados, lo cual reduce el error humano.

Aplicación

Siendo la operación Translator.trans() estática, solo basta con importarla para que esté disponible dentro de todo el contexto, y luego, reemplazar los textos incrustados por las claves de los diccionarios. Tenga en cuenta el siguiente fragmento de código:

import Translator from './translation/Translator'

// …
try {
  this.api.setApiKey(currentValue)
} catch (error: any) {
  console.error(Translator.trans('helloWorld'))
  // @ts-ignore
  this.errors.apiKey = [Translator.trans('anotherTransUnit')]
  this.workflowState = 0
  return
}
// …

En las líneas 7 y 9 se usa el servicio Translator con dos claves previas. En este supuesto caso, si tenemos un servicio API y queremos establecer una clave API inválida, saltará un error e imprimirá en consola el texto definido en la clave helloWorld.

Otras características que podrían ser implementadas

Los servicios de traducción tienen otras características útiles que pueden ser pensadas e implementadas a futuro como:

  • Traducciones con ordinalidad.
  • Soporte RTL y LTR.
  • Extensiones ICU.

Sin embargo, tal como está cumple con el objetivo para el cual fue diseñado.


Deja un comentario

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.

%d