discourse/lib/js_locale_helper.rb
David Taylor c62a4a4759
PERF: Compile main locale bundles just-in-time (#32335)
Previously all locale bundles would be built & compressed during
assets:precompile. For most sites, only one of these languages was
actually used, so this is fairly wasteful.

This commit moves the main locale bundle into the
ExtraLocalesController, which has recently undergone many improvements
to make it more efficient. This allows locale files to be bundled "just
in time" when they're first accessed.

Now that brotli level=6 is enabled for these assets in our nginx config,
this change should have no impact on the locale bundle size.
2025-04-28 10:31:27 +01:00

359 lines
11 KiB
Ruby

# frozen_string_literal: true
module JsLocaleHelper
def self.plugin_client_files(locale_str)
files = Dir["#{Rails.root}/plugins/*/config/locales/client*.#{locale_str}.yml"]
I18n::Backend::DiscourseI18n.sort_locale_files(files)
end
def self.reloadable_plugins(locale_sym, ctx)
return unless Rails.env.development?
I18n.fallbacks[locale_sym].each do |locale|
plugin_client_files(locale.to_s).each { |file| ctx.depend_on(file) }
end
end
def self.plugin_translations(locale_str)
@plugin_translations ||= HashWithIndifferentAccess.new
@plugin_translations[locale_str] ||= begin
translations = {}
plugin_client_files(locale_str).each do |file|
if plugin_translations = YAML.load_file(file)[locale_str]
translations.deep_merge!(plugin_translations)
end
end
translations
end
end
def self.load_translations(locale)
@loaded_translations ||= HashWithIndifferentAccess.new
@loaded_translations[locale] ||= begin
locale_str = locale.to_s
# load default translations
yml_file = "#{Rails.root}/config/locales/client.#{locale_str}.yml"
if File.exist?(yml_file)
translations = YAML.load_file(yml_file)
else
# If we can't find a base file in Discourse, it might only exist in a plugin
# so let's start with a basic object we can merge into
translations = { locale_str => { "js" => {}, "admin_js" => {}, "wizard_js" => {} } }
end
# merge translations (plugin translations overwrite default translations)
if translations[locale_str] && plugin_translations(locale_str)
translations[locale_str]["js"] ||= {}
translations[locale_str]["admin_js"] ||= {}
translations[locale_str]["wizard_js"] ||= {}
if plugin_translations(locale_str)["js"]
translations[locale_str]["js"].deep_merge!(plugin_translations(locale_str)["js"])
end
if plugin_translations(locale_str)["admin_js"]
translations[locale_str]["admin_js"].deep_merge!(
plugin_translations(locale_str)["admin_js"],
)
end
if plugin_translations(locale_str)["wizard_js"]
translations[locale_str]["wizard_js"].deep_merge!(
plugin_translations(locale_str)["wizard_js"],
)
end
end
translations
end
end
# deeply removes keys from "deleting_from" that are already present in "checking_hashes"
def self.deep_delete_matches(deleting_from, checking_hashes)
checking_hashes.compact!
new_hash = deleting_from.dup
deleting_from.each do |key, value|
if value.is_a?(Hash)
new_at_key = deep_delete_matches(deleting_from[key], checking_hashes.map { |h| h[key] })
if new_at_key.empty?
new_hash.delete(key)
else
new_hash[key] = new_at_key
end
else
new_hash.delete(key) if checking_hashes.any? { |h| h.include?(key) }
end
end
new_hash
end
def self.load_translations_merged(*locales)
locales = locales.uniq.compact
@loaded_merges ||= {}
@loaded_merges[locales.join("-")] ||= begin
all_translations = {}
merged_translations = {}
loaded_locales = []
locales
.map(&:to_s)
.each do |locale|
all_translations[locale] = load_translations(locale)
merged_translations[locale] = deep_delete_matches(
all_translations[locale][locale],
loaded_locales.map { |l| merged_translations[l] },
)
loaded_locales << locale
end
merged_translations
end
end
def self.clear_cache!
@loaded_translations = nil
@plugin_translations = nil
@loaded_merges = nil
end
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 || no_fallback
load_translations(locale_sym)
else
load_translations_merged(*I18n.fallbacks[locale_sym])
end
end
Marshal.load(Marshal.dump(translations))
end
def self.output_MF(locale)
require "messageformat"
message_formats =
I18n.fallbacks[locale]
.each_with_object(HashWithIndifferentAccess.new) do |l, hash|
translations = translations_for(l, no_fallback: true)
hash[l] = 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
js_message_formats = message_formats.transform_keys(&:dasherize)
compiled = MessageFormat.compile(js_message_formats.keys, js_message_formats, strict: false)
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
rescue => e
js_locale = locale.to_s.dasherize
message_formats[locale]
.filter_map do |key, value|
next if MessageFormat.compile(js_locale, value, strict: false)
rescue StandardError
key
end
.then do |strings|
Rails.logger.error(
"Failed to compile message formats for #{locale}.\n\nBroken strings are: #{strings.join(", ")}\n\nError: #{e}",
)
end
<<~JS
console.error("Failed to compile message formats for #{locale}. Some translation strings will be missing.");
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)
remove_message_formats!(translations, locale)
result = +<<~JS
require("discourse/loader-shims");
require("discourse-i18n");
JS
translations.keys.each do |l|
translations[l].keys.each { |k| translations[l].delete(k) unless k == "js" }
end
# I18n
result << "I18n.translations = #{translations.to_json};\n"
result << "I18n.locale = '#{locale_str}';\n"
if fallback_locale_str && fallback_locale_str != "en"
result << "I18n.fallbackLocale = '#{fallback_locale_str}';\n"
end
result << <<~JS
require("discourse/lib/load-moment");
JS
result << moment_locale(locale_str)
result << moment_locale(locale_str, timezone_names: true)
result << moment_formats
result
end
def self.output_client_overrides(main_locale)
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
.compact_blank
return "" if all_overrides.blank?
all_overrides.reduce do |(_, main_overrides), (_, fallback_overrides)|
fallback_overrides.slice!(*fallback_overrides.keys - main_overrides.keys)
end
"I18n._overrides = #{all_overrides.compact_blank.to_json};"
end
def self.output_extra_locales(bundle, locale)
translations = translations_for(locale)
locales = translations.keys
locales.each do |l|
translations[l].keys.each do |k|
bundle_translations = translations[l].delete(k)
translations[l].deep_merge!(bundle_translations) if k == bundle
end
end
return "" if translations.blank?
output = +"if (!I18n.extras) { I18n.extras = {}; }"
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_json});
JS
end
output
end
MOMENT_LOCALE_MAPPING = { "hy" => "hy-am", "ug" => "ug-cn" }
def self.find_moment_locale(locale_chain, timezone_names: false)
if timezone_names
path = "#{Rails.root}/node_modules/@discourse/moment-timezone-names-translations/locales"
type = :moment_js_timezones
else
path = "#{Rails.root}/app/assets/javascripts/discourse/node_modules/moment/locale"
type = :moment_js
end
find_locale(locale_chain, path, type, fallback_to_english: false) do |locale|
locale = MOMENT_LOCALE_MAPPING[locale] if MOMENT_LOCALE_MAPPING.key?(locale)
# moment.js uses a different naming scheme for locale files
locale.tr("_", "-").downcase
end
end
def self.find_locale(locale_chain, path, type, fallback_to_english:)
locale_chain.map!(&:to_s)
locale_chain.each do |locale|
plugin_locale = DiscoursePluginRegistry.locales[locale]
return plugin_locale[type] if plugin_locale&.has_key?(type)
locale = yield(locale) if block_given?
filename = File.join(path, "#{locale}.js")
return locale, filename if File.exist?(filename)
end
locale_chain.map! { |locale| yield(locale) } if block_given?
# try again, but this time only with the language itself
locale_chain =
locale_chain.map { |l| l.split(/[-_]/)[0] }.uniq.reject { |l| locale_chain.include?(l) }
if locale_chain.any?
locale_data = find_locale(locale_chain, path, type, fallback_to_english: false)
return locale_data if locale_data
end
# English should always work
["en", File.join(path, "en.js")] if fallback_to_english
end
def self.moment_formats
result = +""
result << moment_format_function("short_date_no_year")
result << moment_format_function("short_date")
result << moment_format_function("long_date")
result << "moment.fn.relativeAge = function(opts){ return Discourse.Formatter.relativeAge(this.toDate(), opts)};\n"
end
def self.moment_format_function(name)
format = I18n.t("dates.#{name}")
"moment.fn.#{name.camelize(:lower)} = function(){ return this.format('#{format}'); };\n"
end
def self.moment_locale(locale, timezone_names: false)
_, filename = find_moment_locale([locale], timezone_names: timezone_names)
filename && File.exist?(filename) ? File.read(filename) << "\n" : ""
end
def self.remove_message_formats!(translations, locale)
message_formats = {}
I18n.fallbacks[locale]
.map(&:to_s)
.each do |l|
next unless translations.key?(l)
%w[js admin_js].each do |k|
message_formats.merge!(strip_out_message_formats!(translations[l][k]))
end
end
message_formats
end
def self.strip_out_message_formats!(hash, prefix = "", message_formats = {})
if hash.is_a?(Hash)
hash.each do |key, value|
if value.is_a?(Hash)
message_formats.merge!(
strip_out_message_formats!(value, join_key(prefix, key), message_formats),
)
elsif key.to_s.end_with?("_MF")
message_formats[join_key(prefix, key)] = value
hash.delete(key)
end
end
end
message_formats
end
def self.join_key(prefix, key)
prefix.blank? ? key : "#{prefix}.#{key}"
end
end