hanami-view 2.0.0.alpha7 → 2.1.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +15 -3
  4. data/hanami-view.gemspec +5 -3
  5. data/lib/hanami/view/cache.rb +16 -0
  6. data/lib/hanami/view/context.rb +12 -70
  7. data/lib/hanami/view/context_helpers/content_helpers.rb +5 -5
  8. data/lib/hanami/view/decorated_attributes.rb +2 -2
  9. data/lib/hanami/view/erb/engine.rb +27 -0
  10. data/lib/hanami/view/erb/filters/block.rb +44 -0
  11. data/lib/hanami/view/erb/filters/trimming.rb +42 -0
  12. data/lib/hanami/view/erb/parser.rb +161 -0
  13. data/lib/hanami/view/erb/template.rb +30 -0
  14. data/lib/hanami/view/errors.rb +5 -7
  15. data/lib/hanami/view/exposure.rb +23 -17
  16. data/lib/hanami/view/exposures.rb +22 -13
  17. data/lib/hanami/view/helpers/escape_helper.rb +221 -0
  18. data/lib/hanami/view/helpers/number_formatting_helper.rb +182 -0
  19. data/lib/hanami/view/helpers/tag_helper/tag_builder.rb +230 -0
  20. data/lib/hanami/view/helpers/tag_helper.rb +210 -0
  21. data/lib/hanami/view/html.rb +104 -0
  22. data/lib/hanami/view/html_safe_string_buffer.rb +46 -0
  23. data/lib/hanami/view/part.rb +13 -15
  24. data/lib/hanami/view/part_builder.rb +68 -108
  25. data/lib/hanami/view/path.rb +4 -31
  26. data/lib/hanami/view/renderer.rb +36 -44
  27. data/lib/hanami/view/rendering.rb +42 -0
  28. data/lib/hanami/view/{render_environment_missing.rb → rendering_missing.rb} +8 -13
  29. data/lib/hanami/view/scope.rb +15 -16
  30. data/lib/hanami/view/scope_builder.rb +42 -78
  31. data/lib/hanami/view/tilt/haml_adapter.rb +40 -0
  32. data/lib/hanami/view/tilt/slim_adapter.rb +40 -0
  33. data/lib/hanami/view/tilt.rb +22 -46
  34. data/lib/hanami/view/version.rb +1 -1
  35. data/lib/hanami/view.rb +351 -26
  36. metadata +64 -29
  37. data/LICENSE +0 -20
  38. data/lib/hanami/view/application_configuration.rb +0 -77
  39. data/lib/hanami/view/application_context.rb +0 -98
  40. data/lib/hanami/view/render_environment.rb +0 -62
  41. data/lib/hanami/view/standalone_view.rb +0 -400
  42. data/lib/hanami/view/tilt/erb.rb +0 -26
  43. data/lib/hanami/view/tilt/erbse.rb +0 -21
  44. data/lib/hanami/view/tilt/haml.rb +0 -26
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "tsort"
4
4
  require "dry/core/equalizer"
5
- require_relative "exposure"
6
5
 
7
6
  module Hanami
8
7
  class View
@@ -46,18 +45,28 @@ module Hanami
46
45
  end
47
46
 
48
47
  def call(input)
49
- # rubocop:disable Style/MultilineBlockChain
50
- tsort.each_with_object({}) { |name, memo|
51
- next unless (exposure = self[name])
52
-
53
- value = exposure.(input, memo)
54
- value = yield(value, exposure) if block_given?
55
-
56
- memo[name] = value
57
- }.each_with_object({}) { |(name, value), memo|
58
- memo[name] = value unless self[name].private?
59
- }
60
- # rubocop:enable Style/MultilineBlockChain
48
+ # Avoid performance cost of tsorting when we don't need it
49
+ names =
50
+ if exposures.values.any?(&:dependencies?) # TODO: this sholud be cachable at time of `#add`
51
+ tsort
52
+ else
53
+ exposures.keys
54
+ end
55
+
56
+ names
57
+ .each_with_object({}) { |name, memo|
58
+ next unless (exposure = self[name])
59
+
60
+ value = exposure.(input, memo)
61
+ value = yield(value, exposure) if block_given?
62
+
63
+ memo[name] = value
64
+ }
65
+ .tap { |hsh|
66
+ names.each do |key|
67
+ hsh.delete(key) if self[key].private?
68
+ end
69
+ }
61
70
  end
