mirror of
https://github.com/discourse/discourse.git
synced 2025-02-08 04:18:23 +00:00
76b75fae36
We have identified some third-party analytics scripts which do things like `window.I18n = window.I18n` 🤦♂️. This leads to the window object having a null I18n property, but `"I18n" in globalThis` returns true.
This commit checks whether `window.I18n` is a truthy value.
385 lines
9.9 KiB
JavaScript
385 lines
9.9 KiB
JavaScript
if (window.I18n) {
|
|
throw new Error(
|
|
"I18n already defined, discourse-i18n unexpectedly loaded twice!"
|
|
);
|
|
}
|
|
|
|
// The placeholder format. Accepts `{{placeholder}}` and `%{placeholder}`.
|
|
const PLACEHOLDER = /(?:\{\{|%\{)(.*?)(?:\}\}?)/gm;
|
|
const SEPARATOR = ".";
|
|
|
|
export class I18n {
|
|
// Set default locale to english
|
|
defaultLocale = "en";
|
|
|
|
// Set current locale to null
|
|
local = null;
|
|
fallbackLocale = null;
|
|
translations = null;
|
|
extras = null;
|
|
noFallbacks = false;
|
|
testing = false;
|
|
|
|
// Set default pluralization rule
|
|
pluralizationRules = {
|
|
en(n) {
|
|
return n === 0 ? ["zero", "none", "other"] : n === 1 ? "one" : "other";
|
|
},
|
|
};
|
|
|
|
translate = (scope, options) => this._translate(scope, options);
|
|
|
|
// shortcut
|
|
t = this.translate;
|
|
|
|
currentLocale() {
|
|
return this.locale || this.defaultLocale;
|
|
}
|
|
|
|
enableVerboseLocalization() {
|
|
let counter = 0;
|
|
let keys = {};
|
|
|
|
this.noFallbacks = true;
|
|
|
|
this.t = this.translate = (scope, options) => {
|
|
let current = keys[scope];
|
|
if (!current) {
|
|
current = keys[scope] = ++counter;
|
|
let message = "Translation #" + current + ": " + scope;
|
|
if (options && Object.keys(options).length > 0) {
|
|
message += ", parameters: " + JSON.stringify(options);
|
|
}
|
|
// eslint-disable-next-line no-console
|
|
console.info(message);
|
|
}
|
|
|
|
return this._translate(scope, options) + " (#" + current + ")";
|
|
};
|
|
}
|
|
|
|
enableVerboseLocalizationSession() {
|
|
sessionStorage.setItem("verbose_localization", "true");
|
|
this.enableVerboseLocalization();
|
|
return "Verbose localization is enabled. Close the browser tab to turn it off. Reload the page to see the translation keys.";
|
|
}
|
|
|
|
_translate(scope, options) {
|
|
options = this.prepareOptions(options);
|
|
options.needsPluralization = typeof options.count === "number";
|
|
options.ignoreMissing = !this.noFallbacks;
|
|
|
|
let 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 I18nMissingInterpolationArgument) {
|
|
throw error;
|
|
} else {
|
|
return (
|
|
options.translatedFallback ||
|
|
this.missingTranslation(scope, null, options)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
toNumber(number, options) {
|
|
options = this.prepareOptions(options, this.lookup("number.format"), {
|
|
precision: 3,
|
|
separator: SEPARATOR,
|
|
delimiter: ",",
|
|
strip_insignificant_zeros: false,
|
|
});
|
|
|
|
let negative = number < 0;
|
|
let string = Math.abs(number).toFixed(options.precision).toString();
|
|
let parts = string.split(SEPARATOR);
|
|
let buffer = [];
|
|
let formattedNumber;
|
|
|
|
number = parts[0];
|
|
|
|
while (number.length > 0) {
|
|
let 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) {
|
|
let regex = {
|
|
separator: new RegExp(options.separator.replace(/\./, "\\.") + "$"),
|
|
zeros: /0+$/,
|
|
};
|
|
|
|
formattedNumber = formattedNumber
|
|
.replace(regex.zeros, "")
|
|
.replace(regex.separator, "");
|
|
}
|
|
|
|
return formattedNumber;
|
|
}
|
|
|
|
toHumanSize(number, options) {
|
|
let kb = 1024;
|
|
let size = number;
|
|
let iterations = 0;
|
|
let 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,
|
|
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;
|
|
}
|
|
|
|
pluralize(translation, scope, options) {
|
|
if (typeof translation !== "object") {
|
|
return translation;
|
|
}
|
|
|
|
options = this.prepareOptions(options);
|
|
let count = options.count.toString();
|
|
|
|
let pluralizer = this.pluralizer(options.locale || this.currentLocale());
|
|
let key = pluralizer(Math.abs(count));
|
|
let keys = typeof key === "object" && key instanceof Array ? key : [key];
|
|
let message = this.findAndTranslateValidNode(keys, translation);
|
|
|
|
if (message !== null || options.ignoreMissing) {
|
|
return message;
|
|
}
|
|
|
|
return this.missingTranslation(scope, keys[0]);
|
|
}
|
|
|
|
pluralizer(locale) {
|
|
return this.pluralizationRules[locale] ?? this.pluralizationRules["en"];
|
|
}
|
|
|
|
listJoiner(listOfStrings, delimiter) {
|
|
if (listOfStrings.length === 1) {
|
|
return listOfStrings[0];
|
|
}
|
|
|
|
if (listOfStrings.length === 2) {
|
|
return listOfStrings[0] + " " + delimiter + " " + listOfStrings[1];
|
|
}
|
|
|
|
let lastString = listOfStrings.pop();
|
|
return listOfStrings.concat(delimiter).join(`, `) + " " + lastString;
|
|
}
|
|
|
|
interpolate(message, options, scope) {
|
|
options = this.prepareOptions(options);
|
|
let matches = message.match(PLACEHOLDER);
|
|
let placeholder, value, name;
|
|
|
|
if (!matches) {
|
|
return message;
|
|
}
|
|
|
|
for (let i = 0; (placeholder = matches[i]); i++) {
|
|
name = placeholder.replace(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 (this.testing) {
|
|
throw new I18nMissingInterpolationArgument(`${scope}: ${value}`);
|
|
}
|
|
}
|
|
|
|
let regex = new RegExp(
|
|
placeholder.replace(/\{/gm, "\\{").replace(/\}/gm, "\\}")
|
|
);
|
|
|
|
message = message.replace(regex, value);
|
|
}
|
|
|
|
return message;
|
|
}
|
|
|
|
findTranslation(scope, options) {
|
|
let translation = this.lookup(scope, options);
|
|
|
|
if (translation && options.needsPluralization) {
|
|
translation = this.pluralize(translation, scope, options);
|
|
}
|
|
|
|
return translation;
|
|
}
|
|
|
|
findAndTranslateValidNode(keys, translation) {
|
|
for (let key of keys) {
|
|
if (this.isValidNode(translation, key)) {
|
|
return translation[key];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
lookup(scope, options = {}) {
|
|
let translations = this.prepareOptions(this.translations);
|
|
let locale = options.locale || this.currentLocale();
|
|
let messages = translations[locale] || {};
|
|
let currentScope;
|
|
|
|
options = this.prepareOptions(options);
|
|
|
|
if (typeof scope === "object") {
|
|
scope = scope.join(SEPARATOR);
|
|
}
|
|
|
|
if (options.scope) {
|
|
scope = options.scope.toString() + SEPARATOR + scope;
|
|
}
|
|
|
|
let originalScope = scope;
|
|
scope = scope.split(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(SEPARATOR);
|
|
|
|
while (messages && scope.length > 0) {
|
|
currentScope = scope.shift();
|
|
messages = messages[currentScope];
|
|
}
|
|
}
|
|
|
|
if (messages === undefined) {
|
|
messages = options.defaultValue;
|
|
}
|
|
|
|
return messages;
|
|
}
|
|
|
|
missingTranslation(scope, key, options) {
|
|
let message = "[" + this.currentLocale() + SEPARATOR + scope;
|
|
|
|
if (key) {
|
|
message += SEPARATOR + key;
|
|
}
|
|
|
|
if (options && options.hasOwnProperty("count")) {
|
|
message += " count=" + JSON.stringify(options.count);
|
|
}
|
|
|
|
return message + "]";
|
|
}
|
|
|
|
// 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"}
|
|
//
|
|
prepareOptions(...args) {
|
|
let options = {};
|
|
let count = args.length;
|
|
let opts;
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
opts = arguments[i];
|
|
|
|
if (!opts) {
|
|
continue;
|
|
}
|
|
|
|
for (let key in opts) {
|
|
if (!this.isValidNode(options, key)) {
|
|
options[key] = opts[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
isValidNode(obj, node) {
|
|
return obj[node] !== null && obj[node] !== undefined;
|
|
}
|
|
}
|
|
|
|
export class I18nMissingInterpolationArgument extends Error {
|
|
constructor(message) {
|
|
super(message);
|
|
this.name = "I18nMissingInterpolationArgument";
|
|
}
|
|
}
|
|
|
|
// Export a default/global instance
|
|
export default globalThis.I18n = new I18n();
|