DEV: Upgrade the MessageFormat library (JS)

This patch upgrades the MessageFormat library to version 3.3.0 from
0.1.5.

Our `I18n.messageFormat` method signature is unchanged, and now uses the
new API under the hood.

We don’t need dedicated locale files for handling pluralization rules
anymore as everything is now included by the library itself.

The compilation of the messages now happens through our
`messageformat-wrapper` gem. It then outputs an ES module that includes
all its needed dependencies.

Most of the changes happen in `JsLocaleHelper` and in the `ExtraLocales`
controller.

A new method called `.output_MF` has been introduced in
`JsLocaleHelper`. It handles all the fetching, compiling and
transpiling to generate the proper MF messages in JS. Overrides and
fallbacks are also handled directly in this method.

The other main change is that now the MF translations are served through
the `ExtraLocales` controller instead of being statically compiled in a
JS file, then having to patch the messages using overrides and
fallbacks. Now the MF translations are just another bundle that is
created on the fly and cached by the client.
This commit is contained in:
Loïc Guitaut 2024-06-17 18:21:04 +02:00 committed by Loïc Guitaut
parent 6591a0654b
commit 301713ef96
103 changed files with 400 additions and 1079 deletions

View File

@ -89,6 +89,7 @@ gem "mini_sql"
gem "pry-rails", require: false
gem "pry-byebug", require: false
gem "rtlcss", require: false
gem "messageformat-wrapper", require: false
gem "rake"
gem "thor", require: false

View File

@ -234,6 +234,8 @@ GEM
memory_profiler (1.0.2)
message_bus (4.3.8)
rack (>= 1.1.3)
messageformat-wrapper (1.0.0)
mini_racer (>= 0.6.3)
method_source (1.1.0)
mini_mime (1.1.5)
mini_racer (0.9.0)
@ -646,6 +648,7 @@ DEPENDENCIES
maxminddb
memory_profiler
message_bus
messageformat-wrapper
mini_mime
mini_racer
mini_scheduler
@ -728,4 +731,4 @@ DEPENDENCIES
yard
BUNDLED WITH
2.5.3
2.5.9

View File

@ -17,7 +17,8 @@
"src"
],
"dependencies": {
"@embroider/addon-shim": "^1.8.9"
"@embroider/addon-shim": "^1.8.9",
"make-plural": "^7.4.0"
},
"engines": {
"node": ">= 18",

View File

@ -4,6 +4,8 @@ if (window.I18n) {
);
}
import * as Cardinals from "make-plural/cardinals";
// The placeholder format. Accepts `{{placeholder}}` and `%{placeholder}`.
const PLACEHOLDER = /(?:\{\{|%\{)(.*?)(?:\}\}?)/gm;
const SEPARATOR = ".";
@ -13,19 +15,14 @@ export class I18n {
defaultLocale = "en";
// Set current locale to null
local = null;
locale = 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";
},
};
pluralizationRules = Cardinals;
translate = (scope, options) => this._translate(scope, options);
@ -36,6 +33,13 @@ export class I18n {
return this.locale || this.defaultLocale;
}
get pluralizationNormalizedLocale() {
if (this.currentLocale() === "pt") {
return "pt_PT";
}
return this.currentLocale().replace(/[_-].*/, "");
}
enableVerboseLocalization() {
let counter = 0;
let keys = {};
@ -192,7 +196,9 @@ export class I18n {
options = this.prepareOptions(options);
let count = options.count.toString();
let pluralizer = this.pluralizer(options.locale || this.currentLocale());
let pluralizer = this.pluralizer(
options.locale || this.pluralizationNormalizedLocale
);
let key = pluralizer(Math.abs(count));
let keys = typeof key === "object" && key instanceof Array ? key : [key];
let message = this.findAndTranslateValidNode(keys, translation);
@ -371,6 +377,22 @@ export class I18n {
isValidNode(obj, node) {
return obj[node] !== null && obj[node] !== undefined;
}
messageFormat(key, options) {
const message = this._mfMessages.hasMessage(
key,
this._mfMessages.locale,
this._mfMessages.defaultLocale
);
if (!message) {
return "Missing Key: " + key;
}
try {
return this._mfMessages.get(key, options);
} catch (err) {
return err.message;
}
}
}
export class I18nMissingInterpolationArgument extends Error {

View File

@ -39,10 +39,5 @@ export default {
}
}
}
for (let [key, value] of Object.entries(I18n._mfOverrides || {})) {
key = key.replace(/^[a-z_]*js\./, "");
I18n._compiledMFs[key] = value;
}
},
};

View File

