mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 14:03:48 +03:00
Moved core/shared/i18n to theme-engine/i18n
- core/shared/i18n is no longer used. Remove it to prevent temptation! - this class needs merging with the one in themes, but for now just co-locate them as that's quicker and easier
This commit is contained in:
parent
f4fb0fcbaa
commit
7280f82722
@ -1,73 +1,312 @@
|
||||
const errors = require('@tryghost/errors');
|
||||
const i18n = require('../../../../shared/i18n');
|
||||
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');
|
||||
|
||||
class ThemeI18n extends i18n.I18n {
|
||||
class I18n {
|
||||
/**
|
||||
* @param {objec} [options]
|
||||
* @param {string} basePath - the base path for the translation directory (e.g. where themes live)
|
||||
* @param {string} basePath - the base path to the translations directory
|
||||
* @param {string} [locale] - a locale string
|
||||
* @param {{dot|fulltext}} [stringMode] - which mode our translation keys use
|
||||
* @param {{object}} [logging] - logging method
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super(options);
|
||||
// We don't care what gets passed in, themes use fulltext mode
|
||||
this._stringMode = 'fulltext';
|
||||
this._basePath = options.basePath || __dirname;
|
||||
this._locale = options.locale || this.defaultLocale();
|
||||
this._stringMode = options.stringMode || 'dot';
|
||||
this._logging = options.logging || console;
|
||||
|
||||
this._strings = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup i18n support for themes:
|
||||
* - Load correct language file into memory
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {String} options.activeTheme - name of the currently loaded theme
|
||||
* @param {String} options.locale - name of the currently loaded locale
|
||||
*
|
||||
* BasePath getter & setter used for testing
|
||||
*/
|
||||
init({activeTheme, locale} = {}) {
|
||||
// This function is called during theme initialization, and when switching language or theme.
|
||||
this._locale = locale || this._locale;
|
||||
this._activetheme = activeTheme || this._activetheme;
|
||||
set basePath(basePath) {
|
||||
this._basePath = basePath;
|
||||
}
|
||||
|
||||
super.init();
|
||||
/**
|
||||
* Need to call init after this
|
||||
*/
|
||||
get basePath() {
|
||||
return this._basePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* English is our default locale
|
||||
*/
|
||||
defaultLocale() {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
supportedLocales() {
|
||||
return [this.defaultLocale()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {string} translationPath Path within the JSON language file to desired string (ie: "errors.init.jsNotBuilt")
|
||||
* @param {object} [bindings]
|
||||
* @returns {string}
|
||||
*/
|
||||
t(translationPath, bindings) {
|
||||
let string;
|
||||
let msg;
|
||||
|
||||
string = this._findString(translationPath);
|
||||
|
||||
// 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
|
||||
// translated/formatted string.
|
||||
if (Array.isArray(string)) {
|
||||
msg = [];
|
||||
string.forEach(function (s) {
|
||||
msg.push(this._formatMessage(s, bindings));
|
||||
});
|
||||
} else {
|
||||
msg = this._formatMessage(string, bindings);
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup i18n support:
|
||||
* - Load proper language file into memory
|
||||
*/
|
||||
init() {
|
||||
this._strings = this._loadStrings();
|
||||
|
||||
this._initializeIntl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to load strings from a file
|
||||
*
|
||||
* @param {sting} [locale]
|
||||
* @returns {object} strings
|
||||
*/
|
||||
_loadStrings(locale) {
|
||||
locale = locale || this.locale();
|
||||
|
||||
try {
|
||||
return this._readTranslationsFile(locale);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
this._handleMissingFileError(locale, err);
|
||||
|
||||
if (locale !== this.defaultLocale()) {
|
||||
this._handleFallbackToDefault();
|
||||
return this._loadStrings(this.defaultLocale());
|
||||
}
|
||||
} else if (err instanceof SyntaxError) {
|
||||
this._handleInvalidFileError(locale, err);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// At this point we've done all we can and strings must be an object
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Do the lookup within the JSON file using jsonpath
|
||||
*
|
||||
* @param {String} msgPath
|
||||
*/
|
||||
_getCandidateString(msgPath) {
|
||||
// Our default string mode is "dot" for dot-notation, e.g. $.something.like.this used in the backend
|
||||
// Both jsonpath's dot-notation and bracket-notation start with '$' E.g.: $.store.book.title or $['store']['book']['title']
|
||||
// While bracket-notation allows any Unicode characters in keys (i.e. for themes / fulltext mode) E.g. $['Read more']
|
||||
// dot-notation allows only word characters in keys for backend messages (that is \w or [A-Za-z0-9_] in RegExp)
|
||||
let jsonPath = `$.${msgPath}`;
|
||||
let fallback = null;
|
||||
|
||||
if (this._stringMode === 'fulltext') {
|
||||
jsonPath = jp.stringify(['$', msgPath]);
|
||||
// In fulltext mode we can use the passed string as a fallback
|
||||
fallback = msgPath;
|
||||
}
|
||||
|
||||
try {
|
||||
return jp.value(this._strings, jsonPath) || fallback;
|
||||
} catch (err) {
|
||||
this._handleInvalidKeyError(msgPath, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON file for matching locale, returns string giving path.
|
||||
*
|
||||
* @param {string} msgPath Path with in the JSON language file to desired string (ie: "errors.init.jsNotBuilt")
|
||||
* @returns {string}
|
||||
*/
|
||||
_findString(msgPath, opts) {
|
||||
const options = merge({log: true}, opts || {});
|
||||
let candidateString;
|
||||
let matchingString;
|
||||
|
||||
// no path? no string
|
||||
if (msgPath.length === 0 || !isString(msgPath)) {
|
||||
this._handleEmptyKeyError();
|
||||
return '';
|
||||
}
|
||||
|
||||
// If not in memory, load translations for core
|
||||
if (isNil(this._strings)) {
|
||||
this._handleUninitialisedError(msgPath);
|
||||
}
|
||||
|
||||
candidateString = this._getCandidateString(msgPath);
|
||||
|
||||
matchingString = candidateString || {};
|
||||
|
||||
if (isObject(matchingString) || isEqual(matchingString, {})) {
|
||||
if (options.log) {
|
||||
this._handleMissingKeyError(msgPath);
|
||||
}
|
||||
|
||||
matchingString = this._fallbackError();
|
||||
}
|
||||
|
||||
return matchingString;
|
||||
}
|
||||
|
||||
_translationFileDirs() {
|
||||
return [this.basePath, this._activetheme, 'locales'];
|
||||
return [this.basePath];
|
||||
}
|
||||
|
||||
// If we are passed a locale, use that, else use this.locale
|
||||
_translationFileName(locale) {
|
||||
return `${locale || this.locale()}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the translations file
|
||||
* Error handling to be done by consumer
|
||||
*
|
||||
* @param {string} locale
|
||||
*/
|
||||
_readTranslationsFile(locale) {
|
||||
const filePath = path.join(...this._translationFileDirs(), this._translationFileName(locale));
|
||||
const content = fs.readFileSync(filePath);
|
||||
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);
|
||||
|
||||
try {
|
||||
msg = msg.format(bindings);
|
||||
} catch (err) {
|
||||
this._handleFormatError(err);
|
||||
|
||||
// fallback
|
||||
msg = new MessageFormat(this._fallbackError(), currentLocale);
|
||||
msg = msg.format();
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* [Private] Setup i18n support:
|
||||
* - Polyfill node.js if it does not have Intl support or support for a particular locale
|
||||
*/
|
||||
_initializeIntl() {
|
||||
let hasBuiltInLocaleData;
|
||||
let IntlPolyfill;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
_handleUninitialisedError(key) {
|
||||
throw new errors.IncorrectUsageError({message: `Theme translation was used before it was initialised with key ${key}`});
|
||||
this._logging.warn(`i18n was used before it was initialised with key ${key}`);
|
||||
this.init();
|
||||
}
|
||||
|
||||
_handleFormatError(err) {
|
||||
this._logging.error(err.message);
|
||||
}
|
||||
|
||||
_handleFallbackToDefault() {
|
||||
this._logging.warn(`Theme translations falling back to locales/${this.defaultLocale()}.json.`);
|
||||
this._logging.warn(`i18n is falling back to ${this.defaultLocale()}.json.`);
|
||||
}
|
||||
|
||||
_handleMissingFileError(locale) {
|
||||
if (locale !== this.defaultLocale()) {
|
||||
this._logging.warn(`Theme translations file locales/${locale}.json not found.`);
|
||||
}
|
||||
this._logging.warn(`i18n was unable to find ${locale}.json.`);
|
||||
}
|
||||
_handleInvalidFileError(locale, err) {
|
||||
this._logging.error(new errors.IncorrectUsageError({
|
||||
err,
|
||||
message: `Theme translations unable to parse locales/${locale}.json. Please check that it is valid JSON.`
|
||||
message: `i18n was unable to parse ${locale}.json. Please check that it is valid JSON.`
|
||||
}));
|
||||
}
|
||||
|
||||
_handleEmptyKeyError() {
|
||||
this._logging.warn('Theme translations {{t}} helper called without a translation key.');
|
||||
this._logging.warn('i18n.t() was called without a key');
|
||||
}
|
||||
|
||||
_handleMissingKeyError() {
|
||||
// This case cannot be reached in themes as we use the key as the fallback
|
||||
_handleMissingKeyError(key) {
|
||||
this._logging.error(new errors.IncorrectUsageError({
|
||||
message: `i18n.t() was called with a key that could not be found: ${key}`
|
||||
}));
|
||||
}
|
||||
|
||||
_handleInvalidKeyError(key, err) {
|
||||
throw new errors.IncorrectUsageError({
|
||||
err,
|
||||
message: `Theme translations {{t}} helper called with an invalid translation key: ${key}`
|
||||
message: `i18n.t() called with an invalid key: ${key}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A really basic error for if everything goes wrong
|
||||
*/
|
||||
_fallbackError() {
|
||||
return get(this._strings, 'errors.errors.anErrorOccurred', 'An error occurred');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ThemeI18n;
|
||||
module.exports = I18n;
|
||||
|
@ -1,7 +1,7 @@
|
||||
const config = require('../../../../shared/config');
|
||||
const logging = require('@tryghost/logging');
|
||||
|
||||
const ThemeI18n = require('./i18n');
|
||||
const ThemeI18n = require('./theme-i18n');
|
||||
|
||||
module.exports = new ThemeI18n({logging, basePath: config.getContentPath('themes')});
|
||||
module.exports.ThemeI18n = ThemeI18n;
|
||||
|
73
core/frontend/services/theme-engine/i18n/theme-i18n.js
Normal file
73
core/frontend/services/theme-engine/i18n/theme-i18n.js
Normal file
@ -0,0 +1,73 @@
|
||||
const errors = require('@tryghost/errors');
|
||||
const I18n = require('./i18n');
|
||||
|
||||
class ThemeI18n extends I18n {
|
||||
/**
|
||||
* @param {objec} [options]
|
||||
* @param {string} basePath - the base path for the translation directory (e.g. where themes live)
|
||||
* @param {string} [locale] - a locale string
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super(options);
|
||||
// We don't care what gets passed in, themes use fulltext mode
|
||||
this._stringMode = 'fulltext';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup i18n support for themes:
|
||||
* - Load correct language file into memory
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {String} options.activeTheme - name of the currently loaded theme
|
||||
* @param {String} options.locale - name of the currently loaded locale
|
||||
*
|
||||
*/
|
||||
init({activeTheme, locale} = {}) {
|
||||
// This function is called during theme initialization, and when switching language or theme.
|
||||
this._locale = locale || this._locale;
|
||||
this._activetheme = activeTheme || this._activetheme;
|
||||
|
||||
super.init();
|
||||
}
|
||||
|
||||
_translationFileDirs() {
|
||||
return [this.basePath, this._activetheme, 'locales'];
|
||||
}
|
||||
|
||||
_handleUninitialisedError(key) {
|
||||
throw new errors.IncorrectUsageError({message: `Theme translation was used before it was initialised with key ${key}`});
|
||||
}
|
||||
|
||||
_handleFallbackToDefault() {
|
||||
this._logging.warn(`Theme translations falling back to locales/${this.defaultLocale()}.json.`);
|
||||
}
|
||||
|
||||
_handleMissingFileError(locale) {
|
||||
if (locale !== this.defaultLocale()) {
|
||||
this._logging.warn(`Theme translations file locales/${locale}.json not found.`);
|
||||
}
|
||||
}
|
||||
_handleInvalidFileError(locale, err) {
|
||||
this._logging.error(new errors.IncorrectUsageError({
|
||||
err,
|
||||
message: `Theme translations unable to parse locales/${locale}.json. Please check that it is valid JSON.`
|
||||
}));
|
||||
}
|
||||
|
||||
_handleEmptyKeyError() {
|
||||
this._logging.warn('Theme translations {{t}} helper called without a translation key.');
|
||||
}
|
||||
|
||||
_handleMissingKeyError() {
|
||||
// This case cannot be reached in themes as we use the key as the fallback
|
||||
}
|
||||
|
||||
_handleInvalidKeyError(key, err) {
|
||||
throw new errors.IncorrectUsageError({
|
||||
err,
|
||||
message: `Theme translations {{t}} helper called with an invalid translation key: ${key}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ThemeI18n;
|
@ -1,312 +0,0 @@
|
||||
const errors = require('@tryghost/errors');
|
||||
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');
|
||||
|
||||
class I18n {
|
||||
/**
|
||||
* @param {objec} [options]
|
||||
* @param {string} basePath - the base path to the translations directory
|
||||
* @param {string} [locale] - a locale string
|
||||
* @param {{dot|fulltext}} [stringMode] - which mode our translation keys use
|
||||
* @param {{object}} [logging] - logging method
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this._basePath = options.basePath || __dirname;
|
||||
this._locale = options.locale || this.defaultLocale();
|
||||
this._stringMode = options.stringMode || 'dot';
|
||||
this._logging = options.logging || console;
|
||||
|
||||
this._strings = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* BasePath getter & setter used for testing
|
||||
*/
|
||||
set basePath(basePath) {
|
||||
this._basePath = basePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Need to call init after this
|
||||
*/
|
||||
get basePath() {
|
||||
return this._basePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* English is our default locale
|
||||
*/
|
||||
defaultLocale() {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
supportedLocales() {
|
||||
return [this.defaultLocale()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {string} translationPath Path within the JSON language file to desired string (ie: "errors.init.jsNotBuilt")
|
||||
* @param {object} [bindings]
|
||||
* @returns {string}
|
||||
*/
|
||||
t(translationPath, bindings) {
|
||||
let string;
|
||||
let msg;
|
||||
|
||||
string = this._findString(translationPath);
|
||||
|
||||
// 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
|
||||
// translated/formatted string.
|
||||
if (Array.isArray(string)) {
|
||||
msg = [];
|
||||
string.forEach(function (s) {
|
||||
msg.push(this._formatMessage(s, bindings));
|
||||
});
|
||||
} else {
|
||||
msg = this._formatMessage(string, bindings);
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup i18n support:
|
||||
* - Load proper language file into memory
|
||||
*/
|
||||
init() {
|
||||
this._strings = this._loadStrings();
|
||||
|
||||
this._initializeIntl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to load strings from a file
|
||||
*
|
||||
* @param {sting} [locale]
|
||||
* @returns {object} strings
|
||||
*/
|
||||
_loadStrings(locale) {
|
||||
locale = locale || this.locale();
|
||||
|
||||
try {
|
||||
return this._readTranslationsFile(locale);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
this._handleMissingFileError(locale, err);
|
||||
|
||||
if (locale !== this.defaultLocale()) {
|
||||
this._handleFallbackToDefault();
|
||||
return this._loadStrings(this.defaultLocale());
|
||||
}
|
||||
} else if (err instanceof SyntaxError) {
|
||||
this._handleInvalidFileError(locale, err);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// At this point we've done all we can and strings must be an object
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Do the lookup within the JSON file using jsonpath
|
||||
*
|
||||
* @param {String} msgPath
|
||||
*/
|
||||
_getCandidateString(msgPath) {
|
||||
// Our default string mode is "dot" for dot-notation, e.g. $.something.like.this used in the backend
|
||||
// Both jsonpath's dot-notation and bracket-notation start with '$' E.g.: $.store.book.title or $['store']['book']['title']
|
||||
// While bracket-notation allows any Unicode characters in keys (i.e. for themes / fulltext mode) E.g. $['Read more']
|
||||
// dot-notation allows only word characters in keys for backend messages (that is \w or [A-Za-z0-9_] in RegExp)
|
||||
let jsonPath = `$.${msgPath}`;
|
||||
let fallback = null;
|
||||
|
||||
if (this._stringMode === 'fulltext') {
|
||||
jsonPath = jp.stringify(['$', msgPath]);
|
||||
// In fulltext mode we can use the passed string as a fallback
|
||||
fallback = msgPath;
|
||||
}
|
||||
|
||||
try {
|
||||
return jp.value(this._strings, jsonPath) || fallback;
|
||||
} catch (err) {
|
||||
this._handleInvalidKeyError(msgPath, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON file for matching locale, returns string giving path.
|
||||
*
|
||||
* @param {string} msgPath Path with in the JSON language file to desired string (ie: "errors.init.jsNotBuilt")
|
||||
* @returns {string}
|
||||
*/
|
||||
_findString(msgPath, opts) {
|
||||
const options = merge({log: true}, opts || {});
|
||||
let candidateString;
|
||||
let matchingString;
|
||||
|
||||
// no path? no string
|
||||
if (msgPath.length === 0 || !isString(msgPath)) {
|
||||
this._handleEmptyKeyError();
|
||||
return '';
|
||||
}
|
||||
|
||||
// If not in memory, load translations for core
|
||||
if (isNil(this._strings)) {
|
||||
this._handleUninitialisedError(msgPath);
|
||||
}
|
||||
|
||||
candidateString = this._getCandidateString(msgPath);
|
||||
|
||||
matchingString = candidateString || {};
|
||||
|
||||
if (isObject(matchingString) || isEqual(matchingString, {})) {
|
||||
if (options.log) {
|
||||
this._handleMissingKeyError(msgPath);
|
||||
}
|
||||
|
||||
matchingString = this._fallbackError();
|
||||
}
|
||||
|
||||
return matchingString;
|
||||
}
|
||||
|
||||
_translationFileDirs() {
|
||||
return [this.basePath];
|
||||
}
|
||||
|
||||
// If we are passed a locale, use that, else use this.locale
|
||||
_translationFileName(locale) {
|
||||
return `${locale || this.locale()}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the translations file
|
||||
* Error handling to be done by consumer
|
||||
*
|
||||
* @param {string} locale
|
||||
*/
|
||||
_readTranslationsFile(locale) {
|
||||
const filePath = path.join(...this._translationFileDirs(), this._translationFileName(locale));
|
||||
const content = fs.readFileSync(filePath);
|
||||
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);
|
||||
|
||||
try {
|
||||
msg = msg.format(bindings);
|
||||
} catch (err) {
|
||||
this._handleFormatError(err);
|
||||
|
||||
// fallback
|
||||
msg = new MessageFormat(this._fallbackError(), currentLocale);
|
||||
msg = msg.format();
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* [Private] Setup i18n support:
|
||||
* - Polyfill node.js if it does not have Intl support or support for a particular locale
|
||||
*/
|
||||
_initializeIntl() {
|
||||
let hasBuiltInLocaleData;
|
||||
let IntlPolyfill;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
_handleUninitialisedError(key) {
|
||||
this._logging.warn(`i18n was used before it was initialised with key ${key}`);
|
||||
this.init();
|
||||
}
|
||||
|
||||
_handleFormatError(err) {
|
||||
this._logging.error(err.message);
|
||||
}
|
||||
|
||||
_handleFallbackToDefault() {
|
||||
this._logging.warn(`i18n is falling back to ${this.defaultLocale()}.json.`);
|
||||
}
|
||||
|
||||
_handleMissingFileError(locale) {
|
||||
this._logging.warn(`i18n was unable to find ${locale}.json.`);
|
||||
}
|
||||
_handleInvalidFileError(locale, err) {
|
||||
this._logging.error(new errors.IncorrectUsageError({
|
||||
err,
|
||||
message: `i18n was unable to parse ${locale}.json. Please check that it is valid JSON.`
|
||||
}));
|
||||
}
|
||||
|
||||
_handleEmptyKeyError() {
|
||||
this._logging.warn('i18n.t() was called without a key');
|
||||
}
|
||||
|
||||
_handleMissingKeyError(key) {
|
||||
this._logging.error(new errors.IncorrectUsageError({
|
||||
message: `i18n.t() was called with a key that could not be found: ${key}`
|
||||
}));
|
||||
}
|
||||
|
||||
_handleInvalidKeyError(key, err) {
|
||||
throw new errors.IncorrectUsageError({
|
||||
err,
|
||||
message: `i18n.t() called with an invalid key: ${key}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A really basic error for if everything goes wrong
|
||||
*/
|
||||
_fallbackError() {
|
||||
return get(this._strings, 'errors.errors.anErrorOccurred', 'An error occurred');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = I18n;
|
@ -1,6 +0,0 @@
|
||||
const path = require('path');
|
||||
const logging = require('@tryghost/logging');
|
||||
const I18n = require('./i18n');
|
||||
|
||||
module.exports = new I18n({logging, basePath: path.join(__dirname, 'translations')});
|
||||
module.exports.I18n = I18n;
|
@ -1,10 +1,90 @@
|
||||
const should = require('should');
|
||||
const sinon = require('sinon');
|
||||
|
||||
const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18n').ThemeI18n;
|
||||
const I18n = require('../../../../../core/frontend/services/theme-engine/i18n/i18n');
|
||||
|
||||
describe('ThemeI18n Class Behaviour', function () {
|
||||
const logging = {
|
||||
warn: sinon.stub(),
|
||||
error: sinon.stub()
|
||||
};
|
||||
|
||||
describe('I18n Class Behaviour', function () {
|
||||
it('defaults to en', function () {
|
||||
const i18n = new ThemeI18n();
|
||||
const i18n = new I18n({logging});
|
||||
i18n.locale().should.eql('en');
|
||||
});
|
||||
|
||||
it('can have a different locale set', function () {
|
||||
const i18n = new I18n({locale: 'fr', logging});
|
||||
i18n.locale().should.eql('fr');
|
||||
});
|
||||
|
||||
describe('file loading behaviour', function () {
|
||||
it('will fallback to en file correctly without changing locale', function () {
|
||||
const i18n = new I18n({locale: 'fr', logging});
|
||||
|
||||
let fileSpy = sinon.spy(i18n, '_readTranslationsFile');
|
||||
|
||||
i18n.locale().should.eql('fr');
|
||||
i18n.init();
|
||||
|
||||
i18n.locale().should.eql('fr');
|
||||
fileSpy.calledTwice.should.be.true();
|
||||
fileSpy.secondCall.args[0].should.eql('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translation key dot notation (default behaviour)', function () {
|
||||
const fakeStrings = {
|
||||
test: {string: {path: 'I am correct'}}
|
||||
};
|
||||
let i18n;
|
||||
|
||||
beforeEach(function initBasicI18n() {
|
||||
i18n = new I18n({logging});
|
||||
sinon.stub(i18n, '_loadStrings').returns(fakeStrings);
|
||||
i18n.init();
|
||||
});
|
||||
|
||||
it('correctly loads strings', function () {
|
||||
i18n._strings.should.eql(fakeStrings);
|
||||
});
|
||||
|
||||
it('correctly uses dot notation', function () {
|
||||
i18n.t('test.string.path').should.eql('I am correct');
|
||||
});
|
||||
|
||||
it('uses key fallback correctly', function () {
|
||||
i18n.t('unknown.string').should.eql('An error occurred');
|
||||
});
|
||||
|
||||
it('errors for invalid strings', function () {
|
||||
should(function () {
|
||||
i18n.t('unknown string');
|
||||
}).throw('i18n.t() called with an invalid key: unknown string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translation key fulltext notation (theme behaviour)', function () {
|
||||
const fakeStrings = {'Full text': 'I am correct'};
|
||||
let i18n;
|
||||
|
||||
beforeEach(function initFulltextI18n() {
|
||||
i18n = new I18n({stringMode: 'fulltext', logging});
|
||||
sinon.stub(i18n, '_loadStrings').returns(fakeStrings);
|
||||
i18n.init();
|
||||
});
|
||||
|
||||
it('correctly loads strings', function () {
|
||||
i18n._strings.should.eql(fakeStrings);
|
||||
});
|
||||
|
||||
it('correctly uses fulltext with bracket notation', function () {
|
||||
i18n.t('Full text').should.eql('I am correct');
|
||||
});
|
||||
|
||||
it('uses key fallback correctly', function () {
|
||||
i18n.t('unknown string').should.eql('unknown string');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
10
test/unit/frontend/services/theme-engine/theme-i18n.test.js
Normal file
10
test/unit/frontend/services/theme-engine/theme-i18n.test.js
Normal file
@ -0,0 +1,10 @@
|
||||
const should = require('should');
|
||||
|
||||
const ThemeI18n = require('../../../../../core/frontend/services/theme-engine/i18n').ThemeI18n;
|
||||
|
||||
describe('ThemeI18n Class Behaviour', function () {
|
||||
it('defaults to en', function () {
|
||||
const i18n = new ThemeI18n();
|
||||
i18n.locale().should.eql('en');
|
||||
});
|
||||
});
|
@ -1,90 +0,0 @@
|
||||
const should = require('should');
|
||||
const sinon = require('sinon');
|
||||
|
||||
const I18n = require('../../../core/shared/i18n').I18n;
|
||||
|
||||
const logging = {
|
||||
warn: sinon.stub(),
|
||||
error: sinon.stub()
|
||||
};
|
||||
|
||||
describe('I18n Class Behaviour', function () {
|
||||
it('defaults to en', function () {
|
||||
const i18n = new I18n({logging});
|
||||
i18n.locale().should.eql('en');
|
||||
});
|
||||
|
||||
it('can have a different locale set', function () {
|
||||
const i18n = new I18n({locale: 'fr', logging});
|
||||
i18n.locale().should.eql('fr');
|
||||
});
|
||||
|
||||
describe('file loading behaviour', function () {
|
||||
it('will fallback to en file correctly without changing locale', function () {
|
||||
const i18n = new I18n({locale: 'fr', logging});
|
||||
|
||||
let fileSpy = sinon.spy(i18n, '_readTranslationsFile');
|
||||
|
||||
i18n.locale().should.eql('fr');
|
||||
i18n.init();
|
||||
|
||||
i18n.locale().should.eql('fr');
|
||||
fileSpy.calledTwice.should.be.true();
|
||||
fileSpy.secondCall.args[0].should.eql('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translation key dot notation (default behaviour)', function () {
|
||||
const fakeStrings = {
|
||||
test: {string: {path: 'I am correct'}}
|
||||
};
|
||||
let i18n;
|
||||
|
||||
beforeEach(function initBasicI18n() {
|
||||
i18n = new I18n({logging});
|
||||
sinon.stub(i18n, '_loadStrings').returns(fakeStrings);
|
||||
i18n.init();
|
||||
});
|
||||
|
||||
it('correctly loads strings', function () {
|
||||
i18n._strings.should.eql(fakeStrings);
|
||||
});
|
||||
|
||||
it('correctly uses dot notation', function () {
|
||||
i18n.t('test.string.path').should.eql('I am correct');
|
||||
});
|
||||
|
||||
it('uses key fallback correctly', function () {
|
||||
i18n.t('unknown.string').should.eql('An error occurred');
|
||||
});
|
||||
|
||||
it('errors for invalid strings', function () {
|
||||
should(function () {
|
||||
i18n.t('unknown string');
|
||||
}).throw('i18n.t() called with an invalid key: unknown string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translation key fulltext notation (theme behaviour)', function () {
|
||||
const fakeStrings = {'Full text': 'I am correct'};
|
||||
let i18n;
|
||||
|
||||
beforeEach(function initFulltextI18n() {
|
||||
i18n = new I18n({stringMode: 'fulltext', logging});
|
||||
sinon.stub(i18n, '_loadStrings').returns(fakeStrings);
|
||||
i18n.init();
|
||||
});
|
||||
|
||||
it('correctly loads strings', function () {
|
||||
i18n._strings.should.eql(fakeStrings);
|
||||
});
|
||||
|
||||
it('correctly uses fulltext with bracket notation', function () {
|
||||
i18n.t('Full text').should.eql('I am correct');
|
||||
});
|
||||
|
||||
it('uses key fallback correctly', function () {
|
||||
i18n.t('unknown string').should.eql('unknown string');
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user