├── .coveralls.yml ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .travis.yml ├── README.md ├── index.js ├── lib └── i18node.js ├── package.json └── test ├── i18n.test.js └── locales ├── en.json └── pt.json /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: vzFQB85vvwaNjI41Pk29t2VBxcEpKc00l 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "mocha": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "rules": { 8 | "indent": ["error", 2], 9 | "linebreak-style": ["error", "unix"], 10 | "quotes": ["error", "single"], 11 | "semi": ["error", "always"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | *.log 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4.0" 4 | - "5.0" 5 | - "6.0" 6 | - "6.1" 7 | script: 8 | - npm test 9 | - npm run coveralls 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # I18node 2 | 3 | TL;DR: It's an i18n package for Node.js, with support to plurals and genders 4 | 5 | [![Build Status](https://travis-ci.org/talyssonoc/i18node.svg?branch=master)](https://travis-ci.org/talyssonoc/i18node) [![Dependency manager](https://david-dm.org/talyssonoc/i18node.png)](https://david-dm.org/talyssonoc/i18node) [![Coverage Status](https://coveralls.io/repos/github/talyssonoc/i18node/badge.svg?branch=master)](https://coveralls.io/github/talyssonoc/i18node?branch=master) 6 | 7 | ```sh 8 | $ npm install i18node 9 | ``` 10 | 11 | ## Usage: 12 | 13 | ```js 14 | var I18Node = require('i18node'); 15 | 16 | var options = { 17 | locales: ['en', 'pt'], 18 | defaultLocale: 'en', 19 | defaultGender: 'masc', 20 | path: './locales' 21 | }; 22 | 23 | var i18n = new I18Node(options); 24 | 25 | i18n.i18n('person'); //person 26 | i18n.i18n('person', 2); //couple 27 | i18n.i18n('person', 3); //people 28 | i18n.i18n('person', { num: 2 }); //couple 29 | i18n.i18n('hello world', { num: 2, greeting: 'hi'}); //hi worlds 30 | i18n.i18n('hello world', { greeting: 'olá', locale: 'pt'}); //olá mundo 31 | i18n.i18n('nidorino', { num: 2 }); //nidorinos 32 | i18n.i18n('nidorino', { gender: 'masc' }); //nidorino 33 | i18n.i18n('nidorino', { num: 2, gender: 'fem' }); //nidorinas 34 | i18n.i18n('none', { gender: 'neutral', locale: 'pt' }); //nenhum 35 | 36 | ``` 37 | 38 | And let's say that, inside `./locales` folder we have the files: 39 | 40 | `en.json`: 41 | 42 | ```json 43 | { 44 | "person": { 45 | "1": "person", 46 | "2": "couple", 47 | "n": "people" 48 | }, 49 | "hello word": { 50 | "1": "{{greeting}} world", 51 | "n": "{{greeting}} worlds" 52 | }, 53 | "nidorino": { 54 | "1" : { 55 | "masc": "nidorino", 56 | "fem": "nidorina" 57 | }, 58 | "n" : { 59 | "masc": "nidorinos", 60 | "fem": "nidorinas" 61 | } 62 | }, 63 | "none": "none" 64 | } 65 | ``` 66 | 67 | `pt.json`: 68 | 69 | ```json 70 | { 71 | "person": { 72 | "1": "pessoa", 73 | "2": "casal", 74 | "n": "pessoas" 75 | }, 76 | "hello word": { 77 | "1": "{{greeting}} mundo", 78 | "n": "{{greeting}} mundos" 79 | }, 80 | "none": { 81 | "neutral": "nenhum", 82 | "fem": "nenhuma" 83 | } 84 | } 85 | ``` 86 | 87 | ## Options 88 | 89 | * `locales`: Array of locale names. Default: ['en']) 90 | * `defaultLocale`: Default: 'en' 91 | * `defaultGender`: Default: 'neutral' 92 | * `path`: Path for the locales folder. Default: './locals' 93 | 94 | ## API 95 | 96 | * `setLocale(locale)`: Set the default locale 97 | * `getLocale()`: Return the current default locale 98 | * `setGender(gender)`: Set the default gender 99 | * `getGender()`: Return the current default gender 100 | * `hasLocale(locale)`: Return if the passed locale is supported 101 | * `i18n(term, data)`: Return the internationalized term, using the given data 102 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/i18node'); -------------------------------------------------------------------------------- /lib/i18node.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var Mustache = require('mustache'); 3 | var _ = require('lodash'); 4 | 5 | function I18Node(_options) { 6 | var options = _options || {}; 7 | 8 | this.locales = options.locales || ['en']; 9 | this.defaultLocale = options.defaultLocale || 'en'; 10 | this.defaultGender = options.defaultGender || 'neutral'; 11 | this.path = options.path || path.join(process.cwd(), '/locales'); 12 | 13 | this._loadLocales(); 14 | } 15 | 16 | I18Node.prototype = { 17 | setLocale: function setLocale(locale) { 18 | this.defaultLocale = locale; 19 | }, 20 | 21 | getLocale: function getLocale() { 22 | return this.defaultLocale; 23 | }, 24 | 25 | setGender: function setLocale(gender) { 26 | this.defaultGender = gender; 27 | }, 28 | 29 | getGender: function getLocale() { 30 | return this.defaultGender; 31 | }, 32 | 33 | hasLocale: function hasLocale(locale) { 34 | return this.locales.indexOf(locale) > -1; 35 | }, 36 | 37 | i18n: function i18n() { 38 | var currentLocale = this.defaultLocale; 39 | var currentGender = this.defaultGender; 40 | 41 | switch(arguments.length) { 42 | case 0: 43 | return ''; 44 | 45 | case 1: 46 | return this._translate(arguments[0], { locale: currentLocale, num: 1, gender: currentGender }); 47 | 48 | case 2: 49 | default: 50 | if(_.isNumber(arguments[1])) { 51 | return this._translate(arguments[0], { locale: currentLocale, num: arguments[1], gender: currentGender }); 52 | } 53 | 54 | if(_.isObject(arguments[1]) && !_.isArray(arguments[1])) { 55 | arguments[1].locale = arguments[1].locale || currentLocale; 56 | arguments[1].gender = arguments[1].gender || currentGender; 57 | arguments[1].num = (typeof arguments[1].num !== 'undefined' ? arguments[1].num : 1); 58 | 59 | return this._translate(arguments[0], arguments[1]); 60 | } 61 | 62 | throw new Error('Invalid data argument type passed as second parameter of I18Node#i18n.'); 63 | } 64 | }, 65 | 66 | _loadLocales: function _loadLocales() { 67 | this._localesData = {}; 68 | 69 | this.locales.forEach(function(locale) { 70 | this._localesData[locale] = require(path.join(this.path, locale + '.json')); 71 | }.bind(this)); 72 | }, 73 | 74 | _translate: function _translate(_term, data) { 75 | var term = this._getTermInLocale(_term, data.locale.toLowerCase()); 76 | 77 | if(!term) { 78 | return _term; 79 | } 80 | 81 | if(_.isString(term)) { 82 | return this._renderTerm(term, data); 83 | } 84 | 85 | if(data.num === 1) { 86 | term = term['1'] || term; 87 | } else { 88 | term = term['' + data.num] || term.n || term; 89 | } 90 | 91 | if(_.isObject(term)) { 92 | term = term[data.gender] || term[this.defaultGender]; 93 | } 94 | 95 | if(!term) { 96 | return _term; 97 | } 98 | 99 | return this._renderTerm(term, data); 100 | }, 101 | 102 | _renderTerm: function _renderTerm(term, data) { 103 | return Mustache.render(term, data); 104 | }, 105 | 106 | _getTermInLocale: function _getTermInLocale(term, locale) { 107 | 108 | var matchedLocale = _.find([ 109 | locale, 110 | this._localeWithoutTerritory(locale, '-'), 111 | this._localeWithoutTerritory(locale, '_'), 112 | this.defaultLocale 113 | ], function(l) { 114 | return this._localesData[l] && this._localesData[l][term]; 115 | }.bind(this)); 116 | 117 | if(matchedLocale) { 118 | return this._localesData[matchedLocale][term]; 119 | } 120 | 121 | return false; 122 | }, 123 | 124 | _localeWithoutTerritory: function _localeWithoutTerritory(locale, separator) { 125 | return locale.split(separator)[0]; 126 | } 127 | }; 128 | 129 | module.exports = I18Node; 130 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "i18node", 3 | "version": "0.5.0", 4 | "description": "I18n library for node with support for plurals and genders", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha test", 8 | "lint": "eslint lib/**/*.js test/**/*.js", 9 | "coverage": "istanbul cover --report lcov node_modules/mocha/bin/_mocha -- test/**/*.js", 10 | "coveralls": "npm run coverage && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage" 11 | }, 12 | "keywords": [ 13 | "i18n", 14 | "internationalization", 15 | "l10n" 16 | ], 17 | "author": { 18 | "name": "talyssonoc", 19 | "email": "talyssonoc@gmail.com" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git://github.com/talyssonoc/i18node.git" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/talyssonoc/i18node/issues" 27 | }, 28 | "dependencies": { 29 | "lodash": "^4.13.1", 30 | "mustache": "^2.2.1" 31 | }, 32 | "devDependencies": { 33 | "chai": "^2.3.0", 34 | "coveralls": "^2.11.9", 35 | "eslint": "^3.0.1", 36 | "istanbul": "^0.4.4", 37 | "mocha": "^2.5.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/i18n.test.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | var path = require('path'); 3 | var I18N = require('../'); 4 | 5 | describe('I18Node tests', function() { 6 | var i18n; 7 | var availableLocales = ['en', 'pt']; 8 | var localesPath = path.join(__dirname, 'locales'); 9 | 10 | beforeEach(function() { 11 | i18n = new I18N({ 12 | locales: availableLocales, 13 | defaultLocale: 'en', 14 | defaultGender: 'neutral', 15 | path: localesPath 16 | }); 17 | }); 18 | 19 | describe('#i18n', function() { 20 | context('when no parameters are sent', function() { 21 | it('should return nothing', function() { 22 | expect(i18n.i18n()).to.equal(''); 23 | }); 24 | }); 25 | 26 | context('when second parameter is of invalid type', function() { 27 | it('should throw an error', function() { 28 | expect(function() { 29 | i18n.i18n('something', []); 30 | }).to.throw(Error); 31 | }); 32 | }); 33 | 34 | context('when it does not have the searched term', function() { 35 | context('when no extra arguments are passed', function() { 36 | it('should return the term itself', function() { 37 | expect(i18n.i18n('nope')).to.equal('nope'); 38 | }); 39 | }); 40 | 41 | context('when arguments are passed', function() { 42 | it('should return the term itself', function() { 43 | expect(i18n.i18n('nope', { num: 2 })).to.equal('nope'); 44 | }); 45 | }); 46 | }); 47 | 48 | context('when the term does not require parameters', function() { 49 | it('should return the correct translation', function() { 50 | expect(i18n.i18n('dev')).to.equal('development'); 51 | }); 52 | }); 53 | 54 | context('when the term require parameters', function() { 55 | it('should return the correct translation using the parameter', function() { 56 | expect(i18n.i18n('greeting', { name: 'world' })).to.equal('hello, world'); 57 | }); 58 | }); 59 | 60 | context('when the term has different genders', function() { 61 | it('should return the translation with the correct gender', function() { 62 | expect(i18n.i18n('human', { gender: 'masc' })).to.equal('man'); 63 | expect(i18n.i18n('human', { gender: 'fem' })).to.equal('woman'); 64 | expect(i18n.i18n('human', { gender: 'neutral' })).to.equal('person'); 65 | }); 66 | 67 | context('when no gender is passed', function() { 68 | it('should use the default gender', function() { 69 | expect(i18n.i18n('human')).to.equal('person'); 70 | }); 71 | }); 72 | 73 | context('when the term does not have the passed gender', function() { 74 | it('should use the default gender', function() { 75 | expect(i18n.i18n('human', { gender: 'cyborg' })).to.equal('person'); 76 | }); 77 | }); 78 | }); 79 | 80 | context('when the term has plural forms', function() { 81 | it('should return the translation with the correct plural', function() { 82 | expect(i18n.i18n('person', { num: 1 })).to.equal('person'); 83 | expect(i18n.i18n('person', { num: 2 })).to.equal('couple'); 84 | }); 85 | 86 | context('when no number is passed', function() { 87 | it('should use 1 as default', function() { 88 | expect(i18n.i18n('person')).to.equal('person'); 89 | }); 90 | }); 91 | 92 | context('when the term does not have the passed number', function() { 93 | context('when term has plural for n', function() { 94 | it('should use n as default', function() { 95 | expect(i18n.i18n('person', { num: 5 })).to.equal('people'); 96 | }); 97 | }); 98 | 99 | context('when term has no plural for n', function() { 100 | it('should return the term itself', function() { 101 | expect(i18n.i18n('side', { num: 3 })).to.equal('side'); 102 | }); 103 | }); 104 | }); 105 | 106 | context('when number is passed directly instead of arguments object', function() { 107 | it('should return the translation with the correct plural', function() { 108 | expect(i18n.i18n('person', 1)).to.equal('person'); 109 | expect(i18n.i18n('person', 2)).to.equal('couple'); 110 | }); 111 | }); 112 | }); 113 | 114 | context('when the term has different genders and plural forms', function() { 115 | context('when term has passed gender and number', function() { 116 | it('should return the correct translation', function() { 117 | expect(i18n.i18n('nidorino', { num: 1, gender: 'masc' })).to.equal('nidorino'); 118 | expect(i18n.i18n('nidorino', { num: 1, gender: 'fem' })).to.equal('nidorina'); 119 | }); 120 | }); 121 | 122 | context('when term has passed gender but not passed number', function() { 123 | it('should return the correct translation using n as default number', function() { 124 | expect(i18n.i18n('nidorino', { num: 2, gender: 'masc' })).to.equal('nidorinos'); 125 | expect(i18n.i18n('nidorino', { num: 2, gender: 'fem' })).to.equal('nidorinas'); 126 | }); 127 | }); 128 | 129 | context('when term has passed number but not passed gender', function() { 130 | it('should return the correct translation using the default gender', function() { 131 | expect(i18n.i18n('nidorino', { num: 1, gender: '?' })).to.equal('nidorino'); 132 | }); 133 | }); 134 | 135 | context('when term has neither the passed number or gender', function() { 136 | context('when term has plural for n and translation for the default gender', function() { 137 | it('should return the correct translation using n as default and default gender', function() { 138 | expect(i18n.i18n('nidorino', { num: 2, gender: '?' })).to.equal('nidorinos'); 139 | }); 140 | }); 141 | 142 | context('when term has no plural for n', function() { 143 | it('should return the term itself', function() { 144 | expect(i18n.i18n('pronoun', { num: 2, gender: '?' })).to.equal('pronoun'); 145 | }); 146 | }); 147 | 148 | context('when term has no translation for default gender', function() { 149 | it('should return the term itself', function() { 150 | expect(i18n.i18n('pronoun', { num: 1, gender: '?' })).to.equal('pronoun'); 151 | }); 152 | }); 153 | }); 154 | }); 155 | 156 | context('when using different locale', function() { 157 | context('when term has translation on passed locale', function() { 158 | it('should return the translation with the correct locale', function() { 159 | expect(i18n.i18n('dev', { locale: 'pt' })).to.equal('desenvolvimento'); 160 | }); 161 | }); 162 | 163 | context('when term has no translation on passed locale', function() { 164 | context('when term has translation on passed locale without the territory', function() { 165 | it('should fallback to passed locale without the territory', function() { 166 | expect(i18n.i18n('dev', { locale: 'pt-br' })).to.equal('desenvolvimento'); 167 | expect(i18n.i18n('dev', { locale: 'pt_br' })).to.equal('desenvolvimento'); 168 | }); 169 | }); 170 | 171 | context('when term has translation neither on passed locale or passed locale without the territory', function() { 172 | context('when default locale has translation for the term', function() { 173 | it('should use default locale', function() { 174 | expect(i18n.i18n('dev', { locale: 'es' })).to.equal('development'); 175 | }); 176 | }); 177 | 178 | context('when default locale has no translation for the term', function() { 179 | it('should return the term itself', function() { 180 | expect(i18n.i18n('nothing', { locale: 'es' })).to.equal('nothing'); 181 | }); 182 | }); 183 | }); 184 | }); 185 | }); 186 | }); 187 | 188 | describe('#getLocale', function() { 189 | it('should return the current default locale', function() { 190 | expect(i18n.getLocale()).to.be.equal('en'); 191 | }); 192 | }); 193 | 194 | describe('#setLocale', function() { 195 | it('should change the current default locale', function() { 196 | i18n.setLocale('pt'); 197 | expect(i18n.getLocale()).to.be.equal('pt'); 198 | }); 199 | }); 200 | 201 | describe('#getGender', function() { 202 | it('should return the current default gender', function() { 203 | expect(i18n.getGender()).to.be.equal('neutral'); 204 | }); 205 | }); 206 | 207 | describe('#setGender', function() { 208 | it('should change the current default gender', function() { 209 | i18n.setGender('fem'); 210 | expect(i18n.getGender()).to.be.equal('fem'); 211 | }); 212 | }); 213 | 214 | describe('#hasLocale', function() { 215 | context('when instance has passed locale', function() { 216 | it('should return true', function() { 217 | expect(i18n.hasLocale('en')).to.be.ok; 218 | }); 219 | }); 220 | 221 | context('when instance does not have passed locale', function() { 222 | it('should return true', function() { 223 | expect(i18n.hasLocale('es')).to.not.be.ok; 224 | }); 225 | }); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /test/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "dev": "development", 3 | "greeting": "hello, {{name}}", 4 | "human": { 5 | "masc": "man", 6 | "fem": "woman", 7 | "neutral": "person" 8 | }, 9 | "person": { 10 | "1": "person", 11 | "2": "couple", 12 | "n": "people" 13 | }, 14 | "side": { 15 | "1": "left", 16 | "2": "right" 17 | }, 18 | "nidorino": { 19 | "1" : { 20 | "masc": "nidorino", 21 | "fem": "nidorina", 22 | "neutral": "nidorino" 23 | }, 24 | "n" : { 25 | "masc": "nidorinos", 26 | "fem": "nidorinas", 27 | "neutral": "nidorinos" 28 | } 29 | }, 30 | "pronoun": { 31 | "masc": "he", 32 | "fem": "she" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/locales/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "dev": "desenvolvimento" 3 | } 4 | --------------------------------------------------------------------------------