diff --git a/.babelrc b/.babelrc
index cbb8b05..4f117e8 100644
--- a/.babelrc
+++ b/.babelrc
@@ -23,6 +23,7 @@
"@babel/preset-flow"
],
"plugins": [
+ "@babel/plugin-syntax-dynamic-import",
["babel-plugin-react-rename", {
"only": "./src/component/**/*.js",
"rename": "./config/babel/getComponentName.babel.js"
diff --git a/.eslintrc b/.eslintrc
index a65116c..7e6be84 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -14,3 +14,4 @@ globals:
module: true
require: true
process: true
+ global: true
diff --git a/config/webpack/client.babel.js b/config/webpack/client.babel.js
index 1675db9..b6182ef 100644
--- a/config/webpack/client.babel.js
+++ b/config/webpack/client.babel.js
@@ -6,7 +6,7 @@ import base from './partial/base';
const getRender = () => {
try {
- return require('../../dist/render').default;
+ return require('../../dist/render');
} catch (err) {
console.log('Unable to load static page renderer.');
console.log('Make sure you built the renderer first.');
@@ -23,13 +23,21 @@ const createConfig = compose(
name: 'html/[path][name].[ext]',
paths: [
'/',
- '/error/404',
'/error/500',
],
mapStatsToProps: (stats) => {
return {stats};
},
- render: getRender(),
+ render: (props) => {
+ const render = getRender();
+ if (props.path === '/error/500') {
+ return render.renderError({
+ ...props,
+ error: new Error(),
+ });
+ }
+ return render.renderApp(props);
+ },
}), config);
},
output({
diff --git a/config/webpack/partial/babel.js b/config/webpack/partial/babel.js
new file mode 100644
index 0000000..df0a76f
--- /dev/null
+++ b/config/webpack/partial/babel.js
@@ -0,0 +1,54 @@
+import path from 'path';
+import fs from 'fs';
+import {loader} from 'webpack-partial';
+import {map} from 'ramda';
+
+const getTargets = (target) => {
+ switch (target) {
+ case 'node':
+ return {node: 'current'};
+ case 'web':
+ default:
+ return undefined;
+ }
+};
+
+export default () => (config) => {
+ const babelConfig = JSON.parse(fs.readFileSync(path.join(
+ config.context,
+ '.babelrc',
+ )));
+ const target = config.target;
+ return loader({
+ loader: 'babel-loader',
+ include: [
+ path.join(config.context, 'src'),
+ path.join(config.context, 'lib'),
+ ],
+ test: /\.js$/,
+ options: {
+ ...babelConfig,
+ presets: map((entry) => {
+ const [name, config] = Array.isArray(entry) ?
+ entry : [entry, {}];
+ if (name === '@babel/preset-env') {
+ return [name, {
+ ...config,
+ modules: false,
+ useBuiltIns: 'usage',
+ ignoreBrowserslistConfig: target === 'node',
+ targets: getTargets(target),
+ include: [
+ ...(config.include || []),
+ // While newer versions of node support this, `webpack` does
+ // not because it uses `acorn`. So adjust accordingly.s
+ 'proposal-object-rest-spread',
+ ],
+ }];
+ }
+ return entry;
+ }, babelConfig.presets),
+ cacheDirectory: false,
+ },
+ }, config);
+};
diff --git a/config/webpack/partial/base.js b/config/webpack/partial/base.js
index c3117f4..d91fb54 100644
--- a/config/webpack/partial/base.js
+++ b/config/webpack/partial/base.js
@@ -1,36 +1,26 @@
-import {compose, assoc, identity, map} from 'ramda';
+import {compose, assoc, identity} from 'ramda';
import nearest from 'find-nearest-file';
import path from 'path';
import webpack from 'webpack';
-import fs from 'fs';
-import {output, loader, plugin} from 'webpack-partial';
+import {output, plugin} from 'webpack-partial';
// import env from 'webpack-config-env';
// import css from './css';
// import icon from './icon';
// import image from './image';
+import babel from './babel';
+import intl from './intl';
import CleanPlugin from 'clean-webpack-plugin';
import CaseSensitivePathsPlugin from 'case-sensitive-paths-webpack-plugin';
import StatsPlugin from 'stats-webpack-plugin';
const context = path.dirname(nearest('package.json'));
-const babelConfig = JSON.parse(fs.readFileSync(path.join(context, '.babelrc')));
const isProduction = process.env.NODE_ENV === 'production';
const isDev = !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
-const getTargets = (target) => {
- switch (target) {
- case 'node':
- return {node: 'current'};
- case 'web':
- default:
- return undefined;
- }
-};
-
const base = ({name, target}) => compose(
(config) => plugin(new CleanPlugin([config.output.path], {
root: config.context,
@@ -49,38 +39,8 @@ const base = ({name, target}) => compose(
NODE_ENV: {required: false},
}),*/
- (config) => loader({
- loader: 'babel-loader',
- include: [
- path.join(config.context, 'src'),
- path.join(config.context, 'lib'),
- ],
- test: /\.js$/,
- options: {
- ...babelConfig,
- presets: map((entry) => {
- const [name, config] = Array.isArray(entry) ?
- entry : [entry, {}];
- if (name === '@babel/preset-env') {
- return [name, {
- ...config,
- modules: false,
- useBuiltIns: 'usage',
- ignoreBrowserslistConfig: target === 'node',
- targets: getTargets(target),
- include: [
- ...(config.include || []),
- // While newer versions of node support this, `webpack` does
- // not because it uses `acorn`. So adjust accordingly.s
- 'proposal-object-rest-spread',
- ],
- }];
- }
- return entry;
- }, babelConfig.presets),
- cacheDirectory: false,
- },
- }, config),
+ babel(),
+ intl(),
// icon(),
// image(),
@@ -122,10 +82,10 @@ const base = ({name, target}) => compose(
path: path.join(context, 'dist', name),
...isProduction && target === 'web' ? {
filename: '[name].[chunkhash].js',
- chunkFilename: '[id].[name].[chunkhash].js',
+ chunkFilename: '[name].[chunkhash].js',
} : {
filename: '[name].js',
- chunkFilename: '[id].[name].js',
+ chunkFilename: '[name].js',
},
}),
diff --git a/config/webpack/partial/intl.js b/config/webpack/partial/intl.js
new file mode 100644
index 0000000..c05dbee
--- /dev/null
+++ b/config/webpack/partial/intl.js
@@ -0,0 +1,75 @@
+import {join, isAbsolute} from 'path';
+import fs from 'fs';
+
+import {pipe, uniq, map, filter, concat} from 'ramda';
+import compact from 'lodash/fp/compact';
+
+import chalk from 'chalk';
+import {ContextReplacementPlugin} from 'webpack';
+import {plugin, alias, loader} from 'webpack-partial';
+import localeEmoji from 'locale-emoji';
+
+export default ({messagesDir = 'intl/messages'} = {}) => (config) => {
+ const messagesDirPath = isAbsolute(messagesDir)
+ ? messagesDir
+ : join(config.context, messagesDir);
+
+ // Infer the current available locales from the names of the files within the
+ // `/locale` directory. This way the current locales are automatically parsed
+ // and can be referenced from within the build.
+ const localeFiles = filter((name) => {
+ return /^[a-z]{2}(-[a-z0-9]{2})?\..*$/i.test(name);
+ }, fs.readdirSync(messagesDirPath));
+
+ const locales = pipe(
+ map((file) => {
+ return /^[a-z]{2}(-[a-z0-9]{2})?/i.exec(file)[0];
+ }),
+ concat(['en-US']),
+ uniq,
+ )(localeFiles);
+
+ // Define a default locale, use the env `DEFAULT_LOCALE` if it is among the
+ // parsed available locales, otherwise fall back to the default `en`.
+ // const defaultLocale = find(eq(process.env.DEFAULT_LOCALE), locales)
+ // || locales[0];
+
+ // Parse the current languages from the current locales by removing the
+ // country codes from the locale identifiers. React intl locale-data polyfill
+ // modules are defined only by language, not country as well.
+ const languages = pipe(map((locale) => locale.split('-')[0]), uniq)(locales);
+
+ // React intl bundles `en` language data by default, so we never want to
+ // include id separately.
+ // const reactIntlLanguages = reject(eq('en'), languages);
+
+ console.log(`🌐 ${chalk.bold('Build Locales')}\n${pipe(
+ map((locale) => ` ${locale} ${localeEmoji(locale)}`),
+ compact,
+ uniq,
+ )(locales).join('\n')}`);
+
+ return pipe(
+ // Alias React Intl and Intl to versions that do not bundle all locale
+ // data by default.
+ alias('react-intl$', require.resolve('react-intl/dist/react-intl.js')),
+ alias('intl$', require.resolve('intl/lib/core.js')),
+ plugin(new ContextReplacementPlugin(/^react-intl\/locale-data/, (x) => {
+ x.regExp = new RegExp(languages.join('|'));
+ x.chunkName = 'intl-react-[request]';
+ })),
+ plugin(new ContextReplacementPlugin(/^intl\/locale-data/, (x) => {
+ x.regExp = new RegExp(locales.join('|'));
+ x.chunkName = 'intl-polyfill-[request]';
+ })),
+ plugin(new ContextReplacementPlugin(/intl\/messages/, (x) => {
+ x.regExp = new RegExp(locales.join('|'));
+ x.chunkName = 'intl-messages-[request]';
+ console.log(x);
+ })),
+ loader({
+ test: /intl\/messages\//,
+ loader: ['json-loader', 'yaml-loader'],
+ })
+ )(config);
+};
diff --git a/intl/generateLocaleFiles.js b/intl/generateLocaleFiles.js
new file mode 100644
index 0000000..0f7ee81
--- /dev/null
+++ b/intl/generateLocaleFiles.js
@@ -0,0 +1,56 @@
+/* eslint-disable no-console */
+import {transformFile} from '@babel/core';
+import glob from 'glob';
+import fs from 'fs';
+
+const config = JSON.parse(fs.readFileSync('.babelrc'));
+
+const options = {
+ ...config,
+ plugins: [
+ 'react-intl',
+ ...config.plugins,
+ ],
+};
+
+const messages = [];
+
+const processMessages = (x) => {
+ messages.push(...x);
+};
+
+const done = () => {
+ const data = messages.map(({description, id, defaultMessage}) => {
+ return [
+ `# ${description}`,
+ `${id}: ${defaultMessage}`,
+ ].join('\n');
+ }).join('\n\n');
+
+ fs.writeFileSync(
+ './intl/messages/default.yml',
+ data,
+ );
+};
+
+glob('./src/component/**/*.js', (err, matches) => {
+ if (err) {
+ console.error(err);
+ process.exit(1);
+ }
+ let count = matches.length;
+ matches.forEach((file) => {
+ transformFile(file, options, (err, result) => {
+ --count;
+ if (!err) {
+ processMessages(result.metadata['react-intl'].messages.map((x) => ({
+ ...x,
+ file,
+ })));
+ }
+ if (count === 0) {
+ done();
+ }
+ });
+ });
+});
diff --git a/intl/messages/default.yml b/intl/messages/default.yml
new file mode 100644
index 0000000..81712fd
--- /dev/null
+++ b/intl/messages/default.yml
@@ -0,0 +1,2 @@
+# undefined
+some message: Hello World
\ No newline at end of file
diff --git a/intl/messages/en-CA.yml b/intl/messages/en-CA.yml
new file mode 100644
index 0000000..da0cd6b
--- /dev/null
+++ b/intl/messages/en-CA.yml
@@ -0,0 +1 @@
+some message: Hello World
diff --git a/package-lock.json b/package-lock.json
index f69a609..a461e04 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -261,6 +261,11 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.0.0-beta.38.tgz",
"integrity": "sha512-YboIs/srf1yKLRvZ6JoaGjpkcW3UYsPKhkPt3hWO54OVouS34A5dwK8wO3wGhvKgUvq2GV+tU4eRNkgEcx7aLA=="
},
+ "@babel/plugin-syntax-dynamic-import": {
+ "version": "7.0.0-beta.38",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.0.0-beta.38.tgz",
+ "integrity": "sha512-P4jmUCkIPPJ2B2DEzePJql0m94kqrkhLYTUs5q29br3jK8SMnzR7mOla8sE+98+oEXi7X39Ksquv2gA18v971A=="
+ },
"@babel/plugin-syntax-flow": {
"version": "7.0.0-beta.38",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.0.0-beta.38.tgz",
@@ -757,7 +762,6 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.9.tgz",
"integrity": "sha1-c9g7wmP4bpf4zE9rrhsOkKfSLIY=",
- "dev": true,
"requires": {
"sprintf-js": "1.0.3"
}
@@ -1375,6 +1379,16 @@
"resolve": "1.5.0"
}
},
+ "babel-plugin-react-intl": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-react-intl/-/babel-plugin-react-intl-2.3.1.tgz",
+ "integrity": "sha1-PUORLoJNoAXgjo6COdW6eEN0uwA=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "intl-messageformat-parser": "1.4.0",
+ "mkdirp": "0.5.1"
+ }
+ },
"babel-plugin-react-rename": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-rename/-/babel-plugin-react-rename-0.1.0.tgz",
@@ -5800,6 +5814,11 @@
"resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz",
"integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ="
},
+ "intl": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/intl/-/intl-1.2.5.tgz",
+ "integrity": "sha1-giRKIZDE5Bn4Nx9ao02qNCDiq94="
+ },
"intl-format-cache": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/intl-format-cache/-/intl-format-cache-2.1.0.tgz",
@@ -6178,7 +6197,6 @@
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.10.0.tgz",
"integrity": "sha512-O2v52ffjLa9VeM43J4XocZE//WT9N0IiwDa3KSHH7Tu8CtH+1qM8SIZvnsTh6v+4yFy5KUY3BHUVwjpfAWsjIA==",
- "dev": true,
"requires": {
"argparse": "1.0.9",
"esprima": "4.0.0"
@@ -6246,6 +6264,11 @@
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.1.tgz",
"integrity": "sha1-5CGiqOINawgZ3yiQj3glJrlt0f4="
},
+ "json-loader": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/json-loader/-/json-loader-0.5.7.tgz",
+ "integrity": "sha512-QLPs8Dj7lnf3e3QYS1zkCo+4ZwqOiF9d/nZnYozTISxXWCfNs9yuky5rJw4/W34s7POaNlbZmQGaB5NiXCbP4w=="
+ },
"json-schema": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
@@ -6625,6 +6648,11 @@
"json5": "0.5.1"
}
},
+ "locale-emoji": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/locale-emoji/-/locale-emoji-0.3.0.tgz",
+ "integrity": "sha512-JGm8+naU49CBDnH1jksS3LecPdfWQLxFgkLN6ZhYONKa850pJ0Xt8DPGJnYK0ZuJI8jTuiDDPCDtSL3nyacXwg=="
+ },
"locate-path": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
@@ -9018,8 +9046,7 @@
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
- "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
- "dev": true
+ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"sshpk": {
"version": "1.13.1",
@@ -10657,6 +10684,14 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
"integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI="
},
+ "yaml-loader": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/yaml-loader/-/yaml-loader-0.5.0.tgz",
+ "integrity": "sha512-p9QIzcFSNm4mCw/m5NdyMfN4RE4aFZJWRRb01ERVNGCym8VNbKtw3OYZXnvUIkim6U/EjqE/2yIh9F/msShH9A==",
+ "requires": {
+ "js-yaml": "3.10.0"
+ }
+ },
"yargs": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-9.0.1.tgz",
diff --git a/package.json b/package.json
index 0613e28..cd1264d 100644
--- a/package.json
+++ b/package.json
@@ -4,10 +4,11 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
- "build:client": "./node_modules/.bin/webpack --config ./config/webpack/client.webpack.config.babel.js",
- "build:render": "./node_modules/.bin/webpack --config ./config/webpack/render.webpack.config.babel.js",
- "build:server": "./node_modules/.bin/webpack --config ./config/webpack/server.webpack.config.babel.js",
- "build": "npm run build:render && npm run build:client",
+ "intl": "node -r @babel/register ./intl/generateLocaleFiles.js",
+ "build:client": "./node_modules/.bin/webpack --config ./config/webpack/client.babel.js",
+ "build:render": "./node_modules/.bin/webpack --config ./config/webpack/render.babel.js",
+ "build:server": "./node_modules/.bin/webpack --config ./config/webpack/server.babel.js",
+ "build": "npm run build:render && npm run build:client && npm run build server",
"dev": "./node_modules/.bin/webpack-udev-server --config ./config/webpack/client.webpack.config.babel.js --config ./config/webpack/server.webpack.config.babel.js",
"test:lint": "./node_modules/.bin/eslint .",
"test:flow": "./node_modules/.bin/flow check",
@@ -16,6 +17,7 @@
},
"dependencies": {
"@babel/core": "^7.0.0-beta.38",
+ "@babel/plugin-syntax-dynamic-import": "^7.0.0-beta.38",
"@babel/plugin-transform-runtime": "^7.0.0-beta.37",
"@babel/preset-env": "^7.0.0-beta.38",
"@babel/preset-flow": "^7.0.0-beta.38",
@@ -25,11 +27,15 @@
"autoprefixer": "^7.2.5",
"babel-loader": "^8.0.0-beta.0",
"babel-plugin-module-resolver": "^3.0.0",
+ "babel-plugin-react-intl": "^2.3.1",
"babel-plugin-react-rename": "^0.1.0",
"clean-webpack-plugin": "^0.1.17",
"cross-loader": "^0.1.0",
"find-nearest-file": "^1.1.0",
"htmlescape": "^1.1.1",
+ "intl": "^1.2.5",
+ "json-loader": "^0.5.7",
+ "locale-emoji": "^0.3.0",
"midori": "^1.0.0",
"pages-webpack-plugin": "^0.1.0",
"postcss-nesting": "^4.2.1",
@@ -48,7 +54,8 @@
"webpack": "^4.0.0-alpha.4",
"webpack-cli": "^2.0.4",
"webpack-node-externals": "^1.6.0",
- "webpack-partial": "^2.2.0"
+ "webpack-partial": "^2.2.0",
+ "yaml-loader": "^0.5.0"
},
"devDependencies": {
"adana-dump": "^0.1.0",
diff --git a/src/action/README.md b/src/action/README.md
new file mode 100644
index 0000000..3b406e6
--- /dev/null
+++ b/src/action/README.md
@@ -0,0 +1 @@
+# /action
diff --git a/src/action/intl.action.js b/src/action/intl.action.js
new file mode 100644
index 0000000..e1ceaf2
--- /dev/null
+++ b/src/action/intl.action.js
@@ -0,0 +1,76 @@
+// @flow
+
+// =============================================================================
+// Import modules.
+// =============================================================================
+import {loadLocaleData, loadMessages} from '/intl';
+import {canUseDOM} from 'fbjs/lib/ExecutionEnvironment';
+
+// =============================================================================
+// Import utils.
+// =============================================================================
+// import {makeSerializable} from '/util/error.util';
+
+// =============================================================================
+// Import errors.
+// =============================================================================
+// import type SerializableError from '/error/SerializableError';
+
+// =============================================================================
+// Import store.
+// =============================================================================
+import type {ThunkAction} from '/types';
+
+export type LocaleLoadStartedAction = {
+ type: 'intl/LOCALE_LOAD_STARTED',
+ payload: {locale: string},
+};
+
+export type LocaleLoadEndedAction = {
+ type: 'intl/LOCALE_LOAD_ENDED',
+ payload: {
+ locale: string,
+ messages: {[string]: string},
+ },
+};
+export type LocaleLoadFailedAction = {
+ type: 'intl/LOCALE_LOAD_FAILED',
+ // payload: SerializableError<*>,
+};
+
+export function loadLocale(locale: string): ThunkAction {
+ return (dispatch) => {
+ dispatch({
+ type: 'intl/LOCALE_LOAD_STARTED',
+ payload: {locale},
+ });
+
+ return Promise.all([
+ loadMessages(locale),
+ loadLocaleData(locale),
+ ]).then(
+ ([messages]) => {
+ if (canUseDOM) {
+ // eslint-disable-next-line no-undef
+ const html = document.documentElement;
+ if (html) {
+ html.setAttribute('lang', locale);
+ }
+ // TODO: Set a cookie to persist locale for subsequent requests.
+ }
+ dispatch({
+ type: 'intl/LOCALE_LOAD_ENDED',
+ payload: {locale, messages},
+ });
+ },
+ (error) => {
+ dispatch({
+ type: 'intl/LOCALE_LOAD_FAILED',
+ // payload: makeSerializable(error),
+ });
+
+ return Promise.reject(error);
+ }
+ );
+ };
+}
diff --git a/src/client/index.js b/src/client/index.js
index d32606b..1791f19 100644
--- a/src/client/index.js
+++ b/src/client/index.js
@@ -2,10 +2,14 @@ import renderApp from './renderApp';
import parseInitialState from './parseInitialState';
import createStore from '/store';
+import {loadLocale} from '/action/intl.action';
+
const state = parseInitialState();
const store = createStore(state);
+loadLocale();
+
// setExceptionHandler(renderError)
// setExceptionHandler(renderApp)
renderApp(store);
diff --git a/src/component/provider/IntlProvider/IntlProvider.js b/src/component/provider/IntlProvider/IntlProvider.js
new file mode 100644
index 0000000..101ae7b
--- /dev/null
+++ b/src/component/provider/IntlProvider/IntlProvider.js
@@ -0,0 +1,40 @@
+// @flow
+// =============================================================================
+// Import modules.
+// =============================================================================
+import React from 'react';
+import {connect} from 'react-redux';
+import {IntlProvider} from 'react-intl';
+
+// =============================================================================
+// Import selectors.
+// =============================================================================
+import {getLocale, getCurrentMessages} from '/selector/intl.selector';
+
+export type Props = {
+ locale: string,
+ messages: {[string]: string},
+};
+
+export const Intl = ({locale, messages, ...props}: Props) => (
+
+);
+
+export default connect(
+ (state): Props => ({
+ locale: getLocale(state),
+ messages: getCurrentMessages(state),
+ }),
+ null,
+)(Intl);
diff --git a/src/component/provider/IntlProvider/index.js b/src/component/provider/IntlProvider/index.js
new file mode 100644
index 0000000..d5277ef
--- /dev/null
+++ b/src/component/provider/IntlProvider/index.js
@@ -0,0 +1,2 @@
+// @flow
+export {default} from './IntlProvider';
diff --git a/src/component/provider/README.md b/src/component/provider/README.md
new file mode 100644
index 0000000..702bafb
--- /dev/null
+++ b/src/component/provider/README.md
@@ -0,0 +1,5 @@
+# Provider Components
+
+Provider components are used within route components to provide global values on the react context for the entire component tree.
+
+Provider component never render any elements of their own and simply return their inner children.
diff --git a/src/component/root/App/App.js b/src/component/root/App/App.js
index 098f406..eba9a23 100644
--- a/src/component/root/App/App.js
+++ b/src/component/root/App/App.js
@@ -5,16 +5,25 @@ import {Switch, Match} from 'waygate';
import {compose} from 'ramda';
import {hot} from 'react-hot-loader';
+import IntlProvider from '/component/provider/IntlProvider';
+
+import {FormattedMessage} from 'react-intl';
+
const App = ({store}) => (
-
-
- Hello world .
-
-
- Not found Y.
-
-
+
+
+
+
+
+
+ Not found Y.
+
+
+
);
diff --git a/src/intl/index.js b/src/intl/index.js
new file mode 100644
index 0000000..7a0f1c8
--- /dev/null
+++ b/src/intl/index.js
@@ -0,0 +1,2 @@
+export {default as loadLocaleData} from './loadLocaleData';
+export {default as loadMessages} from './loadMessages';
diff --git a/src/intl/loadLocaleData.js b/src/intl/loadLocaleData.js
new file mode 100644
index 0000000..1b826be
--- /dev/null
+++ b/src/intl/loadLocaleData.js
@@ -0,0 +1,83 @@
+import {addLocaleData} from 'react-intl';
+import {memoize} from 'ramda';
+
+// Remove the country code from a locale identifier.
+export const parseLanguage = (locale) => locale.split('-')[0];
+
+/**
+ * Load a locale data set for `react-intl`. The loaded data is automatically
+ * provided to `react-intl` through `addLocaleData`. No additional action is
+ * necessary.
+ *
+ * Locale data is hosted in async webpack chunks. Webpack's `require.ensure` is
+ * used to universally handle the request for both client and server.
+ *
+ * This function is memoized to return the same Promise instance for any given
+ * language string.
+ *
+ * This function is defined separately from `loadReactIntlLocaleData` to allow
+ * memoization based on a parsed language value.
+ *
+ * @param {String} language - A languauge string, without country, e.x. `en`.
+ * @returns {Promise} - A Promise.
+ */
+export const loadReactIntlLocaleDataForLanguage = memoize((language) => {
+ if (language === 'en') {
+ return Promise.resolve();
+ }
+ return import(`react-intl/locale-data/${language}.js`).then((data) => {
+ addLocaleData(data);
+ return Promise.resolve(data);
+ });
+});
+
+/**
+ * Load a locale data set for `react-intl`. The loaded data is automatically
+ * provided to `react-intl` through `addLocaleData`. No additional action is
+ * necessary.
+ *
+ * Locale data is hosted in async webpack chunks. Webpack's `require.ensure` is
+ * used to universally handle the request for both client and server.
+ *
+ * @param {String} locale - A locale string, e.x. `en-US`.
+ * @returns {Promise} - A Promise.
+ */
+export const loadReactIntlLocaleData = (locale) => {
+ // react-intl categorizes locale data by language only, the country code of
+ // the locale string is not considered.
+ return loadReactIntlLocaleDataForLanguage(parseLanguage(locale));
+};
+
+/**
+ * Load a locale data set for the `Intl` polyfill. The loaded data self-injects
+ * into the global `Intl` instance provided by the polyfill. No additional
+ * action is necessary.
+ *
+ * This function is memoized to return the same Promise instance for any given
+ * locale string.
+ *
+ * @param {String} locale - A locale string, e.x. `en-US`.
+ * @returns {Promise} - A Promise.
+ */
+export const loadPolyfillLocaleData = memoize((locale) => {
+ if (!global.IntlPolyfill) {
+ // The environment has a built in `Intl` object. Or we are in a server
+ // build. It is not necessary to load additional locale data.
+ // (The Intl polyfill bundles all locale data for node-targeded builds.)
+ return Promise.resolve();
+ }
+ return import(`intl/locale-data/jsonp/${locale}.js`);
+});
+
+/**
+ * Load any initial locale data required for the current locale.
+ *
+ * @param {String} locale - A locale string, e.x. `en-US`.
+ * @returns {Promise} - A Promise.
+ */
+const loadLocaleData = (locale) => Promise.all([
+ loadReactIntlLocaleData(locale),
+ loadPolyfillLocaleData(locale),
+]);
+
+export default loadLocaleData;
diff --git a/src/intl/loadMessages.js b/src/intl/loadMessages.js
new file mode 100644
index 0000000..fae2089
--- /dev/null
+++ b/src/intl/loadMessages.js
@@ -0,0 +1,23 @@
+import {memoize} from 'ramda';
+
+/**
+ * Load a set of translated messages for a specific locale. The new message
+ * data is fulfilled with the promise. It can then be supplied to a react-intl
+ * `` instance.
+ *
+ * This function is memoized to return the same Promise instance for any given
+ * locale string.
+ *
+ * @param {String} locale - A locale string, e.x. `en-US`.
+ * @returns {Promise} - A Promise.
+ */
+const loadMessages = memoize((locale) => {
+ // The app default messages are defined in `en`. There is no external
+ // translation for these messages.
+ if (locale === 'en') {
+ return Promise.resolve();
+ }
+ return import(`../../intl/messages/${locale}.yml`);
+});
+
+export default loadMessages;
diff --git a/src/log/index.js b/src/log/index.js
index 685e5d7..ac84213 100644
--- a/src/log/index.js
+++ b/src/log/index.js
@@ -1,11 +1,50 @@
/* @flow */
/* eslint-disable no-console */
-const createLog = (level: string) => (...msgs: Array<*>) => {
- console[level](...msgs);
+type LogLevel = 'debug' | 'info' | 'error' | 'warn';
+type LogObject = {
+ message: string,
+ level: LogLevel,
+ [string]: any,
+};
+type LogMessage = string | LogObject;
+type LogEntry = (() => LogMessage)
+| LogMessage;
+
+const getLogObject = (
+ level: LogLevel,
+ entry: LogEntry
+): LogObject => {
+ if (typeof entry === 'string') {
+ return {message: entry, level};
+ } else if (typeof entry === 'function') {
+ return getLogObject(level, entry());
+ } else if (entry && (typeof entry === 'object')) {
+ return entry;
+ }
+ return {message: 'Unable to log entry.', level: 'error'};
+};
+
+const printLogMessage = (msg: LogObject) => {
+ console[msg.level](msg.message);
+ if (msg.error && msg.error.stack) {
+ console[msg.level](msg.error.stack);
+ }
+};
+
+const createLog = (level: LogLevel) => (msg: LogEntry) => {
+ const object = getLogObject(level, msg);
+ printLogMessage(object);
};
export const debug = createLog('debug');
export const info = createLog('info');
export const warn = createLog('warn');
export const error = createLog('error');
+
+export default {
+ debug,
+ info,
+ warn,
+ error,
+};
diff --git a/src/reducer/index.js b/src/reducer/index.js
index 4dd62f0..0edde20 100644
--- a/src/reducer/index.js
+++ b/src/reducer/index.js
@@ -7,9 +7,12 @@ import {combineReducers} from 'redux';
import {reducer as form} from 'redux-form';
import {reducer as waygate} from 'waygate';
+import intl from './intl.reducer';
+
const reducer = {
waygate,
form,
+ intl,
};
export type Reducer = typeof reducer;
diff --git a/src/reducer/intl.reducer.js b/src/reducer/intl.reducer.js
new file mode 100644
index 0000000..c3a4edc
--- /dev/null
+++ b/src/reducer/intl.reducer.js
@@ -0,0 +1,60 @@
+// @flow
+
+// =============================================================================
+// Import modules.
+// =============================================================================
+import {combineReducers} from 'redux';
+
+// =============================================================================
+// Import errors.
+// =============================================================================
+// import type SerializableError from '/error/SerializableError';
+
+// =============================================================================
+// Import actions.
+// =============================================================================
+import type {Action} from '/types';
+
+const defaultLocale: string = process.env.DEFAULT_LOCALE || 'en';
+
+function locale(state: string = defaultLocale, action: Action) {
+ switch (action.type) {
+ case 'intl/LOCALE_LOAD_ENDED': return action.payload.locale;
+ default: return state;
+ }
+}
+
+function messages(state: {[string]: ?{[string]: string}} = {}, action: Action) {
+ switch (action.type) {
+ case 'intl/LOCALE_LOAD_ENDED':
+ return {
+ ...state,
+ [action.payload.locale]: action.payload.messages,
+ };
+ default: return state;
+ }
+}
+
+function loadingLocale(state: string | null = null, action: Action) {
+ switch (action.type) {
+ case 'intl/LOCALE_LOAD_STARTED': return action.payload.locale;
+ case 'intl/LOCALE_LOAD_ENDED': return null;
+ case 'intl/LOCALE_LOAD_FAILED': return null;
+ default: return state;
+ }
+}
+
+function error(state: SerializableError<*> | null = null, action: Action) {
+ switch (action.type) {
+ case 'intl/LOCALE_LOAD_STARTED': return null;
+ case 'intl/LOCALE_LOAD_FAILED': return action.payload;
+ default: return state;
+ }
+}
+
+export default combineReducers({
+ locale,
+ messages,
+ loadingLocale,
+ error,
+});
diff --git a/src/selector/README.md b/src/selector/README.md
new file mode 100644
index 0000000..8b09f3f
--- /dev/null
+++ b/src/selector/README.md
@@ -0,0 +1,11 @@
+# State Selectors
+
+Values within the redux state atom are accessed using selector functions.
+
+Any selector function must follow the strict pattern of `(state) => result`, it should take the _entire_ state as the first argument - it should not operate on only a branch of the state tree.
+
+When complex values need to be computed from the state, the logic are defined as memoized selectors. Selectors that need "arguments" are defined as higher-order memoized selector-creators which return memoized selectors for a specific argument set.
+
+Following strict memoization patterns ensures that rapid state updates do not result in numerous unnecessary updates in the component tree. It is paramount to ensure stable UI performance as an app grows in complexity.
+
+It will be tempting to add logic to the `mapState` function of connect calls but anything beyond trivial object value lookup should be defined as a reusable selector.
diff --git a/src/selector/intl.selector.js b/src/selector/intl.selector.js
new file mode 100644
index 0000000..8e16d93
--- /dev/null
+++ b/src/selector/intl.selector.js
@@ -0,0 +1,11 @@
+// @flow
+
+import type {State} from '/store';
+
+export function getLocale(state: State) {
+ return state.intl.locale;
+}
+
+export function getCurrentMessages(state: State) {
+ return state.intl.messages[getLocale(state)] || {};
+}
diff --git a/src/server/createErrorHandler.js b/src/server/createErrorHandler.js
index bbecc08..f771855 100644
--- a/src/server/createErrorHandler.js
+++ b/src/server/createErrorHandler.js
@@ -2,6 +2,7 @@
import {error, compose, status, header, send} from 'midori';
import {readFileSync} from 'fs';
import {renderError} from '/render';
+import log from '/log';
import type {AppCreator} from 'midori/types';
@@ -10,6 +11,7 @@ import type {AppCreator} from 'midori/types';
* @returns {AppCreator} Midori app.
*/
const handleAppError = (): AppCreator => error(async (error, req) => {
+ log.error(error);
const {markup, status} = await renderError({
stats: req.stats,
path: req.url,
@@ -30,6 +32,7 @@ const handleAppError = (): AppCreator => error(async (error, req) => {
const handleEmergencyError = (): AppCreator => {
const markup = readFileSync('error.html', 'utf8');
return error(() => {
+ log.error();
return compose(
status(500),
header('Content-Type', 'text/html; charset=utf-8'),
diff --git a/src/server/createLogHandler.js b/src/server/createLogHandler.js
new file mode 100644
index 0000000..75ea6dc
--- /dev/null
+++ b/src/server/createLogHandler.js
@@ -0,0 +1,12 @@
+import {request, next} from 'midori';
+import log from '/log';
+
+export default () => request((req) => {
+ log.info(() => {
+ return {
+ message: 'request',
+ method: req.method,
+ };
+ });
+ return next;
+});
diff --git a/src/types.js b/src/types.js
index bbcf76a..c3dec9f 100644
--- a/src/types.js
+++ b/src/types.js
@@ -2,7 +2,15 @@
import type {Reducer} from '/reducer';
import type {Store as $Store} from 'redux';
-export type Action = {};
+import type {
+ LocaleLoadStartedAction,
+ LocaleLoadEndedAction,
+ LocaleLoadFailedAction,
+} from '/action/intl.action';
+
+export type Action = LocaleLoadStartedAction |
+ LocaleLoadEndedAction |
+ LocaleLoadFailedAction;
// =============================================================================
// Import context.