scoped_css 0.1.0

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 (5) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +268 -0
  3. data/lib/scoped_css.rb +96 -0
  4. data/lib/version.rb +3 -0
  5. metadata +73 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4cc56b9cf9562506d46c2afe9e006ba1b97150e1451a47b6b9c908252bfdbfe6
4
+ data.tar.gz: de380cffe066073f85e6d661b9b2ccf29c17094aba9671085ee85965e0534206
5
+ SHA512:
6
+ metadata.gz: b89122d003f20e56b8fab45906a18fa24ca5fea48353db7bc43a6f4f1b967038863a6a69fa7f75489f779e0a622e6050efc3204982d6530173217bb39817a0ac
7
+ data.tar.gz: 3210446f837e4ec5900615b6d4a085e3f33aa5c1c652f08920f20edb3097af208635d48739981bb815b8942fe7332687fce1c61be13a989a546ee719a223b747
data/README.md ADDED
@@ -0,0 +1,268 @@
1
+ # Scoped CSS
2
+
3
+
4
+ ## Installation
5
+
6
+ 1. Add to your Gemfile:
7
+ `gem 'scoped_css'`
8
+ 2. Run `bundle install`
9
+ 3. Include the helper in your views:
10
+ ```ruby
11
+ module ApplicationHelper
12
+ include ScopedCss::Helper
13
+ # other helpers...
14
+ end
15
+ ```
16
+ 4. Use the helper in templates:
17
+ ```erb
18
+ <% style_string, styles = scoped_css do %>
19
+ <style>
20
+ .header { font-weight: bold; }
21
+ .content { margin: 10px; }
22
+ <style>
23
+ <% end %>
24
+
25
+ <h1 class="<%= styles[:header] %>">Title</h1>
26
+ <main class="<%= styles[:content] %>">Content here</main>
27
+
28
+ <%= style_string %>
29
+ ```
30
+
31
+ ## Usage with ViewComponent
32
+
33
+ _app/components/section_component.rb_
34
+ ```ruby
35
+ class SectionComponent < ViewComponent::Base
36
+ end
37
+ ```
38
+
39
+ _app/components/section_component.html.erb_
40
+ ```erb
41
+ <% style_string, styles = helpers.scoped_css do %>
42
+ <style>
43
+ .section {
44
+ border: none;
45
+ scroll-snap-align: center;
46
+ color: purple;
47
+ }
48
+ .heading {
49
+ font-size: 2rem;
50
+ }
51
+ </style>
52
+ <% end %>
53
+
54
+ <section class="<%= styles[:section] %>">
55
+ <h2 class="<%= styles[:heading] %>">Section</h2>
56
+ <%= content %>
57
+ </section>
58
+
59
+ <%= style_string %>
60
+ ```
61
+
62
+ ## Attribute Splatting
63
+
64
+ Sometimes you want to apply html attributes to a component from the parent template.
65
+
66
+
67
+ _app/components/section_component.rb_
68
+ ```ruby
69
+ class SectionComponent < ViewComponent::Base
70
+ def initialize(attributes: {})
71
+ @attributes = attributes
72
+ end
73
+ end
74
+ ```
75
+
76
+ _app/views/home/index.html.erb_
77
+ ```erb
78
+
79
+ <% style_string, styles = scoped_css do %>
80
+ <style>
81
+ .section {
82
+ margin: 10px;
83
+ }
84
+ .heading {
85
+ font-size: 3rem;
86
+ }
87
+ </style>
88
+ <% end %>
89
+
90
+ <h1 class="<%= styles[:heading] %>">Title</h1>
91
+ <%= render SectionComponent.new(attributes: { id: "important-section", class: styles[:section] }) do %>
92
+ <p>Section 1</p>
93
+ <% end %>
94
+
95
+ <%= style_string %>
96
+ ```
97
+
98
+ _app/components/section_component.html.erb_
99
+ ```erb
100
+ <% style_string, styles = helpers.scoped_css do %>
101
+ <style>
102
+ .section {
103
+ border: none;
104
+ scroll-snap-align: center;
105
+ color: purple;
106
+ }
107
+ .heading {
108
+ font-size: 2rem;
109
+ }
110
+ </style>
111
+ <% end %>
112
+
113
+ <section <%= helpers.splat_attributes(@attributes, styles[:section]) %>>
114
+ <h2 class="<%= styles[:heading] %>">Section</h2>
115
+ <%= content %>
116
+ </section>
117
+
118
+ <%= style_string %>
119
+ ```
120
+
121
+ :info: Note that `<section class="<%= styles[:heading] %>" <%= helpers.splat_attributes(@attributes, styles[:section]) %>>` is not used. Instead, the `splat_attributes` helper is used to apply the class attribute to the component. The `splat_attributes` helper takes CSS class names after the attributes argument and concatenates these CSS class names with any class names in the attributes hash.
122
+
123
+ This will generate the following HTML:
124
+
125
+ ```html
126
+ <!-- app/views/home/index.html.erb -->
127
+
128
+ <h1 class=".atge5q2e-heading">Title</h1>
129
+
130
+ <!-- app/components/section_component.html.erb -->
131
+
132
+ <section class="agkd94j4-section atge5q2e-section" id="important-section">
133
+ <h2 class="agkd94j4-heading">
134
+ </section>
135
+
136
+ <section class="agkd94j4-section">
137
+ <h2 class="agkd94j4-heading">
138
+ <p>Section</p>
139
+ </section>
140
+
141
+ <style>
142
+ .agkd94j4-section {
143
+ border: none;
144
+ scroll-snap-align: center;
145
+ color: purple;
146
+ }
147
+ .agkd94j4-heading {
148
+ font-size: 2rem;
149
+ }
150
+ </style>
151
+
152
+ <!-- app/views/home/index.html.erb -->
153
+
154
+ <style>
155
+ .atge5q2e-section {
156
+ margin: 10px;
157
+ }
158
+ .atge5q2e-heading {
159
+ font-size: 3rem;
160
+ }
161
+ </style>
162
+
163
+ ```
164
+
165
+ ### CSS Specificity
166
+
167
+ In previous examples, we applied the CSS class `.section` to the `<section>` element. It has the property `color: purple;`. But what if we want one instance of the SectionComponent to have a different color? In the parent template we can define a CSS class for the section component with the different color and apply it to the component.
168
+
169
+ _app/components/section_component.rb_
170
+ ```ruby
171
+ class SectionComponent < ViewComponent::Base
172
+ def initialize(attributes: {})
173
+ @attributes = attributes
174
+ end
175
+ end
176
+ ```
177
+
178
+ _app/components/section_component.html.erb_
179
+ ```erb
180
+ <% style_string, styles = helpers.scoped_css do %>
181
+ <style>
182
+ .section {
183
+ border: none;
184
+ scroll-snap-align: center;
185
+ color: purple;
186
+ }
187
+ .heading {
188
+ font-size: 2rem;
189
+ }
190
+ </style>
191
+ <% end %>
192
+
193
+ <section <%= helpers.splat_attributes(@attributes, styles[:section]) %>>
194
+ <h2 class="<%= styles[:heading] %>">Section</h2>
195
+ <%= content %>
196
+ </section>
197
+
198
+ <%= style_string %>
199
+ ```
200
+
201
+ _app/views/home/index.html.erb_
202
+ ```erb
203
+ <% style_string, styles = scoped_css do %>
204
+ <style>
205
+ .section {
206
+ margin: 10px;
207
+ color: darkgreen;
208
+ }
209
+ .heading {
210
+ font-size: 3rem;
211
+ }
212
+ </style>
213
+ <% end %>
214
+
215
+ <h1 class="<%= styles[:heading] %>">Title</h1>
216
+ <%= render SectionComponent.new(attributes: { class: styles[:section] }) do %>
217
+ <p>Section 1</p>
218
+ <% end %>
219
+
220
+ <%= render SectionComponent.new() do %>
221
+ <p>Section 2</p>
222
+ <% end %>
223
+
224
+ <%= style_string %>
225
+ ```
226
+
227
+ The reason this works is because the we render the style_string in each template at the bottom of the template. Any nested components style tag will be rendered before the parent style tag. The last declared selector has precedence. And because a scoped CSS class name is passed to the component only that instance of the component will use the new color.
228
+
229
+ ```html
230
+ <!-- app/views/home/index.html.erb -->
231
+
232
+ <h1 class=".atge5q2e-heading">Title</h1>
233
+
234
+ <!-- app/components/section_component.html.erb -->
235
+
236
+ <section class="agkd94j4-section atge5q2e-section">
237
+ <h2 class="agkd94j4-heading">
238
+ <p>Section 1</p>
239
+ </section>
240
+
241
+ <section class="agkd94j4-section atge5q2e-section">
242
+ <h2 class="agkd94j4-heading">
243
+ <p>Section 2</p>
244
+ </section>
245
+
246
+ <style>
247
+ .agkd94j4-section {
248
+ border: none;
249
+ scroll-snap-align: center;
250
+ color: purple;
251
+ }
252
+ .agkd94j4-heading {
253
+ font-size: 2rem;
254
+ }
255
+ </style>
256
+
257
+ <!-- app/views/home/index.html.erb -->
258
+
259
+ <style>
260
+ .atge5q2e-section {
261
+ margin: 10px;
262
+ color: darkgreen;
263
+ }
264
+ .atge5q2e-heading {
265
+ font-size: 3rem;
266
+ }
267
+ </style>
268
+ ```
data/lib/scoped_css.rb ADDED
@@ -0,0 +1,96 @@
1
+ require_relative "version"
2
+ require "digest/sha2"
3
+ require "erb"
4
+
5
+ module ScopedCss
6
+ module Helper
7
+ def scoped_css(&css_block)
8
+ @per_template_outputs ||= Hash.new
9
+
10
+ css_block_content = ""
11
+ if block_given?
12
+ css_block_content = capture(&css_block)
13
+ end
14
+
15
+ prefix = "a#{Digest::SHA1.hexdigest(css_block_content)}"[0,8]
16
+
17
+ styles = Hash.new
18
+ prefixed_css_block_content = Rails.env.local? ? ' <!-- previously output --> ' : ''
19
+
20
+ if @per_template_outputs.has_key?(prefix)
21
+ styles = @per_template_outputs[prefix]
22
+ else
23
+ prefixed_css_block_content, styles = prefix_css_classes(css_block_content, prefix)
24
+ @per_template_outputs[prefix] = styles
25
+ end
26
+
27
+ result = prefixed_css_block_content.respond_to?(:html_safe) ? prefixed_css_block_content.html_safe : prefixed_css_block_content
28
+ return [result, styles, prefix]
29
+ end
30
+
31
+ # Helper to take attribute hashes (or strings for classes) and format them
32
+ # into a string suitable for direct HTML attribute "splatting".
33
+ #
34
+ # Example usage in ERB:
35
+ # <section <%= splat_attributes(@attributes, styles[:section]) %>>
36
+ # <%= content %>
37
+ # </section>
38
+ #
39
+ # @param args [Array<Hash, String>] One or more attribute hashes or class strings
40
+ # @return [String] A string of HTML attributes (e.g., 'class="foo bar" id="my-id"')
41
+ def splat_attributes(*args)
42
+ combined_attributes = merge_classes(*args)
43
+
44
+ # Convert the combined hash into an HTML attribute string
45
+ result = combined_attributes.map do |key, value|
46
+ html_key = key.to_s.gsub('_', '-')
47
+ escaped_value = ERB::Util.html_escape(value.to_s)
48
+
49
+ # Handle boolean attributes (e.g., 'disabled' instead of 'disabled="true"')
50
+ if value.is_a?(TrueClass) && !html_key.empty?
51
+ html_key
52
+ elsif value.is_a?(FalseClass)
53
+ # Don't render attributes that are explicitly false
54
+ nil
55
+ elsif !escaped_value.empty?
56
+ "#{html_key}=\"#{escaped_value}\""
57
+ else
58
+ nil # Don't render attributes with empty values
59
+ end
60
+ end.compact.join(" ").strip
61
+
62
+ result.respond_to?(:html_safe) ? result.html_safe : result
63
+ end
64
+
65
+ private
66
+
67
+ def prefix_css_classes(css_string, prefix)
68
+ updated_css_string = css_string.dup
69
+ class_name_map = {}
70
+ class_selector_regex = /\.([_a-zA-Z][_a-zA-Z0-9-]*)/
71
+
72
+ updated_css_string.gsub!(class_selector_regex) do |full_match|
73
+ original_class_name = Regexp.last_match[1]
74
+ prefixed_class_name = "#{prefix}-#{original_class_name}"
75
+ class_name_map[original_class_name.to_sym] = prefixed_class_name
76
+ ".#{prefixed_class_name}"
77
+ end
78
+
79
+ return updated_css_string, class_name_map
80
+ end
81
+
82
+ def merge_classes(attributes, *css_classes)
83
+ merged_attributes = attributes.dup
84
+
85
+ css_class_string = css_classes.compact.join(" ").strip
86
+
87
+ if merged_attributes[:class].nil? || merged_attributes[:class].empty?
88
+ merged_attributes[:class] = css_class_string
89
+ else
90
+ merged_attributes[:class] = "#{css_class_string} #{merged_attributes[:class]}".strip
91
+ end
92
+
93
+ merged_attributes
94
+ end
95
+ end
96
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,3 @@
1
+ module ScopedCss
2
+ VERSION = "0.1.0"
3
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: scoped_css
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ewan McDougall
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-06-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
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: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: Scope CSS to templates and ViewComponents
42
+ email: ewan@mrloop.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - README.md
48
+ - lib/scoped_css.rb
49
+ - lib/version.rb
50
+ homepage:
51
+ licenses:
52
+ - MIT
53
+ metadata: {}
54
+ post_install_message:
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.5.22
70
+ signing_key:
71
+ specification_version: 4
72
+ summary: Scoped CSS
73
+ test_files: []