Home Reference Source

src/cocorita.js

import EventEmitter from 'events';
import yaml from 'js-yaml';
import some from 'lodash.some';
import every from 'lodash.every';
import cloneDeep from 'lodash.clonedeep';

// Events constants

/**
 * Event emitted every time a translation is not found for any
 * of the languages passed in the initialize array.
 *
 * @typedef {string} EVT_INIT_KEY
 * @example
 * coco.on(Cocorita.EVT_INIT_KEY, ({cocorita, data, source, targets}) => {
 *   // cocorita {Cocorita} : Cocorita instance emitting the event
 *   // data {Object}       : The translations data object
 *   // source {String}     : The source text
 *   // targets {Object}    : The target languages translations object
 * });
 */
const EVT_INIT_KEY = 'init-key';

/**
 * Regular expression validating tr replace keys
 * @type {RegExp}
 */
const REPLACE_KEY_REGEXP = /^[a-zA-Z0-9_]+$/;

/**
 * Check if data is a valid translations object
 * @param {Object} data A translations object
 * @return {Boolean} Return true if data is a valid translations object
 */
function isValidTranslationsObject(data) {
  if (typeof data !== 'object') return false;
  return every(data, (source) => {
    if (typeof source !== 'object') return false;
    return every(source, translation => typeof translation === 'string');
  });
}

// Polyfill for Array.isArray
if (!Array.isArray) {
  Array.isArray = function isArray(arg) {
    return Object.prototype.toString.call(arg) === '[object Array]';
  };
}

/**
 * Cocorita class
 */
class Cocorita extends EventEmitter {
  /**
   * Constructor
   *
   * @param {Object} options Options object
   * @param {String} [options.language] The target language identifier
   * @param {String[]} [options.initialize] Array of languages whose translations
   * will be initialized with the source text if not present
   * @return {Cocorita} A new Cocorita instance
   * @throws {Error} Throws a Error if something gone wrong
   */
  constructor(options) {
    super();

    /**
     * Target language
     * @type {String}
     */
    this.lang = '';

    /**
     * Translations object
     * @type {Object}
     */
    this.data = {};

    // Options check
    if (options !== undefined) {
      if (options.language !== undefined) {
        if (typeof options.language !== 'string') throw new Error('options.language must be a string');
        this.language = options.language;
      }

      // Initialize target languages
      if (options.initialize !== undefined) {
        if (!(Array.isArray(options.initialize) && every(options.initialize, value => typeof value === 'string'))) {
          throw new Error('options.initialize must be a string array');
        }

        /**
         * Array of target languages to be initialized if a translation is missing
         * @type {String[]}
         */
        this.initialize = options.initialize;
      }

      // Default target text for tr if missing translation
      if (options.defaultTarget) {
        if (some(['source', 'blank'], def => def === options.defaultTarget)) {
          /**
           * Normally, when a target language translation is found missing,
           * an empty string is emitted.
           * If defaultTarget value is set to 'source', the source text is emitted instead.
           */
          this.defaultTarget = options.defaultTarget;
        } else {
          throw new Error('Invalid defaultTarget option');
        }
      }
    }
  }

  /**
   * Set target language id
   * @type {String}
   */
  set language(lang) {
    if (lang === undefined || typeof lang !== 'string') throw new Error('language must be a string');
    this.lang = lang;
  }

  /**
   * get target language id
   * @type {String}
   */
  get language() {
    return this.lang;
  }

