mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 05:42:36 +08:00
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"
/>
174 lines
5.6 KiB
Ruby
Executable file
Vendored
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__
|