Alan Guo Xiang Tan 038de393ed
DEV: Raise an error in test env when I18n interpolate argument is missing ()
Why this change?

We have been bitten by bugs where tests are not catching missing
interpolate argument in our client side code because the JavaScript
tests are also using `I18n.translate` to assert that the right message
is shown. Before this change, `I18n.interpolate` will just replace the
missing interpolation argument in the final translation with some
placeholder. As a result, we ended up comparing a broken translation
with another broken translation in the test environment.

Why does this change do?

This change introduces the `I18n.testing` property which when set to
`true` will cause `I18n.translate` to throw an error when an interpolate
argument is missing. With this commit, we also set `I18n.testing = true`
when running qunit acceptance test.
2023-09-13 10:53:48 +08:00

381 lines
9.6 KiB
JavaScript

// Instantiate the object
var I18n = I18n || {};
// Set default locale to english
I18n.defaultLocale = "en";
I18n.testing = false;
I18n.missingInterpolationArgument = class I18nMissingInterpolationArgument extends Error {
constructor(message) {
super(message);
this.name = "I18nMissingInterpolationArgument";
}
}
// Set default pluralization rule
I18n.pluralizationRules = {
en(n) {
return n === 0 ? ["zero", "none", "other"] : n === 1 ? "one" : "other";
},
};
// Set current locale to null
I18n.locale = null;
I18n.fallbackLocale = null;
// Set the placeholder format. Accepts `{{placeholder}}` and `%{placeholder}`.
I18n.PLACEHOLDER = /(?:\{\{|%\{)(.*?)(?:\}\}?)/gm;
I18n.SEPARATOR = ".";
I18n.noFallbacks = false;
I18n.isValidNode = function (obj, node, undefined) {
return obj[node] !== null && obj[node] !== undefined;
};
I18n.lookup = function (scope, options) {
options = options || {};
var translations = this.prepareOptions(I18n.translations),
locale = options.locale || I18n.currentLocale(),
messages = translations[locale] || {},
currentScope;
options = this.prepareOptions(options);
if (typeof scope === "object") {
scope = scope.join(this.SEPARATOR);
}
if (options.scope) {
scope = options.scope.toString() + this.SEPARATOR + scope;
}
var originalScope = scope;
scope = scope.split(this.SEPARATOR);
if (scope.length > 0 && scope[0] !== "js") {
scope.unshift("js");
}
while (messages && scope.length > 0) {
currentScope = scope.shift();
messages = messages[currentScope];
}
if (messages === undefined && this.extras && this.extras[locale]) {
messages = this.extras[locale];
scope = originalScope.split(this.SEPARATOR);
while (messages && scope.length > 0) {
currentScope = scope.shift();
messages = messages[currentScope];
}
}
if (messages === undefined) {
messages = options.defaultValue;
}
return messages;
};
// Merge several hash options, checking if value is set before
// overwriting any value. The precedence is from left to right.
//
// I18n.prepareOptions({name: "John Doe"}, {name: "Mary Doe", role: "user"});
// #=> {name: "John Doe", role: "user"}
//
I18n.prepareOptions = function () {
var options = {},
opts,
count = arguments.length;
for (var i = 0; i < count; i++) {
opts = arguments[i];
if (!opts) {
continue;
}
for (var key in opts) {
if (!this.isValidNode(options, key)) {
options[key] = opts[key];
}
}
}
return options;
};
I18n.interpolate = function (message, options, scope) {
options = this.prepareOptions(options);
var matches = message.match(this.PLACEHOLDER),
placeholder,
value,
name;
if (!matches) {
return message;
}
for (var i = 0; (placeholder = matches[i]); i++) {
name = placeholder.replace(this.PLACEHOLDER, "$1");
if (typeof options[name] === "string") {
// The dollar sign (`$`) is a special replace pattern, and `$&` inserts
// the matched string. Thus dollars signs need to be escaped with the
// special pattern `$$`, which inserts a single `$`.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#Specifying_a_string_as_a_parameter
value = options[name].replace(/\$/g, "$$$$");
} else {
value = options[name];
}
if (!this.isValidNode(options, name)) {
value = "[missing " + placeholder + " value]";
if (I18n.testing) {
throw new I18n.missingInterpolationArgument(`${scope}: ${value}`);
}
}
var regex = new RegExp(
placeholder.replace(/\{/gm, "\\{").replace(/\}/gm, "\\}")
);
message = message.replace(regex, value);
}
return message;
};
I18n.translate = function (scope, options) {
options = this.prepareOptions(options);
options.needsPluralization = typeof options.count === "number";
options.ignoreMissing = !this.noFallbacks;
var translation = this.findTranslation(scope, options);
if (!this.noFallbacks) {
if (!translation && this.fallbackLocale) {
options.locale = this.fallbackLocale;
translation = this.findTranslation(scope, options);
}
options.ignoreMissing = false;
if (!translation && this.currentLocale() !== this.defaultLocale) {
options.locale = this.defaultLocale;
translation = this.findTranslation(scope, options);
}
if (!translation && this.currentLocale() !== "en") {
options.locale = "en";
translation = this.findTranslation(scope, options);
}
}
try {
return this.interpolate(translation, options, scope);
} catch (error) {
if (error instanceof I18n.missingInterpolationArgument) {
throw error;
} else {
return (
options.translatedFallback ||
this.missingTranslation(scope, null, options)
);
}
}
};
I18n.findTranslation = function (scope, options) {
var translation = this.lookup(scope, options);
if (translation && options.needsPluralization) {
translation = this.pluralize(translation, scope, options);
}
return translation;
};
I18n.toNumber = function (number, options) {
options = this.prepareOptions(options, this.lookup("number.format"), {
precision: 3,
separator: this.SEPARATOR,
delimiter: ",",
strip_insignificant_zeros: false,
});
var negative = number < 0,
string = Math.abs(number).toFixed(options.precision).toString(),
parts = string.split(this.SEPARATOR),
buffer = [],
formattedNumber;
number = parts[0];
while (number.length > 0) {
var pos = Math.max(0, number.length - 3);
buffer.unshift(number.slice(pos, pos + 3));
number = number.slice(0, -3);
}
formattedNumber = buffer.join(options.delimiter);
if (options.precision > 0) {
formattedNumber += options.separator + parts[1];
}
if (negative) {
formattedNumber = "-" + formattedNumber;
}
if (options.strip_insignificant_zeros) {
var regex = {
separator: new RegExp(options.separator.replace(/\./, "\\.") + "$"),
zeros: /0+$/,
};
formattedNumber = formattedNumber
.replace(regex.zeros, "")
.replace(regex.separator, "");
}
return formattedNumber;
};
I18n.toHumanSize = function (number, options) {
var kb = 1024,
size = number,
iterations = 0,
unit,
precision;
while (size >= kb && iterations < 4) {
size = size / kb;
iterations += 1;
}
if (iterations === 0) {
unit = this.t("number.human.storage_units.units.byte", { count: size });
precision = 0;
} else {
unit = this.t(
"number.human.storage_units.units." +
[null, "kb", "mb", "gb", "tb"][iterations]
);
precision = size - Math.floor(size) === 0 ? 0 : 1;
}
options = this.prepareOptions(options, {
precision: precision,
format: this.t("number.human.storage_units.format"),
delimiter: "",
});
number = this.toNumber(size, options);
number = options.format.replace("%u", unit).replace("%n", number);
return number;
};
I18n.listJoiner = function (listOfStrings, delimiter) {
if (listOfStrings.length === 1) {
return listOfStrings[0];
}
if (listOfStrings.length === 2) {
return listOfStrings[0] + " " + delimiter + " " + listOfStrings[1];
}
var lastString = listOfStrings.pop();
return listOfStrings.concat(delimiter).join(`, `) + " " + lastString;
};
I18n.pluralizer = function (locale) {
var pluralizer = this.pluralizationRules[locale];
if (pluralizer !== undefined) return pluralizer;
return this.pluralizationRules["en"];
};
I18n.findAndTranslateValidNode = function (keys, translation) {
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
if (this.isValidNode(translation, key)) return translation[key];
}
return null;
};
I18n.pluralize = function (translation, scope, options) {
if (typeof translation !== "object") return translation;
options = this.prepareOptions(options);
var count = options.count.toString();
var pluralizer = this.pluralizer(options.locale || this.currentLocale());
var key = pluralizer(Math.abs(count));
var keys = typeof key === "object" && key instanceof Array ? key : [key];
var message = this.findAndTranslateValidNode(keys, translation);
if (message !== null || options.ignoreMissing) {
return message;
}
return this.missingTranslation(scope, keys[0]);
};
I18n.missingTranslation = function (scope, key, options) {
var message = "[" + this.currentLocale() + this.SEPARATOR + scope;
if (key) {
message += this.SEPARATOR + key;
}
if (options && options.hasOwnProperty("count")) {
message += " count=" + JSON.stringify(options.count);
}
return message + "]";
};
I18n.currentLocale = function () {
return I18n.locale || I18n.defaultLocale;
};
I18n.enableVerboseLocalization = function () {
var counter = 0;
var keys = {};
var t = I18n.t;
I18n.noFallbacks = true;
I18n.t = I18n.translate = function (scope, value) {
var current = keys[scope];
if (!current) {
current = keys[scope] = ++counter;
var message = "Translation #" + current + ": " + scope;
if (value && Object.keys(value).length > 0) {
message += ", parameters: " + JSON.stringify(value);
}
// eslint-disable-next-line no-console
console.info(message);
}
return t.apply(I18n, [scope, value]) + " (#" + current + ")";
};
};
I18n.enableVerboseLocalizationSession = function () {
sessionStorage.setItem("verbose_localization", "true");
I18n.enableVerboseLocalization();
return "Verbose localization is enabled. Close the browser tab to turn it off. Reload the page to see the translation keys.";
};
// shortcuts
I18n.t = I18n.translate;