Source: services/Locale/Locale.js

/**
 * Copyright IBM Corp. 2020, 2024
 *
 * This source code is licensed under the Apache-2.0 license found in the
 * LICENSE file in the root directory of this source tree.
 */

import axios from 'axios';
import { DDOAPI } from '../DDO';
import ipcinfoCookie from '../../internal/vendor/@carbon/ibmdotcom-utilities/utilities/ipcinfoCookie/ipcinfoCookie';
import root from 'window-or-global';

/**
 * @typedef {object} Locale
 * @property {string} cc The country code.
 * @property {string} lc The language code.
 */

/**
 * @constant {string | string} Host for the Locale API call.
 * @private
 */
const _host =
  (process &&
    (process.env.REACT_APP_TRANSLATION_HOST || process.env.TRANSLATION_HOST)) ||
  'https://1.www.s81c.com';

/**
 * Sets the default location if nothing is returned.
 *
 * @type {Locale}
 * @private
 */
const _localeDefault = {
  lc: 'en',
  cc: 'us',
};

/**
 * Default display name for lang combination.
 *
 * @type {string}
 * @private
 */
const _localeNameDefault = 'United States — English';

/**
 * Locale API endpoint.
 *
 * @type {string}
 * @private
 */
const _endpoint = `${_host}/common/js/dynamicnav/www/countrylist/jsononly`;

/**
 * Configuration for axios.
 *
 * @type {{headers: {'Content-Type': string}}}
 * @private
 */
const _axiosConfig = {
  headers: {
    'Content-Type': 'application/json; charset=utf-8',
  },
};

/**
 * Session Storage key for country list.
 *
 * @type {string}
 * @private
 */
const _sessionListKey = 'cds-countrylist';

/**
 * Two hours in milliseconds to compare session timestamp.
 *
 * @type {number}
 * @private
 */
const _twoHours = 60 * 60 * 2000;

/**
 * The cache for in-flight or resolved requests for the country list, keyed by
 * the initiating locale.
 *
 * @type {object}
 * @private
 */
const _requestsList = {};

/**
 * Retrieves the default locale.
 *
 * @returns {Locale} The default locale.
 */
const _getLocaleDefault = () => _localeDefault;

/**
 * Use the <html> lang attr to determine a return locale object, or "false"
 * when it's not available so the consumer can decide what to do next.
 *
 * @type {(Locale | boolean)}
 * @private
 */
function _getLocaleFromLangAttr() {
  if (root.document?.documentElement?.lang) {
    const lang = root.document.documentElement.lang.toLowerCase();
    const locale = {};
    if (lang.indexOf('-') === -1) {
      locale.lc = lang;
    } else {
      const codes = lang.split('-');
      locale.cc = codes[1];
      locale.lc = codes[0];
    }
    return locale;
  }
  return false;
}

/**
 * Gets the locale from the cookie and returns it if both 'cc' and 'lc' values
 * are present.
 *
 * @async
 * @returns {Promise<Locale|boolean>} The cookie object if 'cc' and 'lc' values are present, otherwise false.
 */
const _getLocaleFromCookie = async () => {
  const cookie = ipcinfoCookie.get();
  if (cookie && cookie.cc && cookie.lc) {
    await LocaleAPI.getList(cookie);
    return cookie;
  }
  return false;
};

/**
 * Get the locale from the user's browser.
 *
 * @async
 * @returns {Promise<Locale|boolean>} The verified locale or false if not found.
 */
const _getLocaleFromBrowser = async () => {
  try {
    const cc = await DDOAPI.getLocation();

    // Language preference from browser can return in either 'en-US' format or
    // 'en' so will need to extract language only.
    const lang = root.navigator.language;
    const lc = lang.split('-')[0];

    if (cc && lc) {
      const list = await LocaleAPI.getList({ cc, lc });
      const verifiedCodes = LocaleAPI.verifyLocale(cc, lc, list);

      // Set the ipcInfo cookie.
      ipcinfoCookie.set(verifiedCodes);

      return verifiedCodes;
    }
  } catch (e) {
    // Intentionally throw away the exception in favor of returning false.
  }
  return false;
};