@ -64,3 +64,12 @@ loaderShim("truth-helpers/helpers/not", () =>
loaderShim("truth-helpers/helpers/or", () =>
importSync("truth-helpers/helpers/or")
);
loaderShim("@messageformat/runtime/messages", () =>
importSync("@messageformat/runtime/messages")
);
loaderShim("@messageformat/runtime", () =>
importSync("@messageformat/runtime")
);
loaderShim("@messageformat/runtime/lib/cardinals", () =>
importSync("@messageformat/runtime/lib/cardinals")
);

View File

@ -3,7 +3,7 @@ const Yaml = require("js-yaml");
const fs = require("fs");
const concat = require("broccoli-concat");
const mergeTrees = require("broccoli-merge-trees");
const MessageFormat = require("messageformat");
const MessageFormat = require("@messageformat/core");
const deepmerge = require("deepmerge");
const glob = require("glob");
const { shouldLoadPlugins } = require("discourse-plugins");
@ -34,7 +34,7 @@ class TranslationPlugin extends Plugin {
} else if (key.endsWith("_MF")) {
// omit locale.js
let mfPath = subpath.slice(2).join(".");
formats[mfPath] = this.mf.precompile(this.mf.parse(value));
formats[mfPath] = this.mf.compile(value);
}
});
}
@ -74,11 +74,19 @@ class TranslationPlugin extends Plugin {
formats = Object.entries(formats).map(([k, v]) => `"${k}": ${v}`);
let contents = `
I18n.locale = 'en';
I18n.translations = ${JSON.stringify(parsed)};
I18n.extras = ${JSON.stringify(extras)};
MessageFormat = { locale: {} };
I18n._compiledMFs = { ${formats.join(",\n")} };
(function() {
I18n.locale = 'en';
I18n.translations = ${JSON.stringify(parsed)};
I18n.extras = ${JSON.stringify(extras)};
const Messages = require("@messageformat/runtime/messages").default;
const { number, plural, select } = require("@messageformat/runtime");
const { en } = require("@messageformat/runtime/lib/cardinals");
const msgData = { en: { ${formats.join(",\n")} } };
const messages = new Messages(msgData, "en");
messages.defaultLocale = "en";
I18n._mfMessages = messages;
})()
`;
fs.writeFileSync(
@ -110,7 +118,6 @@ module.exports.createI18nTree = function (discourseRoot, vendorJs) {
mergeTrees([
vendorJs,
discourseRoot + "/app/assets/javascripts/locales",
discourseRoot + "/lib/javascripts",
en,
]),
{
@ -118,17 +125,10 @@ module.exports.createI18nTree = function (discourseRoot, vendorJs) {
"i18n.js",
"moment.js",
"moment-timezone-with-data.js",
"messageformat-lookup.js",
"locale/en.js",
"client.en.js",
],
headerFiles: [
"i18n.js",
"moment.js",
"moment-timezone-with-data.js",
"messageformat-lookup.js",
],
footerFiles: ["client.en.js", "locale/en.js"],
headerFiles: ["i18n.js", "moment.js", "moment-timezone-with-data.js"],
footerFiles: ["client.en.js"],
outputFile: `assets/test-i18n.js`,
}
);

View File

@ -20,7 +20,9 @@
"@faker-js/faker": "^8.4.1",
"@glimmer/syntax": "^0.92.0",
"@highlightjs/cdn-assets": "^11.10.0",
"@messageformat/runtime": "^3.0.1",
"ace-builds": "^1.35.2",
"decorator-transforms": "^2.0.0",
"discourse-hbr": "1.0.0",
"discourse-widget-hbs": "1.0.0",
"ember-route-template": "^1.0.3",
@ -29,8 +31,7 @@
"highlight.js": "^11.10.0",
"jspreadsheet-ce": "^4.13.4",
"morphlex": "^0.0.16",
"pretty-text": "1.0.0",
"decorator-transforms": "^2.0.0"
"pretty-text": "1.0.0"
},
"devDependencies": {
"@babel/core": "^7.24.7",
@ -105,7 +106,6 @@
"js-yaml": "^4.1.0",
"loader.js": "^4.7.0",
"message-bus-client": "^4.3.8",
"messageformat": "0.1.5",
"pretender": "^3.4.7",
"qunit": "^2.21.0",
"qunit-dom": "^3.2.0",

View File

@ -72,6 +72,13 @@ module("Unit | Utility | i18n", function (hooks) {
with_multiple_interpolate_arguments: "Hi %{username}, %{username2}",
},
},
ja: {
js: {
topic_stat_sentence_week: {
other: "先週、新しいトピックが %{count} 件投稿されました。",
},
},
},
};
// fake pluralization rules
@ -172,18 +179,6 @@ module("Unit | Utility | i18n", function (hooks) {
},
},
};
I18n.pluralizationRules.pl_PL = function (n) {
if (n === 1) {
return "one";
}
if (n % 10 >= 2 && n % 10 <= 4) {
return "few";
}
if (n % 10 === 0) {
return "many";
}
return "other";
};
assert.strictEqual(
I18n.t("admin.dashboard.title"),
@ -218,6 +213,20 @@ module("Unit | Utility | i18n", function (hooks) {
assert.strictEqual(I18n.t("word_count", { count: 3 }), "3 words");
assert.strictEqual(I18n.t("word_count", { count: 10 }), "10 words");
assert.strictEqual(I18n.t("word_count", { count: 100 }), "100 words");
I18n.locale = "ja";
assert.strictEqual(
I18n.t("topic_stat_sentence_week", { count: 0 }),
"先週、新しいトピックが 0 件投稿されました。"
);
assert.strictEqual(
I18n.t("topic_stat_sentence_week", { count: 1 }),
"先週、新しいトピックが 1 件投稿されました。"
);
assert.strictEqual(
I18n.t("topic_stat_sentence_week", { count: 2 }),
"先週、新しいトピックが 2 件投稿されました。"
);
});
test("adds the count to the missing translation strings", function (assert) {
@ -323,4 +332,28 @@ module("Unit | Utility | i18n", function (hooks) {
I18n.testing = false;
}
});
test("pluralizationNormalizedLocale", function (assert) {
I18n.locale = "pt";
assert.strictEqual(
I18n.pluralizationNormalizedLocale,
"pt_PT",
"returns 'pt_PT' for the 'pt' locale, this is a special case of the 'make-plural' lib."
);
Object.entries({
pt_BR: "pt",
en_GB: "en",
bs_BA: "bs",
"fr-BE": "fr",
}).forEach(([raw, normalized]) => {
I18n.locale = raw;
assert.strictEqual(
I18n.pluralizationNormalizedLocale,
normalized,
`returns '${normalized}' for '${raw}'`
);
});
});
});

View File

