slotify 0.0.0 → 0.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 905eaee14b280c6b59fed9887ab8ecf8e1796ca341296debf5c532bbb83ccd5f
4
- data.tar.gz: a260b0e8f3eda211a58f48ac5389ebebc84935888fcf6355a00145338005f33f
3
+ metadata.gz: 9401d13b9a845572abccc1cd38362bb6facfb5394568a74345d856abc6db80ea
4
+ data.tar.gz: bd8c9df4efe6ee8d40d7967f06326b9950d160c824828c11537f5c44f63f200c
5
5
  SHA512:
6
- metadata.gz: 5a4b87a2792a032cde484a793dea94025f091a1bfa2464938437dda769ada354b788193aa896831da5d10e1506d8ada25e6a41287cfd9ff539eda3447c88a7f0
7
- data.tar.gz: 714fcfd6cfa44f24c7c562f72edaf5833e8b25048d5de639b753f9359c97c7fa0586abb478200ea1a4c5700dbf8774b7e7623a25697084cf8674dc487b9a23e6
6
+ metadata.gz: 1af7661c421919e5bc90069a75781075e27b9f97d1d767148f5ece3abe773dc1b206ea59435e511c7e1b1805a34124b19b4e1d8354fc7cf70e27dfbcd4e7445a
7
+ data.tar.gz: 2c3f889e7f55d65af6be315c9b013a21ab63b8cee3c9304cf3ebd947b3324c899c0830e5ea420d1aac7bda0d54603fe69d97eb87fdc765c1a8c0f8f70ec9af69
data/README.md CHANGED
@@ -1,81 +1,261 @@
1
- <img src=".github/assets/slotify_wordmark.svg" width="140">
2
-
1
+ <img src=".github/assets/slotify_wordmark.svg" width="200">
2
+
3
+
4
+
5
+ ### Superpowered slots for ActionView partials
3
6
 
7
+ ---
4
8
 
5
- #### _A superpowered slot system for Rails partials._
9
+ ## Overview
6
10
 
7
- ----------
11
+ Slotify adds an unobtrusive (but powerful!) **content slot API** to ActionView partials.
8
12
 
9
- ## Overview
13
+ Slots are a convenient way to pass blocks of content in to a partial without having to resort to ugly `<% capture do ... end %>` workarounds or unscoped (global) `<% content_for :foo %>` declarations.
10
14
 
