diff --git a/.babelrc b/.babelrc index 2e236249..3e9bd38e 100644 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,9 @@ { - "plugins": ["transform-object-rest-spread"], - "presets": ["es2015", "react"] + "plugins": [ + "transform-object-rest-spread", + ["react-intl", { + "messagesDir": "./translations/messages/" + }] + ], + "presets": ["es2015", "react"], } diff --git a/.eslintignore b/.eslintignore index fe1b4898..217e2872 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ node_modules/* dist/* playground/ +scripts/* diff --git a/.gitignore b/.gitignore index 1b3b3bf7..c6c5feec 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ dist/* # Editors /#* *~ + +# generated translation files +/translations +/locale diff --git a/package.json b/package.json index ba1ba054..1d7fd76a 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,13 @@ "description": "Graphical User Interface for the Scratch 3.0 paint editor, which is used to make and edit sprites for use in projects.", "main": "./dist/scratch-paint.js", "scripts": { - "build": "npm run clean && webpack --progress --colors --bail", + "build": "npm run clean && npm run i18n:msgs && webpack --progress --colors --bail", "clean": "rimraf ./dist && mkdirp dist && rimraf ./playground && mkdirp playground", "deploy": "touch playground/.nojekyll && gh-pages -t -d playground -m \"Build for $(git log --pretty=format:%H -n1)\"", + "i18n:msgs": "node ./scripts/generate-locale-messages.js", + "i18n:src": "babel src > tmp.js && rimraf tmp.js && ./scripts/build-i18n-source.js ./translations/messages/ ./translations/", "lint": "eslint . --ext .js,.jsx", - "start": "webpack-dev-server", + "start": "npm run i18n:msgs && webpack-dev-server", "test": "npm run lint && npm run build && npm run unit", "unit": "jest", "watch": "webpack --progress --colors --watch" @@ -26,10 +28,12 @@ }, "devDependencies": { "autoprefixer": "7.1.1", + "babel-cli": "6.24.1", "babel-core": "^6.23.1", "babel-eslint": "^7.1.1", "babel-jest": "^20.0.3", "babel-loader": "^7.0.0", + "babel-plugin-react-intl": "2.3.1", "babel-plugin-transform-object-rest-spread": "^6.22.0", "babel-preset-es2015": "^6.22.0", "babel-preset-react": "^6.22.0", @@ -57,6 +61,7 @@ "react": "15.6.1", "react-dom": "15.5.4", "react-intl": "2.3.0", + "react-intl-redux": "0.6.0", "react-redux": "5.0.5", "react-test-renderer": "^15.5.4", "redux": "3.6.0", diff --git a/scripts/build-i18n-source.js b/scripts/build-i18n-source.js new file mode 100755 index 00000000..9f6571c1 --- /dev/null +++ b/scripts/build-i18n-source.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const glob = require('glob'); +const path = require('path'); +const mkdirp = require('mkdirp'); + +var args = process.argv.slice(2); + +if (!args.length) { + process.stdout.write('You must specify the messages dir generated by babel-plugin-react-intl.\n'); + process.exit(1); +} + +const MESSAGES_PATTERN = args.shift() + '/**/*.json'; + +if (!args.length) { + process.stdout.write('A destination directory must be specified.\n'); + process.exit(1); +} + +const LANG_DIR = args.shift(); + +// Aggregates the default messages that were extracted from the example app's +// React components via the React Intl Babel plugin. An error will be thrown if +// there are messages in different components that use the same `id`. The result +// is a chromei18n format collection of `id: {message: defaultMessage, +// description: description}` pairs for the app's default locale. +let defaultMessages = glob.sync(MESSAGES_PATTERN) + .map((filename) => fs.readFileSync(filename, 'utf8')) + .map((file) => JSON.parse(file)) + .reduce((collection, descriptors) => { + descriptors.forEach(({id, defaultMessage, description}) => { + if (collection.hasOwnProperty(id)) { + throw new Error(`Duplicate message id: ${id}`); + } + + collection[id] = {message: defaultMessage, description: description}; + }); + + return collection; + }, {}); + +mkdirp.sync(LANG_DIR); +fs.writeFileSync(path.join(LANG_DIR, 'en.json'), JSON.stringify(defaultMessages, null, 2)); diff --git a/scripts/generate-locale-messages.js b/scripts/generate-locale-messages.js new file mode 100755 index 00000000..44a1c581 --- /dev/null +++ b/scripts/generate-locale-messages.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +/* +Generates locale/messages.json from current translastion files + +Translations are expected to be in the ./translations directory. +Translation files are in Chrome i18n json format: +''' +{ + "message.id": { + "message": "The translated text", + "description": "Tips for translators" + }, + ... +} +''' +They are named by locale, for example: 'fr.json' or 'zh-cn.json' + +Current languages supported are listed in ../src/languages.json + +Converts the collection of translation files to a single set of messages. +Example output: +''' +{ + "en": { + "action.addBackdrop": "Add Backdrop", + "action.addCostume": "Add Costume", + "action.recordSound": "Record Sound", + "action.addSound": "Add Sound" + }, + "fr": { + "action.addSound": "Ajouter Son", + "action.addCostume": "Ajouter Costume", + "action.addBackdrop": "Ajouter Arrière-plan", + "action.recordSound": "Enregistrement du Son" + } +} +''' + +Missing locales are ignored, react-intl will use the default messages for them. + */ +const fs = require('fs'); +const path = require('path'); +const mkdirp = require('mkdirp'); + +const locales = ['en']; +const LANG_DIR = './translations/'; +const MSGS_DIR = './locale/'; + +let messages = locales.reduce((collection, lang) => { + let langMessages = {}; + try { + let langData = JSON.parse( + fs.readFileSync(path.resolve(LANG_DIR, lang + '.json'), 'utf8') + ); + Object.keys(langData).forEach((id) => { + langMessages[id] = langData[id].message; + }); + collection[lang] = langMessages; + } catch (e) { + process.stdout.write(lang + ' translation file missing, will use defaults.\n'); + } + return collection; +}, {}); + +mkdirp.sync(MSGS_DIR); +fs.writeFileSync(MSGS_DIR + 'messages.json', JSON.stringify(messages, null, 2)); diff --git a/src/components/brush-mode.jsx b/src/components/brush-mode.jsx index 865c04f4..84a831dd 100644 --- a/src/components/brush-mode.jsx +++ b/src/components/brush-mode.jsx @@ -1,8 +1,15 @@ import React from 'react'; import PropTypes from 'prop-types'; +import {FormattedMessage} from 'react-intl'; const BrushModeComponent = props => ( - + ); BrushModeComponent.propTypes = { diff --git a/src/containers/brush-mode.jsx b/src/containers/brush-mode.jsx index 44c6a90e..e751030f 100644 --- a/src/containers/brush-mode.jsx +++ b/src/containers/brush-mode.jsx @@ -74,8 +74,8 @@ BrushMode.propTypes = { }; const mapStateToProps = state => ({ - brushModeState: state.brushMode, - isBrushModeActive: state.mode === Modes.BRUSH + brushModeState: state.scratchPaint.brushMode, + isBrushModeActive: state.scratchPaint.mode === Modes.BRUSH }); const mapDispatchToProps = dispatch => ({ changeBrushSize: brushSize => { diff --git a/src/containers/eraser-mode.jsx b/src/containers/eraser-mode.jsx index c8ad4a3b..af932329 100644 --- a/src/containers/eraser-mode.jsx +++ b/src/containers/eraser-mode.jsx @@ -70,8 +70,8 @@ EraserMode.propTypes = { }; const mapStateToProps = state => ({ - eraserModeState: state.eraserMode, - isEraserModeActive: state.mode === Modes.ERASER + eraserModeState: state.scratchPaint.eraserMode, + isEraserModeActive: state.scratchPaint.mode === Modes.ERASER }); const mapDispatchToProps = dispatch => ({ changeBrushSize: brushSize => { diff --git a/src/index.js b/src/index.js index 6611b719..b7d91a70 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,7 @@ import PaintEditor from './containers/paint-editor.jsx'; +import ScratchPaintReducer from './reducers/scratch-paint-reducer'; -export default PaintEditor; +export { + PaintEditor as default, + ScratchPaintReducer +}; diff --git a/src/locale.js b/src/locale.js new file mode 100644 index 00000000..fb1e72e9 --- /dev/null +++ b/src/locale.js @@ -0,0 +1,11 @@ +import localeDataEn from 'react-intl/locale-data/en'; + +import messages from '../locale/messages.json'; // eslint-disable-line import/no-unresolved + +export default { + en: { + name: 'English', + localeData: localeDataEn, + messages: messages.en + } +}; diff --git a/src/playground/playground.jsx b/src/playground/playground.jsx index 8a27161e..9f68704a 100644 --- a/src/playground/playground.jsx +++ b/src/playground/playground.jsx @@ -3,16 +3,20 @@ import ReactDOM from 'react-dom'; import PaintEditor from '..'; import {Provider} from 'react-redux'; import {createStore} from 'redux'; -import reducer from '../reducers/combine-reducers'; +import reducer from './reducers/combine-reducers'; +import {intlInitialState, IntlProvider} from './reducers/intl.js'; const appTarget = document.createElement('div'); document.body.appendChild(appTarget); const store = createStore( reducer, + intlInitialState, window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() ); ReactDOM.render(( - + + + ), appTarget); diff --git a/src/playground/reducers/combine-reducers.js b/src/playground/reducers/combine-reducers.js new file mode 100644 index 00000000..e3182b5e --- /dev/null +++ b/src/playground/reducers/combine-reducers.js @@ -0,0 +1,8 @@ +import {combineReducers} from 'redux'; +import intlReducer from './intl'; +import {ScratchPaintReducer} from '../..'; + +export default combineReducers({ + intl: intlReducer, + scratchPaint: ScratchPaintReducer +}); diff --git a/src/playground/reducers/intl.js b/src/playground/reducers/intl.js new file mode 100644 index 00000000..a9d43eda --- /dev/null +++ b/src/playground/reducers/intl.js @@ -0,0 +1,30 @@ +import {addLocaleData} from 'react-intl'; +import {updateIntl as superUpdateIntl} from 'react-intl-redux'; +import {IntlProvider, intlReducer} from 'react-intl-redux'; + +import locales from '../../locale.js'; + +Object.keys(locales).forEach(locale => { + // TODO: will need to handle locales not in the default intl - see www/custom-locales + addLocaleData(locales[locale].localeData); +}); + +const intlInitialState = { + intl: { + defaultLocale: 'en', + locale: 'en', + messages: locales.en.messages + } +}; + +const updateIntl = locale => superUpdateIntl({ + locale: locale, + messages: locales[locale].messages || locales.en.messages +}); + +export { + intlReducer as default, + IntlProvider, + intlInitialState, + updateIntl +}; diff --git a/src/reducers/combine-reducers.js b/src/reducers/scratch-paint-reducer.js similarity index 100% rename from src/reducers/combine-reducers.js rename to src/reducers/scratch-paint-reducer.js