translation_helper.rb 7.0 KB
Newer Older
1 2
# frozen_string_literal: true

3
require "action_view/helpers/tag_helper"
4
require "active_support/core_ext/symbol/starts_ends_with"
5

S
Sven Fuchs 已提交
6
module ActionView
R
Rizwan Reza 已提交
7
  # = Action View Translation Helpers
8
  module Helpers #:nodoc:
S
Sven Fuchs 已提交
9
    module TranslationHelper
10 11
      extend ActiveSupport::Concern

12
      include TagHelper
13 14

      included do
15
        mattr_accessor :debug_missing_translation, default: true
16 17
      end

18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
      # Delegates to <tt>I18n#translate</tt> but also performs three additional
      # functions.
      #
      # First, it will ensure that any thrown +MissingTranslation+ messages will
      # be rendered as inline spans that:
      #
      # * Have a <tt>translation-missing</tt> class applied
      # * Contain the missing key as the value of the +title+ attribute
      # * Have a titleized version of the last key segment as text
      #
      # For example, the value returned for the missing translation key
      # <tt>"blog.post.title"</tt> will be:
      #
      #    <span
      #      class="translation_missing"
      #      title="translation missing: en.blog.post.title">Title</span>
      #
      # This allows for views to display rather reasonable strings while still
      # giving developers a way to find missing translations.
      #
      # If you would prefer missing translations to raise an error, you can
      # opt out of span-wrapping behavior globally by setting
      # <tt>ActionView::Base.raise_on_missing_translations = true</tt> or
      # individually by passing <tt>raise: true</tt> as an option to
      # <tt>translate</tt>.
      #
      # Second, if the key starts with a period <tt>translate</tt> will scope
      # the key by the current partial. Calling <tt>translate(".foo")</tt> from
      # the <tt>people/index.html.erb</tt> template is equivalent to calling
      # <tt>translate("people.index.foo")</tt>. This makes it less
      # repetitive to translate many keys within the same partial and provides
      # a convention to scope keys consistently.
      #
      # Third, the translation will be marked as <tt>html_safe</tt> if the key
      # has the suffix "_html" or the last element of the key is "html". Calling
      # <tt>translate("footer_html")</tt> or <tt>translate("footer.html")</tt>
      # will return an HTML safe string that won't be escaped by other HTML
      # helper methods. This naming convention helps to identify translations
      # that include HTML tags so that you know what kind of output to expect
      # when you call translate in a template and translators know which keys
      # they can provide HTML values for.
59
      #
60
      # To access the translated text along with the fully resolved
61 62 63 64 65 66 67 68 69
      # translation key, <tt>translate</tt> accepts a block:
      #
      #     <%= translate(".relative_key") do |translation, resolved_key| %>
      #       <span title="<%= resolved_key %>"><%= translation %></span>
      #     <% end %>
      #
      # This enables annotate translated text to be aware of the scope it was
      # resolved against.
      #
70
      def translate(key, **options)
71
        return key.map { |k| translate(k, **options) } if key.is_a?(Array)
72

73 74
        alternatives = if options.key?(:default)
          options[:default].is_a?(Array) ? options.delete(:default).compact : [options.delete(:default)]
75 76
        end

77 78
        options[:raise] = true if options[:raise].nil? && ActionView::Base.raise_on_missing_translations
        default = MISSING_TRANSLATION
79

80 81 82 83
        translation = while key
          if alternatives.blank? && !options[:raise].nil?
            default = NO_DEFAULT # let I18n handle missing translation
          end
84

85 86
          key = scope_key_by_partial(key)
          first_key ||= key
87

88 89 90 91
          if html_safe_translation_key?(key)
            html_safe_options ||= html_escape_translation_options(options)
            translated = I18n.translate(key, **html_safe_options, default: default)
            break html_safe_translation(translated) unless translated.equal?(MISSING_TRANSLATION)
92
          else
93 94
            translated = I18n.translate(key, **options, default: default)
            break translated unless translated.equal?(MISSING_TRANSLATION)
95
          end
96

97
          break alternatives.first if alternatives.present? && !alternatives.first.is_a?(Symbol)
98

99 100
          key = alternatives&.shift
        end
101

102 103 104
        if key.nil?
          translation = missing_translation(first_key, options)
          key = first_key
105
        end
106 107

        block_given? ? yield(translation, key) : translation
S
Sven Fuchs 已提交
108
      end
109
      alias :t :translate
L
Luca Guidi 已提交
110

111
      # Delegates to <tt>I18n.localize</tt> with no additional functionality.
112
      #
113
      # See https://www.rubydoc.info/github/svenfuchs/i18n/master/I18n/Backend/Base:localize
114
      # for more information.
115 116
      def localize(object, **options)
        I18n.localize(object, **options)
L
Luca Guidi 已提交
117
      end
118
      alias :l :localize
119 120

      private
121 122 123
        MISSING_TRANSLATION = Object.new
        private_constant :MISSING_TRANSLATION

124 125 126 127 128 129 130
        NO_DEFAULT = [].freeze
        private_constant :NO_DEFAULT

        def self.i18n_option?(name)
          (@i18n_option_names ||= I18n::RESERVED_KEYS.to_set).include?(name)
        end

131
        def scope_key_by_partial(key)
132
          if key.start_with?(".")
133
            if @current_template&.virtual_path
134
              @_scope_key_by_partial_cache ||= {}
135
              @_scope_key_by_partial_cache[@current_template.virtual_path] ||= @current_template.virtual_path.gsub(%r{/_?}, ".")
136
              "#{@_scope_key_by_partial_cache[@current_template.virtual_path]}#{key}"
J
José Valim 已提交
137
            else
138
              raise "Cannot use t(#{key.inspect}) shortcut because path is not available"
J
José Valim 已提交
139
            end
140 141
          else
            key
142 143
          end
        end
144

145
        def html_escape_translation_options(options)
146
          return options if options.empty?
147 148
          html_safe_options = options.dup

149 150
          options.each do |name, value|
            unless TranslationHelper.i18n_option?(name) || (name == :count && value.is_a?(Numeric))
151 152 153 154 155 156 157
              html_safe_options[name] = ERB::Util.html_escape(value.to_s)
            end
          end

          html_safe_options
        end

158
        def html_safe_translation_key?(key)
159
          /(?:_|\b)html\z/.match?(key)
160
        end
161 162 163 164 165 166 167 168 169

        def html_safe_translation(translation)
          if translation.respond_to?(:map)
            translation.map { |element| element.respond_to?(:html_safe) ? element.html_safe : element }
          else
            translation.respond_to?(:html_safe) ? translation.html_safe : translation
          end
        end

170 171
        def missing_translation(key, options)
          keys = I18n.normalize_keys(options[:locale] || I18n.locale, key, options[:scope])
172 173 174

          title = +"translation missing: #{keys.join(".")}"

175 176 177 178
          options.each do |name, value|
            unless name == :scope
              title << ", " << name.to_s << ": " << ERB::Util.html_escape(value)
            end
179 180 181 182 183 184 185 186
          end

          if ActionView::Base.debug_missing_translation
            content_tag("span", keys.last.to_s.titleize, class: "translation_missing", title: title)
          else
            title
          end
        end
S
Sven Fuchs 已提交
187 188
    end
  end
189
end