  /**
   * Load translations data from a database file
   * or a translations object
   *
   * @param {String|Object} source A json or yaml string or a valid translations object
   * @param {String} [format] 'json' or 'yaml'. Required only if source is a file path.
   * @throws {Error}
   */
  load(source, format) {
    if (source === undefined) throw new Error('source parameter is required');

    if (typeof source === 'object') {
      const data = cloneDeep(source);
      if (isValidTranslationsObject(data)) {
        this.data = data;
        return;
      }
      throw new Error('invalid translations object');
    }

    if (typeof source === 'string') {
      if (format === undefined) throw new Error('format parameter is required if source is a string');
      if (typeof format !== 'string') throw new Error('format parameter must be a string');

      if (format === 'json') {
        try {
          const data = JSON.parse(source);
          if (!isValidTranslationsObject(data)) throw new Error('invalid source');
          this.data = data;
          return;
        } catch (e) {
          throw new Error('invalid source');
        }
      }

      if (format === 'yaml') {
        try {
          const data = yaml.safeLoad(source);
          if (!isValidTranslationsObject(data)) throw new Error('invalid source');
          this.data = data;
          return;
        } catch (e) {
          throw new Error('invalid source');
        }
      }

      throw new Error('unsupported format');
    }

    throw new Error('source parameter must be a valid translations object or a file path');
  }

  /**
   * Get a copy of translations data object
   * @return {Object} Translations data object
   */
  getData() {
    return cloneDeep(this.data);
  }

  /**
   * Get translations data serialized to json or yaml format
   *
   * @param {String} format 'json' or 'yaml'
   * @throws {Error}
   * @return {String} Serialized translations data
   */
  dump(format) {
    if (format === undefined) throw new Error('format parameter is required');
    if (typeof format !== 'string') throw new Error('format parameter must be a string');

    if (format === 'json') {
      const sortedData = {};
      Object.keys(this.data).sort().forEach((key) => {
        sortedData[key] = this.data[key];
      });
      return JSON.stringify(sortedData, null, 2);
    }

    if (format === 'yaml') {
      return yaml.safeDump(this.data, { sortKeys: true });
    }

    throw new Error('unsupported format');
  }

  /**
   * Translate a string of source text in the target language.<br/>
   * If the translation is not available in the database the method return a blank string.<br/>
   * If the initialize option was passed to the constructor, then this method will check all the
   * target languages translations for the source text and will initialize them with a blank string
   * were are not available.
   *
   * @param {String} source The source text
   * @param {Object} replaces Replaces map object
   * @return {String} Translated text in current target language
   * @emits {EVT_INIT_KEY} Emitted if target translation does not exists in data base
   * @throws {Error}
   */
  tr(source, replaces) {
    if (source === undefined) return '';
    if (typeof source !== 'string') throw new Error('source parameter must be a string');

    // Check options object
    const replacesRe = [];
    if (replaces !== undefined) {
      if (typeof replaces !== 'object') throw new Error('replaces parameter must be a map');
      Object.keys(replaces).forEach((key) => {
        const value = replaces[key];
        const strval = `${value}`;
        if (typeof key !== 'string' || !key.match(REPLACE_KEY_REGEXP)) throw new Error(`replace parameter key not matching regular expression ${REPLACE_KEY_REGEXP}`);
        replacesRe.push({
          re: new RegExp(`\\{\\{ *${key} *\\}\\}`, 'g'),
          val: strval,
        });
      });
    }

    // If targets object does not exists then initialize it
    if (this.data[source] === undefined) {
      this.data[source] = {};
    }

    // If initialization is required then initialize target languages
    // if a translation is not available for them
    if (this.initialize !== undefined) {
      let initKey = false;
      this.initialize.forEach((targetLang) => {
        if (this.data[source][targetLang] === undefined) {
          this.data[source][targetLang] = source;
          initKey = true;
        }
      });
      if (initKey) {
        this.emit(EVT_INIT_KEY, this, this.data, source, this.data[source]);
      }
    }

    // If a translation is available for target language then return it
    if (this.data[source][this.lang] !== undefined) {
      let target = this.data[source][this.lang];

      // Apply replaces
      if (replacesRe.length > 0) {
        replacesRe.forEach((replace) => {
          target = target.replace(replace.re, replace.val);
        });
      }

      return target;
    }

    // If no translation available then return a default value
    if (this.defaultTarget === 'source') return source;
    return '';
  }
}

Cocorita.EVT_INIT_KEY = EVT_INIT_KEY;

export default Cocorita;
export { Cocorita };