Skip to content

Commit

Permalink
Add support for i18n dates (fix #1395)
Browse files Browse the repository at this point in the history
Also makes our existing date output in AddonDetails pretty.
  • Loading branch information
tofumatt committed Dec 23, 2016
1 parent 6b957ac commit 3ee7a60
Show file tree
Hide file tree
Showing 14 changed files with 260 additions and 100 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
server.babel.js
dist
coverage
src/locale
35 changes: 33 additions & 2 deletions bin/build-locales
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const po2json = require('po2json');
const glob = require('glob');
const shelljs = require('shelljs');
const chalk = require('chalk');
const toSource = require('tosource');

const appName = config.get('appName');

Expand All @@ -30,7 +31,7 @@ poFiles.forEach((pofile) => {
const subdir = path.dirname(dir);
const locale = path.basename(subdir);
const stem = path.basename(pofile, '.po');
const jsonfile = path.join(dest, locale, `${stem}.json`);
const jsonfile = path.join(dest, locale, `${stem}.js`);
shelljs.mkdir('-p', path.join(dest, locale));

const json = po2json.parseFileSync(pofile, {
Expand All @@ -39,5 +40,35 @@ poFiles.forEach((pofile) => {
format: 'jed1.x',
fuzzy: config.get('po2jsonFuzzyOutput'),
});
fs.writeFileSync(jsonfile, json);
const localeObject = JSON.parse(json);

// Add the moment locale JS into our locale file, if one is available and
// we're building for AMO (which is the only app that uses moment right
// now).
if (appName === 'amo') {
var defineLocale = null;
try {
const momentLocale = locale.replace('_', '-').toLowerCase();
// We're using `new Function()` here to create a function out of the
// raw code in this file; this function won't be executed but will be
// written out by `toSource()` so that it can be used later (at runtime,
// by moment).
defineLocale = new Function(
fs.readFileSync(
`./node_modules/moment/locale/${momentLocale}.js`, 'utf8'
)
);
} catch (e) {
// We ignore missing locale errors for en_US as its moment's default
// locale so we don't need to provide a translation.
if (locale !== 'en_US') {
console.info(oneLine`No moment i18n available for ${locale};
consider adding one or creating a mapping.`);
}
}

localeObject._momentDefineLocale = defineLocale;
}

fs.writeFileSync(jsonfile, `module.exports = ${toSource(localeObject)}`);
});
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@
"isomorphic-fetch": "2.2.1",
"jed": "1.1.1",
"jsdom": "9.9.1",
"moment": "2.17.1",
"mozilla-tabzilla": "0.5.1",
"normalize.css": "5.0.0",
"normalizr": "2.3.0",
Expand Down Expand Up @@ -254,6 +255,7 @@
"supertest": "2.0.1",
"supertest-as-promised": "4.0.2",
"svg-url-loader": "1.1.0",
"tosource": "1.0.0",
"webpack": "1.14.0",
"webpack-dev-middleware": "1.9.0",
"webpack-dev-server": "1.16.2",
Expand Down
11 changes: 10 additions & 1 deletion src/amo/components/AddonMoreInfo.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,16 @@ export class AddonMoreInfoBase extends React.Component {
{addon.current_version.version}
</dd>
<dt>{i18n.gettext('Last updated')}</dt>
<dd>{addon.last_updated}</dd>
<dd>
{i18n.sprintf(
// L10n: This will output, in English:
// "2 months ago (Dec 12 2016)"
i18n.gettext('%(timeFromNow)s (%(date)s)'), {
timeFromNow: i18n.moment(addon.last_updated).fromNow(),
date: i18n.moment(addon.last_updated).format('ll'),
}
)}
</dd>
{addon.current_version.license ? (
<dt ref={(ref) => { this.licenseHeader = ref; }}>
{i18n.gettext('License')}
Expand Down
14 changes: 8 additions & 6 deletions src/core/client/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@

import 'babel-polyfill';
import config from 'config';
import Jed from 'jed';
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { applyRouterMiddleware, Router, browserHistory } from 'react-router';
import { ReduxAsyncConnect } from 'redux-connect';
import useScroll from 'react-router-scroll/lib/useScroll';

import { langToLocale, sanitizeLanguage } from 'core/i18n/utils';
import { langToLocale, makeI18n, sanitizeLanguage } from 'core/i18n/utils';
import I18nProvider from 'core/i18n/Provider';
import log from 'core/logger';

Expand All @@ -24,8 +23,8 @@ export default function makeClient(routes, createStore) {
const locale = langToLocale(lang);
const appName = config.get('appName');

function renderApp(jedData) {
const i18n = new Jed(jedData);
function renderApp(i18nData) {
const i18n = makeI18n(i18nData);

if (initialStateContainer) {
try {
Expand All @@ -44,7 +43,10 @@ export default function makeClient(routes, createStore) {
),
});

const middleware = applyRouterMiddleware(useScroll(), useReduxAsyncConnect());
const middleware = applyRouterMiddleware(
useScroll(),
useReduxAsyncConnect(),
);

render(
<I18nProvider i18n={i18n}>
Expand All @@ -62,7 +64,7 @@ export default function makeClient(routes, createStore) {
try {
if (locale !== langToLocale(config.get('defaultLang'))) {
// eslint-disable-next-line max-len, global-require, import/no-dynamic-require
require(`bundle?name=[name]-i18n-[folder]!json!../../locale/${locale}/${appName}.json`)(renderApp);
require(`bundle?name=[name]-i18n-[folder]!../../locale/${locale}/${appName}.js`)(renderApp);
} else {
renderApp({});
}
Expand Down
1 change: 0 additions & 1 deletion src/core/i18n/translate.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ function getDisplayName(component) {
return component.displayName || component.name || 'Component';
}


export default function translate(options = {}) {
const { withRef = false } = options;

Expand Down
25 changes: 25 additions & 0 deletions src/core/i18n/utils.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import config from 'config';
import Jed from 'jed';
import moment from 'moment';

import log from 'core/logger';

Expand Down Expand Up @@ -173,3 +175,26 @@ export function getLanguage({ lang, acceptLanguage } = {}) {
// - normalization e.g: en-us -> en-US.
return { lang: sanitizeLanguage(userLang), isLangFromHeader };
}

// moment uses locales like "en-gb" whereas we use "en_GB".
export function makeMomentLocale(locale) {
return locale.replace('_', '-').toLowerCase();
}

// Create an i18n object with a translated moment object available we can
// use for translated dates across the app.
export function makeI18n(i18nData, _Jed = Jed) {
const i18n = new _Jed(i18nData);

// This adds the correct moment locale for the active locale so we can get
// localised dates, times, etc.
if (i18n.options && typeof i18n.options._momentDefineLocale === 'function') {
i18n.options._momentDefineLocale();
moment.locale(makeMomentLocale(i18n.options.locale_data.messages[''].lang));
}

// We add a translated "moment" property to our `i18n` object
// to make translated date/time/etc. easy.
i18n.moment = moment;
return i18n;
}
20 changes: 14 additions & 6 deletions src/core/server/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import 'babel-polyfill';
import config from 'config';
import Express from 'express';
import helmet from 'helmet';
import Jed from 'jed';
import cookie from 'react-cookie';
import React from 'react';
import ReactDOM from 'react-dom/server';
Expand All @@ -19,7 +18,12 @@ import { prefixMiddleWare } from 'core/middleware';
import { convertBoolean } from 'core/utils';
import { setClientApp, setLang, setJWT } from 'core/actions';
import log from 'core/logger';
import { getDirection, isValidLang, langToLocale } from 'core/i18n/utils';
import {
getDirection,
isValidLang,
langToLocale,
makeI18n,
} from 'core/i18n/utils';
import I18nProvider from 'core/i18n/Provider';

import WebpackIsomorphicToolsConfig from './webpack-isomorphic-tools-config';
Expand Down Expand Up @@ -121,7 +125,9 @@ function baseServer(routes, createStore, { appInstanceName = appName } = {}) {
webpackIsomorphicTools.refresh();
}

match({ routes, location: req.url }, (err, redirectLocation, renderProps) => {
match({ location: req.url, routes }, (
err, redirectLocation, renderProps
) => {
cookie.plugToRequest(req, res);

if (err) {
Expand Down Expand Up @@ -182,17 +188,19 @@ function baseServer(routes, createStore, { appInstanceName = appName } = {}) {
return loadOnServer({ ...renderProps, store })
.then(() => {
// eslint-disable-next-line global-require
let jedData = {};
let i18nData = {};
try {
if (locale !== langToLocale(config.get('defaultLang'))) {
// eslint-disable-next-line global-require, import/no-dynamic-require
jedData = require(`json!../../locale/${locale}/${appInstanceName}.json`);
i18nData = require(
`../../locale/${locale}/${appInstanceName}.js`);
}
} catch (e) {
log.info(`Locale JSON not found or required for locale: "${locale}"`);
log.info(`Falling back to default lang: "${config.get('defaultLang')}".`);
}
const i18n = new Jed(jedData);
const i18n = makeI18n(i18nData);

const InitialComponent = (
<I18nProvider i18n={i18n}>
<Provider store={store} key="provider">
Expand Down
5 changes: 3 additions & 2 deletions test-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ require('./tests/client/init');

const testsContext = require.context('./tests/client/', true, /\.js$/);
const componentsContext = require.context(
// This regex excludes server.js or server/*.js
'./src/', true, /^(?:(?!server|config|client).)*\.js$/);
// This regex excludes everything in locale/**/*.js, server.js, and
// server/*.js
'./src/', true, /^(?:(?!locale\/[A-Za-z_]{2,5}\/|server|config|client).)*\.js$/);

testsContext.keys().forEach(testsContext);
componentsContext.keys().forEach(componentsContext);
52 changes: 52 additions & 0 deletions tests/client/core/i18n/test_utils.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import config from 'config';
import moment from 'moment';

import * as utils from 'core/i18n/utils';


const defaultLang = config.get('defaultLang');


Expand Down Expand Up @@ -387,4 +389,54 @@ describe('i18n utils', () => {
assert.equal(result, undefined);
});
});

describe('makeI18n', () => {
let FakeJed;

before(() => {
FakeJed = class {
constructor(i18nData) {
return i18nData;
}
};
});

beforeEach(() => {
// FIXME: Our moment is not immutable so we reset it before each test.
// This is annoying to work around because of the locale `require()`s
// and it only affects tests so it'd be nice to fix but doesn't break
// anything.
moment.locale('en');
});

it('adds a localised moment to the i18n object', () => {
const i18nData = {};
const i18n = utils.makeI18n(i18nData, FakeJed);
assert.ok(i18n.moment);
assert.typeOf(i18n.moment, 'function');
});

it('tries to localise moment', () => {
const i18nData = {
options: {
_momentDefineLocale: sinon.stub(),
locale_data: { messages: { '': { lang: 'fr' } } },
},
};
const i18n = utils.makeI18n(i18nData, FakeJed);
assert.equal(i18n.moment.locale(), 'fr');
});

it('does not localise if _momentDefineLocale is not a function', () => {
const i18nData = {
options: {
_momentDefineLocale: null,
locale_data: { messages: { '': { lang: 'fr' } } },
},
};

const i18n = utils.makeI18n(i18nData, FakeJed);
assert.equal(i18n.moment.locale(), 'en');
});
});
});
2 changes: 2 additions & 0 deletions tests/client/helpers.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import base64url from 'base64url';
import { sprintf } from 'jed';
import moment from 'moment';
import React from 'react';
import { createRenderer } from 'react-addons-test-utils';

Expand Down Expand Up @@ -77,6 +78,7 @@ export function getFakeI18nInst() {
npgettext: sinon.stub(),
dnpgettext: sinon.stub(),
sprintf: sinon.spy(sprintf),
moment,
};
}

Expand Down
4 changes: 3 additions & 1 deletion webpack.dev.config.babel.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,12 @@ export default Object.assign({}, webpackConfig, {
}),
// Replaces server config module with the subset clientConfig object.
new webpack.NormalModuleReplacementPlugin(/config$/, 'core/client/config.js'),
// Prevent locales with moment require calls from crashing
new webpack.NormalModuleReplacementPlugin(/\.\.\/moment$/, 'moment'),
// This allow us to exclude locales for other apps being built.
new webpack.ContextReplacementPlugin(
/locale$/,
new RegExp(`^\\.\\/.*?\\/${appName}\\.json$`)
new RegExp(`^\\.\\/.*?\\/${appName}\\.js$`)
),
// Substitutes client only config.
new webpack.NormalModuleReplacementPlugin(/core\/logger$/, 'core/client/logger.js'),
Expand Down
4 changes: 3 additions & 1 deletion webpack.prod.config.babel.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,16 @@ const settings = {
}),
// Replaces server config module with the subset clientConfig object.
new webpack.NormalModuleReplacementPlugin(/config$/, 'core/client/config.js'),
// Prevent locales with moment require calls from crashing
new webpack.NormalModuleReplacementPlugin(/\.\.\/moment$/, 'moment'),
// Substitutes client only config.
new webpack.NormalModuleReplacementPlugin(/core\/logger$/, 'core/client/logger.js'),
// Use the browser's window for window.
new webpack.NormalModuleReplacementPlugin(/core\/window/, 'core/browserWindow.js'),
// This allow us to exclude locales for other apps being built.
new webpack.ContextReplacementPlugin(
/locale$/,
new RegExp(`^\\.\\/.*?\\/${appName}\\.json$`)
new RegExp(`^\\.\\/.*?\\/${appName}\\.js$`)
),
new ExtractTextPlugin('[name]-[contenthash].css', { allChunks: true }),
new SriStatsPlugin({
Expand Down
Loading

0 comments on commit 3ee7a60

Please sign in to comment.