// Copyright 2021 99cloud // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import React from 'react'; import IntlMessageFormat from 'intl-messageformat'; import escapeHtml from 'escape-html'; import cookie from 'cookie'; import queryParser from 'querystring'; import invariant from 'invariant'; import { getLocalStorageItem } from 'utils/local-storage'; // import * as constants from './constants'; import { merge } from 'lodash'; // eslint-disable-next-line no-extend-native String.prototype.defaultMessage = function (msg) { return this || msg || ''; }; class SLI18n { constructor() { this.options = { currentLocale: null, // Current locale such as 'en' locales: {}, // app locale data like {"en":{"key1":"value1"},"zh-hans":{"key1":"值1"}} // eslint-disable-next-line no-console warningHandler: function warn(...msg) { console.warn(...msg); }, // ability to accumulate missing messages using third party services escapeHtml: true, // disable escape html in variable mode // commonLocaleDataUrls: COMMON_LOCALE_DATA_URLS, fallbackLocale: null, // Locale to use if a key is not found in the current locale }; } /** * Get the formatted message by key * @param {string} key The string representing key in locale data file * @param {Object} variables Variables in message * @returns {string} message */ get(key, variables) { invariant(key, 'key is required'); const { locales, currentLocale, formats } = this.options; if (!locales || !locales[currentLocale]) { this.options.warningHandler( `translate locales data "${currentLocale}" not exists.` ); return ''; } let msg = this.getDescendantProp(locales[currentLocale], key); if (msg == null || msg === '') { if (this.options.fallbackLocale) { msg = this.getDescendantProp(locales[this.options.fallbackLocale], key); if (msg == null) { this.options.warningHandler( `translate key "${key}" not defined in ${currentLocale}` ); // this.options.warningHandler( // `translate key "${key}" not defined in ${currentLocale} or the fallback locale, ${this.options.fallbackLocale}` // ); msg = key; } } else { this.options.warningHandler( `translate key "${key}" not defined in ${currentLocale}` ); msg = key; } } if (variables) { variables = { ...variables }; // HTML message with variables. Escape it to avoid XSS attack. Object.keys(variables).forEach((i) => { let value = variables[i]; if ( this.options.escapeHtml === true && (typeof value === 'string' || value instanceof String) && value.indexOf('<') >= 0 && value.indexOf('>') >= 0 ) { value = escapeHtml(value); } variables[i] = value; }); } try { const msgFormatter = new IntlMessageFormat(msg, currentLocale, formats); return msgFormatter.format(variables); } catch (err) { this.options.warningHandler( `translate format message failed for key='${key}'.`, err.message ); return msg; } } /** * Get the formatted html message by key. * @param {string} key The string representing key in locale data file * @param {Object} variables Variables in message * @returns {React.Element} message */ getHTML(key, variables) { const msg = this.get(key, variables); if (msg) { const el = React.createElement('span', { dangerouslySetInnerHTML: { __html: msg, }, }); // when key exists, it should still return element if there's defaultMessage() after getHTML() const defaultMessage = () => el; return { defaultMessage, d: defaultMessage, ...el, }; } return ''; } /** * As same as get(...) API * @param {Object} options * @param {string} options.id * @param {string} options.defaultMessage * @param {Object} variables Variables in message * @returns {string} message */ formatMessage(messageDescriptor, variables) { const { id, defaultMessage } = messageDescriptor; return this.get(id, variables).defaultMessage(defaultMessage); } /** * As same as getHTML(...) API * @param {Object} options * @param {string} options.id * @param {React.Element} options.defaultMessage * @param {Object} variables Variables in message * @returns {React.Element} message */ formatHTMLMessage(messageDescriptor, variables) { const { id, defaultMessage } = messageDescriptor; return this.getHTML(id, variables).defaultMessage(defaultMessage); } /** * Helper: determine user's locale via URL, cookie, localStorage, and browser's language. * You may not this API, if you have other rules to determine user's locale. * @param {string} options.urlLocaleKey URL's query Key to determine locale. Example: if URL=http://localhost?lang=en, then set it 'lang' * @param {string} options.cookieLocaleKey Cookie's Key to determine locale. Example: if cookie=lang:en, then set it 'lang' * @param {string} options.localStorageLocaleKey LocalStorage's Key to determine locale such as 'lang' * @returns {string} determined locale such as 'en' */ determineLocale(options = {}) { return ( this.getLocaleFromLocalStorage(options) || this.getLocaleFromURL(options) || this.getLocaleFromCookie(options) || this.getLocaleFromBrowser() ); } /** * Initialize properties and load CLDR locale data according to currentLocale * @param {Object} options * @param {string} options.currentLocale Current locale such as 'en' * @param {string} options.locales App locale data like {"en":{"key1":"value1"},"zh-hans":{"key1":"值1"}} * @returns {Promise} */ init(options = {}) { invariant(options.currentLocale, 'options.currentLocale is required'); invariant(options.locales, 'options.locales is required'); Object.assign(this.options, options); this.options.formats = { ...this.options.formats, // constants.defaultFormats }; return new Promise((resolve) => { // init() will not load external common locale data anymore. // But, it still return a Promise for backward compatibility. resolve(); }); } /** * Get the initial options */ getInitOptions() { return this.options; } /** * Load more locales after init */ load(locales) { merge(this.options.locales, locales); } getLocaleFromCookie(options) { const { cookieLocaleKey } = options; if (cookieLocaleKey) { const params = cookie.parse(document.cookie); return params && params[cookieLocaleKey]; } } getLocaleFromLocalStorage(options) { const { localStorageLocaleKey } = options; if (localStorageLocaleKey && window.localStorage) { return getLocalStorageItem(localStorageLocaleKey); } } getLocaleFromURL(options) { const { urlLocaleKey } = options; if (urlLocaleKey) { const query = window.location.search.split('?'); if (query.length >= 2) { const params = queryParser.parse(query[1]); return params && params[urlLocaleKey]; } } } getDescendantProp(locale, key) { if (locale[key]) { return locale[key]; } const msg = key .split('.') .reduce((a, b) => (a !== undefined ? a[b] : a), locale); return msg; } getLocaleFromBrowser() { return navigator.language || navigator.userLanguage; } } export default SLI18n;