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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/app/assets/vendor/prism.css +3 -195
  4. data/app/assets/vendor/prism.min.js +11 -11
  5. data/app/controllers/concerns/view_component/preview_actions.rb +108 -0
  6. data/app/controllers/view_components_controller.rb +1 -87
  7. data/app/controllers/view_components_system_test_controller.rb +30 -0
  8. data/app/helpers/preview_helper.rb +30 -12
  9. data/app/views/view_components/_preview_source.html.erb +3 -3
  10. data/app/views/view_components/preview.html.erb +2 -2
  11. data/docs/CHANGELOG.md +1653 -24
  12. data/lib/rails/generators/abstract_generator.rb +16 -10
  13. data/lib/rails/generators/component/component_generator.rb +8 -4
  14. data/lib/rails/generators/component/templates/component.rb.tt +3 -2
  15. data/lib/rails/generators/erb/component_generator.rb +1 -1
  16. data/lib/rails/generators/locale/component_generator.rb +4 -4
  17. data/lib/rails/generators/preview/component_generator.rb +17 -3
  18. data/lib/rails/generators/preview/templates/component_preview.rb.tt +5 -1
  19. data/lib/rails/generators/rspec/component_generator.rb +15 -3
  20. data/lib/rails/generators/rspec/templates/component_spec.rb.tt +3 -1
  21. data/lib/rails/generators/stimulus/component_generator.rb +8 -3
  22. data/lib/rails/generators/stimulus/templates/component_controller.ts.tt +9 -0
  23. data/lib/rails/generators/test_unit/templates/component_test.rb.tt +3 -1
  24. data/lib/view_component/base.rb +352 -196
  25. data/lib/view_component/capture_compatibility.rb +44 -0
  26. data/lib/view_component/collection.rb +28 -9
  27. data/lib/view_component/compiler.rb +162 -193
  28. data/lib/view_component/config.rb +225 -0
  29. data/lib/view_component/configurable.rb +17 -0
  30. data/lib/view_component/deprecation.rb +8 -0
  31. data/lib/view_component/engine.rb +74 -47
  32. data/lib/view_component/errors.rb +240 -0
  33. data/lib/view_component/inline_template.rb +55 -0
  34. data/lib/view_component/instrumentation.rb +10 -2
  35. data/lib/view_component/preview.rb +21 -19
  36. data/lib/view_component/rails/tasks/view_component.rake +11 -2
  37. data/lib/view_component/render_component_helper.rb +1 -0
  38. data/lib/view_component/render_component_to_string_helper.rb +1 -1
  39. data/lib/view_component/render_to_string_monkey_patch.rb +1 -1
  40. data/lib/view_component/rendering_component_helper.rb +1 -1
  41. data/lib/view_component/rendering_monkey_patch.rb +1 -1
  42. data/lib/view_component/slot.rb +119 -1
  43. data/lib/view_component/slotable.rb +393 -96
  44. data/lib/view_component/slotable_default.rb +20 -0
  45. data/lib/view_component/system_test_case.rb +13 -0
  46. data/lib/view_component/system_test_helpers.rb +27 -0
  47. data/lib/view_component/template.rb +134 -0
  48. data/lib/view_component/test_helpers.rb +208 -47
  49. data/lib/view_component/translatable.rb +51 -33
  50. data/lib/view_component/use_helpers.rb +42 -0
  51. data/lib/view_component/version.rb +5 -4
  52. data/lib/view_component/with_content_helper.rb +3 -8
  53. data/lib/view_component.rb +7 -12
  54. metadata +339 -57
  55. data/lib/rails/generators/component/USAGE +0 -13
  56. data/lib/view_component/content_areas.rb +0 -57
  57. data/lib/view_component/polymorphic_slots.rb +0 -73
  58. data/lib/view_component/preview_template_error.rb +0 -6
  59. data/lib/view_component/previewable.rb +0 -62
  60. data/lib/view_component/slot_v2.rb +0 -104
  61. data/lib/view_component/slotable_v2.rb +0 -307
  62. data/lib/view_component/template_error.rb +0 -9
  63. data/lib/yard/mattr_accessor_handler.rb +0 -19
@@ -4,40 +4,68 @@ require "action_view"
4
4
  require "active_support/configurable"
5
5
  require "view_component/collection"
6
6
  require "view_component/compile_cache"
7
- require "view_component/content_areas"
8
- require "view_component/polymorphic_slots"
9
- require "view_component/previewable"
7
+ require "view_component/compiler"
8
+ require "view_component/config"
9
+ require "view_component/errors"
10
+ require "view_component/inline_template"
11
+ require "view_component/preview"
10
12
  require "view_component/slotable"
