DEV: convert I18n pseudo package into real package (discourse-i18n) (#23867)

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.
This commit is contained in:
Godfrey Chan 2023-10-12 06:44:01 -07:00 committed by GitHub
parent 5d632fd30a
commit 2e00482ac4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 512 additions and 428 deletions

View File

@ -1,4 +1,3 @@
app/assets/javascripts/locales/i18n.js
app/assets/javascripts/ember-addons/
lib/javascripts/locale/
lib/javascripts/messageformat.js

View File

@ -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/

View File

@ -91,14 +91,13 @@ function head(buffer, bootstrap, headers, baseURL) {
function localeScript(buffer, bootstrap) {
buffer.push(`<script defer src="${bootstrap.locale_script}"></script>`);
(bootstrap.extra_locales || []).forEach((l) =>
buffer.push(`<script defer src="${l}"></script>`)
);
}
function beforeScriptLoad(buffer, bootstrap) {
buffer.push(bootstrap.html.before_script_load);
localeScript(buffer, bootstrap);
(bootstrap.extra_locales || []).forEach((l) =>
buffer.push(`<script defer src="${l}"></script>`)
);
}
function discoursePreloadStylesheets(buffer, bootstrap) {

View File

@ -0,0 +1,4 @@
"use strict";
const { addonV1Shim } = require("@embroider/addon-shim");
module.exports = addonV1Shim(__dirname);

View File

@ -0,0 +1,36 @@
{
"name": "discourse-i18n",
"version": "1.0.0",
"private": true,
"description": "Discourse's i18n",
"author": "Discourse <team@discourse.org>",
"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": {}
}
}

View File

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

View File

@ -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";

View File

@ -32,6 +32,8 @@
<ember-auto-import-scripts defer entrypoint="app"></ember-auto-import-scripts>
<script defer src="{{rootURL}}assets/discourse.js"></script>
</discourse-chunked-script>
<!-- bootstrap-content locale-script -->
</head>
<body>
<discourse-assets>

View File

@ -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"));

View File

@ -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 (

View File

@ -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",

View File

@ -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);
};
});

View File

@ -40,9 +40,6 @@
height: 1000px;
}
</style>
<script src="{{rootURL}}assets/test-i18n.js" data-embroider-ignore></script>
<script src="{{rootURL}}assets/test-site-settings.js" data-embroider-ignore></script>
</head>
<body>
{{content-for "body"}} {{content-for "test-body"}}
@ -51,7 +48,7 @@
<discourse-chunked-script entrypoint="vendor">
<script src="{{rootURL}}assets/vendor.js"></script>
</discourse-chunked-script>
<discourse-chunked-script entrypoint="test-support">
<script src="{{rootURL}}assets/test-support.js"></script>
<ember-auto-import-scripts entrypoint="tests"></ember-auto-import-scripts>
@ -63,6 +60,8 @@
<script defer src="{{rootURL}}assets/tests.js" data-embroider-ignore></script> <!-- Will 404 under embroider. Can be removed once we drop legacy build. -->
</discourse-chunked-script>
<script src="{{rootURL}}assets/test-i18n.js" data-embroider-ignore></script>
<script src="{{rootURL}}assets/test-site-settings.js" data-embroider-ignore></script>
<script src="{{rootURL}}assets/markdown-it-bundle.js" data-embroider-ignore></script>
<script src="{{rootURL}}assets/admin.js" data-embroider-ignore></script>
<script src="{{rootURL}}assets/wizard.js" data-embroider-ignore></script>

View File

@ -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";

View File

@ -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 {

View File

@ -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");

View File

@ -11,6 +11,7 @@
"discourse",
"discourse-common",
"discourse-hbr",
"discourse-i18n",
"discourse-plugins",
"discourse-widget-hbs",
"ember-cli-progress-ci",

View File

@ -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" %>

View File

@ -6,10 +6,10 @@
<meta name="color-scheme" content="light dark">
<%- 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 %>

View File

@ -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

View File

@ -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