From 6315fa65ae03b25557e245206510818216929a37 Mon Sep 17 00:00:00 2001 From: Dmitry Chleck Date: Mon, 3 Dec 2012 17:16:23 +0400 Subject: [PATCH] Initial commit. --- .gitignore | 3 + .npmignore | 4 + LICENSE | 22 +++ README.md | 316 +++++++++++++++++++++++++++++++++ example/i18n.json | 7 + example/index.html | 91 ++++++++++ example/remote.js | 56 ++++++ example/ru.json | 27 +++ example/ru.jsonp | 28 +++ example/ru.tr | 88 +++++++++ example/scripts/01.init.cmd | 2 + example/scripts/01.init.sh | 4 + example/scripts/02.collect.cmd | 2 + example/scripts/02.collect.sh | 4 + example/scripts/03.json.cmd | 2 + example/scripts/03.json.sh | 4 + example/tr.json | 14 ++ example/translate.js | 84 +++++++++ i18n.min.js | 8 + index.js | 10 ++ lib/i18n.js | 238 +++++++++++++++++++++++++ package.json | 29 +++ test/i18n/i18n.json | 7 + test/i18n/ru.json | 18 ++ test/mocha.opts | 2 + test/test.js | 180 +++++++++++++++++++ 26 files changed, 1250 insertions(+) create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 example/i18n.json create mode 100644 example/index.html create mode 100644 example/remote.js create mode 100644 example/ru.json create mode 100644 example/ru.jsonp create mode 100644 example/ru.tr create mode 100644 example/scripts/01.init.cmd create mode 100755 example/scripts/01.init.sh create mode 100644 example/scripts/02.collect.cmd create mode 100755 example/scripts/02.collect.sh create mode 100644 example/scripts/03.json.cmd create mode 100755 example/scripts/03.json.sh create mode 100644 example/tr.json create mode 100644 example/translate.js create mode 100644 i18n.min.js create mode 100644 index.js create mode 100644 lib/i18n.js create mode 100644 package.json create mode 100644 test/i18n/i18n.json create mode 100644 test/i18n/ru.json create mode 100644 test/mocha.opts create mode 100644 test/test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..944f6fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +locale.* +TODO.md diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..3d32907 --- /dev/null +++ b/.npmignore @@ -0,0 +1,4 @@ +.git* +*.sublime* +i18n.min.js +TODO.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e43559f --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2012 Dmitry A. Chleck + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..70dc71e --- /dev/null +++ b/README.md @@ -0,0 +1,316 @@ +# Locale for Javascript + +# Features +--- +* Multiple target languages (translate to) +* Any base language (translate from) +* Plural forms for any language +* Simultaneous support of multiple languages +* Flexible placeholders +* Remote translation + +## It's simple +--- + // Translate + var who = 'world'; + __('Hello, %s!', who); + + // Translate plural + __([ + 'There is %n item in your cart (total $%s).', + 'There are %n items in your cart (total $%s).' + ], cart.items.length, cart.total + ); + +# Installation +--- + +Install node.js library: + + npm install locale-js + +or download minified library [i18n.min.js](https://bitbucket.org/chleck/locale-js/raw/stable/i18n.min.js "Minified i18n.js") (for browser apps). + +# API +--- + +## Library import +--- + +### For node.js: + + var locale = require('locale-js'); + +### For browser: + + + +## Configuration +--- + +### Library initialization (browser): + + locale.init(base, rule) + +, where: + +- *base* (string) - id of application's base language; +- *rule* (string) - plural rule for base language. + +You should use locale.add() to add translation for each used language. + +### Library initialization (node.js): + + locale.init(path) + +, where *path* is the path to the translation files. + +This function searches for and loads all translations automatically. + +### Example: + + // In browser: + locale.init('ru', '(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)'); + // You can skip parameters if your base language is English + locale.init(); + + // In node.js app: + locale.init('./i18n/'); + +### Add translation: + + locale.add(lang, translation) + +, where: + +- *lang* (string) - language id; +- *translation* (object) - translation JSON. + +## Creating i18n object +--- + +### Create translation object for the given target: + + new locale.i18n(target) + +, where *target* (string or null) - 1) id of the target language, 2) empty string or 3) null. + +Sets target language to base language if *target* is *''* (empty string). +Enables remote translation mode if *target* is *null*. + +### Example: + + // Create translation object for Russian language + var i18n = new locale.i18n('ru'); + // Create translation object for base language (no translation) + var i18n = new locale.i18n(''); + // Create translation object for remote translation + var i18n = new locale.i18n(null); + // And pull up __() function to the local scope + var __ = i18n.__; + +## Translation target +--- + +This methods are accessible for *locale* and *i18n* objects. + +### Get current target: + + to() + +Returns (string or null) current translation target. + +### Set target: + + to(target) + +, where *target* (string or null) - 1) id of target language, 2) empty string or 3) null. + +Sets target language to base language if *target* is *''* (empty string). +Switches i18n object to remote translation mode if *target* is *null*. + +### Example: + + // Set target language for library to German + locale.to('de'); + // Create i18n object and set its target language to Russian + var i18n = new locale.i18n(); + i18n.to('ru'); + // Enable remote translation mode + i18n.to(null); + +## Translation +--- + +This methods are accessible for *locale* and *i18n* objects. + +### For simple form: + + __(phrase) + __(phrase, ...) + __(phrase, array) + __(phrase, object) + +, where: + +- *phrase* (string) - string for translation (can contains placeholders); +- *...* (mixed) - any number of additional args; +- *array* (Array) - array of args; +- *object* (object) - map of args. + +Returns translated string or remote translation data (in case of remote translation target). + +### For plural form: + + __(phrase, n) + __(phrase, n, ...) + __(phrase, n, array) + __(phrase, n, object) + +, where: + +- *phrase* (array) - array of strings each of them is a plural form for translation (can contains placeholders); +- *n* (numeric) - base number of plural form; +- *...* (mixed) - any number of additional args; +- *array* (Array) - array of args; +- *object* (object) - map of args. + +Returns translated string or remote translation data (in case of remote translation target). + +### Example: + + // Simple: + + // Hello! + __('Hello!'); + // Hello, anonymous! + __('Hello, %s!', 'anonymous'); + // Hello, John Smith! + __('Hello, %s(2) %s(1)!', [ 'Smith', 'John' ]); + // Hello, Foo (bar)! + __('Hello, %s(name) (%s(login))!', { name: 'Foo', login: 'bar' }); + + // Plural: + + // Inbox: 1 unreaded message. + __([ 'Inbox: %n unreaded message.', 'Inbox: %n unreaded messages.' ], 1); + // Inbox: 7 unreaded messages. + __([ '%s(1): %n unreaded message.', '%s(1): %n unreaded messages.' ], 7, 'Inbox'); + // Anonymous, you have 365 unreaded messages in the "Spam" folder. + __([ + '%s(1), you have %n unreaded message in the "%s(2)" folder.', + '%s(1), you have %n unreaded messages in the "%s(2)" folder.' + ], 365, [ 'Anonymous', 'Spam' ]); + // The second way + __([ + '%s(login), you have %n unreaded message in the "%s(folder)" folder.', + '%s(login), you have %n unreaded messages in the "%s(folder)" folder.' + ], 365, { login: 'Anonymous', folder: 'Spam' }); + + // Remote: + + // { __i18n: true, phrase: 'Hello!', n: undefined, args: {} } + __('Hello!'); + +See also: Remote translation. + +## Strings format +--- + +### Phrase, id and comment + +Each string consists of *phrase*, *id* and *comment*. +Both *id* and *comment* are optional, *id* separates from *phrase* by '#' sign, *comment* separates from *id* by space. + + [#[ ]] + +*phrase* + *id* is unique key for translation dictionary. You can create several variants of translation of the same *phrase* using different *id*s. + +Also you can place any useful info for translator to *comment*. + +### Example: + + // No id, no comment + __('Hello!'); + // Both id and comment + __('Hello!#another This is a comment for translator'); + // Only id + __('Hello!#another'); + // Only comment + __('Hello!# This is a comment for translator'); + +### Placeholders + +Phrases can contains placeholders. + +Placeholder's format is: + +**%s** - fills with the next arg. +**%s(id)** - fills with the *id* arg (arg number *id* for additional args or arg[id] for array and map args). +**%n** - fills with base number of plural form (valid for plural form only). + +### Example: + +See example of __() function. + +### Special characters + +Special characters are '#' and '%'. If you need it in your string you should type it twice. + + // 40% + __('%s%%', 40); + // It's # not a comment! + __('It\'s ## not a comment!); + +Also you can use '(' character after placeholder. Type it twice too: + + // Order by name(asc) + __('Order by %s((asc)', 'name'); + +## Remote translation +--- + +### Search for remote translation data in the *obj* and replace it by translated string: + + i18n.tr(obj) + +, where *obj* (object) - object for translation. + +### Remote translation data structure: + + { + __i18n: true, + phrase: , + n: , + args: + } + +See also: Creating i18n object. + +### Example: + + // Server: + + // Create i18n object for the remote mode + var i18n = new locale.i18n(null); + var __ = i18n.__(); + submit({ + id: 832367, + errno: 404, + error: __('Path not found!') + }); + + // Client: + + var i18n = new locale.i18n('ru'); + var msg = receive(); + i18n.tr(msg); + /* + + { + id: 832367, + errno: 404, + error: 'Путь не найден!' + } + + */ diff --git a/example/i18n.json b/example/i18n.json new file mode 100644 index 0000000..63a08c8 --- /dev/null +++ b/example/i18n.json @@ -0,0 +1,7 @@ +{ + "base": "en", + "rule": "(n == 1 ? 0 : 1)", + "targets": [ + "ru" + ] +} \ No newline at end of file diff --git a/example/index.html b/example/index.html new file mode 100644 index 0000000..00a6fd0 --- /dev/null +++ b/example/index.html @@ -0,0 +1,91 @@ + + + + Locale library usage examples. + + + + + + + diff --git a/example/remote.js b/example/remote.js new file mode 100644 index 0000000..f0f38f0 --- /dev/null +++ b/example/remote.js @@ -0,0 +1,56 @@ +/* + * # locale library usage examples: remote translation + * + * ## locale.js: i18n for Node.js and browser + * + * @author Dmitry A. Chleck + * @version 1.0.0 + */ + +http = require('http'); + +// Import and init the library +var locale = require('..'); +locale.init('./'); +var i18n = new locale.i18n(null); + +var __ = i18n.__; + +// Start HTTP server on 127.0.0.1:8080 +http.createServer(function (req, res) { + // Make JSON with i18n fields + var json = { + 'title': __('Hello!'), + 'msg': __(['The following error occurred during processing:', 'The following errors occurred during processing:'], 1), + 'error': [ __('Hello!'), __('Email %s is invalid. Please enter valid email.', '%#$@gmail.com') ], + 'session': 'jsahfkjsdfsdhiudfshiuh' + }; + // and submit it to HTTP client + res.writeHead(200, {'Content-Type': 'application/json'}); + res.end(JSON.stringify(json, null, ' ')); +}).listen(8080, '127.0.0.1'); + +// Get JSON from HTTP server +http.get({ host:'127.0.0.1', port:8080, path:'/', agent:false }, function (res) +{ + var data = ''; + res.setEncoding('utf8'); + res.on('data', function (chunk) { + data += chunk; + }); + res.on('end', function(){ + var json + , i18n = new locale.i18n(''); + // Translate to base language (English) + json = JSON.parse(data); + i18n.tr(json); + console.log('\nEnglish:\n', JSON.stringify(json, null, ' ')); + // Translate to Russian + json = JSON.parse(data); + i18n.to('ru'); + i18n.tr(json); + console.log('\nRussian:\n', JSON.stringify(json, null, ' ')); + // Exit + process.exit(0); + }); +}); diff --git a/example/ru.json b/example/ru.json new file mode 100644 index 0000000..7d95b46 --- /dev/null +++ b/example/ru.json @@ -0,0 +1,27 @@ +{ + "": "(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)", + "Hello!": "Привет!", + "The following error occurred during processing:": [ + "Во время обработки возникла следующая ошибка:", + "Во время обработки возникли следующие ошибки:", + "Во время обработки возникли следующие ошибки:" + ], + "Email %s is invalid. Please enter valid email.": "Недопустимый email %s. Пожалуйста, введите правильный email.", + "Hello!#another_hello": "Здаров!", + "Hello!#another_hello2": "Алло!", + "There is ## in this phrase but it is not comment.": "В этой строке есть #, но это не комментарий.", + "Hello?": "", + "This is %% percent symbol. This is placeholder with %s((name).": "Это %% знак процента. Это плейсхолдер с %s((name).", + "My %s(1) is faster then your %s(2)!": "Мой %s(1) быстрее, чем твой %s(2)!", + "Let's count in English: %s, %s, %s(4) and %s.": "Давайте считать по-английски: %s, %s, %s(4) и %s.", + "Inbox: %n unreaded message.": [ + "Входящие: %n непрочитанное сообщение.", + "Входящие: %n непрочитанных сообщения.", + "Входящие: %n непрочитанных сообщений." + ], + "%n developer from our team uses %s(1) with %s(2).": [ + "%n разработчик из нашей команды использует %s(1) с %s(2).", + "%n разработчика из нашей команды используют %s(1) с %s(2).", + "%n разработчиков из нашей команды используют %s(1) с %s(2)." + ] +} \ No newline at end of file diff --git a/example/ru.jsonp b/example/ru.jsonp new file mode 100644 index 0000000..20600e9 --- /dev/null +++ b/example/ru.jsonp @@ -0,0 +1,28 @@ +locale_ru = +{ + "": "(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)", + "Hello!": "Привет!", + "The following error occurred during processing:": [ + "Во время обработки возникла следующая ошибка:", + "Во время обработки возникли следующие ошибки:", + "Во время обработки возникли следующие ошибки:" + ], + "Email %s is invalid. Please enter valid email.": "Недопустимый email %s. Пожалуйста, введите правильный email.", + "Hello!#another_hello": "Здаров!", + "Hello!#another_hello2": "Алло!", + "There is ## in this phrase but it is not comment.": "В этой строке есть #, но это не комментарий.", + "Hello?": "", + "This is %% percent symbol. This is placeholder with %s((name).": "Это %% знак процента. Это плейсхолдер с %s((name).", + "My %s(1) is faster then your %s(2)!": "Мой %s(1) быстрее, чем твой %s(2)!", + "Let's count in English: %s, %s, %s(4) and %s.": "Давайте считать по-английски: %s, %s, %s(4) и %s.", + "Inbox: %n unreaded message.": [ + "Входящие: %n непрочитанное сообщение.", + "Входящие: %n непрочитанных сообщения.", + "Входящие: %n непрочитанных сообщений." + ], + "%n developer from our team uses %s(1) with %s(2).": [ + "%n разработчик из нашей команды использует %s(1) с %s(2).", + "%n разработчика из нашей команды используют %s(1) с %s(2).", + "%n разработчиков из нашей команды используют %s(1) с %s(2)." + ] +} \ No newline at end of file diff --git a/example/ru.tr b/example/ru.tr new file mode 100644 index 0000000..ceeb0cd --- /dev/null +++ b/example/ru.tr @@ -0,0 +1,88 @@ +@ [1] remote.js (23:15) +@ [2] remote.js (25:17) +@ [3] translate.js (36:6) +@ [4] translate.js (38:6) +S Hello! +# [4] This is comment. +? Hello! +! Привет! + +@ [1] remote.js (24:13) +P The following error occurred during processing: +? The following error occurred during processing: +? The following errors occurred during processing: +! Во время обработки возникла следующая ошибка: +! Во время обработки возникли следующие ошибки: +! Во время обработки возникли следующие ошибки: + +@ [1] remote.js (25:31) +S Email %s is invalid. Please enter valid email. +? Email %s is invalid. Please enter valid email. +! Недопустимый email %s. Пожалуйста, введите правильный email. + +@ [1] translate.js (40:6) +S Hello!#another_hello +? Hello! +! Здаров! + +@ [1] translate.js (42:6) +S Hello!#another_hello2 +# Please translate this another way. +? Hello! +! Алло! + +@ [1] translate.js (44:6) +S There is ## in this phrase but it is not comment. +? There is ## in this phrase but it is not comment. +! В этой строке есть #, но это не комментарий. + +@ [1] translate.js (46:6) +S Hello? +? Hello? +! + +@ [1] translate.js (48:6) +S This is %% percent symbol. This is placeholder with %s((name). +? This is %% percent symbol. This is placeholder with %s((name). +! Это %% знак процента. Это плейсхолдер с %s((name). + +@ [1] translate.js (50:6) +@ [2] translate.js (52:6) +@ [3] translate.js (54:6) +S My %s(1) is faster then your %s(2)! +? My %s(1) is faster then your %s(2)! +! Мой %s(1) быстрее, чем твой %s(2)! + +@ [1] translate.js (56:6) +S Let's count in English: %s, %s, %s(4) and %s. +? Let's count in English: %s, %s, %s(4) and %s. +! Давайте считать по-английски: %s, %s, %s(4) и %s. + +@ [1] translate.js (58:6) +@ [2] translate.js (59:6) +@ [3] translate.js (60:6) +P Inbox: %n unreaded message. +? Inbox: %n unreaded message. +? Inbox: %n unreaded messages. +! Входящие: %n непрочитанное сообщение. +! Входящие: %n непрочитанных сообщения. +! Входящие: %n непрочитанных сообщений. + +@ [1] translate.js (62:6) +@ [2] translate.js (67:6) +@ [3] translate.js (72:6) +P %n developer from our team uses %s(1) with %s(2). +# Comment 1 +# [2] Comment 3 +# [3] Multiline +# comment +? %n developer from our team uses %s(1) with %s(2). +# Comment 2 +# [2] Comment 4 +# [3] Another +# multiline +# comment +? %n developers from our team uses %s(1) with %s(2). +! %n разработчик из нашей команды использует %s(1) с %s(2). +! %n разработчика из нашей команды используют %s(1) с %s(2). +! %n разработчиков из нашей команды используют %s(1) с %s(2). diff --git a/example/scripts/01.init.cmd b/example/scripts/01.init.cmd new file mode 100644 index 0000000..00fc97c --- /dev/null +++ b/example/scripts/01.init.cmd @@ -0,0 +1,2 @@ +cd .. +node ../tools/i18n init en ru diff --git a/example/scripts/01.init.sh b/example/scripts/01.init.sh new file mode 100755 index 0000000..ef57640 --- /dev/null +++ b/example/scripts/01.init.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +cd .. +node ../tools/i18n init en ru diff --git a/example/scripts/02.collect.cmd b/example/scripts/02.collect.cmd new file mode 100644 index 0000000..0f7d1ce --- /dev/null +++ b/example/scripts/02.collect.cmd @@ -0,0 +1,2 @@ +cd .. +node ../tools/i18n collect diff --git a/example/scripts/02.collect.sh b/example/scripts/02.collect.sh new file mode 100755 index 0000000..0c75da9 --- /dev/null +++ b/example/scripts/02.collect.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +cd .. +node ../tools/i18n collect diff --git a/example/scripts/03.json.cmd b/example/scripts/03.json.cmd new file mode 100644 index 0000000..8c7230a --- /dev/null +++ b/example/scripts/03.json.cmd @@ -0,0 +1,2 @@ +cd .. +node ../tools/i18n json diff --git a/example/scripts/03.json.sh b/example/scripts/03.json.sh new file mode 100755 index 0000000..a398ecf --- /dev/null +++ b/example/scripts/03.json.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +cd .. +node ../tools/i18n json diff --git a/example/tr.json b/example/tr.json new file mode 100644 index 0000000..63ee76d --- /dev/null +++ b/example/tr.json @@ -0,0 +1,14 @@ +{ + "base": "en", + "n": 2, + "rule": "(n == 1 ? 0 : 1)", + "filter": [ + ".js" + ], + "targets": { + "ru": { + "n": 3, + "rule": "(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)" + } + } +} \ No newline at end of file diff --git a/example/translate.js b/example/translate.js new file mode 100644 index 0000000..70fc546 --- /dev/null +++ b/example/translate.js @@ -0,0 +1,84 @@ +/* + * # locale library usage examples + * + * ## locale.js: i18n for Node.js and browser + * + * @author Dmitry A. Chleck + * @version 1.0.0 + */ + +// Import and init the library +var locale = require('..'); +locale.init('./'); + +w('# LOCALE LIBRARY DEMO:'); + +// Create i18n for base language (English) +var i18n = new locale.i18n(''); + +// Pull __() to locals +__ = i18n.__; + +w('## English:'); +demo(); + +// Switch to Russian +i18n.to('ru'); + +w('## Russian:'); +demo(); + +// THE END + +// !!! Usage of translation functions +function demo() { + // Translate phrase + w(__('Hello!')); + // Comment for translator + w(__('Hello!# This is comment.')); + // Phrase with id + w(__('Hello!#another_hello')); + // Phrase with id and comment + w(__('Hello!#another_hello2 Please translate this another way.')); + // Phrase with # but not comment + w(__('There is ## in this phrase but it is not comment.')); + // This phrase will not be translated - missing in translation. + w(__('Hello?')); + // Escapes for placeholders + w(__('This is %% percent symbol. This is placeholder with %s((name).', 'ignored ')); + // Placeholders with additional arguments + w(__('My %s(1) is faster then your %s(2)!', 'SSD', 'HDD')); + // Placeholders with array + w(__('My %s(1) is faster then your %s(2)!', [ 'Kawasaki', 'Segway' ])); + // Placeholders with object + w(__('My %s(1) is faster then your %s(2)!', { 1: 'Core i7', 2: '486DX' })); + // Both names and order + w(__('Let\'s count in English: %s, %s, %s(4) and %s.', 'one', 'two', 'four', 'three')); + // Plural forms + w(__(['Inbox: %n unreaded message.', 'Inbox: %n unreaded messages.'], 1)); + w(__(['Inbox: %n unreaded message.', 'Inbox: %n unreaded messages.'], 12)); + w(__(['Inbox: %n unreaded message.', 'Inbox: %n unreaded messages.'], 22)); + // All-in-one + w(__([ + '%n developer from our team uses %s(1) with %s(2).# Comment 1', + '%n developers from our team uses %s(1) with %s(2).# Comment 2' + ], 1, 'C', 'vim' + )); + w(__([ + '%n developer from our team uses %s(1) with %s(2).# Comment 3', + '%n developers from our team uses %s(1) with %s(2).# Comment 4' + ], 3, [ 'Python', 'PyCharm' ] + )); + w(__([ + '%n developer from our team uses %s(1) with %s(2).# Multiline\ncomment', + '%n developers from our team uses %s(1) with %s(2).# Another\nmultiline\ncomment' + ], 7, { '1': 'Node.js', '2': 'Sublime Text 2' } + )); + // No args - empty string + w(__()); +} + +// Some support functions + +// The short way message +function w(s) { console.log(s); } diff --git a/i18n.min.js b/i18n.min.js new file mode 100644 index 0000000..208d08e --- /dev/null +++ b/i18n.min.js @@ -0,0 +1,8 @@ +/* + * # i18n library + * + * ## locale.js: i18n for Node.js and browser + * + * @author Dmitry A. Chleck + * @version 1.0.0 + */var locale=new function(){function i(r,i){var o,a;if(!r.phrase)return"";try{r.n===undefined?o=n[i][r.phrase]||u(r.phrase):(a=t[i](r.n),o=n[i][r.phrase[0]][a])}catch(f){if(r.n===undefined)o=u(r.phrase);else try{a=t[e](r.n),o=u(r.phrase[a])}catch(f){o=u(r.phrase[0])}}return s(o,r.args,r.n)}function s(e,t,n){var r=1,i=e.split("%"),s=[i[0]];if(Array.isArray(t)){var o={};t.unshift("");for(var u in t)o[u]=t[u];t=o}for(var a=1;a + * @version 1.0.0 + */ + +module.exports = require('./lib/locale'); diff --git a/lib/i18n.js b/lib/i18n.js new file mode 100644 index 0000000..e556d52 --- /dev/null +++ b/lib/i18n.js @@ -0,0 +1,238 @@ +/* + * # i18n library + * + * ## locale.js: i18n for Node.js and browser + * + * @author Dmitry A. Chleck + * @version 1.0.0 + */ + +var locale = new function() { + + // Base language + var base + , rules; + + /* + + Translation dictionaries + + tr = + { + : { + : , + }, + ... + } + , where: + + (string) - language key. + Example: 'en' for English, 'ru' for Russian etc. + + (string) - translation key, the single or the first form of base language phrase. + Example: 'Hello!' or 'There is %n object.' + + (string) or (Array) - translation phrase(s), string for single or array of string for plural. + Example: 'Привет!' or [ 'Имеется %n объект.', 'Имеется %n объекта.', 'Имеется %n объектов.'] + + */ + var tr = {}; + + // Save context + var self = this; + + // # Library initialization + this.init = function(lang, rule) { + base = lang || 'en'; + rules = {}; + rules[base] = Function('n', 'return ' + (rule || '(n == 1 ? 0 : 1)')); + } + + // # Add translation + this.add = function(lang, translation) { + tr[lang] = translation; + // Compile plural rule + rules[lang] = Function('n', 'return ' + (translation[''] || '(n == 1 ? 0 : 1)')); + } + + // # i18n objects constructor + this.i18n = function(lang) { + var self = this + , to; + + to = lang === undefined ? '' : lang; + + // Get/set target language + this.to = function(lang) { + if(lang === undefined) return to; + // Set lang to base language if empty string + if(lang === '') lang = base || 'en'; + to = lang; + } + + // # Translate + this.__ = function() { + var phrase, n; + // Convert arguments to array + var a = Array.prototype.slice.call(arguments); + // Get phrase + phrase = a.shift() || ''; + // Get n if plural + if(Array.isArray(phrase)) n = a.shift(); + // Trim comments but save id + if(n === undefined) { + phrase = trimKey(phrase); + } else { + for(var i in phrase) { + phrase[i] = trimKey(phrase[i]); + } + } + // Pop array and object arguments + if(typeof a[0] === 'object' || Array.isArray(a[0])) { + a = a[0]; + } + var json = { '__i18n': true, 'phrase': phrase, 'n': n, 'args': a }; + // Check remote mode + return to === null ? json : translate(json, to); + } + + // # Translate remote objects + this.tr = function(obj) { + if(typeof obj !== 'object') return; + for(var o in obj) { + if(obj[o].__i18n) obj[o] = translate(obj[o], to); else self.tr(obj[o]); + } + } + + } // End of i18n + + // Translate args to given language + function translate(args, lang) { + var result, f; + // Empty result for empty phrase + if(!args.phrase) return ''; + // Return translation + try { + if(args.n === undefined) { + // Simple + result = tr[lang][args.phrase] || trimPhrase(args.phrase); + } else { + // Plural + f = rules[lang](args.n); + result = tr[lang][args.phrase[0]][f]; + } + } catch(e) { + // Drop to base language if any error + if(args.n === undefined) { + // Base simple + result = trimPhrase(args.phrase); + } else { + try { + // Base plural + f = rules[base](args.n); + // Return right plural form + result = trimPhrase(args.phrase[f]); + } catch(e) { + // Return first form if no plural rule + result = trimPhrase(args.phrase[0]); + } + } + } + return fill(result, args.args, args.n); + } + + // Fill string's placeholders with given args + function fill(s, args, n) { + var argc = 1 // Current %s argument position + , chunks = s.split('%') // String parts + , result = [ chunks[0] ]; // Result buffer + // If args is array, convert to object + if(Array.isArray(args)) { + var tmp = {}; + // Shift args numeration to start from 1 + args.unshift(''); + for(var k in args) tmp[k] = args[k]; + args = tmp; + } + // Fill placeholders + for(var i = 1; i < chunks.length; i++) { + var chunk = chunks[i]; + var arg = ''; + switch(chunk[0]) { + // %% => % + case undefined: + // Push '%' and next chunk + result.push('%'); + result.push(chunks[++i]); + continue; + // %n => n + case 'n': + arg = n; + break; + // %s => value from args + case 's': + // Get name if present + var name = ''; + // '((' after %s - ignore name + if(chunk[1] === '(') { + if(chunk[2] === '(') { + // %s(( - %s with screened '('' after it but not %s(name) + chunk = chunk.substring(1); + } else { + // %s(name) + name = chunk.substr(2, chunk.indexOf(')') - 2); + // Cut (name) at begin of chunk + chunk = chunk.substring(name.length + 2); + } + } + if(name) arg = args[name]; else arg = args[argc++]; + break; + } + result.push(arg); + result.push(chunk.substring(1)); + } + return result.join(''); + } + + // Make dictionary key (phrase + id) + function trimKey(phrase) { + var i = 0; + var c; + // Search single '#' + while(c = phrase[i++]) { + if(c == '#') { + if(phrase[i] == '#') i++; else break; + } + } + var j = phrase.indexOf(' ', i); + if(j < 0) j = phrase.length; + var key = phrase.slice(0, i-1); + var id = phrase.slice(i, j); + if(id) key += '#' + id; + return key; + } + + // Trim id and comment + function trimPhrase(phrase) { + var i = 0; + var c; + // Search single '#' + while(c = phrase[i++]) { + if(c == '#') { + if(phrase[i] == '#') i++; else break; + } + } + return phrase.slice(0, i-1).replace(/##/g, '#'); + } + + // Create internal i18n object + var i18n = new self.i18n(); + // Extend self with i18n's methods + for(var k in i18n) { + self[k] = i18n[k]; + } + +} // End of locale namespace + +// export locale if node.js +if(typeof exports === 'object') exports.locale = locale; diff --git a/package.json b/package.json new file mode 100644 index 0000000..b0ee47b --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "author": "Dmitry Chleck ", + "license": "MIT", + "name": "locale-js", + "version": "1.0.0", + "description": "i18n for Node.js and browser", + "keywords": "locale i18n translate gettext", + "main": "index.js", + "homepage": "https://bitbucket.org/chleck/locale-js/overview", + "repository": { + "type": "https", + "url": "https://bitbucket.org/chleck/locale-js.git" + }, + "directories": { + "lib": "lib", + "example": "example", + "test": "test" + }, + "dependencies": { + }, + "devDependencies": { "should": "*" }, + "engines": { + "node": "*" + }, + "scripts": { + "test": "mocha", + "make": "uglifyjs -o i18n.min.js lib/i18n.js" + } +} diff --git a/test/i18n/i18n.json b/test/i18n/i18n.json new file mode 100644 index 0000000..63a08c8 --- /dev/null +++ b/test/i18n/i18n.json @@ -0,0 +1,7 @@ +{ + "base": "en", + "rule": "(n == 1 ? 0 : 1)", + "targets": [ + "ru" + ] +} \ No newline at end of file diff --git a/test/i18n/ru.json b/test/i18n/ru.json new file mode 100644 index 0000000..566a737 --- /dev/null +++ b/test/i18n/ru.json @@ -0,0 +1,18 @@ +{ + "": "(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)", + "Hello!": "Привет!", + "Message.": [ + "Сообщение.", + "Сообщения.", + "Сообщений." + ], + "%n message.": [ + "%n сообщение.", + "%n сообщения.", + "%n сообщений." + ], + "%s %s!": "%s, %s!", + "%s(1) %s(2)!": "%s(1), %s(2)!", + "%s(one) %s(two)!": "%s(one), %s(two)!", + "%s(2) %s!": "%s(2), %s!" +} \ No newline at end of file diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 0000000..9495c03 --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1,2 @@ +--reporter spec +--require should diff --git a/test/test.js b/test/test.js new file mode 100644 index 0000000..a8a6168 --- /dev/null +++ b/test/test.js @@ -0,0 +1,180 @@ +/* + * # locale library test suite + * + * ## locale.js: i18n for Node.js and browser + * + * @author Dmitry A. Chleck + * @version 1.0.0 + */ + +var locale = require('..'); + +var i18n, __, tmp; + +describe('Test suite for locale library', function() { + + describe('Configure:', function() { + + it('Library initialization', function() { + locale.init('./test/i18n'); + }) + + it('Add translation', function() { + locale.add('zz', { 'Hello!': 'Bzzzz!' }); + }) + + it('Set target language', function() { + locale.to('ru'); + }) + + it('Get target language', function() { + locale.to().should.equal('ru'); + }) + + it('Create i18n object', function() { + i18n = new locale.i18n(); + }) + + it('Create i18n object for remote translation', function() { + i18n = new locale.i18n(null); + }) + + }) + + describe('Pass through (no translation):', function() { + + it('Switch to pass trough mode', function() { + locale.to(''); + __ = locale.__; + }) + + it('Simple', function() { + __('Hello!').should.equal('Hello!'); + }) + + it('Plural', function() { + __(['Message.', 'Messages.'], 1).should.equal('Message.'); + __(['Message.', 'Messages.'], 2).should.equal('Messages.'); + }) + + }) + + describe('Translate:', function() { + + it('Switch to Russian', function() { + locale.to('ru'); + __ = locale.__; + }) + + it('Simple', function() { + __('Hello!').should.equal('Привет!'); + }) + + it('Plural', function() { + __(['Message.', 'Messages.'], 1).should.equal('Сообщение.'); + __(['Message.', 'Messages.'], 2).should.equal('Сообщения.'); + __(['Message.', 'Messages.'], 5).should.equal('Сообщений.'); + }) + + }) + + describe('Translate to unknown language:', function() { + + it('Switch to German', function() { + locale.to('de'); + __ = locale.__; + }) + + it('Simple', function() { + __('Hello!').should.equal('Hello!'); + }) + + it('Plural', function() { + __(['Message.', 'Messages.'], 1).should.equal('Message.'); + __(['Message.', 'Messages.'], 2).should.equal('Messages.'); + }) + + }) + + describe('Translate with i18n object:', function() { + + it('Create i18n object for Russian', function() { + i18n = new locale.i18n('ru'); + __ = i18n.__; + }) + + it('Simple', function() { + __('Hello!').should.equal('Привет!'); + }) + + it('Plural', function() { + __(['Message.', 'Messages.'], 1).should.equal('Сообщение.'); + __(['Message.', 'Messages.'], 2).should.equal('Сообщения.'); + }) + + }) + + describe('Placeholders:', function() { + + it('%n', function() { + locale.to(''); + __ = locale.__; + __(['%n message.', '%n messages.'], 1).should.equal('1 message.'); + __(['%n message.', '%n messages.'], 2).should.equal('2 messages.'); + }) + + it('%s with additional args', function() { + __('%s %s!', 'Hello', 'world').should.equal('Hello world!'); + }) + + it('%s with array args', function() { + __('%s %s!', [ 'Hello', 'world' ]).should.equal('Hello world!'); + }) + + it('%s(n) with additional args', function() { + __('%s(1) %s(2)!', 'Hello', 'world').should.equal('Hello world!'); + }) + + it('%s(n) with array args', function() { + __('%s(1) %s(2)!', [ 'Hello', 'world' ]).should.equal('Hello world!'); + }) + + it('%s(key) with map args', function() { + __('%s(one) %s(two)!', { one: 'Hello', two: 'world' }).should.equal('Hello world!'); + }) + + it('mixed %s and %s(n) with additional args', function() { + __('%s(2) %s!', 'world', 'Hello').should.equal('Hello world!'); + }) + + it('mixed %s and %s(n) with array args', function() { + __('%s(2) %s!', [ 'world', 'Hello' ]).should.equal('Hello world!'); + }) + + }) + + describe('Remote translation:', function() { + + it('Turn on remote translation mode', function() { + locale.to(null); + __ = locale.__; + }) + + it('Create object containing remote translation data', function() { + tmp = { + data: 'Some data...', + array: [ 1, 2, 3, 4, 5 ], + msg: __(['%n message.', '%n messages.'], 1) + }; + }) + + it('Translate object', function() { + locale.to(''); + locale.tr(tmp); + tmp.msg.should.equal('1 message.'); + tmp.data.should.equal('Some data...'); + }) + + }) + +}); \ No newline at end of file