view_component 2.49.1 → 3.23.2
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/LICENSE.txt +1 -1
- data/app/assets/vendor/prism.css +3 -195
- data/app/assets/vendor/prism.min.js +11 -11
- data/app/controllers/concerns/view_component/preview_actions.rb +108 -0
- data/app/controllers/view_components_controller.rb +1 -87
- data/app/controllers/view_components_system_test_controller.rb +30 -0
- data/app/helpers/preview_helper.rb +30 -12
- data/app/views/view_components/_preview_source.html.erb +3 -3
- data/app/views/view_components/preview.html.erb +2 -2
- data/docs/CHANGELOG.md +1653 -24
- data/lib/rails/generators/abstract_generator.rb +16 -10
- data/lib/rails/generators/component/component_generator.rb +8 -4
- data/lib/rails/generators/component/templates/component.rb.tt +3 -2
- data/lib/rails/generators/erb/component_generator.rb +1 -1
- data/lib/rails/generators/locale/component_generator.rb +4 -4
- data/lib/rails/generators/preview/component_generator.rb +17 -3
- data/lib/rails/generators/preview/templates/component_preview.rb.tt +5 -1
- data/lib/rails/generators/rspec/component_generator.rb +15 -3
- data/lib/rails/generators/rspec/templates/component_spec.rb.tt +3 -1
- data/lib/rails/generators/stimulus/component_generator.rb +8 -3
- data/lib/rails/generators/stimulus/templates/component_controller.ts.tt +9 -0
- data/lib/rails/generators/test_unit/templates/component_test.rb.tt +3 -1
- data/lib/view_component/base.rb +352 -196
- data/lib/view_component/capture_compatibility.rb +44 -0
- data/lib/view_component/collection.rb +28 -9
- data/lib/view_component/compiler.rb +162 -193
- data/lib/view_component/config.rb +225 -0
- data/lib/view_component/configurable.rb +17 -0
- data/lib/view_component/deprecation.rb +8 -0
- data/lib/view_component/engine.rb +74 -47
- data/lib/view_component/errors.rb +240 -0
- data/lib/view_component/inline_template.rb +55 -0
- data/lib/view_component/instrumentation.rb +10 -2
- data/lib/view_component/preview.rb +21 -19
- data/lib/view_component/rails/tasks/view_component.rake +11 -2
- data/lib/view_component/render_component_helper.rb +1 -0
- data/lib/view_component/render_component_to_string_helper.rb +1 -1
- data/lib/view_component/render_to_string_monkey_patch.rb +1 -1
- data/lib/view_component/rendering_component_helper.rb +1 -1
- data/lib/view_component/rendering_monkey_patch.rb +1 -1
- data/lib/view_component/slot.rb +119 -1
- data/lib/view_component/slotable.rb +393 -96
- data/lib/view_component/slotable_default.rb +20 -0
- data/lib/view_component/system_test_case.rb +13 -0
- data/lib/view_component/system_test_helpers.rb +27 -0
- data/lib/view_component/template.rb +134 -0
- data/lib/view_component/test_helpers.rb +208 -47
- data/lib/view_component/translatable.rb +51 -33
- data/lib/view_component/use_helpers.rb +42 -0
- data/lib/view_component/version.rb +5 -4
- data/lib/view_component/with_content_helper.rb +3 -8
- data/lib/view_component.rb +7 -12
- metadata +339 -57
- data/lib/rails/generators/component/USAGE +0 -13
- data/lib/view_component/content_areas.rb +0 -57
- data/lib/view_component/polymorphic_slots.rb +0 -73
- data/lib/view_component/preview_template_error.rb +0 -6
- data/lib/view_component/previewable.rb +0 -62
- data/lib/view_component/slot_v2.rb +0 -104
- data/lib/view_component/slotable_v2.rb +0 -307
- data/lib/view_component/template_error.rb +0 -9
- data/lib/yard/mattr_accessor_handler.rb +0 -19
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ViewComponent
|
4
|
+
class Template
|
5
|
+
DataWithSource = Struct.new(:format, :identifier, :short_identifier, :type, keyword_init: true)
|
6
|
+
DataNoSource = Struct.new(:source, :identifier, :type, keyword_init: true)
|
7
|
+
|
8
|
+
attr_reader :variant, :this_format, :type
|
9
|
+
|
10
|
+
def initialize(
|
11
|
+
component:,
|
12
|
+
type:,
|
13
|
+
this_format: nil,
|
14
|
+
variant: nil,
|
15
|
+
lineno: nil,
|
16
|
+
path: nil,
|
17
|
+
extension: nil,
|
18
|
+
source: nil,
|
19
|
+
method_name: nil,
|
20
|
+
defined_on_self: true
|
21
|
+
)
|
22
|
+
@component = component
|
23
|
+
@type = type
|
24
|
+
@this_format = this_format
|
25
|
+
@variant = variant&.to_sym
|
26
|
+
@lineno = lineno
|
27
|
+
@path = path
|
28
|
+
@extension = extension
|
29
|
+
@source = source
|
30
|
+
@method_name = method_name
|
31
|
+
@defined_on_self = defined_on_self
|
32
|
+
|
33
|
+
@source_originally_nil = @source.nil?
|
34
|
+
|
35
|
+
@call_method_name =
|
36
|
+
if @method_name
|
37
|
+
@method_name
|
38
|
+
else
|
39
|
+
out = +"call"
|
40
|
+
out << "_#{normalized_variant_name}" if @variant.present?
|
41
|
+
out << "_#{@this_format}" if @this_format.present? && @this_format != ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT
|
42
|
+
out
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def compile_to_component
|
47
|
+
if !inline_call?
|
48
|
+
@component.silence_redefinition_of_method(@call_method_name)
|
49
|
+
|
50
|
+
# rubocop:disable Style/EvalWithLocation
|
51
|
+
@component.class_eval <<-RUBY, @path, @lineno
|
52
|
+
def #{@call_method_name}
|
53
|
+
#{compiled_source}
|
54
|
+
end
|
55
|
+
RUBY
|
56
|
+
# rubocop:enable Style/EvalWithLocation
|
57
|
+
end
|
58
|
+
|
59
|
+
@component.define_method(safe_method_name, @component.instance_method(@call_method_name))
|
60
|
+
end
|
61
|
+
|
62
|
+
def safe_method_name_call
|
63
|
+
return safe_method_name unless inline_call?
|
64
|
+
|
65
|
+
"maybe_escape_html(#{safe_method_name}) " \
|
66
|
+
"{ Kernel.warn(\"WARNING: The #{@component} component rendered HTML-unsafe output. " \
|
67
|
+
"The output will be automatically escaped, but you may want to investigate.\") } "
|
68
|
+
end
|
69
|
+
|
70
|
+
def requires_compiled_superclass?
|
71
|
+
inline_call? && !defined_on_self?
|
72
|
+
end
|
73
|
+
|
74
|
+
def inline_call?
|
75
|
+
@type == :inline_call
|
76
|
+
end
|
77
|
+
|
78
|
+
def inline?
|
79
|
+
@type == :inline
|
80
|
+
end
|
81
|
+
|
82
|
+
def default_format?
|
83
|
+
@this_format == ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT
|
84
|
+
end
|
85
|
+
|
86
|
+
def format
|
87
|
+
@this_format
|
88
|
+
end
|
89
|
+
|
90
|
+
def safe_method_name
|
91
|
+
"_#{@call_method_name}_#{@component.name.underscore.gsub("/", "__")}"
|
92
|
+
end
|
93
|
+
|
94
|
+
def normalized_variant_name
|
95
|
+
@variant.to_s.gsub("-", "__").gsub(".", "___")
|
96
|
+
end
|
97
|
+
|
98
|
+
def defined_on_self?
|
99
|
+
@defined_on_self
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
def source
|
105
|
+
if @source_originally_nil
|
106
|
+
# Load file each time we look up #source in case the file has been modified
|
107
|
+
File.read(@path)
|
108
|
+
else
|
109
|
+
@source
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def compiled_source
|
114
|
+
handler = ActionView::Template.handler_for_extension(@extension)
|
115
|
+
this_source = source
|
116
|
+
this_source.rstrip! if @component.strip_trailing_whitespace?
|
117
|
+
|
118
|
+
short_identifier = defined?(Rails.root) ? @path.sub("#{Rails.root}/", "") : @path
|
119
|
+
type = ActionView::Template::Types[@this_format]
|
120
|
+
|
121
|
+
if handler.method(:call).parameters.length > 1
|
122
|
+
handler.call(
|
123
|
+
DataWithSource.new(format: @this_format, identifier: @path, short_identifier: short_identifier, type: type),
|
124
|
+
this_source
|
125
|
+
)
|
126
|
+
# :nocov:
|
127
|
+
# TODO: Remove in v4
|
128
|
+
else
|
129
|
+
handler.call(DataNoSource.new(source: this_source, identifier: @path, type: type))
|
130
|
+
end
|
131
|
+
# :nocov:
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -4,31 +4,38 @@ module ViewComponent
|
|
4
4
|
module TestHelpers
|
5
5
|
begin
|
6
6
|
require "capybara/minitest"
|
7
|
+
|
7
8
|
include Capybara::Minitest::Assertions
|
8
9
|
|
9
10
|
def page
|
10
|
-
Capybara::Node::Simple.new(
|
11
|
+
@page ||= Capybara::Node::Simple.new(rendered_content)
|
11
12
|
end
|
12
13
|
|
13
14
|
def refute_component_rendered
|
14
15
|
assert_no_selector("body")
|
15
16
|
end
|
17
|
+
|
18
|
+
def assert_component_rendered
|
19
|
+
assert_selector("body")
|
20
|
+
end
|
16
21
|
rescue LoadError
|
17
22
|
# We don't have a test case for running an application without capybara installed.
|
18
23
|
# It's probably fine to leave this without coverage.
|
19
24
|
# :nocov:
|
20
25
|
if ENV["DEBUG"]
|
21
26
|
warn(
|
22
|
-
"WARNING in `ViewComponent::TestHelpers`:
|
23
|
-
"to
|
27
|
+
"WARNING in `ViewComponent::TestHelpers`: Add `capybara` " \
|
28
|
+
"to Gemfile to use Capybara assertions."
|
24
29
|
)
|
25
30
|
end
|
26
31
|
|
27
32
|
# :nocov:
|
28
33
|
end
|
29
34
|
|
30
|
-
#
|
31
|
-
|
35
|
+
# Returns the result of a render_inline call.
|
36
|
+
#
|
37
|
+
# @return [ActionView::OutputBuffer]
|
38
|
+
attr_reader :rendered_content
|
32
39
|
|
33
40
|
# Render a component inline. Internally sets `page` to be a `Capybara::Node::Simple`,
|
34
41
|
# allowing for Capybara assertions to be used:
|
@@ -41,31 +48,86 @@ module ViewComponent
|
|
41
48
|
# @param component [ViewComponent::Base, ViewComponent::Collection] The instance of the component to be rendered.
|
42
49
|
# @return [Nokogiri::HTML]
|
43
50
|
def render_inline(component, **args, &block)
|
44
|
-
@
|
51
|
+
@page = nil
|
52
|
+
@rendered_content =
|
45
53
|
if Rails.version.to_f >= 6.1
|
46
|
-
|
54
|
+
vc_test_controller.view_context.render(component, args, &block)
|
55
|
+
|
56
|
+
# :nocov:
|
47
57
|
else
|
48
|
-
|
58
|
+
vc_test_controller.view_context.render_component(component, &block)
|
49
59
|
end
|
50
60
|
|
51
|
-
|
61
|
+
# :nocov:
|
62
|
+
|
63
|
+
Nokogiri::HTML.fragment(@rendered_content)
|
52
64
|
end
|
53
65
|
|
54
|
-
#
|
55
|
-
|
56
|
-
|
66
|
+
# `JSON.parse`-d component output.
|
67
|
+
#
|
68
|
+
# ```ruby
|
69
|
+
# render_inline(MyJsonComponent.new)
|
70
|
+
# assert_equal(rendered_json["hello"], "world")
|
71
|
+
# ```
|
72
|
+
def rendered_json
|
73
|
+
JSON.parse(rendered_content)
|
57
74
|
end
|
58
75
|
|
59
|
-
#
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
76
|
+
# Render a preview inline. Internally sets `page` to be a `Capybara::Node::Simple`,
|
77
|
+
# allowing for Capybara assertions to be used:
|
78
|
+
#
|
79
|
+
# ```ruby
|
80
|
+
# render_preview(:default)
|
81
|
+
# assert_text("Hello, World!")
|
82
|
+
# ```
|
83
|
+
#
|
84
|
+
# Note: `#rendered_preview` expects a preview to be defined with the same class
|
85
|
+
# name as the calling test, but with `Test` replaced with `Preview`:
|
86
|
+
#
|
87
|
+
# MyComponentTest -> MyComponentPreview etc.
|
88
|
+
#
|
89
|
+
# In RSpec, `Preview` is appended to `described_class`.
|
90
|
+
#
|
91
|
+
# @param name [String] The name of the preview to be rendered.
|
92
|
+
# @param from [ViewComponent::Preview] The class of the preview to be rendered.
|
93
|
+
# @param params [Hash] Parameters to be passed to the preview.
|
94
|
+
# @return [Nokogiri::HTML]
|
95
|
+
def render_preview(name, from: __vc_test_helpers_preview_class, params: {})
|
96
|
+
previews_controller = __vc_test_helpers_build_controller(Rails.application.config.view_component.preview_controller.constantize)
|
97
|
+
|
98
|
+
# From what I can tell, it's not possible to overwrite all request parameters
|
99
|
+
# at once, so we set them individually here.
|
100
|
+
params.each do |k, v|
|
101
|
+
previews_controller.request.params[k] = v
|
102
|
+
end
|
103
|
+
|
104
|
+
previews_controller.request.params[:path] = "#{from.preview_name}/#{name}"
|
105
|
+
previews_controller.set_response!(ActionDispatch::Response.new)
|
106
|
+
result = previews_controller.previews
|
107
|
+
|
108
|
+
@rendered_content = result
|
109
|
+
|
110
|
+
Nokogiri::HTML.fragment(@rendered_content)
|
67
111
|
end
|
68
112
|
|
113
|
+
# Execute the given block in the view context (using `instance_exec`).
|
114
|
+
# Internally sets `page` to be a `Capybara::Node::Simple`, allowing for
|
115
|
+
# Capybara assertions to be used. All arguments are forwarded to the block.
|
116
|
+
#
|
117
|
+
# ```ruby
|
118
|
+
# render_in_view_context(arg1, arg2: nil) do |arg1, arg2:|
|
119
|
+
# render(MyComponent.new(arg1, arg2))
|
120
|
+
# end
|
121
|
+
#
|
122
|
+
# assert_text("Hello, World!")
|
123
|
+
# ```
|
124
|
+
def render_in_view_context(*args, &block)
|
125
|
+
@page = nil
|
126
|
+
@rendered_content = vc_test_controller.view_context.instance_exec(*args, &block)
|
127
|
+
Nokogiri::HTML.fragment(@rendered_content)
|
128
|
+
end
|
129
|
+
ruby2_keywords(:render_in_view_context) if respond_to?(:ruby2_keywords, true)
|
130
|
+
|
69
131
|
# Set the Action Pack request variant for the given block:
|
70
132
|
#
|
71
133
|
# ```ruby
|
@@ -76,12 +138,12 @@ module ViewComponent
|
|
76
138
|
#
|
77
139
|
# @param variant [Symbol] The variant to be set for the provided block.
|
78
140
|
def with_variant(variant)
|
79
|
-
old_variants =
|
141
|
+
old_variants = vc_test_controller.view_context.lookup_context.variants
|
80
142
|
|
81
|
-
|
143
|
+
vc_test_controller.view_context.lookup_context.variants = variant
|
82
144
|
yield
|
83
145
|
ensure
|
84
|
-
|
146
|
+
vc_test_controller.view_context.lookup_context.variants = old_variants
|
85
147
|
end
|
86
148
|
|
87
149
|
# Set the controller to be used while executing the given block,
|
@@ -93,14 +155,27 @@ module ViewComponent
|
|
93
155
|
# end
|
94
156
|
# ```
|
95
157
|
#
|
96
|
-
# @param klass [ActionController::Base] The controller to be used.
|
158
|
+
# @param klass [Class<ActionController::Base>] The controller to be used.
|
97
159
|
def with_controller_class(klass)
|
98
|
-
old_controller = defined?(@
|
160
|
+
old_controller = defined?(@vc_test_controller) && @vc_test_controller
|
99
161
|
|
100
|
-
@
|
162
|
+
@vc_test_controller = __vc_test_helpers_build_controller(klass)
|
101
163
|
yield
|
102
164
|
ensure
|
103
|
-
@
|
165
|
+
@vc_test_controller = old_controller
|
166
|
+
end
|
167
|
+
|
168
|
+
# Set format of the current request
|
169
|
+
#
|
170
|
+
# ```ruby
|
171
|
+
# with_format(:json) do
|
172
|
+
# render_inline(MyComponent.new)
|
173
|
+
# end
|
174
|
+
# ```
|
175
|
+
#
|
176
|
+
# @param format [Symbol] The format to be set for the provided block.
|
177
|
+
def with_format(format)
|
178
|
+
with_request_url("/", format: format) { yield }
|
104
179
|
end
|
105
180
|
|
106
181
|
# Set the URL of the current request (such as when using request-dependent path helpers):
|
@@ -111,30 +186,116 @@ module ViewComponent
|
|
111
186
|
# end
|
112
187
|
# ```
|
113
188
|
#
|
114
|
-
#
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
189
|
+
# To use a specific host, pass the host param:
|
190
|
+
#
|
191
|
+
# ```ruby
|
192
|
+
# with_request_url("/users/42", host: "app.example.com") do
|
193
|
+
# render_inline(MyComponent.new)
|
194
|
+
# end
|
195
|
+
# ```
|
196
|
+
#
|
197
|
+
# To specify a request method, pass the method param:
|
198
|
+
#
|
199
|
+
# ```ruby
|
200
|
+
# with_request_url("/users/42", method: "POST") do
|
201
|
+
# render_inline(MyComponent.new)
|
202
|
+
# end
|
203
|
+
# ```
|
204
|
+
#
|
205
|
+
# @param full_path [String] The path to set for the current request.
|
206
|
+
# @param host [String] The host to set for the current request.
|
207
|
+
# @param method [String] The request method to set for the current request.
|
208
|
+
def with_request_url(full_path, host: nil, method: nil, format: ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT)
|
209
|
+
old_request_host = vc_test_request.host
|
210
|
+
old_request_method = vc_test_request.request_method
|
211
|
+
old_request_path_info = vc_test_request.path_info
|
212
|
+
old_request_path_parameters = vc_test_request.path_parameters
|
213
|
+
old_request_query_parameters = vc_test_request.query_parameters
|
214
|
+
old_request_query_string = vc_test_request.query_string
|
215
|
+
old_request_format = vc_test_request.format.symbol
|
216
|
+
old_controller = defined?(@vc_test_controller) && @vc_test_controller
|
217
|
+
|
218
|
+
path, query = full_path.split("?", 2)
|
219
|
+
vc_test_request.instance_variable_set(:@fullpath, full_path)
|
220
|
+
vc_test_request.instance_variable_set(:@original_fullpath, full_path)
|
221
|
+
vc_test_request.host = host if host
|
222
|
+
vc_test_request.request_method = method if method
|
223
|
+
vc_test_request.path_info = path
|
224
|
+
vc_test_request.path_parameters = Rails.application.routes.recognize_path_with_request(vc_test_request, path, {})
|
225
|
+
vc_test_request.set_header("action_dispatch.request.query_parameters",
|
226
|
+
Rack::Utils.parse_nested_query(query).with_indifferent_access)
|
227
|
+
vc_test_request.set_header(Rack::QUERY_STRING, query)
|
228
|
+
vc_test_request.format = format
|
126
229
|
yield
|
127
230
|
ensure
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
231
|
+
vc_test_request.host = old_request_host
|
232
|
+
vc_test_request.request_method = old_request_method
|
233
|
+
vc_test_request.path_info = old_request_path_info
|
234
|
+
vc_test_request.path_parameters = old_request_path_parameters
|
235
|
+
vc_test_request.set_header("action_dispatch.request.query_parameters", old_request_query_parameters)
|
236
|
+
vc_test_request.set_header(Rack::QUERY_STRING, old_request_query_string)
|
237
|
+
vc_test_request.format = old_request_format
|
238
|
+
@vc_test_controller = old_controller
|
133
239
|
end
|
134
240
|
|
135
|
-
#
|
136
|
-
|
137
|
-
|
241
|
+
# Access the controller used by `render_inline`:
|
242
|
+
#
|
243
|
+
# ```ruby
|
244
|
+
# test "logged out user sees login link" do
|
245
|
+
# vc_test_controller.expects(:logged_in?).at_least_once.returns(false)
|
246
|
+
# render_inline(LoginComponent.new)
|
247
|
+
# assert_selector("[aria-label='You must be signed in']")
|
248
|
+
# end
|
249
|
+
# ```
|
250
|
+
#
|
251
|
+
# @return [ActionController::Base]
|
252
|
+
def vc_test_controller
|
253
|
+
@vc_test_controller ||= __vc_test_helpers_build_controller(Base.test_controller.constantize)
|
254
|
+
end
|
255
|
+
|
256
|
+
# Access the request used by `render_inline`:
|
257
|
+
#
|
258
|
+
# ```ruby
|
259
|
+
# test "component does not render in Firefox" do
|
260
|
+
# request.env["HTTP_USER_AGENT"] = "Mozilla/5.0"
|
261
|
+
# render_inline(NoFirefoxComponent.new)
|
262
|
+
# refute_component_rendered
|
263
|
+
# end
|
264
|
+
# ```
|
265
|
+
#
|
266
|
+
# @return [ActionDispatch::TestRequest]
|
267
|
+
def vc_test_request
|
268
|
+
require "action_controller/test_case"
|
269
|
+
|
270
|
+
@vc_test_request ||=
|
271
|
+
begin
|
272
|
+
out = ActionDispatch::TestRequest.create
|
273
|
+
out.session = ActionController::TestSession.new
|
274
|
+
out
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
# Note: We prefix private methods here to prevent collisions in consumer's tests.
|
279
|
+
private
|
280
|
+
|
281
|
+
def __vc_test_helpers_build_controller(klass)
|
282
|
+
klass.new.tap { |c| c.request = vc_test_request }.extend(Rails.application.routes.url_helpers)
|
283
|
+
end
|
284
|
+
|
285
|
+
def __vc_test_helpers_preview_class
|
286
|
+
result = if respond_to?(:described_class)
|
287
|
+
# :nocov:
|
288
|
+
raise "`render_preview` expected a described_class, but it is nil." if described_class.nil?
|
289
|
+
|
290
|
+
"#{described_class}Preview"
|
291
|
+
# :nocov:
|
292
|
+
else
|
293
|
+
self.class.name.gsub("Test", "Preview")
|
294
|
+
end
|
295
|
+
result = result.constantize
|
296
|
+
rescue NameError
|
297
|
+
raise NameError, "`render_preview` expected to find #{result}, but it does not exist."
|
138
298
|
end
|
299
|
+
# :nocov:
|
139
300
|
end
|
140
301
|
end
|
@@ -3,14 +3,14 @@
|
|
3
3
|
require "erb"
|
4
4
|
require "set"
|
5
5
|
require "i18n"
|
6
|
-
require "action_view/helpers/translation_helper"
|
7
6
|
require "active_support/concern"
|
8
7
|
|
9
8
|
module ViewComponent
|
10
9
|
module Translatable
|
11
10
|
extend ActiveSupport::Concern
|
12
11
|
|
13
|
-
HTML_SAFE_TRANSLATION_KEY = /(?:_|\b)html\z
|
12
|
+
HTML_SAFE_TRANSLATION_KEY = /(?:_|\b)html\z/
|
13
|
+
TRANSLATION_EXTENSIONS = %w[yml yaml].freeze
|
14
14
|
|
15
15
|
included do
|
16
16
|
class_attribute :i18n_backend, instance_writer: false, instance_predicate: false
|
@@ -21,21 +21,46 @@ module ViewComponent
|
|
21
21
|
@i18n_scope ||= virtual_path.sub(%r{^/}, "").gsub(%r{/_?}, ".")
|
22
22
|
end
|
23
23
|
|
24
|
-
def
|
25
|
-
|
24
|
+
def build_i18n_backend
|
25
|
+
return if compiled?
|
26
26
|
|
27
|
-
|
27
|
+
# We need to load the translations files from the ancestors so a component
|
28
|
+
# can inherit translations from its parent and is able to overwrite them.
|
29
|
+
translation_files = ancestors.reverse_each.with_object([]) do |ancestor, files|
|
30
|
+
if ancestor.is_a?(Class) && ancestor < ViewComponent::Base
|
31
|
+
files.concat(ancestor.sidecar_files(TRANSLATION_EXTENSIONS))
|
32
|
+
end
|
33
|
+
end
|
28
34
|
|
29
|
-
if
|
30
|
-
|
35
|
+
# In development it will become nil if the translations file is removed
|
36
|
+
self.i18n_backend = if translation_files.any?
|
37
|
+
I18nBackend.new(
|
31
38
|
i18n_scope: i18n_scope,
|
32
|
-
load_paths: translation_files
|
39
|
+
load_paths: translation_files
|
33
40
|
)
|
34
|
-
else
|
35
|
-
# Cleanup if translations file has been removed since the last compilation
|
36
|
-
self.i18n_backend = nil
|
37
41
|
end
|
38
42
|
end
|
43
|
+
|
44
|
+
def i18n_key(key, scope = nil)
|
45
|
+
scope = scope.join(".") if scope.is_a? Array
|
46
|
+
key = key&.to_s unless key.is_a?(String)
|
47
|
+
key = "#{scope}.#{key}" if scope
|
48
|
+
key = "#{i18n_scope}#{key}" if key.start_with?(".")
|
49
|
+
key
|
50
|
+
end
|
51
|
+
|
52
|
+
def translate(key = nil, **options)
|
53
|
+
return key.map { |k| translate(k, **options) } if key.is_a?(Array)
|
54
|
+
|
55
|
+
ensure_compiled
|
56
|
+
|
57
|
+
locale = options.delete(:locale) || ::I18n.locale
|
58
|
+
key = i18n_key(key, options.delete(:scope))
|
59
|
+
|
60
|
+
i18n_backend.translate(locale, key, options)
|
61
|
+
end
|
62
|
+
|
63
|
+
alias_method :t, :translate
|
39
64
|
end
|
40
65
|
|
41
66
|
class I18nBackend < ::I18n::Backend::Simple
|
@@ -53,7 +78,7 @@ module ViewComponent
|
|
53
78
|
|
54
79
|
def scope_data(data)
|
55
80
|
@i18n_scope.reverse_each do |part|
|
56
|
-
data = {
|
81
|
+
data = {part => data}
|
57
82
|
end
|
58
83
|
data
|
59
84
|
end
|
@@ -64,16 +89,16 @@ module ViewComponent
|
|
64
89
|
end
|
65
90
|
|
66
91
|
def translate(key = nil, **options)
|
92
|
+
raise ViewComponent::TranslateCalledBeforeRenderError if view_context.nil?
|
93
|
+
|
67
94
|
return super unless i18n_backend
|
68
95
|
return key.map { |k| translate(k, **options) } if key.is_a?(Array)
|
69
96
|
|
70
97
|
locale = options.delete(:locale) || ::I18n.locale
|
71
|
-
key = key
|
72
|
-
|
98
|
+
key = self.class.i18n_key(key, options.delete(:scope))
|
99
|
+
as_html = HTML_SAFE_TRANSLATION_KEY.match?(key)
|
73
100
|
|
74
|
-
if
|
75
|
-
html_escape_translation_options!(options)
|
76
|
-
end
|
101
|
+
html_escape_translation_options!(options) if as_html
|
77
102
|
|
78
103
|
if key.start_with?(i18n_scope + ".")
|
79
104
|
translated =
|
@@ -86,22 +111,21 @@ module ViewComponent
|
|
86
111
|
return super(key, locale: locale, **options)
|
87
112
|
end
|
88
113
|
|
89
|
-
|
90
|
-
translated = translated.html_safe # rubocop:disable Rails/OutputSafety
|
91
|
-
end
|
92
|
-
|
114
|
+
translated = html_safe_translation(translated) if as_html
|
93
115
|
translated
|
94
116
|
else
|
95
117
|
super(key, locale: locale, **options)
|
96
118
|
end
|
97
119
|
end
|
98
|
-
|
120
|
+
alias_method :t, :translate
|
99
121
|
|
100
122
|
# Exposes .i18n_scope as an instance method
|
101
123
|
def i18n_scope
|
102
124
|
self.class.i18n_scope
|
103
125
|
end
|
104
126
|
|
127
|
+
private
|
128
|
+
|
105
129
|
def html_safe_translation(translation)
|
106
130
|
if translation.respond_to?(:map)
|
107
131
|
translation.map { |element| html_safe_translation(element) }
|
@@ -109,22 +133,16 @@ module ViewComponent
|
|
109
133
|
# It's assumed here that objects loaded by the i18n backend will respond to `#html_safe?`.
|
110
134
|
# It's reasonable that if we're in Rails, `active_support/core_ext/string/output_safety.rb`
|
111
135
|
# will provide this to `Object`.
|
112
|
-
translation.html_safe
|
136
|
+
translation.html_safe
|
113
137
|
end
|
114
138
|
end
|
115
139
|
|
116
|
-
private
|
117
|
-
|
118
140
|
def html_escape_translation_options!(options)
|
119
|
-
options.each do |name, value|
|
120
|
-
|
121
|
-
options[name] = ERB::Util.html_escape(value.to_s)
|
122
|
-
end
|
123
|
-
end
|
124
|
-
end
|
141
|
+
options.except(*::I18n::RESERVED_KEYS).each do |name, value|
|
142
|
+
next if name == :count && value.is_a?(Numeric)
|
125
143
|
|
126
|
-
|
127
|
-
|
144
|
+
options[name] = ERB::Util.html_escape(value.to_s)
|
145
|
+
end
|
128
146
|
end
|
129
147
|
end
|
130
148
|
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ViewComponent::UseHelpers
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
class_methods do
|
7
|
+
def use_helpers(*args, from: nil, prefix: false)
|
8
|
+
args.each { |helper_method| use_helper(helper_method, from: from, prefix: prefix) }
|
9
|
+
end
|
10
|
+
|
11
|
+
def use_helper(helper_method, from: nil, prefix: false)
|
12
|
+
helper_method_name = full_helper_method_name(helper_method, prefix: prefix, source: from)
|
13
|
+
|
14
|
+
class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
|
15
|
+
def #{helper_method_name}(*args, &block)
|
16
|
+
raise HelpersCalledBeforeRenderError if view_context.nil?
|
17
|
+
|
18
|
+
#{define_helper(helper_method: helper_method, source: from)}
|
19
|
+
end
|
20
|
+
RUBY
|
21
|
+
ruby2_keywords(helper_method_name) if respond_to?(:ruby2_keywords, true)
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def full_helper_method_name(helper_method, prefix: false, source: nil)
|
27
|
+
return helper_method unless prefix.present?
|
28
|
+
|
29
|
+
if !!prefix == prefix
|
30
|
+
"#{source.to_s.underscore}_#{helper_method}"
|
31
|
+
else
|
32
|
+
"#{prefix}_#{helper_method}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def define_helper(helper_method:, source:)
|
37
|
+
return "__vc_original_view_context.#{helper_method}(*args, &block)" unless source.present?
|
38
|
+
|
39
|
+
"#{source}.instance_method(:#{helper_method}).bind(self).call(*args, &block)"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|