David Taylor 76b75fae36
DEV: Simplify I18n shim check (#24244)
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.
2023-11-06 16:54:39 +00:00

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();