11
- require "view_component/slotable_v2"
13
+ require "view_component/slotable_default"
14
+ require "view_component/template"
15
+ require "view_component/translatable"
12
16
  require "view_component/with_content_helper"
17
+ require "view_component/use_helpers"
13
18
 
14
19
  module ViewComponent
15
20
  class Base < ActionView::Base
16
- include ActiveSupport::Configurable
17
- include ViewComponent::ContentAreas
18
- include ViewComponent::Previewable
19
- include ViewComponent::SlotableV2
20
- include ViewComponent::WithContentHelper
21
+ class << self
22
+ delegate(*ViewComponent::Config.defaults.keys, to: :config)
23
+
24
+ # Returns the current config.
25
+ #
26
+ # @return [ActiveSupport::OrderedOptions]
27
+ def config
28
+ module_parents.each do |module_parent|
29
+ next unless module_parent.respond_to?(:config)
30
+ module_parent_config = module_parent.config.try(:view_component)
31
+ return module_parent_config if module_parent_config
32
+ end
33
+ ViewComponent::Config.current
34
+ end
35
+ end
21
36
 
22
- ViewContextCalledBeforeRenderError = Class.new(StandardError)
37
+ include ViewComponent::InlineTemplate
38
+ include ViewComponent::UseHelpers
39
+ include ViewComponent::Slotable
40
+ include ViewComponent::Translatable
41
+ include ViewComponent::WithContentHelper
23
42
 
24
43
  RESERVED_PARAMETER = :content
44
+ VC_INTERNAL_DEFAULT_FORMAT = :html
25
45
 
26
46
  # For CSRF authenticity tokens in forms
27
47
  delegate :form_authenticity_token, :protect_against_forgery?, :config, to: :helpers
28
48
 
29
- class_attribute :content_areas
30
- self.content_areas = [] # class_attribute:default doesn't work until Rails 5.2
49
+ # For Content Security Policy nonces
50
+ delegate :content_security_policy_nonce, to: :helpers
51
+
52
+ # Config option that strips trailing whitespace in templates before compiling them.
53
+ class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false
54
+ self.__vc_strip_trailing_whitespace = false # class_attribute:default doesn't work until Rails 5.2
31
55
 
32
56
  attr_accessor :__vc_original_view_context
33
57
 
34
- # EXPERIMENTAL: This API is experimental and may be removed at any time.
35
- # Hook for allowing components to do work as part of the compilation process.
58
+ # Components render in their own view context. Helpers and other functionality
59
+ # require a reference to the original Rails view context, an instance of
60
+ # `ActionView::Base`. Use this method to set a reference to the original
61
+ # view context. Objects that implement this method will render in the component's
62
+ # view context, while objects that don't will render in the original view context
63
+ # so helpers, etc work as expected.
36
64
  #
37
- # For example, one might compile component-specific assets at this point.
38
- # @private TODO: add documentation
39
- def self._after_compile
40
- # noop
65
+ # @param view_context [ActionView::Base] The original view context.
66
+ # @return [void]
67
+ def set_original_view_context(view_context)
68
+ self.__vc_original_view_context = view_context
41
69
  end
42
70
 
43
71
  # Entrypoint for rendering components.
@@ -54,6 +82,8 @@ module ViewComponent
54
82
  @view_context = view_context
55
83
  self.__vc_original_view_context ||= view_context
56
84
 
85
+ @output_buffer = ActionView::OutputBuffer.new
86
+
57
87
  @lookup_context ||= view_context.lookup_context
58
88
 
59
89
  # required for path helpers in older Rails versions
@@ -74,11 +104,7 @@ module ViewComponent
74
104
  @current_template = self
75
105
 
76
106
  if block && defined?(@__vc_content_set_by_with_content)
77
- raise ArgumentError.new(
78
- "It looks like a block was provided after calling `with_content` on #{self.class.name}, " \
79
- "which means that ViewComponent doesn't know which content to use.\n\n" \
80
- "To fix this issue, use either `with_content` or a block."
81
- )
107
+ raise DuplicateContentError.new(self.class.name)
82
108
  end
83
109
 
84
110
  @__vc_content_evaluated = false
@@ -87,7 +113,14 @@ module ViewComponent
87
113
  before_render
88
114
 
89
115
  if render?
90
- render_template_for(@__vc_variant).to_s + _output_postamble
116
+ rendered_template = render_template_for(@__vc_variant, __vc_request&.format&.to_sym).to_s
117
+
118
+ # Avoid allocating new string when output_preamble and output_postamble are blank
119
+ if output_preamble.blank? && output_postamble.blank?
120
+ rendered_template
121
+ else
122
+ safe_output_preamble + rendered_template + safe_output_postamble
123
+ end
91
124
  else
