diff --git a/core/frontend/helpers/date.js b/core/frontend/helpers/date.js index c73c5123d8..d5cfc27c99 100644 --- a/core/frontend/helpers/date.js +++ b/core/frontend/helpers/date.js @@ -3,7 +3,7 @@ // // Formats a date using moment-timezone.js. Formats published_at by default but will also take a date as a parameter -const {SafeString, i18n} = require('./proxy'); +const {SafeString, themeI18n} = require('./proxy'); const moment = require('moment-timezone'); module.exports = function (date, options) { @@ -35,7 +35,7 @@ module.exports = function (date, options) { // Documentation: http://momentjs.com/docs/#/i18n/ // Locales: https://github.com/moment/moment/tree/develop/locale const dateMoment = moment(date); - dateMoment.locale(i18n.locale()); + dateMoment.locale(themeI18n.locale()); if (timeago) { date = timezone ? dateMoment.tz(timezone).from(timeNow) : dateMoment.fromNow(); diff --git a/core/frontend/helpers/lang.js b/core/frontend/helpers/lang.js index 4062ff7563..f1e550e81c 100644 --- a/core/frontend/helpers/lang.js +++ b/core/frontend/helpers/lang.js @@ -12,10 +12,8 @@ // Language tags in HTML and XML // https://www.w3.org/International/articles/language-tags/ -var proxy = require('./proxy'), - i18n = proxy.i18n, - SafeString = proxy.SafeString; +const {SafeString, themeI18n} = require('./proxy'); module.exports = function lang() { - return new SafeString(i18n.locale()); + return new SafeString(themeI18n.locale()); }; diff --git a/core/frontend/helpers/proxy.js b/core/frontend/helpers/proxy.js index b1ea290d83..f359563939 100644 --- a/core/frontend/helpers/proxy.js +++ b/core/frontend/helpers/proxy.js @@ -28,6 +28,9 @@ module.exports = { i18n: require('../../server/lib/common/i18n'), logging: require('../../server/lib/common/logging'), + // Theme i18n is separate to common i18n + themeI18n: require('../services/themes/i18n'), + // This is used to detect if "isPost" is true in prevNext. checks: require('../../server/data/schema').checks, diff --git a/core/frontend/helpers/t.js b/core/frontend/helpers/t.js index e3acbc4165..9c6832398d 100644 --- a/core/frontend/helpers/t.js +++ b/core/frontend/helpers/t.js @@ -10,8 +10,7 @@ // because often other helpers need that (t) returns a string to be able to work as subexpression; e.g.: // {{tags prefix=(t " on ")}} -var proxy = require('./proxy'), - i18n = proxy.i18n; +const {themeI18n} = require('./proxy'); module.exports = function t(text, options) { var bindings = {}, @@ -21,6 +20,6 @@ module.exports = function t(text, options) { bindings[prop] = options.hash[prop]; } } - bindings.isThemeString = true; - return i18n.t(text, bindings); + + return themeI18n.t(text, bindings); }; diff --git a/core/frontend/services/themes/i18n.js b/core/frontend/services/themes/i18n.js new file mode 100644 index 0000000000..f0dfd67eea --- /dev/null +++ b/core/frontend/services/themes/i18n.js @@ -0,0 +1,113 @@ +const {i18n, events, logging, errors} = require('../../../server/lib/common'); +const settingsCache = require('../../../server/services/settings/cache'); +const config = require('../../../server/config'); + +const jp = require('jsonpath'); + +const isNil = require('lodash/isNil'); + +class ThemeI18n extends i18n.I18n { + constructor(locale) { + super(locale); + } + + /** + * Setup i18n support for themes: + * - Load correct language file into memory + */ + init() { + // This function is called during theme initialization, and when switching language or theme. + const currentLocale = this._loadLocale(); + const activeTheme = settingsCache.get('active_theme'); + + // Reading file for current locale and active theme and keeping its content in memory + if (activeTheme) { + // Reading translation file for theme .hbs templates. + // Compatibility with both old themes and i18n-capable themes. + // Preventing missing files. + this._strings = this._tryGetLocale(activeTheme, currentLocale); + + if (!this._strings && currentLocale !== this.defaultLocale()) { + logging.warn(`Falling back to locales/${this.defaultLocale()}.json.`); + this._strings = this._tryGetLocale(activeTheme, this.defaultLocale()); + } + } + + if (isNil(this._strings)) { + // even if empty, themeStrings must be an object for jp.value + this._strings = {}; + } + + this._initializeIntl(); + } + + /** + * Attempt to load a local file and parse the contents + * + * @param {String} activeTheme + * @param {String} locale + */ + _tryGetLocale(activeTheme, locale) { + try { + return this._readStringsFile(config.getContentPath('themes'), activeTheme, 'locales', `${locale}.json`); + } catch (err) { + if (err.code === 'ENOENT') { + if (locale !== this.defaultLocale()) { + logging.warn(`Theme's file locales/${locale}.json not found.`); + } + } else if (err instanceof SyntaxError) { + logging.error(new errors.IncorrectUsageError({ + err, + message: `Unable to parse locales/${locale}.json. Please check that it is valid JSON.` + })); + } else { + throw err; + } + } + } + + /** + * Load the current locale out of the settings cache + */ + _loadLocale() { + this._locale = settingsCache.get('default_locale'); + return this._locale; + } + + /** + * Do the lookup with JSON path + * + * @param {String} msgPath + */ + _getCandidateString(msgPath) { + // Both jsonpath's dot-notation and bracket-notation start with '$' + // E.g.: $.store.book.title or $['store']['book']['title'] + // The {{t}} translation helper passes the default English text + // The full Unicode jsonpath with '$' is built here + // jp.stringify and jp.value are jsonpath methods + // Info: https://www.npmjs.com/package/jsonpath + let path = jp.stringify(['$', msgPath]); + return jp.value(this._strings, path) || msgPath; + } +} + +let themeI18n = new ThemeI18n(); + +// /** +// * When active theme changes, we reload theme translations +// * We listen on the service event, because of the following known case: +// * 1. you override a theme, which is already active +// * 2. The data has not changed, no event is triggered. +// */ +events.on('services.themes.activated', function () { + themeI18n.init(); +}); + +/** + * When locale changes, we reload theme translations + */ +events.on('settings.default_locale.edited', function () { + themeI18n.init(); +}); + +module.exports = themeI18n; diff --git a/core/frontend/services/themes/index.js b/core/frontend/services/themes/index.js index 66b4880f29..3bea4bde91 100644 --- a/core/frontend/services/themes/index.js +++ b/core/frontend/services/themes/index.js @@ -5,6 +5,7 @@ const themeLoader = require('./loader'); const active = require('./active'); const activate = require('./activate'); const validate = require('./validate'); +const i18n = require('./i18n'); const list = require('./list'); const settingsCache = require('../../../server/services/settings/cache'); const engineDefaults = require('./engines/defaults'); @@ -15,6 +16,8 @@ module.exports = { init: function initThemes() { var activeThemeName = settingsCache.get('active_theme'); + i18n.init(); + debug('init themes', activeThemeName); // Register a listener for server-start to load all themes diff --git a/core/server/lib/common/i18n.js b/core/server/lib/common/i18n.js index 4ac5fc20f7..197052eebb 100644 --- a/core/server/lib/common/i18n.js +++ b/core/server/lib/common/i18n.js @@ -1,53 +1,39 @@ -const supportedLocales = ['en'], - chalk = require('chalk'), - fs = require('fs-extra'), - MessageFormat = require('intl-messageformat'), - jp = require('jsonpath'), - isString = require('lodash/isString'), - isObject = require('lodash/isObject'), - isEqual = require('lodash/isEqual'), - merge = require('lodash/merge'), - path = require('path'), - config = require('../../config'), - errors = require('./errors'), - events = require('./events'), - logging = require('./logging'), - settingsCache = require('../../services/settings/cache'), - _private = {}; +const fs = require('fs-extra'); +const path = require('path'); +const MessageFormat = require('intl-messageformat'); +const jp = require('jsonpath'); +const isString = require('lodash/isString'); +const isObject = require('lodash/isObject'); +const isEqual = require('lodash/isEqual'); +const isNil = require('lodash/isNil'); +const merge = require('lodash/merge'); +const get = require('lodash/get'); +const {errors, logging} = require('./'); -// currentLocale, dynamically based on overall settings (key = "default_locale") in the settings db table -// (during Ghost's initialization, settings available inside i18n functions below; see core/server/index.js) -// -// E.g.: en = English (default), es = Spanish, en-US = American English, etc. -// Standard: -// Language tags in HTML and XML -// https://www.w3.org/International/articles/language-tags/ -// -// The corresponding translation files should be at content/themes/mytheme/locales/es.json, etc. -let currentLocale, - activeTheme, - coreStrings, - themeStrings, - I18n; +class I18n { + constructor(locale) { + this._locale = locale || this.defaultLocale(); + this._strings = null; + } -/** - * When active theme changes, we reload theme translations - * We listen on the service event, because of the following known case: - * 1. you override a theme, which is already active - * 2. The data has not changed, no event is triggered. - */ -events.on('services.themes.activated', function () { - I18n.loadThemeTranslations(); -}); + /** + * English is our default locale + */ + defaultLocale() { + return 'en'; + } -/** - * When locale changes, we reload theme translations - */ -events.on('settings.default_locale.edited', function () { - I18n.loadThemeTranslations(); -}); + supportedLocales() { + return [this.defaultLocale()]; + } -I18n = { + /** + * Exporting the current locale (e.g. "en") to make it available for other files as well, + * such as core/frontend/helpers/date.js and core/frontend/helpers/lang.js + */ + locale() { + return this._locale; + } /** * Helper method to find and compile the given data context with a proper string resource. @@ -56,15 +42,10 @@ I18n = { * @param {object} [bindings] * @returns {string} */ - t: function t(path, bindings) { - let string, isTheme, msg; + t(path, bindings) { + let string, msg; - currentLocale = I18n.locale(); - if (bindings !== undefined) { - isTheme = bindings.isThemeString; - delete bindings.isThemeString; - } - string = I18n.findString(path, {isThemeString: isTheme}); + string = this._findString(path); // If the path returns an array (as in the case with anything that has multiple paragraphs such as emails), then // loop through them and return an array of translated/formatted strings. Otherwise, just return the normal @@ -72,36 +53,55 @@ I18n = { if (Array.isArray(string)) { msg = []; string.forEach(function (s) { - let m = new MessageFormat(s, currentLocale); - - try { - m.format(bindings); - } catch (err) { - logging.error(err.message); - - // fallback - m = new MessageFormat(coreStrings.errors.errors.anErrorOccurred, currentLocale); - m = msg.format(); - } - - msg.push(m); + msg.push(this._formatMessage(s, bindings)); }); } else { - msg = new MessageFormat(string, currentLocale); - - try { - msg = msg.format(bindings); - } catch (err) { - logging.error(err.message); - - // fallback - msg = new MessageFormat(coreStrings.errors.errors.anErrorOccurred, currentLocale); - msg = msg.format(); - } + msg = this._formatMessage(string, bindings); } return msg; - }, + } + + /** + * Setup i18n support: + * - Load proper language file into memory + */ + init() { + // This function is called during Ghost's initialization. + // Reading translation file for messages from core .json files and keeping its content in memory + // The English file is always loaded, until back-end translations are enabled in future versions. + try { + this._strings = this._readStringsFile(__dirname, '..', '..', 'translations', `${this.defaultLocale()}.json`); + } catch (err) { + this._strings = null; + throw err; + } + + this._initializeIntl(); + } + + /** + * Check if a key exists in the loaded strings + * @param {String} msgPath + */ + doesTranslationKeyExist(msgPath) { + const translation = this._findString(msgPath, {log: false}); + return translation !== this._fallbackError(); + } + + /** + * Do the lookup with JSON path + * + * @param {String} msgPath + */ + _getCandidateString(msgPath) { + // Backend messages use dot-notation, and the '$.' prefix is added here + // While bracket-notation allows any Unicode characters in keys for themes, + // dot-notation allows only word characters in keys for backend messages + // (that is \w or [A-Za-z0-9_] in RegExp) + let path = `$.${msgPath}`; + return jp.value(this._strings, path); + } /** * Parse JSON file for matching locale, returns string giving path. @@ -109,42 +109,22 @@ I18n = { * @param {string} msgPath Path with in the JSON language file to desired string (ie: "errors.init.jsNotBuilt") * @returns {string} */ - findString: function findString(msgPath, opts) { + _findString(msgPath, opts) { const options = merge({log: true}, opts || {}); - let candidateString, matchingString, path; + let candidateString, matchingString; // no path? no string if (msgPath.length === 0 || !isString(msgPath)) { - chalk.yellow('i18n.t() - received an empty path.'); + logging.warn('i18n.t() - received an empty path.'); return ''; } // If not in memory, load translations for core - if (coreStrings === undefined) { - I18n.init(); + if (isNil(this._strings)) { + this.init(); } - if (options.isThemeString) { - // If not in memory, load translations for theme - if (themeStrings === undefined) { - I18n.loadThemeTranslations(); - } - // Both jsonpath's dot-notation and bracket-notation start with '$' - // E.g.: $.store.book.title or $['store']['book']['title'] - // The {{t}} translation helper passes the default English text - // The full Unicode jsonpath with '$' is built here - // jp.stringify and jp.value are jsonpath methods - // Info: https://www.npmjs.com/package/jsonpath - path = jp.stringify(['$', msgPath]); - candidateString = jp.value(themeStrings, path) || msgPath; - } else { - // Backend messages use dot-notation, and the '$.' prefix is added here - // While bracket-notation allows any Unicode characters in keys for themes, - // dot-notation allows only word characters in keys for backend messages - // (that is \w or [A-Za-z0-9_] in RegExp) - path = `$.${msgPath}`; - candidateString = jp.value(coreStrings, path); - } + candidateString = this._getCandidateString(msgPath); matchingString = candidateString || {}; @@ -155,126 +135,78 @@ I18n = { })); } - matchingString = coreStrings.errors.errors.anErrorOccurred; + matchingString = this._fallbackError(); } return matchingString; - }, - - doesTranslationKeyExist: function doesTranslationKeyExist(msgPath) { - const translation = I18n.findString(msgPath, {log: false}); - return translation !== coreStrings.errors.errors.anErrorOccurred; - }, + } /** - * Setup i18n support: - * - Load proper language file into memory + * Resolve filepath, read file, and attempt a parse + * Error handling to be done by consumer + * + * @param {...String} pathParts */ - init: function init() { - // This function is called during Ghost's initialization. - // Reading translation file for messages from core .js files and keeping its content in memory - // The English file is always loaded, until back-end translations are enabled in future versions. - // Before that, see previous tasks on issue #6526 (error codes or identifiers, error message - // translation at the point of display...) - coreStrings = fs.readFileSync(path.join(__dirname, '..', '..', 'translations', 'en.json')); + _readStringsFile(...pathParts) { + const content = fs.readFileSync(path.join(...pathParts)); + return JSON.parse(content); + } + + /** + * Format the string using the correct locale and applying any bindings + * @param {String} string + * @param {Object} bindings + */ + _formatMessage(string, bindings) { + let currentLocale = this.locale(); + let msg = new MessageFormat(string, currentLocale); - // if translation file is not valid, you will see an error try { - coreStrings = JSON.parse(coreStrings); + msg = msg.format(bindings); } catch (err) { - coreStrings = undefined; - throw err; + logging.error(err.message); + + // fallback + msg = new MessageFormat(this._fallbackError(), currentLocale); + msg = msg.format(); } - _private.initializeIntl(); - }, + return msg; + } /** - * Setup i18n support for themes: - * - Load proper language file into memory + * [Private] Setup i18n support: + * - Polyfill node.js if it does not have Intl support or support for a particular locale */ - loadThemeTranslations() { - // This function is called during theme initialization, and when switching language or theme. - currentLocale = I18n.locale(); - activeTheme = settingsCache.get('active_theme'); - themeStrings = undefined; + _initializeIntl() { + let hasBuiltInLocaleData, IntlPolyfill; - const _tryGetLocale = (locale) => { - try { - const readBuffer = fs.readFileSync( - path.join(config.getContentPath('themes'), activeTheme, 'locales', locale + '.json') - ); - return JSON.parse(readBuffer); - } catch (err) { - if (err.code === 'ENOENT') { - if (locale !== 'en') { - logging.warn(`Theme's file locales/${locale}.json not found.`); - } - } else if (err instanceof SyntaxError) { - logging.error(new errors.IncorrectUsageError({ - err, - message: `Unable to parse locales/${locale}.json. Please check that it is valid JSON.` - })); - } else { - throw err; - } - } - }; - - // Reading file for current locale and active theme and keeping its content in memory - if (activeTheme) { - // Reading translation file for theme .hbs templates. - // Compatibility with both old themes and i18n-capable themes. - // Preventing missing files. - themeStrings = _tryGetLocale(currentLocale); - - if (!themeStrings && currentLocale !== 'en') { - logging.warn('Falling back to locales/en.json.'); - themeStrings = _tryGetLocale('en'); + if (global.Intl) { + // Determine if the built-in `Intl` has the locale data we need. + hasBuiltInLocaleData = this.supportedLocales().every(function (locale) { + return Intl.NumberFormat.supportedLocalesOf(locale)[0] === locale && + Intl.DateTimeFormat.supportedLocalesOf(locale)[0] === locale; + }); + if (!hasBuiltInLocaleData) { + // `Intl` exists, but it doesn't have the data we need, so load the + // polyfill and replace the constructors with need with the polyfill's. + IntlPolyfill = require('intl'); + Intl.NumberFormat = IntlPolyfill.NumberFormat; + Intl.DateTimeFormat = IntlPolyfill.DateTimeFormat; } + } else { + // No `Intl`, so use and load the polyfill. + global.Intl = require('intl'); } - - if (themeStrings === undefined) { - // even if empty, themeStrings must be an object for jp.value - themeStrings = {}; - } - - _private.initializeIntl(); - }, + } /** - * Exporting the current locale (e.g. "en") to make it available for other files as well, - * such as core/frontend/helpers/date.js and core/frontend/helpers/lang.js + * A really basic error for if everything goes wrong */ - locale: function locale() { - return settingsCache.get('default_locale'); + _fallbackError() { + return get(this._strings, 'errors.errors.anErrorOccurred', 'An error occurred'); } -}; +} -/** - * Setup i18n support: - * - Polyfill node.js if it does not have Intl support or support for a particular locale - */ -_private.initializeIntl = function initializeIntl() { - let hasBuiltInLocaleData, IntlPolyfill; - - if (global.Intl) { - // Determine if the built-in `Intl` has the locale data we need. - hasBuiltInLocaleData = supportedLocales.every(function (locale) { - return Intl.NumberFormat.supportedLocalesOf(locale)[0] === locale && - Intl.DateTimeFormat.supportedLocalesOf(locale)[0] === locale; - }); - if (!hasBuiltInLocaleData) { - // `Intl` exists, but it doesn't have the data we need, so load the - // polyfill and replace the constructors with need with the polyfill's. - IntlPolyfill = require('intl'); - Intl.NumberFormat = IntlPolyfill.NumberFormat; - Intl.DateTimeFormat = IntlPolyfill.DateTimeFormat; - } - } else { - // No `Intl`, so use and load the polyfill. - global.Intl = require('intl'); - } -}; - -module.exports = I18n; +module.exports = new I18n(); +module.exports.I18n = I18n; diff --git a/core/test/unit/helpers/lang_spec.js b/core/test/unit/helpers/lang_spec.js index 77c26e48ac..53cb9fbaba 100644 --- a/core/test/unit/helpers/lang_spec.js +++ b/core/test/unit/helpers/lang_spec.js @@ -1,7 +1,7 @@ -const should = require('should'), - settingsCache = require('../../../server/services/settings/cache'), - helpers = require('../../../frontend/helpers'), - proxy = require('../../../frontend/helpers/proxy'); +const should = require('should'); +const settingsCache = require('../../../server/services/settings/cache'); +const helpers = require('../../../frontend/helpers'); +const proxy = require('../../../frontend/helpers/proxy'); describe('{{lang}} helper', function () { beforeEach(function () { @@ -13,7 +13,7 @@ describe('{{lang}} helper', function () { }); it('returns correct language tag', function () { - let expected = proxy.i18n.locale(), + let expected = proxy.themeI18n.locale(), rendered = helpers.lang.call(); should.exist(rendered); diff --git a/core/test/unit/helpers/t_spec.js b/core/test/unit/helpers/t_spec.js index c8ac1561cd..13c2bfbd66 100644 --- a/core/test/unit/helpers/t_spec.js +++ b/core/test/unit/helpers/t_spec.js @@ -1,9 +1,9 @@ -const should = require('should'), - path = require('path'), - settingsCache = require('../../../server/services/settings/cache'), - helpers = require('../../../frontend/helpers'), - common = require('../../../server/lib/common'), - configUtils = require('../../utils/configUtils'); +const should = require('should'); +const path = require('path'); +const settingsCache = require('../../../server/services/settings/cache'); +const helpers = require('../../../frontend/helpers'); +const themeI18n = require('../../../frontend/services/themes/i18n'); +const configUtils = require('../../utils/configUtils'); describe('{{t}} helper', function () { beforeEach(function () { @@ -18,6 +18,7 @@ describe('{{t}} helper', function () { it('theme translation is DE', function () { settingsCache.set('default_locale', {value: 'de'}); + themeI18n.init(); let rendered = helpers.t.call({}, 'Top left Button', { hash: {} @@ -28,7 +29,7 @@ describe('{{t}} helper', function () { it('theme translation is EN', function () { settingsCache.set('default_locale', {value: 'en'}); - common.i18n.loadThemeTranslations(); + themeI18n.init(); let rendered = helpers.t.call({}, 'Top left Button', { hash: {} @@ -39,7 +40,7 @@ describe('{{t}} helper', function () { it('[fallback] no theme translation file found for FR', function () { settingsCache.set('default_locale', {value: 'fr'}); - common.i18n.loadThemeTranslations(); + themeI18n.init(); let rendered = helpers.t.call({}, 'Top left Button', { hash: {} @@ -51,7 +52,7 @@ describe('{{t}} helper', function () { it('[fallback] no theme files at all, use key as translation', function () { settingsCache.set('active_theme', {value: 'casper-1.4'}); settingsCache.set('default_locale', {value: 'de'}); - common.i18n.loadThemeTranslations(); + themeI18n.init(); let rendered = helpers.t.call({}, 'Top left Button', { hash: {}