diff --git a/.eslintignore b/.eslintignore
index 885e2cf3c7c..576c312ab64 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,4 +1,3 @@
-app/assets/javascripts/locales/i18n.js
app/assets/javascripts/ember-addons/
lib/javascripts/locale/
lib/javascripts/messageformat.js
diff --git a/.prettierignore b/.prettierignore
index c1caccc0683..a30fc1055e3 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -10,7 +10,6 @@ config/locales/**/*.yml
script/import_scripts/**/*.yml
app/assets/javascripts/browser-update.js
-app/assets/javascripts/locales/i18n.js
app/assets/javascripts/ember-addons/
app/assets/javascripts/discourse/lib/autosize.js
lib/javascripts/locale/
diff --git a/app/assets/javascripts/bootstrap-json/index.js b/app/assets/javascripts/bootstrap-json/index.js
index 1959ffdb315..a069821aee7 100644
--- a/app/assets/javascripts/bootstrap-json/index.js
+++ b/app/assets/javascripts/bootstrap-json/index.js
@@ -91,14 +91,13 @@ function head(buffer, bootstrap, headers, baseURL) {
function localeScript(buffer, bootstrap) {
buffer.push(``);
+ (bootstrap.extra_locales || []).forEach((l) =>
+ buffer.push(``)
+ );
}
function beforeScriptLoad(buffer, bootstrap) {
buffer.push(bootstrap.html.before_script_load);
- localeScript(buffer, bootstrap);
- (bootstrap.extra_locales || []).forEach((l) =>
- buffer.push(``)
- );
}
function discoursePreloadStylesheets(buffer, bootstrap) {
diff --git a/app/assets/javascripts/discourse-i18n/addon-main.cjs b/app/assets/javascripts/discourse-i18n/addon-main.cjs
new file mode 100644
index 00000000000..0c082321106
--- /dev/null
+++ b/app/assets/javascripts/discourse-i18n/addon-main.cjs
@@ -0,0 +1,4 @@
+"use strict";
+
+const { addonV1Shim } = require("@embroider/addon-shim");
+module.exports = addonV1Shim(__dirname);
diff --git a/app/assets/javascripts/discourse-i18n/package.json b/app/assets/javascripts/discourse-i18n/package.json
new file mode 100644
index 00000000000..e63f725df1b
--- /dev/null
+++ b/app/assets/javascripts/discourse-i18n/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "discourse-i18n",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Discourse's i18n",
+ "author": "Discourse ",
+ "license": "GPL-2.0-only",
+ "keywords": [
+ "ember-addon"
+ ],
+ "exports": {
+ ".": "./src/index.js",
+ "./addon-main.js": "./addon-main.cjs"
+ },
+ "files": [
+ "addon-main.cjs",
+ "src"
+ ],
+ "dependencies": {
+ "@embroider/addon-shim": "^1.0.0"
+ },
+ "engines": {
+ "node": "16.* || >= 18",
+ "npm": "please-use-yarn",
+ "yarn": ">= 1.21.1"
+ },
+ "ember": {
+ "edition": "octane"
+ },
+ "ember-addon": {
+ "version": 2,
+ "type": "addon",
+ "main": "addon-main.cjs",
+ "app-js": {}
+ }
+}
diff --git a/app/assets/javascripts/discourse-i18n/src/index.js b/app/assets/javascripts/discourse-i18n/src/index.js
new file mode 100644
index 00000000000..26b5e4ccd61
--- /dev/null
+++ b/app/assets/javascripts/discourse-i18n/src/index.js
@@ -0,0 +1,384 @@
+if ("I18n" in globalThis) {
+ 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();
diff --git a/app/assets/javascripts/discourse/app/app.js b/app/assets/javascripts/discourse/app/app.js
index c250c6f79e8..35b3740aea7 100644
--- a/app/assets/javascripts/discourse/app/app.js
+++ b/app/assets/javascripts/discourse/app/app.js
@@ -1,6 +1,9 @@
-import Application from "@ember/application";
-import "./global-compat";
+/* eslint-disable simple-import-sort/imports */
import "./loader-shims";
+import "./global-compat";
+/* eslint-enable simple-import-sort/imports */
+
+import Application from "@ember/application";
import require from "require";
import { normalizeEmberEventHandling } from "discourse/lib/ember-events";
import { registerDiscourseImplicitInjections } from "discourse/lib/implicit-injections";
diff --git a/app/assets/javascripts/discourse/app/index.html b/app/assets/javascripts/discourse/app/index.html
index 892531bb483..13db87b245e 100644
--- a/app/assets/javascripts/discourse/app/index.html
+++ b/app/assets/javascripts/discourse/app/index.html
@@ -32,6 +32,8 @@
+
+
diff --git a/app/assets/javascripts/discourse/app/loader-shims.js b/app/assets/javascripts/discourse/app/loader-shims.js
index 170eae87d18..f03a4f8eadc 100644
--- a/app/assets/javascripts/discourse/app/loader-shims.js
+++ b/app/assets/javascripts/discourse/app/loader-shims.js
@@ -24,6 +24,7 @@ loaderShim("@uppy/utils/lib/EventTracker", () =>
);
loaderShim("@uppy/xhr-upload", () => importSync("@uppy/xhr-upload"));
loaderShim("a11y-dialog", () => importSync("a11y-dialog"));
+loaderShim("discourse-i18n", () => importSync("discourse-i18n"));
loaderShim("ember-modifier", () => importSync("ember-modifier"));
loaderShim("ember-route-template", () => importSync("ember-route-template"));
loaderShim("handlebars", () => importSync("handlebars"));
diff --git a/app/assets/javascripts/discourse/ember-cli-build.js b/app/assets/javascripts/discourse/ember-cli-build.js
index abd43ee512a..49ee3c7c227 100644
--- a/app/assets/javascripts/discourse/ember-cli-build.js
+++ b/app/assets/javascripts/discourse/ember-cli-build.js
@@ -58,6 +58,7 @@ module.exports = function (defaults) {
autoImport: {
forbidEval: true,
insertScriptsAt: "ember-auto-import-scripts",
+ watchDependencies: ["discourse-i18n"],
webpack: {
// Workarounds for https://github.com/ef4/ember-auto-import/issues/519 and https://github.com/ef4/ember-auto-import/issues/478
devtool: isProduction ? false : "source-map", // Sourcemaps contain reference to the ephemeral broccoli cache dir, which changes on every deploy
@@ -198,6 +199,13 @@ module.exports = function (defaults) {
packagerOptions: {
webpackConfig: {
devtool: "source-map",
+ resolve: {
+ alias: {
+ // This is a build-time alias is for code in core only – plugins
+ // and legacy bundles go through the runtime loader.js shim
+ I18n: "discourse-i18n",
+ },
+ },
externals: [
function ({ request }, callback) {
if (
diff --git a/app/assets/javascripts/discourse/package.json b/app/assets/javascripts/discourse/package.json
index 20fa53fb8fb..798b7636050 100644
--- a/app/assets/javascripts/discourse/package.json
+++ b/app/assets/javascripts/discourse/package.json
@@ -62,6 +62,7 @@
"deprecation-silencer": "1.0.0",
"dialog-holder": "1.0.0",
"discourse-common": "1.0.0",
+ "discourse-i18n": "1.0.0",
"discourse-plugins": "1.0.0",
"ember-auto-import": "^2.6.3",
"ember-buffered-proxy": "^2.1.1",
diff --git a/app/assets/javascripts/discourse/public/assets/scripts/module-shims.js b/app/assets/javascripts/discourse/public/assets/scripts/module-shims.js
index e4630af4274..8142628670f 100644
--- a/app/assets/javascripts/discourse/public/assets/scripts/module-shims.js
+++ b/app/assets/javascripts/discourse/public/assets/scripts/module-shims.js
@@ -1,8 +1,13 @@
-define("I18n", ["exports", "discourse-common/lib/deprecated"], function (
- exports,
- deprecated
-) {
- exports.default = I18n;
+define("I18n", [
+ "exports",
+ "discourse-i18n",
+ "discourse-common/lib/deprecated",
+], function (exports, I18n, deprecated) {
+ exports.default = I18n.default;
+
+ exports.I18nMissingInterpolationArgument =
+ I18n.I18nMissingInterpolationArgument;
+
exports.t = function () {
deprecated.default(
"Importing t from I18n is deprecated. Use the default export instead.",
@@ -10,7 +15,7 @@ define("I18n", ["exports", "discourse-common/lib/deprecated"], function (
id: "discourse.i18n-t-import",
}
);
- return I18n.t(...arguments);
+ return I18n.default.t(...arguments);
};
});
diff --git a/app/assets/javascripts/discourse/tests/index.html b/app/assets/javascripts/discourse/tests/index.html
index 7e466165c66..79e334f29dc 100644
--- a/app/assets/javascripts/discourse/tests/index.html
+++ b/app/assets/javascripts/discourse/tests/index.html
@@ -40,9 +40,6 @@
height: 1000px;
}
-
-
-
{{content-for "body"}} {{content-for "test-body"}}
@@ -51,7 +48,7 @@
-
+
@@ -63,6 +60,8 @@
+
+
diff --git a/app/assets/javascripts/discourse/tests/setup-tests.js b/app/assets/javascripts/discourse/tests/setup-tests.js
index c02a0b88088..2b937c2d6cc 100644
--- a/app/assets/javascripts/discourse/tests/setup-tests.js
+++ b/app/assets/javascripts/discourse/tests/setup-tests.js
@@ -1,3 +1,7 @@
+/* eslint-disable simple-import-sort/imports */
+import "./loader-shims";
+/* eslint-enable simple-import-sort/imports */
+
import { getOwner } from "@ember/application";
import {
getSettledState,
@@ -5,7 +9,6 @@ import {
setApplication,
setResolver,
} from "@ember/test-helpers";
-import "./loader-shims";
import bootbox from "bootbox";
import { addModuleExcludeMatcher } from "ember-cli-test-loader/test-support/index";
import jQuery from "jquery";
diff --git a/app/assets/javascripts/discourse/tests/unit/lib/i18n-test.js b/app/assets/javascripts/discourse/tests/unit/lib/i18n-test.js
index 94b180a0fef..b1bc96aa3aa 100644
--- a/app/assets/javascripts/discourse/tests/unit/lib/i18n-test.js
+++ b/app/assets/javascripts/discourse/tests/unit/lib/i18n-test.js
@@ -1,7 +1,7 @@
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import { withSilencedDeprecations } from "discourse-common/lib/deprecated";
-import I18n from "I18n";
+import I18n, { I18nMissingInterpolationArgument } from "I18n";
module("Unit | Utility | i18n", function (hooks) {
setupTest(hooks);
@@ -316,7 +316,7 @@ module("Unit | Utility | i18n", function (hooks) {
I18n.t("with_multiple_interpolate_arguments", {
username: "username",
});
- }, new I18n.missingInterpolationArgument(
+ }, new I18nMissingInterpolationArgument(
"with_multiple_interpolate_arguments: [missing %{username2} value]"
));
} finally {
diff --git a/app/assets/javascripts/locales/i18n.js b/app/assets/javascripts/locales/i18n.js
index 1b77f23f8d5..5f0857a0084 100644
--- a/app/assets/javascripts/locales/i18n.js
+++ b/app/assets/javascripts/locales/i18n.js
@@ -1,380 +1,2 @@
-// 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;
+require("discourse/loader-shims");
+require("discourse-i18n");
diff --git a/app/assets/javascripts/package.json b/app/assets/javascripts/package.json
index 02b12679805..4a780e83ee9 100644
--- a/app/assets/javascripts/package.json
+++ b/app/assets/javascripts/package.json
@@ -11,6 +11,7 @@
"discourse",
"discourse-common",
"discourse-hbr",
+ "discourse-i18n",
"discourse-plugins",
"discourse-widget-hbs",
"ember-cli-progress-ci",
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index a3e80fd2c6b..2cd4d6f20e6 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -32,16 +32,17 @@
<%- end %>
<%= preload_script 'browser-detect' %>
- <%= preload_script "locales/#{I18n.locale}" %>
- <%- if ExtraLocalesController.client_overrides_exist? %>
- <%= preload_script_url ExtraLocalesController.url('overrides') %>
- <%- end %>
<%= preload_script "vendor" %>
<%= preload_script "discourse" %>
<%- Discourse.find_plugin_js_assets(include_official: allow_plugins?, include_unofficial: allow_third_party_plugins?, request: request).each do |file| %>
<%= preload_script file %>
<%- end %>
+ <%= preload_script "locales/#{I18n.locale}" %>
+ <%- if ExtraLocalesController.client_overrides_exist? %>
+ <%= preload_script_url ExtraLocalesController.url('overrides') %>
+ <%- end %>
+
<%- if staff? %>
<%= preload_script_url ExtraLocalesController.url("admin") %>
<%= preload_script "admin" %>
diff --git a/app/views/qunit/theme.html.erb b/app/views/qunit/theme.html.erb
index b100db08017..eff72c156ce 100644
--- a/app/views/qunit/theme.html.erb
+++ b/app/views/qunit/theme.html.erb
@@ -6,10 +6,10 @@
<%- if @has_test_bundle && !@suggested_themes %>
- <%= preload_script "locales/#{I18n.locale}" %>
<%= preload_script "vendor" %>
<%= preload_script "test-support" %>
<%= preload_script "discourse-for-tests" %>
+ <%= preload_script "locales/#{I18n.locale}" %>
<%= preload_script "admin" %>
<%- Discourse.find_plugin_js_assets(include_disabled: true).each do |file| %>
<%= preload_script file %>
diff --git a/lib/js_locale_helper.rb b/lib/js_locale_helper.rb
index 6ded40afd38..e3c0ad5d25d 100644
--- a/lib/js_locale_helper.rb
+++ b/lib/js_locale_helper.rb
@@ -222,10 +222,13 @@ module JsLocaleHelper
return "" if translations.blank?
output = +"if (!I18n.extras) { I18n.extras = {}; }"
- locales.each { |l| output << <<~JS }
+ locales.each do |l|
+ translations_json = translations[l].to_json
+ output << <<~JS
if (!I18n.extras["#{l}"]) { I18n.extras["#{l}"] = {}; }
- Object.assign(I18n.extras["#{l}"], #{translations[l].to_json});
+ Object.assign(I18n.extras["#{l}"], #{translations_json});
JS
+ end
output
end
diff --git a/spec/lib/js_locale_helper_spec.rb b/spec/lib/js_locale_helper_spec.rb
index 984fb64c66a..aef5ca44d8a 100644
--- a/spec/lib/js_locale_helper_spec.rb
+++ b/spec/lib/js_locale_helper_spec.rb
@@ -3,6 +3,27 @@
require "mini_racer"
RSpec.describe JsLocaleHelper do
+ let(:v8_ctx) do
+ node_modules = "#{Rails.root}/app/assets/javascripts/node_modules/"
+
+ transpiler = DiscourseJsProcessor::Transpiler.new
+ discourse_i18n =
+ transpiler.perform(
+ File.read("#{Rails.root}/app/assets/javascripts/discourse-i18n/src/index.js"),
+ "app/assets/javascripts/discourse",
+ "discourse-i18n",
+ )
+
+ ctx = MiniRacer::Context.new
+ ctx.load("#{node_modules}/loader.js/dist/loader/loader.js")
+ ctx.eval(discourse_i18n)
+ ctx.eval <<~JS
+ define("discourse/loader-shims", () => {})
+ JS
+ ctx.load("#{Rails.root}/app/assets/javascripts/locales/i18n.js")
+ ctx
+ end
+
module StubLoadTranslations
def set_translations(locale, translations)
@loaded_translations ||= HashWithIndifferentAccess.new
@@ -193,25 +214,24 @@ RSpec.describe JsLocaleHelper do
SiteSetting.default_locale = "ru"
I18n.locale = :uk
- ctx = MiniRacer::Context.new
- ctx.eval("var window = this;")
- ctx.load(Rails.root + "app/assets/javascripts/locales/i18n.js")
- ctx.eval(JsLocaleHelper.output_locale(I18n.locale))
- ctx.eval('I18n.defaultLocale = "ru";')
+ v8_ctx.eval(JsLocaleHelper.output_locale(I18n.locale))
+ v8_ctx.eval('I18n.defaultLocale = "ru";')
- expect(ctx.eval("I18n.translations").keys).to contain_exactly("uk", "en")
- expect(ctx.eval("I18n.translations.uk.js").keys).to contain_exactly(
+ expect(v8_ctx.eval("I18n.translations").keys).to contain_exactly("uk", "en")
+ expect(v8_ctx.eval("I18n.translations.uk.js").keys).to contain_exactly(
"all_three",
"english_and_user",
"only_user",
"site_and_user",
)
- expect(ctx.eval("I18n.translations.en.js").keys).to contain_exactly(
+ expect(v8_ctx.eval("I18n.translations.en.js").keys).to contain_exactly(
"only_english",
"english_and_site",
)
- expected.each { |key, expect| expect(ctx.eval("I18n.t(#{"js.#{key}".inspect})")).to eq(expect) }
+ expected.each do |key, expect|
+ expect(v8_ctx.eval("I18n.t(#{"js.#{key}".inspect})")).to eq(expect)
+ end
end
it "correctly evaluates message formats in en fallback" do
@@ -228,19 +248,16 @@ RSpec.describe JsLocaleHelper do
}
MF
- ctx = MiniRacer::Context.new
- ctx.eval("var window = this;")
- ctx.load(Rails.root + "app/assets/javascripts/locales/i18n.js")
- ctx.eval(JsLocaleHelper.output_locale("de"))
- ctx.eval(JsLocaleHelper.output_client_overrides("de"))
- ctx.eval(<<~JS)
+ v8_ctx.eval(JsLocaleHelper.output_locale("de"))
+ v8_ctx.eval(JsLocaleHelper.output_client_overrides("de"))
+ v8_ctx.eval(<<~JS)
for (let [key, value] of Object.entries(I18n._mfOverrides || {})) {
key = key.replace(/^[a-z_]*js\./, "");
I18n._compiledMFs[key] = value;
}
JS
- expect(ctx.eval("I18n.messageFormat('something_MF', { UNREAD: 1 })")).to eq(
+ expect(v8_ctx.eval("I18n.messageFormat('something_MF', { UNREAD: 1 })")).to eq(
"There is one unread",
)
end
@@ -248,10 +265,7 @@ RSpec.describe JsLocaleHelper do
LocaleSiteSetting.values.each do |locale|
it "generates valid date helpers for #{locale[:value]} locale" do
js = JsLocaleHelper.output_locale(locale[:value])
- ctx = MiniRacer::Context.new
- ctx.eval("var window = this;")
- ctx.load(Rails.root + "app/assets/javascripts/locales/i18n.js")
- ctx.eval(js)
+ v8_ctx.eval(js)
end
it "finds moment.js locale file for #{locale[:value]}" do