mirror of
https://github.com/discourse/discourse.git
synced 2025-08-17 18:04:11 +08:00
Some checks are pending
Licenses / run (push) Waiting to run
Linting / run (push) Waiting to run
Publish Assets / publish-assets (push) Waiting to run
Tests / core backend (push) Waiting to run
Tests / plugins backend (push) Waiting to run
Tests / core frontend (Chrome) (push) Waiting to run
Tests / plugins frontend (push) Waiting to run
Tests / themes frontend (push) Waiting to run
Tests / core system (push) Waiting to run
Tests / plugins system (push) Waiting to run
Tests / themes system (push) Waiting to run
Tests / core frontend (Firefox ESR) (push) Waiting to run
Tests / core frontend (Firefox Evergreen) (push) Waiting to run
Tests / chat system (push) Waiting to run
Tests / merge (push) Blocked by required conditions
Shortened AI agent file, so we conserve tokens Use symlinks for all agent files where needed to avoid round trips to llm Added config for Open AI Codex / Gemini and Cursor
154 lines
4.5 KiB
Ruby
Executable file
154 lines
4.5 KiB
Ruby
Executable file
# 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}:",
|
|
}.merge(
|
|
BANNED_PHRASES
|
|
.map do |banned, recommendation|
|
|
[
|
|
"banned_phrase_#{banned}",
|
|
"The following keys contain the banned phrase '#{banned}' (use '#{recommendation}' instead)",
|
|
]
|
|
end
|
|
.to_h,
|
|
)
|
|
|
|
PLURALIZATION_KEYS = %w[zero one two few many other]
|
|
ENGLISH_KEYS = %w[one other]
|
|
|
|
EXEMPTED_DOUBLE_CURLY_BRACKET_KEYS = [
|
|
"js.discourse_automation.scriptables.auto_responder.fields.word_answer_list.description",
|
|
]
|
|
|
|
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?(/{{.+?}}/) && !key.end_with?("_MF") &&
|
|
!EXEMPTED_DOUBLE_CURLY_BRACKET_KEYS.include?(key)
|
|
@errors[:invalid_interpolation_key_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
|