Theme translations and blog localisation (#8437)

refs #5345, refs #3801

- Blog localisation
  - default is `en` (English)
  - you can change the language code in the admin panel, see https://github.com/TryGhost/Ghost-Admin/pull/703
  - blog behaviour changes depending on the language e.g. date helper format
  - theme translation get's loaded if available depending on the language setting
  - falls back to english if not available

- Theme translation
  - complete automatic translation of Ghost's frontend for site visitors (themes, etc.), to quickly deploy a site in a non-English language
  - added {{t}} and {{lang}} helper
  - no backend or admin panel translations (!)
  - easily readable translation keys - very simple translation
  - server restart required when adding new language files or changing existing files in the theme
  - no language code validation for now (will be added soon)
  - a full theme translation requires to translate Ghost core templates (e.g. subscriber form)
  - when activating a different theme, theme translations are auto re-loaded
  - when switching language of blog, theme translations are auto re-loaded

- Bump gscan to version 1.3.0 to support more known helpers

**Documentation can be found at https://themes.ghost.org/v1.20.0/docs/i18n.**
This commit is contained in:
juan-g 2018-01-09 14:50:57 +01:00 committed by Katharina Irrgang
parent dcb2aa9ad4
commit f671f9d2c9
16 changed files with 369 additions and 65 deletions

View File

@ -5,10 +5,11 @@
var proxy = require('./proxy'),
moment = require('moment-timezone'),
SafeString = proxy.SafeString;
SafeString = proxy.SafeString,
i18n = proxy.i18n;
module.exports = function (date, options) {
var timezone, format, timeago, timeNow;
var timezone, format, timeago, timeNow, dateMoment;
if (!options && date.hasOwnProperty('hash')) {
options = date;
@ -30,10 +31,16 @@ module.exports = function (date, options) {
timezone = options.data.blog.timezone;
timeNow = moment().tz(timezone);
// i18n: Making dates, including month names, translatable to any language.
// Documentation: http://momentjs.com/docs/#/i18n/
// Locales: https://github.com/moment/moment/tree/develop/locale
dateMoment = moment(date);
dateMoment.locale(i18n.locale());
if (timeago) {
date = timezone ? moment(date).tz(timezone).from(timeNow) : moment(date).fromNow();
date = timezone ? dateMoment.tz(timezone).from(timeNow) : dateMoment.fromNow();
} else {
date = timezone ? moment(date).tz(timezone).format(format) : moment(date).format(format);
date = timezone ? dateMoment.tz(timezone).format(format) : dateMoment.format(format);
}
return new SafeString(date);

View File

@ -19,6 +19,7 @@ coreHelpers.ghost_head = require('./ghost_head');
coreHelpers.img_url = require('./img_url');
coreHelpers.is = require('./is');
coreHelpers.has = require('./has');
coreHelpers.lang = require('./lang');
coreHelpers.meta_description = require('./meta_description');
coreHelpers.meta_title = require('./meta_title');
coreHelpers.navigation = require('./navigation');
@ -29,6 +30,7 @@ coreHelpers.post_class = require('./post_class');
coreHelpers.prev_post = require('./prev_next');
coreHelpers.next_post = require('./prev_next');
coreHelpers.reading_time = require('./reading_time');
coreHelpers.t = require('./t');
coreHelpers.tags = require('./tags');
coreHelpers.title = require('./title');
coreHelpers.twitter_url = require('./twitter_url');
@ -47,6 +49,7 @@ registerAllCoreHelpers = function registerAllCoreHelpers() {
registerThemeHelper('has', coreHelpers.has);
registerThemeHelper('is', coreHelpers.is);
registerThemeHelper('img_url', coreHelpers.img_url);
registerThemeHelper('lang', coreHelpers.lang);
registerThemeHelper('meta_description', coreHelpers.meta_description);
registerThemeHelper('meta_title', coreHelpers.meta_title);
registerThemeHelper('navigation', coreHelpers.navigation);
@ -55,6 +58,7 @@ registerAllCoreHelpers = function registerAllCoreHelpers() {
registerThemeHelper('plural', coreHelpers.plural);
registerThemeHelper('post_class', coreHelpers.post_class);
registerThemeHelper('reading_time', coreHelpers.reading_time);
registerThemeHelper('t', coreHelpers.t);
registerThemeHelper('tags', coreHelpers.tags);
registerThemeHelper('title', coreHelpers.title);
registerThemeHelper('twitter_url', coreHelpers.twitter_url);

View File

@ -0,0 +1,21 @@
// # lang helper
// {{lang}} gives the current language tag
// Usage example: <html lang="{{lang}}">
//
// Examples of language tags from RFC 5646:
// de (German)
// fr (French)
// ja (Japanese)
// en-US (English as used in the United States)
//
// Standard:
// Language tags in HTML and XML
// https://www.w3.org/International/articles/language-tags/
var proxy = require('./proxy'),
i18n = proxy.i18n,
SafeString = proxy.SafeString;
module.exports = function lang() {
return new SafeString(i18n.locale());
};

View File

@ -1,7 +1,9 @@
// # Plural Helper
// Usage: `{{plural 0 empty='No posts' singular='% post' plural='% posts'}}`
// Usage example: `{{plural ../pagination.total empty='No posts' singular='1 post' plural='% posts'}}`
// or for translatable themes, with (t) translation helper's subexpressions:
// `{{plural ../pagination.total empty=(t "No posts") singular=(t "1 post") plural=(t "% posts")}}`
//
// pluralises strings depending on item count
// Pluralises strings depending on item count
//
// The 1st argument is the numeric variable which the helper operates on
// The 2nd argument is the string that will be output if the variable's value is 0
@ -30,4 +32,3 @@ module.exports = function plural(number, options) {
return new SafeString(options.hash.plural.replace('%', number));
}
};

View File

@ -1,22 +1,36 @@
// # Reading Time Helper
//
// Usage: `{{reading_time}}`
// or for translatable themes, with (t) translation helper's subexpressions:
// `{{reading_time seconds=(t "< 1 min read") minute=(t "1 min read") minutes=(t "% min read")}}`
// and in the theme translation file, for example Spanish es.json:
// "< 1 min read": "< 1 min de lectura",
// "1 min read": "1 min de lectura",
// "% min read": "% min de lectura",
//
// Returns estimated reading time for post
var proxy = require('./proxy'),
_ = require('lodash'),
schema = require('../data/schema').checks,
SafeString = proxy.SafeString,
localUtils = proxy.localUtils;
module.exports = function reading_time() {// eslint-disable-line camelcase
module.exports = function reading_time(options) {// eslint-disable-line camelcase
options = options || {};
options.hash = options.hash || {};
var html,
wordsPerMinute = 275,
wordsPerSecond = wordsPerMinute / 60,
wordCount,
imageCount,
readingTimeSeconds,
readingTime;
readingTimeMinutes,
readingTime,
seconds = _.isString(options.hash.seconds) ? options.hash.seconds : '< 1 min read',
minute = _.isString(options.hash.minute) ? options.hash.minute : '1 min read',
minutes = _.isString(options.hash.minutes) ? options.hash.minutes : '% min read';
// only calculate reading time for posts
if (!schema.isPost(this)) {
@ -31,10 +45,14 @@ module.exports = function reading_time() {// eslint-disable-line camelcase
// add 12 seconds to reading time if feature image is present
readingTimeSeconds = imageCount ? readingTimeSeconds + 12 : readingTimeSeconds;
readingTimeMinutes = Math.round(readingTimeSeconds / 60);
if (readingTimeSeconds < 60) {
readingTime = '< 1 min read';
readingTime = seconds;
} else if (readingTimeMinutes === 1) {
readingTime = minute;
} else {
readingTime = `${Math.round(readingTimeSeconds / 60)} min read`;
readingTime = minutes.replace('%', readingTimeMinutes);
}
return new SafeString(readingTime);

26
core/server/helpers/t.js Normal file
View File

@ -0,0 +1,26 @@
// # t helper
// i18n: Translatable handlebars expressions for templates of the front-end and themes.
// Front-end: .hbs templates in core/server, overridden by copies in themes. Themes: in content/themes.
//
// Usage examples, for example in .hbs theme templates:
// {{t "Get the latest posts delivered right to your inbox"}}
// {{{t "Proudly published with {ghostlink}" ghostlink="<a href=\"https://ghost.org\">Ghost</a>"}}}
//
// To preserve HTML, use {{{t}}}. This helper doesn't use a SafeString object which would prevent escaping,
// 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;
module.exports = function t(text, options) {
var bindings = {},
prop;
for (prop in options.hash) {
if (options.hash.hasOwnProperty(prop)) {
bindings[prop] = options.hash[prop];
}
}
bindings.isThemeString = true;
return i18n.t(text, bindings);
};

View File

@ -39,9 +39,10 @@ function init() {
var ghostServer, parentApp;
// Initialize Internationalization
// Initialize default internationalization, just for core now
// (settings for language and theme not yet available here)
common.i18n.init();
debug('I18n done');
debug('Default i18n done for core');
models.init();
debug('models done');
@ -52,6 +53,12 @@ function init() {
return settings.init();
}).then(function () {
debug('Update settings cache done');
// Full internationalization for core could be here
// in a future version with backend translations
// (settings for language and theme available here;
// internationalization for theme is done
// shortly after, when activating the theme)
//
// Initialize the permissions actions and objects
return permissions.init();
}).then(function () {

View File

@ -1,19 +1,48 @@
/* global Intl */
var supportedLocales = ['en'],
_ = require('lodash'),
fs = require('fs-extra'),
path = require('path'),
chalk = require('chalk'),
fs = require('fs-extra'),
MessageFormat = require('intl-messageformat'),
logging = require('./logging'),
jp = require('jsonpath'),
_ = require('lodash'),
path = require('path'),
config = require('../../config'),
errors = require('./errors'),
events = require('./events'),
logging = require('./logging'),
settingsCache = require('../../services/settings/cache'),
_private = {},
// TODO: fetch this dynamically based on overall blog settings (`key = "default_locale"`) in the `settings` table
currentLocale = 'en',
blos,
// 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.
currentLocale,
activeTheme,
coreStrings,
themeStrings,
I18n;
/**
* When active theme changes, we reload theme translations
*/
events.on('settings.active_theme.edited', function () {
I18n.loadThemeTranslations();
});
/**
* When locale changes, we reload theme translations
*/
events.on('settings.default_locale.edited', function () {
I18n.loadThemeTranslations();
});
I18n = {
/**
@ -24,8 +53,14 @@ I18n = {
* @returns {string}
*/
t: function t(path, bindings) {
var string = I18n.findString(path),
msg;
var string, isTheme, msg;
currentLocale = I18n.locale();
if (bindings !== undefined) {
isTheme = bindings.isThemeString;
delete bindings.isThemeString;
}
string = I18n.findString(path, {isThemeString: isTheme});
// 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
@ -41,7 +76,7 @@ I18n = {
logging.error(err.message);
// fallback
m = new MessageFormat(blos.errors.errors.anErrorOccurred, currentLocale);
m = new MessageFormat(coreStrings.errors.errors.anErrorOccurred, currentLocale);
m = msg.format();
}
@ -56,7 +91,7 @@ I18n = {
logging.error(err.message);
// fallback
msg = new MessageFormat(blos.errors.errors.anErrorOccurred, currentLocale);
msg = new MessageFormat(coreStrings.errors.errors.anErrorOccurred, currentLocale);
msg = msg.format();
}
}
@ -72,25 +107,43 @@ I18n = {
*/
findString: function findString(msgPath, opts) {
var options = _.merge({log: true}, opts || {}),
matchingString, path;
candidateString, matchingString, path;
// no path? no string
if (_.isEmpty(msgPath) || !_.isString(msgPath)) {
chalk.yellow('i18n:t() - received an empty path.');
chalk.yellow('i18n.t() - received an empty path.');
return '';
}
if (blos === undefined) {
// If not in memory, load translations for core
if (coreStrings === undefined) {
I18n.init();
}
matchingString = blos;
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);
}
matchingString = candidateString || {};
path = msgPath.split('.');
path.forEach(function (key) {
// reassign matching object, or set to an empty string if there is no match
matchingString = matchingString[key] || {};
});
if (_.isObject(matchingString) || _.isEqual(matchingString, {})) {
if (options.log) {
logging.error(new errors.IncorrectUsageError({
@ -98,7 +151,7 @@ I18n = {
}));
}
matchingString = blos.errors.errors.anErrorOccurred;
matchingString = coreStrings.errors.errors.anErrorOccurred;
}
return matchingString;
@ -106,47 +159,120 @@ I18n = {
doesTranslationKeyExist: function doesTranslationKeyExist(msgPath) {
var translation = I18n.findString(msgPath, {log: false});
return translation !== blos.errors.errors.anErrorOccurred;
return translation !== coreStrings.errors.errors.anErrorOccurred;
},
/**
* Setup i18n support:
* - Load proper language file in to memory
* - Polyfill node.js if it does not have Intl support or support for a particular locale
* - Load proper language file into memory
*/
init: function init() {
// read file for current locale and keep its content in memory
blos = fs.readFileSync(path.join(__dirname, '..', '..', 'translations', currentLocale + '.json'));
// 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'));
// if translation file is not valid, you will see an error
try {
blos = JSON.parse(blos);
coreStrings = JSON.parse(coreStrings);
} catch (err) {
blos = undefined;
coreStrings = undefined;
throw err;
}
if (global.Intl) {
// Determine if the built-in `Intl` has the locale data we need.
var hasBuiltInLocaleData,
IntlPolyfill;
_private.initializeIntl();
},
hasBuiltInLocaleData = supportedLocales.every(function (locale) {
return Intl.NumberFormat.supportedLocalesOf(locale)[0] === locale &&
Intl.DateTimeFormat.supportedLocalesOf(locale)[0] === locale;
});
/**
* Setup i18n support for themes:
* - Load proper language file into memory
*/
loadThemeTranslations: function loadThemeTranslations() {
// This function is called during theme initialization, and when switching language or theme.
currentLocale = I18n.locale();
activeTheme = settingsCache.get('active_theme');
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;
// 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.
try {
themeStrings = fs.readFileSync(path.join(config.getContentPath('themes'), activeTheme, 'locales', currentLocale + '.json'));
} catch (err) {
themeStrings = undefined;
if (err.code === 'ENOENT') {
logging.warn('Theme\'s file locales/' + currentLocale + '.json not found.');
} else {
throw err;
}
}
if (themeStrings === undefined && currentLocale !== 'en') {
logging.warn('Falling back to locales/en.json.');
try {
themeStrings = fs.readFileSync(path.join(config.getContentPath('themes'), activeTheme, 'locales', 'en.json'));
} catch (err) {
themeStrings = undefined;
if (err.code === 'ENOENT') {
logging.warn('Theme\'s file locales/en.json not found.');
} else {
throw err;
}
}
}
if (themeStrings !== undefined) {
// if translation file is not valid, you will see an error
try {
themeStrings = JSON.parse(themeStrings);
} catch (err) {
themeStrings = undefined;
throw err;
}
}
} 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/server/helpers/date.js and core/server/helpers/lang.js
*/
locale: function locale() {
return settingsCache.get('default_locale');
}
};
/**
* Setup i18n support:
* - Polyfill node.js if it does not have Intl support or support for a particular locale
*/
_private.initializeIntl = function initializeIntl() {
var 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');
}
};

View File

@ -10,8 +10,8 @@ describe('Helpers', function () {
var hbsHelpers = ['each', 'if', 'unless', 'with', 'helperMissing', 'blockHelperMissing', 'log', 'lookup'],
ghostHelpers = [
'asset', 'author', 'body_class', 'content', 'date', 'encode', 'excerpt', 'facebook_url', 'foreach', 'get',
'ghost_foot', 'ghost_head', 'has', 'img_url', 'is', 'meta_description', 'meta_title', 'navigation',
'next_post', 'page_url', 'pagination', 'plural', 'post_class', 'prev_post', 'reading_time', 'tags', 'title', 'twitter_url',
'ghost_foot', 'ghost_head', 'has', 'img_url', 'is', 'lang', 'meta_description', 'meta_title', 'navigation',
'next_post', 'page_url', 'pagination', 'plural', 'post_class', 'prev_post', 'reading_time', 't', 'tags', 'title', 'twitter_url',
'url'
],
expectedHelpers = _.concat(hbsHelpers, ghostHelpers);

View File

@ -0,0 +1,24 @@
'use strict';
const should = require('should'),
settingsCache = require('../../../server/services/settings/cache'),
helpers = require('../../../server/helpers'),
proxy = require('../../../server/helpers/proxy');
describe('{{lang}} helper', function () {
beforeEach(function () {
settingsCache.set('default_locale', {value: 'en'});
});
afterEach(function () {
settingsCache.shutdown();
});
it('returns correct language tag', function () {
let expected = proxy.i18n.locale(),
rendered = helpers.lang.call();
should.exist(rendered);
rendered.string.should.equal(expected);
});
});

View File

@ -0,0 +1,64 @@
'use strict';
const should = require('should'),
path = require('path'),
settingsCache = require('../../../server/services/settings/cache'),
helpers = require('../../../server/helpers'),
common = require('../../../server/lib/common'),
configUtils = require('../../utils/configUtils');
describe('{{t}} helper', function () {
beforeEach(function () {
settingsCache.set('active_theme', {value: 'casper'});
configUtils.set('paths:contentPath', path.join(__dirname, '../../utils/fixtures/'));
});
afterEach(function () {
configUtils.restore();
settingsCache.shutdown();
});
it('theme translation is DE', function () {
settingsCache.set('default_locale', {value: 'de'});
let rendered = helpers.t.call({}, 'Top left Button', {
hash: {}
});
rendered.should.eql('Oben Links.');
});
it('theme translation is EN', function () {
settingsCache.set('default_locale', {value: 'en'});
common.i18n.loadThemeTranslations();
let rendered = helpers.t.call({}, 'Top left Button', {
hash: {}
});
rendered.should.eql('Left Button on Top');
});
it('[fallback] no theme translation file found for FR', function () {
settingsCache.set('default_locale', {value: 'fr'});
common.i18n.loadThemeTranslations();
let rendered = helpers.t.call({}, 'Top left Button', {
hash: {}
});
rendered.should.eql('Left Button on Top');
});
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();
let rendered = helpers.t.call({}, 'Top left Button', {
hash: {}
});
rendered.should.eql('Top left Button');
});
});

View File

@ -40,7 +40,7 @@ describe('lib/fs: read csv', function () {
return done(err);
}
files.length.should.eql(12);
files.length.should.eql(13);
done();
});
});

View File

@ -0,0 +1,3 @@
{
"Top left Button": "Oben Links."
}

View File

@ -0,0 +1,3 @@
{
"Top left Button": "Left Button on Top"
}

View File

@ -56,7 +56,7 @@
"ghost-storage-base": "0.0.1",
"glob": "5.0.15",
"got": "7.1.0",
"gscan": "1.2.3",
"gscan": "1.3.0",
"html-to-text": "3.3.0",
"image-size": "0.6.2",
"intl": "1.2.5",

View File

@ -2667,9 +2667,9 @@ grunt@~0.4.0:
underscore.string "~2.2.1"
which "~1.0.5"
gscan@1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/gscan/-/gscan-1.2.3.tgz#0a53809a651287705f25e0436a630f3056ca610f"
gscan@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/gscan/-/gscan-1.3.0.tgz#2e04e777b51be8f56981a866b7584f872c99ac0c"
dependencies:
bluebird "^3.4.6"
chalk "^1.1.1"