diff --git a/bin/browserid b/bin/browserid index 225526ab27a818edbc3f7545e0f216b088c5573f..f3399bb57408478b01731f1f6993375150f544b3 100755 --- a/bin/browserid +++ b/bin/browserid @@ -61,7 +61,7 @@ app.use(i18n.abide({ supported_languages: config.get('supported_languages'), default_lang: config.get('default_lang'), debug_lang: config.get('debug_lang'), - locale_directory: config.get('locale_directory'), + translation_directory: config.get('translation_directory'), disable_locale_check: config.get('disable_locale_check') })); diff --git a/bin/static b/bin/static index 47af95441d6858387a0bfa09918fef5c124bdc52..d9f3894084f03958960848d9ffcc60ec085f09ef 100755 --- a/bin/static +++ b/bin/static @@ -53,7 +53,7 @@ app.use(i18n.abide({ supported_languages: config.get('supported_languages'), default_lang: config.get('default_lang'), debug_lang: config.get('debug_lang'), - locale_directory: config.get('locale_directory'), + translation_directory: config.get('translation_directory'), disable_locale_check: config.get('disable_locale_check') })); diff --git a/config/aws.json b/config/aws.json index c944c2e28d64c4d0f930b044c504fc6e711b6806..336f5ed2ab338b43791738d08bb8ac660e7944cf 100644 --- a/config/aws.json +++ b/config/aws.json @@ -10,9 +10,9 @@ // are inverted and reversed), and en-US. // This set can be overridden by adding more to config.json on the VM. "supported_languages": [ - "en-US" + "en-US", "it-CH" ], - "locale_directory": "/home/app/code/locale", + "debug_lang": "it-CH", "var_path": "/home/app/var", "http_proxy": { diff --git a/lib/configuration.js b/lib/configuration.js index ef4d97f42e2609563a792783337bbc04f2697663..79d7aa72f8d398dd806e1154377eac953ba2a427 100644 --- a/lib/configuration.js +++ b/lib/configuration.js @@ -185,14 +185,18 @@ var conf = module.exports = convict({ debug_lang: 'string = "it-CH"', supported_languages: { doc: "List of languages this deployment should detect and display localized strings.", - format: 'array { string }* = [ "en-US" ]', + format: 'array { string }* = [ "en-US", "it-CH" ]', env: 'SUPPORTED_LANGUAGES' }, disable_locale_check: { doc: "Skip checking for gettext .mo files for supported locales", format: 'boolean = false' }, - locale_directory: 'string = "locale"', + translation_directory: { + doc: "The directory where per-locale .json files containing translations reside", + format: 'string = "resources/static/i18n/"', + env: "TRANSLATION_DIR" + }, express_log_format: 'string [ "default_bid", "dev_bid", "default", "dev", "short", "tiny" ] = "default"', keysigner_url: { format: 'string?', @@ -256,6 +260,11 @@ if (process.env['CONFIG_FILES']) { }); } +// allow supported langauges to be specified in the env as a CSV string +if (process.env['SUPPORTED_LANGUAGES']) { + conf.set('supported_languages', process.env['SUPPORTED_LANGUAGES'].split(',')); +} + // special handling of HTTP_PROXY env var if (process.env['HTTP_PROXY']) { var p = process.env['HTTP_PROXY'].split(':'); diff --git a/lib/i18n.js b/lib/i18n.js index 145365aea0bdfae53db1bc839e17fb568e39bf97..e81eb32982b4f174b17bed1ab68e5b57e0e7269d 100644 --- a/lib/i18n.js +++ b/lib/i18n.js @@ -18,7 +18,8 @@ var logger = require('./logging.js').logger, path = require('path'), util = require('util'), - fs = require('fs'); + fs = require('fs'), + gobbledygook = require('gobbledygook'); // existsSync moved from path in 0.6.x to fs in 0.8.x if (typeof fs.existsSync === 'function') { @@ -27,7 +28,7 @@ if (typeof fs.existsSync === 'function') { var existsSync = path.existsSync; } -const BIDI_RTL_LANGS = ['ar', 'db-LB', 'fa', 'he']; +const BIDI_RTL_LANGS = ['ar', 'fa', 'he']; var translations = {}; @@ -38,33 +39,29 @@ var translations = {}; app.use(i18n.abide({ supported_languages: ['en-US', 'fr', 'pl'], default_lang: 'en-US', - locale_directory: 'locale' })); * * Other valid options: gettext_alias, ngettext_alias */ exports.abide = function (options) { - - if (! options.gettext_alias) options.gettext_alias = 'gettext'; - if (! options.ngettext_alias) options.ngettext_alias = 'ngettext'; - if (! options.supported_languages) options.supported_languages = ['en-US']; - if (! options.default_lang) options.default_lang = 'en-US'; - if (! options.debug_lang) options.debug_lang = 'it-CH'; - if (! options.disable_locale_check) options.disable_locale_check = false; - if (! options.locale_directory) options.locale_directory = 'locale'; - if (! options.i18n_json_dir) options.i18n_json_dir = 'resources/static/i18n/'; + if (! options.gettext_alias) options.gettext_alias = 'gettext'; + if (! options.supported_languages) options.supported_languages = ['en-US']; + if (! options.default_lang) options.default_lang = 'en-US'; + if (! options.debug_lang) options.debug_lang = 'it-CH'; + if (! options.disable_locale_check) options.disable_locale_check = false; + if (! options.translation_directory) options.i18n_json_dir = 'l10n/'; var json_dir = path.resolve( path.join(__dirname, '..'), - path.join(options.i18n_json_dir)); + path.join(options.translation_directory)); var debug_locale = localeFrom(options.debug_lang); options.supported_languages.forEach(function (lang, i) { - var l = (lang === options.debug_lang ? 'db_LB' : localeFrom(lang)); + // ignore .json files for default and debug languages + if (options.default_lang == lang || options.debug_lang == lang) return; - // ignore .json files for en-US - if (lang == 'en-US') return; + var l = localeFrom(lang); try { // populate the in-memory translation cache with client.json, which contains @@ -101,14 +98,10 @@ exports.abide = function (options) { debug_lang = options.debug_lang.toLowerCase(), locale; - if (lang && lang.toLowerCase && lang.toLowerCase() == debug_lang) { - lang = 'db-LB'; // What? http://www.youtube.com/watch?v=rJLnGjhPT1Q - } - resp.local('lang', lang); // BIDI support, which direction does text flow? - lang_dir = BIDI_RTL_LANGS.indexOf(lang) >= 0 ? 'rtl' : 'ltr'; + lang_dir = ((BIDI_RTL_LANGS.indexOf(lang) >= 0) || debug_lang == lang) ? 'rtl' : 'ltr'; resp.local('lang_dir', lang_dir); req.lang = lang; @@ -122,9 +115,15 @@ exports.abide = function (options) { var gt; - if (translations[locale]) { + if (lang.toLowerCase() === debug_lang) { + gt = gobbledygook; + resp.local('lang', 'db-LB'); + } else if (translations[locale]) { gt = function(sid) { - return (translations[locale][sid] ? translations[locale][sid][1] : sid); + if (translations[locale][sid] && translations[locale][sid][1].length) { + sid = translations[locale][sid][1]; + } + return sid; }; } else { gt = function(a) { return a; } diff --git a/lib/static/views.js b/lib/static/views.js index 957bfb09c92d0b3d1e6ec25d0392c9222d6f2530..0d96e7e0894f2c91965087748cd28c6569e64a20 100644 --- a/lib/static/views.js +++ b/lib/static/views.js @@ -197,6 +197,15 @@ exports.setup = function(app) { app.get(/^\/test\/(?:index.html)?$/, function (req, res) { res.render('test.ejs', {title: 'Mozilla Persona QUnit Test', layout: false}); }); + + // l10n test template + var testPath = path.join(__dirname, '..', '..', 'tests', 'i18n_test_templates'); + app.get('/i18n_test', function(req, res) { + renderCachableView(req, res, path.join(testPath, 'i18n_test.ejs'), { layout: false, title: 'l10n testing title' }); + }); + app.get('/i18n_fallback_test', function(req, res) { + renderCachableView(req, res, path.join(testPath, 'i18n_fallback_test.ejs'), { layout: false, title: 'l10n testing title' }); + }); } else { // this is stage or production, explicitly disable all resources under /test app.get(/^\/test/, function(req, res) { diff --git a/lib/static_resources.js b/lib/static_resources.js index 31cf4e08e158a5cbb870a13daeec2bb4138b2b2e..87575a1375eea7c4f0ab9aace005ce4f8df9f019 100644 --- a/lib/static_resources.js +++ b/lib/static_resources.js @@ -23,9 +23,9 @@ var common_js = [ '/common/js/lib/ejs.js', '/common/js/lib/micrajax.js', '/common/js/lib/urlparse.js', + '/common/js/lib/gobbledygook.js', '/common/js/javascript-extensions.js', '/i18n/:locale/client.json', - '/common/js/gettext.js', '/common/js/browserid.js', '/common/js/lib/hub.js', '/common/js/lib/dom-jquery.js', @@ -39,6 +39,7 @@ var common_js = [ '/common/js/validation.js', '/common/js/helpers.js', '/common/js/dom-helpers.js', + '/common/js/gettext.js', '/common/js/screens.js', '/common/js/browser-support.js', '/common/js/enable_cookies_url.js', diff --git a/lib/wsapi.js b/lib/wsapi.js index cabedf038c27df25dc4dcb4f6b0eb765b8f8e8a7..7cee435e2afba35e628923bb3a1c2583447eca40 100644 --- a/lib/wsapi.js +++ b/lib/wsapi.js @@ -34,7 +34,7 @@ i18n = require('./i18n'); var abide = i18n.abide({ supported_languages: config.get('supported_languages'), default_lang: config.get('default_lang'), - locale_directory: config.get('locale_directory'), + translation_directory: config.get('translation_directory'), disable_locale_check: config.get('disable_locale_check') }); diff --git a/package.json b/package.json index 2d1810ee6348b898996d666ebd354ee317e9d2cd..fda87e86ad8085eed862b99af52c51f1902a6442 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "ejs": "0.4.3", "etagify": "0.0.2", "express": "2.5.0", + "gobbledygook": "0.0.3", "mustache": "0.3.1-dev", "jwcrypto": "0.3.2", "mysql": "0.9.5", diff --git a/resources/static/common/js/gettext.js b/resources/static/common/js/gettext.js index a5d3413949a8c464599e7114cad10564d8144a19..a8794883efadfdd829b5e79df8a6c2d29c0d88da 100644 --- a/resources/static/common/js/gettext.js +++ b/resources/static/common/js/gettext.js @@ -6,17 +6,25 @@ (function() { "use strict"; + var bid = BrowserID, + dom = bid.DOM; + function Gettext() { return { gettext: function (msgid) { + if (window.Gobbledygook && + dom.getAttr('html', 'lang') === 'db-LB') { + return window.Gobbledygook(msgid); + } + if (window.json_locale_data && json_locale_data["client"]) { - var dict = json_locale_data["client"]; + var dict = json_locale_data["client"]; if (dict[msgid] && dict[msgid].length >= 2 && dict[msgid][1].trim() != "") { return dict[msgid][1]; } - } - return msgid; + } + return msgid; }, // See lib/i18n.js format docs format: function (fmt, obj, named) { diff --git a/resources/static/common/js/lib/gobbledygook.js b/resources/static/common/js/lib/gobbledygook.js new file mode 120000 index 0000000000000000000000000000000000000000..1403b642aef4fd46ccca2f5868826d9027e9f02f --- /dev/null +++ b/resources/static/common/js/lib/gobbledygook.js @@ -0,0 +1 @@ +../../../../../node_modules/gobbledygook/gobbledygook.js \ No newline at end of file diff --git a/resources/views/test.ejs b/resources/views/test.ejs index 6cdbd8907f1af81db029b244cf4a28f1f33c3139..7573f5a20438dbd4d3c88276db03c900efa14ed7 100644 --- a/resources/views/test.ejs +++ b/resources/views/test.ejs @@ -69,12 +69,12 @@ <script src="/common/js/lib/jquery-1.7.1.min.js"></script> <script src="/common/js/lib/underscore.js"></script> <script src="/common/js/lib/ejs.js"></script> - <script src="/i18n/en_US/client.json"></script> + <script src="/common/js/lib/gobbledygook.js"></script> <script src="/common/js/javascript-extensions.js"></script> - <script src="/common/js/gettext.js"></script> <script src="/common/js/lib/bidbundle.js"></script> <script src="http://testmob.org/include.js"></script> <script src="/common/js/browserid.js"></script> + <script src="/common/js/gettext.js"></script> <script src="/common/js/lib/dom-jquery.js"></script> <script src="/common/js/lib/hub.js"></script> <script src="/common/js/lib/module.js"></script> diff --git a/tests/i18n-tests.js b/tests/i18n-tests.js index 88287ec5d0ebed4d9fb3ec04b7a8134999a6ccfe..b2abd422e1db1b2f7b9546155da1a4a1615f4caa 100755 --- a/tests/i18n-tests.js +++ b/tests/i18n-tests.js @@ -8,16 +8,22 @@ require('./lib/test_env.js'); const assert = require('assert'), vows = require('vows'), - i18n = require('../lib/i18n'); + i18n = require('../lib/i18n'), + start_stop = require('./lib/start-stop.js'), + wsapi = require('./lib/wsapi.js'), + http = require('http'), + path = require('path'); var suite = vows.describe('i18n'); +suite.options.error = false; + suite.addBatch({ "format a string with place values": { topic: function () { return i18n.format("%s %s!", ["Hello", "World"]); }, - "was interpolated": function (err, str) { + "was interpolated": function (str) { assert.equal(str, "Hello World!"); } } @@ -29,7 +35,7 @@ suite.addBatch({ var params = { salutation: "Hello", place: "World" }; return i18n.format("%(salutation) %(place)!", params); }, - "was interpolated": function (err, str) { + "was interpolated": function (str) { assert.equal(str, "Hello World!"); } } @@ -40,7 +46,7 @@ suite.addBatch({ topic: function () { return i18n.format("Hello World!"); }, - "was interpolated": function (err, str) { + "was interpolated": function (str) { assert.equal(str, "Hello World!"); } }, @@ -48,7 +54,7 @@ suite.addBatch({ topic: function () { return i18n.format(null); }, - "was interpolated": function (err, str) { + "was interpolated": function (str) { assert.equal(str, ""); } } @@ -64,7 +70,7 @@ suite.addBatch({ i18n.parseAcceptLanguage(accept), supported, def); }, - "For Punjabi": function (err, locale) { + "For Punjabi": function (locale) { assert.equal(locale, "pa"); } }, @@ -77,7 +83,7 @@ suite.addBatch({ i18n.parseAcceptLanguage(accept), supported, def); }, - "For Punjabi (India) serve Punjabi": function (err, locale) { + "For Punjabi (India) serve Punjabi": function (locale) { assert.equal(locale, "pa"); } }, @@ -90,12 +96,97 @@ suite.addBatch({ i18n.parseAcceptLanguage(accept), supported, def); }, - "Don't choose Punjabi (India)": function (err, locale) { + "Don't choose Punjabi (India)": function (locale) { assert.equal(locale, "en-us"); } } }); +// point to test translation files +process.env['TRANSLATION_DIR'] = path.join(__dirname, "i18n_test_files"); + +// supported languages for the purposes of this test +process.env['SUPPORTED_LANGUAGES'] = 'en,bg,it-CH'; + +// now let's start up our servers +start_stop.addStartupBatches(suite); + +function getTestTemplate(langs, tp) { + tp = tp || '/i18n_test'; + return function() { + var self = this; + var req = http.request({ + host: '127.0.0.1', + port: 10002, + path: tp, + method: "GET", + headers: { 'Accept-Language': langs } + }, function (res) { + var body = ""; + res.on('data', function(chunk) { body += chunk; }) + .on('end', function() { + self.callback(null, { code: res.statusCode, body: body }); + }); + }).on('error', function (e) { + self.callback(e); + }); + req.end(); + }; +} + +suite.addBatch({ + // test default language + "test template with no headers": { + topic: getTestTemplate(undefined), + "returns english" : function(err, r) { + assert.strictEqual(r.code, 200); + assert.strictEqual( + r.body.trim(), + 'This is a translation <strong>test</strong> string.'); + } + }, + // test un-supported case + "test template with german headers": { + topic: getTestTemplate('de'), + "returns english" : function(err, r) { + assert.strictEqual(200, r.code); + assert.strictEqual( + r.body.trim(), + 'This is a translation <strong>test</strong> string.'); + } + }, + // test debug translation + "test template with debug headers": { + topic: getTestTemplate('it-CH'), + "returns gobbledygook" : function(err, r) { + assert.strictEqual(200, r.code); + assert.strictEqual( + r.body.trim(), + '.ƃuıɹʇs <strong>ʇsÇʇ</strong> uoıʇaÊ…suaɹʇ a sı sıɥ⊥'); + } + }, + // test .json extraction + "bulgarian accept headers": { + topic: getTestTemplate('bg'), + "return a translation extacted from .json file" : function(err, r) { + assert.strictEqual(200, r.code); + assert.strictEqual(r.body.trim(), "Прова? Прова? Четери, пет, шещ?"); + } + }, + // test .json extraction fallback when translation is the empty string + "bulgarian accept headers without a translation": { + topic: getTestTemplate('bg', '/i18n_fallback_test'), + "return a non-translated string" : function(err, r) { + assert.strictEqual(200, r.code); + assert.strictEqual(r.body.trim(), "This is not translated"); + } + } + +}); + +// and let's stop them servers +start_stop.addShutdownBatches(suite); + // run or export the suite. if (process.argv[1] === __filename) suite.run(); else suite.export(module); diff --git a/tests/i18n_test_files/bg/client.json b/tests/i18n_test_files/bg/client.json new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/i18n_test_files/bg/messages.json b/tests/i18n_test_files/bg/messages.json new file mode 100644 index 0000000000000000000000000000000000000000..95da66000168b39db20e0979b294af25f8f7c348 --- /dev/null +++ b/tests/i18n_test_files/bg/messages.json @@ -0,0 +1,12 @@ +var json_locale_data = { + messages: { + "This is a translation <strong>test</strong> string.": [ + null, + "Прова? Прова? Четери, пет, шещ?" + ], + "This is not translated": [ + null, + "" + ] + } +}; diff --git a/tests/i18n_test_templates/i18n_fallback_test.ejs b/tests/i18n_test_templates/i18n_fallback_test.ejs new file mode 100644 index 0000000000000000000000000000000000000000..10015bc61850a24da9f0489395444642f581b833 --- /dev/null +++ b/tests/i18n_test_templates/i18n_fallback_test.ejs @@ -0,0 +1 @@ +<%- gettext("This is not translated") %> diff --git a/tests/i18n_test_templates/i18n_test.ejs b/tests/i18n_test_templates/i18n_test.ejs new file mode 100644 index 0000000000000000000000000000000000000000..fcec0740e9970d17cebf07224cd7bf4d1918bd47 --- /dev/null +++ b/tests/i18n_test_templates/i18n_test.ejs @@ -0,0 +1 @@ +<%- gettext("This is a translation <strong>test</strong> string.") %> diff --git a/tests/static-resource-test.js b/tests/static-resource-test.js index 76307a47acec4dcc7023601ac53bdff5055bbc8c..8a91b133109db90b3c8a4414c105a5ddd234b66d 100755 --- a/tests/static-resource-test.js +++ b/tests/static-resource-test.js @@ -57,7 +57,7 @@ suite.addBatch({ }); // Fragile - filename with :locale... // When fixing this test case... console.log(res[Object.keys(res)[0]]); - var localeIndex = 8; + var localeIndex = 9; assert.notEqual(files[minFile][localeIndex], res[minRes][localeIndex]); var counter = 0;