adapt-authoring-core/lib/Lang.js

import fs from 'node:fs'
import { globSync } from 'glob'
import path from 'node:path'

/**
 * Handles loading and translation of language strings.
 * @memberof core
 */
class Lang {
  /**
   * @param {Object} options
   * @param {Object} options.dependencies Key/value map of dependency configs (each with a rootDir)
   * @param {String} options.defaultLang The default language for translations
   * @param {String} options.rootDir The application root directory
   * @param {Function} [options.log] Optional logging function (level, id, ...args)
   */
  constructor ({ dependencies, defaultLang, rootDir, log } = {}) {
    /**
     * The loaded language phrases
     * @type {Object}
     */
    this.phrases = {}
    /**
     * The default language for translations
     * @type {String}
     */
    this.defaultLang = defaultLang
    /**
     * Optional logging function (level, id, ...args)
     * @type {Function}
     */
    this.log = log
    this.loadPhrases(dependencies, rootDir, log)
  }

  /**
   * Returns the languages supported by the application
   * @type {Array<String>}
   */
  get supportedLanguages () {
    return Object.keys(this.phrases)
  }

  /**
   * Loads and merges all language phrases from dependencies
   * @param {Object} dependencies Key/value map of dependency configs (each with a rootDir)
   * @param {String} appRootDir The application root directory
   * @param {Function} [log] Optional logging function (level, id, ...args)
   */
  loadPhrases (dependencies = {}, appRootDir, log) {
    const dirs = [
      ...(appRootDir ? [appRootDir] : []),
      ...Object.values(dependencies).map(d => d.rootDir)
    ]
    for (const dir of dirs) {
      const files = globSync('lang/**/*.json', { cwd: dir, absolute: true })
      for (const f of files) {
        try {
          const relative = path.relative(path.join(dir, 'lang'), f)
          const parts = relative.replace(/\.json$/, '').split(path.sep)
          const lang = parts[0]
          const prefix = parts.length > 1 ? parts.slice(1).join('.') + '.' : ''
          if (!this.phrases[lang]) this.phrases[lang] = {}
          const contents = JSON.parse(fs.readFileSync(f, 'utf8'))
          Object.entries(contents).forEach(([k, v]) => { this.phrases[lang][`${prefix}${k}`] = v })
        } catch (e) {
          log?.('error', 'lang', e.message, f)
        }
      }
    }
  }

  /**
   * Returns translated language string. If key is an Error, translates using
   * the error code as the key and error data for substitution. Non-Error,
   * non-string values are returned unchanged.
   * @param {String} lang The target language (falls back to defaultLang)
   * @param {String|Error} key The unique string key, or an Error to translate
   * @param {Object} data Dynamic data to be inserted into translated string
   * @return {String}
   */
  translate (lang, key, data) {
    if (typeof lang !== 'string') {
      lang = this.defaultLang
    }
    if (key instanceof Error) {
      if (!key.code) return key.message || String(key)
      return this.translate(lang, `error.${key.code}`, key.data ?? key)
    }
    if (typeof key !== 'string') {
      return key
    }
    const s = this.phrases[lang]?.[key]
    if (!s) {
      this.log?.('warn', 'lang', `missing key '${lang}.${key}'`)
      return key
    }
    if (!data) {
      return s
    }
    return this.substituteData(s, lang, data)
  }

  /**
   * Replaces placeholders in a translated string with data values.
   * Supports ${key} for simple substitution, and $map{key:attrs:delim}
   * for mapping over array values.
   * @param {String} s The translated string
   * @param {String} lang The target language
   * @param {Object} data Key/value pairs to substitute
   * @return {String}
   */
  substituteData (s, lang, data) {
    for (const [k, v] of Object.entries(data)) {
      const items = [v].flat().map(item => item instanceof Error ? this.translate(lang, item) : item)
      s = s.replaceAll(`\${${k}}`, items)
      for (const [match, expr] of s.matchAll(new RegExp(String.raw`\$map{${k}:(.+)}`, 'g'))) {
        const [attrs, delim] = expr.split(':')
        s = s.replace(match, items.map(val => attrs.split(',').map(a => val?.[a] ?? a).join(delim)))
      }
    }
    return s
  }
}

export default Lang