0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 21:57:36 +08:00
discourse/script/i18n_lint.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

174 lines
5.6 KiB
Ruby
Executable file
Vendored

# frozen_string_literal: true
require "colored2"
require "psych"
class I18nLinter
def initialize(filenames_or_patterns)
@filenames = filenames_or_patterns.map { |fp| Dir[fp] }.flatten
@errors = {}
end
def run
has_errors = false
@filenames.each do |filename|
validator = LocaleFileValidator.new(filename)
if validator.has_errors?
validator.print_errors
has_errors = true
end
end
exit 1 if has_errors
end
end
class LocaleFileValidator
# Format: "banned phrase" => "recommendation"
BANNED_PHRASES = { "color scheme" => "color palette", "private message" => "personal message" }
ERROR_MESSAGES = {
invalid_relative_links:
"The following keys have relative links, but do not start with %{base_url} or %{base_path}:",
invalid_relative_image_sources:
"The following keys have relative image sources, but do not start with %{base_url} or %{base_path}:",
invalid_interpolation_key_format:
"The following keys use {{key}} instead of %{key} for interpolation keys:",
wrong_pluralization_keys:
"Pluralized strings must have only the sub-keys 'one' and 'other'.\nThe following keys have missing or additional keys:",
invalid_file_format:
"The file is not a valid YAML format or does not contain a valid locale structure.",
invalid_one_keys:
"The following keys contain the number 1 instead of the interpolation key %{count}:",
invalid_setting_link_format:
"The following keys contain malformed setting link markers.\nUse {{setting:setting_name}} or {{settings:name_one,name_two|Link label}} (lowercase snake_case names, no spaces around commas):",
}.merge(
BANNED_PHRASES
.map do |banned, recommendation|
[
"banned_phrase_#{banned}",
"The following keys contain the banned phrase '#{banned}' (use '#{recommendation}' instead)",
]
end
.to_h,
)
# Must stay in sync with SETTING_LINK_PATTERN and SETTINGS_LINK_PATTERN in
# lib/site_settings/label_formatter.rb — markers that don't match there are
# rendered verbatim, so lint anything that only looks like one.
VALID_SETTING_LINK_REGEX =
/\{\{setting:[a-z][a-z0-9_]*\}\}|\{\{settings:[a-z][a-z0-9_]*(?:,[a-z][a-z0-9_]*)*(?:\|[^{}|]+)?\}\}/
SETTING_LINK_START_REGEX = /{{settings?:/
VALID_SETTING_LINK_AT_START_REGEX = /\A(?:#{VALID_SETTING_LINK_REGEX})/
PLURALIZATION_KEYS = %w[zero one two few many other]
ENGLISH_KEYS = %w[one other]
EXEMPTED_DOUBLE_CURLY_BRACKET_KEYS = %w[
js.discourse_automation.scriptables.auto_responder.fields.word_answer_list.description
discourse_automation.scriptables.email_on_flagged_post.default_template
js.discourse_ai.discourse_workflows.ai_agent.upload_ids_placeholder
]
def initialize(filename)
@filename = filename
@errors = {}
ERROR_MESSAGES.keys.each { |type| @errors[type] = [] }
end
def has_errors?
yaml = Psych.safe_load(File.read(@filename), aliases: true)
yaml = yaml[yaml.keys.first]
validate_pluralizations(yaml)
validate_content(yaml)
@errors.any? { |_, value| value.any? }
rescue StandardError => e
@errors[:invalid_file_format] = ["Failed to parse #{@filename}: #{e.message}"]
true
end
def print_errors
puts "", "Errors in #{@filename}".red
@errors.each do |type, keys|
next if keys.empty?
ERROR_MESSAGES[type].split("\n").each { |msg| puts " #{msg}" }
keys.each { |key| puts " * #{key}" }
end
end
private
def each_translation(hash, parent_key = "", &block)
hash.each do |key, value|
current_key = parent_key.empty? ? key : "#{parent_key}.#{key}"
if Hash === value
each_translation(value, current_key, &block)
else
yield(current_key, value.to_s)
end
end
end
def validate_content(yaml)
each_translation(yaml) do |key, value|
@errors[:invalid_relative_links] << key if value.match?(%r{href\s*=\s*["']/[^/]|\]\(/[^/]}i)
@errors[:invalid_relative_image_sources] << key if value.match?(%r{src\s*=\s*["']/[^/]}i)
if value.match?(/{{(?!settings?:).+?}}/)
exempt = key.end_with?("_MF") || EXEMPTED_DOUBLE_CURLY_BRACKET_KEYS.include?(key)
@errors[:invalid_interpolation_key_format] << key unless exempt
end
if value
.enum_for(:scan, SETTING_LINK_START_REGEX)
.map { Regexp.last_match.begin(0) }
.any? { |start| !value[start..].match?(VALID_SETTING_LINK_AT_START_REGEX) }
@errors[:invalid_setting_link_format] << key
end
BANNED_PHRASES.keys.each do |banned|
@errors["banned_phrase_#{banned}"] << key if value.downcase.include?(banned.downcase)
end
end
end
def each_pluralization(hash, parent_key = "", &block)
hash.each do |key, value|
if Hash === value
current_key = parent_key.empty? ? key : "#{parent_key}.#{key}"
each_pluralization(value, current_key, &block)
elsif PLURALIZATION_KEYS.include? key
yield(parent_key, hash)
end
end
end
def validate_pluralizations(yaml)
if !yaml.is_a?(Hash)
@errors[:wrong_pluralization_keys] << ["Root of the locale file must be a hash"]
return
end
each_pluralization(yaml) do |key, hash|
# ignore errors from some ActiveRecord messages
next if key.include?("messages.restrict_dependent_destroy")
@errors[:wrong_pluralization_keys] << key if hash.keys.sort != ENGLISH_KEYS
one_value = hash["one"]
if one_value && one_value.include?("1") && !one_value.match?(/%{count}|{{count}}/)
@errors[:invalid_one_keys] << key
end
end
end
end
I18nLinter.new(ARGV).run if $PROGRAM_NAME == __FILE__