92
125
  ""
93
126
  end
@@ -95,26 +128,64 @@ module ViewComponent
95
128
  @current_template = old_current_template
96
129
  end
97
130
 
98
- # EXPERIMENTAL: Optional content to be returned after the rendered template.
131
+ # Subclass components that call `super` inside their template code will cause a
132
+ # double render if they emit the result.
133
+ #
134
+ # ```erb
135
+ # <%= super %> # double-renders
136
+ # <% super %> # doesn't double-render
137
+ # ```
138
+ #
139
+ # `super` also doesn't consider the current variant. `render_parent` renders the
140
+ # parent template considering the current variant and emits the result without
141
+ # double-rendering.
142
+ def render_parent
143
+ render_parent_to_string
144
+ nil
145
+ end
146
+
147
+ # Renders the parent component to a string and returns it. This method is meant
148
+ # to be used inside custom #call methods when a string result is desired, eg.
149
+ #
150
+ # ```ruby
151
+ # def call
152
+ # "<div>#{render_parent_to_string}</div>"
153
+ # end
154
+ # ```
155
+ #
156
+ # When rendering the parent inside an .erb template, use `#render_parent` instead.
157
+ def render_parent_to_string
158
+ @__vc_parent_render_level ||= 0 # ensure a good starting value
159
+
160
+ begin
161
+ target_render = self.class.instance_variable_get(:@__vc_ancestor_calls)[@__vc_parent_render_level]
162
+ @__vc_parent_render_level += 1
163
+
164
+ target_render.bind_call(self, @__vc_variant)
165
+ ensure
166
+ @__vc_parent_render_level -= 1
167
+ end
168
+ end
169
+
170
+ # Optional content to be returned before the rendered template.
99
171
  #
100
172
  # @return [String]
101
- def _output_postamble
102
- ""
173
+ def output_preamble
174
+ @@default_output_preamble ||= "".html_safe
103
175
  end
104
176
 
105
- # Called before rendering the component. Override to perform operations that
106
- # depend on having access to the view context, such as helpers.
177
+ # Optional content to be returned after the rendered template.
107
178
  #
108
- # @return [void]
109
- def before_render
110
- before_render_check
179
+ # @return [String]
180
+ def output_postamble
181
+ @@default_output_postamble ||= "".html_safe
111
182
  end
112
183
 
113
- # Called after rendering the component.
184
+ # Called before rendering the component. Override to perform operations that
185
+ # depend on having access to the view context, such as helpers.
114
186
  #
115
- # @deprecated Use `#before_render` instead. Will be removed in v3.0.0.
116
187
  # @return [void]
117
- def before_render_check
188
+ def before_render
118
189
  # noop
119
190
  end
120
191
 
@@ -125,8 +196,11 @@ module ViewComponent
125
196
  true
126
197
  end
127
198
 
199
+ # Override the ActionView::Base initializer so that components
200
+ # do not need to define their own initializers.
128
201
  # @private
129
- def initialize(*); end
202
+ def initialize(*)
203
+ end
130
204
 
131
205
  # Re-use original view_context if we're not rendering a component.
132
206
  #
@@ -136,8 +210,8 @@ module ViewComponent
136
210
  #
137
211
  # @private
138
212
  def render(options = {}, args = {}, &block)
139
- if options.is_a? ViewComponent::Base
140
- options.__vc_original_view_context = __vc_original_view_context
213
+ if options.respond_to?(:set_original_view_context)
214
+ options.set_original_view_context(self.__vc_original_view_context)
141
215
  super
142
216
  else
143
217
  __vc_original_view_context.render(options, args, &block)
@@ -149,16 +223,7 @@ module ViewComponent
149
223
  #
150
224
  # @return [ActionController::Base]
151
225
  def controller
152
- if view_context.nil?
153
- raise(
154
- ViewContextCalledBeforeRenderError,
155
- "`#controller` can't be used during initialization, as it depends " \
156
- "on the view context that only exists once a ViewComponent is passed to " \
157
- "the Rails render pipeline.\n\n" \
158
- "It's sometimes possible to fix this issue by moving code dependent on " \
159
- "`#controller` to a `#before_render` method: https://viewcomponent.org/api.html#before_render--void."
160
- )
161
- end
226
+ raise ControllerCalledBeforeRenderError if view_context.nil?
162
227
 
163
228
  @__vc_controller ||= view_context.controller
164
229
  end
@@ -168,16 +233,7 @@ module ViewComponent
168
233
  #
169
234
  # @return [ActionView::Base]
170
235
  def helpers
