# 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_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? } 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) 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