diff --git a/package.json b/package.json
index b18438df8..37f271d6a 100644
--- a/package.json
+++ b/package.json
@@ -16,12 +16,15 @@
"express": "4.13.4",
"express-graphql": "0.5.1",
"express-jwt": "3.3.0",
+ "express-request-language": "^1.1.4",
"fastclick": "1.0.6",
- "fbjs": "0.8.1",
+ "fbjs": "0.8.0",
"front-matter": "2.0.7",
"graphiql": "0.7.0",
"graphql": "0.5.0",
- "history": "2.1.0",
+ "history": "2.0.2",
+ "intl": "^1.1.0",
+ "intl-locales-supported": "^1.0.0",
"isomorphic-style-loader": "1.0.0",
"jade": "1.11.0",
"jsonwebtoken": "5.7.0",
@@ -33,6 +36,10 @@
"pretty-error": "2.0.0",
"react": "15.0.1",
"react-dom": "15.0.1",
+ "react-intl": "^2.1.0",
+ "react-redux": "^4.4.5",
+ "redux": "^3.4.0",
+ "redux-thunk": "^2.0.1",
"sequelize": "^3.21.0",
"source-map-support": "0.4.0",
"sqlite3": "^3.1.3",
@@ -42,10 +49,11 @@
"devDependencies": {
"assets-webpack-plugin": "^3.4.0",
"autoprefixer": "^6.3.6",
- "babel-cli": "^6.7.7",
- "babel-core": "^6.7.7",
- "babel-eslint": "^6.0.3",
+ "babel-cli": "^6.7.5",
+ "babel-core": "^6.7.6",
+ "babel-eslint": "^6.0.2",
"babel-loader": "^6.2.4",
+ "babel-plugin-react-intl": "^2.1.2",
"babel-plugin-react-transform": "^2.0.2",
"babel-plugin-rewire": "^1.0.0-rc-2",
"babel-plugin-transform-react-constant-elements": "^6.5.0",
@@ -58,7 +66,7 @@
"babel-preset-stage-0": "^6.5.0",
"babel-register": "^6.7.2",
"babel-template": "^6.7.0",
- "babel-types": "^6.7.7",
+ "babel-types": "^6.7.2",
"browser-sync": "^2.12.3",
"chai": "^3.5.0",
"css-loader": "^0.23.1",
@@ -90,9 +98,10 @@
"react-transform-catch-errors": "^1.0.2",
"react-transform-hmr": "^1.0.4",
"redbox-react": "^1.2.3",
+ "redux-logger": "^2.6.1",
"sinon": "^2.0.0-pre",
- "stylelint": "^6.0.3",
- "stylelint-config-standard": "^6.0.0",
+ "stylelint": "^5.4.0",
+ "stylelint-config-standard": "^5.0.0",
"url-loader": "^0.5.7",
"webpack": "^1.13.0",
"webpack-hot-middleware": "^2.10.0",
@@ -153,6 +162,7 @@
"test:watch": "mocha src/**/*.test.js --require test/setup.js --compilers js:babel-register --reporter min --watch",
"clean": "babel-node tools/run clean",
"copy": "babel-node tools/run copy",
+ "extractMessages": "babel-node tools/run extractMessages",
"bundle": "babel-node tools/run bundle",
"build": "babel-node tools/run build",
"deploy": "babel-node tools/run deploy",
diff --git a/src/actions/README.md b/src/actions/README.md
new file mode 100644
index 000000000..be6bb8f18
--- /dev/null
+++ b/src/actions/README.md
@@ -0,0 +1,3 @@
+# Action creators
+
+Action Creators should go there
diff --git a/src/actions/intl.js b/src/actions/intl.js
new file mode 100644
index 000000000..e9e5d3632
--- /dev/null
+++ b/src/actions/intl.js
@@ -0,0 +1,55 @@
+import fetch from '../core/fetch';
+import {
+ SET_LOCALE_START,
+ SET_LOCALE_SUCCESS,
+ SET_LOCALE_ERROR,
+} from '../constants';
+
+export function setLocale({ locale }) {
+ return async (dispatch) => {
+ dispatch({
+ type: SET_LOCALE_START,
+ payload: {
+ locale,
+ },
+ });
+
+ try {
+ const resp = await fetch('/graphql', {
+ method: 'post',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ query: `query{intl(locale:${JSON.stringify(locale)}){id,message}}`,
+ }),
+ credentials: 'include',
+ });
+ if (resp.status !== 200) throw new Error(resp.statusText);
+ const { data } = await resp.json();
+ const messages = data.intl.reduce((msgs, msg) => {
+ msgs[msg.id] = msg.message; // eslint-disable-line no-param-reassign
+ return msgs;
+ }, {});
+ dispatch({
+ type: SET_LOCALE_SUCCESS,
+ payload: {
+ locale,
+ messages,
+ },
+ });
+ } catch (error) {
+ dispatch({
+ type: SET_LOCALE_ERROR,
+ payload: {
+ locale,
+ error,
+ },
+ });
+ return false;
+ }
+
+ return true;
+ };
+}
diff --git a/src/actions/runtime.js b/src/actions/runtime.js
new file mode 100644
index 000000000..353aba1a6
--- /dev/null
+++ b/src/actions/runtime.js
@@ -0,0 +1,11 @@
+import { SET_RUNTIME_VARIABLE } from '../constants';
+
+export function setRuntimeVariable({ name, value }) {
+ return {
+ type: SET_RUNTIME_VARIABLE,
+ payload: {
+ name,
+ value,
+ },
+ };
+}
diff --git a/src/client.js b/src/client.js
index 4f9de5b16..87021c979 100644
--- a/src/client.js
+++ b/src/client.js
@@ -8,14 +8,25 @@
*/
import 'babel-polyfill';
+import React from 'react';
import ReactDOM from 'react-dom';
import FastClick from 'fastclick';
import { match } from 'universal-router';
import routes from './routes';
import history from './core/history';
+import configureStore from './store/configureStore';
import { addEventListener, removeEventListener } from './core/DOMUtils';
+import Provide from './components/Provide';
+
+import { addLocaleData } from 'react-intl';
+
+import en from 'react-intl/locale-data/en';
+import cs from 'react-intl/locale-data/cs';
+
+[en, cs].forEach(addLocaleData);
const context = {
+ store: null,
insertCss: styles => styles._insertCss(),
setTitle: value => (document.title = value),
setMeta: (name, content) => {
@@ -60,11 +71,20 @@ let renderComplete = (state, callback) => {
};
};
-function render(container, state, component) {
+function render(container, state, config, component) {
return new Promise((resolve, reject) => {
+ if (process.env.NODE_ENV === 'development') {
+ console.log(// eslint-disable-line no-console
+ 'React rendering. State:',
+ config.store.getState()
+ );
+ }
+
try {
ReactDOM.render(
- component,
+
+ {component}
+ ,
container,
renderComplete.bind(undefined, state, resolve)
);
@@ -77,10 +97,18 @@ function render(container, state, component) {
function run() {
let currentLocation = null;
const container = document.getElementById('app');
+ const initialState = JSON.parse(
+ document.
+ getElementById('source').
+ getAttribute('data-initial-state')
+ );
// Make taps on links and buttons work fast on mobiles
FastClick.attach(document.body);
+ const store = configureStore(initialState);
+ context.store = store;
+
// Re-render the app when window.location changes
const removeHistoryListener = history.listen(location => {
currentLocation = location;
@@ -89,7 +117,7 @@ function run() {
query: location.query,
state: location.state,
context,
- render: render.bind(undefined, container, location.state),
+ render: render.bind(undefined, container, location.state, { store }),
}).catch(err => console.error(err)); // eslint-disable-line no-console
});
diff --git a/src/components/App/App.js b/src/components/App/App.js
index a8483dc3a..2cd2455ae 100644
--- a/src/components/App/App.js
+++ b/src/components/App/App.js
@@ -21,7 +21,7 @@ class App extends Component {
insertCss: PropTypes.func,
setTitle: PropTypes.func,
setMeta: PropTypes.func,
- }),
+ }).isRequired,
children: PropTypes.element.isRequired,
error: PropTypes.object,
};
@@ -51,14 +51,18 @@ class App extends Component {
}
render() {
- return !this.props.error ? (
+ if (this.props.error) {
+ return this.props.children;
+ }
+
+ return (
{this.props.children}
- ) : this.props.children;
+ );
}
}
diff --git a/src/components/Header/Header.js b/src/components/Header/Header.js
index 125268b58..2470d03ac 100644
--- a/src/components/Header/Header.js
+++ b/src/components/Header/Header.js
@@ -8,10 +8,30 @@
*/
import React from 'react';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Header.scss';
import Link from '../Link';
import Navigation from '../Navigation';
+import LanguageSwitcher from '../LanguageSwitcher';
+
+const messages = defineMessages({
+ brand: {
+ id: 'header.brand',
+ defaultMessage: 'Your Company Brand',
+ description: 'Brand name displayed in header',
+ },
+ bannerTitle: {
+ id: 'header.banner.title',
+ defaultMessage: 'React',
+ description: 'Title in page header',
+ },
+ bannerDesc: {
+ id: 'header.banner.desc',
+ defaultMessage: 'Complex web apps made easy',
+ description: 'Description in header',
+ },
+});
function Header() {
return (
@@ -20,15 +40,20 @@ function Header() {
- Your Company
+
+
+
+
-
React
-
Complex web apps made easy
+
+
+
+
);
}
-export default withStyles(s)(Header);
+export default injectIntl(withStyles(s)(Header));
diff --git a/src/components/LanguageSwitcher/LanguageSwitcher.js b/src/components/LanguageSwitcher/LanguageSwitcher.js
new file mode 100644
index 000000000..cc7e1b43c
--- /dev/null
+++ b/src/components/LanguageSwitcher/LanguageSwitcher.js
@@ -0,0 +1,42 @@
+/* eslint-disable no-shadow */
+
+import React, { PropTypes } from 'react';
+import { connect } from 'react-redux';
+import { setLocale } from '../../actions/intl';
+
+function LanguageSwitcher({ currentLocale, availableLocales, setLocale }) {
+ const isSelected = locale => locale === currentLocale;
+ return (
+
+ );
+}
+
+LanguageSwitcher.propTypes = {
+ currentLocale: PropTypes.string.isRequired,
+ availableLocales: PropTypes.arrayOf(PropTypes.string).isRequired,
+ setLocale: PropTypes.func.isRequired,
+};
+
+export default connect(state => ({
+ availableLocales: state.runtime.availableLocales,
+ currentLocale: state.intl.locale,
+}), {
+ setLocale,
+})(LanguageSwitcher);
diff --git a/src/components/LanguageSwitcher/package.json b/src/components/LanguageSwitcher/package.json
new file mode 100644
index 000000000..ef3f495fa
--- /dev/null
+++ b/src/components/LanguageSwitcher/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "LanguageSwitcher",
+ "version": "0.0.0",
+ "private": true,
+ "main": "./LanguageSwitcher.js"
+}
diff --git a/src/components/Navigation/Navigation.js b/src/components/Navigation/Navigation.js
index 8e75b17f4..bd75039de 100644
--- a/src/components/Navigation/Navigation.js
+++ b/src/components/Navigation/Navigation.js
@@ -8,20 +8,59 @@
*/
import React, { PropTypes } from 'react';
+import { defineMessages, FormattedMessage } from 'react-intl';
import cx from 'classnames';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Navigation.scss';
import Link from '../Link';
+const messages = defineMessages({
+ about: {
+ id: 'navigation.about',
+ defaultMessage: 'About',
+ description: 'About link in header',
+ },
+ contact: {
+ id: 'navigation.contact',
+ defaultMessage: 'Contact',
+ description: 'Contact link in header',
+ },
+ login: {
+ id: 'navigation.login',
+ defaultMessage: 'Log in',
+ description: 'Log in link in header',
+ },
+ or: {
+ id: 'navigation.separator.or',
+ defaultMessage: 'or',
+ description: 'Last separator in list, lowercase "or"',
+ },
+ signup: {
+ id: 'navigation.signup',
+ defaultMessage: 'Sign up',
+ description: 'Sign up link in header',
+ },
+});
+
function Navigation({ className }) {
return (
- About
- Contact
+
+
+
+
+
+
|
- Log in
- or
- Sign up
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/components/Provide/IntlProvider.js b/src/components/Provide/IntlProvider.js
new file mode 100644
index 000000000..12985004b
--- /dev/null
+++ b/src/components/Provide/IntlProvider.js
@@ -0,0 +1,26 @@
+import React, { PropTypes } from 'react';
+import { connect } from 'react-redux';
+import { IntlProvider, intlShape } from 'react-intl';
+
+function ProvideIntl({ intl, children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+ProvideIntl.propTypes = {
+ intl: intlShape,
+ children: PropTypes.element.isRequired,
+};
+
+export default connect(state => ({
+ runtime: state.runtime,
+ intl: state.intl,
+}))(ProvideIntl);
diff --git a/src/components/Provide/Provide.js b/src/components/Provide/Provide.js
new file mode 100644
index 000000000..1671505db
--- /dev/null
+++ b/src/components/Provide/Provide.js
@@ -0,0 +1,24 @@
+import React, { PropTypes } from 'react';
+import { Provider } from 'react-redux';
+import IntlProvider from './IntlProvider';
+
+function Provide({ store, children }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+Provide.propTypes = {
+ store: PropTypes.shape({
+ subscribe: PropTypes.func.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ getState: PropTypes.func.isRequired,
+ }).isRequired,
+ children: PropTypes.element.isRequired,
+};
+
+export default Provide;
diff --git a/src/components/Provide/package.json b/src/components/Provide/package.json
new file mode 100644
index 000000000..056dd1dfd
--- /dev/null
+++ b/src/components/Provide/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "Provide",
+ "version": "0.0.0",
+ "private": true,
+ "main": "./Provide.js"
+}
diff --git a/src/config.js b/src/config.js
index 7ead528bf..93f2ffb3d 100644
--- a/src/config.js
+++ b/src/config.js
@@ -13,6 +13,9 @@
export const port = process.env.PORT || 3000;
export const host = process.env.WEBSITE_HOSTNAME || `localhost:${port}`;
+// default locale is the first one
+export const locales = ['en', 'cs'];
+
export const databaseUrl = process.env.DATABASE_URL || 'sqlite:database.sqlite';
export const analytics = {
diff --git a/src/constants/index.js b/src/constants/index.js
new file mode 100644
index 000000000..c0a21d497
--- /dev/null
+++ b/src/constants/index.js
@@ -0,0 +1,4 @@
+export const SET_RUNTIME_VARIABLE = 'SET_RUNTIME_VARIABLE';
+export const SET_LOCALE_START = 'SET_LOCALE_START';
+export const SET_LOCALE_SUCCESS = 'SET_LOCALE_SUCCESS';
+export const SET_LOCALE_ERROR = 'SET_LOCALE_ERROR';
diff --git a/src/data/models/UserClaim.js b/src/data/models/UserClaim.js
index 8eb90bbae..132de13ee 100644
--- a/src/data/models/UserClaim.js
+++ b/src/data/models/UserClaim.js
@@ -17,7 +17,7 @@ const UserClaim = Model.define('UserClaim', {
},
value: {
- type: DataType.STRING,
+ type: DataType.INTEGER,
},
});
diff --git a/src/data/queries/intl.js b/src/data/queries/intl.js
new file mode 100644
index 000000000..baaf976a7
--- /dev/null
+++ b/src/data/queries/intl.js
@@ -0,0 +1,49 @@
+/**
+ * React Starter Kit (https://www.reactstarterkit.com/)
+ *
+ * Copyright © 2014-2016 Kriasoft, LLC. All rights reserved.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE.txt file in the root directory of this source tree.
+ */
+
+import fs from 'fs';
+import { join } from 'path';
+import Promise from 'bluebird';
+import {
+ GraphQLList as List,
+ GraphQLString as StringType,
+ GraphQLNonNull as NonNull,
+} from 'graphql';
+import IntlMessageType from '../types/IntlMessageType';
+import { locales } from '../../config';
+
+// A folder with messages
+const CONTENT_DIR = join(__dirname, './messages');
+
+const readFile = Promise.promisify(fs.readFile);
+
+const intl = {
+ type: new List(IntlMessageType),
+ args: {
+ locale: { type: new NonNull(StringType) },
+ },
+ async resolve({ request }, { locale }) {
+ if (!locales.includes(locale)) {
+ throw new Error(`Locale '${locale}' not supported`);
+ }
+
+ let localeData;
+ try {
+ localeData = await readFile(join(CONTENT_DIR, `${locale}.json`));
+ } catch (err) {
+ if (err.code === 'ENOENT') {
+ throw new Error(`Locale '${locale}' not found`);
+ }
+ }
+
+ return JSON.parse(localeData);
+ },
+};
+
+export default intl;
diff --git a/src/data/schema.js b/src/data/schema.js
index 43ca42834..ee2298157 100644
--- a/src/data/schema.js
+++ b/src/data/schema.js
@@ -15,6 +15,7 @@ import {
import me from './queries/me';
import content from './queries/content';
import news from './queries/news';
+import intl from './queries/intl';
const schema = new Schema({
query: new ObjectType({
@@ -23,6 +24,7 @@ const schema = new Schema({
me,
content,
news,
+ intl,
},
}),
});
diff --git a/src/data/types/IntlMessageType.js b/src/data/types/IntlMessageType.js
new file mode 100644
index 000000000..ce4829137
--- /dev/null
+++ b/src/data/types/IntlMessageType.js
@@ -0,0 +1,28 @@
+/**
+ * React Starter Kit (https://www.reactstarterkit.com/)
+ *
+ * Copyright © 2014-2016 Kriasoft, LLC. All rights reserved.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE.txt file in the root directory of this source tree.
+ */
+
+import {
+ GraphQLObjectType as ObjectType,
+ GraphQLString as StringType,
+ GraphQLNonNull as NonNull,
+ GraphQLList as List,
+} from 'graphql';
+
+const IntlMessageType = new ObjectType({
+ name: 'IntlMessage',
+ fields: {
+ id: { type: new NonNull(StringType) },
+ defaultMessage: { type: new NonNull(StringType) },
+ message: { type: StringType },
+ description: { type: StringType },
+ files: { type: new List(StringType) },
+ },
+});
+
+export default IntlMessageType;
diff --git a/src/messages/_default.json b/src/messages/_default.json
new file mode 100644
index 000000000..e7b9a3c79
--- /dev/null
+++ b/src/messages/_default.json
@@ -0,0 +1,66 @@
+[
+ {
+ "id": "header.banner.desc",
+ "defaultMessage": "Complex web apps made easy",
+ "message": "",
+ "files": [
+ "src/components/Header/Header.js"
+ ]
+ },
+ {
+ "id": "header.banner.title",
+ "defaultMessage": "React",
+ "message": "",
+ "files": [
+ "src/components/Header/Header.js"
+ ]
+ },
+ {
+ "id": "header.brand",
+ "defaultMessage": "Your Company Brand",
+ "message": "",
+ "files": [
+ "src/components/Header/Header.js"
+ ]
+ },
+ {
+ "id": "navigation.about",
+ "defaultMessage": "About",
+ "message": "",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ },
+ {
+ "id": "navigation.contact",
+ "defaultMessage": "Contact",
+ "message": "",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ },
+ {
+ "id": "navigation.login",
+ "defaultMessage": "Log in",
+ "message": "",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ },
+ {
+ "id": "navigation.signup",
+ "defaultMessage": "Sign up",
+ "message": "",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ },
+ {
+ "id": "navigation.separator.or",
+ "defaultMessage": "or",
+ "message": "",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ }
+]
\ No newline at end of file
diff --git a/src/messages/cs.json b/src/messages/cs.json
new file mode 100644
index 000000000..9c3d32764
--- /dev/null
+++ b/src/messages/cs.json
@@ -0,0 +1,66 @@
+[
+ {
+ "id": "header.banner.desc",
+ "defaultMessage": "Complex web apps made easy",
+ "message": "Komplexní aplikace jednodušše",
+ "files": [
+ "src/components/Header/Header.js"
+ ]
+ },
+ {
+ "id": "header.banner.title",
+ "defaultMessage": "React",
+ "message": "React",
+ "files": [
+ "src/components/Header/Header.js"
+ ]
+ },
+ {
+ "id": "header.brand",
+ "defaultMessage": "Your Company Brand",
+ "message": "Vaše firma",
+ "files": [
+ "src/components/Header/Header.js"
+ ]
+ },
+ {
+ "id": "navigation.about",
+ "defaultMessage": "About",
+ "message": "O nás",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ },
+ {
+ "id": "navigation.contact",
+ "defaultMessage": "Contact",
+ "message": "Kontakt",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ },
+ {
+ "id": "navigation.login",
+ "defaultMessage": "Log in",
+ "message": "Přihlásit",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ },
+ {
+ "id": "navigation.signup",
+ "defaultMessage": "Sign up",
+ "message": "Registrace",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ },
+ {
+ "id": "navigation.separator.or",
+ "defaultMessage": "or",
+ "message": "nebo",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ }
+]
\ No newline at end of file
diff --git a/src/messages/en.json b/src/messages/en.json
new file mode 100644
index 000000000..e7b9a3c79
--- /dev/null
+++ b/src/messages/en.json
@@ -0,0 +1,66 @@
+[
+ {
+ "id": "header.banner.desc",
+ "defaultMessage": "Complex web apps made easy",
+ "message": "",
+ "files": [
+ "src/components/Header/Header.js"
+ ]
+ },
+ {
+ "id": "header.banner.title",
+ "defaultMessage": "React",
+ "message": "",
+ "files": [
+ "src/components/Header/Header.js"
+ ]
+ },
+ {
+ "id": "header.brand",
+ "defaultMessage": "Your Company Brand",
+ "message": "",
+ "files": [
+ "src/components/Header/Header.js"
+ ]
+ },
+ {
+ "id": "navigation.about",
+ "defaultMessage": "About",
+ "message": "",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ },
+ {
+ "id": "navigation.contact",
+ "defaultMessage": "Contact",
+ "message": "",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ },
+ {
+ "id": "navigation.login",
+ "defaultMessage": "Log in",
+ "message": "",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ },
+ {
+ "id": "navigation.signup",
+ "defaultMessage": "Sign up",
+ "message": "",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ },
+ {
+ "id": "navigation.separator.or",
+ "defaultMessage": "or",
+ "message": "",
+ "files": [
+ "src/components/Navigation/Navigation.js"
+ ]
+ }
+]
\ No newline at end of file
diff --git a/src/reducers/index.js b/src/reducers/index.js
new file mode 100644
index 000000000..7d2d9a069
--- /dev/null
+++ b/src/reducers/index.js
@@ -0,0 +1,8 @@
+import { combineReducers } from 'redux';
+import runtime from './runtime';
+import intl from './intl';
+
+export default combineReducers({
+ runtime,
+ intl,
+});
diff --git a/src/reducers/intl.js b/src/reducers/intl.js
new file mode 100644
index 000000000..6bec9709a
--- /dev/null
+++ b/src/reducers/intl.js
@@ -0,0 +1,47 @@
+import {
+ SET_LOCALE_START,
+ SET_LOCALE_SUCCESS,
+ SET_LOCALE_ERROR,
+} from '../constants';
+
+export default function intl(state = null, action) {
+ if (state === null) {
+ return {
+ initialNow: Date.now(),
+ };
+ }
+
+ switch (action.type) {
+ case SET_LOCALE_START: {
+ const locale = state[action.payload.locale] ? action.payload.locale : state.locale;
+ return {
+ ...state,
+ locale,
+ newLocale: action.payload.locale,
+ };
+ }
+
+ case SET_LOCALE_SUCCESS: {
+ return {
+ ...state,
+ locale: action.payload.locale,
+ newLocale: null,
+ messages: {
+ ...state.messages,
+ [action.payload.locale]: action.payload.messages,
+ },
+ };
+ }
+
+ case SET_LOCALE_ERROR: {
+ return {
+ ...state,
+ newLocale: null,
+ };
+ }
+
+ default: {
+ return state;
+ }
+ }
+}
diff --git a/src/reducers/runtime.js b/src/reducers/runtime.js
new file mode 100644
index 000000000..f5f2584ea
--- /dev/null
+++ b/src/reducers/runtime.js
@@ -0,0 +1,13 @@
+import { SET_RUNTIME_VARIABLE } from '../constants';
+
+export default function runtime(state = {}, action) {
+ switch (action.type) {
+ case SET_RUNTIME_VARIABLE:
+ return {
+ ...state,
+ [action.payload.name]: action.payload.value,
+ };
+ default:
+ return state;
+ }
+}
diff --git a/src/routes/home/Home.js b/src/routes/home/Home.js
index 7bcecfb2e..f6a5f20a4 100644
--- a/src/routes/home/Home.js
+++ b/src/routes/home/Home.js
@@ -10,6 +10,7 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Home.scss';
+import { FormattedRelative } from 'react-intl';
const title = 'React Starter Kit';
@@ -22,7 +23,13 @@ function Home({ news }, context) {
{news.map((item, index) => (
-
- {item.title}
+
+ {item.title}
+ {' '}
+
+
+
+
{
let css = [];
let statusCode = 200;
const template = require('./views/index.jade');
- const data = { title: '', description: '', css: '', body: '', entry: assets.main.js };
+ const locale = req.language;
+ const data = {
+ lang: locale,
+ title: '',
+ description: '',
+ css: '',
+ body: '',
+ entry: assets.main.js,
+ };
if (process.env.NODE_ENV === 'production') {
data.trackingId = analytics.google.trackingId;
}
+ const store = configureStore({});
+
+ store.dispatch(setRuntimeVariable({
+ name: 'initialNow',
+ value: Date.now(),
+ }));
+
+ store.dispatch(setRuntimeVariable({
+ name: 'availableLocales',
+ value: locales,
+ }));
+
+ await store.dispatch(setLocale({
+ locale,
+ }));
+
await match(routes, {
path: req.path,
query: req.query,
context: {
+ store,
insertCss: styles => css.push(styles._getCss()),
setTitle: value => (data.title = value),
setMeta: (key, value) => (data[key] = value),
@@ -102,7 +143,21 @@ app.get('*', async (req, res, next) => {
render(component, status = 200) {
css = [];
statusCode = status;
- data.body = ReactDOM.renderToString(component);
+
+ // Fire all componentWill... hooks
+ data.body = ReactDOM.renderToString({component});
+
+ // If you have async actions, wait for store when stabilizes here.
+ // This may be asynchronous loop if you have complicated structure.
+ // Then render again
+
+ // If store has no changes, you do not need render again!
+ // data.body = ReactDOM.renderToString({component});
+
+ // It is important to have rendered output and state in sync,
+ // otherwise React will write error to console when mounting on client
+ data.state = JSON.stringify(store.getState());
+
data.css = css.join('');
return true;
},
diff --git a/src/serverIntlPolyfill.js b/src/serverIntlPolyfill.js
new file mode 100644
index 000000000..21502b557
--- /dev/null
+++ b/src/serverIntlPolyfill.js
@@ -0,0 +1,17 @@
+import areIntlLocalesSupported from 'intl-locales-supported';
+
+import { locales } from './config';
+
+if (global.Intl) {
+ // Determine if the built-in `Intl` has the locale data we need.
+ if (!areIntlLocalesSupported(locales)) {
+ // `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.
+ const IntlPolyfill = require('intl');
+ Intl.NumberFormat = IntlPolyfill.NumberFormat;
+ Intl.DateTimeFormat = IntlPolyfill.DateTimeFormat;
+ }
+} else {
+ // No `Intl`, so use and load the polyfill.
+ global.Intl = require('intl');
+}
diff --git a/src/store/configureStore.js b/src/store/configureStore.js
new file mode 100644
index 000000000..d4b39c202
--- /dev/null
+++ b/src/store/configureStore.js
@@ -0,0 +1,39 @@
+import { createStore, applyMiddleware, compose } from 'redux';
+import thunk from 'redux-thunk';
+import rootReducer from '../reducers';
+
+const middleware = [thunk];
+
+let enhancer;
+
+if (__DEV__ && process.env.BROWSER) {
+ const createLogger = require('redux-logger');
+ const logger = createLogger({
+ collapsed: true,
+ });
+ middleware.push(logger);
+
+ enhancer = compose(
+ applyMiddleware(...middleware),
+
+ // https://github.com/zalmoxisus/redux-devtools-extension#redux-devtools-extension
+ window.devToolsExtension ? window.devToolsExtension() : f => f,
+ );
+} else {
+ enhancer = applyMiddleware(...middleware);
+}
+
+export default function configureStore(initialState) {
+ // Note: only Redux >= 3.1.0 supports passing enhancer as third argument.
+ // See https://github.com/rackt/redux/releases/tag/v3.1.0
+ const store = createStore(rootReducer, initialState, enhancer);
+
+ // Hot reload reducers (requires Webpack or Browserify HMR to be enabled)
+ if (__DEV__ && module.hot) {
+ module.hot.accept('../reducers', () =>
+ store.replaceReducer(require('../reducers').default)
+ );
+ }
+
+ return store;
+}
diff --git a/src/views/index.jade b/src/views/index.jade
index 03a38031c..a9e6d8b8c 100644
--- a/src/views/index.jade
+++ b/src/views/index.jade
@@ -1,5 +1,5 @@
doctype html
-html(class="no-js", lang="")
+html(class="no-js", lang=lang)
head
meta(charset="utf-8")
meta(http-equiv="x-ua-compatible", content="ie=edge")
@@ -10,7 +10,7 @@ html(class="no-js", lang="")
style#css!= css
body
#app!= body
- script(src=entry)
+ script#source(src=entry, data-initial-state=state)
script.
window.ga=function(){ga.q.push(arguments)};ga.q=[];ga.l=+new Date;
ga('create','#{trackingId}','auto');ga('send','pageview')
diff --git a/tools/README.md b/tools/README.md
index 85881717d..6a0b40463 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -10,9 +10,15 @@
[HMR](https://webpack.github.io/docs/hot-module-replacement), and
[React Transform](https://github.com/gaearon/babel-plugin-react-transform)
+##### `npm run extractMessages` (`extractMessages.js`)
+
+* Extract intl messages from source (`src/**/*.{js,jsx}`)
+* Update messages in `src/messages` directory
+
##### `npm run build` (`build.js`)
* Cleans up the output `/build` folder (`clean.js`)
+* Extract intl messages from source (`extractMessages.js`)
* Copies static files to the output folder (`copy.js`)
* Creates application bundles with Webpack (`bundle.js`, `webpack.config.js`)
diff --git a/tools/build.js b/tools/build.js
index 482de1bda..74132d95b 100644
--- a/tools/build.js
+++ b/tools/build.js
@@ -9,6 +9,7 @@
import run from './run';
import clean from './clean';
+import extractMessages from './extractMessages';
import copy from './copy';
import bundle from './bundle';
@@ -18,6 +19,7 @@ import bundle from './bundle';
*/
async function build() {
await run(clean);
+ await run(extractMessages);
await run(copy);
await run(bundle);
}
diff --git a/tools/copy.js b/tools/copy.js
index 46107342b..954e645ac 100644
--- a/tools/copy.js
+++ b/tools/copy.js
@@ -22,6 +22,7 @@ async function copy({ watch } = {}) {
await Promise.all([
ncp('src/public', 'build/public'),
ncp('src/content', 'build/content'),
+ ncp('src/messages', 'build/messages'),
]);
await fs.writeFile('./build/package.json', JSON.stringify({
@@ -35,11 +36,11 @@ async function copy({ watch } = {}) {
if (watch) {
const watcher = await new Promise((resolve, reject) => {
- gaze('src/content/**/*.*', (err, val) => err ? reject(err) : resolve(val));
+ gaze('src/{content,messages}/**/*.*', (err, val) => err ? reject(err) : resolve(val));
});
watcher.on('changed', async (file) => {
- const relPath = file.substr(path.join(__dirname, '../src/content/').length);
- await ncp(`src/content/${relPath}`, `build/content/${relPath}`);
+ const relPath = file.substr(path.join(__dirname, '../src/').length);
+ await ncp(`src/${relPath}`, `build/${relPath}`);
});
}
}
diff --git a/tools/extractMessages.js b/tools/extractMessages.js
new file mode 100644
index 000000000..a82a53fa5
--- /dev/null
+++ b/tools/extractMessages.js
@@ -0,0 +1,146 @@
+/**
+ * React Starter Kit (https://www.reactstarterkit.com/)
+ *
+ * Copyright © 2014-2016 Kriasoft, LLC. All rights reserved.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE.txt file in the root directory of this source tree.
+ */
+
+import path from 'path';
+import gaze from 'gaze';
+import Promise from 'bluebird';
+import fs from './lib/fs';
+import pkg from '../package.json';
+import { transform } from 'babel-core';
+import { locales } from '../src/config';
+
+const GLOB_PATTERN = 'src/**/*.{js,jsx}';
+const fileToMessages = {};
+let messages = {};
+
+// merge messages to source files
+async function mergeToFile(locale, toBuild) {
+ const fileName = `src/messages/${locale}.json`;
+ const originalMessages = {};
+ try {
+ const oldFile = await fs.readFile(fileName);
+
+ let oldJson;
+ try {
+ oldJson = JSON.parse(oldFile);
+ } catch (err) {
+ throw new Error(`Error parsing messages JSON in file ${fileName}`);
+ }
+
+ oldJson.forEach(message => {
+ originalMessages[message.id] = message;
+ delete originalMessages[message.id].files;
+ });
+ } catch (err) {
+ if (err.code !== 'ENOENT') {
+ throw err;
+ }
+ }
+
+ Object.keys(messages).forEach(id => {
+ const newMsg = messages[id];
+ originalMessages[id] = originalMessages[id] || { id };
+ const msg = originalMessages[id];
+ msg.defaultMessage = newMsg.defaultMessage || msg.defaultMessage;
+ msg.message = msg.message || '';
+ msg.files = newMsg.files;
+ });
+
+ const result = Object.keys(originalMessages)
+ .map(key => originalMessages[key])
+ .filter(msg => msg.files || msg.message);
+
+ await fs.writeFile(fileName, JSON.stringify(result, null, 2));
+
+ console.log(`Messages updated: ${fileName}`);
+
+ if (toBuild && locale !== '_default') {
+ const buildFileName = `build/messages/${locale}.json`;
+ try {
+ await fs.writeFile(buildFileName, JSON.stringify(result, null, 2));
+ console.log(`Build messages updated: ${buildFileName}`);
+ } catch (err) {
+ console.error(`Failed to update ${buildFileName}`);
+ }
+ }
+}
+
+// call everytime before updating file!
+function mergeMessages() {
+ messages = {};
+ Object.keys(fileToMessages).forEach(fileName => {
+ fileToMessages[fileName].forEach(newMsg => {
+ const message = messages[newMsg.id] || {};
+ messages[newMsg.id] = {
+ description: newMsg.description || message.description,
+ defaultMessage: newMsg.defaultMessage || message.defaultMessage,
+ message: newMsg.message || message.message || '',
+ files: message.files ? [...message.files, fileName] : [fileName],
+ };
+ });
+ });
+}
+
+async function updateMessages(toBuild) {
+ mergeMessages();
+ await Promise.all(
+ ['_default', ...locales].map(locale => mergeToFile(locale, toBuild))
+ );
+}
+
+/**
+ * Extract react-intl messages and write it to src/messages/_default.json
+ * Also extends known localizations
+ */
+async function extractMessages({ watch } = {}) {
+ const compare = (a, b) => {
+ if (a === b) {
+ return 0;
+ }
+
+ return a < b ? -1 : 1;
+ };
+
+ const compareMessages = (a, b) => compare(a.id, b.id);
+
+ const processFile = async (fileName) => {
+ try {
+ const code = await fs.readFile(fileName);
+ const result = transform(code, {
+ presets: pkg.babel.presets,
+ plugins: ['react-intl'],
+ }).metadata['react-intl'];
+ if (result.messages && result.messages.length) {
+ fileToMessages[fileName] = result.messages.sort(compareMessages);
+ } else {
+ delete fileToMessages[fileName];
+ }
+ } catch (err) {
+ console.error(`In ${fileName}:\n`, err.codeFrame);
+ }
+ };
+
+ const files = await fs.glob(GLOB_PATTERN);
+
+ await Promise.all(files.map(processFile));
+ await updateMessages(false);
+
+ if (watch) {
+ const watcher = await new Promise((resolve, reject) => {
+ gaze(GLOB_PATTERN, (err, val) => err ? reject(err) : resolve(val));
+ });
+ watcher.on('changed', async (file) => {
+ const relPath = file.substr(path.join(__dirname, '../').length);
+ await processFile(relPath);
+ await updateMessages(true);
+ });
+ }
+}
+
+export default extractMessages;
diff --git a/tools/lib/fs.js b/tools/lib/fs.js
index fa028b8e6..09df45825 100644
--- a/tools/lib/fs.js
+++ b/tools/lib/fs.js
@@ -9,6 +9,11 @@
import fs from 'fs';
import mkdirp from 'mkdirp';
+import globPkg from 'glob';
+
+const readFile = (file) => new Promise((resolve, reject) => {
+ fs.readFile(file, 'utf8', (err, content) => err ? reject(err) : resolve(content));
+});
const writeFile = (file, contents) => new Promise((resolve, reject) => {
fs.writeFile(file, contents, 'utf8', err => err ? reject(err) : resolve());
@@ -18,4 +23,8 @@ const makeDir = (name) => new Promise((resolve, reject) => {
mkdirp(name, err => err ? reject(err) : resolve());
});
-export default { writeFile, makeDir };
+const glob = (pattern) => new Promise((resolve, reject) => {
+ globPkg(pattern, (err, val) => err ? reject(err) : resolve(val));
+});
+
+export default { readFile, writeFile, makeDir, glob };
diff --git a/tools/start.js b/tools/start.js
index 754ff386c..5d38242bc 100644
--- a/tools/start.js
+++ b/tools/start.js
@@ -15,6 +15,7 @@ import run from './run';
import runServer from './runServer';
import webpackConfig from './webpack.config';
import clean from './clean';
+import extractMessages from './extractMessages';
import copy from './copy';
const DEBUG = !process.argv.includes('--release');
@@ -25,6 +26,7 @@ const DEBUG = !process.argv.includes('--release');
*/
async function start() {
await run(clean);
+ await run(extractMessages.bind(undefined, { watch: true }));
await run(copy.bind(undefined, { watch: true }));
await new Promise(resolve => {
// Patch the client-side bundle configurations
diff --git a/tools/webpack.config.js b/tools/webpack.config.js
index ed249f940..a592f34a6 100644
--- a/tools/webpack.config.js
+++ b/tools/webpack.config.js
@@ -14,6 +14,7 @@ import AssetsPlugin from 'assets-webpack-plugin';
const DEBUG = !process.argv.includes('--release');
const VERBOSE = process.argv.includes('--verbose');
+const INTL_REQUIRE_DESCRIPTIONS = true;
const AUTOPREFIXER_BROWSERS = [
'Android 2.3',
'Android >= 4',
@@ -70,6 +71,13 @@ const config = {
'transform-react-constant-elements',
'transform-react-inline-elements',
],
+
+ // https://github.com/yahoo/babel-plugin-react-intl#options
+ ['react-intl',
+ {
+ enforceDescriptions: INTL_REQUIRE_DESCRIPTIONS,
+ },
+ ],
],
},
},