@ -10,9 +10,7 @@ module("initializer:localization", function (hooks) {
this._locale = I18n.locale;
this._translations = I18n.translations;
this._extras = I18n.extras;
this._compiledMFs = I18n._compiledMFs;
this._overrides = I18n._overrides;
this._mfOverrides = I18n._mfOverrides;
I18n.locale = "fr";
@ -37,10 +35,6 @@ module("initializer:localization", function (hooks) {
},
};
I18n._compiledMFs = {
"user.messages.some_key_MF": () => "user.messages.some_key_MF (FR)",
};
I18n.extras = {
fr: {
admin: {
@ -67,9 +61,7 @@ module("initializer:localization", function (hooks) {
I18n.locale = this._locale;
I18n.translations = this._translations;
I18n.extras = this._extras;
I18n._compiledMFs = this._compiledMFs;
I18n._overrides = this._overrides;
I18n._mfOverrides = this._mfOverrides;
});
test("translation overrides", function (assert) {
@ -159,21 +151,6 @@ module("initializer:localization", function (hooks) {
);
});
test("translation overrides for MessageFormat strings", function (assert) {
I18n._mfOverrides = {
"js.user.messages.some_key_MF": () =>
"user.messages.some_key_MF (FR override)",
};
LocalizationInitializer.initialize(this.owner);
assert.strictEqual(
I18n.messageFormat("user.messages.some_key_MF", {}),
"user.messages.some_key_MF (FR override)",
"overrides existing MessageFormat string"
);
});
test("skip translation override if parent node is not an object", function (assert) {
I18n._overrides = {
fr: {

View File

@ -11,6 +11,61 @@ class ExtraLocalesController < ApplicationController
OVERRIDES_BUNDLE ||= "overrides"
MD5_HASH_LENGTH ||= 32
MF_BUNDLE = "mf"
BUNDLES = [OVERRIDES_BUNDLE, MF_BUNDLE]
class << self
def js_digests
@js_digests ||= {}
end
def bundle_js_hash(bundle)
bundle_key = "#{bundle}_#{I18n.locale}"
if bundle.in?(BUNDLES)
site = RailsMultisite::ConnectionManagement.current_db
js_digests[site] ||= {}
js_digests[site][bundle_key] ||= begin
js = bundle_js(bundle)
js.present? ? Digest::MD5.hexdigest(js) : nil
end
else
js_digests[bundle_key] ||= Digest::MD5.hexdigest(bundle_js(bundle))
end
end
def url(bundle)
"#{Discourse.base_path}/extra-locales/#{bundle}?v=#{bundle_js_hash(bundle)}"
end
def client_overrides_exist?
bundle_js_hash(OVERRIDES_BUNDLE).present?
end
def bundle_js(bundle)
locale_str = I18n.locale.to_s
bundle_str = "#{bundle}_js"
case bundle
when OVERRIDES_BUNDLE
JsLocaleHelper.output_client_overrides(locale_str)
when MF_BUNDLE
JsLocaleHelper.output_MF(locale_str)
else
JsLocaleHelper.output_extra_locales(bundle_str, locale_str)
end
end
def bundle_js_with_hash(bundle)
js = bundle_js(bundle)
[js, Digest::MD5.hexdigest(js)]
end
def clear_cache!
site = RailsMultisite::ConnectionManagement.current_db
js_digests.delete(site)
end
end
def show
bundle = params[:bundle]
@ -18,60 +73,18 @@ class ExtraLocalesController < ApplicationController
version = params[:v]
if version.present?
if version.kind_of?(String) && version.length == MD5_HASH_LENGTH
hash = ExtraLocalesController.bundle_js_hash(bundle)
immutable_for(1.year) if hash == version
else
raise Discourse::InvalidParameters.new(:v)
end
raise Discourse::InvalidParameters.new(:v) unless version.to_s.size == MD5_HASH_LENGTH
end
render plain: ExtraLocalesController.bundle_js(bundle), content_type: "application/javascript"
end
content, hash = ExtraLocalesController.bundle_js_with_hash(bundle)
immutable_for(1.year) if hash == version
def self.bundle_js_hash(bundle)
if bundle == OVERRIDES_BUNDLE
site = RailsMultisite::ConnectionManagement.current_db
@by_site ||= {}
@by_site[site] ||= {}
@by_site[site][I18n.locale] ||= begin
js = bundle_js(bundle)
js.present? ? Digest::MD5.hexdigest(js) : nil
end
else
@bundle_js_hash ||= {}
@bundle_js_hash["#{bundle}_#{I18n.locale}"] ||= Digest::MD5.hexdigest(bundle_js(bundle))
end
end
def self.url(bundle)
"#{Discourse.base_path}/extra-locales/#{bundle}?v=#{bundle_js_hash(bundle)}"
end
def self.client_overrides_exist?
bundle_js_hash(OVERRIDES_BUNDLE).present?
end
def self.bundle_js(bundle)
locale_str = I18n.locale.to_s
bundle_str = "#{bundle}_js"
if bundle == OVERRIDES_BUNDLE
JsLocaleHelper.output_client_overrides(locale_str)
else
JsLocaleHelper.output_extra_locales(bundle_str, locale_str)
end
end
def self.clear_cache!
site = RailsMultisite::ConnectionManagement.current_db
@by_site&.delete(site)
render plain: content, content_type: "application/javascript"
end
private
def valid_bundle?(bundle)
bundle == OVERRIDES_BUNDLE || (bundle =~ /\A(admin|wizard)\z/ && current_user&.staff?)
bundle.in?(BUNDLES) || (bundle =~ /\A(admin|wizard)\z/ && current_user&.staff?)
end
end

View File

@ -48,6 +48,14 @@ class TranslationOverride < ActiveRecord::Base
attribute :status, :integer
enum status: { up_to_date: 0, outdated: 1, invalid_interpolation_keys: 2, deprecated: 3 }
scope :mf_locales, ->(locale) { where(locale: locale).where("translation_key LIKE '%_MF'") }
scope :client_locales,
->(locale) do
where(locale: locale)
.where("translation_key LIKE 'js.%' OR translation_key LIKE 'admin_js.%'")
.where.not("translation_key LIKE '%_MF'")
end
def self.upsert!(locale, key, value)
params = { locale: locale, translation_key: key }
@ -58,10 +66,6 @@ class TranslationOverride < ActiveRecord::Base
I18n.overrides_disabled { I18n.t(transform_pluralized_key(key), locale: :en) }
data = { value: sanitized_value, original_translation: original_translation }
if key.end_with?("_MF")
_, filename = JsLocaleHelper.find_message_format_locale([locale], fallback_to_english: false)
data[:compiled_js] = JsLocaleHelper.compile_message_format(filename, locale, sanitized_value)
end
params.merge!(data) if translation_override.new_record?
i18n_changed(locale, [key]) if translation_override.update(data)

View File

@ -37,8 +37,9 @@
<%- end %>
<%= preload_script "locales/#{I18n.locale}" %>
<%= preload_script_url ExtraLocalesController.url("mf") %>
<%- if ExtraLocalesController.client_overrides_exist? %>
<%= preload_script_url ExtraLocalesController.url('overrides') %>
<%= preload_script_url ExtraLocalesController.url("overrides") %>
<%- end %>
<%- if staff? %>

View File

@ -11,6 +11,7 @@
<%= preload_script "discourse" %>
<%= preload_script "test" %>
<%= preload_script "locales/#{I18n.locale}" %>
<%= preload_script_url ExtraLocalesController.url("mf") %>
<%= preload_script "admin" %>
<%- Discourse.find_plugin_js_assets(include_disabled: true).each do |file| %>
<%= preload_script file %>
@ -50,7 +51,7 @@
<% elsif @suggested_themes %>
<h2>Theme QUnit Test Runner</h2>
<%- if @suggested_themes.size == 0 %>
<%- if @suggested_themes.empty? %>
<p>Cannot find any theme tests.</p>
<%- else %>
<h3>Select a theme/component: </h3>

View File

@ -916,7 +916,6 @@ module Discourse
PrettyText.reset_context
DiscourseJsProcessor::Transpiler.reset_context if defined?(DiscourseJsProcessor::Transpiler)
JsLocaleHelper.reset_context if defined?(JsLocaleHelper)
# warm up v8 after fork, that way we do not fork a v8 context
# it may cause issues if bg threads in a v8 isolate randomly stop

View File

@ -1,6 +0,0 @@
MessageFormat.locale.af = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.am = function(n) {
if (n === 0 || n == 1) {
return 'one';
}
return 'other';
};

View File

@ -1,18 +0,0 @@
MessageFormat.locale.ar = function(n) {
if (n === 0) {
return 'zero';
}
if (n == 1) {
return 'one';
}
if (n == 2) {
return 'two';
}
if ((n % 100) >= 3 && (n % 100) <= 10 && n == Math.floor(n)) {
return 'few';
}
if ((n % 100) >= 11 && (n % 100) <= 99 && n == Math.floor(n)) {
return 'many';
}
return 'other';
};

View File

@ -1,14 +0,0 @@
MessageFormat.locale.be = function (n) {
var r10 = n % 10, r100 = n % 100;
if (r10 == 1 && r100 != 11)
return 'one';
if (r10 >= 2 && r10 <= 4 && (r100 < 12 || r100 > 14) && n == Math.floor(n))
return 'few';
if ((r10 == 0 || (r10 >= 5 && r10 <= 9) || (r100 >= 11 && r100 <= 14)) && n == Math.floor(n))
return 'many';
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.bg = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.bn = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,18 +0,0 @@
MessageFormat.locale.br = function (n) {
if (n === 0) {
return 'zero';
}
if (n == 1) {
return 'one';
}
if (n == 2) {
return 'two';
}
if (n == 3) {
return 'few';
}
if (n == 6) {
return 'many';
}
return 'other';
};

View File

@ -1,10 +0,0 @@
MessageFormat.locale.bs = function (n) {
if ((n % 10) == 1 && (n % 100) != 11) {
return 'one';
}
if ((n % 10) >= 2 && (n % 10) <= 4 &&
((n % 100) < 12 || (n % 100) > 14) && n == Math.floor(n)) {
return 'few';
}
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.ca = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,9 +0,0 @@
MessageFormat.locale.cs = function (n) {
if (n == 1) {
return 'one';
}
if (n == 2 || n == 3 || n == 4) {
return 'few';
}
return 'other';
};

View File

@ -1,18 +0,0 @@
MessageFormat.locale.cy = function (n) {
if (n === 0) {
return 'zero';
}
if (n == 1) {
return 'one';
}
if (n == 2) {
return 'two';
}
if (n == 3) {
return 'few';
}
if (n == 6) {
return 'many';
}
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.da = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.de = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.el = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.en = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.es = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.et = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.eu = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.fa = function ( n ) {
return "other";
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.fa_IR = function ( n ) {
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.fi = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.fil = function(n) {
if (n === 0 || n == 1) {
return 'one';
}
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.fr = function (n) {
if (n >= 0 && n < 2) {
return 'one';
}
return 'other';
};

View File

@ -1,9 +0,0 @@
MessageFormat.locale.ga = function (n) {
if (n == 1) {
return 'one';
}
if (n == 2) {
return 'two';
}
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.gl = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.gsw = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.gu = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.he = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.hi = function(n) {
if (n === 0 || n == 1) {
return 'one';
}
return 'other';
};

View File

@ -1,10 +0,0 @@
MessageFormat.locale.hr = function (n) {
if ((n % 10) == 1 && (n % 100) != 11) {
return 'one';
}
if ((n % 10) >= 2 && (n % 10) <= 4 &&
((n % 100) < 12 || (n % 100) > 14) && n == Math.floor(n)) {
return 'few';
}
return 'other';
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.hu = function(n) {
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.hy = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.id = function(n) {
return 'other';
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale["in"] = function(n) {
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.is = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.it = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.iw = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.ja = function ( n ) {
return "other";
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.kn = function ( n ) {
return "other";
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.ko = function ( n ) {
return "other";
};

View File

@ -1,9 +0,0 @@
MessageFormat.locale.lag = function (n) {
if (n === 0) {
return 'zero';
}
if (n > 0 && n < 2) {
return 'one';
}
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.ln = function(n) {
if (n === 0 || n == 1) {
return 'one';
}
return 'other';
};

View File

@ -1,10 +0,0 @@
MessageFormat.locale.lt = function (n) {
if ((n % 10) == 1 && ((n % 100) < 11 || (n % 100) > 19)) {
return 'one';
}
if ((n % 10) >= 2 && (n % 10) <= 9 &&
((n % 100) < 11 || (n % 100) > 19) && n == Math.floor(n)) {
return 'few';
}
return 'other';
};

View File

@ -1,9 +0,0 @@
MessageFormat.locale.lv = function (n) {
if (n === 0) {
return 'zero';
}
if ((n % 10) == 1 && (n % 100) != 11) {
return 'one';
}
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.mk = function (n) {
if ((n % 10) == 1 && n != 11) {
return 'one';
}
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.ml = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,10 +0,0 @@
MessageFormat.locale.mo = function (n) {
if (n == 1) {
return 'one';
}
if (n === 0 || n != 1 && (n % 100) >= 1 &&
(n % 100) <= 19 && n == Math.floor(n)) {
return 'few';
}
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.mr = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.ms = function ( n ) {
return "other";
};

View File

@ -1,12 +0,0 @@
MessageFormat.locale.mt = function (n) {
if (n == 1) {
return 'one';
}
if (n === 0 || ((n % 100) >= 2 && (n % 100) <= 4 && n == Math.floor(n))) {
return 'few';
}
if ((n % 100) >= 11 && (n % 100) <= 19 && n == Math.floor(n)) {
return 'many';
}
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.nl = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.no = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.or = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,15 +0,0 @@
MessageFormat.locale.pl_PL = function (n) {
if (n == 1) {
return 'one';
}
if ((n % 10) >= 2 && (n % 10) <= 4 &&
((n % 100) < 12 || (n % 100) > 14) && n == Math.floor(n)) {
return 'few';
}
if ((n % 10) === 0 || n != 1 && (n % 10) == 1 ||
((n % 10) >= 5 && (n % 10) <= 9 || (n % 100) >= 12 && (n % 100) <= 14) &&
n == Math.floor(n)) {
return 'many';
}
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.pt = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.pt_BR = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,10 +0,0 @@
MessageFormat.locale.ro = function (n) {
if (n == 1) {
return 'one';
}
if (n === 0 || n != 1 && (n % 100) >= 1 &&
(n % 100) <= 19 && n == Math.floor(n)) {
return 'few';
}
return 'other';
};

View File

@ -1,16 +0,0 @@
MessageFormat.locale.ru = function (n) {
var r10 = n % 10, r100 = n % 100;
if (r10 == 1 && r100 != 11)
return 'one';
if (r10 >= 2 && r10 <= 4 && (r100 < 12 || r100 > 14) && n == Math.floor(n))
return 'few';
if (r10 === 0 || (r10 >= 5 && r10 <= 9) ||
(r100 >= 11 && r100 <= 14) && n == Math.floor(n)) {
return 'many';
}
return 'other';
};

View File

@ -1,9 +0,0 @@
MessageFormat.locale.shi = function(n) {
if (n >= 0 && n <= 1) {
return 'one';
}
if (n >= 2 && n <= 10 && n == Math.floor(n)) {
return 'few';
}
return 'other';
};

View File

@ -1,9 +0,0 @@
MessageFormat.locale.sk = function (n) {
if (n == 1) {
return 'one';
}
if (n == 2 || n == 3 || n == 4) {
return 'few';
}
return 'other';
};

View File

@ -1,12 +0,0 @@
MessageFormat.locale.sl = function (n) {
if ((n % 100) == 1) {
return 'one';
}
if ((n % 100) == 2) {
return 'two';
}
if ((n % 100) == 3 || (n % 100) == 4) {
return 'few';
}
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.sq = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,11 +0,0 @@
MessageFormat.locale.sr = function (n) {
var r10 = n % 10, r100 = n % 100;
if (r10 == 1 && r100 != 11)
return 'one';
if (r10 >= 2 && r10 <= 4 && (r100 < 12 || r100 > 14) && n == Math.floor(n))
return 'few';
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.sv = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.sw = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.ta = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.te = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.th = function ( n ) {
return "other";
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.tl = function(n) {
if (n === 0 || n == 1) {
return 'one';
}
return 'other';
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.tr = function(n) {
return 'other';
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.tr_TR = function(n) {
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.ug = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,15 +0,0 @@
MessageFormat.locale.uk = function (n) {
if ((n % 10) == 1 && (n % 100) != 11) {
return 'one';
}
if ((n % 10) >= 2 && (n % 10) <= 4 &&
((n % 100) < 12 || (n % 100) > 14) && n == Math.floor(n)) {
return 'few';
}
if ((n % 10) === 0 || ((n % 10) >= 5 && (n % 10) <= 9) ||
((n % 100) >= 11 && (n % 100) <= 14) && n == Math.floor(n)) {
// return 'many';
return 'other'; // TODO should be "many" but is not defined in translations
}
return 'other';
};

View File

@ -1,6 +0,0 @@
MessageFormat.locale.ur = function ( n ) {
if ( n === 1 ) {
return "one";
}
return "other";
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.vi = function ( n ) {
return "other";
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.zh = function ( n ) {
return "other";
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.zh_CN = function ( n ) {
return "other";
};

View File

@ -1,3 +0,0 @@
MessageFormat.locale.zh_TW = function ( n ) {
return "other";
};

View File

@ -1,14 +0,0 @@
(function () {
I18n.messageFormat = function (key, options) {
var fn = I18n._compiledMFs[key];
if (fn) {
try {
return fn(options);
} catch (err) {
return err.message;
}
} else {
return "Missing Key: " + key;
}
};
})();

View File

@ -117,14 +117,14 @@ module JsLocaleHelper
@loaded_merges = nil
end
def self.translations_for(locale_str)
def self.translations_for(locale_str, no_fallback: false)
clear_cache! if Rails.env.development?
locale_sym = locale_str.to_sym
translations =
I18n.with_locale(locale_sym) do
if locale_sym == :en
if locale_sym == :en || no_fallback
load_translations(locale_sym)
else
load_translations_merged(*I18n.fallbacks[locale_sym])
@ -134,14 +134,43 @@ module JsLocaleHelper
Marshal.load(Marshal.dump(translations))
end
def self.output_MF(locale)
require "messageformat"
message_formats =
I18n.fallbacks[locale]
.each_with_object({}) do |l, hash|
translations = translations_for(l, no_fallback: true)
hash[l.to_s.dasherize] = remove_message_formats!(translations, l).merge(
TranslationOverride
.mf_locales(l)
.pluck(:translation_key, :value)
.to_h
.transform_keys { _1.sub(/^[a-z_]*js\./, "") },
)
end
.compact_blank
compiled = MessageFormat.compile(message_formats.keys, message_formats)
transpiled = DiscourseJsProcessor.transpile(<<~JS, "", "discourse-mf")
import Messages from '@messageformat/runtime/messages';
#{compiled.sub("export default", "const msgData =")};
const messages = new Messages(msgData, "#{locale.to_s.dasherize}");
messages.defaultLocale = "en";
globalThis.I18n._mfMessages = messages;
JS
<<~JS
#{transpiled}
require("discourse-mf");
JS
end
def self.output_locale(locale)
locale_str = locale.to_s
fallback_locale_str = LocaleSiteSetting.fallback_locale(locale_str)&.to_s
translations = translations_for(locale_str)
message_formats = remove_message_formats!(translations, locale)
mf_locale, mf_filename = find_message_format_locale([locale_str], fallback_to_english: true)
result = generate_message_format(message_formats, mf_locale, mf_filename)
remove_message_formats!(translations, locale)
result = +""
translations.keys.each do |l|
translations[l].keys.each { |k| translations[l].delete(k) unless k == "js" }
@ -153,9 +182,6 @@ module JsLocaleHelper
if fallback_locale_str && fallback_locale_str != "en"
result << "I18n.fallbackLocale = '#{fallback_locale_str}';\n"
end
if mf_locale != "en"
result << "I18n.pluralizationRules.#{locale_str} = MessageFormat.locale.#{mf_locale};\n"
end
# moment
result << File.read("#{Rails.root}/vendor/assets/javascripts/moment.js")
@ -168,44 +194,24 @@ module JsLocaleHelper
end
def self.output_client_overrides(main_locale)
all_overrides = {}
has_overrides = false
I18n.fallbacks[main_locale].each do |locale|
overrides =
all_overrides[locale] = TranslationOverride
.where(locale: locale)
.where("translation_key LIKE 'js.%' OR translation_key LIKE 'admin_js.%'")
.pluck(:translation_key, :value, :compiled_js)
has_overrides ||= overrides.present?
end
return "" if !has_overrides
result = +"I18n._overrides = {};"
existing_keys = Set.new
message_formats = []
all_overrides.each do |locale, overrides|
translations = {}
overrides.each do |key, value, compiled_js|
next if existing_keys.include?(key)
existing_keys << key
if key.end_with?("_MF")
message_formats << "#{key.inspect}: #{compiled_js}"
else
translations[key] = value
locales = I18n.fallbacks[main_locale]
all_overrides =
locales
.each_with_object({}) do |locale, overrides|
overrides[locale] = TranslationOverride
.client_locales(locale)
.pluck(:translation_key, :value)
.to_h
end
end
.compact_blank
result << "I18n._overrides['#{locale}'] = #{translations.to_json};" if translations.present?
return "" if all_overrides.blank?
all_overrides.reduce do |(_, main_overrides), (_, fallback_overrides)|
fallback_overrides.slice!(*fallback_overrides.keys - main_overrides.keys)
end
result << "I18n._mfOverrides = {#{message_formats.join(", ")}};"
result
"I18n._overrides = #{all_overrides.compact_blank.to_json};"
end
def self.output_extra_locales(bundle, locale)
@ -251,11 +257,6 @@ module JsLocaleHelper
end
end
def self.find_message_format_locale(locale_chain, fallback_to_english:)
path = "#{Rails.root}/lib/javascripts/locale"
find_locale(locale_chain, path, :message_format, fallback_to_english: fallback_to_english)
end
def self.find_locale(locale_chain, path, type, fallback_to_english:)
locale_chain.map!(&:to_s)
@ -301,55 +302,6 @@ module JsLocaleHelper
filename && File.exist?(filename) ? File.read(filename) << "\n" : ""
end
def self.generate_message_format(message_formats, locale, filename)
formats =
message_formats
.map { |k, v| k.inspect << " : " << compile_message_format(filename, locale, v) }
.join(", ")
result = +"MessageFormat = {locale: {}};\n"
result << "I18n._compiledMFs = {#{formats}};\n"
result << File.read(filename) << "\n"
if locale != "en"
# Include "en" pluralization rules for use in fallbacks
_, en_filename = find_message_format_locale(["en"], fallback_to_english: false)
result << File.read(en_filename) << "\n"
end
result << File.read("#{Rails.root}/lib/javascripts/messageformat-lookup.js") << "\n"
end
def self.reset_context
@ctx&.dispose
@ctx = nil
end
@mutex = Mutex.new
def self.with_context
@mutex.synchronize do
yield(
@ctx ||=
begin
ctx = MiniRacer::Context.new(timeout: 15_000, ensure_gc_after_idle: 2000)
ctx.load("#{Rails.root}/node_modules/messageformat/messageformat.js")
ctx
end
)
end
end
def self.compile_message_format(path, locale, format)
with_context do |ctx|
ctx.load(path) if File.exist?(path)
ctx.eval("mf = new MessageFormat('#{locale}');")
ctx.eval("mf.precompile(mf.parse(#{format.inspect}))")
end
rescue MiniRacer::EvalError => e
message = +"Invalid Format: " << e.message
"function(){ return #{message.inspect};}"
end
def self.remove_message_formats!(translations, locale)
message_formats = {}
I18n.fallbacks[locale]

View File

@ -1275,13 +1275,6 @@ class Plugin::Instance
locale_chain = opts[:fallbackLocale] ? [locale, opts[:fallbackLocale]] : [locale]
lib_locale_path = File.join(root_path, "lib/javascripts/locale")
path = File.join(lib_locale_path, "message_format")
opts[:message_format] = find_locale_file(locale_chain, path)
opts[:message_format] = JsLocaleHelper.find_message_format_locale(
locale_chain,
fallback_to_english: false,
) unless opts[:message_format]
path = File.join(lib_locale_path, "moment_js")
opts[:moment_js] = find_locale_file(locale_chain, path)
opts[:moment_js] = JsLocaleHelper.find_moment_locale(locale_chain) unless opts[:moment_js]
@ -1357,8 +1350,7 @@ class Plugin::Instance
def valid_locale?(custom_locale)
File.exist?(custom_locale[:client_locale_file]) &&
File.exist?(custom_locale[:server_locale_file]) &&
File.exist?(custom_locale[:js_locale_file]) && custom_locale[:message_format] &&
custom_locale[:moment_js]
File.exist?(custom_locale[:js_locale_file]) && custom_locale[:moment_js]
end
def find_locale_file(locale_chain, path)

View File

@ -55,7 +55,6 @@ task "qunit:test", %i[timeout qunit_path filter] do |_, args|
begin
success = true
test_path = "#{Rails.root}/test"
qunit_path = args[:qunit_path]
filter = args[:filter]

View File

@ -13,6 +13,7 @@
"@glint/environment-ember-template-imports": "^1.4.0",
"@glint/template": "^1.4.0",
"@json-editor/json-editor": "2.10.0",
"@messageformat/core": "^3.3.0",
"@mixer/parallel-prettier": "^2.0.3",
"chart.js": "3.5.1",
"chartjs-plugin-datalabels": "2.2.0",

View File

@ -5,23 +5,27 @@ require "mini_racer"
RSpec.describe JsLocaleHelper do
let(:v8_ctx) do
node_modules = "#{Rails.root}/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("var window = globalThis;")
ctx.eval(discourse_i18n)
{
"@messageformat/runtime/messages": "#{node_modules}/@messageformat/runtime/esm/messages.js",
"@messageformat/runtime": "#{node_modules}/@messageformat/runtime/esm/runtime.js",
"@messageformat/runtime/lib/cardinals":
"#{node_modules}/@messageformat/runtime/esm/cardinals.js",
"make-plural/cardinals": "#{node_modules}/make-plural/cardinals.mjs",
"discourse-i18n": "#{Rails.root}/app/assets/javascripts/discourse-i18n/src/index.js",
}.each do |module_name, path|
ctx.eval(transpiler.perform(File.read(path), "", module_name.to_s))
end
ctx.eval <<~JS
define("discourse/loader-shims", () => {})
JS
ctx.load("#{Rails.root}/app/assets/javascripts/locales/i18n.js")
# As there are circular references in the return value, this raises an
# error if we let MiniRacer try to convert the value to JSON. Forcing
# returning `null` from `#eval` will prevent that.
ctx.eval("#{File.read("#{Rails.root}/app/assets/javascripts/locales/i18n.js")};null")
ctx
end
@ -54,116 +58,6 @@ RSpec.describe JsLocaleHelper do
end
end
describe "message format" do
def message_format_filename(locale)
Rails.root + "lib/javascripts/locale/#{locale}.js"
end
def setup_message_format(format)
filename = message_format_filename("en")
compiled = JsLocaleHelper.compile_message_format(filename, "en", format)
@ctx = MiniRacer::Context.new
@ctx.eval("MessageFormat = {locale: {}};")
@ctx.load(filename)
@ctx.eval("var test = #{compiled}")
end
def localize(opts)
@ctx.eval("test(#{opts.to_json})")
end
it "handles plurals" do
setup_message_format(
"{NUM_RESULTS, plural,
one {1 result}
other {# results}
}",
)
expect(localize(NUM_RESULTS: 1)).to eq("1 result")
expect(localize(NUM_RESULTS: 2)).to eq("2 results")
end
it "handles double plurals" do
setup_message_format(
"{NUM_RESULTS, plural,
one {1 result}
other {# results}
} and {NUM_APPLES, plural,
one {1 apple}
other {# apples}
}",
)
expect(localize(NUM_RESULTS: 1, NUM_APPLES: 2)).to eq("1 result and 2 apples")
expect(localize(NUM_RESULTS: 2, NUM_APPLES: 1)).to eq("2 results and 1 apple")
end
it "handles select" do
setup_message_format("{GENDER, select, male {He} female {She} other {They}} read a book")
expect(localize(GENDER: "male")).to eq("He read a book")
expect(localize(GENDER: "female")).to eq("She read a book")
expect(localize(GENDER: "none")).to eq("They read a book")
end
it "can strip out message formats" do
hash = { "a" => "b", "c" => { "d" => { "f_MF" => "bob" } } }
expect(JsLocaleHelper.strip_out_message_formats!(hash)).to eq("c.d.f_MF" => "bob")
expect(hash["c"]["d"]).to eq({})
end
it "handles message format special keys" do
JsLocaleHelper.set_translations(
"en",
"en" => {
"js" => {
"hello" => "world",
"test_MF" => "{HELLO} {COUNT, plural, one {1 duck} other {# ducks}}",
"error_MF" => "{{BLA}",
"simple_MF" => "{COUNT, plural, one {1} other {#}}",
},
"admin_js" => {
"foo_MF" => "{HELLO} {COUNT, plural, one {1 duck} other {# ducks}}",
},
},
)
ctx = MiniRacer::Context.new
ctx.eval("I18n = { pluralizationRules: {} };")
ctx.eval(JsLocaleHelper.output_locale("en"))
expect(ctx.eval('I18n.translations["en"]["js"]["hello"]')).to eq("world")
expect(ctx.eval('I18n.translations["en"]["js"]["test_MF"]')).to eq(nil)
expect(ctx.eval('I18n.messageFormat("test_MF", { HELLO: "hi", COUNT: 3 })')).to eq(
"hi 3 ducks",
)
expect(ctx.eval('I18n.messageFormat("error_MF", { HELLO: "hi", COUNT: 3 })')).to match(
/Invalid Format/,
)
expect(ctx.eval('I18n.messageFormat("missing", {})')).to match(/missing/)
expect(ctx.eval('I18n.messageFormat("simple_MF", {})')).to match(/COUNT/) # error
expect(ctx.eval('I18n.messageFormat("foo_MF", { HELLO: "hi", COUNT: 4 })')).to eq(
"hi 4 ducks",
)
end
it "load pluralization rules before precompile" do
message = JsLocaleHelper.compile_message_format(message_format_filename("ru"), "ru", "format")
expect(message).not_to match "Plural Function not found"
end
it "uses message formats from fallback locale" do
translations = JsLocaleHelper.translations_for(:en_GB)
en_gb_message_formats = JsLocaleHelper.remove_message_formats!(translations, :en_GB)
expect(en_gb_message_formats).to_not be_empty
translations = JsLocaleHelper.translations_for(:en)
en_message_formats = JsLocaleHelper.remove_message_formats!(translations, :en)
expect(en_gb_message_formats).to eq(en_message_formats)
end
end
it "performs fallbacks to English if a translation is not available" do
JsLocaleHelper.set_translations(
"en",
@ -235,35 +129,6 @@ RSpec.describe JsLocaleHelper do
end
end
it "correctly evaluates message formats in en fallback" do
allow_missing_translations do
JsLocaleHelper.set_translations("en", "en" => { "js" => { "something_MF" => "en mf" } })
JsLocaleHelper.set_translations("de", "de" => { "js" => { "something_MF" => "de mf" } })
TranslationOverride.upsert!("en", "js.something_MF", <<~MF.strip)
There {
UNREAD, plural,
=0 {are no}
one {is one unread}
other {are # unread}
}
MF
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(v8_ctx.eval("I18n.messageFormat('something_MF', { UNREAD: 1 })")).to eq(
"There is one unread",
)
end
end
LocaleSiteSetting.values.each do |locale|
it "generates valid date helpers for #{locale[:value]} locale" do
js = JsLocaleHelper.output_locale(locale[:value])
@ -281,31 +146,86 @@ RSpec.describe JsLocaleHelper do
end
end
describe ".find_message_format_locale" do
it "finds locale's message format rules" do
locale, filename =
JsLocaleHelper.find_message_format_locale([:de], fallback_to_english: false)
expect(locale).to eq("de")
expect(filename).to end_with("/de.js")
describe ".output_MF" do
let(:output) { described_class.output_MF(locale).gsub(/^import.*$/, "") }
let(:generated_locales) { v8_ctx.eval("Object.keys(I18n._mfMessages._data)") }
let(:translated_message) do
v8_ctx.eval("I18n._mfMessages.get('posts_likes_MF', {count: 3, ratio: 'med'})")
end
let!(:overriden_translation) do
Fabricate(
:translation_override,
translation_key: "admin_js.admin.user.penalty_history_MF",
value: "OVERRIDEN",
)
end
it "finds locale for en_GB" do
locale, filename =
JsLocaleHelper.find_message_format_locale([:en_GB], fallback_to_english: false)
expect(locale).to eq("en")
expect(filename).to end_with("/en.js")
before { v8_ctx.eval(output) }
locale, filename =
JsLocaleHelper.find_message_format_locale(["en_GB"], fallback_to_english: false)
expect(locale).to eq("en")
expect(filename).to end_with("/en.js")
context "when locale is 'en'" do
let(:locale) { "en" }
it "generates messages for the 'en' locale only" do
expect(generated_locales).to eq %w[en]
end
it "translates messages properly" do
expect(
translated_message,
).to eq "This topic has 3 replies with a very high like to post ratio\n"
end
context "when the translation is overriden" do
let(:translated_message) do
v8_ctx.eval(
"I18n._mfMessages.get('admin.user.penalty_history_MF', { SUSPENDED: 3, SILENCED: 2 })",
)
end
it "returns the overriden translation" do
expect(translated_message).to eq "OVERRIDEN"
end
end
end
it "falls back to en when locale doesn't have own message format rules" do
locale, filename =
JsLocaleHelper.find_message_format_locale([:nonexistent], fallback_to_english: true)
expect(locale).to eq("en")
expect(filename).to end_with("/en.js")
context "when locale is not 'en'" do
let(:locale) { "fr" }
it "generates messages for the current locale and uses 'en' as fallback" do
expect(generated_locales).to match(%w[fr en])
end
it "translates messages properly" do
expect(
translated_message,
).to eq "Ce sujet comprend 3 réponses avec un taux très élevé de « J'aime » par message\n"
end
context "when a translation is missing" do
before { v8_ctx.eval("delete I18n._mfMessages._data.fr.posts_likes_MF") }
it "returns the fallback translation" do
expect(
translated_message,
).to eq "This topic has 3 replies with a very high like to post ratio\n"
end
context "when the fallback translation is overriden" do
let(:translated_message) do
v8_ctx.eval(
"I18n._mfMessages.get('admin.user.penalty_history_MF', { SUSPENDED: 3, SILENCED: 2 })",
)
end
before do
v8_ctx.eval("delete I18n._mfMessages._data.fr['admin.user.penalty_history_MF']")
end
it "returns the overriden fallback translation" do
expect(translated_message).to eq "OVERRIDEN"
end
end
end
end
end
end

View File

@ -523,9 +523,6 @@ TEXT
expect(DiscoursePluginRegistry.locales).to have_key(:foo_BAR)
expect(locale[:fallbackLocale]).to be_nil
expect(locale[:message_format]).to eq(
["foo_BAR", "#{plugin_path}/lib/javascripts/locale/message_format/foo_BAR.js"],
)
expect(locale[:moment_js]).to eq(
["foo_BAR", "#{plugin_path}/lib/javascripts/locale/moment_js/foo_BAR.js"],
)
@ -536,9 +533,6 @@ TEXT
expect(Rails.configuration.assets.precompile).to include("locales/foo_BAR.js")
expect(
JsLocaleHelper.find_message_format_locale(["foo_BAR"], fallback_to_english: true),
).to eq(locale[:message_format])
expect(JsLocaleHelper.find_moment_locale(["foo_BAR"])).to eq(locale[:moment_js])
expect(JsLocaleHelper.find_moment_locale(["foo_BAR"], timezone_names: true)).to eq(
locale[:moment_js_timezones],
@ -552,9 +546,6 @@ TEXT
expect(DiscoursePluginRegistry.locales).to have_key(:tup)
expect(locale[:fallbackLocale]).to eq("pt_BR")
expect(locale[:message_format]).to eq(
["pt_BR", "#{Rails.root}/lib/javascripts/locale/pt_BR.js"],
)
expect(locale[:moment_js]).to eq(
["pt-br", "#{Rails.root}/vendor/assets/javascripts/moment-locale/pt-br.js"],
)
@ -565,9 +556,6 @@ TEXT
expect(Rails.configuration.assets.precompile).to include("locales/tup.js")
expect(JsLocaleHelper.find_message_format_locale(["tup"], fallback_to_english: true)).to eq(
locale[:message_format],
)
expect(JsLocaleHelper.find_moment_locale(["tup"])).to eq(locale[:moment_js])
end
@ -578,9 +566,6 @@ TEXT
expect(DiscoursePluginRegistry.locales).to have_key(:tlh)
expect(locale[:fallbackLocale]).to be_nil
expect(locale[:message_format]).to eq(
["tlh", "#{plugin_path}/lib/javascripts/locale/message_format/tlh.js"],
)
expect(locale[:moment_js]).to eq(
["tlh", "#{Rails.root}/vendor/assets/javascripts/moment-locale/tlh.js"],
)
@ -588,9 +573,6 @@ TEXT
expect(Rails.configuration.assets.precompile).to include("locales/tlh.js")
expect(JsLocaleHelper.find_message_format_locale(["tlh"], fallback_to_english: true)).to eq(
locale[:message_format],
)
expect(JsLocaleHelper.find_moment_locale(["tlh"])).to eq(locale[:moment_js])
end
@ -602,7 +584,6 @@ TEXT
%w[
config/locales/client.foo_BAR.yml
config/locales/server.foo_BAR.yml
lib/javascripts/locale/message_format/foo_BAR.js
lib/javascripts/locale/moment_js/foo_BAR.js
assets/locales/foo_BAR.js.erb
].each do |path|

Some files were not shown because too many files have changed in this diff Show More