mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 01:10:43 +08:00
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>
378 lines
12 KiB
Ruby
Vendored
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
|