diff --git a/.babelrc b/.babelrc index 0578679..5fbb85d 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,8 @@ { - "presets": ["react", "es2015"] + "presets": ["react", "es2015"], + "plugins": [ + ["react-intl", { + "messagesDir": "./build/messages/" + }] + ] } diff --git a/.gitignore b/.gitignore index 5a0aa70..482e0fa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules dist *.css *.log +build diff --git a/README.md b/README.md index be0cbd0..163fa86 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,32 @@ Generate the app: `cd tpa-demo-app-react-jsx/` 3. Install npm `npm install` -4. Run webpack -`Webpack` +4. Run build +`npm run build` 5. Run the app `npm start` - This will run a webserver on your local host. You can now view your app in your browser: http://localhost:3000/ +###**Internationalisation** +The entire app supports dates formating, numbers, and strings, including pluralization and handling translations, using [react-intl](https://github.com/yahoo/react-intl) +#### Adding new locale +Add comma separated locales to environment variable `WIX_TPA_LOCALES` +Example: +* for development `WIX_TPA_LOCALES=en,ru npm run start` +* for deploying `WIX_TPA_LOCALES=en,ru npm run build` + +There is no need to do any importing or preloading, it is served automatically + +#### Translating messages (strings) +Each app (`src/settings` and `src/widget`) has `lang` dir inside. +`en.json` will be automatically added to this directory (task: build:langs) +1. Copy and rename `en.json` to desired locale name e.g. `ru.json` +2. Translate content of `ru.json` +See `src/settings/lang/README.md` for messages syntax + +#### Language detection +Currently language is defined by browser (first value of `navigator.languages`) + ###**Registering the App** 1. Follow the registration guide to create a new app. diff --git a/package.json b/package.json index 494e5c8..b3f675f 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,40 @@ { "name": "tpa-demo-app-react-jsx", "scripts": { - "start": "node server.js" + "start": "./node_modules/.bin/cross-env node server.js", + "build:run": "./node_modules/.bin/cross-env ./node_modules/.bin/webpack", + "build:langs": "./node_modules/.bin/cross-env ./node_modules/.bin/babel-node scripts/translate.js", + "build": "npm run build:langs && npm run build:run" }, - "version": "0.0.1", + "version": "0.1.2", "dependencies": { + "async": "^2.1.5", + "intl": "^1.2.5", "react": "^15.3.0", - "react-dom": "^15.3.0" + "react-dom": "^15.3.0", + "react-intl": "^2.2.3" }, "devDependencies": { + "babel-cli": "^6.23.0", "babel-core": "^6.3.26", "babel-loader": "^6.2.0", + "babel-plugin-react-intl": "^2.3.1", "babel-preset-es2015": "^6.5.0", "babel-preset-react": "^6.5.0", + "bundle-loader": "^0.5.5", + "cross-env": "^3.1.4", "css-loader": "^0.23.1", + "file-loader": "^0.10.1", + "glob": "^7.1.1", + "json-loader": "^0.5.4", + "mkdirp": "^0.5.1", "node-sass": "^3.4.2", + "react-hot-loader": "^1.3.0", + "rimraf": "^2.6.1", "sass-loader": "^3.2.0", "script-loader": "^0.6.1", "style-loader": "^0.13.0", "webpack": "^1.12.14", - "webpack-dev-server": "^1.14.1", - "react-hot-loader": "^1.3.0" + "webpack-dev-server": "^1.14.1" } } diff --git a/scripts/bundle-loader-i18n.js b/scripts/bundle-loader-i18n.js new file mode 100644 index 0000000..71a4a72 --- /dev/null +++ b/scripts/bundle-loader-i18n.js @@ -0,0 +1,91 @@ +import waterfall from 'async/waterfall'; +import {addLocaleData} from 'react-intl'; + +export default function (resultCallback, appName) { + const + fbLocale = 'en', + locale = (navigator.languages && navigator.languages[0]) || + navigator.language || + navigator.userLanguage || + fbLocale; + + waterfall([ + function (callback) { + if (!window.Intl) { + require(`bundle?lazy&name=intl.pf!intl`)((file) => { + callback(null, true); + }); + } else { + callback(null, false); + } + }, + function (oldBrowser, callback) { + if (oldBrowser) { + try { + require(`bundle?lazy&name=[name].locale.pf!intl/locale-data/jsonp/${locale}`)((file) => { + callback(null, oldBrowser); + }); + } catch (err) { + console.error(`Can not find polyfill for locale '${locale}'. Fallback to locale ${fbLocale}`); + + require(`bundle?lazy&name=[name].locale.pf!intl/locale-data/jsonp/${fbLocale}`)((file) => { + callback(null, oldBrowser); + }); + } + } else { + callback(null, oldBrowser); + } + }, + function (oldBrowser, callback) { + if (oldBrowser) { + callback(null, oldBrowser); + } else { + try { + require(`bundle?lazy&name=[name].locale!react-intl/locale-data/${locale.substring(0, 2)}`)((file) => { + addLocaleData(file); + callback(null, oldBrowser); + }); + } catch (err) { + console.error(`Can not find any data for locale '${locale}'. Fallback to locale ${fbLocale}`); + + require(`bundle?lazy&name=[name].locale!react-intl/locale-data/${fbLocale}`)((file) => { + addLocaleData(file); + callback(null, oldBrowser); + }); + } + } + }, function (oldBrowser, callback) { + try { + // no way to substitute string - it should be known outside of a runtime + if (appName == 'settings') { + require(`bundle?lazy&name=[name].settings!../src/settings/lang/${locale}.json`)((file) => { + callback(null, {locale: locale, data: file}); + }); + } else if (appName == 'widget') { + require(`bundle?lazy&name=[name].widget!../src/widget/lang/${locale}.json`)((file) => { + callback(null, {locale: locale, data: file}); + }); + } + + } catch (err) { + console.error(`Can not load translated messages for locale '${locale}'. Check existence in 'lang' directory. Fallback to default strings of locale: ${fbLocale}`); + + // no way to substitute string - it should be known outside of a runtime + if (appName == 'settings') { + require(`bundle?lazy&name=[name].settings!../src/settings/lang/${fbLocale}.json`)((file) => { + callback(null, {locale: fbLocale, data: file}); + }); + } else if (appName == 'widget') { + require(`bundle?lazy&name=[name].widget!../src/widget/lang/${fbLocale}.json`)((file) => { + callback(null, {locale: fbLocale, data: file}); + }); + } + + } + } + ], function (err, result) { + if (err) throw new Error('Locale initialization problem.'); + + resultCallback(result.locale, result.data); + }); +} diff --git a/scripts/translate.js b/scripts/translate.js new file mode 100644 index 0000000..7170534 --- /dev/null +++ b/scripts/translate.js @@ -0,0 +1,46 @@ +import * as fs from 'fs'; +import path from 'path'; +import {sync as globSync} from 'glob'; +import {sync as mkdirpSync} from 'mkdirp'; + +const SRC_MESSAGES_BASE = './build/messages/src'; +const SRC_MESSAGES_PATTERN = SRC_MESSAGES_BASE + '/**/*.json'; +const DST_MESSAGES_BASE = './src/'; +const LANG_DIR_NAME = 'lang'; + +let defaultMessages = globSync(SRC_MESSAGES_PATTERN) + .map((filename) => { + return { + subDir: path.relative(SRC_MESSAGES_BASE, filename).split('/').shift(), + filename: filename + }; + }) + .map((item) => { + return { + subDir: item.subDir, + content: fs.readFileSync(item.filename, 'utf8') + }; + }) + .map((file) => { + return JSON.parse(file.content).map( + (item) => Object.assign({subDir: file.subDir}, item) + ); + }) + .reduce((collection, descriptor) => { + descriptor.forEach(({id, defaultMessage, subDir}) => { + if (collection[subDir] && collection[subDir].hasOwnProperty(id)) { + throw new Error(`Duplicate message id: ${id}`); + } + if (!collection[subDir]) collection[subDir] = {}; + collection[subDir][id] = defaultMessage; + }); + + return collection; + }, {}); + +Object.keys(defaultMessages).map(function (subDir) { + let destDir = DST_MESSAGES_BASE + subDir + '/' + LANG_DIR_NAME; + mkdirpSync(destDir); + fs.writeFileSync(destDir + '/en.json', + JSON.stringify(defaultMessages[subDir], null, 2)); +}); diff --git a/settings.html b/settings.html index b679b7a..73f4936 100644 --- a/settings.html +++ b/settings.html @@ -13,6 +13,6 @@
- + diff --git a/src/settings/App.js b/src/settings/App.js index a5c700a..9b99329 100644 --- a/src/settings/App.js +++ b/src/settings/App.js @@ -1,24 +1,29 @@ import React from 'react' -import { render } from 'react-dom'; -import Wix from 'Wix'; -import settingsApp from './settings' -import $ from 'jquery'; +import ReactDOM from 'react-dom'; +import bundleLoaderI18n from '../../scripts/bundle-loader-i18n'; +import {IntlProvider} from 'react-intl'; +import App from './settings' import './settings.scss'; -var rootInstance = null; -$(document).ready(function () { - rootInstance = render( - React.createElement(settingsApp), - $('#root').get(0) - ); +let rootInstance; - if (module.hot) { - require('react-hot-loader/Injection').RootInstanceProvider.injectProvider({ - getRootInstances: function () { - // Help React Hot Loader figure out the root component instances on the page: - return [rootInstance]; - } - }); - } -}); +bundleLoaderI18n(renderRootComponent, 'settings'); + +function renderRootComponent(locale, messages) { + rootInstance = ReactDOM.render( + + + , + document.getElementById('root') + ); +} +if (module.hot) { + module.hot.accept(); + require('react-hot-loader/Injection').RootInstanceProvider.injectProvider({ + getRootInstances: function () { + // Help React Hot Loader figure out the root component instances on the page: + return [rootInstance]; + } + }); +} diff --git a/src/settings/lang/README.md b/src/settings/lang/README.md new file mode 100644 index 0000000..59aff69 --- /dev/null +++ b/src/settings/lang/README.md @@ -0,0 +1,25 @@ +`en.json` will be automatically added to this directory (task: build:langs) + +### To support more languages +1. Copy and rename `en.json` to desired locale name e.g. `ru.json` +2. Translate content of `ru.json` + +##### Message Syntax + +String/Message formatting is a paramount feature of React Intl and it builds on [ICU Message Formatting](http://userguide.icu-project.org/formatparse/messages) by using the [ICU Message Syntax](http://formatjs.io/guides/message-syntax/). This message syntax allows for simple to complex messages to defined, translated, and then formatted at runtime. + +**Simple Message:** +``` +Hello, {name} +``` + +**Complex Message:** +``` +Hello, {name}, you have {itemCount, plural, + =0 {no items} + one {# item} + other {# items} +}. +``` + +**See:** The [Message Syntax Guide](http://formatjs.io/guides/message-syntax/) on the [FormatJS website](http://formatjs.io/). diff --git a/src/settings/lang/en.json b/src/settings/lang/en.json new file mode 100644 index 0000000..d545869 --- /dev/null +++ b/src/settings/lang/en.json @@ -0,0 +1,5 @@ +{ + "settings.main.intro": "This is the Wix App settings demo.", + "settings.main.introDesc": "Please add a short description of your App + CTA for the main action.", + "settings.support.enjoy": "Have you enjoyed the app? Spread the word and rate us in the app market" +} \ No newline at end of file diff --git a/logo.svg b/src/settings/modules/main/logo.svg similarity index 100% rename from logo.svg rename to src/settings/modules/main/logo.svg diff --git a/src/settings/modules/main/main.js b/src/settings/modules/main/main.js index fcd1a09..14a59d6 100644 --- a/src/settings/modules/main/main.js +++ b/src/settings/modules/main/main.js @@ -1,5 +1,6 @@ import UI from 'editor-ui-lib'; -import React from 'react' +import React from 'react'; +import { FormattedMessage } from 'react-intl'; export default class Main extends React.Component { onClick () { @@ -9,14 +10,22 @@ export default class Main extends React.Component { render () { return (
- app logo + app logo

- This is the Wix App settings demo. -
- Please add a short description of your App + CTA for the main action. + +
+

this.onClick()}/>
) } -} \ No newline at end of file +} diff --git a/src/settings/modules/support/support.js b/src/settings/modules/support/support.js index 9cffcd6..b514324 100644 --- a/src/settings/modules/support/support.js +++ b/src/settings/modules/support/support.js @@ -1,3 +1,5 @@ +import { FormattedMessage } from 'react-intl'; + export default class Support extends React.Component { constructor (props) { super(props); @@ -53,7 +55,12 @@ export default class Support extends React.Component {
-

Have you enjoyed the app? Spread the word and rate us in the app market

+

+ +

{ @@ -41,7 +43,16 @@ define(['react', 'Wix'], function (React, Wix) {

Demo App

-

Welcome to the Wix Demo App, let's play!

+

+ +

diff --git a/src/widget/widget_main.js b/src/widget/widget_main.js index 2fc2f57..62218ba 100644 --- a/src/widget/widget_main.js +++ b/src/widget/widget_main.js @@ -1,12 +1,29 @@ -var React = require('react'); -var ReactDOM = require('react-dom'); -var widget = require('./widget'); -var $ = require('jquery'); -require('./widget.scss'); - -$(document).ready(function () { - ReactDOM.render( - React.createElement(widget), - $('#root').get(0) - ); -}); +import React from 'react' +import ReactDOM from 'react-dom'; +import bundleLoaderI18n from '../../scripts/bundle-loader-i18n'; +import {IntlProvider} from 'react-intl'; +import App from './widget' +import './widget.scss'; + +let rootInstance; + +bundleLoaderI18n(renderRootComponent, 'widget'); + +function renderRootComponent(locale, messages) { + rootInstance = ReactDOM.render( + + + , + document.getElementById('root') + ); +} + +if (module.hot) { + module.hot.accept(); + require('react-hot-loader/Injection').RootInstanceProvider.injectProvider({ + getRootInstances: function () { + // Help React Hot Loader figure out the root component instances on the page: + return [rootInstance]; + } + }); +} diff --git a/webpack.config.js b/webpack.config.js index 45b62be..0d8f6c7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,9 +1,10 @@ var path = require('path'); var webpack = require('webpack'); +var rimraf = require('rimraf'); var getPlugins = function() { var plugins = []; - var hotReload = new webpack.HotModuleReplacementPlugin() + var hotReload = new webpack.HotModuleReplacementPlugin(); var noErrorsPlugin = new webpack.NoErrorsPlugin(); var uglifyPlugin = new webpack.optimize.UglifyJsPlugin({ compressor: { @@ -18,6 +19,49 @@ var getPlugins = function() { plugins.push(hotReload) } + + var + fbLocale = 'en', + localesFormatted, + localesFormattedShort, + rexLocales, + rexLocalesShort, + localesRaw = process.env.WIX_TPA_LOCALES; + + // add locale fallback + if (!localesRaw) { + localesRaw = fbLocale; + } else if (localesRaw.indexOf(fbLocale) < 0) { + localesRaw += ','+fbLocale; + console.log(`'${fbLocale}' locale automatically added to support a fallback`); + } + + localesFormatted = localesRaw.replace(/[\s,]+/g, '|'); + localesFormattedShort = localesFormatted.replace(/-[a-z]{2}/gi, ''); + rexLocales = new RegExp('^\.\/('+localesFormatted+')\\.'); + rexLocalesShort = new RegExp('^\.\/('+localesFormattedShort+')'); + + plugins.push(new webpack.ContextReplacementPlugin( + /node_modules\/intl\/locale-data\/jsonp/, + rexLocales + )); + + plugins.push(new webpack.ContextReplacementPlugin( + /node_modules\/react-intl\/locale-data/, + rexLocalesShort + )); + + plugins.push(new webpack.ContextReplacementPlugin( + /src\/(.*)\/lang/, + rexLocales + )); + + plugins.push({ + apply: (compiler) => { + rimraf.sync(compiler.options.output.path); + } + }); + return plugins; }; @@ -28,7 +72,11 @@ var webpackConfig = { 'webpack/hot/only-dev-server', __dirname + '/src/settings/App' ], - widget: __dirname + '/src/widget/widget_main' + widget: [ + 'webpack-dev-server/client?http://localhost:3000', + 'webpack/hot/only-dev-server', + __dirname + '/src/widget/widget_main' + ] }, output: { path: path.join(__dirname, 'dist'), @@ -51,6 +99,11 @@ var webpackConfig = { {test: /\.js$/, exclude: [/node_modules/], loader: 'babel-loader'}, {test: /\.rt/, loader: "react-templates-loader"}, {test: /\.css$/, loader: "style-loader!css-loader"}, + {test: /\.json$/, loader: 'json-loader'}, + { + test: /\.(gif|png|jpg|jpeg|svg|eot|woff2|woff|ttf)$/, + loader: 'file?name=assets/[hash:7].[ext]' + }, {test: /\.scss$/, loader: "style-loader!css-loader!sass-loader?functions=selector-parse&root=" + path.resolve('./js')} ] } diff --git a/widget.html b/widget.html index b90eca2..3898b23 100644 --- a/widget.html +++ b/widget.html @@ -45,6 +45,6 @@
- +