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

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