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:
parent
5d632fd30a
commit
2e00482ac4
|
@ -1,4 +1,3 @@
|
|||
app/assets/javascripts/locales/i18n.js
|
||||
app/assets/javascripts/ember-addons/
|
||||
lib/javascripts/locale/
|
||||
lib/javascripts/messageformat.js
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
"use strict";
|
||||
|
||||
const { addonV1Shim } = require("@embroider/addon-shim");
|
||||
module.exports = addonV1Shim(__dirname);
|
|
@ -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": {}
|
||||
}
|
||||
}
|
|
@ -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();
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"));
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
"discourse",
|
||||
"discourse-common",
|
||||
"discourse-hbr",
|
||||
"discourse-i18n",
|
||||
"discourse-plugins",
|
||||
"discourse-widget-hbs",
|
||||
"ember-cli-progress-ci",
|
||||
|
|
|
@ -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" %>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue