0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 01:10:43 +08:00
discourse/lib/js_locale_helper.rb
David Taylor d7de0f6735
DEV: Refactor locale bundle loading (#37114)
Previously, locale bundles were using `require()` directly, and were
very sensitive to load order. This made it very hard to refactor things,
especially for our upcoming move to Vite.

This commit updates the four types of locale bundle to contain only very
simple JS code, with no dependencies at the top level. They simply add
POJO and functions to the `window._discourse_locale_data` global. When
`discourse-i18n` is loaded, it checks that global, loads up the data,
and executes the functions (passing in any dependencies like
messageformat/runtime where required).

Changes in the HTML files are to bring the locale bundles up to the
first position, since they no longer have any dependencies, and we need
to be 100% sure they're loaded before discourse-i18n.

Tests are updated for the new bundle format, and the most complex ones
have been converted to system specs, so that we no longer need to create
a mock environment for executing messageformat/discourse-i18n/etc.

`lib/deprecated` changes (and associated pretty-text changes) are to
make the dependencies more formalized, because `require()` is subject to
race conditions, which started being hit following the locale refactor.

---------

Co-authored-by: Jarek Radosz <jradosz@gmail.com>
2026-01-16 11:45:14 +00:00

378 lines
12 KiB
Ruby
Vendored

# 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 ||= ActiveSupport::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 ||= ActiveSupport::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
if Rails.env.test?
def self.set_translations(locale, translations)
@loaded_translations ||= ActiveSupport::HashWithIndifferentAccess.new
@loaded_translations[locale] = translations
end
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(ActiveSupport::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)
# convert to exported function instead of module
result =
compiled.gsub(/import ({.*}) from (.*);\n/, "const \\1 = messageFormatModules[\\2];\n").sub(
"export default",
"return",
)
<<~JS
const localeData = window._discourse_locale_data ??= {};
localeData.messageFormatData = function (messageFormatModules) {
#{result.indent(2)}
}
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
const localeData = window._discourse_locale_data ??= {};
localeData.messageFormatData = function () {
console.error("Failed to compile message formats for #{locale}. Some translation strings will be missing.");
return {};
}
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
const localeData = window._discourse_locale_data ??= {};
JS
translations.keys.each do |l|
translations[l].keys.each { |k| translations[l].delete(k) unless k == "js" }
end
result << "localeData.translations = #{translations.to_json};\n"
result << "localeData.locale = '#{locale_str}';\n"
if fallback_locale_str && fallback_locale_str != "en"
result << "localeData.fallbackLocale = '#{fallback_locale_str}';\n"
end
result << <<~JS
localeData.configureMoment = function () {
if (!globalThis.moment) {
throw new Error("globalThis.moment not defined. Failed to initialize locales.")
}
#{moment_locale(locale_str)}
#{moment_locale(locale_str, timezone_names: true)}
#{moment_formats}
}
JS
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
<<~JS
const localeData = window._discourse_locale_data ??= {};
localeData.overrides = #{all_overrides.compact_blank.to_json};
JS
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
<<~JS
const localeData = window._discourse_locale_data ??= {};
localeData.extra ??= {};
localeData.extra[#{bundle.to_json}] = #{translations.to_json};
JS
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}/frontend/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)
if filename && File.exist?(filename)
<<~JS
(function(){
#{File.read(filename)}
}).apply(globalThis);
JS
else
""
end
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