171
- if view_context.nil?
172
- raise(
173
- ViewContextCalledBeforeRenderError,
174
- "`#helpers` can't be used during initialization, as it depends " \
175
- "on the view context that only exists once a ViewComponent is passed to " \
176
- "the Rails render pipeline.\n\n" \
177
- "It's sometimes possible to fix this issue by moving code dependent on " \
178
- "`#helpers` to a `#before_render` method: https://viewcomponent.org/api.html#before_render--void."
179
- )
180
- end
236
+ raise HelpersCalledBeforeRenderError if view_context.nil?
181
237
 
182
238
  # Attempt to re-use the original view_context passed to the first
183
239
  # component rendered in the rendering pipeline. This prevents the
@@ -189,6 +245,22 @@ module ViewComponent
189
245
  @__vc_helpers ||= __vc_original_view_context || controller.view_context
190
246
  end
191
247
 
248
+ if ::Rails.env.development? || ::Rails.env.test?
249
+ # @private
250
+ def method_missing(method_name, *args) # rubocop:disable Style/MissingRespondToMissing
251
+ super
252
+ rescue => e # rubocop:disable Style/RescueStandardError
253
+ e.set_backtrace e.backtrace.tap(&:shift)
254
+ raise e, <<~MESSAGE.chomp if view_context && e.is_a?(NameError) && helpers.respond_to?(method_name)
255
+ #{e.message}
256
+
257
+ You may be trying to call a method provided as a view helper. Did you mean `helpers.#{method_name}`?
258
+ MESSAGE
259
+
260
+ raise
261
+ end
262
+ end
263
+
192
264
  # Exposes .virtual_path as an instance method
193
265
  #
194
266
  # @private
@@ -206,25 +278,7 @@ module ViewComponent
206
278
  #
207
279
  # @private
208
280
  def format
209
- # Ruby 2.6 throws a warning without checking `defined?`, 2.7 doesn't
210
- if defined?(@__vc_variant)
211
- @__vc_variant
212
- end
213
- end
214
-
215
- # Use the provided variant instead of the one determined by the current request.
216
- #
217
- # @deprecated Will be removed in v3.0.0.
218
- # @param variant [Symbol] The variant to be used by the component.
219
- # @return [self]
220
- def with_variant(variant)
221
- ActiveSupport::Deprecation.warn(
222
- "`with_variant` is deprecated and will be removed in ViewComponent v3.0.0."
223
- )
224
-
225
- @__vc_variant = variant
226
-
227
- self
281
+ @__vc_variant if defined?(@__vc_variant)
228
282
  end
229
283
 
230
284
  # The current request. Use sparingly as doing so introduces coupling that
@@ -232,116 +286,199 @@ module ViewComponent
232
286
  #
233
287
  # @return [ActionDispatch::Request]
234
288
  def request
235
- @request ||= controller.request if controller.respond_to?(:request)
289
+ __vc_request
236
290
  end
237
291
 
238
- private
239
-
240
- attr_reader :view_context
292
+ # Enables consumers to override request/@request
293
+ #
294
+ # @private
295
+ def __vc_request
296
+ @__vc_request ||= controller.request if controller.respond_to?(:request)
297
+ end
241
298
 
299
+ # The content passed to the component instance as a block.
300
+ #
301
+ # @return [String]
242
302
  def content
243
303
  @__vc_content_evaluated = true
244
304
  return @__vc_content if defined?(@__vc_content)
245
305
 
246
306
  @__vc_content =
247
- if @view_context && @__vc_render_in_block
307
+ if __vc_render_in_block_provided?
248
308
  view_context.capture(self, &@__vc_render_in_block)
249
- elsif defined?(@__vc_content_set_by_with_content)
309
+ elsif __vc_content_set_by_with_content_defined?
250
310
  @__vc_content_set_by_with_content
251
311
  end
252
312
  end
253
313
 
314
+ # Whether `content` has been passed to the component.
315
+ #
316
+ # @return [Boolean]
317
+ def content?
318
+ __vc_render_in_block_provided? || __vc_content_set_by_with_content_defined?
319
+ end
320
+
321
+ private
322
+
323
+ attr_reader :view_context
324
+
325
+ def __vc_render_in_block_provided?
326
+ defined?(@view_context) && @view_context && @__vc_render_in_block
327
+ end
328
+
329
+ def __vc_content_set_by_with_content_defined?
330
+ defined?(@__vc_content_set_by_with_content)
331
+ end
332
+
254
333
  def content_evaluated?
