UNPKG

i18n-abide

Version:

Express/connect module for Node i18n and l10n support

426 lines (376 loc) 13.6 kB
/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /* * i18n-abide * * This module abides by the user's language preferences and makes it * available throughout the app. * * This module abides by the Mozilla L10n way of doing things. * * The module abides. * * See docs/I18N.md for details. */ var fs = require('fs'), gobbledygook = require('gobbledygook'), path = require('path'), util = require('util'), plist = require('plist'); const DAVID_B_LABYRN = 'db-LB'; const BIDI_RTL_LANGS = ['ar', DAVID_B_LABYRN, 'fa', 'he']; // Number of characters before and after valid JSON in messages.json files const JS_PRE_LEN = 24; const JS_POST_LEN = 3; var translations = {}; var logger; // forward references var localeFrom, parseAcceptLanguage, bestLanguage, format, languageFrom, normalizeLocale; /** * Connect middleware which is i18n aware. * * Usage: app.use(i18n.abide({ supported_languages: ['en-US', 'fr', 'pl'], default_lang: 'en-US', translation_directory: 'locale' })); * * Other valid options: gettext_alias, debug_lang, disable_locale_check, * translation_type, locale_on_url, or logger */ exports.abide = function(options) { options = options || {}; 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.translation_directory = 'l18n/'; if (! options.logger) options.logger = console; // We expect to use PO->json files, unless configured to use another format. options.translation_type = options.translation_type || 'po'; // Only check URL for locale if told to do so. options.locale_on_url = options.locale_on_url === true; logger = options.logger; function messages_file_path(locale) { // check if the option is 'key-value-json' if so the file format would be 'json' instead var file_format = options.translation_type === 'plist' ? 'plist' : 'json'; var filename = 'messages.' + file_format; return path.resolve(path.join(__dirname, '..', '..', '..'), options.translation_directory, path.join(locale, filename)); } function parse_messages_file(locale) { var filepath = messages_file_path(locale); if (options.translation_type === 'plist') { return plist.parseFileSync(filepath); } else if (options.translation_type === 'key-value-json') { return require(filepath); } // PO->json file else { var rawMessages = fs.readFileSync(filepath).toString(); return JSON.parse(rawMessages).messages; } } options.supported_languages.forEach(function(lang) { var l = localeFrom(lang); // populate the in-memory translation cache with client.json, which contains // strings relevant on the server try { translations[l] = parse_messages_file(l); } catch (e) { // an exception here means that there was a problem with the translation // files for this locale! if (options.default_lang === lang || options.debug_lang === lang) return; var msg = util.format( 'Bad locale=[%s] missing .%s files in [%s]. See locale/README (%s)', l, options.translation_type, messages_file_path(l), e); if (!options.disable_locale_check) { logger.warn(msg); } else { logger.error(msg); throw msg; } } }); function checkUrlLocale(req) { if (!options.locale_on_url) { return; } // Given a URL, http://foo.com/ab/xyz/, we check to see if the first directory // is actually a locale we know about, and if so, we strip it out of the URL // (i.e., URL becomes http://foo.com/xyz/) and store that locale info on the // request's accept-header. var matches = req.url.match(/^\/([^\/]+)(\/|$)/); if (!(matches && matches[1])) { return; } // Look for a lang we know about, and if found, strip it off the URL so routes // continue to work. If we don't find it (i.e., comes back "unknown") then bail. // We do this so that we don't falsely consume more of the URL than we should // and stip things that aren't actually locales we know about. var lang = bestLanguage(parseAcceptLanguage(matches[1]), options.supported_languages, "unknown"); if (lang === "unknown") { return; } req.url = req.url.replace(matches[0], '/'); req.headers['accept-language'] = lang; } return function(req, resp, next) { checkUrlLocale(req); var langs = parseAcceptLanguage(req.headers['accept-language']), lang_dir, lang = bestLanguage(langs, options.supported_languages, options.default_lang), debug_lang = options.debug_lang.toLowerCase(), locale, locals = {}, gt; if (lang && lang.toLowerCase && lang.toLowerCase() === debug_lang) { // What? http://www.youtube.com/watch?v=rJLnGjhPT1Q lang = DAVID_B_LABYRN; } // Express 2 support if (!! resp.local) { resp.locals = function(args, orValue) { if ('string' === typeof args) { resp.local(args, orValue); } else { Object.keys(args).forEach(function(key) { resp.local(key, args[key]); }); } }; } locals.lang = lang; // BIDI support, which direction does text flow? lang_dir = BIDI_RTL_LANGS.indexOf(lang) >= 0 ? 'rtl' : 'ltr'; locals.lang_dir = lang_dir; req.lang = lang; locale = localeFrom(lang); locals.locale = locale; req.locale = locale; var formatFnName = 'format'; if (!! locals.format || !! req.format) { if (!! options.format_fn_name) { formatFnName = options.format_fn_name; } else { console.error("It appears you are using middleware which " + "already sets a variable 'format' on either the request " + "or reponse. Please use format_fn_name in options to " + "override this setting."); throw new Error("Bad Config - override format_fn_name"); } } locals[formatFnName] = format; req[formatFnName] = format; locals.setLocale = function(assignedLocale) { var assignedLang = languageFrom(assignedLocale); if (translations[assignedLocale] || assignedLang === options.default_lang) { locale = assignedLocale; var newLocals = {}; newLocals.locale = assignedLocale; req.locale = assignedLocale; newLocals.lang = assignedLang; req.lang = newLocals.lang; newLocals.lang_dir = BIDI_RTL_LANGS.indexOf(newLocals.lang) >= 0 ? 'rtl' : 'ltr'; req.lang_dir = newLocals.lang_dir; if (typeof resp.locals === 'function') { resp.locals(newLocals); } else { Object.keys(newLocals).forEach(function(k) { resp.locals[k] = newLocals[k]; }); } } }; req.setLocale = locals.setLocale; if (lang.toLowerCase() === DAVID_B_LABYRN.toLowerCase()) { gt = gobbledygook; locals.lang = DAVID_B_LABYRN; } else { gt = function(sid) { // default lang in a non gettext environment... fake it if (!translations[locale]) { return sid; } // The plist and PO->json files are in a slightly different format. if (options.translation_type === 'plist' || options.translation_type === 'key-value-json') { if (translations[locale][sid] && translations[locale][sid].length) { return translations[locale][sid]; } else { // Return the default lang's string if missing in the translation. return (translations[options.default_lang][sid] || sid); } } // PO->json file else { if (translations[locale][sid] && translations[locale][sid][1].length) { return translations[locale][sid][1]; } } return sid; }; } locals[options.gettext_alias] = gt; req.gettext = gt; // resp.locals(string, value) doesn't seem to work with EJS // issue #68; check if resp.locals is a function or object resp. express 3 and express 4 typeof resp.locals === 'function' ? resp.locals(locals) : resp.locals = locals; next(); }; }; function qualityCmp(a, b) { if (a.quality === b.quality) { return 0; } else if (a.quality < b.quality) { return 1; } else { return -1; } } /** * Parses the HTTP accept-language header and returns a * sorted array of objects. Example object: * { * lang: 'pl', quality: 0.7 * } */ exports.parseAcceptLanguage = parseAcceptLanguage = function(header) { // pl,fr-FR;q=0.3,en-US;q=0.1 if (! header || ! header.split) { return []; } var raw_langs = header.split(','); var langs = raw_langs.map(function(raw_lang) { var parts = raw_lang.split(';'); var q = 1; if (parts.length > 1 && parts[1].indexOf('q=') === 0) { var qval = parseFloat(parts[1].split('=')[1]); if (isNaN(qval) === false) { q = qval; } } return { lang: parts[0].trim(), quality: q }; }); langs.sort(qualityCmp); return langs; }; // Given the user's prefered languages and a list of currently // supported languages, returns the best match or a default language. // // languages must be a sorted list, the first match is returned. exports.bestLanguage = bestLanguage = function(languages, supported_languages, defaultLanguage) { var lower = supported_languages.map(function(l) { return l.toLowerCase(); }); for(var i=0; i < languages.length; i++) { var lq = languages[i]; if (lower.indexOf(lq.lang.toLowerCase()) !== -1) { return lq.lang; // Issue#1128 match locale, even if region isn't supported } else if (lower.indexOf(lq.lang.split('-')[0].toLowerCase()) !== -1) { return lq.lang.split('-')[0]; } } return defaultLanguage; }; /** * Given a language code, return a locale code the OS understands. * * language: en-US * locale: en_US */ exports.localeFrom = localeFrom = function(language) { if (! language || ! language.split) { return ""; } var parts = language.split('-'); var pt2; if (parts.length === 1) { return parts[0].toLowerCase(); } else if (parts.length === 2) { pt2 = parts[1]; pt2 = (pt2.length > 2) ? pt2[0].toUpperCase() + pt2.slice(1).toLowerCase() : pt2.toUpperCase(); return util.format('%s_%s', parts[0].toLowerCase(), pt2); } else if (parts.length === 3) { // sr-Cyrl-RS should be sr_RS return util.format('%s_%s', parts[0].toLowerCase(), parts[2].toUpperCase()); } else { logger.error( util.format("Unable to map a local from language code [%s]", language)); return language; } }; /** * Given a locale code, return a language code */ exports.languageFrom = languageFrom = function(locale) { if (!locale || !locale.split) { return ""; } var parts = locale.split('_'); var pt2; if (parts.length === 1) { return parts[0].toLowerCase(); } else if (parts.length === 2) { pt2 = parts[1]; pt2 = (pt2.length > 2) ? pt2[0].toUpperCase() + pt2.slice(1).toLowerCase() : pt2.toUpperCase(); return util.format('%s-%s', parts[0].toLowerCase(), pt2); } else if (parts.length === 3) { // sr_RS should be sr-RS return util.format('%s-%s', parts[0].toLowerCase(), parts[2].toUpperCase()); } else { logger.error( util.format("Unable to map a language from locale code [%s]", locale)); return locale; } }; /** * Given a language code, return a normalized language code. * @param {String} language A language code. For example, 'en-us'. * @return {String} A normalized language code, such as 'en-US'. */ exports.normalizeLanguage = normalizeLanguage = function (language) { return languageFrom(localeFrom(language)); }; /** * Given a locale code, return a normalized locale code. * @param {String} locale A locale code. For example, 'en_us'. * @return {String} A normalized locale code, such as 'en_US'. */ exports.normalizeLocale = normalizeLocale = function (locale) { return localeFrom(languageFrom(locale)); }; /** * format provides string interpolation on the client and server side. * It can be used with either an object for named variables, or an array * of values for positional replacement. * * Named Example: * format("%(salutation)s %(place)s", {salutation: "Hello", place: "World"}, true); * Positional Example: * format("%s %s", ["Hello", "World"]); */ exports.format = format = function(fmt, obj, named) { if (!fmt) return ""; if (Array.isArray(obj) || named === false) { return fmt.replace(/%s/g, function(){return String(obj.shift());}); } else if (typeof obj === 'object' || named === true) { return fmt.replace(/%\(\s*([^)]+)\s*\)s/g, function(m, v){ return String(obj[v.trim()]); }); } else { return fmt; } }; /** * Returns the list of translations abide is currently configured to support. */ exports.getLocales = function() { return Object.keys(translations); };