0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/lib/site_settings/label_formatter.rb
Kris 24278059d2
UX: Link to required settings from language switcher validation error (#38374)
Currently the `content_localization_language_switcher` validation error
names prerequisite settings as plain text so admins have to search for
each one manually.

This change extends the `{{setting:...}}` marker from #40338 with a
multi-setting form: `{{settings:one,two|label}}`. This renders a single
link to a filtered view of all the referenced settings.

The error message now links each required setting inline plus an "All
required settings" link. The admin UI receives an HTML message (and
plain text is still provided elsewhere, like the API).

<img width="1408" height="464" alt="image"
src="https://github.com/user-attachments/assets/576bcd79-daf1-4bd7-84ec-2add4db27f85"
/>

All settings are shown with an OR search prefixed by `any:` so
pipe-delineated settings can still be searched for separately
`any:content_localization_language_switcher|set_locale_from_cookie|allow_user_locale|content_localization_supported_locales`

<img width="2318" height="1706" alt="image"
src="https://github.com/user-attachments/assets/332092de-8d7c-4935-9e08-c3cb38a38fad"
/>
2026-07-16 15:51:06 -04:00

294 lines
8.4 KiB
Ruby
Vendored

# frozen_string_literal: true
module SiteSettings
class LabelFormatter
HUMANIZED_ACRONYMS =
Set.new(
%w[
2fa
acl
ai
api
arn
aws
bg
cdn
cors
csp
csrf
css
cta
csv
cx
db
dm
dns
eu
faq
fg
ga
gb
gif
gpu
gpt
gtm
hd
html
http
https
iam
id
imap
ip
jpg
json
kb
llm
mb
mfa
oauth
oidc
pdf
pm
png
pop3
rest
rss
s3
saml
smtp
sso
svg
tei
tl
tl0
tl1
tl2
tl3
tl4
tld
totp
txt
ui
url
ux
vpc
xml
yaml
yml
],
).freeze
HUMANIZED_MIXED_CASE = [
%w[apple Apple],
["adobe analytics", "Adobe Analytics"],
["amazon web services", "Amazon Web Services"],
%w[android Android],
%w[chinese Chinese],
%w[discord Discord],
%w[discourse Discourse],
["discourse connect", "Discourse Connect"],
["discourse discover", "Discourse Discover"],
["discourse narrative bot", "Discourse Narrative Bot"],
%w[facebook Facebook],
%w[foundation Foundation],
%w[github GitHub],
%w[google Google],
["google analytics", "Google Analytics"],
["google tag manager", "Google Tag Manager"],
%w[gravatar Gravatar],
%w[gravatars Gravatars],
%w[gitter Gitter],
%w[horizon Horizon],
%w[ios iOS],
%w[japanese Japanese],
%w[linkedin LinkedIn],
%w[meta Meta],
%w[mediaconvert MediaConvert],
%w[microsoft Microsoft],
%w[matrix Matrix],
%w[mattermost Mattermost],
%w[oauth2 OAuth2],
["openid connect", "OpenID Connect"],
%w[openai OpenAI],
%w[opengraph OpenGraph],
["powered by discourse", "Powered by Discourse"],
%w[tiktok TikTok],
%w[tos ToS],
%w[twitter Twitter],
%w[telegram Telegram],
%w[teams Teams],
%w[rocketchat RocketChat],
%w[slack Slack],
%w[vimeo Vimeo],
%w[wordpress WordPress],
%w[webex WebEx],
%w[youtube YouTube],
%w[zulip Zulip],
].freeze
HUMANIZED_MIXED_CASE_REGEX =
HUMANIZED_MIXED_CASE.map { |key, value| [/\b#{Regexp.escape(key)}\b/i, value] }.freeze
SETTING_LINK_PATTERN = /\{\{setting:([a-z][a-z0-9_]*)\}\}/
SETTINGS_LINK_PATTERN =
/\{\{settings:([a-z][a-z0-9_]*(?:,[a-z][a-z0-9_]*)*)(?:\|([^{}|]+))?\}\}/
LINK_ATTRIBUTES = %w[
class
data-setting-name
data-setting-area
data-setting-category
data-setting-plugin
].freeze
class << self
def description(setting)
desc = I18n.t("site_settings.#{setting}", base_path: Discourse.base_path, default: "")
expand_setting_links(desc)
end
def settings_filter_href(filter)
"#{Discourse.base_path}/admin/site_settings/category/all_results?filter=#{CGI.escape(filter)}"
end
def linkify(setting)
setting = setting.to_sym
# The href points at the generic "all settings" page as a fallback that
# works without JavaScript. In the admin UI the linkify-setting-links
# modifier rewrites it to the setting's actual config page, using the
# data attributes below so it doesn't have to look the metadata up.
attributes = {
"class" => "site-setting-link",
"href" => settings_filter_href(setting.to_s),
"data-setting-name" => setting,
}
if (area = SiteSetting.areas[setting]&.first)
attributes["data-setting-area"] = area
end
if (category = SiteSetting.categories[setting])
attributes["data-setting-category"] = category
end
if (plugin = SiteSetting.plugins[setting])
attributes["data-setting-plugin"] = plugin
end
attribute_string =
attributes.map { |name, value| %(#{name}="#{CGI.escapeHTML(value.to_s)}") }.join(" ")
%(<a #{attribute_string}>#{CGI.escapeHTML(humanized_name(setting))}</a>).html_safe
end
# Links a group of settings as a single anchor pointing at the
# all-settings page filtered to every name at once, via the explicit
# `any:one|two` OR filter syntax (unprefixed pipes stay literal so
# admins can search pipe-delimited list values exactly). Unlike #linkify
# there is no per-setting config page to rewrite the href to, so no data
# attributes are emitted for the linkify-setting-links modifier.
def linkify_settings(settings, label: nil)
filter = "any:#{settings.map(&:to_s).join("|")}"
label ||= settings.map { |setting| humanized_name(setting) }.join(", ")
%(<a class="site-setting-link" href="#{CGI.escapeHTML(settings_filter_href(filter))}">#{CGI.escapeHTML(label)}</a>).html_safe
end
def contains_setting_links?(text)
return false if text.blank?
text.match?(SETTING_LINK_PATTERN) || text.match?(SETTINGS_LINK_PATTERN)
end
def expand_setting_links(text, escape_text: false)
return text if text.blank? || !text.include?("{{setting")
text = CGI.escapeHTML(text) if escape_text
text
.gsub(SETTINGS_LINK_PATTERN) do
label = Regexp.last_match(2)&.strip
label = CGI.unescapeHTML(label) if escape_text && label
linkify_settings(Regexp.last_match(1).split(","), label:)
end
.gsub(SETTING_LINK_PATTERN) { linkify(Regexp.last_match(1)) }
.html_safe
end
# Renders markers as plain text — the humanized setting names (quoted)
# for {{setting:...}}, and the label or name list for {{settings:...}} —
# for surfaces that display error messages outside the admin UI.
def plain_setting_links(text)
return text if text.blank? || !text.include?("{{setting")
text
.gsub(SETTINGS_LINK_PATTERN) do
Regexp.last_match(2)&.strip ||
Regexp.last_match(1).split(",").map { |s| humanized_name(s) }.join(", ")
end
.gsub(SETTING_LINK_PATTERN) { "'#{humanized_name(Regexp.last_match(1))}'" }
end
def humanized_name(setting)
name = setting.to_s.tr("_", " ")
words = name.split(" ")
words[0] = words[0].capitalize
words.map! do |word|
word_downcase = word.downcase
if HUMANIZED_ACRONYMS.include?(word_downcase)
word.upcase
elsif word.end_with?("s") && HUMANIZED_ACRONYMS.include?(word_downcase[0...-1])
word_downcase[0...-1].upcase + "s"
else
word
end
end
result = words.join(" ")
HUMANIZED_MIXED_CASE_REGEX.each do |regex, replacement|
result = result.gsub(regex, replacement)
end
result
end
def keywords(setting)
translated_keywords = I18n.t("site_settings.keywords.#{setting}", default: "")
english_translated_keywords = []
if I18n.locale != :en
english_translated_keywords =
I18n.t("site_settings.keywords.#{setting}", default: "", locale: :en).split("|")
end
# TODO (martin) We can remove this workaround of checking if
# we get an array back once keyword translations in languages other
# than English have been updated not to use YAML arrays.
if translated_keywords.is_a?(Array)
return(
(
translated_keywords + [SiteSetting.deprecated_setting_alias(setting)] +
english_translated_keywords
).compact
)
end
translated_keywords
.split("|")
.concat([SiteSetting.deprecated_setting_alias(setting)] + english_translated_keywords)
.compact
end
def placeholder(setting)
if !I18n.t("site_settings.placeholder.#{setting}", default: "").empty?
I18n.t("site_settings.placeholder.#{setting}")
elsif SiteIconManager.respond_to?("#{setting}_url")
SiteIconManager.public_send("#{setting}_url")
end
end
end
end
end