From 2e00482ac4f0e066be4c371a5765240eca319084 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Thu, 12 Oct 2023 06:44:01 -0700 Subject: [PATCH] DEV: convert I18n pseudo package into real package (discourse-i18n) (#23867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, `window.I18n` is defined in an old school hand written script, inlined into locale/*.js by the Rails asset pipeline, and then the global variable is shimmed into a pseudo AMD module later in `module-shims.js`. This approach has some problems – for one thing, when we add a new V2 addon (e.g. in #23859), Embroider/Webpack is stricter about its dependencies and won't let you `import from "I18n";` when `"I18n"` isn't listed as one of its `dependencies` or `peerDependencies`. This moves `I18n` into a real package – `discourse-i18n`. (I was originally planning to keep the `I18n` name since it's a private package anyway, but NPM packages are supposed to have lower case names and that may cause problems with other tools.) This package defines and exports a regular class, but also defines the default global instance for backwards compatibility. We should use the exported class in tests to make one-off instances without mutating the global instance and having to clean it up after the test run. However, I did not attempt that refactor in this PR. Since `discourse-i18n` is now included by the app, the locale scripts needs to be loaded after the app chunks. Since no "real" work happens until later on when we kick things off in the boot script, the order in which the script tags appear shouldn't be a problem. Alternatively, we can rework the locale bundles to be more lazy like everything else, and require/import them into the app. I avoided renaming the imports in this commit since that would be quite noisy and drowns out the actual changes here. Instead, I used a Webpack alias to redirect the current `"I18n"` import to the new package for the time being. In a separate commit later on, I'll rename all the imports in oneshot and remove the alias. As always, plugins and the legacy bundles (admin/wizard) still relies on the runtime AMD shims regardless. For the most part, I avoided refactoring the actual I18n code too much other than making it a class, and some light stuff like `var` into `let`. However, now that it is in a reasonable format to work with (no longer inside the global script context!) it may also be a good opportunity to refactor and make clear what is intended to be public API vs internal implementation details. Speaking of, I took the librety to make `PLACEHOLDER`, `SEPARATOR` and `I18nMissingInterpolationArgument` actual constants since it seemed pretty clear to me those were just previously stashed on to the `I18n` global to avoid polluting the global namespace, rather than something we expect the consumers to set/replace. --- .eslintignore | 1 - .prettierignore | 1 - .../javascripts/bootstrap-json/index.js | 7 +- .../javascripts/discourse-i18n/addon-main.cjs | 4 + .../javascripts/discourse-i18n/package.json | 36 ++ .../javascripts/discourse-i18n/src/index.js | 384 ++++++++++++++++++ app/assets/javascripts/discourse/app/app.js | 7 +- .../javascripts/discourse/app/index.html | 2 + .../javascripts/discourse/app/loader-shims.js | 1 + .../javascripts/discourse/ember-cli-build.js | 8 + app/assets/javascripts/discourse/package.json | 1 + .../public/assets/scripts/module-shims.js | 17 +- .../javascripts/discourse/tests/index.html | 7 +- .../discourse/tests/setup-tests.js | 5 +- .../discourse/tests/unit/lib/i18n-test.js | 4 +- app/assets/javascripts/locales/i18n.js | 382 +---------------- app/assets/javascripts/package.json | 1 + app/views/layouts/application.html.erb | 9 +- app/views/qunit/theme.html.erb | 2 +- lib/js_locale_helper.rb | 7 +- spec/lib/js_locale_helper_spec.rb | 54 ++- 21 files changed, 512 insertions(+), 428 deletions(-) create mode 100644 app/assets/javascripts/discourse-i18n/addon-main.cjs create mode 100644 app/assets/javascripts/discourse-i18n/package.json create mode 100644 app/assets/javascripts/discourse-i18n/src/index.js 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