255
- @__vc_content_evaluated
334
+ defined?(@__vc_content_evaluated) && @__vc_content_evaluated
335
+ end
336
+
337
+ def maybe_escape_html(text)
338
+ return text if __vc_request && !__vc_request.format.html?
339
+ return text if text.blank?
340
+
341
+ if text.html_safe?
342
+ text
343
+ else
344
+ yield
345
+ html_escape(text)
346
+ end
347
+ end
348
+
349
+ def safe_output_preamble
350
+ maybe_escape_html(output_preamble) do
351
+ Kernel.warn("WARNING: The #{self.class} component was provided an HTML-unsafe preamble. The preamble will be automatically escaped, but you may want to investigate.")
352
+ end
353
+ end
354
+
355
+ def safe_output_postamble
356
+ maybe_escape_html(output_postamble) do
357
+ Kernel.warn("WARNING: The #{self.class} component was provided an HTML-unsafe postamble. The postamble will be automatically escaped, but you may want to investigate.")
358
+ end
256
359
  end
257
360
 
258
361
  # Set the controller used for testing components:
259
362
  #
260
- # config.view_component.test_controller = "MyTestController"
363
+ # ```ruby
364
+ # config.view_component.test_controller = "MyTestController"
365
+ # ```
261
366
  #
262
- # Defaults to ApplicationController. Can also be configured on a per-test
263
- # basis using `with_controller_class`.
367
+ # Defaults to `nil`. If this is falsy, `"ApplicationController"` is used. Can also be
368
+ # configured on a per-test basis using `with_controller_class`.
264
369
  #
265
- mattr_accessor :test_controller
266
- @@test_controller = "ApplicationController"
267
370
 
268
371
  # Set if render monkey patches should be included or not in Rails <6.1:
269
372
  #
270
- # config.view_component.render_monkey_patch_enabled = false
373
+ # ```ruby
374
+ # config.view_component.render_monkey_patch_enabled = false
375
+ # ```
271
376
  #
272
- mattr_accessor :render_monkey_patch_enabled, instance_writer: false, default: true
273
377
 
274
- # Always generate a Stimulus controller alongside the component:
378
+ # Path for component files
275
379
  #
276
- # config.view_component.generate_stimulus_controller = true
380
+ # ```ruby
381
+ # config.view_component.view_component_path = "app/my_components"
382
+ # ```
277
383
  #
278
- # Defaults to `false`.
384
+ # Defaults to `nil`. If this is falsy, `app/components` is used.
279
385
  #
280
- mattr_accessor :generate_stimulus_controller, instance_writer: false, default: false
281
386
 
282
- # Always generate translations file alongside the component:
387
+ # Parent class for generated components
283
388
  #
284
- # config.view_component.generate_locale = true
389
+ # ```ruby
390
+ # config.view_component.component_parent_class = "MyBaseComponent"
391
+ # ```
285
392
  #
286
- # Defaults to `false`.
393
+ # Defaults to nil. If this is falsy, generators will use
394
+ # "ApplicationComponent" if defined, "ViewComponent::Base" otherwise.
287
395
  #
288
- mattr_accessor :generate_locale, instance_writer: false, default: false
289
396
 
290
- # Always generate as many translations files as available locales:
397
+ # Configuration for generators.
291
398
  #
292
- # config.view_component.generate_distinct_locale_files = true
399
+ # All options under this namespace default to `false` unless otherwise
400
+ # stated.
293
401
  #
294
- # Defaults to `false`.
402
+ # #### #sidecar
295
403
  #
296
- # One file will be generated for each configured `I18n.available_locales`.
297
- # Fallback on `[:en]` when no available_locales is defined.
404
+ # Always generate a component with a sidecar directory:
298
405
  #
299
- mattr_accessor :generate_distinct_locale_files, instance_writer: false, default: false
300
-
301
- # Path for component files
406
+ # ```ruby
407
+ # config.view_component.generate.sidecar = true
408
+ # ```
302
409
  #
303
- # config.view_component.view_component_path = "app/my_components"
410
+ # #### #stimulus_controller
304
411
  #
305
- # Defaults to `app/components`.
412
+ # Always generate a Stimulus controller alongside the component:
306
413
  #
307
- mattr_accessor :view_component_path, instance_writer: false, default: "app/components"
308
-
309
- # Parent class for generated components
414
+ # ```ruby
415
+ # config.view_component.generate.stimulus_controller = true
416
+ # ```
310
417
  #
311
- # config.view_component.component_parent_class = "MyBaseComponent"
418
+ # #### `#typescript`
312
419
  #
313
- # Defaults to "ApplicationComponent" if defined, "ViewComponent::Base" otherwise.
420
+ # Generate TypeScript files instead of JavaScript files:
314
421
  #