11
- Slotify adds a lightweight (but powerful!) slot system for providing content to partials when rendering them.
15
+ Slotified partials are a great way to build components in a Rails app without the additional overhead and learning curve of libraries like [ViewComponent](https://viewcomponent.org/) or [Phlex](https://www.phlex.fun/).
12
16
 
13
- Slots are defined using a `strict locals`-style magic comment at the top of the partial.
14
- Slot content is accessed via 'regular' local variables within the template.
17
+ ###
18
+
19
+ ## Slotify basics
20
+
21
+ Slotify slots are defined using a **[strict locals](https://guides.rubyonrails.org/action_view_overview.html#strict-locals)-style magic comment** at the top of partial templates ([more details here](#defining-slots)).
15
22
 
16
23
  ```erb
17
- <!-- views/_my_partial_.html.erb -->
24
+ <%# slots: (slot_name: "default value", optional_slot_name: nil, required_slot_name:) -%>
25
+ ```
18
26
 
19
- <%# slots: (title: "Example title", items: nil, link:) -%>
27
+ Slot content is accessed via **standard local variables** within the partial. So a simple, slot-enabled `article` partial template might look something like this:
20
28
 
21
- <div>
22
- <h1><%= title %></h1>
29
+ ```erb
30
+ <!-- _article.html.erb -->
31
+
32
+ <%# slots: (heading: "Default title", body: nil) -%>
23
33
 
24
- <% if items.any? %>
25
- <ul>
26
- <%= items.each do |item| %>
27
- <li <%= item.options %>>
28
- <%= item %>
29
- </li>
30
- <% end%>
31
- </ul>
34
+ <article>
35
+ <h1><%= heading %></h1>
36
+ <% if body.present? %>
37
+ <div>
38
+ <%= body %>
39
+ </div>
32
40
  <% end %>
41
+ </article>
42
+ ```
33
43
 
34
- <p>
35
- Example link: <%= partial.link_to link, class: "example-link" %>
36
- </p>
37
- </div>
44
+ > [!NOTE]
45
+ > _The above should feel familiar to anyone who has partials (and strict locals) in the past. This is just regular partial syntax but with `slots` defined instead of `locals` (don't worry - you can still define locals too!)._
46
+
47
+ When the partial is rendered, a special `partial` object is yielded as an argument to the block. Slot content is set by calling the appropriate `#with_<slot_name>` methods on this partial object.
48
+
49
+ For example, here our `article` partial is being rendered with content for the `heading` and `body` slots that were defined above:
50
+
51
+ ```erb
52
+ <%= render "article" do |partial| %>
53
+ <% partial.with_heading "This is a title" %>
54
+ <% partial.with_body do %>
55
+ <p>You can use <%= tag.strong "markup" %> within slot content blocks without
56
+ having to worry about marking the output as <code>html_safe</code> later.</p>
57
+ <% end %>
58
+ <% end %>
59
+ ```
60
+
61
+ > [!NOTE]
62
+ > _If you've ever used [ViewComponent](https://viewcomponent.org) then the above code should also feel quite familiar to you - it's pretty much the same syntax used to provide content to [component slots](https://viewcomponent.org/guide/slots.html)._
63
+
64
+ But this example just scratches the surface of what Slotify slots can do! Read on to learn more (or [jump to a more full-featured example here](#full-example)).
65
+
66
+ ## Usage
67
+
68
+ <a name="defining-slots" id="defining-slots"></a>
69
+ ### Defining slots
70
+
71
+ Slots are defined using a [strict locals](https://guides.rubyonrails.org/action_view_overview.html#strict-locals)-style magic comment at the top of the partial template. The `slots:` signature uses the same syntax as standard Ruby method signatures:
72
+
73
+ ```erb
74
+ <%# slots: (title:, summary: "No summary available", author: nil) -%>
75
+ ```
76
+
77
+ #### Required slots
78
+
79
+ Required slots are defined without a default value.
80
+ If no content is provided for a required slot then a `StrictSlotsError` exception will be raised.
81
+
82
+ ```erb
83
+ <!-- _required.html.erb -->
84
+
85
+ <%# slots: (title:) -%>
86
+ <h1><%= title %></h1>
87
+ ```
88
+
89
+ ```erb
90
+ <%= render "required" do |partial| %>
91
+ <!-- ❌ raises an error, no content set for the `title` slot -->
92
+ <% end %>
93
+ ```
94
+
95
+ #### Optional slots
96
+
97
+ If a default value is set then the slot becomes _optional_. If no content is provided when rendering the partial then
98
+ the default value will be used instead.
99
+
100
+ ```erb
101
+ <%# slots: (title: "Default title", author: nil) -%>
102
+ ```
103
+
104
+ ### Using alongside strict locals
105
+
106
+ Strict locals can be defined in 'slotified' partial templates in the same way as usual,
107
+ either above or below the `slots` definition.
108
+
109
+ ```erb
110
+ <!-- _article.html.erb -->
111
+
112
+ <%# locals: (title:) -%>
113
+ <%# slots: (summary: "No summary available") -%>
114
+
115
+ <article>
116
+ <h1><%= title %></h1>
117
+ <div><%= summary %></div>
118
+ </article>
119
+ ```
120
+
121
+ Locals are provided when rendering the partial in the usual way.
122
+
123
+ ```erb
124
+ <%= render "article", title: "Article title here" do |partial| %>
125
+ <% partial.with_summary do %>
126
+ <p>Summary content here...</p>
127
+ <% end %>
128
+ <% end %>
38
129
  ```
39
130
 
40
- Content can then be provided to slots when rendering the partial:
131
+ ### Slot content and options
132
+
133
+ Content is passed into slots using dynamically generated `partial#with_<slot_name>` methods.
134
+
135
+ Content can be provided as either the **first argument** or **as a block** when calling these methods at render time.
136
+ The following `#with_title` calls are both equivalent:
41
137
 
42
138
  ```erb
43
- <%= render "my_partial", do |partial| %>
44
- <%= partial.with_title do %>
45
- This is a title
139
+ <%= render "example" do |partial| %>
140
+ <% partial.with_title "Title passed as argument" %>
141
+ <% partial.with_title do %>
142
+ Title passed as block content
46
143
  <% end %>
144
+ <% end %>
145
+ ```
146
+
147
+ ### Slot types
148
+
149
+ There are two types of slots.
150
+
151
+ * **Single-value** slots can only be called **once** and return **a single value**.
152
+ * **Multiple-value** slots can be called **many times** and return **an array of values**.
153
+
154
+ #### Single-value slots
155
+
156
+ Single-value slots are defined using a **singlular** slot name:
157
+
158
+ ```erb
159
+ <%# slots: (item: nil) -%>
160
+ ```
47
161
 
48
- <%= partial.with_item "Item 1" %>
49
- <%= partial.with_item "Item 2", class: "text-green-700" %>
162
+ Single-value slots can be called once (at most)
163
+ and their corresponding template variable represents a single value:
50
164
 
51
- <% partial.with_link "example.com", "https://example.com", target: "_blank" %>
165
+ ```erb
166
+ <%= render "example" do |partial| %>
167
+ <% partial.with_item "Item one" %>
52
168
  <% end %>
53
169
  ```
54
170
 
55
- Slots defined with singular names can only be called with content once whereas slots defined with plural names can be called multiple times.
171
+ ```erb
172
+ <%# slots: (item: nil) -%>
173
+ <div>
174
+ <%= item %> <!-- "Item one" -->
175
+ </div>
176
+ ```
56
177
 
57
- ### Requirements
178
+ > [!WARNING]
179
+ > Calling a single-value slot more than once when rendering a partial will raise an error:
180
+ >
181
+ > ```erb
182
+ > <%= render "example" do |partial| %>
183
+ > <% partial.with_item "Item one" %>
184
+ > <% partial.with_item "Item two" %> # ❌ raises an error!
185
+ > <% end %>
186
+ > ```
58
187
 
59
- * `Rails 8.0+`
60
- * `Ruby 3.1+`
188
+ #### Multiple-value slots
61
189
 
62
- ## Usage
190
+ Multiple-value slots are defined using a **plural** slot name:
63
191
 
64
- 🚧 Work in progress! 🚧
192
+ ```erb
193
+ <%# slots: (items: nil) -%>
194
+ ```
65
195
 
66
- ### Defining slots
196
+ Multiple-value slots can be called as many times as needed
197
+ and their corresponding template variable represents an array of values:
67
198
 
68
- Slots are defined using a `strict locals`-style magic comment at the top of the partial template.
199
+ ```erb
200
+ <%= render "example" do |partial| %>
201
+ <% partial.with_item "Item one" %>
202
+ <% partial.with_item "Item two" %>
203
+ <% partial.with_item "Item three" %>
204
+ <% end %>
205
+ ```
69
206
 
70
207
  ```erb
71
- <%# slots: (title: "Example title", lists: nil, quotes: nil, website_link:) -%>
208
+ <%# slots: (items: nil) -%>
209
+
210
+ <%= items %> <!-- ["Item one", "Item two", "Item three"] -->
211
+
212
+ <ul>
213
+ <% items.each do |item| %>
214
+ <li>
215
+ <% item %>
216
+ </li>
217
+ <% end %>
218
+ </ul>
72
219
  ```
73
220
 
74
- * Singular slots can only accept one entry, plural slots can accept many.
75
- * Slots can be required (no default value) or optional.
76
- * Optional slots can additionaly specify default content as needed.
221
+ ### Passing slot content to helpers
77
222
 
78
- ### A more complete example
223
+ > _Docs coming soon..._
224
+
225
+ ### Rendering slots
226
+
227
+ > _Docs coming soon..._
228
+
229
+ ### Slot value objects
230
+
231
+ > _Docs coming soon..._
232
+
233
+ ## Installation
234
+
235
+ Add the following to your Rails app Gemfile:
236
+
237
+ ```rb
238
+ gem "slotify"
239
+ ```
240
+
241
+ And then run `bundle install`. You are good to go!
242
+
243
+ ## Requirements
244
+
245
+ * `Rails 7.1+`
246
+ * `Ruby 3.1+`
247
+
248
+ ## Credits
249
+
250
+ Slotify was inspired by the excellent [nice_partials gem](https://github.com/bullet-train-co/nice_partials) as well as ViewComponent's [slots implementation](https://viewcomponent.org/guide/slots.html).
251
+
252
+ `nice_partials` provides very similar functionality to Slotify but takes a slightly different approach/style. So if you are not convinced by Slotify then definitely [check it out](https://github.com/bullet-train-co/nice_partials)!
253
+
254
+ ---
255
+
256
+ <a name="full-example" id="full-example"></a>
257
+
258
+ ## A more full-featured example
79
259
 
80
260
  ```erb
81
261
  <!-- views/_example.html.erb -->
@@ -88,7 +268,7 @@ Slots are defined using a `strict locals`-style magic comment at the top of the
88
268
  <%= title %>
89
269
  </h1>
90
270
 
91
- <p>Example link: <%= partial.link_to website_link, data: {controller: "external-link"} %></p>
271
+ <p>Example link: <%= link_to website_link, data: {controller: "external-link"} %></p>
92
272
 
93
273
  <%= render lists, title: "Default title" %>
94
274
 
@@ -114,7 +294,7 @@ Slots are defined using a `strict locals`-style magic comment at the top of the
114
294
 
115
295
  <% if items.any? %>
116
296
  <%= tag.ul class: "list" do %>
117
- <%= partial.li items, class: "list-item" %>
297
+ <%= content_tag :li, items, class: "list-item" %>
118
298
  <% end %>
119
299
  <% end %>
120
300
  ```
@@ -145,6 +325,4 @@ Slots are defined using a `strict locals`-style magic comment at the top of the
145
325
  <% end %>
146
326
  ```
147
327
 
148
- ## Credits
149
328
 
150
- Slotify was heavily influenced by (and borrows some code from) the excellent [nice_partials gem](https://github.com/bullet-train-co/nice_partials). It provides similar functionality to Slotify so if you are not convinced by what you see here then go check it out!
@@ -0,0 +1,16 @@
1
+ module Slotify
2
+ module HelpersConcern
3
+ def make_compatible_with_slots(*method_names)
4
+ proxy = Module.new
5
+ method_names.each do |name|
6
+ proxy.define_method(name) do |*args, **kwargs, &block|
7
+ return super(*args, **kwargs, &block) if args.none?
8
+
9
+ results = MethodArgsResolver.call(args, kwargs, block) { super(*_1, **_2, &_3) }
10
+ results.reduce(ActiveSupport::SafeBuffer.new) { _1 << _2 }
11
+ end
12
+ end
13
+ prepend proxy
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,18 @@
1
+ module Slotify
2
+ module InflectionHelper
3
+ extend ActiveSupport::Concern
4
+
5
+ def singular?(str)
6
+ str = str.to_s
7
+ str.singularize == str && str.pluralize != str
8
+ end
9
+
10
+ def singularize(sym)
11
+ sym.to_s.singularize.to_sym
12
+ end
13
+
14
+ def plural?(str)
15
+ !singular?(str)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,19 @@
1
+ module Slotify
2
+ class UnknownSlotError < NameError
3
+ end
4
+
5
+ class SlotsAccessError < RuntimeError
6
+ end
7
+
8
+ class UndefinedSlotError < StandardError
9
+ end
10
+
11
+ class MultipleSlotEntriesError < ArgumentError
12
+ end
13
+
14
+ class SlotArgumentError < ArgumentError
15
+ end
16
+
17
+ class StrictSlotsError < ArgumentError
18
+ end
19
+ end
@@ -0,0 +1,34 @@
1
+ module Slotify
2
+ module Extensions
3
+ module Base
4
+ extend HelpersConcern
5
+
6
+ attr_reader :partial
7
+
8
+ def render(target = {}, locals = {}, &block)
9
+ @partial = Slotify::Partial.new(self)
10
+ super
11
+ ensure
12
+ @partial = partial.outer_partial
13
+ end
14
+
15
+ def _layout_for(*args, &block)
16
+ if block && args.first.is_a?(Symbol)
17
+ capture_with_outer_partial_access(*args, &block)
18
+ else
19
+ super
20
+ end
21
+ end
22
+
23
+ def capture_with_outer_partial_access(*args, &block)
24
+ inner_partial, @partial = partial, partial.outer_partial
25
+ inner_partial.capture(*args, &block)
26
+ ensure
27
+ @partial = inner_partial
28
+ end
29
+
30
+ make_compatible_with_slots :url_for, :link_to, :button_to, :link_to_unless_current,
31
+ :link_to_unless, :link_to_if, :mail_to, :sms_to, :phone_to, :tag, :content_tag
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,39 @@
1
+ module Slotify
2
+ module Extensions
3
+ module PartialRenderer
4
+ def render_partial_template(view, locals, template, layout, block)
5
+ return super unless template.strict_slots?
6
+
7
+ partial = view.partial
8
+ view.capture_with_outer_partial_access(&block) if block
9
+ partial.with_strict_slots(template.strict_slots_keys)
10
+ locals = locals.merge(partial.slot_locals)
11
+
12
+ decorate_strict_slots_errors do
13
+ super(view, locals, template, layout, block)
14
+ end
15
+ end
16
+
17
+ def decorate_strict_slots_errors(&block)
18
+ yield
19
+ rescue ActionView::Template::Error => error
20
+ if missing_strict_locals_error?(error)
21
+ local_names = error.cause.message.scan(/\s:(\w+)/).flatten
22
+ if local_names.any?
23
+ slot_names = local_names.intersection(error.template.strict_slots_keys).map { ":#{_1}" }
24
+ if slot_names.any?
25
+ message = "missing #{"slot".pluralize(slot_names.size)}: #{slot_names.join(", ")} for #{error.template.short_identifier}"
26
+ raise Slotify::StrictSlotsError, message
27
+ end
28
+ end
29
+ end
30
+ raise error
31
+ end
32
+
33
+ def missing_strict_locals_error?(error)
34
+ error.template && (defined?(ActionView::StrictLocalsError) && error.cause.is_a?(ActionView::StrictLocalsError)) ||
35
+ (error.cause.is_a?(ArgumentError) && error.cause.message.match(/missing local/))
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,50 @@
1
+ require "action_view/template/error"
2
+
3
+ module Slotify
4
+ module Extensions
5
+ module Template
6
+ STRICT_SLOTS_REGEX = /\#\s+slots:\s+\((.*)\)/
7
+ STRICT_SLOTS_KEYS_REGEX = /(\w+):(?=(?:[^"\\]*(?:\\.|"(?:[^"\\]*\\.)*[^"\\]*"))*[^"]*$)/
8
+
9
+ def initialize(...)
10
+ super
11
+ @strict_slots = ActionView::Template::NONE
12
+ end
13
+
14
+ def strict_slots!
15
+ if @strict_slots == ActionView::Template::NONE
16
+ source.sub!(STRICT_SLOTS_REGEX, "")
17
+ strict_slots = $1
18
+ @strict_slots = if strict_slots.nil?
19
+ ""
20
+ else
21
+ strict_slots.sub("**", "").strip.delete_suffix(",")
22
+ end
23
+ end
24
+
25
+ @strict_slots
26
+ end
27
+
28
+ def strict_slots?
29
+ strict_slots!.present?
30
+ end
31
+
32
+ def strict_slots_keys
33
+ strict_slots!.scan(STRICT_SLOTS_KEYS_REGEX).map(&:first)
34
+ end
35
+
36
+ def strict_locals!
37
+ return super unless strict_slots?
38
+
39
+ [strict_slots!, super].compact.join(", ")
40
+ end
41
+
42
+ def locals_code
43
+ return super unless strict_slots?
44
+ strict_slots_keys.each_with_object(+super) do |key, code|
45
+ code << "#{key} = partial.content_for(:#{key}, binding.local_variable_get(:#{key}));"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,124 @@
1
+ module Slotify
2
+ class Partial
3
+ include InflectionHelper
4
+
5
+ attr_reader :outer_partial
6
+
7
+ def initialize(view_context)
8
+ @view_context = view_context
9
+ @outer_partial = view_context.partial
10
+ @values = []
11
+ @strict_slots = nil
12
+ end
13
+
14
+ def content_for(slot_name, fallback_value = nil)
15
+ raise SlotsAccessError, "slot values cannot be accessed from outside the partial" unless slots_defined?
16
+ raise UnknownSlotError, "unknown slot :#{slot_name}" unless slot_defined?(slot_name)
17
+
18
+ values = slot_values(slot_name)
19
+ if values.none? && !fallback_value.nil?
20
+ values = add_values(slot_name, Array(fallback_value))
21
+ end
22
+
23
+ singular?(slot_name) ? values.first : ValueCollection.new(values)
24
+ end
25
+
26
+ def content_for?(slot_name)
27
+ raise SlotsAccessError, "slot values cannot be accessed from outside the partial" unless slots_defined?
28
+ raise UnknownSlotError, "unknown slot :#{slot_name}" unless slot_defined?(slot_name)
29
+
30
+ slot_values(slot_name).any?
31
+ end
32
+
33
+ def capture(*args, &block)
34
+ @captured_buffer = @view_context.capture(*args, self, &block)
35
+ end
36
+
37
+ def yield(*args)
38
+ if args.empty?
39
+ @captured_buffer
40
+ else
41
+ content_for(args.first)
42
+ end
43
+ end
44
+
45
+ def slot_locals
46
+ pairs = @strict_slots.map do |slot_name|
47
+ values = slot_values(slot_name)
48
+ values = singular?(slot_name) ? values&.first : values
49
+ [slot_name, values]
50
+ end
51
+ pairs.filter do |key, value|
52
+ # keep empty strings as local value but filter out empty arrays
53
+ # and objects so they don't override any default values set via strict slots.
54
+ value.is_a?(String) || value&.present?
55
+ end.to_h
56
+ end
57
+
58
+ def with_strict_slots(strict_slot_names)
59
+ @strict_slots = strict_slot_names.map(&:to_sym)
60
+ validate_slots!
61
+ end
62
+
63
+ def respond_to_missing?(name, include_private = false)
64
+ name.start_with?("with_")
65
+ end
66
+
67
+ def method_missing(name, *args, **options, &block)
68
+ if name.start_with?("with_")
69
+ slot_name = name.to_s.delete_prefix("with_")
70
+ if singular?(slot_name)
71
+ add_value(slot_name, args, options, block)
72
+ else
73
+ collection = args.first
74
+ add_values(slot_name, collection, options, block)
75
+ end
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def slots_defined?
82
+ !@strict_slots.nil?
83
+ end
84
+
85
+ def slot_defined?(slot_name)
86
+ slot_name && slots_defined? && @strict_slots.include?(slot_name.to_sym)
87
+ end
88
+
89
+ def slot_values(slot_name)
90
+ @values.filter { _1.slot_name == singularize(slot_name) }
91
+ end
92
+
93
+ def add_values(slot_name, collection, options = {}, block = nil)
94
+ collection.map { add_value(slot_name, _1, options, block) }
95
+ rescue NoMethodError
96
+ raise SlotArgumentError, "expected array to be passed to slot :#{slot_name} (received #{collection.class.name})"
97
+ end
98
+
99
+ def add_value(slot_name, args = [], options = {}, block = nil)
100
+ MethodArgsResolver.call(args, options, block) do
101
+ @values << Value.new(@view_context, singularize(slot_name), _1, _2, _3)
102
+ end
103
+
104
+ @values.last
105
+ end
106
+
107
+ def validate_slots!
108
+ return if @strict_slots.nil?
109
+
110
+ singular_slots = @strict_slots.map { singularize(_1) }
111
+ slots_called = @values.map(&:slot_name).uniq
112
+ undefined_slots = slots_called - singular_slots
113
+
114
+ if undefined_slots.any?
115
+ raise UndefinedSlotError, "missing slot #{"definition".pluralize(undefined_slots.size)} for `#{undefined_slots.map { ":#{_1}(s)" }.join(", ")}`"
116
+ end
117
+
118
+ @strict_slots.filter { singular?(_1) }.each do |slot_name|
119
+ values = slot_values(slot_name)
120
+ raise MultipleSlotEntriesError, "slot :#{slot_name} called #{values.size} times (expected 1)" if values.many?
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,26 @@
1
+ module Slotify
2
+ module MethodArgsResolver
3
+ class << self
4
+ def call(args = [], options = {}, block = nil)
5
+ args = args.is_a?(Array) ? args.clone : [args]
6
+ value_index = args.index { _1.is_a?(ValueCollection) || _1.is_a?(Value) }
7
+ if value_index.nil?
8
+ [yield(args, options, block)]
9
+ else
10
+ target = args[value_index]
11
+ values = target.is_a?(ValueCollection) ? target : [target]
12
+ values.map do |value|
13
+ cloned_args = args.clone
14
+ cloned_args[value_index, 1] = value.args.clone
15
+
16
+ yield(
17
+ cloned_args,
18
+ TagOptionsMerger.call(options, value.options),
19
+ value.block || block
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,54 @@
1
+ module Slotify
2
+ module TagOptionsMerger
3
+ class << self
4
+ include ::ActionView::Helpers::TagHelper
5
+
6
+ def call(original, target)
7
+ original = original.to_h.deep_symbolize_keys
8
+ target = target.to_h.deep_symbolize_keys
9
+
10
+ target.each do |key, value|
11
+ original[key] = case key
12
+ when :data
13
+ merge_data_options(original[key], value)
14
+ when :class
15
+ merge_class_options(original[key], value)
16
+ else
17
+ value
18
+ end
19
+ end
20
+
21
+ original
22
+ end
23
+
24
+ private
25
+
26
+ def merge_data_options(original_data, target_data)
27
+ original_data = original_data.dup
28
+
29
+ target_data.each do |key, value|
30
+ values = [original_data[key], value]
31
+ original_data[key] = if key.in?([:controller, :action]) && all_kind_of?(String, values)
32
+ merge_strings(values)
33
+ else
34
+ value
35
+ end
36
+ end
37
+
38
+ original_data
39
+ end
40
+
41
+ def merge_class_options(original_classes, target_classes)
42
+ class_names(original_classes, target_classes)
43
+ end
44
+
45
+ def merge_strings(*args)
46
+ args.map(&:presence).compact.join(" ")
47
+ end
48
+
49
+ def all_kind_of?(kind, values)
50
+ values.none? { !_1.is_a?(kind) }
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,83 @@
1
+ module Slotify
2
+ class Value
3
+ include InflectionHelper
4
+
5
+ attr_reader :slot_name, :args, :block
6
+
7
+ delegate :presence, to: :@content
8
+
9
+ def initialize(view_context, slot_name, args = [], options = {}, block = nil, partial_path: nil)
10
+ @view_context = view_context
11
+ @slot_name = slot_name.to_sym
12
+ @args = args
13
+ @options = options.to_h
14
+ @block = block
15
+ @partial_path = partial_path
16
+ end
17
+
18
+ def options
19
+ ValueOptions.new(@view_context, @options)
20
+ end
21
+
22
+ def content
23
+ body = if @block && @block.arity == 0
24
+ @view_context.capture(&@block)
25
+ else
26
+ begin
27
+ args.first.to_str
28
+ rescue NoMethodError
29
+ ""
30
+ end
31
+ end
32
+ ActiveSupport::SafeBuffer.new(body.presence || "")
33
+ end
34
+
35
+ alias_method :to_s, :content
36
+ alias_method :to_str, :content
37
+
38
+ def present?
39
+ @args.present? || @options.present? || @block
40
+ end
41
+
42
+ def empty?
43
+ @args.empty? || @options.empty? || !@block
44
+ end
45
+
46
+ alias_method :blank?, :empty?
47
+
48
+ def to_h
49
+ @options
50
+ end
51
+
52
+ alias_method :to_hash, :to_h
53
+
54
+ def with_partial_path(partial_path)
55
+ Value.new(@view_context, @slot_name, @args, options, @block, partial_path:)
56
+ end
57
+
58
+ def with_default_options(default_options)
59
+ options = TagOptionsMerger.call(default_options, @options)
60
+ Value.new(@view_context, @slot_name, @args, options, @block)
61
+ end
62
+
63
+ def respond_to_missing?(name, include_private = false)
64
+ name.start_with?("to_") || super
65
+ end
66
+
67
+ def method_missing(name, *args, **options)
68
+ if name.start_with?("to_") && args.none?
69
+ @args.first.public_send(name)
70
+ end
71
+ end
72
+
73
+ def render_in(view_context, &block)
74
+ view_context.render partial_path, **@options.to_h, &@block || block
75
+ end
76
+
77
+ private
78
+
79
+ def partial_path
80
+ @partial_path || @slot_name.to_s
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,31 @@
1
+ module Slotify
2
+ class ValueCollection
3
+ include Enumerable
4
+
5
+ delegate_missing_to :@values
6
+
7
+ def initialize(values = [])
8
+ @values = values.is_a?(Value) ? [values] : values
9
+ end
10
+
11
+ def with_default_options(...)
12
+ ValueCollection.new(map { _1.with_default_options(...) })
13
+ end
14
+
15
+ def with_partial_path(...)
16
+ ValueCollection.new(map { _1.with_partial_path(...) })
17
+ end
18
+
19
+ def to_s
20
+ @values.reduce(ActiveSupport::SafeBuffer.new) do |buffer, value|
21
+ buffer << value.to_s
22
+ end
23
+ end
24
+
25
+ def render_in(view_context, &block)
26
+ @values.reduce(ActiveSupport::SafeBuffer.new) do |buffer, value|
27
+ buffer << value.render_in(view_context, &block)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ module Slotify
2
+ class ValueOptions < ActiveSupport::OrderedOptions
3
+ def initialize(view_context, options = {})
4
+ @view_context = view_context
5
+ merge!(options)
6
+ end
7
+
8
+ def except(...)
9
+ ValueOptions.new(@view_context, to_h.except(...))
10
+ end
11
+
12
+ def slice(...)
13
+ ValueOptions.new(@view_context, to_h.slice(...))
14
+ end
15
+
16
+ def to_s
17
+ @view_context.tag.attributes(self)
18
+ end
19
+ end
20
+ end
@@ -1,3 +1,3 @@
1
1
  module Slotify
2
- VERSION = "0.0.0"
2
+ VERSION = "0.0.1"
3
3
  end
data/lib/slotify.rb CHANGED
@@ -1,3 +1,21 @@
1
+ require "zeitwerk"
2
+ require "action_view"
1
3
  require_relative "slotify/version"
4
+ require_relative "slotify/error"
5
+
6
+ loader = Zeitwerk::Loader.for_gem
7
+ loader.tag = "slotify"
8
+ loader.push_dir("#{__dir__}/slotify", namespace: Slotify)
9
+ loader.collapse("#{__dir__}/slotify/concerns")
10
+ loader.collapse("#{__dir__}/slotify/services")
11
+ loader.enable_reloading if ENV["RAILS_ENV"] == "development"
12
+ loader.setup
13
+
14
+ ActiveSupport.on_load :action_view do
15
+ prepend Slotify::Extensions::Base
16
+ ActionView::Template.prepend Slotify::Extensions::Template
17
+ ActionView::PartialRenderer.prepend Slotify::Extensions::PartialRenderer
18
+ end
19
+
2
20
  module Slotify
3
21
  end
metadata CHANGED
@@ -1,15 +1,43 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slotify
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mark Perkins
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-03-31 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2025-04-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: zeitwerk
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: actionview
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
13
41
  description:
14
42
  email:
15
43
  executables: []
@@ -18,6 +46,18 @@ extra_rdoc_files: []
18
46
  files:
19
47
  - README.md
20
48
  - lib/slotify.rb
49
+ - lib/slotify/concerns/helpers_concern.rb
50
+ - lib/slotify/concerns/inflection_helper.rb
51
+ - lib/slotify/error.rb
52
+ - lib/slotify/extensions/base.rb
53
+ - lib/slotify/extensions/partial_renderer.rb
54
+ - lib/slotify/extensions/template.rb
55
+ - lib/slotify/partial.rb
56
+ - lib/slotify/services/method_args_resolver.rb
57
+ - lib/slotify/services/tag_options_merger.rb
58
+ - lib/slotify/value.rb
59
+ - lib/slotify/value_collection.rb
60
+ - lib/slotify/value_options.rb
21
61
  - lib/slotify/version.rb
22
62
  homepage:
23
63
  licenses:
@@ -38,8 +78,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
38
78
  - !ruby/object:Gem::Version
39
79
  version: '0'
40
80
  requirements: []
41
- rubygems_version: 3.3.3
81
+ rubygems_version: 3.5.10
42
82
  signing_key:
43
83
  specification_version: 4
44
- summary: A superpowered slot system for Rails partials.
84
+ summary: Superpowered slots for your Rails partials.
45
85
  test_files: []