/**
 * Return a locale object based on the DDO API, or "false" so the consumer can
 * decide what to do next.
 *
 * @returns {(Locale | boolean)} Locale from the DDO, or "false" if not present.
 * @private
 */
function _getLocaleFromDDO() {
  const ddoLocal = Object.assign({}, root.digitalData || {});

  if (ddoLocal.page?.pageInfo?.language) {
    const lang = {};

    // if DDO language contains both lc & cc (ie. en-US)
    if (
      ddoLocal.page?.pageInfo?.language.includes('-') &&
      ddoLocal.page?.pageInfo?.ibm?.country
    ) {
      // Set proper LC for us to use.
      lang.lc = ddoLocal.page.pageInfo.language.substring(0, 2).toLowerCase();

      lang.cc = ddoLocal.page.pageInfo.ibm.country.toLowerCase().trim();

      // If there are multiple countries use just the first one for the CC value
      if (lang.cc.indexOf(',') > -1) {
        lang.cc = lang.cc.substring(0, lang.cc.indexOf(',')).trim();
      }

      // Gb will be uk elsewhere
      if (lang.cc === 'gb') {
        lang.cc = 'uk';
      }

      // Map worldwide (ZZ) pages to US
      if (lang.cc === 'zz') {
        lang.cc = 'us';
      }
    } else {
      // set lc with just the language code
      lang.lc = ddoLocal.page.pageInfo.language.substring(0, 2).toLowerCase();
    }

    return lang;
  }
  return false;
}

/**
 * Locale API class with method of fetching user's locale for ibm.com.
 */
class LocaleAPI {
  /**
   * Clears the cache.
   */
  static clearCache() {
    if (typeof sessionStorage !== 'undefined') {
      Object.keys(_requestsList).forEach((key) => delete _requestsList[key]);
      for (let i = 0; i < sessionStorage.length; ++i) {
        const key = sessionStorage.key(i);
        if (key.indexOf(_sessionListKey) === 0) {
          sessionStorage.removeItem(key);
        }
      }
    }
  }

  /**
   * Gets the user's locale.
   *
   * Grab the locale from the available information on the page in the following
   * order:
   *
   * 1. DDO
   * 2. HTML lang attribute
   * 3. ipcInfo cookie
   * 4. Browser (navigator.language)
   * 5. Default (us-EN)
   *
   * @returns {Promise<Locale>} Locale object.
   * @example
   * import { LocaleAPI } from '@carbon/ibmdotcom-services';
   *
   * async function getLocale() {
   *   const locale = await LocaleAPI.getLocale();
   *   return locale;
   * }
   */
  static async getLocale() {
    const localeGetters = [
      _getLocaleFromDDO,
      _getLocaleFromLangAttr,
      _getLocaleFromCookie,
      _getLocaleFromBrowser,
    ];
    for (const getter of localeGetters) {
      const locale = await getter();
      if (locale) {
        return locale;
      }
    }
    return _getLocaleDefault();
  }

  /**
   * Gets the user's locale.
   *
   * @returns {Promise<Locale>} Locale object.
   * @example
   * import { LocaleAPI } from '@carbon/ibmdotcom-services';
   *
   * function async getLocale() {
   *    const locale = await LocaleAPI.getLang();
   * }
   *
   * @deprecated in favor of LocalAPI.getLocale.
   */
  static async getLang() {
    return this.getLocale();
  }

  /**
   * This fetches the language display name based on locale.
   *
   * @param {(Locale | boolean)} locale (optional) If not given, uses LocaleAPI.getLocale logic.
   * @returns {Promise<string>} Display name of locale/language.
   */
  static async getLangDisplay(locale) {
    const lang = locale ? locale : await this.getLocale();
    const list = await this.getList(lang);
    // combines the countryList arrays
    let countries = [];
    list.regionList.forEach((region) => {
      countries = countries.concat(region.countryList);
    });

    // get match for countries with multiple languages
    const location = countries.filter((country) => {
      let htmlLang = country.locale.findIndex(
        (loc) => loc[0] === `${lang.lc}-${lang.cc}`
      );

      if (htmlLang !== -1) {
        let localeMatch = country.locale.filter((l) =>
          l.includes(`${lang.lc}-${lang.cc}`)
        );
        country.locale.splice(0, country.locale.length, ...localeMatch);
        return country;
      }
    });

    if (location.length) {
      return `${location[0].name} — ${location[0].locale[0][1]}`;
    } else {
      return _localeNameDefault;
    }
  }

