mirror of
https://github.com/discourse/discourse.git
synced 2026-08-04 10:39:43 +08:00
This replaces the old ember-cli build with a modern Rolldown build. In local testing, this provides an 80% improvement in build times, while remaining 100% backwards compatible for themes and plugins. As part of this move, we have decided to stop using a proxy in front of Discourse for development. Development should now be done directly against the Rails server. `bin/ember-cli -u` has been replaced with `bin/dev`. This will launch Rails on `:3000`, and will run the rolldown build in the background. Log output from both processes will be shown with an appropriate prefix. You should visit `:3000` in your browser. `:4200` will no longer serve anything. To help with migration, `bin/ember-cli` is now a backwards-compatible shim. It will print help information, and will launch a lightweight server on `:4200` with instructions to move to `:3000`. If you prefer to launch Rails and the JS build as separate commands, you can still do that. Rails boot commands are unchanged, and the rolldown development builder can be run using `bin/dev --only ember`. https://meta.discourse.org/t/403908 --------- Co-authored-by: Jarek Radosz <jarek@cvx.dev> Co-authored-by: Chris Manson <chris@manson.ie>
379 lines
12 KiB
Ruby
Vendored
379 lines
12 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
module JsLocaleHelper
|
|
def self.plugin_client_files(locale_str)
|
|
files = Dir["#{Rails.root.join("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.join("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 { it.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.join("node_modules/@discourse/moment-timezone-names-translations/locales")}"
|
|
type = :moment_js_timezones
|
|
else
|
|
path = "#{Rails.root.join("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
|