hanami-view 2.0.0.alpha8 → 2.1.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -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 +15 -55
  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 +8 -2
  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 +14 -15
  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 +53 -91
  36. metadata +64 -26
  37. data/LICENSE +0 -20
  38. data/lib/hanami/view/render_environment.rb +0 -62
  39. data/lib/hanami/view/tilt/erb.rb +0 -26
  40. data/lib/hanami/view/tilt/erbse.rb +0 -21
  41. 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