  /**
   * Get the country list of all supported countries and their languages
   * if it is not already stored in session storage.
   *
   * @param {Locale} locale Locale object.
   * @param {string} locale.cc Country code.
   * @param {string} locale.lc Language code.
   * @returns {Promise<any>} Promise object.
   * @example
   * import { LocaleAPI } from '@carbon/ibmdotcom-services';
   *
   * function async getLocale() {
   *    const list = await LocaleAPI.getList({ cc: 'us', lc: 'en' });
   *    return list;
   * }
   */
  static async getList({ cc, lc }) {
    return new Promise((resolve, reject) => {
      this.fetchList(cc, lc, resolve, reject);
    });
  }

  /**
   * Fetches the list data based on cc/lc combination.
   *
   * @param {string} cc Country code.
   * @param {string} lc Language code.
   * @param {Function} resolve Resolves the Promise.
   * @param {Function} reject Rejects the promise.
   */
  static fetchList(cc, lc, resolve, reject) {
    const key = cc !== 'undefined' ? `${lc}-${cc}` : `${lc}`;
    const itemKey = `${_sessionListKey}-${key}`;

    const sessionList = this.getSessionCache(itemKey);

    if (sessionList) {
      resolve(sessionList);
    } else {
      if (!_requestsList[key]) {
        const url = `${_endpoint}/${
          cc !== 'undefined' ? `${cc}${lc}` : `${lc}`
        }-utf8.json`;

        _requestsList[key] = axios.get(url, _axiosConfig).then((response) => {
          const { data } = response;
          data['timestamp'] = Date.now();
          sessionStorage.setItem(
            `${_sessionListKey}-${key}`,
            JSON.stringify(data)
          );
          return data;
        });
      }

      _requestsList[key].then(resolve, (error) => {
        if (cc === _localeDefault.cc && lc === _localeDefault.lc) {
          reject(error);
        } else {
          this.fetchList(_localeDefault.cc, _localeDefault.lc, resolve, reject);
        }
      });
    }
  }

  /**
   * Verify that the cc and lc combo is in the list of supported cc-lc combos.
   *
   * @param {string} cc Country code.
   * @param {string} lc Language code.
   * @param {object} list Country list.
   * @returns {object} Object with lc and cc.
   * @example
   * import { LocaleAPI } from '@carbon/ibmdotcom-services';
   *
   * async function getLocale() {
   *   const locale = await LocaleAPI.verifyLocale(cc, lc, data);
   *   return locale;
   * }
   */
  static verifyLocale(cc, lc, list) {
    let priorityLC;
    let locale;

    const language =
      list &&
      list.regionList.forEach((region) =>
        region.countryList.forEach((country) => {
          const code = country.locale[0][0].split('-');
          const languageCode = code[0];
          const countryCode = code[1];
          if (countryCode === cc && languageCode === lc) {
            locale = { cc, lc };
          }
          // save the priority language associated with the user's country code
          else if (countryCode === cc && !priorityLC) {
            priorityLC = languageCode;
          }
        })
      );
    if (!language && priorityLC) {
      locale = { cc, lc: priorityLC };
    }
    return locale;
  }

  /**
   * Retrieves session cache and checks if cache needs to be refreshed
   *
   * @param {string} key Session storage key.
   */
  static getSessionCache(key) {
    const session =
      typeof sessionStorage === 'undefined'
        ? undefined
        : JSON.parse(sessionStorage.getItem(key));

    if (!session || !session.timestamp) {
      return;
    }

    const currentTime = Date.now(),
      timeDiff = currentTime - session.timestamp;

    if (timeDiff > _twoHours) {
      sessionStorage.removeItem(key);
      return;
    }
    return session;
  }
}

export default LocaleAPI;