i18nliner-js (part II)

this swaps out (most of) our runtime i18n.js hacks with i18nliner-js

slightly tweak our js htmlEscape implementation (but not behavior) to play
nicely with i18nliner-js

remove auto-scoping abilities of I18n.lookup, but that's not a huge
deal as we didn't really need it

test plan:
1. verify english defaults:
   1. use canvas in english
   2. confirm everything looks correct
2. verify translation keys/scopes:
   1. run canvas w/ RAILS_LOAD_ALL_LOCALES=true and optimized js
   2. use canvas in spanish
   3. confirm that todo está bien
3. confirm you can now use i18nliner-y features:
   1. call `I18n.t` without a key

Change-Id: I93a2763f638f2807a7f804d320409fbdc80f0454
Reviewed-on: https://gerrit.instructure.com/42895
Reviewed-by: Michael Ziwisky <mziwisky@instructure.com>
Product-Review: Jennifer Stern <jstern@instructure.com>
QA-Review: Matt Fairbourn <mfairbourn@instructure.com>
Tested-by: Jenkins <jenkins@instructure.com>
This commit is contained in:
Jon Jensen 2014-10-17 03:34:43 -06:00
parent 9fbea5bd09
commit 2d37c16193
11 changed files with 1284 additions and 140 deletions

View File

