mirror of
https://github.com/discourse/discourse.git
synced 2026-08-05 15:18:34 +08:00
The /about page (unlike /guidelines or /tos) is not backed by a topic, but instead via a bunch of site settings. If it were backed by a topic, localization would be easy as it can just use TopicLocalizations. We also can't swap it out to use a topic, since there are so many fields on the about page that can't be safely contained in a topic. ---------- This PR adds SiteSettingLocalization, allowing new attributes in site settings config for localization. For now this is wired into the About page settings flow: - admins can select a non-default locale on /admin/config/about - only translatable About fields are shown for that locale - localized About values are used on /about and /about.json - company_url can be set manually per locale, but is excluded from automatic translation - ~~AI backfill can translate eligible site setting localizations~~ no backfill (added in https://github.com/discourse/discourse/pull/41123/commits/386c655d2f44aca54440c069bd06ea8007793d84, removed in https://github.com/discourse/discourse/pull/41123/commits/6f8ec9cc43dca4195bda638b1ce980eb68a59941. about page has very non automa-ble fields This intentionally keeps the scope narrow. It does not make every site setting localizable, and any other setting can be localized by adding to that allowlist. Some screenshots |page | 📸 | |--|--| | eng setting | <img width="1278" height="871" alt="Screenshot 2026-06-23 at 11 00 34 PM" src="https://github.com/user-attachments/assets/59431464-fb05-41d9-8c4f-fd9aae7fb45f" /> | ja setting | <img width="1278" height="871" alt="Screenshot 2026-06-23 at 11 01 20 PM" src="https://github.com/user-attachments/assets/73cd2878-f59a-4dc4-a76d-2f46b74a6eed" /> | ja anon about page | <img width="1278" height="871" alt="Screenshot 2026-06-23 at 11 01 48 PM" src="https://github.com/user-attachments/assets/02464a5f-9ca0-4519-9521-be41498c1967" />
180 lines
5.1 KiB
Ruby
Vendored
180 lines
5.1 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
class SiteSettingLocalization < ActiveRecord::Base
|
|
include LocaleMatchable
|
|
|
|
ABOUT_CONFIG_PARAM_MAP = {
|
|
general_settings: {
|
|
name: "title",
|
|
summary: "site_description",
|
|
extended_description: "extended_site_description",
|
|
},
|
|
}.freeze
|
|
|
|
class << self
|
|
def localizable_settings
|
|
SiteSetting.localizable_settings.deep_merge(registered_settings)
|
|
end
|
|
|
|
def registered_settings
|
|
@registered_settings ||= {}
|
|
end
|
|
|
|
def localizable_setting_names
|
|
localizable_settings.keys
|
|
end
|
|
|
|
def register(setting_name, **options)
|
|
registered_settings[setting_name.to_s] = options.symbolize_keys
|
|
end
|
|
|
|
def localizable?(setting_name)
|
|
localizable_settings.key?(setting_name.to_s) && SiteSetting.respond_to?(setting_name)
|
|
end
|
|
|
|
def value_for(setting_name, locale:, cooked: false, fallback: nil, show_original: false)
|
|
fallback = SiteSetting.public_send(setting_name) if fallback.nil? &&
|
|
SiteSetting.respond_to?(setting_name)
|
|
|
|
return fallback if show_original
|
|
return fallback if locale.blank?
|
|
return fallback if !SiteSetting.content_localization_enabled?
|
|
return fallback if !localizable?(setting_name)
|
|
|
|
localization = lookup(setting_name, locale)
|
|
return fallback if localization.blank?
|
|
|
|
if cooked
|
|
localization.cooked.presence || fallback
|
|
else
|
|
localization.value.presence || fallback
|
|
end
|
|
end
|
|
|
|
def lookup(setting_name, locale)
|
|
locale = normalize_locale(locale)
|
|
scope = where(setting_name: setting_name.to_s)
|
|
scope.find_by(locale:) || scope.matching_locale(locale).order(:locale).first
|
|
end
|
|
|
|
def normalize_locale(locale)
|
|
locale.to_s.tr("-", "_")
|
|
end
|
|
|
|
def supported_content_locale?(locale)
|
|
normalized_locale = normalize_locale(locale)
|
|
return false if normalized_locale == normalize_locale(SiteSetting.default_locale)
|
|
|
|
SiteSetting
|
|
.content_localization_supported_locales_map
|
|
.map { |supported_locale| normalize_locale(supported_locale) }
|
|
.include?(normalized_locale)
|
|
end
|
|
|
|
def about_config_setting_names
|
|
ABOUT_CONFIG_PARAM_MAP.values.flat_map(&:values)
|
|
end
|
|
|
|
def about_config_settings_from_params(params)
|
|
ABOUT_CONFIG_PARAM_MAP.flat_map do |section_name, param_map|
|
|
section_params = params[section_name.to_s] || params[section_name]
|
|
next [] if section_params.blank?
|
|
|
|
param_map.filter_map do |param_name, setting_name|
|
|
if section_params.key?(param_name.to_s) || section_params.key?(param_name)
|
|
value =
|
|
if section_params.key?(param_name.to_s)
|
|
section_params[param_name.to_s]
|
|
else
|
|
section_params[param_name]
|
|
end
|
|
{ setting_name:, value: value.to_s }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def about_config_payload(locale)
|
|
localizations =
|
|
where(locale:, setting_name: about_config_setting_names)
|
|
.where.not(value: "")
|
|
.index_by(&:setting_name)
|
|
.transform_values do |localization|
|
|
{ value: localization.value, cooked: localization.cooked }
|
|
end
|
|
|
|
{ locale:, localizations: }
|
|
end
|
|
end
|
|
|
|
before_validation :normalize_setting_name
|
|
before_validation :normalize_locale
|
|
before_validation :cook_value
|
|
|
|
validates :setting_name, presence: true
|
|
validates :locale, presence: true, length: { maximum: 20 }
|
|
validates :value, presence: true
|
|
validates :setting_name, uniqueness: { scope: :locale }
|
|
validate :setting_is_localizable
|
|
validate :value_length_is_valid
|
|
validate :cooked_value_is_supported
|
|
|
|
def supports_cooked?
|
|
self.class.localizable_settings.dig(setting_name, :cooked)
|
|
end
|
|
|
|
private
|
|
|
|
def normalize_setting_name
|
|
self.setting_name = setting_name.to_s
|
|
end
|
|
|
|
def normalize_locale
|
|
self.locale = self.class.normalize_locale(locale)
|
|
end
|
|
|
|
def cook_value
|
|
return if !supports_cooked?
|
|
|
|
self.cooked = value.present? ? PrettyText.markdown(value) : nil
|
|
end
|
|
|
|
def setting_is_localizable
|
|
return if self.class.localizable?(setting_name)
|
|
|
|
errors.add(:setting_name, :inclusion)
|
|
end
|
|
|
|
def value_length_is_valid
|
|
max_length = self.class.localizable_settings.dig(setting_name, :max_length)
|
|
return if max_length.blank?
|
|
return if value.blank? || value.length <= max_length
|
|
|
|
errors.add(:value, :too_long, count: max_length)
|
|
end
|
|
|
|
def cooked_value_is_supported
|
|
return if cooked.blank? || supports_cooked?
|
|
|
|
errors.add(:cooked, :present)
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: site_setting_localizations
|
|
#
|
|
# id :bigint not null, primary key
|
|
# cooked :text
|
|
# locale :string(20) not null
|
|
# setting_name :string not null
|
|
# value :text not null
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# localizer_user_id :integer
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_site_setting_localizations_on_locale (locale)
|
|
# index_site_setting_localizations_on_setting_name_and_locale (setting_name,locale) UNIQUE
|
|
#
|