315
- mattr_accessor :component_parent_class, instance_writer: false
316
-
317
- # Always generate a component with a sidecar directory:
422
+ # ```ruby
423
+ # config.view_component.generate.typescript = true
424
+ # ```
425
+ #
426
+ # #### #locale
427
+ #
428
+ # Always generate translations file alongside the component:
429
+ #
430
+ # ```ruby
431
+ # config.view_component.generate.locale = true
432
+ # ```
433
+ #
434
+ # #### #distinct_locale_files
435
+ #
436
+ # Always generate as many translations files as available locales:
318
437
  #
319
- # config.view_component.generate_sidecar = true
438
+ # ```ruby
439
+ # config.view_component.generate.distinct_locale_files = true
440
+ # ```
320
441
  #
321
- # Defaults to `false`.
442
+ # One file will be generated for each configured `I18n.available_locales`,
443
+ # falling back to `[:en]` when no `available_locales` is defined.
322
444
  #
323
- mattr_accessor :generate_sidecar, instance_writer: false, default: false
445
+ # #### #preview
446
+ #
447
+ # Always generate preview alongside the component:
448
+ #
449
+ # ```ruby
450
+ # config.view_component.generate.preview = true
451
+ # ```
452
+ #
453
+ # Defaults to `false`.
324
454
 
325
455
  class << self
456
+ # The file path of the component Ruby file.
457
+ #
458
+ # @return [String]
459
+ attr_reader :identifier
460
+
326
461
  # @private
327
- attr_accessor :source_location, :virtual_path
462
+ attr_writer :identifier
463
+
464
+ # @private
465
+ attr_accessor :virtual_path
328
466
 
329
- # EXPERIMENTAL: This API is experimental and may be removed at any time.
330
467
  # Find sidecar files for the given extensions.
331
468
  #
332
469
  # The provided array of extensions is expected to contain
333
- # strings starting without the "dot", example: `["erb", "haml"]`.
470
+ # strings starting without the dot, example: `["erb", "haml"]`.
334
471
  #
335
472
  # For example, one might collect sidecar CSS files that need to be compiled.
336
- # @private TODO: add documentation
337
- def _sidecar_files(extensions)
338
- return [] unless source_location
473
+ # @param extensions [Array<String>] Extensions of which to return matching sidecar files.
474
+ def sidecar_files(extensions)
475
+ return [] unless identifier
339
476
 
340
477
  extensions = extensions.join(",")
341
478
 
342
479
  # view files in a directory named like the component
343
- directory = File.dirname(source_location)
344
- filename = File.basename(source_location, ".rb")
480
+ directory = File.dirname(identifier)
481
+ filename = File.basename(identifier, ".rb")
345
482
  component_name = name.demodulize.underscore
346
483
 
347
484
  # Add support for nested components defined in the same file.
@@ -366,24 +503,20 @@ module ViewComponent
366
503
 
367
504
  sidecar_directory_files = Dir["#{directory}/#{component_name}/#{filename}.*{#{extensions}}"]
368
505
 
369
- (sidecar_files - [source_location] + sidecar_directory_files + nested_component_files).uniq
506
+ (sidecar_files - [identifier] + sidecar_directory_files + nested_component_files).uniq
370
507
  end
371
508
 
372
509
  # Render a component for each element in a collection ([documentation](/guide/collections)):
373
510
  #
374
- # render(ProductsComponent.with_collection(@products, foo: :bar))
511
+ # ```ruby
512
+ # render(ProductsComponent.with_collection(@products, foo: :bar))
513
+ # ```
375
514
  #
376
515
  # @param collection [Enumerable] A list of items to pass the ViewComponent one at a time.
516
+ # @param spacer_component [ViewComponent::Base] Component instance to be rendered between items.
377
517
  # @param args [Arguments] Arguments to pass to the ViewComponent every time.
378
- def with_collection(collection, **args)
379
- Collection.new(self, collection, **args)
380
- end
381
-
382
- # Provide identifier for ActionView template annotations
383
- #
384
- # @private
385
- def short_identifier
386
- @short_identifier ||= defined?(Rails.root) ? source_location.sub("#{Rails.root}/", "") : source_location
518
+ def with_collection(collection, spacer_component: nil, **args)
519
+ Collection.new(self, collection, spacer_component, **args)
387
520
  end
388
521
 
389
522
  # @private
@@ -392,25 +525,52 @@ module ViewComponent
392
525
  # `compile` defines
393
526
  compile
394
527
 