@ -27,9 +27,8 @@ define [
wrappers[new Array(parseInt(key.replace('w', '')) + 2).join('*')] = value
delete options[key]
options.wrapper = wrappers if wrappers['*']
options.needsEscaping = true
options = $.extend(options, this) unless this instanceof String or typeof this is 'string'
I18n.scoped(scope).t(translationKey, defaultValue, options)
htmlEscape I18n.scoped(scope).t(translationKey, defaultValue, options)
hiddenIf : (condition) -> " display:none; " if condition

View File

@ -39,7 +39,7 @@ define [
year = (new Date()).getFullYear()
position = {year: 1, month: 2, day: 3}
dateSettings = I18n.lookup('#date')
dateSettings = I18n.lookup('date')
if options.type is 'birthdate'
_.defaults options,

View File

@ -9,7 +9,7 @@ define [
if strings.length > cutoff
strings = strings[0...cutoff].concat([strings[cutoff...strings.length]])
$.toSentence(for strOrArray in strings
if typeof strOrArray is 'string' or strOrArray._icHTMLSafe
if typeof strOrArray is 'string' or strOrArray instanceof h.SafeString
"<span>#{h(strOrArray)}</span>"
else
"""

View File

@ -1,6 +1,14 @@
define(function(require) {
var I18n = require('i18n!something');
describe('I18n.t', function() {
it('should work with just a string default', function() {
expect(I18n.t('Foo')).toBe('Foo');
});
it('should work with a default object and an options object', function() {
expect(I18n.t({one: "1 person", other: "%{count} people"}, {count: 2})).toBe('2 people');
});
it('should work with two params', function() {
expect(I18n.t('foo', 'Foo')).toBe('Foo');
});
@ -38,4 +46,4 @@ define(function(require) {
})).toBe('3 students');
});
});
});
});

View File

@ -1,5 +1,7 @@
define([], function() {
var INTERPOLATER = /\%\{([^\}]+)\}/g;
var KEY_PATTERN = /^\#?\w+(\.\w+)+$/; // handle our absolute keys
var COUNT_KEY_MAP = ["zero", "one"];
var i18n = {
interpolate: function(contents, options) {
@ -15,56 +17,51 @@ define([], function() {
return contents;
},
load : function(name, req, onLoad) {
isKeyProvided: function(keyOrDefault, defaultOrOptions, maybeOptions) {
if (typeof keyOrDefault === 'object')
return false;
if (typeof defaultOrOptions === 'string')
return true;
if (maybeOptions)
return true;
if (typeof keyOrDefault === 'string' && keyOrDefault.match(this.keyPattern))
return true;
return false;
},
inferArguments: function(args) {
var hasKey = this.isKeyProvided.apply(this, args);
if (hasKey) args = args.slice(1);
return args;
},
load: function(name, req, onLoad) {
// Development only.
// This gets replaced by Canvas I18n when embedded.
//
// Adapted/simplified from i18nliner-js and canvas' i18nObj
//
// Returns the defaultValue you provide with variables interpolated,
// if specified.
//
// See the project README for i18n work.
var t = function(__key__, defaultValue, options) {
var value;
if (arguments.length === 2) {
if (typeof defaultValue === 'string') {
options = { defaultValue: defaultValue };
}
else if (typeof defaultValue === 'object') {
options = defaultValue;
}
else {
throw new Error("Bad I18n.t() call, expected an options object or a defaultValue string.");
}
}
else if (arguments.length === 3 && !options.defaultValue) {
options.defaultValue = defaultValue;
var t = function() {
var args = i18n.inferArguments([].slice.call(arguments));
var defaultValue = args[0];
var options = args[1] || {};
var countKey;
if (typeof defaultValue !== 'string' && typeof defaultValue !== 'object') {
throw new Error("Bad I18n.t() call, expected a default string or object.");
}
if (options.hasOwnProperty('count') && typeof defaultValue === 'object') {
switch(options.count) {
case 0:
if (defaultValue.zero) {
options.defaultValue = defaultValue.zero;
}
break;
case 1:
if (defaultValue.one) {
options.defaultValue = defaultValue.one;
}
break;
default:
if (defaultValue.other) {
options.defaultValue = defaultValue.other;
}
}
countKey = COUNT_KEY_MAP[options.count];
defaultValue = defaultValue[countKey] || defaultValue.other;
}
value = i18n.interpolate(''+options.defaultValue, options);
return value;
return i18n.interpolate(''+defaultValue, options);
};
var l = function(scope, value) {
@ -79,4 +76,4 @@ define([], function() {
};
return i18n;
});
});

View File

@ -1,23 +1,13 @@
define([
'vendor/i18n',
'vendor/i18n_js_extension',
'jquery',
'str/htmlEscape',
'str/pluralize',
'str/escapeRegex',
'compiled/str/i18nLolcalize',
'vendor/date' /* Date.parse, Date.UTC */
], function(I18n, $, htmlEscape, pluralize, escapeRegex, i18nLolcalize) {
// Export globally for tinymce/specs
window.I18n = I18n;
], function(I18n, $, htmlEscape, i18nLolcalize) {
I18n.locale = document.documentElement.getAttribute('lang');
// Set the placeholder format. Accepts `%{placeholder}` and %h{placeholder}.
// %h{placeholder} indicate it is an htmlSafe value, (e.g. an input) and
// anything not already safe should be html-escaped
I18n.PLACEHOLDER = /%h?\{(.*?)\}/gm;
I18n.isValidNode = function(obj, node) {
// handle names like "foo.bar.baz"
var nameParts = node.split('.');
@ -61,23 +51,20 @@ I18n.lookup = function(scope, options) {
return messages;
};
I18n.interpolate = function(message, options) {
var placeholder, value, name, matches, needsEscaping = false, htmlSafe;
// i18nliner-js overrides interpolate with a wrapper-and-html-safety-aware
// version, so we need to override the now-renamed original
I18n.interpolateWithoutHtmlSafety = function(message, options) {
options = this.prepareOptions(options);
if (options.wrapper) {
needsEscaping = true;
message = this.applyWrappers(message, options.wrapper);
}
if (options.needsEscaping) {
needsEscaping = true;
var matches = message.match(this.PLACEHOLDER);
if (!matches) {
return message;
}
matches = message.match(this.PLACEHOLDER) || [];
var placeholder, value, name;
for (var i = 0; placeholder = matches[i]; i++) {
name = placeholder.replace(this.PLACEHOLDER, "$1");
htmlSafe = (placeholder[1] === 'h'); // e.g. %h{input}
// handle names like "foo.bar.baz"
var nameParts = name.split('.');
@ -89,14 +76,6 @@ I18n.interpolate = function(message, options) {
if (!this.isValidNode(options, name)) {
value = "[missing " + placeholder + " value]";
}
if (needsEscaping) {
if (!value._icHTMLSafe && !htmlSafe) {
value = htmlEscape(value);
}
} else if (value._icHTMLSafe || htmlSafe) {
needsEscaping = true;
message = htmlEscape(message);
}
regex = new RegExp(placeholder.replace(/\{/gm, "\\{").replace(/\}/gm, "\\}"));
message = message.replace(regex, value);
@ -105,31 +84,6 @@ I18n.interpolate = function(message, options) {
return message;
};
I18n.wrapperRegexes = {};
I18n.applyWrappers = function(string, wrappers) {
var keys = [];
var key;
string = htmlEscape(string);
if (typeof(wrappers) == "string") {
wrappers = {'*': wrappers};
}
for (key in wrappers) {
keys.push(key);
}
keys.sort().reverse();
for (var i=0, l=keys.length; i < l; i++) {
key = keys[i];
if (!this.wrapperRegexes[key]) {
var escapedKey = escapeRegex(key);
this.wrapperRegexes[key] = new RegExp(escapedKey + "([^" + escapedKey + "]*)" + escapedKey, "g");
}
string = string.replace(this.wrapperRegexes[key], wrappers[key]);
}
return string;
};
var _localize = I18n.localize;
I18n.localize = function(scope, value) {
var result = _localize.call(this, scope, value);
@ -261,11 +215,18 @@ I18n.strftime = function(date, format) {
return f;
};
I18n.Utils.HtmlSafeString = htmlEscape.SafeString; // this is what we use elsewhere in canvas, so make i18nliner use it too
I18n.CallHelpers.keyPattern = /^\#?\w+(\.\w+)+$/ // handle our absolute keys
I18n.CallHelpers.normalizeKey = function(key, options) {
if (key[0] === '#') {
key = key.slice(1);
delete options.scope;
}
return key;
}
var normalizeDefault = function(str) { return str };
if (window.ENV && window.ENV.lolcalize) {
normalizeDefault = i18nLolcalize;
I18n.CallHelpers.normalizeDefault = i18nLolcalize;
}
I18n.scoped = function(scope, callback) {
@ -279,36 +240,26 @@ I18n.scope = function(scope) {
this.scope = scope;
};
I18n.scope.prototype = {
resolveScope: function(key) {
if (typeof(key) == "object") {
key = key.join(I18n.defaultSeparator);
}
if (key[0] == '#') {
return key.replace(/^#/, '');
} else {
return this.scope + I18n.defaultSeparator + key;
HtmlSafeString: I18n.HtmlSafeString,
translate: function() {
var args = [].slice.call(arguments);
var options = args[args.length - 1];
if (!(options instanceof Object)) {
options = {}
args.push(options);
}
options.scope = this.scope;
return I18n.translate.apply(I18n, args);
},
translate: function(scope, defaultValue, options) {
options = options || {};
if (typeof(options.count) != 'undefined' && typeof(defaultValue) == "string" && defaultValue.match(/^[\w\-]+$/)) {
defaultValue = pluralize.withCount(options.count, defaultValue);
}
options.defaultValue = normalizeDefault(defaultValue);
return I18n.translate(this.resolveScope(scope), options);
},
localize: function(scope, value) {
return I18n.localize(this.resolveScope(scope), value);
},
pluralize: function(count, scope, options) {
return I18n.pluralize(count, this.resolveScope(scope), options);
localize: function(key, date) {
if (key[0] === '#') key = key.slice(1);
return I18n.localize(key, date);
},
beforeLabel: function(text) {
return this.t("#before_label_wrapper", "%{text}:", {'text': text});
},
lookup: function(scope, options) {
return I18n.lookup(this.resolveScope(scope), options);
},
lookup: I18n.lookup.bind(I18n),
toTime: I18n.toTime.bind(I18n),
toNumber: I18n.toNumber.bind(I18n),
toCurrency: I18n.toCurrency.bind(I18n),

View File

@ -66,12 +66,10 @@ define([
};
// useful for i18n, e.g. t('key', 'pick one: %{select}', {select: $.raw('<select><option>...')})
// note that raw returns a String object, so you may want to call toString
// note that raw returns a SafeString object, so you may want to call toString
// if you're using it elsewhere
$.raw = function(str) {
str = new String(str);
str._icHTMLSafe = true;
return str;
return new htmlEscape.SafeString(str);
}
$.replaceOneTag = function(text, name, value) {

View File

@ -1,10 +1,17 @@
define(['INST', 'jquery'], function(INST, $) {
var dummy = $('<div/>');
function SafeString(string) {
this.string = (typeof string === 'string' ? string : "" + string);
}
SafeString.prototype.toString = function() {
return this.string;
};
var htmlEscape = function(str) {
return str && str._icHTMLSafe ?
str.toString() :
dummy.text(str).html();
// ideally we should wrap this in a SafeString, but this is how it has
// always worked :-/
return dummy.text(str).html();
}
// Escapes HTML tags from string, or object string props of `strOrObject`.
@ -12,6 +19,8 @@ define(['INST', 'jquery'], function(INST, $) {
var escape = function(strOrObject) {
if (typeof strOrObject === 'string') {
return htmlEscape(strOrObject);
} else if (strOrObject instanceof SafeString) {
return strOrObject;
}
var k, v;
@ -22,7 +31,8 @@ define(['INST', 'jquery'], function(INST, $) {
}
}
return strOrObject;
}
};
escape.SafeString = SafeString;
// tinymce plugins use this and they need it global :(
INST.htmlEscape = escape;

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,6 @@
# note: most of these tests are now redundant w/ i18nliner-js, leaving them
# for a little bit though
define [
"jquery"
"i18nObj"
@ -21,7 +24,7 @@ define [
originalLocale = I18n.locale
try
$.extend(true, I18n, {locale: 'bad-locale', translations: {en: {foo: {fallback_message: 'this is in the en locale'}}}})
equal scope.lookup('fallback_message'),
equal scope.lookup('foo.fallback_message'),
'this is in the en locale'
finally
I18n.locale = originalLocale
@ -32,11 +35,11 @@ define [
test "html safety: should html-escape translations and interpolations if any interpolated values are htmlSafe", ->
equal t('bar', "only one of these won't get escaped: <input>, %{a}, %{b} & %{c}", {a: '<img>', b: $.raw('<br>'), c: '<hr>'}),
'only one of these won\'t get escaped: &lt;input&gt;, &lt;img&gt;, <br> &amp; &lt;hr&gt;'
'only one of these won&#39;t get escaped: &lt;input&gt;, &lt;img&gt;, <br> &amp; &lt;hr&gt;'
test "html safety: should html-escape translations and interpolations if any placeholders are flagged as safe", ->
equal t('bar', "only one of these won't get escaped: <input>, %{a}, %h{b} & %{c}", {a: '<img>', b: '<br>', c: '<hr>'}),
'only one of these won\'t get escaped: &lt;input&gt;, &lt;img&gt;, <br> &amp; &lt;hr&gt;'
'only one of these won&#39;t get escaped: &lt;input&gt;, &lt;img&gt;, <br> &amp; &lt;hr&gt;'
test "wrappers: should auto-html-escape", ->
equal t('bar', '*2* > 1', {wrapper: '<b>$1</b>'}),

View File

@ -38,8 +38,8 @@ describe "handlebars" do
<li>{{#t "protip" type=../type}}Important {{type}} tip:{{/t}} {{this}}</li>
{{/each}}
</ol>
<p>{{#t "html"}}lemme instructure you some html: if you type {{input}}, you get {{{input}}}{{/t}}</p>
<p>{{#t "reversed"}}in other words you get {{{input}}} when you type {{input}}{{/t}}</p>
<p>{{#t "html"}}lemme instructure you some html: if you type {{input}}, you get {{{raw_input}}}{{/t}}</p>
<p>{{#t "reversed"}}in other words you get {{{raw_input}}} when you type {{input}}{{/t}}</p>
<p>{{#t "escapage"}}this is {{escaped}}{{/t}}</p>
<p>{{#t "unescapage"}}this is {{{unescaped}}}{{/t}}</p>
{{#t "bye"}}welp, see you l8r! dont forget 2 <a href="{{url}}">like us</a> on facebook lol{{/t}}
@ -51,6 +51,8 @@ describe "handlebars" do
type: 'yoga',
items: ['dont forget to stretch!!!'],
input: '<input>',
raw_input: '<input>', # note; this is temporary due to a change in the html-safety implementation.
# once i18nliner-handlebars lands, the old spec will pass
url: 'http://foo.bar',
escaped: '<b>escaped</b>',
unescaped: '<b>unescaped</b>'
@ -129,7 +131,7 @@ describe "handlebars" do
<b> Je voudrais un croissant</b>
</p>
<p>
<i> Yes, that's true, he would </i>
<i> Yes, that&#39;s true, he would </i>
</p>
HTML
end