view_component-scoped_styles 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.
- checksums.yaml +7 -0
- data/README.md +226 -0
- data/Rakefile +12 -0
- data/lib/generators/view_component/scoped_styles/install_generator.rb +40 -0
- data/lib/generators/view_component/scoped_styles/templates/view_component_scoped_styles.rb.tt +9 -0
- data/lib/view_component/scoped_styles/concern.rb +163 -0
- data/lib/view_component/scoped_styles/configuration.rb +49 -0
- data/lib/view_component/scoped_styles/railtie.rb +38 -0
- data/lib/view_component/scoped_styles/stylist/writer.rb +100 -0
- data/lib/view_component/scoped_styles/stylist.rb +20 -0
- data/lib/view_component/scoped_styles/version.rb +7 -0
- data/lib/view_component/scoped_styles.rb +13 -0
- metadata +147 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 43108a25040819bdeee257cd43228632e8de7822b24e96d675625208ff5f1544
|
|
4
|
+
data.tar.gz: 1023d48e94469ea2db73b971eb0c09440bdfb18167aea8abb461cb45e5bc6ed5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e40669a1884ae189bc18edcf04cad923e210875078095d6fd7238b3dd9ff061172253f8c6dd86632ee207cd3e1ae108bf04d1dac1866daedd948c078e0a95e50
|
|
7
|
+
data.tar.gz: 6f67241201f8e8452e8eec7e316ff4a1259496347bc62f58dc6b2a7739557dc3df7f4e69ce75cc1041843dcd5d95d9a3cc40d590d7108c917f3850ae630e89e0
|
data/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# ViewComponent::ScopedStyles
|
|
2
|
+
|
|
3
|
+
Scoped, colocated CSS for [ViewComponent](https://viewcomponent.org/).
|
|
4
|
+
|
|
5
|
+
Avoids collisions by rewriting class selectors to stable, content-derived names.
|
|
6
|
+
|
|
7
|
+
E.g. `.button` becomes `.c-a1b2c3d4`
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
- [ViewComponent::ScopedStyles](#viewcomponentscopedstyles)
|
|
12
|
+
- [Table of Contents](#table-of-contents)
|
|
13
|
+
- [Installation](#installation)
|
|
14
|
+
- [Usage](#usage)
|
|
15
|
+
- [1. Using a sidecar stylesheet](#1-using-a-sidecar-stylesheet)
|
|
16
|
+
- [2. Using a styles block in the component](#2-using-a-styles-block-in-the-component)
|
|
17
|
+
- [Referencing classes](#referencing-classes)
|
|
18
|
+
- [Using the scoped CSS](#using-the-scoped-css)
|
|
19
|
+
- [Configuration](#configuration)
|
|
20
|
+
- [Related projects](#related-projects)
|
|
21
|
+
- [Development](#development)
|
|
22
|
+
- [Contributing](#contributing)
|
|
23
|
+
- [License](#license)
|
|
24
|
+
- [Code of Conduct](#code-of-conduct)
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bundle add view_component-scoped_styles
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or add it to the Gemfile manually:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
gem "view_component-scoped_styles"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
gem install view_component-scoped_styles
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
Include the module in any component class you would like to use with scoped CSS.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
class ExampleComponent < ViewComponent::Base
|
|
52
|
+
include ViewComponent::ScopedStyles
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
CSS can be written in two ways:
|
|
57
|
+
|
|
58
|
+
### 1. Using a sidecar stylesheet
|
|
59
|
+
|
|
60
|
+
Learn more about sidecar [here](https://viewcomponent.org/guide/generators.html#place-the-view-in-a-sidecar-directory).
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
bin/rails generate view_component:component Example title --sidecar
|
|
64
|
+
|
|
65
|
+
create app/components/example_component.rb
|
|
66
|
+
invoke test_unit
|
|
67
|
+
create test/components/example_component_test.rb
|
|
68
|
+
invoke erb
|
|
69
|
+
create app/components/example_component/example_component.html.erb
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then add a matching stylesheet in the sidecar directory:
|
|
74
|
+
|
|
75
|
+
```css
|
|
76
|
+
/* app/components/example_component/example_component.css */
|
|
77
|
+
|
|
78
|
+
.component {
|
|
79
|
+
position: relative;
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 2. Using a styles block in the component
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
# app/components/example_component.rb
|
|
87
|
+
|
|
88
|
+
class ExampleComponent < ViewComponent::Base
|
|
89
|
+
include ViewComponent::ScopedStyles
|
|
90
|
+
|
|
91
|
+
styles do
|
|
92
|
+
<<~CSS
|
|
93
|
+
.component {
|
|
94
|
+
position: relative;
|
|
95
|
+
}
|
|
96
|
+
CSS
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**NB:** Using a styles block will take precedence over a sidecar stylesheet.
|
|
102
|
+
|
|
103
|
+
### Referencing classes
|
|
104
|
+
|
|
105
|
+
Use the `component_class` helper inside component templates to refer to the scoped CSS classes:
|
|
106
|
+
|
|
107
|
+
```erb
|
|
108
|
+
<div class="<%= component_class %>">
|
|
109
|
+
My component content
|
|
110
|
+
</div>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The default selector is `.component` but you can change this by defining `component_css_class` in your component:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
class ExampleComponent < ViewComponent::Base
|
|
117
|
+
include ViewComponent::ScopedStyles
|
|
118
|
+
|
|
119
|
+
component_css_class "example"
|
|
120
|
+
|
|
121
|
+
styles do
|
|
122
|
+
<<~CSS
|
|
123
|
+
.example {
|
|
124
|
+
position: relative;
|
|
125
|
+
}
|
|
126
|
+
CSS
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`component_class` takes an optional string argument to reference other classes in the CSS:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
class ExampleComponent < ViewComponent::Base
|
|
135
|
+
include ViewComponent::ScopedStyles
|
|
136
|
+
|
|
137
|
+
component_css_class "example"
|
|
138
|
+
|
|
139
|
+
styles do
|
|
140
|
+
<<~CSS
|
|
141
|
+
.example {
|
|
142
|
+
position: relative;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.inner {
|
|
146
|
+
position: absolute;
|
|
147
|
+
}
|
|
148
|
+
CSS
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```erb
|
|
154
|
+
<div class="<%= component_class %>">
|
|
155
|
+
My component content
|
|
156
|
+
|
|
157
|
+
<div class="<%= component_class("inner") %>">
|
|
158
|
+
Inner content
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Using the scoped CSS
|
|
164
|
+
|
|
165
|
+
All scoped CSS will be compiled into `app/assets/stylesheets/components.scoped.css`.
|
|
166
|
+
|
|
167
|
+
You should import this stylesheet within your app:
|
|
168
|
+
|
|
169
|
+
```css
|
|
170
|
+
/* app/assets/stylesheets/application.css */
|
|
171
|
+
|
|
172
|
+
@import url("./components.scoped.css");
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Configuration
|
|
176
|
+
|
|
177
|
+
Run the install generator in your Rails app:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
bin/rails generate view_component:scoped_styles:install
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
That creates `config/initializers/view_component_scoped_styles.rb` with the same defaults as `ViewComponent::ScopedStyles::Configuration`.
|
|
184
|
+
|
|
185
|
+
Or create the initializer manually:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
ViewComponent::ScopedStyles.configure do |config|
|
|
189
|
+
# Where ViewComponent classes live (relative to Rails.root). Default: "app/components"
|
|
190
|
+
config.components_path = File.join("app", "components")
|
|
191
|
+
|
|
192
|
+
# Optional @layer name for components.scoped.css (e.g. "components"). Default: nil.
|
|
193
|
+
config.components_layer = nil
|
|
194
|
+
end
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
| Option | Default | Description |
|
|
198
|
+
| --- | --- | --- |
|
|
199
|
+
| `components_path` | `"app/components"` | Where ViewComponent classes live, relative to `Rails.root`. |
|
|
200
|
+
| `components_layer` | `nil` | When set, wraps generated CSS in `@layer <name> { ... }` for cascade control. |
|
|
201
|
+
|
|
202
|
+
## Related projects
|
|
203
|
+
|
|
204
|
+
This gem was heavily inspired by Partials Fx, and indeed takes its foundations from it, modified to work with ViewComponent instead.
|
|
205
|
+
|
|
206
|
+
- https://github.com/Rails-Designer/partials_fx
|
|
207
|
+
- https://github.com/aileron-inc/view_component_scoped_css
|
|
208
|
+
- https://github.com/amkisko/style_capsule.rb
|
|
209
|
+
|
|
210
|
+
## Development
|
|
211
|
+
|
|
212
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
|
|
213
|
+
|
|
214
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
215
|
+
|
|
216
|
+
## Contributing
|
|
217
|
+
|
|
218
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/chrise86/view_component-scoped_styles. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/chrise86/view_component-scoped_styles/blob/master/CODE_OF_CONDUCT.md).
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
223
|
+
|
|
224
|
+
## Code of Conduct
|
|
225
|
+
|
|
226
|
+
Everyone interacting in the ViewComponent::ScopedStyles project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/chrise86/view_component-scoped_styles/blob/master/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "view_component/scoped_styles/configuration"
|
|
4
|
+
require "rails/generators"
|
|
5
|
+
|
|
6
|
+
module ViewComponent
|
|
7
|
+
module ScopedStyles
|
|
8
|
+
module Generators
|
|
9
|
+
# Installs +config/initializers/view_component_scoped_styles.rb+ with defaults
|
|
10
|
+
# from {ViewComponent::ScopedStyles::Configuration}.
|
|
11
|
+
#
|
|
12
|
+
# bin/rails generate view_component:scoped_styles:install
|
|
13
|
+
class InstallGenerator < Rails::Generators::Base
|
|
14
|
+
source_root File.expand_path("templates", __dir__)
|
|
15
|
+
|
|
16
|
+
desc "Creates a ViewComponent::ScopedStyles initializer with default configuration"
|
|
17
|
+
|
|
18
|
+
def copy_initializer
|
|
19
|
+
template "view_component_scoped_styles.rb.tt",
|
|
20
|
+
"config/initializers/view_component_scoped_styles.rb"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def configuration_defaults
|
|
26
|
+
@configuration_defaults ||= Configuration.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def components_path_expression
|
|
30
|
+
segments = Pathname(configuration_defaults.components_path).each_filename.to_a
|
|
31
|
+
"File.join(#{segments.map { |segment| %("#{segment}") }.join(", ")})"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def components_layer_value
|
|
35
|
+
configuration_defaults.components_layer.inspect
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
ViewComponent::ScopedStyles.configure do |config|
|
|
4
|
+
# Where ViewComponent classes live (relative to Rails.root). Default: "app/components"
|
|
5
|
+
config.components_path = <%= components_path_expression %>
|
|
6
|
+
|
|
7
|
+
# Optional @layer name for components.scoped.css (e.g. "components"). Default: nil.
|
|
8
|
+
config.components_layer = <%= components_layer_value %>
|
|
9
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponent
|
|
4
|
+
module ScopedStyles
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
CACHED_VARIABLES = %i[@component_styles @component_id @component_class_map].freeze
|
|
8
|
+
CLASS_SELECTOR_PATTERN = /\.([a-zA-Z_][\w-]*)\b/
|
|
9
|
+
|
|
10
|
+
# Default root class for +component_class+ when it matches a selector in the CSS.
|
|
11
|
+
COMPONENT_CSS_CLASS = "component".freeze
|
|
12
|
+
|
|
13
|
+
class_methods do
|
|
14
|
+
# Sets which CSS class is the root for +component_class+ (no argument).
|
|
15
|
+
# Also triggers style registration when Rails is loaded (registration also
|
|
16
|
+
# runs via the Railtie for all styled components).
|
|
17
|
+
#
|
|
18
|
+
# All class selectors are rewritten to scoped names in +components.scoped.css+.
|
|
19
|
+
# The primary class uses an id from the full stylesheet (e.g. +.icon+ → +.c-99d08d5a+);
|
|
20
|
+
# other classes get per-class ids (e.g. +.input-box+ → +.c-a1b2c3d4+).
|
|
21
|
+
#
|
|
22
|
+
# Call with a name when the root is not +.component+ (the default).
|
|
23
|
+
#
|
|
24
|
+
# Clears cached generated styles when +name+ changes …
|
|
25
|
+
#
|
|
26
|
+
# @param name [String, nil] selector name without a leading dot (e.g. +"icon"+)
|
|
27
|
+
def component_css_class(name = nil)
|
|
28
|
+
if name
|
|
29
|
+
const_set(:COMPONENT_CSS_CLASS, name)
|
|
30
|
+
clear_component_style_cache
|
|
31
|
+
end
|
|
32
|
+
register_styles_if_rails_loaded
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns processed CSS with scoped class selectors, or +nil+ if none.
|
|
36
|
+
def component_styles
|
|
37
|
+
return @component_styles if defined?(@component_styles)
|
|
38
|
+
return nil unless @styles_block || has_stylesheet?
|
|
39
|
+
|
|
40
|
+
generate_component_styles
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Writes this component's processed styles to the bundled or host stylesheet.
|
|
44
|
+
def register_styles
|
|
45
|
+
return unless @styles_block || has_stylesheet?
|
|
46
|
+
|
|
47
|
+
Stylist.register(self)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# +true+ when a sidecar +.css+ file exists for this component.
|
|
51
|
+
def has_stylesheet?
|
|
52
|
+
stylesheet_path && File.exist?(stylesheet_path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def styles(&block)
|
|
56
|
+
@styles_block = block
|
|
57
|
+
register_styles_if_rails_loaded
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def stylesheet_path
|
|
63
|
+
return @stylesheet_path if defined?(@stylesheet_path)
|
|
64
|
+
|
|
65
|
+
@stylesheet_path = sidecar_files(["css"]).first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def register_styles_if_rails_loaded
|
|
69
|
+
return unless defined?(Rails) && Rails.root
|
|
70
|
+
return unless defined?(Rails::Server) # only web server boot path
|
|
71
|
+
|
|
72
|
+
register_styles
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def generate_component_styles
|
|
76
|
+
styles_content = generate_styles_content
|
|
77
|
+
css_classes = extract_css_classes(styles_content)
|
|
78
|
+
primary_class = primary_css_class(css_classes)
|
|
79
|
+
|
|
80
|
+
@component_id = generate_component_id(styles_content)
|
|
81
|
+
@component_class_map = build_component_class_map(styles_content, css_classes, primary_class)
|
|
82
|
+
@component_styles = replace_css_classes(styles_content, @component_class_map)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def generate_styles_content
|
|
86
|
+
@styles_block ? @styles_block.call : File.read(stylesheet_path)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def extract_css_classes(styles_content)
|
|
90
|
+
styles_content.scan(CLASS_SELECTOR_PATTERN).flatten.uniq
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def primary_css_class(css_classes)
|
|
94
|
+
configured = self::COMPONENT_CSS_CLASS.delete_prefix(".")
|
|
95
|
+
css_classes.include?(configured) ? configured : css_classes.first
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_component_class_map(styles_content, css_classes, primary_class)
|
|
99
|
+
css_classes.index_with do |css_class|
|
|
100
|
+
if css_class == primary_class
|
|
101
|
+
@component_id
|
|
102
|
+
else
|
|
103
|
+
generate_scoped_class_id(styles_content, css_class)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def replace_css_classes(styles_content, class_map)
|
|
109
|
+
class_map.keys.sort_by(&:length).reverse.reduce(styles_content) do |content, css_class|
|
|
110
|
+
content.gsub(/\.#{Regexp.escape(css_class)}\b/, ".#{class_map[css_class]}")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def generate_component_id(styles_content)
|
|
115
|
+
hash = Digest::MD5.hexdigest(styles_content)[0..7]
|
|
116
|
+
|
|
117
|
+
"c-#{hash}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def generate_scoped_class_id(styles_content, css_class)
|
|
121
|
+
hash = Digest::MD5.hexdigest("#{styles_content}:#{css_class}")[0..7]
|
|
122
|
+
|
|
123
|
+
"c-#{hash}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def clear_component_style_cache
|
|
127
|
+
CACHED_VARIABLES.each do |ivar|
|
|
128
|
+
remove_instance_variable(ivar) if instance_variable_defined?(ivar)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Scoped CSS class for a selector (e.g. +"c-99d08d5a"+).
|
|
134
|
+
#
|
|
135
|
+
# With no argument, returns the scoped root class ({COMPONENT_CSS_CLASS} when it
|
|
136
|
+
# appears in the CSS, otherwise the first class in the stylesheet).
|
|
137
|
+
#
|
|
138
|
+
# @param name [String, Symbol] CSS class without a leading dot (e.g. +"input-box"+)
|
|
139
|
+
def component_class(name = nil)
|
|
140
|
+
return nil unless component_has_styles? || component_has_stylesheet?
|
|
141
|
+
|
|
142
|
+
self.class.component_styles
|
|
143
|
+
|
|
144
|
+
if name
|
|
145
|
+
class_map = self.class.instance_variable_get(:@component_class_map)
|
|
146
|
+
class_map[name.to_s.delete_prefix(".")]
|
|
147
|
+
else
|
|
148
|
+
self.class.instance_variable_get(:@component_id)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def component_has_stylesheet?
|
|
155
|
+
self.class.has_stylesheet?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def component_has_styles?
|
|
159
|
+
self.class.instance_variable_defined?(:@styles_block) &&
|
|
160
|
+
self.class.instance_variable_get(:@styles_block)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponent
|
|
4
|
+
module ScopedStyles
|
|
5
|
+
# Global settings for ViewComponent::ScopedStyles.
|
|
6
|
+
#
|
|
7
|
+
# Configure in an initializer:
|
|
8
|
+
#
|
|
9
|
+
# ViewComponent::ScopedStyles.configure do |config|
|
|
10
|
+
# config.components_path = File.join("app", "view_components")
|
|
11
|
+
# config.components_layer = "components"
|
|
12
|
+
# end
|
|
13
|
+
class Configuration
|
|
14
|
+
# Directory where ViewComponent classes live, relative to {Rails.root}.
|
|
15
|
+
#
|
|
16
|
+
# @return [String] default: +"app/components"+
|
|
17
|
+
attr_accessor :components_path
|
|
18
|
+
|
|
19
|
+
# Optional CSS cascade layer name for generated styles in
|
|
20
|
+
# +app/assets/stylesheets/components.scoped.css+.
|
|
21
|
+
#
|
|
22
|
+
# When set, the bundled stylesheet is wrapped in +@layer <name> { ... }+ so
|
|
23
|
+
# you can control specificity relative to other layers in your app.
|
|
24
|
+
#
|
|
25
|
+
# @return [String, nil] default: +nil+ (no layer wrapper)
|
|
26
|
+
attr_accessor :components_layer
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@components_path = File.join("app", "components")
|
|
30
|
+
@components_layer = nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# Returns the global configuration object, creating it on first access.
|
|
36
|
+
#
|
|
37
|
+
# @return [Configuration]
|
|
38
|
+
def configuration
|
|
39
|
+
@configuration ||= Configuration.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Yields the global configuration for block-style setup.
|
|
43
|
+
#
|
|
44
|
+
# @yieldparam config [Configuration]
|
|
45
|
+
# @return [void]
|
|
46
|
+
def configure = yield(configuration)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module ViewComponent
|
|
6
|
+
module ScopedStyles
|
|
7
|
+
class Railtie < Rails::Railtie
|
|
8
|
+
config.after_initialize do
|
|
9
|
+
ViewComponent::ScopedStyles::Railtie.load_and_register_components
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
if Rails.env.development?
|
|
13
|
+
config.to_prepare do
|
|
14
|
+
ViewComponent::ScopedStyles::Railtie.register_components
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def component_path
|
|
20
|
+
Rails.root.join("app/components/**/*.rb")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def load_and_register_components
|
|
24
|
+
Dir[component_path].each { require_dependency _1 }
|
|
25
|
+
|
|
26
|
+
register_components
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def register_components
|
|
30
|
+
ObjectSpace
|
|
31
|
+
.each_object(Class)
|
|
32
|
+
.select { _1.ancestors.include?(ViewComponent::ScopedStyles) }
|
|
33
|
+
.each(&:register_styles)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponent
|
|
4
|
+
module ScopedStyles
|
|
5
|
+
class Stylist
|
|
6
|
+
class Writer
|
|
7
|
+
def self.print(component_class)
|
|
8
|
+
new.print!(component_class)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def print!(component_class)
|
|
12
|
+
component_name = component_class.name
|
|
13
|
+
component_class.instance_variable_get(:@component_id)
|
|
14
|
+
styles = component_class.component_styles
|
|
15
|
+
|
|
16
|
+
ensure_stylesheet_exists!
|
|
17
|
+
|
|
18
|
+
update_stylesheet_with(component_name, styles)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def ensure_stylesheet_exists!
|
|
24
|
+
return if File.exist?(stylesheet_path)
|
|
25
|
+
|
|
26
|
+
FileUtils.mkdir_p(File.dirname(stylesheet_path))
|
|
27
|
+
File.write(stylesheet_path, initial_content)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def update_stylesheet_with(component_name, styles)
|
|
31
|
+
content = File.read(stylesheet_path)
|
|
32
|
+
updated_content = merge_styles_into(content, component_name, styles)
|
|
33
|
+
|
|
34
|
+
write_atomically(updated_content)
|
|
35
|
+
|
|
36
|
+
notify_asset_pipeline!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def merge_styles_into(content, component_name, styles)
|
|
40
|
+
wrapped_styles = wrap_with(component_name, styles)
|
|
41
|
+
pattern = style_pattern_for(component_name)
|
|
42
|
+
|
|
43
|
+
if content.match?(pattern)
|
|
44
|
+
content.gsub(pattern, wrapped_styles.strip)
|
|
45
|
+
else
|
|
46
|
+
append_styles_to(content, wrapped_styles)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write_atomically(content)
|
|
51
|
+
if ViewComponent::ScopedStyles.configuration.components_layer
|
|
52
|
+
content = content.rstrip + "\n}\n"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
temp_file = generate_temp_file_name
|
|
56
|
+
|
|
57
|
+
File.write(temp_file, content)
|
|
58
|
+
FileUtils.mv(temp_file, stylesheet_path)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Multiple Rails processes can boot concurrently (e.g. web + jobs),
|
|
62
|
+
# so each writer needs its own temp file to avoid rename races.
|
|
63
|
+
def generate_temp_file_name
|
|
64
|
+
"#{stylesheet_path}.#{Process.pid}.#{Thread.current.object_id}.#{Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)}.tmp"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def notify_asset_pipeline! = FileUtils.touch(stylesheet_path)
|
|
68
|
+
|
|
69
|
+
def wrap_with(component_name, styles)
|
|
70
|
+
start_marker = "/* #{component_name} */\n"
|
|
71
|
+
end_marker = "/* /#{component_name} */"
|
|
72
|
+
|
|
73
|
+
"#{start_marker}#{styles}#{end_marker}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def style_pattern_for(component_name)
|
|
77
|
+
/\/\* #{Regexp.escape(component_name)} \*\/\n.*?\/\* \/#{Regexp.escape(component_name)} \*\//m
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def append_styles_to(content, wrapped_styles)
|
|
81
|
+
[content.chomp, wrapped_styles, "\n"].join("\n")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def initial_content
|
|
85
|
+
content = "/* Generated by ViewComponent::ScopedStyles - Do not edit manually */\n"
|
|
86
|
+
|
|
87
|
+
if (layer = ViewComponent::ScopedStyles.configuration.components_layer)
|
|
88
|
+
content += "@layer #{layer} {\n"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
content
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def stylesheet_path
|
|
95
|
+
@stylesheet_path ||= Rails.root.join("app/assets/stylesheets/components.scoped.css")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "view_component/scoped_styles/stylist/writer"
|
|
4
|
+
|
|
5
|
+
module ViewComponent
|
|
6
|
+
module ScopedStyles
|
|
7
|
+
class Stylist
|
|
8
|
+
def self.register(component_class)
|
|
9
|
+
return if unstyled?(component_class)
|
|
10
|
+
|
|
11
|
+
Writer.print(component_class)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private_class_method def self.unstyled?(component_class)
|
|
15
|
+
!component_class.component_styles &&
|
|
16
|
+
!component_class.instance_variable_get(:@component_id)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "scoped_styles/version"
|
|
4
|
+
require_relative "scoped_styles/configuration"
|
|
5
|
+
require "active_support/concern"
|
|
6
|
+
require_relative "scoped_styles/concern"
|
|
7
|
+
require_relative "scoped_styles/stylist"
|
|
8
|
+
require_relative "scoped_styles/railtie"
|
|
9
|
+
|
|
10
|
+
module ViewComponent
|
|
11
|
+
module ScopedStyles
|
|
12
|
+
end
|
|
13
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: view_component-scoped_styles
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Chris Edwards
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: view_component
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '3.0'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '3.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: rubocop
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '0'
|
|
53
|
+
type: :development
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '0'
|
|
60
|
+
- !ruby/object:Gem::Dependency
|
|
61
|
+
name: rubocop-rails-omakase
|
|
62
|
+
requirement: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0'
|
|
67
|
+
type: :development
|
|
68
|
+
prerelease: false
|
|
69
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: rspec
|
|
76
|
+
requirement: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
type: :development
|
|
82
|
+
prerelease: false
|
|
83
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0'
|
|
88
|
+
- !ruby/object:Gem::Dependency
|
|
89
|
+
name: rake
|
|
90
|
+
requirement: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - "~>"
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '13.0'
|
|
95
|
+
type: :development
|
|
96
|
+
prerelease: false
|
|
97
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - "~>"
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '13.0'
|
|
102
|
+
description: Rewrites ViewComponent component styles to content-derived class names,
|
|
103
|
+
bundles them into a single stylesheet, and provides helpers to use those classes
|
|
104
|
+
in templates.
|
|
105
|
+
email:
|
|
106
|
+
- chris@chrise.net
|
|
107
|
+
executables: []
|
|
108
|
+
extensions: []
|
|
109
|
+
extra_rdoc_files: []
|
|
110
|
+
files:
|
|
111
|
+
- README.md
|
|
112
|
+
- Rakefile
|
|
113
|
+
- lib/generators/view_component/scoped_styles/install_generator.rb
|
|
114
|
+
- lib/generators/view_component/scoped_styles/templates/view_component_scoped_styles.rb.tt
|
|
115
|
+
- lib/view_component/scoped_styles.rb
|
|
116
|
+
- lib/view_component/scoped_styles/concern.rb
|
|
117
|
+
- lib/view_component/scoped_styles/configuration.rb
|
|
118
|
+
- lib/view_component/scoped_styles/railtie.rb
|
|
119
|
+
- lib/view_component/scoped_styles/stylist.rb
|
|
120
|
+
- lib/view_component/scoped_styles/stylist/writer.rb
|
|
121
|
+
- lib/view_component/scoped_styles/version.rb
|
|
122
|
+
homepage: https://github.com/chrise86/view_component-scoped_styles
|
|
123
|
+
licenses:
|
|
124
|
+
- MIT
|
|
125
|
+
metadata:
|
|
126
|
+
homepage_uri: https://github.com/chrise86/view_component-scoped_styles
|
|
127
|
+
source_code_uri: https://github.com/chrise86/view_component-scoped_styles
|
|
128
|
+
changelog_uri: https://github.com/chrise86/view_component-scoped_styles/blob/main/CHANGELOG.md
|
|
129
|
+
rubygems_mfa_required: 'true'
|
|
130
|
+
rdoc_options: []
|
|
131
|
+
require_paths:
|
|
132
|
+
- lib
|
|
133
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: 3.2.0
|
|
138
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
139
|
+
requirements:
|
|
140
|
+
- - ">="
|
|
141
|
+
- !ruby/object:Gem::Version
|
|
142
|
+
version: '0'
|
|
143
|
+
requirements: []
|
|
144
|
+
rubygems_version: 4.0.3
|
|
145
|
+
specification_version: 4
|
|
146
|
+
summary: Scoped, colocated CSS for ViewComponent components.
|
|
147
|
+
test_files: []
|