0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Moved theme i18n into theme service + refactor (#11674)

- The existing common.i18n library contained code for core and theme translations
- There is some shared logic and some theme-specific logic, and the theme-specific logic has dependencies we don't want in lib/common
- This refactor introduces an I18n base class that does all the main shared logic, with no dependencies on other parts of the codebase
- ThemeI18n then extends this logic, and replaces the functions it needs to handle differently and adds it's dependencies on config and settingsCache
- The class has several methods broken down into smaller pieces to make it easier to extend only the necessary parts
- The class also encapsulates all of its logic, without external functions or variables
- The function loadThemeTranslations becomes the 'init()' function overridden in themeI18n.
This commit is contained in:
Hannah Wolfe 2020-03-19 14:07:20 +00:00 committed by GitHub
parent 7e4de9b0c1
commit 5e2c62e328
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 279 additions and 230 deletions

View file

@ -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();

View file

@ -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());
};

View file

@ -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,

View file

@ -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);
};

View file

@ -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;

View file

@ -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

View file

@ -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;

View file

@ -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);

View file

@ -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: {}