adapt-authoring-lang/lib/LangModule.js

import { AbstractModule } from 'adapt-authoring-core'
import fs from 'fs/promises'
import { glob } from 'glob'
import path from 'path'

/**
 * Module to handle localisation of language strings
 * @memberof lang
 * @extends {AbstractModule}
 */
class LangModule extends AbstractModule {
  /** @override */
  async init () {
    this.app.lang = this
    await this.loadPhrases()
    this.loadRoutes()
  }

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

  /**
   * Loads, validates and merges all defined langage phrases
   * @return {Promise}
   */
  async loadPhrases () {
    /**
     * The loaded language phrases to be used for translation
     * @type {Object}
     */
    this.phrases = {}
    const deps = [
      { name: this.app.name, rootDir: process.cwd() },
      ...Object.values(this.app.dependencies)
    ]
    return Promise.all(deps.map(async d => this.loadPhrasesForDir(d.rootDir)))
  }

  /**
   * Load all lang phrases for a given directory
   * @param {String} dir Directory to search
   * @return {Promise} Resolves with the phrases
   */
  async loadPhrasesForDir (dir) {
    const files = await glob('lang/*.json', { cwd: dir, absolute: true })
    await Promise.all(files.map(async f => {
      try {
        const contents = JSON.parse((await fs.readFile(f)).toString())
        Object.entries(contents).forEach(([k, v]) => this.storeStrings(`${path.basename(f).replace('.json', '')}.${k}`, v))
      } catch (e) {
        this.log('error', e.message, f)
      }
    }))
  }

  storeStrings (key, value) {
    const i = key.indexOf('.')
    const lang = key.slice(0, i)
    if (!this.phrases[lang]) this.phrases[lang] = {}
    this.phrases[lang][key.slice(i + 1)] = value
  }

  /**
   * Loads the router & routes
   * @return {Promise}
   */
  async loadRoutes () {
    const [auth, server] = await this.app.waitForModule('auth', 'server')

    server.api.addMiddleware(this.addTranslationUtils.bind(this))

    const router = server.api.createChildRouter('lang')
    router.addRoute({
      route: '/:lang?',
      handlers: { get: this.requestHandler.bind(this) },
      meta: {
        get: {
          summary: 'Retrieve lang strings for single locale',
          responses: {
            200: {
              description: 'Lang strings for the specified locale',
              content: {
                'application/json': {}
              }
            }
          }
        }
      }
    })
    auth.unsecureRoute(router.path, 'get')
  }

  /**
   * Load all lang phrases for a language
   * @param {String} lang The language of strings to load
   * @return {Object} The phrases
   */
  getPhrasesForLang (lang) {
    const phrases = {}
    Object.entries(this.phrases).forEach(([key, value]) => {
      const i = key.indexOf('.')
      const keyLang = key.slice(0, i)
      const newKey = key.slice(i + 1)
      if (keyLang === lang) phrases[newKey] = value
    })
    return Object.keys(phrases).length > 1 ? phrases : undefined
  }

  /**
   * Adds a translate function to incoming API requests for generating language strings in the original request's supported language
   * @param {external:ExpressRequest} req
   * @param {external:ExpressResponse} res
   * @param {function} next
   */
  addTranslationUtils (req, res, next) {
    const lang = req.acceptsLanguages(this.supportedLanguages)
    req.translate = (key, data) => this.translate(lang, key, data)
    next()
  }

  /**
   * Shortcut to log a missing language key
   * @param {external:ExpressRequest} req The client request object
   * @param {external:ExpressResponse} res The server response object
   * @param {Function} next The callback function
   */
  requestHandler (req, res, next) {
    // defaults to the request (browser) lang
    const lang = req.params.lang || req.acceptsLanguages(this.supportedLanguages)
    if (!lang || !this.phrases[lang]) {
      return next(this.app.errors.UNKNOWN_LANG.setData({ lang }))
    }
    res.json(this.phrases[lang])
  }

  /**
   * Returns translated language string
   * @param {String} lang The target language (if undefined, the default server language will be used)
   * @param {String|AdaptError} key The unique string key (if an AdaptError is passed, the error data will be used for the data param)
   * @param {Object} data Dynamic data to be inserted into translated string
   * @return {String}
   */
  translate (lang, key, data) {
    if (typeof lang !== 'string') {
      lang = this.getConfig('defaultLang')
    }
    if (key.constructor.name === 'AdaptError') {
      return this.translateError(lang, key)
    }
    const s = this.phrases[lang][key]
    if (!s) {
      this.log('warn', `missing key '${lang}.${key}'`)
      return key
    }
    if (!data) {
      return s
    }
    return Object.entries(data).reduce((s, [k, v]) => {
      // map any errors specified in data
      v = Array.isArray(v) ? v.map(v2 => this.translateError(lang, v2)) : this.translateError(lang, v)
      s = s.replaceAll(`\${${k}}`, v)
      // handle special-case array replacements
      if (Array.isArray(v)) {
        const matches = [...s.matchAll(new RegExp(String.raw`\$map{${k}:(.+)}`, 'g'))]
        matches.forEach(([replace, data]) => {
          const [attrs, delim] = data.split(':')
          s = s.replace(replace, v.map(val => attrs.split(',').map(a => Object.prototype.hasOwnProperty.call(val, a) ? val[a] : a)).join(delim))
        })
      }
      return s
    }, s)
  }

  /**
   * Translates an AdaptError
   * @param {String} lang The target language
   * @param {AdaptError} error Error to translate
   * @returns The translated error (if passed error is not an instance of AdaptError, the original value will be returned)
   */
  translateError (lang, error) {
    return error?.constructor?.name === 'AdaptError' ? this.translate(lang, `error.${error.code}`, error.data) : error
  }
}

export default LangModule