FEATURE: Serve localized content in the site's default locale when user's language is unsupported (#36160)

The content localization feature we have (e.g. on meta) currently
assumes that everyone has set their user preferences language to
a language that we support.

If Eleni sets Greek as her Interface language, it is a non-supported
language. Posts written in Chinese or French will remain in Chinese or
French, as there is no Greek translation.

The commit shows Eleni localized posts in English (i.e. site default locale)
when a post is written in a different language. A site setting
`content_localization_use_default_locale_when_unsupported` is default
true and introduced.
This commit is contained in:
Natalie Tay
2025-11-21 17:19:04 +08:00
committed by GitHub
parent 3183a95823
commit 6cc063b845
8 changed files with 152 additions and 22 deletions
+14 -3
View File
@@ -5,9 +5,13 @@ module Localizable
included { has_many :localizations, class_name: "#{model_name}Localization", dependent: :destroy }
# Returns the localization for the given locale, or the best match if an exact match is not found.
# Returns the localization for (in order of priority):
# - the given locale,
# - or the best match if an exact match is not found
# - or the site default locale if `content_localization_use_default_locale_when_unsupported` enabled
#
# The query used to find the localization is optimized for performance, and assumes
# that localizations are indexed by locale, and have been preloaded where necessary.
# that localizations are indexed by locale, and have been preloaded.
# @return [Localization, nil] the localization object for the given locale, or nil if no match is found.
def get_localization(locale = I18n.locale)
locale_str = locale.to_s.sub("-", "_")
@@ -17,7 +21,14 @@ module Localizable
return match
end
localizations.find { |l| LocaleNormalizer.is_same?(l.locale, locale_str) }
if match = localizations.find { |l| LocaleNormalizer.is_same?(l.locale, locale_str) }
return match
end
if SiteSetting.content_localization_use_default_locale_when_unsupported
default_locale = SiteSetting.default_locale.to_s.sub("-", "_")
localizations.find { |l| LocaleNormalizer.is_same?(l.locale, default_locale) }
end
end
def in_user_locale?
+1
View File
@@ -2773,6 +2773,7 @@ en:
content_localization_allowed_groups: "Groups allowed to update localized content. Requires 'content localization enabled'."
content_localization_language_switcher: "Show a language switcher in the header, allowing visitors to switch between translated versions of Discourse and user-contributed content. Uses languages defined in 'content localization supported locales'"
content_localization_crawler_param: "Serve localized content to web crawlers when 'content localization enabled' and 'set locale from param' is enabled. The list of supported locales is defined in 'content localization supported locales'."
content_localization_use_default_locale_when_unsupported: "Serve localized content in the site's default locale to users whose preferred language is not listed in 'content localization supported locales'."
enable_upcoming_changes: "Enable upcoming changes"
fake_upcoming_change: "This is a fake upcoming change for testing purposes. No need to translate this string."
+3
View File
@@ -1745,6 +1745,9 @@ content_localization:
content_localization_crawler_param:
default: false
area: "localization"
content_localization_use_default_locale_when_unsupported:
default: true
area: "localization"
email:
email_time_window_mins:
+1 -1
View File
@@ -432,7 +432,7 @@ RSpec.describe Tag do
end
it "returns nil when no localization exists" do
Fabricate(:tag_localization, tag:, locale: "en")
Fabricate(:tag_localization, tag:, locale: "el")
expect(tag.get_localization("es")).to be_nil
end
+20 -6
View File
@@ -22,13 +22,27 @@ RSpec.describe BasicPostSerializer do
expect(json[:cooked]).to eq(post.cooked)
end
it "returns the localized cooked" do
SiteSetting.content_localization_enabled = true
Fabricate(:post_localization, post: post, cooked: "X", locale: "ja")
I18n.locale = "ja"
post.update!(locale: "en")
describe "localizations" do
it "returns the localized cooked" do
SiteSetting.content_localization_enabled = true
Fabricate(:post_localization, post: post, cooked: "X", locale: "ja")
I18n.locale = "ja"
post.update!(locale: "en")
expect(json[:cooked]).to eq("X")
expect(json[:cooked]).to eq("X")
end
it "returns the site default locale cooked when no exact match found and `content_localization_use_default_locale_when_unsupported` is true" do
SiteSetting.content_localization_enabled = true
SiteSetting.content_localization_use_default_locale_when_unsupported = true
SiteSetting.default_locale = "el"
Fabricate(:post_localization, post:, cooked: "site default cooked", locale: "el")
I18n.locale = "ja"
post.update!(locale: "en")
expect(json[:cooked]).to eq("site default cooked")
end
end
end
end
@@ -10,15 +10,36 @@ describe BasicTopicSerializer do
expect(json[:basic_topic][:fancy_title]).to eq(topic.title)
end
it "returns the fancy title with a modifier" do
SiteSetting.content_localization_enabled = true
Fabricate(:topic_localization, topic:, fancy_title: "X", locale: "ja")
I18n.locale = "ja"
topic.update!(locale: "en")
describe "localizations" do
it "returns the fancy title with a modifier" do
SiteSetting.content_localization_enabled = true
Fabricate(:topic_localization, topic:, fancy_title: "X", locale: "ja")
I18n.locale = "ja"
topic.update!(locale: "en")
json = BasicTopicSerializer.new(topic).as_json
json = BasicTopicSerializer.new(topic).as_json
expect(json[:basic_topic][:fancy_title]).to eq("X")
expect(json[:basic_topic][:fancy_title]).to eq("X")
end
it "returns the site default locale fancy title when no exact match found and `content_localization_use_default_locale_when_unsupported` is true" do
SiteSetting.content_localization_enabled = true
SiteSetting.content_localization_use_default_locale_when_unsupported = true
SiteSetting.default_locale = "el"
Fabricate(
:topic_localization,
topic:,
fancy_title: "site default fancy title",
locale: "el",
)
I18n.locale = "ja"
topic.update!(locale: "en")
json = BasicTopicSerializer.new(topic).as_json
expect(json[:basic_topic][:fancy_title]).to eq("site default fancy title")
end
end
end
end
+80 -4
View File
@@ -29,7 +29,11 @@ describe "Content Localization" do
)
end
fab!(:post_3) { Fabricate(:post, topic:, locale: "ja", raw: "将とは、智・信・仁・勇・厳なり。") }
fab!(:topic_ja_localization) do
Fabricate(:topic_localization, topic:, locale: "ja", fancy_title: "孫子兵法からの人生戦略")
end
# page objects
let(:topic_page) { PageObjects::Pages::Topic.new }
let(:topic_list) { PageObjects::Components::TopicList.new }
let(:composer) { PageObjects::Components::Composer.new }
@@ -38,10 +42,6 @@ describe "Content Localization" do
let(:post_3_obj) { PageObjects::Components::Post.new(3) }
let(:post_4_obj) { PageObjects::Components::Post.new(4) }
fab!(:topic_ja_localization) do
Fabricate(:topic_localization, topic:, locale: "ja", fancy_title: "孫子兵法からの人生戦略")
end
def scroll_to_post(post_number)
5.times do
break if page.has_css?("#post_#{post_number} .cooked", visible: :all, wait: 0)
@@ -371,6 +371,82 @@ describe "Content Localization" do
expect(page).to have_title(shady_topic.title)
end
end
context "for a Greek user in an English forum with Japanese users" do
fab!(:greek_user) { Fabricate(:user, locale: "el") }
fab!(:jap_post) { Fabricate(:post, locale: "ja", cooked: "皆さんは「ジョジョの奇妙な冒険」をご存知ですか?") }
fab!(:jap_topic) do
jap_post.topic.tap { |t| t.update(locale: "ja", fancy_title: "ジョジョの奇妙な冒険") }
end
fab!(:en_loc_jap_post) do
Fabricate(
:post_localization,
locale: "en",
post: jap_post,
cooked: "Do you know “JoJos Bizarre Adventure”?",
)
end
fab!(:en_loc_jap_topic) do
Fabricate(
:topic_localization,
locale: "en",
topic: jap_topic,
fancy_title: "JoJo's Bizarre Adventure",
)
end
before do
SiteSetting.default_locale = "en" # explicit
SiteSetting.content_localization_use_default_locale_when_unsupported = true
end
context "for a topic / post with no locale" do
it "shows content as-is" do
jap_post.update(locale: nil)
jap_topic.update(locale: nil)
sign_in(greek_user)
topic_page.visit_topic(jap_topic)
expect(topic_page).to have_topic_title(jap_topic.fancy_title)
expect(post_1_obj).to have_cooked_content(jap_post.cooked)
SiteSetting.content_localization_enabled = false
page.refresh
expect(topic_page).to have_topic_title(jap_topic.fancy_title)
expect(post_1_obj).to have_cooked_content(jap_post.cooked)
end
end
context "for a topic / post written in Site default language (en)" do
it "shows Site default language (en) translation to Greek user" do
sign_in(greek_user)
topic_page.visit_topic(jap_topic)
expect(topic_page).to have_topic_title(en_loc_jap_topic.fancy_title)
expect(post_1_obj).to have_cooked_content(en_loc_jap_post.cooked)
SiteSetting.content_localization_use_default_locale_when_unsupported = false
page.refresh
expect(topic_page).to have_topic_title(jap_topic.fancy_title)
expect(post_1_obj).to have_cooked_content(jap_post.cooked)
end
end
it "shows content as-is when no localization exists" do
en_loc_jap_topic.destroy
en_loc_jap_post.destroy
sign_in(greek_user)
topic_page.visit_topic(jap_topic)
expect(topic_page).to have_topic_title(jap_topic.fancy_title)
expect(post_1_obj).to have_cooked_content(jap_post.cooked)
end
end
end
context "for site settings" do
+5 -1
View File
@@ -28,8 +28,12 @@ module PageObjects
find("#embedded-posts__bottom--#{@post_number} .load-more-replies").click
end
def cooked_content
post.find(".contents > .cooked")
end
def has_cooked_content?(value)
post.find(".contents > .cooked").has_content?(value)
cooked_content.has_content?(value)
end
def has_replies?(count: nil)