528
+ # Give the child its own personal #render_template_for to protect against the case when
529
+ # eager loading is disabled and the parent component is rendered before the child. In
530
+ # such a scenario, the parent will override ViewComponent::Base#render_template_for,
531
+ # meaning it will not be called for any children and thus not compile their templates.
532
+ if !child.instance_methods(false).include?(:render_template_for) && !child.compiled?
533
+ child.class_eval <<~RUBY, __FILE__, __LINE__ + 1
534
+ def render_template_for(variant = nil, format = nil)
535
+ # Force compilation here so the compiler always redefines render_template_for.
536
+ # This is mostly a safeguard to prevent infinite recursion.
537
+ self.class.compile(raise_errors: true, force: true)
538
+ # .compile replaces this method; call the new one
539
+ render_template_for(variant, format)
540
+ end
541
+ RUBY
542
+ end
543
+
395
544
  # If Rails application is loaded, add application url_helpers to the component context
396
545
  # we need to check this to use this gem as a dependency
397
- if defined?(Rails) && Rails.application
398
- child.include Rails.application.routes.url_helpers unless child < Rails.application.routes.url_helpers
546
+ if defined?(Rails) && Rails.application && !(child < Rails.application.routes.url_helpers)
547
+ child.include Rails.application.routes.url_helpers
399
548
  end
400
549
 
401
550
  # Derive the source location of the component Ruby file from the call stack.
402
551
  # We need to ignore `inherited` frames here as they indicate that `inherited`
403
552
  # has been re-defined by the consuming application, likely in ApplicationComponent.
404
- child.source_location = caller_locations(1, 10).reject { |l| l.label == "inherited" }[0].path
553
+ # We use `base_label` method here instead of `label` to avoid cases where the method
554
+ # owner is included in a prefix like `ApplicationComponent.inherited`.
555
+ child.identifier = caller_locations(1, 10).reject { |l| l.base_label == "inherited" }[0].path
405
556
 