62
71
 
63
72
  private
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "temple"
4
+ require "uri"
5
+
6
+ module Hanami
7
+ class View
8
+ module Helpers
9
+ # Helper methods for escaping content for safely including in HTML.
10
+ #
11
+ # When using full Hanami apps, these helpers will be automatically available in your view
12
+ # templates, part classes and scope classes.
13
+ #
14
+ # When using hanami-view standalone, include this module directly in your base part and scope
15
+ # classes, or in specific classes as required.
16
+ #
17
+ # @example Standalone usage
18
+ # class BasePart < Hanami::View::Part
19
+ # include Hanami::View::Helpers::EscapeHelper
20
+ # end
21
+ #
22
+ # class BaseScope < Hanami::View::Scope
23
+ # include Hanami::View::Helpers::EscapeHelper
24
+ # end
25
+ #
26
+ # class BaseView < Hanami::View
27
+ # config.part_class = BasePart
28
+ # config.scope_class = BaseScope
29
+ # end
30
+ #
31
+ # @api public
32
+ # @since 2.0.0
33
+ module EscapeHelper
34
+ module_function
35
+
36
+ # Returns an escaped string that is safe to include in HTML.
37
+ #
38
+ # Use this helper when including any untrusted user input in your view content.
39
+ #
40
+ # If the given string is already marked as HTML safe, then it will be returned without
41
+ # escaping.
42
+ #
43
+ # Marks the escaped string marked as HTML safe, ensuring it will not be escaped again.
44
+ #
45
+ # @param input [String] the input string
46
+ # @return [Hanami::View::HTML::SafeString] the escaped string
47
+ #
48
+ # @example
49
+ # escape_html("Safe content")
50
+ # # => "Safe content"
51
+ #
52
+ # escape_html("<script>alert('xss')</script>")
53
+ # # => "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
54
+ #
55
+ # escape_html(raw("<p>Not escaped</p>"))
56
+ # # => "<p>Not escaped</p>"
57
+ #
58
+ # @api public
59
+ # @since 2.0.0
60
+ def escape_html(input)
61
+ Temple::Utils.escape_html_safe(input)
62
+ end
63
+
64
+ # @api public
65
+ # @since 2.0.0
66
+ alias_method :h, :escape_html
67
+
68
+ # Returns an escaped string from joining the elements in a given array.
69
+ #
70
+ # Behaves similarly to `Array#join`. The given array is flattened, and all items, including
71
+ # the supplied separator, are HTML escaped unless they are already HTML safe.
72
+ #
73
+ # Marks the returned string as HTML safe, ensuring it will not be escaped again.
74
+ #
75
+ # @param array [Array<#to_s>] the array
76
+ # @param separator[String] the separator for the joined string
77
+ # @return [Hanami::View::HTML::SafeString] the escaped string
78
+ #
79
+ # @example
80
+ # safe_join([raw("<p>foo</p>"), "<p>bar</p>"], "<br>")
81
+ # # => "<p>foo</p>&lt;br&gt;&lt;p&gt;bar&lt;/p&gt;"
82
+ #
83
+ # safe_join([raw("<p>foo</p>"), raw("<p>bar</p>")], raw("<br>"))
84
+ # # => "<p>foo</p><br><p>bar</p>"
85
+ #
86
+ # @see #escape_html
87
+ #
88
+ # @api public
89
+ # @since 2.0.0
90
+ def escape_join(array, separator = $,)
91
+ separator = escape_html(separator)
92
+
93
+ array.flatten.map! { |i| escape_html(i) }.join(separator).html_safe
94
+ end
95
+
96
+ # Returns a the given URL string if it has one of the permitted URL schemes. For URLs with
97
+ # non-permitted schemes, returns an empty string.
98
+ #
99
+ # Use this method when including URLs from untrusted user input in your view content.
100
+ #
101
+ # The default permitted schemes are:
102
+ # - `http`
103
+ # - `https`
104
+ # - `mailto`
105
+ #
106
+ # @param input [String] the URL string
107
+ # @param permitted_schemes [Array<string>] an optional array of permitted schemes
108
+ #
109
+ # @return [String] the permitted URL, or empty string
110
+ #
111
+ # @example
112
+ # sanitize_url("https://hanamirb.org") # => "http://hanamirb.org"
113
+ # sanitize_url("javascript:alert('xss')") # => ""
114
+ #
115
+ # sanitize_url("gemini://gemini.circumlunar.space/", %w[http https gemini])
116
+ # # => "gemini://gemini.circumlunar.space/"
117
+ #
118
+ # @api public
119
+ # @since 2.0.0
120
+ def sanitize_url(input, permitted_schemes = PERMITTED_URL_SCHEMES)
121
+ return input if input.html_safe?
122
+
123
+ URI::DEFAULT_PARSER.extract(
124
+ URI.decode_www_form_component(input.to_s),
125
+ permitted_schemes
126
+ ).first.to_s.html_safe
127
+ end
128
+
129
+ # @api private
130
+ # @since 2.0.0
131
+ PERMITTED_URL_SCHEMES = %w[http https mailto].freeze
132
+ private_constant :PERMITTED_URL_SCHEMES
133
+
134
+ # Returns an escaped name from the given string, intended for use as an XML tag or attribute
135
+ # name.
136
+ #
137
+ # Replaces non-safe characters with an underscore.
138
+ #
139
+ # Follows the requirements of the [XML specification](https://www.w3.org/TR/REC-xml/#NT-Name).
140
+ #
141
+ # @example
142
+ # escape_xml_name("1 < 2 & 3") # => "1___2___3"
143
+ #
144
+ # @api public
145
+ # @since 2.0.0
146
+ def escape_xml_name(name)
147
+ name = name.to_s
148
+ return "" if name.match?(BLANK_STRING_REGEXP)
149
+ return name if name.match?(SAFE_XML_TAG_NAME_REGEXP)
150
+
151
+ starting_char = name[0]
152
+ starting_char.gsub!(INVALID_TAG_NAME_START_REGEXP, TAG_NAME_REPLACEMENT_CHAR)
153
+
154
+ return starting_char if name.size == 1
155
+
156
+ following_chars = name[1..-1]
157
+ following_chars.gsub!(INVALID_TAG_NAME_FOLLOWING_REGEXP, TAG_NAME_REPLACEMENT_CHAR)
158
+
159
+ starting_char << following_chars
160
+ end
161
+
162
+ # @api private
163
+ # @since 2.0.0
164
+ BLANK_STRING_REGEXP = /\A\s*\z/
165
+
166
+ # Following XML requirements: https://www.w3.org/TR/REC-xml/#NT-Name
167
+ # @api private
168
+ # @since 2.0.0
169
+ TAG_NAME_START_CODEPOINTS = \
170
+ "@:A-Z_a-z\u{C0}-\u{D6}\u{D8}-\u{F6}\u{F8}-\u{2FF}\u{370}-\u{37D}\u{37F}-\u{1FFF}" \
171
+ "\u{200C}-\u{200D}\u{2070}-\u{218F}\u{2C00}-\u{2FEF}\u{3001}-\u{D7FF}\u{F900}-\u{FDCF}" \
172
+ "\u{FDF0}-\u{FFFD}\u{10000}-\u{EFFFF}"
173
+ private_constant :TAG_NAME_START_CODEPOINTS
174
+
175
+ # @api private
176
+ # @since 2.0.0
177
+ INVALID_TAG_NAME_START_REGEXP = /[^#{TAG_NAME_START_CODEPOINTS}]/
178
+ private_constant :INVALID_TAG_NAME_START_REGEXP
179
+
180
+ # @api private
181
+ # @since 2.0.0
182
+ TAG_NAME_FOLLOWING_CODEPOINTS = "#{TAG_NAME_START_CODEPOINTS}\\-.0-9\u{B7}\u{0300}-\u{036F}\u{203F}-\u{2040}"
183
+ private_constant :TAG_NAME_FOLLOWING_CODEPOINTS
184
+
185
+ # @api private
186
+ # @since 2.0.0
187
+ INVALID_TAG_NAME_FOLLOWING_REGEXP = /[^#{TAG_NAME_FOLLOWING_CODEPOINTS}]/
188
+ private_constant :INVALID_TAG_NAME_FOLLOWING_REGEXP
189
+
190
+ # @api private
191
+ # @since 2.0.0
192
+ SAFE_XML_TAG_NAME_REGEXP = /\A[#{TAG_NAME_START_CODEPOINTS}][#{TAG_NAME_FOLLOWING_CODEPOINTS}]*\z/
193
+ private_constant :INVALID_TAG_NAME_FOLLOWING_REGEXP
194
+
195
+ # @api private
196
+ # @since 2.0.0
197
+ TAG_NAME_REPLACEMENT_CHAR = "_"
198
+ private_constant :TAG_NAME_REPLACEMENT_CHAR
199
+
200
+ # Returns the given string marked as HTML safe, meaning it will not be escaped when included
201
+ # in your view's HTML.
202
+ #
203
+ # This is NOT recommended if the string is coming from untrusted user input. Use at your own
204
+ # peril.
205
+ #
206
+ # @param input [String] the input
207
+ # @return [Hanami::View::HTML::SafeString] the string marked as HTML safe
208
+ #
209
+ # @example
210
+ # raw(user.name) # => "Little Bobby <alert>Tables</alert>"
211
+ # raw(user.name).html_safe? # => true
212
+ #
213
+ # @api public
214
+ # @since 2.0.0
215
+ def raw(input)
216
+ input.to_s.html_safe
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class View
5
+ module Helpers
6
+ # Helper methods for formatting numbers as text.
7
+ #
8
+ # When using full Hanami apps, these helpers will be automatically available in your view
9
+ # templates, part classes and scope classes.
10
+ #
11
+ # When using hanami-view standalone, include this module directly in your base part and scope
12
+ # classes, or in specific classes as required.
13
+ #
14
+ # @example Standalone usage
15
+ # class BasePart < Hanami::View::Part
16
+ # include Hanami::View::Helpers::NumberFormattingHelper
17
+ # end
18
+ #
19
+ # class BaseScope < Hanami::View::Scope
20
+ # include Hanami::View::Helpers::NumberFormattingHelper
21
+ # end
22
+ #
23
+ # class BaseView < Hanami::View
24
+ # config.part_class = BasePart
25
+ # config.scope_class = BaseScope
26
+ # end
27
+ #
28
+ # @api public
29
+ # @since 2.0.0
30
+ module NumberFormattingHelper
31
+ module_function
32
+
33
+ # Default delimiter
34
+ #
35
+ # @return [String] default delimiter
36
+ #
37
+ # @since 2.0.0
38
+ # @api private
39
+ DEFAULT_DELIMITER = ","
40
+ private_constant :DEFAULT_DELIMITER
41
+
42
+ # Default separator
43
+ #
44
+ # @return [String] default separator
45
+ #
46
+ # @since 2.0.0
47
+ # @api private
48
+ DEFAULT_SEPARATOR = "."
49
+ private_constant :DEFAULT_SEPARATOR
50
+
51
+ # Default precision
52
+ #
53
+ # @return [Integer] default rounding precision
54
+ #
55
+ # @since 2.0.0
56
+ # @api private
57
+ DEFAULT_PRECISION = 2
58
+ private_constant :DEFAULT_PRECISION
59
+
60
+ # Returns a formatted string for the given number.
61
+ #
62
+ # Accepts a number (`Numeric`) or a string representation of a number.
63
+ #
64
+ # If an integer is given, applies no precision in the returned string. For all other kinds
65
+ # (`Float`, `BigDecimal`, etc.), formats the number as a float.
66
+ #
67
+ # Raises an `ArgumentError` if the argument cannot be coerced into a number for formatting.
68
+ #
69
+ # @param number [Numeric, String] the number to be formatted
70
+ # @param delimiter [String] hundred delimiter
71
+ # @param separator [String] fractional part separator
72
+ # @param precision [String] rounding precision
73
+ #
74
+ # @return [String] formatted number
75
+ #
76
+ # @raise [ArgumentError] if the number can't be formatted
77
+ #
78
+ # @example
79
+ # format_number(1_000_000) # => "1,000,000"
80
+ # format_number(Math::PI) # => "3.14"
81
+ # format_number(Math::PI, precision: 4) # => "3.1416"
82
+ # format_number(1256.95, delimiter: ".", separator: ",") # => "1.256,95"
83
+ #
84
+ # @api public
85
+ # @since 2.0.0
86
+ def format_number(number, delimiter: DEFAULT_DELIMITER, separator: DEFAULT_SEPARATOR, precision: DEFAULT_PRECISION) # rubocop:disable Layout/LineLength
87
+ Formatter.call(number, delimiter: delimiter, separator: separator, precision: precision)
88
+ end
89
+
90
+ private
91
+
92
+ # Formatter
93
+ #
94
+ # @since 2.0.0
95
+ # @api private
96
+ class Formatter
97
+ # Regex to delimit the integer part of a number into groups of three digits.
98
+ #
99
+ # @since 2.0.0
100
+ # @api private
101
+ DELIMITING_REGEX = /(\d)(?=(\d{3})+$)/
102
+ private_constant :DELIMITING_REGEX
103
+
104
+ # Regex to guess if the number is a integer.
105
+ #
106
+ # @since 2.0.0
107
+ # @api private
108
+ INTEGER_REGEXP = /\A\d+\z/
109
+ private_constant :INTEGER_REGEXP
110
+
111
+ # @see NumberFormattingHelper#format_number
112
+ #
113
+ # @since 2.0.0
114
+ # @api private
115
+ def self.call(number, delimiter:, separator:, precision:)
116
+ number = coerce(number)
117
+ str = to_str(number, precision)
118
+ array = parts(str, delimiter)
119
+
120
+ array.join(separator)
121
+ end
122
+
123
+ # Coerces the given number or string into a number.
124
+ #
125
+ # @since 2.0.0
126
+ # @api private
127
+ def self.coerce(number)
128
+ case number
129
+ when NilClass
130
+ raise ArgumentError, "failed to convert #{number.inspect} to number"
131
+ when ->(n) { n.to_s.match(INTEGER_REGEXP) }
132
+ Integer(number)
133
+ else
134
+ begin
135
+ Float(number)
136
+ rescue TypeError
137
+ raise ArgumentError, "failed to convert #{number.inspect} to float"
138
+ rescue ArgumentError => e
139
+ raise e.class, "failed to convert #{number.inspect} to float"
140
+ end
141
+ end
142
+ end
143
+
144
+ # Formats the given number as a string.
145
+ #
146
+ # @since 2.0.0
147
+ # @api private
148
+ def self.to_str(number, precision)
149
+ case number
150
+ when Integer
151
+ number.to_s
152
+ else
153
+ ::Kernel.format("%.#{precision}f", number)
154
+ end
155
+ end
156
+
157
+ # Returns the integer and fractional parts of the given number string.
158
+ #
159
+ # @since 2.0.0
160
+ # @api private
161
+ def self.parts(string, delimiter)
162
+ integer_part, fractional_part = string.split(DEFAULT_SEPARATOR)
163
+ [delimit_integer(integer_part, delimiter), fractional_part].compact
164
+ end
165
+
166
+ # Delimits the given integer part of a number.
167
+ #
168
+ # @param integer_part [String] integer part of the number
169
+ # @param delimiter [String] hundreds delimiter
170
+ #
171
+ # @return [String] delimited integer string
172
+ #
173
+ # @since 2.0.0
174
+ # @api private
175
+ def self.delimit_integer(integer_part, delimiter)
176
+ integer_part.gsub(DELIMITING_REGEX) { |digit| "#{digit}#{delimiter}" }
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+
6
+ module Hanami
7
+ class View
8
+ module Helpers
9
+ module TagHelper
10
+ # Tag builder returned from {TagHelper#tag}.
11
+ #
12
+ # @see TagHelper#tag
13
+ #
14
+ # @api public
15
+ # @since 2.0.0
16
+ class TagBuilder
17
+ # @api private
18
+ # @since 2.0.0
19
+ HTML_VOID_ELEMENTS = %i(
20
+ area base br col embed hr img input keygen link meta param source track wbr
21
+ ).to_set
22
+
23
+ # @api private
24
+ # @since 2.0.0
25
+ SVG_SELF_CLOSING_ELEMENTS = %i(
26
+ animate animateMotion animateTransform circle ellipse line path polygon polyline rect set stop use view
27
+ ).to_set
28
+
29
+ # @api private
30
+ # @since 2.0.0
31
+ ATTRIBUTE_SEPARATOR = " "
32
+
33
+ # @api private
34
+ # @since 2.0.0
35
+ BOOLEAN_ATTRIBUTES = %w(
36
+ allowfullscreen allowpaymentrequest async autofocus
37
+ autoplay checked compact controls declare default
38
+ defaultchecked defaultmuted defaultselected defer
39
+ disabled enabled formnovalidate hidden indeterminate
40
+ inert ismap itemscope loop multiple muted nohref
41
+ nomodule noresize noshade novalidate nowrap open
42
+ pauseonexit playsinline readonly required reversed
43
+ scoped seamless selected sortable truespeed
44
+ typemustmatch visible
45
+ ).to_set
46
+ BOOLEAN_ATTRIBUTES.merge(BOOLEAN_ATTRIBUTES.map(&:to_sym))
47
+ BOOLEAN_ATTRIBUTES.freeze
48
+
49
+ # @api private
50
+ # @since 2.0.0
51
+ ARIA_PREFIXES = ["aria", :aria].to_set.freeze
52
+
53
+ # @api private
54
+ # @since 2.0.0
55
+ DATA_PREFIXES = ["data", :data].to_set.freeze
56
+
57
+ # @api private
58
+ # @since 2.0.0
59
+ TAG_TYPES = {}.tap do |hsh|
60
+ BOOLEAN_ATTRIBUTES.each { |attr| hsh[attr] = :boolean }
61
+ DATA_PREFIXES.each { |attr| hsh[attr] = :data }
62
+ ARIA_PREFIXES.each { |attr| hsh[attr] = :aria }
63
+ hsh.freeze
64
+ end
65
+
66
+ # @api private
67
+ # @since 2.0.0
68
+ PRE_CONTENT_STRINGS = Hash.new { "" }
69
+ PRE_CONTENT_STRINGS[:textarea] = "\n"
70
+ PRE_CONTENT_STRINGS["textarea"] = "\n"
71
+ PRE_CONTENT_STRINGS.freeze
72
+
73
+ # @api private
74
+ # @since 2.0.0
75
+ attr_reader :inflector
76
+
77
+ # @api private
78
+ # @since 2.0.0
79
+ def initialize(inflector:)
80
+ @inflector = inflector
81
+ end
82
+
83
+ # Transforms a Hash into HTML Attributes, ready to be interpolated into
84
+ # ERB.
85
+ #
86
+ # @example
87
+ # <input <%= tag.attributes(type: :text, aria: { label: "Search" }) %> >
88
+ # # => <input type="text" aria-label="Search">
89
+ #
90
+ # @api public
91
+ # @since 2.0.0
92
+ def attributes(**attributes)
93
+ tag_options(**attributes).to_s.strip.html_safe
94
+ end
95
+
96
+ # Returns a `<p>` HTML tag.
97
+ #
98
+ # @api public
99
+ # @since 2.0.0
100
+ def p(*args, **options, &block)
101
+ tag_string(:p, *args, **options, &block)
102
+ end
103
+
104
+ # @api private
105
+ # @since 2.0.0
106
+ def tag_string(name, content = nil, **options)
107
+ content = yield if block_given?
108
+ self_closing = SVG_SELF_CLOSING_ELEMENTS.include?(name)
109
+
110
+ if (HTML_VOID_ELEMENTS.include?(name) || self_closing) && content.nil?
111
+ "<#{inflector.dasherize(name.to_s)}#{tag_options(**options)}#{self_closing ? " />" : ">"}".html_safe
112
+ else
113
+ content_tag_string(inflector.dasherize(name.to_s), content || "", **options)
114
+ end
115
+ end
116
+
117
+ # @api private
118
+ # @since 2.0.0
119
+ def content_tag_string(name, content, **options)
120
+ tag_options = tag_options(**options) unless options.empty?
121
+
122
+ name = EscapeHelper.escape_xml_name(name)
123
+ content = EscapeHelper.escape_html(content)
124
+
125
+ "<#{name}#{tag_options}>#{PRE_CONTENT_STRINGS[name]}#{content}</#{name}>".html_safe
126
+ end
127
+
128
+ # @api private
129
+ # @since 2.0.0
130
+ def tag_options(**options)
131
+ return if options.none?
132
+
133
+ output = +""
134
+
135
+ options.each_pair do |key, value|
136
+ type = TAG_TYPES[key]
137
+
138
+ if type == :data && value.is_a?(Hash)
139
+ value.each_pair do |k, v|
140
+ next if v.nil?
141
+
142
+ output << ATTRIBUTE_SEPARATOR
143
+ output << prefix_tag_option(key, k, v)
144
+ end
145
+ elsif type == :aria && value.is_a?(Hash)
146
+ value.each_pair do |k, v|
147
+ next if v.nil?
148
+
149
+ case v
150
+ when Array, Hash
151
+ tokens = TagHelper.build_tag_values(v)
152
+ next if tokens.none?
153
+
154
+ v = EscapeHelper.escape_join(tokens, " ")
155
+ else
156
+ v = v.to_s
157
+ end
158
+
159
+ output << ATTRIBUTE_SEPARATOR
160
+ output << prefix_tag_option(key, k, v)
161
+ end
162
+ elsif type == :boolean
163
+ if value
164
+ output << ATTRIBUTE_SEPARATOR
165
+ output << boolean_tag_option(key)
166
+ end
167
+ elsif !value.nil?
168
+ output << ATTRIBUTE_SEPARATOR
169
+ output << tag_option(key, value)
170
+ end
171
+ end
172
+
173
+ output unless output.empty?
174
+ end
175
+
176
+ # @api private
177
+ # @since 2.0.0
178
+ def boolean_tag_option(key)
179
+ %(#{key}="#{key}")
180
+ end
181
+
182
+ # @api private
183
+ # @since 2.0.0
184
+ def tag_option(key, value)
185
+ key = EscapeHelper.escape_xml_name(key)
186
+
187
+ case value
188
+ when Array, Hash
189
+ value = TagHelper.build_tag_values(value) if key.to_s == "class"
190
+ value = EscapeHelper.escape_join(value, " ")
191
+ when Regexp
192
+ value = EscapeHelper.escape_html(value.source)
193
+ else
194
+ value = EscapeHelper.escape_html(value)
195
+ end
196
+ value = value.gsub('"', "&quot;") if value.include?('"')
197
+
198
+ %(#{key}="#{value}")
199
+ end
200
+
201
+ private
202
+
203
+ # @api private
204
+ # @since 2.0.0
205
+ def method_missing(called, *args, **options, &block)
206
+ tag_string(called, *args, **options, &block)
207
+ end
208
+
209
+ # @api private
210
+ # @since 2.0.0
211
+ def respond_to_missing?(*args)
212
+ true
213
+ end
214
+
215
+ # @api private
216
+ # @since 2.0.0
217
+ def prefix_tag_option(prefix, key, value)
218
+ key = "#{prefix}-#{inflector.dasherize(key.to_s)}"
219
+
220
+ unless value.is_a?(String) || value.is_a?(Symbol)
221
+ value = value.to_json
222
+ end
223
+
224
+ tag_option(key, value)
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end