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