406
- # Removes the first part of the path and the extension.
407
- child.virtual_path = child.source_location.gsub(
408
- %r{(.*#{Regexp.quote(ViewComponent::Base.view_component_path)})|(\.rb)}, ""
409
- )
557
+ # If Rails application is loaded, removes the first part of the path and the extension.
558
+ if defined?(Rails) && Rails.application
559
+ child.virtual_path = child.identifier.gsub(
560
+ /(.*#{Regexp.quote(ViewComponent::Base.config.view_component_path)})|(\.rb)/, ""
561
+ )
562
+ end
410
563
 
411
564
  # Set collection parameter to the extended component
412
565
  child.with_collection_parameter provided_collection_parameter
413
566
 
567
+ if instance_methods(false).include?(:render_template_for)
568
+ vc_ancestor_calls = defined?(@__vc_ancestor_calls) ? @__vc_ancestor_calls.dup : []
569
+
570
+ vc_ancestor_calls.unshift(instance_method(:render_template_for))
571
+ child.instance_variable_set(:@__vc_ancestor_calls, vc_ancestor_calls)
572
+ end
573
+
414
574
  super
415
575
  end
416
576
 
@@ -419,43 +579,51 @@ module ViewComponent
419
579
  compiler.compiled?
420
580
  end
421
581
 
422
- # Compile templates to instance methods, assuming they haven't been compiled already.
423
- #
424
- # Do as much work as possible in this step, as doing so reduces the amount
425
- # of work done each time a component is rendered.
426
- # @private
427
- def compile(raise_errors: false)
428
- compiler.compile(raise_errors: raise_errors)
429
- end
430
-
431
- # @private
432
- def compiler
433
- @__vc_compiler ||= Compiler.new(self)
434
- end
435
-
436
- # we'll eventually want to update this to support other types
437
582
  # @private
438
- def type
439
- "text/html"
583
+ def ensure_compiled
584
+ compile unless compiled?
440
585
  end
441
586
 
442
587
  # @private
443
- def format
444
- :html
588
+ def compile(raise_errors: false, force: false)
589
+ compiler.compile(raise_errors: raise_errors, force: force)
445
590
  end
446
591
 
447
592
  # @private
448
- def identifier
449
- source_location
593
+ def compiler
594
+ @__vc_compiler ||= Compiler.new(self)
450
595
  end
451
596
 
452
597
  # Set the parameter name used when rendering elements of a collection ([documentation](/guide/collections)):
453
598
  #
454
- # with_collection_parameter :item
599
+ # ```ruby
600
+ # with_collection_parameter :item
601
+ # ```
455
602
  #
456
603
  # @param parameter [Symbol] The parameter name used when rendering elements of a collection.
457
604
  def with_collection_parameter(parameter)
458
605
  @provided_collection_parameter = parameter
606
+ @initialize_parameters = nil
607
+ end
608
+
609
+ # Strips trailing whitespace from templates before compiling them.
610
+ #
611
+ # ```ruby
612
+ # class MyComponent < ViewComponent::Base
613
+ # strip_trailing_whitespace
614
+ # end
615
+ # ```
616
+ #
617
+ # @param value [Boolean] Whether to strip newlines.
618
+ def strip_trailing_whitespace(value = true)
619
+ self.__vc_strip_trailing_whitespace = value
620
+ end
621
+
622
+ # Whether trailing whitespace will be stripped before compilation.
623
+ #
624
+ # @return [Boolean]
625
+ def strip_trailing_whitespace?
626
+ __vc_strip_trailing_whitespace
459
627
  end
460
628
 
461
629
  # Ensure the component initializer accepts the
@@ -463,58 +631,41 @@ module ViewComponent
463
631
  # validate that the default parameter name
464
632
  # is accepted, as support for collection
465
633
  # rendering is optional.
466
- # @private TODO: add documentation
634
+ # @private
467
635
  def validate_collection_parameter!(validate_default: false)
468
636
  parameter = validate_default ? collection_parameter : provided_collection_parameter
469
637
 
470
638
  return unless parameter
471
- return if initialize_parameter_names.include?(parameter)
639
+ return if initialize_parameter_names.include?(parameter) || splatted_keyword_argument_present?
472
640
 
473
- # If Ruby can't parse the component class, then the initalize
641
+ # If Ruby can't parse the component class, then the initialize
474
642
  # parameters will be empty and ViewComponent will not be able to render
475
643
  # the component.
476
644
  if initialize_parameters.empty?
477
- raise ArgumentError.new(
478
- "The #{self} initializer is empty or invalid." \
479
- "It must accept the parameter `#{parameter}` to render it as a collection.\n\n" \
480
- "To fix this issue, update the initializer to accept `#{parameter}`.\n\n" \
481
- "See https://viewcomponent.org/guide/collections.html for more information on rendering collections."
482
- )
645
+ raise EmptyOrInvalidInitializerError.new(name, parameter)
483
646
  end
484
647
 
485
- raise ArgumentError.new(
486
- "The initializer for #{self} doesn't accept the parameter `#{parameter}`, " \
487
- "which is required in order to render it as a collection.\n\n" \
488
- "To fix this issue, update the initializer to accept `#{parameter}`.\n\n" \
489
- "See https://viewcomponent.org/guide/collections.html for more information on rendering collections."
490
- )
648
+ raise MissingCollectionArgumentError.new(name, parameter)
491
649
  end
492
650
 
493
651
  # Ensure the component initializer doesn't define
494
652
  # invalid parameters that could override the framework's
495
653
  # methods.
496
- # @private TODO: add documentation
654
+ # @private
497
655
  def validate_initialization_parameters!
498
656
  return unless initialize_parameter_names.include?(RESERVED_PARAMETER)
499
657
 
500
- raise ViewComponent::ComponentError.new(
501
- "#{self} initializer can't accept the parameter `#{RESERVED_PARAMETER}`, as it will override a " \
502
- "public ViewComponent method. To fix this issue, rename the parameter."
503
- )
658
+ raise ReservedParameterError.new(name, RESERVED_PARAMETER)
504
659
  end
505
660
 
506
661
  # @private
507
662
  def collection_parameter
508
- if provided_collection_parameter
509
- provided_collection_parameter
510
- else
511
- name && name.demodulize.underscore.chomp("_component").to_sym
512
- end
663
+ provided_collection_parameter || name && name.demodulize.underscore.chomp("_component").to_sym
513
664
  end
514
665
 
515
666
  # @private
516
667
  def collection_counter_parameter
517
- "#{collection_parameter}_counter".to_sym
668
+ :"#{collection_parameter}_counter"
518
669
  end
519
670
 
520
671
  # @private
@@ -524,7 +675,7 @@ module ViewComponent
524
675
 
525
676
  # @private
526
677
  def collection_iteration_parameter
527
- "#{collection_parameter}_iteration".to_sym
678
+ :"#{collection_parameter}_iteration"
528
679
  end
529
680
 
530
681
  # @private
@@ -534,6 +685,11 @@ module ViewComponent
534
685
 
535
686
  private
536
687
 
688
+ def splatted_keyword_argument_present?
689
+ initialize_parameters.flatten.include?(:keyrest) &&
690
+ !initialize_parameters.include?([:keyrest, :**]) # Un-named splatted keyword args don't count!
691
+ end
692
+
537
693
  def initialize_parameter_names
538
694
  return attribute_names.map(&:to_sym) if respond_to?(:attribute_names)
539
695
 
@@ -543,7 +699,7 @@ module ViewComponent
543
699
  end
544
700
 
545
701
  def initialize_parameters
546
- instance_method(:initialize).parameters
702
+ @initialize_parameters ||= instance_method(:initialize).parameters
547
703
  end
548
704
 
549
705
  def provided_collection_parameter