easyi18n
Version:
Easy to use internationalisation (i18n): translate and localise your website or app. Uniquely, this does not require any refactoring of your code to work.
514 lines (480 loc) • 15.9 kB
JavaScript
'use strict';
/**
* i18n.js A simple flexible Javascript internationalisation system
*
* Author: Daniel Winterstein
* Copyright: Winterwell http://winterwell.com
* Requires: jQuery, and SJTest (optional but recommended) for synchronous ajax loading.
* License: MIT (a commercially friendly open source license)
*/
/**
* @class I18N
*
* @param lang {string} - Two-character ISO639 language code of the destination language,
* or a language_region locale code (e.g. "en_US"),
* or a custom value for special languages (eg 'lolcat', or 'user-defined')
*
* @param data {?string} - Contents of translation csv file for `lang`,
* OR a url to load a translation csv file.
* OR an app-tag (obtained from the i18njs portal; begins with a #) to load from the i18njs portal (if you have an account).
* Loading is done synchronously (it will block), using jQuery.
*
* @param appTag {?string} Tag to report translation misses to the i18njs portal (if you have an account).
* If an appTag is provided for the data parameter (see above), then there is no need to repeat it here.
* appTags must begin with a #
*
* @param local {boolean} Use the local server for this I18N rather than i18n.soda.sh.
* Defaults to using i18n.soda.sh if absent.
**/
function I18N(lang, data, appTag, local) {
this.verbose = false;
this.version = "0.2.4";
/** Two-character ISO639 language code of the destination language,
* or a custom value for special languages (eg 'lolcat', or 'user-defined') */
this.lang = lang;
/**
* {string} Used for reporting untranslatable items.
* @see I18N.onfail()
*/
this.appTag = appTag ? appTag : false;
this.urlPrefix = local ? '' : 'https://i18n.soda.sh';
/**
* Record failed translations, so we only report them once.
* {string: boolean} but set to {string} "!" if it gets too big.
*/
this.fails = {};
/**
* Format dates. By default uses Date.toLocaleString(), which uses the browser's locale setting.
* Users can replace this with their own function -- or with false to switch off.
* @param date {Date}
* @returns {string}
*/
this.dateFormat = function (date) {
// TODO Maybe pass a locale in, taken from this.lang? Newer browsers will support it.
return date.toLocaleString();
};
/**
* Format numbers. By default does nothing.
* Users can replace this with their own function -- or with false to switch off.
* @param num {number}
* @returns {string}
*/
this.numberFormat = function (num) {
return num.toString();
};
/**
* {boolean} Is it safe to use this I18N object?
*/
this.loaded = true; // may be reset to false by ajax call below
this.en2lang = {};
this.active(true);
// Load data?
if (!data) {
return;
}
// Is the file more than one word? Then treat it as the input
if (!data.match(/^\S+$/)) {
this._parseFile(data);
return;
}
// Treat file as a url.
// Is it an i18njs app-tag? Then load from the portal
if (data.charAt(0) === '#') {
// Portal resource
if (!this.appTag) this.appTag = data;
// Guess the language? This isn't reliable but it's a sensible fallback.
if (!this.lang) {
var _lang = I18N.getBrowserLanguage();
// But don't guess English, as that's probably the original
if (_lang !== 'en') this.lang = _lang;
// Don't load null
if (!this.lang) return;
}
data = this.urlPrefix + '/i18n-trans.csv?tag=' + escape(data) + '&lang=' + escape(this.lang);
}
try {
this._loadFile(data);
} catch (err) {
/* Swallow file-load errors! That way you still get an I18N object */
console.error(err);
}
}
/**
* Automatically called when an I18N object is made (so the most recently made is the active one).
* You can also call it explicitly to swap between objects.
* @param on {?boolean} Set this to be active (or not).
* @returns true if this is active
*/
I18N.prototype.active = function (on) {
/**
* {I18N} The most recently made (or activated) I18N object. This will be used as a default by the jQuery plugin.
*/
if (on) I18N.active = this;else if (on !== undefined && this === I18N.active) {
I18N.active = null;
}
return this === I18N.active;
};
/**
* Convenient static access to a global I18N
*/
I18N.tr = function (original) {
if (!I18N.active) new I18N();
return I18N.active.tr(original);
};
I18N._MARKERCHAR = "␚";
/**
* Add a translation to the dictionary.
*
* @param original {string}
* @param translation {string}
* @param type {?object} Plural or gender for categorise() based advanced multiple-choice translations.
*/
I18N.prototype.add = function (original, translation, type) {
// TODO unescape tab, \r\n and #?
var vars = [],
tvars = [];
var key = this.canon(original, vars);
var meaning = this.canon(translation, vars, true);
// Check for multiple translations, keep multiple translations
var old = this.en2lang[key];
if (old && old !== meaning) {
if (typeof old === 'string') {
old = [old];
}
old.push([original, meaning, type]);
this.en2lang[key] = old;
} else {
// normal case
this.en2lang[key] = meaning;
}
};
/**
* @param file {string} csv text, tab separated, # to comment out lines
* 1st-column: original, 2nd-column: Translation, 3rd or more: ignored (can use for comments)
* @private
*/
I18N.prototype._parseFile = function (file) {
var lines = file.split(/[\r\n]/);
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
// skip blank lines & comments
if (!line || line.charAt(0) == '#') continue;
var bits = line.split("\t");
if (bits.length < 2) continue;
this.add(bits[0], bits[1]);
// bits[2], if present, is just a comment
}
console.log("I18N", "loaded", this);
};
/**
* Do a synchronous load of a csv file
* @param data {string} The url
*/
I18N.prototype._loadFile = function (data) {
this.file = data;
var req = {
async: false,
cache: true
};
// Is it a cross-domain fetch? Probably yes
// var i = data.indexOf('//');
// var hostname = window.location? window.location : '';
// var hn = data.substring(i+2, i+2+hostname.length);
// if (true || i === -1 || (hostname && hn === hostname)) {
// // Our server :)
// } else {
// // jsonp with caching?? TODO Does CORS work to allow cross-domain?? try-catch??
// req.jsonpCallback='_i18nCallback';
// req.dataType='jsonp';
// console.log('I18N', 'Using asynchronous loading: The race is on (this is bad, and may produce unpredictable results). Please add SJTest.js for safer loading.');
// }
// Fetch it
this.loaded = false;
$.ajax(data, req).done(function (result) {
this._parseFile(result);
}.bind(this)).always(function () {
this.loaded = true;
}.bind(this));
};
/**
* @param english {string} Original text (often English)
*/
I18N.prototype.tr = function (english) {
var vars = [],
key = this.canon(english, vars),
trans = this.en2lang[key];
// multiple translations?
if (trans && typeof trans !== 'string') {
trans = this._tr2_multi(english, vars, trans);
}
if (trans) {
return this.uncanon(trans, vars);
}
// fail -- Log it to the backend for translators to work on
if (this.loaded && english) {
if (this.lang) this.onfail(english, this.lang, key);
}
// Remove {}s and (s)
var _english = this.uncanon(key, vars);
return _english;
};
/**
* @param english {string} Raw-form to translate
* @param vars {array} From canon()
* @returns {string} translation to use
* @private
*/
I18N.prototype._tr2_multi = function (english, vars, trans) {
// exact match?
for (var j = 1; j < trans.length; j++) {
if (english === trans[j][0]) {
return trans[j][1];
}
}
// typed match?
var category = {};
for (var vi = 0; vi < vars.length; vi++) {
var cati = this.categorise(vars[vi]);
if (cati) {
for (p in cati) {
category[p] = cati[p];
}
}
}
if (category === {}) {
return trans[0];
}
for (var j = 1; j < trans.length; j++) {
var catj = trans[j][2];
if (!catj) continue;
var ok = true;
for (p in catj) {
if (category[p] !== catj[p]) {
ok = false;break;
}
}
if (ok) return trans[j][1];
}
// just use the first
return trans[0];
};
/**
* Is this plural or singular? Male or female?
* @param {string} v - Variable value (probably a word or a number) to analyse;
* @returns {object}
*/
I18N.prototype.categorise = function (v) {
return false;
};
/**
* Called when we can't translate a phrase.
* The default version is for a SoDash backend -- replace it with your own logging call!
* Note: This will skip repeats, too-long texts (max:1000 characters), and it stops logging after 1000 fails.
* @param english {string} The original text.
* @param lang {string} The language we're translating to.
* @param key {string} The internal lookup key, as produced by canon(). Useful if debugging corner cases.
*/
I18N.prototype.onfail = function (english, lang, key) {
if (this.fails === "!") return;
// ignore empty tags
try {
var $en = $.parseHTML(english);
if ($en.length > 0 && !$en.text()) {
return;
}
} catch (ohwell) {}
// Don't log giant blocks of text. Test on key, to be lenient towards tags (which can get bloated).
if (key.length > 1000) return;
// Only log a fail once!
if (this.fails[key]) return;
// Too many fails for one page to log?
var size = 0;
for (f in this.fails) {
size++;
} // NB: this will count a few bits of prototype gumpf, but it doesn't matter.
if (size > 1000) {
console.warn("I18N", "Switching off fail logging (" + lang + ")");
this.fails = "!";
return;
}
// NB: Memory paranoia: cap the size of fails -- Not needed --we stopped logging long ago. if (size>10000) this.fails = {};
// Mark it as logged.
this.fails[key] = true;
if (this.verbose) console.warn("I18N", "fail (" + lang + "): " + english + " (internal key: " + key + ")");
if (!this.appTag) return;
// canon the whitespace (but not variables, etc)
english = english.replace(/\s+/g, ' ');
// Send a cross-domain ping
$.ajax({
url: this.urlPrefix + '/lg.json',
dataType: 'jsonp',
data: {
tag: this.appTag,
msg: lang + "\t" + english
}
});
};
I18N.NUMBER = /[0-9,]+(\.\d+)?/g;
/**
* numbers, emails, html tags -- keep them untranslated
*/
I18N.KEEPME = new RegExp(I18N.NUMBER.source + "|\{.*?\}|\b\S+@[a-zA-Z\.]+|<\/?[a-z][a-zA-Z0-9]*[^>]*?>", 'g');
/**
* Convert into a canonical form for internal lookup.
* @param varCatcher {array}, which will collect the raw versions of "variables", for uncanon to put back.
* TODO OR the output from a previous canon(original), used to establish place-marker ordering in canon(translation).
* @param varOrder {?boolean} If true, varCatcher is interpreted as the output from a previous canon().
* @returns The "canonical" form -- with variable markers, standardised whitespace, etc.
*/
I18N.prototype.canon = function (english, varCatcher, varOrder) {
if (!english) return english;
if (varCatcher === undefined) varCatcher = [];
// Replace untranslated stuff with markers: numbers, {wrapped}, emails, html tags
var _canon = english.replace(I18N.KEEPME, function (m) {
if (!varOrder) {
var vi = varCatcher.length;
varCatcher.push(m);
return I18N._MARKERCHAR + vi; // Mark the place
}
// Which marker?
var vi = varCatcher.indexOf(m);
if (vi == -1) {
return m; // A new var-like thing. Leave it alone.
}
return I18N._MARKERCHAR + vi;
});
// standardise whitespace as " "
// TODO trim -- but we should preserve leading/trailing whitespace to avoid wordsbeingstucktogether.
_canon = _canon.replace(/\s+/g, ' ');
return _canon;
};
/**
* Inverse of canon. Sort of.
* @param canon The output from canon
* @param vars The varCatcher array from canon.
*/
I18N.prototype.uncanon = function (canon, vars) {
if (!canon) return canon;
var uncanon = canon;
// (s) -- done before vars are put in, as they shouldnt be edited.
if (vars.length != 0) uncanon = this._uncanon2_pluralise(canon, vars);
// vars
for (var vi = 0; vi < vars.length; vi++) {
var v = vars[vi];
// Convert Dates and numbers
v = this._uncanon2_convert(v);
// Insert v back into the string
uncanon = uncanon.replace(I18N._MARKERCHAR + vi, v);
}
return uncanon;
};
/**
* @param v {string}
* @returns formatted version of v, e.g. numbers are run through numberFormat()
*/
I18N.prototype._uncanon2_convert = function (v) {
// TODO Maybe move the is number/date tests into key-storage (using different marker-chars), for some repeated-use efficiency.
// ...Is it a number?
if (this.numberFormat) {
var n = Number(v);
if (!isNaN(n)) return this.numberFormat(v);
}
// ...Is it a date?
if (this.dateFormat) {
var d = Date(v);
if (!isNaN(d.valueOf())) {
return this.dateFormat(d);
}
}
// Remove wrapping {}s if present
if (v.length > 1 && v.charAt(0) == '{' && v.charAt(v.length - 1) == '}') {
v = v.substring(1, v.length - 1);
}
return v;
};
/**
* Convert (s) endings into s or ""
* @param text {string} e.g. "$0 monkey(s)"
* @param vars Placeholder values, e.g. [2] The first numerical var determines whether to pluralise or not
* @returns {string} e.g. "2 monkeys"
* @private
*/
I18N.prototype._uncanon2_pluralise = function (text, vars) {
// ??we'd get a small efficiency boost if we cached whether a key requires plural handling
var isPlural = null;
for (var vi = 0; vi < vars.length; vi++) {
var vs = '' + vars[vi];
if (vs.match(I18N.NUMBER)) {
if (vs === '1' || vs === '1.0') isPlural = false;else isPlural = true;
break;
}
}
// Plural forms:
// Normal: +s, +es (eg potatoes, boxes), y->ies (eg parties), +en (e.g. oxen)
// See http://www.englisch-hilfen.de/en/grammar/plural.htm, or https://en.wikipedia.org/wiki/English_plurals for the full horror.
// We also cover some French, German (+e, +n) and Spanish.
// regex matches letter(es)
if (isPlural === true) {
// Get the correction from the translation
text = text.replace(/(\w)\((s|es|en|e|n)\)/g, '$1$2');
// Inline complex form: e.g. "child (plural: children)" or "children (sing: child)"
// NB: The OED has pl, sing as abbreviations, c.f. http://public.oed.com/how-to-use-the-oed/abbreviations/
text = text.replace(/(\w+)\s*\((plural|pl): ?(\w+)\)/g, '$3');
text = text.replace(/(\w+)\s*\((singular|sing): ?(\w+)\)/g, '$1');
} else if (isPlural === false) {
text = text.replace(/(\w)\((s|es|en|e|n)\)/g, '$1');
// Inline complex form
text = text.replace(/(\w+)\s*\((plural|pl): ?(\w+)\)/g, '$1');
text = text.replace(/(\w+)\s*\((singular|sing): ?(\w+)\)/g, '$3');
}
return text;
};
/**
* Try to guess the user's language from the browser.
* @returns language code (which could be incorrect), or null
*/
I18N.getBrowserLanguage = function () {
var locale = navigator && (navigator.language || navigator.userLanguage);
if (locale) {
// chop down "en-GB" to just "en"
var lang = locale.substring(0, 2);
return lang;
}
return null;
};
/**
* Find out if there's a translation available for this string
* @param english
*/
I18N.prototype.canTranslate = function (english) {
var vars = [],
key = this.canon(english, vars);
if (this.en2lang[key]) return true;
return false;
};
// CommonJS module exports
if (typeof exports != 'undefined') {
exports.I18N = I18N;
}
/* Optional jQuery plugin
* Define $().tr(), which applies translation from the most recent I18N object */
(function (jQuery) {
if (!jQuery) return;
/**
* Translate the element(s).
* @param i18n {?I18N} If unset, use the latest made/active one, or make a new one. */
jQuery.fn.tr = function (i18n) {
if (!i18n) i18n = I18N.active || new I18N();
return this.each(function () {
var $el = jQuery(this);
// Store the raw version (in case we switch languages later)
var raw = $el.data('i18n-raw');
if (!raw) {
raw = $el.html();
$el.data('i18n-raw', raw);
}
var trans = i18n.tr(raw);
$el.html(trans);
});
};
})(window.jQuery || window.$);