hanami-view 2.0.0.alpha8 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +15 -3
- data/hanami-view.gemspec +5 -3
- data/lib/hanami/view/cache.rb +16 -0
- data/lib/hanami/view/context.rb +15 -55
- data/lib/hanami/view/context_helpers/content_helpers.rb +5 -5
- data/lib/hanami/view/decorated_attributes.rb +2 -2
- data/lib/hanami/view/erb/engine.rb +27 -0
- data/lib/hanami/view/erb/filters/block.rb +44 -0
- data/lib/hanami/view/erb/filters/trimming.rb +42 -0
- data/lib/hanami/view/erb/parser.rb +161 -0
- data/lib/hanami/view/erb/template.rb +30 -0
- data/lib/hanami/view/errors.rb +8 -2
- data/lib/hanami/view/exposure.rb +23 -17
- data/lib/hanami/view/exposures.rb +22 -13
- data/lib/hanami/view/helpers/escape_helper.rb +221 -0
- data/lib/hanami/view/helpers/number_formatting_helper.rb +182 -0
- data/lib/hanami/view/helpers/tag_helper/tag_builder.rb +230 -0
- data/lib/hanami/view/helpers/tag_helper.rb +210 -0
- data/lib/hanami/view/html.rb +104 -0
- data/lib/hanami/view/html_safe_string_buffer.rb +46 -0
- data/lib/hanami/view/part.rb +13 -15
- data/lib/hanami/view/part_builder.rb +68 -108
- data/lib/hanami/view/path.rb +4 -31
- data/lib/hanami/view/renderer.rb +36 -44
- data/lib/hanami/view/rendering.rb +42 -0
- data/lib/hanami/view/{render_environment_missing.rb → rendering_missing.rb} +8 -13
- data/lib/hanami/view/scope.rb +14 -15
- data/lib/hanami/view/scope_builder.rb +42 -78
- data/lib/hanami/view/tilt/haml_adapter.rb +40 -0
- data/lib/hanami/view/tilt/slim_adapter.rb +40 -0
- data/lib/hanami/view/tilt.rb +22 -46
- data/lib/hanami/view/version.rb +1 -1
- data/lib/hanami/view.rb +53 -91
- metadata +64 -26
- data/LICENSE +0 -20
- data/lib/hanami/view/render_environment.rb +0 -62
- data/lib/hanami/view/tilt/erb.rb +0 -26
- data/lib/hanami/view/tilt/erbse.rb +0 -21
- 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
|
-
#
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
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
|
+
# # => "<script>alert('xss')</script>"
|
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><br><p>bar</p>"
|
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('"', """) 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
|