From fcd98f19e11aa0533b78e424194c1ef92381aa8a Mon Sep 17 00:00:00 2001 From: Pavel Lang Date: Sat, 16 Apr 2016 15:31:37 +0200 Subject: [PATCH 1/3] Add redux and react-redux dependencies --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index f7567d538..e2efe3ce3 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "pretty-error": "2.0.0", "react": "15.0.1", "react-dom": "15.0.1", + "react-redux": "^4.4.5", + "redux": "^3.4.0", "sequelize": "^3.21.0", "source-map-support": "0.4.0", "sqlite3": "^3.1.3", From 69fb34fa4f9a62cdd25d46f1a2bd52bc4308c6eb Mon Sep 17 00:00:00 2001 From: Pavel Lang Date: Sat, 16 Apr 2016 22:25:39 +0200 Subject: [PATCH 2/3] Redux + React-redux --- package.json | 2 ++ src/actions/README.md | 3 +++ src/actions/runtime.js | 11 +++++++++++ src/client.js | 9 +++++++++ src/components/App/App.js | 27 ++++++++++++++++--------- src/constants/index.js | 1 + src/reducers/index.js | 6 ++++++ src/reducers/runtime.js | 13 +++++++++++++ src/server.js | 11 +++++++++++ src/store/configureStore.js | 39 +++++++++++++++++++++++++++++++++++++ src/views/index.jade | 2 +- 11 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 src/actions/README.md create mode 100644 src/actions/runtime.js create mode 100644 src/constants/index.js create mode 100644 src/reducers/index.js create mode 100644 src/reducers/runtime.js create mode 100644 src/store/configureStore.js diff --git a/package.json b/package.json index e2efe3ce3..0d0be354c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "react-dom": "15.0.1", "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", @@ -92,6 +93,7 @@ "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": "^5.4.0", "stylelint-config-standard": "^5.0.0", 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/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..577e7e2d5 100644 --- a/src/client.js +++ b/src/client.js @@ -13,9 +13,11 @@ 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'; const context = { + store: null, insertCss: styles => styles._insertCss(), setTitle: value => (document.title = value), setMeta: (name, content) => { @@ -77,10 +79,17 @@ 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); + context.store = configureStore(initialState); + // Re-render the app when window.location changes const removeHistoryListener = history.listen(location => { currentLocation = location; diff --git a/src/components/App/App.js b/src/components/App/App.js index a8483dc3a..6884acc41 100644 --- a/src/components/App/App.js +++ b/src/components/App/App.js @@ -13,15 +13,17 @@ import s from './App.scss'; import Header from '../Header'; import Feedback from '../Feedback'; import Footer from '../Footer'; +import { Provider } from 'react-redux'; class App extends Component { static propTypes = { context: PropTypes.shape({ + store: PropTypes.object.isRequired, insertCss: PropTypes.func, setTitle: PropTypes.func, setMeta: PropTypes.func, - }), + }).isRequired, children: PropTypes.element.isRequired, error: PropTypes.object, }; @@ -51,14 +53,21 @@ class App extends Component { } render() { - return !this.props.error ? ( -
-
- {this.props.children} - -
-
- ) : this.props.children; + if (this.props.error) { + return this.props.children; + } + + const store = this.props.context.store; + return ( + +
+
+ {this.props.children} + +
+
+
+ ); } } diff --git a/src/constants/index.js b/src/constants/index.js new file mode 100644 index 000000000..91b1222c5 --- /dev/null +++ b/src/constants/index.js @@ -0,0 +1 @@ +export const SET_RUNTIME_VARIABLE = 'SET_RUNTIME_VARIABLE'; diff --git a/src/reducers/index.js b/src/reducers/index.js new file mode 100644 index 000000000..d0495ad8f --- /dev/null +++ b/src/reducers/index.js @@ -0,0 +1,6 @@ +import { combineReducers } from 'redux'; +import runtime from './runtime'; + +export default combineReducers({ + runtime, +}); 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/server.js b/src/server.js index b5671415a..dda2eab67 100644 --- a/src/server.js +++ b/src/server.js @@ -24,6 +24,8 @@ import schema from './data/schema'; import routes from './routes'; import assets from './assets'; import { port, auth, analytics } from './config'; +import configureStore from './store/configureStore'; +import { setRuntimeVariable } from './actions/runtime'; const app = express(); @@ -91,10 +93,18 @@ app.get('*', async (req, res, next) => { data.trackingId = analytics.google.trackingId; } + const store = configureStore({}); + + store.dispatch(setRuntimeVariable({ + name: 'initialNow', + value: Date.now(), + })); + 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,6 +112,7 @@ app.get('*', async (req, res, next) => { render(component, status = 200) { css = []; statusCode = status; + data.state = JSON.stringify(store.getState()); data.body = ReactDOM.renderToString(component); data.css = css.join(''); return true; 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..2eaf7780c 100644 --- a/src/views/index.jade +++ b/src/views/index.jade @@ -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') From 86eadfd3d11d804cf858aa21f657022fcc098752 Mon Sep 17 00:00:00 2001 From: Pavel Lang Date: Fri, 22 Apr 2016 15:23:20 +0200 Subject: [PATCH 3/3] React Intl --- package.json | 22 ++- src/actions/intl.js | 55 +++++++ src/client.js | 27 +++- src/components/App/App.js | 17 +- src/components/Header/Header.js | 33 +++- .../LanguageSwitcher/LanguageSwitcher.js | 42 +++++ src/components/LanguageSwitcher/package.json | 6 + src/components/Navigation/Navigation.js | 49 +++++- src/components/Provide/IntlProvider.js | 26 ++++ src/components/Provide/Provide.js | 24 +++ src/components/Provide/package.json | 6 + src/config.js | 3 + src/constants/index.js | 3 + src/data/models/UserClaim.js | 2 +- src/data/queries/intl.js | 49 ++++++ src/data/schema.js | 2 + src/data/types/IntlMessageType.js | 28 ++++ src/messages/_default.json | 66 ++++++++ src/messages/cs.json | 66 ++++++++ src/messages/en.json | 66 ++++++++ src/reducers/index.js | 2 + src/reducers/intl.js | 47 ++++++ src/routes/home/Home.js | 9 +- src/routes/home/Home.scss | 5 + src/routes/home/index.js | 2 +- src/server.js | 50 +++++- src/serverIntlPolyfill.js | 17 ++ src/views/index.jade | 2 +- tools/README.md | 6 + tools/build.js | 2 + tools/copy.js | 7 +- tools/extractMessages.js | 146 ++++++++++++++++++ tools/lib/fs.js | 11 +- tools/start.js | 2 + tools/webpack.config.js | 8 + 35 files changed, 865 insertions(+), 43 deletions(-) create mode 100644 src/actions/intl.js create mode 100644 src/components/LanguageSwitcher/LanguageSwitcher.js create mode 100644 src/components/LanguageSwitcher/package.json create mode 100644 src/components/Provide/IntlProvider.js create mode 100644 src/components/Provide/Provide.js create mode 100644 src/components/Provide/package.json create mode 100644 src/data/queries/intl.js create mode 100644 src/data/types/IntlMessageType.js create mode 100644 src/messages/_default.json create mode 100644 src/messages/cs.json create mode 100644 src/messages/en.json create mode 100644 src/reducers/intl.js create mode 100644 src/serverIntlPolyfill.js create mode 100644 tools/extractMessages.js diff --git a/package.json b/package.json index 109e63278..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,7 @@ "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", @@ -45,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", @@ -61,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", @@ -95,8 +100,8 @@ "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", @@ -157,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/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/client.js b/src/client.js index 577e7e2d5..87021c979 100644 --- a/src/client.js +++ b/src/client.js @@ -8,6 +8,7 @@ */ import 'babel-polyfill'; +import React from 'react'; import ReactDOM from 'react-dom'; import FastClick from 'fastclick'; import { match } from 'universal-router'; @@ -15,6 +16,14 @@ 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, @@ -62,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) ); @@ -88,7 +106,8 @@ function run() { // Make taps on links and buttons work fast on mobiles FastClick.attach(document.body); - context.store = configureStore(initialState); + const store = configureStore(initialState); + context.store = store; // Re-render the app when window.location changes const removeHistoryListener = history.listen(location => { @@ -98,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 6884acc41..2cd2455ae 100644 --- a/src/components/App/App.js +++ b/src/components/App/App.js @@ -13,13 +13,11 @@ import s from './App.scss'; import Header from '../Header'; import Feedback from '../Feedback'; import Footer from '../Footer'; -import { Provider } from 'react-redux'; class App extends Component { static propTypes = { context: PropTypes.shape({ - store: PropTypes.object.isRequired, insertCss: PropTypes.func, setTitle: PropTypes.func, setMeta: PropTypes.func, @@ -57,16 +55,13 @@ class App extends Component { return this.props.children; } - const store = this.props.context.store; 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() { React - 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 ( +
+ {availableLocales.map(locale => ( + + {isSelected(locale) ? ( + {locale} + ) : ( + { + setLocale({ locale }); + e.preventDefault(); + }} + >{locale} + )} + {' '} + + ))} +
+ ); +} + +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 index 91b1222c5..c0a21d497 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -1 +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 index d0495ad8f..7d2d9a069 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -1,6 +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/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; @@ -100,6 +122,15 @@ app.get('*', async (req, res, next) => { value: Date.now(), })); + store.dispatch(setRuntimeVariable({ + name: 'availableLocales', + value: locales, + })); + + await store.dispatch(setLocale({ + locale, + })); + await match(routes, { path: req.path, query: req.query, @@ -112,8 +143,21 @@ app.get('*', async (req, res, next) => { render(component, status = 200) { css = []; statusCode = status; + + // 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.body = ReactDOM.renderToString(component); + 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/views/index.jade b/src/views/index.jade index 2eaf7780c..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") 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, + }, + ], ], }, },