├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .noderequirer.json ├── .npmignore ├── LICENSE ├── README.md ├── package.json └── src ├── actionTypes.js ├── actions.js ├── getLang.js ├── index.js └── store.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"], 3 | "plugins": ["transform-runtime"] 4 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | lib 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "rules": { 4 | "no-undef": [2], 5 | "no-trailing-spaces": [1], 6 | "space-before-blocks": [2, "always"], 7 | "no-unused-expressions": [0], 8 | "no-underscore-dangle": [0], 9 | "quote-props": [1, "as-needed"], 10 | "no-multi-spaces": [0], 11 | "no-unused-vars": [1], 12 | "no-loop-func": [0], 13 | "key-spacing": [0], 14 | "max-len": [1, 100], 15 | "strict": [0], 16 | "eol-last": [1], 17 | "no-console": [1], 18 | "indent": [1, 2], 19 | "quotes": [2, "single", "avoid-escape"], 20 | "curly": [0], 21 | 22 | "generator-star-spacing": 0, 23 | "new-cap": 0, 24 | "object-curly-spacing": 0, 25 | "object-shorthand": 0, 26 | 27 | "babel/generator-star-spacing": 1, 28 | "babel/new-cap": 1, 29 | "babel/object-curly-spacing": [1, "always"], 30 | "babel/object-shorthand": 1 31 | }, 32 | "plugins": [ 33 | "babel" 34 | ], 35 | "settings": { 36 | "ecmascript": 6, 37 | "jsx": true 38 | }, 39 | "env": { 40 | "browser": true, 41 | "node": true 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | lib 5 | -------------------------------------------------------------------------------- /.noderequirer.json: -------------------------------------------------------------------------------- 1 | { 2 | "import": true 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | static 2 | src 3 | demo 4 | .* 5 | server.js 6 | webpack.config.js 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Alexander Kuznetsov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-pagan 2 | managing internationalization via redux 3 | 4 | (see `react-pagan` [demo](http://alexkuz.github.io/react-pagan/)) 5 | 6 | #### Setup redux store 7 | 8 | Include i18n reducer in redux: 9 | 10 | ```js 11 | import { i18n } from 'redux-pagan'; 12 | 13 | const createStoreWithMiddleware = applyMiddleware( 14 | thunk 15 | )(createStore); 16 | 17 | const rootReducer = combineReducers({ 18 | i18n 19 | }); 20 | 21 | const store = createStoreWithMiddleware(rootReducer); 22 | ... 23 | ``` 24 | 25 | #### Loading lang data 26 | 27 | ```js 28 | import { loadLang } from 'redux-pagan'; 29 | 30 | function getLangData(locale) { 31 | // here we use promise-loader to load lang data by demand 32 | return require('promise?global,[name].i18n!json!./i18n/' + locale + '.i18n.json'); 33 | } 34 | 35 | @connect(...) 36 | class App extends Component { 37 | componentDidMount() { 38 | this.props.dispatch(loadLang(cookie.get('lang') || 'en-US', getLangData)); 39 | } 40 | 41 | render() { 42 | return ( 43 | 49 | ); 50 | } 51 | 52 | handleLocaleChange = e => { 53 | this.props.dispatch(loadLang(e.target.value, getLangData)); 54 | } 55 | } 56 | ``` 57 | 58 | #### Using lang data 59 | 60 | `getLang(state.i18n, ...path)` is a special method that safely obtains strings from lang data. If no strings were found on stated path, last key is returned. This method's calling is chainable: 61 | ```js 62 | getLang(state.i18n, 'some', 'path', 'to')('lang', 'string') 63 | ``` 64 | Every call is memoized. To receive string value from last call, use `.toString()` or `.s` property. 65 | 66 | ```js 67 | // in this case lang data looks like: 68 | // { 69 | // "app": { 70 | // "some": { 71 | // "text": "foobar" 72 | // }, 73 | // "element": { 74 | // "something": { 75 | // "else": "foobar" 76 | // } 77 | // } 78 | // } 79 | // } 80 | 81 | import { connect } from 'react-redux'; 82 | import { getLang } from 'redux-pagan'; 83 | 84 | @connect(state => ({ 85 | lang: getLang(state.i18n, 'app'), 86 | locale: state.i18n.locale 87 | })) 88 | class App extends Component { 89 | render() { 90 | // string methods are proxied, no need to call .toString() either 91 | const str = this.props.lang('some', 'text').replace('foo', 'bar'); 92 | 93 | return ( 94 |
95 | {this.props.lang('some', 'text').s} 96 | 97 |
98 | ); 99 | } 100 | } 101 | 102 | class Element extends Component { 103 | render() { 104 | return ( 105 |
106 | {this.props.lang('something', 'else').s} 107 |
108 | ); 109 | } 110 | } 111 | ``` 112 | 113 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-pagan", 3 | "version": "0.2.0", 4 | "description": "redux binding for react-pagan internationalization module", 5 | "scripts": { 6 | "build": "./node_modules/.bin/babel src --out-dir lib", 7 | "start": "node server.js", 8 | "lint": "eslint src", 9 | "version": "npm run build && git add -A .", 10 | "postversion": "git push", 11 | "prepublish": "npm run build" 12 | }, 13 | "main": "lib/index.js", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/alexkuz/redux-pagan.git" 17 | }, 18 | "keywords": [ 19 | "redux", 20 | "intl", 21 | "i18n" 22 | ], 23 | "author": "Alexander (http://kuzya.org/)", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/alexkuz/redux-pagan/issues" 27 | }, 28 | "homepage": "https://github.com/alexkuz/redux-pagan", 29 | "devDependencies": { 30 | "babel-cli": "^6.0.0", 31 | "babel-core": "^6.0.0", 32 | "babel-eslint": "^6.0.0", 33 | "babel-runtime": "^6.0.0", 34 | "babel-preset-es2015": "^6.0.0", 35 | "babel-preset-stage-0": "^6.0.0", 36 | "babel-plugin-transform-runtime": "^6.0.0", 37 | "eslint-loader": "^1.0.0", 38 | "eslint-plugin-babel": "^3.2.0" 39 | }, 40 | "dependencies": { 41 | "lodash.memoize": "^3.0.4" 42 | }, 43 | "peerDependencies": { 44 | "intl-messageformat": "^1.1.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const LOAD_LANG = '@@i18n/loadLang'; 2 | export const LOAD_LANG_SUCCESS = '@@i18n/loadLangSuccess'; 3 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | import { LOAD_LANG, LOAD_LANG_SUCCESS } from './actionTypes'; 2 | 3 | 4 | export function loadLang(locale, data) { 5 | return async dispatch => { 6 | dispatch({ 7 | type: LOAD_LANG, 8 | locale 9 | }); 10 | 11 | let loader = typeof data === 'function' ? data(locale) : data; 12 | let lang; 13 | 14 | if (typeof loader === 'function') { 15 | lang = await loader(); 16 | } else { 17 | lang = loader; 18 | } 19 | 20 | dispatch({ 21 | type: LOAD_LANG_SUCCESS, 22 | locale, 23 | lang 24 | }); 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/getLang.js: -------------------------------------------------------------------------------- 1 | import memoize from 'lodash.memoize'; 2 | import IntlMessageFormat from 'intl-messageformat'; 3 | 4 | /* eslint-disable no-console */ 5 | 6 | function isEmpty(data) { 7 | return !data || Object.keys(data).length === 0; 8 | } 9 | 10 | const emptyLocaleDataWarned = {}; 11 | const notFoundKeyWarned = {}; 12 | 13 | function getLangString(locale, data, fullpath) { 14 | if (fullpath.filter(key => typeof key !== 'string').length > 0) { 15 | if (process.env.NODE_ENV !== 'production') { 16 | console.error('Invalid langpack path: ', fullpath); 17 | } 18 | } 19 | 20 | const str = fullpath.reduce( 21 | (obj, key, idx) => { 22 | if (idx === fullpath.length - 1) { 23 | if (obj && (obj[key] !== undefined)) { 24 | return obj[key].toString(); 25 | } else { 26 | const keyPath = `${locale}:${fullpath.join('/')}`; 27 | if (!isEmpty(data) && !notFoundKeyWarned[keyPath]) { 28 | if (process.env.NODE_ENV !== 'production') { 29 | console.error(`Redux-Pagan: key was not found at path: ${keyPath}`); 30 | } 31 | notFoundKeyWarned[keyPath] = true; 32 | } 33 | return key; 34 | } 35 | } else { 36 | return (obj && obj[key]) ? obj[key] : null 37 | } 38 | }, 39 | data 40 | ); 41 | 42 | if (str && !(typeof str === 'string')) { 43 | if (process.env.NODE_ENV !== 'production') { 44 | console.error('String expected, got: ', str); 45 | } 46 | } 47 | 48 | return str; 49 | } 50 | 51 | function concatPath(locale, data, path, subpath) { 52 | if (isEmpty(data) && !emptyLocaleDataWarned[locale] && locale !== null) { 53 | if (process.env.NODE_ENV !== 'production') { 54 | console.error(`Redux-Pagan: got empty data for locale '${locale}'`); 55 | } 56 | emptyLocaleDataWarned[locale] = true; 57 | } 58 | 59 | if (subpath.length === 0) { 60 | return getLangString(locale, data, path); 61 | } 62 | const _path = [...path, ...subpath]; 63 | 64 | const i18nPartial = function(..._subpath) { 65 | return concatPath(locale, data, _path, _subpath); 66 | }; 67 | 68 | const memoizedI18nPartial = memoize(i18nPartial, function(..._subpath) { 69 | return [locale, ..._path, ..._subpath].join(','); 70 | }); 71 | 72 | memoizedI18nPartial.toString = function() { 73 | return getLangString(locale, data, _path); 74 | } 75 | 76 | memoizedI18nPartial.format = function(values) { 77 | const str = getLangString(locale, data, _path); 78 | const formatter = new IntlMessageFormat(str, locale); 79 | return formatter.format(values); 80 | } 81 | 82 | Object.defineProperty(memoizedI18nPartial, 's', { 83 | get() { return this.toString(); } 84 | }); 85 | 86 | // proxy string methods 87 | Object.getOwnPropertyNames(String.prototype).forEach(prop => { 88 | if (typeof String.prototype[prop] === 'function' && 89 | ['constructor', 'toString', 'valueOf'].indexOf(prop) === -1) { // find more elegant way maybe 90 | Object.defineProperty(memoizedI18nPartial, prop, { 91 | get() { return this.toString()[prop]; } 92 | }); 93 | } 94 | }); 95 | 96 | const desc = Object.getOwnPropertyDescriptor(memoizedI18nPartial, Symbol.iterator); 97 | 98 | if (!desc || desc.configurable) { 99 | memoizedI18nPartial[Symbol.iterator] = function *() { 100 | yield memoizedI18nPartial.toString(); 101 | } 102 | } 103 | 104 | return memoizedI18nPartial; 105 | } 106 | 107 | /* eslint-enable no-console */ 108 | 109 | const i18nResolver = memoize(function i18nResolver(locale, data, version, ...path) { 110 | return concatPath(locale, data, [], path); 111 | }, function(locale, data, version, ...path) { 112 | return [locale, version, ...path].join(','); 113 | }); 114 | 115 | export default function getLang(i18n, ...args) { 116 | return i18nResolver(i18n.locale, i18n.data[i18n.locale], i18n.__version__, ...args); 117 | } 118 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { loadLang } from './actions'; 2 | export i18n from './store'; 3 | export getLang from './getLang'; 4 | export { LOAD_LANG, LOAD_LANG_SUCCESS } from './actions'; 5 | 6 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { LOAD_LANG_SUCCESS } from './actionTypes'; 2 | 3 | const DEFAULT_STATE = { 4 | __version__: 0, 5 | locale: null, 6 | data: {} 7 | }; 8 | 9 | const mergeLang = (state, { locale, lang }) => ({ 10 | ...state, 11 | data: { 12 | ...state.data, 13 | [locale]: lang 14 | }, 15 | locale, 16 | __version__: state.__version__ + 1 17 | }); 18 | 19 | export default function i18n(state = DEFAULT_STATE, action) { 20 | return (({ 21 | [LOAD_LANG_SUCCESS]: mergeLang 22 | })[action.type] || (s => s))(state, action); 23 | } 24 | --------------------------------------------------------------------------------